apsis-rs/crates/network/apsis-constellation/tests/walker.rs

387 lines
13 KiB
Rust

//! Tests for the Walker constellation pattern.
use apsis_constellation::{Constellation, ConstellationErrorKind, Satellite, WalkerPattern};
use apsis_kepler::true_from_mean;
use core::f64::consts::PI;
const TOLERANCE: f64 = 1e-12;
/// A Walker 53 degree pattern with 24 satellites in 3 planes, as used by a
/// mid latitude broadband constellation.
fn broadband_pattern() -> WalkerPattern {
WalkerPattern {
total_satellites: 24,
planes: 3,
phase_factor: 1,
inclination: 53.0_f64.to_radians(),
semi_major_axis: 6_878.137,
}
}
#[test]
fn the_derived_counts_are_correct() {
let pattern = broadband_pattern();
assert_eq!(pattern.satellites_per_plane(), Some(8));
assert!((pattern.plane_spacing().expect("valid") - 2.0 * PI / 3.0).abs() < TOLERANCE);
// The phase offset is 1 * 360 / 24 degrees.
let expected = 2.0 * PI / 24.0;
assert!((pattern.phase_offset().expect("valid") - expected).abs() < TOLERANCE);
}
#[test]
fn instantiation_produces_the_right_number_of_satellites() {
let constellation = Constellation::walker(broadband_pattern(), 60_000.0).expect("valid");
assert_eq!(constellation.len(), 24);
assert_eq!(constellation.plane_count(), 3);
assert_eq!(constellation.plane_populations(), vec![8, 8, 8]);
assert!(!constellation.is_empty());
}
#[test]
fn every_satellite_is_circular_and_at_the_same_radius() {
let constellation = Constellation::walker(broadband_pattern(), 60_000.0).expect("valid");
for satellite in constellation.satellites() {
assert_eq!(satellite.elements.eccentricity, 0.0);
assert!((satellite.elements.semi_major_axis - 6_878.137).abs() < TOLERANCE);
assert!((satellite.elements.inclination - 53.0_f64.to_radians()).abs() < TOLERANCE);
}
}
#[test]
fn the_planes_are_evenly_spaced_in_right_ascension() {
let constellation = Constellation::walker(broadband_pattern(), 60_000.0).expect("valid");
let expected = [0.0, 2.0 * PI / 3.0, 4.0 * PI / 3.0];
for (plane, expected_right_ascension) in expected.iter().enumerate() {
for satellite in constellation.plane(plane) {
assert!(
(satellite.elements.right_ascension - expected_right_ascension).abs() < 1e-9,
"plane {plane}: {} against {expected_right_ascension}",
satellite.elements.right_ascension
);
}
}
}
#[test]
fn satellites_within_a_plane_are_evenly_spaced_in_anomaly() {
let constellation = Constellation::walker(broadband_pattern(), 60_000.0).expect("valid");
for plane in 0..3 {
let satellites = constellation.plane(plane);
let mut anomalies: Vec<f64> = satellites
.iter()
.map(|satellite| satellite.elements.mean_anomaly())
.collect();
anomalies.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
for pair in anomalies.windows(2) {
let spacing = pair[1] - pair[0];
assert!((spacing - 2.0 * PI / 8.0).abs() < 1e-9, "spacing {spacing}");
}
}
}
#[test]
fn the_phase_offset_appears_between_adjacent_planes() {
let pattern = broadband_pattern();
let constellation = Constellation::walker(pattern, 60_000.0).expect("valid");
let offset = pattern.phase_offset().expect("valid");
// The first satellite of each plane is advanced by the phase offset
// relative to the first satellite of the previous plane.
let first_of_plane = |plane: usize| -> f64 {
constellation
.plane(plane)
.iter()
.min_by(|a, b| {
a.index_in_plane
.partial_cmp(&b.index_in_plane)
.expect("indices are integers")
})
.expect("the plane is not empty")
.elements
.mean_anomaly()
};
let first = first_of_plane(0);
let second = first_of_plane(1);
let third = first_of_plane(2);
assert!(
(second - first - offset).abs() < 1e-9,
"{second} against {first}"
);
assert!(
(third - second - offset).abs() < 1e-9,
"{third} against {second}"
);
}
#[test]
fn a_zero_phase_factor_gives_no_offset_between_planes() {
let pattern = WalkerPattern {
phase_factor: 0,
..broadband_pattern()
};
let constellation = Constellation::walker(pattern, 0.0).expect("valid");
assert_eq!(constellation.len(), 24);
let first_of_plane = |plane: usize| -> f64 {
constellation
.plane(plane)
.iter()
.find(|satellite| satellite.index_in_plane == 0)
.expect("the plane is not empty")
.elements
.mean_anomaly()
};
// Every plane starts at the same anomaly.
assert!((first_of_plane(0) - first_of_plane(1)).abs() < 1e-9);
assert!((first_of_plane(1) - first_of_plane(2)).abs() < 1e-9);
}
#[test]
fn the_true_anomaly_matches_the_mean_anomaly_for_a_circular_orbit() {
let constellation = Constellation::walker(broadband_pattern(), 0.0).expect("valid");
for satellite in constellation.satellites() {
let mean = satellite.elements.mean_anomaly();
let expected = true_from_mean(mean, 0.0);
assert!((satellite.elements.true_anomaly - expected).abs() < 1e-12);
}
}
#[test]
fn a_single_plane_pattern_is_accepted() {
let pattern = WalkerPattern {
total_satellites: 6,
planes: 1,
phase_factor: 0,
inclination: 0.0,
semi_major_axis: 7_000.0,
};
let constellation = Constellation::walker(pattern, 0.0).expect("valid");
assert_eq!(constellation.len(), 6);
assert_eq!(constellation.plane_count(), 1);
// All satellites share a plane, so they share a right ascension.
for satellite in constellation.satellites() {
assert_eq!(satellite.elements.right_ascension, 0.0);
}
}
#[test]
fn a_pattern_with_one_satellite_per_plane_is_accepted() {
let pattern = WalkerPattern {
total_satellites: 4,
planes: 4,
phase_factor: 3,
inclination: 1.0,
semi_major_axis: 7_000.0,
};
let constellation = Constellation::walker(pattern, 0.0).expect("valid");
assert_eq!(constellation.len(), 4);
assert_eq!(constellation.plane_count(), 4);
assert_eq!(constellation.plane_populations(), vec![1, 1, 1, 1]);
}
#[test]
fn an_uneven_split_is_rejected() {
let pattern = WalkerPattern {
total_satellites: 25,
planes: 3,
..broadband_pattern()
};
assert_eq!(pattern.satellites_per_plane(), None);
assert!(!pattern.is_valid());
let error = pattern
.instantiate()
.expect_err("25 is not a multiple of 3");
assert_eq!(error.kind(), ConstellationErrorKind::UnevenPlanes);
}
#[test]
fn a_zero_satellite_count_is_rejected() {
let pattern = WalkerPattern {
total_satellites: 0,
..broadband_pattern()
};
let error = pattern.instantiate().expect_err("no satellites");
assert_eq!(error.kind(), ConstellationErrorKind::NoSatellites);
}
#[test]
fn a_zero_plane_count_is_rejected() {
let pattern = WalkerPattern {
planes: 0,
..broadband_pattern()
};
let error = pattern.instantiate().expect_err("no planes");
assert_eq!(error.kind(), ConstellationErrorKind::NoPlanes);
}
#[test]
fn an_out_of_range_phase_factor_is_rejected() {
let pattern = WalkerPattern {
phase_factor: 5,
..broadband_pattern()
};
assert!(!pattern.is_valid());
let error = pattern
.instantiate()
.expect_err("phase factor exceeds the plane count");
assert_eq!(error.kind(), ConstellationErrorKind::InvalidOrbit);
}
#[test]
fn an_invalid_inclination_is_rejected() {
let pattern = WalkerPattern {
inclination: PI + 0.1,
..broadband_pattern()
};
assert!(!pattern.is_valid());
let error = pattern
.instantiate()
.expect_err("inclination is out of range");
assert_eq!(error.kind(), ConstellationErrorKind::InvalidOrbit);
}
#[test]
fn a_non_positive_semi_major_axis_is_rejected() {
let pattern = WalkerPattern {
semi_major_axis: 0.0,
..broadband_pattern()
};
assert!(!pattern.is_valid());
assert_eq!(
pattern.instantiate().expect_err("zero radius").kind(),
ConstellationErrorKind::InvalidOrbit
);
}
#[test]
fn a_non_finite_reference_time_is_rejected() {
let error =
Constellation::walker(broadband_pattern(), f64::NAN).expect_err("the time is not finite");
assert_eq!(error.kind(), ConstellationErrorKind::InvalidOrbit);
}
#[test]
fn the_reference_time_is_retained() {
let constellation = Constellation::walker(broadband_pattern(), 60_123.5).expect("valid");
assert_eq!(constellation.reference_mjd(), 60_123.5);
}
#[test]
fn the_pattern_is_retained() {
let pattern = broadband_pattern();
let constellation = Constellation::walker(pattern, 0.0).expect("valid");
assert_eq!(constellation.pattern(), Some(pattern));
}
#[test]
fn the_satellite_indices_are_consistent() {
let constellation = Constellation::walker(broadband_pattern(), 0.0).expect("valid");
for (position, satellite) in constellation.satellites().iter().enumerate() {
assert_eq!(satellite.index, position);
// The satellites are emitted plane by plane, so the plane index is
// the quotient of the position and the plane population.
assert_eq!(satellite.plane, position / 8);
assert_eq!(satellite.index_in_plane, position % 8);
}
}
#[test]
fn the_altitude_is_measured_above_the_given_radius() {
let constellation = Constellation::walker(broadband_pattern(), 0.0).expect("valid");
let altitude = constellation
.altitude_above(6_378.137)
.expect("the constellation is not empty");
assert!((altitude - 500.0).abs() < 1e-9, "altitude {altitude}");
}
#[test]
fn the_notation_reports_the_walker_fields() {
let pattern = WalkerPattern {
total_satellites: 24,
planes: 3,
phase_factor: 1,
inclination: 53.0_f64.to_radians(),
semi_major_axis: 6_878.137,
};
let notation = pattern.notation();
assert!(notation.starts_with("53"), "notation {notation}");
assert!(notation.contains("24/3/1"), "notation {notation}");
}
#[test]
fn a_custom_constellation_groups_satellites_by_right_ascension() {
use apsis_kepler::KeplerianElements;
let elements = [
KeplerianElements {
semi_major_axis: 7_000.0,
eccentricity: 0.001,
inclination: 0.9,
right_ascension: 0.0,
argument_of_perigee: 0.0,
true_anomaly: 0.0,
},
KeplerianElements {
semi_major_axis: 7_000.0,
eccentricity: 0.001,
inclination: 0.9,
right_ascension: 0.0,
argument_of_perigee: 0.0,
true_anomaly: PI,
},
KeplerianElements {
semi_major_axis: 7_000.0,
eccentricity: 0.001,
inclination: 0.9,
right_ascension: 2.0,
argument_of_perigee: 0.0,
true_anomaly: 0.5,
},
];
let constellation = Constellation::from_satellites(&elements, 0.0).expect("valid");
assert_eq!(constellation.len(), 3);
assert_eq!(constellation.plane_count(), 2);
assert_eq!(constellation.plane_populations(), vec![2, 1]);
assert_eq!(constellation.pattern(), None);
// The first two share a plane.
assert_eq!(constellation.satellites()[0].plane, 0);
assert_eq!(constellation.satellites()[1].plane, 0);
assert_eq!(constellation.satellites()[2].plane, 1);
assert_eq!(constellation.satellites()[0].index_in_plane, 0);
assert_eq!(constellation.satellites()[1].index_in_plane, 1);
}
#[test]
fn an_empty_custom_constellation_is_rejected() {
let error = Constellation::from_satellites(&[], 0.0).expect_err("no satellites");
assert_eq!(error.kind(), ConstellationErrorKind::NoSatellites);
}
#[test]
fn a_custom_constellation_rejects_invalid_elements() {
use apsis_kepler::KeplerianElements;
let elements = [KeplerianElements {
semi_major_axis: -1.0,
eccentricity: 0.0,
inclination: 0.0,
right_ascension: 0.0,
argument_of_perigee: 0.0,
true_anomaly: 0.0,
}];
let error = Constellation::from_satellites(&elements, 0.0).expect_err("negative radius");
assert_eq!(error.kind(), ConstellationErrorKind::InvalidOrbit);
}
#[test]
fn an_out_of_range_plane_query_is_empty() {
let constellation = Constellation::walker(broadband_pattern(), 0.0).expect("valid");
assert!(constellation.plane(99).is_empty());
}
#[test]
fn the_satellite_type_is_copyable() {
// The type must remain cheap to pass around, since coverage and access
// analysis iterate over it repeatedly.
fn assert_copy<T: Copy>() {}
assert_copy::<Satellite>();
}