Skip to content

Commit 8fac8c6

Browse files
authored
Merge branch 'wills106:main' into main
2 parents 7e808c0 + 6bf2680 commit 8fac8c6

3 files changed

Lines changed: 100 additions & 23 deletions

File tree

custom_components/solax_modbus/plugin_growatt.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9763,12 +9763,9 @@ async def async_determineInverterType(self, hub: Any, configdict: dict[str, Any]
97639763
invertertype = HYBRID | GEN4 | X3 # Hybrid TL3-XH (BP) 3kW - 10kW (MOD), 11kW - 30kW (MID)
97649764
elif seriesnumber.startswith("V"):
97659765
invertertype = HYBRID | GEN4 | X3 # Hybrid TL3-XH 3kW - 10kW (MOD)
9766-
elif seriesnumber.startswith("067"):
9766+
# Include additional SPF5000ES firmware branches for auto-detection (e.g. 113).
9767+
elif seriesnumber.startswith(("067", "113", "500")):
97679768
invertertype = HYBRID | SPF | X1 # Hybrid SPF 5kW
9768-
elif seriesnumber.startswith("500"):
9769-
invertertype = HYBRID | SPF | X1 # Hybrid SPF 5kW
9770-
# elif seriesnumber.startswith('SPA'): invertertype = AC | GEN2 | X3 # AC SPA 4kW - 10kW Could be based SPF?
9771-
97729769
else:
97739770
invertertype = 0
97749771
_LOGGER.error(f"unrecognized {hub.name} inverter type - firmware version : {seriesnumber}")

custom_components/solax_modbus/plugin_sofar.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,8 @@ def value_function_sync_rtc_ymd_sofar(initval: Any, descr: Any, datadict: dict[s
768768
3: "Passive Mode",
769769
4: "Peak Cut Mode",
770770
5: "Off-grid Mode",
771+
6: "Generator mode",
772+
7: "Feed-In Priority Mode",
771773
},
772774
allowedtypes=HYBRID,
773775
write_method=WRITE_MULTISINGLE_MODBUS,
@@ -3587,6 +3589,8 @@ def value_function_sync_rtc_ymd_sofar(initval: Any, descr: Any, datadict: dict[s
35873589
3: "Passive Mode",
35883590
4: "Peak Cut Mode",
35893591
5: "Off-grid Mode",
3592+
6: "Generator mode",
3593+
7: "Feed-In Priority Mode",
35903594
},
35913595
internal=True,
35923596
allowedtypes=HYBRID,

custom_components/solax_modbus/plugin_solax.py

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@ def autorepeat_bms_charge(datadict: dict[str, Any], battery_capacity: float, max
563563
except Exception:
564564
f = 1.0
565565

566+
# Near full charge rate limit
567+
chargeable_soc = max(0, max_charge_soc - battery_capacity)
568+
if chargeable_soc <= 4:
569+
# For last few % of charge, further reduce the rate limit to account
570+
# for non-ideal charging curves and reduce battery wear
571+
f = f * (float(chargeable_soc + 2.0) / 6.0)
572+
566573
# BMS charge capability approximation
567574
bms_a = datadict.get("bms_charge_max_current", None)
568575
batt_v = (
@@ -631,19 +638,92 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
631638

632639
if power_control == "Mode 8 - PV and BAT control - Duration":
633640
pvlimit = setpvlimit # import capping is done later
634-
elif power_control == "Negative Injection Price": # grid export zero; PV restricted to house_load and battery charge
635-
datadict.get("measured_power", 0) # positive for export, negative for import - for future correction purposes
636-
houseload = max(0, houseload)
637-
if battery_capacity >= 92:
638-
pvlimit = houseload + abs(setpvlimit) * (100.0 - battery_capacity) / 15.0 + 60 # slow down charging - nearly full
639-
else:
640-
pvlimit = setpvlimit + houseload + 60 # inverter overhead 40
641-
pvlimit = max(houseload, pvlimit)
642-
pushmode_power = houseload - min(pv, pvlimit) - 90 + pv / 14 # some kind of empiric correction for losses - machine learning would be better
641+
elif power_control == "Negative Injection Price":
642+
# --- Negative Injection Price (Mode 8 custom) ---
643+
# Controller goals:
644+
# 1) If PV < house load (deficit): discharge the battery up to the deficit (respecting min SOC),
645+
# aiming to prefer a slight grid import bias over export bias.
646+
# 2) If PV ≥ house load (surplus): let PV feed the battery first. PV limit is then adjusted using
647+
# bounded step changes based on the measured power to prevent export.
648+
649+
# Use the alternative house load for house load measurement, clamping to strict positive values.
650+
hl = max(0, int(houseload_alt))
651+
652+
# SOC bounds
653+
min_discharge_soc = datadict.get("selfuse_discharge_min_soc", 10)
654+
max_charge_soc = datadict.get("battery_charge_upper_soc", 100)
655+
# bias towards import
656+
export_target = int(datadict.get("negative_injection_bias", -20) or -20)
657+
export_deadband_w = int(datadict.get("export_feedback_deadband_w", 50) or 50)
658+
min_step_w = int(datadict.get("export_first_step_min_w", 100) or 100)
659+
max_step_w = int(datadict.get("export_feedback_max_w", 500) or 500)
660+
661+
# Local copies
662+
battery_charge = max(0, int(datadict.get("battery_power_charge", 0) or 0))
663+
pvlimit = setpvlimit
664+
cur_pvlimit = max(0, setpvlimit if (cur_pvlimit := datadict.get("remotecontrol_current_pv_power_limit", None)) is None else cur_pvlimit)
665+
pushmode_power = 0 # + = discharge, - = charge
666+
667+
# Debug inputs
643668
_LOGGER.debug(
644-
f"***debug*** setpvlimit: {setpvlimit} pvlimit: {pvlimit} pushmode: {pushmode_power} houseload:{houseload} pv: {pv} batcap: {battery_capacity}"
669+
f"[Mode8 Negative Injection] inputs pv={pv}W hl={houseload}W hl_alt={houseload_alt}W (using hl) imp_lim={import_limit}W "
670+
f"soc={battery_capacity}% min_soc={min_discharge_soc}% max_soc={max_charge_soc}% cur_pvlimit={cur_pvlimit}W "
671+
f"battery_charge={battery_charge}W"
645672
)
646673

674+
# Optional probes (if available)
675+
measured_power = datadict.get("measured_power", None)
676+
_LOGGER.debug(f"[Mode8 Negative Injection] probes: measured_power={measured_power if measured_power is not None else 'n/a'} ")
677+
678+
if pv >= hl or cur_pvlimit < setpvlimit:
679+
# Surplus or limited pv path: battery is requested to charge at up to the rate
680+
# limit from PV alone then use measured export as the control signal to adjust PV limit.
681+
# Below target: PV should be reduced to prevent export.
682+
# At/above target: PV can be increased to reduce import in bounded steps.
683+
# If PV has been limited below the setpoint and is now below house load, continue in this
684+
# loop to release PV restriction slowly.
685+
surplus = max(0, pv - hl)
686+
measured_power = int(measured_power or 0)
687+
error = measured_power - export_target
688+
control_state = "surplus" if pv >= hl else "clipping"
689+
690+
# Battery gets surplus up to BMS limit
691+
desired_charge, bms_cap_w, pct_cap_w = autorepeat_bms_charge(datadict, battery_capacity, max_charge_soc, surplus)
692+
pushmode_power = -desired_charge
693+
694+
if abs(error) <= export_deadband_w:
695+
step_w = 0
696+
pvlimit = cur_pvlimit
697+
control_reason = "hold"
698+
elif error > 0:
699+
step_w = min(max_step_w, max(min_step_w, error))
700+
pvlimit = max(0, cur_pvlimit - step_w)
701+
control_reason = "decrease-pv"
702+
else:
703+
step_w = min(max_step_w, max(min_step_w, -error))
704+
pvlimit = min(setpvlimit, cur_pvlimit + step_w)
705+
control_reason = "increase-pv"
706+
707+
_LOGGER.debug(
708+
f"[Mode8 Negative Injection] {control_state}: surplus={surplus}W measured_power={measured_power}W "
709+
f"export_target={export_target}W error={error}W step={step_w}W reason={control_reason} "
710+
f"bms_cap≈{bms_cap_w}W pct_cap={pct_cap_w}W -> charge={desired_charge}W pvlimit={pvlimit}W hl={hl}W"
711+
)
712+
713+
else:
714+
# Deficit path: discharge battery up to the current house deficit (if SOC allows).
715+
# Note this is only reached if pvlimit has been restored to above the house load by
716+
# the limited pv path and we therefore have insufficient PV to cover the load.
717+
deficit = hl + export_target - pv
718+
if battery_capacity > min_discharge_soc:
719+
pushmode_power = min(deficit, 30000)
720+
else:
721+
pushmode_power = 0
722+
_LOGGER.debug(
723+
f"[Mode8 Negative Injection] deficit: deficit={deficit}W export_target={export_target}W "
724+
f"soc={battery_capacity}% chosen_push={pushmode_power}W"
725+
)
726+
647727
elif power_control == "Negative Injection and Consumption Price": # disable PV, charge from grid
648728
pvlimit = 0
649729
pushmode_power = houseload - import_limit
@@ -720,14 +800,10 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
720800

721801
# Export limit no readscale:
722802
export_limit = datadict.get("export_control_user_limit", 30000)
723-
inverter_limit = datadict.get("inverter_power_type", 30000)
724-
725-
# Both house load and exported power come from the inverter and must fit within the inverter
726-
# power limit. For example if we have a 6kW inverter and 4kW of house load, then we cannot
727-
# export more than 2kW even if our export limit is higher. Trim the export limit so that any
728-
# excess can be used for charging the battery rather than PV being clamped.
729-
export_available = max(inverter_limit - hl, 0)
730-
export_limit = min(export_limit, export_available)
803+
804+
# Test mode: do not trim the export target by inverter power minus house load.
805+
# This lets Export-First try to use the configured export limit directly before charging the battery.
806+
export_available = export_limit
731807

732808
# SOC bounds
733809
min_discharge_soc = datadict.get("selfuse_discharge_min_soc", 10)

0 commit comments

Comments
 (0)