use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::time::Epoch;
use crate::utils::BraheError;
use super::constraints::{AscDsc, LookDirection};
#[allow(unused_imports)]
use crate::access::AccessWindow;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PropertyValue {
Scalar(f64),
Vector(Vec<f64>),
TimeSeries {
times: Vec<f64>,
values: Vec<f64>,
},
Boolean(bool),
String(String),
Json(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SamplingConfig {
Midpoint,
RelativePoints(Vec<f64>),
FixedInterval {
interval: f64,
offset: f64,
},
FixedCount(usize),
}
impl SamplingConfig {
pub fn generate_sample_epochs(&self, window_open: Epoch, window_close: Epoch) -> Vec<Epoch> {
let duration = window_close - window_open;
match self {
SamplingConfig::Midpoint => {
vec![window_open + duration * 0.5]
}
SamplingConfig::RelativePoints(relative_times) => {
if relative_times.is_empty() {
panic!("SamplingConfig::RelativePoints: relative_times cannot be empty");
}
for &t in relative_times.iter() {
if !(0.0..=1.0).contains(&t) {
panic!(
"SamplingConfig::RelativePoints: all relative times must be in [0.0, 1.0], got {}",
t
);
}
}
relative_times
.iter()
.map(|&t| window_open + duration * t)
.collect()
}
SamplingConfig::FixedInterval { interval, offset } => {
if *interval <= 0.0 {
panic!(
"SamplingConfig::FixedInterval: interval must be positive, got {}",
interval
);
}
if *offset < 0.0 {
panic!(
"SamplingConfig::FixedInterval: offset must be non-negative, got {}",
offset
);
}
if *offset > duration {
panic!(
"SamplingConfig::FixedInterval: offset ({}) exceeds window duration ({})",
offset, duration
);
}
let mut epochs = Vec::new();
let mut t = *offset;
while t <= duration {
epochs.push(window_open + t);
t += interval;
}
if epochs.is_empty() {
panic!(
"SamplingConfig::FixedInterval: no samples generated within window (interval too large)"
);
}
epochs
}
SamplingConfig::FixedCount(count) => {
if *count == 0 {
panic!("SamplingConfig::FixedCount: count must be positive, got 0");
}
if *count == 1 {
return vec![window_open + duration * 0.5];
}
(0..*count)
.map(|i| {
let fraction = i as f64 / (*count as f64 - 1.0);
window_open + duration * fraction
})
.collect()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessProperties {
pub azimuth_open: f64,
pub azimuth_close: f64,
pub elevation_min: f64,
pub elevation_max: f64,
pub elevation_open: f64,
pub elevation_close: f64,
pub off_nadir_min: f64,
pub off_nadir_max: f64,
pub local_time: f64,
pub look_direction: LookDirection,
pub asc_dsc: AscDsc,
pub center_lon: f64,
pub center_lat: f64,
pub center_alt: f64,
pub center_ecef: [f64; 3],
#[serde(default)]
pub additional: HashMap<String, PropertyValue>,
}
impl AccessProperties {
#[allow(clippy::too_many_arguments)]
pub fn new(
azimuth_open: f64,
azimuth_close: f64,
elevation_min: f64,
elevation_max: f64,
elevation_open: f64,
elevation_close: f64,
off_nadir_min: f64,
off_nadir_max: f64,
local_time: f64,
look_direction: LookDirection,
asc_dsc: AscDsc,
center_lon: f64,
center_lat: f64,
center_alt: f64,
center_ecef: [f64; 3],
) -> Self {
Self {
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,
additional: HashMap::new(),
}
}
pub fn add_property(&mut self, key: String, value: PropertyValue) {
self.additional.insert(key, value);
}
pub fn get_property(&self, key: &str) -> Option<&PropertyValue> {
self.additional.get(key)
}
}
pub trait AccessPropertyComputer: Send + Sync {
fn sampling_config(&self) -> SamplingConfig;
fn compute(
&self,
window: &AccessWindow,
sample_epochs: &[f64],
sample_states_ecef: &[nalgebra::SVector<f64, 6>],
location_ecef: &nalgebra::Vector3<f64>,
location_geodetic: &nalgebra::Vector3<f64>,
) -> Result<HashMap<String, PropertyValue>, BraheError>;
fn property_names(&self) -> Vec<String>;
}
#[derive(Clone)]
pub struct DopplerComputer {
pub uplink_frequency: Option<f64>,
pub downlink_frequency: Option<f64>,
pub sampling_config: SamplingConfig,
}
impl DopplerComputer {
pub fn new(
uplink_frequency: Option<f64>,
downlink_frequency: Option<f64>,
sampling_config: SamplingConfig,
) -> Self {
Self {
uplink_frequency,
downlink_frequency,
sampling_config,
}
}
}
impl AccessPropertyComputer for DopplerComputer {
fn sampling_config(&self) -> SamplingConfig {
self.sampling_config.clone()
}
fn compute(
&self,
window: &AccessWindow,
sample_epochs: &[f64],
sample_states_ecef: &[nalgebra::SVector<f64, 6>],
location_ecef: &nalgebra::Vector3<f64>,
_location_geodetic: &nalgebra::Vector3<f64>,
) -> Result<HashMap<String, PropertyValue>, BraheError> {
let mut props = HashMap::new();
let v_los_values: Vec<f64> = sample_states_ecef
.iter()
.map(|state| {
let sat_pos = state.fixed_rows::<3>(0);
let sat_vel = state.fixed_rows::<3>(3);
let los_vec = sat_pos - location_ecef;
let los_unit = los_vec.normalize();
sat_vel.dot(&los_unit)
})
.collect();
let relative_times: Vec<f64> = sample_epochs
.iter()
.map(|&epoch| (epoch - window.window_open.mjd()) * 86400.0)
.collect();
if let Some(f_uplink) = self.uplink_frequency {
let doppler_uplink: Vec<f64> = v_los_values
.iter()
.map(|&v_los| f_uplink * v_los / (crate::constants::C_LIGHT - v_los))
.collect();
let value = if doppler_uplink.len() == 1 {
PropertyValue::Scalar(doppler_uplink[0])
} else {
PropertyValue::TimeSeries {
times: relative_times.clone(),
values: doppler_uplink,
}
};
props.insert("doppler_uplink".to_string(), value);
}
if let Some(f_downlink) = self.downlink_frequency {
let doppler_downlink: Vec<f64> = v_los_values
.iter()
.map(|&v_los| -f_downlink * v_los / crate::constants::C_LIGHT)
.collect();
let value = if doppler_downlink.len() == 1 {
PropertyValue::Scalar(doppler_downlink[0])
} else {
PropertyValue::TimeSeries {
times: relative_times,
values: doppler_downlink,
}
};
props.insert("doppler_downlink".to_string(), value);
}
Ok(props)
}
fn property_names(&self) -> Vec<String> {
let mut names = Vec::new();
if self.uplink_frequency.is_some() {
names.push("doppler_uplink".to_string());
}
if self.downlink_frequency.is_some() {
names.push("doppler_downlink".to_string());
}
names
}
}
#[derive(Clone)]
pub struct RangeComputer {
pub sampling_config: SamplingConfig,
}
impl RangeComputer {
pub fn new(sampling_config: SamplingConfig) -> Self {
Self { sampling_config }
}
}
impl AccessPropertyComputer for RangeComputer {
fn sampling_config(&self) -> SamplingConfig {
self.sampling_config.clone()
}
fn compute(
&self,
window: &AccessWindow,
sample_epochs: &[f64],
sample_states_ecef: &[nalgebra::SVector<f64, 6>],
location_ecef: &nalgebra::Vector3<f64>,
_location_geodetic: &nalgebra::Vector3<f64>,
) -> Result<HashMap<String, PropertyValue>, BraheError> {
let mut props = HashMap::new();
let range_values: Vec<f64> = sample_states_ecef
.iter()
.map(|state| {
let sat_pos = state.fixed_rows::<3>(0);
(sat_pos - location_ecef).norm()
})
.collect();
let relative_times: Vec<f64> = sample_epochs
.iter()
.map(|&epoch| (epoch - window.window_open.mjd()) * 86400.0)
.collect();
let value = if range_values.len() == 1 {
PropertyValue::Scalar(range_values[0])
} else {
PropertyValue::TimeSeries {
times: relative_times,
values: range_values,
}
};
props.insert("range".to_string(), value);
Ok(props)
}
fn property_names(&self) -> Vec<String> {
vec!["range".to_string()]
}
}
#[derive(Clone)]
pub struct RangeRateComputer {
pub sampling_config: SamplingConfig,
}
impl RangeRateComputer {
pub fn new(sampling_config: SamplingConfig) -> Self {
Self { sampling_config }
}
}
impl AccessPropertyComputer for RangeRateComputer {
fn sampling_config(&self) -> SamplingConfig {
self.sampling_config.clone()
}
fn compute(
&self,
window: &AccessWindow,
sample_epochs: &[f64],
sample_states_ecef: &[nalgebra::SVector<f64, 6>],
location_ecef: &nalgebra::Vector3<f64>,
_location_geodetic: &nalgebra::Vector3<f64>,
) -> Result<HashMap<String, PropertyValue>, BraheError> {
let mut props = HashMap::new();
let range_rate_values: Vec<f64> = sample_states_ecef
.iter()
.map(|state| {
let sat_pos = state.fixed_rows::<3>(0);
let sat_vel = state.fixed_rows::<3>(3);
let los_vec = sat_pos - location_ecef;
let los_unit = los_vec.normalize();
sat_vel.dot(&los_unit)
})
.collect();
let relative_times: Vec<f64> = sample_epochs
.iter()
.map(|&epoch| (epoch - window.window_open.mjd()) * 86400.0)
.collect();
let value = if range_rate_values.len() == 1 {
PropertyValue::Scalar(range_rate_values[0])
} else {
PropertyValue::TimeSeries {
times: relative_times,
values: range_rate_values,
}
};
props.insert("range_rate".to_string(), value);
Ok(props)
}
fn property_names(&self) -> Vec<String> {
vec!["range_rate".to_string()]
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use nalgebra::{Vector3, Vector6};
use crate::constants::AngleFormat;
use crate::coordinates::position_geodetic_to_ecef;
use crate::propagators::KeplerianPropagator;
use crate::propagators::traits::SOrbitStateProvider;
use crate::time::{Epoch, TimeSystem};
use crate::utils::state_providers::DOrbitStateProvider;
use crate::utils::testing::setup_global_test_eop;
use super::super::geometry::{
compute_asc_dsc, compute_azimuth, compute_elevation, compute_local_time,
compute_look_direction, compute_off_nadir,
};
#[test]
fn test_property_value_serialization() {
let scalar = PropertyValue::Scalar(42.0);
let json = serde_json::to_string(&scalar).unwrap();
let deserialized: PropertyValue = serde_json::from_str(&json).unwrap();
assert_eq!(scalar, deserialized);
let vector = PropertyValue::Vector(vec![1.0, 2.0, 3.0]);
let json = serde_json::to_string(&vector).unwrap();
let deserialized: PropertyValue = serde_json::from_str(&json).unwrap();
assert_eq!(vector, deserialized);
let ts = PropertyValue::TimeSeries {
times: vec![0.0, 10.0, 20.0],
values: vec![1.0, 2.0, 3.0],
};
let json = serde_json::to_string(&ts).unwrap();
let deserialized: PropertyValue = serde_json::from_str(&json).unwrap();
assert_eq!(ts, deserialized);
let boolean = PropertyValue::Boolean(true);
let json = serde_json::to_string(&boolean).unwrap();
let deserialized: PropertyValue = serde_json::from_str(&json).unwrap();
assert_eq!(boolean, deserialized);
let string = PropertyValue::String("test".to_string());
let json = serde_json::to_string(&string).unwrap();
let deserialized: PropertyValue = serde_json::from_str(&json).unwrap();
assert_eq!(string, deserialized);
}
#[test]
fn test_state_provider_propagator() {
use crate::trajectories::traits::{OrbitFrame, OrbitRepresentation};
setup_global_test_eop();
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let elements = Vector6::new(
7000e3, 0.001, 0.9, 0.0, 0.0, 0.0, );
let prop = KeplerianPropagator::new(
epoch,
elements,
OrbitFrame::ECI,
OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let state = prop.state_ecef(epoch).unwrap();
assert_eq!(state.len(), 6);
assert!(state.norm() > 0.0);
}
#[test]
fn test_state_provider_orbit_trajectory() {
use crate::trajectories::sorbit_trajectory::SOrbitTrajectory;
use crate::trajectories::traits::{OrbitFrame, OrbitRepresentation, Trajectory};
setup_global_test_eop();
let epoch1 = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let epoch2 = epoch1 + 60.0;
let state1 = Vector6::new(7000e3, 0.0, 0.0, 0.0, 7500.0, 0.0);
let state2 = Vector6::new(7000e3, 100e3, 10e3, 10.0, 7500.0, 100.0);
let mut traj = SOrbitTrajectory::new(OrbitFrame::ECI, OrbitRepresentation::Cartesian, None);
traj.add(epoch1, state1);
traj.add(epoch2, state2);
let mid_epoch = epoch1 + 30.0;
let state = traj.state_ecef(mid_epoch).unwrap();
assert_eq!(state.len(), 6);
}
#[test]
fn test_access_properties_creation() {
let props = AccessProperties::new(
45.0,
135.0,
10.0,
85.0,
12.0,
10.5,
5.0,
80.0,
43200.0,
LookDirection::Right,
AscDsc::Ascending,
0.0,
45.0,
0.0,
[4517.59e3, 4517.59e3, 0.0],
);
assert_eq!(props.azimuth_open, 45.0);
assert_eq!(props.azimuth_close, 135.0);
assert_eq!(props.elevation_min, 10.0);
assert_eq!(props.elevation_max, 85.0);
assert_eq!(props.elevation_open, 12.0);
assert_eq!(props.elevation_close, 10.5);
assert_eq!(props.off_nadir_min, 5.0);
assert_eq!(props.off_nadir_max, 80.0);
assert_eq!(props.local_time, 43200.0);
assert_eq!(props.look_direction, LookDirection::Right);
assert_eq!(props.asc_dsc, AscDsc::Ascending);
assert_eq!(props.center_lon, 0.0);
assert_eq!(props.center_lat, 45.0);
assert_eq!(props.center_alt, 0.0);
assert_eq!(props.center_ecef, [4517.59e3, 4517.59e3, 0.0]);
assert!(props.additional.is_empty());
}
#[test]
fn test_access_properties_additional() {
let mut props = AccessProperties::new(
45.0,
135.0,
10.0,
85.0,
12.0,
10.5,
5.0,
80.0,
43200.0,
LookDirection::Right,
AscDsc::Ascending,
0.0,
45.0,
0.0,
[4517.59e3, 4517.59e3, 0.0],
);
props.add_property("doppler".to_string(), PropertyValue::Scalar(2500.0));
let doppler = props.get_property("doppler").unwrap();
match doppler {
PropertyValue::Scalar(val) => assert_eq!(*val, 2500.0),
_ => panic!("Expected Scalar"),
}
assert!(props.get_property("nonexistent").is_none());
}
#[test]
fn test_compute_azimuth_elevation() {
use crate::coordinates::position_geodetic_to_ecef;
let loc_geodetic = Vector3::new(0.0, 45.0_f64.to_radians(), 0.0);
let loc_ecef = position_geodetic_to_ecef(loc_geodetic, AngleFormat::Radians).unwrap();
let sat_ecef = loc_ecef + Vector3::new(0.0, 500e3, 500e3);
let azimuth = compute_azimuth(&sat_ecef, &loc_ecef);
let elevation = compute_elevation(&sat_ecef, &loc_ecef);
assert!((0.0..=360.0).contains(&azimuth));
assert!(elevation > 0.0);
assert!(elevation < 90.0);
}
#[test]
fn test_compute_off_nadir() {
let sat_ecef = Vector3::new(7000e3, 0.0, 0.0);
let loc_geodetic = Vector3::new(0.0, 0.0, 0.0);
let loc_ecef = position_geodetic_to_ecef(loc_geodetic, AngleFormat::Radians).unwrap();
let off_nadir = compute_off_nadir(&sat_ecef, &loc_ecef);
assert!(off_nadir >= 0.0);
assert!(off_nadir <= 180.0);
}
#[test]
fn test_compute_local_time() {
setup_global_test_eop();
let loc_geodetic = Vector3::new(0.0, 0.0, 0.0);
let epoch = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let local_time = compute_local_time(&epoch, &loc_geodetic);
assert!(local_time >= 0.0);
assert!(local_time <= 86400.0);
}
#[test]
fn test_compute_asc_dsc() {
let state_ascending = Vector6::new(
7000e3, 0.0, 0.0, 0.0, 7500.0, 100.0, );
let asc_dsc = compute_asc_dsc(&state_ascending);
assert_eq!(asc_dsc, AscDsc::Ascending);
let state_descending = Vector6::new(
7000e3, 0.0, 0.0, 0.0, 7500.0, -100.0, );
let asc_dsc = compute_asc_dsc(&state_descending);
assert_eq!(asc_dsc, AscDsc::Descending);
}
#[test]
fn test_compute_look_direction() {
let sat_state = Vector6::new(
7000e3, 0.0, 0.0, 0.0, 7500.0, 0.0, );
let loc_right = Vector3::new(6000e3, 0.0, 0.0);
let look_dir = compute_look_direction(&sat_state, &loc_right);
assert!(look_dir == LookDirection::Left || look_dir == LookDirection::Right);
}
struct TestPropertyComputer;
impl AccessPropertyComputer for TestPropertyComputer {
fn sampling_config(&self) -> SamplingConfig {
SamplingConfig::Midpoint
}
fn compute(
&self,
_window: &AccessWindow,
_sample_epochs: &[f64],
sample_states_ecef: &[nalgebra::SVector<f64, 6>],
_location_ecef: &nalgebra::Vector3<f64>,
_location_geodetic: &nalgebra::Vector3<f64>,
) -> Result<HashMap<String, PropertyValue>, BraheError> {
let mut props = HashMap::new();
let state = &sample_states_ecef[0];
let altitude = state.fixed_rows::<3>(0).norm() - 6371e3;
props.insert(
"altitude_km".to_string(),
PropertyValue::Scalar(altitude / 1e3),
);
Ok(props)
}
fn property_names(&self) -> Vec<String> {
vec!["altitude_km".to_string()]
}
}
#[test]
fn test_property_computer() {
use crate::access::{AccessibleLocation, PointLocation};
use crate::trajectories::traits::{OrbitFrame, OrbitRepresentation};
setup_global_test_eop();
let epoch1 = Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let epoch2 = epoch1 + 120.0;
let window = crate::access::AccessWindow {
window_open: epoch1,
window_close: epoch2,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
45.0,
0.0,
[4517.59e3, 4517.59e3, 0.0],
),
};
let elements = Vector6::new(7000e3, 0.001, 0.9, 0.0, 0.0, 0.0);
let prop = KeplerianPropagator::new(
epoch1,
elements,
OrbitFrame::ECI,
OrbitRepresentation::Keplerian,
Some(AngleFormat::Radians),
60.0,
);
let location = PointLocation::new(0.0, 45.0, 0.0);
let location_ecef = location.center_ecef();
let location_geodetic = Vector3::new(0.0_f64.to_radians(), 45.0_f64.to_radians(), 0.0);
let computer = TestPropertyComputer;
let sampling_config = computer.sampling_config();
let sample_epochs =
sampling_config.generate_sample_epochs(window.window_open, window.window_close);
let sample_states: Vec<nalgebra::SVector<f64, 6>> = sample_epochs
.iter()
.map(|&epoch| prop.state_ecef(epoch).unwrap())
.collect();
let sample_epochs_mjd: Vec<f64> = sample_epochs.iter().map(|e| e.mjd()).collect();
let props = computer
.compute(
&window,
&sample_epochs_mjd,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(props.contains_key("altitude_km"));
assert_eq!(computer.property_names(), vec!["altitude_km"]);
}
#[test]
fn test_sampling_config_midpoint() {
let config = SamplingConfig::Midpoint;
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 1);
assert_eq!(epochs[0], window_open + 1800.0); }
#[test]
fn test_sampling_config_relative_points() {
let config = SamplingConfig::RelativePoints(vec![0.0, 0.5, 1.0]);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 3);
assert_eq!(epochs[0], window_open); assert_eq!(epochs[1], window_open + 1800.0); assert_eq!(epochs[2], window_close); }
#[test]
#[should_panic(expected = "all relative times must be in [0.0, 1.0]")]
fn test_sampling_config_relative_points_out_of_bounds_negative() {
let config = SamplingConfig::RelativePoints(vec![-0.5, 0.0, 0.5]);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
#[should_panic(expected = "all relative times must be in [0.0, 1.0]")]
fn test_sampling_config_relative_points_out_of_bounds_positive() {
let config = SamplingConfig::RelativePoints(vec![0.0, 0.5, 1.5]);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
#[should_panic(expected = "relative_times cannot be empty")]
fn test_sampling_config_relative_points_empty() {
let config = SamplingConfig::RelativePoints(vec![]);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
fn test_sampling_config_fixed_interval() {
let config = SamplingConfig::FixedInterval {
interval: 600.0, offset: 0.0,
};
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3000.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 6);
assert_eq!(epochs[0], window_open);
assert_eq!(epochs[1], window_open + 600.0);
assert_eq!(epochs[2], window_open + 1200.0);
assert_eq!(epochs[3], window_open + 1800.0);
assert_eq!(epochs[4], window_open + 2400.0);
assert_eq!(epochs[5], window_open + 3000.0);
}
#[test]
fn test_sampling_config_fixed_interval_with_offset() {
let config = SamplingConfig::FixedInterval {
interval: 1200.0, offset: 600.0, };
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3000.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 3);
assert_eq!(epochs[0], window_open + 600.0);
assert_eq!(epochs[1], window_open + 1800.0);
assert_eq!(epochs[2], window_open + 3000.0);
}
#[test]
#[should_panic(expected = "interval must be positive")]
fn test_sampling_config_fixed_interval_zero() {
let config = SamplingConfig::FixedInterval {
interval: 0.0,
offset: 0.0,
};
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
#[should_panic(expected = "interval must be positive")]
fn test_sampling_config_fixed_interval_negative() {
let config = SamplingConfig::FixedInterval {
interval: -0.1,
offset: 0.0,
};
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
#[should_panic(expected = "offset must be non-negative")]
fn test_sampling_config_fixed_interval_negative_offset() {
let config = SamplingConfig::FixedInterval {
interval: 600.0,
offset: -100.0,
};
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
#[should_panic(expected = "offset")]
fn test_sampling_config_fixed_interval_offset_beyond_window() {
let config = SamplingConfig::FixedInterval {
interval: 600.0,
offset: 4000.0, };
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
fn test_sampling_config_fixed_count() {
let config = SamplingConfig::FixedCount(5);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 2400.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 5);
assert_eq!(epochs[0], window_open);
assert_eq!(epochs[1], window_open + 600.0);
assert_eq!(epochs[2], window_open + 1200.0);
assert_eq!(epochs[3], window_open + 1800.0);
assert_eq!(epochs[4], window_open + 2400.0);
}
#[test]
fn test_sampling_config_fixed_count_single() {
let config = SamplingConfig::FixedCount(1);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 1);
assert_eq!(epochs[0], window_open + 1800.0);
}
#[test]
fn test_sampling_config_fixed_count_two() {
let config = SamplingConfig::FixedCount(2);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
let epochs = config.generate_sample_epochs(window_open, window_close);
assert_eq!(epochs.len(), 2);
assert_eq!(epochs[0], window_open);
assert_eq!(epochs[1], window_close);
}
#[test]
#[should_panic(expected = "count must be positive")]
fn test_sampling_config_fixed_count_zero() {
let config = SamplingConfig::FixedCount(0);
let window_open =
crate::time::Epoch::from_datetime(2024, 1, 1, 12, 0, 0.0, 0.0, TimeSystem::UTC);
let window_close = window_open + 3600.0;
config.generate_sample_epochs(window_open, window_close);
}
#[test]
fn test_sampling_config_serialization() {
let config = SamplingConfig::Midpoint;
let json = serde_json::to_string(&config).unwrap();
let deserialized: SamplingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
let config = SamplingConfig::RelativePoints(vec![0.0, 0.5, 1.0]);
let json = serde_json::to_string(&config).unwrap();
let deserialized: SamplingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
let config = SamplingConfig::FixedInterval {
interval: 0.1,
offset: 0.05,
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: SamplingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
let config = SamplingConfig::FixedCount(10);
let json = serde_json::to_string(&config).unwrap();
let deserialized: SamplingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_doppler_computer_downlink() {
setup_global_test_eop();
let computer = DopplerComputer::new(
None,
Some(2.2e9), SamplingConfig::Midpoint,
);
let window_open = crate::time::Epoch::from_datetime(
2024,
1,
1,
0,
0,
0.0,
0.0,
crate::time::TimeSystem::UTC,
);
let window_close = window_open + 60.0;
let sample_epochs = vec![window_open.mjd() + 30.0 / 86400.0];
let location_ecef = nalgebra::Vector3::new(4000000.0, 1000000.0, 4500000.0);
let sat_pos = nalgebra::Vector3::new(1000000.0, 2000000.0, 3000000.0);
let to_station = (location_ecef - sat_pos).normalize();
let approaching_velocity = to_station * 1000.0;
let sample_states = vec![nalgebra::SVector::<f64, 6>::new(
sat_pos[0],
sat_pos[1],
sat_pos[2],
approaching_velocity[0],
approaching_velocity[1],
approaching_velocity[2],
)];
let location_geodetic = nalgebra::Vector3::new(0.0, 0.0, 0.0);
let temp_window = crate::access::AccessWindow {
window_open,
window_close,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
0.0,
0.0,
[0.0, 0.0, 0.0],
),
};
let result = computer
.compute(
&temp_window,
&sample_epochs,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(result.contains_key("doppler_downlink"));
if let PropertyValue::Scalar(doppler) = result.get("doppler_downlink").unwrap() {
assert!(
*doppler > 0.0,
"Doppler should be positive for approaching satellite"
);
assert!(
*doppler < 100000.0,
"Doppler should be reasonable (<100 kHz)"
);
} else {
panic!("Expected scalar value");
}
}
#[test]
fn test_doppler_computer_uplink() {
setup_global_test_eop();
let computer = DopplerComputer::new(
Some(2.0e9), None,
SamplingConfig::Midpoint,
);
let window_open = crate::time::Epoch::from_datetime(
2024,
1,
1,
0,
0,
0.0,
0.0,
crate::time::TimeSystem::UTC,
);
let window_close = window_open + 60.0;
let sample_epochs = vec![window_open.mjd() + 30.0 / 86400.0];
let location_ecef = nalgebra::Vector3::new(4000000.0, 1000000.0, 4500000.0);
let sat_pos = nalgebra::Vector3::new(1000000.0, 2000000.0, 3000000.0);
let from_station = (sat_pos - location_ecef).normalize();
let receding_velocity = from_station * 1000.0;
let sample_states = vec![nalgebra::SVector::<f64, 6>::new(
sat_pos[0],
sat_pos[1],
sat_pos[2],
receding_velocity[0],
receding_velocity[1],
receding_velocity[2],
)];
let location_geodetic = nalgebra::Vector3::new(0.0, 0.0, 0.0);
let temp_window = crate::access::AccessWindow {
window_open,
window_close,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
0.0,
0.0,
[0.0, 0.0, 0.0],
),
};
let result = computer
.compute(
&temp_window,
&sample_epochs,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(result.contains_key("doppler_uplink"));
if let PropertyValue::Scalar(doppler) = result.get("doppler_uplink").unwrap() {
assert!(
*doppler > 0.0,
"Uplink doppler should be positive for receding satellite"
);
assert!(*doppler < 100000.0, "Doppler should be reasonable");
} else {
panic!("Expected scalar value");
}
}
#[test]
fn test_doppler_computer_both_frequencies() {
setup_global_test_eop();
let computer = DopplerComputer::new(
Some(2.0e9), Some(2.2e9), SamplingConfig::FixedCount(3),
);
let window_open = crate::time::Epoch::from_datetime(
2024,
1,
1,
0,
0,
0.0,
0.0,
crate::time::TimeSystem::UTC,
);
let window_close: crate::time::Epoch = window_open + 120.0;
let config = SamplingConfig::FixedCount(3);
let sample_epochs = config.generate_sample_epochs(window_open, window_close);
let sample_states = vec![
nalgebra::SVector::<f64, 6>::new(
1000000.0, 2000000.0, 3000000.0, -1000.0, -500.0, -200.0,
),
nalgebra::SVector::<f64, 6>::new(
1010000.0, 2005000.0, 3002000.0, -800.0, -400.0, -150.0,
),
nalgebra::SVector::<f64, 6>::new(
1020000.0, 2010000.0, 3004000.0, -600.0, -300.0, -100.0,
),
];
let location_ecef = nalgebra::Vector3::new(4000000.0, 1000000.0, 4500000.0);
let location_geodetic = nalgebra::Vector3::new(0.0, 0.0, 0.0);
let sample_epochs_mjd: Vec<f64> = sample_epochs.iter().map(|e| e.mjd()).collect();
let temp_window = crate::access::AccessWindow {
window_open,
window_close,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
0.0,
0.0,
[0.0, 0.0, 0.0],
),
};
let result = computer
.compute(
&temp_window,
&sample_epochs_mjd,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(result.contains_key("doppler_uplink"));
assert!(result.contains_key("doppler_downlink"));
match result.get("doppler_uplink").unwrap() {
PropertyValue::TimeSeries { times, values } => {
assert_eq!(times.len(), 3);
assert_eq!(values.len(), 3);
}
_ => panic!("Expected time series value"),
}
match result.get("doppler_downlink").unwrap() {
PropertyValue::TimeSeries { times, values } => {
assert_eq!(times.len(), 3);
assert_eq!(values.len(), 3);
}
_ => panic!("Expected time series value"),
}
}
#[test]
fn test_range_computer() {
setup_global_test_eop();
let computer = RangeComputer::new(SamplingConfig::FixedCount(2));
let window_open = crate::time::Epoch::from_datetime(
2024,
1,
1,
0,
0,
0.0,
0.0,
crate::time::TimeSystem::UTC,
);
let window_close: crate::time::Epoch = window_open + 60.0;
let config = SamplingConfig::FixedCount(2);
let sample_epochs = config.generate_sample_epochs(window_open, window_close);
let location_ecef = nalgebra::Vector3::new(4000000.0, 1000000.0, 4500000.0);
let sample_states = vec![
nalgebra::SVector::<f64, 6>::new(
location_ecef[0] + 1000000.0,
location_ecef[1],
location_ecef[2],
0.0,
0.0,
0.0,
),
nalgebra::SVector::<f64, 6>::new(
location_ecef[0] + 2000000.0,
location_ecef[1],
location_ecef[2],
0.0,
0.0,
0.0,
),
];
let location_geodetic = nalgebra::Vector3::new(0.0, 0.0, 0.0);
let sample_epochs_mjd: Vec<f64> = sample_epochs.iter().map(|e| e.mjd()).collect();
let temp_window = crate::access::AccessWindow {
window_open,
window_close,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
0.0,
0.0,
[0.0, 0.0, 0.0],
),
};
let result = computer
.compute(
&temp_window,
&sample_epochs_mjd,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(result.contains_key("range"));
match result.get("range").unwrap() {
PropertyValue::TimeSeries { times, values } => {
assert_eq!(times.len(), 2);
assert_eq!(values.len(), 2);
assert!(
(values[0] - 1000000.0).abs() < 1.0,
"First range should be ~1000 km"
);
assert!(
(values[1] - 2000000.0).abs() < 1.0,
"Second range should be ~2000 km"
);
}
_ => panic!("Expected time series value"),
}
}
#[test]
fn test_range_rate_computer() {
setup_global_test_eop();
let computer = RangeRateComputer::new(SamplingConfig::Midpoint);
let window_open = crate::time::Epoch::from_datetime(
2024,
1,
1,
0,
0,
0.0,
0.0,
crate::time::TimeSystem::UTC,
);
let window_close = window_open + 60.0;
let sample_epochs = vec![window_open.mjd() + 30.0 / 86400.0];
let location_ecef = nalgebra::Vector3::new(4000000.0, 1000000.0, 4500000.0);
let sat_to_station =
location_ecef - nalgebra::Vector3::new(1000000.0, 2000000.0, 3000000.0);
let los_direction = sat_to_station.normalize();
let velocity = -los_direction * 1000.0;
let sample_states = vec![nalgebra::SVector::<f64, 6>::new(
1000000.0,
2000000.0,
3000000.0,
velocity[0],
velocity[1],
velocity[2],
)];
let location_geodetic = nalgebra::Vector3::new(0.0, 0.0, 0.0);
let temp_window = crate::access::AccessWindow {
window_open,
window_close,
location_name: None,
location_id: None,
location_uuid: None,
satellite_name: None,
satellite_id: None,
satellite_uuid: None,
name: None,
id: None,
uuid: None,
properties: crate::access::AccessProperties::new(
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
crate::access::LookDirection::Either,
crate::access::AscDsc::Either,
0.0,
0.0,
0.0,
[0.0, 0.0, 0.0],
),
};
let result = computer
.compute(
&temp_window,
&sample_epochs,
&sample_states,
&location_ecef,
&location_geodetic,
)
.unwrap();
assert!(result.contains_key("range_rate"));
if let PropertyValue::Scalar(range_rate) = result.get("range_rate").unwrap() {
assert!(
(*range_rate - 1000.0).abs() < 1.0,
"Range rate should be ~1000 m/s, got {}",
range_rate
);
} else {
panic!("Expected scalar value");
}
}
}