Initial commit
This commit is contained in:
commit
0f1cac531e
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
cache/
|
||||||
|
.history/
|
||||||
|
venv/
|
||||||
|
.rosti.state
|
||||||
|
cache*.json
|
||||||
|
Rostifile
|
||||||
|
__pycache__/
|
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"makefile.extensionOutputFolder": "./.vscode",
|
||||||
|
"python.analysis.autoImportCompletions": true
|
||||||
|
}
|
12
Taskfile.yml
Normal file
12
Taskfile.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# https://taskfile.dev
|
||||||
|
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
deploy:
|
||||||
|
cmds:
|
||||||
|
- ssh -p 11335 app@ssh.rosti.cz mkdir -p /srv/app/cache
|
||||||
|
- rsync -av -e "ssh -p 11335" --delete ./calculator app@ssh.rosti.cz:/srv/app/
|
||||||
|
- rsync -av -e "ssh -p 11335" ./requirements.txt app@ssh.rosti.cz:/srv/app/
|
||||||
|
- ssh -p 11335 app@ssh.rosti.cz /srv/venv/bin/pip install -r /srv/app/requirements.txt
|
||||||
|
- ssh -p 11335 app@ssh.rosti.cz supervisorctl restart app
|
0
calculator/__init__.py
Normal file
0
calculator/__init__.py
Normal file
1
calculator/consts.py
Normal file
1
calculator/consts.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
VAT = 1.21
|
127
calculator/main.py
Normal file
127
calculator/main.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import datetime
|
||||||
|
import calendar
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from calculator.miner import get_energy_prices, get_eur_czk_ratio
|
||||||
|
from calculator.schema import CheapestHours, DayPrice, Price
|
||||||
|
|
||||||
|
from .consts import VAT
|
||||||
|
|
||||||
|
def days_in_month(year: int, month: int) -> int:
|
||||||
|
return calendar.monthrange(year, month)[1]
|
||||||
|
|
||||||
|
app = FastAPI(title="Spot market home calculator", version="0.1", description="Calculate your energy costs based on spot market prices")
|
||||||
|
|
||||||
|
@app.get("/", description="Redirect to /docs")
|
||||||
|
def docs():
|
||||||
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
docs = """
|
||||||
|
Return spot prices for the whole day with all fees included.<br>
|
||||||
|
<br>
|
||||||
|
Options:<br>
|
||||||
|
date - date in format YYYY-MM-DD, default is today<br>
|
||||||
|
hour - hour of the day, default is current hour, works only when date is today<br>
|
||||||
|
monthly_fees - monthly fees, default is 509.24 (D57d, BezDodavatele)<br>
|
||||||
|
daily_fees - daily fees, default is 4.18 (BezDodavatele)<br>
|
||||||
|
kwh_fees_low - additional fees per kWh in low tariff, usually distribution + other fees, default is 1.62421 (D57d, BezDodavatele)<br>
|
||||||
|
kwh_fees_high - additional fees per kWh in high tariff, usually distribution + other fees, default is 1.83474 (D57d, BezDodavatele)<br>
|
||||||
|
sell_fees - selling energy fees, default is 0.45 (BezDodavatele)<br>
|
||||||
|
num_cheapest_hours - number of cheapest hours to return, default is 8, use this to plan your consumption<br>
|
||||||
|
low_tariff_hours - list of low tariff hours, default is 0,1,2,3,4,5,6,7,9,10,11,13,14,16,17,18,20,21,22,23 (D57d, ČEZ)<br>
|
||||||
|
<br>
|
||||||
|
Output:<br>
|
||||||
|
spot - current spot prices on our market<br>
|
||||||
|
total - total price for the day including all fees and VAT<br>
|
||||||
|
sell - current spot prices minus sell_fees<br>
|
||||||
|
<br>
|
||||||
|
The final price on the invoice is calculated as:<br>
|
||||||
|
monthly_fees + kWh consumption in low tariff * kwh_fees_low + kWh consumption in high tariff * kwh_fees_high<br>
|
||||||
|
<br>
|
||||||
|
Except spot and sell prices all prices include VAT.<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
Integration into Home Assistant can be done in configuration.yaml file:<br>
|
||||||
|
<br>
|
||||||
|
```
|
||||||
|
sensor:
|
||||||
|
- platform: rest
|
||||||
|
resource: "https://pricepower2.rostiapp.cz/price/day"
|
||||||
|
name: "energy_price"
|
||||||
|
value_template: "{{ value_json.total.now|round(2) }}"
|
||||||
|
unit_of_measurement: "CZK/kWh"
|
||||||
|
- platform: rest
|
||||||
|
resource: "https://pricepower2.rostiapp.cz/price/day"
|
||||||
|
name: "energy_market_price"
|
||||||
|
value_template: "{{ value_json.spot.now|round(2) }}"
|
||||||
|
unit_of_measurement: "CZK/kWh"
|
||||||
|
- platform: rest
|
||||||
|
resource: "https://pricepower2.rostiapp.cz/price/day"
|
||||||
|
name: "energy_market_price_sell"
|
||||||
|
value_template: "{{ value_json.sell.now|round(2) }}"
|
||||||
|
unit_of_measurement: "CZK/kWh"
|
||||||
|
- platform: rest
|
||||||
|
resource: "https://pricepower2.rostiapp.cz/price/day"
|
||||||
|
name: "energy_cheap_hour"
|
||||||
|
value_template: "{{ value_json.cheapest_hours.is_cheapest }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@app.get("/price/day", description=docs)
|
||||||
|
@app.get("/price/day/{date}", description=docs)
|
||||||
|
def read_item(
|
||||||
|
date: datetime.date=datetime.date.today(),
|
||||||
|
hour:int=datetime.datetime.now().hour,
|
||||||
|
monthly_fees: float = 509.24,
|
||||||
|
daily_fees: float=4.18,
|
||||||
|
kwh_fees_low: float=1.62421,
|
||||||
|
kwh_fees_high: float=1.83474,
|
||||||
|
sell_fees: float=0.45,
|
||||||
|
low_tariff_hours:str="0,1,2,3,4,5,6,7,9,10,11,13,14,16,17,18,20,21,22,23",
|
||||||
|
no_cache:bool = False,
|
||||||
|
num_cheapest_hours:int = 8,
|
||||||
|
) -> DayPrice:
|
||||||
|
is_today = datetime.date.today() == date
|
||||||
|
|
||||||
|
low_tariff_hours_parsed = [int(x.strip()) for x in low_tariff_hours.split(",")]
|
||||||
|
monthly_fees = (monthly_fees + daily_fees * days_in_month(date.year, date.month))
|
||||||
|
monthly_fees_hour = monthly_fees / days_in_month(date.year, date.month) / 24
|
||||||
|
currency_ratio = get_eur_czk_ratio(date, no_cache=no_cache)
|
||||||
|
|
||||||
|
spot_hours = {}
|
||||||
|
spot_hours_for_sell = {}
|
||||||
|
spot_hours_total = {}
|
||||||
|
spot_data = get_energy_prices(date, no_cache=no_cache)
|
||||||
|
for hour, value in spot_data.items():
|
||||||
|
kwh_fees = kwh_fees_low if int(hour) in low_tariff_hours_parsed else kwh_fees_high
|
||||||
|
|
||||||
|
spot_hours[hour] = value / currency_ratio
|
||||||
|
spot_hours_total[hour] = (value / currency_ratio + kwh_fees) * VAT
|
||||||
|
spot_hours_for_sell[hour] = value / currency_ratio - sell_fees
|
||||||
|
|
||||||
|
spot = Price(hours=spot_hours, now=spot_hours[str(hour)] if is_today else None)
|
||||||
|
|
||||||
|
spot_hours_total_sorted = {k: v for k, v in sorted(spot_hours_total.items(), key=lambda item: item[1])}
|
||||||
|
spot_total = Price(hours=spot_hours_total_sorted, now=spot_hours_total[str(hour)] if is_today else None)
|
||||||
|
spot_for_sell = Price(hours=spot_hours_for_sell, now=spot_hours_for_sell[str(hour)] if is_today else None)
|
||||||
|
|
||||||
|
cheapest_hours = [int(k) for k, v in list(spot_hours_total_sorted.items())[0:num_cheapest_hours]]
|
||||||
|
|
||||||
|
data = DayPrice(
|
||||||
|
monthly_fees=monthly_fees * VAT,
|
||||||
|
monthly_fees_hour=monthly_fees_hour * VAT,
|
||||||
|
kwh_fees_low=kwh_fees_low * VAT,
|
||||||
|
kwh_fees_high=kwh_fees_high * VAT,
|
||||||
|
sell_fees=sell_fees,
|
||||||
|
low_tariff_hours=low_tariff_hours_parsed,
|
||||||
|
vat=VAT,
|
||||||
|
spot=spot,
|
||||||
|
total=spot_total,
|
||||||
|
sell=spot_for_sell,
|
||||||
|
cheapest_hours=CheapestHours(hours=cheapest_hours, is_cheapest=hour in cheapest_hours if is_today else None)
|
||||||
|
)
|
||||||
|
return data
|
77
calculator/miner.py
Normal file
77
calculator/miner.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class PriceException(Exception): pass
|
||||||
|
|
||||||
|
|
||||||
|
url_energy = "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date={}"
|
||||||
|
url_currency = "https://data.kurzy.cz/json/meny/b[1].json"
|
||||||
|
url_currency2 = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt?date={day}.{month}.{year}"
|
||||||
|
CACHE_PATH = "./cache"
|
||||||
|
|
||||||
|
|
||||||
|
def get_energy_prices(d: datetime.date=datetime.date.today(), no_cache:bool=False) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
date_str = d.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
hours = {}
|
||||||
|
|
||||||
|
cache_file = os.path.join(CACHE_PATH, "hours-{}.json".format(date_str))
|
||||||
|
|
||||||
|
if os.path.isfile(cache_file) and not no_cache:
|
||||||
|
with open(cache_file, "r") as f:
|
||||||
|
hours = json.load(f)
|
||||||
|
|
||||||
|
if not hours or no_cache:
|
||||||
|
r = requests.get(url_energy.format(date_str))
|
||||||
|
|
||||||
|
for raw in r.json()["data"]["dataLine"][1]["point"]:
|
||||||
|
hours[str(int(raw["x"])-1)] = raw["y"]
|
||||||
|
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
f.write(json.dumps(hours))
|
||||||
|
|
||||||
|
return hours
|
||||||
|
|
||||||
|
#def get_currency_ratio(currency):
|
||||||
|
# r = requests.get(url_currency)
|
||||||
|
# return r.json()["kurzy"][currency]["dev_stred"]
|
||||||
|
|
||||||
|
def get_eur_czk_ratio(d: datetime.date=datetime.date.today(), no_cache:bool=False) -> float:
|
||||||
|
ratio = 0
|
||||||
|
|
||||||
|
cache_file = os.path.join(CACHE_PATH, "eur-czk-{}.json".format(d.strftime("%Y-%m-%d")))
|
||||||
|
|
||||||
|
if os.path.isfile(cache_file):
|
||||||
|
with open(cache_file, "r") as f:
|
||||||
|
ratio = float(f.read())
|
||||||
|
|
||||||
|
if not ratio or no_cache:
|
||||||
|
url = url_currency2.format(day=d.day,month=d.month,year=d.year)
|
||||||
|
r = requests.get(url)
|
||||||
|
for row in [x.split("|") for x in r.text.split("\n") if x and "|" in x]:
|
||||||
|
if row[3] == "EUR":
|
||||||
|
ratio = float(row[4].replace(",", "."))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ratio:
|
||||||
|
raise PriceException("EUR not found")
|
||||||
|
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
f.write(str(ratio))
|
||||||
|
|
||||||
|
return ratio
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
34
calculator/schema.py
Normal file
34
calculator/schema.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Price:
|
||||||
|
hours: Dict[str, float]
|
||||||
|
now: Optional[float] = None # Price in current hour
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheapestHours:
|
||||||
|
hours: List[int]
|
||||||
|
is_cheapest: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DayPrice:
|
||||||
|
monthly_fees: float
|
||||||
|
monthly_fees_hour: float
|
||||||
|
kwh_fees_low: float
|
||||||
|
kwh_fees_high: float
|
||||||
|
sell_fees: float
|
||||||
|
low_tariff_hours: List[int]
|
||||||
|
vat: float
|
||||||
|
spot: Price
|
||||||
|
total: Price
|
||||||
|
sell: Price
|
||||||
|
cheapest_hours: CheapestHours
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
21
calculator/tests.py
Normal file
21
calculator/tests.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
import datetime
|
||||||
|
from .miner import get_energy_prices, get_eur_czk_ratio
|
||||||
|
|
||||||
|
class TestMiner(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_get_energy_prices(self):
|
||||||
|
data = get_energy_prices(datetime.datetime.now(), no_cache=True)
|
||||||
|
|
||||||
|
data_cache = get_energy_prices(datetime.datetime.now())
|
||||||
|
|
||||||
|
self.assertGreater(data["0"], 0)
|
||||||
|
self.assertEqual(data["0"], data_cache["0"])
|
||||||
|
self.assertEqual(data, data_cache)
|
||||||
|
|
||||||
|
def test_currency_ratio(self):
|
||||||
|
data = get_eur_czk_ratio(datetime.datetime.now(), no_cache=True)
|
||||||
|
data_cache = get_eur_czk_ratio(datetime.datetime.now())
|
||||||
|
|
||||||
|
self.assertGreater(data, 0)
|
||||||
|
self.assertEqual(data, data_cache)
|
161
main.py
Normal file
161
main.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from genericpath import isfile
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import calendar
|
||||||
|
from flask import Flask
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
url_energy = "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date={}"
|
||||||
|
url_currency = "https://data.kurzy.cz/json/meny/b[1].json"
|
||||||
|
url_currency2 = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt?date={day}.{month}.{year}"
|
||||||
|
CACHE_PATH = "./cache.json"
|
||||||
|
ENERGY_CACHE_PATH = "./cache-energy.json"
|
||||||
|
NO_CACHE = False
|
||||||
|
SELL_FEE = 0.45
|
||||||
|
BUY_FEE = 0.45
|
||||||
|
VAT=1.21
|
||||||
|
|
||||||
|
class PriceException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_distribution_price():
|
||||||
|
cez_57d_vt = 0.64862
|
||||||
|
cez_57d_nt = 0.43809
|
||||||
|
|
||||||
|
tax = 0.0283
|
||||||
|
services = 0.21282
|
||||||
|
ote = 0 # this is 4 crown per month
|
||||||
|
oze = 0.495
|
||||||
|
|
||||||
|
additional = tax+services+ote+oze
|
||||||
|
|
||||||
|
return {
|
||||||
|
"high": cez_57d_vt+additional,
|
||||||
|
"low": cez_57d_nt+additional,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_breaker_payment():
|
||||||
|
# https://www.tzb-info.cz/ceny-paliv-a-energii/14-ceny-elektriny#D57d
|
||||||
|
cez_32a = 500
|
||||||
|
|
||||||
|
currentDate = datetime.date.today()
|
||||||
|
daysInMonth = calendar.monthrange(currentDate.year, currentDate.month)[1]
|
||||||
|
return cez_32a/daysInMonth/24
|
||||||
|
|
||||||
|
def get_current_energy_price(date_str, selected_hour):
|
||||||
|
selected_hour = str(selected_hour)
|
||||||
|
data = {
|
||||||
|
"date": "",
|
||||||
|
"hours": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.isfile(ENERGY_CACHE_PATH) and not NO_CACHE:
|
||||||
|
with open(ENERGY_CACHE_PATH, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
if data["date"] == date_str:
|
||||||
|
return data["hours"][selected_hour]
|
||||||
|
|
||||||
|
r = requests.get(url_energy.format(date_str))
|
||||||
|
|
||||||
|
print(r.text)
|
||||||
|
for hour in r.json()["data"]["dataLine"][1]["point"]:
|
||||||
|
data["hours"][str(int(hour["x"])-1)] = hour["y"]
|
||||||
|
|
||||||
|
data["date"] = date_str
|
||||||
|
with open(ENERGY_CACHE_PATH, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
return data["hours"][selected_hour]
|
||||||
|
|
||||||
|
#def get_currency_ratio(currency):
|
||||||
|
# r = requests.get(url_currency)
|
||||||
|
# return r.json()["kurzy"][currency]["dev_stred"]
|
||||||
|
|
||||||
|
def get_currency_ratio(currency):
|
||||||
|
now = datetime.date.today()
|
||||||
|
url = url_currency2.format(day=now.day,month=now.month,year=now.year)
|
||||||
|
r = requests.get(url)
|
||||||
|
for row in [x.split("|") for x in r.text.split("\n") if x and "|" in x]:
|
||||||
|
if row[3] == "EUR":
|
||||||
|
return float(row[4].replace(",", "."))
|
||||||
|
|
||||||
|
raise PriceException("EUR not found")
|
||||||
|
|
||||||
|
def hour_result():
|
||||||
|
current_hour = datetime.datetime.now().hour
|
||||||
|
today = datetime.date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.isfile(CACHE_PATH) and not NO_CACHE:
|
||||||
|
with open(CACHE_PATH, "r") as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
if cache.get("date") == today and cache.get("hour") == current_hour:
|
||||||
|
cache["cached"] = True
|
||||||
|
return cache
|
||||||
|
|
||||||
|
energy_price = get_current_energy_price(today, current_hour)
|
||||||
|
eur2czk = get_currency_ratio("EUR")
|
||||||
|
breaker = get_breaker_payment()
|
||||||
|
distribution = get_distribution_price()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"date": datetime.date.today().strftime("%Y-%m-%d"),
|
||||||
|
"hour": datetime.datetime.now().hour,
|
||||||
|
"breaker": "32A",
|
||||||
|
"tariff": "57d",
|
||||||
|
"price": {
|
||||||
|
"distribution": distribution,
|
||||||
|
"market": energy_price/1000*eur2czk,
|
||||||
|
"market_vat": energy_price/1000*eur2czk*VAT,
|
||||||
|
"marker_sell": energy_price/1000*eur2czk-SELL_FEE, # Typo
|
||||||
|
"market_sell": energy_price/1000*eur2czk-SELL_FEE,
|
||||||
|
"breaker": breaker,
|
||||||
|
"breaker_vat": breaker*VAT,
|
||||||
|
"sell_fee": SELL_FEE,
|
||||||
|
"buy_fee": BUY_FEE,
|
||||||
|
"total": {
|
||||||
|
"high": energy_price/1000*eur2czk+breaker+distribution["high"]+BUY_FEE,
|
||||||
|
"low": energy_price/1000*eur2czk+breaker+distribution["low"]+BUY_FEE,
|
||||||
|
},
|
||||||
|
"total_no_breaker": {
|
||||||
|
"high": energy_price/1000*eur2czk+distribution["high"]+BUY_FEE,
|
||||||
|
"low": energy_price/1000*eur2czk+distribution["low"]+BUY_FEE,
|
||||||
|
},
|
||||||
|
"total_vat": {
|
||||||
|
"high": (energy_price/1000*eur2czk+breaker+distribution["high"]+BUY_FEE)*VAT,
|
||||||
|
"low": (energy_price/1000*eur2czk+breaker+distribution["low"]+BUY_FEE)*VAT,
|
||||||
|
},
|
||||||
|
"total_no_breaker_vat": {
|
||||||
|
"high": (energy_price/1000*eur2czk+distribution["high"]+BUY_FEE)*VAT,
|
||||||
|
"low": (energy_price/1000*eur2czk+distribution["low"]+BUY_FEE)*VAT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(CACHE_PATH, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def result():
|
||||||
|
return jsonify(hour_result())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True)
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
fastapi[standard]
|
||||||
|
requests
|
Loading…
Reference in New Issue
Block a user