Hems/FNN: add curtail and dim for TAB26 Steuerbox relay standard (#29886)
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: andig <cpuidle@gmail.com> Co-authored-by: andig <andi@evcc.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
498fbaba46
commit
4f943650b2
3 changed files with 232 additions and 154 deletions
|
|
@ -19,7 +19,7 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]any, site s
|
|||
return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager")
|
||||
case "eebus":
|
||||
return eebus.NewFromConfig(ctx, other, site)
|
||||
case "fnn-3":
|
||||
case "fnn", "fnn-3":
|
||||
return fnn.NewFromConfig(ctx, other, site)
|
||||
case "relay":
|
||||
return relay.NewFromConfig(ctx, other, site)
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
package fnn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
type Fnn3 struct {
|
||||
mu sync.Mutex
|
||||
log *util.Logger
|
||||
|
||||
root api.Circuit
|
||||
s1, s2, w3 func() (bool, error)
|
||||
|
||||
smartgridID uint
|
||||
limit *float64
|
||||
maxPower float64
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewFromConfig creates an Fnn3 HEMS from generic config
|
||||
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn3, error) {
|
||||
cc := struct {
|
||||
MaxPower float64
|
||||
W3 plugin.Config
|
||||
S1 *plugin.Config
|
||||
S2 *plugin.Config
|
||||
Interval time.Duration
|
||||
}{
|
||||
Interval: 10 * time.Second,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// setup grid control circuit
|
||||
gridcontrol, err := smartgrid.SetupCircuit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
site.SetCircuit(gridcontrol)
|
||||
|
||||
// s1 getter
|
||||
s1G, err := cc.S1.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s2G, err := cc.S2.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w3G, err := cc.W3.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewFnn3(gridcontrol, s1G, s2G, w3G, cc.MaxPower, cc.Interval)
|
||||
}
|
||||
|
||||
// NewFnn3 creates Fnn3 HEMS
|
||||
func NewFnn3(root api.Circuit, s1, s2, w3 func() (bool, error), maxPower float64, interval time.Duration) (*Fnn3, error) {
|
||||
c := &Fnn3{
|
||||
log: util.NewLogger("fnn3"),
|
||||
root: root,
|
||||
maxPower: maxPower,
|
||||
s1: s1,
|
||||
s2: s2,
|
||||
w3: w3,
|
||||
interval: interval,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Fnn3) Run() {
|
||||
for range time.Tick(c.interval) {
|
||||
if err := c.run(); err != nil {
|
||||
c.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Fnn3) run() error {
|
||||
w3, err := c.w3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w3 {
|
||||
// 0%
|
||||
return c.curtail(0.0)
|
||||
}
|
||||
|
||||
if c.s2 != nil {
|
||||
s2, err := c.s2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s2 {
|
||||
// 30%
|
||||
return c.curtail(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
if c.s1 != nil {
|
||||
s1, err := c.s1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s1 {
|
||||
// 60%
|
||||
return c.curtail(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
// 100%
|
||||
return c.curtail(1.0)
|
||||
}
|
||||
|
||||
func (c *Fnn3) curtail(frac float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
active := frac < 1.0
|
||||
|
||||
c.limit = nil
|
||||
if active {
|
||||
c.limit = new(c.maxPower * frac)
|
||||
}
|
||||
|
||||
c.root.Curtail(active)
|
||||
// TODO make ProductionNominalMax configurable (Site kWp)
|
||||
// c.root.SetMaxPower(c.maxPower*frac)
|
||||
|
||||
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Curtail, c.root.GetChargePower(), c.maxPower*frac, active); err != nil {
|
||||
c.log.ERROR.Printf("smartgrid session: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
231
hems/fnn/fnn.go
Normal file
231
hems/fnn/fnn.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package fnn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// NewFromConfig creates an FNN HEMS from generic config.
|
||||
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) {
|
||||
cc := struct {
|
||||
MaxPower float64 // TODO deprecated
|
||||
MaxDimPower float64
|
||||
MaxCurtailPower *float64
|
||||
W3 *plugin.Config
|
||||
S1 *plugin.Config
|
||||
S2 *plugin.Config
|
||||
W4 *plugin.Config
|
||||
Interval time.Duration
|
||||
}{
|
||||
Interval: 10 * time.Second,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// setup grid control circuit
|
||||
gridcontrol, err := smartgrid.SetupCircuit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
site.SetCircuit(gridcontrol)
|
||||
|
||||
w3G, err := cc.W3.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w4G, err := cc.W4.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// either dim or curtail must be configured
|
||||
if w3G == nil && w4G == nil {
|
||||
return nil, errors.New("must have either W3 or W4")
|
||||
}
|
||||
|
||||
s1G, err := cc.S1.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s2G, err := cc.S2.BoolGetter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxDimPower := math.Abs(cc.MaxDimPower)
|
||||
|
||||
var maxCurtailPower *float64
|
||||
switch {
|
||||
case cc.MaxCurtailPower != nil:
|
||||
maxCurtailPower = new(math.Abs(*cc.MaxCurtailPower))
|
||||
case cc.MaxPower > 0:
|
||||
// fnn-3 backwards compatibility: legacy MaxPower was the PV/curtail cap
|
||||
maxCurtailPower = new(math.Abs(cc.MaxPower))
|
||||
}
|
||||
|
||||
return &Fnn{
|
||||
log: util.NewLogger("fnn"),
|
||||
root: gridcontrol,
|
||||
maxDimPower: maxDimPower,
|
||||
maxCurtailPower: maxCurtailPower,
|
||||
s1: s1G,
|
||||
s2: s2G,
|
||||
w3: w3G,
|
||||
w4: w4G,
|
||||
interval: cc.Interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fnn implements the FNN HEMS logic for curtailment and dimming.
|
||||
type Fnn struct {
|
||||
mu sync.Mutex
|
||||
log *util.Logger
|
||||
|
||||
root api.Circuit
|
||||
s1, s2, w3 func() (bool, error)
|
||||
w4 func() (bool, error)
|
||||
|
||||
smartgridConsumptionID uint
|
||||
smartgridProductionID uint
|
||||
|
||||
maxDimPower float64
|
||||
maxCurtailPower *float64
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// Run starts the FNN control loop.
|
||||
func (c *Fnn) Run() {
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := c.runCurtail(); err != nil {
|
||||
c.log.ERROR.Println(err)
|
||||
}
|
||||
|
||||
if err := c.runDim(); err != nil {
|
||||
c.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCurtail evaluates curtailment rules and applies the appropriate limit.
|
||||
// No-op if no curtail input is configured.
|
||||
func (c *Fnn) runCurtail() error {
|
||||
if c.w3 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rules := []struct {
|
||||
get func() (bool, error)
|
||||
frac float64
|
||||
}{
|
||||
{get: c.w3, frac: 0.0},
|
||||
{get: c.s2, frac: 0.3},
|
||||
{get: c.s1, frac: 0.6},
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.get == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
active, err := rule.get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if active {
|
||||
return c.setProductionLimit(rule.frac)
|
||||
}
|
||||
}
|
||||
|
||||
// 100%
|
||||
return c.setProductionLimit(1.0)
|
||||
}
|
||||
|
||||
// runDim evaluates the dimming rule and applies the dim limit.
|
||||
// No-op if dim input is not configured.
|
||||
func (c *Fnn) runDim() error {
|
||||
if c.w4 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
active, err := c.w4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
limit := 0.0
|
||||
if active {
|
||||
if c.maxDimPower <= 0 {
|
||||
return errors.New("dim active but no limit configured")
|
||||
}
|
||||
|
||||
limit = c.maxDimPower
|
||||
}
|
||||
|
||||
return c.setConsumptionLimit(limit)
|
||||
}
|
||||
|
||||
func (c *Fnn) applyMode(id *uint, typ smartgrid.Type, active bool, limit float64, applyRoot func()) {
|
||||
applyRoot()
|
||||
|
||||
if err := smartgrid.UpdateSession(id, typ, c.root.GetChargePower(), limit, active); err != nil {
|
||||
c.log.ERROR.Printf("smartgrid session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setProductionLimit applies the curtailment limit to the circuit.
|
||||
func (c *Fnn) setProductionLimit(frac float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
limit := 0.0
|
||||
active := frac < 1.0
|
||||
|
||||
if active {
|
||||
if c.maxCurtailPower == nil {
|
||||
return errors.New("curtail active but no limit configured")
|
||||
}
|
||||
|
||||
limit = *c.maxCurtailPower * frac
|
||||
}
|
||||
|
||||
c.applyMode(&c.smartgridProductionID, smartgrid.Curtail, active, limit, func() {
|
||||
c.root.Curtail(active)
|
||||
// TODO make ProductionNominalMax configurable (Site kWp)
|
||||
// c.root.SetMaxPower(c.maxPower*frac)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setConsumptionLimit applies the dimming limit to the circuit.
|
||||
func (c *Fnn) setConsumptionLimit(limit float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
active := limit > 0
|
||||
|
||||
c.applyMode(&c.smartgridConsumptionID, smartgrid.Dim, active, limit, func() {
|
||||
c.root.Dim(active)
|
||||
c.root.SetMaxPower(limit)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue