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
11 changes: 11 additions & 0 deletions custom_components/rce_pse/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
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,
DEFAULT_SECOND_EXPENSIVE_TIME_WINDOW_START,
DEFAULT_SECOND_EXPENSIVE_TIME_WINDOW_END,
DEFAULT_SECOND_EXPENSIVE_WINDOW_DURATION_HOURS,
DEFAULT_USE_HOURLY_PRICES,
DEFAULT_USE_GROSS_PRICES,
DEFAULT_LOW_PRICE_THRESHOLD,
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/rce_pse/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions custom_components/rce_pse/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion custom_components/rce_pse/sensors/today_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 8 additions & 4 deletions custom_components/rce_pse/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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."
}
}
},
Expand All @@ -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)",
Expand All @@ -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."
}
}
},
Expand Down
12 changes: 8 additions & 4 deletions custom_components/rce_pse/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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."
}
}
},
Expand All @@ -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)",
Expand All @@ -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."
}
}
},
Expand Down
14 changes: 14 additions & 0 deletions docs/KONFIGURACJA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
38 changes: 36 additions & 2 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion tests/test_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
Loading