fix package

This commit is contained in:
Bartosz Wieczorek 2025-09-10 08:07:57 +02:00
parent 5c1e02ffc9
commit d58bb8094a
7 changed files with 141 additions and 209 deletions

View File

@ -1,5 +1,6 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<settings> <settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" /> <option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" /> <version value="1.0" />
</settings> </settings>

44
src/app.py Normal file
View File

@ -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)

View File

@ -18,6 +18,9 @@ class PriceCalculator:
def _round2(v: float) -> float: def _round2(v: float) -> float:
return round(v, 2) 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: def gross_price(self, ts: datetime, side: str) -> float:
ts_loc = self.energy_provider.to_local_dt(ts) ts_loc = self.energy_provider.to_local_dt(ts)

View File

@ -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

View File

@ -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:0024: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}

View File

@ -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())

View File

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import time from datetime import time, datetime, date
from typing import Tuple from typing import Tuple
import zoneinfo import zoneinfo
@ -13,3 +13,16 @@ def in_range_local(t_local: time, rng: TimeRange) -> bool:
if start <= end: if start <= end:
return start <= t_local < end return start <= t_local < end
return (t_local >= start) or (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)