diff --git a/custom_components/rce_pse/config_flow.py b/custom_components/rce_pse/config_flow.py index 753ecf5..7be3ffb 100644 --- a/custom_components/rce_pse/config_flow.py +++ b/custom_components/rce_pse/config_flow.py @@ -21,6 +21,7 @@ CONF_SECOND_EXPENSIVE_WINDOW_DURATION_HOURS, CONF_USE_HOURLY_PRICES, CONF_LOW_PRICE_THRESHOLD, + CONF_USE_GROSS_PRICES, DEFAULT_TIME_WINDOW_START, DEFAULT_TIME_WINDOW_END, DEFAULT_WINDOW_DURATION_HOURS, @@ -28,6 +29,7 @@ DEFAULT_SECOND_EXPENSIVE_TIME_WINDOW_END, DEFAULT_SECOND_EXPENSIVE_WINDOW_DURATION_HOURS, DEFAULT_USE_HOURLY_PRICES, + DEFAULT_USE_GROSS_PRICES, DEFAULT_LOW_PRICE_THRESHOLD, ) @@ -109,6 +111,9 @@ vol.Optional(CONF_USE_HOURLY_PRICES, default=DEFAULT_USE_HOURLY_PRICES): selector.BooleanSelector( selector.BooleanSelectorConfig() ), + vol.Optional(CONF_USE_GROSS_PRICES, default=DEFAULT_USE_GROSS_PRICES): selector.BooleanSelector( + selector.BooleanSelectorConfig() + ), vol.Optional(CONF_LOW_PRICE_THRESHOLD, default=DEFAULT_LOW_PRICE_THRESHOLD): selector.NumberSelector( selector.NumberSelectorConfig( min=-2000, @@ -302,6 +307,12 @@ async def async_step_init( ): selector.BooleanSelector( selector.BooleanSelectorConfig() ), + vol.Optional( + CONF_USE_GROSS_PRICES, + default=current_data.get(CONF_USE_GROSS_PRICES, DEFAULT_USE_GROSS_PRICES) + ): selector.BooleanSelector( + selector.BooleanSelectorConfig() + ), vol.Optional( CONF_LOW_PRICE_THRESHOLD, default=current_data.get(CONF_LOW_PRICE_THRESHOLD, DEFAULT_LOW_PRICE_THRESHOLD) diff --git a/custom_components/rce_pse/const.py b/custom_components/rce_pse/const.py index adf7814..c18cd20 100644 --- a/custom_components/rce_pse/const.py +++ b/custom_components/rce_pse/const.py @@ -35,11 +35,13 @@ CONF_WINDOW_DURATION_HOURS: Final[str] = "window_duration_hours" CONF_USE_HOURLY_PRICES: Final[str] = "use_hourly_prices" CONF_LOW_PRICE_THRESHOLD: Final[str] = "low_price_threshold" +CONF_USE_GROSS_PRICES: Final[str] = "use_gross_prices" DEFAULT_TIME_WINDOW_START: Final[int] = 0 DEFAULT_TIME_WINDOW_END: Final[int] = 24 DEFAULT_WINDOW_DURATION_HOURS: Final[int] = 2 DEFAULT_USE_HOURLY_PRICES: Final[bool] = False +DEFAULT_USE_GROSS_PRICES: Final[bool] = False DEFAULT_SECOND_EXPENSIVE_TIME_WINDOW_START: Final[int] = 6 DEFAULT_SECOND_EXPENSIVE_TIME_WINDOW_END: Final[int] = 10 diff --git a/custom_components/rce_pse/coordinator.py b/custom_components/rce_pse/coordinator.py index 5aafc60..6759f86 100644 --- a/custom_components/rce_pse/coordinator.py +++ b/custom_components/rce_pse/coordinator.py @@ -17,10 +17,13 @@ API_SELECT, API_UPDATE_INTERVAL, CONF_USE_HOURLY_PRICES, + CONF_USE_GROSS_PRICES, DEFAULT_USE_HOURLY_PRICES, + DEFAULT_USE_GROSS_PRICES, DOMAIN, PSE_API_URL, PSE_PDGSZ_API_URL, + TAX_RATE, ) _LOGGER = logging.getLogger(__name__) @@ -139,6 +142,11 @@ async def _fetch_data(self) -> dict[str, Any]: else: _LOGGER.debug("Hourly prices option disabled, using original 15-minute data") processed_data = self._add_neg_to_zero_key(raw_data) + + use_gross_prices = self._get_config_value(CONF_USE_GROSS_PRICES, DEFAULT_USE_GROSS_PRICES) + if use_gross_prices: + _LOGGER.debug("Gross prices option enabled, applying TAX_RATE %.2f to all price fields", TAX_RATE) + processed_data = self._apply_tax_to_data(processed_data) try: pdgsz_data = await self._fetch_pdgsz(session, today) @@ -265,6 +273,34 @@ def _add_neg_to_zero_key(self, raw_data: list[dict]) -> list[dict]: return processed_data + def _apply_tax_to_data(self, data: list[dict]) -> list[dict]: + if not data: + return data + + processed_data: list[dict] = [] + + for record in data: + try: + new_record = record.copy() + base_price = float(new_record["rce_pln"]) + gross_price = base_price * (1 + TAX_RATE) + new_record["rce_pln"] = f"{gross_price:.2f}" + + neg_to_zero_value = new_record.get("rce_pln_neg_to_zero") + if neg_to_zero_value is not None: + neg_to_zero_price = float(neg_to_zero_value) + gross_neg_to_zero = neg_to_zero_price * (1 + TAX_RATE) + new_record["rce_pln_neg_to_zero"] = f"{gross_neg_to_zero:.2f}" + + processed_data.append(new_record) + except (ValueError, KeyError) as e: + _LOGGER.warning("Failed to apply tax to record: %s, error: %s", record, e) + processed_data.append(record) + + _LOGGER.debug("Applied TAX_RATE to %d records", len(processed_data)) + + return processed_data + async def async_close(self) -> None: _LOGGER.debug("Closing PSE API session") if self.session: diff --git a/custom_components/rce_pse/sensors/today_main.py b/custom_components/rce_pse/sensors/today_main.py index 6c50cfd..972fe5d 100644 --- a/custom_components/rce_pse/sensors/today_main.py +++ b/custom_components/rce_pse/sensors/today_main.py @@ -4,7 +4,7 @@ from typing import Any, TYPE_CHECKING from .base import RCEBaseSensor -from ..const import TAX_RATE +from ..const import CONF_USE_GROSS_PRICES, DEFAULT_USE_GROSS_PRICES, TAX_RATE if TYPE_CHECKING: from ..coordinator import RCEPSEDataUpdateCoordinator @@ -64,5 +64,13 @@ def native_value(self) -> float | None: price = float(current_data["rce_pln_neg_to_zero"]) if price <= 0: return 0 + + use_gross_prices = self.coordinator._get_config_value( + CONF_USE_GROSS_PRICES, DEFAULT_USE_GROSS_PRICES + ) + + if use_gross_prices: + return round(price, 2) + return round(price * (1 + TAX_RATE), 2) return None \ No newline at end of file diff --git a/custom_components/rce_pse/translations/en.json b/custom_components/rce_pse/translations/en.json index fdfd7a2..1c5f713 100644 --- a/custom_components/rce_pse/translations/en.json +++ b/custom_components/rce_pse/translations/en.json @@ -15,7 +15,8 @@ "second_expensive_time_window_end": "Second expensive - time window end (hour)", "second_expensive_window_duration_hours": "Second expensive - search window duration (hours)", "use_hourly_prices": "Use hourly prices", - "low_price_threshold": "Low sell price threshold (PLN/MWh)" + "low_price_threshold": "Low sell price threshold (PLN/MWh)", + "use_gross_prices": "Use gross prices (with VAT)" }, "data_description": { "cheapest_time_window_start": "Starting hour for searching cheapest windows (0-23)", @@ -28,7 +29,8 @@ "second_expensive_time_window_end": "Ending hour for searching second expensive windows (1-24)", "second_expensive_window_duration_hours": "Duration of continuous second expensive time window (1-24 hours)", "use_hourly_prices": "Useful in net-billing settlements due to prosumer metering with hourly accuracy despite 15-minute prices. In this mode, the average price for a given hour is calculated from published quarter-hour prices. Settlement according to Art. 4b sec. 11 of the Ustawa o OZE", - "low_price_threshold": "Used by low price window sensors." + "low_price_threshold": "Used by low price window sensors.", + "use_gross_prices": "When enabled, all prices will be converted to gross using the VAT (currently 23%), so you do not need to apply VAT manually in automations." } } }, @@ -55,7 +57,8 @@ "second_expensive_time_window_end": "Second expensive - time window end (hour)", "second_expensive_window_duration_hours": "Second expensive - search window duration (hours)", "use_hourly_prices": "Use hourly prices", - "low_price_threshold": "Low sell price threshold (PLN/MWh)" + "low_price_threshold": "Low sell price threshold (PLN/MWh)", + "use_gross_prices": "Use gross prices (with VAT)" }, "data_description": { "cheapest_time_window_start": "Starting hour for searching cheapest windows (0-23)", @@ -68,7 +71,8 @@ "second_expensive_time_window_end": "Ending hour for searching second expensive windows (1-24)", "second_expensive_window_duration_hours": "Duration of continuous second expensive time window (1-24 hours)", "use_hourly_prices": "Useful in net-billing settlements due to prosumer metering with hourly accuracy despite 15-minute prices. In this mode, the average price for a given hour is calculated from published quarter-hour prices. Settlement according to Art. 4b sec. 11 of the Ustawa o OZE", - "low_price_threshold": "Used by low price window sensors." + "low_price_threshold": "Used by low price window sensors.", + "use_gross_prices": "When enabled, all prices will be converted to gross using the VAT (currently 23%), so you do not need to apply VAT manually in automations." } } }, diff --git a/custom_components/rce_pse/translations/pl.json b/custom_components/rce_pse/translations/pl.json index 73ba29f..9366674 100644 --- a/custom_components/rce_pse/translations/pl.json +++ b/custom_components/rce_pse/translations/pl.json @@ -15,7 +15,8 @@ "second_expensive_time_window_end": "Drugie najdroższe - koniec przeszukiwania (godzina)", "second_expensive_window_duration_hours": "Drugie najdroższe - długość poszukiwanego okna (godziny)", "use_hourly_prices": "Korzystaj ze średnich cen godzinowych", - "low_price_threshold": "Próg niskiej ceny sprzedaży (PLN/MWh)" + "low_price_threshold": "Próg niskiej ceny sprzedaży (PLN/MWh)", + "use_gross_prices": "Korzystaj z cen brutto (z VAT)" }, "data_description": { "cheapest_time_window_start": "Godzina początkowa dla poszukiwania najtańszych okien (0-23)", @@ -28,7 +29,8 @@ "second_expensive_time_window_end": "Godzina końcowa dla poszukiwania drugiego najdroższego okna (1-24)", "second_expensive_window_duration_hours": "Długość ciągłego drugiego najdroższego okna czasowego (1-24 godzin)", "use_hourly_prices": "Przydatne w rozliczeniach net-billing z uwagi na opomiarowanie prosumentów z dokładnością do godziny mimo cen 15 minutowych. W tym trybie obliczana jest średnia cena dla danej godziny z publikowanych cen dla kwadransów. Rozliczenie zgodnie z Art. 4b ust. 11 Ustawy o OZE", - "low_price_threshold": "Używany przez sensory okien niskiej ceny." + "low_price_threshold": "Używany przez sensory okien niskiej ceny.", + "use_gross_prices": "Po włączeniu wszystkie ceny będą przeliczane na brutto z wykorzystaniem stawki VAT (obecnie 23%) bez konieczności ręcznych przeliczeń w automatyzacjach." } } }, @@ -55,7 +57,8 @@ "second_expensive_time_window_end": "Drugie najdroższe - koniec przeszukiwania (godzina)", "second_expensive_window_duration_hours": "Drugie najdroższe - długość poszukiwanego okna (godziny)", "use_hourly_prices": "Korzystaj ze średnich cen godzinowych", - "low_price_threshold": "Próg niskiej ceny sprzedaży (PLN/MWh)" + "low_price_threshold": "Próg niskiej ceny sprzedaży (PLN/MWh)", + "use_gross_prices": "Korzystaj z cen brutto (z VAT)" }, "data_description": { "cheapest_time_window_start": "Godzina początkowa dla poszukiwania najtańszych okien (0-23)", @@ -68,7 +71,8 @@ "second_expensive_time_window_end": "Godzina końcowa dla poszukiwania drugiego najdroższego okna (1-24)", "second_expensive_window_duration_hours": "Długość ciągłego drugiego najdroższego okna czasowego (1-24 godzin)", "use_hourly_prices": "Przydatne w rozliczeniach net-billing z uwagi na opomiarowanie prosumentów z dokładnością do godziny mimo cen 15 minutowych. W tym trybie obliczana jest średnia cena dla danej godziny z publikowanych cen dla kwadransów. Rozliczenie zgodnie z Art. 4b ust. 11 Ustawy o OZE", - "low_price_threshold": "Używane przez sensory okien niskiej ceny." + "low_price_threshold": "Używane przez sensory okien niskiej ceny.", + "use_gross_prices": "Po włączeniu wszystkie ceny będą przeliczane na brutto z wykorzystaniem stawki VAT (obecnie 23%) bez konieczności ręcznych przeliczeń w automatyzacjach." } } }, diff --git a/docs/KONFIGURACJA.md b/docs/KONFIGURACJA.md index 069223c..887e751 100644 --- a/docs/KONFIGURACJA.md +++ b/docs/KONFIGURACJA.md @@ -58,6 +58,20 @@ Opcja przydatna przy rozliczeniach net-billing (prosumenci, liczniki z rozliczen - Włączone: liczone są średnie godzinowe; ta sama cena jest przypisana do wszystkich czterech przedziałów 15-min w danej godzinie. - Przykład: godzina 0 z cenami [300, 320, 340, 360] PLN/MWh → we wszystkich czterech przedziałach wyświetlana jest 330 PLN (średnia). +### Ceny netto/brutto + +Integracja pozwala wybrać, czy wszystkie prezentowane ceny mają być traktowane jako netto (tak jak w API PSE), czy brutto (z doliczonym VAT według stawki `TAX_RATE`, obecnie 23%). + +- **Użyj cen brutto (z VAT)** (tak/nie): globalne przełączenie netto/brutto + - *Domyślnie:* nie – ceny pozostają w formie netto z API PSE. + - *Po włączeniu:* wszystkie wartości cenowe w danych koordynatora (`rce_pln` oraz `rce_pln_neg_to_zero`) są przemnażane przez `(1 + TAX_RATE)` i dopiero takie dane są przekazywane do sensorów. + - *Efekt:* wszystkie sensory oparte na cenach (dzisiejsze, jutrzejsze, statystyki, okna itp.) automatycznie działają na cenach brutto, bez potrzeby dodatkowych przeliczeń w automatyzacjach. + +Sensor ceny sprzedaży prosumenta (`rce_pse_today_prosumer_selling_price`) zawsze bazuje na wartości `rce_pln_neg_to_zero`: + +- w trybie **netto** nadal dolicza VAT lokalnie (mnożenie przez `(1 + TAX_RATE)`), +- w trybie **brutto** nie dolicza VAT ponownie – zwraca już przeliczoną wartość brutto, aby uniknąć podwójnego naliczania podatku. + ### Próg niskiej ceny sprzedaży Próg (PLN/MWh) używany do wyznaczania "okna niskiej ceny" w dedykowanych sensorach. Pierwszy ciągły okres w danym dniu z ceną ≤ progu pokazują sensory "Początek/Koniec okna poniżej progu dzisiaj/jutro"; binary sensor "Cena poniżej progu" ma stan `on`, gdy aktualny czas jest w tym okresie (dzisiaj). Gdy w danym dniu nie ma takiego okresu, sensory mają stan "unknown" (integracja działa normalnie). diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 6674f51..e7664f9 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,16 +2,50 @@ import asyncio from datetime import timedelta -from unittest.mock import MagicMock, patch, AsyncMock, Mock +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util from custom_components.rce_pse.coordinator import RCEPSEDataUpdateCoordinator -from custom_components.rce_pse.const import CONF_USE_HOURLY_PRICES +from custom_components.rce_pse.const import CONF_USE_HOURLY_PRICES, TAX_RATE +def _build_record(rce_pln: float, rce_pln_neg_to_zero: float | None = None) -> dict[str, Any]: + record: dict[str, Any] = { + "rce_pln": f"{rce_pln:.2f}", + } + if rce_pln_neg_to_zero is not None: + record["rce_pln_neg_to_zero"] = f"{rce_pln_neg_to_zero:.2f}" + return record + + +def test_apply_tax_to_data_with_and_without_neg_to_zero(mock_hass) -> None: + coordinator = RCEPSEDataUpdateCoordinator(mock_hass) + + data = [ + _build_record(300.0, 280.0), + _build_record(0.0, 0.0), + _build_record(350.0), + ] + + processed = coordinator._apply_tax_to_data(data) + + assert len(processed) == 3 + + first = processed[0] + assert first["rce_pln"] == f"{300.0 * (1 + TAX_RATE):.2f}" + assert first["rce_pln_neg_to_zero"] == f"{280.0 * (1 + TAX_RATE):.2f}" + + second = processed[1] + assert second["rce_pln"] == f"{0.0 * (1 + TAX_RATE):.2f}" + assert second["rce_pln_neg_to_zero"] == f"{0.0 * (1 + TAX_RATE):.2f}" + + third = processed[2] + assert third["rce_pln"] == f"{350.0 * (1 + TAX_RATE):.2f}" + class TestRCEPSEDataUpdateCoordinator: @pytest.mark.asyncio diff --git a/tests/test_sensors.py b/tests/test_sensors.py index faacd56..87fe302 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -57,7 +57,8 @@ def test_today_prosumer_selling_price_sensor_state_with_data(self, mock_coordina with patch.object(sensor, "get_current_price_data") as mock_current_price: mock_current_price.return_value = {"rce_pln_neg_to_zero": "350.50"} - + mock_coordinator._get_config_value.return_value = False + state = sensor.native_value assert state == 431.12 @@ -91,6 +92,16 @@ def test_today_prosumer_selling_price_sensor_negative_to_zero_conversion(self, m state = sensor.native_value assert state == 0 + def test_today_prosumer_selling_price_sensor_state_with_data_gross_mode(self, mock_coordinator): + sensor = RCETodayProsumerSellingPriceSensor(mock_coordinator) + + with patch.object(sensor, "get_current_price_data") as mock_current_price: + mock_current_price.return_value = {"rce_pln_neg_to_zero": "350.50"} + mock_coordinator._get_config_value.return_value = True + + state = sensor.native_value + assert state == 350.5 + class TestTodayStatsSensors: