Skip to content

Commit dea14f2

Browse files
committed
feat(energy): pörssisähkö spot-price section
Adds a public, keyless spot-price upstream (spot-hinta.fi), aggregated 15-min → hourly and served at GET /api/spot (today + tomorrow, c/kWh). Frontend: first section of the energy tab — two slim per-hour bar charts (today/tomorrow), absolute green→red→purple colour by price with shared heights, current hour outlined, past greyed, per-day min–max, hover price, today's average, and a tap-to-toggle gradient legend. Mobile-safe.
1 parent 68de246 commit dea14f2

10 files changed

Lines changed: 484 additions & 1 deletion

File tree

‎backend/src/lib.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod pv;
44
pub mod reserve;
55
pub mod settings;
66
pub mod solis;
7+
pub mod spot;
78
pub mod storage;
89
pub mod weather;
910

@@ -28,6 +29,7 @@ pub struct AppState {
2829
pub weather_cache: Cache<weather::models::WeatherResponse>,
2930
pub solis_cache: Cache<solis::models::SolisWidgetData>,
3031
pub pv_cache: Cache<pv::models::PvForecast>,
32+
pub spot_cache: Cache<spot::models::SpotResponse>,
3133
pub hue_events_tx: broadcast::Sender<hue::events::HueLiveEvent>,
3234
pub storage: storage::Storage,
3335
}
@@ -48,6 +50,7 @@ pub struct AppState {
4850
pv::handlers::get_forecast,
4951
pv::handlers::post_forecast,
5052
reserve::handlers::get_reserve,
53+
spot::handlers::get_spot,
5154
status,
5255
sensor_history,
5356
),
@@ -67,6 +70,8 @@ pub struct AppState {
6770
pv::models::PvPoint,
6871
reserve::models::ReserveResponse,
6972
reserve::models::ReservePoint,
73+
spot::models::SpotResponse,
74+
spot::models::HourPrice,
7075
StatusResponse,
7176
storage::SensorReading,
7277
))
@@ -224,7 +229,8 @@ pub fn create_app(
224229
)
225230
.service(
226231
web::scope("/reserve").route("", web::get().to(reserve::handlers::get_reserve)),
227-
),
232+
)
233+
.service(web::scope("/spot").route("", web::get().to(spot::handlers::get_spot))),
228234
)
229235
.service(SwaggerUi::new("/docs/{_:.*}").url("/api-doc/openapi.json", openapi))
230236
.service({
@@ -268,6 +274,7 @@ pub fn create_test_app_state_with(settings: Settings) -> Arc<AppState> {
268274
weather_cache: Cache::new(std::time::Duration::from_secs(3600)),
269275
solis_cache: Cache::new(std::time::Duration::from_secs(300)),
270276
pv_cache: Cache::new(std::time::Duration::from_secs(60)),
277+
spot_cache: Cache::new(std::time::Duration::from_secs(1800)),
271278
hue_events_tx,
272279
storage,
273280
})
@@ -307,6 +314,7 @@ pub async fn run_server() -> std::io::Result<()> {
307314
weather_cache: Cache::new(std::time::Duration::from_secs(3600)),
308315
solis_cache: Cache::new(std::time::Duration::from_secs(300)),
309316
pv_cache: Cache::new(std::time::Duration::from_secs(60)),
317+
spot_cache: Cache::new(std::time::Duration::from_secs(1800)),
310318
hue_events_tx: hue_events_tx.clone(),
311319
storage,
312320
});

‎backend/src/settings.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub struct Settings {
1818
/// Default provider key for the reserve tables. Generic default; a local
1919
/// deploy can set RESERVE_PROVIDER to a real name to match its updater.
2020
pub reserve_provider: String,
21+
pub spot_base_url: String,
2122
}
2223

2324
impl Settings {
@@ -38,6 +39,7 @@ impl Settings {
3839
solis_station_id: String::new(),
3940
solis_base_url: "https://www.soliscloud.com:13333".into(),
4041
reserve_provider: "reserve".into(),
42+
spot_base_url: "https://api.spot-hinta.fi".into(),
4143
}
4244
}
4345

@@ -71,6 +73,8 @@ impl Settings {
7173
solis_base_url: env::var("SOLIS_BASE_URL")
7274
.unwrap_or_else(|_| "https://www.soliscloud.com:13333".into()),
7375
reserve_provider: env::var("RESERVE_PROVIDER").unwrap_or_else(|_| "reserve".into()),
76+
spot_base_url: env::var("SPOT_BASE_URL")
77+
.unwrap_or_else(|_| "https://api.spot-hinta.fi".into()),
7478
}
7579
}
7680
}

‎backend/src/spot/client.rs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use std::collections::BTreeMap;
2+
3+
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike};
4+
5+
use super::models::{HourPrice, SpotEntry, SpotResponse};
6+
7+
pub async fn fetch(
8+
client: &reqwest::Client,
9+
base_url: &str,
10+
) -> Result<SpotResponse, reqwest::Error> {
11+
let url = format!("{}/TodayAndDayForward", base_url.trim_end_matches('/'));
12+
let entries: Vec<SpotEntry> = client
13+
.get(&url)
14+
.send()
15+
.await?
16+
.error_for_status()?
17+
.json()
18+
.await?;
19+
Ok(aggregate(entries))
20+
}
21+
22+
/// Average the 15-min quarters into hourly bars and split into today/tomorrow
23+
/// (the two distinct local dates the upstream returns).
24+
fn aggregate(entries: Vec<SpotEntry>) -> SpotResponse {
25+
// (local date, hour) -> (price sum, count, hour-start datetime)
26+
let mut buckets: BTreeMap<(NaiveDate, u32), (f64, u32, DateTime<FixedOffset>)> =
27+
BTreeMap::new();
28+
for e in &entries {
29+
let Ok(dt) = DateTime::parse_from_rfc3339(&e.date_time) else {
30+
continue;
31+
};
32+
let hour_start = dt
33+
.with_minute(0)
34+
.and_then(|d| d.with_second(0))
35+
.and_then(|d| d.with_nanosecond(0))
36+
.unwrap_or(dt);
37+
let slot = buckets
38+
.entry((dt.date_naive(), dt.hour()))
39+
.or_insert((0.0, 0, hour_start));
40+
slot.0 += e.price_with_tax;
41+
slot.1 += 1;
42+
}
43+
44+
let mut dates: Vec<NaiveDate> = buckets.keys().map(|(d, _)| *d).collect();
45+
dates.dedup(); // BTreeMap keys are sorted, so dups are adjacent
46+
47+
let to_hours = |target: Option<NaiveDate>| -> Vec<HourPrice> {
48+
let Some(target) = target else {
49+
return Vec::new();
50+
};
51+
buckets
52+
.iter()
53+
.filter(|((d, _), _)| *d == target)
54+
.map(|(_, (sum, count, hour_start))| HourPrice {
55+
hour: hour_start.to_rfc3339(),
56+
price: (sum / *count as f64) * 100.0, // €/kWh -> c/kWh
57+
})
58+
.collect()
59+
};
60+
61+
let today = to_hours(dates.first().copied());
62+
let tomorrow = to_hours(dates.get(1).copied());
63+
let today_average = if today.is_empty() {
64+
0.0
65+
} else {
66+
today.iter().map(|h| h.price).sum::<f64>() / today.len() as f64
67+
};
68+
69+
SpotResponse {
70+
unit: "c/kWh".into(),
71+
today,
72+
tomorrow,
73+
today_average,
74+
}
75+
}

‎backend/src/spot/handlers.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
use std::sync::Arc;
2+
3+
use actix_web::{web, HttpResponse};
4+
5+
use crate::AppState;
6+
7+
#[utoipa::path(
8+
get,
9+
path = "/api/spot",
10+
responses(
11+
(status = 200, description = "Finnish spot prices, today + tomorrow hourly", body = super::models::SpotResponse),
12+
(status = 502, description = "Failed to fetch spot prices"),
13+
)
14+
)]
15+
pub async fn get_spot(state: web::Data<Arc<AppState>>) -> HttpResponse {
16+
if let Some(cached) = state.spot_cache.get("spot").await {
17+
return HttpResponse::Ok().json(cached);
18+
}
19+
20+
match super::client::fetch(&state.http_client, &state.settings.spot_base_url).await {
21+
Ok(data) => {
22+
state.spot_cache.set("spot".into(), data.clone()).await;
23+
HttpResponse::Ok().json(data)
24+
}
25+
Err(e) => {
26+
tracing::error!("Failed to fetch spot prices: {e}");
27+
if let Some(stale) = state.spot_cache.get_stale("spot").await {
28+
tracing::warn!("Returning stale spot prices");
29+
return HttpResponse::Ok().json(stale);
30+
}
31+
HttpResponse::BadGateway().json(serde_json::json!({"error": e.to_string()}))
32+
}
33+
}
34+
}

‎backend/src/spot/mod.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Finnish spot electricity prices — public, keyless upstream (spot-hinta.fi),
2+
//! fetched directly like the weather sources. 15-min data aggregated to hourly.
3+
pub mod client;
4+
pub mod handlers;
5+
pub mod models;

‎backend/src/spot/models.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use serde::{Deserialize, Serialize};
2+
use utoipa::ToSchema;
3+
4+
/// Raw entry from spot-hinta.fi `/TodayAndDayForward` (15-min resolution).
5+
#[derive(Debug, Deserialize)]
6+
pub struct SpotEntry {
7+
#[serde(rename = "DateTime")]
8+
pub date_time: String, // RFC3339 with local offset, e.g. 2026-06-14T00:15:00+03:00
9+
#[serde(rename = "PriceWithTax", default)]
10+
pub price_with_tax: f64, // €/kWh, incl. VAT
11+
}
12+
13+
/// One hourly bar. `hour` is the local-time hour start (RFC3339); `price` is
14+
/// c/kWh incl. VAT (the 15-min quarters averaged).
15+
#[derive(Debug, Clone, Serialize, ToSchema)]
16+
#[serde(rename_all = "camelCase")]
17+
pub struct HourPrice {
18+
pub hour: String,
19+
pub price: f64,
20+
}
21+
22+
/// Today + tomorrow hourly spot prices. `tomorrow` is empty until the day-ahead
23+
/// auction publishes (~14:15 local).
24+
#[derive(Debug, Clone, Serialize, ToSchema)]
25+
#[serde(rename_all = "camelCase")]
26+
pub struct SpotResponse {
27+
pub unit: String,
28+
pub today: Vec<HourPrice>,
29+
pub tomorrow: Vec<HourPrice>,
30+
pub today_average: f64,
31+
}

‎backend/tests/smoke.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fn test_settings_with_mock(mock_url: &str) -> Settings {
5959
solis_key_secret: "".into(),
6060
solis_station_id: "".into(),
6161
reserve_provider: "reserve".into(),
62+
spot_base_url: mock_url.into(),
6263
}
6364
}
6465

‎frontend/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import CurrentTime from "./components/CurrentTime";
66
import EnergyDailyHistory from "./components/Energy/DailyHistory";
77
import EnergyFlow from "./components/Energy/Flow";
88
import EnergyReserve from "./components/Energy/Reserve";
9+
import EnergySpotPrice from "./components/Energy/SpotPrice";
910
import EnergySummary from "./components/Energy/Summary";
1011
import FmiWeatherBox from "./components/FmiWeatherBox";
1112
import History from "./components/History";
@@ -328,6 +329,7 @@ const App = () => {
328329
gap: 16,
329330
}}
330331
>
332+
<EnergySpotPrice />
331333
<EnergyFlow />
332334
<EnergySummary />
333335
<EnergyDailyHistory />

0 commit comments

Comments
 (0)