Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**,
Expand Down
35 changes: 28 additions & 7 deletions custom_components/span_panel/current_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down
180 changes: 180 additions & 0 deletions tests/test_current_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading