Skip to content

Commit eb8e0ae

Browse files
committed
Prevent negative PV string energy (v1.2.7.4)
1 parent 928f5cf commit eb8e0ae

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

custom_components/sigen/calculated_sensor.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,17 @@ def calculate_pv_power(
239239
try:
240240
voltage_dec = safe_decimal(pv_voltage)
241241
current_dec = safe_decimal(pv_current)
242-
if voltage_dec and current_dec:
243-
power = voltage_dec * current_dec # Already in Watts
244-
else:
242+
if voltage_dec is None or current_dec is None:
243+
return None
244+
245+
# The signed Modbus inputs can contain small negative offsets
246+
# under low-light conditions. A PV string is a production source,
247+
# so these offsets must not become negative power and make its
248+
# accumulated or daily energy decrease.
249+
if voltage_dec <= Decimal("0") or current_dec <= Decimal("0"):
245250
return 0.0
251+
252+
power = voltage_dec * current_dec # Already in Watts
246253
except (ValueError, TypeError, InvalidOperation):
247254
_LOGGER.warning(
248255
"[CS][PV Power] Error converting values to Decimal: V=%s, I=%s",

custom_components/sigen/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@
3131
"requirements": [
3232
"pymodbus>=3.8.3"
3333
],
34-
"version": "1.2.7.3"
34+
"version": "1.2.7.4"
3535
}

tests/test_pv_power_floor.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Regression tests for PV string power measurement offsets."""
2+
3+
from __future__ import annotations
4+
5+
import ast
6+
import copy
7+
import unittest
8+
from decimal import Decimal, InvalidOperation
9+
from pathlib import Path
10+
from unittest.mock import Mock
11+
12+
13+
SOURCE_PATH = (
14+
Path(__file__).resolve().parents[1]
15+
/ "custom_components"
16+
/ "sigen"
17+
/ "calculated_sensor.py"
18+
)
19+
20+
21+
def _calculate_pv_power_function():
22+
"""Load calculate_pv_power without requiring Home Assistant."""
23+
tree = ast.parse(SOURCE_PATH.read_text(encoding="utf-8"))
24+
calculations_class = next(
25+
node
26+
for node in tree.body
27+
if isinstance(node, ast.ClassDef) and node.name == "SigenergyCalculations"
28+
)
29+
function = copy.deepcopy(
30+
next(
31+
node
32+
for node in calculations_class.body
33+
if isinstance(node, ast.FunctionDef)
34+
and node.name == "calculate_pv_power"
35+
)
36+
)
37+
function.decorator_list = []
38+
module = ast.fix_missing_locations(
39+
ast.Module(
40+
body=[
41+
ast.ImportFrom(
42+
module="__future__",
43+
names=[ast.alias(name="annotations")],
44+
level=0,
45+
),
46+
function,
47+
],
48+
type_ignores=[],
49+
)
50+
)
51+
namespace = {
52+
"Decimal": Decimal,
53+
"InvalidOperation": InvalidOperation,
54+
"_LOGGER": Mock(),
55+
"safe_decimal": lambda value: Decimal(str(value)),
56+
"safe_float": float,
57+
}
58+
exec(compile(module, SOURCE_PATH, "exec"), namespace)
59+
return namespace["calculate_pv_power"]
60+
61+
62+
class TestPVPowerFloor(unittest.TestCase):
63+
"""Ensure measurement offsets cannot produce negative PV power."""
64+
65+
@classmethod
66+
def setUpClass(cls) -> None:
67+
cls.calculate_pv_power = staticmethod(_calculate_pv_power_function())
68+
69+
def _calculate(self, voltage: float, current: float) -> float | None:
70+
return self.calculate_pv_power(
71+
None,
72+
{
73+
"inverters": {
74+
"Sigen Inverter": {
75+
"inverter_pv3_voltage": voltage,
76+
"inverter_pv3_current": current,
77+
}
78+
}
79+
},
80+
{"pv_idx": 3, "device_name": "Sigen Inverter"},
81+
)
82+
83+
def test_positive_voltage_and_current_produce_power(self) -> None:
84+
self.assertAlmostEqual(2.245789, self._calculate(336.7, 6.67))
85+
86+
def test_non_positive_current_produces_zero_power(self) -> None:
87+
for current in (0.0, -0.01, -0.04):
88+
with self.subTest(current=current):
89+
self.assertEqual(0.0, self._calculate(168.6, current))
90+
91+
def test_non_positive_voltage_produces_zero_power(self) -> None:
92+
for voltage in (0.0, -0.1):
93+
with self.subTest(voltage=voltage):
94+
self.assertEqual(0.0, self._calculate(voltage, 0.01))
95+
96+
def test_negative_voltage_and_current_do_not_create_phantom_power(self) -> None:
97+
self.assertEqual(0.0, self._calculate(-0.1, -0.01))
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

0 commit comments

Comments
 (0)