diff --git a/CHANGELOG.md b/CHANGELOG.md index 826f1ca9..b2b9aca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. - **An energy counter reading beyond what its breaker could ever carry is never used as the dip-compensation baseline**, so it cannot skew energy totals. - **The current-monitoring summary is hidden while monitoring is off**, instead of reading "0 circuits". +- **A circuit or main breaker whose current monitoring is turned off no longer shows a frozen utilization or alert window** in the panel view or Get + monitoring status, and turning monitoring back on starts a fresh window rather than alerting on one opened before. - **DSM State, DSM Grid State and Current Run Config stay on-grid on a panel with no Microgrid Interconnect Device**, instead of dropping to unknown for a moment whenever Grid Power read near zero. - **Power sensors show unknown while the panel is offline, and Battery, PV, Grid and Site Power show unknown whenever the panel has not published them**, diff --git a/custom_components/span_panel/current_monitor.py b/custom_components/span_panel/current_monitor.py index eced9a33..8621db56 100644 --- a/custom_components/span_panel/current_monitor.py +++ b/custom_components/span_panel/current_monitor.py @@ -197,6 +197,8 @@ def set_circuit_override(self, circuit_id: str, overrides: MonitoringSettings) - self._circuit_overrides.pop(key, None) else: self._circuit_overrides[key] = existing + if is_monitoring_disabled(existing): + self._forget_judgement(self._circuit_states.get(key)) self._hass.async_create_task(self.async_save_overrides()) def clear_circuit_override(self, circuit_id: str) -> None: @@ -206,6 +208,19 @@ def clear_circuit_override(self, circuit_id: str) -> None: self._circuit_states.pop(key, None) self._hass.async_create_task(self.async_save_overrides()) + @staticmethod + def _forget_judgement(state: MonitoredPointState | None) -> None: + """Drop what monitoring last saw of a point whose monitoring was just turned off. + + The sample and the window it opened belong to monitoring that has stopped: + kept, a window opened long ago would make the first sample after + monitoring resumes alert at once. The alert times stay, since they are + history and hold the cooldown. + """ + if state is not None: + state.last_current_a = None + state.over_threshold_since = None + def _override_key(self, circuit_id: str) -> str: """Return the key a circuit's override and state are held under. @@ -246,6 +261,8 @@ def set_mains_override(self, leg: str, overrides: MonitoringSettings) -> None: self._mains_overrides.pop(leg, None) else: self._mains_overrides[leg] = existing + if is_monitoring_disabled(existing): + self._forget_judgement(self._mains_states.get(leg)) self._hass.async_create_task(self.async_save_overrides()) def _is_redundant_override(self, override: MonitoringSettings) -> bool: @@ -426,17 +443,20 @@ def get_monitoring_status(self) -> dict[str, Any]: point_id = point.point_id if point else cid state = self._circuit_states.get(point_id) rating = float(point.rating_a) if point and point.rating_a else None - last_current = state.last_current_a if state else None + override = self._circuit_overrides.get(point_id, {}) + # A point nobody is monitoring has no judgement to report, whatever it last + # sampled; its alert times stay, being history. + judged = not is_monitoring_disabled(override) + last_current = state.last_current_a if state and judged else None utilization = ( round(last_current / rating * 100, 1) if rating and last_current is not None else None ) cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( - self._circuit_overrides.get(point_id, {}), self.get_global_settings() + override, self.get_global_settings() ) entity_id = self._resolve_circuit_entity_id(cid) - override = self._circuit_overrides.get(point_id, {}) has_override = bool(override) monitoring_enabled = override.get("monitoring_enabled", True) @@ -453,7 +473,7 @@ def get_monitoring_status(self) -> dict[str, Any]: "has_override": has_override, "monitoring_enabled": monitoring_enabled, "over_threshold_since": state.over_threshold_since.isoformat() - if state and state.over_threshold_since + if state and judged and state.over_threshold_since else None, "last_spike_alert": state.last_spike_alert.isoformat() if state and state.last_spike_alert @@ -474,7 +494,9 @@ def get_monitoring_status(self) -> dict[str, Any]: for leg in upstream if leg is not None and leg.last_current_a is not None ] - peak_current = max(sampled) if sampled else None + override = self._mains_overrides.get("upstream_l1", {}) + # As for a circuit: the mains' figures are a judgement, and none is made while it is off. + peak_current = max(sampled) if sampled and not is_monitoring_disabled(override) else None utilization = ( round(peak_current / main_rating * 100, 1) if main_rating and peak_current is not None @@ -483,9 +505,8 @@ def get_monitoring_status(self) -> dict[str, Any]: # Use upstream_l1 thresholds as the representative (they're the same unless overridden) cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( - self._mains_overrides.get("upstream_l1", {}), self.get_global_settings() + override, self.get_global_settings() ) - override = self._mains_overrides.get("upstream_l1", {}) has_override = bool(override) monitoring_enabled = override.get("monitoring_enabled", True) diff --git a/tests/test_current_monitor.py b/tests/test_current_monitor.py index 804cf066..46ff7f21 100644 --- a/tests/test_current_monitor.py +++ b/tests/test_current_monitor.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterator from contextlib import contextmanager +from dataclasses import replace from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -1330,3 +1331,182 @@ def test_a_missing_sample_raises_no_alert(self): assert monitor.get_circuit_state("1") is None assert monitor.get_mains_state("upstream_l1") is None hass.bus.async_fire.assert_not_called() + + +def _over_threshold(current_a: float = 17.0) -> SpanPanelSnapshot: + """One 20 A circuit over an 80 % continuous threshold, on a 100 A panel.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", current_a=current_a, breaker_rating_a=20.0 + ) + return SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=100, + upstream_l1_current_a=90.0, + upstream_l2_current_a=None, + ) + + +def _windowed_monitor(hass: MagicMock) -> CurrentMonitor: + """Return a monitor that alerts on a 15 minute window over 80 %, and never on a spike.""" + return _make_monitor( + hass, + _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80, WINDOW_DURATION_M: 15, SPIKE_THRESHOLD_PCT: 500} + ), + ) + + +def _continuous_alerts(hass: MagicMock) -> list[str]: + """Return the alert types fired on the event bus for continuous overloads.""" + return [ + call.args[1]["alert_type"] + for call in hass.bus.async_fire.call_args_list + if call.args[0] == EVENT_CURRENT_ALERT and call.args[1]["alert_type"] != "spike" + ] + + +@pytest.mark.usefixtures("_no_registered_entities") +class TestStatusWhileMonitoringIsOff: + """A point whose monitoring is off reports no judgement; turning it back on resumes it.""" + + def test_a_circuit_turned_off_reports_no_utilization_current_or_window(self): + """Over threshold, then off: the frozen figures are gone, the alert history is kept.""" + hass = _make_hass() + monitor = _windowed_monitor(hass) + snapshot = _over_threshold() + monitor.process_snapshot(snapshot) + monitor.get_circuit_state("1").over_threshold_since = datetime.now(UTC) - timedelta( + minutes=16 + ) + monitor.process_snapshot(snapshot) + before = monitor.get_monitoring_status()["circuits"]["1"] + assert before["utilization_pct"] == 85.0 + assert before["over_threshold_since"] is not None + assert before["last_continuous_alert"] is not None + + monitor.set_circuit_override("1", {"monitoring_enabled": False}) + monitor.process_snapshot(snapshot) + + entry = monitor.get_monitoring_status()["circuits"]["1"] + assert entry["monitoring_enabled"] is False + assert entry["utilization_pct"] is None + assert entry["last_current_a"] is None + assert entry["over_threshold_since"] is None + assert entry["last_continuous_alert"] == before["last_continuous_alert"] + + def test_turning_it_back_on_resumes_with_no_stale_window(self): + """A window opened before it was turned off does not make the first sample after alert.""" + hass = _make_hass() + monitor = _windowed_monitor(hass) + snapshot = _over_threshold() + monitor.process_snapshot(snapshot) + monitor.get_circuit_state("1").over_threshold_since = datetime.now(UTC) - timedelta( + minutes=16 + ) + + monitor.set_circuit_override("1", {"monitoring_enabled": False}) + monitor.set_circuit_override("1", {"monitoring_enabled": True}) + monitor.process_snapshot(snapshot) + + entry = monitor.get_monitoring_status()["circuits"]["1"] + assert entry["last_current_a"] == 17.0 + assert entry["utilization_pct"] == 85.0 + assert datetime.fromisoformat(entry["over_threshold_since"]) > datetime.now( + UTC + ) - timedelta(minutes=1) + assert _continuous_alerts(hass) == [] + + def test_a_shared_group_turned_off_reports_no_judgement(self): + """Turning off either member turns off the group, and both rows read null.""" + hass = _make_hass() + monitor = _make_monitor(hass) + members = { + cid: replace( + SpanCircuitSnapshotFactory.create( + circuit_id=cid, tabs=[44], current_a=12.0, breaker_rating_a=15.0 + ), + meter_shared_with=(peer,), + ) + for cid, peer in (("circuit-52", "circuit-53"), ("circuit-53", "circuit-52")) + } + snapshot = SpanPanelSnapshotFactory.create(circuits=members, main_breaker_rating_a=100) + monitor.process_snapshot(snapshot) + + monitor.set_circuit_override("circuit-53", {"monitoring_enabled": False}) + monitor.process_snapshot(snapshot) + + status = monitor.get_monitoring_status()["circuits"] + for member in members: + assert status[member]["monitoring_enabled"] is False + assert status[member]["utilization_pct"] is None + assert status[member]["last_current_a"] is None + assert status[member]["over_threshold_since"] is None + + monitor.set_circuit_override("circuit-53", {"monitoring_enabled": True}) + monitor.process_snapshot(snapshot) + + status = monitor.get_monitoring_status()["circuits"] + for member in members: + assert status[member]["last_current_a"] == 12.0 + assert status[member]["utilization_pct"] == 40.0 + + def test_the_mains_turned_off_reports_no_utilization_or_current(self): + """The mains entry nulls its figures while off and resumes when back on.""" + hass = _make_hass() + monitor = _make_monitor(hass) + snapshot = _over_threshold(current_a=10.0) + monitor.process_snapshot(snapshot) + assert monitor.get_monitoring_status()["mains"]["upstream_l1"]["utilization_pct"] == 90.0 + + monitor.set_mains_override("upstream_l1", {"monitoring_enabled": False}) + monitor.process_snapshot(snapshot) + + entry = monitor.get_monitoring_status()["mains"]["upstream_l1"] + assert entry["monitoring_enabled"] is False + assert entry["utilization_pct"] is None + assert entry["last_current_a"] is None + + monitor.set_mains_override("upstream_l1", {"monitoring_enabled": True}) + monitor.process_snapshot(snapshot) + + entry = monitor.get_monitoring_status()["mains"]["upstream_l1"] + assert entry["last_current_a"] == 90.0 + assert entry["utilization_pct"] == 90.0 + + def test_the_mains_back_on_resumes_with_no_stale_window(self): + """A mains window opened before monitoring was turned off does not alert on the first sample after.""" + hass = _make_hass() + monitor = _windowed_monitor(hass) + snapshot = _over_threshold(current_a=10.0) + monitor.process_snapshot(snapshot) + leg = monitor.get_mains_state("upstream_l1") + assert leg is not None and leg.over_threshold_since is not None + leg.over_threshold_since = datetime.now(UTC) - timedelta(minutes=16) + + monitor.set_mains_override("upstream_l1", {"monitoring_enabled": False}) + monitor.set_mains_override("upstream_l1", {"monitoring_enabled": True}) + monitor.process_snapshot(snapshot) + + assert _continuous_alerts(hass) == [] + + async def test_the_report_does_not_depend_on_the_state_having_been_reset(self): + """An override loaded from storage, with a figure already held, still reports null.""" + hass = _make_hass() + monitor = _make_monitor(hass) + monitor.process_snapshot(_over_threshold()) + monitor._store = MagicMock() + monitor._store.async_load = AsyncMock( + return_value={ + "circuit_overrides": {"1": {"monitoring_enabled": False}}, + "mains_overrides": {"upstream_l1": {"monitoring_enabled": False}}, + } + ) + + await monitor.async_load_overrides() + + status = monitor.get_monitoring_status() + assert status["circuits"]["1"]["utilization_pct"] is None + assert status["circuits"]["1"]["last_current_a"] is None + assert status["circuits"]["1"]["over_threshold_since"] is None + assert status["mains"]["upstream_l1"]["utilization_pct"] is None + assert status["mains"]["upstream_l1"]["last_current_a"] is None