caustic 0.0.12

A General-Purpose 6D Collisionless Gravitational Dynamics Solver
Documentation
//! Plummer sphere with small radial perturbation.
//! Tests that a self-consistent response shows oscillatory behavior
//! rather than unphysical growth.

#[test]
#[ignore] // Requires higher resolution for meaningful oscillation measurement
fn plummer_perturbation_response() {
    use crate::sim::Simulation;
    use crate::tooling::core::algos::lagrangian::SemiLagrangian;
    use crate::tooling::core::init::{
        domain::{Domain, SpatialBoundType, VelocityBoundType},
        isolated::{PlummerIC, sample_on_grid},
    };
    use crate::tooling::core::phasespace::PhaseSpaceRepr;
    use crate::tooling::core::poisson::fft::FftPoisson;
    use crate::tooling::core::time::strang::StrangSplitting;

    let domain = Domain::builder()
        .spatial_extent(8.0)
        .velocity_extent(2.5)
        .spatial_resolution(16)
        .velocity_resolution(8)
        .t_final(8.0) // ~4 dynamical times
        .spatial_bc(SpatialBoundType::Periodic)
        .velocity_bc(VelocityBoundType::Truncated)
        .build()
        .unwrap();

    let ic = PlummerIC::new(1.0, 1.0, 1.0);
    let mut snap = sample_on_grid(&ic, &domain);

    // Add small l=0 radial perturbation: multiply each cell's f by (1 + epsilon * cos(2*pi*r/L))
    let epsilon = 0.05;
    let dx = domain.dx();
    let dv = domain.dv();
    let lx = 8.0f64;
    let [nx, ny, nz, nv1, nv2, nv3] = snap.shape;
    for ix in 0..nx {
        for iy in 0..ny {
            for iz in 0..nz {
                let x = -lx + (ix as f64 + 0.5) * dx[0];
                let y = -lx + (iy as f64 + 0.5) * dx[1];
                let z = -lx + (iz as f64 + 0.5) * dx[2];
                let r = (x * x + y * y + z * z).sqrt();
                let perturbation =
                    1.0 + epsilon * (2.0 * std::f64::consts::PI * r / (2.0 * lx)).cos();
                for iv1 in 0..nv1 {
                    for iv2 in 0..nv2 {
                        for iv3 in 0..nv3 {
                            let idx = ix * ny * nz * nv1 * nv2 * nv3
                                + iy * nz * nv1 * nv2 * nv3
                                + iz * nv1 * nv2 * nv3
                                + iv1 * nv2 * nv3
                                + iv2 * nv3
                                + iv3;
                            snap.data[idx] *= perturbation;
                        }
                    }
                }
            }
        }
    }

    let poisson = FftPoisson::new(&domain);
    let mut sim = Simulation::builder()
        .domain(domain)
        .poisson_solver(poisson)
        .advector(SemiLagrangian::new())
        .integrator(StrangSplitting::new(1.0))
        .initial_conditions(snap)
        .time_final(8.0)
        .build()
        .unwrap();

    let pkg = sim.run().unwrap();
    let hist = &pkg.diagnostics_history;

    // The perturbed system should oscillate — verify it doesn't blow up
    let e0 = hist[0].total_energy;
    let e_final = hist.last().unwrap().total_energy;
    let e_drift = if e0.abs() > 1e-30 {
        (e_final - e0).abs() / e0.abs()
    } else {
        0.0
    };

    // Track density at center over time to detect oscillation
    // We use max_density as a proxy (perturbed center should oscillate)
    // ... Just verify the system doesn't crash and energy is bounded
    println!(
        "Plummer perturbation: e_drift={e_drift:.4}, steps={}",
        hist.len()
    );
    assert!(hist.len() >= 5, "Should complete multiple steps");
    assert!(!e_final.is_nan(), "Energy should not be NaN");
    assert!(
        hist.last().unwrap().mass_in_box > 0.0,
        "Mass should remain positive"
    );
}