Skip to content

Commit 9ad008e

Browse files
fix(solis): derive nominal_voltage from live battery data instead of hard-coded 48V (#4502)
* fix(solis): derive nominal_voltage from live battery data instead of hard-coded 48V (#4493) SolisAPI.nominal_voltage was hard-coded to 48.0 and never updated from the actual battery, making every derived power and capacity value wrong by roughly the ratio of the real pack voltage to 48V (~11x on a 533V HV battery). Reported and confirmed live under load by @jibbej, who also found the capacity path needs a *different* voltage (nominal pack, not live measured) plus parallel_battery_count, which the max current calculation already applies but capacity didn't. - get_nominal_voltage(): power/current conversions (max charge/discharge power, amp<->watt round trips) now use the live measured batteryVoltage, retaining the last known-good reading if it becomes unavailable rather than reverting to 48V. - get_capacity_voltage() + new solis_nominal_voltage apps.yaml option: the capacity calculation uses the nominal pack voltage (a fixed physical property) when configured. Without it, capacity still falls back to the live voltage - existing installs already had this sensor published and dropping it outright had no migration path - but is flagged unreliable (reliable/voltage_source attributes, a one-time warning) since that value drifts with charge state. - battery_capacity now applies parallel_battery_count, matching the existing max current calculation. - soc_max auto-binding to battery_capacity stays deliberately disabled: without solis_nominal_voltage configured the sensor can still wobble with charge state, which soc_max never did before. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent b0f1fb8 commit 9ad008e

7 files changed

Lines changed: 223 additions & 28 deletions

File tree

‎apps/predbat/components.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@
487487
"automatic": {"required": False, "config": "solis_automatic", "default": False},
488488
"base_url": {"required": False, "config": "solis_base_url", "default": "https://www.soliscloud.com:13333"},
489489
"control_enable": {"required": False, "config": "solis_control_enable", "default": True},
490+
"nominal_voltage": {"required": False, "config": "solis_nominal_voltage"},
490491
},
491492
# Gate activation on having at least one auth path — HMAC (api_key) OR OAuth
492493
# (access_token). Without this the component would start for every instance

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2464,6 +2464,7 @@
24642464
"solis_base_url": {"type": "string", "empty": False},
24652465
"solis_control_enable": {"type": "boolean"},
24662466
"solis_cloud_pv_load_ignore": {"type": "boolean"},
2467+
"solis_nominal_voltage": {"type": "float"},
24672468
"solis_auth_method": {"type": "string", "empty": False},
24682469
"solis_access_token": {"type": "string", "empty": False},
24692470
"solis_token_expires_at": {"type": "string", "empty": False},

‎apps/predbat/solis.py‎

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def __init__(self, message, status_code=None, response_code=None):
278278
class SolisAPI(ComponentBase, OAuthMixin):
279279
"""Solis Cloud API integration component"""
280280

281-
def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic=False, base_url=SOLIS_BASE_URL, control_enable=True, auth_method=None, access_token=None, token_expires_at=None, token_hash=None):
281+
def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic=False, base_url=SOLIS_BASE_URL, control_enable=True, auth_method=None, access_token=None, token_expires_at=None, token_hash=None, nominal_voltage=None):
282282
"""Initialise the Solis API component"""
283283
self.api_key = api_key
284284
self.api_secret = api_secret
@@ -295,7 +295,23 @@ def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic=
295295
self.base_url = SOLIS_OAUTH_BASE_URL if self.auth_method == "oauth" else base_url
296296
self.automatic = automatic
297297
self.session = None
298-
self.nominal_voltage = 48.0 # Default nominal battery voltage
298+
# Fallback used only when an inverter has never reported a live batteryVoltage - matches
299+
# the previous hard-coded assumption (issue #4493). get_nominal_voltage() below is the
300+
# real source of truth once live data is available.
301+
self.nominal_voltage = 48.0
302+
self.nominal_voltage_last_known = {} # {inverter_sn: last measured battery voltage}
303+
# Nominal *pack* voltage (e.g. cell count x nominal cell voltage), used for the capacity
304+
# calculation only - deliberately distinct from the live measured voltage used for
305+
# power/current conversions, since the two are not the same value on an HV battery
306+
# be supplied via apps.yaml (solis_nominal_voltage) if capacity is to be accurate.
307+
try:
308+
configured_pack_v = float(nominal_voltage) if nominal_voltage is not None else None
309+
except (TypeError, ValueError):
310+
configured_pack_v = None
311+
if configured_pack_v is not None and configured_pack_v <= 0:
312+
self.log(f"Warn: Solis API: solis_nominal_voltage must be > 0, got {configured_pack_v}; ignoring")
313+
configured_pack_v = None
314+
self.nominal_pack_voltage = configured_pack_v
299315
self.control_enable = control_enable
300316

301317
# Handle inverter SN configuration - can be None, a single string, or a list of strings
@@ -1278,7 +1294,12 @@ async def automatic_config(self):
12781294
self.set_arg_auto("export_today", [f"sensor.{self.prefix}_solis_{device}_today_export_energy" for device in devices])
12791295

12801296
# Battery capacity and limits from cached details
1281-
# XXX: This is currently broken, user must set manually in apps.yaml
1297+
# The arithmetic bug that made this sensor wrong by ~11x on HV batteries is fixed
1298+
# (issue #4493), but this stays commented out deliberately: without solis_nominal_voltage
1299+
# configured, battery_capacity falls back to the *live* measured voltage, which drifts
1300+
# with charge state - auto-binding soc_max to it would make soc_max itself wobble slightly
1301+
# cycle to cycle, which it never did before (the old 48V constant was wrong but static).
1302+
# User must still set soc_max manually in apps.yaml.
12821303
# self.set_arg("soc_max", [f"sensor.{self.prefix}_solis_{device}_battery_capacity" for device in devices])
12831304

12841305
# Reserve and limits
@@ -1364,6 +1385,39 @@ def _calculate_max_currents(self, inverter_sn):
13641385

13651386
self.log(f"Solis API: Calculated max currents for {inverter_sn}: charge={max_charge}A, discharge={max_discharge}A")
13661387

1388+
def get_nominal_voltage(self, inverter_sn):
1389+
"""
1390+
Return the voltage to use for amp<->watt conversions for this inverter: the live measured
1391+
battery voltage if we have one, otherwise the last value we did measure, otherwise the
1392+
48V fallback. A hard-coded 48V here was issue #4493 - on a high-voltage battery it made
1393+
every derived power value wrong by roughly the ratio of the real pack voltage to 48V.
1394+
"""
1395+
detail = self.inverter_details.get(inverter_sn, {})
1396+
try:
1397+
voltage = float(detail.get("batteryVoltage"))
1398+
except (ValueError, TypeError):
1399+
voltage = None
1400+
if voltage and voltage > 0:
1401+
self.nominal_voltage_last_known[inverter_sn] = voltage
1402+
return voltage
1403+
return self.nominal_voltage_last_known.get(inverter_sn, self.nominal_voltage)
1404+
1405+
def get_capacity_voltage(self, inverter_sn):
1406+
"""
1407+
Return the configured nominal pack voltage to use for the battery capacity calculation,
1408+
or None if solis_nominal_voltage isn't set. This is deliberately NOT the same value as
1409+
get_nominal_voltage() (the live measured voltage): capacity should use the nominal pack
1410+
voltage (a fixed physical property, e.g. cell count x nominal cell voltage), and the live
1411+
value drifts with charge state.
1412+
1413+
Note on the None case: DEYE's equivalent (nominal_pack_voltage()/derive_battery_capacity()
1414+
in deye.py) refuses to publish a capacity at all rather than guess, on the basis that a
1415+
wrong soc_max source is worse than none. Solis instead falls back to the live voltage and
1416+
flags the result unreliable (see the caller in publish_entities) so existing installs that
1417+
already had this sensor (using the old, worse, hard-coded 48V) don't lose it outright -
1418+
a different tradeoff than DEYE's for the same underlying problem, worth reviewer scrutiny.
1419+
"""
1420+
return self.nominal_pack_voltage
13671421

13681422
async def fetch_entity_data(self, sn):
13691423
"""
@@ -1410,7 +1464,7 @@ async def fetch_entity_data(self, sn):
14101464
if power_state is not None:
14111465
try:
14121466
max_current_amps = self.max_charge_current.get(sn, 100) if direction == 'charge' else self.max_discharge_current.get(sn, 100)
1413-
value = max(0, min(int(float(power_state) / self.nominal_voltage), max_current_amps))
1467+
value = max(0, min(int(float(power_state) / self.get_nominal_voltage(sn)), max_current_amps))
14141468
self.charge_discharge_time_windows[sn][slot][f"{direction}_current"] = value
14151469
except (ValueError, TypeError):
14161470
pass
@@ -1882,13 +1936,13 @@ async def publish_entities(self):
18821936
current_value_watts = None
18831937
if current_value_amps is not None:
18841938
try:
1885-
current_value_watts = int(float(current_value_amps) * self.nominal_voltage)
1939+
current_value_watts = int(float(current_value_amps) * self.get_nominal_voltage(inverter_sn))
18861940
except (ValueError, TypeError):
18871941
self.log("Warn: Failed to convert charge current to watts for {} slot {}: {}".format(inverter_sn, slot_num, current_value_amps)) # Debug log
18881942

18891943
# Use pre-calculated max current (convert to watts)
18901944
max_current_amps = self.max_charge_current.get(inverter_sn, 100)
1891-
max_power_watts = int(max_current_amps * self.nominal_voltage)
1945+
max_power_watts = int(max_current_amps * self.get_nominal_voltage(inverter_sn))
18921946

18931947
self.dashboard_item(
18941948
entity_id,
@@ -1898,7 +1952,7 @@ async def publish_entities(self):
18981952
"unit_of_measurement": "W",
18991953
"min": 0,
19001954
"max": max_power_watts,
1901-
"step": self.nominal_voltage,
1955+
"step": self.get_nominal_voltage(inverter_sn),
19021956
"device_class": "power",
19031957
"icon": "mdi:flash",
19041958
},
@@ -1999,13 +2053,13 @@ async def publish_entities(self):
19992053
current_value_watts = None
20002054
if current_value_amps is not None:
20012055
try:
2002-
current_value_watts = int(float(current_value_amps) * self.nominal_voltage)
2056+
current_value_watts = int(float(current_value_amps) * self.get_nominal_voltage(inverter_sn))
20032057
except (ValueError, TypeError):
20042058
self.log("Warn: Failed to convert discharge current to watts for {} slot {}: {}".format(inverter_sn, slot_num, current_value_amps)) # Debug log
20052059

20062060
# Use pre-calculated max current (convert to watts)
20072061
max_current_amps = self.max_discharge_current.get(inverter_sn, 100)
2008-
max_power_watts = int(max_current_amps * self.nominal_voltage)
2062+
max_power_watts = int(max_current_amps * self.get_nominal_voltage(inverter_sn))
20092063

20102064
self.dashboard_item(
20112065
entity_id,
@@ -2015,7 +2069,7 @@ async def publish_entities(self):
20152069
"unit_of_measurement": "W",
20162070
"min": 0,
20172071
"max": max_power_watts,
2018-
"step": self.nominal_voltage,
2072+
"step": self.get_nominal_voltage(inverter_sn),
20192073
"device_class": "power",
20202074
"icon": "mdi:flash",
20212075
},
@@ -2129,7 +2183,7 @@ async def publish_entities(self):
21292183
max_charge_power_watts = None
21302184
if max_charge_current_amps is not None:
21312185
try:
2132-
max_charge_power_watts = int(float(max_charge_current_amps) * self.nominal_voltage)
2186+
max_charge_power_watts = int(float(max_charge_current_amps) * self.get_nominal_voltage(inverter_sn))
21332187
except (ValueError, TypeError):
21342188
self.log("Warn: Failed to convert max charge current to watts for {}: {}".format(inverter_sn, max_charge_current_amps)) # Debug log
21352189

@@ -2140,8 +2194,8 @@ async def publish_entities(self):
21402194
"friendly_name": f"Solis {inverter_name} Battery Max Charge Power",
21412195
"unit_of_measurement": "W",
21422196
"min": 0,
2143-
"max": int(1000 * self.nominal_voltage),
2144-
"step": self.nominal_voltage,
2197+
"max": int(1000 * self.get_nominal_voltage(inverter_sn)),
2198+
"step": self.get_nominal_voltage(inverter_sn),
21452199
"device_class": "power",
21462200
"icon": "mdi:battery-arrow-down-outline",
21472201
},
@@ -2155,7 +2209,7 @@ async def publish_entities(self):
21552209
max_discharge_power_watts = None
21562210
if max_discharge_current_amps is not None:
21572211
try:
2158-
max_discharge_power_watts = int(float(max_discharge_current_amps) * self.nominal_voltage)
2212+
max_discharge_power_watts = int(float(max_discharge_current_amps) * self.get_nominal_voltage(inverter_sn))
21592213
except (ValueError, TypeError):
21602214
self.log("Warn: Failed to convert max discharge current to watts for {}: {}".format(inverter_sn, max_discharge_current_amps)) # Debug log
21612215

@@ -2166,8 +2220,8 @@ async def publish_entities(self):
21662220
"friendly_name": f"Solis {inverter_name} Battery Max Discharge Power",
21672221
"unit_of_measurement": "W",
21682222
"min": 0,
2169-
"max": int(1000 * self.nominal_voltage),
2170-
"step": self.nominal_voltage,
2223+
"max": int(1000 * self.get_nominal_voltage(inverter_sn)),
2224+
"step": self.get_nominal_voltage(inverter_sn),
21712225
"device_class": "power",
21722226
"icon": "mdi:battery-arrow-up-outline",
21732227
},
@@ -2239,7 +2293,31 @@ async def publish_entities(self):
22392293
if battery_capacity_ah is not None:
22402294
try:
22412295
battery_capacity_ah = float(battery_capacity_ah)
2242-
battery_capacity_kWh = battery_capacity_ah * self.nominal_voltage / 1000.0
2296+
# Must account for parallel batteries (issue #4493) - SOLIS_CID_BATTERY_CAPACITY
2297+
# is the per-battery Ah rating, same as the max charge/discharge current CIDs
2298+
# handled in _calculate_max_currents() above.
2299+
battery_count = self.parallel_battery_count.get(inverter_sn, 1)
2300+
# Prefer the configured nominal pack voltage. Without it, fall back to the live
2301+
# measured voltage rather than dropping the sensor entirely - existing installs
2302+
# already have this sensor published (albeit with the old, worse, hard-coded 48V
2303+
# figure) and losing it outright without any migration path was judged too
2304+
# disruptive. It's flagged as unreliable via the reliable/voltage_source
2305+
# attributes and a warning instead, matching neither "guess silently" nor "go
2306+
# unavailable silently" - reviewers may want a different tradeoff here.
2307+
capacity_voltage = self.get_capacity_voltage(inverter_sn)
2308+
reliable = capacity_voltage is not None
2309+
if not reliable:
2310+
capacity_voltage = self.get_nominal_voltage(inverter_sn)
2311+
# publish_entities() runs roughly once a minute - only warn once per
2312+
# inverter rather than forever, so this doesn't drown out the log for the
2313+
# many existing installs that haven't set solis_nominal_voltage yet.
2314+
if inverter_sn not in self.capacity_voltage_warned:
2315+
self.capacity_voltage_warned.add(inverter_sn)
2316+
self.log(
2317+
f"Warn: Solis API {inverter_name} battery capacity is estimated from the live measured voltage ({capacity_voltage}V), which varies with charge state - "
2318+
"set solis_nominal_voltage in apps.yaml for an accurate, stable value (this warning will not repeat)"
2319+
)
2320+
battery_capacity_kWh = battery_capacity_ah * battery_count * capacity_voltage / 1000.0
22432321
entity_id = f"sensor.{prefix}_solis_{inverter_sn_lower}_battery_capacity"
22442322
self.dashboard_item(
22452323
entity_id,
@@ -2249,7 +2327,9 @@ async def publish_entities(self):
22492327
"unit_of_measurement": "kWh",
22502328
"device_class": "energy",
22512329
"state_class": "measurement",
2252-
"icon": "mdi:battery",
2330+
"icon": "mdi:battery" if reliable else "mdi:battery-alert",
2331+
"reliable": reliable,
2332+
"voltage_source": "configured (solis_nominal_voltage)" if reliable else "estimated from the live measured voltage - set solis_nominal_voltage in apps.yaml for an accurate, stable value",
22532333
},
22542334
app="solis"
22552335
)
@@ -2501,7 +2581,7 @@ async def number_event(self, entity_id, value):
25012581
return
25022582

25032583
# Convert watts to amps for inverter
2504-
amps = int(value / self.nominal_voltage)
2584+
amps = int(value / self.get_nominal_voltage(inverter_sn))
25052585

25062586
# Update charge_discharge_time_windows cache
25072587
if inverter_sn not in self.charge_discharge_time_windows:
@@ -2558,7 +2638,7 @@ async def number_event(self, entity_id, value):
25582638
return
25592639

25602640
# Convert watts to amps for inverter
2561-
amps = round(float(value) / self.nominal_voltage, 1)
2641+
amps = round(float(value) / self.get_nominal_voltage(inverter_sn), 1)
25622642

25632643
# Update charge_discharge_time_windows cache
25642644
if inverter_sn not in self.charge_discharge_time_windows:
@@ -2601,7 +2681,7 @@ async def number_event(self, entity_id, value):
26012681
cid = cid_map[field]
26022682

26032683
# Convert watts to amps for inverter
2604-
amps = round(float(value) / self.nominal_voltage, 1)
2684+
amps = round(float(value) / self.get_nominal_voltage(inverter_sn), 1)
26052685

26062686
# Write to inverter
26072687
await self.read_and_write_cid(inverter_sn, cid, amps, field_description=f"{field} to {value_str}W ({amps}A)")

0 commit comments

Comments
 (0)