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}