fix package
This commit is contained in:
parent
5c1e02ffc9
commit
d58bb8094a
@ -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
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:
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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 __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)
|
||||||
Loading…
Reference in New Issue
Block a user