diff --git a/Cargo.toml b/Cargo.toml index 602f9db..6a1003a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -serde = { version = "1.0", features = ["derive"] } - # process JSON from https +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0", default_features = false } # perform requests to the internet diff --git a/examples/dk_jets.md b/examples/dk_jets.md new file mode 100644 index 0000000..f460e96 --- /dev/null +++ b/examples/dk_jets.md @@ -0,0 +1,24 @@ +# Use of Danish private jets + +{from_date} to {to_date}, Danish private jets emitted +{emissions_tons.claim} tons of CO2e[^5], the equivalent of what **{dane_years.claim} Danes** +emit in a year[^4]. + +Of these, +* {number_of_legs_less_300km} trips were less than 300 km and could have been replaced by + a limusine or first class train ticket, which would have emitted ~30-50x less CO2. +* {number_of_legs_more_300km} trips could have been replaced by + a first class ticket on a commercial aircraft, which would have emitted + ~{ratio_commercial_300km}x less CO2[^5]. + +Billionaires and companies alike are incapable of regulating their emissions, +recklessly destroying the common good. +Ban private jets now and until they emit what equivalent means of transportation would emit. + +## References + +[^2]: {number_of_legs.source} - retrieved on {number_of_legs.date} +[^4]: {dane_years.source} - retrieved on {dane_years.date} +[^5]: {emissions_tons.source} - retrieved on {emissions_tons.date} + +Copyright Jorge Leitão, released under [CC0](https://creativecommons.org/public-domain/cc0/) - No Rights Reserved. diff --git a/examples/dk_jets.rs b/examples/dk_jets.rs new file mode 100644 index 0000000..52c07ee --- /dev/null +++ b/examples/dk_jets.rs @@ -0,0 +1,198 @@ +use std::{collections::HashMap, error::Error, sync::Arc}; + +use clap::Parser; +use futures::{StreamExt, TryStreamExt}; +use simple_logger::SimpleLogger; + +use flights::{emissions, load_aircraft_types, load_aircrafts, Class, Fact, Position}; +use time::macros::date; + +fn render(context: &Context) -> Result<(), Box> { + let path = "all_dk_jets.md"; + + let template = std::fs::read_to_string("examples/dk_jets.md")?; + + let mut tt = tinytemplate::TinyTemplate::new(); + tt.set_default_formatter(&tinytemplate::format_unescaped); + tt.add_template("t", &template)?; + + let rendered = tt.render("t", context)?; + + log::info!("Story written to {path}"); + std::fs::write(path, rendered)?; + Ok(()) +} + +#[derive(serde::Serialize)] +pub struct Context { + pub from_date: String, + pub to_date: String, + pub number_of_legs: Fact, + pub emissions_tons: Fact, + pub dane_years: Fact, + pub number_of_legs_less_300km: usize, + pub number_of_legs_more_300km: usize, + pub ratio_commercial_300km: String, +} + +#[derive(clap::ValueEnum, Debug, Clone)] +enum Backend { + Disk, + Azure, +} + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// The Azure token + #[arg(short, long)] + azure_sas_token: Option, + #[arg(short, long, value_enum, default_value_t=Backend::Azure)] + backend: Backend, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let cli = Cli::parse(); + + // optionally initialize Azure client + let client = match (cli.backend, cli.azure_sas_token) { + (Backend::Disk, None) => None, + (Backend::Azure, None) => Some(flights::fs_azure::initialize_anonymous( + "privatejets", + "data", + )), + (_, Some(token)) => Some(flights::fs_azure::initialize_sas( + &token, + "privatejets", + "data", + )?), + }; + + // load datasets to memory + let aircrafts = load_aircrafts(client.as_ref()).await?; + let types = load_aircraft_types()?; + + let dk_private_jets = aircrafts + .into_iter() + // is private jet + .filter(|(_, a)| types.contains_key(&a.model)) + // is from DK + .filter(|(a, _)| a.starts_with("OY-")) + .collect::>(); + + let to = time::OffsetDateTime::now_utc().date() - time::Duration::days(1); + let from = date!(2021 - 01 - 01); + + let from_date = from.to_string(); + let to_date = to.to_string(); + + let dates = flights::DateIter { + from, + to, + increment: time::Duration::days(1), + }; + + let iter = dates + .map(|date| { + let client = client.as_ref(); + dk_private_jets + .iter() + .map(move |(_, a)| flights::positions(&a.icao_number, date.clone(), 1000.0, client)) + }) + .flatten(); + + let positions = futures::stream::iter(iter) + // limit to 5 concurrent tasks + .buffer_unordered(100) + .try_collect::>() + .await?; + let positions = positions.into_iter().flatten().collect::>(); + + // group by aircraft + let mut positions = positions.into_iter().fold( + HashMap::, Vec>::default(), + |mut acc, v| { + acc.entry(v.icao().clone()) + .and_modify(|positions| positions.push(v.clone())) + .or_insert_with(|| vec![v]); + acc + }, + ); + // sort positions by datetime + positions.iter_mut().for_each(|(_, positions)| { + positions.sort_unstable_by_key(|x| x.datetime()); + }); + + // compute legs + let legs = positions + .into_iter() + .map(|(icao, positions)| (icao, flights::real_legs(positions.into_iter()))) + .collect::>(); + + let number_of_legs = Fact { + claim: legs.iter().map(|(_, legs)| legs.len()).sum::(), + source: format!("[adsbexchange.com](https://globe.adsbexchange.com) between {from_date} and {to_date} and all aircraft whose tail number starts with \"OY-\" and model is a private jet"), + date: to.to_string() + }; + + let commercial_to_private_ratio = 10.0; + let commercial_emissions_tons = legs + .iter() + .map(|(_, legs)| { + legs.iter() + .map(|leg| emissions(leg.from.pos(), leg.to.pos(), Class::First) / 1000.0) + .sum::() + }) + .sum::(); + let emissions_tons = Fact { + claim: (commercial_emissions_tons * commercial_to_private_ratio) as usize, + source: format!("Commercial flights would have emitted {commercial_emissions_tons:.1} tons of CO2e (based on [myclimate.org](https://www.myclimate.org/en/information/about-myclimate/downloads/flight-emission-calculator/) - retrieved on 2023-10-19). Private jets emit 5-14x times. 10x was used based on [transportenvironment.org](https://www.transportenvironment.org/discover/private-jets-can-the-super-rich-supercharge-zero-emission-aviation/)"), + date: "2023-10-05, from 2021-05-27".to_string(), + }; + + let short_legs = legs + .iter() + .map(|(_, legs)| legs.iter().filter(|leg| leg.distance() < 300.0).count()) + .sum::(); + let long_legs = legs + .iter() + .map(|(_, legs)| legs.iter().filter(|leg| leg.distance() >= 300.0).count()) + .sum::(); + + let dane_emissions_tons = Fact { + claim: 5.1, + source: "A dane emitted 5.1 t CO2/person/year in 2019 according to [work bank data](https://ourworldindata.org/co2/country/denmark).".to_string(), + date: "2023-10-08".to_string(), + }; + + let dane_years = format!( + "{:.0}", + emissions_tons.claim as f32 / dane_emissions_tons.claim as f32 + ); + let dane_years = Fact { + claim: dane_years, + source: "https://ourworldindata.org/co2/country/denmark Denmark emits 5.1 t CO2/person/year in 2019.".to_string(), + date: "2023-10-08".to_string(), + }; + + let context = Context { + from_date, + to_date, + number_of_legs, + emissions_tons, + dane_years, + number_of_legs_less_300km: short_legs, + number_of_legs_more_300km: long_legs, + ratio_commercial_300km: format!("{:.0}", commercial_to_private_ratio), + }; + + render(&context)?; + + Ok(()) +} diff --git a/examples/period.rs b/examples/period.rs index 0d718ef..a447bdc 100644 --- a/examples/period.rs +++ b/examples/period.rs @@ -82,9 +82,9 @@ async fn main() -> Result<(), Box> { let aircrafts = load_aircrafts(client.as_ref()).await?; let to = time::OffsetDateTime::now_utc().date() - time::Duration::days(1); - let from = to - time::Duration::days(90); + let from = to - time::Duration::days(365 * 3); - let tail_number = "OY-GFS"; + let tail_number = "OY-DBS"; let aircraft = aircrafts .get(tail_number) .ok_or_else(|| Into::>::into("Aircraft ICAO number not found"))? @@ -118,16 +118,7 @@ async fn main() -> Result<(), Box> { let mut positions = positions.into_iter().flatten().collect::>(); positions.sort_unstable_by_key(|x| x.datetime()); - let legs = flights::legs(positions.into_iter()); - let legs = legs - .into_iter() - // ignore legs that are too fast, as they are likely noise - .filter(|leg| leg.duration() > time::Duration::minutes(5)) - // ignore legs that are too short, as they are likely noise - .filter(|leg| leg.distance() > 3.0) - // ignore legs that are too low, as they are likely noise - .filter(|leg| leg.maximum_altitude > 1000.0) - .collect::>(); + let legs = flights::real_legs(positions.into_iter()); log::info!("number_of_legs: {}", legs.len()); for leg in &legs { log::info!( diff --git a/src/aircraft_db.rs b/src/aircraft_db.rs index 5c0b5d1..286f5ed 100644 --- a/src/aircraft_db.rs +++ b/src/aircraft_db.rs @@ -1,6 +1,6 @@ -/// Contains the implementation to extract the database of all aircrafts available in ADS-B exchange -use std::collections::HashMap; use std::error::Error; +/// Contains the implementation to extract the database of all aircrafts available in ADS-B exchange +use std::{collections::HashMap, sync::Arc}; use async_recursion::async_recursion; use reqwest; @@ -16,7 +16,7 @@ pub type Aircrafts = HashMap; #[derive(Deserialize, Serialize, Debug, Clone)] pub struct Aircraft { /// The ICAO number of the aircraft (e.g. `459CD3`) - pub icao_number: String, + pub icao_number: Arc, /// The tail number of the aircraft (e.g. `OY-GFS`) pub tail_number: String, /// The ICAO number of the aicraft model (e.g. `F2TH`) @@ -131,7 +131,7 @@ pub async fn load_aircrafts( Some(( tail_number.clone(), Aircraft { - icao_number: icao_number.to_ascii_lowercase(), + icao_number: icao_number.to_ascii_lowercase().into(), tail_number, model, }, diff --git a/src/icao_to_trace.rs b/src/icao_to_trace.rs index fedcf77..109347c 100644 --- a/src/icao_to_trace.rs +++ b/src/icao_to_trace.rs @@ -1,4 +1,5 @@ use std::error::Error; +use std::sync::Arc; use rand::Rng; use reqwest::header; @@ -105,7 +106,7 @@ async fn globe_history(icao: &str, date: &time::Date) -> Result, std::io } else { Err(std::io::Error::new::( std::io::ErrorKind::Other, - "could not retrieve data from globe.adsbexchange.com".into(), + response.text().await.map_err(to_io_err)?, ) .into()) } @@ -176,53 +177,61 @@ pub async fn trace_cached( /// Returns an iterator of [`Position`] over the trace of `icao` on day `date` assuming that /// a flight below `threshold` feet is grounded. pub async fn positions( - icao: &str, + icao_number: &str, date: time::Date, threshold: f64, client: Option<&fs_azure::ContainerClient>, ) -> Result, Box> { use time::ext::NumericalDuration; - trace_cached(icao, &date, client).await.map(move |trace| { - trace.into_iter().map(move |entry| { - let time_seconds = entry[0].as_f64().unwrap(); - let time = time::Time::MIDNIGHT + time_seconds.seconds(); - let datetime = PrimitiveDateTime::new(date.clone(), time); - let latitude = entry[1].as_f64().unwrap(); - let longitude = entry[2].as_f64().unwrap(); - entry[3] - .as_str() - .and_then(|x| { - (x == "ground").then_some(Position::Grounded { - datetime, - latitude, - longitude, - }) - }) - .unwrap_or_else(|| { - entry[3] - .as_f64() - .and_then(|altitude| { - Some(if altitude < threshold { - Position::Grounded { - datetime, - latitude, - longitude, - } - } else { - Position::Flying { - datetime, - latitude, - longitude, - altitude, - } - }) - }) - .unwrap_or(Position::Grounded { + let icao: Arc = icao_number.to_string().into(); + trace_cached(icao_number, &date, client) + .await + .map(move |trace| { + trace.into_iter().map(move |entry| { + let icao = icao.clone(); + let time_seconds = entry[0].as_f64().unwrap(); + let time = time::Time::MIDNIGHT + time_seconds.seconds(); + let datetime = PrimitiveDateTime::new(date.clone(), time); + let latitude = entry[1].as_f64().unwrap(); + let longitude = entry[2].as_f64().unwrap(); + entry[3] + .as_str() + .and_then(|x| { + (x == "ground").then_some(Position::Grounded { + icao: icao.clone(), datetime, latitude, longitude, }) - }) + }) + .unwrap_or_else(|| { + entry[3] + .as_f64() + .and_then(|altitude| { + Some(if altitude < threshold { + Position::Grounded { + icao: icao.clone(), + datetime, + latitude, + longitude, + } + } else { + Position::Flying { + icao: icao.clone(), + datetime, + latitude, + longitude, + altitude, + } + }) + }) + .unwrap_or(Position::Grounded { + icao, + datetime, + latitude, + longitude, + }) + }) + }) }) - }) } diff --git a/src/legs.rs b/src/legs.rs index 02da248..d175a9a 100644 --- a/src/legs.rs +++ b/src/legs.rs @@ -2,7 +2,7 @@ use crate::Position; /// Represents a leg, also known as a [non-stop flight](https://en.wikipedia.org/wiki/Non-stop_flight) /// between two positions. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Leg { pub from: Position, pub to: Position, @@ -28,17 +28,17 @@ pub fn legs(mut positions: impl Iterator) -> Vec { return vec![]; }; - let first = prev_position; + let first = prev_position.clone(); let mut legs: Vec = vec![]; - let mut maximum_altitude = prev_position.altitude(); + let mut maximum_altitude = first.altitude(); positions.for_each(|position| { maximum_altitude = position.altitude().max(maximum_altitude); - match (prev_position, position) { + match (&prev_position, &position) { (Position::Grounded { .. }, Position::Flying { .. }) => { // departed, still do not know to where legs.push(Leg { - from: prev_position, - to: prev_position, + from: prev_position.clone(), + to: prev_position.clone(), maximum_altitude, }); } @@ -46,12 +46,12 @@ pub fn legs(mut positions: impl Iterator) -> Vec { // arrived if let Some(leg) = legs.last_mut() { // there is a leg - set its arrival position - leg.to = position; + leg.to = position.clone(); } else { // if it was initially flying, need to push to the leg legs.push(Leg { - from: first, - to: position, + from: first.clone(), + to: position.clone(), maximum_altitude, }); maximum_altitude = 0.0 @@ -69,3 +69,19 @@ pub fn legs(mut positions: impl Iterator) -> Vec { legs } + +/// Computes legs that, under the below heuristic, is a real leg: +/// * Its maximum altitude is higher than 1000 feet +/// * Its distance is higher than 3km +/// * Its duration is longer than 5m +pub fn real_legs(positions: impl Iterator) -> Vec { + legs(positions) + .into_iter() + // ignore legs that are too fast, as they are likely noise + .filter(|leg| leg.duration() > time::Duration::minutes(5)) + // ignore legs that are too short, as they are likely noise + .filter(|leg| leg.distance() > 3.0) + // ignore legs that are too low, as they are likely noise + .filter(|leg| leg.maximum_altitude > 1000.0) + .collect() +} diff --git a/src/lib.rs b/src/lib.rs index 5ba20d8..1da58f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,8 @@ mod legs; mod model; mod owners; +use std::sync::Arc; + pub use aircraft_db::*; pub use aircraft_owners::*; pub use aircraft_types::*; @@ -22,16 +24,18 @@ pub use model::*; pub use owners::*; /// A position of an aircraft -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum Position { /// Aircraft transponder declares the aircraft is grounded Grounded { + icao: Arc, datetime: time::PrimitiveDateTime, latitude: f64, longitude: f64, }, /// Aircraft transponder declares the aircraft is flying at a given altitude Flying { + icao: Arc, datetime: time::PrimitiveDateTime, latitude: f64, longitude: f64, @@ -40,6 +44,12 @@ pub enum Position { } impl Position { + pub fn icao(&self) -> &Arc { + match self { + Position::Flying { icao, .. } | Position::Grounded { icao, .. } => icao, + } + } + pub fn latitude(&self) -> f64 { match *self { Position::Flying { latitude, .. } | Position::Grounded { latitude, .. } => latitude, diff --git a/tests/it/main.rs b/tests/it/main.rs index 55cb71e..f24bac8 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -48,16 +48,7 @@ fn acceptance_test_emissions() { #[tokio::test] async fn legs_() -> Result<(), Box> { let positions = flights::positions("459cd3", date!(2023 - 11 - 17), 1000.0, None).await?; - let legs = flights::legs(positions.into_iter()); - let legs = legs - .into_iter() - // ignore legs that are too fast, as they are likely noise - .filter(|leg| leg.duration() > time::Duration::minutes(5)) - // ignore legs that are too short, as they are likely noise - .filter(|leg| leg.distance() > 3.0) - // ignore legs that are too low, as they are likely noise - .filter(|leg| leg.maximum_altitude > 1000.0) - .collect::>(); + let legs = flights::real_legs(positions); // same as ads-b computes: https://globe.adsbexchange.com/?icao=459cd3&lat=53.265&lon=8.038&zoom=6.5&showTrace=2023-11-17 assert_eq!(legs.len(), 5);