use crate::command::{apogee, parse, run_trajectory, Row};
#[cfg(feature = "ballistics")]
use crate::Vec3;
pub(crate) fn impact_row(rows: &[Row]) -> Option<Row> {
for pair in rows.windows(2) {
let (a, b) = (pair[0], pair[1]);
if a.y > 0.0 && b.y <= 0.0 {
let fraction = a.y / (a.y - b.y);
return Some(Row {
t: a.t + (b.t - a.t) * fraction,
x: a.x + (b.x - a.x) * fraction,
y: 0.0,
z: a.z + (b.z - a.z) * fraction,
speed: a.speed + (b.speed - a.speed) * fraction,
mass: 0.0,
thrust: 0.0,
phase: "impact",
});
}
}
None
}
struct SplitMix64(u64);
impl SplitMix64 {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn normal(&mut self) -> f64 {
let u1 = self.next_f64().max(1e-300);
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
pub(crate) fn dispersion(args: &[String]) -> Result<String, String> {
let parsed = parse(args)?;
if parsed.runs < 2 {
return Err("error: --runs needs at least 2 samples".to_string());
}
if parsed.cd_sigma < 0.0 || parsed.wind_sigma < 0.0 || parsed.speed_sigma < 0.0 {
return Err("error: sigmas must be nonnegative".to_string());
}
let mut rng = SplitMix64(parsed.seed);
let mut apogees = Vec::with_capacity(parsed.runs);
let mut impacts: Vec<(f64, f64)> = Vec::with_capacity(parsed.runs);
for _ in 0..parsed.runs {
let mut opts = parsed.opts.clone();
#[cfg(feature = "ballistics")]
{
opts.cd_scale *= (parsed.cd_sigma * rng.normal()).exp();
let wind = Vec3::new(
parsed.wind_sigma * rng.normal(),
parsed.wind_sigma * rng.normal(),
parsed.wind_sigma * rng.normal(),
);
opts.wind_mps += wind;
}
#[cfg(not(feature = "ballistics"))]
let _ = &mut rng;
opts.speed_mps *= (1.0 + parsed.speed_sigma * rng.normal()).max(0.0);
let (rows, _) = run_trajectory(&opts, true)?;
let (peak, _) = apogee(&rows).ok_or("error: empty trajectory")?;
apogees.push(peak);
if let Some(impact) = impact_row(&rows) {
impacts.push((impact.x, impact.z));
} else if let Some(last) = rows.last() {
impacts.push((last.x, last.z));
}
}
let (mean_apogee, sd_apogee) = mean_sd(&apogees);
let ranges: Vec<f64> = impacts
.iter()
.map(|(x, z)| (x * x + z * z).sqrt())
.collect();
let (mean_range, sd_range) = mean_sd(&ranges);
let cep = circular_error_probable(&impacts);
Ok(format!(
"runs: {} (seed {})\napogee_m: mean {mean_apogee:.2}, sd {sd_apogee:.2}\nrange_m: mean {mean_range:.2}, sd {sd_range:.2}\nimpact CEP_m: {cep:.2}\n",
parsed.runs, parsed.seed
))
}
fn mean_sd(values: &[f64]) -> (f64, f64) {
if values.is_empty() {
return (0.0, 0.0);
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
if values.len() < 2 {
return (mean, 0.0);
}
let variance =
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (values.len() - 1) as f64;
(mean, variance.sqrt())
}
fn circular_error_probable(impacts: &[(f64, f64)]) -> f64 {
if impacts.is_empty() {
return 0.0;
}
let mean_x = impacts.iter().map(|p| p.0).sum::<f64>() / impacts.len() as f64;
let mean_z = impacts.iter().map(|p| p.1).sum::<f64>() / impacts.len() as f64;
let mut distances: Vec<f64> = impacts
.iter()
.map(|(x, z)| ((x - mean_x).powi(2) + (z - mean_z).powi(2)).sqrt())
.collect();
distances.sort_by(|a, b| a.partial_cmp(b).unwrap());
distances[distances.len() / 2]
}
pub(crate) fn solve_range(args: &[String]) -> Result<String, String> {
let parsed = parse(args)?;
let target = parsed
.target_m
.ok_or("error: solve-range needs a target distance in metres")?;
if target <= 0.0 {
return Err("error: target range must be positive".to_string());
}
let range_at = |elevation: f64| -> Result<f64, String> {
let mut opts = parsed.opts.clone();
opts.elevation_deg = elevation;
let (rows, _) = run_trajectory(&opts, true)?;
impact_row(&rows).map(|r| r.x).ok_or_else(|| {
"error: no ground impact; increase --duration-s or the launch speed".to_string()
})
};
let (lo, hi) = if parsed.high_angle {
(45.0, 89.5)
} else {
(0.5, 45.0)
};
let range_lo = range_at(lo)?;
let range_hi = range_at(hi)?;
let bracketed = if parsed.high_angle {
range_lo >= target && range_hi <= target
} else {
range_lo <= target && range_hi >= target
};
if !bracketed {
return Err(format!(
"error: target {target} m is not bracketed on this branch: {lo}° → {range_lo:.1} m, {hi}° → {range_hi:.1} m"
));
}
let (mut lo, mut hi) = (lo, hi);
let mut mid = 0.0;
for _ in 0..48 {
mid = 0.5 * (lo + hi);
let range = range_at(mid)?;
let overshoots = if parsed.high_angle {
range < target
} else {
range > target
};
if overshoots {
hi = mid;
} else {
lo = mid;
}
if hi - lo < 1e-6 {
break;
}
}
let mut opts = parsed.opts.clone();
opts.elevation_deg = mid;
let (rows, _) = run_trajectory(&opts, false)?;
let (peak, t) = apogee(&rows).ok_or("error: empty trajectory")?;
let impact = impact_row(&rows).ok_or("error: no ground impact")?;
Ok(format!(
"target_range_m: {target:.2}\nelevation_deg: {mid:.4}\nrange_m: {:.2}\napogee_m: {peak:.2} at t = {t:.3} s\nimpact_speed_mps: {:.2}\n",
impact.x, impact.speed
))
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(s: &str) -> Vec<String> {
crate::command::tokenize(s)
}
#[test]
fn dispersion_is_deterministic_per_seed() {
let args = argv(
"dispersion --curve 0:0,0.5:200,1:0 --dry-mass 0.3 --propellant-mass 0.05 \
--elevation-deg 60 --speed-mps 80 --duration-s 20 --dt-s 0.2 --runs 8 --seed 7",
);
let a = dispersion(&args[1..]).unwrap();
let b = dispersion(&args[1..]).unwrap();
assert_eq!(a, b);
assert!(a.contains("runs: 8 (seed 7)"));
assert!(a.contains("impact CEP_m:"));
}
#[test]
fn dispersion_needs_two_runs() {
let err = dispersion(
&argv(
"dispersion --curve 0:0,0.5:200,1:0 --dry-mass 0.3 --propellant-mass 0.05 --runs 1",
)[1..],
)
.unwrap_err();
assert!(err.contains("at least 2"));
}
#[test]
fn solve_range_recovers_the_elevation_used_to_make_the_target() {
let base = "--curve 0:0,0.05:50,0.1:0 --dry-mass 1.0 --propellant-mass 0.01 \
--speed-mps 120 --duration-s 60 --dt-s 0.1";
let mut opts = parse(&argv(&format!("simulate {base} --elevation-deg 30"))[1..])
.unwrap()
.opts;
opts.elevation_deg = 30.0;
let (rows, _) = run_trajectory(&opts, true).unwrap();
let target = impact_row(&rows).unwrap().x;
let out =
solve_range(&argv(&format!("solve-range {target} {base} --elevation-deg 30"))[1..])
.unwrap();
assert!(out.contains("elevation_deg: 30.0"), "got {out}");
assert!(out.contains("range_m:"));
}
#[test]
fn solve_range_rejects_unreachable_targets() {
let err = solve_range(
&argv(
"solve-range 1000000 --curve 0:0,0.05:50,0.1:0 --dry-mass 1.0 \
--propellant-mass 0.01 --speed-mps 120 --duration-s 60 --dt-s 0.1",
)[1..],
)
.unwrap_err();
assert!(err.contains("not bracketed"));
}
}