fix package
This commit is contained in:
parent
5c1e02ffc9
commit
d58bb8094a
@ -1,5 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
|
||||
44
src/app.py
Normal file
44
src/app.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
79
src/victron_energy_price_calculator/VictronShedule.py
Normal file
79
src/victron_energy_price_calculator/VictronShedule.py
Normal 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: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}
|
||||
@ -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())
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user