Skip to content

Commit 76f5e5d

Browse files
committed
fix(spot): don't spill a lone post-midnight bar into tomorrow
Key spot buckets to the real Helsinki today/tomorrow dates and only show tomorrow once it's a near-complete published day (>=20h). Before ~14:00 Elering returns just an hour or two of tomorrow; that lone 00:00 bar now stays out of the tomorrow chart (placeholder shown) and today keeps its full 00:00–23:00.
1 parent 055bd82 commit 76f5e5d

1 file changed

Lines changed: 23 additions & 18 deletions

File tree

‎backend/src/spot/client.rs‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ use super::models::{HourPrice, NpsEntry, NpsResponse, SpotResponse};
99
/// consumer price like the price apps do.
1010
const VAT: f64 = 1.255;
1111

12+
/// Minimum hours before tomorrow counts as "published" — until the day-ahead
13+
/// auction lands (~14:00) Elering returns only an hour or two of tomorrow, and a
14+
/// lone bar isn't worth a chart (the frontend shows a placeholder instead).
15+
const MIN_TOMORROW_HOURS: usize = 20;
16+
1217
/// Fetch today + tomorrow Finnish prices straight from the TSO-grade open source
1318
/// (Elering LIVE, Nord Pool day-ahead). API: <https://dashboard.elering.ee/api>
1419
pub async fn fetch(
@@ -42,12 +47,14 @@ pub async fn fetch(
4247
.error_for_status()?
4348
.json()
4449
.await?;
45-
Ok(aggregate(resp.data.fi))
50+
Ok(aggregate(resp.data.fi, now.date_naive()))
4651
}
4752

48-
/// Average the 15-min entries into hourly bars (in Helsinki local time) and split
49-
/// into today/tomorrow; convert EUR/MWh ex-VAT → c/kWh incl. VAT.
50-
fn aggregate(entries: Vec<NpsEntry>) -> SpotResponse {
53+
/// Average the 15-min entries into hourly bars (in Helsinki local time), keyed to
54+
/// the real local dates so today/tomorrow don't drift; convert EUR/MWh ex-VAT →
55+
/// c/kWh incl. VAT. Tomorrow is dropped until it's a near-complete published day,
56+
/// so a lone post-midnight bar doesn't spill into the tomorrow chart.
57+
fn aggregate(entries: Vec<NpsEntry>, today: NaiveDate) -> SpotResponse {
5158
// (local date, hour) -> (price sum, count, hour-start datetime)
5259
let mut buckets: BTreeMap<(NaiveDate, u32), (f64, u32, DateTime<Tz>)> = BTreeMap::new();
5360
for e in &entries {
@@ -67,35 +74,33 @@ fn aggregate(entries: Vec<NpsEntry>) -> SpotResponse {
6774
slot.1 += 1;
6875
}
6976

70-
let mut dates: Vec<NaiveDate> = buckets.keys().map(|(d, _)| *d).collect();
71-
dates.dedup(); // BTreeMap keys are sorted, so dups are adjacent
72-
73-
let to_hours = |target: Option<NaiveDate>| -> Vec<HourPrice> {
74-
let Some(target) = target else {
75-
return Vec::new();
76-
};
77+
let hours_for = |date: NaiveDate| -> Vec<HourPrice> {
7778
buckets
7879
.iter()
79-
.filter(|((d, _), _)| *d == target)
80+
.filter(|((d, _), _)| *d == date)
8081
.map(|(_, (sum, count, hour_start))| HourPrice {
8182
hour: hour_start.to_rfc3339(),
8283
price: (sum / *count as f64) / 10.0 * VAT, // EUR/MWh ex-VAT -> c/kWh incl VAT
8384
})
8485
.collect()
8586
};
8687

87-
let today = to_hours(dates.first().copied());
88-
let tomorrow = to_hours(dates.get(1).copied());
89-
let today_average = if today.is_empty() {
88+
let today_v = hours_for(today);
89+
let tomorrow_v = today
90+
.succ_opt()
91+
.map(hours_for)
92+
.filter(|h| h.len() >= MIN_TOMORROW_HOURS)
93+
.unwrap_or_default();
94+
let today_average = if today_v.is_empty() {
9095
0.0
9196
} else {
92-
today.iter().map(|h| h.price).sum::<f64>() / today.len() as f64
97+
today_v.iter().map(|h| h.price).sum::<f64>() / today_v.len() as f64
9398
};
9499

95100
SpotResponse {
96101
unit: "c/kWh".into(),
97-
today,
98-
tomorrow,
102+
today: today_v,
103+
tomorrow: tomorrow_v,
99104
today_average,
100105
}
101106
}

0 commit comments

Comments
 (0)