init2
This commit is contained in:
parent
a5b16136f0
commit
2182f25b84
6
.idea/misc.xml
Normal file
6
.idea/misc.xml
Normal 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
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -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>
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
36
app.py
@ -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())
|
||||
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user