364 lines
13 KiB
Rust
364 lines
13 KiB
Rust
//! The Walker constellation pattern and its instantiation.
|
|
|
|
use crate::error::{ConstellationError, ConstellationErrorKind};
|
|
use apsis_kepler::KeplerianElements;
|
|
use core::f64::consts::PI;
|
|
|
|
/// A Walker delta pattern.
|
|
///
|
|
/// A Walker pattern places a number of satellites evenly around the Earth in
|
|
/// a number of planes that share an inclination and a semi-major axis. The
|
|
/// relative phasing of adjacent planes is set by the phase factor, so the
|
|
/// whole constellation is described by five numbers.
|
|
///
|
|
/// The pattern is written `i: t/p/f`, where `i` is the inclination, `t` the
|
|
/// total number of satellites, `p` the number of planes, and `f` the phase
|
|
/// factor. This structure stores the same quantities, with the inclination
|
|
/// in radians and the semi-major axis in kilometers.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct WalkerPattern {
|
|
/// The total number of satellites.
|
|
pub total_satellites: usize,
|
|
/// The number of orbital planes.
|
|
pub planes: usize,
|
|
/// The phase factor, in the range `0..planes`.
|
|
pub phase_factor: usize,
|
|
/// The inclination of every plane, in radians.
|
|
pub inclination: f64,
|
|
/// The semi-major axis of every satellite, in kilometers.
|
|
pub semi_major_axis: f64,
|
|
}
|
|
|
|
impl WalkerPattern {
|
|
/// Returns the number of satellites in each plane.
|
|
///
|
|
/// Returns `None` when the satellite count is not a multiple of the
|
|
/// plane count.
|
|
pub fn satellites_per_plane(self) -> Option<usize> {
|
|
if self.planes == 0 || self.total_satellites % self.planes != 0 {
|
|
return None;
|
|
}
|
|
Some(self.total_satellites / self.planes)
|
|
}
|
|
|
|
/// Returns the spacing between adjacent planes in right ascension, in
|
|
/// radians.
|
|
///
|
|
/// Returns the spacing regardless of whether the pattern is consistent,
|
|
/// so that a caller can report the value it would use.
|
|
pub fn plane_spacing(self) -> Option<f64> {
|
|
(self.planes > 0).then(|| 2.0 * PI / self.planes as f64)
|
|
}
|
|
|
|
/// Returns the phase offset applied between adjacent planes, in radians.
|
|
///
|
|
/// This is `f * 360 / t` degrees, where `f` is the phase factor and `t`
|
|
/// the total satellite count.
|
|
pub fn phase_offset(self) -> Option<f64> {
|
|
if self.total_satellites == 0 {
|
|
return None;
|
|
}
|
|
Some(2.0 * PI * self.phase_factor as f64 / self.total_satellites as f64)
|
|
}
|
|
|
|
/// Returns `true` when the pattern can be instantiated.
|
|
pub fn is_valid(self) -> bool {
|
|
self.total_satellites > 0
|
|
&& self.planes > 0
|
|
&& self.total_satellites % self.planes == 0
|
|
&& self.phase_factor < self.planes
|
|
&& self.inclination.is_finite()
|
|
&& (0.0..=PI).contains(&self.inclination)
|
|
&& self.semi_major_axis.is_finite()
|
|
&& self.semi_major_axis > 0.0
|
|
}
|
|
|
|
/// Returns the Walker notation for this pattern, such as `53: 24/3/1`.
|
|
///
|
|
/// The inclination is rounded to one decimal place, which is the
|
|
/// precision the notation is normally quoted to.
|
|
pub fn notation(self) -> String {
|
|
format!(
|
|
"{}: {}/{}/{}",
|
|
self.inclination.to_degrees(),
|
|
self.total_satellites,
|
|
self.planes,
|
|
self.phase_factor
|
|
)
|
|
}
|
|
|
|
/// Instantiates the pattern as a list of orbital elements.
|
|
///
|
|
/// The mean anomaly of the reference satellite of the first plane is
|
|
/// zero. Each further satellite in a plane is advanced by
|
|
/// `360 / (t / p)` degrees, and each further plane is advanced by the
|
|
/// phase offset.
|
|
///
|
|
/// Returns an error describing why the pattern cannot be instantiated.
|
|
///
|
|
/// Source: the phasing rule is the one defined in Walker, J. G.,
|
|
/// *Circular Orbit Patterns Providing Continuous Whole Earth Coverage*,
|
|
/// Royal Aircraft Establishment Technical Report 70211, 1970.
|
|
pub fn instantiate(self) -> Result<Vec<KeplerianElements>, ConstellationError> {
|
|
if self.total_satellites == 0 {
|
|
return Err(ConstellationError::new(
|
|
ConstellationErrorKind::NoSatellites,
|
|
));
|
|
}
|
|
if self.planes == 0 {
|
|
return Err(ConstellationError::new(ConstellationErrorKind::NoPlanes));
|
|
}
|
|
let per_plane = self.satellites_per_plane().ok_or_else(|| {
|
|
ConstellationError::new(ConstellationErrorKind::UnevenPlanes).with_detail(format!(
|
|
"{} satellites cannot be spread over {} planes",
|
|
self.total_satellites, self.planes
|
|
))
|
|
})?;
|
|
if !self.is_valid() {
|
|
return Err(
|
|
ConstellationError::new(ConstellationErrorKind::InvalidOrbit).with_detail(format!(
|
|
"inclination {} rad, semi-major axis {} km, phase factor {}",
|
|
self.inclination, self.semi_major_axis, self.phase_factor
|
|
)),
|
|
);
|
|
}
|
|
|
|
let plane_spacing = self.plane_spacing().expect("a positive plane count");
|
|
let phase_offset = self.phase_offset().expect("a positive satellite count");
|
|
let anomaly_spacing = 2.0 * PI / per_plane as f64;
|
|
|
|
let mut satellites = Vec::with_capacity(self.total_satellites);
|
|
for plane in 0..self.planes {
|
|
// The right ascension of this plane.
|
|
let right_ascension = (plane as f64 * plane_spacing).rem_euclid(2.0 * PI);
|
|
// The phase offset of this plane relative to the first.
|
|
let plane_phase = plane as f64 * phase_offset;
|
|
for satellite in 0..per_plane {
|
|
let mean_anomaly =
|
|
(satellite as f64 * anomaly_spacing + plane_phase).rem_euclid(2.0 * PI);
|
|
satellites.push(KeplerianElements {
|
|
semi_major_axis: self.semi_major_axis,
|
|
// The Walker pattern is circular.
|
|
eccentricity: 0.0,
|
|
inclination: self.inclination,
|
|
right_ascension,
|
|
// The argument of perigee is undefined for a circular
|
|
// orbit, so it is set to zero and the position along the
|
|
// orbit is carried entirely by the mean anomaly.
|
|
argument_of_perigee: 0.0,
|
|
true_anomaly: apsis_kepler::true_from_mean(mean_anomaly, 0.0),
|
|
});
|
|
}
|
|
}
|
|
Ok(satellites)
|
|
}
|
|
}
|
|
|
|
/// One satellite in an instantiated constellation.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Satellite {
|
|
/// The index of the satellite within the constellation.
|
|
pub index: usize,
|
|
/// The index of the orbital plane it belongs to.
|
|
pub plane: usize,
|
|
/// The index of the satellite within its plane.
|
|
pub index_in_plane: usize,
|
|
/// The orbital elements at the reference time.
|
|
pub elements: KeplerianElements,
|
|
}
|
|
|
|
/// A constellation instantiated at a reference time.
|
|
///
|
|
/// The elements are the ones at the reference time passed to the
|
|
/// constructor. Advancing them in time is the job of the orbit layer, which
|
|
/// propagates each satellite independently.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Constellation {
|
|
satellites: Vec<Satellite>,
|
|
pattern: Option<WalkerPattern>,
|
|
reference_mjd: f64,
|
|
}
|
|
|
|
impl Constellation {
|
|
/// Instantiates a Walker pattern at a reference time.
|
|
///
|
|
/// The reference time is a Modified Julian Date on the TAI scale. It is
|
|
/// stored with the constellation and returned unchanged; the elements
|
|
/// are the ones at that instant.
|
|
///
|
|
/// Returns an error when the pattern cannot be instantiated.
|
|
pub fn walker(pattern: WalkerPattern, reference_mjd: f64) -> Result<Self, ConstellationError> {
|
|
if !reference_mjd.is_finite() {
|
|
return Err(
|
|
ConstellationError::new(ConstellationErrorKind::InvalidOrbit)
|
|
.with_detail("the reference time is not finite"),
|
|
);
|
|
}
|
|
let elements = pattern.instantiate()?;
|
|
let per_plane = pattern
|
|
.satellites_per_plane()
|
|
.expect("instantiation checked the division");
|
|
|
|
let satellites = elements
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, elements)| Satellite {
|
|
// The satellites are produced plane by plane, so the plane
|
|
// index is the quotient and the position within it the
|
|
// remainder.
|
|
index,
|
|
plane: index / per_plane,
|
|
index_in_plane: index % per_plane,
|
|
elements,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Self {
|
|
satellites,
|
|
pattern: Some(pattern),
|
|
reference_mjd,
|
|
})
|
|
}
|
|
|
|
/// Builds a constellation from a list of satellites.
|
|
///
|
|
/// This is the constructor for a constellation that does not follow the
|
|
/// Walker pattern. The plane index of each satellite is derived from the
|
|
/// right ascension of its elements, so satellites sharing a plane are
|
|
/// grouped.
|
|
///
|
|
/// Returns an error when the list is empty, when the reference time is
|
|
/// not finite, or when any element set is not a valid orbit.
|
|
pub fn from_satellites(
|
|
elements: &[KeplerianElements],
|
|
reference_mjd: f64,
|
|
) -> Result<Self, ConstellationError> {
|
|
if elements.is_empty() {
|
|
return Err(ConstellationError::new(
|
|
ConstellationErrorKind::NoSatellites,
|
|
));
|
|
}
|
|
if !reference_mjd.is_finite() {
|
|
return Err(
|
|
ConstellationError::new(ConstellationErrorKind::InvalidOrbit)
|
|
.with_detail("the reference time is not finite"),
|
|
);
|
|
}
|
|
|
|
// Group by right ascension, assigning plane indices in order of
|
|
// first appearance so that the numbering is stable.
|
|
let mut planes: Vec<f64> = Vec::new();
|
|
let mut satellites = Vec::with_capacity(elements.len());
|
|
let mut counts: Vec<usize> = Vec::new();
|
|
for (index, elements) in elements.iter().enumerate() {
|
|
if !elements.is_valid() {
|
|
return Err(
|
|
ConstellationError::new(ConstellationErrorKind::InvalidOrbit)
|
|
.with_detail(format!("satellite {index} has invalid elements")),
|
|
);
|
|
}
|
|
let plane = match planes
|
|
.iter()
|
|
.position(|value| (value - elements.right_ascension).abs() < 1e-12)
|
|
{
|
|
Some(plane) => plane,
|
|
None => {
|
|
planes.push(elements.right_ascension);
|
|
counts.push(0);
|
|
planes.len() - 1
|
|
}
|
|
};
|
|
let index_in_plane = counts[plane];
|
|
counts[plane] += 1;
|
|
satellites.push(Satellite {
|
|
index,
|
|
plane,
|
|
index_in_plane,
|
|
elements: *elements,
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
satellites,
|
|
pattern: None,
|
|
reference_mjd,
|
|
})
|
|
}
|
|
|
|
/// Returns the satellites in the constellation.
|
|
pub fn satellites(&self) -> &[Satellite] {
|
|
&self.satellites
|
|
}
|
|
|
|
/// Returns the number of satellites.
|
|
pub fn len(&self) -> usize {
|
|
self.satellites.len()
|
|
}
|
|
|
|
/// Returns `true` when the constellation is empty.
|
|
///
|
|
/// A constellation built through either constructor is never empty, so
|
|
/// this always returns `false`; it exists because a collection type
|
|
/// should offer it alongside [`Constellation::len`].
|
|
pub fn is_empty(&self) -> bool {
|
|
self.satellites.is_empty()
|
|
}
|
|
|
|
/// Returns the number of orbital planes.
|
|
pub fn plane_count(&self) -> usize {
|
|
self.satellites
|
|
.iter()
|
|
.map(|satellite| satellite.plane)
|
|
.max()
|
|
.map_or(0, |highest| highest + 1)
|
|
}
|
|
|
|
/// Returns the Walker pattern, when the constellation was built from
|
|
/// one.
|
|
pub fn pattern(&self) -> Option<WalkerPattern> {
|
|
self.pattern
|
|
}
|
|
|
|
/// Returns the reference time as a Modified Julian Date on the TAI
|
|
/// scale.
|
|
pub fn reference_mjd(&self) -> f64 {
|
|
self.reference_mjd
|
|
}
|
|
|
|
/// Returns the satellites of one plane.
|
|
///
|
|
/// Returns an empty slice when the plane index is out of range.
|
|
pub fn plane(&self, plane: usize) -> Vec<Satellite> {
|
|
self.satellites
|
|
.iter()
|
|
.filter(|satellite| satellite.plane == plane)
|
|
.copied()
|
|
.collect()
|
|
}
|
|
|
|
/// Returns the altitude of the constellation above the given radius, in
|
|
/// kilometers.
|
|
///
|
|
/// Every satellite of a Walker pattern shares a semi-major axis, so a
|
|
/// single altitude describes the whole constellation. For a custom
|
|
/// constellation this is the altitude of the first satellite.
|
|
///
|
|
/// Returns `None` when the constellation is empty.
|
|
pub fn altitude_above(&self, body_radius_km: f64) -> Option<f64> {
|
|
self.satellites
|
|
.first()
|
|
.map(|satellite| satellite.elements.semi_major_axis - body_radius_km)
|
|
}
|
|
|
|
/// Returns the count of satellites in each plane.
|
|
///
|
|
/// The result has one entry per plane, in plane order.
|
|
pub fn plane_populations(&self) -> Vec<usize> {
|
|
let mut populations = vec![0; self.plane_count()];
|
|
for satellite in &self.satellites {
|
|
populations[satellite.plane] += 1;
|
|
}
|
|
populations
|
|
}
|
|
}
|