use crate::core::{PoliastroError, PoliastroResult};
const STANDARD_GRAVITY: f64 = 9.80665;
#[derive(Debug, Clone, PartialEq)]
pub struct DeltaVManeuver {
pub name: String,
pub delta_v: f64,
pub notes: Option<String>,
}
impl DeltaVManeuver {
pub fn new(name: impl Into<String>, delta_v: f64) -> PoliastroResult<Self> {
if !delta_v.is_finite() {
return Err(PoliastroError::invalid_parameter(
"delta_v",
delta_v,
"must be finite",
));
}
if delta_v < 0.0 {
return Err(PoliastroError::invalid_parameter(
"delta_v",
delta_v,
"cannot be negative",
));
}
Ok(Self {
name: name.into(),
delta_v,
notes: None,
})
}
pub fn with_notes(
name: impl Into<String>,
delta_v: f64,
notes: impl Into<String>,
) -> PoliastroResult<Self> {
let mut maneuver = Self::new(name, delta_v)?;
maneuver.notes = Some(notes.into());
Ok(maneuver)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeltaVBudget {
pub mission_name: String,
pub maneuvers: Vec<DeltaVManeuver>,
pub contingency_margin: f64,
}
impl DeltaVBudget {
pub fn new(mission_name: impl Into<String>) -> Self {
Self {
mission_name: mission_name.into(),
maneuvers: Vec::new(),
contingency_margin: 0.0,
}
}
pub fn with_contingency(
mission_name: impl Into<String>,
contingency_margin: f64,
) -> PoliastroResult<Self> {
if !contingency_margin.is_finite() {
return Err(PoliastroError::invalid_parameter(
"contingency_margin",
contingency_margin,
"must be finite",
));
}
if !(0.0..=1.0).contains(&contingency_margin) {
return Err(PoliastroError::out_of_range(
"contingency_margin",
contingency_margin,
0.0,
1.0,
));
}
Ok(Self {
mission_name: mission_name.into(),
maneuvers: Vec::new(),
contingency_margin,
})
}
pub fn add_maneuver(&mut self, maneuver: DeltaVManeuver) {
self.maneuvers.push(maneuver);
}
pub fn add(&mut self, name: impl Into<String>, delta_v: f64) -> PoliastroResult<()> {
let maneuver = DeltaVManeuver::new(name, delta_v)?;
self.add_maneuver(maneuver);
Ok(())
}
pub fn add_with_notes(
&mut self,
name: impl Into<String>,
delta_v: f64,
notes: impl Into<String>,
) -> PoliastroResult<()> {
let maneuver = DeltaVManeuver::with_notes(name, delta_v, notes)?;
self.add_maneuver(maneuver);
Ok(())
}
pub fn total_delta_v(&self) -> f64 {
self.maneuvers.iter().map(|m| m.delta_v).sum()
}
pub fn total_with_contingency(&self) -> f64 {
self.total_delta_v() * (1.0 + self.contingency_margin)
}
pub fn propellant_mass(&self, dry_mass: f64, specific_impulse: f64) -> PoliastroResult<f64> {
if !dry_mass.is_finite() || dry_mass <= 0.0 {
return Err(PoliastroError::invalid_parameter(
"dry_mass",
dry_mass,
"must be positive and finite",
));
}
if !specific_impulse.is_finite() || specific_impulse <= 0.0 {
return Err(PoliastroError::invalid_parameter(
"specific_impulse",
specific_impulse,
"must be positive and finite",
));
}
let total_dv = self.total_with_contingency();
let exhaust_velocity = specific_impulse * STANDARD_GRAVITY;
let mass_ratio = (total_dv / exhaust_velocity).exp();
let propellant = dry_mass * (mass_ratio - 1.0);
Ok(propellant)
}
pub fn propellant_fraction(
&self,
dry_mass: f64,
specific_impulse: f64,
) -> PoliastroResult<f64> {
let propellant = self.propellant_mass(dry_mass, specific_impulse)?;
let total_mass = dry_mass + propellant;
Ok(propellant / total_mass)
}
pub fn total_mass(&self, dry_mass: f64, specific_impulse: f64) -> PoliastroResult<f64> {
let propellant = self.propellant_mass(dry_mass, specific_impulse)?;
Ok(dry_mass + propellant)
}
pub fn set_contingency(&mut self, margin: f64) -> PoliastroResult<()> {
if !margin.is_finite() {
return Err(PoliastroError::invalid_parameter(
"margin",
margin,
"must be finite",
));
}
if !(0.0..=1.0).contains(&margin) {
return Err(PoliastroError::out_of_range(
"margin",
margin,
0.0,
1.0,
));
}
self.contingency_margin = margin;
Ok(())
}
pub fn num_maneuvers(&self) -> usize {
self.maneuvers.len()
}
pub fn is_empty(&self) -> bool {
self.maneuvers.is_empty()
}
pub fn clear(&mut self) {
self.maneuvers.clear();
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeltaVBudgetResult {
pub mission_name: String,
pub total_delta_v: f64,
pub contingency_margin: f64,
pub total_with_contingency: f64,
pub num_maneuvers: usize,
pub dry_mass: Option<f64>,
pub specific_impulse: Option<f64>,
pub propellant_mass: Option<f64>,
pub propellant_fraction: Option<f64>,
pub total_mass: Option<f64>,
}
impl DeltaVBudget {
pub fn result(
&self,
dry_mass: Option<f64>,
specific_impulse: Option<f64>,
) -> PoliastroResult<DeltaVBudgetResult> {
let mut result = DeltaVBudgetResult {
mission_name: self.mission_name.clone(),
total_delta_v: self.total_delta_v(),
contingency_margin: self.contingency_margin,
total_with_contingency: self.total_with_contingency(),
num_maneuvers: self.num_maneuvers(),
dry_mass,
specific_impulse,
propellant_mass: None,
propellant_fraction: None,
total_mass: None,
};
if let (Some(dm), Some(isp)) = (dry_mass, specific_impulse) {
result.propellant_mass = Some(self.propellant_mass(dm, isp)?);
result.propellant_fraction = Some(self.propellant_fraction(dm, isp)?);
result.total_mass = Some(self.total_mass(dm, isp)?);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_maneuver_creation() {
let maneuver = DeltaVManeuver::new("Test", 1000.0).unwrap();
assert_eq!(maneuver.name, "Test");
assert_eq!(maneuver.delta_v, 1000.0);
assert_eq!(maneuver.notes, None);
}
#[test]
fn test_maneuver_with_notes() {
let maneuver = DeltaVManeuver::with_notes("Test", 1000.0, "Important").unwrap();
assert_eq!(maneuver.notes, Some("Important".to_string()));
}
#[test]
fn test_maneuver_negative_delta_v() {
let result = DeltaVManeuver::new("Test", -100.0);
assert!(result.is_err());
}
#[test]
fn test_budget_creation() {
let budget = DeltaVBudget::new("Test Mission");
assert_eq!(budget.mission_name, "Test Mission");
assert_eq!(budget.maneuvers.len(), 0);
assert_eq!(budget.contingency_margin, 0.0);
}
#[test]
fn test_budget_with_contingency() {
let budget = DeltaVBudget::with_contingency("Test", 0.15).unwrap();
assert_eq!(budget.contingency_margin, 0.15);
}
#[test]
fn test_add_maneuver() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn 1", 1000.0).unwrap();
budget.add("Burn 2", 500.0).unwrap();
assert_eq!(budget.num_maneuvers(), 2);
assert_eq!(budget.total_delta_v(), 1500.0);
}
#[test]
fn test_contingency_calculation() {
let mut budget = DeltaVBudget::with_contingency("Test", 0.1).unwrap();
budget.add("Burn", 1000.0).unwrap();
assert_eq!(budget.total_delta_v(), 1000.0);
assert_eq!(budget.total_with_contingency(), 1100.0);
}
#[test]
fn test_propellant_mass_calculation() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn", 3000.0).unwrap();
let propellant = budget.propellant_mass(1000.0, 300.0).unwrap();
assert!((propellant - 1772.0).abs() < 10.0);
}
#[test]
fn test_propellant_fraction() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn", 3000.0).unwrap();
let fraction = budget.propellant_fraction(1000.0, 300.0).unwrap();
assert!((fraction - 0.639).abs() < 0.01);
}
#[test]
fn test_leo_to_geo_mission() {
let mut budget = DeltaVBudget::with_contingency("LEO to GEO", 0.1).unwrap();
budget.add("Hohmann transfer burn 1", 2440.0).unwrap();
budget.add("Hohmann transfer burn 2", 1475.0).unwrap();
budget.add("Station keeping (per year)", 50.0).unwrap();
assert_eq!(budget.num_maneuvers(), 3);
let total = budget.total_delta_v();
assert!((total - 3965.0).abs() < 1.0);
let with_margin = budget.total_with_contingency();
assert!((with_margin - 4361.5).abs() < 1.0); }
#[test]
fn test_budget_result_without_propellant() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn", 1000.0).unwrap();
let result = budget.result(None, None).unwrap();
assert_eq!(result.mission_name, "Test");
assert_eq!(result.total_delta_v, 1000.0);
assert_eq!(result.num_maneuvers, 1);
assert!(result.propellant_mass.is_none());
}
#[test]
fn test_budget_result_with_propellant() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn", 1000.0).unwrap();
let result = budget.result(Some(1000.0), Some(300.0)).unwrap();
assert!(result.propellant_mass.is_some());
assert!(result.propellant_fraction.is_some());
assert!(result.total_mass.is_some());
}
#[test]
fn test_clear_budget() {
let mut budget = DeltaVBudget::new("Test");
budget.add("Burn 1", 1000.0).unwrap();
budget.add("Burn 2", 500.0).unwrap();
assert!(!budget.is_empty());
budget.clear();
assert!(budget.is_empty());
assert_eq!(budget.total_delta_v(), 0.0);
}
#[test]
fn test_invalid_contingency() {
let result = DeltaVBudget::with_contingency("Test", 1.5);
assert!(result.is_err());
let result = DeltaVBudget::with_contingency("Test", -0.1);
assert!(result.is_err());
}
}