awattar_api/lib.rs
1#![doc = include_str!("../README.md")]
2#![warn(rust_2018_idioms)]
3
4use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
5use serde::Deserialize;
6use thiserror::Error;
7
8/// A single price slot.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct PriceSlot {
11 /// Start time of this price slot
12 start: DateTime<Utc>,
13 /// End time of this price slot
14 end: DateTime<Utc>,
15 /// Price in Euro-Cents/MWh. The price is stored as an integer to
16 /// avoid floating-point errors.
17 price_cents_per_mwh: i32,
18}
19
20impl PriceSlot {
21 /// DateTime this `PriceSlot` is valid from.
22 pub fn start(&self) -> DateTime<Utc> {
23 self.start
24 }
25
26 /// Non-inclusive DateTime this `PriceSlot` is valid to.
27 pub fn end(&self) -> DateTime<Utc> {
28 self.end
29 }
30
31 /// Price in Euro-Cents/MWh.
32 pub fn price_cents_per_mwh(&self) -> i32 {
33 self.price_cents_per_mwh
34 }
35}
36
37impl TryFrom<AwattarDataItem> for PriceSlot {
38 type Error = AwattarError;
39
40 fn try_from(item: AwattarDataItem) -> Result<Self, Self::Error> {
41 let price_cents_per_mwh = match item.unit.as_str() {
42 "Eur/MWh" => Ok((item.marketprice * 100.0) as i32),
43 _ => Err(AwattarError::UnsupportedResponse(format!(
44 "Unsupported unit {}",
45 item.unit
46 ))),
47 }?;
48
49 Ok(Self {
50 start: Utc.timestamp_millis(item.start_timestamp),
51 end: Utc.timestamp_millis(item.end_timestamp),
52 price_cents_per_mwh,
53 })
54 }
55}
56
57/// Holds a set of price slots and provides some utility functions for working with price data.
58#[derive(Clone, Debug)]
59pub struct PriceData {
60 slots: Vec<PriceSlot>,
61 zone: AwattarZone,
62}
63
64impl PriceData {
65 /// Query prices from the awattar API between the given start- and end-datetime in the given
66 /// zone.
67 ///
68 /// # Examples
69 ///
70 /// ```
71 /// # tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
72 /// use awattar_api::{AwattarZone, PriceData};
73 /// use chrono::{Local, TimeZone};
74 ///
75 /// let prices = PriceData::query(
76 /// AwattarZone::Germany,
77 /// Some(Local.ymd(2022, 08, 1).and_hms(0, 0, 0)),
78 /// Some(Local.ymd(2022, 08, 2).and_hms(0, 0, 0)),
79 /// )
80 /// .await
81 /// .unwrap();
82 /// println!("Prices: {:?}", prices);
83 /// # });
84 /// ```
85 pub async fn query<TZ: TimeZone>(
86 zone: AwattarZone,
87 start: Option<DateTime<TZ>>,
88 end: Option<DateTime<TZ>>,
89 ) -> Result<Self, AwattarError> {
90 let client = reqwest::Client::new();
91 let query_params = [("start", start), ("end", end)]
92 .into_iter()
93 .filter_map(|(param, timestamp)| {
94 Some((param, timestamp?.timestamp_millis().to_string()))
95 })
96 .collect::<Vec<_>>();
97
98 let response = client
99 .get(zone.api_endpoint())
100 .query(&query_params)
101 .send()
102 .await?
103 .json::<AwattarResponse>()
104 .await?;
105
106 Ok(Self::from_slots(
107 response
108 .data
109 .into_iter()
110 .map(PriceSlot::try_from)
111 .collect::<Result<Vec<_>, _>>()?,
112 zone,
113 ))
114 }
115
116 /// Query prices from the awattar API for a given date.
117 ///
118 /// The NaiveDate is converted to a timezone-aware Date using the given [`AwattarZone`]s local
119 /// timezone. This always yields price data from 00:00 on the start date to 00:00 on the
120 /// end-date (24 slots on days without switch between daylight saving and standard time).
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// # tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
126 /// use awattar_api::{AwattarZone, PriceData};
127 /// use chrono::Local;
128 ///
129 /// let prices = PriceData::query_date(AwattarZone::Germany, Local::today().naive_local())
130 /// .await
131 /// .unwrap();
132 /// println!("Prices: {:?}", prices);
133 /// # });
134 /// ```
135 pub async fn query_date(zone: AwattarZone, date: NaiveDate) -> Result<Self, AwattarError> {
136 let start = date
137 .and_hms(0, 0, 0)
138 .and_local_timezone(zone.timezone())
139 .unwrap();
140 let end = (date + Duration::days(1))
141 .and_hms(0, 0, 0)
142 .and_local_timezone(zone.timezone())
143 .unwrap();
144
145 Self::query(zone, Some(start), Some(end)).await
146 }
147
148 /// Create a new instance from a [`Vec`] of [`PriceSlot`]s.
149 pub fn from_slots(slots: Vec<PriceSlot>, zone: AwattarZone) -> Self {
150 Self { slots, zone }
151 }
152
153 /// Returns the number of slots this instance is holding.
154 pub fn len(&self) -> usize {
155 self.slots.len()
156 }
157
158 /// Return `true` when this instance contains any [`PriceSlot`]s.
159 pub fn is_empty(&self) -> bool {
160 self.slots.is_empty()
161 }
162
163 /// Return a [`Vec`] of [`PriceSlot`]s this instance is holding.
164 pub fn slots(&self) -> &Vec<PriceSlot> {
165 &self.slots
166 }
167
168 /// Currently only used for deprecated API, but could be turned into a public
169 /// API if there is any need for it.
170 fn into_slots(self) -> Vec<PriceSlot> {
171 self.slots
172 }
173
174 /// Provides an iterator over all [`PriceSlot`]s this instance holds. Useful for things like
175 /// calculating the average price over a day.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// # tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
181 /// use awattar_api::{AwattarZone, PriceData};
182 /// use chrono::Local;
183 ///
184 /// let prices = PriceData::query_date(AwattarZone::Germany, Local::today().naive_local())
185 /// .await
186 /// .unwrap();
187 /// let avg_price = prices.slots_iter().fold(0, |sum, slot| sum + slot.price_cents_per_mwh())
188 /// / prices.len() as i32;
189 /// # });
190 /// ```
191 pub fn slots_iter(&self) -> impl Iterator<Item = &PriceSlot> {
192 self.slots.iter()
193 }
194
195 /// Finds and returns the [`PriceSlot`] for the given datetime.
196 ///
197 /// If no slot could be found, `None` is returned.
198 pub fn slot_for_datetime<TZ: TimeZone>(&self, datetime: DateTime<TZ>) -> Option<&PriceSlot> {
199 self.slots
200 .iter()
201 .find(|slot| slot.start() >= datetime && slot.end < datetime)
202 }
203
204 /// Returns the [`PriceSlot`] with the lowest price.
205 ///
206 /// If this instance does not contain any price slots, `None` is returned.
207 pub fn min_price(&self) -> Option<&PriceSlot> {
208 self.slots
209 .iter()
210 .min_by_key(|slot| slot.price_cents_per_mwh())
211 }
212
213 /// Returns the [`PriceSlot`] with the highest price.
214 ///
215 /// If this instance does not contain any price slots, `None` is returned.
216 pub fn max_price(&self) -> Option<&PriceSlot> {
217 self.slots
218 .iter()
219 .max_by_key(|slot| slot.price_cents_per_mwh())
220 }
221
222 /// Returns the zone this instance belongs in.
223 pub fn zone(&self) -> AwattarZone {
224 self.zone
225 }
226}
227
228/// Struct for deserialzing time-slots from the awattar API.
229#[derive(Deserialize)]
230struct AwattarDataItem {
231 start_timestamp: i64,
232 end_timestamp: i64,
233 marketprice: f32,
234 unit: String,
235}
236
237/// Struct for deserializing the JSON response from the awattar API.
238#[derive(Deserialize)]
239struct AwattarResponse {
240 data: Vec<AwattarDataItem>,
241}
242
243/// Common error enum for this crate.
244#[derive(Error, Debug)]
245pub enum AwattarError {
246 #[error("HTTP request error")]
247 Reqwest(#[from] reqwest::Error),
248 #[error("API responded with an unsupported response")]
249 UnsupportedResponse(String),
250}
251
252/// Zone for awattar prices.
253///
254/// Currently supports Austria and Germany, but could expand in the future as Germany might
255/// split their price zones or awattar adds support for further countries.
256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
257pub enum AwattarZone {
258 /// Prices for Austria
259 Austria,
260 /// Prices for Germany
261 Germany,
262}
263
264impl AwattarZone {
265 /// Returns the API endpoint for the given zone.
266 pub const fn api_endpoint(&self) -> &'static str {
267 match self {
268 AwattarZone::Austria => "https://api.awattar.at/v1/marketdata",
269 AwattarZone::Germany => "https://api.awattar.de/v1/marketdata",
270 }
271 }
272
273 /// Returns the `TimeZone` of the [`AwattarZone`].
274 ///
275 /// While all currently support zones have the same TZ, it's not unheard of that a
276 /// country might eliminate DST or future zones may have different timezones.
277 ///
278 /// This especially comes in handy when you want to query times from the first to the
279 /// last hour of a day (i.e. full 24 hours).
280 pub const fn timezone(&self) -> chrono_tz::Tz {
281 match self {
282 AwattarZone::Austria => chrono_tz::Europe::Vienna,
283 AwattarZone::Germany => chrono_tz::Europe::Berlin,
284 }
285 }
286}
287
288/// Query prices from the API in the given `zone` with an optional `start` and `end`
289/// DateTime.
290///
291/// Supplying only `start` returns only prices from `start` up until `start` + 24 hours.
292/// Supplying `start` and `end` returns all prices within the given datetimes (within the
293/// limits of the API).
294/// Supplying neither `start` nor `end` returns all prices starting now, up to 24 hours
295/// into the future. Better use `query_prices_now()` as a convencience function in this
296/// case.
297#[deprecated(since = "0.2.0", note = "Use `PriceData::query` instead")]
298pub async fn query_prices<TZ>(
299 zone: AwattarZone,
300 start: Option<DateTime<TZ>>,
301 end: Option<DateTime<TZ>>,
302) -> Result<Vec<PriceSlot>, AwattarError>
303where
304 TZ: TimeZone,
305{
306 Ok(PriceData::query(zone, start, end).await?.into_slots())
307}
308
309/// This is a shortcut for `query_prices::<Utc>(zone, None, None)`.
310#[deprecated(since = "0.2.0", note = "Use `PriceData::query` instead")]
311pub async fn query_prices_now(zone: AwattarZone) -> Result<Vec<PriceSlot>, AwattarError> {
312 Ok(PriceData::query::<Utc>(zone, None, None)
313 .await?
314 .into_slots())
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_priceslot_from_item() {
323 let item = AwattarDataItem {
324 start_timestamp: 1428591600000,
325 end_timestamp: 1428595200000,
326 marketprice: 42.09,
327 unit: "Eur/MWh".to_owned(),
328 };
329
330 let slot = PriceSlot::try_from(item).unwrap();
331
332 assert_eq!(slot.start, Utc.timestamp_millis(1428591600000));
333 assert_eq!(slot.end, Utc.timestamp_millis(1428595200000));
334 assert_eq!(slot.price_cents_per_mwh, 4209);
335 }
336
337 #[test]
338 fn test_priceslot_from_item_negative() {
339 let item = AwattarDataItem {
340 start_timestamp: 0,
341 end_timestamp: 0,
342 marketprice: -42.09,
343 unit: "Eur/MWh".to_owned(),
344 };
345
346 let slot = PriceSlot::try_from(item).unwrap();
347
348 assert_eq!(slot.price_cents_per_mwh(), -4209);
349 }
350}