Skip to content

Commit ce60cf7

Browse files
webalexeuclaude
andcommitted
feat(battery): make the fast loop the whole controller (snapshot + continuous direction)
Invert the main/fast split: the main loop becomes a supervisor that sets the battery mode and publishes a snapshot (per-battery SoC/limits/caps + config), and the 1s fast loop owns every power decision - direction, tiering, sticky selection, swaps, scaling, stops - off fresh grid and battery readings. Direction is continuous (the sign of the ramp-invariant energy-balance need), so there is no separate charge/discharge decision and no flip-latency to bridge. Running the full selection every tick off a measured target lets computeTier absorb both saturation and under-delivery and makes direction fall out of the sign, so the previous bridge machinery is deleted: crossing detector, out-of-band replan poke + channel, dual offsets, explicit tier-up/standby, shortfall dwell, single-writer carve-outs, and the batteryControlPlan type. Net less code, one controller, one cadence. Charge↔discharge reversals keep a 1s dwell plus adaptive backoff to bound thrash; same-direction and idle transitions are immediate. Swaps keep the write-before-stop ordering but drop the ACK check and one-tick overlap (brief blip on infrequent SoC-driven swaps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4cb95b4 commit ce60cf7

4 files changed

Lines changed: 438 additions & 1077 deletions

File tree

core/site.go

Lines changed: 29 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,8 @@ var _ site.API = (*Site)(nil)
5252

5353
// Site is the main configuration container. A site can host multiple loadpoints.
5454
type Site struct {
55-
valueChan chan<- util.Param // client push messages
56-
lpUpdateChan chan *Loadpoint
57-
batteryReplanChan chan struct{} // fast loop requests an out-of-band battery re-decision (direction flip)
55+
valueChan chan<- util.Param // client push messages
56+
lpUpdateChan chan *Loadpoint
5857

5958
sync.RWMutex
6059
log *util.Logger
@@ -76,26 +75,30 @@ type Site struct {
7675
consumerMeters []config.Device[api.Meter] // Consumer meters
7776

7877
// battery settings
79-
prioritySoc float64 // prefer battery up to this Soc
80-
bufferSoc float64 // continue charging on battery above this Soc (EV loadpoints)
81-
bufferStartSoc float64 // start charging on battery above this Soc (EV loadpoints)
82-
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
83-
batterySolarControl bool // actively charge from surplus / discharge to cover loads
84-
batteryCalibrationCharge bool // one-shot: bypass maxSoc and charge to 100% for LFP calibration (not persisted)
85-
batteryControlDeadBand float64 // minimum surplus/deficit (W) to start charge or discharge (stability dead band)
86-
batteryGridChargeLimit *float64 // grid charging limit
87-
batterySolarPool bool // distribute power equally across all batteries (no per-battery selection)
88-
batterySolarTiering bool // activate minimum number of batteries needed to stay above inverter's effective power floor
89-
batterySolarSticky bool // keep the same battery selection across ticks; swap only on significant SoC divergence
90-
batterySolarTapering bool // linearly reduce charge power in the last SoC band before maxSoc to protect cells
91-
batteryChargeTier int // tiered activation: current number of batteries charging (0 = uninitialised)
92-
batteryDischargeTier int // tiered activation: current number of batteries discharging (0 = uninitialised)
93-
batteryChargeActive []string // sticky selection: names of batteries currently in the charge tier
94-
batteryDischargeActive []string // sticky selection: names of batteries currently in the discharge tier
95-
batteryStopped map[string]int // ticks since stop was last sent per battery; skips redundant re-stops
96-
batteryPlanMu sync.Mutex // guards batteryPlan and serializes main loop battery section vs fast loop
97-
batteryPlan *batteryControlPlan // contract between main loop (writes) and fast loop (adjusts)
98-
batteryLastDirection batteryPlanDirection // direction of the previous solar control tick; direction changes force fresh power commands
78+
prioritySoc float64 // prefer battery up to this Soc
79+
bufferSoc float64 // continue charging on battery above this Soc (EV loadpoints)
80+
bufferStartSoc float64 // start charging on battery above this Soc (EV loadpoints)
81+
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
82+
batterySolarControl bool // actively charge from surplus / discharge to cover loads
83+
batteryCalibrationCharge bool // one-shot: bypass maxSoc and charge to 100% for LFP calibration (not persisted)
84+
batteryControlDeadBand float64 // minimum surplus/deficit (W) to start charge or discharge (stability dead band)
85+
batteryGridChargeLimit *float64 // grid charging limit
86+
batterySolarPool bool // distribute power equally across all batteries (no per-battery selection)
87+
batterySolarTiering bool // activate minimum number of batteries needed to stay above inverter's effective power floor
88+
batterySolarSticky bool // keep the same battery selection across ticks; swap only on significant SoC divergence
89+
batterySolarTapering bool // linearly reduce charge power in the last SoC band before maxSoc to protect cells
90+
batteryChargeTier int // tiered activation: current number of batteries charging (0 = uninitialised)
91+
batteryDischargeTier int // tiered activation: current number of batteries discharging (0 = uninitialised)
92+
batteryChargeActive []string // sticky selection: names of batteries currently in the charge tier
93+
batteryDischargeActive []string // sticky selection: names of batteries currently in the discharge tier
94+
batteryStopped map[string]int // ticks since stop was last sent per battery; skips redundant re-stops
95+
batteryPlanMu sync.Mutex // guards batterySnapshot and serializes snapshot build vs fast loop
96+
batterySnapshot *batterySnapshot // main loop → fast loop contract (SoC/limits/caps + config, no power)
97+
batteryFastDirection batteryPlanDirection // direction the fast loop is currently committed to
98+
batteryLastGrid float64 // fast loop meter guard: previous grid reading
99+
batteryLastBatt float64 // fast loop meter guard: previous total battery reading
100+
batteryGuardValid bool // fast loop meter guard: previous readings are valid
101+
batteryFlipSince time.Time // fast loop: when the opposite direction first became wanted
99102

100103
// optimizer settings
101104
optimizerChargingStrategy string // optimizer grid charging strategy
@@ -117,10 +120,8 @@ type Site struct {
117120
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
118121
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
119122
batteryModeExternalTimer time.Time // Battery mode timer for external control
120-
lastFlexiblePower float64 // last PV-mode charge flexibility, reused by out-of-band battery replan
121-
lastTotalChargePower float64 // last total loadpoint charge power, reused by out-of-band battery replan
122-
lastBatteryFlipRequest time.Time // last fast-loop flip poke; adaptive spacing to bound direction thrash
123-
batteryFlipBackoff time.Duration // current minimum spacing between flip pokes; grows on rapid re-flips, resets when calm
123+
lastBatteryFlipRequest time.Time // fast loop: last direction flip; adaptive spacing to bound thrash
124+
batteryFlipBackoff time.Duration // fast loop: current minimum spacing between flips; grows on rapid re-flips, resets when calm
124125
}
125126

126127
// MetersConfig contains the site's meter configuration
@@ -1146,11 +1147,6 @@ func (site *Site) update(lp updater) {
11461147
site.log.WARN.Println("planner:", msg)
11471148
}
11481149

1149-
// cache loadpoint-derived scalars so an out-of-band battery replan can reuse them
1150-
// without re-running the loadpoint cycle (see replanBattery)
1151-
site.lastFlexiblePower = flexiblePower
1152-
site.lastTotalChargePower = totalChargePower
1153-
11541150
// update battery after reading meters to ensure that (modbus) connection is open
11551151
batteryGridChargeActive := site.batteryGridChargeActive(rate)
11561152
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
@@ -1159,31 +1155,6 @@ func (site *Site) update(lp updater) {
11591155
site.stats.Update(site)
11601156
}
11611157

1162-
// replanBattery re-runs only the battery direction decision with fresh meter
1163-
// readings, without re-running the loadpoint cycle. Triggered out-of-band by the
1164-
// fast loop when it detects a charge<->discharge crossing, so a direction flip does
1165-
// not wait for the next scheduled tick. Reuses the last loadpoint-derived scalars
1166-
// (flexiblePower is irrelevant unless an EV charges in PV mode; totalChargePower is
1167-
// unused when grid and PV meters are present). Runs in the Run goroutine, so it never
1168-
// overlaps a scheduled update.
1169-
func (site *Site) replanBattery() {
1170-
consumption, err := site.tariffRates(api.TariffUsagePlanner)
1171-
if err != nil {
1172-
site.log.WARN.Println("planner:", err)
1173-
}
1174-
1175-
sitePower, _, _, err := site.sitePower(site.lastTotalChargePower, site.lastFlexiblePower)
1176-
if err != nil {
1177-
site.log.ERROR.Println(err)
1178-
return
1179-
}
1180-
1181-
rate, _ := consumption.At(time.Now())
1182-
batteryGridChargeActive := site.batteryGridChargeActive(rate)
1183-
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
1184-
site.updateBatteryMode(batteryGridChargeActive, rate, sitePower, true)
1185-
}
1186-
11871158
// prepare publishes initial values
11881159
func (site *Site) prepare() {
11891160
if err := site.restoreSettings(); err != nil {
@@ -1245,8 +1216,7 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
12451216
}
12461217
}()
12471218

1248-
site.lpUpdateChan = make(chan *Loadpoint, 1) // 1 capacity to avoid deadlock
1249-
site.batteryReplanChan = make(chan struct{}, 1) // 1 capacity; non-blocking send coalesces requests
1219+
site.lpUpdateChan = make(chan *Loadpoint, 1) // 1 capacity to avoid deadlock
12501220

12511221
site.prepare()
12521222

@@ -1321,8 +1291,6 @@ func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
13211291
site.update(<-loadpointChan)
13221292
case lp := <-site.lpUpdateChan:
13231293
site.update(lp)
1324-
case <-site.batteryReplanChan:
1325-
site.replanBattery()
13261294
case <-stopC:
13271295
return
13281296
}

0 commit comments

Comments
 (0)