fusion_core/
particles.rs

1//! Reduced particle tracker overlay for hybrid MHD/PIC-style current feedback.
2//!
3//! This module introduces a deterministic charged-particle pusher using the
4//! Boris integrator and a simple toroidal current deposition path on the
5//! Grad-Shafranov grid.
6
7use fusion_types::error::{FusionError, FusionResult};
8use fusion_types::state::Grid2D;
9use ndarray::{Array1, Array2};
10use std::f64::consts::PI;
11
12const MIN_RADIUS_M: f64 = 1e-9;
13const MIN_CELL_AREA_M2: f64 = 1e-12;
14const MIN_CURRENT_INTEGRAL: f64 = 1e-9;
15const ELEMENTARY_CHARGE_C: f64 = 1.602_176_634e-19;
16const ALPHA_MASS_KG: f64 = 6.644_657_335_7e-27;
17const ALPHA_CHARGE_C: f64 = 2.0 * ELEMENTARY_CHARGE_C;
18
19// Coulomb collision constants
20const ELECTRON_MASS_KG: f64 = 9.109_383_701_5e-31;
21const PROTON_MASS_KG: f64 = 1.672_621_923_69e-27;
22const VACUUM_PERMITTIVITY: f64 = 8.854_187_812_8e-12;
23const BOLTZMANN_J_PER_KEV: f64 = 1.602_176_634e-16;
24
25/// Charged macro-particle state.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct ChargedParticle {
28    pub x_m: f64,
29    pub y_m: f64,
30    pub z_m: f64,
31    pub vx_m_s: f64,
32    pub vy_m_s: f64,
33    pub vz_m_s: f64,
34    pub charge_c: f64,
35    pub mass_kg: f64,
36    pub weight: f64,
37}
38
39impl ChargedParticle {
40    /// Cylindrical major radius R from Cartesian position.
41    pub fn cylindrical_radius_m(&self) -> f64 {
42        (self.x_m * self.x_m + self.y_m * self.y_m).sqrt()
43    }
44
45    /// Toroidal velocity component v_phi in cylindrical basis.
46    pub fn toroidal_velocity_m_s(&self) -> f64 {
47        let r = self.cylindrical_radius_m().max(MIN_RADIUS_M);
48        (-self.y_m * self.vx_m_s + self.x_m * self.vy_m_s) / r
49    }
50
51    /// Non-relativistic kinetic energy [J].
52    pub fn kinetic_energy_j(&self) -> f64 {
53        let v2 = self.vx_m_s * self.vx_m_s + self.vy_m_s * self.vy_m_s + self.vz_m_s * self.vz_m_s;
54        0.5 * self.mass_kg * v2
55    }
56
57    /// Non-relativistic kinetic energy [MeV].
58    pub fn kinetic_energy_mev(&self) -> f64 {
59        self.kinetic_energy_j() / (1.0e6 * ELEMENTARY_CHARGE_C)
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct ParticlePopulationSummary {
65    pub count: usize,
66    pub mean_energy_mev: f64,
67    pub p95_energy_mev: f64,
68    pub max_energy_mev: f64,
69    pub runaway_fraction: f64,
70}
71
72fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
73    [
74        a[1] * b[2] - a[2] * b[1],
75        a[2] * b[0] - a[0] * b[2],
76        a[0] * b[1] - a[1] * b[0],
77    ]
78}
79
80fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
81    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
82}
83
84fn validate_particle_state(particle: &ChargedParticle, label: &str) -> FusionResult<()> {
85    if !particle.x_m.is_finite() || !particle.y_m.is_finite() || !particle.z_m.is_finite() {
86        return Err(FusionError::PhysicsViolation(format!(
87            "{label} position components must be finite"
88        )));
89    }
90    if !particle.vx_m_s.is_finite() || !particle.vy_m_s.is_finite() || !particle.vz_m_s.is_finite()
91    {
92        return Err(FusionError::PhysicsViolation(format!(
93            "{label} velocity components must be finite"
94        )));
95    }
96    if !particle.charge_c.is_finite() {
97        return Err(FusionError::PhysicsViolation(format!(
98            "{label}.charge_c must be finite"
99        )));
100    }
101    if !particle.mass_kg.is_finite() || particle.mass_kg <= 0.0 {
102        return Err(FusionError::PhysicsViolation(format!(
103            "{label}.mass_kg must be finite and > 0"
104        )));
105    }
106    if !particle.weight.is_finite() || particle.weight <= 0.0 {
107        return Err(FusionError::PhysicsViolation(format!(
108            "{label}.weight must be finite and > 0"
109        )));
110    }
111    Ok(())
112}
113
114fn validate_particle_projection_grid(grid: &Grid2D, label: &str) -> FusionResult<()> {
115    if grid.nr == 0 || grid.nz == 0 {
116        return Err(FusionError::PhysicsViolation(format!(
117            "{label} requires non-empty grid dimensions"
118        )));
119    }
120    if grid.r.len() != grid.nr || grid.z.len() != grid.nz {
121        return Err(FusionError::PhysicsViolation(format!(
122            "{label} grid axis length mismatch: r_len={}, nr={}, z_len={}, nz={}",
123            grid.r.len(),
124            grid.nr,
125            grid.z.len(),
126            grid.nz
127        )));
128    }
129    if grid.rr.dim() != (grid.nz, grid.nr) || grid.zz.dim() != (grid.nz, grid.nr) {
130        return Err(FusionError::PhysicsViolation(format!(
131            "{label} grid mesh shape mismatch: rr={:?}, zz={:?}, expected=({}, {})",
132            grid.rr.dim(),
133            grid.zz.dim(),
134            grid.nz,
135            grid.nr
136        )));
137    }
138    if grid.rr.iter().any(|v| !v.is_finite()) || grid.zz.iter().any(|v| !v.is_finite()) {
139        return Err(FusionError::PhysicsViolation(format!(
140            "{label} grid mesh coordinates must be finite"
141        )));
142    }
143    if !grid.dr.is_finite() || !grid.dz.is_finite() || grid.dr == 0.0 || grid.dz == 0.0 {
144        return Err(FusionError::PhysicsViolation(format!(
145            "{label} grid spacing must be finite and non-zero, got dr={}, dz={}",
146            grid.dr, grid.dz
147        )));
148    }
149    if grid.r.iter().any(|v| !v.is_finite()) || grid.z.iter().any(|v| !v.is_finite()) {
150        return Err(FusionError::PhysicsViolation(format!(
151            "{label} grid axes must be finite"
152        )));
153    }
154    Ok(())
155}
156
157fn nearest_index(axis: &Array1<f64>, value: f64, label: &str) -> FusionResult<usize> {
158    if axis.is_empty() {
159        return Err(FusionError::PhysicsViolation(format!(
160            "{label} axis must be non-empty"
161        )));
162    }
163    if !value.is_finite() {
164        return Err(FusionError::PhysicsViolation(format!(
165            "{label} lookup coordinate must be finite"
166        )));
167    }
168    let mut best_idx = 0usize;
169    let mut best_dist = f64::INFINITY;
170    for (idx, x) in axis.iter().copied().enumerate() {
171        if !x.is_finite() {
172            return Err(FusionError::PhysicsViolation(format!(
173                "{label} axis contains non-finite coordinate at index {idx}"
174            )));
175        }
176        let dist = (x - value).abs();
177        if dist < best_dist {
178            best_dist = dist;
179            best_idx = idx;
180        }
181    }
182    Ok(best_idx)
183}
184
185/// Create deterministic alpha test particles for hybrid kinetic-fluid overlays.
186pub fn seed_alpha_test_particles(
187    n_particles: usize,
188    major_radius_m: f64,
189    z_m: f64,
190    kinetic_energy_mev: f64,
191    pitch_cos: f64,
192    weight_per_particle: f64,
193) -> FusionResult<Vec<ChargedParticle>> {
194    if n_particles == 0 {
195        return Err(FusionError::PhysicsViolation(
196            "n_particles must be >= 1".to_string(),
197        ));
198    }
199    if !major_radius_m.is_finite() || major_radius_m <= 0.0 {
200        return Err(FusionError::PhysicsViolation(
201            "major_radius_m must be finite and > 0".to_string(),
202        ));
203    }
204    if !z_m.is_finite() {
205        return Err(FusionError::PhysicsViolation(
206            "z_m must be finite".to_string(),
207        ));
208    }
209    if !kinetic_energy_mev.is_finite() || kinetic_energy_mev <= 0.0 {
210        return Err(FusionError::PhysicsViolation(
211            "kinetic_energy_mev must be finite and > 0".to_string(),
212        ));
213    }
214    if !pitch_cos.is_finite() || !(-1.0..=1.0).contains(&pitch_cos) {
215        return Err(FusionError::PhysicsViolation(
216            "pitch_cos must be finite and in [-1, 1]".to_string(),
217        ));
218    }
219    if !weight_per_particle.is_finite() || weight_per_particle <= 0.0 {
220        return Err(FusionError::PhysicsViolation(
221            "weight_per_particle must be finite and > 0".to_string(),
222        ));
223    }
224
225    let r0 = major_radius_m;
226    let energy_j = kinetic_energy_mev * 1.0e6 * ELEMENTARY_CHARGE_C;
227    if !energy_j.is_finite() || energy_j <= 0.0 {
228        return Err(FusionError::PhysicsViolation(format!(
229            "seeded particle energy_j must be finite and > 0, got {energy_j}"
230        )));
231    }
232    let speed = (2.0 * energy_j / ALPHA_MASS_KG).sqrt();
233    if !speed.is_finite() || speed <= 0.0 {
234        return Err(FusionError::PhysicsViolation(format!(
235            "seeded particle speed must be finite and > 0, got {speed}"
236        )));
237    }
238    let pitch = pitch_cos;
239    let v_par = speed * pitch;
240    let perp_factor = (1.0 - pitch * pitch).max(0.0);
241    let v_perp = speed * perp_factor.sqrt();
242    if !v_par.is_finite() || !v_perp.is_finite() {
243        return Err(FusionError::PhysicsViolation(
244            "seeded particle velocity components became non-finite".to_string(),
245        ));
246    }
247    let weight = weight_per_particle;
248
249    let mut out = Vec::with_capacity(n_particles);
250    for i in 0..n_particles {
251        let phi = 2.0 * PI * (i as f64) / (n_particles as f64);
252        let x = r0 * phi.cos();
253        let y = r0 * phi.sin();
254        let ex = -phi.sin();
255        let ey = phi.cos();
256        out.push(ChargedParticle {
257            x_m: x,
258            y_m: y,
259            z_m,
260            vx_m_s: v_perp * ex,
261            vy_m_s: v_perp * ey,
262            vz_m_s: v_par,
263            charge_c: ALPHA_CHARGE_C,
264            mass_kg: ALPHA_MASS_KG,
265            weight,
266        });
267    }
268    Ok(out)
269}
270
271/// Summarize kinetic state of a particle population.
272pub fn summarize_particle_population(
273    particles: &[ChargedParticle],
274    runaway_threshold_mev: f64,
275) -> FusionResult<ParticlePopulationSummary> {
276    if !runaway_threshold_mev.is_finite() || runaway_threshold_mev < 0.0 {
277        return Err(FusionError::PhysicsViolation(
278            "runaway_threshold_mev must be finite and >= 0".to_string(),
279        ));
280    }
281    if particles.is_empty() {
282        return Ok(ParticlePopulationSummary {
283            count: 0,
284            mean_energy_mev: 0.0,
285            p95_energy_mev: 0.0,
286            max_energy_mev: 0.0,
287            runaway_fraction: 0.0,
288        });
289    }
290
291    let mut energies: Vec<f64> = Vec::with_capacity(particles.len());
292    for (idx, particle) in particles.iter().enumerate() {
293        validate_particle_state(particle, &format!("particle[{idx}]"))?;
294        let energy = particle.kinetic_energy_mev();
295        if !energy.is_finite() || energy < 0.0 {
296            return Err(FusionError::PhysicsViolation(format!(
297                "particle[{idx}] kinetic energy must be finite and >= 0, got {energy}"
298            )));
299        }
300        energies.push(energy);
301    }
302    energies.sort_by(f64::total_cmp);
303    let count = energies.len();
304    let mean_energy_mev = energies.iter().sum::<f64>() / (count as f64);
305    if !mean_energy_mev.is_finite() {
306        return Err(FusionError::PhysicsViolation(
307            "mean particle energy became non-finite".to_string(),
308        ));
309    }
310    let p95_idx = ((count - 1) as f64 * 0.95).round() as usize;
311    let p95_energy_mev = energies[p95_idx.min(count - 1)];
312    let max_energy_mev = energies[count - 1];
313    let n_runaway = energies
314        .iter()
315        .filter(|&&e| e >= runaway_threshold_mev)
316        .count();
317    let runaway_fraction = (n_runaway as f64) / (count as f64);
318    if !runaway_fraction.is_finite() {
319        return Err(FusionError::PhysicsViolation(
320            "runaway fraction became non-finite".to_string(),
321        ));
322    }
323
324    Ok(ParticlePopulationSummary {
325        count,
326        mean_energy_mev,
327        p95_energy_mev,
328        max_energy_mev,
329        runaway_fraction,
330    })
331}
332
333/// Estimate alpha-particle heating power density [W/m^3] on the R-Z grid.
334pub fn estimate_alpha_heating_profile(
335    particles: &[ChargedParticle],
336    grid: &Grid2D,
337    confinement_tau_s: f64,
338) -> FusionResult<Array2<f64>> {
339    if !confinement_tau_s.is_finite() || confinement_tau_s <= 0.0 {
340        return Err(FusionError::PhysicsViolation(
341            "confinement_tau_s must be finite and > 0".to_string(),
342        ));
343    }
344    validate_particle_projection_grid(grid, "alpha heating profile")?;
345    let mut heat = Array2::zeros((grid.nz, grid.nr));
346    if particles.is_empty() {
347        return Ok(heat);
348    }
349
350    let tau = confinement_tau_s;
351    let cell_volume = (grid.dr.abs() * grid.dz.abs() * 2.0 * PI).max(MIN_CELL_AREA_M2);
352    let r_min = grid.r[0].min(grid.r[grid.nr - 1]);
353    let r_max = grid.r[0].max(grid.r[grid.nr - 1]);
354    let z_min = grid.z[0].min(grid.z[grid.nz - 1]);
355    let z_max = grid.z[0].max(grid.z[grid.nz - 1]);
356
357    for (idx, particle) in particles.iter().enumerate() {
358        validate_particle_state(particle, &format!("particle[{idx}]"))?;
359        let r = particle.cylindrical_radius_m();
360        let z = particle.z_m;
361        if r < r_min || r > r_max || z < z_min || z > z_max {
362            continue;
363        }
364        let ir = nearest_index(&grid.r, r, "alpha heating R-axis")?;
365        let iz = nearest_index(&grid.z, z, "alpha heating Z-axis")?;
366        let p_w = particle.kinetic_energy_j() * particle.weight / tau;
367        if !p_w.is_finite() {
368            return Err(FusionError::PhysicsViolation(format!(
369                "particle[{idx}] deposited heating power became non-finite"
370            )));
371        }
372        let local_volume = (cell_volume * r.max(MIN_RADIUS_M)).max(MIN_CELL_AREA_M2);
373        let contribution = p_w / local_volume;
374        if !contribution.is_finite() {
375            return Err(FusionError::PhysicsViolation(format!(
376                "particle[{idx}] heating contribution became non-finite"
377            )));
378        }
379        heat[[iz, ir]] += contribution;
380    }
381    if heat.iter().any(|v| !v.is_finite()) {
382        return Err(FusionError::PhysicsViolation(
383            "alpha heating profile contains non-finite values".to_string(),
384        ));
385    }
386    Ok(heat)
387}
388
389/// Advance one particle state using the Boris push.
390pub fn boris_push_step(
391    particle: &mut ChargedParticle,
392    electric_v_m: [f64; 3],
393    magnetic_t: [f64; 3],
394    dt_s: f64,
395) -> FusionResult<()> {
396    validate_particle_state(particle, "particle")?;
397    if electric_v_m.iter().any(|v| !v.is_finite()) || magnetic_t.iter().any(|v| !v.is_finite()) {
398        return Err(FusionError::PhysicsViolation(
399            "electric_v_m and magnetic_t must be finite vectors".to_string(),
400        ));
401    }
402    if !dt_s.is_finite() || dt_s <= 0.0 {
403        return Err(FusionError::PhysicsViolation(
404            "dt_s must be finite and > 0".to_string(),
405        ));
406    }
407
408    let qmdt2 = particle.charge_c * dt_s / (2.0 * particle.mass_kg);
409    if !qmdt2.is_finite() {
410        return Err(FusionError::PhysicsViolation(
411            "boris qmdt2 became non-finite".to_string(),
412        ));
413    }
414    let v_minus = [
415        particle.vx_m_s + qmdt2 * electric_v_m[0],
416        particle.vy_m_s + qmdt2 * electric_v_m[1],
417        particle.vz_m_s + qmdt2 * electric_v_m[2],
418    ];
419
420    let t = [
421        qmdt2 * magnetic_t[0],
422        qmdt2 * magnetic_t[1],
423        qmdt2 * magnetic_t[2],
424    ];
425    let t2 = dot(t, t);
426    let s = [
427        (2.0 * t[0]) / (1.0 + t2),
428        (2.0 * t[1]) / (1.0 + t2),
429        (2.0 * t[2]) / (1.0 + t2),
430    ];
431
432    let v_prime = {
433        let c = cross(v_minus, t);
434        [v_minus[0] + c[0], v_minus[1] + c[1], v_minus[2] + c[2]]
435    };
436    let v_plus = {
437        let c = cross(v_prime, s);
438        [v_minus[0] + c[0], v_minus[1] + c[1], v_minus[2] + c[2]]
439    };
440
441    let vx_new = v_plus[0] + qmdt2 * electric_v_m[0];
442    let vy_new = v_plus[1] + qmdt2 * electric_v_m[1];
443    let vz_new = v_plus[2] + qmdt2 * electric_v_m[2];
444    if !vx_new.is_finite() || !vy_new.is_finite() || !vz_new.is_finite() {
445        return Err(FusionError::PhysicsViolation(
446            "boris velocity update became non-finite".to_string(),
447        ));
448    }
449
450    particle.vx_m_s = vx_new;
451    particle.vy_m_s = vy_new;
452    particle.vz_m_s = vz_new;
453    particle.x_m += vx_new * dt_s;
454    particle.y_m += vy_new * dt_s;
455    particle.z_m += vz_new * dt_s;
456    if !particle.x_m.is_finite() || !particle.y_m.is_finite() || !particle.z_m.is_finite() {
457        return Err(FusionError::PhysicsViolation(
458            "boris position update became non-finite".to_string(),
459        ));
460    }
461    Ok(())
462}
463
464/// Advance a particle set for a fixed number of Boris steps.
465pub fn advance_particles_boris(
466    particles: &mut [ChargedParticle],
467    electric_v_m: [f64; 3],
468    magnetic_t: [f64; 3],
469    dt_s: f64,
470    steps: usize,
471) -> FusionResult<()> {
472    if particles.is_empty() {
473        return Err(FusionError::PhysicsViolation(
474            "particles must be non-empty".to_string(),
475        ));
476    }
477    if steps == 0 {
478        return Err(FusionError::PhysicsViolation(
479            "steps must be >= 1".to_string(),
480        ));
481    }
482    if !dt_s.is_finite() || dt_s <= 0.0 {
483        return Err(FusionError::PhysicsViolation(
484            "dt_s must be finite and > 0".to_string(),
485        ));
486    }
487    for _ in 0..steps {
488        for (idx, particle) in particles.iter_mut().enumerate() {
489            boris_push_step(particle, electric_v_m, magnetic_t, dt_s).map_err(|err| match err {
490                FusionError::PhysicsViolation(msg) => FusionError::PhysicsViolation(format!(
491                    "particle[{idx}] boris push failed: {msg}"
492                )),
493                other => other,
494            })?;
495        }
496    }
497    Ok(())
498}
499
500/// Deposit particle toroidal current density J_phi on the GS R-Z grid.
501pub fn deposit_toroidal_current_density(
502    particles: &[ChargedParticle],
503    grid: &Grid2D,
504) -> FusionResult<Array2<f64>> {
505    validate_particle_projection_grid(grid, "particle current deposition")?;
506    if particles.is_empty() {
507        return Err(FusionError::PhysicsViolation(
508            "particles must be non-empty".to_string(),
509        ));
510    }
511    let mut j_phi: Array2<f64> = Array2::zeros((grid.nz, grid.nr));
512    let area = (grid.dr.abs() * grid.dz.abs()).max(MIN_CELL_AREA_M2);
513    let r_min = grid.r[0].min(grid.r[grid.nr - 1]);
514    let r_max = grid.r[0].max(grid.r[grid.nr - 1]);
515    let z_min = grid.z[0].min(grid.z[grid.nz - 1]);
516    let z_max = grid.z[0].max(grid.z[grid.nz - 1]);
517
518    for (idx, particle) in particles.iter().enumerate() {
519        validate_particle_state(particle, &format!("particle[{idx}]"))?;
520        let r = particle.cylindrical_radius_m();
521        let z = particle.z_m;
522        if r < r_min || r > r_max || z < z_min || z > z_max {
523            continue;
524        }
525
526        let ir = nearest_index(&grid.r, r, "particle current R-axis")?;
527        let iz = nearest_index(&grid.z, z, "particle current Z-axis")?;
528        let v_phi = particle.toroidal_velocity_m_s();
529        let j_contrib = particle.charge_c * particle.weight * v_phi / area;
530        if !j_contrib.is_finite() {
531            return Err(FusionError::PhysicsViolation(format!(
532                "particle[{idx}] toroidal current contribution became non-finite"
533            )));
534        }
535        j_phi[[iz, ir]] += j_contrib;
536    }
537
538    if j_phi.iter().any(|v| !v.is_finite()) {
539        return Err(FusionError::PhysicsViolation(
540            "deposited particle current contains non-finite values".to_string(),
541        ));
542    }
543    Ok(j_phi)
544}
545
546/// Blend fluid and particle current maps and renormalize integral to `i_target`.
547pub fn blend_particle_current(
548    fluid_j_phi: &Array2<f64>,
549    particle_j_phi: &Array2<f64>,
550    grid: &Grid2D,
551    i_target: f64,
552    particle_coupling: f64,
553) -> FusionResult<Array2<f64>> {
554    validate_particle_projection_grid(grid, "particle current blend")?;
555    let expected_shape = (grid.nz, grid.nr);
556    if fluid_j_phi.dim() != expected_shape || particle_j_phi.dim() != expected_shape {
557        return Err(FusionError::PhysicsViolation(format!(
558            "Particle-current blend shape mismatch: expected {:?}, fluid {:?}, particle {:?}",
559            expected_shape,
560            fluid_j_phi.dim(),
561            particle_j_phi.dim(),
562        )));
563    }
564    if !particle_coupling.is_finite() || !(0.0..=1.0).contains(&particle_coupling) {
565        return Err(FusionError::PhysicsViolation(
566            "particle_coupling must be finite and in [0, 1]".to_string(),
567        ));
568    }
569    if !i_target.is_finite() {
570        return Err(FusionError::PhysicsViolation(
571            "i_target must be finite".to_string(),
572        ));
573    }
574    if fluid_j_phi.iter().any(|v| !v.is_finite()) || particle_j_phi.iter().any(|v| !v.is_finite()) {
575        return Err(FusionError::PhysicsViolation(
576            "fluid_j_phi and particle_j_phi must be finite".to_string(),
577        ));
578    }
579
580    let coupling = particle_coupling;
581    let fluid_weight = 1.0 - coupling;
582    let mut combined = Array2::zeros(expected_shape);
583
584    for iz in 0..grid.nz {
585        for ir in 0..grid.nr {
586            combined[[iz, ir]] =
587                fluid_weight * fluid_j_phi[[iz, ir]] + coupling * particle_j_phi[[iz, ir]];
588        }
589    }
590
591    let i_current = combined.iter().sum::<f64>() * grid.dr * grid.dz;
592    if !i_current.is_finite() {
593        return Err(FusionError::PhysicsViolation(
594            "blended current integral became non-finite".to_string(),
595        ));
596    }
597    if i_current.abs() > MIN_CURRENT_INTEGRAL {
598        let scale = i_target / i_current;
599        if !scale.is_finite() {
600            return Err(FusionError::PhysicsViolation(
601                "blended current scale became non-finite".to_string(),
602            ));
603        }
604        combined.mapv_inplace(|v| v * scale);
605    } else if i_target.abs() > MIN_CURRENT_INTEGRAL {
606        return Err(FusionError::PhysicsViolation(format!(
607            "cannot renormalize blended current: |i_current| <= {MIN_CURRENT_INTEGRAL} while |i_target| > {MIN_CURRENT_INTEGRAL}"
608        )));
609    } else {
610        combined.fill(0.0);
611    }
612    if combined.iter().any(|v| !v.is_finite()) {
613        return Err(FusionError::PhysicsViolation(
614            "blended current map contains non-finite values".to_string(),
615        ));
616    }
617
618    Ok(combined)
619}
620
621// ═══════════════════════════════════════════════════════════════════════
622// Coulomb Collision Operator (Fokker-Planck Monte Carlo)
623// ═══════════════════════════════════════════════════════════════════════
624
625/// Parameters describing the background plasma for Coulomb collisions.
626#[derive(Debug, Clone, Copy, PartialEq)]
627pub struct CoulombCollisionParams {
628    /// Electron density [m^-3].
629    pub n_e: f64,
630    /// Electron temperature [keV].
631    pub t_e_kev: f64,
632    /// Ion temperature [keV].
633    pub t_i_kev: f64,
634    /// Ion mass number (e.g. 2 for deuterium).
635    pub a_i: f64,
636    /// Ion charge number.
637    pub z_i: f64,
638    /// Effective charge Z_eff.
639    pub z_eff: f64,
640}
641
642fn validate_collision_params(p: &CoulombCollisionParams) -> FusionResult<()> {
643    if !p.n_e.is_finite() || p.n_e <= 0.0 {
644        return Err(FusionError::PhysicsViolation(
645            "n_e must be finite and > 0".into(),
646        ));
647    }
648    if !p.t_e_kev.is_finite() || p.t_e_kev <= 0.0 {
649        return Err(FusionError::PhysicsViolation(
650            "t_e_kev must be finite and > 0".into(),
651        ));
652    }
653    if !p.t_i_kev.is_finite() || p.t_i_kev <= 0.0 {
654        return Err(FusionError::PhysicsViolation(
655            "t_i_kev must be finite and > 0".into(),
656        ));
657    }
658    if !p.a_i.is_finite() || p.a_i <= 0.0 {
659        return Err(FusionError::PhysicsViolation(
660            "a_i must be finite and > 0".into(),
661        ));
662    }
663    if !p.z_i.is_finite() || p.z_i <= 0.0 {
664        return Err(FusionError::PhysicsViolation(
665            "z_i must be finite and > 0".into(),
666        ));
667    }
668    if !p.z_eff.is_finite() || p.z_eff < 1.0 {
669        return Err(FusionError::PhysicsViolation(
670            "z_eff must be finite and >= 1".into(),
671        ));
672    }
673    Ok(())
674}
675
676/// Coulomb logarithm via NRL formula, clamped to [5, 30].
677pub fn coulomb_logarithm(n_e_m3: f64, t_e_kev: f64) -> FusionResult<f64> {
678    if !n_e_m3.is_finite() || n_e_m3 <= 0.0 {
679        return Err(FusionError::PhysicsViolation(
680            "n_e must be finite and > 0".into(),
681        ));
682    }
683    if !t_e_kev.is_finite() || t_e_kev <= 0.0 {
684        return Err(FusionError::PhysicsViolation(
685            "t_e_kev must be finite and > 0".into(),
686        ));
687    }
688    // NRL Formulary: ln Λ ≈ 24 − ln(sqrt(n_e) / T_e)  [T_e in eV]
689    let t_e_ev = t_e_kev * 1000.0;
690    let ln_lambda = 24.0 - (n_e_m3.sqrt() / t_e_ev).ln();
691    Ok(ln_lambda.clamp(5.0, 30.0))
692}
693
694/// Spitzer slowing-down time [s] for a test particle on field electrons.
695///
696/// τ_s = 3(2π)^{3/2} ε₀² m_a T_e^{3/2} / (n_e Z_a² e⁴ m_e^{1/2} ln Λ)
697pub fn spitzer_slowing_down_time(
698    mass_kg: f64,
699    charge_number: f64,
700    n_e_m3: f64,
701    t_e_kev: f64,
702    ln_lambda: f64,
703) -> FusionResult<f64> {
704    if !mass_kg.is_finite() || mass_kg <= 0.0 {
705        return Err(FusionError::PhysicsViolation(
706            "mass_kg must be finite and > 0".into(),
707        ));
708    }
709    if !charge_number.is_finite() || charge_number <= 0.0 {
710        return Err(FusionError::PhysicsViolation(
711            "charge_number must be > 0".into(),
712        ));
713    }
714    if !n_e_m3.is_finite() || n_e_m3 <= 0.0 {
715        return Err(FusionError::PhysicsViolation("n_e must be > 0".into()));
716    }
717    if !t_e_kev.is_finite() || t_e_kev <= 0.0 {
718        return Err(FusionError::PhysicsViolation("t_e_kev must be > 0".into()));
719    }
720    if !ln_lambda.is_finite() || ln_lambda <= 0.0 {
721        return Err(FusionError::PhysicsViolation(
722            "ln_lambda must be > 0".into(),
723        ));
724    }
725    let t_e_j = t_e_kev * BOLTZMANN_J_PER_KEV;
726    let e = ELEMENTARY_CHARGE_C;
727    let numerator = 3.0
728        * (2.0 * PI).powf(1.5)
729        * VACUUM_PERMITTIVITY
730        * VACUUM_PERMITTIVITY
731        * mass_kg
732        * t_e_j.powf(1.5);
733    let denominator =
734        n_e_m3 * (charge_number * e).powi(2) * e * e * ELECTRON_MASS_KG.sqrt() * ln_lambda;
735    let tau = numerator / denominator;
736    if !tau.is_finite() || tau <= 0.0 {
737        return Err(FusionError::PhysicsViolation(format!(
738            "spitzer time became non-physical: {tau}"
739        )));
740    }
741    Ok(tau)
742}
743
744/// Critical velocity where electron drag equals ion drag [m/s].
745pub fn critical_velocity(t_e_kev: f64, a_i: f64, z_i: f64, z_eff: f64) -> FusionResult<f64> {
746    if !t_e_kev.is_finite() || t_e_kev <= 0.0 {
747        return Err(FusionError::PhysicsViolation("t_e_kev must be > 0".into()));
748    }
749    if !a_i.is_finite() || a_i <= 0.0 {
750        return Err(FusionError::PhysicsViolation("a_i must be > 0".into()));
751    }
752    if !z_i.is_finite() || z_i <= 0.0 {
753        return Err(FusionError::PhysicsViolation("z_i must be > 0".into()));
754    }
755    if !z_eff.is_finite() || z_eff < 1.0 {
756        return Err(FusionError::PhysicsViolation("z_eff must be >= 1".into()));
757    }
758    let t_e_j = t_e_kev * BOLTZMANN_J_PER_KEV;
759    let v_te = (2.0 * t_e_j / ELECTRON_MASS_KG).sqrt();
760    // v_c = v_te * (3 sqrt(π) m_e / (4 m_i))^{1/3} * Z_eff^{1/3}
761    let mass_ratio = ELECTRON_MASS_KG / (a_i * PROTON_MASS_KG);
762    let factor = (0.75 * PI.sqrt() * mass_ratio).powf(1.0 / 3.0);
763    let v_c = v_te * factor * z_eff.powf(1.0 / 3.0);
764    if !v_c.is_finite() || v_c <= 0.0 {
765        return Err(FusionError::PhysicsViolation(format!(
766            "critical velocity non-physical: {v_c}"
767        )));
768    }
769    Ok(v_c)
770}
771
772/// Collision frequencies (ν_slow, ν_defl, ν_energy) for a test particle at given speed.
773pub fn collision_frequencies(
774    speed: f64,
775    params: &CoulombCollisionParams,
776    ln_lambda: f64,
777    tau_s: f64,
778    v_c: f64,
779) -> FusionResult<(f64, f64, f64)> {
780    if !speed.is_finite() || speed < 0.0 {
781        return Err(FusionError::PhysicsViolation(
782            "speed must be finite and >= 0".into(),
783        ));
784    }
785    if !tau_s.is_finite() || tau_s <= 0.0 {
786        return Err(FusionError::PhysicsViolation("tau_s must be > 0".into()));
787    }
788    if !v_c.is_finite() || v_c <= 0.0 {
789        return Err(FusionError::PhysicsViolation("v_c must be > 0".into()));
790    }
791    let _ = ln_lambda; // used indirectly via tau_s
792    let _ = params;
793
794    let v_safe = speed.max(1e-6);
795    let x3 = (v_safe / v_c).powi(3);
796
797    // Slowing-down frequency: ν_s = (1 + x³) / τ_s  where x = v/v_c
798    let nu_slow = (1.0 + x3) / tau_s;
799
800    // Deflection (pitch-angle scattering): ν_d ≈ (1 + Z_eff/2) / (τ_s · x³)
801    let nu_defl = if x3 > 1e-30 {
802        1.0 / (tau_s * x3)
803    } else {
804        1.0 / (tau_s * 1e-30)
805    };
806
807    // Energy diffusion: ν_ε ≈ 2 T_e / (m v² τ_s)
808    let nu_energy = nu_slow * 0.5; // simplified
809
810    Ok((nu_slow, nu_defl, nu_energy))
811}
812
813/// XORshift64 PRNG: returns next state and a uniform f64 in [0, 1).
814fn xorshift_uniform(state: &mut u64) -> f64 {
815    let mut s = *state;
816    s ^= s << 13;
817    s ^= s >> 7;
818    s ^= s << 17;
819    *state = s;
820    (s as f64) / (u64::MAX as f64)
821}
822
823/// Box-Muller transform producing one standard normal variate from xorshift.
824fn xorshift_normal(state: &mut u64) -> f64 {
825    let u1 = xorshift_uniform(state).max(1e-300);
826    let u2 = xorshift_uniform(state);
827    (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
828}
829
830/// Find an orthonormal basis (e1, e2) perpendicular to unit vector `v_hat`.
831fn perpendicular_basis(v_hat: [f64; 3]) -> ([f64; 3], [f64; 3]) {
832    // Choose the axis most perpendicular to v_hat
833    let abs_x = v_hat[0].abs();
834    let abs_y = v_hat[1].abs();
835    let abs_z = v_hat[2].abs();
836    let seed = if abs_x <= abs_y && abs_x <= abs_z {
837        [1.0, 0.0, 0.0]
838    } else if abs_y <= abs_z {
839        [0.0, 1.0, 0.0]
840    } else {
841        [0.0, 0.0, 1.0]
842    };
843
844    // e1 = normalize(seed × v_hat)
845    let raw = cross(seed, v_hat);
846    let norm = dot(raw, raw).sqrt().max(1e-30);
847    let e1 = [raw[0] / norm, raw[1] / norm, raw[2] / norm];
848
849    // e2 = v_hat × e1
850    let e2 = cross(v_hat, e1);
851    (e1, e2)
852}
853
854/// Apply Coulomb collision Monte Carlo kick to a single particle.
855///
856/// Uses Langevin approach: drag (slowing-down) + stochastic pitch-angle
857/// scattering and energy diffusion over timestep dt_s.
858pub fn collision_step(
859    particle: &mut ChargedParticle,
860    params: &CoulombCollisionParams,
861    dt_s: f64,
862    rng_state: &mut u64,
863) -> FusionResult<()> {
864    validate_particle_state(particle, "collision particle")?;
865    validate_collision_params(params)?;
866    if !dt_s.is_finite() || dt_s <= 0.0 {
867        return Err(FusionError::PhysicsViolation("dt_s must be > 0".into()));
868    }
869
870    let v = [particle.vx_m_s, particle.vy_m_s, particle.vz_m_s];
871    let speed = dot(v, v).sqrt();
872    if speed < 1e-10 {
873        return Ok(()); // particle at rest — no collision
874    }
875
876    let ln_lam = coulomb_logarithm(params.n_e, params.t_e_kev)?;
877    let za = (particle.charge_c / ELEMENTARY_CHARGE_C).abs();
878    let tau_s =
879        spitzer_slowing_down_time(particle.mass_kg, za, params.n_e, params.t_e_kev, ln_lam)?;
880    let v_c = critical_velocity(params.t_e_kev, params.a_i, params.z_i, params.z_eff)?;
881    let (nu_slow, nu_defl, _nu_energy) = collision_frequencies(speed, params, ln_lam, tau_s, v_c)?;
882
883    // Unit velocity direction
884    let v_hat = [v[0] / speed, v[1] / speed, v[2] / speed];
885
886    // 1. Slowing-down drag: Δv_∥ = -ν_s · v · dt
887    let dv_par = -nu_slow * speed * dt_s;
888
889    // 2. Pitch-angle scattering: random perpendicular kick
890    //    σ_perp = v · sqrt(ν_d · dt)
891    let sigma_perp = speed * (nu_defl * dt_s).sqrt();
892    let kick1 = xorshift_normal(rng_state) * sigma_perp;
893    let kick2 = xorshift_normal(rng_state) * sigma_perp;
894
895    let (e1, e2) = perpendicular_basis(v_hat);
896
897    // New velocity = (v + dv_par) v_hat + kick1 e1 + kick2 e2
898    let new_speed_par = speed + dv_par;
899    // Ensure speed doesn't go negative (particle thermalized)
900    let new_speed_par = new_speed_par.max(0.0);
901
902    particle.vx_m_s = new_speed_par * v_hat[0] + kick1 * e1[0] + kick2 * e2[0];
903    particle.vy_m_s = new_speed_par * v_hat[1] + kick1 * e1[1] + kick2 * e2[1];
904    particle.vz_m_s = new_speed_par * v_hat[2] + kick1 * e1[2] + kick2 * e2[2];
905
906    if !particle.vx_m_s.is_finite() || !particle.vy_m_s.is_finite() || !particle.vz_m_s.is_finite()
907    {
908        return Err(FusionError::PhysicsViolation(
909            "collision step produced non-finite velocity".into(),
910        ));
911    }
912    Ok(())
913}
914
915/// Apply Coulomb collisions to all particles in a batch.
916pub fn apply_coulomb_collisions(
917    particles: &mut [ChargedParticle],
918    params: &CoulombCollisionParams,
919    dt_s: f64,
920    seed: u64,
921) -> FusionResult<()> {
922    validate_collision_params(params)?;
923    if particles.is_empty() {
924        return Ok(());
925    }
926    if !dt_s.is_finite() || dt_s <= 0.0 {
927        return Err(FusionError::PhysicsViolation("dt_s must be > 0".into()));
928    }
929    if seed == 0 {
930        return Err(FusionError::PhysicsViolation(
931            "seed must be != 0 for xorshift".into(),
932        ));
933    }
934
935    for (idx, particle) in particles.iter_mut().enumerate() {
936        // Each particle gets a deterministic RNG state derived from seed + index
937        let mut rng = seed
938            .wrapping_add(idx as u64)
939            .wrapping_mul(6364136223846793005)
940            .wrapping_add(1);
941        if rng == 0 {
942            rng = 1;
943        }
944        collision_step(particle, params, dt_s, &mut rng).map_err(|e| match e {
945            FusionError::PhysicsViolation(msg) => {
946                FusionError::PhysicsViolation(format!("particle[{idx}] collision failed: {msg}"))
947            }
948            other => other,
949        })?;
950    }
951    Ok(())
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    #[test]
959    fn test_boris_push_preserves_speed_without_electric_field() {
960        let particle = ChargedParticle {
961            x_m: 2.0,
962            y_m: 0.0,
963            z_m: 0.0,
964            vx_m_s: 80_000.0,
965            vy_m_s: 10_000.0,
966            vz_m_s: 5_000.0,
967            charge_c: 1.602_176_634e-19,
968            mass_kg: 1.672_621_923_69e-27,
969            weight: 1.0,
970        };
971        let speed_0 =
972            (particle.vx_m_s.powi(2) + particle.vy_m_s.powi(2) + particle.vz_m_s.powi(2)).sqrt();
973        let mut particles = vec![particle];
974        advance_particles_boris(&mut particles, [0.0, 0.0, 0.0], [0.0, 0.0, 2.5], 5e-10, 600)
975            .expect("valid boris advance should succeed");
976        let updated = particles[0];
977        let speed_1 =
978            (updated.vx_m_s.powi(2) + updated.vy_m_s.powi(2) + updated.vz_m_s.powi(2)).sqrt();
979        let rel = (speed_1 - speed_0).abs() / speed_0;
980        assert!(rel < 1e-10, "Speed drift too high for Boris push: {rel}");
981    }
982
983    #[test]
984    fn test_toroidal_current_deposition_is_nonzero() {
985        let grid = Grid2D::new(17, 17, 1.0, 9.0, -4.0, 4.0);
986        let particles = vec![
987            ChargedParticle {
988                x_m: 5.0,
989                y_m: 0.0,
990                z_m: 0.0,
991                vx_m_s: 0.0,
992                vy_m_s: 100_000.0,
993                vz_m_s: 0.0,
994                charge_c: 1.602_176_634e-19,
995                mass_kg: 1.672_621_923_69e-27,
996                weight: 2.0e16,
997            },
998            ChargedParticle {
999                x_m: 5.0,
1000                y_m: 0.2,
1001                z_m: 0.3,
1002                vx_m_s: 10_000.0,
1003                vy_m_s: 90_000.0,
1004                vz_m_s: -2_000.0,
1005                charge_c: 1.602_176_634e-19,
1006                mass_kg: 1.672_621_923_69e-27,
1007                weight: 1.5e16,
1008            },
1009        ];
1010
1011        let j = deposit_toroidal_current_density(&particles, &grid)
1012            .expect("valid particles/grid should deposit current");
1013        let sum_abs = j.iter().map(|v| v.abs()).sum::<f64>();
1014        assert!(
1015            sum_abs > 0.0,
1016            "Expected non-zero toroidal current deposition"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_blend_particle_current_renormalizes_target_current() {
1022        let grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1023        let fluid = Array2::from_elem((8, 8), 2.0);
1024        let particle = Array2::from_elem((8, 8), 6.0);
1025        let i_target = 15.0e6;
1026        let blended = blend_particle_current(&fluid, &particle, &grid, i_target, 0.25).unwrap();
1027        let i_actual = blended.iter().sum::<f64>() * grid.dr * grid.dz;
1028        let rel = ((i_actual - i_target) / i_target).abs();
1029        assert!(
1030            rel < 1e-12,
1031            "Blended current should match target after renormalization"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_blend_particle_current_shape_mismatch_errors() {
1037        let grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1038        let fluid = Array2::zeros((8, 8));
1039        let particle_bad = Array2::zeros((7, 8));
1040        let err = blend_particle_current(&fluid, &particle_bad, &grid, 1.0, 0.5).unwrap_err();
1041        match err {
1042            FusionError::PhysicsViolation(msg) => {
1043                assert!(msg.contains("shape mismatch"));
1044            }
1045            other => panic!("Unexpected error: {other:?}"),
1046        }
1047    }
1048
1049    #[test]
1050    fn test_blend_particle_current_rejects_invalid_coupling_and_target() {
1051        let grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1052        let fluid = Array2::from_elem((8, 8), 2.0);
1053        let particle = Array2::from_elem((8, 8), 6.0);
1054        for bad_coupling in [f64::NAN, -0.1, 1.1] {
1055            let err = blend_particle_current(&fluid, &particle, &grid, 1.0, bad_coupling)
1056                .expect_err("invalid coupling must error");
1057            match err {
1058                FusionError::PhysicsViolation(msg) => {
1059                    assert!(msg.contains("particle_coupling"));
1060                }
1061                other => panic!("Unexpected error: {other:?}"),
1062            }
1063        }
1064        let err = blend_particle_current(&fluid, &particle, &grid, f64::INFINITY, 0.5)
1065            .expect_err("non-finite target current must error");
1066        match err {
1067            FusionError::PhysicsViolation(msg) => {
1068                assert!(msg.contains("i_target"));
1069            }
1070            other => panic!("Unexpected error: {other:?}"),
1071        }
1072    }
1073
1074    #[test]
1075    fn test_seed_alpha_particles_matches_requested_energy_band() {
1076        let particles =
1077            seed_alpha_test_particles(24, 6.2, 0.0, 3.5, 0.6, 5.0e12).expect("valid seeds");
1078        assert_eq!(particles.len(), 24);
1079        let summary = summarize_particle_population(&particles, 2.0).expect("valid threshold");
1080        assert!(summary.mean_energy_mev > 3.0);
1081        assert!(summary.max_energy_mev < 4.2);
1082        assert!(summary.runaway_fraction > 0.9);
1083    }
1084
1085    #[test]
1086    fn test_alpha_heating_profile_is_positive_when_particles_in_domain() {
1087        let grid = Grid2D::new(33, 33, 3.0, 9.0, -2.5, 2.5);
1088        let particles =
1089            seed_alpha_test_particles(16, 6.0, 0.1, 3.5, 0.4, 1.0e13).expect("valid seeds");
1090        let heat =
1091            estimate_alpha_heating_profile(&particles, &grid, 0.25).expect("valid confinement");
1092        let total = heat.iter().sum::<f64>();
1093        assert!(total > 0.0, "Expected positive deposited alpha heating");
1094        assert!(!heat.iter().any(|v| !v.is_finite()));
1095    }
1096
1097    #[test]
1098    fn test_alpha_heating_profile_supports_descending_axes() {
1099        let grid = Grid2D::new(33, 33, 9.0, 3.0, 2.5, -2.5);
1100        let particles =
1101            seed_alpha_test_particles(16, 6.0, 0.1, 3.5, 0.4, 1.0e13).expect("valid seeds");
1102        let heat =
1103            estimate_alpha_heating_profile(&particles, &grid, 0.25).expect("valid confinement");
1104        let total = heat.iter().sum::<f64>();
1105        assert!(
1106            total > 0.0,
1107            "Expected positive deposited alpha heating on descending axes"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_alpha_heating_profile_rejects_invalid_confinement_time() {
1113        let grid = Grid2D::new(17, 17, 3.0, 9.0, -1.5, 1.5);
1114        let particles =
1115            seed_alpha_test_particles(8, 6.0, 0.0, 3.5, 0.2, 1.0e12).expect("valid seeds");
1116        for bad_tau in [0.0, f64::NAN] {
1117            let err = estimate_alpha_heating_profile(&particles, &grid, bad_tau)
1118                .expect_err("invalid confinement time must error");
1119            match err {
1120                FusionError::PhysicsViolation(msg) => {
1121                    assert!(msg.contains("confinement_tau_s"));
1122                }
1123                other => panic!("Unexpected error: {other:?}"),
1124            }
1125        }
1126    }
1127
1128    #[test]
1129    fn test_blend_particle_current_rejects_non_finite_maps() {
1130        let grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1131        let mut fluid = Array2::from_elem((8, 8), 2.0);
1132        let particle = Array2::from_elem((8, 8), 6.0);
1133        fluid[[0, 0]] = f64::NAN;
1134        let err = blend_particle_current(&fluid, &particle, &grid, 1.0, 0.5)
1135            .expect_err("non-finite current map must fail");
1136        match err {
1137            FusionError::PhysicsViolation(msg) => {
1138                assert!(msg.contains("must be finite"));
1139            }
1140            other => panic!("Unexpected error: {other:?}"),
1141        }
1142    }
1143
1144    #[test]
1145    fn test_blend_particle_current_rejects_zero_integral_nonzero_target() {
1146        let grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1147        let fluid = Array2::zeros((8, 8));
1148        let particle = Array2::zeros((8, 8));
1149        let err = blend_particle_current(&fluid, &particle, &grid, 1.0e6, 0.5)
1150            .expect_err("non-zero target with zero blended current must fail");
1151        match err {
1152            FusionError::PhysicsViolation(msg) => {
1153                assert!(msg.contains("cannot renormalize blended current"));
1154            }
1155            other => panic!("Unexpected error: {other:?}"),
1156        }
1157    }
1158
1159    #[test]
1160    fn test_blend_particle_current_rejects_invalid_grid_spacing() {
1161        let mut grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1162        grid.dr = 0.0;
1163        let fluid = Array2::from_elem((8, 8), 2.0);
1164        let particle = Array2::from_elem((8, 8), 6.0);
1165        let err = blend_particle_current(&fluid, &particle, &grid, 1.0, 0.5)
1166            .expect_err("invalid grid spacing must fail");
1167        match err {
1168            FusionError::PhysicsViolation(msg) => {
1169                assert!(msg.contains("grid spacing"));
1170            }
1171            other => panic!("Unexpected error: {other:?}"),
1172        }
1173    }
1174
1175    #[test]
1176    fn test_seed_alpha_particles_rejects_invalid_parameters() {
1177        let bad = [
1178            seed_alpha_test_particles(0, 6.2, 0.0, 3.5, 0.6, 1.0),
1179            seed_alpha_test_particles(8, 0.0, 0.0, 3.5, 0.6, 1.0),
1180            seed_alpha_test_particles(8, 6.2, f64::NAN, 3.5, 0.6, 1.0),
1181            seed_alpha_test_particles(8, 6.2, 0.0, -1.0, 0.6, 1.0),
1182            seed_alpha_test_particles(8, 6.2, 0.0, 3.5, 1.2, 1.0),
1183            seed_alpha_test_particles(8, 6.2, 0.0, 3.5, 0.6, 0.0),
1184        ];
1185        for candidate in bad {
1186            assert!(
1187                candidate.is_err(),
1188                "Expected invalid seed parameters to return an error"
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn test_seed_alpha_particles_rejects_non_finite_kinematics() {
1195        let err = seed_alpha_test_particles(4, 6.2, 0.0, f64::MAX, 0.6, 1.0)
1196            .expect_err("overflowing kinetic seed should fail");
1197        match err {
1198            FusionError::PhysicsViolation(msg) => {
1199                assert!(msg.contains("energy_j") || msg.contains("speed"));
1200            }
1201            other => panic!("Unexpected error: {other:?}"),
1202        }
1203    }
1204
1205    #[test]
1206    fn test_population_summary_empty_is_zero() {
1207        let summary = summarize_particle_population(&[], 1.0).expect("valid threshold");
1208        assert_eq!(summary.count, 0);
1209        assert_eq!(summary.mean_energy_mev, 0.0);
1210        assert_eq!(summary.runaway_fraction, 0.0);
1211    }
1212
1213    #[test]
1214    fn test_population_summary_rejects_invalid_threshold() {
1215        let particles = seed_alpha_test_particles(4, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds");
1216        for bad in [f64::NAN, -0.01] {
1217            let err = summarize_particle_population(&particles, bad)
1218                .expect_err("invalid threshold must error");
1219            match err {
1220                FusionError::PhysicsViolation(msg) => {
1221                    assert!(msg.contains("runaway_threshold_mev"));
1222                }
1223                other => panic!("Unexpected error: {other:?}"),
1224            }
1225        }
1226    }
1227
1228    #[test]
1229    fn test_blend_particle_current_rejects_non_finite_grid_mesh() {
1230        let mut grid = Grid2D::new(8, 8, 1.0, 5.0, -2.0, 2.0);
1231        grid.rr[[0, 0]] = f64::NAN;
1232        let fluid = Array2::from_elem((8, 8), 2.0);
1233        let particle = Array2::from_elem((8, 8), 6.0);
1234        let err = blend_particle_current(&fluid, &particle, &grid, 1.0, 0.5)
1235            .expect_err("non-finite grid mesh must fail");
1236        match err {
1237            FusionError::PhysicsViolation(msg) => {
1238                assert!(msg.contains("mesh coordinates"));
1239            }
1240            other => panic!("Unexpected error: {other:?}"),
1241        }
1242    }
1243
1244    #[test]
1245    fn test_population_summary_rejects_non_finite_particle_state() {
1246        let mut particles =
1247            seed_alpha_test_particles(4, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds");
1248        particles[0].vx_m_s = f64::INFINITY;
1249        let err = summarize_particle_population(&particles, 1.0)
1250            .expect_err("non-finite particle state must fail");
1251        match err {
1252            FusionError::PhysicsViolation(msg) => {
1253                assert!(msg.contains("velocity"));
1254            }
1255            other => panic!("Unexpected error: {other:?}"),
1256        }
1257    }
1258
1259    #[test]
1260    fn test_toroidal_current_deposition_rejects_invalid_particle_state() {
1261        let grid = Grid2D::new(17, 17, 1.0, 9.0, -4.0, 4.0);
1262        let mut particles =
1263            seed_alpha_test_particles(4, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds");
1264        particles[1].weight = f64::NAN;
1265        let err = deposit_toroidal_current_density(&particles, &grid)
1266            .expect_err("invalid particle state must fail");
1267        match err {
1268            FusionError::PhysicsViolation(msg) => {
1269                assert!(msg.contains("weight"));
1270            }
1271            other => panic!("Unexpected error: {other:?}"),
1272        }
1273    }
1274
1275    #[test]
1276    fn test_toroidal_current_deposition_rejects_empty_population() {
1277        let grid = Grid2D::new(17, 17, 1.0, 9.0, -4.0, 4.0);
1278        let err = deposit_toroidal_current_density(&[], &grid)
1279            .expect_err("empty particle population must fail");
1280        match err {
1281            FusionError::PhysicsViolation(msg) => {
1282                assert!(msg.contains("non-empty"));
1283            }
1284            other => panic!("Unexpected error: {other:?}"),
1285        }
1286    }
1287
1288    #[test]
1289    fn test_toroidal_current_deposition_supports_descending_axes() {
1290        let grid = Grid2D::new(17, 17, 9.0, 1.0, 4.0, -4.0);
1291        let particles = vec![ChargedParticle {
1292            x_m: 5.0,
1293            y_m: 0.0,
1294            z_m: 0.0,
1295            vx_m_s: 0.0,
1296            vy_m_s: 100_000.0,
1297            vz_m_s: 0.0,
1298            charge_c: 1.602_176_634e-19,
1299            mass_kg: 1.672_621_923_69e-27,
1300            weight: 2.0e16,
1301        }];
1302        let j = deposit_toroidal_current_density(&particles, &grid)
1303            .expect("descending axes should still deposit current");
1304        let sum_abs = j.iter().map(|v| v.abs()).sum::<f64>();
1305        assert!(
1306            sum_abs > 0.0,
1307            "Expected non-zero toroidal deposition on descending axes"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_boris_push_rejects_invalid_runtime_inputs() {
1313        let mut particle =
1314            seed_alpha_test_particles(1, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds")[0];
1315        let err = boris_push_step(&mut particle, [0.0, 0.0, f64::NAN], [0.0, 0.0, 2.5], 1e-9)
1316            .expect_err("non-finite electric field must fail");
1317        match err {
1318            FusionError::PhysicsViolation(msg) => {
1319                assert!(msg.contains("electric_v_m"));
1320            }
1321            other => panic!("Unexpected error: {other:?}"),
1322        }
1323        let err = boris_push_step(&mut particle, [0.0, 0.0, 0.0], [0.0, 0.0, 2.5], 0.0)
1324            .expect_err("non-positive dt must fail");
1325        match err {
1326            FusionError::PhysicsViolation(msg) => {
1327                assert!(msg.contains("dt_s"));
1328            }
1329            other => panic!("Unexpected error: {other:?}"),
1330        }
1331    }
1332
1333    #[test]
1334    fn test_advance_particles_boris_rejects_invalid_particle_state() {
1335        let mut particles =
1336            seed_alpha_test_particles(2, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds");
1337        particles[0].mass_kg = 0.0;
1338        let err =
1339            advance_particles_boris(&mut particles, [0.0, 0.0, 0.0], [0.0, 0.0, 2.5], 1e-9, 1)
1340                .expect_err("invalid particle mass must fail");
1341        match err {
1342            FusionError::PhysicsViolation(msg) => {
1343                assert!(msg.contains("particle[0]"));
1344            }
1345            other => panic!("Unexpected error: {other:?}"),
1346        }
1347    }
1348
1349    #[test]
1350    fn test_advance_particles_boris_rejects_empty_slice_and_zero_steps() {
1351        let mut empty: Vec<ChargedParticle> = Vec::new();
1352        let err = advance_particles_boris(&mut empty, [0.0, 0.0, 0.0], [0.0, 0.0, 2.5], 1e-9, 1)
1353            .expect_err("empty particle slice must fail");
1354        match err {
1355            FusionError::PhysicsViolation(msg) => {
1356                assert!(msg.contains("non-empty"));
1357            }
1358            other => panic!("Unexpected error: {other:?}"),
1359        }
1360
1361        let mut particles =
1362            seed_alpha_test_particles(1, 6.2, 0.0, 3.5, 0.2, 1.0).expect("valid seeds");
1363        let err =
1364            advance_particles_boris(&mut particles, [0.0, 0.0, 0.0], [0.0, 0.0, 2.5], 1e-9, 0)
1365                .expect_err("zero steps must fail");
1366        match err {
1367            FusionError::PhysicsViolation(msg) => {
1368                assert!(msg.contains("steps"));
1369            }
1370            other => panic!("Unexpected error: {other:?}"),
1371        }
1372    }
1373
1374    // ═══════════════════════════════════════════════════════════════════
1375    // Coulomb collision tests
1376    // ═══════════════════════════════════════════════════════════════════
1377
1378    fn default_collision_params() -> CoulombCollisionParams {
1379        CoulombCollisionParams {
1380            n_e: 1.0e20,
1381            t_e_kev: 10.0,
1382            t_i_kev: 8.0,
1383            a_i: 2.0, // deuterium
1384            z_i: 1.0,
1385            z_eff: 1.5,
1386        }
1387    }
1388
1389    #[test]
1390    fn test_coulomb_logarithm_range() {
1391        let ln_lam = coulomb_logarithm(1e20, 10.0).unwrap();
1392        assert!((5.0..=30.0).contains(&ln_lam), "ln_lambda={ln_lam}");
1393        // For fusion plasma, NRL formula gives ln Λ ≈ 10-20 range
1394        assert!(
1395            ln_lam > 5.0 && ln_lam < 25.0,
1396            "fusion plasma ln_lambda={ln_lam}"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_coulomb_logarithm_clamp() {
1402        // Very cold dense plasma -> clamped to 5
1403        let low = coulomb_logarithm(1e30, 0.001).unwrap();
1404        assert!((low - 5.0).abs() < 1e-12, "should clamp to 5, got {low}");
1405        // Moderately hot tenuous plasma -> still within [5,30]
1406        let mid = coulomb_logarithm(1e10, 1000.0).unwrap();
1407        assert!(
1408            (5.0..=30.0).contains(&mid),
1409            "should be in [5,30], got {mid}"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_coulomb_logarithm_rejects_invalid() {
1415        assert!(coulomb_logarithm(-1.0, 10.0).is_err());
1416        assert!(coulomb_logarithm(1e20, 0.0).is_err());
1417        assert!(coulomb_logarithm(1e20, f64::NAN).is_err());
1418    }
1419
1420    #[test]
1421    fn test_spitzer_slowing_down_time() {
1422        let ln_lam = coulomb_logarithm(1e20, 10.0).unwrap();
1423        let tau = spitzer_slowing_down_time(ALPHA_MASS_KG, 2.0, 1e20, 10.0, ln_lam).unwrap();
1424        // For 3.5 MeV alpha in ITER plasma, τ_s ~ 0.1-1.0 s
1425        assert!(
1426            tau > 0.01 && tau < 10.0,
1427            "spitzer time {tau} s out of range"
1428        );
1429    }
1430
1431    #[test]
1432    fn test_critical_velocity() {
1433        let v_c = critical_velocity(10.0, 2.0, 1.0, 1.5).unwrap();
1434        // v_c should be much less than speed of light but significant
1435        assert!(v_c > 1e5 && v_c < 1e8, "v_c={v_c} m/s");
1436    }
1437
1438    #[test]
1439    fn test_collision_frequencies_positive() {
1440        let params = default_collision_params();
1441        let ln_lam = coulomb_logarithm(params.n_e, params.t_e_kev).unwrap();
1442        let tau_s =
1443            spitzer_slowing_down_time(ALPHA_MASS_KG, 2.0, params.n_e, params.t_e_kev, ln_lam)
1444                .unwrap();
1445        let v_c = critical_velocity(params.t_e_kev, params.a_i, params.z_i, params.z_eff).unwrap();
1446        let speed = 1e7; // fast alpha
1447        let (nu_s, nu_d, nu_e) = collision_frequencies(speed, &params, ln_lam, tau_s, v_c).unwrap();
1448        assert!(nu_s > 0.0, "slowing-down frequency must be > 0");
1449        assert!(nu_d > 0.0, "deflection frequency must be > 0");
1450        assert!(nu_e > 0.0, "energy diffusion frequency must be > 0");
1451    }
1452
1453    #[test]
1454    fn test_collision_step_slows_alpha() {
1455        let params = default_collision_params();
1456        let mut particles = seed_alpha_test_particles(1, 6.2, 0.0, 3.5, 0.6, 1e12).unwrap();
1457        let e0 = particles[0].kinetic_energy_mev();
1458        let mut rng: u64 = 12345;
1459        // Many small collision steps should reduce energy
1460        for _ in 0..100 {
1461            collision_step(&mut particles[0], &params, 1e-3, &mut rng).unwrap();
1462        }
1463        let e1 = particles[0].kinetic_energy_mev();
1464        assert!(e1 < e0, "collisions should slow alpha: {e0} -> {e1} MeV");
1465    }
1466
1467    #[test]
1468    fn test_batch_collisions_deterministic() {
1469        let params = default_collision_params();
1470        let mut p1 = seed_alpha_test_particles(8, 6.2, 0.0, 3.5, 0.6, 1e12).unwrap();
1471        let mut p2 = p1.clone();
1472        apply_coulomb_collisions(&mut p1, &params, 1e-4, 42).unwrap();
1473        apply_coulomb_collisions(&mut p2, &params, 1e-4, 42).unwrap();
1474        for (a, b) in p1.iter().zip(p2.iter()) {
1475            assert!((a.vx_m_s - b.vx_m_s).abs() < 1e-12, "determinism broken");
1476            assert!((a.vy_m_s - b.vy_m_s).abs() < 1e-12, "determinism broken");
1477            assert!((a.vz_m_s - b.vz_m_s).abs() < 1e-12, "determinism broken");
1478        }
1479    }
1480
1481    #[test]
1482    fn test_collision_rejects_invalid_params() {
1483        let mut bad = default_collision_params();
1484        bad.n_e = -1.0;
1485        let mut p = seed_alpha_test_particles(1, 6.2, 0.0, 3.5, 0.6, 1e12).unwrap();
1486        assert!(apply_coulomb_collisions(&mut p, &bad, 1e-4, 42).is_err());
1487    }
1488
1489    #[test]
1490    fn test_perpendicular_basis_orthonormal() {
1491        for v_hat in [
1492            [1.0, 0.0, 0.0],
1493            [0.0, 1.0, 0.0],
1494            [0.0, 0.0, 1.0],
1495            [0.577, 0.577, 0.577],
1496        ] {
1497            let norm = dot(v_hat, v_hat).sqrt();
1498            let v_hat = [v_hat[0] / norm, v_hat[1] / norm, v_hat[2] / norm];
1499            let (e1, e2) = perpendicular_basis(v_hat);
1500            // e1 ⊥ v_hat
1501            assert!(dot(e1, v_hat).abs() < 1e-10, "e1 not perp to v_hat");
1502            // e2 ⊥ v_hat
1503            assert!(dot(e2, v_hat).abs() < 1e-10, "e2 not perp to v_hat");
1504            // e1 ⊥ e2
1505            assert!(dot(e1, e2).abs() < 1e-10, "e1 not perp to e2");
1506            // |e1| ≈ 1, |e2| ≈ 1
1507            assert!((dot(e1, e1).sqrt() - 1.0).abs() < 1e-10);
1508            assert!((dot(e2, e2).sqrt() - 1.0).abs() < 1e-10);
1509        }
1510    }
1511}