use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EvolutionaryPhase {
PreMainSequence,
ZeroAgeMainSequence,
MainSequence,
Subgiant,
RedGiant,
HorizontalBranch,
AsymptoticGiantBranch,
PostAgb,
WhiteDwarf,
NeutronStar,
BlackHole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RemnantType {
WhiteDwarf,
NeutronStar,
BlackHole,
}
#[must_use]
pub fn mass_luminosity(mass_solar: f64) -> f64 {
if mass_solar <= 0.0 {
tracing::warn!(
mass_solar,
"non-positive mass in mass_luminosity — returning 0"
);
return 0.0;
}
if mass_solar < 0.43 {
0.23 * mass_solar.powf(2.3)
} else if mass_solar < 2.0 {
mass_solar.powf(4.0)
} else if mass_solar < 55.0 {
1.4 * mass_solar.powf(3.5)
} else {
32_000.0 * mass_solar
}
}
#[must_use]
pub fn main_sequence_lifetime(mass_solar: f64) -> f64 {
if mass_solar <= 0.0 {
return 0.0;
}
let l = mass_luminosity(mass_solar);
if l <= 0.0 {
return 0.0;
}
1e10 * mass_solar / l
}
#[must_use]
pub fn remnant_type(initial_mass_solar: f64) -> RemnantType {
if initial_mass_solar < 8.0 {
RemnantType::WhiteDwarf
} else if initial_mass_solar < 25.0 {
RemnantType::NeutronStar
} else {
RemnantType::BlackHole
}
}
pub const CHANDRASEKHAR_LIMIT: f64 = 1.44;
#[must_use]
#[inline]
pub fn white_dwarf_mass(initial_mass_solar: f64) -> f64 {
(0.109 * initial_mass_solar + 0.394).clamp(0.0, CHANDRASEKHAR_LIMIT)
}
#[must_use]
pub fn evolutionary_phase(mass_solar: f64, age_years: f64) -> EvolutionaryPhase {
if mass_solar <= 0.0 || age_years < 0.0 {
tracing::warn!(
mass_solar,
age_years,
"non-physical input to evolutionary_phase — defaulting to PreMainSequence"
);
return EvolutionaryPhase::PreMainSequence;
}
let t_ms = main_sequence_lifetime(mass_solar);
const F_ZAMS: f64 = 0.001; const F_SUBGIANT: f64 = 1.1; const F_RGB: f64 = 1.3; const F_HB: f64 = 1.4; const F_AGB: f64 = 1.5; const F_POST_AGB: f64 = 1.6;
if age_years < 1e6 {
EvolutionaryPhase::PreMainSequence
} else if age_years < t_ms * F_ZAMS {
EvolutionaryPhase::ZeroAgeMainSequence
} else if age_years < t_ms {
EvolutionaryPhase::MainSequence
} else if age_years < t_ms * F_SUBGIANT {
EvolutionaryPhase::Subgiant
} else if age_years < t_ms * F_RGB {
EvolutionaryPhase::RedGiant
} else if age_years < t_ms * F_HB {
EvolutionaryPhase::HorizontalBranch
} else if age_years < t_ms * F_AGB {
EvolutionaryPhase::AsymptoticGiantBranch
} else if age_years < t_ms * F_POST_AGB && mass_solar < 8.0 {
EvolutionaryPhase::PostAgb
} else if mass_solar < 8.0 {
EvolutionaryPhase::WhiteDwarf
} else if mass_solar < 25.0 {
EvolutionaryPhase::NeutronStar
} else {
EvolutionaryPhase::BlackHole
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solar_luminosity_from_mass() {
let l = mass_luminosity(1.0);
assert!(
(l - 1.0).abs() < 0.01,
"Solar mass-luminosity: {l}, expected 1.0"
);
}
#[test]
fn massive_star_brighter() {
assert!(mass_luminosity(10.0) > mass_luminosity(1.0));
assert!(mass_luminosity(50.0) > mass_luminosity(10.0));
}
#[test]
fn low_mass_dimmer() {
assert!(mass_luminosity(0.1) < mass_luminosity(1.0));
}
#[test]
fn solar_lifetime() {
let t = main_sequence_lifetime(1.0);
assert!((t - 1e10).abs() / 1e10 < 0.01, "Solar lifetime: {t:.2e} yr");
}
#[test]
fn massive_stars_shorter_lived() {
assert!(main_sequence_lifetime(10.0) < main_sequence_lifetime(1.0));
}
#[test]
fn low_mass_longer_lived() {
assert!(main_sequence_lifetime(0.1) > main_sequence_lifetime(1.0));
}
#[test]
fn remnant_types() {
assert_eq!(remnant_type(1.0), RemnantType::WhiteDwarf);
assert_eq!(remnant_type(5.0), RemnantType::WhiteDwarf);
assert_eq!(remnant_type(10.0), RemnantType::NeutronStar);
assert_eq!(remnant_type(20.0), RemnantType::NeutronStar);
assert_eq!(remnant_type(30.0), RemnantType::BlackHole);
}
#[test]
fn solar_evolution_main_sequence() {
let phase = evolutionary_phase(1.0, 4.6e9);
assert_eq!(phase, EvolutionaryPhase::MainSequence);
}
#[test]
fn old_solar_mass_becomes_wd() {
let phase = evolutionary_phase(1.0, 20e9);
assert_eq!(phase, EvolutionaryPhase::WhiteDwarf);
}
#[test]
fn evolutionary_phase_serde_roundtrip() {
let phases = [
EvolutionaryPhase::PreMainSequence,
EvolutionaryPhase::MainSequence,
EvolutionaryPhase::RedGiant,
EvolutionaryPhase::WhiteDwarf,
EvolutionaryPhase::NeutronStar,
EvolutionaryPhase::BlackHole,
];
for phase in &phases {
let json = serde_json::to_string(phase).unwrap();
let back: EvolutionaryPhase = serde_json::from_str(&json).unwrap();
assert_eq!(&back, phase);
}
}
#[test]
fn remnant_type_serde_roundtrip() {
for rt in [
RemnantType::WhiteDwarf,
RemnantType::NeutronStar,
RemnantType::BlackHole,
] {
let json = serde_json::to_string(&rt).unwrap();
let back: RemnantType = serde_json::from_str(&json).unwrap();
assert_eq!(back, rt);
}
}
#[test]
fn white_dwarf_mass_solar() {
let m = white_dwarf_mass(1.0);
assert!(
(m - 0.503).abs() < 0.01,
"Solar WD mass: {m}, expected ~0.503"
);
}
#[test]
fn white_dwarf_mass_clamped_to_chandrasekhar() {
let m = white_dwarf_mass(100.0);
assert!(
(m - CHANDRASEKHAR_LIMIT).abs() < f64::EPSILON,
"WD mass should clamp to Chandrasekhar limit: {m}, expected {CHANDRASEKHAR_LIMIT}"
);
}
#[test]
fn post_agb_phase() {
let t_ms = main_sequence_lifetime(1.0);
let phase = evolutionary_phase(1.0, t_ms * 1.55);
assert_eq!(phase, EvolutionaryPhase::PostAgb);
}
}