carbonintensity/
lib.rs

1//! API for retrieving data from the Carbon Intensity API
2//! <https://api.carbonintensity.org.uk/>
3
4use futures::future;
5use std::sync::LazyLock;
6
7use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime};
8use reqwest::{Client, StatusCode};
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use thiserror::Error;
11
12mod region;
13mod target;
14
15pub use region::Region;
16pub use target::Target;
17
18// oldest entry available for 2018-05-10 23:30:00
19static OLDEST_VALID_DATE: LazyLock<NaiveDateTime> = LazyLock::new(|| {
20    NaiveDate::from_ymd_opt(2018, 5, 10)
21        .unwrap()
22        .and_hms_opt(23, 30, 0)
23        .unwrap()
24});
25
26/// An error communicating with the Carbon Intensity API.
27#[derive(Debug, Error)]
28pub enum ApiError {
29    /// There was an error making the HTTP request.
30    #[error("HTTP request error: {0}")]
31    HttpError(#[from] reqwest::Error),
32    /// A REST API method returned an error status.
33    #[error("REST error {status}: {body}")]
34    RestError { status: StatusCode, body: String },
35    /// There was an error parsing a URL from a string.
36    #[error("Error parsing URL: {0}")]
37    UrlParseError(#[from] url::ParseError),
38    #[error("Error parsing date: {0}")]
39    DateParseError(#[from] chrono::ParseError),
40    #[error("Error executing concurrent task: {0}")]
41    ConcurrentTaskFailedError(#[from] tokio::task::JoinError),
42    #[error("Error: {0}")]
43    Error(String),
44}
45
46pub type Result<T> = std::result::Result<T, ApiError>;
47
48pub type IntensityForDate = (NaiveDateTime, i32);
49
50#[derive(Debug, Serialize, Deserialize)]
51pub struct GenerationMix {
52    fuel: String,
53    perc: f64,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct Intensity {
58    forecast: i32,
59    index: String,
60    actual: Option<i32>,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64pub struct Data {
65    from: String,
66    to: String,
67    intensity: Intensity,
68    generationmix: Option<Vec<GenerationMix>>,
69}
70
71#[serde_with::skip_serializing_none]
72#[derive(Debug, Serialize, Deserialize)]
73pub struct RegionData {
74    regionid: i32,
75    dnoregion: Option<String>,
76    shortname: String,
77    postcode: Option<String>,
78    data: Vec<Data>,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82struct Root {
83    data: Vec<RegionData>,
84}
85
86#[derive(Debug, Serialize, Deserialize)]
87struct PowerData {
88    data: RegionData,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92struct NationalData {
93    data: Vec<Data>,
94}
95
96static BASE_URL: &str = "https://api.carbonintensity.org.uk";
97
98/// Current carbon intensity for a target (e.g. a region)
99///
100/// Uses one of
101/// - <https://api.carbonintensity.org.uk/regional/postcode/>
102/// - <https://api.carbonintensity.org.uk/regional/regionid/>
103/// - <https://api.carbonintensity.org.uk/intensity>
104pub async fn get_intensity(target: &Target) -> Result<i32> {
105    let path = match target {
106        Target::Postcode(postcode) => {
107            if postcode.len() < 2 || postcode.len() > 4 {
108                return Err(ApiError::Error("Invalid postcode".to_string()));
109            }
110            format!("regional/postcode/{postcode}")
111        }
112        &Target::Region(region) => {
113            let region_id = region as u8;
114            format!("regional/regionid/{region_id}")
115        }
116        &Target::National => "intensity".to_string(),
117    };
118
119    let url = format!("{BASE_URL}/{path}");
120    if *target != Target::National {
121        get_intensity_for_url(&url).await
122    } else {
123        get_intensity_for_url_national(&url).await
124    }
125}
126
127fn parse_date(date: &str) -> std::result::Result<NaiveDateTime, chrono::ParseError> {
128    if let Ok(date) = NaiveDate::parse_from_str(date, "%Y-%m-%d") {
129        return Ok(date.and_hms_opt(0, 0, 0).unwrap());
130    }
131    // try the longest form or fail
132    NaiveDateTime::parse_from_str(date, "%Y-%m-%dT%H:%MZ")
133}
134
135/// Normalises the start and end dates
136/// returns ranges that are acceptable by the API
137/// both in their duration and string representation
138fn normalise_dates(start: &str, end: &Option<&str>) -> Result<Vec<(NaiveDateTime, NaiveDateTime)>> {
139    let start_date = parse_date(start)?;
140
141    let now = Local::now().naive_local();
142
143    // if the end is not set - use now
144    let end_date = match end {
145        None => now,
146        Some(end_date) => parse_date(end_date)?,
147    };
148
149    let start_date = validate_date(start_date);
150    let end_date = validate_date(end_date);
151
152    //  split into ranges
153    let mut ranges = Vec::new();
154
155    let duration = Duration::days(13);
156    let mut current = start_date;
157    loop {
158        let mut next_end = current + duration;
159        // break the end of year boundary
160        let new_year_d = NaiveDate::from_ymd_opt(current.year() + 1, 1, 1).unwrap();
161        let new_year = NaiveDateTime::new(new_year_d, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
162        if next_end >= new_year {
163            next_end = new_year;
164        }
165        if next_end >= end_date {
166            ranges.push((current, end_date));
167            break;
168        } else {
169            ranges.push((current, next_end));
170        }
171
172        current = next_end;
173    }
174    Ok(ranges)
175}
176
177/// Get intensities for a given target (region or postcode) in 30 minutes windows
178///
179/// Dates are strings in ISO-8601 format YYYY-MM-DDThh:mmZ
180/// but YYYY-MM-DD is tolerated
181///
182/// Uses one of
183/// - https://api.carbonintensity.org.uk/regional/intensity/2023-05-15/2023-05-20/postcode/RG10
184/// - https://api.carbonintensity.org.uk/regional/intensity/2023-05-15/2023-05-20/regionid/13
185/// - https://api.carbonintensity.org.uk/intensity/2023-05-15/2023-05-20/
186pub async fn get_intensities(
187    target: &Target,
188    start: &str,
189    end: &Option<&str>,
190) -> Result<Vec<IntensityForDate>> {
191    let path = match target {
192        Target::Postcode(postcode) => {
193            if postcode.len() < 2 || postcode.len() > 4 {
194                return Err(ApiError::Error("Invalid postcode".to_string()));
195            }
196
197            format!("postcode/{postcode}")
198        }
199        &Target::Region(region) => {
200            let region_id = region as u8;
201            format!("regionid/{region_id}")
202        }
203        &Target::National => "intensity".to_string(),
204    };
205
206    let ranges = normalise_dates(start, end)?;
207
208    // Spawns concurrent tasks...
209    let tasks: Vec<_> = ranges
210        .into_iter()
211        .map(|(start_date, end_date)| {
212            // shift dates by one minute
213            let start_date = start_date + Duration::minutes(1);
214            let end_date = end_date + Duration::minutes(1);
215            // format dates
216            let start_date = start_date.format("%Y-%m-%dT%H:%MZ");
217            let end_date = end_date.format("%Y-%m-%dT%H:%MZ");
218
219            if *target != Target::National {
220                let url = format!("{BASE_URL}/regional/intensity/{start_date}/{end_date}/{path}");
221
222                tokio::spawn(async move {
223                    let region_data = get_intensities_for_url(&url).await?;
224                    to_tuples(region_data.data)
225                })
226            } else {
227                let url = format!("{BASE_URL}/{path}/{start_date}/{end_date}/");
228
229                tokio::spawn(async move {
230                    let national_data = get_intensities_for_url_national(&url).await?;
231                    to_tuples(national_data.data)
232                })
233            }
234        })
235        .collect();
236
237    let tasks_results = future::try_join_all(tasks).await?;
238    tasks_results
239        .into_iter()
240        .collect::<Result<Vec<_>>>() // convert to single Result
241        .map(|nested_tuples| nested_tuples.into_iter().flatten().collect())
242}
243
244/// converts the values from JSON into a simpler
245/// representation Vec<DateTime, float>
246fn to_tuples(data: Vec<Data>) -> Result<Vec<IntensityForDate>> {
247    data.into_iter()
248        .map(|datum| {
249            let start_date = parse_date(&datum.from)?;
250            let intensity = datum.intensity.actual.unwrap_or(datum.intensity.forecast);
251            Ok((start_date, intensity))
252        })
253        .collect()
254}
255
256/// Returns a date within a valid date
257///
258/// Datetimes older than 2018-05-10 23:30:00 are invalid.
259/// Also, datetimes in the future are invalid.
260///
261/// - if a datetime is too old, returns the oldest valid date
262/// - if a datetime is in the future, returns now
263/// - otherwise returns the input datetime
264fn validate_date(date: NaiveDateTime) -> NaiveDateTime {
265    let now = Local::now().naive_local();
266
267    // check if date is too old
268    if date < *OLDEST_VALID_DATE {
269        return *OLDEST_VALID_DATE;
270    }
271    // check that the date is not in the future
272    if date > now {
273        return now;
274    }
275
276    date
277}
278
279async fn get_intensities_for_url(url: &str) -> Result<RegionData> {
280    let PowerData { data } = get_response(url).await?;
281    Ok(data)
282}
283
284async fn get_intensities_for_url_national(url: &str) -> Result<NationalData> {
285    let data = get_response::<NationalData>(url).await?;
286    Ok(data)
287}
288
289/// Retrieves the intensity value from a structure
290async fn get_intensity_for_url(url: &str) -> Result<i32> {
291    let result = get_instant_data(url).await?;
292
293    let intensity = result
294        .data
295        .first()
296        .ok_or_else(|| ApiError::Error("No data found".to_string()))?
297        .data
298        .first()
299        .ok_or_else(|| ApiError::Error("No intensity data found".to_string()))?
300        .intensity
301        .forecast;
302
303    Ok(intensity)
304}
305
306/// Retrieves the intensity value from a structure
307async fn get_intensity_for_url_national(url: &str) -> Result<i32> {
308    let result = get_response::<NationalData>(url).await?;
309
310    let intensity = result
311        .data
312        .first()
313        .ok_or_else(|| ApiError::Error("No data found".to_string()))?
314        .intensity
315        .actual
316        .unwrap();
317
318    Ok(intensity)
319}
320
321// Internal method to handle the querying and parsing
322async fn get_instant_data(url: &str) -> Result<Root> {
323    get_response::<Root>(url).await
324}
325
326/// Makes a GET request to the given URL
327///
328/// Deserialise the JSON response as `T` and returns Ok<T> if all is well.
329/// Returns an `ApiError` when the HTTP request failed or the response body
330/// couldn't be deserialised as a `T` value.
331async fn get_response<T>(url: &str) -> Result<T>
332where
333    T: DeserializeOwned,
334{
335    let client = Client::new();
336    #[cfg(debug_assertions)]
337    eprintln!("GET {url}");
338    let response = client.get(url).send().await?;
339
340    let status = response.status();
341    if !status.is_success() {
342        let body = response.text().await?;
343        return Err(ApiError::RestError { status, body });
344    }
345
346    let target = response.json::<T>().await?;
347    Ok(target)
348}
349
350#[cfg(test)]
351mod tests {
352
353    use std::str::FromStr;
354
355    use chrono::{Days, Months, SubsecRound};
356
357    use super::*;
358
359    impl Data {
360        fn test_data(from: &str, to: &str, intensity: i32) -> Self {
361            Self {
362                from: from.to_string(),
363                to: to.to_string(),
364                intensity: Intensity {
365                    forecast: intensity,
366                    index: "very high".to_string(),
367                    actual: None,
368                },
369                generationmix: Option::from(vec![
370                    GenerationMix {
371                        fuel: "gas".to_string(),
372                        perc: 80.0,
373                    },
374                    GenerationMix {
375                        fuel: "wind".to_string(),
376                        perc: 10.0,
377                    },
378                    GenerationMix {
379                        fuel: "other".to_string(),
380                        perc: 10.0,
381                    },
382                ]),
383            }
384        }
385    }
386
387    /// Returns a NaiveDateTime from a string slice. Assumes input is valid
388    fn test_date_time(date: &str) -> NaiveDateTime {
389        NaiveDate::from_str(date)
390            .unwrap()
391            .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
392    }
393
394    #[test]
395    fn to_tuples_test() {
396        // One of the dates is invalid
397        let data = vec![
398            Data::test_data("2024-01-01", "2024-02-01", 350),
399            Data::test_data("Invalid", "2024-03-01", 300),
400            Data::test_data("2024-03-01", "2024-04-01", 250),
401        ];
402        let result = to_tuples(data);
403        assert!(matches!(result, Err(ApiError::DateParseError(_))));
404
405        // Happy path
406        let data = vec![
407            Data::test_data("2024-01-01", "2024-02-01", 350),
408            Data::test_data("2024-02-01", "2024-03-01", 300),
409        ];
410        let result = to_tuples(data);
411
412        let jan = test_date_time("2024-01-01");
413        let feb = test_date_time("2024-02-01");
414        let expected = vec![(jan, 350), (feb, 300)];
415
416        assert!(result.is_ok());
417        assert_eq!(result.unwrap(), expected);
418    }
419
420    #[test]
421    fn deserialise_power_data_test() {
422        let json_str = r#"
423        {"data":{"regionid":11,"shortname":"South West England","postcode":"BS7","data":[{"from":"2022-12-31T23:30Z","to":"2023-01-01T00:00Z","intensity":{"forecast":152,"index":"moderate"},"generationmix":[{"fuel":"biomass","perc":1.4},{"fuel":"coal","perc":3.3},{"fuel":"imports","perc":14.3},{"fuel":"gas","perc":28.5},{"fuel":"nuclear","perc":7},{"fuel":"other","perc":0},{"fuel":"hydro","perc":0.5},{"fuel":"solar","perc":0},{"fuel":"wind","perc":45.1}]},{"from":"2023-01-01T00:00Z","to":"2023-01-01T00:30Z","intensity":{"forecast":181,"index":"moderate"},"generationmix":[{"fuel":"biomass","perc":1.4},{"fuel":"coal","perc":3.4},{"fuel":"imports","perc":9.1},{"fuel":"gas","perc":36.1},{"fuel":"nuclear","perc":6.8},{"fuel":"other","perc":0},{"fuel":"hydro","perc":0.4},{"fuel":"solar","perc":0},{"fuel":"wind","perc":42.8}]},{"from":"2023-01-01T00:30Z","to":"2023-01-01T01:00Z","intensity":{"forecast":189,"index":"moderate"},"generationmix":[{"fuel":"biomass","perc":1.3},{"fuel":"coal","perc":3.4},{"fuel":"imports","perc":12.1},{"fuel":"gas","perc":37.6},{"fuel":"nuclear","perc":6.4},{"fuel":"other","perc":0},{"fuel":"hydro","perc":0.4},{"fuel":"solar","perc":0},{"fuel":"wind","perc":38.8}]},{"from":"2023-01-01T01:00Z","to":"2023-01-01T01:30Z","intensity":{"forecast":183,"index":"moderate"},"generationmix":[{"fuel":"biomass","perc":1.7},{"fuel":"coal","perc":3.2},{"fuel":"imports","perc":6.1},{"fuel":"gas","perc":37.3},{"fuel":"nuclear","perc":7.3},{"fuel":"other","perc":0},{"fuel":"hydro","perc":0.4},{"fuel":"solar","perc":0},{"fuel":"wind","perc":44}]},{"from":"2023-01-01T01:30Z","to":"2023-01-01T02:00Z","intensity":{"forecast":175,"index":"moderate"},"generationmix":[{"fuel":"biomass","perc":1.5},{"fuel":"coal","perc":2.9},{"fuel":"imports","perc":6.6},{"fuel":"gas","perc":36},{"fuel":"nuclear","perc":7.2},{"fuel":"other","perc":0},{"fuel":"hydro","perc":0.4},{"fuel":"solar","perc":0},{"fuel":"wind","perc":45.5}]}]}}
424    "#;
425
426        let _result: std::result::Result<PowerData, serde_json::Error> =
427            serde_json::from_str(json_str);
428    }
429
430    #[test]
431    fn normalise_dates_invalid() {
432        // Invalid start date
433        let result = normalise_dates("not a date", &None);
434        assert!(matches!(result, Err(ApiError::DateParseError(_))));
435
436        // Invalid end date
437        let result = normalise_dates("2024-01-01", &Some("not a date"));
438        assert!(matches!(result, Err(ApiError::DateParseError(_))));
439    }
440
441    #[test]
442    fn normalise_dates_too_old() {
443        let oldest_valid_date = NaiveDate::from_ymd_opt(2018, 5, 10)
444            .unwrap()
445            .and_hms_opt(23, 30, 0)
446            .unwrap();
447
448        // Start date too old
449        let result = normalise_dates("1111-01-01", &Some("2018-05-15"));
450        assert!(result.is_ok());
451
452        let ranges = result.unwrap();
453        assert_eq!(ranges.len(), 1);
454
455        let expected = vec![(oldest_valid_date, test_date_time("2018-05-15"))];
456        assert_eq!(ranges, expected);
457    }
458
459    #[test]
460    fn normalise_dates_future() {
461        // End date in the future
462        let now = Local::now().naive_local();
463        let five_days = Days::new(5);
464        let five_days_ago = now.checked_sub_days(five_days).unwrap().date();
465        let in_five_days = now.checked_add_days(five_days).unwrap().date();
466
467        let result = normalise_dates(&five_days_ago.to_string(), &Some(&in_five_days.to_string()));
468        assert!(result.is_ok());
469
470        let ranges = result.unwrap();
471        assert_eq!(ranges.len(), 1);
472
473        let (start, end) = ranges[0];
474        let expected_start = five_days_ago.and_hms_opt(0, 0, 0).unwrap();
475        // start unchanged
476        assert_eq!(start, expected_start);
477        // end became now because it was in the future
478        assert_eq!(end.trunc_subsecs(0), now.trunc_subsecs(0));
479    }
480
481    #[test]
482    fn normalise_dates_splitting() {
483        // Ranges splitting logic
484        let result = normalise_dates("2022-12-01", &Some("2023-01-01"));
485        assert!(result.is_ok());
486        let ranges = result.unwrap();
487        let expected = vec![
488            (test_date_time("2022-12-01"), test_date_time("2022-12-14")),
489            (test_date_time("2022-12-14"), test_date_time("2022-12-27")),
490            (test_date_time("2022-12-27"), test_date_time("2023-01-01")),
491        ];
492        assert_eq!(ranges, expected);
493    }
494
495    #[test]
496    fn normalise_dates_skipping_year() {
497        // Ranges spanning 2 year. See: https://github.com/jnioche/carbonintensity-api/issues/6
498        // The API doesn't cope well with ranges spanning more than one year.
499        // If end_date is in a different year the API would use year end as
500        // end_date and don't return any values beyond that datetime.
501        let result = normalise_dates("2022-12-31", &Some("2023-01-02"));
502        assert!(result.is_ok());
503        let ranges = result.unwrap();
504        let expected = vec![
505            (test_date_time("2022-12-31"), test_date_time("2023-01-01")),
506            (test_date_time("2023-01-01"), test_date_time("2023-01-02")),
507        ];
508        assert_eq!(ranges, expected);
509    }
510
511    #[test]
512    fn validate_date_test() {
513        // valid dates just returned as-is
514        let just_a_day = test_date_time("2024-07-30");
515        let datetime = validate_date(just_a_day);
516        assert_eq!(datetime.trunc_subsecs(0), just_a_day.trunc_subsecs(0));
517
518        // future dates turns into now
519        let future = Local::now()
520            .naive_local()
521            .checked_add_months(Months::new(2))
522            .unwrap();
523        let datetime = validate_date(future);
524        let now = Local::now().naive_local();
525        assert_eq!(datetime.trunc_subsecs(0), now.trunc_subsecs(0));
526
527        // oldest is fine
528        let oldest_date = NaiveDate::from_ymd_opt(2018, 5, 10)
529            .unwrap()
530            .and_hms_opt(23, 30, 0)
531            .unwrap();
532        let datetime = validate_date(oldest_date);
533        assert_eq!(datetime.trunc_subsecs(0), oldest_date.trunc_subsecs(0));
534
535        // just too old - turn into the oldest valid date
536        let old = test_date_time("1980-12-31");
537        let datetime = validate_date(old);
538        assert_eq!(datetime, oldest_date);
539    }
540}