Skip to content

Commit 13ae53e

Browse files
committed
feat: add ev charging control task
1 parent f3c13e6 commit 13ae53e

7 files changed

Lines changed: 226 additions & 40 deletions

File tree

‎config.example.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"location": "CET",
1818
"plant_modbus_address": "192.168.1.100:502",
1919
"device_id": 0,
20-
"pv_poll_interval": "10s",
20+
"pv_poll_interval": "5s",
2121
"pv_integration_period": "15m",
2222
"postgres_conn_string": "<your-neon-connection-string>",
2323
"battery_capacity": 24.0,

‎scheduler/config.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func DefaultConfig() *Config {
112112
MinerTimeout: 5 * time.Second,
113113
HealthCheckPort: 0,
114114
DeviceID: 0,
115-
PVPollInterval: 10 * time.Second,
115+
PVPollInterval: 5 * time.Second,
116116
PVIntegrationPeriod: 15 * time.Minute,
117117
PostgresConnString: "",
118118
URLFormat: "https://web-api.tp.entsoe.eu/api?documentType=A44&out_Domain=10YLV-1001A00074&in_Domain=10YLV-1001A00074&periodStart=%s&periodEnd=%s&securityToken=%s",

‎scheduler/ev.go‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package scheduler
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/devskill-org/ems/sigenergy"
8+
)
9+
10+
// runEVControl is the single owner of all DC-charger EV session logic.
11+
// It runs at PVPollInterval (default 5 s) — fast enough to react within the
12+
// EV protocol handshake window — and is responsible for:
13+
//
14+
// 1. Detecting cable plug-in via DCChargerVehicleVoltage (register 31500).
15+
// Voltage is at or above the plug-in threshold for the entire session
16+
// (negotiation, BMS pauses, thermal holds, full battery) whereas output
17+
// power drops to zero in all those cases and would incorrectly appear as
18+
// "no EV present". A 10 V floor filters out the ~1–2 V residual bus
19+
// charge that persists briefly after physical cable removal.
20+
//
21+
// 2. Maintaining the sticky evSessionActive flag with a 2-reading debounce
22+
// on session-end to survive transient Modbus glitches.
23+
//
24+
// 3. Applying Mode 2 (Maximum self-consumption) with full battery charge and
25+
// discharge limits whenever a session is active, so the battery buffers
26+
// intermittent PV and prevents power gaps from reaching the DC charger.
27+
//
28+
// executeMPCDecision reads evSessionActive as a read-only guard and yields
29+
// control to this task for the duration of the EV session. No other function
30+
// writes evSessionActive or issues EV-related inverter commands.
31+
func (s *MinerScheduler) runEVControl(ctx context.Context) error {
32+
config := s.GetConfig()
33+
34+
if config.PlantModbusAddress == "" {
35+
return nil
36+
}
37+
38+
client, err := sigenergy.NewTCPClient(ctx, config.PlantModbusAddress, sigenergy.PlantAddress)
39+
if err != nil {
40+
return fmt.Errorf("EV control: failed to connect to Plant Modbus: %w", err)
41+
}
42+
defer client.Close()
43+
44+
// ReadPlantRunningInfo now returns an error when the DC charger registers
45+
// cannot be read (fail-safe), so a Modbus failure here causes this task to
46+
// return an error and be retried on the next tick rather than silently
47+
// treating the EV as absent.
48+
info, err := client.ReadPlantRunningInfo(byte(config.DCChargerSlaveID)) //nolint:gosec // SlaveID is expected to be in [0,255] range
49+
if err != nil {
50+
return fmt.Errorf("EV control: failed to read plant info: %w", err)
51+
}
52+
53+
// Update sticky session state.
54+
s.mu.Lock()
55+
if info.EVPluggedIn {
56+
s.evSessionActive = true
57+
s.evSessionClearCount = 0
58+
} else if s.evSessionActive {
59+
// Require two consecutive zero-voltage readings before declaring the
60+
// session ended. This prevents a single transient Modbus glitch from
61+
// releasing battery control mid-session.
62+
s.evSessionClearCount++
63+
if s.evSessionClearCount >= 2 {
64+
s.evSessionActive = false
65+
s.evSessionClearCount = 0
66+
s.logger.Printf("EV control: session ended (2 consecutive zero-voltage readings)")
67+
} else {
68+
s.logger.Printf("EV control: zero vehicle voltage detected (pending session-end debounce %d/2)", s.evSessionClearCount)
69+
}
70+
}
71+
evActive := s.evSessionActive
72+
s.mu.Unlock()
73+
74+
if !evActive {
75+
return nil
76+
}
77+
78+
// Session is active — apply Mode 2 so the battery supports intermittent PV
79+
// and prevents power gaps from reaching the DC charger.
80+
s.logger.Printf("EV control: session active - Mode 2 battery support (VehicleVoltage: %.0f V, ChargePower: %.1f kW, VehicleSOC: %.1f%%)",
81+
info.DCChargerVehicleVoltage, info.DCChargerOutputPower, info.DCChargerVehicleSOC)
82+
83+
if config.DryRun {
84+
s.logger.Printf("EV control: DRY-RUN - would apply Mode 2 (chargeLimit: %.1f kW, dischargeLimit: %.1f kW)",
85+
config.BatteryMaxCharge, config.BatteryMaxDischarge)
86+
return nil
87+
}
88+
89+
return s.applyInverterMode(client, 2, config.BatteryMaxCharge, config.BatteryMaxDischarge, true, true)
90+
}

‎scheduler/mpc.go‎

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func (s *MinerScheduler) RunMPCOptimize(ctx context.Context) error {
8888
s.mu.Lock()
8989
s.mpcDecisions = decisions
9090
s.lastExecutedDecision = nil // Clear last executed decision for new optimization
91+
s.lastWrittenMode = 0 // Force re-write of inverter registers after new plan
9192
s.mu.Unlock()
9293

9394
// Step 5.1: Persist decisions to database (only when not in dry run mode)
@@ -297,7 +298,7 @@ func (s *MinerScheduler) getSolarForecast(config *Config, now time.Time, slotDur
297298
// A single instantaneous sample can be near zero if a cloud passes at the
298299
// exact moment the MPC optimization runs, causing the optimizer to pull from
299300
// the grid for the entire slot even though the day is otherwise sunny.
300-
// Averaging the last 5 minutes of samples (≈30 readings at the default 10 s
301+
// Averaging the last 5 minutes of samples (≈60 readings at the default 5 s
301302
// poll interval) smooths out these transient dips while still tracking genuine
302303
// sustained low-irradiance conditions. Falls back to the latest single reading
303304
// when the buffer has no samples within that window (e.g. on startup).
@@ -744,27 +745,21 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
744745
}
745746
defer client.Close()
746747

747-
// Check whether an EV is plugged into the DC charger. When it is, the
748-
// inverter is already managing power delivery for the fast-charge session
749-
// and we must not override its control mode or limits.
750-
plantInfo, err := client.ReadPlantRunningInfo(byte(config.DCChargerSlaveID)) //nolint:gosec // SlaveID is expected to be in [0,255] range
751-
if err != nil {
752-
return fmt.Errorf("failed to read plant info for EV DC-charger check: %w", err)
753-
}
754-
if plantInfo.DCChargerOutputPower > 0 {
755-
s.logger.Printf("Skipping MPC battery control: EV is plugged into DC charger (ChargePower: %.1f kW, VehicleSOC: %.1f%%)",
756-
plantInfo.DCChargerOutputPower, plantInfo.DCChargerVehicleSOC)
757-
return nil
758-
}
748+
// Check whether an EV session is currently active. All session state
749+
// management (register reads, evSessionActive updates, debounce, and Mode 2
750+
// writes) is owned exclusively by runEVControl, which runs at PVPollInterval.
751+
// This function only reads the flag — it never writes it.
752+
s.mu.RLock()
753+
evActive := s.evSessionActive
754+
s.mu.RUnlock()
759755

760-
// Enable Remote EMS control
761-
if err := client.EnableRemoteEMS(true); err != nil {
762-
return fmt.Errorf("failed to enable remote EMS: %w", err)
756+
if evActive {
757+
s.logger.Printf("EV session active - MPC battery control deferred to runEVControl")
758+
return nil
763759
}
764-
s.logger.Printf("Enabled Remote EMS control")
765760

766761
// Get recent average PV power for the grid-charging gate. A 5-minute
767-
// window covers ~30 readings at the default 10 s poll interval and is
762+
// window covers ~60 readings at the default 5 s poll interval and is
768763
// long enough to distinguish a sustained cloud-free period from a brief
769764
// spike. Falls back to 0 (gate disabled) when no samples are available.
770765
const pvGateWindow = 5 * time.Minute
@@ -773,18 +768,8 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
773768
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV)
774769
s.logger.Print(action.logMsg)
775770

776-
if err := client.SetRemoteEMSMode(action.mode); err != nil {
777-
return fmt.Errorf("failed to set remote EMS mode: %w", err)
778-
}
779-
if action.setCharge {
780-
if err := client.SetESSMaxChargingLimit(action.chargeLimit); err != nil {
781-
return fmt.Errorf("failed to set ESS charging limit: %w", err)
782-
}
783-
}
784-
if action.setDischarge {
785-
if err := client.SetESSMaxDischargingLimit(action.dischargeLimit); err != nil {
786-
return fmt.Errorf("failed to set ESS discharging limit: %w", err)
787-
}
771+
if err := s.applyInverterMode(client, action.mode, action.chargeLimit, action.dischargeLimit, action.setCharge, action.setDischarge); err != nil {
772+
return err
788773
}
789774

790775
s.logger.Printf("Successfully executed MPC decision - Mode: %d, SOC: %.1f%%, ChargeFromPV: %.1f kW, ChargeFromGrid: %.1f kW, Discharge: %.1f kW, GridImport: %.1f kW, GridExport: %.1f kW",
@@ -793,6 +778,59 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
793778
return nil
794779
}
795780

781+
// applyInverterMode writes Remote EMS enable + mode + charge/discharge limits to
782+
// the inverter via Modbus. Writes are suppressed when the values are identical
783+
// to what was last successfully written, reducing register churn that can disturb
784+
// the DC charging protocol during an active EV session.
785+
func (s *MinerScheduler) applyInverterMode(
786+
client interface {
787+
EnableRemoteEMS(bool) error
788+
SetRemoteEMSMode(uint16) error
789+
SetESSMaxChargingLimit(float64) error
790+
SetESSMaxDischargingLimit(float64) error
791+
},
792+
mode uint16, chargeLimit, dischargeLimit float64,
793+
setCharge, setDischarge bool,
794+
) error {
795+
s.mu.Lock()
796+
lastMode := s.lastWrittenMode
797+
lastCharge := s.lastWrittenChargeLimit
798+
lastDischarge := s.lastWrittenDischargeLimit
799+
s.mu.Unlock()
800+
801+
// Always enable Remote EMS on the first write (lastMode == 0) or when
802+
// switching modes, but skip the Modbus call when nothing changed.
803+
if lastMode == 0 || lastMode != mode || (setCharge && lastCharge != chargeLimit) || (setDischarge && lastDischarge != dischargeLimit) {
804+
if err := client.EnableRemoteEMS(true); err != nil {
805+
return fmt.Errorf("failed to enable remote EMS: %w", err)
806+
}
807+
if err := client.SetRemoteEMSMode(mode); err != nil {
808+
return fmt.Errorf("failed to set remote EMS mode: %w", err)
809+
}
810+
if setCharge {
811+
if err := client.SetESSMaxChargingLimit(chargeLimit); err != nil {
812+
return fmt.Errorf("failed to set ESS charging limit: %w", err)
813+
}
814+
}
815+
if setDischarge {
816+
if err := client.SetESSMaxDischargingLimit(dischargeLimit); err != nil {
817+
return fmt.Errorf("failed to set ESS discharging limit: %w", err)
818+
}
819+
}
820+
s.mu.Lock()
821+
s.lastWrittenMode = mode
822+
if setCharge {
823+
s.lastWrittenChargeLimit = chargeLimit
824+
}
825+
if setDischarge {
826+
s.lastWrittenDischargeLimit = dischargeLimit
827+
}
828+
s.mu.Unlock()
829+
s.logger.Printf("Inverter mode applied: mode=%d chargeLimit=%.1f kW dischargeLimit=%.1f kW", mode, chargeLimit, dischargeLimit)
830+
}
831+
return nil
832+
}
833+
796834
// runMPCExecution re-executes the current MPC decision only if previous execution failed
797835
// This ensures the decision is applied even if previous execution failed
798836
func (s *MinerScheduler) runMPCExecution(ctx context.Context) error {

‎scheduler/scheduler.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ type MinerScheduler struct {
131131
mpcDecisions []mpc.ControlDecision
132132
lastExecutedDecision *mpc.ControlDecision // Tracks the last successfully executed decision
133133

134+
// EV session state — tracks whether a DC charging cable is currently
135+
// connected so that the fast-poll path can apply battery support mode
136+
// immediately on plug-in without waiting for the next MPC execution tick.
137+
evSessionActive bool // true while an EV cable is confirmed connected
138+
evSessionClearCount int // consecutive zero-voltage readings since last plug-in
139+
140+
// Last inverter control values written via Remote EMS so we can skip
141+
// redundant Modbus writes when nothing has changed.
142+
lastWrittenMode uint16
143+
lastWrittenChargeLimit float64
144+
lastWrittenDischargeLimit float64
145+
134146
// Web server
135147
webServer *WebServer
136148

@@ -335,6 +347,14 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
335347
return s.runDataPoll(ctx, dataSamples)
336348
},
337349
},
350+
{
351+
name: "EVControl",
352+
initialDelay: 0, // Run immediately so the first poll catches a cable already connected on startup
353+
interval: config.PVPollInterval,
354+
runFunc: func() error {
355+
return s.runEVControl(ctx)
356+
},
357+
},
338358
{
339359
name: "DataIntegration",
340360
initialDelay: pvDataInitialDelay,

‎sigenergy/info.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ func ShowPlantInfo(ctx context.Context, plantModbusAddress string, dcChargerSlav
103103
// DC Charger Information
104104
fmt.Println("DC CHARGER")
105105
fmt.Println("--------------------------------------------------")
106+
fmt.Printf(" Cable Connected: %s\n", getEVPluggedInStatus(info.EVPluggedIn))
107+
fmt.Printf(" Vehicle Battery Voltage: %.1f V\n", info.DCChargerVehicleVoltage)
108+
fmt.Printf(" Charging Current: %.1f A\n", info.DCChargerChargingCurrent)
106109
fmt.Printf(" Output Power: %.3f kW\n", info.DCChargerOutputPower)
107110
fmt.Printf(" Vehicle SOC: %.1f %%\n", info.DCChargerVehicleSOC)
108111
fmt.Println()
@@ -171,3 +174,10 @@ func getESSPowerStatus(power float64) string {
171174
}
172175
return "(Idle)"
173176
}
177+
178+
func getEVPluggedInStatus(pluggedIn bool) string {
179+
if pluggedIn {
180+
return "Yes"
181+
}
182+
return "No"
183+
}

‎sigenergy/registers.go‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@ type PlantRunningInfo struct {
4040
ESSDischargeOffSOC float64 // %
4141
ESSSOH float64 // %
4242
ESSAvgCellTemperature float64 // °C
43-
DCChargerOutputPower float64 // kW
44-
DCChargerVehicleSOC float64 // %
43+
DCChargerVehicleVoltage float64 // V (register 31500) — ≥ evPluggedInVoltageThreshold whenever a vehicle is physically connected
44+
DCChargerChargingCurrent float64 // A (register 31501)
45+
DCChargerOutputPower float64 // kW (register 31502)
46+
DCChargerVehicleSOC float64 // % (register 31504)
47+
// EVPluggedIn is true when the DC charger reports a vehicle battery voltage
48+
// at or above evPluggedInVoltageThreshold, indicating a cable is physically
49+
// connected regardless of whether power is currently flowing. A hard
50+
// threshold (rather than > 0) is used because the DC bus retains a small
51+
// residual charge (~1–2 V) after cable removal due to capacitor discharge,
52+
// which would otherwise produce a false "plugged in" reading. Any real EV
53+
// battery presents at least 100 V on the DC bus, so a 10 V floor cleanly
54+
// separates genuine connections from noise.
55+
EVPluggedIn bool
4556
}
4657

4758
// ReadPlantRunningInfo reads plant running information (slave address 247).
@@ -99,17 +110,34 @@ func (c *SigenModbusClient) ReadPlantRunningInfo(dcChargerSlaveID byte) (*PlantR
99110
}
100111

101112
// Read DC Charger and ESS cell temperature from first inverter (slave address 1)
102-
// Registers 31502-31504 are Hybrid Inverter registers (section 5.3) and only respond
113+
// Registers 31500-31504 are Hybrid Inverter registers (section 5.3) and only respond
103114
// to slave addresses 1-246, NOT the plant address 247.
104115
// Note: This assumes at least one hybrid inverter is present with slave ID 1
105116
c.SetSlaveID(dcChargerSlaveID)
106117

107-
// Read DC Charger data (31502-31504)
108-
data3, err := c.client.ReadInputRegisters(31502, 3)
109-
if err == nil {
110-
info.DCChargerOutputPower = float64(bytesToS32(data3[0:4])) / 1000.0
111-
info.DCChargerVehicleSOC = float64(bytesToU16(data3[4:6])) / 10.0
118+
// Read DC Charger data (31500-31504): vehicle voltage, charging current, output
119+
// power (2 registers), and vehicle SOC — 5 registers total.
120+
// Register 31500 (vehicle battery voltage) is non-zero whenever a cable is
121+
// physically connected to a vehicle, even when power flow is zero (BMS pause,
122+
// thermal hold, negotiation phase, or fully charged battery).
123+
// On read error we propagate the failure instead of leaving the fields at zero,
124+
// so the caller can distinguish "read succeeded, no vehicle" from "read failed".
125+
data3, err := c.client.ReadInputRegisters(31500, 5)
126+
if err != nil {
127+
c.SetSlaveID(PlantAddress)
128+
return nil, fmt.Errorf("failed to read DC charger registers: %w", err)
112129
}
130+
info.DCChargerVehicleVoltage = float64(bytesToU16(data3[0:2])) / 10.0
131+
info.DCChargerChargingCurrent = float64(bytesToU16(data3[2:4])) / 10.0
132+
info.DCChargerOutputPower = float64(bytesToS32(data3[4:8])) / 1000.0
133+
info.DCChargerVehicleSOC = float64(bytesToU16(data3[8:10])) / 10.0
134+
// evPluggedInVoltageThreshold is the minimum vehicle battery voltage (V)
135+
// treated as evidence of a physical cable connection. The DC bus retains a
136+
// small residual charge after disconnection (~1–2 V from capacitor bleed-off);
137+
// a real EV battery always presents ≥ 100 V, so 10 V cleanly separates
138+
// genuine connections from post-disconnect noise.
139+
const evPluggedInVoltageThreshold = 10.0
140+
info.EVPluggedIn = info.DCChargerVehicleVoltage >= evPluggedInVoltageThreshold
113141

114142
data4, err := c.client.ReadInputRegisters(30603, 1)
115143
if err == nil {

0 commit comments

Comments
 (0)