use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use space_dust::observations::Observations;
use space_dust::state::GeodeticState;
use space_dust::tle::Tle;
use crate::coordinates::{EquatorialPosition, HorizontalPosition, TrackingRates};
use crate::error::{MountError, MountResult};
use crate::mount::{Mount, SiteLocation};
pub const DEFAULT_MIN_ELEVATION: f64 = 10.0;
pub const DEFAULT_UPDATE_INTERVAL_MS: u64 = 100;
pub const DEFAULT_PRE_SLEW_SEC: f64 = 30.0;
pub const DEFAULT_AZ_MIN_LIMIT: f64 = -180.0;
pub const DEFAULT_AZ_MAX_LIMIT: f64 = 540.0;
pub const DEFAULT_ALT_MIN_LIMIT: f64 = 0.0;
pub const DEFAULT_ALT_MAX_LIMIT: f64 = 90.0;
#[derive(Debug, Clone)]
pub struct PidController {
pub kp: f64,
pub ki: f64,
pub kd: f64,
integral: f64,
prev_error: f64,
pub integral_limit: f64,
pub output_limit: f64,
}
impl PidController {
pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
Self {
kp,
ki,
kd,
integral: 0.0,
prev_error: 0.0,
integral_limit: 10.0,
output_limit: 5.0,
}
}
pub fn reset(&mut self) {
self.integral = 0.0;
self.prev_error = 0.0;
}
pub fn update(&mut self, error: f64, dt: f64) -> f64 {
if dt <= 0.0 {
return 0.0;
}
let p_term = self.kp * error;
self.integral += error * dt;
self.integral = self
.integral
.clamp(-self.integral_limit, self.integral_limit);
let i_term = self.ki * self.integral;
let derivative = (error - self.prev_error) / dt;
let d_term = self.kd * derivative;
self.prev_error = error;
let output = p_term + i_term + d_term;
output.clamp(-self.output_limit, self.output_limit)
}
}
impl Default for PidController {
fn default() -> Self {
Self::new(2.0, 0.1, 0.5)
}
}
#[derive(Debug, Clone)]
pub struct SatellitePass {
pub name: String,
pub norad_id: i32,
pub aos_time: DateTime<Utc>,
pub tca_time: DateTime<Utc>,
pub los_time: DateTime<Utc>,
pub max_elevation: f64,
pub aos_azimuth: f64,
pub los_azimuth: f64,
}
impl SatellitePass {
pub fn duration(&self) -> Duration {
let duration_secs = (self.los_time - self.aos_time).num_seconds();
Duration::from_secs(duration_secs.max(0) as u64)
}
pub fn is_active(&self, now: DateTime<Utc>) -> bool {
now >= self.aos_time && now <= self.los_time
}
pub fn is_good_pass(&self, min_max_elevation: f64) -> bool {
self.max_elevation >= min_max_elevation
}
}
impl std::fmt::Display for SatellitePass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({}): AOS {} @ {:.1}° Az, Max El {:.1}°, LOS {} @ {:.1}° Az, Duration {:?}",
self.name,
self.norad_id,
self.aos_time.format("%H:%M:%S"),
self.aos_azimuth,
self.max_elevation,
self.los_time.format("%H:%M:%S"),
self.los_azimuth,
self.duration()
)
}
}
#[derive(Debug, Clone)]
pub struct TrackingState {
pub equatorial: EquatorialPosition,
pub horizontal: HorizontalPosition,
pub ra_rate: f64,
pub dec_rate: f64,
pub range_km: f64,
pub range_rate_km_s: f64,
pub timestamp: DateTime<Utc>,
pub is_visible: bool,
}
impl std::fmt::Display for TrackingState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} | {} | Range: {:.1} km | Rates: RA {:.2}\"/s, Dec {:.2}\"/s | {}",
self.equatorial,
self.horizontal,
self.range_km,
self.ra_rate,
self.dec_rate,
if self.is_visible {
"VISIBLE"
} else {
"BELOW HORIZON"
}
)
}
}
#[derive(Debug, Clone)]
pub struct TrackingConfig {
pub min_elevation: f64,
pub update_interval_ms: u64,
pub lead_time_sec: f64,
pub use_rate_tracking: bool,
pub max_slew_rate: f64,
pub pre_slew_sec: f64,
pub pid_kp_az: f64,
pub pid_ki_az: f64,
pub pid_kd_az: f64,
pub pid_kp_alt: f64,
pub pid_ki_alt: f64,
pub pid_kd_alt: f64,
pub az_min_limit: f64,
pub az_max_limit: f64,
pub alt_min_limit: f64,
pub alt_max_limit: f64,
pub enable_cable_wrap_prevention: bool,
}
impl Default for TrackingConfig {
fn default() -> Self {
Self {
min_elevation: DEFAULT_MIN_ELEVATION,
update_interval_ms: DEFAULT_UPDATE_INTERVAL_MS,
lead_time_sec: 0.5,
use_rate_tracking: true,
max_slew_rate: 5.0,
pre_slew_sec: DEFAULT_PRE_SLEW_SEC,
pid_kp_az: 2.0,
pid_ki_az: 0.1,
pid_kd_az: 0.5,
pid_kp_alt: 2.0,
pid_ki_alt: 0.1,
pid_kd_alt: 0.5,
az_min_limit: DEFAULT_AZ_MIN_LIMIT,
az_max_limit: DEFAULT_AZ_MAX_LIMIT,
alt_min_limit: DEFAULT_ALT_MIN_LIMIT,
alt_max_limit: DEFAULT_ALT_MAX_LIMIT,
enable_cable_wrap_prevention: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CableWrapState {
Safe,
Warning,
AtLimit,
}
#[derive(Debug, Clone)]
pub struct CableWrapCheck {
pub state: CableWrapState,
pub unwrapped_az: f64,
pub distance_to_min: f64,
pub distance_to_max: f64,
pub safe_azimuth: Option<f64>,
}
pub struct SatelliteTracker {
tle: Tle,
name: Option<String>,
observer: GeodeticState,
site_location: SiteLocation,
config: TrackingConfig,
last_state: Option<TrackingState>,
unwrapped_azimuth: f64,
azimuth_initialized: bool,
last_update: Option<Instant>,
}
impl SatelliteTracker {
pub fn from_tle(line1: &str, line2: &str) -> MountResult<Self> {
let tle =
Tle::parse(line1, line2).map_err(|e| MountError::satellite_error(e.to_string()))?;
Ok(Self {
tle,
name: None,
observer: GeodeticState::new(0.0, 0.0, 0.0),
site_location: SiteLocation::default(),
config: TrackingConfig::default(),
last_state: None,
unwrapped_azimuth: 0.0,
azimuth_initialized: false,
last_update: None,
})
}
pub fn from_tle_with_name(name: &str, line1: &str, line2: &str) -> MountResult<Self> {
let tle = Tle::parse_3le(name, line1, line2)
.map_err(|e| MountError::satellite_error(e.to_string()))?;
Ok(Self {
tle,
name: Some(name.trim().to_string()),
observer: GeodeticState::new(0.0, 0.0, 0.0),
site_location: SiteLocation::default(),
config: TrackingConfig::default(),
last_state: None,
unwrapped_azimuth: 0.0,
azimuth_initialized: false,
last_update: None,
})
}
pub fn initialize_cable_wrap(&mut self, current_azimuth: f64) {
self.unwrapped_azimuth = current_azimuth;
self.azimuth_initialized = true;
log::info!(
"Cable wrap tracking initialized at Az {:.1}°",
current_azimuth
);
}
fn update_unwrapped_azimuth(&mut self, new_azimuth: f64) {
if !self.azimuth_initialized {
self.initialize_cable_wrap(new_azimuth);
return;
}
let mut delta = new_azimuth - (self.unwrapped_azimuth.rem_euclid(360.0));
if delta > 180.0 {
delta -= 360.0;
} else if delta < -180.0 {
delta += 360.0;
}
self.unwrapped_azimuth += delta;
}
pub fn check_cable_wrap(&self, target_azimuth: f64) -> CableWrapCheck {
if !self.config.enable_cable_wrap_prevention {
return CableWrapCheck {
state: CableWrapState::Safe,
unwrapped_az: self.unwrapped_azimuth,
distance_to_min: f64::MAX,
distance_to_max: f64::MAX,
safe_azimuth: None,
};
}
let mut delta = target_azimuth - (self.unwrapped_azimuth.rem_euclid(360.0));
if delta > 180.0 {
delta -= 360.0;
} else if delta < -180.0 {
delta += 360.0;
}
let projected_unwrapped = self.unwrapped_azimuth + delta;
let distance_to_min = projected_unwrapped - self.config.az_min_limit;
let distance_to_max = self.config.az_max_limit - projected_unwrapped;
const WARNING_THRESHOLD: f64 = 30.0;
let state = if distance_to_min < 0.0 || distance_to_max < 0.0 {
CableWrapState::AtLimit
} else if distance_to_min < WARNING_THRESHOLD || distance_to_max < WARNING_THRESHOLD {
CableWrapState::Warning
} else {
CableWrapState::Safe
};
let safe_azimuth = if state == CableWrapState::AtLimit {
if distance_to_min < 0.0 {
Some((self.config.az_min_limit + 10.0).rem_euclid(360.0))
} else {
Some((self.config.az_max_limit - 10.0).rem_euclid(360.0))
}
} else {
None
};
CableWrapCheck {
state,
unwrapped_az: projected_unwrapped,
distance_to_min,
distance_to_max,
safe_azimuth,
}
}
pub fn get_unwrapped_azimuth(&self) -> f64 {
self.unwrapped_azimuth
}
pub fn is_position_safe(&self, target_azimuth: f64) -> bool {
let check = self.check_cable_wrap(target_azimuth);
check.state != CableWrapState::AtLimit
}
pub fn safe_horizontal_position(&self, azimuth: f64, altitude: f64) -> HorizontalPosition {
let safe_alt = altitude.clamp(self.config.alt_min_limit, self.config.alt_max_limit);
let safe_az = azimuth.rem_euclid(360.0);
if (safe_alt - altitude).abs() > 0.01 {
log::debug!(
"Altitude clamped from {:.2}° to {:.2}° (limits: {:.1}° to {:.1}°)",
altitude,
safe_alt,
self.config.alt_min_limit,
self.config.alt_max_limit
);
}
HorizontalPosition::new(safe_az, safe_alt)
}
pub fn is_altitude_safe(&self, altitude: f64) -> bool {
altitude >= self.config.alt_min_limit && altitude <= self.config.alt_max_limit
}
pub fn set_observer_location(&mut self, latitude: f64, longitude: f64, altitude: f64) {
self.observer = GeodeticState::new(latitude, longitude, altitude / 1000.0);
self.site_location = SiteLocation::new(latitude, longitude, altitude);
log::info!(
"Observer location set to lat={:.4}°, lon={:.4}°, alt={:.1}m",
latitude,
longitude,
altitude
);
}
pub fn set_site_location(&mut self, location: SiteLocation) {
self.set_observer_location(location.latitude, location.longitude, location.altitude);
}
pub fn set_config(&mut self, config: TrackingConfig) {
self.config = config;
}
pub fn get_name(&self) -> String {
self.name
.clone()
.unwrap_or_else(|| format!("NORAD {}", self.tle.catalog_number()))
}
pub fn get_norad_id(&self) -> i32 {
self.tle.catalog_number().parse().unwrap_or(0)
}
pub fn get_tle_epoch(&self) -> DateTime<Utc> {
self.tle.epoch()
}
pub fn get_current_state(&mut self) -> MountResult<TrackingState> {
let now = Utc::now();
self.get_state_at_time(now)
}
pub fn get_state_at_time(&mut self, time: DateTime<Utc>) -> MountResult<TrackingState> {
let teme_state = self
.tle
.propagate(&time)
.map_err(|e| MountError::satellite_error(e.to_string()))?;
let eci_state = teme_state.to_eci();
let ra_dec = Observations::compute_ra_dec_with_rates(&self.observer, &eci_state);
let ra_deg = ra_dec.ra_deg();
let dec_deg = ra_dec.dec_deg();
let range = ra_dec.range.unwrap_or(0.0);
let range_rate = ra_dec.range_rate.unwrap_or(0.0);
let ra_rate_arcsec = ra_dec.right_ascension_rate.unwrap_or(0.0) * 206264.806;
let dec_rate_arcsec = ra_dec.declination_rate.unwrap_or(0.0) * 206264.806;
let ra_hours = ra_deg / 15.0;
let equatorial = EquatorialPosition::new(ra_hours, dec_deg);
let az_el = Observations::ra_dec_to_az_el(&ra_dec, &self.observer);
let horizontal = HorizontalPosition::new(az_el.azimuth_deg(), az_el.elevation_deg());
let state = TrackingState {
equatorial,
horizontal,
ra_rate: ra_rate_arcsec,
dec_rate: dec_rate_arcsec,
range_km: range,
range_rate_km_s: range_rate,
timestamp: time,
is_visible: horizontal.altitude > self.config.min_elevation,
};
self.last_state = Some(state.clone());
self.last_update = Some(Instant::now());
Ok(state)
}
pub fn get_current_position(&mut self) -> MountResult<EquatorialPosition> {
let state = self.get_current_state()?;
Ok(state.equatorial)
}
pub fn get_current_altaz(&mut self) -> MountResult<HorizontalPosition> {
let state = self.get_current_state()?;
Ok(state.horizontal)
}
pub fn get_current_rates(&mut self) -> MountResult<TrackingRates> {
let state = self.get_current_state()?;
Ok(TrackingRates::new(state.ra_rate, state.dec_rate))
}
pub fn is_visible(&mut self) -> MountResult<bool> {
let state = self.get_current_state()?;
Ok(state.is_visible)
}
pub fn get_lead_position(&mut self) -> MountResult<(EquatorialPosition, TrackingRates)> {
let future_time = Utc::now()
+ chrono::Duration::milliseconds((self.config.lead_time_sec * 1000.0) as i64);
let state = self.get_state_at_time(future_time)?;
Ok((
state.equatorial,
TrackingRates::new(state.ra_rate, state.dec_rate),
))
}
pub fn find_next_pass(
&mut self,
start_time: DateTime<Utc>,
search_hours: f64,
) -> MountResult<Option<SatellitePass>> {
let step_seconds = 60.0; let _fine_step_seconds = 1.0;
let mut current_time = start_time;
let end_time = start_time + chrono::Duration::seconds((search_hours * 3600.0) as i64);
let mut pass_start: Option<DateTime<Utc>> = None;
let mut max_elevation = 0.0f64;
let mut tca_time = start_time;
let mut aos_azimuth = 0.0;
while current_time < end_time {
let state = self.get_state_at_time(current_time)?;
if state.is_visible {
if pass_start.is_none() {
let refined_aos = self.refine_crossing_time(
current_time - chrono::Duration::seconds(step_seconds as i64),
current_time,
true,
)?;
pass_start = Some(refined_aos);
let aos_state = self.get_state_at_time(refined_aos)?;
aos_azimuth = aos_state.horizontal.azimuth;
}
if state.horizontal.altitude > max_elevation {
max_elevation = state.horizontal.altitude;
tca_time = current_time;
}
} else if pass_start.is_some() {
let refined_los = self.refine_crossing_time(
current_time - chrono::Duration::seconds(step_seconds as i64),
current_time,
false,
)?;
let los_state = self.get_state_at_time(refined_los)?;
return Ok(Some(SatellitePass {
name: self.get_name(),
norad_id: self.get_norad_id(),
aos_time: pass_start.unwrap(),
tca_time,
los_time: refined_los,
max_elevation,
aos_azimuth,
los_azimuth: los_state.horizontal.azimuth,
}));
}
current_time = current_time + chrono::Duration::seconds(step_seconds as i64);
}
if let Some(aos) = pass_start {
let final_state = self.get_state_at_time(end_time)?;
return Ok(Some(SatellitePass {
name: self.get_name(),
norad_id: self.get_norad_id(),
aos_time: aos,
tca_time,
los_time: end_time,
max_elevation,
aos_azimuth,
los_azimuth: final_state.horizontal.azimuth,
}));
}
Ok(None)
}
pub fn find_passes(
&mut self,
start_time: DateTime<Utc>,
search_hours: f64,
min_max_elevation: f64,
) -> MountResult<Vec<SatellitePass>> {
let mut passes = Vec::new();
let mut current_search_time = start_time;
let end_time = start_time + chrono::Duration::seconds((search_hours * 3600.0) as i64);
while current_search_time < end_time {
let remaining_hours = (end_time - current_search_time).num_seconds() as f64 / 3600.0;
if let Some(pass) = self.find_next_pass(current_search_time, remaining_hours)? {
current_search_time = pass.los_time + chrono::Duration::seconds(60);
if pass.max_elevation >= min_max_elevation {
passes.push(pass);
}
} else {
break;
}
}
Ok(passes)
}
fn refine_crossing_time(
&mut self,
before: DateTime<Utc>,
after: DateTime<Utc>,
rising: bool,
) -> MountResult<DateTime<Utc>> {
let mut low = before;
let mut high = after;
for _ in 0..20 {
let mid =
low + chrono::Duration::milliseconds(((high - low).num_milliseconds() / 2) as i64);
let state = self.get_state_at_time(mid)?;
let is_visible = state.horizontal.altitude > self.config.min_elevation;
if rising {
if is_visible {
high = mid;
} else {
low = mid;
}
} else {
if is_visible {
low = mid;
} else {
high = mid;
}
}
if (high - low).num_milliseconds() < 100 {
break;
}
}
Ok(if rising { high } else { low })
}
pub fn track_pass(&mut self, mount: &mut dyn Mount) -> MountResult<Duration> {
let start_time = Instant::now();
if mount.is_parked()? {
mount.unpark()?;
}
mount.set_altaz_mode()?;
let mut state = self.get_current_state()?;
if !state.is_visible {
log::info!(
"Satellite {} is below horizon (El: {:.1}°), searching for next pass...",
self.get_name(),
state.horizontal.altitude
);
let next_pass = self.find_next_pass(Utc::now(), 24.0)?;
match next_pass {
Some(pass) => {
log::info!(
"Next pass: AOS at {} UTC, Max El: {:.1}°",
pass.aos_time.format("%H:%M:%S"),
pass.max_elevation
);
let pre_slew_time = pass.aos_time
- chrono::Duration::milliseconds(
(self.config.pre_slew_sec * 1000.0) as i64,
);
loop {
let now = Utc::now();
if now >= pre_slew_time {
break;
}
let wait_secs = (pre_slew_time - now).num_seconds();
log::info!(
"Waiting for pre-slew time... {} seconds remaining",
wait_secs
);
let sleep_ms = std::cmp::min(wait_secs as u64 * 1000, 5000);
std::thread::sleep(Duration::from_millis(sleep_ms));
}
let aos_state = self.get_state_at_time(pass.aos_time)?;
let safe_alt = aos_state
.horizontal
.altitude
.clamp(self.config.alt_min_limit, self.config.alt_max_limit);
let safe_pos = HorizontalPosition::new(aos_state.horizontal.azimuth, safe_alt);
log::info!(
"Pre-slewing to AOS position: Az {:.1}°, Alt {:.1}°",
safe_pos.azimuth,
safe_pos.altitude
);
mount.goto_altaz(safe_pos)?;
while mount.is_slewing()? {
std::thread::sleep(Duration::from_millis(100));
}
log::info!("Pre-slew complete, waiting for AOS...");
loop {
let now = Utc::now();
if now >= pass.aos_time {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
state = self.get_current_state()?;
}
None => {
log::warn!("No passes found in the next 24 hours");
return Err(MountError::BelowHorizon(state.horizontal.altitude));
}
}
} else {
let safe_alt = state
.horizontal
.altitude
.clamp(self.config.alt_min_limit, self.config.alt_max_limit);
let safe_pos = HorizontalPosition::new(state.horizontal.azimuth, safe_alt);
log::info!(
"Satellite {} is visible, slewing to current position: Az {:.1}°, Alt {:.1}°",
self.get_name(),
safe_pos.azimuth,
safe_pos.altitude
);
mount.goto_altaz(safe_pos)?;
while mount.is_slewing()? {
std::thread::sleep(Duration::from_millis(100));
}
}
let initial_mount_pos = mount.get_altaz()?;
self.initialize_cable_wrap(initial_mount_pos.azimuth);
log::info!(
"Starting PID tracking of {} at Az {:.2}°, Alt {:.2}°",
self.get_name(),
state.horizontal.azimuth,
state.horizontal.altitude
);
let mut pid_az = PidController::new(
self.config.pid_kp_az,
self.config.pid_ki_az,
self.config.pid_kd_az,
);
let mut pid_alt = PidController::new(
self.config.pid_kp_alt,
self.config.pid_ki_alt,
self.config.pid_kd_alt,
);
let update_interval = Duration::from_millis(self.config.update_interval_ms);
let mut last_update = Instant::now();
loop {
let loop_start = Instant::now();
let dt = last_update.elapsed().as_secs_f64();
last_update = loop_start;
let target_state = self.get_state_at_time(
Utc::now()
+ chrono::Duration::milliseconds((self.config.lead_time_sec * 1000.0) as i64),
)?;
if target_state.horizontal.altitude < self.config.alt_min_limit {
log::info!(
"Satellite {} below minimum elevation (El: {:.1}° < {:.1}°), stopping tracking",
self.get_name(),
target_state.horizontal.altitude,
self.config.alt_min_limit
);
break;
}
let current_pos = mount.get_altaz()?;
let mut az_error = target_state.horizontal.azimuth - current_pos.azimuth;
if az_error > 180.0 {
az_error -= 360.0;
} else if az_error < -180.0 {
az_error += 360.0;
}
let alt_error = target_state.horizontal.altitude - current_pos.altitude;
let az_correction = pid_az.update(az_error, dt);
let alt_correction = pid_alt.update(alt_error, dt);
let corrected_az = current_pos.azimuth
+ az_correction * (self.config.update_interval_ms as f64 / 1000.0);
let corrected_alt = current_pos.altitude
+ alt_correction * (self.config.update_interval_ms as f64 / 1000.0);
self.update_unwrapped_azimuth(current_pos.azimuth);
let cable_check = self.check_cable_wrap(corrected_az);
let final_az = match cable_check.state {
CableWrapState::AtLimit => {
log::warn!(
"Cable wrap limit reached! Unwrapped Az: {:.1}°, Limits: [{:.1}°, {:.1}°]",
cable_check.unwrapped_az,
self.config.az_min_limit,
self.config.az_max_limit
);
log::warn!("Stopping tracking to prevent cable damage.");
break;
}
CableWrapState::Warning => {
log::warn!(
"Cable wrap WARNING: Approaching limit. Unwrapped Az: {:.1}°, Distance to limits: min={:.1}°, max={:.1}°",
cable_check.unwrapped_az,
cable_check.distance_to_min,
cable_check.distance_to_max
);
corrected_az.rem_euclid(360.0)
}
CableWrapState::Safe => corrected_az.rem_euclid(360.0),
};
let final_alt =
corrected_alt.clamp(self.config.alt_min_limit, self.config.alt_max_limit);
if final_alt <= 0.0 {
log::warn!(
"Altitude would go to {:.1}° (clamped from {:.1}°) - stopping to prevent mount flip",
final_alt,
corrected_alt
);
break;
}
log::debug!(
"Track: Target Az={:.2}° Alt={:.2}° | Current Az={:.2}° Alt={:.2}° | Error Az={:.3}° Alt={:.3}° | Unwrapped Az={:.1}°",
target_state.horizontal.azimuth,
target_state.horizontal.altitude,
current_pos.azimuth,
current_pos.altitude,
az_error,
alt_error,
self.unwrapped_azimuth
);
let corrected_pos = HorizontalPosition::new(final_az, final_alt);
mount.goto_altaz(corrected_pos)?;
let elapsed = loop_start.elapsed();
if elapsed < update_interval {
std::thread::sleep(update_interval - elapsed);
}
}
mount.tracking_off()?;
let tracking_duration = start_time.elapsed();
log::info!(
"Tracking complete. Duration: {:.1}s, Final unwrapped Az: {:.1}°",
tracking_duration.as_secs_f64(),
self.unwrapped_azimuth
);
Ok(tracking_duration)
}
pub fn update_tracking(&mut self, mount: &mut dyn Mount) -> MountResult<TrackingState> {
let (position, rates) = self.get_lead_position()?;
let state = self.get_current_state()?;
if !state.is_visible {
return Err(MountError::BelowHorizon(state.horizontal.altitude));
}
if self.config.use_rate_tracking && rates.is_valid_for_satellite() {
mount.set_custom_tracking_rates(rates)?;
} else {
mount.goto_equatorial(position)?;
}
Ok(state)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ISS_LINE1: &str = "1 25544U 98067A 26014.62805721 .00006818 00000+0 13044-3 0 9991";
const ISS_LINE2: &str = "2 25544 51.6333 339.6562 0007763 17.9854 342.1408 15.49289811547943";
#[test]
fn test_create_tracker() {
let result = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2);
assert!(result.is_ok());
let tracker = result.unwrap();
assert_eq!(tracker.get_norad_id(), 25544);
}
#[test]
fn test_create_tracker_with_name() {
let result = SatelliteTracker::from_tle_with_name("ISS (ZARYA)", ISS_LINE1, ISS_LINE2);
assert!(result.is_ok());
let tracker = result.unwrap();
assert_eq!(tracker.get_name(), "ISS (ZARYA)");
}
#[test]
fn test_set_observer_location() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
tracker.set_observer_location(34.0522, -118.2437, 71.0);
assert!((tracker.site_location.latitude - 34.0522).abs() < 0.0001);
assert!((tracker.site_location.longitude - (-118.2437)).abs() < 0.0001);
}
#[test]
fn test_tracking_config_default() {
let config = TrackingConfig::default();
assert!((config.min_elevation - DEFAULT_MIN_ELEVATION).abs() < 0.01);
assert_eq!(config.update_interval_ms, DEFAULT_UPDATE_INTERVAL_MS);
}
#[test]
fn test_satellite_pass_duration() {
let pass = SatellitePass {
name: "ISS".to_string(),
norad_id: 25544,
aos_time: Utc::now(),
tca_time: Utc::now() + chrono::Duration::seconds(300),
los_time: Utc::now() + chrono::Duration::seconds(600),
max_elevation: 75.0,
aos_azimuth: 180.0,
los_azimuth: 45.0,
};
assert_eq!(pass.duration(), Duration::from_secs(600));
assert!(pass.is_good_pass(45.0));
assert!(!pass.is_good_pass(80.0));
}
#[test]
fn test_get_state() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
tracker.set_observer_location(34.0522, -118.2437, 71.0);
let result = tracker.get_current_state();
assert!(result.is_ok());
let state = result.unwrap();
assert!(state.range_km > 0.0);
assert!(state.range_km < 50000.0); }
#[test]
fn test_pid_controller() {
let mut pid = PidController::new(1.0, 0.1, 0.5);
let correction = pid.update(10.0, 0.1);
assert!(correction > 0.0);
pid.reset();
assert_eq!(pid.integral, 0.0);
assert_eq!(pid.prev_error, 0.0);
}
#[test]
fn test_cable_wrap_initialization() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
assert!(!tracker.azimuth_initialized);
tracker.initialize_cable_wrap(180.0);
assert!(tracker.azimuth_initialized);
assert!((tracker.unwrapped_azimuth - 180.0).abs() < 0.001);
}
#[test]
fn test_cable_wrap_tracking() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
tracker.initialize_cable_wrap(0.0);
tracker.update_unwrapped_azimuth(90.0);
assert!((tracker.unwrapped_azimuth - 90.0).abs() < 0.001);
tracker.update_unwrapped_azimuth(180.0);
assert!((tracker.unwrapped_azimuth - 180.0).abs() < 0.001);
tracker.update_unwrapped_azimuth(270.0);
assert!((tracker.unwrapped_azimuth - 270.0).abs() < 0.001);
tracker.update_unwrapped_azimuth(10.0);
assert!((tracker.unwrapped_azimuth - 370.0).abs() < 0.001);
}
#[test]
fn test_cable_wrap_reverse_tracking() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
tracker.initialize_cable_wrap(180.0);
tracker.update_unwrapped_azimuth(90.0);
assert!((tracker.unwrapped_azimuth - 90.0).abs() < 0.001);
tracker.update_unwrapped_azimuth(350.0);
assert!((tracker.unwrapped_azimuth - (-10.0)).abs() < 0.001);
}
#[test]
fn test_cable_wrap_limit_check() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let mut config = TrackingConfig::default();
config.az_min_limit = -90.0;
config.az_max_limit = 450.0;
config.enable_cable_wrap_prevention = true;
tracker.set_config(config);
tracker.initialize_cable_wrap(0.0);
let check = tracker.check_cable_wrap(180.0);
assert_eq!(check.state, CableWrapState::Safe);
tracker.update_unwrapped_azimuth(90.0);
tracker.update_unwrapped_azimuth(180.0);
tracker.update_unwrapped_azimuth(270.0);
tracker.update_unwrapped_azimuth(0.0); tracker.update_unwrapped_azimuth(90.0);
let check = tracker.check_cable_wrap(100.0); assert_eq!(check.state, CableWrapState::AtLimit);
}
#[test]
fn test_cable_wrap_warning() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let mut config = TrackingConfig::default();
config.az_min_limit = -90.0;
config.az_max_limit = 450.0;
config.enable_cable_wrap_prevention = true;
tracker.set_config(config);
tracker.initialize_cable_wrap(0.0);
tracker.update_unwrapped_azimuth(90.0);
tracker.update_unwrapped_azimuth(180.0);
tracker.update_unwrapped_azimuth(270.0);
tracker.update_unwrapped_azimuth(0.0); tracker.update_unwrapped_azimuth(60.0);
let check = tracker.check_cable_wrap(70.0); assert_eq!(check.state, CableWrapState::Warning);
}
#[test]
fn test_cable_wrap_disabled() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let mut config = TrackingConfig::default();
config.enable_cable_wrap_prevention = false;
tracker.set_config(config);
tracker.initialize_cable_wrap(0.0);
let check = tracker.check_cable_wrap(1000.0);
assert_eq!(check.state, CableWrapState::Safe);
}
#[test]
fn test_is_position_safe() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let mut config = TrackingConfig::default();
config.az_min_limit = -90.0;
config.az_max_limit = 450.0;
config.enable_cable_wrap_prevention = true;
tracker.set_config(config);
tracker.initialize_cable_wrap(0.0);
assert!(tracker.is_position_safe(180.0));
tracker.update_unwrapped_azimuth(90.0);
tracker.update_unwrapped_azimuth(180.0);
tracker.update_unwrapped_azimuth(270.0);
tracker.update_unwrapped_azimuth(0.0); tracker.update_unwrapped_azimuth(80.0);
assert!(!tracker.is_position_safe(100.0)); }
#[test]
fn test_altitude_safety() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
assert!(tracker.is_altitude_safe(45.0));
assert!(tracker.is_altitude_safe(0.0));
assert!(tracker.is_altitude_safe(90.0));
assert!(!tracker.is_altitude_safe(-5.0));
assert!(!tracker.is_altitude_safe(95.0));
let mut config = TrackingConfig::default();
config.alt_min_limit = 5.0;
config.alt_max_limit = 85.0;
tracker.set_config(config);
assert!(tracker.is_altitude_safe(45.0));
assert!(!tracker.is_altitude_safe(0.0)); assert!(!tracker.is_altitude_safe(90.0)); assert!(tracker.is_altitude_safe(5.0)); assert!(tracker.is_altitude_safe(85.0)); }
#[test]
fn test_safe_horizontal_position() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let mut config = TrackingConfig::default();
config.alt_min_limit = 5.0;
config.alt_max_limit = 85.0;
tracker.set_config(config);
let pos = tracker.safe_horizontal_position(180.0, 45.0);
assert!((pos.azimuth - 180.0).abs() < 0.001);
assert!((pos.altitude - 45.0).abs() < 0.001);
let pos = tracker.safe_horizontal_position(90.0, -10.0);
assert!((pos.azimuth - 90.0).abs() < 0.001);
assert!((pos.altitude - 5.0).abs() < 0.001);
let pos = tracker.safe_horizontal_position(270.0, 95.0);
assert!((pos.azimuth - 270.0).abs() < 0.001);
assert!((pos.altitude - 85.0).abs() < 0.001);
let pos = tracker.safe_horizontal_position(450.0, 45.0);
assert!((pos.azimuth - 90.0).abs() < 0.001);
let pos = tracker.safe_horizontal_position(-90.0, 45.0);
assert!((pos.azimuth - 270.0).abs() < 0.001); }
#[test]
fn test_safe_horizontal_position_prevents_flip() {
let mut tracker = SatelliteTracker::from_tle(ISS_LINE1, ISS_LINE2).unwrap();
let pos = tracker.safe_horizontal_position(180.0, -30.0);
assert!(pos.altitude >= 0.0);
let mut config = TrackingConfig::default();
config.alt_min_limit = 5.0;
tracker.set_config(config);
let pos = tracker.safe_horizontal_position(180.0, 0.0);
assert!((pos.altitude - 5.0).abs() < 0.001);
let pos = tracker.safe_horizontal_position(180.0, -45.0);
assert!((pos.altitude - 5.0).abs() < 0.001);
}
}