Switch to 15 mins intervals

This commit is contained in:
Adam Štrauch 2025-10-02 17:48:31 +02:00
parent 96e4d2f1df
commit 6071a1ccc2
Signed by: cx
GPG key ID: 7262DAFE292BCE20
5 changed files with 265 additions and 131 deletions

View file

@ -8,7 +8,7 @@ from calculator.miner import PriceNotFound, get_energy_prices, get_eur_czk_ratio
from calculator.schema import BatteryChargingInfo, Price, SpotPrices
def get_spot_prices(date: datetime.date, hour:int, kwh_fees_low:float, kwh_fees_high:float, sell_fees:float, VAT:float, low_tariff_hours:List[int], no_cache: bool = False) -> SpotPrices:
def get_spot_prices(date: datetime.date, hour:str, kwh_fees_low:float, kwh_fees_high:float, sell_fees:float, VAT:float, low_tariff_hours:List[int], no_cache: bool = False) -> SpotPrices:
is_today = datetime.date.today() == date
spot_hours = {}
@ -19,21 +19,21 @@ def get_spot_prices(date: datetime.date, hour:int, kwh_fees_low:float, kwh_fees_
currency_ratio = get_eur_czk_ratio(date, no_cache=no_cache)
for key, value in spot_data.items():
kwh_fees = kwh_fees_low if int(key) in low_tariff_hours else kwh_fees_high
kwh_fees = kwh_fees_low if key in low_tariff_hours else kwh_fees_high
spot_hours[key] = value * currency_ratio / 1000
spot_hours_total[key] = (value * currency_ratio / 1000 + kwh_fees) * VAT
spot_hours_for_sell[key] = value * currency_ratio / 1000 - sell_fees
spot = Price(hours=spot_hours, now=spot_hours[str(hour)] if is_today else None)
spot = Price(hours=spot_hours, now=spot_hours[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)
spot_total = Price(hours=spot_hours_total_sorted, now=spot_hours_total[hour] if is_today else None)
spot_for_sell = Price(hours=spot_hours_for_sell, now=spot_hours_for_sell[hour] if is_today else None)
return SpotPrices(
spot=spot,
spot_hours_total_sorted=Price(hours=spot_hours_total_sorted, now=spot_hours_total[str(hour)] if is_today else None),
spot_hours_total_sorted=Price(hours=spot_hours_total_sorted, now=spot_hours_total[hour] if is_today else None),
spot_total=spot_total,
spot_for_sell=spot_for_sell,
)
@ -48,14 +48,13 @@ def battery_charging_info(kwh_fees_low:float, kwh_fees_high:float, sell_fees:flo
# average4hours = sum(list(spot_prices_today.spot_hours_total_sorted.hours.values())[0:4]) / 4
max_cheapest_hour = max(list(spot_prices_today.spot_hours_total_sorted.hours.values())[0:4])
average4expensive_hours = sum(list(spot_prices_today.spot_hours_total_sorted.hours.values())[20:24]) / 4
max_most_expensive_hour = max(
[x[1] for x in list(spot_prices_today.spot_hours_total_sorted.hours.items())[0:20]]
) if spot_prices_today.spot_hours_total_sorted.hours else 0
diff = max_most_expensive_hour - max_cheapest_hour
charging_hours = [int(k) for k, v in spot_prices_today.spot_hours_total_sorted.hours.items()][0:4]
discharging_hours = [int(k) for k, v in spot_prices_today.spot_hours_total_sorted.hours.items() if v > (max_cheapest_hour + battery_kwh_price)]
charging_hours = [k for k, v in spot_prices_today.spot_hours_total_sorted.hours.items()][0:4]
discharging_hours = [k for k, v in spot_prices_today.spot_hours_total_sorted.hours.items() if v > (max_cheapest_hour + battery_kwh_price)]
# Add charging hours if the price is just 10% above the most expensive charging hour
if charging_hours:
@ -87,3 +86,16 @@ def battery_charging_info(kwh_fees_low:float, kwh_fees_high:float, sell_fees:flo
is_discharging_hour=hour in discharging_hours if len(discharging_hours) > 0 and is_viable else False,
total_price=spot_prices_today.spot_hours_total_sorted
)
def minutes_to_15mins(mins:int|str) -> str:
mins = int(mins)
if mins < 15:
return "00"
elif mins < 30:
return "15"
elif mins < 45:
return "30"
else:
return "45"

View file

@ -7,31 +7,31 @@
<script>
const priceColorMap = {
0: "bg-lime-200",
1: "bg-lime-300",
2: "bg-lime-400",
3: "bg-lime-500",
4: "bg-lime-600",
5: "bg-lime-700",
6: "bg-amber-200",
7: "bg-amber-300",
8: "bg-amber-400",
9: "bg-amber-500",
10: "bg-amber-600",
11: "bg-amber-700",
12: "bg-orange-300",
13: "bg-orange-400",
14: "bg-orange-500",
15: "bg-orange-600",
16: "bg-rose-400",
17: "bg-rose-500",
18: "bg-rose-600",
19: "bg-rose-700",
20: "bg-rose-800",
21: "bg-fuchsia-500",
22: "bg-fuchsia-700",
23: "bg-fuchsia-800",
24: "bg-fuchsia-950",
0: "bg-lime-100",
1: "bg-lime-200",
2: "bg-lime-300",
3: "bg-lime-400",
4: "bg-lime-500",
5: "bg-lime-600",
6: "bg-lime-700",
7: "bg-amber-100",
8: "bg-amber-200",
9: "bg-amber-300",
10: "bg-amber-400",
11: "bg-amber-500",
12: "bg-amber-600",
13: "bg-amber-700",
14: "bg-orange-200",
15: "bg-orange-300",
16: "bg-orange-400",
17: "bg-orange-500",
18: "bg-orange-600",
19: "bg-orange-700",
20: "bg-rose-300",
21: "bg-rose-400",
22: "bg-rose-500",
23: "bg-rose-600",
24: "bg-rose-700",
}
function getBgColor(value) {
@ -47,67 +47,26 @@
return `${year}-${month}-${day}`;
}
function loadData(date, today) {
let prefix = today ? 'today' : 'tomorrow';
if(prefix == 'today') {
document.getElementById(prefix).innerText = "Dnes ("+date+")";
} else {
document.getElementById(prefix).innerText = "Zítra ("+date+")";
}
fetch('/price/day/'+date+'?num_cheapest_hours=8')
.then(response => response.json())
.then(data => {
if(data.detail == "prices not found") {
for (let i = 0; i < 24; i++) {
document.getElementById(prefix + i).innerText = "-";
}
return;
}
for (let i = 0; i < 24; i++) {
let extra_class = "";
let border = "border-dotted"
if(today && i == new Date().getHours()) {
extra_class = " font-bold text-xl";
border = "border-solid"
}
let value = Math.round(data.total.hours[i]*100)/100;
if(data.cheapest_hours_by_average.hours.includes(i)) {
document.getElementById(prefix + i).className = "px-1 "+border+" border-4 border-lime-400 "+ getBgColor(value) + extra_class;
} else if (data.most_expensive_hours_by_average.hours.includes(i)) {
document.getElementById(prefix + i).className = "px-1 "+border+" border-4 border-rose-400 "+ getBgColor(value) + extra_class;
} else {
if(extra_class != "") {
extra_class = " border-4 border-solid border-gray-500";
}
document.getElementById(prefix + i).className = "px-1 "+ getBgColor(value) + extra_class;
}
document.getElementById(prefix + i).innerText = value;
}
})
function getHourFromTimeString(timeStr) {
return parseInt(timeStr.split(':')[0]);
}
function loadAllData() {
const currentDate = new Date();
const tomorrowDate = new Date(currentDate);
tomorrowDate.setDate(currentDate.getDate() + 1);
const currentDateString = formatDate(currentDate);
const tomorrowDateString = formatDate(tomorrowDate);
loadData(currentDateString, true);
loadData(tomorrowDateString, false);
function getMinuteFromTimeString(timeStr) {
return parseInt(timeStr.split(':')[1]);
}
function isCurrentQuarter(hour, minute, isToday) {
if (!isToday) return false;
const now = new Date();
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
if (hour !== currentHour) return false;
const currentQuarter = Math.floor(currentMinute / 15) * 15;
return minute === currentQuarter;
}
window.onload = function() {
loadAllData();
// Reload data every hour (3600000 milliseconds)
setInterval(loadAllData, 3600000);
};
async function createTableRows() {
const tbody = document.getElementById('prices-table-body');
tbody.innerHTML = '';
@ -115,6 +74,7 @@
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const tomorrowString = formatDate(tomorrow);
// Check if tomorrow's data is available
let tomorrowAvailable = false;
try {
@ -138,10 +98,12 @@
const dateString = formatDate(date);
label = offset === (tomorrowAvailable ? 1 : 0) ? `Dnes (${dateString})` : dateString;
}
const dateString = formatDate(date);
const rowId = `row-${dateString}`;
const tr = document.createElement('tr');
tr.id = rowId;
// Date cell
const dateCell = document.createElement('td');
let dateCellClass = 'px-1 w-16 ';
@ -155,14 +117,16 @@
dateCell.id = `date-${dateString}`;
dateCell.innerText = label;
tr.appendChild(dateCell);
// Hour cells
// Hour cells (24 hours)
for (let i = 0; i < 24; i++) {
const td = document.createElement('td');
td.className = 'px-1';
td.className = 'px-1 text-xs leading-tight';
td.id = `${dateString}-${i}`;
td.innerText = '-';
td.innerHTML = '<div>-</div><div>-</div><div>-</div><div>-</div>';
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
@ -173,30 +137,135 @@
.then(data => {
if (data.detail == "prices not found") {
for (let i = 0; i < 24; i++) {
document.getElementById(`${date}-${i}`).innerText = "-";
document.getElementById(`${date}-${i}`).innerHTML = '<div>-</div><div>-</div><div>-</div><div>-</div>';
}
return;
}
// Group prices by hour
const hourlyData = {};
for (let i = 0; i < 24; i++) {
let extra_class = "";
let border = "border-dotted";
if (isToday && i == new Date().getHours()) {
extra_class = " font-bold text-xl";
border = "border-solid";
}
let value = Math.round(data.total.hours[i] * 100) / 100;
let td = document.getElementById(`${date}-${i}`);
if (data.cheapest_hours_by_average.hours.includes(i)) {
td.className = `px-1 ${border} border-4 border-lime-400 ${getBgColor(value)}${extra_class}`;
} else if (data.most_expensive_hours_by_average.hours.includes(i)) {
td.className = `px-1 ${border} border-4 border-rose-400 ${getBgColor(value)}${extra_class}`;
} else {
if (extra_class != "") {
extra_class = " border-4 border-solid border-gray-500";
hourlyData[i] = [];
}
// Process all time entries and group by hour
Object.keys(data.total.hours).forEach(timeStr => {
const hour = getHourFromTimeString(timeStr);
const minute = getMinuteFromTimeString(timeStr);
const value = data.total.hours[timeStr];
hourlyData[hour].push({
minute: minute,
value: value,
timeStr: timeStr
});
});
// Sort each hour's data by minute
for (let hour = 0; hour < 24; hour++) {
hourlyData[hour].sort((a, b) => a.minute - b.minute);
}
// Get cheapest and most expensive hours for 15-min intervals
const cheapestTimes = new Set();
const expensiveTimes = new Set();
if (data.cheapest_hours_by_average && data.cheapest_hours_by_average.hours) {
data.cheapest_hours_by_average.hours.forEach(timeStr => {
if (typeof timeStr === 'string' && timeStr.includes(':')) {
cheapestTimes.add(timeStr);
} else {
// Legacy hourly format - add all quarters for this hour
for (let min = 0; min < 60; min += 15) {
cheapestTimes.add(`${timeStr}:${min.toString().padStart(2, '0')}`);
}
}
td.className = `px-1 ${getBgColor(value)}${extra_class}`;
});
}
if (data.most_expensive_hours_by_average && data.most_expensive_hours_by_average.hours) {
data.most_expensive_hours_by_average.hours.forEach(timeStr => {
if (typeof timeStr === 'string' && timeStr.includes(':')) {
expensiveTimes.add(timeStr);
} else {
// Legacy hourly format - add all quarters for this hour
for (let min = 0; min < 60; min += 15) {
expensiveTimes.add(`${timeStr}:${min.toString().padStart(2, '0')}`);
}
}
});
}
// Update each hour cell
for (let hour = 0; hour < 24; hour++) {
const td = document.getElementById(`${date}-${hour}`);
const quarters = hourlyData[hour];
let cellClasses = 'px-1 text-xs leading-tight';
// Check if we have only one value for this hour
if (quarters.length === 1) {
// Display single value across entire cell
const quarter = quarters[0];
const value = Math.round(quarter.value * 100) / 100;
const bgColor = getBgColor(value);
let extraClass = '';
let borderClass = '';
// Check if this is the current time quarter
if (isCurrentQuarter(hour, quarter.minute, isToday)) {
extraClass = 'font-bold';
borderClass = 'border-2 border-solid border-gray-800';
}
// Check if this quarter is in cheapest or most expensive
if (cheapestTimes.has(quarter.timeStr)) {
borderClass = 'border-2 border-dotted border-lime-400';
} else if (expensiveTimes.has(quarter.timeStr)) {
borderClass = 'border-2 border-dotted border-rose-400';
}
td.innerHTML = `<div class="${bgColor} ${extraClass} ${borderClass} h-full flex items-center justify-center">${value}</div>`;
} else {
// Fill missing quarters with empty divs for 4-quarter display
while (quarters.length < 4) {
quarters.push({ minute: quarters.length * 15, value: null, timeStr: null });
}
let cellHtml = '';
quarters.forEach((quarter, index) => {
if (quarter.value !== null) {
const value = Math.round(quarter.value * 100) / 100;
const bgColor = getBgColor(value);
let extraClass = '';
let borderClass = '';
// Check if this is the current time quarter
if (isCurrentQuarter(hour, quarter.minute, isToday)) {
extraClass = 'font-bold';
borderClass = 'border-2 border-solid border-gray-800';
}
// Check if this quarter is in cheapest or most expensive
if (cheapestTimes.has(quarter.timeStr)) {
borderClass = 'border-2 border-dotted border-lime-400';
} else if (expensiveTimes.has(quarter.timeStr)) {
borderClass = 'border-2 border-dotted border-rose-400';
}
cellHtml += `<div class="${bgColor} ${extraClass} ${borderClass}">${value}</div>`;
} else {
cellHtml += '<div>-</div>';
}
});
td.innerHTML = cellHtml;
}
td.innerText = value;
td.className = cellClasses;
}
});
}
@ -207,6 +276,7 @@
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const tomorrowString = formatDate(tomorrow);
// Check if tomorrow's data is available
let tomorrowAvailable = false;
try {
@ -216,6 +286,7 @@
tomorrowAvailable = true;
}
} catch (e) {}
let rowCount = tomorrowAvailable ? 15 : 14;
for (let offset = 0; offset < rowCount; offset++) {
let date;

View file

@ -1,12 +1,13 @@
import datetime
import calendar
import re
from typing import List, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from calculator.calc import battery_charging_info, get_spot_prices
from calculator.calc import battery_charging_info, get_spot_prices, minutes_to_15mins
from calculator.miner import PriceNotFound, get_energy_prices, get_eur_czk_ratio
from calculator.schema import BatteryChargingInfo, CheapestHours, DayPrice, MostExpensiveHours, Price
@ -33,7 +34,7 @@ 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>
**hour** - hour of the day, default is current hour, works only when date is today<br>, in format HH:MM where MM can be 00, 15, 30 or 45 if the data is in 15-min intervals, or 00 if the data is in hourly intervals
**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>
@ -137,7 +138,7 @@ rest:
@app.get("/price/day/{date}", description=docs)
def read_item(
date: Optional[datetime.date]=None,
hour: Optional[int]=None,
hour: Optional[str]=None,
monthly_fees: float=610.84,
daily_fees: float=4.18,
kwh_fees_low: float=1.35022,
@ -154,11 +155,24 @@ def read_item(
if not date:
date = datetime.date.today()
if not hour:
hour = datetime.datetime.now().hour
now = datetime.datetime.now()
hour = f"{now.hour}:{minutes_to_15mins(now.minute)}"
if re.match(r"^\d{1,2}$", hour):
hour = f"{hour}:00"
hour_parts = hour.split(":")
hour = f"{hour_parts[0]}:{minutes_to_15mins(hour_parts[1])}"
is_today = datetime.date.today() == date
low_tariff_hours_parsed = [int(x.strip()) for x in low_tariff_hours.split(",")]
low_tariff_hours_parsed = []
for low_hour in [x.strip() for x in low_tariff_hours.split(",")]:
low_tariff_hours_parsed.append(f"{low_hour}:00")
low_tariff_hours_parsed.append(f"{low_hour}:15")
low_tariff_hours_parsed.append(f"{low_hour}:30")
low_tariff_hours_parsed.append(f"{low_hour}:45")
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
@ -167,14 +181,14 @@ def read_item(
except PriceNotFound:
raise HTTPException(status_code=404, detail="prices not found")
cheapest_hours = [int(k) for k, v in list(spot_prices.spot_hours_total_sorted.hours.items())[0:num_cheapest_hours]]
most_expensive_hours = [int(k) for k, v in list(reversed(spot_prices.spot_hours_total_sorted.hours.items()))[0:num_most_expensive_hours]]
cheapest_hours = [k for k, v in list(spot_prices.spot_hours_total_sorted.hours.items())[0:num_cheapest_hours]]
most_expensive_hours = [k for k, v in list(reversed(spot_prices.spot_hours_total_sorted.hours.items()))[0:num_most_expensive_hours]]
# Average over four cheapest hours and calculation of all hours that are in this average +20 %
four_cheapest_hours = [v for k, v in list(spot_prices.spot_hours_total_sorted.hours.items())[0:average_hours]]
four_cheapest_hours_average = sum(four_cheapest_hours) / average_hours
cheapest_hours_by_average = [int(k) for k, v in list(spot_prices.spot_hours_total_sorted.hours.items()) if v < four_cheapest_hours_average * average_hours_threshold]
most_expensive_hours_by_average = list(set(range(24)) - set(cheapest_hours_by_average))
cheapest_hours_by_average = [k for k, v in list(spot_prices.spot_hours_total_sorted.hours.items()) if v < four_cheapest_hours_average * average_hours_threshold]
most_expensive_hours_by_average = list(set([k for k,v in spot_prices.spot_hours_total_sorted.hours.items()]) - set(cheapest_hours_by_average))
data = DayPrice(
monthly_fees=monthly_fees * VAT,

View file

@ -2,7 +2,7 @@ from dataclasses import dataclass
import datetime
import json
import os
from typing import Dict
from typing import Dict, List
import requests
@ -33,19 +33,56 @@ def get_energy_prices(d: datetime.date=datetime.date.today(), no_cache:bool=Fals
if not hours or no_cache:
r = requests.get(url_energy.format(date_str))
try:
for raw in r.json()["data"]["dataLine"][1]["point"]:
hours[str(int(raw["x"])-1)] = raw["y"]
except IndexError:
raise PriceNotFound()
data = r.json()["data"]["dataLine"][1]["point"]
if len(data) == 24:
try:
for raw in data:
hour = str(int(raw["x"])-1)
hours[hour] = raw["y"]
except IndexError:
raise PriceNotFound()
else:
try:
mins_index = 0
hour = 0
for raw in data:
hour_str = f"{hour}:00"
if mins_index == 1:
hour_str = f"{hour}:15"
elif mins_index == 2:
hour_str = f"{hour}:30"
elif mins_index == 3:
hour_str = f"{hour}:45"
hours[hour_str] = raw["y"]
mins_index += 1
if mins_index >= 4:
mins_index = 0
hour += 1
except IndexError:
raise PriceNotFound()
# Only cache if all 24 hours are present
if len(hours) == 24:
if len(hours) in (24, 96): # 96 for 15-min intervals
with open(cache_file, "w") as f:
f.write(json.dumps(hours))
# If incomplete, do not cache, but return what is available
return hours
# Ensure all hours are in the right format of HH:MM
correct_format_hours = {}
if len(hours) == 24:
for k, v in hours.items():
if ":" in k:
correct_format_hours[k] = v
else:
hour_int = int(k)
correct_format_hours[f"{hour_int}:00"] = v
else:
correct_format_hours = hours
return correct_format_hours
#def get_currency_ratio(currency):
# r = requests.get(url_currency)

View file

@ -5,7 +5,7 @@ from typing import Dict, List, Optional
@dataclass
class Price:
hours: Dict[str, float]
hours: Dict[str, List[float]]
now: Optional[float] = None # Price in current hour