1use 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
19const 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#[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 pub fn cylindrical_radius_m(&self) -> f64 {
42 (self.x_m * self.x_m + self.y_m * self.y_m).sqrt()
43 }
44
45 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 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 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
185pub 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
271pub 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
333pub 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
389pub 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
464pub 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
500pub 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
546pub 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#[derive(Debug, Clone, Copy, PartialEq)]
627pub struct CoulombCollisionParams {
628 pub n_e: f64,
630 pub t_e_kev: f64,
632 pub t_i_kev: f64,
634 pub a_i: f64,
636 pub z_i: f64,
638 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
676pub 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 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
694pub 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
744pub 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 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
772pub 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; let _ = params;
793
794 let v_safe = speed.max(1e-6);
795 let x3 = (v_safe / v_c).powi(3);
796
797 let nu_slow = (1.0 + x3) / tau_s;
799
800 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 let nu_energy = nu_slow * 0.5; Ok((nu_slow, nu_defl, nu_energy))
811}
812
813fn 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
823fn 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
830fn perpendicular_basis(v_hat: [f64; 3]) -> ([f64; 3], [f64; 3]) {
832 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 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 let e2 = cross(v_hat, e1);
851 (e1, e2)
852}
853
854pub 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(()); }
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 let v_hat = [v[0] / speed, v[1] / speed, v[2] / speed];
885
886 let dv_par = -nu_slow * speed * dt_s;
888
889 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 let new_speed_par = speed + dv_par;
899 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
915pub 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 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 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, 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 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 let low = coulomb_logarithm(1e30, 0.001).unwrap();
1404 assert!((low - 5.0).abs() < 1e-12, "should clamp to 5, got {low}");
1405 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 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 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; let (nu_s, nu_d, nu_e) = collision_frequencies(speed, ¶ms, 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 for _ in 0..100 {
1461 collision_step(&mut particles[0], ¶ms, 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, ¶ms, 1e-4, 42).unwrap();
1473 apply_coulomb_collisions(&mut p2, ¶ms, 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 assert!(dot(e1, v_hat).abs() < 1e-10, "e1 not perp to v_hat");
1502 assert!(dot(e2, v_hat).abs() < 1e-10, "e2 not perp to v_hat");
1504 assert!(dot(e1, e2).abs() < 1e-10, "e1 not perp to e2");
1506 assert!((dot(e1, e1).sqrt() - 1.0).abs() < 1e-10);
1508 assert!((dot(e2, e2).sqrt() - 1.0).abs() < 1e-10);
1509 }
1510 }
1511}