use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use keplemon::bodies::{Observatory, Satellite};
use keplemon::elements::TLE;
use keplemon::enums::{ReferenceFrame, TimeSystem};
use keplemon::time::{Epoch, TimeSpan};
use crate::coordinates::{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;
#[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,
}
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,
}
}
}
pub struct SatelliteTracker {
satellite: Satellite,
tle: TLE,
observatory: Observatory,
site_location: SiteLocation,
config: TrackingConfig,
last_state: Option<TrackingState>,
last_update: Option<Instant>,
}
impl SatelliteTracker {
pub fn from_tle(line1: &str, line2: &str) -> MountResult<Self> {
let tle = TLE::from_two_lines(line1, line2).map_err(|e| MountError::satellite_error(e))?;
let satellite = Satellite::from(tle.clone());
Ok(Self {
satellite,
tle,
observatory: Observatory::new(0.0, 0.0, 0.0),
site_location: SiteLocation::default(),
config: TrackingConfig::default(),
last_state: None,
last_update: None,
})
}
pub fn from_tle_with_name(name: &str, line1: &str, line2: &str) -> MountResult<Self> {
let tle = TLE::from_three_lines(name, line1, line2)
.map_err(|e| MountError::satellite_error(e))?;
let satellite = Satellite::from(tle.clone());
Ok(Self {
satellite,
tle,
observatory: Observatory::new(0.0, 0.0, 0.0),
site_location: SiteLocation::default(),
config: TrackingConfig::default(),
last_state: None,
last_update: None,
})
}
pub fn set_observer_location(&mut self, latitude: f64, longitude: f64, altitude: f64) {
self.observatory = Observatory::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.tle
.get_name()
.unwrap_or_else(|| format!("NORAD {}", self.tle.norad_id))
}
pub fn get_norad_id(&self) -> i32 {
self.tle.norad_id
}
pub fn get_tle_epoch(&self) -> DateTime<Utc> {
let epoch = self.tle.get_epoch();
datetime_from_epoch(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 epoch = epoch_from_datetime(time);
let topo = self
.observatory
.get_topocentric_to_satellite(epoch, &self.satellite, ReferenceFrame::TEME)
.map_err(|e| MountError::satellite_error(e))?;
let ra = topo.right_ascension;
let dec = topo.declination;
let range = topo.range.unwrap_or(0.0);
let range_rate = topo.range_rate.unwrap_or(0.0);
let dt_sec = 1.0;
let future_epoch = epoch + TimeSpan::from_seconds(dt_sec);
let future_topo = self
.observatory
.get_topocentric_to_satellite(future_epoch, &self.satellite, ReferenceFrame::TEME)
.map_err(|e| MountError::satellite_error(e))?;
let ra_rate = (future_topo.right_ascension - ra) * 3600.0 / dt_sec;
let dec_rate = (future_topo.declination - dec) * 3600.0 / dt_sec;
let ra_hours = ra / 15.0;
let equatorial = EquatorialPosition::new(ra_hours, dec);
let lst = Coordinates::julian_to_lst(
epoch.days_since_1950 + 2433281.5, self.site_location.longitude,
);
let horizontal = equatorial.to_horizontal(self.site_location.latitude, lst);
let state = TrackingState {
equatorial,
horizontal,
ra_rate,
dec_rate,
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()?;
}
let initial_state = self.get_current_state()?;
if !initial_state.is_visible {
return Err(MountError::BelowHorizon(initial_state.horizontal.altitude));
}
log::info!(
"Starting tracking of {} at {}",
self.get_name(),
initial_state
);
mount.goto_equatorial(initial_state.equatorial)?;
while mount.is_slewing()? {
std::thread::sleep(Duration::from_millis(100));
}
let update_interval = Duration::from_millis(self.config.update_interval_ms);
loop {
let loop_start = Instant::now();
let (position, rates) = self.get_lead_position()?;
let state = self.get_current_state()?;
if !state.is_visible {
log::info!(
"Satellite {} below horizon, stopping tracking",
self.get_name()
);
break;
}
if self.config.use_rate_tracking && rates.is_valid_for_satellite() {
mount.set_custom_tracking_rates(rates)?;
} else {
mount.goto_equatorial(position)?;
}
log::debug!("Tracking: {}", state);
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",
tracking_duration.as_secs_f64()
);
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)
}
}
fn datetime_from_epoch(epoch: Epoch) -> DateTime<Utc> {
let unix_seconds = (epoch.days_since_1950 * 86400.0) - 631152000.0;
let secs = unix_seconds.floor() as i64;
let nsecs = ((unix_seconds - secs as f64) * 1_000_000_000.0) as u32;
DateTime::from_timestamp(secs, nsecs).unwrap_or_else(|| Utc::now())
}
fn epoch_from_datetime(dt: DateTime<Utc>) -> Epoch {
let unix_seconds = dt.timestamp() as f64 + dt.timestamp_subsec_nanos() as f64 / 1_000_000_000.0;
let days_since_1950 = (unix_seconds + 631152000.0) / 86400.0;
Epoch::from_days_since_1950(days_since_1950, TimeSystem::UTC)
}
#[cfg(test)]
mod tests {
use super::*;
const ISS_LINE1: &str = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025";
const ISS_LINE2: &str = "2 25544 51.6400 208.9163 0006703 35.6028 75.3281 15.49560066429339";
#[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_epoch_conversion() {
let now = Utc::now();
let epoch = epoch_from_datetime(now);
let converted = datetime_from_epoch(epoch);
let diff = (now - converted).num_seconds().abs();
assert!(diff <= 1);
}
}