use clap::{Parser, Subcommand, ValueEnum};
use ephemerust::Result;
use serde::Serialize;
#[derive(Parser)]
#[command(name = "ephemerust")]
#[command(about = "Astronomy, orbital-mechanics, and satellite-tracking toolkit, written in Rust")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long)]
verbose: bool,
}
#[derive(Subcommand)]
enum Commands {
Convert {
#[arg(short, long)]
from: String,
#[arg(short, long)]
to: String,
#[arg(short, long)]
coords: String,
#[arg(long)]
gmst: Option<f64>,
},
RiseSet {
#[arg(short = 'j', long)]
object: String,
#[arg(short = 'a', long)]
latitude: f64,
#[arg(short = 'o', long)]
longitude: f64,
#[arg(short, long)]
date: Option<String>,
},
Position {
#[arg(short, long)]
object: String,
#[arg(short, long)]
date: String,
},
Time {
#[arg(short, long)]
date: String,
#[arg(short, long)]
time: Option<String>,
},
Orbital {
#[arg(short, long)]
semi_major: f64,
#[arg(short, long)]
eccentricity: f64,
#[arg(short, long)]
inclination: f64,
#[arg(long, default_value_t = 0.0)]
raan: f64,
#[arg(long = "arg-periapsis", default_value_t = 0.0)]
arg_periapsis: f64,
#[arg(long, default_value_t = 0.0)]
mean_anomaly: f64,
#[arg(long, default_value_t = 398600.4418)]
mu: f64,
},
Track {
#[arg(short = 'f', long)]
tle_file: Option<String>,
#[arg(short = 't', long)]
tle: Option<String>,
#[cfg_attr(not(feature = "network"), arg(skip))]
#[cfg_attr(feature = "network", arg(long = "tle-url"))]
tle_url: Option<String>,
#[arg(long, value_enum, default_value_t = TrackMode::All)]
mode: TrackMode,
#[arg(long, value_enum, default_value_t = TrackFormat::Human)]
format: TrackFormat,
#[arg(short = 'a', long)]
latitude: Option<f64>,
#[arg(short = 'o', long)]
longitude: Option<f64>,
#[arg(long, default_value_t = 0_u32)]
predict_passes_hours: u32,
#[arg(long, default_value_t = 10.0)]
pass_min_elevation_deg: f64,
#[arg(long, default_value_t = 0_u32)]
ground_track_hours: u32,
#[arg(long, default_value_t = 60_u64)]
ground_track_step_sec: u64,
#[arg(long, default_value_t = false)]
ground_track_json: bool,
#[arg(long, default_value_t = false)]
afspc: bool,
},
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)]
enum TrackMode {
#[default]
All,
Tle,
State,
Subpoint,
Look,
Passes,
Ground,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)]
enum TrackFormat {
#[default]
Human,
Json,
}
fn main() {
if let Err(err) = run() {
eprintln!("Error: {err}");
if let Some(hint) = err.hint() {
eprintln!("Hint: {hint}");
}
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
init_logging(cli.verbose);
match cli.command {
Commands::Convert {
from,
to,
coords,
gmst,
} => match (from.to_lowercase().as_str(), to.to_lowercase().as_str()) {
("ra-dec" | "radec", "alt-az" | "altaz") => {
let result = parse_and_convert_radec_to_altaz(&coords)?;
let (alt_deg, alt_min, alt_sec, alt_sign) = format_angle(result.alt);
let (az_deg, az_min, az_sec, _) = format_angle(result.az);
println!(
"Alt: {}{:02}°{:02}'{:02}\"",
alt_sign, alt_deg, alt_min, alt_sec
);
println!("Az: {:03}°{:02}'{:02}\"", az_deg, az_min, az_sec);
}
("alt-az" | "altaz", "ra-dec" | "radec") => {
let result = parse_and_convert_altaz_to_radec(&coords)?;
let (ra_h, ra_m, ra_s) = format_time(result.ra);
let (dec_deg, dec_min, dec_sec, dec_sign) = format_angle(result.dec);
println!("RA: {:02}:{:02}:{:02}", ra_h, ra_m, ra_s);
println!(
"Dec: {}{:02}°{:02}'{:02}\"",
dec_sign, dec_deg, dec_min, dec_sec
);
}
("ecef", "eci") => {
let result = parse_and_convert_ecef_to_eci(&coords, gmst)?;
println!("X: {:.3} m", result.x);
println!("Y: {:.3} m", result.y);
println!("Z: {:.3} m", result.z);
}
("eci", "ecef") => {
let result = parse_and_convert_eci_to_ecef(&coords, gmst)?;
println!("X: {:.3} m", result.x);
println!("Y: {:.3} m", result.y);
println!("Z: {:.3} m", result.z);
}
_ => {
return Err(ephemerust::AstroError::InvalidCoordinate(format!(
"Unsupported conversion: {} to {}",
from, to
)));
}
},
Commands::RiseSet {
object,
latitude,
longitude,
date,
} => {
let date_time = if let Some(date_str) = date {
parse_date_time(&date_str, None)?
} else {
chrono::Utc::now()
};
let location = ephemerust::celestial::ObserverLocation {
latitude,
longitude,
elevation: 0.0,
};
let obj = parse_celestial_object(&object)?;
let rise_set =
ephemerust::celestial::calculate_rise_set_times(obj, location, date_time)?;
match rise_set.rise {
Some(t) => println!("Rise: {}", t.format("%H:%M:%S UTC")),
None => println!("Rise: Does not rise"),
}
match rise_set.set {
Some(t) => println!("Set: {}", t.format("%H:%M:%S UTC")),
None => println!("Set: Does not set"),
}
}
Commands::Position { object, date } => {
let date_time = parse_date_time(&date, None)?;
let obj = parse_celestial_object(&object)?;
let pos = ephemerust::celestial::calculate_position(obj, date_time)?;
let (ra_h, ra_m, ra_s) = format_time(pos.ra);
let (dec_deg, dec_min, dec_sec, dec_sign) = format_angle(pos.dec);
println!("RA: {:02}:{:02}:{:02}", ra_h, ra_m, ra_s);
println!(
"Dec: {}{:02}°{:02}'{:02}\"",
dec_sign, dec_deg, dec_min, dec_sec
);
}
Commands::Time { date, time } => {
let date_time = parse_date_time(&date, time.as_deref())?;
let jd = ephemerust::time::julian_date(date_time);
let gmst = ephemerust::time::greenwich_mean_sidereal_time(jd);
let (h, m, s) = format_time(gmst);
println!("JD: {:.6}", jd);
println!("GMST: {:02}:{:02}:{:02}", h, m, s);
}
Commands::Orbital {
semi_major,
eccentricity,
inclination,
raan,
arg_periapsis,
mean_anomaly,
mu,
} => {
use ephemerust::orbital::{
elements_to_state_vector, mean_to_true_anomaly, orbital_period, OrbitalElements,
};
let elements = OrbitalElements {
semi_major_axis: semi_major,
eccentricity,
inclination,
longitude_ascending_node: raan,
argument_periapsis: arg_periapsis,
mean_anomaly,
};
let period_s = orbital_period(semi_major, mu);
let true_anomaly = mean_to_true_anomaly(mean_anomaly, eccentricity);
let state = elements_to_state_vector(elements, mu)?;
println!(
"Elements: a={} km, e={}, i={}°, Ω={}°, ω={}°, M={}°",
semi_major, eccentricity, inclination, raan, arg_periapsis, mean_anomaly
);
println!("Period: {:.1} s ({:.2} min)", period_s, period_s / 60.0);
println!("True anomaly: {:.4}°", true_anomaly);
println!("State vector (inertial frame):");
println!(
" Position [km]: x={:.3} y={:.3} z={:.3}",
state.position[0], state.position[1], state.position[2]
);
println!(
" Velocity [km/s]: vx={:.6} vy={:.6} vz={:.6}",
state.velocity[0], state.velocity[1], state.velocity[2]
);
}
Commands::Track {
tle_file,
tle,
tle_url,
mode,
format,
latitude,
longitude,
predict_passes_hours,
pass_min_elevation_deg,
ground_track_hours,
ground_track_step_sec,
ground_track_json,
afspc,
} => run_track(
tle_file,
tle,
tle_url,
mode,
format,
latitude,
longitude,
predict_passes_hours,
pass_min_elevation_deg,
ground_track_hours,
ground_track_step_sec,
ground_track_json,
afspc,
)?,
}
Ok(())
}
#[derive(Serialize)]
struct TleSummaryJson {
name: Option<String>,
catalog_number: u32,
classification: char,
international_designator: String,
epoch_rfc3339: String,
mean_motion_dot: f64,
mean_motion_ddot: f64,
bstar: f64,
element_set_number: u32,
inclination_deg: f64,
raan_deg: f64,
eccentricity: f64,
arg_perigee_deg: f64,
mean_anomaly_deg: f64,
mean_motion: f64,
revolution_number: u32,
}
#[derive(Serialize)]
struct ObserverJson {
latitude_deg: f64,
longitude_deg: f64,
elevation_m: f64,
}
#[derive(Serialize)]
struct TrackJsonOutput {
#[serde(skip_serializing_if = "Option::is_none")]
tle: Option<TleSummaryJson>,
#[serde(skip_serializing_if = "Option::is_none")]
observer: Option<ObserverJson>,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<ephemerust::satellite::TemeState>,
#[serde(skip_serializing_if = "Option::is_none")]
subpoint: Option<ephemerust::satellite::Subpoint>,
#[serde(skip_serializing_if = "Option::is_none")]
look_angles: Option<ephemerust::satellite::LookAngles>,
#[serde(skip_serializing_if = "Option::is_none")]
passes: Option<Vec<ephemerust::satellite::Pass>>,
#[serde(skip_serializing_if = "Option::is_none")]
ground_track: Option<Vec<ephemerust::satellite::GroundTrackSample>>,
#[serde(skip_serializing_if = "Option::is_none")]
predict_passes_hours: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pass_min_elevation_deg: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
ground_track_hours: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
ground_track_step_sec: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
propagation_model: Option<ephemerust::satellite::PropagationModel>,
}
fn tle_summary_json(tle: &ephemerust::satellite::Tle) -> TleSummaryJson {
TleSummaryJson {
name: tle.name.clone(),
catalog_number: tle.catalog_number,
classification: tle.classification,
international_designator: tle.international_designator.clone(),
epoch_rfc3339: tle
.epoch
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
mean_motion_dot: tle.mean_motion_dot,
mean_motion_ddot: tle.mean_motion_ddot,
bstar: tle.bstar,
element_set_number: tle.element_set_number,
inclination_deg: tle.inclination_deg,
raan_deg: tle.raan_deg,
eccentricity: tle.eccentricity,
arg_perigee_deg: tle.arg_perigee_deg,
mean_anomaly_deg: tle.mean_anomaly_deg,
mean_motion: tle.mean_motion,
revolution_number: tle.revolution_number,
}
}
fn resolve_tle_input(
tle_file: Option<String>,
tle: Option<String>,
tle_url: Option<String>,
) -> Result<ephemerust::satellite::Tle> {
use ephemerust::satellite::Tle;
#[cfg(feature = "network")]
const TLE_ONE_OF: &str = "provide exactly one of --tle-file, --tle, or --tle-url";
#[cfg(not(feature = "network"))]
const TLE_ONE_OF: &str = "provide exactly one of --tle-file or --tle";
#[cfg(feature = "network")]
const TLE_ONLY_ONE: &str = "only one of --tle-file, --tle, or --tle-url may be given";
#[cfg(not(feature = "network"))]
const TLE_ONLY_ONE: &str = "only one of --tle-file or --tle may be given";
let n = tle_file.is_some() as u8 + tle.is_some() as u8 + tle_url.is_some() as u8;
if n == 0 {
return Err(ephemerust::AstroError::SatelliteError(TLE_ONE_OF.into()));
}
if n > 1 {
return Err(ephemerust::AstroError::SatelliteError(TLE_ONLY_ONE.into()));
}
if tle_url.is_some() {
return Err(ephemerust::AstroError::SatelliteError(
"fetching a TLE from --tle-url is not implemented yet; use --tle or --tle-file.".into(),
));
}
match (tle_file, tle) {
(Some(path), None) => Tle::from_file(&path),
(None, Some(text)) => Tle::parse(&text),
_ => Err(ephemerust::AstroError::SatelliteError(
"internal: TLE source resolution".into(),
)),
}
}
#[allow(clippy::too_many_arguments)]
fn run_track(
tle_file: Option<String>,
tle: Option<String>,
tle_url: Option<String>,
mode: TrackMode,
format: TrackFormat,
latitude: Option<f64>,
longitude: Option<f64>,
predict_passes_hours: u32,
pass_min_elevation_deg: f64,
ground_track_hours: u32,
ground_track_step_sec: u64,
ground_track_json: bool,
afspc: bool,
) -> Result<()> {
use chrono::Duration;
use ephemerust::celestial::ObserverLocation;
use ephemerust::satellite::{
ground_track_with_model, ground_track_to_csv, ground_track_to_json, look_angles_with_model,
predict_passes_with_model, propagate_with_model, subpoint_with_model,
PropagationModel, Tle,
};
let model = if afspc {
PropagationModel::AfspcCompatibility
} else {
PropagationModel::Modern
};
const DEFAULT_LAT_DEG: f64 = 47.9088;
const DEFAULT_LON_DEG: f64 = -122.2503;
if !(-90.0..=90.0).contains(&pass_min_elevation_deg) {
return Err(ephemerust::AstroError::SatelliteError(
"--pass-min-elevation-deg must be between -90 and 90".into(),
));
}
if mode == TrackMode::Passes && predict_passes_hours == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"mode `passes` requires --predict-passes-hours > 0".into(),
));
}
if mode == TrackMode::Ground && ground_track_hours == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"mode `ground` requires --ground-track-hours > 0".into(),
));
}
let parsed: Tle = resolve_tle_input(tle_file, tle, tle_url)?;
if afspc {
log::info!("track: using AFSPC compatibility propagation (WGS72 + legacy sidereal/epoch)");
}
let obs = ObserverLocation {
latitude: latitude.unwrap_or(DEFAULT_LAT_DEG),
longitude: longitude.unwrap_or(DEFAULT_LON_DEG),
elevation: 0.0,
};
match format {
TrackFormat::Json => {
let mut out = TrackJsonOutput {
tle: None,
observer: None,
state: None,
subpoint: None,
look_angles: None,
passes: None,
ground_track: None,
predict_passes_hours: None,
pass_min_elevation_deg: None,
ground_track_hours: None,
ground_track_step_sec: None,
propagation_model: None,
};
let set_propagation_meta = |out: &mut TrackJsonOutput| {
out.propagation_model = Some(model);
};
let include_tle = matches!(
mode,
TrackMode::All
| TrackMode::Tle
| TrackMode::State
| TrackMode::Subpoint
| TrackMode::Look
| TrackMode::Passes
| TrackMode::Ground
);
if include_tle {
out.tle = Some(tle_summary_json(&parsed));
}
match mode {
TrackMode::Tle => {
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::State => {
set_propagation_meta(&mut out);
out.state = Some(propagate_with_model(&parsed, parsed.epoch, model)?);
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::Subpoint => {
set_propagation_meta(&mut out);
out.subpoint = Some(subpoint_with_model(&parsed, parsed.epoch, model)?);
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::Look => {
set_propagation_meta(&mut out);
out.observer = Some(ObserverJson {
latitude_deg: obs.latitude,
longitude_deg: obs.longitude,
elevation_m: obs.elevation,
});
out.look_angles = Some(look_angles_with_model(&parsed, parsed.epoch, obs, model)?);
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::Passes => {
set_propagation_meta(&mut out);
let win_end = parsed.epoch + Duration::hours(i64::from(predict_passes_hours));
out.observer = Some(ObserverJson {
latitude_deg: obs.latitude,
longitude_deg: obs.longitude,
elevation_m: obs.elevation,
});
out.predict_passes_hours = Some(predict_passes_hours);
out.pass_min_elevation_deg = Some(pass_min_elevation_deg);
out.passes = Some(predict_passes_with_model(
&parsed,
obs,
parsed.epoch,
win_end,
pass_min_elevation_deg,
model,
)?);
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::Ground => {
set_propagation_meta(&mut out);
if ground_track_step_sec == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec must be at least 1".into(),
));
}
let win_end = parsed.epoch + Duration::hours(i64::from(ground_track_hours));
let step =
Duration::seconds(i64::try_from(ground_track_step_sec).map_err(|_| {
ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec is too large for chrono::Duration".into(),
)
})?);
out.ground_track_hours = Some(ground_track_hours);
out.ground_track_step_sec = Some(ground_track_step_sec);
out.ground_track = Some(ground_track_with_model(
&parsed, parsed.epoch, win_end, step, model,
)?);
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
TrackMode::All => {
set_propagation_meta(&mut out);
out.observer = Some(ObserverJson {
latitude_deg: obs.latitude,
longitude_deg: obs.longitude,
elevation_m: obs.elevation,
});
out.state = Some(propagate_with_model(&parsed, parsed.epoch, model)?);
out.subpoint = Some(subpoint_with_model(&parsed, parsed.epoch, model)?);
out.look_angles = Some(look_angles_with_model(&parsed, parsed.epoch, obs, model)?);
if predict_passes_hours > 0 {
let win_end =
parsed.epoch + Duration::hours(i64::from(predict_passes_hours));
out.predict_passes_hours = Some(predict_passes_hours);
out.pass_min_elevation_deg = Some(pass_min_elevation_deg);
out.passes = Some(predict_passes_with_model(
&parsed,
obs,
parsed.epoch,
win_end,
pass_min_elevation_deg,
model,
)?);
}
if ground_track_hours > 0 {
if ground_track_step_sec == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec must be at least 1".into(),
));
}
let win_end = parsed.epoch + Duration::hours(i64::from(ground_track_hours));
let step = Duration::seconds(
i64::try_from(ground_track_step_sec).map_err(|_| {
ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec is too large for chrono::Duration"
.into(),
)
})?,
);
out.ground_track_hours = Some(ground_track_hours);
out.ground_track_step_sec = Some(ground_track_step_sec);
out.ground_track =
Some(ground_track_with_model(
&parsed, parsed.epoch, win_end, step, model,
)?);
}
let s = serde_json::to_string_pretty(&out).map_err(|e| {
ephemerust::AstroError::SatelliteError(format!("JSON: {e}"))
})?;
println!("{s}");
return Ok(());
}
}
}
TrackFormat::Human => match mode {
TrackMode::Tle => print_tle_summary(&parsed),
TrackMode::State => {
let state = propagate_with_model(&parsed, parsed.epoch, model)?;
println!("State at epoch (TEME frame):");
println!(
" Position [km]: x={:.3} y={:.3} z={:.3}",
state.position_km[0], state.position_km[1], state.position_km[2]
);
println!(
" Velocity [km/s]: vx={:.6} vy={:.6} vz={:.6}",
state.velocity_km_s[0], state.velocity_km_s[1], state.velocity_km_s[2]
);
}
TrackMode::Subpoint => {
let sub = subpoint_with_model(&parsed, parsed.epoch, model)?;
println!("Sub-satellite point at epoch (WGS84 geodetic):");
println!(" Latitude: {:+.6}°", sub.latitude_deg);
println!(" Longitude: {:+.6}°", sub.longitude_deg);
println!(" Altitude: {:.3} km (ellipsoidal)", sub.altitude_km);
}
TrackMode::Look => {
let look = look_angles_with_model(&parsed, parsed.epoch, obs, model)?;
println!(
"Look angles at epoch (observer {:.4}° N, {:.4}° lon, WGS84 h = {:.0} m):",
obs.latitude, obs.longitude, obs.elevation
);
println!(
" Azimuth: {:7.3}° (clockwise from true north)",
look.azimuth_deg
);
println!(" Elevation: {:7.3}°", look.elevation_deg);
println!(" Range: {:10.3} km", look.range_km);
println!(
" Range rate: {:+.6} km/s (negative → approaching)",
look.range_rate_km_s
);
}
TrackMode::Passes => {
let win_end = parsed.epoch + Duration::hours(i64::from(predict_passes_hours));
let passes = predict_passes_with_model(
&parsed,
obs,
parsed.epoch,
win_end,
pass_min_elevation_deg,
model,
)?;
println!(
"Predicted passes ({} h from epoch, min elevation {:.1}°):",
predict_passes_hours, pass_min_elevation_deg
);
if passes.is_empty() {
println!(" (none)");
} else {
for (i, p) in passes.iter().enumerate() {
println!(
" Pass {}: AOS {} max el {:5.1}° @ {} LOS {}",
i + 1,
p.aos.format("%Y-%m-%d %H:%M:%S"),
p.max_elevation_deg,
p.culmination.format("%H:%M:%S"),
p.los.format("%Y-%m-%d %H:%M:%S"),
);
println!(
" az @ AOS {:6.1}° az @ LOS {:6.1}°",
p.aos_azimuth_deg, p.los_azimuth_deg
);
}
}
}
TrackMode::Ground => {
if ground_track_step_sec == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec must be at least 1".into(),
));
}
let win_end = parsed.epoch + Duration::hours(i64::from(ground_track_hours));
let step =
Duration::seconds(i64::try_from(ground_track_step_sec).map_err(|_| {
ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec is too large for chrono::Duration".into(),
)
})?);
let samples = ground_track_with_model(&parsed, parsed.epoch, win_end, step, model)?;
if ground_track_json {
println!("Ground track (JSON, {} samples):", samples.len());
println!("{}", ground_track_to_json(&samples)?);
} else {
println!(
"Ground track (CSV, {} h from epoch, step {} s, {} samples):",
ground_track_hours,
ground_track_step_sec,
samples.len()
);
print!("{}", ground_track_to_csv(&samples));
}
}
TrackMode::All => {
print_tle_summary(&parsed);
let state = propagate_with_model(&parsed, parsed.epoch, model)?;
let sub = subpoint_with_model(&parsed, parsed.epoch, model)?;
let look = look_angles_with_model(&parsed, parsed.epoch, obs, model)?;
println!();
println!("State at epoch (TEME frame):");
println!(
" Position [km]: x={:.3} y={:.3} z={:.3}",
state.position_km[0], state.position_km[1], state.position_km[2]
);
println!(
" Velocity [km/s]: vx={:.6} vy={:.6} vz={:.6}",
state.velocity_km_s[0], state.velocity_km_s[1], state.velocity_km_s[2]
);
println!();
println!("Sub-satellite point at epoch (WGS84 geodetic):");
println!(" Latitude: {:+.6}°", sub.latitude_deg);
println!(" Longitude: {:+.6}°", sub.longitude_deg);
println!(" Altitude: {:.3} km (ellipsoidal)", sub.altitude_km);
println!();
println!(
"Look angles at epoch (observer {:.4}° N, {:.4}° lon, WGS84 h = {:.0} m):",
obs.latitude, obs.longitude, obs.elevation
);
println!(
" Azimuth: {:7.3}° (clockwise from true north)",
look.azimuth_deg
);
println!(" Elevation: {:7.3}°", look.elevation_deg);
println!(" Range: {:10.3} km", look.range_km);
println!(
" Range rate: {:+.6} km/s (negative → approaching)",
look.range_rate_km_s
);
if predict_passes_hours > 0 {
let win_end = parsed.epoch + Duration::hours(i64::from(predict_passes_hours));
let passes = predict_passes_with_model(
&parsed,
obs,
parsed.epoch,
win_end,
pass_min_elevation_deg,
model,
)?;
println!();
println!(
"Predicted passes ({} h from epoch, min elevation {:.1}°):",
predict_passes_hours, pass_min_elevation_deg
);
if passes.is_empty() {
println!(" (none)");
} else {
for (i, p) in passes.iter().enumerate() {
println!(
" Pass {}: AOS {} max el {:5.1}° @ {} LOS {}",
i + 1,
p.aos.format("%Y-%m-%d %H:%M:%S"),
p.max_elevation_deg,
p.culmination.format("%H:%M:%S"),
p.los.format("%Y-%m-%d %H:%M:%S"),
);
println!(
" az @ AOS {:6.1}° az @ LOS {:6.1}°",
p.aos_azimuth_deg, p.los_azimuth_deg
);
}
}
}
if ground_track_hours > 0 {
if ground_track_step_sec == 0 {
return Err(ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec must be at least 1".into(),
));
}
let win_end = parsed.epoch + Duration::hours(i64::from(ground_track_hours));
let step =
Duration::seconds(i64::try_from(ground_track_step_sec).map_err(|_| {
ephemerust::AstroError::SatelliteError(
"--ground-track-step-sec is too large for chrono::Duration".into(),
)
})?);
let samples = ground_track_with_model(&parsed, parsed.epoch, win_end, step, model)?;
println!();
if ground_track_json {
println!("Ground track (JSON, {} samples):", samples.len());
println!("{}", ground_track_to_json(&samples)?);
} else {
println!(
"Ground track (CSV, {} h from epoch, step {} s, {} samples):",
ground_track_hours,
ground_track_step_sec,
samples.len()
);
print!("{}", ground_track_to_csv(&samples));
}
}
}
},
}
Ok(())
}
fn print_tle_summary(tle: &ephemerust::satellite::Tle) {
if let Some(name) = &tle.name {
println!("Object: {}", name);
}
println!(
"Catalog #: {} ({})",
tle.catalog_number, tle.classification
);
println!("Intl. desig.: {}", tle.international_designator);
println!(
"Epoch (UTC): {}",
tle.epoch.format("%Y-%m-%d %H:%M:%S%.3f")
);
println!("Inclination: {:.4}°", tle.inclination_deg);
println!("RAAN: {:.4}°", tle.raan_deg);
println!("Eccentricity: {:.7}", tle.eccentricity);
println!("Arg perigee: {:.4}°", tle.arg_perigee_deg);
println!("Mean anomaly: {:.4}°", tle.mean_anomaly_deg);
println!("Mean motion: {:.8} rev/day", tle.mean_motion);
println!("B* drag: {:.6e} 1/earth-radii", tle.bstar);
println!("Rev # @ epoch: {}", tle.revolution_number);
}
fn init_logging(verbose: bool) {
let log_level = if verbose { "debug" } else { "info" };
std::env::set_var("RUST_LOG", log_level);
env_logger::init();
}
fn parse_date_time(
date_str: &str,
time_str: Option<&str>,
) -> Result<chrono::DateTime<chrono::Utc>> {
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| ephemerust::AstroError::InvalidTime(format!("Invalid date: {}", e)))?;
let time = if let Some(ts) = time_str {
NaiveTime::parse_from_str(ts, "%H:%M:%S")
.map_err(|e| ephemerust::AstroError::InvalidTime(format!("Invalid time: {}", e)))?
} else {
NaiveTime::from_hms_opt(12, 0, 0).unwrap()
};
Ok(DateTime::from_naive_utc_and_offset(
date.and_time(time),
Utc,
))
}
fn format_time(hours: f64) -> (i32, i32, i32) {
let h = hours as i32;
let m = ((hours - h as f64) * 60.0) as i32;
let s = ((hours - h as f64 - m as f64 / 60.0) * 3600.0) as i32;
(h, m, s)
}
fn format_angle(degrees: f64) -> (i32, i32, i32, &'static str) {
let deg = degrees.abs() as i32;
let min = ((degrees.abs() - deg as f64) * 60.0) as i32;
let sec = ((degrees.abs() - deg as f64 - min as f64 / 60.0) * 3600.0) as i32;
let sign = if degrees >= 0.0 { "+" } else { "-" };
(deg, min, sec, sign)
}
fn parse_and_convert_radec_to_altaz(coords: &str) -> Result<ephemerust::coordinates::AltAz> {
use ephemerust::coordinates::{ra_dec_to_alt_az, RaDec};
use ephemerust::time::{greenwich_mean_sidereal_time, julian_date, local_sidereal_time};
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() != 2 {
return Err(ephemerust::AstroError::InvalidCoordinate(
"Expected: hours,degrees".to_string(),
));
}
let ra: f64 = parts[0]
.trim()
.parse()
.map_err(|_| ephemerust::AstroError::InvalidCoordinate("Invalid RA".to_string()))?;
let dec: f64 = parts[1]
.trim()
.parse()
.map_err(|_| ephemerust::AstroError::InvalidCoordinate("Invalid Dec".to_string()))?;
let jd = julian_date(chrono::Utc::now());
let gmst = greenwich_mean_sidereal_time(jd);
let (lat, lon) = (47.9088, -122.2503);
let lst = local_sidereal_time(gmst, lon);
ra_dec_to_alt_az(RaDec { ra, dec }, lat, lon, lst)
}
fn parse_and_convert_altaz_to_radec(coords: &str) -> Result<ephemerust::coordinates::RaDec> {
use ephemerust::coordinates::{alt_az_to_ra_dec, AltAz};
use ephemerust::time::{greenwich_mean_sidereal_time, julian_date, local_sidereal_time};
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() != 2 {
return Err(ephemerust::AstroError::InvalidCoordinate(
"Expected: altitude,azimuth".to_string(),
));
}
let alt: f64 = parts[0]
.trim()
.parse()
.map_err(|_| ephemerust::AstroError::InvalidCoordinate("Invalid altitude".to_string()))?;
let az: f64 = parts[1]
.trim()
.parse()
.map_err(|_| ephemerust::AstroError::InvalidCoordinate("Invalid azimuth".to_string()))?;
let jd = julian_date(chrono::Utc::now());
let gmst = greenwich_mean_sidereal_time(jd);
let (lat, lon) = (47.9088, -122.2503);
let lst = local_sidereal_time(gmst, lon);
alt_az_to_ra_dec(AltAz { alt, az }, lat, lon, lst)
}
fn parse_and_convert_ecef_to_eci(
coords: &str,
gmst_opt: Option<f64>,
) -> Result<ephemerust::coordinates::Eci> {
use ephemerust::coordinates::{ecef_to_eci, Ecef};
use ephemerust::time::{greenwich_mean_sidereal_time, julian_date};
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() != 3 {
return Err(ephemerust::AstroError::InvalidCoordinate(
"Expected: x,y,z (in meters)".to_string(),
));
}
let x: f64 = parts[0].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid x coordinate".to_string())
})?;
let y: f64 = parts[1].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid y coordinate".to_string())
})?;
let z: f64 = parts[2].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid z coordinate".to_string())
})?;
let gmst = if let Some(gmst_val) = gmst_opt {
gmst_val
} else {
let jd = julian_date(chrono::Utc::now());
greenwich_mean_sidereal_time(jd)
};
ecef_to_eci(Ecef { x, y, z }, gmst)
}
fn parse_and_convert_eci_to_ecef(
coords: &str,
gmst_opt: Option<f64>,
) -> Result<ephemerust::coordinates::Ecef> {
use ephemerust::coordinates::{eci_to_ecef, Eci};
use ephemerust::time::{greenwich_mean_sidereal_time, julian_date};
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() != 3 {
return Err(ephemerust::AstroError::InvalidCoordinate(
"Expected: x,y,z (in meters)".to_string(),
));
}
let x: f64 = parts[0].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid x coordinate".to_string())
})?;
let y: f64 = parts[1].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid y coordinate".to_string())
})?;
let z: f64 = parts[2].trim().parse().map_err(|_| {
ephemerust::AstroError::InvalidCoordinate("Invalid z coordinate".to_string())
})?;
let gmst = if let Some(gmst_val) = gmst_opt {
gmst_val
} else {
let jd = julian_date(chrono::Utc::now());
greenwich_mean_sidereal_time(jd)
};
eci_to_ecef(Eci { x, y, z }, gmst)
}
fn parse_celestial_object(object_name: &str) -> Result<ephemerust::celestial::CelestialObject> {
let obj_lower = object_name.to_lowercase();
match obj_lower.as_str() {
"sun" => Ok(ephemerust::celestial::CelestialObject::Sun),
"moon" => Ok(ephemerust::celestial::CelestialObject::Moon),
planet_name => {
if let Some(planet) = ephemerust::planets::Planet::from_name(planet_name) {
Ok(ephemerust::celestial::CelestialObject::Planet(planet))
} else {
Err(ephemerust::AstroError::InvalidCoordinate(
format!("Unknown object: {}. Supported: sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune", object_name)
))
}
}
}
}