satellitesfactory 0.0.2

Satellite factory — classify, build and catalogue natural satellites for any planetary system: Solar System moons (Moon, Galileans, Titan, Triton…) or custom configurations.
Documentation
// validation.rs — Validate all SatellitesFactory modules with physical sanity checks.

use satellitesfactory::config::parameters::*;
use satellitesfactory::engine::evolution::*;
use satellitesfactory::engine::formation::*;
use satellitesfactory::engine::generator::*;
use satellitesfactory::engine::orbits::*;
use satellitesfactory::observables::astrometry::*;
use satellitesfactory::observables::photometry::*;
use satellitesfactory::observables::spectra::*;
use satellitesfactory::physics::gravitation::*;
use satellitesfactory::physics::interiors::*;
use satellitesfactory::physics::tidal_dynamics::*;
use satellitesfactory::types::captured::{CapturedComposition, CapturedSatellite};
use satellitesfactory::types::icy_moon::IcyMoon;
use satellitesfactory::types::regular::{RegularSatellite, SatelliteClass};
use satellitesfactory::types::ring_moon::{RingAssociation, RingMoon};
use satellitesfactory::types::subsurface_ocean_moon::SubsurfaceOceanMoon;
use satellitesfactory::types::volcanic_moon::VolcanicMoon;
use satellitesfactory::utils::helpers::*;

struct ValidationResult {
    module: &'static str,
    checks: usize,
    passed: usize,
}

impl ValidationResult {
    fn new(module: &'static str) -> Self {
        Self {
            module,
            checks: 0,
            passed: 0,
        }
    }
    fn check(&mut self, ok: bool) {
        self.checks += 1;
        if ok {
            self.passed += 1;
        }
    }
}

fn validate_types() -> ValidationResult {
    let mut v = ValidationResult::new("types");

    // Regular (Moon-like)
    let moon = RegularSatellite::new(
        "Moon",
        "Earth",
        MOON_MASS,
        MOON_RADIUS,
        2.03e-4,
        6.687 * DEG,
        OrbitalElements::from_km_deg(384_400.0, 0.054_9, 5.145, 125.08, 318.15, 135.27),
    )
    .with_composition(SatelliteClass::Regular, 0.12, 0.0, 0.83);
    v.check((moon.mass_moon() - 1.0).abs() < 0.01);
    v.check(moon.surface_gravity() > 1.6 && moon.surface_gravity() < 1.7);
    v.check(moon.escape_velocity() > 2300.0 && moon.escape_velocity() < 2400.0);
    v.check(moon.ice_mass() == 0.0);
    v.check(moon.rock_mass() > 0.0);
    v.check(moon.moment_of_inertia_factor() > 0.3 && moon.moment_of_inertia_factor() < 0.4);

    // Regular (Io-like)
    let io = RegularSatellite::new(
        "Io",
        "Jupiter",
        IO_MASS,
        IO_RADIUS,
        1.86e-3,
        0.05 * DEG,
        OrbitalElements::from_km_deg(421_700.0, 0.004_1, 0.036, 0.0, 0.0, 0.0),
    )
    .with_composition(SatelliteClass::Regular, 0.63, 0.0, 0.94);
    v.check(io.mass > moon.mass);
    v.check(io.mean_density() > 3500.0);

    // Captured (Triton-like)
    let triton = CapturedSatellite::new(
        "Triton",
        "Neptune",
        TRITON_MASS,
        TRITON_RADIUS,
        0.0,
        0.0,
        OrbitalElements::from_km_deg(354_759.0, 0.000_016, 156.865, 0.0, 0.0, 0.0),
    )
    .with_capture(0.76, true, 300.0, CapturedComposition::IceRich);
    v.check(triton.is_retrograde());
    v.check(triton.capture_energy() > 0.0);

    // Ring moon (Enceladus-like)
    let enceladus = RingMoon::new(
        "Enceladus",
        "Saturn",
        ENCELADUS_MASS,
        ENCELADUS_RADIUS,
        OrbitalElements::from_km_deg(238_042.0, 0.004_7, 0.009, 0.0, 0.0, 0.0),
    )
    .with_ring(0.99, RingAssociation::Embedded, true, 0.57);
    v.check(enceladus.is_cryovolcanic());
    v.check(enceladus.plume_escape_fraction() > 0.0);
    v.check(enceladus.ice_mass() > 0.0);

    // Volcanic moon
    let io_vol = VolcanicMoon::new(
        "Io",
        "Jupiter",
        IO_MASS,
        IO_RADIUS,
        1.86e-3,
        0.05 * DEG,
        OrbitalElements::from_km_deg(421_700.0, 0.004_1, 0.036, 0.0, 0.0, 0.0),
    );
    v.check(io_vol.surface_gravity() > 1.7);
    v.check(io_vol.tidal_heating(JUPITER_MASS) > 1e10);

    // Icy moon
    let ganymede_icy = IcyMoon::new(
        "Ganymede",
        "Jupiter",
        GANYMEDE_MASS,
        GANYMEDE_RADIUS,
        1.27e-4,
        0.17 * DEG,
        OrbitalElements::from_km_deg(1_070_400.0, 0.001_3, 0.20, 0.0, 0.0, 0.0),
    )
    .with_surface(0.43, 0.40, 0.55, 110.0);
    v.check(ganymede_icy.ice_mass() > 0.0);
    v.check(ganymede_icy.mean_density() > 1900.0);

    // Subsurface ocean moon
    let europa_ocean = SubsurfaceOceanMoon::new(
        "Europa",
        "Jupiter",
        EUROPA_MASS,
        EUROPA_RADIUS,
        4.35e-4,
        0.1 * DEG,
        OrbitalElements::from_km_deg(671_100.0, 0.009_0, 0.47, 0.0, 0.0, 0.0),
    )
    .with_ocean(0.67, 15.0e3, 50.0, 30.0);
    v.check(europa_ocean.ocean_depth() > 0.0);
    v.check(europa_ocean.habitability_index(JUPITER_MASS) > 0.0);

    v
}

fn validate_physics() -> ValidationResult {
    let mut v = ValidationResult::new("physics");

    let grav = SatelliteGravity::new(MOON_MASS, MOON_RADIUS);
    v.check(grav.surface_acceleration() > 1.6 && grav.surface_acceleration() < 1.7);
    v.check(grav.escape_velocity() > 2300.0);
    v.check(grav.orbital_period(384_400.0e3) > 0.0);
    v.check(grav.sphere_of_influence(EARTH_MASS, 384_400.0e3) > 0.0);

    let f = two_body_force(EARTH_MASS, MOON_MASS, 384_400.0e3);
    v.check(f > 1.9e20 && f < 2.1e20);

    let rl = satellitesfactory::physics::gravitation::roche_limit(SATURN_RADIUS, 687.0, 917.0);
    v.check(rl > SATURN_RADIUS);

    let tidal = TidalSystem::new(IO_MASS, IO_RADIUS, JUPITER_MASS, 421_700.0e3, 0.004_1)
        .with_tidal(JUPITER_RADIUS, 100.0, 0.015);
    v.check(tidal.tidal_heating_rate() > 0.0);
    v.check(tidal.tidal_heating_flux() > 0.0);
    v.check(tidal.equilibrium_temperature_from_tidal() > 0.0);

    let enc_tidal = TidalSystem::new(
        ENCELADUS_MASS,
        ENCELADUS_RADIUS,
        SATURN_MASS,
        238_042.0e3,
        0.004_7,
    )
    .with_tidal(SATURN_RADIUS, 20.0, 0.01);
    v.check(enc_tidal.tidal_heating_rate() > 0.0);
    v.check(enc_tidal.circularization_timescale() > 0.0);

    let interior = SatelliteInterior::new(EUROPA_MASS, EUROPA_RADIUS, 0.08, 0.86);
    v.check(interior.ocean_depth_estimate() > 0.0);
    v.check(interior.central_pressure() > 0.0);
    v.check(interior.gravitational_binding_energy() > 0.0);

    let io_int = SatelliteInterior::new(IO_MASS, IO_RADIUS, 0.0, 0.94);
    v.check(io_int.ocean_depth_estimate() == 0.0);

    v
}

fn validate_engine() -> ValidationResult {
    let mut v = ValidationResult::new("engine");

    let elem = OrbitalElements::from_km_deg(384_400.0, 0.054_9, 5.145, 125.08, 318.15, 135.27);
    let (pos, vel) = elements_to_state(&elem, EARTH_MASS + MOON_MASS);
    let r = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt();
    v.check(r > 3.5e8 && r < 4.1e8);
    let spd = (vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]).sqrt();
    v.check(spd > 900.0 && spd < 1100.0);

    let ea = solve_kepler(1.0, 0.5, 1e-14);
    v.check((ea - 0.5 * ea.sin() - 1.0).abs() < 1e-12);

    let (r12, r23) = laplace_resonance_check(1.769, 3.551, 7.155);
    v.check((r12 - 2.0).abs() < 0.02);
    v.check((r23 - 2.0).abs() < 0.02);

    let fm = FormationModel::new(
        JUPITER_MASS,
        JUPITER_RADIUS,
        0.02,
        30.0 * JUPITER_RADIUS,
        400.0,
    );
    v.check(fm.disk_mass() > 0.0);
    v.check(fm.ice_line_radius() > fm.parent_radius);
    v.check(fm.maximum_satellite_mass() > 0.0);
    let prob = FormationModel::capture_probability(200.0, 1000.0, 0.5);
    v.check(prob > 0.0 && prob < 1.0);

    let evo = SatelliteEvolution::new(
        MOON_MASS,
        MOON_RADIUS,
        384_400.0e3,
        0.054_9,
        EARTH_MASS,
        EARTH_RADIUS,
    );
    v.check(evo.tidal_migration_rate(27.0) > 0.0);
    v.check(evo.orbital_decay_timescale(27.0) > 0.0);

    let kozai = kozai_lidov_max_eccentricity(0.7);
    v.check(kozai > 0.0 && kozai < 1.0);

    let config = SystemGeneratorConfig::new(
        "Jupiter",
        JUPITER_MASS,
        JUPITER_RADIUS,
        2.0 * JUPITER_RADIUS,
        30.0 * JUPITER_RADIUS,
    )
    .with_counts(4, 2, 3, 0, 0);
    let generated = generate_system(&config);
    v.check(generated.len() == 9);

    v
}

fn validate_observables() -> ValidationResult {
    let mut v = ValidationResult::new("observables");

    let eq_t = equilibrium_temperature(3.828e26, 5.2 * AU, 0.34);
    v.check(eq_t > 100.0 && eq_t < 200.0);

    let phase = phase_curve_lambertian(1.0);
    v.check(phase > 0.0);

    let td = transit_depth_satellite(EUROPA_RADIUS, 6.957e8);
    v.check(td > 0.0 && td < 1e-4);

    let bd = band_depth(1.0, 0.7);
    v.check((bd - 0.3).abs() < 0.01);

    let wii = water_ice_index(0.8, 0.4);
    v.check(wii > 1.5);

    let comp = composition_from_albedo_and_density(0.99, 1200.0);
    v.check(comp == SurfaceComposition::WaterIce);

    let ang = angular_separation_arcsec(10.0 * 3.086e16, 5.2 * AU);
    v.check(ang > 0.0);

    let occ = occultation_duration(EUROPA_RADIUS, 13_740.0, JUPITER_RADIUS, 0.0);
    v.check(occ > 0.0);

    let ltt = light_travel_time(AU);
    v.check((ltt - 499.0).abs() < 1.0);

    v
}

fn validate_utils() -> ValidationResult {
    let mut v = ValidationResult::new("utils");

    v.check((lerp(0.0, 10.0, 0.5) - 5.0).abs() < 1e-12);
    v.check((lerp(0.0, 10.0, 0.0)).abs() < 1e-12);
    v.check((lerp(0.0, 10.0, 1.0) - 10.0).abs() < 1e-12);

    let integral = simpson_integrate(|x| x * x, 0.0, 1.0, 100);
    v.check((integral - 1.0 / 3.0).abs() < 1e-6);

    let r = log_range(1.0, 1000.0, 4);
    v.check(r.len() == 4);
    v.check((r[0] - 1.0).abs() < 0.01);
    v.check((r[3] - 1000.0).abs() < 1.0);

    let s = format_si(1.5e9);
    v.check(s.contains("G"));

    v.check((clamp(-1.0, 0.0, 1.0)).abs() < 1e-12);
    v.check((clamp(2.0, 0.0, 1.0) - 1.0).abs() < 1e-12);

    v
}

fn main() {
    let results = [
        validate_types(),
        validate_physics(),
        validate_engine(),
        validate_observables(),
        validate_utils(),
    ];

    let total_checks: usize = results.iter().map(|r| r.checks).sum();
    let total_passed: usize = results.iter().map(|r| r.passed).sum();

    for r in &results {
        assert!(
            r.passed == r.checks,
            "FAIL: {} — {}/{} checks passed",
            r.module,
            r.passed,
            r.checks
        );
    }

    assert!(
        total_passed == total_checks,
        "OVERALL: {total_passed}/{total_checks} checks passed"
    );
}