Feat/battery loop#31603
Conversation
ce60cf7 to
ce59623
Compare
There was a problem hiding this comment.
Hey - I've found 5 issues, and left some high level feedback:
- In batteryFastTick, the goroutine launched in the
for i := range snap.batteriesloop closes over the loop variableiinstead of taking it as a parameter, which can lead to races and writing power/err results into the wrong slot; passiinto the goroutine (e.g.go func(i int) { ... }(i)). - batteryFastTick holds batteryPlanMu across potentially slow operations (meter reads, power commands, stopBatteries), which can block buildBatterySnapshot and make snapshots stale; consider narrowing the critical section (copy the snapshot under the lock, then release it before doing I/O and control).
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In batteryFastTick, the goroutine launched in the `for i := range snap.batteries` loop closes over the loop variable `i` instead of taking it as a parameter, which can lead to races and writing power/err results into the wrong slot; pass `i` into the goroutine (e.g. `go func(i int) { ... }(i)`).
- batteryFastTick holds batteryPlanMu across potentially slow operations (meter reads, power commands, stopBatteries), which can block buildBatterySnapshot and make snapshots stale; consider narrowing the critical section (copy the snapshot under the lock, then release it before doing I/O and control).
## Individual Comments
### Comment 1
<location path="core/site_api.go" line_range="388" />
<code_context>
+ return site.batteryCalibrationCharge
+}
+
+// SetBatteryCalibrationCharge enables/disables the one-shot LFP calibration charge.
+// When active, maxSoc is bypassed and the battery charges to 100%. Auto-disables
+// when aggregate SoC reaches 99%. Not persisted — resets to false on restart.
</code_context>
<issue_to_address>
**issue:** Calibration auto-disable comment disagrees with implementation threshold
The comment states that calibration auto-disables at 99% aggregate SoC, but `buildBatterySnapshot` turns `batteryCalibrationCharge` off at `site.battery.Soc >= 100`. Please align the comment and the threshold so it’s clear exactly when calibration stops.
</issue_to_address>
### Comment 2
<location path="docs/agents/battery-management.md" line_range="154-159" />
<code_context>
+Linearly reduces charge power in the last 10% of SoC before `maxSoc`. Mimics the CC/CV charging profile that protects lithium cells from stress near full charge.
+
+```
+taperFactor = (maxSoc - currentSoc) / chargeTaperRange (clamped to minimum 0.10)
+chargePower = requestedPower × taperFactor
+```
+
+- **Taper range**: 5% SoC below maxSoc
+- **Minimum factor**: 25% of requested power (never fully stopped by taper)
+- **Per-battery**: applied individually using each battery's `BatterySocLimiter.GetSocLimits()`
+- Applied after the hard-cap from `BatteryPowerLimiter`
</code_context>
<issue_to_address>
**issue:** The documented minimum taper factor is inconsistent (0.10 vs 25%).
The formula comment says `taperFactor` is clamped to a minimum of 0.10, while the bullet below defines a "Minimum factor" of 25% of requested power. These look like the same lower bound but use different values. Please clarify which minimum is correct and update the docs to be consistent, or explicitly explain if they refer to different concepts.
</issue_to_address>
### Comment 3
<location path="core/site_battery_fast.go" line_range="116" />
<code_context>
+ }
+}
+
+func (site *Site) batteryFastTick() {
+ site.batteryPlanMu.Lock()
+ defer site.batteryPlanMu.Unlock()
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the meter-guard/target computation from `batteryFastTick` and splitting `fastControl` into smaller helpers so each step of the fast loop is handled by a focused, testable function.
You can reduce the cognitive load without changing behavior by carving out a few focused helpers from the monolithic functions. Two low‑risk wins:
---
### 1. Split `batteryFastTick` into guard + planning
`batteryFastTick` currently handles snapshot age, meter guards, direction choice, and calling the controller in one go. Extracting the guard + target computation into a pure helper keeps the main tick readable and easier to test.
```go
// small data carrier for the fast loop
type fastLoopState struct {
gridPower, battPower float64
dischargeTarget, chargeTarget float64
desired batteryPlanDirection
}
// pure-ish helper: all meter guards + target computation
func (site *Site) computeFastLoopState(snap *batterySnapshot) (*fastLoopState, bool) {
gridPower, err := site.gridMeter.CurrentPower()
if err != nil {
batteryLog.DEBUG.Printf("solar power (fast): grid power: %v", err)
return nil, false
}
firstTick := !site.batteryGuardValid
if !firstTick && gridPower == site.batteryLastGrid {
batteryLog.TRACE.Printf("solar power (fast): stale grid %.0fW, skip", gridPower)
return nil, false
}
powers := make([]float64, len(snap.batteries))
errs := make([]error, len(snap.batteries))
var wg sync.WaitGroup
for i := range snap.batteries {
i := i
wg.Add(1)
go func() {
defer wg.Done()
powers[i], errs[i] = snap.batteries[i].meter.CurrentPower()
}()
}
wg.Wait()
var battPower float64
for i, e := range snap.batteries {
if errs[i] != nil {
batteryLog.DEBUG.Printf("solar power (fast): %s power: %v", e.name, errs[i])
return nil, false
}
battPower += powers[i]
}
dGrid, dBatt := gridPower-site.batteryLastGrid, battPower-site.batteryLastBatt
site.batteryLastGrid, site.batteryLastBatt, site.batteryGuardValid = gridPower, battPower, true
if !firstTick && math.Abs(dBatt) > fastLoopSkewThreshold && math.Abs(dGrid+dBatt) > fastLoopSkewThreshold {
batteryLog.TRACE.Printf("solar power (fast): meters inconsistent (Δgrid %.0fW, Δbattery %.0fW), skip", dGrid, dBatt)
return nil, false
}
dischargeTarget := battPower + gridPower + snap.dischargeOffset - snap.dischargeEvExcluded
chargeTarget := -battPower - (gridPower + snap.chargeOffset)
desired := batteryPlanIdle
switch {
case dischargeTarget > snap.threshold && dischargeTarget >= chargeTarget:
desired = batteryPlanDischarge
case chargeTarget > snap.threshold:
desired = batteryPlanCharge
}
return &fastLoopState{
gridPower: gridPower,
battPower: battPower,
dischargeTarget: dischargeTarget,
chargeTarget: chargeTarget,
desired: desired,
}, true
}
```
Then `batteryFastTick` becomes mostly orchestration, with much less nesting:
```go
func (site *Site) batteryFastTick() {
site.batteryPlanMu.Lock()
defer site.batteryPlanMu.Unlock()
snap := site.batterySnapshot
if snap == nil || !snap.enabled || len(snap.batteries) == 0 || time.Since(snap.created) > batterySnapshotMaxAge {
// existing logging here
return
}
if site.batteryStopped == nil {
site.batteryStopped = make(map[string]int)
}
st, ok := site.computeFastLoopState(snap)
if !ok {
return
}
batteryLog.TRACE.Printf("solar power (fast): grid=%.0fW batt=%.0fW dis=%.0fW chg=%.0fW committed=%s desired=%s",
st.gridPower, st.battPower, st.dischargeTarget, st.chargeTarget,
batteryPlanDirectionString(site.batteryFastDirection), batteryPlanDirectionString(st.desired))
direction := site.batteryArbitrateDirection(st.desired)
switch direction {
case batteryPlanCharge:
site.fastControl(snap, batteryPlanCharge, math.Max(0, st.chargeTarget))
case batteryPlanDischarge:
site.fastControl(snap, batteryPlanDischarge, math.Max(0, st.dischargeTarget))
default:
site.stopBatteries(fastEntriesAll(snap))
site.batteryChargeActive, site.batteryDischargeActive = nil, nil
site.batteryChargeTier, site.batteryDischargeTier = 0, 0
}
site.batteryFastDirection = direction
}
```
This isolates the meter guard / skew logic and target computation into something you can unit‑test independently without touching direction arbitration or dispatch.
---
### 2. Decompose `fastControl` into small, composable steps
`fastControl` currently does: eligibility filtering → tiering/sticky/concentration → scaling/tapering → dispatch/stop. Splitting this into 2–3 focused helpers keeps behavior but flattens nesting.
For example:
```go
func (site *Site) filterEligible(snap *batterySnapshot, dir batteryPlanDirection) (active, ineligible []fastEntry) {
charge := dir == batteryPlanCharge
for i := range snap.batteries {
e := &snap.batteries[i]
fe := fastEntry{batterySnapEntry: e}
if charge {
if !snap.calibration && e.hasSocLimit && e.maxSoc > 0 && e.socOK && e.soc >= e.maxSoc {
ineligible = append(ineligible, fe)
continue
}
} else {
if !e.socOK || (e.hasSocLimit && e.soc <= e.minSoc) {
ineligible = append(ineligible, fe)
continue
}
}
active = append(active, fe)
}
return
}
func (site *Site) applyTieringAndSticky(
snap *batterySnapshot,
dir batteryPlanDirection,
target float64,
active []fastEntry,
) (selected, toStop []fastEntry) {
charge := dir == batteryPlanCharge
if snap.pool {
return active, nil
}
capOf := func(e fastEntry) float64 {
if charge {
return e.chargeCap
}
return e.dischargeCap
}
var maxPerBat float64
for _, e := range active {
if c := capOf(e); c > 0 && (maxPerBat == 0 || c < maxPerBat) {
maxPerBat = c
}
}
selected = active
if maxPerBat > 0 && snap.tiering {
tierPerBat := maxPerBat * batteryTierFraction
tier := &site.batteryChargeTier
activeNames := &site.batteryChargeActive
if !charge {
tier = &site.batteryDischargeTier
activeNames = &site.batteryDischargeActive
}
*tier = computeTier(target, tierPerBat, *tier, len(active))
needed := *tier
if needed < len(active) {
var stop []fastEntry
selected, stop = site.selectSticky(active, needed, charge, activeNames)
toStop = append(toStop, stop...)
} else {
*activeNames = nil
}
} else if maxPerBat == 0 {
share := target / float64(len(active))
if share < minEffectiveShare && len(active) > 1 {
best := 0
for i, e := range active {
if charge {
if e.socOK && (i == 0 || e.soc < active[best].soc) {
best = i
}
} else if e.socOK && (i == 0 || e.soc > active[best].soc) {
best = i
}
}
for i, e := range active {
if i != best {
toStop = append(toStop, e)
}
}
selected = active[best : best+1]
}
}
return
}
```
Then `fastControl` becomes a thin pipeline:
```go
func (site *Site) fastControl(snap *batterySnapshot, dir batteryPlanDirection, target float64) {
active, ineligible := site.filterEligible(snap, dir)
deferStop := append([]fastEntry{}, ineligible...)
if len(active) == 0 {
site.stopBatteries(fastEntriesAll(snap))
return
}
selected, moreStop := site.applyTieringAndSticky(snap, dir, target, active)
deferStop = append(deferStop, moreStop...)
charge := dir == batteryPlanCharge
commands := site.computeCommands(snap, selected, dir, target)
site.sendBatteries(selected, charge, commands)
var total float64
for _, c := range commands {
total += c
}
batteryLog.DEBUG.Printf("solar power (fast): %s %.0fW across %d/%d batteries (grid target %.0fW)",
batteryPlanDirectionString(dir), total, len(selected), len(snap.batteries), target)
site.stopBatteries(deferStop)
}
```
`computeCommands` can hold the cap + taper logic you already have, but in a small, self‑contained function.
This keeps all existing semantics (tiering, sticky, tapering, stop‑guard), but breaks them into named steps aligned with the reviewer’s suggested responsibilities (`filterEligible`, `applyTieringAndSticky`, `computeCommands`), which reduces branching in each function and makes the module easier to reason about and test.
</issue_to_address>
### Comment 4
<location path="core/site.go" line_range="82" />
<code_context>
+ bufferSoc float64 // continue charging on battery above this Soc (EV loadpoints)
+ bufferStartSoc float64 // start charging on battery above this Soc (EV loadpoints)
+ batteryDischargeControl bool // prevent battery discharge for fast and planned charging
+ batterySolarControl bool // actively charge from surplus / discharge to cover loads
+ batteryCalibrationCharge bool // one-shot: bypass maxSoc and charge to 100% for LFP calibration (not persisted)
+ batteryControlDeadBand float64 // minimum surplus/deficit (W) to start charge or discharge (stability dead band)
</code_context>
<issue_to_address>
**issue (complexity):** Consider encapsulating the new battery solar/fast-loop configuration and state into a dedicated controller struct that `Site` delegates to, instead of expanding `Site` with many loosely related fields and behaviors.
You can reduce the added complexity without changing behavior by encapsulating the new battery‑solar/fast‑loop state into a dedicated struct and letting `Site` delegate to it.
### 1. Group the new fields into a controller struct
Instead of adding all battery‑solar/fast‑loop fields directly on `Site`, move them into a dedicated type:
```go
type BatterySolarController struct {
// configuration
solarControl bool
calibrationCharge bool
controlDeadBand float64
solarPool bool
solarTiering bool
solarSticky bool
solarTapering bool
// tiered / sticky state
chargeTier int
dischargeTier int
chargeActive []string
dischargeActive []string
stopped map[string]int
// snapshot / fast‑loop contract
mu sync.Mutex
snapshot *batterySnapshot
direction batteryPlanDirection
// fast‑loop metering guard
lastGrid float64
lastBatt float64
guardValid bool
// flip timing / backoff
flipSince time.Time
lastFlipRequest time.Time
flipBackoff time.Duration
}
```
Attach it to `Site`:
```go
type Site struct {
// ...
prioritySoc float64
bufferSoc float64
bufferStartSoc float64
batteryDischargeControl bool
batteryGridChargeLimit *float64
batterySolar *BatterySolarController
// cached state ...
}
```
This keeps the god‑struct surface area down and makes the conceptual boundary of “battery solar control” obvious.
### 2. Delegate settings load/restore into the controller
Move the solar‑related settings wiring out of `Site.restoreSettings` into a method on `BatterySolarController`. The `Site` owns the controller but doesn’t need to know about all flags:
```go
func (c *BatterySolarController) LoadSettings(settings *util.Settings) error {
// defaults
c.solarPool = false
c.solarTiering = true
c.solarSticky = true
c.solarTapering = true
if v, err := settings.Bool(keys.BatterySolarControl); err == nil {
c.solarControl = v
}
if v, err := settings.Float(keys.BatteryControlDeadBand); err == nil {
c.controlDeadBand = v
}
if v, err := settings.Bool(keys.BatterySolarPool); err == nil {
c.solarPool = v
}
if v, err := settings.Bool(keys.BatterySolarTiering); err == nil {
c.solarTiering = v
}
if v, err := settings.Bool(keys.BatterySolarSticky); err == nil {
c.solarSticky = v
}
if v, err := settings.Bool(keys.BatterySolarTapering); err == nil {
c.solarTapering = v
}
return nil
}
```
`Site.restoreSettings` then becomes simpler:
```go
func (site *Site) restoreSettings() error {
// existing non‑battery settings...
if err := site.batterySolar.LoadSettings(settings); err != nil {
return err
}
// rest of method...
return nil
}
```
### 3. Delegate publishing of solar‑related state
Similarly, move the publish calls into the controller, so `prepare` doesn’t need to know every flag:
```go
func (c *BatterySolarController) Publish(publish func(key string, val any)) {
publish(keys.BatterySolarControl, c.solarControl)
publish(keys.BatteryCalibrationCharge, c.calibrationCharge)
publish(keys.BatteryControlDeadBand, c.controlDeadBand)
publish(keys.BatterySolarPool, c.solarPool)
publish(keys.BatterySolarTiering, c.solarTiering)
publish(keys.BatterySolarSticky, c.solarSticky)
publish(keys.BatterySolarTapering, c.solarTapering)
}
```
Then in `Site.prepare`:
```go
func (site *Site) prepare() {
if err := site.restoreSettings(); err != nil {
site.log.ERROR.Println(err)
}
// other publishes...
site.batterySolar.Publish(site.publish)
}
```
### 4. Encapsulate fast‑loop direction / flip logic
The fast‑loop‑specific fields and logic (`batteryFastDirection`, `batteryLastGrid`, `batteryLastBatt`, `batteryGuardValid`, `batteryFlipSince`, `lastBatteryFlipRequest`, `batteryFlipBackoff`) can live behind methods on `BatterySolarController` so `Site.updateBatteryMode` and `batteryFastLoop` just call into it:
```go
func (c *BatterySolarController) Plan(
now time.Time,
sitePower float64,
sitePowerValid bool,
batt types.BatteryState,
) batteryPlan {
c.mu.Lock()
defer c.mu.Unlock()
// use c.direction, c.lastGrid, c.flipSince, c.lastFlipRequest, c.flipBackoff, etc.
// to compute the next plan, respecting dead band and backoff
return plan
}
```
Then adjust `Site.updateBatteryMode` to delegate:
```go
func (site *Site) updateBatteryMode(
gridChargeActive bool,
rate *planner.Rate,
sitePower float64,
sitePowerValid bool,
) {
if !site.batterySolar.solarControl {
// existing non‑solar logic
return
}
plan := site.batterySolar.Plan(time.Now(), sitePower, sitePowerValid, site.battery)
// apply plan to underlying batteries / modes (unchanged)
}
```
This preserves all behavior but:
- keeps `Site` focused on orchestration and wiring,
- makes the battery solar controller a clear, testable unit,
- groups tightly coupled flags/state so you don’t have to reason about them scattered across `Site`.
</issue_to_address>
### Comment 5
<location path="core/site_battery.go" line_range="165" />
<code_context>
+ }
+}
+
+// buildBatterySnapshot reads the slow-moving per-battery state (SoC, limits, power caps) and
+// the current config, then publishes a snapshot for the fast loop under batteryPlanMu. It
+// commands no power and chooses no direction - those are the fast loop's job.
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting small helper functions from buildBatterySnapshot and separating snapshot updates from mode decisions to make the battery control flow easier to scan and maintain without changing behavior.
You can reduce the perceived complexity without changing behavior by factoring out a few narrowly‑scoped helpers and loosening the coupling between mode logic and snapshot building.
### 1. Split `buildBatterySnapshot` into small helpers
Right now `buildBatterySnapshot` handles:
- EV power aggregation
- Calibration charge completion
- Residual/offset calculations
- Snapshot assembly
- Per‑battery capability discovery
You can keep the same behavior but move each concern into a helper with a clear contract. That makes the main function easier to scan and reason about.
Example (shortened for illustration only):
```go
func (site *Site) buildBatterySnapshot(rate api.Rate) {
evPower, evPowerFast := site.computeEvPowers()
site.maybeFinishCalibrationCharge()
chargeOffset, dischargeOffset, dischargeEvExcluded := site.computeBatteryOffsets(rate, evPower, evPowerFast)
snap := &batterySnapshot{
enabled: true,
pool: site.batterySolarPool,
tiering: site.batterySolarTiering,
sticky: site.batterySolarSticky,
tapering: site.batterySolarTapering,
calibration: site.batteryCalibrationCharge,
chargeOffset: chargeOffset,
dischargeOffset: dischargeOffset,
dischargeEvExcluded: dischargeEvExcluded,
threshold: standbyPower + site.batteryControlDeadBand,
created: time.Now(),
}
site.fillBatteryEntries(snap)
site.batteryPlanMu.Lock()
site.batterySnapshot = snap
site.batteryPlanMu.Unlock()
batteryLog.TRACE.Printf(
"battery snapshot: %d batteries soc=%.0f%% chargeOff=%.0fW dischargeOff=%.0fW evExcl=%.0fW threshold=%.0fW",
len(snap.batteries), site.battery.Soc, chargeOffset, dischargeOffset, dischargeEvExcluded, snap.threshold,
)
}
```
Helpers (kept short and focused):
```go
func (site *Site) computeEvPowers() (evPower, evPowerFast float64) {
for _, lp := range site.loadpoints {
if lp.IsHeating() {
continue
}
p := lp.GetChargePower()
evPower += p
if lp.GetStatus() != api.StatusA && lp.IsFastChargingActive() {
evPowerFast += p
}
}
return
}
func (site *Site) maybeFinishCalibrationCharge() {
if site.batteryCalibrationCharge && site.battery.Soc >= 100 {
site.Lock()
site.batteryCalibrationCharge = false
site.Unlock()
site.publish(keys.BatteryCalibrationCharge, false)
batteryLog.DEBUG.Printf("battery calibration charge complete (soc %.0f%%)", site.battery.Soc)
}
}
func (site *Site) computeBatteryOffsets(
rate api.Rate, evPower, evPowerFast float64,
) (chargeOffset, dischargeOffset, dischargeEvExcluded float64) {
residual := site.GetResidualPower()
if site.battery.Soc >= site.prioritySoc {
chargeOffset = residual
}
if site.dischargeControlActive(rate) {
dischargeEvExcluded = evPowerFast
} else if !(site.bufferSoc > 0 && site.battery.Soc > site.bufferSoc) {
dischargeEvExcluded = evPower
}
return chargeOffset, residual, dischargeEvExcluded
}
func (site *Site) fillBatteryEntries(snap *batterySnapshot) {
for _, dev := range site.batteryMeters {
ctrl, ok := api.Cap[api.BatteryPowerController](dev.Instance())
if !ok {
continue
}
e := batterySnapEntry{
ctrl: ctrl,
meter: dev.Instance(),
name: dev.Config().Name,
}
if bat, ok := api.Cap[api.Battery](dev.Instance()); ok {
if soc, err := bat.Soc(); err == nil {
e.soc, e.socOK = soc, true
}
}
if limiter, ok := api.Cap[api.BatterySocLimiter](dev.Instance()); ok {
e.hasSocLimit = true
e.minSoc, e.maxSoc = limiter.GetSocLimits()
}
if limiter, ok := api.Cap[api.BatteryPowerLimiter](dev.Instance()); ok {
e.chargeCap, e.dischargeCap = limiter.GetPowerLimits()
}
snap.batteries = append(snap.batteries, e)
}
}
```
This keeps all logic in the same place but makes each step self‑documenting and individually testable.
### 2. Decouple mode decisions from snapshot updates
`updateBatteryMode` currently decides the mode and also manages the snapshot for the fast loop. To reduce implicit coupling, you can keep the behavior but make the separation explicit: one function decides modes, another updates the snapshot.
Minimal change example:
```go
func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate, sitePower float64, sitePowerValid bool) {
batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate, sitePower)
// existing HEMS dimming + applyBatteryMode logic stays as-is...
// ...
site.updateBatterySnapshot(rate)
}
func (site *Site) updateBatterySnapshot(rate api.Rate) {
if !site.batterySolarControl {
site.batteryPlanMu.Lock()
site.batterySnapshot = nil
site.batteryPlanMu.Unlock()
return
}
site.buildBatterySnapshot(rate)
}
```
This keeps all current behavior (including when the snapshot is built/cleared) but makes the file’s responsibilities clearer and prepares for moving snapshot/selection concerns into a dedicated controller later if needed.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| return site.batteryCalibrationCharge | ||
| } | ||
|
|
||
| // SetBatteryCalibrationCharge enables/disables the one-shot LFP calibration charge. |
There was a problem hiding this comment.
issue: Calibration auto-disable comment disagrees with implementation threshold
The comment states that calibration auto-disables at 99% aggregate SoC, but buildBatterySnapshot turns batteryCalibrationCharge off at site.battery.Soc >= 100. Please align the comment and the threshold so it’s clear exactly when calibration stops.
| taperFactor = (maxSoc - currentSoc) / chargeTaperRange (clamped to minimum 0.10) | ||
| chargePower = requestedPower × taperFactor | ||
| ``` | ||
|
|
||
| - **Taper range**: 5% SoC below maxSoc | ||
| - **Minimum factor**: 25% of requested power (never fully stopped by taper) |
There was a problem hiding this comment.
issue: The documented minimum taper factor is inconsistent (0.10 vs 25%).
The formula comment says taperFactor is clamped to a minimum of 0.10, while the bullet below defines a "Minimum factor" of 25% of requested power. These look like the same lower bound but use different values. Please clarify which minimum is correct and update the docs to be consistent, or explicitly explain if they refer to different concepts.
| } | ||
| } | ||
|
|
||
| func (site *Site) batteryFastTick() { |
There was a problem hiding this comment.
issue (complexity): Consider extracting the meter-guard/target computation from batteryFastTick and splitting fastControl into smaller helpers so each step of the fast loop is handled by a focused, testable function.
You can reduce the cognitive load without changing behavior by carving out a few focused helpers from the monolithic functions. Two low‑risk wins:
1. Split batteryFastTick into guard + planning
batteryFastTick currently handles snapshot age, meter guards, direction choice, and calling the controller in one go. Extracting the guard + target computation into a pure helper keeps the main tick readable and easier to test.
// small data carrier for the fast loop
type fastLoopState struct {
gridPower, battPower float64
dischargeTarget, chargeTarget float64
desired batteryPlanDirection
}
// pure-ish helper: all meter guards + target computation
func (site *Site) computeFastLoopState(snap *batterySnapshot) (*fastLoopState, bool) {
gridPower, err := site.gridMeter.CurrentPower()
if err != nil {
batteryLog.DEBUG.Printf("solar power (fast): grid power: %v", err)
return nil, false
}
firstTick := !site.batteryGuardValid
if !firstTick && gridPower == site.batteryLastGrid {
batteryLog.TRACE.Printf("solar power (fast): stale grid %.0fW, skip", gridPower)
return nil, false
}
powers := make([]float64, len(snap.batteries))
errs := make([]error, len(snap.batteries))
var wg sync.WaitGroup
for i := range snap.batteries {
i := i
wg.Add(1)
go func() {
defer wg.Done()
powers[i], errs[i] = snap.batteries[i].meter.CurrentPower()
}()
}
wg.Wait()
var battPower float64
for i, e := range snap.batteries {
if errs[i] != nil {
batteryLog.DEBUG.Printf("solar power (fast): %s power: %v", e.name, errs[i])
return nil, false
}
battPower += powers[i]
}
dGrid, dBatt := gridPower-site.batteryLastGrid, battPower-site.batteryLastBatt
site.batteryLastGrid, site.batteryLastBatt, site.batteryGuardValid = gridPower, battPower, true
if !firstTick && math.Abs(dBatt) > fastLoopSkewThreshold && math.Abs(dGrid+dBatt) > fastLoopSkewThreshold {
batteryLog.TRACE.Printf("solar power (fast): meters inconsistent (Δgrid %.0fW, Δbattery %.0fW), skip", dGrid, dBatt)
return nil, false
}
dischargeTarget := battPower + gridPower + snap.dischargeOffset - snap.dischargeEvExcluded
chargeTarget := -battPower - (gridPower + snap.chargeOffset)
desired := batteryPlanIdle
switch {
case dischargeTarget > snap.threshold && dischargeTarget >= chargeTarget:
desired = batteryPlanDischarge
case chargeTarget > snap.threshold:
desired = batteryPlanCharge
}
return &fastLoopState{
gridPower: gridPower,
battPower: battPower,
dischargeTarget: dischargeTarget,
chargeTarget: chargeTarget,
desired: desired,
}, true
}Then batteryFastTick becomes mostly orchestration, with much less nesting:
func (site *Site) batteryFastTick() {
site.batteryPlanMu.Lock()
defer site.batteryPlanMu.Unlock()
snap := site.batterySnapshot
if snap == nil || !snap.enabled || len(snap.batteries) == 0 || time.Since(snap.created) > batterySnapshotMaxAge {
// existing logging here
return
}
if site.batteryStopped == nil {
site.batteryStopped = make(map[string]int)
}
st, ok := site.computeFastLoopState(snap)
if !ok {
return
}
batteryLog.TRACE.Printf("solar power (fast): grid=%.0fW batt=%.0fW dis=%.0fW chg=%.0fW committed=%s desired=%s",
st.gridPower, st.battPower, st.dischargeTarget, st.chargeTarget,
batteryPlanDirectionString(site.batteryFastDirection), batteryPlanDirectionString(st.desired))
direction := site.batteryArbitrateDirection(st.desired)
switch direction {
case batteryPlanCharge:
site.fastControl(snap, batteryPlanCharge, math.Max(0, st.chargeTarget))
case batteryPlanDischarge:
site.fastControl(snap, batteryPlanDischarge, math.Max(0, st.dischargeTarget))
default:
site.stopBatteries(fastEntriesAll(snap))
site.batteryChargeActive, site.batteryDischargeActive = nil, nil
site.batteryChargeTier, site.batteryDischargeTier = 0, 0
}
site.batteryFastDirection = direction
}This isolates the meter guard / skew logic and target computation into something you can unit‑test independently without touching direction arbitration or dispatch.
2. Decompose fastControl into small, composable steps
fastControl currently does: eligibility filtering → tiering/sticky/concentration → scaling/tapering → dispatch/stop. Splitting this into 2–3 focused helpers keeps behavior but flattens nesting.
For example:
func (site *Site) filterEligible(snap *batterySnapshot, dir batteryPlanDirection) (active, ineligible []fastEntry) {
charge := dir == batteryPlanCharge
for i := range snap.batteries {
e := &snap.batteries[i]
fe := fastEntry{batterySnapEntry: e}
if charge {
if !snap.calibration && e.hasSocLimit && e.maxSoc > 0 && e.socOK && e.soc >= e.maxSoc {
ineligible = append(ineligible, fe)
continue
}
} else {
if !e.socOK || (e.hasSocLimit && e.soc <= e.minSoc) {
ineligible = append(ineligible, fe)
continue
}
}
active = append(active, fe)
}
return
}
func (site *Site) applyTieringAndSticky(
snap *batterySnapshot,
dir batteryPlanDirection,
target float64,
active []fastEntry,
) (selected, toStop []fastEntry) {
charge := dir == batteryPlanCharge
if snap.pool {
return active, nil
}
capOf := func(e fastEntry) float64 {
if charge {
return e.chargeCap
}
return e.dischargeCap
}
var maxPerBat float64
for _, e := range active {
if c := capOf(e); c > 0 && (maxPerBat == 0 || c < maxPerBat) {
maxPerBat = c
}
}
selected = active
if maxPerBat > 0 && snap.tiering {
tierPerBat := maxPerBat * batteryTierFraction
tier := &site.batteryChargeTier
activeNames := &site.batteryChargeActive
if !charge {
tier = &site.batteryDischargeTier
activeNames = &site.batteryDischargeActive
}
*tier = computeTier(target, tierPerBat, *tier, len(active))
needed := *tier
if needed < len(active) {
var stop []fastEntry
selected, stop = site.selectSticky(active, needed, charge, activeNames)
toStop = append(toStop, stop...)
} else {
*activeNames = nil
}
} else if maxPerBat == 0 {
share := target / float64(len(active))
if share < minEffectiveShare && len(active) > 1 {
best := 0
for i, e := range active {
if charge {
if e.socOK && (i == 0 || e.soc < active[best].soc) {
best = i
}
} else if e.socOK && (i == 0 || e.soc > active[best].soc) {
best = i
}
}
for i, e := range active {
if i != best {
toStop = append(toStop, e)
}
}
selected = active[best : best+1]
}
}
return
}Then fastControl becomes a thin pipeline:
func (site *Site) fastControl(snap *batterySnapshot, dir batteryPlanDirection, target float64) {
active, ineligible := site.filterEligible(snap, dir)
deferStop := append([]fastEntry{}, ineligible...)
if len(active) == 0 {
site.stopBatteries(fastEntriesAll(snap))
return
}
selected, moreStop := site.applyTieringAndSticky(snap, dir, target, active)
deferStop = append(deferStop, moreStop...)
charge := dir == batteryPlanCharge
commands := site.computeCommands(snap, selected, dir, target)
site.sendBatteries(selected, charge, commands)
var total float64
for _, c := range commands {
total += c
}
batteryLog.DEBUG.Printf("solar power (fast): %s %.0fW across %d/%d batteries (grid target %.0fW)",
batteryPlanDirectionString(dir), total, len(selected), len(snap.batteries), target)
site.stopBatteries(deferStop)
}computeCommands can hold the cap + taper logic you already have, but in a small, self‑contained function.
This keeps all existing semantics (tiering, sticky, tapering, stop‑guard), but breaks them into named steps aligned with the reviewer’s suggested responsibilities (filterEligible, applyTieringAndSticky, computeCommands), which reduces branching in each function and makes the module easier to reason about and test.
| bufferSoc float64 // continue charging on battery above this Soc (EV loadpoints) | ||
| bufferStartSoc float64 // start charging on battery above this Soc (EV loadpoints) | ||
| batteryDischargeControl bool // prevent battery discharge for fast and planned charging | ||
| batterySolarControl bool // actively charge from surplus / discharge to cover loads |
There was a problem hiding this comment.
issue (complexity): Consider encapsulating the new battery solar/fast-loop configuration and state into a dedicated controller struct that Site delegates to, instead of expanding Site with many loosely related fields and behaviors.
You can reduce the added complexity without changing behavior by encapsulating the new battery‑solar/fast‑loop state into a dedicated struct and letting Site delegate to it.
1. Group the new fields into a controller struct
Instead of adding all battery‑solar/fast‑loop fields directly on Site, move them into a dedicated type:
type BatterySolarController struct {
// configuration
solarControl bool
calibrationCharge bool
controlDeadBand float64
solarPool bool
solarTiering bool
solarSticky bool
solarTapering bool
// tiered / sticky state
chargeTier int
dischargeTier int
chargeActive []string
dischargeActive []string
stopped map[string]int
// snapshot / fast‑loop contract
mu sync.Mutex
snapshot *batterySnapshot
direction batteryPlanDirection
// fast‑loop metering guard
lastGrid float64
lastBatt float64
guardValid bool
// flip timing / backoff
flipSince time.Time
lastFlipRequest time.Time
flipBackoff time.Duration
}Attach it to Site:
type Site struct {
// ...
prioritySoc float64
bufferSoc float64
bufferStartSoc float64
batteryDischargeControl bool
batteryGridChargeLimit *float64
batterySolar *BatterySolarController
// cached state ...
}This keeps the god‑struct surface area down and makes the conceptual boundary of “battery solar control” obvious.
2. Delegate settings load/restore into the controller
Move the solar‑related settings wiring out of Site.restoreSettings into a method on BatterySolarController. The Site owns the controller but doesn’t need to know about all flags:
func (c *BatterySolarController) LoadSettings(settings *util.Settings) error {
// defaults
c.solarPool = false
c.solarTiering = true
c.solarSticky = true
c.solarTapering = true
if v, err := settings.Bool(keys.BatterySolarControl); err == nil {
c.solarControl = v
}
if v, err := settings.Float(keys.BatteryControlDeadBand); err == nil {
c.controlDeadBand = v
}
if v, err := settings.Bool(keys.BatterySolarPool); err == nil {
c.solarPool = v
}
if v, err := settings.Bool(keys.BatterySolarTiering); err == nil {
c.solarTiering = v
}
if v, err := settings.Bool(keys.BatterySolarSticky); err == nil {
c.solarSticky = v
}
if v, err := settings.Bool(keys.BatterySolarTapering); err == nil {
c.solarTapering = v
}
return nil
}Site.restoreSettings then becomes simpler:
func (site *Site) restoreSettings() error {
// existing non‑battery settings...
if err := site.batterySolar.LoadSettings(settings); err != nil {
return err
}
// rest of method...
return nil
}3. Delegate publishing of solar‑related state
Similarly, move the publish calls into the controller, so prepare doesn’t need to know every flag:
func (c *BatterySolarController) Publish(publish func(key string, val any)) {
publish(keys.BatterySolarControl, c.solarControl)
publish(keys.BatteryCalibrationCharge, c.calibrationCharge)
publish(keys.BatteryControlDeadBand, c.controlDeadBand)
publish(keys.BatterySolarPool, c.solarPool)
publish(keys.BatterySolarTiering, c.solarTiering)
publish(keys.BatterySolarSticky, c.solarSticky)
publish(keys.BatterySolarTapering, c.solarTapering)
}Then in Site.prepare:
func (site *Site) prepare() {
if err := site.restoreSettings(); err != nil {
site.log.ERROR.Println(err)
}
// other publishes...
site.batterySolar.Publish(site.publish)
}4. Encapsulate fast‑loop direction / flip logic
The fast‑loop‑specific fields and logic (batteryFastDirection, batteryLastGrid, batteryLastBatt, batteryGuardValid, batteryFlipSince, lastBatteryFlipRequest, batteryFlipBackoff) can live behind methods on BatterySolarController so Site.updateBatteryMode and batteryFastLoop just call into it:
func (c *BatterySolarController) Plan(
now time.Time,
sitePower float64,
sitePowerValid bool,
batt types.BatteryState,
) batteryPlan {
c.mu.Lock()
defer c.mu.Unlock()
// use c.direction, c.lastGrid, c.flipSince, c.lastFlipRequest, c.flipBackoff, etc.
// to compute the next plan, respecting dead band and backoff
return plan
}Then adjust Site.updateBatteryMode to delegate:
func (site *Site) updateBatteryMode(
gridChargeActive bool,
rate *planner.Rate,
sitePower float64,
sitePowerValid bool,
) {
if !site.batterySolar.solarControl {
// existing non‑solar logic
return
}
plan := site.batterySolar.Plan(time.Now(), sitePower, sitePowerValid, site.battery)
// apply plan to underlying batteries / modes (unchanged)
}This preserves all behavior but:
- keeps
Sitefocused on orchestration and wiring, - makes the battery solar controller a clear, testable unit,
- groups tightly coupled flags/state so you don’t have to reason about them scattered across
Site.
| } | ||
| } | ||
|
|
||
| // buildBatterySnapshot reads the slow-moving per-battery state (SoC, limits, power caps) and |
There was a problem hiding this comment.
issue (complexity): Consider extracting small helper functions from buildBatterySnapshot and separating snapshot updates from mode decisions to make the battery control flow easier to scan and maintain without changing behavior.
You can reduce the perceived complexity without changing behavior by factoring out a few narrowly‑scoped helpers and loosening the coupling between mode logic and snapshot building.
1. Split buildBatterySnapshot into small helpers
Right now buildBatterySnapshot handles:
- EV power aggregation
- Calibration charge completion
- Residual/offset calculations
- Snapshot assembly
- Per‑battery capability discovery
You can keep the same behavior but move each concern into a helper with a clear contract. That makes the main function easier to scan and reason about.
Example (shortened for illustration only):
func (site *Site) buildBatterySnapshot(rate api.Rate) {
evPower, evPowerFast := site.computeEvPowers()
site.maybeFinishCalibrationCharge()
chargeOffset, dischargeOffset, dischargeEvExcluded := site.computeBatteryOffsets(rate, evPower, evPowerFast)
snap := &batterySnapshot{
enabled: true,
pool: site.batterySolarPool,
tiering: site.batterySolarTiering,
sticky: site.batterySolarSticky,
tapering: site.batterySolarTapering,
calibration: site.batteryCalibrationCharge,
chargeOffset: chargeOffset,
dischargeOffset: dischargeOffset,
dischargeEvExcluded: dischargeEvExcluded,
threshold: standbyPower + site.batteryControlDeadBand,
created: time.Now(),
}
site.fillBatteryEntries(snap)
site.batteryPlanMu.Lock()
site.batterySnapshot = snap
site.batteryPlanMu.Unlock()
batteryLog.TRACE.Printf(
"battery snapshot: %d batteries soc=%.0f%% chargeOff=%.0fW dischargeOff=%.0fW evExcl=%.0fW threshold=%.0fW",
len(snap.batteries), site.battery.Soc, chargeOffset, dischargeOffset, dischargeEvExcluded, snap.threshold,
)
}Helpers (kept short and focused):
func (site *Site) computeEvPowers() (evPower, evPowerFast float64) {
for _, lp := range site.loadpoints {
if lp.IsHeating() {
continue
}
p := lp.GetChargePower()
evPower += p
if lp.GetStatus() != api.StatusA && lp.IsFastChargingActive() {
evPowerFast += p
}
}
return
}
func (site *Site) maybeFinishCalibrationCharge() {
if site.batteryCalibrationCharge && site.battery.Soc >= 100 {
site.Lock()
site.batteryCalibrationCharge = false
site.Unlock()
site.publish(keys.BatteryCalibrationCharge, false)
batteryLog.DEBUG.Printf("battery calibration charge complete (soc %.0f%%)", site.battery.Soc)
}
}
func (site *Site) computeBatteryOffsets(
rate api.Rate, evPower, evPowerFast float64,
) (chargeOffset, dischargeOffset, dischargeEvExcluded float64) {
residual := site.GetResidualPower()
if site.battery.Soc >= site.prioritySoc {
chargeOffset = residual
}
if site.dischargeControlActive(rate) {
dischargeEvExcluded = evPowerFast
} else if !(site.bufferSoc > 0 && site.battery.Soc > site.bufferSoc) {
dischargeEvExcluded = evPower
}
return chargeOffset, residual, dischargeEvExcluded
}
func (site *Site) fillBatteryEntries(snap *batterySnapshot) {
for _, dev := range site.batteryMeters {
ctrl, ok := api.Cap[api.BatteryPowerController](dev.Instance())
if !ok {
continue
}
e := batterySnapEntry{
ctrl: ctrl,
meter: dev.Instance(),
name: dev.Config().Name,
}
if bat, ok := api.Cap[api.Battery](dev.Instance()); ok {
if soc, err := bat.Soc(); err == nil {
e.soc, e.socOK = soc, true
}
}
if limiter, ok := api.Cap[api.BatterySocLimiter](dev.Instance()); ok {
e.hasSocLimit = true
e.minSoc, e.maxSoc = limiter.GetSocLimits()
}
if limiter, ok := api.Cap[api.BatteryPowerLimiter](dev.Instance()); ok {
e.chargeCap, e.dischargeCap = limiter.GetPowerLimits()
}
snap.batteries = append(snap.batteries, e)
}
}This keeps all logic in the same place but makes each step self‑documenting and individually testable.
2. Decouple mode decisions from snapshot updates
updateBatteryMode currently decides the mode and also manages the snapshot for the fast loop. To reduce implicit coupling, you can keep the behavior but make the separation explicit: one function decides modes, another updates the snapshot.
Minimal change example:
func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate, sitePower float64, sitePowerValid bool) {
batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate, sitePower)
// existing HEMS dimming + applyBatteryMode logic stays as-is...
// ...
site.updateBatterySnapshot(rate)
}
func (site *Site) updateBatterySnapshot(rate api.Rate) {
if !site.batterySolarControl {
site.batteryPlanMu.Lock()
site.batterySnapshot = nil
site.batteryPlanMu.Unlock()
return
}
site.buildBatterySnapshot(rate)
}This keeps all current behavior (including when the snapshot is built/cleared) but makes the file’s responsibilities clearer and prepares for moving snapshot/selection concerns into a dedicated controller later if needed.
4df2ce5 to
9032ceb
Compare
8492c6d to
97d26cd
Compare
Watt-level RS485 control for multiple home batteries (Marstek Venus E), driven from solar surplus/deficit with a dedicated 500ms fast loop. Main loop (planner): direction decision, SoC-based tiering with hysteresis, sticky selection, safe swap handoff (ACK-checked), charge tapering, LFP calibration charge, min/max SoC enforcement, battery mode (Hold to keep RS485 active), and per-tick control-plan publication. Fast loop (actuator, site_battery_fast.go): re-scales the active set against a fresh grid reading every 500ms using an absolute measured energy-balance target (ramp-state invariant), with a two-rule meter consistency guard (stale grid register, sampling skew), one-tick gain, parallel per-battery writes, a watchdog heartbeat, and asymmetric tier-up that engages a pre-selected standby battery on saturation while the main loop owns tier-down. Includes the solar-control experimental UI, Marstek Venus E v3 template, and the docs/agents/battery-management.md reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each active battery has its own connection; reading them serially added one round-trip per battery to every fast tick, worst during high-load multi-battery periods. Mirror the existing parallel writes so a tier of N batteries reads in one round-trip instead of N. Same values, same skip-on-error behavior, just off the tick's critical path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…direction re-decision The fast loop clamps the active direction to zero at a charge<->discharge crossing and previously waited for the next scheduled main tick to flip. It now detects the crossing - active target ~0, inverter ramped down, and the opposite-direction need past the same dead band the main loop uses, sustained for a short dwell - and sends a non-blocking poke on a new channel. The main loop drains it in its Run select and re-runs only the battery decision (fresh meters, no loadpoint cycle), so the existing direction logic is byte-identical, just triggered sooner. The handler runs in the Run goroutine alongside the scheduled tick and the loadpoint poke, so Go's select serialises them with no new concurrency. A spurious poke simply re-decides the same direction. This decouples direction-flip reactivity from the main interval, so the interval can be raised back toward the recommended 30s without losing battery responsiveness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wer than a short main loop The crossing detector required the inverter to ramp the old direction down to <100W before starting its dwell, adding 3-4s on top of the 3s dwell for ~6-7s total - slower than a 5s main loop, so the scheduled tick always won and the detector never fired in practice. The gate was unnecessary: the opposite-direction need adds back the battery's current power, exactly like the main loop's energy balance, so it is ramp-invariant (reads the same value throughout the ramp). The crossing is detectable immediately, so the poke now fires ~3s (dwell only), during the ramp, which beats a short main interval as intended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ction thrash Make the crossing-detector dwell wall-clock (2s) instead of a tick count, so stale-grid ticks that carry no new reading no longer stretch it, and shorten it for a snappier flip. Add a 15s minimum spacing between fast-loop flip pokes to bound charge<->discharge thrash when power hovers near the crossing (oscillating loads, scattered clouds). The scheduled main tick still owns genuine flips during the cooldown, so this only rate-limits the fast path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, backing off under oscillation Replace the fixed flip cooldown with an adaptive minimum spacing: it starts at 2s and doubles (up to 60s) each time a flip arrives while still oscillating near the crossing, then resets to the minimum after a calm gap. An isolated reversal (morning charge to evening discharge) fires instantly; a flickering-cloud or cycling-load oscillation is progressively damped to at most one fast flip per minute. Confirmation dwell shortened to 1s for quicker reaction. The scheduled main tick still owns genuine flips inside the backoff window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ery decision Battery logging was DEBUG-only, so setting the log level to trace revealed nothing extra. Add purely additive TRACE lines (DEBUG output unchanged): per fast tick state (direction, grid, battery, target, total, offsets), stale-grid skips, parked state, crossing dwell/backoff progress, and the main-loop decision inputs. Makes it possible to see exactly why a direction flip did or didn't fire without cluttering normal logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route all battery-control and fast-loop logging through a package-level
batteryLog = util.NewLogger("battery") instead of the site logger, so
battery lines carry a [battery] prefix and can be filtered and
level-controlled independently (levels: {battery: trace}).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntinuous 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>
…ar control FORK deviation from upstream: watt-level solar control keeps the battery in HOLD mode (RS485 enabled) while actively charging/discharging, so the mode-priority status and gauge mislabelled it "discharge locked". - BatteryStatusCard.statusState: drop HOLD from the mode short-circuit so the status follows measured power; add an "idle" label for tiered-off units (0W) that previously showed nothing. - SocGauge: drop HOLD from LOCKED_MODES and from the Pause-icon condition, so a discharging HOLD battery shows the arrow, not arrow+pause overlaid. All deviations marked FORK; restore the HOLD lines to follow upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97d26cd to
5daab10
Compare
No description provided.