This commit is contained in:
Bartosz Wieczorek 2025-09-04 12:51:18 +02:00
parent a5b16136f0
commit 2182f25b84
11 changed files with 107 additions and 79 deletions

6
.idea/misc.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 (victron-energy-prices-calculator) (2)" />
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (victron-energy-prices-calculator)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.13 (victron-energy-prices-calculator) (2)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import zoneinfo
from utils.time_helpers import WARSAW_TZ
from utils.time import WARSAW_TZ
@dataclass
class EnergyPriceBase:

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Tuple, Optional
from typing import Dict, List, Tuple, Optional, ClassVar
from zoneinfo import ZoneInfo
import psycopg
from EnergyPrice import EnergyPriceBase
@ -25,29 +25,31 @@ class DynamicPricesProvider(EnergyPriceBase):
max_cached_days: int = 14 # ile różnych dób trzymać w cache
# identyfikatory nadpisujesz w podklasie
SIDE: str = "" # 'buy' albo 'sell'
PROVIDER: str = ""
KIND: str = ""
SIDE: ClassVar[str] = "" # 'buy' | 'sell'
PROVIDER: ClassVar[str] = ""
KIND: ClassVar[str] = ""
# prosty cache: klucz = początek doby (local), wartość = lista interwałów (start, end, price)
_cache: Dict[datetime, List[Interval]] = field(default_factory=dict, init=False, repr=False)
_cache_order: List[datetime] = field(default_factory=list, init=False, repr=False)
# ---------- public API ----------
def provider(self) -> str:
if not self.PROVIDER:
raise NotImplementedError("Subclass must define PROVIDER")
return self.PROVIDER
side: str = field(init=False, repr=True)
provider: str = field(init=False, repr=True)
kind: str = field(init=False, repr=True)
def kind(self) -> str:
if not self.KIND:
raise NotImplementedError("Subclass must define KIND")
return self.KIND
def __post_init__(self):
self.side = type(self).SIDE.lower().strip()
self.provider = type(self).PROVIDER.strip()
self.kind = type(self).KIND.strip()
def side(self) -> str:
if not self.SIDE:
raise NotImplementedError("Subclass must define SIDE")
return self.SIDE
if self.side not in ("buy", "sell"):
raise ValueError("SIDE must be 'buy' or 'sell'")
if not self.provider:
raise ValueError("PROVIDER must be set in subclass")
if not self.kind:
raise ValueError("KIND must be set in subclass")
def rate(self, ts: datetime) -> float:
"""Zwraca cenę netto PLN/kWh dla chwili ts. Ładuje cały dzień do cache przy pierwszym wywołaniu."""
@ -57,7 +59,7 @@ class DynamicPricesProvider(EnergyPriceBase):
for start, end, price in self._cache.get(day_key, []):
if start <= dt < end:
return price
raise KeyError(f"No price for {dt.isoformat()} (provider={self.provider()}, kind={self.kind()})")
raise KeyError(f"No price for {dt.isoformat()} (provider={self.provider}, kind={self.kind})")
def preload_day(self, day: datetime | None = None):
"""Opcjonalnie: prefetch doby (początek dnia lokalnie)."""
@ -107,7 +109,7 @@ class DynamicPricesProvider(EnergyPriceBase):
"""
with self._ensure_conn().cursor() as cur:
cur.execute(sql, (self.provider(), self.kind(), self.side(), day_start_local, day_end_local))
cur.execute(sql, (self.provider, self.kind, self.side, day_start_local, day_end_local))
rows = cur.fetchall()
# rows: List[Tuple[datetime, datetime, Decimal]]
return [(r[0], r[1], float(r[2])) for r in rows]

View File

@ -0,0 +1,11 @@
from EnergyPriceProvider.DynamicPricesProvider import DynamicPricesProvider
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class PstrykBuyProvider(DynamicPricesProvider):
SIDE: ClassVar[str] = "buy"
PROVIDER: ClassVar[str] = "PSTRYK"
KIND: ClassVar[str] = "market_price" # albo 'day_ahead', 'fixing_I', etc.

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import time, datetime
from EnergyPrice import EnergyPriceBase
from utils.time_helpers import in_range_local
from utils.time import in_range_local
from utils.calendar_pl import gov_energy_prices_shield
class TauronG12Provider(EnergyPriceBase):

View File

@ -1,56 +1,31 @@
from __future__ import annotations
import os
import json
import math
import logging
import argparse
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, time, date
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Tuple, Optional, Dict, Any
import psycopg
from psycopg.rows import dict_row
from utils.time import WARSAW_TZ
from EnergyPrice import EnergyPriceBase
from DistributionCost import DistributionCostBase
# --- Kalkulator brutto + price_components -----------------------------------
@dataclass
class PriceCalculator:
energy_provider: EnergyPriceBase
distribution_provider: DistributionCostBase
energy_vat: Decimal = Decimal("0.23")
distribution_vat: Decimal = Decimal("0.23")
apply_distribution_on_sell: bool = False
rounding: str = "0.01" # 2 miejsca po przecinku
def _round2(self, v: Decimal) -> Decimal:
return v.quantize(Decimal(self.rounding), rounding=ROUND_HALF_UP)
@staticmethod
def _round2(v: float) -> float:
return round(v, 2)
def gross_price(self, ts: datetime, side: str) -> Decimal:
def gross_price(self, ts: datetime, side: str) -> float:
ts_loc = self.energy_provider.to_local_dt(ts)
energy_net = Decimal(str(self.energy_provider.rate(ts_loc)))
# Dystrybucja tylko dla BUY (domyślnie)
dist_net = Decimal("0")
energy_net = self.energy_provider.rate(ts_loc)
dist_net = 0.0
if side.lower() == "buy" or self.apply_distribution_on_sell:
dist_net = Decimal(str(self.distribution_provider.rate(ts_loc)))
dist_net = self.distribution_provider.rate(ts_loc)
energy_gross = energy_net * (Decimal("1") + self.energy_vat)
dist_gross = dist_net * (Decimal("1") + self.distribution_vat)
return self._round2(energy_gross + dist_gross)
def components_summary(self) -> Dict[str, Any]:
return {
"formula": "energy_net*(1+energy_vat) + distribution_net*(1+distribution_vat)",
"vat": {
"energy_vat": float(self.energy_vat),
"distribution_vat": float(self.distribution_vat),
},
"distribution_applied_on": "buy" if not self.apply_distribution_on_sell else "buy+sell",
"providers": {
"energy": self.energy_provider.__class__.__name__,
"distribution": self.distribution_provider.__class__.__name__,
},
}
return self._round2((energy_net + dist_net + 0.08) * 1.23)

View File

@ -4,10 +4,12 @@ from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone, date
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Dict, Any, Optional
from zoneinfo import ZoneInfo
import json
import psycopg
from psycopg.rows import dict_row
from PriceCalculator import PriceCalculator
from utils.time import WARSAW_TZ
@ -17,18 +19,15 @@ from utils.time import WARSAW_TZ
class VRMPriceRow:
for_date: date
provider: str
kind: str
side: str # 'buy' | 'sell'
schedule: List[Dict[str, Any]] # [{"from":"HH:MM","to":"HH:MM","price":0.12}, ...]
price_components: Dict[str, Any] = field(default_factory=dict)
source_meta: Dict[str, Any] = field(default_factory=dict)
#sent_to_vrm: bool = False # zawsze resetujemy do False przy update
def to_sql_params(self) -> tuple:
return (
self.for_date,
self.provider,
self.kind,
self.side,
json.dumps(self.schedule, ensure_ascii=False),
json.dumps(self.price_components, ensure_ascii=False),
@ -39,9 +38,8 @@ class VRMPriceRow:
# --- Writer: buduje dobowe schedule (bez scalania) i ZAWSZE upsertuje --------
@dataclass
class VictronPriceDailyWriter:
dsn: str
conn: psycopg.Connection
provider: str
kind: str
side: str # 'buy' | 'sell'
calculator: PriceCalculator
period: timedelta = timedelta(hours=1) # brak scalania: stały krok, domyślnie 1h
@ -87,6 +85,11 @@ class VictronPriceDailyWriter:
return segments
def _ensure_conn(self) -> psycopg.Connection:
if self.conn is not None:
return self.conn
raise RuntimeError("Provide conn= to DynamicPricesProvider")
def upsert(self, row: VRMPriceRow) -> None:
"""
ZAWSZE update: na konflikcie nadpisuje schedule, price_components, source_meta
@ -94,28 +97,25 @@ class VictronPriceDailyWriter:
"""
sql = f"""
INSERT INTO {self.table}
(for_date, provider, kind, side, schedule, price_components, source_meta, sent_to_vrm)
VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s)
ON CONFLICT (for_date, provider, kind, side)
(for_date, provider, side, schedule, price_components, source_meta)
VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb)
ON CONFLICT (for_date, provider, side)
DO UPDATE SET
schedule = EXCLUDED.schedule,
price_components = EXCLUDED.price_components,
source_meta = {self.table}.source_meta || EXCLUDED.source_meta,
sent_to_vrm = false, -- ważne: reset flagi po zmianach
inserted_at = now();
"""
with psycopg.connect(self.dsn) as con, con.cursor() as cur:
with self._ensure_conn() as con, con.cursor() as cur:
cur.execute(sql, row.to_sql_params())
con.commit()
def process_day(self, d: date, extra_meta: Optional[Dict[str, Any]] = None) -> VRMPriceRow:
schedule = self.build_schedule_for_day(d)
components = self.calculator.components_summary()
source_meta = {
"built_at": datetime.now(timezone.utc).isoformat(),
"period_minutes": int(self.period.total_seconds() // 60),
"provider": self.provider,
"kind": self.kind,
"side": self.side,
}
if extra_meta:
@ -124,10 +124,8 @@ class VictronPriceDailyWriter:
row = VRMPriceRow(
for_date=d,
provider=self.provider,
kind=self.kind,
side=self.side,
schedule=schedule,
price_components=components,
source_meta=source_meta
)
self.upsert(row)

36
app.py
View File

@ -26,7 +26,39 @@ from importlib.metadata import distribution
from typing import List, Tuple, Optional, Dict, Any
import DistributionCostFactory
import EnergyPriceFactory
import PriceCalculator
import VictronPriceWriter
distribution = DistributionCostFactory.create("TauronG13")
import psycopg
print(distribution.rate(datetime.now()))
from utils.time import WARSAW_TZ
DB_HOST = os.getenv("PGHOST", "192.168.30.10")
DB_PORT = int(os.getenv("PGPORT", "5432"))
DB_NAME = os.getenv("PGDATABASE", "postgres")
DB_USER = os.getenv("PGUSER", "postgres")
DB_PASS = os.getenv("PGPASSWORD", "BYrZwbl0r7xplrT")
def setup_db():
# psycopg 3
conn = psycopg.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS
)
return conn
distribution_costs = DistributionCostFactory.create("TauronG13")
energy_price = EnergyPriceFactory.create("PstrykBuy", conn=setup_db())
calc = PriceCalculator.PriceCalculator(energy_provider=energy_price, distribution_provider=distribution_costs)
vic = VictronPriceWriter.VictronPriceDailyWriter(
conn=setup_db(),
side="buy",
tz=WARSAW_TZ,
calculator=calc,
provider="Zakup:Pstryk;Dystrybucja:TauronG13"
)
vic.process_day((datetime.now(WARSAW_TZ)+timedelta(days=0)).date())

View File

@ -1,6 +1,4 @@
psycopg[binary]>=3.1,<3.3
pandas~=2.3.2
requests>=2.32,<3.0
apscheduler>=3.10,<4.0
psycopg[binary,pool]>=3.1,<3.3
apscheduler>=3.10,<4.0
systemd-python>=235; sys_platform != "win32"