use crate::constants::{
CORDELL_GAMMA_ENVELOPE_HI, CORDELL_GAMMA_ENVELOPE_LO, CORDELL_MACH_ENVELOPE_HI,
CORDELL_MACH_ENVELOPE_LO, JARVINEN_ADAMS_TRANSITION_PRESSURE_RATIO_LO, real_from_f64,
sibulkin_scaling_coefficient,
};
use crate::{
Length, PhysicsError, PlumeGeometry, Pressure, Temperature, area_mach_ratio_kernel,
isentropic_pressure_ratio_kernel,
};
use deep_causality_algebra::RealField;
use deep_causality_num::FromPrimitive;
const PLUME_SOLVE_ITERATIONS: usize = 200;
const BARREL_SIMPSON_INTERVALS: usize = 2000;
const BARREL_UPPER_LIMIT: f64 = 0.999;
#[inline]
fn lift<R: RealField + FromPrimitive>(x: f64, what: &'static str) -> Result<R, PhysicsError> {
R::from_f64(x).ok_or_else(|| PhysicsError::NumericalInstability(what.into()))
}
pub fn prandtl_meyer_kernel<R>(mach: R, gamma: R) -> Result<R, PhysicsError>
where
R: RealField + FromPrimitive,
{
let one = R::one();
if mach < one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Prandtl-Meyer function requires Mach >= 1".into(),
));
}
if gamma <= one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Ratio of specific heats must be > 1".into(),
));
}
let a = ((gamma + one) / (gamma - one)).sqrt();
let m2m1 = mach * mach - one;
Ok(a * (m2m1.sqrt() / a).atan() - m2m1.sqrt().atan())
}
pub fn choked_mass_flow_kernel<R>(
throat_area: crate::Area<R>,
chamber_pressure: Pressure<R>,
chamber_temperature: Temperature<R>,
gamma: R,
r_specific: R,
) -> Result<crate::MassFlowRate<R>, PhysicsError>
where
R: RealField + FromPrimitive,
{
let one = R::one();
if gamma <= one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Ratio of specific heats must be > 1".into(),
));
}
let a_star = throat_area.value();
let p0 = chamber_pressure.value();
let t0 = chamber_temperature.value();
if a_star <= R::zero() || p0 <= R::zero() || t0 <= R::zero() || r_specific <= R::zero() {
return Err(PhysicsError::Singularity(
"Throat area, chamber state, and gas constant must be positive".into(),
));
}
let two: R = lift(2.0, "R::from_f64(2.0) failed")?;
let exponent = (gamma + one) / (two * (gamma - one));
let factor = (two / (gamma + one)).powf(exponent);
crate::MassFlowRate::new(a_star * p0 * (gamma / (r_specific * t0)).sqrt() * factor)
}
fn normal_shock_total_pressure_ratio<R>(mach: R, gamma: R) -> Result<R, PhysicsError>
where
R: RealField + FromPrimitive,
{
let one = R::one();
let two: R = lift(2.0, "R::from_f64(2.0) failed")?;
let m2 = mach * mach;
let a = ((gamma + one) * m2 / ((gamma - one) * m2 + two)).powf(gamma / (gamma - one));
let b = ((gamma + one) / (two * gamma * m2 - (gamma - one))).powf(one / (gamma - one));
Ok(a * b)
}
pub fn srp_terminal_shock_mach_kernel<R>(
chamber_pressure: Pressure<R>,
post_bow_shock_total_pressure: Pressure<R>,
gamma_jet: R,
) -> Result<R, PhysicsError>
where
R: RealField + FromPrimitive,
{
if gamma_jet <= R::one() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Ratio of specific heats must be > 1".into(),
));
}
let pt_jet = chamber_pressure.value();
let pt_1 = post_bow_shock_total_pressure.value();
if pt_jet <= R::zero() || pt_1 <= R::zero() {
return Err(PhysicsError::Singularity(
"Stagnation pressures must be positive".into(),
));
}
let target = pt_1 / pt_jet;
if target >= R::one() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Jet stagnation pressure must exceed the post-bow-shock stagnation pressure for a \
terminal shock to form (the low-thrust regime is outside the model)"
.into(),
));
}
let half: R = lift(0.5, "R::from_f64(0.5) failed")?;
let mut lo = R::one() + lift(1.0e-9, "R::from_f64(1e-9) failed")?;
let mut hi = lift(1000.0, "R::from_f64(1000.0) failed")?;
if normal_shock_total_pressure_ratio(hi, gamma_jet)? > target {
return Err(PhysicsError::NumericalInstability(
"srp_terminal_shock_mach_kernel: terminal Mach exceeds the solve bracket".into(),
));
}
for _ in 0..PLUME_SOLVE_ITERATIONS {
let mid = (lo + hi) * half;
if mid <= lo || mid >= hi {
break;
}
if normal_shock_total_pressure_ratio(mid, gamma_jet)? > target {
lo = mid;
} else {
hi = mid;
}
}
Ok((lo + hi) * half)
}
pub fn srp_post_bow_shock_total_pressure_kernel<R>(
p_inf: Pressure<R>,
mach_inf: R,
gamma_inf: R,
) -> Result<Pressure<R>, PhysicsError>
where
R: RealField + FromPrimitive,
{
if mach_inf <= R::one() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Freestream must be supersonic for a bow shock".into(),
));
}
let pt_inf = p_inf.value() * isentropic_pressure_ratio_kernel(mach_inf, gamma_inf)?;
let loss = normal_shock_total_pressure_ratio(mach_inf, gamma_inf)?;
Pressure::new(pt_inf * loss)
}
pub fn srp_jet_edge_mach_kernel<R>(
exit_mach: R,
exit_pressure: Pressure<R>,
post_bow_shock_total_pressure: Pressure<R>,
gamma_jet: R,
) -> Result<R, PhysicsError>
where
R: RealField + FromPrimitive,
{
let one = R::one();
if exit_mach < one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Nozzle exit flow must be supersonic".into(),
));
}
if gamma_jet <= one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Ratio of specific heats must be > 1".into(),
));
}
let p_exit = exit_pressure.value();
let pt_1 = post_bow_shock_total_pressure.value();
if p_exit <= R::zero() || pt_1 <= R::zero() {
return Err(PhysicsError::Singularity(
"Pressures must be positive".into(),
));
}
let two: R = lift(2.0, "R::from_f64(2.0) failed")?;
let half: R = lift(0.5, "R::from_f64(0.5) failed")?;
let stag = one + (gamma_jet - one) * half * exit_mach * exit_mach;
let val = stag * (pt_1 / p_exit).powf((one - gamma_jet) / gamma_jet) - one;
if val <= R::zero() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Backpressure too high: the jet does not expand supersonically at its edge".into(),
));
}
Ok((two / (gamma_jet - one) * val).sqrt())
}
#[allow(clippy::too_many_arguments)]
pub fn cordell_braun_plume_boundary_kernel<R>(
chamber_pressure: Pressure<R>,
chamber_temperature: Temperature<R>,
r_specific: R,
gamma_jet: R,
exit_mach: R,
nozzle_half_angle_rad: R,
throat_diameter: Length<R>,
exit_radius: Length<R>,
cone_length: Length<R>,
p_inf: Pressure<R>,
mach_inf: R,
gamma_inf: R,
) -> Result<PlumeGeometry<R>, PhysicsError>
where
R: RealField + FromPrimitive,
{
let one = R::one();
let two: R = lift(2.0, "R::from_f64(2.0) failed")?;
let half: R = lift(0.5, "R::from_f64(0.5) failed")?;
let m_lo: R = real_from_f64(CORDELL_MACH_ENVELOPE_LO);
let m_hi: R = real_from_f64(CORDELL_MACH_ENVELOPE_HI);
if mach_inf < m_lo || mach_inf > m_hi {
return Err(PhysicsError::PhysicalInvariantBroken(
"Freestream Mach outside the Cordell model's validated envelope [2, 4]".into(),
));
}
let g_lo: R = real_from_f64(CORDELL_GAMMA_ENVELOPE_LO);
let g_hi: R = real_from_f64(CORDELL_GAMMA_ENVELOPE_HI);
if gamma_jet < g_lo || gamma_jet > g_hi {
return Err(PhysicsError::PhysicalInvariantBroken(
"Jet gamma outside the Cordell model's validated envelope [1.2, 1.4]".into(),
));
}
if exit_mach < one {
return Err(PhysicsError::PhysicalInvariantBroken(
"Nozzle exit flow must be supersonic".into(),
));
}
if nozzle_half_angle_rad < R::zero() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Nozzle half-angle cannot be negative".into(),
));
}
let d_throat = throat_diameter.value();
let r_exit = exit_radius.value();
let l_cone = cone_length.value();
if d_throat <= R::zero() || r_exit <= R::zero() || l_cone < R::zero() {
return Err(PhysicsError::Singularity(
"Nozzle geometry must be positive".into(),
));
}
if chamber_temperature.value() <= R::zero() || r_specific <= R::zero() {
return Err(PhysicsError::Singularity(
"Chamber temperature and gas constant must be positive".into(),
));
}
let pt_jet = chamber_pressure.value();
let p_exit = pt_jet / isentropic_pressure_ratio_kernel(exit_mach, gamma_jet)?;
let transition: R = real_from_f64(JARVINEN_ADAMS_TRANSITION_PRESSURE_RATIO_LO);
if p_exit / p_inf.value() < transition {
return Err(PhysicsError::PhysicalInvariantBroken(
"Jet exit pressure ratio below the blunt-flow transition (P_exit/P_inf < 7): the \
unsteady jet-penetration regime is outside the model"
.into(),
));
}
let pt_1 = srp_post_bow_shock_total_pressure_kernel(p_inf, mach_inf, gamma_inf)?;
let m_terminal = srp_terminal_shock_mach_kernel(chamber_pressure, pt_1, gamma_jet)?;
let nu_vacuum = {
let pi_: R = lift(core::f64::consts::PI, "R::from_f64(pi) failed")?;
pi_ * half * (((gamma_jet + one) / (gamma_jet - one)).sqrt() - one)
};
let nu_exit = prandtl_meyer_kernel(exit_mach, gamma_jet)?;
let theta_max = nu_vacuum - nu_exit + nozzle_half_angle_rad; if theta_max <= R::zero() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Maximum jet turn angle must be positive".into(),
));
}
let pi_: R = lift(core::f64::consts::PI, "R::from_f64(pi) failed")?;
let psi_solid = two * pi_ * (one - theta_max.cos()); let b_sibulkin = sibulkin_scaling_coefficient::<R>() * pi_ / psi_solid;
let p_terminal = pt_jet / isentropic_pressure_ratio_kernel(m_terminal, gamma_jet)?;
let rho_ratio = (p_terminal / pt_jet).powf(one / gamma_jet); let x_terminal_throat = d_throat * (b_sibulkin / rho_ratio).sqrt(); let standoff_exit = x_terminal_throat - l_cone;
if standoff_exit <= R::zero() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Terminal shock inside the nozzle: the thrust is too low for the model".into(),
));
}
let m_edge = srp_jet_edge_mach_kernel(exit_mach, Pressure::new(p_exit)?, pt_1, gamma_jet)?;
let ar_edge = area_mach_ratio_kernel(m_edge, gamma_jet)?;
let ar_exit = area_mach_ratio_kernel(exit_mach, gamma_jet)?;
let psi_charwat = two / (gamma_jet + one) * (m_edge * m_edge - one).sqrt()
/ (m_edge * m_edge * ar_edge)
* ar_exit; let nu_edge = prandtl_meyer_kernel(m_edge, gamma_jet)?;
let theta_0 = nu_edge - nu_exit + nozzle_half_angle_rad; if theta_0 <= R::zero() || psi_charwat <= R::zero() {
return Err(PhysicsError::PhysicalInvariantBroken(
"Charwat shape parameters must be positive".into(),
));
}
let rho_a = (one + theta_0 / psi_charwat).sqrt(); let phi = psi_charwat + theta_0; if phi >= pi_ {
return Err(PhysicsError::NumericalInstability(
"Charwat shape angle exceeds pi: cot integrand undefined".into(),
));
}
let upper: R = lift(BARREL_UPPER_LIMIT, "R::from_f64(0.999) failed")?;
let a_lim = one / rho_a;
if a_lim >= upper {
return Err(PhysicsError::NumericalInstability(
"Degenerate barrel-shock bracket".into(),
));
}
let n = BARREL_SIMPSON_INTERVALS;
let n_r: R = lift(n as f64, "R::from_f64(N) failed")?;
let h = (upper - a_lim) / n_r;
let cot = |r: R| -> Result<R, PhysicsError> {
let arg = phi * (one - r * r);
let s = arg.sin();
if s <= R::zero() {
return Err(PhysicsError::NumericalInstability(
"cot argument left (0, pi) in the barrel integral".into(),
));
}
Ok(arg.cos() / s)
};
let mut sum = cot(a_lim)? + cot(upper)?;
let four: R = lift(4.0, "R::from_f64(4.0) failed")?;
let mut r_i = a_lim;
for i in 1..n {
r_i += h;
let w = if i % 2 == 1 { four } else { two };
sum += w * cot(r_i)?;
}
let three: R = lift(3.0, "R::from_f64(3.0) failed")?;
let x_apex_nd = sum * h / three;
let r_max_raw = upper * rho_a * r_exit;
let x_apex = x_apex_nd * rho_a * r_exit;
let rho_t_jet = pt_jet / (r_specific * chamber_temperature.value()); let a_star = pi_ * d_throat * d_throat / four;
let mdot_in = choked_mass_flow_kernel(
crate::Area::new(a_star)?,
chamber_pressure,
chamber_temperature,
gamma_jet,
r_specific,
)?
.value();
let rho_terminal = rho_ratio * rho_t_jet;
let v_terminal = m_terminal * (gamma_jet * p_terminal / rho_terminal).sqrt();
const CURVE_POINTS: usize = 400;
let np: R = lift(CURVE_POINTS as f64, "R::from_f64(points) failed")?;
let dr_step = (upper - a_lim) / np;
let mut xs: alloc::vec::Vec<R> = alloc::vec::Vec::with_capacity(CURVE_POINTS + 2);
let mut rs: alloc::vec::Vec<R> = alloc::vec::Vec::with_capacity(CURVE_POINTS + 2);
xs.push(R::zero());
rs.push(a_lim * rho_a * r_exit);
let mut acc = R::zero();
let mut f_prev = cot(a_lim)?;
for i in 1..=CURVE_POINTS {
let r_now = a_lim + dr_step * lift(i as f64, "R::from_f64(i) failed")?;
let f_now = cot(r_now)?;
acc += (f_prev + f_now) * half * dr_step;
xs.push(acc * rho_a * r_exit);
rs.push(r_now * rho_a * r_exit);
f_prev = f_now;
}
let x_end = if standoff_exit > xs[CURVE_POINTS] {
standoff_exit
} else {
xs[CURVE_POINTS]
};
xs.push(x_end);
rs.push(rs[CURVE_POINTS]);
let last = CURVE_POINTS + 1;
let r_term_raw = rs[last];
let mdot_total = |c: R| -> Result<R, PhysicsError> {
let m_term = rho_terminal * v_terminal * pi_ * (c * r_term_raw) * (c * r_term_raw);
let mut m_bar = R::zero();
for i in 1..last {
let x = xs[i];
let r = rs[i];
let dx = (xs[i + 1] - xs[i - 1]) * half;
let dr = (rs[i + 1] - rs[i - 1]) * half;
let d = ((x + l_cone) * (x + l_cone) + (c * r) * (c * r)).sqrt(); let rr = b_sibulkin * (d_throat / d) * (d_throat / d); if rr >= one {
continue; }
let m_i = (two / (gamma_jet - one) * (rr.powf(-(gamma_jet - one)) - one)).sqrt(); let p_i = pt_jet / isentropic_pressure_ratio_kernel(m_i, gamma_jet)?;
let rho_i = rr * rho_t_jet;
let v_i = m_i * (gamma_jet * p_i / rho_i).sqrt(); let norm = ((c * dr) * (c * dr) + dx * dx).sqrt();
if norm <= R::zero() {
continue;
}
let v_dot_a = v_i / d * (dx / norm) * (c * r * dx - (x + l_cone) * c * dr);
m_bar += two * pi_ * c * r * rho_i * v_dot_a; }
Ok(m_term + m_bar) };
let mut c_lo = lift(0.2, "R::from_f64(0.2) failed")?;
let mut c_hi = lift(10.0, "R::from_f64(10.0) failed")?;
if mdot_total(c_lo)? > mdot_in || mdot_total(c_hi)? < mdot_in {
return Err(PhysicsError::NumericalInstability(
"Mass-flow scaling parameter outside the solve bracket".into(),
));
}
for _ in 0..PLUME_SOLVE_ITERATIONS {
let mid = (c_lo + c_hi) * half;
if mid <= c_lo || mid >= c_hi {
break;
}
if mdot_total(mid)? < mdot_in {
c_lo = mid;
} else {
c_hi = mid;
}
}
let c_scale = (c_lo + c_hi) * half;
let max_radius = Length::new(c_scale * r_max_raw)?;
let penetration = if x_apex > standoff_exit {
x_apex
} else {
standoff_exit
};
Ok(PlumeGeometry::new(
max_radius,
Length::new(penetration)?,
Length::new(standoff_exit)?,
))
}