diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml index 105ce2d..dd4c951 100644 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -1,5 +1,6 @@ + diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..4860390 --- /dev/null +++ b/src/app.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +import os +import sys +from os.path import dirname +sys.path.append(dirname(__file__)) + +import victron_energy_price_calculator.DistributionCostFactory as DistributionCostFactory +import victron_energy_price_calculator.EnergyPriceFactory as EnergyPriceFactory +import victron_energy_price_calculator.PriceCalculator as PriceCalculator +import victron_energy_price_calculator.VictronShedule as VictronShedule + +import psycopg + +from victron_energy_price_calculator.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 = VictronShedule.VictronPriceDailyWriter( + side="buy", + tz=WARSAW_TZ, + calculator=calc +) + +prices = vic.build_buy_price_schedule_ending_tomorrow() +print(prices) \ No newline at end of file diff --git a/src/victron_energy_price_calculator/PriceCalculator.py b/src/victron_energy_price_calculator/PriceCalculator.py index 74258e5..6ae0f5c 100644 --- a/src/victron_energy_price_calculator/PriceCalculator.py +++ b/src/victron_energy_price_calculator/PriceCalculator.py @@ -18,6 +18,9 @@ class PriceCalculator: def _round2(v: float) -> float: return round(v, 2) + def has_rate(self, date: datetime) -> bool: + return self.energy_provider.has_rate(date) + def gross_price(self, ts: datetime, side: str) -> float: ts_loc = self.energy_provider.to_local_dt(ts) diff --git a/src/victron_energy_price_calculator/VictronPriceWriter.py b/src/victron_energy_price_calculator/VictronPriceWriter.py deleted file mode 100644 index 2484de8..0000000 --- a/src/victron_energy_price_calculator/VictronPriceWriter.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone, date -from typing import List, Dict, Any, Optional -from zoneinfo import ZoneInfo - -import json -import psycopg - -from .PriceCalculator import PriceCalculator - -from .utils.time import WARSAW_TZ - - -# --- Dataclass do insertu do victron.vrm_price_daily ------------------------- -@dataclass -class VRMPriceRow: - for_date: date - provider: 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) - - def to_sql_params(self) -> tuple: - return ( - self.for_date, - self.provider, - self.side, - json.dumps(self.schedule, ensure_ascii=False), - json.dumps(self.price_components, ensure_ascii=False), - json.dumps(self.source_meta, ensure_ascii=False) - ) - - -# --- Writer: buduje dobowe schedule (bez scalania) i ZAWSZE upsertuje -------- -@dataclass -class VictronPriceDailyWriter: - conn: psycopg.Connection - provider: str - side: str # 'buy' | 'sell' - calculator: PriceCalculator - period: timedelta = timedelta(hours=1) # brak scalania: stały krok, domyślnie 1h - table: str = "victron.vrm_price_daily" - tz: ZoneInfo = WARSAW_TZ - - def _bounds_local(self, d: date) -> tuple[datetime, datetime]: - start = datetime(d.year, d.month, d.day, 0, 0, tzinfo=self.tz) - return start, start + timedelta(days=1) - - def _fmt_hhmm(self, dt: datetime) -> str: - return dt.strftime("%H:%M") - - def build_schedule_for_day(self, d: date) -> List[Dict[str, Any]]: - """ - Zwraca listę odcinków po stałym kroku (23/24/25 „godzin” zależnie od DST), - BEZ scalania. Dla ostatniego segmentu 'to' = '00:00'. - Rzuca KeyError jeśli brakuje stawki dla któregokolwiek segmentu. - """ - start_local, end_local = self._bounds_local(d) - segments: List[Dict[str, Any]] = [] - - t = start_local - while t < end_local: - t_next = t + self.period - if t_next > end_local: - t_next = end_local - - # pobranie brutto dla początku segmentu - price_gross = self.calculator.gross_price(t, side=self.side) - - seg = { - "from": self._fmt_hhmm(t), - "to": "00:00" if t_next == end_local else self._fmt_hhmm(t_next), - "price": float(price_gross), - } - segments.append(seg) - t = t_next - - # walidacja minimalna: pełna doba - if not segments or segments[0]["from"] != "00:00" or segments[-1]["to"] != "00:00": - raise KeyError(f"Niekompletna doba dla {d} (provider={self.provider}/{self.kind}/{self.side})") - - 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 - i RESETUJE sent_to_vrm=false (żeby Node-RED wysłał świeże dane). - """ - sql = f""" - INSERT INTO {self.table} - (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, - inserted_at = now(); - """ - 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) - source_meta = { - "built_at": datetime.now(timezone.utc).isoformat(), - "period_minutes": int(self.period.total_seconds() // 60), - "provider": self.provider, - "side": self.side, - } - if extra_meta: - source_meta.update(extra_meta) - - row = VRMPriceRow( - for_date=d, - provider=self.provider, - side=self.side, - schedule=schedule, - source_meta=source_meta - ) - self.upsert(row) - return row - - def process_range(self, start: date, end_inclusive: date, extra_meta: Optional[Dict[str, Any]] = None) -> int: - """ - Przetworzy dni od 'start' do 'end_inclusive' włącznie. - Zwraca liczbę udanych upsertów. - """ - count = 0 - cur = start - while cur <= end_inclusive: - self.process_day(cur, extra_meta=extra_meta) - count += 1 - cur += timedelta(days=1) - return count diff --git a/src/victron_energy_price_calculator/VictronShedule.py b/src/victron_energy_price_calculator/VictronShedule.py new file mode 100644 index 0000000..225d929 --- /dev/null +++ b/src/victron_energy_price_calculator/VictronShedule.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta, date +from typing import List, Dict, Any +from zoneinfo import ZoneInfo +from dataclasses import dataclass, field + +from .PriceCalculator import PriceCalculator +from .utils.time import WARSAW_TZ, local_midnight + + +@dataclass +class VictronPriceDailyWriter: + side: str # 'buy' | 'sell' + calculator: PriceCalculator + period: timedelta = timedelta(hours=1) # brak scalania: stały krok, domyślnie 1h + table: str = "victron.vrm_price_daily" + tz: ZoneInfo = WARSAW_TZ + + def _fmt_hhmm(self, dt: datetime) -> str: + return dt.strftime("%H:%M") + + def build_schedule_for_day(self, d: date) -> List[Dict[str, Any]]: + """ + Buduje 24 jednogodzinne odcinki [from,to) dla lokalnej doby 00:00–24:00, + ignorując niuanse DST (uogólnienie do 24h). + Zwraca listę słowników: {"from": "HH:MM", "to": "HH:MM", "price": float}. + """ + segments: List[Dict[str, Any]] = [] + tz = self.tz if hasattr(self, "tz") else WARSAW_TZ + + for hour in range(24): + t = datetime(d.year, d.month, d.day, hour, 0, tzinfo=tz) + price_gross = self.calculator.gross_price(t, side="buy") + + segments.append({ + "from": f"{hour:02d}:00", + "to": "00:00" if hour == 23 else f"{(hour + 1):02d}:00", + "price": round(float(price_gross), 2), # zł/kWh, 2 miejsca + }) + + # walidacja: 24 odcinki, start 00:00 i koniec 00:00 następnego dnia + if len(segments) != 24 or segments[0]["from"] != "00:00" or segments[-1]["to"] != "00:00": + raise KeyError(f"Niekompletna doba dla {d}") + return segments + + def build_buy_day_block(self, d: date) -> Dict[str, Any]: + """ + Zwraca blok: {"days": [weekday], "schedule": [...]}, gdzie weekday 0=poniedziałek. + """ + return { + "days": [d.weekday()], # 0=Mon ... 6=Sun + "schedule": self.build_schedule_for_day(d), + } + + def build_buy_price_schedule_ending_tomorrow(self) -> dict[str, list[dict[str, Any]]] | None: + """ + Generuje: + { + "buyPriceSchedule": [ + {"days":[...], "schedule":[...]}, # 7 bloków — każdy dzień osobno + ... + ] + } + Zakres: 7 kolejnych dni, kończąc na dniu jutrzejszym (Europe/Warsaw). + """ + + today_waw = datetime.now(self.tz if hasattr(self, "tz") else WARSAW_TZ).date() + end_date = today_waw + timedelta(days=0) # jutro + start_date = end_date - timedelta(days=6) # 7 dni łącznie + + if not self.calculator.has_rate(local_midnight(end_date, WARSAW_TZ)): + return None + + blocks: List[Dict[str, Any]] = [] + cur = start_date + while cur <= end_date: + blocks.append(self.build_buy_day_block(cur)) + cur += timedelta(days=1) + + return {"buyPriceSchedule": blocks} diff --git a/src/victron_energy_price_calculator/app.py b/src/victron_energy_price_calculator/app.py deleted file mode 100644 index 77105fd..0000000 --- a/src/victron_energy_price_calculator/app.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Populate victron.vrm_price_daily from pricing.energy_prices. - -- Detects complete local days in Europe/Warsaw (23/24/25h ok – DST safe) -- Computes gross price (energy + distribution, VATs configurable) -- Merges adjacent intervals with identical rounded(2) price -- Upserts per (for_date, provider, kind, side) -- Skips rows already sent_to_vrm=true (unless --overwrite-sent) - -""" - -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 importlib.metadata import distribution -from typing import List, Tuple, Optional, Dict, Any - -import DistributionCostFactory -import EnergyPriceFactory -import PriceCalculator -import VictronPriceWriter - -import psycopg - -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()) \ No newline at end of file diff --git a/src/victron_energy_price_calculator/utils/time.py b/src/victron_energy_price_calculator/utils/time.py index def46f5..bfa37ca 100644 --- a/src/victron_energy_price_calculator/utils/time.py +++ b/src/victron_energy_price_calculator/utils/time.py @@ -1,5 +1,5 @@ from __future__ import annotations -from datetime import time +from datetime import time, datetime, date from typing import Tuple import zoneinfo @@ -13,3 +13,16 @@ def in_range_local(t_local: time, rng: TimeRange) -> bool: if start <= end: return start <= t_local < end return (t_local >= start) or (t_local < end) + +def local_midnight(d: date, tz: zoneinfo.ZoneInfo) -> datetime: + """Return local midnight (tz-aware) for a given calendar date.""" + return datetime(d.year, d.month, d.day, 0, 0, tzinfo=tz) + +def end_of_business_day_utc(business_day: date, tz: zoneinfo.ZoneInfo) -> datetime: + """ + End of the business day [start, end) expressed in UTC. + For day D this is local midnight of D+1 converted to UTC. + Correct across 23h/25h DST days. + """ + end_local = local_midnight(business_day + timedelta(days=1), tz=tz) + return end_local.astimezone(UTC) \ No newline at end of file