Repository: evcc-io/evcc
Pull request #13207 (2024-03-29 14:55:39)

Load Management and Peak Shaving
Potential replacement for https://github.com/evcc-io/evcc/pull/8427.  Closer alignment to evcc standards, untested. 

Configuration from #8427 should hopefully work, use W instead of kW. Published on Docker Hub als `:lm-new`. Turn on trace log for testing. Live circuit data is available at /api/state unter /circuits and also published on MQTT. 

Scope:
- load management
- peak shaving

**NOTE**

We consider load management an advanced feature targeted at professional users (this charging parks etc). The pricing model is not yet determined, but we will make sure that a basic version of load management remains free for all evcc users.

**TODO**

- [x] reverse parent/children handling
- [x] move circuits to UI-based config (implement circuits as devices)
- [x] tests
- [x] implement current limits (only power implemented)
- [x] publish circuits
- [x] add circuit titles
- [ ] test loadpoint interaction
- [ ] check publishing on InfluxDB

Out of scope

- [ ] UI @naltatis 
- [ ] load balancing
- [ ] priority support
- [ ] planner support


andig: @naltatis der Integration Build läuft hier jeweils ewig, könntest Du mal schauen warum?
OneLineTwoBugs: I can't get it to start

[main  ] FATAL 2024/03/31 22:19:04 missing parent circuit: map[maxcurrent:200 meter:grid parent:<nil>]

relevant config section:
`circuits:
  - name: main
    maxCurrent: 200
    meter: grid
    parent:

  - name: emob
    maxCurrent: 125
    meter: emob
    parent: main`
OneLineTwoBugs: I can't get it to start.
`[main  ] FATAL 2024/03/31 22:22:38 missing parent circuit: map[maxpower:138000 meter:grid parent:<nil>]`

relevant config section:
```
circuits:
  - name: main
    maxPower: 138000
    meter: grid
    parent:

  - name: emob
    maxCurrent: 86250
    meter: emob
    parent: main
```

andig: Fixed. Gib ihm bitte nochmal eine Chance.
ross-w: Having some issues with the published `lm-new` Docker tag. The UI never comes up (doesn't listen on `7070`), but I can't see any errors in the log (trace or otherwise) as to why.

evcc.yaml modified slightly from the version I was using with the other PR (kW to W, added circuit to site, removed currents):
```
---
site:
  - title: xxx
    circuit: main
    meters:
      grid: grid
      pv:
        - solar

loadpoints:
  - title: Garage
    charger: zjbeny
    phases: 1
    vehicle: ioniq5
    mode: pv
    maxcurrent: 32
    circuit: garage
    priority: 1
  - title: Ebike
    charger: ebike
    phases: 1
    mode: pv
    mincurrent: 1
    maxcurrent: 1
    circuit: garagegpo

chargers:
  - name: zjbeny
    type: template
    template: ocpp
    stationid: zjb
    getconfiguration: false
    timeout: 2m
  - name: ebike
    type: template
    template: tasmota
    host: 172.16.3.124
    channel: 1
    standbypower: 15
    integrateddevice: true
    icon: bike

meters:
  - name: grid
    type: custom
    power:
      source: mqtt
      topic: SunGather/A22D350470
      jq: .meter_power
      timeout: 30s
  - name: solar
    type: custom
    power:
      source: mqtt
      topic: SunGather/A22D350470
      jq: .total_active_power
      timeout: 30s

vehicles:
  - name: ioniq5
    type: template
    template: hyundai
    icon: car
    title: Ioniq 5
    user: redacted
    password: redacted
    language: en # 'en' for English and 'de' for German (optional)
    mode: pv
    minCurrent: 6
    maxCurrent: 32

circuits:
  - name: main
    #maxCurrent: 0
    maxPower: 9600 #9.6
    meter: grid
    parent:
  - name: garage
    #maxCurrent: 32
    maxPower: 7200 #7.2
    meter:
    parent: main
  - name: garagegpo
    #maxCurrent: 10
    maxPower: 2400 #2.4
    meter:
    parent: main

tariffs:
  currency: AUD
  grid:
    type: amber
    token: redacted
    siteid: redacted
    channel: general

  feedin:
    type: amber
    token: redacted
    siteid: redacted
    channel: feedIn

mqtt:
  broker: mosquitto.mosquitto
  topic: evcc # root topic for publishing, set empty to disable publishing
  user: redacted
  password: redacted

interval: 10s
log: trace
```
andig: @ross-w I can't reproduce this. Are you sure it isn't waiting for the OCPP charger or an MQTT update?
TMA84: @andig , @OneLineTwoBugs 
It works on my test installation.
andig: @TMA84 thanks for letting me know! Just running or actual load balancing behavior?
TMA84: @andig actually just running. Is it possible to run this version on Home Assistant? 
andig: @TMA84 For time being Docker only. We'll discuss if we can bring this into the nightly for broader testing once it seems stable enough.
OneLineTwoBugs: seems to work relatively well now. This is my circuit config:
```
circuits:
  - name: main
    maxCurrent: 200
    meter: grid
    parent:

  - name: emob
    maxCurrent: 125
    meter: emob
    parent: main
```

My findings
- site now needs a `circuit: main`
- Loadpoints are shut off when current limit is reached. Reduction in MaxCurrent instead of shut off would be nice :)
- circuits without meters are not possible (getting the circuit's current by adding all attached loadpoints' current instead of a meter's value
- Overcurrent warnings are displayed in UI - nice. But warning should also state in which circuit it detected overcurrent
- in the scenario above I saw correct handling of current propagation (i.e. both circuits were monitored for overcurrents and loadpoints were shut off if overcurrent was detected)
- in it's current state, it's a breaking change because a `circuits` section as well as a `circuit` value in `site` are now mandatory. I therefore strongly suggest to make them optional or it'll break every single installation if this goes into main :)
andig: @OneLineTwoBugs 

> site now needs a circuit: main config setting

*if* circuits are defined, we expect a main circuit at site level, but not otherwise. Is that reasonable?

> Loadpoints are shut off when current limit is reached. Reduction in MaxCurrent instead of shut off would be nice :)

That should already be the case. The should only be shut of if minCurrent is not reached. Could you share a logfile for the issue?

> circuits without meters are not possible (getting the circuit's current by adding all attached loadpoints' current instead of a meter's value

Should be possible. A circuit without meter will collect its data from associated loadpoints. What exactly is not possible? Could you share the config?

> Overcurrent warnings are displayed in UI - nice. But warning should also state in which circuit it detected overcurrent
in the scenario above I saw correct handling of current propagation (i.e. both circuits were monitored for overcurrents and loadpoints were shut off if overcurrent was detected)

Yeah, I think we should add circuit titles.

> in it's current state, it's a breaking change because a circuits section as well as a circuit value in site are now mandatory. 

They are not according to my tests, otherwise that would be a bug. I'm not able to reproduce this.

> There is oscillation between stopping and starting a loadpoint. With my settings, the emob circuit can handle 7 loadpoints with 16A each. when the 8th loadpoint starts to charge, it is shut off by load management. But then after it stopped consuming current, there is no load management limitation and evcc starts charging again.

Please share logfile. In this case, the 8th loadpoint should not start. 
ross-w: > @ross-w I can't reproduce this. Are you sure it isn't waiting for the OCPP charger or an MQTT update?

It doesn't appear to be the case, as I can see OCPP and MQTT messages scrolling in the log. Here's the trace log
[tracelmlogs.txt](https://github.com/evcc-io/evcc/files/14835214/tracelmlogs.txt)

I could well be missing something obvious. My meters all read in power (not current); not sure if that makes a difference. It's strange the UI port never starts listening (it does listen on the OCPP port, however)
OneLineTwoBugs: > _If_ circuits are defined, we expect a root circuit at site level, but not otherwise. Is that reasonable?
yes, it is.

> They [circuits][ are not according to my tests, otherwise that would be a bug. I'm not able to reproduce this.
Neither am I as of now. Perhaps this went away with a recent commit? I'm pretty sure I had this issue at some point, but it was late at night

> > circuits without meters are not possible (getting the circuit's current by adding all attached loadpoints' current instead of a meter's value
> 
> Should be possible. A circuit without meter will collect its data from associated loadpoints. What exactly is not possible? Could you share the config?
[evcc_circuit_no_meter.log](https://github.com/evcc-io/evcc/files/14835990/evcc_circuit_no_meter.log)

regarding shut off of loadpoints during overcurrent and oscillation:
[oscillation.log](https://github.com/evcc-io/evcc/files/14836129/oscillation.log)
at 13:55:34, lp-8 starts to charge and we are in an overcurrent state.
at 13:55:37, overcurrent is detected on circuit emob - it seems that no action has been taken yet
at 13:55:40, lp-1 is shut off
at 13:56:34, lp-9 starts to charge, again leading to an overcurrent
at 13:56:37, overcurrent is detected - lp-2 will be shut off

I see no reduction in current in the logs, and the oscillation probably happens because there are more loadpoints ready to be turned on.

OneLineTwoBugs: Just for clarification: I assume we don't look at currents per phase and just take the max current of each possible data source (loadpoint / meter etc)? I think there is no other reliable option because there could be phase shifting betweeen different meters, i.e. the values are not comparable (L1 of meter emob could be attached to L2 on the loadpoint).

This also means that we will see unexpected results when we change the charging current of single phase chargers. I.e. it could happen that evcc reduces the current of a single phase charger, but this won't help at all in solving the overcurrent situation. Perhaps it's best to just shut off single phase chargers in an overcurrent situation? Just a thought ...


RTTTC: I hope we look for current per phase for `grid` meter

Regarding phase-shift @ chargers - I agree that the best solution is:
 

> in order to do proper phase regulation, meters/chargers would need a new parameter phase order (i.e. a way to tell evcc that the meter value for L1 actually corresponds to the meter value L2 on the cirtuit)
OneLineTwoBugs: > I hope we look for current per phase for `grid` meter
> 
> Regarding phase-shift @ chargers - I agree that the best solution is:
> 
> > in order to do proper phase regulation, meters/chargers would need a new parameter phase order (i.e. a way to tell evcc that the meter value for L1 actually corresponds to the meter value L2 on the cirtuit)

It would be very neat, but it's potentially a lot of work for relatively little gain. Also, there is another possible caveat: there is no guarantee that a charger uses L1 for singlephase charging. So we need another config parameter that specifies the phase used for single phase charging.
ross-w: Minimal reproducible config for my issue (no UI) using only MQTT as an external source:
```
---
site:
  - title: Test
    circuit: main
    meters:
      grid: grid
      pv:
        - solar

loadpoints:
  - title: Garage
    charger: wallbox
    phases: 1
    vehicle: dummy
    mode: pv
    circuit: garage

chargers:
  - name: wallbox
    type: custom
    status:
      source: mqtt
      topic: dummycharge/1/status
    enabled:
      source: mqtt
      topic: dummycharge/1/state
    enable:
      source: mqtt
      topic: dummycharge/1/enable
    maxcurrent:
      source: mqtt
      topic: dummycharge/1/maxCurrent

meters:
  - name: grid
    type: custom
    power:
      source: mqtt
      topic: SunGather/Serial
      jq: .meter_power
      timeout: 30s
  - name: solar
    type: custom
    power:
      source: mqtt
      topic: SunGather/Serial
      jq: .total_active_power
      timeout: 30s

vehicles:
  - name: dummy
    type: custom
    title: Test Car
    capacity: 50
    soc:
      source: mqtt
      topic: dummycar/1
      jq: .soc

tariffs:
  currency: AUD
  grid:
    type: fixed
    price: 0.294
  feedin:
    type: fixed
    price: 0.294

mqtt:
  broker: mqttserver
  topic: evcc
  user: redacted
  password: redacted

circuits:
  - name: main
    maxPower: 9600
    meter: grid
  - name: garage
    maxPower: 7200
    parent: main

log: trace
interval: 10s
```

Trace logs confirm MQTT data is being received.

There are two main differences I see between my config and the working ones above:
1. My meters (and limits) are in power, not current; and
2. I have a single phase supply (and single phase chargers)

Does this implementation assume something that makes one of the above an issue?
OneLineTwoBugs: > @OneLineTwoBugs no, with the 'capacity tariff' here in BE, working with maxPower seems more useful than maxCurrent
> 
> see also #11324 [#11324 (comment)](https://github.com/evcc-io/evcc/issues/11324#issue-2059857986)

But your charger wants the lowest possible power in amperes :) The entire IEC62196 standard is based on current, not power. If you define a limit in Power, evcc will at some point convert it to an ampere value to send to your charger.
andig: @OneLineTwoBugs see https://github.com/evcc-io/evcc/pull/7338. We're starting to see power-controlled devices.
RenatusRo: @OneLineTwoBugs 
if we return to the original load management PR https://github.com/evcc-io/evcc/pull/8427 the goal was twofold:
- limit power (all wallboxes together max 11kW, grid power < 5 kW)
- safeguarding infrastructure (max current per circuit, fuses)

Both  should be using the appropriate measurement/unit.
And evcc is used for more/other devices than just IEC62196-like chargers.
andig: @ross-w spotted the bug, fixed and docker pushed.

> It would be very neat, but it's potentially a lot of work for relatively little gain. Also, there is another possible caveat: there is no guarantee that a charger uses L1 for singlephase charging. So we need another config parameter that specifies the phase used for single phase charging.

@OneLineTwoBugs I *guess* we could add a `phaseorder` thing plus require that phase ordering has current measurement in place. Activating a charger we would then either a) always assume current on all three phases or b) just assume L1 for 1p and take the risk.
OneLineTwoBugs: > @OneLineTwoBugs I _guess_ we could add a `phaseorder` thing plus require that phase ordering has current measurement in place. Activating a charger we would then either a) always assume current on all three phases or b) just assume L1 for 1p and take the risk.

Would work. But I guess the hard part regarding the code is the `phaseorder` thing and juggling three ampere values instead of one? The parameter that specifies which phase to look at for single phase charging is probably quite simple? It's also very easy to default (L1). But you're the expert! The phase ordering, current measuring per phase and single phase charging against overcurrent situations is already more than I've dreamed of :)
andig: Lets look at that when we get there. Basics first!
RenatusRo: for the simple peak shaving case (one main circuit tied to the grid, multiple load points) it seams to work.
OneLineTwoBugs: I think I found a bug: If (no chargers * 6A) > maxCurrent in a circuit, no charging happens. Expected behaviour would be that 2 chargers charge @6A, or perhaps even 8A.

[evcc.yaml.txt](https://github.com/evcc-io/evcc/files/14857435/evcc.yaml.txt)
[no_charge.log](https://github.com/evcc-io/evcc/files/14857416/no_charge.log)

And my demo charger template to make the config a bit neater

[democharger.yaml.txt](https://github.com/evcc-io/evcc/files/14857428/democharger.yaml.txt)

andig: > For some reason I can't see any loadpoint enable/disable messages, they just start which makes it impossible to reason about why this happens.

Same thing as before- your loadpoints seem to start by themselves...
ross-w: > @ross-w spotted the bug, fixed and docker pushed.

Thanks @andig ! I can confirm it starts now

@OneLineTwoBugs 
> Can i ask: what is the use case of limiting power rather than current? I can't really think of one, because the limit of all electrical cirtuits is always current, not power, right? 

You are correct. Tolerance in Australia is the same, so you're correct there is a lot of room for discrepancy.
I'm using power as all my meters only report power, not current. I'll see if I can find a source of the voltage and publish this to MQTT so I can calculate currents from the power meters and do it that way.
vistalba: Is this already in 0.125 included or any hint when it will be in the official release? (As Homeassinstant Addon I can't test other image tags :() 

Frintrop: > Is this already in 0.125 included or any hint when it will be in the official release? (As Homeassinstant Addon I can't test other image tags :()

you may fork the hassio addon repository and set another docker image there ?
TMA84: > > Is this already in 0.125 included or any hint when it will be in the official release? (As Homeassinstant Addon I can't test other image tags :()
> 
> you may fork the hassio addon repository and set another docker image there ?

Unfortunately this didn't work in my tests
Frintrop: > > > Is this already in 0.125 included or any hint when it will be in the official release? (As Homeassinstant Addon I can't test other image tags :()
> > 
> > 
> > you may fork the hassio addon repository and set another docker image there ?
> 
> Unfortunately this didn't work in my tests

you can try my repository: https://github.com/Frintrop/hassio-addon. 
you have to disable origin evcc repo to do that
ross-w: So far the monitoring is working OK and it has prevented a breaker trip on a number of occasions. I might see if I can get a voltage feed from somewhere and switch to current monitoring, as it seems to shut off charging altogether in power mode (as opposed to current)

Any chance we could get a rebased version of this pushed up to docker hub? Specifically I'm after [this PR](https://github.com/evcc-io/evcc/pull/12459/files), as my EVSE balks when a transaction is stopped and refuses to restart without physically disconnecting the cable
djfanatix: I tested this and I get a warning when it is over power.
I assume this is static Load-Balancing (chargers shuts off when overpower) or is dynamic also possible (Charger tries to keep power value under certain value, same as PV Surpluss)?
andig: It does
djfanatix: > It does

Hmm, then I need to test it further.
It didn’t adjust the load with a go-e charger
andig: See note on commercial model above.

Patch:
diff --git a/api/api.go b/api/api.go
index 77500df0..cc6a9ea7 100644
--- a/api/api.go
+++ b/api/api.go
@@ -7,7 +7,7 @@ import (
 	"time"
 )
 
-//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
+//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
 
 // Meter provides total active power in W
 type Meter interface {
@@ -202,3 +202,32 @@ type FeatureDescriber interface {
 type CsvWriter interface {
 	WriteCsv(context.Context, io.Writer) error
 }
+
+// CircuitMeasurements is the measurements a circuit or load must deliver
+type CircuitMeasurements interface {
+	GetChargePower() float64
+	GetMaxPhaseCurrent() float64
+}
+
+// CircuitLoad represents a loadpoint attached to a circuit
+type CircuitLoad interface {
+	CircuitMeasurements
+	GetCircuit() Circuit
+}
+
+// Circuit defines the load control domain
+type Circuit interface {
+	CircuitMeasurements
+	GetTitle() string
+	SetTitle(string)
+	GetParent() Circuit
+	RegisterChild(child Circuit)
+	HasMeter() bool
+	GetMaxPower() float64
+	GetMaxCurrent() float64
+	SetMaxPower(float64)
+	SetMaxCurrent(float64)
+	Update([]CircuitLoad) error
+	ValidateCurrent(old, new float64) float64
+	ValidatePower(old, new float64) float64
+}
diff --git a/api/mock.go b/api/mock.go
index c3f79d52..9b81c990 100644
--- a/api/mock.go
+++ b/api/mock.go
@@ -1,9 +1,9 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController)
+// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit)
 //
 // Generated by this command:
 //
-//	mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
+//	mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PhaseSwitcher,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit
 //
 
 // Package api is a generated GoMock package.
@@ -324,6 +324,46 @@ func (mr *MockMeterEnergyMockRecorder) TotalEnergy() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TotalEnergy", reflect.TypeOf((*MockMeterEnergy)(nil).TotalEnergy))
 }
 
+// MockPhaseCurrents is a mock of PhaseCurrents interface.
+type MockPhaseCurrents struct {
+	ctrl     *gomock.Controller
+	recorder *MockPhaseCurrentsMockRecorder
+}
+
+// MockPhaseCurrentsMockRecorder is the mock recorder for MockPhaseCurrents.
+type MockPhaseCurrentsMockRecorder struct {
+	mock *MockPhaseCurrents
+}
+
+// NewMockPhaseCurrents creates a new mock instance.
+func NewMockPhaseCurrents(ctrl *gomock.Controller) *MockPhaseCurrents {
+	mock := &MockPhaseCurrents{ctrl: ctrl}
+	mock.recorder = &MockPhaseCurrentsMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockPhaseCurrents) EXPECT() *MockPhaseCurrentsMockRecorder {
+	return m.recorder
+}
+
+// Currents mocks base method.
+func (m *MockPhaseCurrents) Currents() (float64, float64, float64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Currents")
+	ret0, _ := ret[0].(float64)
+	ret1, _ := ret[1].(float64)
+	ret2, _ := ret[2].(float64)
+	ret3, _ := ret[3].(error)
+	return ret0, ret1, ret2, ret3
+}
+
+// Currents indicates an expected call of Currents.
+func (mr *MockPhaseCurrentsMockRecorder) Currents() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Currents", reflect.TypeOf((*MockPhaseCurrents)(nil).Currents))
+}
+
 // MockVehicle is a mock of Vehicle interface.
 type MockVehicle struct {
 	ctrl     *gomock.Controller
@@ -636,3 +676,214 @@ func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 any) *gomock.Ca
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
 }
+
+// MockCircuit is a mock of Circuit interface.
+type MockCircuit struct {
+	ctrl     *gomock.Controller
+	recorder *MockCircuitMockRecorder
+}
+
+// MockCircuitMockRecorder is the mock recorder for MockCircuit.
+type MockCircuitMockRecorder struct {
+	mock *MockCircuit
+}
+
+// NewMockCircuit creates a new mock instance.
+func NewMockCircuit(ctrl *gomock.Controller) *MockCircuit {
+	mock := &MockCircuit{ctrl: ctrl}
+	mock.recorder = &MockCircuitMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockCircuit) EXPECT() *MockCircuitMockRecorder {
+	return m.recorder
+}
+
+// GetChargePower mocks base method.
+func (m *MockCircuit) GetChargePower() float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetChargePower")
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// GetChargePower indicates an expected call of GetChargePower.
+func (mr *MockCircuitMockRecorder) GetChargePower() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePower", reflect.TypeOf((*MockCircuit)(nil).GetChargePower))
+}
+
+// GetMaxCurrent mocks base method.
+func (m *MockCircuit) GetMaxCurrent() float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMaxCurrent")
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// GetMaxCurrent indicates an expected call of GetMaxCurrent.
+func (mr *MockCircuitMockRecorder) GetMaxCurrent() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxCurrent", reflect.TypeOf((*MockCircuit)(nil).GetMaxCurrent))
+}
+
+// GetMaxPhaseCurrent mocks base method.
+func (m *MockCircuit) GetMaxPhaseCurrent() float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMaxPhaseCurrent")
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// GetMaxPhaseCurrent indicates an expected call of GetMaxPhaseCurrent.
+func (mr *MockCircuitMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockCircuit)(nil).GetMaxPhaseCurrent))
+}
+
+// GetMaxPower mocks base method.
+func (m *MockCircuit) GetMaxPower() float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMaxPower")
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// GetMaxPower indicates an expected call of GetMaxPower.
+func (mr *MockCircuitMockRecorder) GetMaxPower() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPower", reflect.TypeOf((*MockCircuit)(nil).GetMaxPower))
+}
+
+// GetParent mocks base method.
+func (m *MockCircuit) GetParent() Circuit {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetParent")
+	ret0, _ := ret[0].(Circuit)
+	return ret0
+}
+
+// GetParent indicates an expected call of GetParent.
+func (mr *MockCircuitMockRecorder) GetParent() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParent", reflect.TypeOf((*MockCircuit)(nil).GetParent))
+}
+
+// GetTitle mocks base method.
+func (m *MockCircuit) GetTitle() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetTitle")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetTitle indicates an expected call of GetTitle.
+func (mr *MockCircuitMockRecorder) GetTitle() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockCircuit)(nil).GetTitle))
+}
+
+// HasMeter mocks base method.
+func (m *MockCircuit) HasMeter() bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "HasMeter")
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// HasMeter indicates an expected call of HasMeter.
+func (mr *MockCircuitMockRecorder) HasMeter() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasMeter", reflect.TypeOf((*MockCircuit)(nil).HasMeter))
+}
+
+// RegisterChild mocks base method.
+func (m *MockCircuit) RegisterChild(arg0 Circuit) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "RegisterChild", arg0)
+}
+
+// RegisterChild indicates an expected call of RegisterChild.
+func (mr *MockCircuitMockRecorder) RegisterChild(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterChild", reflect.TypeOf((*MockCircuit)(nil).RegisterChild), arg0)
+}
+
+// SetMaxCurrent mocks base method.
+func (m *MockCircuit) SetMaxCurrent(arg0 float64) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetMaxCurrent", arg0)
+}
+
+// SetMaxCurrent indicates an expected call of SetMaxCurrent.
+func (mr *MockCircuitMockRecorder) SetMaxCurrent(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMaxCurrent", reflect.TypeOf((*MockCircuit)(nil).SetMaxCurrent), arg0)
+}
+
+// SetMaxPower mocks base method.
+func (m *MockCircuit) SetMaxPower(arg0 float64) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetMaxPower", arg0)
+}
+
+// SetMaxPower indicates an expected call of SetMaxPower.
+func (mr *MockCircuitMockRecorder) SetMaxPower(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMaxPower", reflect.TypeOf((*MockCircuit)(nil).SetMaxPower), arg0)
+}
+
+// SetTitle mocks base method.
+func (m *MockCircuit) SetTitle(arg0 string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetTitle", arg0)
+}
+
+// SetTitle indicates an expected call of SetTitle.
+func (mr *MockCircuitMockRecorder) SetTitle(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockCircuit)(nil).SetTitle), arg0)
+}
+
+// Update mocks base method.
+func (m *MockCircuit) Update(arg0 []CircuitLoad) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Update", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockCircuitMockRecorder) Update(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockCircuit)(nil).Update), arg0)
+}
+
+// ValidateCurrent mocks base method.
+func (m *MockCircuit) ValidateCurrent(arg0, arg1 float64) float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidateCurrent", arg0, arg1)
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// ValidateCurrent indicates an expected call of ValidateCurrent.
+func (mr *MockCircuitMockRecorder) ValidateCurrent(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateCurrent", reflect.TypeOf((*MockCircuit)(nil).ValidateCurrent), arg0, arg1)
+}
+
+// ValidatePower mocks base method.
+func (m *MockCircuit) ValidatePower(arg0, arg1 float64) float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidatePower", arg0, arg1)
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// ValidatePower indicates an expected call of ValidatePower.
+func (mr *MockCircuitMockRecorder) ValidatePower(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), arg0, arg1)
+}
diff --git a/cmd/setup.go b/cmd/setup.go
index 14470200..0fa77e76 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -45,6 +45,7 @@ import (
 	"github.com/gorilla/handlers"
 	"github.com/gorilla/mux"
 	"github.com/libp2p/zeroconf/v2"
+	"github.com/spf13/cast"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"golang.org/x/sync/errgroup"
@@ -95,6 +96,7 @@ type globalConfig struct {
 	Tariffs      tariffConfig
 	Site         map[string]interface{}
 	Loadpoints   []map[string]interface{}
+	Circuits     []config.Named
 }
 
 type mqttConfig struct {
@@ -183,6 +185,115 @@ func loadConfigFile(conf *globalConfig) error {
 	return err
 }
 
+func configureCircuits(static []config.Named, names ...string) error {
+	children := slices.Clone(static)
+
+	// TODO: check for circular references
+NEXT:
+	for i, cc := range children {
+		if cc.Name == "" {
+			return fmt.Errorf("cannot create circuit: missing name")
+		}
+
+		if err := nameValid(cc.Name); err != nil {
+			return fmt.Errorf("cannot create circuit: duplicate name: %s", cc.Name)
+		}
+
+		if parent := cast.ToString(cc.Property("parent")); parent != "" {
+			if _, err := config.Circuits().ByName(parent); err != nil {
+				continue
+			}
+		}
+
+		log := util.NewLogger("circuit-" + cc.Name)
+		instance, err := core.NewCircuitFromConfig(log, cc.Other)
+		if err != nil {
+			return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
+		}
+
+		// ensure config has title
+		if instance.GetTitle() == "" {
+			//lint:ignore SA1019 as Title is safe on ascii
+			instance.SetTitle(strings.Title(cc.Name))
+		}
+
+		if err := config.Circuits().Add(config.NewStaticDevice(cc, instance)); err != nil {
+			return err
+		}
+
+		children = slices.Delete(children, i, i+1)
+		goto NEXT
+	}
+
+	if len(children) > 0 {
+		return fmt.Errorf("circuit is missing parent: %s", children[0].Name)
+	}
+
+	// append devices from database
+	configurable, err := config.ConfigurationsByClass(templates.Circuit)
+	if err != nil {
+		return err
+	}
+
+	children2 := slices.Clone(configurable)
+
+NEXT2:
+	for i, conf := range children2 {
+		cc := conf.Named()
+
+		if len(names) > 0 && !slices.Contains(names, cc.Name) {
+			return nil
+		}
+
+		if parent := cast.ToString(cc.Property("parent")); parent != "" {
+			if _, err := config.Circuits().ByName(parent); err != nil {
+				continue
+			}
+		}
+
+		log := util.NewLogger("circuit-" + cc.Name)
+		instance, err := core.NewCircuitFromConfig(log, cc.Other)
+		if err != nil {
+			return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
+		}
+
+		// ensure config has title
+		if instance.GetTitle() == "" {
+			//lint:ignore SA1019 as Title is safe on ascii
+			instance.SetTitle(strings.Title(cc.Name))
+		}
+
+		if err := config.Circuits().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+			return err
+		}
+
+		children2 = slices.Delete(children2, i, i+1)
+		goto NEXT2
+	}
+
+	if len(children2) > 0 {
+		return fmt.Errorf("missing parent circuit: %s", children2[0].Named().Name)
+	}
+
+	var rootFound bool
+	for _, dev := range config.Circuits().Devices() {
+		c := dev.Instance()
+
+		if c.GetParent() == nil {
+			if rootFound {
+				return errors.New("cannot have multiple root circuits")
+			}
+			rootFound = true
+		}
+	}
+
+	if !rootFound && len(config.Circuits().Devices()) > 0 {
+		return errors.New("root circuit required")
+	}
+
+	return nil
+}
+
 func configureMeters(static []config.Named, names ...string) error {
 	for i, cc := range static {
 		if cc.Name == "" {
@@ -647,6 +758,9 @@ func configureDevices(conf globalConfig) error {
 	if err := configureChargers(conf.Chargers); err != nil {
 		return err
 	}
+	if err := configureCircuits(conf.Circuits); err != nil {
+		return err
+	}
 	return configureVehicles(conf.Vehicles)
 }
 
@@ -665,7 +779,43 @@ func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
 		return nil, err
 	}
 
-	return configureSite(conf.Site, loadpoints, tariffs)
+	site, err := configureSite(conf.Site, loadpoints, tariffs)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(config.Circuits().Devices()) > 0 {
+		if err := validateCircuits(site, loadpoints); err != nil {
+			return nil, err
+		}
+	}
+
+	return site, nil
+}
+
+func validateCircuits(site site.API, loadpoints []*core.Loadpoint) error {
+CONTINUE:
+	for _, dev := range config.Circuits().Devices() {
+		instance := dev.Instance()
+
+		if instance.HasMeter() || site.GetCircuit() == instance {
+			continue
+		}
+
+		for _, lp := range loadpoints {
+			if lp.GetCircuit() == instance {
+				continue CONTINUE
+			}
+		}
+
+		return fmt.Errorf("circuit %s has no meter or loadpoint assigned", dev.Config().Name)
+	}
+
+	if site.GetCircuit() == nil {
+		return errors.New("site has no circuit")
+	}
+
+	return nil
 }
 
 func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, tariffs *tariff.Tariffs) (*core.Site, error) {
@@ -674,19 +824,25 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
 		return nil, fmt.Errorf("failed configuring site: %w", err)
 	}
 
+	if len(config.Circuits().Devices()) > 0 && site.GetCircuit() == nil {
+		return nil, errors.New("site has no circuit")
+	}
+
 	return site, nil
 }
 
-func configureLoadpoints(conf globalConfig) (loadpoints []*core.Loadpoint, err error) {
+func configureLoadpoints(conf globalConfig) ([]*core.Loadpoint, error) {
 	if len(conf.Loadpoints) == 0 {
 		return nil, errors.New("missing loadpoints")
 	}
 
-	for id, lpc := range conf.Loadpoints {
+	var loadpoints []*core.Loadpoint
+
+	for id, cfg := range conf.Loadpoints {
 		log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
 		settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
 
-		lp, err := core.NewLoadpointFromConfig(log, settings, lpc)
+		lp, err := core.NewLoadpointFromConfig(log, settings, cfg)
 		if err != nil {
 			return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
 		}
diff --git a/core/circuit.go b/core/circuit.go
new file mode 100644
index 00000000..9c4ee28b
--- /dev/null
+++ b/core/circuit.go
@@ -0,0 +1,308 @@
+package core
+
+import (
+	"fmt"
+	"sync"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+)
+
+var _ api.Circuit = (*Circuit)(nil)
+
+// the circuit instances to control the load
+type Circuit struct {
+	mu  sync.RWMutex
+	log *util.Logger
+
+	title    string
+	parent   api.Circuit   // parent circuit
+	children []api.Circuit // child circuits
+	meter    api.Meter     // meter to determine current power
+
+	maxCurrent float64 // max allowed current
+	maxPower   float64 // max allowed power
+
+	current float64
+	power   float64
+}
+
+// NewCircuitFromConfig creates a new Circuit
+func NewCircuitFromConfig(log *util.Logger, other map[string]interface{}) (api.Circuit, error) {
+	var cc struct {
+		Title      string  `mapstructure:"title"`      // title
+		ParentRef  string  `mapstructure:"parent"`     // parent circuit reference
+		MeterRef   string  `mapstructure:"meter"`      // meter reference
+		MaxCurrent float64 `mapstructure:"maxCurrent"` // the max allowed current
+		MaxPower   float64 `mapstructure:"maxPower"`   // the max allowed power
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	var meter api.Meter
+	if cc.MeterRef != "" {
+		dev, err := config.Meters().ByName(cc.MeterRef)
+		if err != nil {
+			return nil, err
+		}
+		meter = dev.Instance()
+	}
+
+	circuit, err := NewCircuit(log, cc.Title, cc.MaxCurrent, cc.MaxPower, meter)
+	if err != nil {
+		return nil, err
+	}
+
+	if cc.ParentRef != "" {
+		dev, err := config.Circuits().ByName(cc.ParentRef)
+		if err != nil {
+			return nil, err
+		}
+		circuit.SetParent(dev.Instance())
+	}
+
+	return circuit, err
+}
+
+// NewCircuit creates a circuit
+func NewCircuit(log *util.Logger, title string, maxCurrent, maxPower float64, meter api.Meter) (*Circuit, error) {
+	c := &Circuit{
+		log:        log,
+		title:      title,
+		maxCurrent: maxCurrent,
+		maxPower:   maxPower,
+		meter:      meter,
+	}
+
+	if maxPower == 0 {
+		c.log.DEBUG.Printf("validation of max power disabled")
+	}
+
+	if maxCurrent == 0 {
+		c.log.DEBUG.Printf("validation of max phase current disabled")
+	} else if _, ok := meter.(api.PhaseCurrents); meter != nil && !ok {
+		return nil, fmt.Errorf("meter does not support phase currents")
+	}
+
+	return c, nil
+}
+
+func (c *Circuit) GetTitle() string {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.title
+}
+
+func (c *Circuit) SetTitle(title string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.title = title
+}
+
+// GetParent returns the parent circuit
+func (c *Circuit) GetParent() api.Circuit {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.parent
+}
+
+// SetParent set parent circuit
+func (c *Circuit) SetParent(parent api.Circuit) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.parent = parent
+	if parent != nil {
+		parent.RegisterChild(c)
+	}
+}
+
+// HasMeter returns the max power setting
+func (c *Circuit) HasMeter() bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.meter != nil
+}
+
+// GetMaxPower returns the max power setting
+func (c *Circuit) GetMaxPower() float64 {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.maxPower
+}
+
+// SetMaxPower sets the max power
+func (c *Circuit) SetMaxPower(power float64) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.maxPower = power
+}
+
+// GetMaxCurrent returns the max current setting
+func (c *Circuit) GetMaxCurrent() float64 {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.maxCurrent
+}
+
+// SetMaxCurrent sets the max current
+func (c *Circuit) SetMaxCurrent(current float64) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.maxCurrent = current
+}
+
+// RegisterChild registers child circuit
+func (c *Circuit) RegisterChild(child api.Circuit) {
+	c.children = append(c.children, child)
+}
+
+func (c *Circuit) updateLoadpoints(loadpoints []api.CircuitLoad) {
+	c.power = 0
+	c.current = 0
+
+	for _, lp := range loadpoints {
+		if lp.GetCircuit() != c {
+			continue
+		}
+
+		c.power += lp.GetChargePower()
+		c.current += lp.GetMaxPhaseCurrent()
+	}
+}
+
+func (c *Circuit) updateMeters() error {
+	if f, err := c.meter.CurrentPower(); err == nil {
+		// TODO handle negative powers
+		c.power = f
+	} else {
+		return fmt.Errorf("circuit power: %w", err)
+	}
+
+	if phaseMeter, ok := c.meter.(api.PhaseCurrents); ok {
+		if l1, l2, l3, err := phaseMeter.Currents(); err == nil {
+			// TODO handle negative currents
+			c.current = max(l1, l2, l3)
+		} else {
+			return fmt.Errorf("circuit currents: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func (c *Circuit) Update(loadpoints []api.CircuitLoad) (err error) {
+	defer func() {
+		if c.maxPower != 0 && c.power > c.maxPower {
+			c.log.WARN.Printf("over power detected: %gW > %gW", c.power, c.maxPower)
+		} else {
+			c.log.DEBUG.Printf("power: %gW", c.power)
+		}
+
+		if c.maxCurrent != 0 && c.current > c.maxCurrent {
+			c.log.WARN.Printf("over current detected: %gA > %gA", c.current, c.maxCurrent)
+		} else {
+			c.log.DEBUG.Printf("current: %gA", c.current)
+		}
+	}()
+
+	// update children depth-first
+	for _, ch := range c.children {
+		if err := ch.Update(loadpoints); err != nil {
+			return err
+		}
+	}
+
+	// meter available
+	if c.meter != nil {
+		return c.updateMeters()
+	}
+
+	// no meter available
+	c.updateLoadpoints(loadpoints)
+	for _, ch := range c.children {
+		c.power += ch.GetChargePower()
+		c.current += ch.GetMaxPhaseCurrent()
+	}
+
+	return nil
+}
+
+// GetChargePower returns the actual power
+func (c *Circuit) GetChargePower() float64 {
+	return c.power
+}
+
+// GetMaxPhaseCurrent returns the actual current
+func (c *Circuit) GetMaxPhaseCurrent() float64 {
+	return c.current
+}
+
+// ValidatePower validates power request
+func (c *Circuit) ValidatePower(old, new float64) float64 {
+	delta := max(0, new-old)
+
+	if c.maxPower != 0 {
+		if c.power+delta > c.maxPower {
+			new = max(0, c.maxPower-c.power)
+			c.log.DEBUG.Printf("validate power: %gW -> %gW <= %gW at %gW: capped at %gW", old, new, c.maxPower, c.power, new)
+		} else {
+			c.log.TRACE.Printf("validate power: %gW -> %gW <= %gW at %gW: ok", old, new, c.maxPower, c.power)
+		}
+	}
+
+	if c.parent != nil {
+		res := c.parent.ValidatePower(c.power, new)
+		if res != new {
+			c.log.TRACE.Printf("validate power: %gW -> %gW at %gW: capped at %gW", old, new, c.power, new)
+		}
+		return res
+	}
+
+	return new
+}
+
+// ValidateCurrent validates current request
+func (c *Circuit) ValidateCurrent(old, new float64) (res float64) {
+	delta := max(0, new-old)
+
+	if c.maxCurrent != 0 {
+		if c.current+delta > c.maxCurrent {
+			new = max(0, c.maxCurrent-c.current)
+			c.log.DEBUG.Printf("validate current: %gA -> %gA <= %gA at %gA: capped at %gA", old, new, c.maxCurrent, c.current, new)
+		} else {
+			c.log.TRACE.Printf("validate current: %gA -> %gA <= %gA at %gA: ok", old, new, c.maxCurrent, c.current)
+		}
+	}
+
+	if c.parent != nil {
+		res := c.parent.ValidateCurrent(c.current, new)
+		if res != new {
+			c.log.TRACE.Printf("validate current: %gA -> %gA at %gA: capped by parent at %gA", old, new, c.current, res)
+		}
+		return res
+	}
+
+	return new
+}
+
+// func (c *Circuit) validate(typ string, current, old, new float64, parentFunc func(o, n float64) float64) float64 {
+// 	delta := max(0, new-old)
+
+// 	if c.maxPower != 0 {
+// 		if c.power+delta > c.maxPower {
+// 			new = max(0, c.maxPower-c.power)
+// 			c.log.TRACE.Printf("validate power: %g -> %g <= %g at %g: capped at %g", old, new, c.maxPower, c.power, new)
+// 		} else {
+// 			c.log.TRACE.Printf("validate power: %g -> %g <= %g at %g: ok", old, new, c.maxPower, c.power)
+// 		}
+// 	}
+
+// 	if c.parent != nil {
+// 		return c.parent.ValidatePower(c.power, new)
+// 	}
+
+// 	return new
+// }
diff --git a/core/helper.go b/core/helper.go
index 471523d2..5110d384 100644
--- a/core/helper.go
+++ b/core/helper.go
@@ -30,6 +30,14 @@ func powerToCurrent(power float64, phases int) float64 {
 	return power / (float64(phases) * Voltage)
 }
 
+// currentToPower is a helper function to convert current to sum power
+func currentToPower(current float64, phases int) float64 {
+	if Voltage == 0 {
+		panic("Voltage is not set")
+	}
+	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 {
diff --git a/core/keys/site.go b/core/keys/site.go
index e4be858f..5c88f9cd 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -28,6 +28,7 @@ const (
 	TariffPriceHome       = "tariffPriceHome"
 	TariffPriceLoadpoints = "tariffPriceLoadpoints"
 	Vehicles              = "vehicles"
+	Circuits              = "circuits"
 	PasswordConfigured    = "passwordConfigured"
 	Interval              = "interval"
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index b5c76e86..bdb173fe 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -108,6 +108,7 @@ type Loadpoint struct {
 
 	Title_          string `mapstructure:"title"`    // UI title
 	Priority_       int    `mapstructure:"priority"` // Priority
+	CircuitRef      string `mapstructure:"circuit"`  // Circuit reference
 	ChargerRef      string `mapstructure:"charger"`  // Charger reference
 	VehicleRef      string `mapstructure:"vehicle"`  // Vehicle reference
 	MeterRef        string `mapstructure:"meter"`    // Charge meter reference
@@ -144,6 +145,7 @@ type Loadpoint struct {
 	chargeRater      api.ChargeRater
 	chargedAtStartup float64 // session energy at startup
 
+	circuit        api.Circuit // Circuit
 	chargeMeter    api.Meter   // Charger usage meter
 	vehicle        api.Vehicle // Currently active vehicle
 	defaultVehicle api.Vehicle // Default vehicle (disables detection)
@@ -203,6 +205,14 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 		lp.Soc.Poll.Mode = pollCharging
 	}
 
+	if lp.CircuitRef != "" {
+		dev, err := config.Circuits().ByName(lp.CircuitRef)
+		if err != nil {
+			return nil, err
+		}
+		lp.circuit = dev.Instance()
+	}
+
 	if lp.MeterRef != "" {
 		dev, err := config.Meters().ByName(lp.MeterRef)
 		if err != nil {
@@ -761,6 +771,17 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64) error {
 		chargeCurrent = math.Trunc(chargeCurrent)
 	}
 
+	// apply circuit limits
+	if lp.circuit != nil {
+		currentLimit := lp.circuit.ValidateCurrent(lp.chargeCurrent, chargeCurrent)
+
+		activePhases := lp.ActivePhases()
+		powerLimit := lp.circuit.ValidatePower(lp.chargePower, currentToPower(chargeCurrent, activePhases))
+		currentLimitViaPower := powerToCurrent(powerLimit, activePhases)
+
+		chargeCurrent = min(currentLimit, currentLimitViaPower)
+	}
+
 	// set current
 	if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.effectiveMinCurrent() {
 		var err error
@@ -1293,8 +1314,8 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
 	return targetCurrent
 }
 
-// UpdateChargePower updates charge meter power
-func (lp *Loadpoint) UpdateChargePower() {
+// UpdateChargePowerAndCurrents updates charge meter power and currents for load management
+func (lp *Loadpoint) UpdateChargePowerAndCurrents() {
 	bo := backoff.NewExponentialBackOff()
 	bo.MaxElapsedTime = time.Second
 
@@ -1313,12 +1334,10 @@ func (lp *Loadpoint) UpdateChargePower() {
 			lp.log.WARN.Printf("charge power must not be negative: %.0f", power)
 		}
 	} else {
-		lp.log.ERROR.Printf("charge meter: %v", err)
+		lp.log.ERROR.Printf("charge power: %v", err)
 	}
-}
 
-// updateChargeCurrents uses PhaseCurrents interface to count phases with current >=1A
-func (lp *Loadpoint) updateChargeCurrents() {
+	// update charge currents
 	lp.chargeCurrents = nil
 
 	phaseMeter, ok := lp.chargeMeter.(api.PhaseCurrents)
@@ -1326,15 +1345,27 @@ func (lp *Loadpoint) updateChargeCurrents() {
 		return // don't guess
 	}
 
-	i1, i2, i3, err := phaseMeter.Currents()
-	if err != nil {
-		lp.log.ERROR.Printf("charge meter: %v", err)
-		return
+	if err := backoff.Retry(func() error {
+		i1, i2, i3, err := phaseMeter.Currents()
+		if err != nil {
+			return err
+		}
+
+		lp.chargeCurrents = []float64{i1, i2, i3}
+		lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents)
+		lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
+
+		return nil
+	}, bo); err != nil {
+		lp.log.ERROR.Printf("charge currents: %v", err)
 	}
+}
 
-	lp.chargeCurrents = []float64{i1, i2, i3}
-	lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents)
-	lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
+// phasesFromChargeCurrents uses PhaseCurrents interface to count phases with current >=1A
+func (lp *Loadpoint) phasesFromChargeCurrents() {
+	if lp.chargeCurrents == nil {
+		return
+	}
 
 	if lp.charging() && lp.phaseSwitchCompleted() {
 		var phases int
@@ -1555,9 +1586,9 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
 	lp.publish(keys.SmartCostActive, autoCharge)
 	lp.processTasks()
 
-	// read and publish meters first- charge power has already been updated by the site
+	// read and publish meters first- charge power and currents have already been updated by the site
 	lp.updateChargeVoltages()
-	lp.updateChargeCurrents()
+	lp.phasesFromChargeCurrents()
 
 	lp.sessionEnergy.SetEnvironment(greenShare, effPrice, effCo2)
 
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 567b88d9..9eb97505 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -124,6 +124,8 @@ type API interface {
 	GetChargePower() float64
 	// GetChargePowerFlexibility returns the flexible amount of current charging power
 	GetChargePowerFlexibility() float64
+	// GetMaxPhaseCurrent returns max phase current
+	GetMaxPhaseCurrent() float64
 
 	//
 	// charge progress
@@ -146,4 +148,7 @@ type API interface {
 	SetVehicle(vehicle api.Vehicle)
 	// StartVehicleDetection allows triggering vehicle detection for debugging purposes
 	StartVehicleDetection()
+
+	// GetCircuit gets the assigned circuit
+	GetCircuit() api.Circuit
 }
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 0fd00c5b..cf7578bb 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -138,6 +138,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility))
 }
 
+// GetCircuit mocks base method.
+func (m *MockAPI) GetCircuit() api.Circuit {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetCircuit")
+	ret0, _ := ret[0].(api.Circuit)
+	return ret0
+}
+
+// GetCircuit indicates an expected call of GetCircuit.
+func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit))
+}
+
 // GetDisableThreshold mocks base method.
 func (m *MockAPI) GetDisableThreshold() float64 {
 	m.ctrl.T.Helper()
@@ -208,6 +222,20 @@ func (mr *MockAPIMockRecorder) GetMaxCurrent() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxCurrent))
 }
 
+// GetMaxPhaseCurrent mocks base method.
+func (m *MockAPI) GetMaxPhaseCurrent() float64 {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMaxPhaseCurrent")
+	ret0, _ := ret[0].(float64)
+	return ret0
+}
+
+// GetMaxPhaseCurrent indicates an expected call of GetMaxPhaseCurrent.
+func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent))
+}
+
 // GetMinCurrent mocks base method.
 func (m *MockAPI) GetMinCurrent() float64 {
 	m.ctrl.T.Helper()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index e828215b..738dc302 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -323,6 +323,16 @@ func (lp *Loadpoint) GetChargePowerFlexibility() float64 {
 	return max(0, lp.GetChargePower()-lp.EffectiveMinPower())
 }
 
+// GetMaxPhaseCurrent returns the current charge power
+func (lp *Loadpoint) GetMaxPhaseCurrent() float64 {
+	lp.RLock()
+	defer lp.RUnlock()
+	if lp.chargeCurrents == nil {
+		return lp.chargeCurrent
+	}
+	return max(lp.chargeCurrents[0], lp.chargeCurrents[1], lp.chargeCurrents[2])
+}
+
 // GetMinCurrent returns the min loadpoint current
 func (lp *Loadpoint) GetMinCurrent() float64 {
 	lp.RLock()
@@ -498,3 +508,16 @@ func (lp *Loadpoint) SetSmartCostLimit(val float64) {
 		lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
 	}
 }
+
+// GetCircuit returns the assigned circuit
+func (lp *Loadpoint) GetCircuit() api.Circuit {
+	lp.RLock()
+	defer lp.RUnlock()
+
+	// return untyped nil
+	if lp.circuit == nil {
+		return nil
+	}
+
+	return lp.circuit
+}
diff --git a/core/site.go b/core/site.go
index 21558801..2b282eac 100644
--- a/core/site.go
+++ b/core/site.go
@@ -65,13 +65,16 @@ type Site struct {
 	log *util.Logger
 
 	// configuration
-	Title                             string       `mapstructure:"title"`         // UI title
-	Voltage                           float64      `mapstructure:"voltage"`       // Operating voltage. 230V for Germany.
-	ResidualPower                     float64      `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
-	Meters                            MetersConfig // Meter references
-	MaxGridSupplyWhileBatteryCharging float64      `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
+	Title         string       `mapstructure:"title"`         // UI title
+	Voltage       float64      `mapstructure:"voltage"`       // Operating voltage. 230V for Germany.
+	ResidualPower float64      `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
+	Meters        MetersConfig `mapstructure:"meters"`        // Meter references
+	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
 	gridMeter     api.Meter   // Grid usage meter
 	pvMeters      []api.Meter // PV generation meters
 	batteryMeters []api.Meter // Battery charging meters
@@ -162,6 +165,15 @@ func NewSiteFromConfig(
 	// add meters from config
 	site.restoreMeters()
 
+	// circuit
+	if site.CircuitRef != "" {
+		dev, err := config.Circuits().ByName(site.CircuitRef)
+		if err != nil {
+			return nil, err
+		}
+		site.circuit = dev.Instance()
+	}
+
 	// grid meter
 	if site.Meters.GridMeterRef != "" {
 		dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
@@ -753,12 +765,21 @@ func (site *Site) update(lp updater) {
 	// update all loadpoint's charge power
 	var totalChargePower float64
 	for _, lp := range site.loadpoints {
-		lp.UpdateChargePower()
+		lp.UpdateChargePowerAndCurrents()
 		totalChargePower += lp.GetChargePower()
 
 		site.prioritizer.UpdateChargePowerFlexibility(lp)
 	}
 
+	// update all circuits' power and currents
+	if site.circuit != nil {
+		if err := site.circuit.Update(site.loadpointsAsCircuitDevices()); err != nil {
+			site.log.ERROR.Println(err)
+		}
+
+		site.publishCircuits()
+	}
+
 	// prioritize if possible
 	var flexiblePower float64
 	if lp.GetMode() == api.ModePV {
diff --git a/core/site/api.go b/core/site/api.go
index 86ce5b3f..7ea5b476 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -11,6 +11,9 @@ type API interface {
 	Loadpoints() []loadpoint.API
 	Vehicles() Vehicles
 
+	// GetCircuit returns the assigned circuit
+	GetCircuit() api.Circuit
+
 	// Meta
 	GetTitle() string
 	SetTitle(string)
diff --git a/core/site_api.go b/core/site_api.go
index d22d3afd..7d216998 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -10,6 +10,7 @@ import (
 	"github.com/evcc-io/evcc/core/site"
 	"github.com/evcc-io/evcc/server/db/settings"
 	"github.com/evcc-io/evcc/util/config"
+	"github.com/samber/lo"
 )
 
 var _ site.API = (*Site)(nil)
@@ -121,13 +122,14 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
 	settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
 }
 
-// Loadpoints returns the list loadpoints
+// Loadpoints returns the loadpoints as api interfaces
 func (site *Site) Loadpoints() []loadpoint.API {
-	res := make([]loadpoint.API, len(site.loadpoints))
-	for id, lp := range site.loadpoints {
-		res[id] = lp
-	}
-	return res
+	return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
+}
+
+// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
+func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
+	return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
 }
 
 // Vehicles returns the site vehicles
@@ -135,6 +137,15 @@ func (site *Site) Vehicles() site.Vehicles {
 	return &vehicles{log: site.log}
 }
 
+// GetCircuit returns the circuit
+func (site *Site) GetCircuit() api.Circuit {
+	if site.circuit == nil {
+		// return untyped nil
+		return nil
+	}
+	return site.circuit
+}
+
 // GetPrioritySoc returns the PrioritySoc
 func (site *Site) GetPrioritySoc() float64 {
 	site.RLock()
diff --git a/core/site_circuits.go b/core/site_circuits.go
new file mode 100644
index 00000000..1e733800
--- /dev/null
+++ b/core/site_circuits.go
@@ -0,0 +1,38 @@
+package core
+
+import (
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+)
+
+type circuitStruct struct {
+	Power      float64  `json:"power"`
+	Current    *float64 `json:"current,omitempty"`
+	MaxPower   float64  `json:"maxPower,omitempty"`
+	MaxCurrent float64  `json:"maxCurrent,omitempty"`
+}
+
+// publishCircuits returns a list of circuit titles
+func (site *Site) publishCircuits() {
+	cc := config.Circuits().Devices()
+	res := make(map[string]circuitStruct, len(cc))
+
+	for _, c := range cc {
+		instance := c.Instance()
+
+		data := circuitStruct{
+			Power:      instance.GetChargePower(),
+			MaxPower:   instance.GetMaxPower(),
+			MaxCurrent: instance.GetMaxCurrent(),
+		}
+
+		if instance.GetMaxCurrent() > 0 {
+			data.Current = util.PtrTo(instance.GetMaxPhaseCurrent())
+		}
+
+		res[c.Config().Name] = data
+	}
+
+	site.publish(keys.Circuits, res)
+}
diff --git a/lm.md b/lm.md
new file mode 100644
index 00000000..f7ea066e
--- /dev/null
+++ b/lm.md
@@ -0,0 +1,43 @@
+## Heute (ohne LM)
+
+je Ladepunkt:
+
+- Leistung Site aktualisieren
+- Leistung alle Ladepunkte aktualisieren
+- Gesamtbudget berechnen
+- Aktuellen Ladepunkt steuern
+
+## Lastmanagement #8427
+
+Setup:
+
+- alle Circuits hierarchisch dem Parent vMeter zurodnen
+
+je Ladepunkt:
+
+- Leistung Site aktualisieren
+- Leistung aller Ladepunkte aktualisieren
+- Gesamtbudget berechnen
+- Aktuellen Ladepunkt steuern
+  - dabei Strom/Leistung durch Circuit begrenzen
+    - Circuit aktualisieren oder aggregierte Strom/Leistung aus vMeter verwenden
+      -> u.U. mehrere Circuit-Zähler auszulesen
+  - eigenen Strom/Leistung an LM zurück melden
+
+## Vorschlag zur Vereinfachung der vMeter
+
+Setup:
+
+- ENTFÄLLT: alle Circuits hierarchisch dem Parent vMeter zuordnen
+
+je Ladepunkt:
+
+- Leistung Site aktualisieren
+- Leistung alle Ladepunkte aktualisieren
+- NEU: Ströme alle Ladepunkte aktualisieren (falls vorhanden)
+- NEU: Leistung aller Circuits depth-first aktualisieren
+  - dafür Werte der Ladepunkte verwenden wo kein Circuit Meter vorhanden
+- Gesamtbudget berechnen
+- Aktuellen Ladepunkt steuern
+  - dabei Strom/Leistung durch Circuit begrenzen
+    - ENTFÄLLT: Circuit aktualisieren oder aggregierte Strom/Leistung aus vMeter verwenden
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 74e09c9b..a28cc550 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -6,8 +6,11 @@ import (
 	"net/http"
 	"strconv"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/charger"
+	"github.com/evcc-io/evcc/core"
 	"github.com/evcc-io/evcc/meter"
+	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/vehicle"
@@ -50,6 +53,9 @@ func devicesHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		res, err = devicesConfig(class, config.Vehicles())
+
+	case templates.Circuit:
+		res, err = devicesConfig(class, config.Circuits())
 	}
 
 	if err != nil {
@@ -123,6 +129,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		res, err = deviceConfig(class, id, config.Vehicles())
+
+	case templates.Circuit:
+		res, err = deviceConfig(class, id, config.Circuits())
 	}
 
 	if err != nil {
@@ -166,6 +175,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		instance, err = deviceStatus(name, config.Vehicles())
+
+	case templates.Circuit:
+		instance, err = deviceStatus(name, config.Circuits())
 	}
 
 	if err != nil {
@@ -218,6 +230,11 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		conf, err = newDevice(class, req, vehicle.NewFromConfig, config.Vehicles())
+
+	case templates.Circuit:
+		conf, err = newDevice(class, req, func(_ string, other map[string]interface{}) (api.Circuit, error) {
+			return core.NewCircuitFromConfig(util.NewLogger("circuit"), other)
+		}, config.Circuits())
 	}
 
 	if err != nil {
@@ -284,6 +301,11 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		err = updateDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
+
+	case templates.Circuit:
+		err = updateDevice(id, class, req, func(_ string, other map[string]interface{}) (api.Circuit, error) {
+			return core.NewCircuitFromConfig(util.NewLogger("circuit"), other)
+		}, config.Circuits())
 	}
 
 	setConfigDirty()
@@ -347,6 +369,9 @@ func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		err = deleteDevice(id, config.Vehicles())
+
+	case templates.Circuit:
+		err = deleteDevice(id, config.Circuits())
 	}
 
 	setConfigDirty()
@@ -413,6 +438,9 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
 
 	case templates.Vehicle:
 		instance, err = testConfig(id, class, req, vehicle.NewFromConfig, config.Vehicles())
+
+	case templates.Circuit:
+		err = api.ErrNotAvailable
 	}
 
 	if err != nil {
diff --git a/util/config/instance.go b/util/config/instance.go
index 2fd2dcfa..d6fbee1b 100644
--- a/util/config/instance.go
+++ b/util/config/instance.go
@@ -7,14 +7,22 @@ import (
 
 var bus = evbus.New()
 
-var instance = struct {
+var instance struct {
 	meters   *handler[api.Meter]
 	chargers *handler[api.Charger]
 	vehicles *handler[api.Vehicle]
-}{
-	meters:   &handler[api.Meter]{topic: "meter"},
-	chargers: &handler[api.Charger]{topic: "charger"},
-	vehicles: &handler[api.Vehicle]{topic: "vehicle"},
+	circuits *handler[api.Circuit]
+}
+
+func init() {
+	Reset()
+}
+
+func Reset() {
+	instance.meters = &handler[api.Meter]{topic: "meter"}
+	instance.chargers = &handler[api.Charger]{topic: "charger"}
+	instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
+	instance.circuits = &handler[api.Circuit]{topic: "circuit"}
 }
 
 type Handler[T any] interface {
@@ -37,6 +45,10 @@ func Vehicles() Handler[api.Vehicle] {
 	return instance.vehicles
 }
 
+func Circuits() Handler[api.Circuit] {
+	return instance.circuits
+}
+
 // Instances returns the instances of the given devices
 func Instances[T any](devices []Device[T]) []T {
 	res := make([]T, 0, len(devices))
diff --git a/util/config/types.go b/util/config/types.go
index af37a20c..9e7334b9 100644
--- a/util/config/types.go
+++ b/util/config/types.go
@@ -1,5 +1,9 @@
 package config
 
+import (
+	"strings"
+)
+
 type Typed struct {
 	Type  string                 `json:"type"`
 	Other map[string]interface{} `mapstructure:",remain"`
@@ -10,3 +14,13 @@ type Named struct {
 	Type  string                 `json:"type"`
 	Other map[string]interface{} `mapstructure:",remain"`
 }
+
+// Property returns the value of the named property
+func (n Named) Property(key string) any {
+	for k, v := range n.Other {
+		if strings.EqualFold(k, key) {
+			return v
+		}
+	}
+	return nil
+}
diff --git a/util/ptr.go b/util/ptr.go
new file mode 100644
index 00000000..5525b754
--- /dev/null
+++ b/util/ptr.go
@@ -0,0 +1,15 @@
+package util
+
+// PtrTo returns a pointer to the value passed as argument. The zero is returned as nil.
+func PtrTo[T comparable](v T) *T {
+	var zero T
+	if v == zero {
+		return nil
+	}
+	return &v
+}
+
+// PtrToWithZero returns a pointer to the value passed as argument, including its zero value.
+func PtrToWithZero[T any](v T) *T {
+	return &v
+}
diff --git a/util/templates/class.go b/util/templates/class.go
index faa734e4..8e970ec6 100644
--- a/util/templates/class.go
+++ b/util/templates/class.go
@@ -9,4 +9,5 @@ const (
 	Meter
 	Vehicle
 	Tariff
+	Circuit
 )
diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go
index b21ff61d..0891e5c0 100644
--- a/util/templates/class_enumer.go
+++ b/util/templates/class_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _ClassName = "ChargerMeterVehicleTariff"
+const _ClassName = "ChargerMeterVehicleTariffCircuit"
 
-var _ClassIndex = [...]uint8{0, 7, 12, 19, 25}
+var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32}
 
-const _ClassLowerName = "chargermetervehicletariff"
+const _ClassLowerName = "chargermetervehicletariffcircuit"
 
 func (i Class) String() string {
 	i -= 1
@@ -29,9 +29,10 @@ func _ClassNoOp() {
 	_ = x[Meter-(2)]
 	_ = x[Vehicle-(3)]
 	_ = x[Tariff-(4)]
+	_ = x[Circuit-(5)]
 }
 
-var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff}
+var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit}
 
 var _ClassNameToValueMap = map[string]Class{
 	_ClassName[0:7]:        Charger,
@@ -42,6 +43,8 @@ var _ClassNameToValueMap = map[string]Class{
 	_ClassLowerName[12:19]: Vehicle,
 	_ClassName[19:25]:      Tariff,
 	_ClassLowerName[19:25]: Tariff,
+	_ClassName[25:32]:      Circuit,
+	_ClassLowerName[25:32]: Circuit,
 }
 
 var _ClassNames = []string{
@@ -49,6 +52,7 @@ var _ClassNames = []string{
 	_ClassName[7:12],
 	_ClassName[12:19],
 	_ClassName[19:25],
+	_ClassName[25:32],
 }
 
 // ClassString retrieves an enum value from the enum constants string name.


Test patch:
diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go
new file mode 100644
index 00000000..ab48fa19
--- /dev/null
+++ b/cmd/setup_circuits_test.go
@@ -0,0 +1,140 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core"
+	"github.com/evcc-io/evcc/meter"
+	"github.com/evcc-io/evcc/server/db"
+	"github.com/evcc-io/evcc/tariff"
+	"github.com/evcc-io/evcc/util/config"
+	"github.com/spf13/viper"
+	"github.com/stretchr/testify/suite"
+	"go.uber.org/mock/gomock"
+)
+
+func TestSetupCircuits(t *testing.T) {
+	suite.Run(t, new(circuitsTestSuite))
+}
+
+type circuitsTestSuite struct {
+	suite.Suite
+}
+
+func (suite *circuitsTestSuite) SetupSuite() {
+	db, err := db.New("sqlite", ":memory:")
+	if err != nil {
+		suite.T().Fatal(err)
+	}
+	config.Init(db)
+}
+
+func (suite *circuitsTestSuite) SetupTest() {
+	config.Reset()
+}
+
+func (suite *circuitsTestSuite) TestCircuitConf() {
+	var conf globalConfig
+	viper.SetConfigType("yaml")
+
+	suite.Require().NoError(viper.ReadConfig(strings.NewReader(`circuits:
+- name: master
+  maxPower: 10000
+- name: slave
+  parent: master
+  maxPower: 10000
+loadpoints:
+- charger: test
+  circuit: slave
+`)))
+
+	suite.Require().NoError(viper.UnmarshalExact(&conf))
+
+	suite.Require().NoError(configureCircuits(conf.Circuits))
+	suite.Require().Len(config.Circuits().Devices(), 2)
+	suite.Require().False(config.Circuits().Devices()[0].Instance().HasMeter())
+
+	// empty charger
+	suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
+		Name: "test",
+	}, api.Charger(nil))))
+
+	lps, err := configureLoadpoints(conf)
+	suite.Require().NoError(err)
+	suite.Require().Len(lps, 1)
+	suite.Require().NotNil(lps[0].GetCircuit())
+}
+
+func (suite *circuitsTestSuite) TestLoadpointMissingCircuitError() {
+	var conf globalConfig
+	viper.SetConfigType("yaml")
+
+	suite.Require().NoError(viper.ReadConfig(strings.NewReader(`
+loadpoints:
+- charger: test
+`)))
+
+	suite.Require().NoError(viper.UnmarshalExact(&conf))
+
+	ctrl := gomock.NewController(suite.T())
+	circuit := api.NewMockCircuit(ctrl)
+
+	// mock circuit
+	suite.Require().NoError(config.Circuits().Add(config.NewStaticDevice(config.Named{
+		Name: "test",
+	}, api.Circuit(circuit))))
+
+	// mock charger
+	suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
+		Name: "test",
+	}, api.Charger(nil))))
+
+	lps, err := configureLoadpoints(conf)
+	suite.Require().NoError(err)
+
+	site := core.NewSite()
+	circuit.EXPECT().HasMeter().Return(false)
+	suite.Require().Error(validateCircuits(site, lps))
+}
+
+func (suite *circuitsTestSuite) TestSiteMissingCircuitError() {
+	var conf globalConfig
+	viper.SetConfigType("yaml")
+
+	suite.Require().NoError(viper.ReadConfig(strings.NewReader(`
+loadpoints:
+- charger: test
+site:
+  meters:
+    grid: grid
+`)))
+
+	suite.Require().NoError(viper.UnmarshalExact(&conf))
+
+	lps := []*core.Loadpoint{
+		new(core.Loadpoint),
+	}
+
+	// mock circuit
+	suite.Require().NoError(config.Circuits().Add(config.NewStaticDevice(config.Named{
+		Name: "test",
+	}, api.Circuit(nil))))
+
+	// mock meter
+	m, _ := meter.NewConfigurable(func() (float64, error) {
+		return 0, nil
+	})
+	suite.Require().NoError(config.Meters().Add(config.NewStaticDevice(config.Named{
+		Name: "grid",
+	}, api.Meter(m))))
+
+	// mock charger
+	suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
+		Name: "test",
+	}, api.Charger(nil))))
+
+	_, err := configureSite(conf.Site, lps, new(tariff.Tariffs))
+	suite.Require().Error(err)
+}
diff --git a/cmd/setup_test.go b/cmd/setup_test.go
index f00f8f5b..068b7883 100644
--- a/cmd/setup_test.go
+++ b/cmd/setup_test.go
@@ -10,15 +10,12 @@ import (
 	"github.com/spf13/viper"
 )
 
-const sample = `
-loadpoints:
-- mode: off
-`
-
 func TestYamlOff(t *testing.T) {
 	var conf globalConfig
 	viper.SetConfigType("yaml")
-	if err := viper.ReadConfig(strings.NewReader(sample)); err != nil {
+	if err := viper.ReadConfig(strings.NewReader(`loadpoints:
+- mode: off
+`)); err != nil {
 		t.Error(err)
 	}
 
diff --git a/core/circuit_test.go b/core/circuit_test.go
new file mode 100644
index 00000000..f29e67d7
--- /dev/null
+++ b/core/circuit_test.go
@@ -0,0 +1,123 @@
+package core
+
+import (
+	"testing"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+func TestCircuitPower(t *testing.T) {
+	log := util.NewLogger("foo")
+
+	circ := func(t *testing.T, ctrl *gomock.Controller, maxP float64) (*Circuit, *api.MockMeter) {
+		m := api.NewMockMeter(ctrl)
+		c, err := NewCircuit(log, "foo", 0, maxP, m)
+		require.NoError(t, err)
+		return c, m
+	}
+
+	for _, tc := range []struct {
+		pm, cm1, cm2 float64
+		req, res     float64
+	}{
+		// no load
+		{0, 0, 0, 0, 0},
+		{0, 0, 0, 1, 1},
+		{0, 0, 0, 2, 1},
+
+		// c1 loaded
+		{0, 1, 0, 0, 0},
+		{0, 1, 0, 1, 0},
+		{0, 1, 0, 2, 0},
+
+		// pc loaded
+		{1, 0, 0, 0, 0},
+		{1, 0, 0, 1, 0},
+		{1, 0, 0, 2, 0},
+	} {
+		ctrl := gomock.NewController(t)
+
+		pc, pm := circ(t, ctrl, 1)
+		c1, cm1 := circ(t, ctrl, 1)
+		c2, cm2 := circ(t, ctrl, 1)
+
+		c1.SetParent(pc)
+		c2.SetParent(pc)
+
+		// update meters
+		pm.EXPECT().CurrentPower().Return(tc.pm, nil)
+		cm1.EXPECT().CurrentPower().Return(tc.cm1, nil)
+		cm2.EXPECT().CurrentPower().Return(tc.cm2, nil)
+		require.NoError(t, pc.Update(nil))
+
+		require.Equal(t, tc.res, c1.ValidatePower(0, tc.req))
+
+		ctrl.Finish()
+	}
+}
+
+// func TestCircuitCurrents(t *testing.T) {
+// 	log := util.NewLogger("foo")
+
+// 	type mockMeter struct {
+// 		*api.MockMeter
+// 		*api.MockPhaseCurrents
+// 	}
+
+// 	circ := func(t *testing.T, ctrl *gomock.Controller, maxP float64) (*Circuit, *mockMeter) {
+// 		m := api.NewMockMeter(ctrl)
+// 		mc := api.NewMockPhaseCurrents(ctrl)
+// 		mm := &mockMeter{m, mc}
+// 		c, err := NewCircuit(log, 0, maxP, mm)
+// 		require.NoError(t, err)
+// 		return c, mm
+// 	}
+
+// 	for _, tc := range []struct {
+// 		pm, cm1, cm2 float64
+// 		req, res     float64
+// 	}{
+// 		// no load
+// 		{0, 0, 0, 0, 0},
+// 		{0, 0, 0, 1, 1},
+// 		{0, 0, 0, 2, 1},
+
+// 		// c1 loaded
+// 		{0, 1, 0, 0, 0},
+// 		{0, 1, 0, 1, 0},
+// 		{0, 1, 0, 2, 0},
+
+// 		// pc loaded
+// 		{1, 0, 0, 0, 0},
+// 		{1, 0, 0, 1, 0},
+// 		{1, 0, 0, 2, 0},
+// 	} {
+// 		ctrl := gomock.NewController(t)
+
+// 		pc, pm := circ(t, ctrl, 1)
+// 		c1, cm1 := circ(t, ctrl, 1)
+// 		c2, cm2 := circ(t, ctrl, 1)
+
+// 		c1.SetParent(pc)
+// 		c2.SetParent(pc)
+
+// 		// update meters
+// 		pm.MockMeter.EXPECT().CurrentPower().Return(tc.pm, nil)
+// 		cm1.MockMeter.EXPECT().CurrentPower().Return(tc.cm1, nil)
+// 		cm2.MockMeter.EXPECT().CurrentPower().Return(tc.cm2, nil)
+
+// 		// update meters
+// 		pm.MockPhaseCurrents.EXPECT().Currents().Return(tc.pm, tc.pm, tc.pm, nil)
+// 		cm1.MockPhaseCurrents.EXPECT().Currents().Return(tc.cm1, tc.cm1, tc.cm1, nil)
+// 		cm2.MockPhaseCurrents.EXPECT().Currents().Return(tc.cm2, tc.cm2, tc.cm2, nil)
+// 		require.NoError(t, pc.Update(nil))
+
+// 		require.Equal(t, tc.res, c1.ValidatePower(0, tc.req))
+// 		require.Equal(t, tc.res, c1.ValidateCurrent(0, tc.req))
+
+// 		ctrl.Finish()
+// 	}
+// }