@@ -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
798836func (s * MinerScheduler ) runMPCExecution (ctx context.Context ) error {
0 commit comments