use glam::DVec3;
use crate::radiation_pressure::STEFAN_BOLTZMANN;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ThermalFacet {
pub area: f64,
pub emissivity_ir: f64,
pub absorptivity_solar: f64,
pub albedo: f64,
pub conductivity: f64,
pub specific_heat: f64,
pub mass: f64,
pub temperature: f64,
}
impl ThermalFacet {
#[inline]
pub fn heat_capacity(&self) -> f64 {
self.mass * self.specific_heat
}
#[inline]
pub fn radiative_constant(&self) -> f64 {
self.area * self.emissivity_ir * STEFAN_BOLTZMANN
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ThermalEnvironment {
pub solar_flux: f64,
pub earth_albedo_flux: f64,
pub earth_ir_flux: f64,
pub sun_direction: DVec3,
pub earth_direction: DVec3,
}
impl Default for ThermalEnvironment {
fn default() -> Self {
Self {
solar_flux: 0.0,
earth_albedo_flux: 0.0,
earth_ir_flux: 0.0,
sun_direction: DVec3::X,
earth_direction: -DVec3::Z,
}
}
}
#[derive(Debug, Clone)]
pub struct ThermalPowerBalance {
pub temp_dots: Vec<f64>,
pub q_solar: Vec<f64>,
pub q_albedo: Vec<f64>,
pub q_ir: Vec<f64>,
pub q_emitted: Vec<f64>,
}
pub fn compute_thermal_power_balance(
facets: &[ThermalFacet],
env: &ThermalEnvironment,
structural_normals: &[DVec3],
) -> ThermalPowerBalance {
let n = facets.len();
assert_eq!(
structural_normals.len(),
n,
"structural_normals length must match facets length"
);
debug_assert!(
(env.sun_direction.length_squared() - 1.0).abs() < 1e-6,
"env.sun_direction must be a unit vector (|s|² = {})",
env.sun_direction.length_squared()
);
debug_assert!(
(env.earth_direction.length_squared() - 1.0).abs() < 1e-6,
"env.earth_direction must be a unit vector (|e|² = {})",
env.earth_direction.length_squared()
);
let mut temp_dots = vec![0.0_f64; n];
let mut q_solar = vec![0.0_f64; n];
let mut q_albedo = vec![0.0_f64; n];
let mut q_ir = vec![0.0_f64; n];
let mut q_emitted = vec![0.0_f64; n];
for (i, facet) in facets.iter().enumerate() {
let n_i = structural_normals[i];
debug_assert!(
(n_i.length_squared() - 1.0).abs() < 1e-6,
"structural_normals[{i}] must be a unit vector (|n|² = {})",
n_i.length_squared()
);
let cos_sun = (-n_i.dot(env.sun_direction)).max(0.0);
q_solar[i] = facet.absorptivity_solar * env.solar_flux * cos_sun * facet.area;
let cos_earth = (-n_i.dot(env.earth_direction)).max(0.0);
q_albedo[i] = facet.absorptivity_solar * env.earth_albedo_flux * cos_earth * facet.area;
q_ir[i] = facet.emissivity_ir * env.earth_ir_flux * cos_earth * facet.area;
q_emitted[i] = facet.radiative_constant() * facet.temperature.powi(4);
let c = facet.heat_capacity();
if c > 0.0 {
let q_in = q_solar[i] + q_albedo[i] + q_ir[i];
temp_dots[i] = (q_in - q_emitted[i]) / c;
}
}
ThermalPowerBalance {
temp_dots,
q_solar,
q_albedo,
q_ir,
q_emitted,
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1e-12;
fn simple_facet(area: f64, eps_ir: f64, abs_sol: f64, temp: f64) -> ThermalFacet {
ThermalFacet {
area,
emissivity_ir: eps_ir,
absorptivity_solar: abs_sol,
albedo: 1.0 - abs_sol,
conductivity: 0.0,
specific_heat: 1000.0,
mass: 1.0,
temperature: temp,
}
}
#[test]
fn thermal_equilibrium_no_coupling() {
let eps = 0.9_f64;
let alpha = 0.8_f64;
let flux = 1361.0_f64;
let t_eq = (alpha * flux / (eps * STEFAN_BOLTZMANN)).powf(0.25);
let facet = ThermalFacet {
area: 1.0,
emissivity_ir: eps,
absorptivity_solar: alpha,
albedo: 1.0 - alpha,
conductivity: 0.0,
specific_heat: 1000.0,
mass: 1.0,
temperature: t_eq,
};
let env = ThermalEnvironment {
solar_flux: flux,
earth_albedo_flux: 0.0,
earth_ir_flux: 0.0,
sun_direction: DVec3::X,
earth_direction: -DVec3::Z,
};
let normals = [-DVec3::X];
let balance = compute_thermal_power_balance(&[facet], &env, &normals);
assert!(
balance.temp_dots[0].abs() < 1e-9,
"At equilibrium dT/dt ≈ 0, got {:e}",
balance.temp_dots[0]
);
let q_in = balance.q_solar[0];
assert!(
(q_in - balance.q_emitted[0]).abs() < 1e-6,
"Q_solar={q_in:e}, Q_emitted={:e}",
balance.q_emitted[0]
);
}
#[test]
fn earth_albedo_zero_at_night() {
let facet = simple_facet(2.0, 0.9, 0.8, 300.0);
let env = ThermalEnvironment {
solar_flux: 0.0,
earth_albedo_flux: 300.0,
earth_ir_flux: 0.0,
sun_direction: DVec3::X,
earth_direction: -DVec3::Z,
};
let night_normal = [-DVec3::Z];
let balance = compute_thermal_power_balance(&[facet], &env, &night_normal);
assert_eq!(
balance.q_albedo[0], 0.0,
"Night-side facet should receive zero albedo"
);
let day_normal = [DVec3::Z];
let balance_day = compute_thermal_power_balance(&[facet], &env, &day_normal);
assert!(balance_day.q_albedo[0] > 0.0);
}
#[test]
fn earth_ir_scales_with_cosine() {
let facet = simple_facet(1.0, 0.9, 0.0, 300.0);
let env = ThermalEnvironment {
solar_flux: 0.0,
earth_albedo_flux: 0.0,
earth_ir_flux: 240.0,
sun_direction: DVec3::X,
earth_direction: -DVec3::Z,
};
let straight_down = [DVec3::Z];
let b1 = compute_thermal_power_balance(&[facet], &env, &straight_down);
let tilted = [DVec3::new(3.0_f64.sqrt() / 2.0, 0.0, 0.5)];
let b2 = compute_thermal_power_balance(&[facet], &env, &tilted);
assert!((b2.q_ir[0] / b1.q_ir[0] - 0.5).abs() < 1e-12);
}
#[test]
fn stefan_boltzmann_emission() {
let eps = 0.8_f64;
let area = 3.0_f64;
let t = 350.0_f64;
let facet = ThermalFacet {
area,
emissivity_ir: eps,
absorptivity_solar: 0.0,
albedo: 1.0,
conductivity: 0.0,
specific_heat: 1000.0,
mass: 1.0,
temperature: t,
};
let env = ThermalEnvironment::default();
let normals = [DVec3::Z];
let balance = compute_thermal_power_balance(&[facet], &env, &normals);
let expected = eps * STEFAN_BOLTZMANN * area * t.powi(4);
assert!(
(balance.q_emitted[0] - expected).abs() / expected < 1e-14,
"Q_emit: expected {expected:e}, got {:e}",
balance.q_emitted[0]
);
}
#[test]
fn solar_backface_zero_absorption() {
let facet = simple_facet(1.0, 0.9, 0.8, 300.0);
let env = ThermalEnvironment {
solar_flux: 1361.0,
earth_albedo_flux: 0.0,
earth_ir_flux: 0.0,
sun_direction: DVec3::X, earth_direction: -DVec3::Z,
};
let normals = [DVec3::X];
let balance = compute_thermal_power_balance(&[facet], &env, &normals);
assert_eq!(balance.q_solar[0], 0.0);
}
#[test]
fn facet_cools_in_deep_space() {
let facet = simple_facet(1.0, 0.9, 0.0, 300.0);
let env = ThermalEnvironment {
solar_flux: 0.0,
earth_albedo_flux: 0.0,
earth_ir_flux: 0.0,
sun_direction: DVec3::X,
earth_direction: -DVec3::Z,
};
let normals = [DVec3::Z];
let balance = compute_thermal_power_balance(&[facet], &env, &normals);
assert!(
balance.temp_dots[0] < 0.0,
"Facet in deep space should cool, got {}",
balance.temp_dots[0]
);
}
#[test]
fn thermal_facet_accessors() {
let f = ThermalFacet {
area: 2.0,
emissivity_ir: 0.5,
absorptivity_solar: 0.7,
albedo: 0.3,
conductivity: 200.0,
specific_heat: 900.0,
mass: 5.0,
temperature: 300.0,
};
assert!((f.heat_capacity() - 5.0 * 900.0).abs() < TOL);
let expected_rad = 2.0 * 0.5 * STEFAN_BOLTZMANN;
assert!((f.radiative_constant() - expected_rad).abs() < TOL * expected_rad);
}
}