1use 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
18static 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#[derive(Debug, Error)]
28pub enum ApiError {
29 #[error("HTTP request error: {0}")]
31 HttpError(#[from] reqwest::Error),
32 #[error("REST error {status}: {body}")]
34 RestError { status: StatusCode, body: String },
35 #[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
98pub 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 NaiveDateTime::parse_from_str(date, "%Y-%m-%dT%H:%MZ")
133}
134
135fn 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 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 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 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
177pub 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 let tasks: Vec<_> = ranges
210 .into_iter()
211 .map(|(start_date, end_date)| {
212 let start_date = start_date + Duration::minutes(1);
214 let end_date = end_date + Duration::minutes(1);
215 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<_>>>() .map(|nested_tuples| nested_tuples.into_iter().flatten().collect())
242}
243
244fn 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
256fn validate_date(date: NaiveDateTime) -> NaiveDateTime {
265 let now = Local::now().naive_local();
266
267 if date < *OLDEST_VALID_DATE {
269 return *OLDEST_VALID_DATE;
270 }
271 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
289async 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
306async 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
321async fn get_instant_data(url: &str) -> Result<Root> {
323 get_response::<Root>(url).await
324}
325
326async 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 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 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 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 let result = normalise_dates("not a date", &None);
434 assert!(matches!(result, Err(ApiError::DateParseError(_))));
435
436 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 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 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 assert_eq!(start, expected_start);
477 assert_eq!(end.trunc_subsecs(0), now.trunc_subsecs(0));
479 }
480
481 #[test]
482 fn normalise_dates_splitting() {
483 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 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 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 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 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 let old = test_date_time("1980-12-31");
537 let datetime = validate_date(old);
538 assert_eq!(datetime, oldest_date);
539 }
540}