use crate::access::constraints::AccessConstraint;
use crate::access::geometry::*;
use crate::access::location::AccessibleLocation;
use crate::access::properties::{AccessProperties, AccessPropertyComputer};
use crate::constants::{AngleFormat, GM_EARTH};
use crate::coordinates::position_ecef_to_geodetic;
use crate::orbits::keplerian::orbital_period_from_state;
use crate::time::Epoch;
use crate::traits::Identifiable;
use crate::utils::BraheError;
use crate::utils::state_providers::DIdentifiableStateProvider;
use std::sync::atomic::{AtomicUsize, Ordering};
use uuid::Uuid;
static ACCESS_COUNTER: AtomicUsize = AtomicUsize::new(1);
#[derive(Debug, Clone)]
pub struct AccessWindow {
pub window_open: Epoch,
pub window_close: Epoch,
pub location_name: Option<String>,
pub location_id: Option<u64>,
pub location_uuid: Option<Uuid>,
pub satellite_name: Option<String>,
pub satellite_id: Option<u64>,
pub satellite_uuid: Option<Uuid>,
pub name: Option<String>,
pub id: Option<u64>,
pub uuid: Option<Uuid>,
pub properties: AccessProperties,
}
impl AccessWindow {
pub fn new<L: AccessibleLocation, S: Identifiable>(
window_open: Epoch,
window_close: Epoch,
location: &L,
satellite: &S,
properties: AccessProperties,
) -> Self {
let counter = ACCESS_COUNTER.fetch_add(1, Ordering::SeqCst);
let loc_name = location.get_name().map(|s| s.to_string());
let sat_name = satellite.get_name().map(|s| s.to_string());
let name = match (&loc_name, &sat_name) {
(Some(loc), Some(sat)) => Some(format!("{}-{}-Access-{:03}", loc, sat, counter)),
_ => Some(format!("Access-{:03}", counter)),
};
Self {
window_open,
window_close,
location_name: loc_name,
location_id: location.get_id(),
location_uuid: location.get_uuid(),
satellite_name: sat_name,
satellite_id: satellite.get_id(),
satellite_uuid: satellite.get_uuid(),
name,
id: None,
uuid: None,
properties,
}
}
pub fn start(&self) -> Epoch {
self.window_open
}
pub fn end(&self) -> Epoch {
self.window_close
}
pub fn t_start(&self) -> Epoch {
self.window_open
}
pub fn t_end(&self) -> Epoch {
self.window_close
}
pub fn midtime(&self) -> Epoch {
self.window_open + (self.window_close - self.window_open) / 2.0
}
pub fn duration(&self) -> f64 {
self.window_close - self.window_open
}
}
impl Identifiable for AccessWindow {
fn with_name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
fn with_uuid(mut self, uuid: Uuid) -> Self {
self.uuid = Some(uuid);
self
}
fn with_new_uuid(mut self) -> Self {
self.uuid = Some(Uuid::now_v7());
self
}
fn with_id(mut self, id: u64) -> Self {
self.id = Some(id);
self
}
fn with_identity(mut self, name: Option<&str>, uuid: Option<Uuid>, id: Option<u64>) -> Self {
if let Some(n) = name {
self.name = Some(n.to_string());
}
self.uuid = uuid;
self.id = id;
self
}
fn set_identity(&mut self, name: Option<&str>, uuid: Option<Uuid>, id: Option<u64>) {
if let Some(n) = name {
self.name = Some(n.to_string());
} else {
self.name = None;
}
self.uuid = uuid;
self.id = id;
}
fn set_id(&mut self, id: Option<u64>) {
self.id = id;
}
fn set_name(&mut self, name: Option<&str>) {
self.name = name.map(|s| s.to_string());
}
fn generate_uuid(&mut self) {
self.uuid = Some(Uuid::now_v7());
}
fn get_id(&self) -> Option<u64> {
self.id
}
fn get_name(&self) -> Option<&str> {
self.name.as_deref()
}
fn get_uuid(&self) -> Option<Uuid> {
self.uuid
}
}
#[derive(Debug, Clone, Copy)]
pub enum SubdivisionConfig {
EqualCount {
count: usize,
},
FixedDuration {
duration: f64,
offset: f64,
gap: f64,
truncate_partial: bool,
},
}
impl SubdivisionConfig {
pub fn equal_count(count: usize) -> Result<Self, BraheError> {
if count < 1 {
return Err(BraheError::Error(
"SubdivisionConfig: count must be >= 1".to_string(),
));
}
Ok(Self::EqualCount { count })
}
pub fn fixed_duration(
duration: f64,
offset: f64,
gap: f64,
truncate_partial: bool,
) -> Result<Self, BraheError> {
if duration <= 0.0 {
return Err(BraheError::Error(
"SubdivisionConfig: duration must be > 0.0".to_string(),
));
}
if offset < 0.0 {
return Err(BraheError::Error(
"SubdivisionConfig: offset must be >= 0.0".to_string(),
));
}
if gap <= -duration {
return Err(BraheError::Error(
"SubdivisionConfig: gap must be > -duration to ensure forward progress".to_string(),
));
}
Ok(Self::FixedDuration {
duration,
offset,
gap,
truncate_partial,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct AccessSearchConfig {
pub initial_time_step: f64,
pub adaptive_step: bool,
pub adaptive_fraction: f64,
pub parallel: bool,
pub num_threads: Option<usize>,
pub time_tolerance: f64,
pub subdivisions: Option<SubdivisionConfig>,
}
impl Default for AccessSearchConfig {
fn default() -> Self {
Self {
initial_time_step: 60.0,
adaptive_step: false,
adaptive_fraction: 0.75,
parallel: true,
num_threads: None,
time_tolerance: 0.001,
subdivisions: None,
}
}
}
pub fn find_access_candidates<L: AccessibleLocation, P: DIdentifiableStateProvider>(
location: &L,
propagator: &P,
search_start: Epoch,
search_end: Epoch,
constraint: &dyn AccessConstraint,
config: &AccessSearchConfig,
) -> Result<Vec<(Epoch, Epoch)>, BraheError> {
let mut candidates = Vec::new();
let location_ecef = location.center_ecef();
let orbital_period = if config.adaptive_step {
let state_eci = propagator.state_eci(search_start)?;
Some(orbital_period_from_state(&state_eci, GM_EARTH))
} else {
None
};
let mut current_time = search_start;
let mut in_window = false;
let mut window_start = search_start;
while current_time <= search_end {
let sat_state_ecef = propagator.state_ecef(current_time)?;
let is_satisfied = constraint.evaluate(¤t_time, &sat_state_ecef, &location_ecef);
if is_satisfied && !in_window {
window_start = current_time;
in_window = true;
current_time += config.initial_time_step;
} else if !is_satisfied && in_window {
candidates.push((window_start, current_time - config.initial_time_step));
in_window = false;
if let Some(period) = orbital_period {
current_time += config.adaptive_fraction * period - config.initial_time_step;
}
current_time += config.initial_time_step;
} else {
current_time += config.initial_time_step;
}
}
if in_window {
candidates.push((window_start, search_end));
}
Ok(candidates)
}
pub enum StepDirection {
Forward,
Backward,
}
#[allow(clippy::too_many_arguments)]
pub fn bisection_search<L: AccessibleLocation, P: DIdentifiableStateProvider>(
location: &L,
propagator: &P,
time: Epoch,
direction: StepDirection,
step: f64,
condition: bool,
constraint: &dyn AccessConstraint,
tolerance: f64,
min_bound: Epoch,
max_bound: Epoch,
) -> Result<Epoch, BraheError> {
let location_ecef = location.center_ecef();
let mut current_time = time;
let mut steps_taken = 0;
const MAX_STEPS: usize = 1000;
loop {
let next_time = match direction {
StepDirection::Forward => current_time + step,
StepDirection::Backward => current_time - step,
};
if next_time < min_bound || next_time > max_bound || steps_taken >= MAX_STEPS {
return Ok(current_time);
}
current_time = next_time;
steps_taken += 1;
let sat_state_ecef = propagator.state_ecef(current_time)?;
let current_condition = constraint.evaluate(¤t_time, &sat_state_ecef, &location_ecef);
if current_condition != condition {
if step < tolerance {
return Ok(current_time);
} else {
let new_direction = match direction {
StepDirection::Forward => StepDirection::Backward,
StepDirection::Backward => StepDirection::Forward,
};
return bisection_search(
location,
propagator,
current_time,
new_direction,
step / 2.0,
current_condition,
constraint,
tolerance,
min_bound,
max_bound,
);
}
}
}
}
pub fn compute_window_properties<L: AccessibleLocation, P: DIdentifiableStateProvider>(
window_open: Epoch,
window_close: Epoch,
location: &L,
propagator: &P,
property_computers: Option<&[&dyn AccessPropertyComputer]>,
) -> Result<AccessProperties, crate::utils::BraheError> {
let location_ecef = location.center_ecef();
let location_geodetic = position_ecef_to_geodetic(location_ecef, AngleFormat::Radians);
let midtime = window_open + (window_close - window_open) / 2.0;
let state_open_ecef = propagator.state_ecef(window_open)?;
let state_close_ecef = propagator.state_ecef(window_close)?;
let state_mid_ecef = propagator.state_ecef(midtime)?;
let sat_pos_open = state_open_ecef.fixed_rows::<3>(0).into_owned();
let sat_pos_close = state_close_ecef.fixed_rows::<3>(0).into_owned();
let sat_pos_mid = state_mid_ecef.fixed_rows::<3>(0).into_owned();
let azimuth_open = compute_azimuth(&sat_pos_open, &location_ecef);
let azimuth_close = compute_azimuth(&sat_pos_close, &location_ecef);
let elevation_open = compute_elevation(&sat_pos_open, &location_ecef);
let elevation_close = compute_elevation(&sat_pos_close, &location_ecef);
let elevation_mid = compute_elevation(&sat_pos_mid, &location_ecef);
let mut elevation_samples = vec![elevation_open, elevation_close, elevation_mid];
for i in 1..4 {
let t = window_open + (window_close - window_open) * (i as f64 / 4.0);
let state_ecef = propagator.state_ecef(t)?;
let pos = state_ecef.fixed_rows::<3>(0).into_owned();
elevation_samples.push(compute_elevation(&pos, &location_ecef));
}
let elevation_min = elevation_samples
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
let elevation_max = elevation_samples
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let off_nadir_open = compute_off_nadir(&sat_pos_open, &location_ecef);
let off_nadir_close = compute_off_nadir(&sat_pos_close, &location_ecef);
let off_nadir_mid = compute_off_nadir(&sat_pos_mid, &location_ecef);
let off_nadir_min = off_nadir_open.min(off_nadir_close).min(off_nadir_mid);
let off_nadir_max = off_nadir_open.max(off_nadir_close).max(off_nadir_mid);
let local_time = compute_local_time(&midtime, &location_geodetic);
let look_direction = compute_look_direction(&state_mid_ecef, &location_ecef);
let asc_dsc = compute_asc_dsc(&state_mid_ecef);
let center_lon = location_geodetic[0].to_degrees();
let center_lat = location_geodetic[1].to_degrees();
let center_alt = location_geodetic[2];
let center_ecef = [location_ecef[0], location_ecef[1], location_ecef[2]];
let mut properties = AccessProperties::new(
azimuth_open,
azimuth_close,
elevation_min,
elevation_max,
elevation_open,
elevation_close,
off_nadir_min,
off_nadir_max,
local_time,
look_direction,
asc_dsc,
center_lon,
center_lat,
center_alt,
center_ecef,
);
if let Some(computers) = property_computers {
let temp_window = AccessWindow {
window_open,
window_close,
location_name: location.get_name().map(|s| s.to_string()),
location_id: location.get_id(),
location_uuid: location.get_uuid(),
satellite_name: propagator.get_name().map(|s| s.to_string()),
satellite_id: propagator.get_id(),
satellite_uuid: propagator.get_uuid(),
name: None, id: None,
uuid: None,
properties: properties.clone(),
};
for computer in computers {
let sampling_config = computer.sampling_config();
let sample_epochs = sampling_config.generate_sample_epochs(window_open, window_close);
let sample_states: Vec<nalgebra::SVector<f64, 6>> = sample_epochs
.iter()
.map(|&epoch| propagator.state_ecef(epoch))
.collect::<Result<Vec<_>, _>>()?;
let sample_epochs_mjd: Vec<f64> = sample_epochs.iter().map(|e| e.mjd()).collect();
let additional = computer.compute(
&temp_window,
&sample_epochs_mjd,
&sample_states,
&location_ecef,
&location_geodetic,
)?;
for (key, value) in additional {
properties.add_property(key, value);
}
}
}
Ok(properties)
}
fn compute_window_properties_internal<L: AccessibleLocation, P: DIdentifiableStateProvider>(
window_open: Epoch,
window_close: Epoch,
location: &L,
propagator: &P,
property_computers: Option<&[&dyn AccessPropertyComputer]>,
) -> Result<AccessProperties, crate::utils::BraheError> {
compute_window_properties(
window_open,
window_close,
location,
propagator,
property_computers,
)
}
pub fn find_access_windows<L: AccessibleLocation, P: DIdentifiableStateProvider>(
location: &L,
propagator: &P,
search_start: Epoch,
search_end: Epoch,
constraint: &dyn AccessConstraint,
property_computers: Option<&[&dyn AccessPropertyComputer]>,
config: Option<&AccessSearchConfig>,
) -> Result<Vec<AccessWindow>, BraheError> {
let config = config.copied().unwrap_or_default();
let candidates = find_access_candidates(
location,
propagator,
search_start,
search_end,
constraint,
&config,
)?;
let mut windows = Vec::new();
for (coarse_start, coarse_end) in candidates {
let location_ecef = location.center_ecef();
let refined_start = if coarse_start > search_start {
let sat_state = propagator.state_ecef(coarse_start)?;
let start_condition = constraint.evaluate(&coarse_start, &sat_state, &location_ecef);
bisection_search(
location,
propagator,
coarse_start,
StepDirection::Backward,
config.initial_time_step,
start_condition,
constraint,
config.time_tolerance,
search_start, coarse_start, )?
} else {
coarse_start
};
let refined_end = if coarse_end < search_end {
let sat_state = propagator.state_ecef(coarse_end)?;
let end_condition = constraint.evaluate(&coarse_end, &sat_state, &location_ecef);
bisection_search(
location,
propagator,
coarse_end,
StepDirection::Forward, config.initial_time_step,
end_condition,
constraint,
config.time_tolerance,
coarse_end, search_end, )?
} else {
coarse_end
};
match &config.subdivisions {
Some(SubdivisionConfig::EqualCount { count }) => {
let duration = refined_end - refined_start;
let sub_duration = duration / *count as f64;
for i in 0..*count {
let sub_open = refined_start + sub_duration * i as f64;
let sub_close = refined_start + sub_duration * (i + 1) as f64;
let sub_properties = compute_window_properties_internal(
sub_open,
sub_close,
location,
propagator,
property_computers,
)?;
windows.push(AccessWindow::new(
sub_open,
sub_close,
location,
propagator,
sub_properties,
));
}
}
Some(SubdivisionConfig::FixedDuration {
duration,
offset,
gap,
truncate_partial,
}) => {
let parent_duration = refined_end - refined_start;
if *offset < parent_duration {
let mut sub_start = refined_start + *offset;
while sub_start < refined_end {
let mut sub_end = sub_start + *duration;
if sub_end > refined_end {
if *truncate_partial {
sub_end = refined_end;
break; } else {
break; }
}
if sub_end > sub_start {
let sub_properties = compute_window_properties_internal(
sub_start,
sub_end,
location,
propagator,
property_computers,
)?;
windows.push(AccessWindow::new(
sub_start,
sub_end,
location,
propagator,
sub_properties,
));
}
sub_start = sub_end + *gap;
}
}
}
None => {
let properties = compute_window_properties_internal(
refined_start,
refined_end,
location,
propagator,
property_computers,
)?;
windows.push(AccessWindow::new(
refined_start,
refined_end,
location,
propagator,
properties,
));
}
}
}
Ok(windows)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use crate::access::constraints::ElevationConstraint;
use crate::access::location::PointLocation;
use crate::constants::{AngleFormat, R_EARTH};
use crate::propagators::keplerian_propagator::KeplerianPropagator;
use crate::time::TimeSystem;
use crate::utils::state_providers::DOrbitStateProvider;
use crate::utils::testing::setup_global_test_eop;
use nalgebra::Vector6;
use serial_test::serial;
#[test]
#[serial]
fn test_find_access_candidates() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(
R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0, );
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0; let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config = AccessSearchConfig::default();
let candidates = find_access_candidates(
&location,
&propagator,
epoch,
search_end,
&constraint,
&config,
)
.unwrap();
assert!(
!candidates.is_empty(),
"Expected to find at least 1 access window, found {}",
candidates.len()
);
for (start, end) in candidates {
assert!(start < end);
}
}
#[test]
fn test_bisection_search() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let constraint = ElevationConstraint::new(Some(10.0), None).unwrap();
let t_start = epoch + 300.0;
let location_ecef = location.center_ecef();
let sat_state = propagator.state_ecef(t_start).unwrap();
let initial_condition = constraint.evaluate(&t_start, &sat_state, &location_ecef);
let refined = bisection_search(
&location,
&propagator,
t_start,
StepDirection::Backward,
60.0,
initial_condition,
&constraint,
0.01,
epoch, t_start, )
.unwrap();
assert!(refined <= t_start);
assert!(refined >= epoch);
}
#[test]
fn test_compute_window_properties() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 45.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
assert!((0.0..=360.0).contains(&properties.azimuth_open));
assert!((0.0..=360.0).contains(&properties.azimuth_close));
assert!((-90.0..=90.0).contains(&properties.elevation_min));
assert!((-90.0..=90.0).contains(&properties.elevation_max));
assert!((0.0..=180.0).contains(&properties.off_nadir_min));
assert!((0.0..=180.0).contains(&properties.off_nadir_max));
assert!((0.0..=86400.0).contains(&properties.local_time));
}
#[test]
#[serial]
fn test_find_access_windows() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config = AccessSearchConfig {
time_tolerance: 0.1,
..Default::default()
};
let windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config),
)
.unwrap();
assert!(
!windows.is_empty(),
"Expected to find at least 1 access window, found {}",
windows.len()
);
for window in windows {
assert!(window.window_open < window.window_close);
assert!(window.duration() > 0.0);
assert!((0.0..=360.0).contains(&window.properties.azimuth_open));
assert!((-90.0..=90.0).contains(&window.properties.elevation_max));
}
}
#[test]
fn test_access_window_implements_identifiable() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 45.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
assert!(window.get_name().is_some());
assert!(window.get_id().is_none());
assert!(window.get_uuid().is_none());
let window_with_id = window.clone().with_id(123);
assert_eq!(window_with_id.get_id(), Some(123));
let window_with_uuid = window.clone().with_new_uuid();
assert!(window_with_uuid.get_uuid().is_some());
let window_with_custom_name = window.clone().with_name("CustomAccess");
assert_eq!(window_with_custom_name.get_name(), Some("CustomAccess"));
}
#[test]
fn test_access_window_auto_naming_with_location_and_satellite() {
setup_global_test_eop();
let location = PointLocation::new(15.4, 78.2, 0.0).with_name("Svalbard");
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let mut propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
propagator.set_name(Some("Sentinel1"));
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
let name = window.get_name().unwrap();
assert!(name.starts_with("Svalbard-Sentinel1-Access-"));
assert!(name.contains("-Access-"));
}
#[test]
fn test_access_window_auto_naming_without_names() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
let name = window.get_name().unwrap();
assert!(name.starts_with("Access-"));
assert!(!name.contains("-Access-")); }
#[test]
fn test_access_window_counter_increments() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window1 = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties.clone(),
);
let window2 = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties.clone(),
);
let window3 = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
let name1 = window1.get_name().unwrap();
let name2 = window2.get_name().unwrap();
let name3 = window3.get_name().unwrap();
assert_ne!(name1, name2);
assert_ne!(name2, name3);
assert_ne!(name1, name3);
assert!(name1.starts_with("Access-"));
assert!(name2.starts_with("Access-"));
assert!(name3.starts_with("Access-"));
}
#[test]
#[serial]
fn test_elevation_boundary_precision() {
setup_global_test_eop();
let location = PointLocation::new(-74.0060, 40.7128, 0.0);
let oe = Vector6::new(
R_EARTH + 500e3, 0.001, 97.8_f64.to_radians(), 0.0, 0.0, 0.0, );
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let search_end = epoch + 86400.0; let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
None,
)
.unwrap();
assert!(
!windows.is_empty(),
"Expected to find at least 1 access window for LEO satellite over NYC in 24 hours"
);
let tolerance = 0.001; let constraint_elevation = 5.0;
for (i, window) in windows.iter().enumerate() {
let elevation_open = window.properties.elevation_open;
let elevation_close = window.properties.elevation_close;
let error_open = (elevation_open - constraint_elevation).abs();
let error_close = (elevation_close - constraint_elevation).abs();
assert!(
error_open <= tolerance,
"Window {}: elevation_open = {:.6}° differs from constraint ({:.1}°) by {:.6}° (tolerance: {:.3}°)",
i,
elevation_open,
constraint_elevation,
error_open,
tolerance
);
assert!(
error_close <= tolerance,
"Window {}: elevation_close = {:.6}° differs from constraint ({:.1}°) by {:.6}° (tolerance: {:.3}°)",
i,
elevation_close,
constraint_elevation,
error_close,
tolerance
);
}
println!(
"\nValidated {} access windows - all boundary elevations within {:.3}° of {:.1}° constraint",
windows.len(),
tolerance,
constraint_elevation
);
}
#[test]
fn test_access_window_with_uuid() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
assert!(window.get_uuid().is_none());
let test_uuid = Uuid::now_v7();
let window_with_uuid = window.with_uuid(test_uuid);
assert_eq!(window_with_uuid.get_uuid(), Some(test_uuid));
}
#[test]
fn test_access_window_with_identity() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
let test_uuid = Uuid::now_v7();
let window_with_identity =
window
.clone()
.with_identity(Some("TestAccess"), Some(test_uuid), Some(42));
assert_eq!(window_with_identity.get_name(), Some("TestAccess"));
assert_eq!(window_with_identity.get_uuid(), Some(test_uuid));
assert_eq!(window_with_identity.get_id(), Some(42));
let window_partial = window.with_identity(Some("PartialAccess"), None, Some(99));
assert_eq!(window_partial.get_name(), Some("PartialAccess"));
assert_eq!(window_partial.get_uuid(), None);
assert_eq!(window_partial.get_id(), Some(99));
}
#[test]
fn test_access_window_set_identity() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let mut window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
assert!(window.get_name().is_some());
assert!(window.get_uuid().is_none());
assert!(window.get_id().is_none());
let test_uuid = Uuid::now_v7();
window.set_identity(Some("MutableAccess"), Some(test_uuid), Some(123));
assert_eq!(window.get_name(), Some("MutableAccess"));
assert_eq!(window.get_uuid(), Some(test_uuid));
assert_eq!(window.get_id(), Some(123));
window.set_identity(None, Some(test_uuid), Some(456));
assert_eq!(window.get_name(), None);
assert_eq!(window.get_uuid(), Some(test_uuid));
assert_eq!(window.get_id(), Some(456));
}
#[test]
fn test_access_window_generate_uuid() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let mut window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
assert!(window.get_uuid().is_none());
window.generate_uuid();
assert!(window.get_uuid().is_some());
let uuid = window.get_uuid().unwrap();
assert_eq!(uuid.get_version_num(), 7);
let first_uuid = uuid;
window.generate_uuid();
let second_uuid = window.get_uuid().unwrap();
assert_ne!(first_uuid, second_uuid);
}
#[test]
fn test_access_window_time_methods() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
let properties =
compute_window_properties(window_open, window_close, &location, &propagator, None)
.unwrap();
let window = AccessWindow::new(
window_open,
window_close,
&location,
&propagator,
properties,
);
assert_eq!(window.start(), window_open);
assert_eq!(window.t_start(), window_open);
assert_eq!(window.start(), window.t_start());
assert_eq!(window.end(), window_close);
assert_eq!(window.t_end(), window_close);
assert_eq!(window.end(), window.t_end());
let expected_midtime = window_open + 150.0; assert_eq!(window.midtime(), expected_midtime);
}
#[test]
fn test_compute_window_properties_with_property_computer() {
setup_global_test_eop();
let location = PointLocation::new(0.0, 45.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let window_open = epoch;
let window_close = epoch + 300.0;
use crate::access::properties::{DopplerComputer, RangeComputer, SamplingConfig};
let doppler_computer = DopplerComputer::new(
Some(2.2e9), Some(8.4e9), SamplingConfig::Midpoint,
);
let range_computer = RangeComputer::new(SamplingConfig::FixedCount(5));
let computers: [&dyn AccessPropertyComputer; 2] = [&doppler_computer, &range_computer];
let properties = compute_window_properties(
window_open,
window_close,
&location,
&propagator,
Some(&computers),
)
.unwrap();
assert!((0.0..=360.0).contains(&properties.azimuth_open));
assert!((0.0..=360.0).contains(&properties.azimuth_close));
assert!((-90.0..=90.0).contains(&properties.elevation_min));
assert!((-90.0..=90.0).contains(&properties.elevation_max));
assert!(
properties.get_property("doppler_uplink").is_some(),
"Uplink Doppler property should be computed"
);
assert!(
properties.get_property("doppler_downlink").is_some(),
"Downlink Doppler property should be computed"
);
assert!(
properties.get_property("range").is_some(),
"Range property should be computed"
);
if let Some(doppler_uplink) = properties.get_property("doppler_uplink") {
match doppler_uplink {
crate::access::properties::PropertyValue::Scalar(value) => {
assert!(
value.abs() < 20000.0,
"Uplink Doppler should be reasonable: {}",
value
);
}
_ => panic!("Expected Scalar property for doppler_uplink"),
}
}
if let Some(range) = properties.get_property("range") {
match range {
crate::access::properties::PropertyValue::TimeSeries { times, values } => {
assert_eq!(times.len(), 5, "Should have 5 time samples");
assert_eq!(values.len(), 5, "Should have 5 range values");
for value in values {
assert!(*value > 0.0, "Range should be positive");
assert!(
*value < 10000e3,
"Range should be less than 10000 km for visible LEO satellite"
);
}
}
_ => panic!("Expected TimeSeries property for range"),
}
}
}
#[test]
#[serial]
fn test_find_access_candidates_with_adaptive_stepping() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(
R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0, );
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0; let search_end = epoch + (period * 5.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config_no_adaptive = AccessSearchConfig {
initial_time_step: 60.0,
adaptive_step: false,
adaptive_fraction: 0.5,
parallel: false,
num_threads: Some(1),
..Default::default()
};
let candidates_no_adaptive = find_access_candidates(
&location,
&propagator,
epoch,
search_end,
&constraint,
&config_no_adaptive,
)
.unwrap();
let config_adaptive = AccessSearchConfig {
initial_time_step: 60.0,
adaptive_step: true,
adaptive_fraction: 0.5, parallel: false,
num_threads: Some(1),
..Default::default()
};
let candidates_adaptive = find_access_candidates(
&location,
&propagator,
epoch,
search_end,
&constraint,
&config_adaptive,
)
.unwrap();
assert!(
!candidates_no_adaptive.is_empty(),
"Expected to find access windows without adaptive stepping"
);
assert!(
!candidates_adaptive.is_empty(),
"Expected to find access windows with adaptive stepping"
);
let count_diff =
(candidates_no_adaptive.len() as i32 - candidates_adaptive.len() as i32).abs();
assert!(
count_diff <= 2,
"Window counts should be similar: no_adaptive={}, adaptive={}, diff={}",
candidates_no_adaptive.len(),
candidates_adaptive.len(),
count_diff
);
for (adaptive_start, adaptive_end) in &candidates_adaptive {
let has_overlap = candidates_no_adaptive.iter().any(|(start, end)| {
!(adaptive_end < start || adaptive_start > end)
});
assert!(
has_overlap,
"Adaptive window ({} to {}) should overlap with at least one non-adaptive window",
adaptive_start, adaptive_end
);
}
println!(
"Adaptive stepping test: found {} windows without adaptive, {} windows with adaptive",
candidates_no_adaptive.len(),
candidates_adaptive.len()
);
}
#[test]
#[serial]
fn test_find_access_windows_with_subdivisions() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config_no_sub = AccessSearchConfig {
time_tolerance: 0.1,
..Default::default()
};
let parent_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_sub),
)
.unwrap();
let parent_count = parent_windows.len();
assert!(
parent_count > 0,
"Need at least 1 parent window for subdivision test"
);
let n = 4;
let config_sub = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::EqualCount { count: n }),
..Default::default()
};
let sub_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_sub),
)
.unwrap();
assert_eq!(
sub_windows.len(),
n * parent_count,
"Expected {} sub-windows ({}*{}), found {}",
n * parent_count,
n,
parent_count,
sub_windows.len()
);
for (parent_idx, parent) in parent_windows.iter().enumerate() {
let base = parent_idx * n;
let parent_open = parent.window_open;
let parent_close = parent.window_close;
assert!(
(sub_windows[base].window_open - parent_open).abs() < 1e-6,
"First sub-window open should match parent open"
);
assert!(
(sub_windows[base + n - 1].window_close - parent_close).abs() < 1e-6,
"Last sub-window close should match parent close"
);
for i in 1..n {
let prev_close = sub_windows[base + i - 1].window_close;
let curr_open = sub_windows[base + i].window_open;
assert!(
(prev_close - curr_open).abs() < 1e-6,
"Sub-windows should be contiguous: sub[{}].close ({}) != sub[{}].open ({})",
i - 1,
prev_close,
i,
curr_open,
);
}
for i in 0..n {
let sw = &sub_windows[base + i];
assert!(sw.window_open < sw.window_close);
assert!(sw.duration() > 0.0);
assert!((0.0..=360.0).contains(&sw.properties.azimuth_open));
assert!((-90.0..=90.0).contains(&sw.properties.elevation_max));
}
}
}
#[test]
#[serial]
fn test_find_access_windows_no_subdivisions_preserves_behavior() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: None,
..Default::default()
};
let _windows_explicit = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config),
)
.unwrap();
let windows_default = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
None,
)
.unwrap();
let config_default_tol = AccessSearchConfig::default();
let windows_default_tol = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_default_tol),
)
.unwrap();
assert_eq!(windows_default.len(), windows_default_tol.len());
}
#[test]
fn test_subdivision_config_equal_count_validation() {
assert!(SubdivisionConfig::equal_count(1).is_ok());
assert!(SubdivisionConfig::equal_count(10).is_ok());
}
#[test]
fn test_subdivision_config_fixed_duration_validation() {
assert!(SubdivisionConfig::fixed_duration(30.0, 0.0, 0.0, false).is_ok());
assert!(SubdivisionConfig::fixed_duration(30.0, 10.0, -5.0, true).is_ok());
assert!(SubdivisionConfig::fixed_duration(30.0, 0.0, -29.99, false).is_ok());
assert!(SubdivisionConfig::fixed_duration(0.0, 0.0, 0.0, false).is_err());
assert!(SubdivisionConfig::fixed_duration(-1.0, 0.0, 0.0, false).is_err());
assert!(SubdivisionConfig::fixed_duration(30.0, -1.0, 0.0, false).is_err());
assert!(SubdivisionConfig::fixed_duration(30.0, 0.0, -30.0, false).is_err());
assert!(SubdivisionConfig::fixed_duration(30.0, 0.0, -31.0, false).is_err());
}
#[test]
#[serial]
fn test_find_access_windows_fixed_duration() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config_no_sub = AccessSearchConfig {
time_tolerance: 0.1,
..Default::default()
};
let parent_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_sub),
)
.unwrap();
assert!(!parent_windows.is_empty());
let sub_dur = 30.0; let config_sub = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset: 0.0,
gap: 0.0,
truncate_partial: false,
}),
..Default::default()
};
let sub_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_sub),
)
.unwrap();
assert!(!sub_windows.is_empty());
for sw in &sub_windows {
assert!(sw.duration() <= sub_dur + 1e-6);
assert!(sw.duration() > 0.0);
}
}
#[test]
#[serial]
fn test_find_access_windows_fixed_duration_with_gap() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let sub_dur = 30.0;
let gap = 10.0;
let config_sub = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset: 0.0,
gap,
truncate_partial: false,
}),
..Default::default()
};
let sub_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_sub),
)
.unwrap();
assert!(!sub_windows.is_empty());
for sw in &sub_windows {
assert!(
(sw.duration() - sub_dur).abs() < 1e-6,
"Expected sub-window duration {}, got {}",
sub_dur,
sw.duration()
);
}
}
#[test]
#[serial]
fn test_find_access_windows_fixed_duration_truncate() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config_no_sub = AccessSearchConfig {
time_tolerance: 0.1,
..Default::default()
};
let parent_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_sub),
)
.unwrap();
assert!(!parent_windows.is_empty());
let sub_dur = 30.0;
let config_trunc = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset: 0.0,
gap: 0.0,
truncate_partial: true,
}),
..Default::default()
};
let trunc_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_trunc),
)
.unwrap();
let config_no_trunc = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset: 0.0,
gap: 0.0,
truncate_partial: false,
}),
..Default::default()
};
let no_trunc_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_trunc),
)
.unwrap();
assert!(
trunc_windows.len() >= no_trunc_windows.len(),
"Truncation should produce >= sub-windows: trunc={}, no_trunc={}",
trunc_windows.len(),
no_trunc_windows.len()
);
}
#[test]
#[serial]
fn test_find_access_windows_fixed_duration_with_offset() {
setup_global_test_eop();
let location = PointLocation::new(45.0, 0.0, 0.0);
let oe = Vector6::new(R_EARTH + 500e3, 0.0, 45.0_f64.to_radians(), 0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 0, 0, 0.0, 0.0, TimeSystem::UTC);
let propagator = KeplerianPropagator::new(
epoch,
oe,
crate::trajectories::traits::OrbitFrame::ECI,
crate::trajectories::traits::OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let period = 5674.0;
let search_end = epoch + (period * 2.0);
let constraint = ElevationConstraint::new(Some(5.0), None).unwrap();
let config_no_sub = AccessSearchConfig {
time_tolerance: 0.1,
..Default::default()
};
let parent_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_sub),
)
.unwrap();
assert!(!parent_windows.is_empty());
let offset = 10.0;
let sub_dur = 30.0;
let config_offset = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset,
gap: 0.0,
truncate_partial: false,
}),
..Default::default()
};
let sub_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_offset),
)
.unwrap();
assert!(!sub_windows.is_empty());
let config_no_offset = AccessSearchConfig {
time_tolerance: 0.1,
subdivisions: Some(SubdivisionConfig::FixedDuration {
duration: sub_dur,
offset: 0.0,
gap: 0.0,
truncate_partial: false,
}),
..Default::default()
};
let no_offset_windows = find_access_windows(
&location,
&propagator,
epoch,
search_end,
&constraint,
None,
Some(&config_no_offset),
)
.unwrap();
assert!(
sub_windows.len() <= no_offset_windows.len(),
"Offset should produce <= sub-windows: offset={}, no_offset={}",
sub_windows.len(),
no_offset_windows.len()
);
}
}