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
70 changes: 15 additions & 55 deletions custom_components/rce_pse/sensors/peak_hours.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,39 +38,6 @@ def _load_state_display_names(lang: str, translation_key: str) -> dict[str, str]
return result


def _pdgsz_records_to_ranges(records: list[dict]) -> dict[str, list[str]]:
attr_keys = set(PDGSZ_USAGE_FCST_TO_ATTR.values())
result: dict[str, list[str]] = {k: [] for k in attr_keys}
if not records:
return result
records_sorted = sorted(records, key=lambda r: r.get("dtime", ""))
i = 0
while i < len(records_sorted):
rec = records_sorted[i]
fcst = rec.get("usage_fcst", 1)
attr_key = PDGSZ_USAGE_FCST_TO_ATTR.get(fcst, "normal_usage")
dtime_str = rec.get("dtime", "")
if " " not in dtime_str:
i += 1
continue
start_part = dtime_str.split(" ", 1)[1]
try:
start_hour = int(start_part.split(":")[0])
except (ValueError, IndexError):
i += 1
continue
j = i + 1
while j < len(records_sorted) and records_sorted[j].get("usage_fcst") == fcst:
j += 1
end_hour = start_hour + (j - i)
if end_hour > 24:
end_hour = 24
end_part = f"{end_hour:02d}:00" if end_hour < 24 else "24:00"
result[attr_key].append(f"{start_part}–{end_part}")
i = j
return result


def _pdgsz_records_to_hourly_state(records: list[dict]) -> dict[int, str]:
result: dict[int, str] = {}
for rec in records:
Expand All @@ -87,18 +54,22 @@ def _pdgsz_records_to_hourly_state(records: list[dict]) -> dict[int, str]:
return result


def _hourly_states_attributes(
hourly: dict[int, str],
def _records_to_values(
records: list[dict],
display_names: dict[str, str],
) -> list[dict[str, Any]]:
return [
{
"hour": f"{h:02d}:00",
"state": hourly.get(h),
"state_display": display_names.get(hourly.get(h, ""), ""),
}
for h in range(24)
]
result: list[dict[str, Any]] = []
for rec in records:
usage_fcst = rec.get("usage_fcst", 1)
state = PDGSZ_USAGE_FCST_TO_ATTR.get(usage_fcst, "normal_usage")
result.append({
"dtime": rec.get("dtime"),
"usage_fcst": usage_fcst,
"business_date": rec.get("business_date"),
"state": state,
"display_state": display_names.get(state, ""),
})
return result


class RCEPeakHoursSensorBase(RCEBaseSensor):
Expand Down Expand Up @@ -137,7 +108,6 @@ def native_value(self) -> str | None:
@property
def extra_state_attributes(self) -> dict[str, Any]:
records = self._get_pdgsz_records()
hourly = _pdgsz_records_to_hourly_state(records)
try:
lang = self.hass.config.language
if lang not in ("pl", "en"):
Expand All @@ -146,8 +116,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
except (AttributeError, KeyError):
display_names = {}
return {
"records": records,
"hourly_states": _hourly_states_attributes(hourly, display_names),
"values": _records_to_values(records, display_names),
}

@property
Expand All @@ -171,12 +140,3 @@ def __init__(self, coordinator: RCEPSEDataUpdateCoordinator) -> None:

def _get_pdgsz_records(self) -> list[dict]:
return self.get_tomorrow_pdgsz_data()

def _get_current_hour_for_state(self) -> int:
return 0

@property
def available(self) -> bool:
if not self.is_tomorrow_data_available():
return False
return super().available
4 changes: 2 additions & 2 deletions custom_components/rce_pse/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@
"name": "Lowest Price End Tomorrow"
},
"rce_pse_today_peak_hours": {
"name": "Peak Hours Today",
"name": "Energy Compass Today",
"state_attributes": {
"recommended_usage": {
"name": "Recommended usage"
Expand All @@ -189,7 +189,7 @@
}
},
"rce_pse_tomorrow_peak_hours": {
"name": "Peak Hours Tomorrow",
"name": "Energy Compass Tomorrow",
"state_attributes": {
"recommended_usage": {
"name": "Recommended usage"
Expand Down
4 changes: 2 additions & 2 deletions custom_components/rce_pse/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@
"name": "Koniec Najniższej Ceny Jutro"
},
"rce_pse_today_peak_hours": {
"name": "Godziny Szczytu Dzisiaj",
"name": "Kompas Energetyczny Dzisiaj",
"state_attributes": {
"recommended_usage": {
"name": "Zalecane użytkowanie"
Expand All @@ -189,7 +189,7 @@
}
},
"rce_pse_tomorrow_peak_hours": {
"name": "Godziny Szczytu Jutro",
"name": "Kompas Energetyczny Jutro",
"state_attributes": {
"recommended_usage": {
"name": "Zalecane użytkowanie"
Expand Down
6 changes: 3 additions & 3 deletions docs/SENSORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@

Wszystkie zwracają **timestamp** (datetime). Aby pokazać tylko godzinę (np. HH:MM), użyj szablonu: `as_timestamp(...) | timestamp_custom('%H:%M')`. Szczegóły: [Migracja do v2.0.0](MIGRACJA-V2.md).

## Godziny Szczytu (PDGSZ)
## Kompas Energetyczny (PDGSZ)

Dane z raportu PSE „Godziny Szczytu” (API PDGSZ) – kiedy zalecane jest użytkowanie energii, a kiedy oszczędzanie.

- **Godziny Szczytu Dzisiaj** – stan: wartość tekstowa dla bieżącej godziny (np. „Zalecane użytkowanie”) lub brak danych jak przy sensorach „Jutro” (np. okna najniższej ceny) – wtedy stan „unknown”. Atrybuty: **records** – surowa odpowiedź API z dnia (tylko wpisy aktywne, is_active); **hourly_states** – lista 24 wpisów `{ "hour": "HH:00", "state": "klucz", "state_display": "tekst np. Zalecane użytkowanie" }` dla każdej godziny dnia.
- **Godziny Szczytu Jutro** – to samo dla następnego dnia (dostępne po 14:00 CET); stan odnosi się do godziny 00:00 jutro.
- **Kompas Energetyczny Dzisiaj** – stan: wartość tekstowa dla bieżącej godziny (np. „Zalecane użytkowanie”) lub brak danych jak przy sensorach „Jutro” (np. okna najniższej ceny) – wtedy stan „unknown”. Atrybut **values**: lista wpisów z API (tylko `dtime`, `usage_fcst`, `business_date`) z dodanymi `state` i `display_state` (klucz i tekst w języku interfejsu).
- **Kompas Energetyczny Jutro** – to samo dla następnego dnia (dostępne po 14:00 CET); stan dla aktualnej godziny, ale dnia jutrzejszego (jak sensor „Cena Jutro”).

Możliwe stany (wyświetlane jako tekst w języku interfejsu):

Expand Down
111 changes: 23 additions & 88 deletions tests/test_peak_hours_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,83 +3,11 @@
from unittest.mock import patch

from custom_components.rce_pse.sensors.peak_hours import (
_pdgsz_records_to_hourly_state,
_pdgsz_records_to_ranges,
RCETodayPeakHoursSensor,
RCETomorrowPeakHoursSensor,
)


class TestPdgszRecordsToRanges:

def test_empty_records_returns_all_keys_empty(self):
result = _pdgsz_records_to_ranges([])
assert result["recommended_usage"] == []
assert result["normal_usage"] == []
assert result["recommended_saving"] == []
assert result["required_restriction"] == []

def test_single_hour_single_category(self):
records = [
{"dtime": "2025-05-29 07:00", "business_date": "2025-05-29", "usage_fcst": 0},
]
result = _pdgsz_records_to_ranges(records)
assert result["recommended_usage"] == ["07:00–08:00"]
assert result["normal_usage"] == []
assert result["recommended_saving"] == []
assert result["required_restriction"] == []

def test_consecutive_same_fcst_merged(self):
records = [
{"dtime": "2025-05-29 07:00", "business_date": "2025-05-29", "usage_fcst": 0},
{"dtime": "2025-05-29 08:00", "business_date": "2025-05-29", "usage_fcst": 0},
{"dtime": "2025-05-29 09:00", "business_date": "2025-05-29", "usage_fcst": 0},
]
result = _pdgsz_records_to_ranges(records)
assert result["recommended_usage"] == ["07:00–10:00"]
assert result["normal_usage"] == []

def test_two_categories_two_ranges(self):
records = [
{"dtime": "2025-05-29 06:00", "business_date": "2025-05-29", "usage_fcst": 1},
{"dtime": "2025-05-29 07:00", "business_date": "2025-05-29", "usage_fcst": 1},
{"dtime": "2025-05-29 08:00", "business_date": "2025-05-29", "usage_fcst": 2},
{"dtime": "2025-05-29 09:00", "business_date": "2025-05-29", "usage_fcst": 2},
]
result = _pdgsz_records_to_ranges(records)
assert result["normal_usage"] == ["06:00–08:00"]
assert result["recommended_saving"] == ["08:00–10:00"]

def test_usage_fcst_3_maps_to_required_restriction(self):
records = [
{"dtime": "2025-05-29 18:00", "business_date": "2025-05-29", "usage_fcst": 3},
]
result = _pdgsz_records_to_ranges(records)
assert result["required_restriction"] == ["18:00–19:00"]
assert result["recommended_usage"] == []


class TestPdgszRecordsToHourlyState:

def test_empty_returns_empty_dict(self):
assert _pdgsz_records_to_hourly_state([]) == {}

def test_maps_hour_to_attr_key(self):
records = [
{"dtime": "2025-05-29 07:00", "business_date": "2025-05-29", "usage_fcst": 0},
{"dtime": "2025-05-29 08:00", "business_date": "2025-05-29", "usage_fcst": 2},
]
result = _pdgsz_records_to_hourly_state(records)
assert result[7] == "recommended_usage"
assert result[8] == "recommended_saving"

def test_skips_invalid_dtime(self):
records = [
{"dtime": "no-space", "usage_fcst": 0},
]
assert _pdgsz_records_to_hourly_state(records) == {}


class TestTodayPeakHoursSensor:

def test_initialization(self, mock_coordinator):
Expand Down Expand Up @@ -107,7 +35,7 @@ def test_native_value_empty_data_returns_none(self, mock_coordinator):
with patch.object(sensor, 'get_today_pdgsz_data', return_value=[]):
assert sensor.native_value is None

def test_extra_state_attributes_records_and_hourly_states(self, mock_coordinator):
def test_extra_state_attributes_values_filtered_with_state_and_display_state(self, mock_coordinator):
today = "2025-05-29"
records = [
{"dtime": f"{today} 07:00", "business_date": today, "usage_fcst": 0},
Expand All @@ -119,15 +47,23 @@ def test_extra_state_attributes_records_and_hourly_states(self, mock_coordinator
with patch.object(sensor, 'get_today_pdgsz_data') as mock_get:
mock_get.return_value = records
attrs = sensor.extra_state_attributes
assert "records" in attrs
assert attrs["records"] == records
assert "hourly_states" in attrs
hourly = attrs["hourly_states"]
assert len(hourly) == 24
assert hourly[7] == {"hour": "07:00", "state": "recommended_usage", "state_display": "Recommended usage"}
assert hourly[8] == {"hour": "08:00", "state": "recommended_saving", "state_display": "Recommended saving"}
assert hourly[0]["state"] is None
assert hourly[0]["state_display"] == ""
assert "values" in attrs
values = attrs["values"]
assert len(values) == 2
assert values[0] == {
"dtime": f"{today} 07:00",
"usage_fcst": 0,
"business_date": today,
"state": "recommended_usage",
"display_state": "Recommended usage",
}
assert values[1] == {
"dtime": f"{today} 08:00",
"usage_fcst": 2,
"business_date": today,
"state": "recommended_saving",
"display_state": "Recommended saving",
}

def test_available_when_coordinator_has_data(self, mock_coordinator):
mock_coordinator.data["pdgsz_data"] = []
Expand All @@ -149,14 +85,13 @@ def test_initialization(self, mock_coordinator):
sensor = RCETomorrowPeakHoursSensor(mock_coordinator)
assert sensor._attr_unique_id == "rce_pse_tomorrow_peak_hours"

def test_not_available_before_14(self, mock_coordinator):
def test_available_when_coordinator_has_data_like_tomorrow_price(self, mock_coordinator):
mock_coordinator.data["pdgsz_data"] = []
sensor = RCETomorrowPeakHoursSensor(mock_coordinator)
with patch.object(sensor, 'is_tomorrow_data_available', return_value=False):
assert sensor.available is False
assert sensor.available is True

def test_available_after_14_when_pdgsz_present(self, mock_coordinator):
def test_native_value_none_when_no_tomorrow_data_shows_unknown(self, mock_coordinator):
mock_coordinator.data["pdgsz_data"] = []
sensor = RCETomorrowPeakHoursSensor(mock_coordinator)
with patch.object(sensor, 'is_tomorrow_data_available', return_value=True):
assert sensor.available is True
with patch.object(sensor, 'get_tomorrow_pdgsz_data', return_value=[]):
assert sensor.native_value is None
Loading