Skip to content

Commit fafcc2e

Browse files
committed
fix: last balancing time
1 parent 13ae53e commit fafcc2e

5 files changed

Lines changed: 96 additions & 40 deletions

File tree

‎mpc/mpc.go‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,20 @@ type Controller struct {
9595
LastBalancingTime int64
9696
}
9797

98-
// NewController creates a new MPC controller
98+
// NewController creates a new MPC controller.
99+
// If initialSOC is at or above 100%, LastBalancingTime is initialised to the
100+
// current time so the optimizer knows balancing is already complete.
99101
func NewController(config SystemConfig, horizon int, initialSOC float64) *Controller {
100-
return &Controller{
102+
c := &Controller{
101103
Config: config,
102104
Horizon: horizon,
103105
CurrentSOC: initialSOC,
104106
CurrentBatteryTemp: 20.0, // Default to room temperature
105107
}
108+
if initialSOC >= 1.0 {
109+
c.LastBalancingTime = time.Now().Unix()
110+
}
111+
return c
106112
}
107113

108114
// Optimize finds the optimal control strategy using dynamic programming
@@ -114,8 +120,8 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
114120
}
115121

116122
// Determine whether cell-balancing (charging to BatteryMaxSOC) should be
117-
// incentivised during this optimisation run – at most once per calendar day.
118-
needsBalancing := mpc.needsDailyBalancing(forecast)
123+
// incentivised during this optimisation run – at most once per calendar week.
124+
needsBalancing := mpc.needsWeeklyBalancing(forecast)
119125

120126
// Run optimization with full solar forecast
121127
decisionsWithSolar := mpc.optimizeWithForecast(forecast, true, needsBalancing)
@@ -640,19 +646,18 @@ func (mpc *Controller) isFeasible(dec ControlDecision) bool {
640646
return true
641647
}
642648

643-
// needsDailyBalancing returns true when cell-balancing (charging to BatteryMaxSOC)
649+
// needsWeeklyBalancing returns true when cell-balancing (charging to BatteryMaxSOC)
644650
// should be incentivised during this optimisation run.
645651
//
646652
// Balancing is needed when ALL of the following hold:
647653
// - BatteryBalancingBonus is configured (non-zero) – feature is enabled
648-
// - The battery has NOT yet reached BatteryMaxSOC on the same calendar day as
649-
// the first slot in the provided forecast
654+
// - At least 7 days have elapsed since the last balancing (or it has never happened)
650655
//
651656
// The caller is responsible for updating LastBalancingTime whenever the battery
652-
// is observed to reach BatteryMaxSOC (e.g. in the EMS main loop).
657+
// is observed to reach 100% SOC (e.g. in the EMS main loop).
653658
// Setting LastBalancingTime prevents the optimizer from attempting balancing again
654-
// on the same day, keeping the battery cycle count to a minimum.
655-
func (mpc *Controller) needsDailyBalancing(forecast []TimeSlot) bool {
659+
// within the following week, keeping the battery cycle count to a minimum.
660+
func (mpc *Controller) needsWeeklyBalancing(forecast []TimeSlot) bool {
656661
if mpc.Config.BatteryBalancingBonus <= 0 {
657662
return false // feature disabled – no bonus configured
658663
}
@@ -662,10 +667,7 @@ func (mpc *Controller) needsDailyBalancing(forecast []TimeSlot) bool {
662667
if mpc.LastBalancingTime == 0 {
663668
return true // battery has never been fully charged for balancing
664669
}
665-
lastTime := time.Unix(mpc.LastBalancingTime, 0)
666-
forecastStart := time.Unix(forecast[0].Timestamp, 0)
667-
ly, lm, ld := lastTime.Date()
668-
fy, fm, fd := forecastStart.Date()
669-
// Balancing is needed when the last balancing did NOT occur on the same calendar day.
670-
return ly != fy || lm != fm || ld != fd
670+
const week = int64(7 * 24 * 3600)
671+
// Balancing is needed once at least a full week has passed since the last one.
672+
return forecast[0].Timestamp-mpc.LastBalancingTime >= week
671673
}

‎mpc/mpc_balancing_test.go‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Package mpc – cell-balancing feature tests.
22
//
33
// These tests cover:
4-
// 1. needsDailyBalancing – the once-per-day guard
4+
// 1. needsWeeklyBalancing – the once-per-week guard
55
// 2. CV-phase energy modelling – reduced efficiency near BatteryMaxSOC
66
// 3. Optimizer integration – bonus incentivises full charge, no double-counting
77
package mpc
@@ -35,9 +35,9 @@ func balancingConfig() SystemConfig {
3535
}
3636
}
3737

38-
// Anchor timestamps at noon UTC on two consecutive days.
39-
// Using noon (12:00 UTC) keeps the calendar-day comparison in
40-
// needsDailyBalancing consistent across all populated timezones (UTC-11 … UTC+11).
38+
// Anchor timestamps at noon UTC.
39+
// Using noon (12:00 UTC) keeps time-based comparisons in needsWeeklyBalancing
40+
// consistent across all populated timezones (UTC-11 … UTC+11).
4141
//
4242
// 2024-01-04 00:00:00 UTC = 1704326400 (used by existing tests)
4343
// 2024-01-04 12:00:00 UTC = 1704326400 + 12*3600 = 1704369600
@@ -73,52 +73,53 @@ func expensiveBalancingSlot(ts int64, hour int) TimeSlot {
7373
}
7474

7575
// ─── needsDailyBalancing ─────────────────────────────────────────────────────
76+
// ─── needsWeeklyBalancing ──────────────────────────────────────────────────────────────────────
7677

77-
func TestNeedsDailyBalancing_FeatureDisabled(t *testing.T) {
78+
func TestNeedsWeeklyBalancing_FeatureDisabled(t *testing.T) {
7879
config := balancingConfig()
7980
config.BatteryBalancingBonus = 0 // feature off
8081

8182
ctrl := NewController(config, 1, 0.5)
8283
ctrl.LastBalancingTime = 0 // never balanced
8384

84-
if ctrl.needsDailyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
85-
t.Error("needsDailyBalancing must return false when BatteryBalancingBonus is 0")
85+
if ctrl.needsWeeklyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
86+
t.Error("needsWeeklyBalancing must return false when BatteryBalancingBonus is 0")
8687
}
8788
}
8889

89-
func TestNeedsDailyBalancing_NeverBalanced(t *testing.T) {
90+
func TestNeedsWeeklyBalancing_NeverBalanced(t *testing.T) {
9091
ctrl := NewController(balancingConfig(), 1, 0.5)
9192
ctrl.LastBalancingTime = 0 // zero means "never"
9293

93-
if !ctrl.needsDailyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
94-
t.Error("needsDailyBalancing must return true when the battery has never been fully charged")
94+
if !ctrl.needsWeeklyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
95+
t.Error("needsWeeklyBalancing must return true when the battery has never been fully charged")
9596
}
9697
}
9798

98-
func TestNeedsDailyBalancing_AlreadyBalancedToday(t *testing.T) {
99+
func TestNeedsWeeklyBalancing_AlreadyBalancedWithinAWeek(t *testing.T) {
99100
ctrl := NewController(balancingConfig(), 1, 0.5)
100-
ctrl.LastBalancingTime = jan05noon - 2*3600 // 2 h before forecast start, same calendar day
101+
ctrl.LastBalancingTime = jan05noon - 6*24*3600 // 6 days before forecast start
101102

102-
if ctrl.needsDailyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
103-
t.Error("needsDailyBalancing must return false when balancing already happened today")
103+
if ctrl.needsWeeklyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
104+
t.Error("needsWeeklyBalancing must return false when balancing happened less than 7 days ago")
104105
}
105106
}
106107

107-
func TestNeedsDailyBalancing_BalancedYesterday(t *testing.T) {
108+
func TestNeedsWeeklyBalancing_BalancedExactlyAWeekAgo(t *testing.T) {
108109
ctrl := NewController(balancingConfig(), 1, 0.5)
109-
ctrl.LastBalancingTime = jan04noon // previous calendar day (24 h before jan05noon)
110+
ctrl.LastBalancingTime = jan05noon - 7*24*3600 // exactly 7 days before forecast start
110111

111-
if !ctrl.needsDailyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
112-
t.Error("needsDailyBalancing must return true when last balancing was on a previous day")
112+
if !ctrl.needsWeeklyBalancing([]TimeSlot{{Timestamp: jan05noon}}) {
113+
t.Error("needsWeeklyBalancing must return true when at least 7 days have elapsed since last balancing")
113114
}
114115
}
115116

116-
func TestNeedsDailyBalancing_EmptyForecast(t *testing.T) {
117+
func TestNeedsWeeklyBalancing_EmptyForecast(t *testing.T) {
117118
ctrl := NewController(balancingConfig(), 0, 0.5)
118119
ctrl.LastBalancingTime = 0
119120

120-
if ctrl.needsDailyBalancing([]TimeSlot{}) {
121-
t.Error("needsDailyBalancing must return false for an empty forecast")
121+
if ctrl.needsWeeklyBalancing([]TimeSlot{}) {
122+
t.Error("needsWeeklyBalancing must return false for an empty forecast")
122123
}
123124
}
124125

@@ -321,9 +322,9 @@ func TestOptimizer_BalancingSkippedWhenAlreadyDoneToday(t *testing.T) {
321322
ctrlA.LastBalancingTime = 0
322323
decisionsA := ctrlA.Optimize(forecast)
323324

324-
// Run B: balancing already done today → incentive suppressed
325+
// Run B: balancing done recently (within 7 days) → incentive suppressed
325326
ctrlB := NewController(config, len(forecast), 0.90)
326-
ctrlB.LastBalancingTime = jan05noon - 3600 // 1 h before the forecast, same calendar day
327+
ctrlB.LastBalancingTime = jan05noon - 3600 // 1 h before the forecast start
327328
decisionsB := ctrlB.Optimize(forecast)
328329

329330
peakA, peakB := 0.0, 0.0

‎scheduler/mpc.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ func (s *MinerScheduler) RunMPCOptimize(ctx context.Context) error {
7777
controller := mpc.NewController(systemConfig, horizon, initialSOC)
7878
controller.CurrentBatteryTemp = plantInfo.ESSAvgCellTemperature
7979

80+
// Persist lastBalancingTime across optimization runs.
81+
// NewController sets LastBalancingTime when initialSOC >= BatteryMaxSOC;
82+
// otherwise fall back to the value remembered from a previous run.
83+
s.mu.Lock()
84+
if controller.LastBalancingTime > 0 {
85+
s.lastBalancingTime = controller.LastBalancingTime
86+
s.logger.Printf("Battery at 100%% SOC, updating lastBalancingTime")
87+
} else {
88+
controller.LastBalancingTime = s.lastBalancingTime
89+
}
90+
s.mu.Unlock()
91+
8092
// Step 4: Run optimization
8193
decisions := controller.Optimize(forecast)
8294
if len(decisions) == 0 {

‎scheduler/mpc_persistence.go‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package scheduler
33
import (
44
"bytes"
55
"context"
6+
"database/sql"
67
"encoding/json"
78
"fmt"
89
"io"
@@ -111,7 +112,29 @@ func (s *MinerScheduler) loadLatestMPCDecisions(ctx context.Context) ([]mpc.Cont
111112
return decisions, nil
112113
}
113114

114-
// loadDecisionsFromReader decodes MPC decisions from an io.Reader.
115+
// loadLastBalancingTimeFromDB queries the metrics table for the most recent
116+
// timestamp at which battery_soc reached 100%, returning it as a Unix timestamp.
117+
// Returns 0 with no error when no such record exists or db is nil.
118+
func loadLastBalancingTimeFromDB(ctx context.Context, db *sql.DB, deviceID int) (int64, error) {
119+
if db == nil {
120+
return 0, nil
121+
}
122+
var ts sql.NullTime
123+
err := db.QueryRowContext(ctx,
124+
`SELECT MAX(timestamp) FROM metrics
125+
WHERE device_id = $1
126+
AND metric_name = 'energy_flow'
127+
AND battery_soc >= 100.0`,
128+
deviceID,
129+
).Scan(&ts)
130+
if err != nil {
131+
return 0, fmt.Errorf("querying last balancing time: %w", err)
132+
}
133+
if !ts.Valid {
134+
return 0, nil // no 100 % SOC row found yet
135+
}
136+
return ts.Time.Unix(), nil
137+
}
115138
func loadDecisionsFromReader(r io.Reader) ([]mpc.ControlDecision, error) {
116139
var decisions []mpc.ControlDecision
117140
err := json.NewDecoder(r).Decode(&decisions)

‎scheduler/scheduler.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ type MinerScheduler struct {
143143
lastWrittenChargeLimit float64
144144
lastWrittenDischargeLimit float64
145145

146+
// lastBalancingTime is the Unix timestamp of the most recent observation
147+
// where the battery SOC reached BatteryMaxSOC (i.e. a full balancing
148+
// charge completed). 0 means it has never been observed.
149+
// Updated by RunMPCOptimize whenever ESSSOC >= BatteryMaxSOC.
150+
lastBalancingTime int64
151+
146152
// Web server
147153
webServer *WebServer
148154

@@ -285,6 +291,18 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
285291
}
286292
}
287293

294+
// Restore the last balancing timestamp from the metrics DB so the weekly
295+
// balancing guard survives process restarts.
296+
if lastBalancingTime, err := loadLastBalancingTimeFromDB(ctx, dataDB, config.DeviceID); err != nil {
297+
s.logger.Printf("Warning: failed to load last balancing time from DB: %v", err)
298+
} else if lastBalancingTime > 0 {
299+
s.mu.Lock()
300+
s.lastBalancingTime = lastBalancingTime
301+
s.mu.Unlock()
302+
s.logger.Printf("Loaded last balancing time from DB: %s",
303+
time.Unix(lastBalancingTime, 0).Format(time.RFC3339))
304+
}
305+
288306
// Calculate initial delays
289307
now := time.Now()
290308
minersControlInitialDelay := s.getInitialDelay(now, config.CheckPriceInterval) + time.Second

0 commit comments

Comments
 (0)