Skip to main content

caustic/tooling/core/init/
stability.rs

1//! Disk stability initial conditions: equilibrium disk + bulge + halo with a seeded
2//! perturbation mode for studying spiral arm / bar instabilities.
3
4use rust_decimal::prelude::ToPrimitive;
5
6use super::super::types::PhaseSpaceSnapshot;
7use super::domain::Domain;
8use super::isolated::IsolatedEquilibrium;
9
10/// Complete elliptic integral K(m) via Abramowitz & Stegun polynomial approximation (17.3.34).
11/// Argument m is the parameter (not the modulus k; m = k²). Valid for 0 ≤ m < 1.
12/// Maximum absolute error ~2e-8.
13fn elliptic_k(m: f64) -> f64 {
14    if m < 0.0 {
15        return std::f64::consts::FRAC_PI_2;
16    }
17    if m >= 1.0 {
18        return f64::INFINITY;
19    }
20    let m1 = 1.0 - m;
21    let a0 = 1.386_294_361_12;
22    let a1 = 0.096_663_442_59;
23    let a2 = 0.035_900_923_83;
24    let a3 = 0.037_425_637_13;
25    let a4 = 0.014_511_962_12;
26    let b0 = 0.5;
27    let b1 = 0.124_985_935_97;
28    let b2 = 0.068_802_485_76;
29    let b3 = 0.033_283_553_46;
30    let b4 = 0.004_417_870_12;
31    let poly_a = a0 + m1 * (a1 + m1 * (a2 + m1 * (a3 + m1 * a4)));
32    let poly_b = b0 + m1 * (b1 + m1 * (b2 + m1 * (b3 + m1 * b4)));
33    poly_a - poly_b * m1.ln()
34}
35
36/// Complete elliptic integral E(m) via Abramowitz & Stegun polynomial approximation (17.3.36).
37/// Argument m is the parameter. Valid for 0 ≤ m ≤ 1.
38/// Maximum absolute error ~2e-8.
39fn elliptic_e(m: f64) -> f64 {
40    if m <= 0.0 {
41        return std::f64::consts::FRAC_PI_2;
42    }
43    if m >= 1.0 {
44        return 1.0;
45    }
46    let m1 = 1.0 - m;
47    let a1 = 0.443_251_414_09;
48    let a2 = 0.062_606_012_20;
49    let a3 = 0.047_573_835_46;
50    let a4 = 0.017_365_064_51;
51    let b1 = 0.249_968_730_16;
52    let b2 = 0.092_001_800_37;
53    let b3 = 0.040_696_975_26;
54    let b4 = 0.005_264_496_39;
55    let poly_a = 1.0 + m1 * (a1 + m1 * (a2 + m1 * (a3 + m1 * a4)));
56    let poly_b = m1 * (b1 + m1 * (b2 + m1 * (b3 + m1 * b4)));
57    poly_a - poly_b * m1.ln()
58}
59
60/// Disk stability IC: f(E, Lz) for axisymmetric disk plus an optional perturbation.
61pub struct DiskStabilityIC {
62    /// Disk surface density Σ(R) as function of cylindrical radius.
63    pub disk_surface_density: Box<dyn Fn(f64) -> f64 + Send + Sync>,
64    /// Radial velocity dispersion σ_R(R).
65    pub disk_velocity_dispersion: Box<dyn Fn(f64) -> f64 + Send + Sync>,
66    /// Optional central bulge component.
67    pub bulge: Option<Box<dyn IsolatedEquilibrium>>,
68    /// Optional fixed dark matter halo potential Φ_halo(x).
69    pub halo_potential: Option<Box<dyn Fn([f64; 3]) -> f64 + Send + Sync>>,
70    /// Azimuthal mode number m (m=2 = bar, m=3 = triangle).
71    pub perturbation_mode_m: u32,
72    /// Pattern speed Ω_p in rad/time.
73    pub perturbation_pattern_speed: f64,
74    /// Relative amplitude δΣ/Σ.
75    pub perturbation_amplitude: f64,
76}
77
78impl DiskStabilityIC {
79    pub fn new(
80        disk_surface_density: Box<dyn Fn(f64) -> f64 + Send + Sync>,
81        disk_velocity_dispersion: Box<dyn Fn(f64) -> f64 + Send + Sync>,
82        perturbation_mode_m: u32,
83        perturbation_pattern_speed: f64,
84        perturbation_amplitude: f64,
85    ) -> Self {
86        Self {
87            disk_surface_density,
88            disk_velocity_dispersion,
89            bulge: None,
90            halo_potential: None,
91            perturbation_mode_m,
92            perturbation_pattern_speed,
93            perturbation_amplitude,
94        }
95    }
96
97    /// Compute the circular velocity squared v_c²(R) from the combined disk+bulge+halo
98    /// potential using the enclosed-mass approximation for the disk, plus optional
99    /// bulge and halo contributions via finite-difference on their potentials.
100    ///
101    /// G = 1.0 (natural units).
102    fn vc_squared(&self, radius: f64) -> f64 {
103        let g = 1.0;
104        let eps = 1e-6 * radius.max(1e-10);
105
106        // Disk contribution: enclosed-mass approximation
107        // v_c²(R) ≈ G * M_enc(R) / R where M_enc = 2π ∫₀^R Σ(R') R' dR'
108        let n_quad = 200;
109        let dr = radius / n_quad as f64;
110        let mut m_enc = 0.0;
111        for i in 0..n_quad {
112            let r_mid = (i as f64 + 0.5) * dr;
113            m_enc += (self.disk_surface_density)(r_mid) * r_mid * dr;
114        }
115        m_enc *= 2.0 * std::f64::consts::PI;
116        let mut vc2 = if radius > 1e-30 {
117            g * m_enc / radius
118        } else {
119            0.0
120        };
121
122        // Bulge contribution: v_c² = -R dΦ/dR via finite difference
123        if let Some(ref bulge) = self.bulge {
124            let phi_plus = bulge.potential(radius + eps);
125            let phi_minus = bulge.potential((radius - eps).max(1e-30));
126            let dphi_dr = (phi_plus - phi_minus) / (2.0 * eps);
127            vc2 += -radius * dphi_dr;
128        }
129
130        // Halo contribution: v_c² = -R dΦ/dR via finite difference on Φ(R,0,0)
131        if let Some(ref halo_pot) = self.halo_potential {
132            let phi_plus = halo_pot([radius + eps, 0.0, 0.0]);
133            let phi_minus = halo_pot([(radius - eps).max(1e-30), 0.0, 0.0]);
134            let dphi_dr = (phi_plus - phi_minus) / (2.0 * eps);
135            vc2 += -radius * dphi_dr;
136        }
137
138        vc2.max(0.0)
139    }
140
141    /// Compute the total potential Φ(R, z) using the spherical enclosed-mass approximation
142    /// for the disk plus optional bulge and halo contributions.
143    fn total_potential(&self, r_cyl: f64, z: f64, x: [f64; 3]) -> f64 {
144        let g = 1.0;
145        let r_sph = (r_cyl * r_cyl + z * z).sqrt();
146
147        // Disk: spherical approximation Φ(r) ≈ -G * M_enc(r) / r
148        let n_quad = 200;
149        let r_int = r_sph.max(1e-30);
150        let dr = r_int / n_quad as f64;
151        let mut m_enc = 0.0;
152        for i in 0..n_quad {
153            let r_mid = (i as f64 + 0.5) * dr;
154            m_enc += (self.disk_surface_density)(r_mid) * r_mid * dr;
155        }
156        m_enc *= 2.0 * std::f64::consts::PI;
157        let mut phi = if r_sph > 1e-30 {
158            -g * m_enc / r_sph
159        } else {
160            0.0
161        };
162
163        // Bulge contribution
164        if let Some(ref bulge) = self.bulge {
165            phi += bulge.potential(r_sph);
166        }
167
168        // Halo contribution
169        if let Some(ref halo_pot) = self.halo_potential {
170            phi += halo_pot(x);
171        }
172
173        phi
174    }
175
176    /// Find the guiding-center radius R_c for a given angular momentum L_z
177    /// by bisection: solve L_z = R_c * v_c(R_c).
178    fn find_guiding_radius(&self, lz: f64, r_max: f64) -> f64 {
179        // L_z = R_c * v_c(R_c) => L_z² = R_c² * v_c²(R_c)
180        // Find R_c such that R_c * sqrt(v_c²(R_c)) = L_z
181        let mut lo = 1e-10_f64;
182        let mut hi = r_max;
183
184        for _ in 0..80 {
185            let mid = 0.5 * (lo + hi);
186            let vc2 = self.vc_squared(mid);
187            let lz_mid = mid * vc2.sqrt();
188            if lz_mid < lz {
189                lo = mid;
190            } else {
191                hi = mid;
192            }
193        }
194        0.5 * (lo + hi)
195    }
196
197    /// Toomre Q(R) = σ_R κ / (3.36 G Σ). Q > 1 means locally stable.
198    ///
199    /// Computes the epicyclic frequency κ(R) = √(R dΩ²/dR + 4Ω²)
200    /// from the combined disk+bulge+halo circular velocity curve.
201    pub fn toomre_q(&self, radius: f64) -> f64 {
202        let g = 1.0;
203        let sigma_r = (self.disk_velocity_dispersion)(radius);
204        let sigma_surf = (self.disk_surface_density)(radius);
205
206        if sigma_surf.abs() < 1e-30 {
207            return f64::INFINITY; // no surface density → trivially stable
208        }
209
210        // Compute Ω²(R) = v_c²(R) / R²
211        let vc2 = self.vc_squared(radius);
212        let omega2 = if radius > 1e-30 {
213            vc2 / (radius * radius)
214        } else {
215            return f64::INFINITY;
216        };
217
218        // Epicyclic frequency: κ² = R dΩ²/dR + 4Ω²
219        // Compute dΩ²/dR by finite difference
220        let eps = 1e-4 * radius.max(1e-8);
221        let r_plus = radius + eps;
222        let r_minus = (radius - eps).max(1e-30);
223        let vc2_plus = self.vc_squared(r_plus);
224        let vc2_minus = self.vc_squared(r_minus);
225        let omega2_plus = vc2_plus / (r_plus * r_plus);
226        let omega2_minus = vc2_minus / (r_minus * r_minus);
227        let domega2_dr = (omega2_plus - omega2_minus) / (r_plus - r_minus);
228
229        let kappa2 = radius * domega2_dr + 4.0 * omega2;
230        if kappa2 <= 0.0 {
231            return 0.0; // negative κ² signals pathological rotation curve
232        }
233        let kappa = kappa2.sqrt();
234
235        // Q = σ_R * κ / (3.36 * G * Σ)
236        sigma_r * kappa / (3.36 * g * sigma_surf)
237    }
238
239    /// Sample onto 6D grid: construct f(E, Lz) for disk using the Shu (1969)
240    /// distribution function, then superpose an azimuthal perturbation mode.
241    ///
242    /// Shu DF: f(E, L_z) = [Σ(R_c) Ω(R_c)] / [π κ(R_c) σ_R²(R_c)]
243    ///                      × exp[-(E - E_c(L_z)) / σ_R²(R_c)]
244    ///
245    /// for L_z > 0 (prograde orbits). f = 0 for retrograde (L_z ≤ 0).
246    ///
247    /// The vertical structure uses a sech²(z/z_0) profile with
248    /// z_0 = σ_z / √(4πGΣ), assuming σ_z ≈ σ_R (isotropic approximation).
249    pub fn sample_on_grid(&self, domain: &Domain) -> PhaseSpaceSnapshot {
250        let g = 1.0;
251
252        let nx1 = domain.spatial_res.x1 as usize;
253        let nx2 = domain.spatial_res.x2 as usize;
254        let nx3 = domain.spatial_res.x3 as usize;
255        let nv1 = domain.velocity_res.v1 as usize;
256        let nv2 = domain.velocity_res.v2 as usize;
257        let nv3 = domain.velocity_res.v3 as usize;
258
259        let dx = domain.dx();
260        let dv = domain.dv();
261        let lx = [
262            domain.spatial.x1.to_f64().unwrap(),
263            domain.spatial.x2.to_f64().unwrap(),
264            domain.spatial.x3.to_f64().unwrap(),
265        ];
266        let lv = [
267            domain.velocity.v1.to_f64().unwrap(),
268            domain.velocity.v2.to_f64().unwrap(),
269            domain.velocity.v3.to_f64().unwrap(),
270        ];
271
272        // Maximum radius for guiding-center search
273        let r_max = (lx[0] * lx[0] + lx[1] * lx[1]).sqrt() * 1.5;
274
275        // Row-major strides (same layout as UniformGrid6D)
276        let s_v3 = 1usize;
277        let s_v2 = nv3;
278        let s_v1 = nv2 * nv3;
279        let s_x3 = nv1 * s_v1;
280        let s_x2 = nx3 * s_x3;
281        let s_x1 = nx2 * s_x2;
282
283        let total = nx1 * nx2 * nx3 * nv1 * nv2 * nv3;
284        let mut data = vec![0.0f64; total];
285
286        let perturbation_m = self.perturbation_mode_m;
287        let perturbation_amp = self.perturbation_amplitude;
288
289        for ix1 in 0..nx1 {
290            let x1 = -lx[0] + (ix1 as f64 + 0.5) * dx[0];
291            for ix2 in 0..nx2 {
292                let x2 = -lx[1] + (ix2 as f64 + 0.5) * dx[1];
293
294                let r_cyl = (x1 * x1 + x2 * x2).sqrt();
295                let phi_azimuthal = x2.atan2(x1);
296
297                // Perturbation factor: 1 + ε cos(m φ)
298                let pert_factor =
299                    1.0 + perturbation_amp * (perturbation_m as f64 * phi_azimuthal).cos();
300
301                for ix3 in 0..nx3 {
302                    let x3 = -lx[2] + (ix3 as f64 + 0.5) * dx[2];
303                    let pos = [x1, x2, x3];
304
305                    let phi_total = self.total_potential(r_cyl, x3, pos);
306
307                    // Vertical sech² profile: f_z(z) = sech²(z/z_0) / (2 z_0)
308                    // z_0 = σ_z / √(4πGΣ)
309                    let sigma_r_here = (self.disk_velocity_dispersion)(r_cyl);
310                    let sigma_surf_here = (self.disk_surface_density)(r_cyl);
311                    let sigma_z = sigma_r_here; // isotropic approximation
312                    let z_0 = if sigma_surf_here > 1e-30 {
313                        sigma_z / (4.0 * std::f64::consts::PI * g * sigma_surf_here).sqrt()
314                    } else {
315                        1e10 // effectively infinite scale height if no surface density
316                    };
317                    let sech_arg = x3 / z_0;
318                    let f_z = if sech_arg.abs() < 50.0 {
319                        let cosh_val = sech_arg.cosh();
320                        1.0 / (cosh_val * cosh_val * 2.0 * z_0)
321                    } else {
322                        0.0 // negligible contribution
323                    };
324
325                    if f_z < 1e-30 {
326                        // Skip velocity loops if vertical contribution is negligible
327                        continue;
328                    }
329
330                    let base = ix1 * s_x1 + ix2 * s_x2 + ix3 * s_x3;
331
332                    for iv1 in 0..nv1 {
333                        let v1 = -lv[0] + (iv1 as f64 + 0.5) * dv[0];
334                        for iv2 in 0..nv2 {
335                            let v2 = -lv[1] + (iv2 as f64 + 0.5) * dv[1];
336                            for iv3 in 0..nv3 {
337                                let v3 = -lv[2] + (iv3 as f64 + 0.5) * dv[2];
338
339                                // Angular momentum L_z = R * v_φ
340                                // v_φ = (v2*x1 - v1*x2) / R
341                                let lz = if r_cyl > 1e-30 {
342                                    v2 * x1 - v1 * x2 // = R * v_φ
343                                } else {
344                                    0.0
345                                };
346
347                                // Prograde disk only
348                                if lz <= 0.0 {
349                                    continue;
350                                }
351
352                                // Find guiding-center radius R_c(L_z)
353                                let r_c = self.find_guiding_radius(lz, r_max);
354
355                                // Properties at guiding center
356                                let vc2_rc = self.vc_squared(r_c);
357                                let vc_rc = vc2_rc.sqrt();
358                                let omega_rc = if r_c > 1e-30 {
359                                    vc_rc / r_c
360                                } else {
361                                    continue;
362                                };
363
364                                let sigma_r_rc = (self.disk_velocity_dispersion)(r_c);
365                                let sigma_surf_rc = (self.disk_surface_density)(r_c);
366                                let sigma_r2_rc = sigma_r_rc * sigma_r_rc;
367
368                                if sigma_r2_rc < 1e-30 || sigma_surf_rc < 1e-30 {
369                                    continue;
370                                }
371
372                                // Epicyclic frequency at R_c
373                                let eps_fd = 1e-4 * r_c.max(1e-8);
374                                let r_cp = r_c + eps_fd;
375                                let r_cm = (r_c - eps_fd).max(1e-30);
376                                let omega2_rc = omega_rc * omega_rc;
377                                let omega2_plus = self.vc_squared(r_cp) / (r_cp * r_cp);
378                                let omega2_minus = self.vc_squared(r_cm) / (r_cm * r_cm);
379                                let domega2_dr = (omega2_plus - omega2_minus) / (r_cp - r_cm);
380                                let kappa2_rc = r_c * domega2_dr + 4.0 * omega2_rc;
381                                if kappa2_rc <= 0.0 {
382                                    continue;
383                                }
384                                let kappa_rc = kappa2_rc.sqrt();
385
386                                // Circular orbit energy at R_c:
387                                // E_c = ½ v_c(R_c)² + Φ(R_c, 0)
388                                let phi_rc = self.total_potential(r_c, 0.0, [r_c, 0.0, 0.0]);
389                                let e_c = 0.5 * vc2_rc + phi_rc;
390
391                                // Total energy E = ½ v² + Φ(x)
392                                let v2sq = v1 * v1 + v2 * v2 + v3 * v3;
393                                let energy = 0.5 * v2sq + phi_total;
394
395                                // Shu DF: only for bound orbits near the guiding center
396                                let de = energy - e_c;
397                                if de > 0.0 {
398                                    // Unbound relative to circular orbit
399                                    continue;
400                                }
401
402                                // Exponential argument: -(E - E_c) / σ_R²
403                                let arg = -de / sigma_r2_rc;
404                                if arg > 500.0 {
405                                    continue; // overflow guard
406                                }
407
408                                // Shu DF (in-plane part):
409                                // f_shu = Σ(R_c) Ω(R_c) / (π κ(R_c) σ_R²(R_c))
410                                //         × exp(-(E-E_c)/σ_R²(R_c))
411                                let f_shu = sigma_surf_rc * omega_rc
412                                    / (std::f64::consts::PI * kappa_rc * sigma_r2_rc)
413                                    * arg.exp();
414
415                                // Combined: f = f_shu(E, Lz) × f_z(z) × perturbation
416                                let f_val = f_shu * f_z * pert_factor;
417
418                                if f_val > 0.0 {
419                                    data[base + iv1 * s_v1 + iv2 * s_v2 + iv3 * s_v3] = f_val;
420                                }
421                            }
422                        }
423                    }
424                }
425            }
426        }
427
428        PhaseSpaceSnapshot {
429            data,
430            shape: [nx1, nx2, nx3, nv1, nv2, nv3],
431            time: 0.0,
432        }
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::tooling::core::init::domain::{Domain, SpatialBoundType, VelocityBoundType};
440
441    #[test]
442    fn toomre_q_exponential_disk() {
443        // Exponential disk: Σ(R) = Σ₀ exp(-R/Rd), σ_R(R) = σ₀ exp(-R/(2Rd))
444        let rd = 1.0;
445        let sigma_0 = 0.5;
446        let sigma_d0 = 0.3;
447        let ic = DiskStabilityIC::new(
448            Box::new(move |r: f64| sigma_0 * (-r / rd).exp()),
449            Box::new(move |r: f64| sigma_d0 * (-r / (2.0 * rd)).exp()),
450            2,
451            0.0,
452            0.0,
453        );
454        let q = ic.toomre_q(2.0);
455        assert!(q.is_finite(), "Toomre Q should be finite, got {q}");
456        assert!(q > 0.0, "Toomre Q should be positive, got {q}");
457        println!("Toomre Q at R=2: {q:.4}");
458    }
459
460    #[test]
461    fn disk_stability_sample_on_grid() {
462        let rd = 3.0;
463        let sigma_0 = 0.5;
464        let sigma_d0 = 0.3;
465        let ic = DiskStabilityIC::new(
466            Box::new(move |r: f64| sigma_0 * (-r / rd).exp()),
467            Box::new(move |r: f64| sigma_d0 * (-r / (2.0 * rd)).exp()),
468            2,
469            0.1,
470            0.05,
471        );
472        let domain = Domain::builder()
473            .spatial_extent(8.0)
474            .velocity_extent(2.0)
475            .spatial_resolution(8)
476            .velocity_resolution(8)
477            .t_final(1.0)
478            .spatial_bc(SpatialBoundType::Periodic)
479            .velocity_bc(VelocityBoundType::Open)
480            .build()
481            .unwrap();
482        let snap = ic.sample_on_grid(&domain);
483        assert_eq!(snap.shape, [8, 8, 8, 8, 8, 8]);
484        assert!(snap.data.iter().all(|v| v.is_finite()));
485        assert!(
486            snap.data.iter().any(|&v| v > 0.0),
487            "should have some nonzero values"
488        );
489    }
490}