diff --git a/examples/period.rs b/examples/period.rs new file mode 100644 index 0000000..c2ebcb6 --- /dev/null +++ b/examples/period.rs @@ -0,0 +1,159 @@ +use std::error::Error; + +use flights::{ + emissions, load_aircraft_owners, load_aircrafts, load_owners, Aircraft, Class, Company, Fact, +}; + +#[derive(serde::Serialize)] +pub struct Context { + pub owner: Fact, + pub aircraft: Aircraft, + 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, +} + +fn render(context: &Context) -> Result<(), Box> { + let path = "story.md"; + + let template = std::fs::read_to_string("examples/template.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)?; + + println!("Story written to {path}"); + std::fs::write(path, rendered)?; + Ok(()) +} + +fn main() -> Result<(), Box> { + let owners = load_owners()?; + let aircraft_owners = load_aircraft_owners()?; + let aircrafts = load_aircrafts()?; + + let to = time::OffsetDateTime::now_utc().date() - time::Duration::days(1); + let from = to - time::Duration::days(90); + + let tail_number = "OY-GFS"; + let aircraft = aircrafts + .get(tail_number) + .ok_or_else(|| Into::>::into("Aircraft ICAO number not found"))? + .clone(); + let aircraft_owner = aircraft_owners + .get(tail_number) + .ok_or_else(|| Into::>::into("Owner of tail number not found"))?; + println!("Aircraft owner: {}", aircraft_owner.owner); + let company = owners + .get(&aircraft_owner.owner) + .ok_or_else(|| Into::>::into("Owner not found"))?; + println!("Owner information found"); + let owner = Fact { + claim: company.clone(), + source: aircraft_owner.source.clone(), + date: aircraft_owner.date.clone(), + }; + + let icao = &aircraft.icao_number; + println!("ICAO number: {}", icao); + + let iter = flights::DateIter { + from, + to, + increment: time::Duration::days(1), + }; + + let mut positions = vec![]; + for date in iter { + positions.extend(flights::positions(icao, &date, 1000.0)?); + } + + 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::>(); + println!("number_of_legs: {}", legs.len()); + for leg in &legs { + println!( + "{},{},{},{},{},{},{},{},{}", + leg.from.datetime(), + leg.from.latitude(), + leg.from.longitude(), + leg.from.altitude(), + leg.to.datetime(), + leg.to.latitude(), + leg.to.longitude(), + leg.to.altitude(), + leg.maximum_altitude + ); + } + + let commercial_to_private_ratio = 10.0; + let commercial_emissions_tons = legs + .iter() + .map(|leg| emissions(leg.from.pos(), leg.to.pos(), Class::First) / 1000.0) + .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().filter(|leg| leg.distance() < 300.0); + let long_legs = legs.iter().filter(|leg| leg.distance() >= 300.0); + + 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 from_date = from.to_string(); + let to_date = to.to_string(); + + let number_of_legs = Fact { + claim: legs.len(), + source: format!("[adsbexchange.com](https://globe.adsbexchange.com/?icao={icao}) between {from_date} and {to_date}"), + date: to.to_string() + }; + + let context = Context { + owner, + aircraft, + from_date, + to_date, + number_of_legs, + emissions_tons, + dane_years, + number_of_legs_less_300km: short_legs.count(), + number_of_legs_more_300km: long_legs.count(), + ratio_commercial_300km: format!("{:.0}", commercial_to_private_ratio), + }; + + render(&context)?; + + Ok(()) +} diff --git a/examples/template.md b/examples/template.md new file mode 100644 index 0000000..44aa345 --- /dev/null +++ b/examples/template.md @@ -0,0 +1,29 @@ +# {owner.claim.name} use of private jets + +{owner.claim.name} {owner.claim.statement.claim}[^1]. + +Yet, between {from_date} and {to_date}, their staff travelled **{number_of_legs.claim} times**[^2] on {owner.claim.name}'s own private jet, {aircraft.tail_number}[^3]. + +> These {number_of_legs.claim} trips emitted {emissions_tons.claim} tons of CO2. A Dane + takes **{dane_years.claim} years** to emit this amount of CO2[^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 + +[^1]: {owner.claim.statement.source} - retrieved on {owner.claim.statement.date} +[^2]: {number_of_legs.source} - retrieved on {number_of_legs.date} +[^3]: {owner.source} - retrieved on {owner.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/src/aircraft_db.rs b/src/aircraft_db.rs index 5baa892..1830e0d 100644 --- a/src/aircraft_db.rs +++ b/src/aircraft_db.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use std::error::Error; use reqwest; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json; /// [`HashMap`] between tail number (e.g. "OY-TWM") and an [`Aircraft`] pub type Aircrafts = HashMap; /// An in-memory representation of an aircraft data -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct Aircraft { /// The ICAO number of the aircraft (e.g. `459CD3`) pub icao_number: String, diff --git a/src/emissions.rs b/src/emissions.rs index 21847b4..560f592 100644 --- a/src/emissions.rs +++ b/src/emissions.rs @@ -101,7 +101,7 @@ fn distance_to_emissions(distance: f64, class: Class) -> f64 { } } -/// Returns emissions of a commercial flight flying `from` -> `to`, in kg of eCO2. +/// Returns emissions of a commercial flight flying `from` -> `to`, in kg of CO2e. /// The exact calculation is described here: https://www.myclimate.org/en/information/about-myclimate/downloads/flight-emission-calculator/ pub fn emissions(from: (f64, f64), to: (f64, f64), class: Class) -> f64 { distance_to_emissions(super::distance(from, to), class) diff --git a/src/icao_to_trace.rs b/src/icao_to_trace.rs index 40ad888..aeda839 100644 --- a/src/icao_to_trace.rs +++ b/src/icao_to_trace.rs @@ -5,6 +5,8 @@ use reqwest::header; use reqwest::{self, StatusCode}; use time::PrimitiveDateTime; +use super::Position; + fn last_2(icao: &str) -> &str { let bytes = icao.as_bytes(); std::str::from_utf8(&bytes[bytes.len() - 2..]).unwrap() @@ -127,56 +129,6 @@ pub fn trace_cached( Ok(std::mem::take(trace)) } -/// A position of an aircraft -#[derive(Debug, Clone, Copy)] -pub enum Position { - /// Aircraft transponder declares the aircraft is grounded - Grounded { - datetime: PrimitiveDateTime, - latitude: f64, - longitude: f64, - }, - /// Aircraft transponder declares the aircraft is flying at a given altitude - Flying { - datetime: PrimitiveDateTime, - latitude: f64, - longitude: f64, - altitude: f64, - }, -} - -impl Position { - pub fn latitude(&self) -> f64 { - match *self { - Position::Flying { latitude, .. } | Position::Grounded { latitude, .. } => latitude, - } - } - - pub fn longitude(&self) -> f64 { - match *self { - Position::Flying { longitude, .. } | Position::Grounded { longitude, .. } => longitude, - } - } - - pub fn pos(&self) -> (f64, f64) { - (self.latitude(), self.longitude()) - } - - pub fn altitude(&self) -> f64 { - match *self { - Position::Flying { altitude, .. } => altitude, - Position::Grounded { .. } => 0.0, - } - } - - pub fn datetime(&self) -> PrimitiveDateTime { - match *self { - Position::Flying { datetime, .. } => datetime, - Position::Grounded { datetime, .. } => datetime, - } - } -} - /// Returns an iterator of [`Position`] over the trace of `icao` on day `date` assuming that /// a flight below `threshold` feet is grounded. pub fn positions( diff --git a/src/legs.rs b/src/legs.rs index 27a6758..02da248 100644 --- a/src/legs.rs +++ b/src/legs.rs @@ -6,6 +6,20 @@ use crate::Position; pub struct Leg { pub from: Position, pub to: Position, + /// in feet + pub maximum_altitude: f64, +} + +impl Leg { + /// Leg geo distance in km + pub fn distance(&self) -> f64 { + self.from.distace(&self.to) + } + + /// Leg duration + pub fn duration(&self) -> time::Duration { + self.to.datetime() - self.from.datetime() + } } /// Returns a set of [`Leg`]s from a sequence of [`Position`]s. @@ -16,13 +30,16 @@ pub fn legs(mut positions: impl Iterator) -> Vec { let first = prev_position; let mut legs: Vec = vec![]; + let mut maximum_altitude = prev_position.altitude(); positions.for_each(|position| { + maximum_altitude = position.altitude().max(maximum_altitude); match (prev_position, position) { (Position::Grounded { .. }, Position::Flying { .. }) => { // departed, still do not know to where legs.push(Leg { from: prev_position, to: prev_position, + maximum_altitude, }); } (Position::Flying { .. }, Position::Grounded { .. }) => { @@ -35,7 +52,9 @@ pub fn legs(mut positions: impl Iterator) -> Vec { legs.push(Leg { from: first, to: position, - }) + maximum_altitude, + }); + maximum_altitude = 0.0 } } _ => {} @@ -43,9 +62,9 @@ pub fn legs(mut positions: impl Iterator) -> Vec { prev_position = position; }); - // if it is still flying, we leave the last leg as incomplete. + // if it is still flying, remove the incomplete leg if matches!(prev_position, Position::Flying { .. }) && !legs.is_empty() { - legs.last_mut().unwrap().to = prev_position; + legs.pop(); } legs diff --git a/src/lib.rs b/src/lib.rs index b353edb..d0690f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,9 +19,84 @@ pub use legs::*; pub use model::*; pub use owners::*; +/// A position of an aircraft +#[derive(Debug, Clone, Copy)] +pub enum Position { + /// Aircraft transponder declares the aircraft is grounded + Grounded { + datetime: time::PrimitiveDateTime, + latitude: f64, + longitude: f64, + }, + /// Aircraft transponder declares the aircraft is flying at a given altitude + Flying { + datetime: time::PrimitiveDateTime, + latitude: f64, + longitude: f64, + altitude: f64, + }, +} + +impl Position { + pub fn latitude(&self) -> f64 { + match *self { + Position::Flying { latitude, .. } | Position::Grounded { latitude, .. } => latitude, + } + } + + pub fn longitude(&self) -> f64 { + match *self { + Position::Flying { longitude, .. } | Position::Grounded { longitude, .. } => longitude, + } + } + + pub fn pos(&self) -> (f64, f64) { + (self.latitude(), self.longitude()) + } + + pub fn altitude(&self) -> f64 { + match *self { + Position::Flying { altitude, .. } => altitude, + Position::Grounded { .. } => 0.0, + } + } + + pub fn datetime(&self) -> time::PrimitiveDateTime { + match *self { + Position::Flying { datetime, .. } => datetime, + Position::Grounded { datetime, .. } => datetime, + } + } + + /// Returns the distance to another [`Position`] in km + pub fn distace(&self, other: &Self) -> f64 { + distance(self.pos(), other.pos()) + } +} + /// Returns the distance between two geo-points in km fn distance(from: (f64, f64), to: (f64, f64)) -> f64 { let from = geoutils::Location::new(from.0, from.1); let to = geoutils::Location::new(to.0, to.1); from.distance_to(&to).unwrap().meters() / 1000.0 } + +/// An iterator between two [`time::Date`]s in increments +pub struct DateIter { + pub from: time::Date, + pub to: time::Date, + pub increment: time::Duration, +} + +impl Iterator for DateIter { + type Item = time::Date; + + fn next(&mut self) -> Option { + if self.from >= self.to { + return None; + } + let maybe_next = self.from.saturating_add(self.increment); + self.from = maybe_next; + (maybe_next < self.to).then_some(maybe_next) + } +} diff --git a/tests/it/main.rs b/tests/it/main.rs index 3323d75..a1ec3ae 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -44,3 +44,22 @@ fn acceptance_test_emissions() { let emissions = flights::emissions(berlin, brussels, flights::Class::First); assert!(abs_difference(emissions, expected) / expected < accepted_error); } + +#[test] +fn legs_() -> Result<(), Box> { + let positions = flights::positions("459cd3", &date!(2023 - 11 - 17), 1000.0)?; + 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::>(); + + // 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); + Ok(()) +}