aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
#![allow(clippy::expect_used, clippy::panic)]

use aerocontext_core::{AircraftProfile, GeoPoint};

use super::*;
use crate::flightplan::{FlightPlan, PlanWaypoint, WaypointType};

fn profile() -> AircraftProfile {
    serde_json::from_str(
        r#"{
            "cruise_tas_kt": 450.0,
            "climb_rate_fpm": 2500.0,
            "climb_tas_kt": 280.0,
            "descent_rate_fpm": 1800.0,
            "descent_tas_kt": 300.0,
            "cruise_burn_per_hour": 200.0,
            "taxi_fuel": 30.0,
            "fuel_capacity": 2000.0,
            "reserve_minutes": 45.0
        }"#,
    )
    .expect("profile parses")
}

fn wp(ident: &str, lat: f64, lon: f64) -> PlanWaypoint {
    PlanWaypoint {
        identifier: ident.to_owned(),
        kind: WaypointType::Int,
        position: GeoPoint { lat, lon },
        country_code: None,
    }
}

fn plan(cruise_ft: Option<i32>) -> FlightPlan {
    FlightPlan {
        cruise_altitude_ft: cruise_ft,
        route: vec![
            wp("KEWR", 40.6925, -74.1687),
            wp("PSB", 40.9163, -77.9927),
            wp("KAAO", 37.7461, -97.2211),
        ],
        ..FlightPlan::default()
    }
}

#[test]
fn profile_climbs_cruises_and_descends() {
    let xs = CrossSection::build(&plan(Some(40000)), &profile()).expect("builds");
    assert_eq!(xs.samples.len(), 3);
    assert_eq!(xs.samples[0].phase, FlightPhase::Climb);
    assert!(xs.samples[0].altitude_ft < 1000.0, "departure starts low");
    assert_eq!(xs.samples[0].eta_from_etd.as_secs(), 0);
    // Destination is the end of the descent, back near the ground.
    let last = xs.samples.last().expect("dest");
    assert_eq!(last.phase, FlightPhase::Descent);
    assert!(last.altitude_ft < 1000.0, "arrives low");
    // A ~1080 NM jet leg is a few hours, well under capacity.
    assert!(
        (2.0..4.0).contains(&(xs.time_en_route.as_secs_f64() / 3600.0)),
        "ete hours = {}",
        xs.time_en_route.as_secs_f64() / 3600.0
    );
    assert!(xs.fuel_within_capacity);
    assert!(xs.fuel_required > profile().taxi_fuel);
}

#[test]
fn eta_increases_monotonically_along_the_route() {
    let xs = CrossSection::build(&plan(Some(40000)), &profile()).expect("builds");
    for pair in xs.samples.windows(2) {
        assert!(
            pair[1].eta_from_etd >= pair[0].eta_from_etd,
            "ETA must not go backwards"
        );
        assert!(pair[1].along_track_nm > pair[0].along_track_nm);
    }
}

#[test]
fn missing_cruise_altitude_is_an_error() {
    assert!(matches!(
        CrossSection::build(&plan(None), &profile()),
        Err(CrossSectionError::NoCruiseAltitude)
    ));
}

#[test]
fn a_single_point_plan_cannot_be_profiled() {
    let mut p = plan(Some(40000));
    p.route.truncate(1);
    assert!(matches!(
        CrossSection::build(&p, &profile()),
        Err(CrossSectionError::TooFewPoints)
    ));
}