|
18 | 18 | from solis import SOLIS_CID_TOU_V2_MODE, SOLIS_CID_LIST_TOU_V2 |
19 | 19 | from solis import SOLIS_CID_ALLOW_EXPORT, SOLIS_ALLOW_EXPORT_ON, SOLIS_ALLOW_EXPORT_OFF, SOLIS_CID_BATTERY_RESERVE_SOC |
20 | 20 | from solis import SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT, SOLIS_CID_BATTERY_RECOVERY_SOC, SOLIS_CID_DISCHARGE_SOC |
| 21 | +from solis import SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT, SOLIS_CID_DISCHARGE_CURRENT |
21 | 22 | from solis import SOLIS_CID_POWER_LIMIT, SOLIS_BIT_BACKUP_MODE |
22 | 23 | from solis import SOLIS_READ_ENDPOINT, SOLIS_READ_BATCH_ENDPOINT, SOLIS_CONTROL_ENDPOINT, SOLIS_INVERTER_LIST_ENDPOINT, SOLIS_INVERTER_DETAIL_ENDPOINT |
23 | 24 | from solis import get_solis_mode_enum, compute_solis_mode_value |
@@ -1490,6 +1491,11 @@ def run_solis_tests(my_predbat): |
1490 | 1491 | failed |= asyncio.run(test_discharge_soc_unchanged_above_recovery()) |
1491 | 1492 | failed |= asyncio.run(test_discharge_soc_unclamped_when_recovery_unknown()) |
1492 | 1493 | failed |= asyncio.run(test_recovery_soc_not_lowered_below_inverter_minimum()) |
| 1494 | + failed |= asyncio.run(test_implausible_recovery_soc_falls_back_to_inverter_minimum()) |
| 1495 | + failed |= test_get_rated_current() |
| 1496 | + failed |= asyncio.run(test_v2_slot_currents_capped_at_inverter_rating()) |
| 1497 | + failed |= asyncio.run(test_v1_slot_currents_capped_at_inverter_rating()) |
| 1498 | + failed |= asyncio.run(test_slot_currents_uncapped_when_inverter_size_unknown()) |
1493 | 1499 | failed |= asyncio.run(test_control_write_failure_withholds_success_timestamp()) |
1494 | 1500 | failed |= asyncio.run(test_control_write_success_updates_success_timestamp()) |
1495 | 1501 | failed |= asyncio.run(test_storage_mode_failure_does_not_fail_control_write()) |
@@ -2607,6 +2613,173 @@ async def test_recovery_soc_not_lowered_below_inverter_minimum(): |
2607 | 2613 | return False |
2608 | 2614 |
|
2609 | 2615 |
|
| 2616 | +async def test_implausible_recovery_soc_falls_back_to_inverter_minimum(): |
| 2617 | + """A recovery SOC reading that cannot be right is replaced by over-discharge + 1 (issue #5187). |
| 2618 | +
|
| 2619 | + Most of the fleet reports 0, 1, the over-discharge SOC itself or 65521 for CID 7229. Taken at |
| 2620 | + face value any of those let a target at the over-discharge SOC through unclamped, which the |
| 2621 | + inverter refuses, so the slot kept a stale cut-off of 40-50%. The fallback is never written |
| 2622 | + back to the inverter, since the reading it would be verified against is the one that is wrong. |
| 2623 | + """ |
| 2624 | + print("\n=== Test: implausible recovery SOC falls back to over-discharge + 1 ===") |
| 2625 | + |
| 2626 | + # (description, discharge target, recovery reading, over-discharge, expected SOC write) |
| 2627 | + cases = [ |
| 2628 | + ("recovery reads 0", 12, 0, 12, "13"), |
| 2629 | + ("recovery reads the over-discharge SOC", 15, 15, 15, "16"), |
| 2630 | + ("recovery reads 65521", 10, 65521, 10, "11"), |
| 2631 | + ("recovery reads 0 but the target is reachable anyway", 40, 0, 12, "40"), |
| 2632 | + ("recovery reads 65521 and over-discharge is unknown", 20, 65521, None, "20"), |
| 2633 | + ] |
| 2634 | + for description, target, recovery, over_discharge, expected in cases: |
| 2635 | + api = _discharge_slot_api(discharge_soc=target, recovery_soc=recovery, over_discharge_soc=over_discharge) |
| 2636 | + assert await api.write_time_windows_if_changed("TEST123") is True, f"{description}: write_time_windows_if_changed should succeed" |
| 2637 | + assert _written_soc(api) == expected, f"{description}: discharge SOC should be {expected}, got {_written_soc(api)}" |
| 2638 | + assert _written_recovery(api) is None, f"{description}: an implausible recovery SOC must not be written back, got {_written_recovery(api)}" |
| 2639 | + |
| 2640 | + print("PASSED: Implausible recovery SOC readings clamp to over-discharge + 1 without being written") |
| 2641 | + return False |
| 2642 | + |
| 2643 | + |
| 2644 | +def _rated_inverter_api(inverter_sn="TEST123", power=3.6): |
| 2645 | + """Build a V2-mode MockSolisAPI for a 3.6kW inverter whose battery limits read 100A, as in issue #5187. |
| 2646 | +
|
| 2647 | + Args: |
| 2648 | + inverter_sn: Inverter serial number to use |
| 2649 | + power: inverterDetail power in kW, or None to leave the inverter size unknown |
| 2650 | +
|
| 2651 | + Returns: The configured MockSolisAPI, with an enabled discharge slot 1 asking for 100A |
| 2652 | + """ |
| 2653 | + api = _discharge_slot_api(discharge_soc=40, recovery_soc=21, over_discharge_soc=20, inverter_sn=inverter_sn) |
| 2654 | + api.charge_discharge_time_windows[inverter_sn][1]["discharge_current"] = 100 |
| 2655 | + api.cached_values[inverter_sn][SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT] = "100" |
| 2656 | + api.cached_values[inverter_sn][SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT] = "100" |
| 2657 | + if power is not None: |
| 2658 | + api.inverter_details[inverter_sn] = {"power": power, "powerStr": "kW"} |
| 2659 | + return api |
| 2660 | + |
| 2661 | + |
| 2662 | +def _written_value(api, cid): |
| 2663 | + """Return the last value written to a CID, or None if it was not written. |
| 2664 | +
|
| 2665 | + Args: |
| 2666 | + api: MockSolisAPI whose recorded calls should be searched |
| 2667 | + cid: CID to look for |
| 2668 | +
|
| 2669 | + Returns: The written value as a string, or None |
| 2670 | + """ |
| 2671 | + values = [c["value"] for c in api.read_and_write_cid_calls if c["cid"] == cid] |
| 2672 | + return values[-1] if values else None |
| 2673 | + |
| 2674 | + |
| 2675 | +def test_get_rated_current(): |
| 2676 | + """The rated current is the inverter size over the nominal pack voltage, floored to whole amps.""" |
| 2677 | + print("\n=== Test: get_rated_current ===") |
| 2678 | + |
| 2679 | + sn = "TEST123" |
| 2680 | + # (description, inverterDetail, expected amps) |
| 2681 | + cases = [ |
| 2682 | + ("3.6kW on a 48V pack", {"power": 3.6, "powerStr": "kW"}, 75.0), |
| 2683 | + ("3.6kW on a 16S pack", {"power": "3.6", "powerStr": "kW", "batteryAcvSet": 56.8}, 70.0), |
| 2684 | + ("size reported in watts", {"power": 3600, "powerStr": "W"}, 75.0), |
| 2685 | + ("unit missing, taken as kW", {"power": 3.6}, 75.0), |
| 2686 | + ("size missing", {}, None), |
| 2687 | + ("size zero", {"power": 0, "powerStr": "kW"}, None), |
| 2688 | + ("size not a number", {"power": "junk", "powerStr": "kW"}, None), |
| 2689 | + ("unrecognised unit", {"power": 3.6, "powerStr": "kVA"}, None), |
| 2690 | + ] |
| 2691 | + for description, detail, expected in cases: |
| 2692 | + api = MockSolisAPI() |
| 2693 | + api.inverter_details[sn] = detail |
| 2694 | + rated = api.get_rated_current(sn) |
| 2695 | + assert rated == expected, f"{description}: expected {expected}, got {rated}" |
| 2696 | + |
| 2697 | + print("PASSED: Rated current derived from inverter size and nominal voltage") |
| 2698 | + return False |
| 2699 | + |
| 2700 | + |
| 2701 | +async def test_v2_slot_currents_capped_at_inverter_rating(): |
| 2702 | + """A V2 slot current is never written above what the inverter can deliver (issue #5187). |
| 2703 | +
|
| 2704 | + The battery limit (CID 7226) reads 100A, but on a 3.6kW inverter at 48V the most it can move is |
| 2705 | + 75A; the inverter refused 100A every minute and left the slot at 0A instead. |
| 2706 | + """ |
| 2707 | + print("\n=== Test: V2 slot currents capped at the inverter rating ===") |
| 2708 | + |
| 2709 | + sn = "TEST123" |
| 2710 | + api = _rated_inverter_api(inverter_sn=sn) |
| 2711 | + api.cached_values[sn][SOLIS_CID_DISCHARGE_CURRENT[0]] = "0" |
| 2712 | + assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed" |
| 2713 | + assert _written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0]) == "75.0", f"Discharge current should be capped at 75.0A, got {_written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0])}" |
| 2714 | + assert any("Capping slot currents on TEST123 at 75.0A" in m for m in api.log_messages), "The cap should be logged" |
| 2715 | + |
| 2716 | + # The same cap applies to a charge slot |
| 2717 | + api = _rated_inverter_api(inverter_sn=sn) |
| 2718 | + api.cached_values[sn][SOLIS_CID_CHARGE_CURRENT[0]] = "0" |
| 2719 | + slot = api.charge_discharge_time_windows[sn][1] |
| 2720 | + slot.update({"charge_enable": 1, "charge_start_time": "02:00", "charge_end_time": "05:00", "charge_current": 100, "discharge_enable": 0}) |
| 2721 | + assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed" |
| 2722 | + assert _written_value(api, SOLIS_CID_CHARGE_CURRENT[0]) == "75.0", f"Charge current should be capped at 75.0A, got {_written_value(api, SOLIS_CID_CHARGE_CURRENT[0])}" |
| 2723 | + |
| 2724 | + # The windows Predbat asked for are left as they were, only the write is capped |
| 2725 | + assert api.charge_discharge_time_windows[sn][1]["charge_current"] == 100, "The requested current must not be rewritten in the local windows" |
| 2726 | + |
| 2727 | + print("PASSED: 100A requests written as 75A on a 3.6kW inverter") |
| 2728 | + return False |
| 2729 | + |
| 2730 | + |
| 2731 | +async def test_v1_slot_currents_capped_at_inverter_rating(): |
| 2732 | + """The V1 path encodes capped currents into CID 103 as well.""" |
| 2733 | + print("\n=== Test: V1 slot currents capped at the inverter rating ===") |
| 2734 | + |
| 2735 | + sn = "TEST123" |
| 2736 | + api = MockSolisAPI() |
| 2737 | + api._test_v2_mode = False |
| 2738 | + api._mock_storage_mode = True |
| 2739 | + api.inverter_sn = [sn] |
| 2740 | + api.inverter_details[sn] = {"power": 3.6, "powerStr": "kW"} |
| 2741 | + windows = {} |
| 2742 | + for slot in range(1, 4): |
| 2743 | + windows[slot] = { |
| 2744 | + "charge_start_time": "00:00", |
| 2745 | + "charge_end_time": "00:00", |
| 2746 | + "charge_current": 0, |
| 2747 | + "discharge_start_time": "00:00", |
| 2748 | + "discharge_end_time": "00:00", |
| 2749 | + "discharge_current": 0, |
| 2750 | + "field_length": 18, |
| 2751 | + } |
| 2752 | + windows[1].update({"charge_enable": 1, "charge_start_time": "02:00", "charge_end_time": "05:00", "charge_current": 100, "discharge_current": 100}) |
| 2753 | + api.charge_discharge_time_windows[sn] = windows |
| 2754 | + api.cached_values[sn] = {} |
| 2755 | + # Outside the window, so the in-slot SOC handling cannot zero a current either way |
| 2756 | + api._test_now_utc_exact = datetime(2026, 9, 21, 12, 0, tzinfo=api.local_tz) |
| 2757 | + |
| 2758 | + assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed" |
| 2759 | + encoded = _written_value(api, SOLIS_CID_CHARGE_DISCHARGE_SETTINGS) |
| 2760 | + assert encoded is not None, "CID 103 should be written" |
| 2761 | + assert encoded.startswith("75,75,02:00,05:00,"), f"Slot 1 currents should be encoded as 75A, got {encoded}" |
| 2762 | + |
| 2763 | + print("PASSED: V1 currents encoded at the 75A rating") |
| 2764 | + return False |
| 2765 | + |
| 2766 | + |
| 2767 | +async def test_slot_currents_uncapped_when_inverter_size_unknown(): |
| 2768 | + """Without an inverter size there is no rating to cap at, so the existing limits alone apply.""" |
| 2769 | + print("\n=== Test: slot currents uncapped when the inverter size is unknown ===") |
| 2770 | + |
| 2771 | + sn = "TEST123" |
| 2772 | + api = _rated_inverter_api(inverter_sn=sn, power=None) |
| 2773 | + # An absent register is taken to be at the battery limit already, so give it something to change from |
| 2774 | + api.cached_values[sn][SOLIS_CID_DISCHARGE_CURRENT[0]] = "0" |
| 2775 | + assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed" |
| 2776 | + assert _written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0]) == "100.0", f"Discharge current should be the uncapped 100.0A, got {_written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0])}" |
| 2777 | + assert not any("Capping" in m for m in api.log_messages), "Nothing should be capped" |
| 2778 | + |
| 2779 | + print("PASSED: Unknown inverter size leaves the current at the battery limit") |
| 2780 | + return False |
| 2781 | + |
| 2782 | + |
2610 | 2783 | async def test_control_write_failure_withholds_success_timestamp(): |
2611 | 2784 | """A refused control register write stops the success timestamp advancing, so the component ages out. |
2612 | 2785 |
|
|
0 commit comments