use astrodyn_quantities::aliases::{Force, Position};
use glam::DVec3;
#[cfg(test)]
use astrodyn_quantities::ext::Vec3Ext;
use astrodyn_quantities::frame::{RootInertial, StructuralFrame, Vehicle};
use uom::si::f64::{Area, Ratio};
pub const SOLAR_LUMINOSITY: f64 = 3.827e26;
pub const SOLAR_RADIUS: f64 = 6.98e8;
pub const SPEED_OF_LIGHT: f64 = 299_792_458.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RadiationForce {
pub force: DVec3,
pub torque: DVec3,
}
impl Default for RadiationForce {
fn default() -> Self {
Self {
force: DVec3::ZERO,
torque: DVec3::ZERO,
}
}
}
const TWO_THIRDS: f64 = 2.0 / 3.0;
#[derive(Debug, Clone, Copy)]
pub struct FlatPlate<V: Vehicle> {
pub area: f64,
pub normal: DVec3,
pub position: Position<StructuralFrame<V>>,
}
impl<V: Vehicle> FlatPlate<V> {
#[inline]
pub fn assert_vehicle<W: Vehicle>(self) -> Self
where
(): astrodyn_quantities::diagnostics::CompatibleVehicles<V, W>,
{
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct FlatPlateParams {
pub albedo: f64,
pub diffuse: f64,
}
pub fn compute_flat_plate_srp<V: Vehicle>(
plates: &[(FlatPlate<V>, FlatPlateParams)],
flux_struct_hat: DVec3,
flux_mag: f64,
center_grav: DVec3,
illum_factor: f64,
) -> RadiationForce {
if illum_factor <= 0.0 || flux_mag <= 0.0 {
return RadiationForce::default();
}
let effective_flux = flux_mag * illum_factor;
let mut total_force = DVec3::ZERO;
let mut total_torque = DVec3::ZERO;
for (i, (plate, params)) in plates.iter().enumerate() {
assert!(
plate.area > 0.0,
"FlatPlate.area must be > 0 (got {} for plate index {}); set a positive area in m^2",
plate.area,
i,
);
let sin_theta = -plate.normal.dot(flux_struct_hat);
if sin_theta <= 0.0 {
continue; }
let cx_area = plate.area * sin_theta;
let areaxflux = cx_area * effective_flux / SPEED_OF_LIGHT;
let f_absorption = flux_struct_hat * (areaxflux * (1.0 - params.albedo));
let ref_flux = areaxflux * params.albedo;
let f_diffuse = (flux_struct_hat - TWO_THIRDS * plate.normal) * (params.diffuse * ref_flux);
let f_specular = plate.normal * (2.0 * (params.diffuse - 1.0) * ref_flux * sin_theta);
let plate_force = f_absorption + f_diffuse + f_specular;
let crot_to_cp = plate.position.raw_si() - center_grav;
let plate_torque = crot_to_cp.cross(plate_force);
total_force += plate_force;
total_torque += plate_torque;
}
RadiationForce {
force: total_force,
torque: total_torque,
}
}
pub const STEFAN_BOLTZMANN: f64 = 5.6704004e-8;
#[derive(Debug, Clone, Copy)]
pub struct FlatPlateThermal {
pub emissivity: f64,
pub heat_capacity_per_area: f64,
pub thermal_power_dump: f64,
}
#[derive(Debug, Clone, Default)]
pub struct ThermalConductionMatrix {
pub links: Vec<(usize, usize, f64)>,
}
pub struct FlatPlateSrpResult {
pub force: DVec3,
pub torque: DVec3,
pub temp_dots: Vec<f64>,
}
pub fn compute_flat_plate_srp_thermal<V: Vehicle>(
plates: &[(FlatPlate<V>, FlatPlateParams, FlatPlateThermal)],
t_pow4_cached: &[f64],
flux_struct_hat: DVec3,
flux_mag: f64,
center_grav: DVec3,
illum_factor: f64,
) -> FlatPlateSrpResult {
compute_flat_plate_srp_thermal_conduction(
plates,
t_pow4_cached,
None, flux_struct_hat,
flux_mag,
center_grav,
illum_factor,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn compute_flat_plate_srp_thermal_conduction<V: Vehicle>(
plates: &[(FlatPlate<V>, FlatPlateParams, FlatPlateThermal)],
t_pow4_cached: &[f64],
temperatures: Option<&[f64]>,
flux_struct_hat: DVec3,
flux_mag: f64,
center_grav: DVec3,
illum_factor: f64,
conduction: Option<&ThermalConductionMatrix>,
) -> FlatPlateSrpResult {
let n = plates.len();
assert_eq!(n, t_pow4_cached.len());
let effective_flux = if illum_factor > 0.0 && flux_mag > 0.0 {
flux_mag * illum_factor
} else {
0.0
};
let mut total_force = DVec3::ZERO;
let mut total_torque = DVec3::ZERO;
let mut power_absorbs = vec![0.0_f64; n];
for (i, (plate, params, thermal)) in plates.iter().enumerate() {
assert!(
plate.area > 0.0,
"FlatPlate.area must be > 0 (got {} for plate index {}); set a positive area in m^2",
plate.area,
i,
);
assert!(
thermal.emissivity > 0.0,
"FlatPlateThermal.emissivity must be > 0 (got {} for plate index {})",
thermal.emissivity,
i,
);
let sin_theta = -plate.normal.dot(flux_struct_hat);
let illuminated = sin_theta > 0.0 && effective_flux > 0.0;
if illuminated {
power_absorbs[i] = (1.0 - params.albedo) * plate.area * sin_theta * effective_flux;
}
power_absorbs[i] += thermal.thermal_power_dump;
}
if let Some(cond) = conduction {
let temps =
temperatures.expect("temperatures must be provided when conduction matrix is present");
assert_eq!(temps.len(), n);
for &(i, j, conductance) in &cond.links {
assert!(i < n && j < n, "conduction link indices out of range");
let cond_calc = conductance * (temps[j] - temps[i]);
power_absorbs[i] += cond_calc;
power_absorbs[j] -= cond_calc;
}
}
let mut temp_dots = vec![0.0; n];
for (i, (plate, params, thermal)) in plates.iter().enumerate() {
let sin_theta = -plate.normal.dot(flux_struct_hat);
let illuminated = sin_theta > 0.0 && effective_flux > 0.0;
let mut plate_force = DVec3::ZERO;
if illuminated {
let cx_area = plate.area * sin_theta;
let areaxflux = cx_area * effective_flux / SPEED_OF_LIGHT;
let f_absorption = flux_struct_hat * (areaxflux * (1.0 - params.albedo));
let ref_flux = areaxflux * params.albedo;
let f_diffuse =
(flux_struct_hat - TWO_THIRDS * plate.normal) * (params.diffuse * ref_flux);
let f_specular = plate.normal * (2.0 * (params.diffuse - 1.0) * ref_flux * sin_theta);
plate_force = f_absorption + f_diffuse + f_specular;
}
let rad_constant = plate.area * thermal.emissivity * STEFAN_BOLTZMANN;
let power_emit = rad_constant * t_pow4_cached[i];
let heat_capacity = thermal.heat_capacity_per_area * plate.area;
if heat_capacity > 0.0 {
temp_dots[i] = (power_absorbs[i] - power_emit) / heat_capacity;
}
let f_emission = -(TWO_THIRDS * power_emit / SPEED_OF_LIGHT) * plate.normal;
plate_force += f_emission;
let crot_to_cp = plate.position.raw_si() - center_grav;
let plate_torque = crot_to_cp.cross(plate_force);
total_force += plate_force;
total_torque += plate_torque;
}
FlatPlateSrpResult {
force: total_force,
torque: total_torque,
temp_dots,
}
}
pub fn compute_cannonball_srp(
body_pos: DVec3,
sun_pos: DVec3,
cx_area: f64,
albedo: f64,
diffuse: f64,
illum_factor: f64,
) -> DVec3 {
let sun_to_vehicle = body_pos - sun_pos;
let distance = sun_to_vehicle.length();
if distance < 1.0 {
return DVec3::ZERO;
}
let flux_hat = sun_to_vehicle / distance;
let flux_mag = solar_flux_at_distance(distance);
let coeff = 1.0 + albedo * diffuse * (4.0 / 9.0);
let force_mag = cx_area * flux_mag / SPEED_OF_LIGHT * coeff * illum_factor;
flux_hat * force_mag
}
pub fn compute_cannonball_srp_typed(
body_pos: Position<RootInertial>,
sun_pos: Position<RootInertial>,
cx_area: Area,
albedo: Ratio,
diffuse: Ratio,
illum_factor: Ratio,
) -> Force<RootInertial> {
let force = compute_cannonball_srp(
body_pos.raw_si(),
sun_pos.raw_si(),
cx_area.value,
albedo.value,
diffuse.value,
illum_factor.value,
);
Force::<RootInertial>::from_raw_si(force)
}
pub fn integrate_plate_temperature_euler(
old_temp: f64,
old_t_pow4: f64,
temp_dot: f64,
area: f64,
emissivity: f64,
heat_capacity_per_area: f64,
dt: f64,
) -> (f64, f64) {
let rad_constant = area * emissivity * STEFAN_BOLTZMANN;
let heat_cap = heat_capacity_per_area * area;
if heat_cap <= 0.0 {
return (old_temp, old_t_pow4);
}
let power_absorb = temp_dot * heat_cap + rad_constant * old_t_pow4;
let new_temp = (old_temp + temp_dot * dt).max(0.0);
let new_t_pow4 = new_temp * new_temp * new_temp * new_temp;
if rad_constant > 0.0 {
let t_eq_pow4 = power_absorb / rad_constant;
if temp_dot * (t_eq_pow4 - new_t_pow4) < 0.0 {
let clamped_t_pow4 = t_eq_pow4.max(0.0);
return (clamped_t_pow4.sqrt().sqrt(), clamped_t_pow4);
}
}
(new_temp, new_t_pow4)
}
#[allow(clippy::too_many_arguments)]
pub fn integrate_plate_temperature_rk4(
temp0: f64,
t_pow4_0: f64,
k1: f64,
k2: f64,
k3: f64,
k4: f64,
area: f64,
emissivity: f64,
heat_capacity_per_area: f64,
dt: f64,
) -> (f64, f64) {
let sixth_dt = dt / 6.0;
let combined_tdot = k1 + 2.0 * k2 + 2.0 * k3 + k4;
let mut new_temp = (temp0 + combined_tdot * sixth_dt).max(0.0);
let mut new_t_pow4 = new_temp * new_temp * new_temp * new_temp;
let rad_constant = area * emissivity * STEFAN_BOLTZMANN;
if rad_constant > 0.0 {
let heat_cap = heat_capacity_per_area * area;
if heat_cap > 0.0 {
let power_absorb = k1 * heat_cap + rad_constant * t_pow4_0;
let t_eq_pow4 = power_absorb / rad_constant;
if k1 * (t_eq_pow4 - new_t_pow4) < 0.0 {
new_t_pow4 = t_eq_pow4.max(0.0);
new_temp = new_t_pow4.sqrt().sqrt();
}
}
}
(new_temp, new_t_pow4)
}
pub fn solar_flux_at_distance(distance: f64) -> f64 {
if distance < 1.0 {
return 0.0;
}
SOLAR_LUMINOSITY / (4.0 * std::f64::consts::PI * distance * distance)
}
#[cfg(test)]
mod tests {
use super::*;
use astrodyn_quantities::frame::SelfRef;
use std::f64::consts::PI;
#[test]
fn compute_cannonball_srp_typed_matches_untyped() {
use uom::si::area::square_meter;
use uom::si::ratio::ratio;
let au = 1.496e11;
let body = DVec3::new(au, 0.0, 0.0);
let sun = DVec3::ZERO;
let cx_area = 4.5;
let albedo = 0.3;
let diffuse = 0.5;
let illum = 1.0;
let untyped = compute_cannonball_srp(body, sun, cx_area, albedo, diffuse, illum);
let typed = compute_cannonball_srp_typed(
Position::<RootInertial>::from_raw_si(body),
Position::<RootInertial>::from_raw_si(sun),
Area::new::<square_meter>(cx_area),
Ratio::new::<ratio>(albedo),
Ratio::new::<ratio>(diffuse),
Ratio::new::<ratio>(illum),
);
assert_eq!(typed.raw_si(), untyped);
let coincident = DVec3::new(0.5, 0.0, 0.0); let untyped_zero = compute_cannonball_srp(coincident, sun, cx_area, albedo, diffuse, illum);
let typed_zero = compute_cannonball_srp_typed(
Position::<RootInertial>::from_raw_si(coincident),
Position::<RootInertial>::from_raw_si(sun),
Area::new::<square_meter>(cx_area),
Ratio::new::<ratio>(albedo),
Ratio::new::<ratio>(diffuse),
Ratio::new::<ratio>(illum),
);
assert_eq!(typed_zero.raw_si(), untyped_zero);
assert_eq!(typed_zero.raw_si(), DVec3::ZERO);
}
#[test]
fn pressure_at_1au() {
let au = 1.496e11; let flux_1au = SOLAR_LUMINOSITY / (4.0 * PI * au * au);
let pressure = flux_1au / SPEED_OF_LIGHT;
assert!(
(pressure - 4.56e-6).abs() < 0.05e-6,
"SRP at 1 AU should be ~4.56e-6 N/m², got {pressure}"
);
}
#[test]
fn flat_plate_normal_to_flux() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::new(-1.0, 0.0, 0.0), position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.0,
diffuse: 0.0,
}; let flux_hat = DVec3::new(1.0, 0.0, 0.0); let flux_mag = 1000.0;
let result =
compute_flat_plate_srp(&[(plate, params)], flux_hat, flux_mag, DVec3::ZERO, 1.0);
let expected_force = 10.0 * 1000.0 / SPEED_OF_LIGHT;
assert!(
(result.force.x - expected_force).abs() < 1e-20,
"Force X: expected {expected_force}, got {}",
result.force.x
);
assert!(result.force.y.abs() < 1e-30);
assert!(result.force.z.abs() < 1e-30);
}
#[test]
fn flat_plate_facing_away() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0), position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let flux_hat = DVec3::new(1.0, 0.0, 0.0);
let result = compute_flat_plate_srp(&[(plate, params)], flux_hat, 1000.0, DVec3::ZERO, 1.0);
assert_eq!(
result.force,
DVec3::ZERO,
"Back-facing plate should produce no force"
);
}
#[test]
fn flat_plate_specular_reflection() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::new(-1.0, 0.0, 0.0),
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 1.0,
diffuse: 0.0,
};
let flux_hat = DVec3::new(1.0, 0.0, 0.0);
let flux_mag = 1000.0;
let result =
compute_flat_plate_srp(&[(plate, params)], flux_hat, flux_mag, DVec3::ZERO, 1.0);
let areaxflux = 10.0 * 1000.0 / SPEED_OF_LIGHT;
assert!(
(result.force.x - 2.0 * areaxflux).abs() < 1e-20,
"Specular: expected {}, got {}",
2.0 * areaxflux,
result.force.x
);
}
#[test]
fn flat_plate_torque_from_offset() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::new(-1.0, 0.0, 0.0),
position: DVec3::new(0.0, 2.0, 0.0).m_at::<StructuralFrame<SelfRef>>(), };
let params = FlatPlateParams {
albedo: 0.0,
diffuse: 0.0,
};
let flux_hat = DVec3::new(1.0, 0.0, 0.0);
let cg = DVec3::ZERO;
let result = compute_flat_plate_srp(&[(plate, params)], flux_hat, 1000.0, cg, 1.0);
assert!(result.torque.z < 0.0, "Torque Z should be negative");
assert!(result.torque.x.abs() < 1e-30);
assert!(result.torque.y.abs() < 1e-30);
}
#[test]
fn flat_plate_shadow_scaling() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::new(-1.0, 0.0, 0.0),
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let flux_hat = DVec3::new(1.0, 0.0, 0.0);
let full = compute_flat_plate_srp(&[(plate, params)], flux_hat, 1000.0, DVec3::ZERO, 1.0);
let half = compute_flat_plate_srp(&[(plate, params)], flux_hat, 1000.0, DVec3::ZERO, 0.5);
let ratio = half.force.length() / full.force.length();
assert!(
(ratio - 0.5).abs() < 1e-12,
"Half shadow should give half force, ratio = {ratio}"
);
}
#[test]
fn thermal_emission_opposes_normal() {
let plate = FlatPlate {
area: 60.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let t_pow4 = [270.0_f64.powi(4)];
let result = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&t_pow4,
DVec3::X,
0.0, DVec3::ZERO,
1.0,
);
assert!(
result.force.x < 0.0,
"Emission should push in -normal direction"
);
assert!(result.force.y.abs() < 1e-30);
assert!(result.force.z.abs() < 1e-30);
}
#[test]
fn thermal_emission_magnitude() {
let plate = FlatPlate {
area: 60.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let t_pow4 = [270.0_f64.powi(4)];
let result = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&t_pow4,
DVec3::X,
0.0,
DVec3::ZERO,
1.0,
);
let power_emit = 0.5 * STEFAN_BOLTZMANN * 60.0 * 270.0_f64.powi(4);
let expected = TWO_THIRDS * power_emit / SPEED_OF_LIGHT;
let actual = result.force.length();
let rel_err = (actual - expected).abs() / expected;
assert!(
rel_err < 1e-10,
"Emission force: expected {expected:.6e}, got {actual:.6e}, rel_err={rel_err:.2e}"
);
}
#[test]
fn thermal_temperature_cools_in_shadow() {
let plate = FlatPlate {
area: 60.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let t_pow4 = [270.0_f64.powi(4)];
let result = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&t_pow4,
DVec3::X,
0.0,
DVec3::ZERO,
0.0,
);
assert!(
result.temp_dots[0] < 0.0,
"temp_dot should be negative when not illuminated, got {}",
result.temp_dots[0]
);
}
#[test]
fn thermal_increases_total_force() {
let plate = FlatPlate {
area: 60.0,
normal: -DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let flux_hat = DVec3::X;
let flux_mag = 1400.0;
let no_thermal =
compute_flat_plate_srp(&[(plate, params)], flux_hat, flux_mag, DVec3::ZERO, 1.0);
let t_pow4 = [270.0_f64.powi(4)];
let with_thermal = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&t_pow4,
flux_hat,
flux_mag,
DVec3::ZERO,
1.0,
);
assert!(
with_thermal.force.length() > no_thermal.force.length(),
"Thermal emission should increase total force: with={:.6e} vs without={:.6e}",
with_thermal.force.length(),
no_thermal.force.length()
);
}
#[test]
fn sim3_orbit_six_plate_identity_attitude() {
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let plates: Vec<(FlatPlate<SelfRef>, FlatPlateParams)> = vec![
(
FlatPlate {
area: 60.0,
normal: DVec3::X,
position: DVec3::new(2.0, 0.0, 0.0).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
(
FlatPlate {
area: 60.0,
normal: -DVec3::Y,
position: DVec3::new(0.0, -2.0, 0.0).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
(
FlatPlate {
area: 60.0,
normal: -DVec3::X,
position: DVec3::new(-2.0, 0.0, 0.0).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
(
FlatPlate {
area: 60.0,
normal: DVec3::Y,
position: DVec3::new(0.0, 2.0, 0.0).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
(
FlatPlate {
area: 16.0,
normal: DVec3::Z,
position: DVec3::new(0.0, 0.0, 7.5).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
(
FlatPlate {
area: 16.0,
normal: -DVec3::Z,
position: DVec3::new(0.0, 0.0, -7.5).m_at::<StructuralFrame<SelfRef>>(),
},
params,
),
];
let flux_hat = DVec3::X;
let flux_mag = 1000.0;
let result = compute_flat_plate_srp(&plates, flux_hat, flux_mag, DVec3::ZERO, 1.0);
assert!(result.force.length() > 0.0, "Should have non-zero force");
assert!(
result.force.x > 0.0,
"Force should push in +X (away from source)"
);
}
#[test]
fn euler_cooling() {
let (new_temp, new_t_pow4) = integrate_plate_temperature_euler(
300.0, 300.0_f64.powi(4),
-5.0, 60.0, 0.5, 50.0, 10.0, );
assert!(new_temp < 300.0, "Should cool: got {new_temp}");
assert!(new_t_pow4 < 300.0_f64.powi(4));
}
#[test]
fn euler_non_negative_temperature() {
let (new_temp, new_t_pow4) = integrate_plate_temperature_euler(
10.0,
10.0_f64.powi(4),
-100.0, 60.0,
0.5,
50.0,
1.0,
);
assert!(
new_temp >= 0.0,
"Temperature must not go negative: got {new_temp}"
);
assert!(new_t_pow4 >= 0.0);
}
#[test]
fn euler_zero_heat_capacity() {
let (new_temp, new_t_pow4) = integrate_plate_temperature_euler(
300.0,
300.0_f64.powi(4),
10.0,
60.0,
0.5,
0.0, 10.0,
);
assert_eq!(new_temp, 300.0);
assert_eq!(new_t_pow4, 300.0_f64.powi(4));
}
#[test]
fn euler_overshoot_clamp() {
let area = 60.0;
let emissivity = 0.5;
let heat_cap_per_area = 50.0;
let old_temp: f64 = 200.0;
let old_t_pow4 = old_temp.powi(4);
let rad_constant = area * emissivity * STEFAN_BOLTZMANN;
let power_emit = rad_constant * old_t_pow4;
let power_absorb = power_emit + 1000.0; let heat_cap = heat_cap_per_area * area;
let temp_dot = (power_absorb - power_emit) / heat_cap;
let t_eq_pow4 = power_absorb / rad_constant;
let t_eq = t_eq_pow4.sqrt().sqrt();
let dt = 1e6;
let (new_temp, _) = integrate_plate_temperature_euler(
old_temp,
old_t_pow4,
temp_dot,
area,
emissivity,
heat_cap_per_area,
dt,
);
let rel_err = (new_temp - t_eq).abs() / t_eq;
assert!(
rel_err < 1e-10,
"Overshoot should clamp to equilibrium {t_eq:.2}, got {new_temp:.2}"
);
}
#[test]
fn rk4_combination() {
let temp0: f64 = 300.0;
let t_pow4_0 = temp0.powi(4);
let tdot = 2.0;
let dt = 10.0;
let (rk4_temp, _) = integrate_plate_temperature_rk4(
temp0, t_pow4_0, tdot, tdot, tdot, tdot, 60.0, 0.5, 50.0, dt,
);
let expected = temp0 + tdot * dt;
let rel_err = (rk4_temp - expected).abs() / expected;
assert!(
rel_err < 1e-12,
"Constant-derivative RK4 should match Euler: expected {expected}, got {rk4_temp}"
);
}
#[test]
fn rk4_non_negative_temperature() {
let (new_temp, new_t_pow4) = integrate_plate_temperature_rk4(
10.0,
10.0_f64.powi(4),
-100.0,
-100.0,
-100.0,
-100.0,
60.0,
0.5,
50.0,
1.0,
);
assert!(
new_temp >= 0.0,
"Temperature must not go negative: got {new_temp}"
);
assert!(new_t_pow4 >= 0.0);
}
#[test]
fn conduction_heat_flow_direction() {
let plate_a = FlatPlate {
area: 10.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let plate_b = FlatPlate {
area: 10.0,
normal: -DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let plates = vec![(plate_a, params, thermal), (plate_b, params, thermal)];
let temp_hot = 400.0_f64;
let temp_cold = 200.0_f64;
let t_pow4 = [temp_hot.powi(4), temp_cold.powi(4)];
let temps = [temp_hot, temp_cold];
let no_cond = compute_flat_plate_srp_thermal(
&plates,
&t_pow4,
DVec3::Y, 0.0,
DVec3::ZERO,
0.0,
);
let cond = ThermalConductionMatrix {
links: vec![(0, 1, 100.0)],
};
let with_cond = compute_flat_plate_srp_thermal_conduction(
&plates,
&t_pow4,
Some(&temps),
DVec3::Y,
0.0,
DVec3::ZERO,
0.0,
Some(&cond),
);
assert!(
with_cond.temp_dots[0] < no_cond.temp_dots[0],
"Hot plate should lose heat via conduction: with={:.4e} vs without={:.4e}",
with_cond.temp_dots[0],
no_cond.temp_dots[0]
);
assert!(
with_cond.temp_dots[1] > no_cond.temp_dots[1],
"Cold plate should gain heat via conduction: with={:.4e} vs without={:.4e}",
with_cond.temp_dots[1],
no_cond.temp_dots[1]
);
}
#[test]
fn conduction_zero_at_equal_temperatures() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let plates = vec![(plate, params, thermal), (plate, params, thermal)];
let temp = 300.0_f64;
let t_pow4 = [temp.powi(4), temp.powi(4)];
let temps = [temp, temp];
let cond = ThermalConductionMatrix {
links: vec![(0, 1, 500.0)], };
let with_cond = compute_flat_plate_srp_thermal_conduction(
&plates,
&t_pow4,
Some(&temps),
DVec3::Y,
0.0,
DVec3::ZERO,
0.0,
Some(&cond),
);
let no_cond =
compute_flat_plate_srp_thermal(&plates, &t_pow4, DVec3::Y, 0.0, DVec3::ZERO, 0.0);
assert!(
(with_cond.temp_dots[0] - no_cond.temp_dots[0]).abs() < 1e-20,
"Equal-temp conduction should be zero: diff={:.4e}",
(with_cond.temp_dots[0] - no_cond.temp_dots[0]).abs()
);
assert!(
(with_cond.temp_dots[1] - no_cond.temp_dots[1]).abs() < 1e-20,
"Equal-temp conduction should be zero: diff={:.4e}",
(with_cond.temp_dots[1] - no_cond.temp_dots[1]).abs()
);
}
#[test]
fn conduction_magnitude() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.0,
diffuse: 0.0,
};
let thermal = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let plates = vec![(plate, params, thermal), (plate, params, thermal)];
let temp_a = 350.0_f64;
let temp_b = 250.0_f64;
let t_pow4 = [temp_a.powi(4), temp_b.powi(4)];
let temps = [temp_a, temp_b];
let conductance = 200.0;
let cond = ThermalConductionMatrix {
links: vec![(0, 1, conductance)],
};
let with_cond = compute_flat_plate_srp_thermal_conduction(
&plates,
&t_pow4,
Some(&temps),
DVec3::Y,
0.0,
DVec3::ZERO,
0.0,
Some(&cond),
);
let no_cond =
compute_flat_plate_srp_thermal(&plates, &t_pow4, DVec3::Y, 0.0, DVec3::ZERO, 0.0);
let heat_cap = thermal.heat_capacity_per_area * plate.area;
let expected_cond_power = conductance * (temp_b - temp_a);
let tdot_diff_0 = with_cond.temp_dots[0] - no_cond.temp_dots[0];
let tdot_diff_1 = with_cond.temp_dots[1] - no_cond.temp_dots[1];
let expected_tdot_diff_0 = expected_cond_power / heat_cap;
let expected_tdot_diff_1 = -expected_cond_power / heat_cap;
assert!(
(tdot_diff_0 - expected_tdot_diff_0).abs() < 1e-10,
"Plate 0 tdot diff: expected {expected_tdot_diff_0:.4e}, got {tdot_diff_0:.4e}"
);
assert!(
(tdot_diff_1 - expected_tdot_diff_1).abs() < 1e-10,
"Plate 1 tdot diff: expected {expected_tdot_diff_1:.4e}, got {tdot_diff_1:.4e}"
);
}
#[test]
fn thermal_equilibrium_temp_dot_zero() {
let area = 10.0;
let emissivity = 0.8;
let absorptivity = 0.6; let flux_mag = 1400.0;
let t_eq_pow4 = absorptivity * flux_mag / (emissivity * STEFAN_BOLTZMANN);
let plate = FlatPlate {
area,
normal: -DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 1.0 - absorptivity,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let result = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&[t_eq_pow4],
DVec3::X, flux_mag,
DVec3::ZERO,
1.0,
);
assert!(
result.temp_dots[0].abs() < 1e-6,
"At equilibrium, temp_dot should be ~0, got {:.4e}",
result.temp_dots[0]
);
}
#[test]
fn re_radiation_force_at_known_temperature() {
let area = 20.0;
let emissivity = 0.9;
let temp = 350.0_f64;
let t_pow4 = temp.powi(4);
let plate = FlatPlate {
area,
normal: DVec3::Z,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal = FlatPlateThermal {
emissivity,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let result = compute_flat_plate_srp_thermal(
&[(plate, params, thermal)],
&[t_pow4],
DVec3::X,
0.0,
DVec3::ZERO,
0.0,
);
let expected_power = emissivity * STEFAN_BOLTZMANN * t_pow4 * area;
let expected_force_mag = TWO_THIRDS * expected_power / SPEED_OF_LIGHT;
let actual_force_mag = result.force.length();
let rel_err = (actual_force_mag - expected_force_mag).abs() / expected_force_mag;
assert!(
rel_err < 1e-10,
"Re-radiation force: expected {expected_force_mag:.6e}, got {actual_force_mag:.6e}"
);
assert!(result.force.z < 0.0, "Force should oppose normal (+Z)");
}
#[test]
fn thermal_power_dump_increases_temp_dot() {
let plate = FlatPlate {
area: 10.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.5,
diffuse: 0.5,
};
let thermal_no_dump = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let thermal_with_dump = FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 500.0, };
let temp = 300.0_f64;
let t_pow4 = [temp.powi(4)];
let no_dump = compute_flat_plate_srp_thermal(
&[(plate, params, thermal_no_dump)],
&t_pow4,
DVec3::Y,
0.0,
DVec3::ZERO,
0.0,
);
let with_dump = compute_flat_plate_srp_thermal(
&[(plate, params, thermal_with_dump)],
&t_pow4,
DVec3::Y,
0.0,
DVec3::ZERO,
0.0,
);
let heat_cap = 50.0 * 10.0;
let expected_diff = 500.0 / heat_cap;
let actual_diff = with_dump.temp_dots[0] - no_dump.temp_dots[0];
assert!(
(actual_diff - expected_diff).abs() < 1e-10,
"Power dump should add {expected_diff:.4e} K/s, got {actual_diff:.4e}"
);
}
#[test]
fn flat_plate_assert_vehicle_compiles_when_phantoms_match() {
use astrodyn_quantities::define_vehicle;
use astrodyn_quantities::ext::Vec3Ext;
use astrodyn_quantities::frame::StructuralFrame;
define_vehicle!(Iss);
let plate: FlatPlate<Iss> = FlatPlate {
area: 10.0,
normal: DVec3::X,
position: DVec3::ZERO.m_at::<StructuralFrame<Iss>>(),
};
let plate = plate.assert_vehicle::<Iss>();
assert_eq!(plate.area, 10.0);
}
}