Skip to main content

oxiphysics_core/
statistical_mechanics.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Statistical mechanics: ensembles, Ising model, phase transitions, fluctuation-dissipation,
5//! Green-Kubo relations, and equations of state.
6//!
7//! Implements canonical, grand canonical, and microcanonical partition functions,
8//! Boltzmann distributions, 1D/2D Ising model with Metropolis MC, Landau theory,
9//! Einstein relations, transport coefficients, and classical equations of state.
10
11#![allow(dead_code)]
12
13// ─────────────────────────────────────────────────────────────────────────────
14// Local LCG RNG
15// ─────────────────────────────────────────────────────────────────────────────
16
17/// Lightweight linear congruential generator used internally.
18struct SmRng {
19    state: u64,
20}
21
22impl SmRng {
23    fn new(seed: u64) -> Self {
24        Self { state: seed.max(1) }
25    }
26
27    fn next_u64(&mut self) -> u64 {
28        self.state = self
29            .state
30            .wrapping_mul(6_364_136_223_846_793_005)
31            .wrapping_add(1_442_695_040_888_963_407);
32        self.state
33    }
34
35    fn next_f64(&mut self) -> f64 {
36        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
37    }
38}
39
40// ─────────────────────────────────────────────────────────────────────────────
41// PartitionFunction
42// ─────────────────────────────────────────────────────────────────────────────
43
44/// Ensemble type for computing the partition function.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum EnsembleKind {
47    /// Canonical ensemble (fixed N, V, T).
48    Canonical,
49    /// Grand canonical ensemble (fixed μ, V, T).
50    GrandCanonical,
51    /// Microcanonical ensemble (fixed N, V, E).
52    Microcanonical,
53}
54
55/// Partition function for statistical mechanical ensembles.
56///
57/// Stores a discrete energy spectrum and computes thermodynamic quantities
58/// such as free energy, entropy, and average energy.
59#[derive(Debug, Clone)]
60pub struct PartitionFunction {
61    /// Ensemble type.
62    pub kind: EnsembleKind,
63    /// Energy levels of the system.
64    pub energy_levels: Vec<f64>,
65    /// Degeneracy of each energy level.
66    pub degeneracies: Vec<f64>,
67    /// Temperature in units where k_B = 1.
68    pub temperature: f64,
69    /// Chemical potential (used for grand canonical ensemble).
70    pub chemical_potential: f64,
71    /// Particle numbers for each state (grand canonical).
72    pub particle_numbers: Vec<f64>,
73}
74
75impl PartitionFunction {
76    /// Constructs a canonical partition function from energy levels and degeneracies.
77    ///
78    /// # Arguments
79    /// * `energy_levels` - Vector of energy eigenvalues.
80    /// * `degeneracies`  - Degeneracy of each level (same length as `energy_levels`).
81    /// * `temperature`   - Temperature (k_B = 1 units).
82    pub fn canonical(energy_levels: Vec<f64>, degeneracies: Vec<f64>, temperature: f64) -> Self {
83        Self {
84            kind: EnsembleKind::Canonical,
85            energy_levels,
86            degeneracies,
87            temperature,
88            chemical_potential: 0.0,
89            particle_numbers: vec![],
90        }
91    }
92
93    /// Constructs a grand canonical partition function.
94    pub fn grand_canonical(
95        energy_levels: Vec<f64>,
96        degeneracies: Vec<f64>,
97        particle_numbers: Vec<f64>,
98        temperature: f64,
99        chemical_potential: f64,
100    ) -> Self {
101        Self {
102            kind: EnsembleKind::GrandCanonical,
103            energy_levels,
104            degeneracies,
105            temperature,
106            chemical_potential,
107            particle_numbers,
108        }
109    }
110
111    /// Constructs a microcanonical partition function (density of states).
112    pub fn microcanonical(energy_levels: Vec<f64>, degeneracies: Vec<f64>) -> Self {
113        Self {
114            kind: EnsembleKind::Microcanonical,
115            energy_levels,
116            degeneracies,
117            temperature: 0.0,
118            chemical_potential: 0.0,
119            particle_numbers: vec![],
120        }
121    }
122
123    /// Returns the canonical partition function Z = Σ g_i exp(-β E_i).
124    ///
125    /// Returns 0.0 if temperature is non-positive.
126    pub fn canonical_z(&self) -> f64 {
127        if self.temperature <= 0.0 {
128            return 0.0;
129        }
130        let beta = 1.0 / self.temperature;
131        self.energy_levels
132            .iter()
133            .zip(self.degeneracies.iter())
134            .map(|(&e, &g)| g * (-beta * e).exp())
135            .sum()
136    }
137
138    /// Returns the grand canonical partition function Ξ = Σ g_i exp(-β(E_i - μ N_i)).
139    pub fn grand_canonical_z(&self) -> f64 {
140        if self.temperature <= 0.0 {
141            return 0.0;
142        }
143        let beta = 1.0 / self.temperature;
144        let mu = self.chemical_potential;
145        let n_iter = self
146            .particle_numbers
147            .iter()
148            .chain(std::iter::repeat(&0.0_f64));
149        self.energy_levels
150            .iter()
151            .zip(self.degeneracies.iter())
152            .zip(n_iter)
153            .map(|((&e, &g), &n)| g * (-beta * (e - mu * n)).exp())
154            .sum()
155    }
156
157    /// Returns the microcanonical density of states Ω(E) for a target energy within tolerance.
158    ///
159    /// Sums degeneracies of levels within `±tolerance` of `target_energy`.
160    pub fn density_of_states(&self, target_energy: f64, tolerance: f64) -> f64 {
161        self.energy_levels
162            .iter()
163            .zip(self.degeneracies.iter())
164            .filter(|&(&e, _)| (e - target_energy).abs() <= tolerance)
165            .map(|(_, &g)| g)
166            .sum()
167    }
168
169    /// Returns the Helmholtz free energy F = -k_B T ln Z (canonical).
170    pub fn free_energy(&self) -> f64 {
171        let z = self.canonical_z();
172        if z <= 0.0 {
173            return f64::INFINITY;
174        }
175        -self.temperature * z.ln()
176    }
177
178    /// Returns the average energy ⟨E⟩ = -∂ ln Z / ∂β (canonical).
179    pub fn average_energy(&self) -> f64 {
180        if self.temperature <= 0.0 {
181            return 0.0;
182        }
183        let beta = 1.0 / self.temperature;
184        let z = self.canonical_z();
185        if z <= 0.0 {
186            return 0.0;
187        }
188        let num: f64 = self
189            .energy_levels
190            .iter()
191            .zip(self.degeneracies.iter())
192            .map(|(&e, &g)| g * e * (-beta * e).exp())
193            .sum();
194        num / z
195    }
196
197    /// Returns the entropy S = (⟨E⟩ - F) / T (canonical).
198    pub fn entropy(&self) -> f64 {
199        if self.temperature <= 0.0 {
200            return 0.0;
201        }
202        (self.average_energy() - self.free_energy()) / self.temperature
203    }
204
205    /// Returns the heat capacity C_V = ∂⟨E⟩/∂T = (⟨E²⟩ - ⟨E⟩²) / (k_B T²).
206    pub fn heat_capacity(&self) -> f64 {
207        if self.temperature <= 0.0 {
208            return 0.0;
209        }
210        let beta = 1.0 / self.temperature;
211        let z = self.canonical_z();
212        if z <= 0.0 {
213            return 0.0;
214        }
215        let e_avg = self.average_energy();
216        let e2_avg: f64 = self
217            .energy_levels
218            .iter()
219            .zip(self.degeneracies.iter())
220            .map(|(&e, &g)| g * e * e * (-beta * e).exp())
221            .sum::<f64>()
222            / z;
223        (e2_avg - e_avg * e_avg) / (self.temperature * self.temperature)
224    }
225}
226
227// ─────────────────────────────────────────────────────────────────────────────
228// BoltzmannDistribution
229// ─────────────────────────────────────────────────────────────────────────────
230
231/// Boltzmann probability distribution over a discrete energy spectrum.
232///
233/// Computes Boltzmann weights, occupation probabilities, entropy, and free energy.
234#[derive(Debug, Clone)]
235pub struct BoltzmannDistribution {
236    /// Energy levels.
237    pub energies: Vec<f64>,
238    /// Degeneracies per level.
239    pub degeneracies: Vec<f64>,
240    /// Temperature (k_B = 1).
241    pub temperature: f64,
242}
243
244impl BoltzmannDistribution {
245    /// Creates a new Boltzmann distribution.
246    pub fn new(energies: Vec<f64>, degeneracies: Vec<f64>, temperature: f64) -> Self {
247        Self {
248            energies,
249            degeneracies,
250            temperature,
251        }
252    }
253
254    /// Creates a uniform degeneracy distribution (all degeneracies = 1).
255    pub fn uniform(energies: Vec<f64>, temperature: f64) -> Self {
256        let n = energies.len();
257        Self::new(energies, vec![1.0; n], temperature)
258    }
259
260    /// Returns the un-normalised Boltzmann weight for level `i`.
261    pub fn weight(&self, i: usize) -> f64 {
262        if self.temperature <= 0.0 {
263            return 0.0;
264        }
265        let beta = 1.0 / self.temperature;
266        self.degeneracies[i] * (-beta * self.energies[i]).exp()
267    }
268
269    /// Returns the partition function Z = Σ_i weight(i).
270    pub fn partition_function(&self) -> f64 {
271        (0..self.energies.len()).map(|i| self.weight(i)).sum()
272    }
273
274    /// Returns the normalised probability P_i = weight(i) / Z.
275    pub fn probability(&self, i: usize) -> f64 {
276        let z = self.partition_function();
277        if z <= 0.0 {
278            return 0.0;
279        }
280        self.weight(i) / z
281    }
282
283    /// Returns all occupation probabilities as a vector.
284    pub fn probabilities(&self) -> Vec<f64> {
285        let z = self.partition_function();
286        if z <= 0.0 {
287            return vec![0.0; self.energies.len()];
288        }
289        (0..self.energies.len())
290            .map(|i| self.weight(i) / z)
291            .collect()
292    }
293
294    /// Returns the Gibbs entropy S = -Σ_i P_i ln P_i (natural units).
295    pub fn entropy(&self) -> f64 {
296        self.probabilities()
297            .iter()
298            .filter(|&&p| p > 0.0)
299            .map(|&p| -p * p.ln())
300            .sum()
301    }
302
303    /// Returns the Helmholtz free energy F = -T ln Z.
304    pub fn free_energy(&self) -> f64 {
305        let z = self.partition_function();
306        if z <= 0.0 {
307            return f64::INFINITY;
308        }
309        -self.temperature * z.ln()
310    }
311
312    /// Returns the mean energy ⟨E⟩ = Σ_i P_i E_i.
313    pub fn mean_energy(&self) -> f64 {
314        let probs = self.probabilities();
315        probs
316            .iter()
317            .zip(self.energies.iter())
318            .map(|(&p, &e)| p * e)
319            .sum()
320    }
321}
322
323// ─────────────────────────────────────────────────────────────────────────────
324// IsingModel
325// ─────────────────────────────────────────────────────────────────────────────
326
327/// Ising model on a 1D or 2D lattice with Metropolis Monte Carlo.
328///
329/// Spins are stored as `i8` values ±1.  The Hamiltonian is
330/// H = -J Σ_{⟨i,j⟩} s_i s_j - h Σ_i s_i.
331#[derive(Debug, Clone)]
332pub struct IsingModel {
333    /// Lattice dimension (1 or 2).
334    pub dim: usize,
335    /// Number of sites along each axis (Lx for 1D; Lx × Ly for 2D).
336    pub lx: usize,
337    /// Number of sites along y-axis (only used for 2D; 1 for 1D).
338    pub ly: usize,
339    /// Coupling constant J (J > 0 → ferromagnetic).
340    pub j_coupling: f64,
341    /// External magnetic field h.
342    pub field: f64,
343    /// Spin configuration: +1 or -1.
344    pub spins: Vec<i8>,
345}
346
347impl IsingModel {
348    /// Constructs a 1D Ising model with `n` sites, all spins +1.
349    pub fn new_1d(n: usize, j_coupling: f64, field: f64) -> Self {
350        Self {
351            dim: 1,
352            lx: n,
353            ly: 1,
354            j_coupling,
355            field,
356            spins: vec![1i8; n],
357        }
358    }
359
360    /// Constructs a 2D Ising model (lx × ly lattice) with all spins +1.
361    pub fn new_2d(lx: usize, ly: usize, j_coupling: f64, field: f64) -> Self {
362        Self {
363            dim: 2,
364            lx,
365            ly,
366            j_coupling,
367            field,
368            spins: vec![1i8; lx * ly],
369        }
370    }
371
372    /// Randomises the spin configuration using the given seed.
373    pub fn randomise(&mut self, seed: u64) {
374        let mut rng = SmRng::new(seed);
375        for s in self.spins.iter_mut() {
376            *s = if rng.next_f64() < 0.5 { 1 } else { -1 };
377        }
378    }
379
380    /// Returns the total energy of the current configuration.
381    pub fn energy(&self) -> f64 {
382        let mut e = 0.0_f64;
383        let n = self.spins.len();
384        if self.dim == 1 {
385            for i in 0..n {
386                let j = (i + 1) % n;
387                e -= self.j_coupling * (self.spins[i] as f64) * (self.spins[j] as f64);
388                e -= self.field * self.spins[i] as f64;
389            }
390        } else {
391            for row in 0..self.ly {
392                for col in 0..self.lx {
393                    let idx = row * self.lx + col;
394                    let right = row * self.lx + (col + 1) % self.lx;
395                    let down = ((row + 1) % self.ly) * self.lx + col;
396                    e -= self.j_coupling
397                        * (self.spins[idx] as f64)
398                        * (self.spins[right] as f64 + self.spins[down] as f64);
399                    e -= self.field * self.spins[idx] as f64;
400                }
401            }
402        }
403        e
404    }
405
406    /// Returns the magnetisation per spin m = (1/N) Σ_i s_i.
407    pub fn magnetisation(&self) -> f64 {
408        let total: i64 = self.spins.iter().map(|&s| s as i64).sum();
409        total as f64 / self.spins.len() as f64
410    }
411
412    /// Computes the local energy change if spin at site `idx` is flipped.
413    fn delta_energy(&self, idx: usize) -> f64 {
414        let s = self.spins[idx] as f64;
415        let neighbour_sum: f64 = if self.dim == 1 {
416            let n = self.spins.len();
417            let left = (idx + n - 1) % n;
418            let right = (idx + 1) % n;
419            self.spins[left] as f64 + self.spins[right] as f64
420        } else {
421            let row = idx / self.lx;
422            let col = idx % self.lx;
423            let left = row * self.lx + (col + self.lx - 1) % self.lx;
424            let right = row * self.lx + (col + 1) % self.lx;
425            let up = ((row + self.ly - 1) % self.ly) * self.lx + col;
426            let down = ((row + 1) % self.ly) * self.lx + col;
427            self.spins[left] as f64
428                + self.spins[right] as f64
429                + self.spins[up] as f64
430                + self.spins[down] as f64
431        };
432        2.0 * s * (self.j_coupling * neighbour_sum + self.field)
433    }
434
435    /// Runs `n_sweeps` full Metropolis sweeps at temperature `temperature`.
436    ///
437    /// Each sweep attempts one flip per spin.
438    pub fn metropolis_sweep(&mut self, n_sweeps: usize, temperature: f64, seed: u64) {
439        let mut rng = SmRng::new(seed);
440        let n = self.spins.len();
441        for _ in 0..n_sweeps {
442            for _attempt in 0..n {
443                let idx = (rng.next_u64() as usize) % n;
444                let de = self.delta_energy(idx);
445                if de <= 0.0 || rng.next_f64() < (-de / temperature).exp() {
446                    self.spins[idx] = -self.spins[idx];
447                }
448            }
449        }
450    }
451
452    /// Returns the magnetic susceptibility χ = (⟨m²⟩ - ⟨m⟩²) N / T
453    /// estimated over `n_samples` post-equilibration sweeps.
454    pub fn susceptibility(
455        &mut self,
456        temperature: f64,
457        n_eq: usize,
458        n_samples: usize,
459        seed: u64,
460    ) -> f64 {
461        self.metropolis_sweep(n_eq, temperature, seed);
462        let n = self.spins.len();
463        let mut m_sum = 0.0_f64;
464        let mut m2_sum = 0.0_f64;
465        let mut rng = SmRng::new(seed.wrapping_add(1));
466        for _ in 0..n_samples {
467            // one sweep
468            for _attempt in 0..n {
469                let idx = (rng.next_u64() as usize) % n;
470                let de = self.delta_energy(idx);
471                if de <= 0.0 || rng.next_f64() < (-de / temperature).exp() {
472                    self.spins[idx] = -self.spins[idx];
473                }
474            }
475            let m = self.magnetisation();
476            m_sum += m;
477            m2_sum += m * m;
478        }
479        let m_avg = m_sum / n_samples as f64;
480        let m2_avg = m2_sum / n_samples as f64;
481        (m2_avg - m_avg * m_avg) * n as f64 / temperature
482    }
483
484    /// Exact 1D critical temperature (returns J for reference; true T_c → 0 in 1D).
485    pub fn critical_temperature_1d_approx(&self) -> f64 {
486        // 1D Ising has T_c = 0; return the exchange energy scale
487        2.0 * self.j_coupling.abs()
488    }
489
490    /// Mean-field critical temperature for the 2D Ising model: T_c ≈ z J / (2 k_B).
491    /// For a 2D square lattice z = 4.
492    pub fn mean_field_tc_2d(&self) -> f64 {
493        4.0 * self.j_coupling
494    }
495
496    /// Exact Onsager critical temperature for the 2D square-lattice Ising model.
497    /// T_c = 2J / ln(1 + √2).
498    pub fn onsager_tc(&self) -> f64 {
499        2.0 * self.j_coupling / (1.0 + 2.0_f64.sqrt()).ln()
500    }
501}
502
503// ─────────────────────────────────────────────────────────────────────────────
504// PhaseTransition
505// ─────────────────────────────────────────────────────────────────────────────
506
507/// Landau theory description of a second-order phase transition.
508///
509/// Free energy density: f(φ) = a(T) φ²/2 + b φ⁴/4 + c φ⁶/6
510/// where a(T) = a₀ (T - T_c).
511#[derive(Debug, Clone)]
512pub struct PhaseTransition {
513    /// Coefficient a₀ (> 0).
514    pub a0: f64,
515    /// Critical temperature T_c.
516    pub tc: f64,
517    /// Coefficient b of the φ⁴ term (> 0 for a normal second-order transition).
518    pub b: f64,
519    /// Coefficient c of the φ⁶ term (stabilising; ≥ 0).
520    pub c: f64,
521    /// Current temperature.
522    pub temperature: f64,
523}
524
525impl PhaseTransition {
526    /// Creates a new Landau free energy model.
527    pub fn new(a0: f64, tc: f64, b: f64, c: f64, temperature: f64) -> Self {
528        Self {
529            a0,
530            tc,
531            b,
532            c,
533            temperature,
534        }
535    }
536
537    /// Returns a(T) = a₀(T - T_c).
538    pub fn a_coeff(&self) -> f64 {
539        self.a0 * (self.temperature - self.tc)
540    }
541
542    /// Returns the equilibrium order parameter φ* that minimises f(φ).
543    ///
544    /// For T > T_c: φ* = 0.
545    /// For T < T_c: φ* = √(-a(T)/b) (when c = 0 and b > 0).
546    pub fn order_parameter(&self) -> f64 {
547        let a = self.a_coeff();
548        if a >= 0.0 {
549            return 0.0;
550        }
551        if self.b > 0.0 {
552            (-a / self.b).sqrt()
553        } else {
554            0.0
555        }
556    }
557
558    /// Returns the Landau free energy density at order parameter `phi`.
559    pub fn free_energy_density(&self, phi: f64) -> f64 {
560        let a = self.a_coeff();
561        a * phi * phi / 2.0 + self.b * phi.powi(4) / 4.0 + self.c * phi.powi(6) / 6.0
562    }
563
564    /// Returns the reduced temperature t = (T - T_c) / T_c.
565    pub fn reduced_temperature(&self) -> f64 {
566        if self.tc == 0.0 {
567            return 0.0;
568        }
569        (self.temperature - self.tc) / self.tc
570    }
571
572    /// Returns the critical exponent β (mean-field value = 1/2).
573    pub fn mean_field_beta_exponent(&self) -> f64 {
574        0.5
575    }
576
577    /// Returns the susceptibility χ ~ |T - T_c|^(-γ); mean-field γ = 1.
578    pub fn susceptibility(&self) -> f64 {
579        let a = self.a_coeff().abs();
580        if a < 1e-15 {
581            return f64::INFINITY;
582        }
583        1.0 / (2.0 * a)
584    }
585}
586
587// ─────────────────────────────────────────────────────────────────────────────
588// FluctuationDissipation
589// ─────────────────────────────────────────────────────────────────────────────
590
591/// Tools for the fluctuation-dissipation theorem and related relations.
592///
593/// Implements the Einstein relation, response function, and Johnson-Nyquist
594/// noise power spectral density.
595#[derive(Debug, Clone)]
596pub struct FluctuationDissipation {
597    /// Temperature (k_B = 1).
598    pub temperature: f64,
599}
600
601impl FluctuationDissipation {
602    /// Creates a new `FluctuationDissipation` at the given temperature.
603    pub fn new(temperature: f64) -> Self {
604        Self { temperature }
605    }
606
607    /// Einstein relation: D = μ k_B T, where μ is the mobility.
608    ///
609    /// Returns the diffusion coefficient D.
610    pub fn einstein_relation(&self, mobility: f64) -> f64 {
611        mobility * self.temperature
612    }
613
614    /// Returns the mobility from diffusivity via the Einstein relation: μ = D / (k_B T).
615    pub fn mobility_from_diffusivity(&self, diffusivity: f64) -> f64 {
616        if self.temperature <= 0.0 {
617            return 0.0;
618        }
619        diffusivity / self.temperature
620    }
621
622    /// Johnson-Nyquist noise power spectral density S_V = 4 k_B T R.
623    ///
624    /// # Arguments
625    /// * `resistance` - Electrical resistance R in Ohms.
626    pub fn noise_power_spectral_density(&self, resistance: f64) -> f64 {
627        4.0 * self.temperature * resistance
628    }
629
630    /// Imaginary part of the linear response function (Kubo formula).
631    ///
632    /// For a Lorentzian oscillator:
633    /// χ''(ω) = (γ ω) / ((ω₀² - ω²)² + γ² ω²)
634    pub fn response_function_imaginary(&self, omega: f64, omega0: f64, gamma: f64) -> f64 {
635        let denom = (omega0 * omega0 - omega * omega).powi(2) + gamma * gamma * omega * omega;
636        if denom < 1e-30 {
637            return 0.0;
638        }
639        gamma * omega / denom
640    }
641
642    /// Real part of the linear response function (Kramers-Kronig).
643    ///
644    /// χ'(ω) = (ω₀² - ω²) / ((ω₀² - ω²)² + γ² ω²)
645    pub fn response_function_real(&self, omega: f64, omega0: f64, gamma: f64) -> f64 {
646        let denom = (omega0 * omega0 - omega * omega).powi(2) + gamma * gamma * omega * omega;
647        if denom < 1e-30 {
648            return 0.0;
649        }
650        (omega0 * omega0 - omega * omega) / denom
651    }
652
653    /// Fluctuation power spectral density via FDT: S(ω) = 2 k_B T χ''(ω) / ω.
654    ///
655    /// Returns 0 if ω = 0.
656    pub fn fluctuation_spectrum(&self, omega: f64, omega0: f64, gamma: f64) -> f64 {
657        if omega.abs() < 1e-30 {
658            return 0.0;
659        }
660        2.0 * self.temperature * self.response_function_imaginary(omega, omega0, gamma) / omega
661    }
662}
663
664// ─────────────────────────────────────────────────────────────────────────────
665// GreenKubo
666// ─────────────────────────────────────────────────────────────────────────────
667
668/// Green-Kubo transport coefficient computation from time-correlation functions.
669///
670/// Implements the velocity autocorrelation function (VACF) and the Green-Kubo
671/// integrals for shear viscosity, electrical conductivity, and diffusivity.
672#[derive(Debug, Clone)]
673pub struct GreenKubo {
674    /// Simulation time step.
675    pub dt: f64,
676    /// Temperature (k_B = 1).
677    pub temperature: f64,
678    /// Volume of the simulation cell.
679    pub volume: f64,
680}
681
682impl GreenKubo {
683    /// Creates a new Green-Kubo integrator.
684    pub fn new(dt: f64, temperature: f64, volume: f64) -> Self {
685        Self {
686            dt,
687            temperature,
688            volume,
689        }
690    }
691
692    /// Computes the normalised autocorrelation function of `signal`.
693    ///
694    /// C(τ) = ⟨A(t) A(t+τ)⟩ / ⟨A(0)²⟩.
695    /// Returns a vector of length `signal.len()` (full autocorrelation).
696    pub fn autocorrelation(signal: &[f64]) -> Vec<f64> {
697        let n = signal.len();
698        if n == 0 {
699            return vec![];
700        }
701        let mean: f64 = signal.iter().sum::<f64>() / n as f64;
702        let centered: Vec<f64> = signal.iter().map(|&x| x - mean).collect();
703        let norm: f64 = centered.iter().map(|&x| x * x).sum();
704        if norm < 1e-30 {
705            return vec![0.0; n];
706        }
707        (0..n)
708            .map(|lag| {
709                let count = n - lag;
710                let sum: f64 = (0..count).map(|i| centered[i] * centered[i + lag]).sum();
711                sum / norm
712            })
713            .collect()
714    }
715
716    /// Green-Kubo diffusivity D = (1/3) ∫₀^∞ ⟨v(0)·v(t)⟩ dt.
717    ///
718    /// # Arguments
719    /// * `vacf` - Velocity autocorrelation function C(t) (not normalised; units: velocity²).
720    pub fn diffusivity_from_vacf(&self, vacf: &[f64]) -> f64 {
721        // Trapezoidal integration
722        let n = vacf.len();
723        if n == 0 {
724            return 0.0;
725        }
726        let integral: f64 = (0..n - 1)
727            .map(|i| 0.5 * (vacf[i] + vacf[i + 1]) * self.dt)
728            .sum();
729        integral / 3.0
730    }
731
732    /// Green-Kubo shear viscosity η = (V / k_B T) ∫₀^∞ ⟨P_xy(0) P_xy(t)⟩ dt.
733    ///
734    /// # Arguments
735    /// * `stress_acf` - Autocorrelation of the off-diagonal stress element P_xy.
736    pub fn shear_viscosity(&self, stress_acf: &[f64]) -> f64 {
737        if self.temperature <= 0.0 || self.volume <= 0.0 {
738            return 0.0;
739        }
740        let n = stress_acf.len();
741        if n == 0 {
742            return 0.0;
743        }
744        let integral: f64 = (0..n - 1)
745            .map(|i| 0.5 * (stress_acf[i] + stress_acf[i + 1]) * self.dt)
746            .sum();
747        self.volume / self.temperature * integral
748    }
749
750    /// Green-Kubo electrical conductivity σ = (V / k_B T) ∫₀^∞ ⟨J(0)·J(t)⟩ dt / 3.
751    ///
752    /// # Arguments
753    /// * `current_acf` - Autocorrelation of total current.
754    pub fn electrical_conductivity(&self, current_acf: &[f64]) -> f64 {
755        if self.temperature <= 0.0 || self.volume <= 0.0 {
756            return 0.0;
757        }
758        let n = current_acf.len();
759        if n == 0 {
760            return 0.0;
761        }
762        let integral: f64 = (0..n - 1)
763            .map(|i| 0.5 * (current_acf[i] + current_acf[i + 1]) * self.dt)
764            .sum();
765        self.volume / (3.0 * self.temperature) * integral
766    }
767
768    /// Estimates the correlation time τ = ∫₀^∞ C(t) dt / C(0) from normalised ACF.
769    pub fn correlation_time(acf: &[f64], dt: f64) -> f64 {
770        if acf.is_empty() || acf[0].abs() < 1e-30 {
771            return 0.0;
772        }
773        let integral: f64 = (0..acf.len() - 1)
774            .map(|i| 0.5 * (acf[i] + acf[i + 1]) * dt)
775            .sum();
776        integral / acf[0]
777    }
778}
779
780// ─────────────────────────────────────────────────────────────────────────────
781// EquationOfState
782// ─────────────────────────────────────────────────────────────────────────────
783
784/// Classical cubic equations of state for real gases.
785///
786/// Provides van der Waals, Peng-Robinson, and Redlich-Kwong equations.
787#[derive(Debug, Clone)]
788pub struct EquationOfState {
789    /// Universal gas constant R.
790    pub r_gas: f64,
791    /// Temperature T.
792    pub temperature: f64,
793}
794
795/// EOS model variant.
796#[derive(Debug, Clone, Copy, PartialEq)]
797pub enum EosModel {
798    /// Van der Waals equation of state.
799    VanDerWaals,
800    /// Peng-Robinson equation of state.
801    PengRobinson,
802    /// Redlich-Kwong equation of state.
803    RedlichKwong,
804}
805
806impl EquationOfState {
807    /// Creates a new EOS calculator.
808    ///
809    /// `r_gas` is usually 8.314 J/(mol·K).
810    pub fn new(r_gas: f64, temperature: f64) -> Self {
811        Self { r_gas, temperature }
812    }
813
814    /// Van der Waals pressure: P = RT/(V-b) - a/V².
815    ///
816    /// # Arguments
817    /// * `molar_volume` - Molar volume V (m³/mol).
818    /// * `a`           - Attractive parameter a (Pa·m⁶/mol²).
819    /// * `b`           - Repulsive parameter b (m³/mol).
820    pub fn van_der_waals_pressure(&self, molar_volume: f64, a: f64, b: f64) -> f64 {
821        if molar_volume <= b {
822            return f64::INFINITY;
823        }
824        self.r_gas * self.temperature / (molar_volume - b) - a / (molar_volume * molar_volume)
825    }
826
827    /// Van der Waals critical constants from parameters a and b.
828    ///
829    /// Returns `(T_c, P_c, V_c)`.
830    pub fn vdw_critical_constants(a: f64, b: f64, r_gas: f64) -> (f64, f64, f64) {
831        let tc = 8.0 * a / (27.0 * r_gas * b);
832        let pc = a / (27.0 * b * b);
833        let vc = 3.0 * b;
834        (tc, pc, vc)
835    }
836
837    /// Peng-Robinson pressure.
838    ///
839    /// P = RT/(V-b) - a(T) / (V(V+b) + b(V-b))
840    ///
841    /// where a(T) = a_c · α(T), α(T) = \[1 + κ(1 - √(T/T_c))\]².
842    #[allow(clippy::too_many_arguments)]
843    pub fn peng_robinson_pressure(
844        &self,
845        molar_volume: f64,
846        a_c: f64,
847        b: f64,
848        kappa: f64,
849        tc: f64,
850    ) -> f64 {
851        if molar_volume <= b || tc <= 0.0 {
852            return f64::INFINITY;
853        }
854        let tr = self.temperature / tc;
855        let alpha = (1.0 + kappa * (1.0 - tr.sqrt())).powi(2);
856        let a_t = a_c * alpha;
857        self.r_gas * self.temperature / (molar_volume - b)
858            - a_t / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
859    }
860
861    /// Redlich-Kwong pressure.
862    ///
863    /// P = RT/(V-b) - a / (T^0.5 V(V+b))
864    pub fn redlich_kwong_pressure(&self, molar_volume: f64, a: f64, b: f64) -> f64 {
865        if molar_volume <= b || self.temperature <= 0.0 {
866            return f64::INFINITY;
867        }
868        self.r_gas * self.temperature / (molar_volume - b)
869            - a / (self.temperature.sqrt() * molar_volume * (molar_volume + b))
870    }
871
872    /// Compressibility factor Z = PV/(RT).
873    pub fn compressibility_factor(&self, pressure: f64, molar_volume: f64) -> f64 {
874        if self.r_gas <= 0.0 || self.temperature <= 0.0 {
875            return 0.0;
876        }
877        pressure * molar_volume / (self.r_gas * self.temperature)
878    }
879
880    /// Boyle temperature for van der Waals gas: T_B = a / (Rb).
881    pub fn boyle_temperature_vdw(a: f64, b: f64, r_gas: f64) -> f64 {
882        a / (r_gas * b)
883    }
884}
885
886// ─────────────────────────────────────────────────────────────────────────────
887// Utility: Fermi-Dirac and Bose-Einstein distributions
888// ─────────────────────────────────────────────────────────────────────────────
889
890/// Returns the Fermi-Dirac occupation number f(ε) = 1/(exp((ε-μ)/T) + 1).
891///
892/// # Arguments
893/// * `energy`   - Single-particle energy ε.
894/// * `mu`       - Chemical potential μ.
895/// * `temperature` - Temperature T (k_B = 1).
896pub fn fermi_dirac(energy: f64, mu: f64, temperature: f64) -> f64 {
897    if temperature <= 0.0 {
898        return if energy < mu { 1.0 } else { 0.0 };
899    }
900    1.0 / (((energy - mu) / temperature).exp() + 1.0)
901}
902
903/// Returns the Bose-Einstein occupation number n(ε) = 1/(exp((ε-μ)/T) - 1).
904///
905/// Returns 0 if the exponent makes the denominator non-positive.
906pub fn bose_einstein(energy: f64, mu: f64, temperature: f64) -> f64 {
907    if temperature <= 0.0 {
908        return 0.0;
909    }
910    let x = (energy - mu) / temperature;
911    if x <= 0.0 {
912        return 0.0;
913    }
914    let denom = x.exp() - 1.0;
915    if denom < 1e-30 { 0.0 } else { 1.0 / denom }
916}
917
918/// Stefan-Boltzmann law: power radiated per unit area P = σ T⁴.
919///
920/// Uses σ = 5.670374419 × 10⁻⁸ W m⁻² K⁻⁴.
921pub fn stefan_boltzmann_power(temperature: f64) -> f64 {
922    const SIGMA: f64 = 5.670_374_419e-8;
923    SIGMA * temperature.powi(4)
924}
925
926/// Wien displacement law: λ_max T = b, returns λ_max.
927///
928/// b = 2.897771955 × 10⁻³ m·K.
929pub fn wien_peak_wavelength(temperature: f64) -> f64 {
930    const B: f64 = 2.897_771_955e-3;
931    if temperature <= 0.0 {
932        return f64::INFINITY;
933    }
934    B / temperature
935}
936
937/// Planck spectral radiance B_λ(λ, T) = 2hc² / (λ⁵ (exp(hc/λkT) - 1)).
938///
939/// Returns radiance in SI units W sr⁻¹ m⁻³.
940pub fn planck_spectral_radiance(wavelength: f64, temperature: f64) -> f64 {
941    const H: f64 = 6.626_070_15e-34;
942    const C: f64 = 2.997_924_58e8;
943    const KB: f64 = 1.380_649e-23;
944    if wavelength <= 0.0 || temperature <= 0.0 {
945        return 0.0;
946    }
947    let exponent = H * C / (wavelength * KB * temperature);
948    if exponent > 700.0 {
949        return 0.0;
950    }
951    2.0 * H * C * C / (wavelength.powi(5) * (exponent.exp() - 1.0))
952}
953
954// ─────────────────────────────────────────────────────────────────────────────
955// Tests
956// ─────────────────────────────────────────────────────────────────────────────
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    // ── PartitionFunction ────────────────────────────────────────────────────
963
964    #[test]
965    fn test_canonical_z_two_level() {
966        // Two-level system: ε=0 and ε=1, g=1 each, T=1
967        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 1.0);
968        let z = pf.canonical_z();
969        assert!((z - (1.0 + (-1.0_f64).exp())).abs() < 1e-12);
970    }
971
972    #[test]
973    fn test_canonical_z_zero_temp() {
974        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 0.0);
975        assert_eq!(pf.canonical_z(), 0.0);
976    }
977
978    #[test]
979    fn test_free_energy_two_level() {
980        let t = 2.0;
981        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], t);
982        let z = pf.canonical_z();
983        let f_expected = -t * z.ln();
984        assert!((pf.free_energy() - f_expected).abs() < 1e-12);
985    }
986
987    #[test]
988    fn test_average_energy_zero_temp() {
989        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 0.0);
990        assert_eq!(pf.average_energy(), 0.0);
991    }
992
993    #[test]
994    fn test_average_energy_high_temp() {
995        // At high T both levels equally occupied → ⟨E⟩ = 0.5
996        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 1e6);
997        let e_avg = pf.average_energy();
998        assert!((e_avg - 0.5).abs() < 1e-3);
999    }
1000
1001    #[test]
1002    fn test_entropy_nonnegative() {
1003        let pf = PartitionFunction::canonical(vec![0.0, 1.0, 2.0], vec![1.0, 2.0, 1.0], 1.5);
1004        assert!(pf.entropy() >= 0.0);
1005    }
1006
1007    #[test]
1008    fn test_heat_capacity_positive() {
1009        let pf = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 1.0);
1010        assert!(pf.heat_capacity() > 0.0);
1011    }
1012
1013    #[test]
1014    fn test_grand_canonical_z() {
1015        let pf = PartitionFunction::grand_canonical(
1016            vec![0.0, 1.0],
1017            vec![1.0, 1.0],
1018            vec![0.0, 1.0],
1019            1.0,
1020            0.5,
1021        );
1022        let z = pf.grand_canonical_z();
1023        assert!(z > 0.0);
1024    }
1025
1026    #[test]
1027    fn test_density_of_states() {
1028        let pf = PartitionFunction::microcanonical(vec![0.0, 1.0, 2.0], vec![1.0, 3.0, 1.0]);
1029        let omega = pf.density_of_states(1.0, 0.1);
1030        assert!((omega - 3.0).abs() < 1e-12);
1031    }
1032
1033    // ── BoltzmannDistribution ────────────────────────────────────────────────
1034
1035    #[test]
1036    fn test_boltzmann_probabilities_sum_to_one() {
1037        let bd = BoltzmannDistribution::uniform(vec![0.0, 1.0, 2.0, 3.0], 1.0);
1038        let sum: f64 = bd.probabilities().iter().sum();
1039        assert!((sum - 1.0).abs() < 1e-12);
1040    }
1041
1042    #[test]
1043    fn test_boltzmann_entropy_nonneg() {
1044        let bd = BoltzmannDistribution::uniform(vec![0.0, 1.0, 2.0], 1.0);
1045        assert!(bd.entropy() >= 0.0);
1046    }
1047
1048    #[test]
1049    fn test_boltzmann_free_energy() {
1050        let bd = BoltzmannDistribution::uniform(vec![0.0, 0.5, 1.0], 0.5);
1051        let f = bd.free_energy();
1052        assert!(f.is_finite());
1053    }
1054
1055    #[test]
1056    fn test_boltzmann_mean_energy() {
1057        let bd = BoltzmannDistribution::uniform(vec![0.0, 1.0], 1e6);
1058        // High T → equal weights → mean ≈ 0.5
1059        assert!((bd.mean_energy() - 0.5).abs() < 1e-3);
1060    }
1061
1062    #[test]
1063    fn test_boltzmann_zero_temp() {
1064        let bd = BoltzmannDistribution::uniform(vec![0.0, 1.0], 0.0);
1065        assert_eq!(bd.partition_function(), 0.0);
1066    }
1067
1068    // ── IsingModel ───────────────────────────────────────────────────────────
1069
1070    #[test]
1071    fn test_ising_1d_energy_all_up() {
1072        // All spins +1, H=0 → E = -J * N
1073        let model = IsingModel::new_1d(4, 1.0, 0.0);
1074        assert!((model.energy() - (-4.0)).abs() < 1e-12);
1075    }
1076
1077    #[test]
1078    fn test_ising_magnetisation_all_up() {
1079        let model = IsingModel::new_1d(4, 1.0, 0.0);
1080        assert!((model.magnetisation() - 1.0).abs() < 1e-12);
1081    }
1082
1083    #[test]
1084    fn test_ising_magnetisation_alternating() {
1085        let mut model = IsingModel::new_1d(4, 1.0, 0.0);
1086        for i in 0..4 {
1087            model.spins[i] = if i % 2 == 0 { 1 } else { -1 };
1088        }
1089        assert!(model.magnetisation().abs() < 1e-12);
1090    }
1091
1092    #[test]
1093    fn test_ising_2d_energy_all_up() {
1094        // 2×2 lattice, J=1, h=0, all spins up
1095        // Each spin has 2 right/down bonds; 4 spins, 8 bonds total (with PBC)
1096        let model = IsingModel::new_2d(2, 2, 1.0, 0.0);
1097        assert!(model.energy() < 0.0);
1098    }
1099
1100    #[test]
1101    fn test_ising_randomise_changes_spins() {
1102        let mut model = IsingModel::new_1d(10, 1.0, 0.0);
1103        model.randomise(42);
1104        // After randomisation the spins should not all be +1 (very unlikely)
1105        let all_up = model.spins.iter().all(|&s| s == 1);
1106        let all_dn = model.spins.iter().all(|&s| s == -1);
1107        // At least one of all_up/all_dn is false for a 10-spin lattice
1108        assert!(!(all_up && all_dn));
1109    }
1110
1111    #[test]
1112    fn test_ising_metropolis_runs() {
1113        let mut model = IsingModel::new_1d(8, 1.0, 0.0);
1114        model.randomise(1);
1115        // Should not panic
1116        model.metropolis_sweep(10, 2.0, 99);
1117        assert!(model.magnetisation().abs() <= 1.0);
1118    }
1119
1120    #[test]
1121    fn test_ising_onsager_tc() {
1122        let model = IsingModel::new_2d(8, 8, 1.0, 0.0);
1123        let tc = model.onsager_tc();
1124        // Known value ≈ 2.269
1125        assert!((tc - 2.269).abs() < 0.01);
1126    }
1127
1128    #[test]
1129    fn test_ising_susceptibility_positive() {
1130        let mut model = IsingModel::new_1d(8, 1.0, 0.0);
1131        model.randomise(7);
1132        let chi = model.susceptibility(2.5, 20, 50, 11);
1133        assert!(chi >= 0.0);
1134    }
1135
1136    // ── PhaseTransition ──────────────────────────────────────────────────────
1137
1138    #[test]
1139    fn test_phase_transition_order_param_above_tc() {
1140        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.0, 3.0);
1141        assert_eq!(pt.order_parameter(), 0.0);
1142    }
1143
1144    #[test]
1145    fn test_phase_transition_order_param_below_tc() {
1146        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.0, 1.0);
1147        let phi = pt.order_parameter();
1148        assert!(phi > 0.0);
1149    }
1150
1151    #[test]
1152    fn test_phase_transition_free_energy_density() {
1153        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.5, 1.0);
1154        let f0 = pt.free_energy_density(0.0);
1155        let phi_eq = pt.order_parameter();
1156        let f_eq = pt.free_energy_density(phi_eq);
1157        // Minimum should be at equilibrium
1158        assert!(f_eq <= f0 + 1e-10);
1159    }
1160
1161    #[test]
1162    fn test_phase_transition_reduced_temperature() {
1163        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.0, 3.0);
1164        assert!((pt.reduced_temperature() - 0.5).abs() < 1e-12);
1165    }
1166
1167    #[test]
1168    fn test_phase_transition_susceptibility_diverges_near_tc() {
1169        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.0, 2.0 + 1e-8);
1170        assert!(pt.susceptibility() > 1e5);
1171    }
1172
1173    // ── FluctuationDissipation ───────────────────────────────────────────────
1174
1175    #[test]
1176    fn test_einstein_relation() {
1177        let fd = FluctuationDissipation::new(1.0);
1178        assert!((fd.einstein_relation(0.5) - 0.5).abs() < 1e-12);
1179    }
1180
1181    #[test]
1182    fn test_mobility_from_diffusivity() {
1183        let fd = FluctuationDissipation::new(2.0);
1184        assert!((fd.mobility_from_diffusivity(4.0) - 2.0).abs() < 1e-12);
1185    }
1186
1187    #[test]
1188    fn test_noise_power_spectral_density() {
1189        let fd = FluctuationDissipation::new(300.0);
1190        // S = 4 k_B T R; with k_B=1 here
1191        let s = fd.noise_power_spectral_density(50.0);
1192        assert!((s - 4.0 * 300.0 * 50.0).abs() < 1e-6);
1193    }
1194
1195    #[test]
1196    fn test_response_function_imaginary_at_resonance() {
1197        let fd = FluctuationDissipation::new(1.0);
1198        let chi_im = fd.response_function_imaginary(1.0, 1.0, 0.1);
1199        assert!(chi_im > 0.0);
1200    }
1201
1202    #[test]
1203    fn test_fluctuation_spectrum_zero_omega() {
1204        let fd = FluctuationDissipation::new(1.0);
1205        assert_eq!(fd.fluctuation_spectrum(0.0, 1.0, 0.1), 0.0);
1206    }
1207
1208    // ── GreenKubo ────────────────────────────────────────────────────────────
1209
1210    #[test]
1211    fn test_autocorrelation_constant_signal() {
1212        let signal = vec![1.0; 10];
1213        let acf = GreenKubo::autocorrelation(&signal);
1214        // Constant signal → all zero after removing mean
1215        assert_eq!(acf.len(), 10);
1216        for v in &acf {
1217            assert!(v.abs() < 1e-12);
1218        }
1219    }
1220
1221    #[test]
1222    fn test_autocorrelation_lag_zero_is_one() {
1223        let signal: Vec<f64> = (0..20).map(|i| (i as f64 * 0.3).sin()).collect();
1224        let acf = GreenKubo::autocorrelation(&signal);
1225        assert!(!acf.is_empty());
1226        assert!((acf[0] - 1.0).abs() < 1e-10);
1227    }
1228
1229    #[test]
1230    fn test_diffusivity_from_vacf() {
1231        let gk = GreenKubo::new(0.01, 1.0, 1.0);
1232        // Exponentially decaying VACF: C(t) = C0 exp(-t/tau)
1233        let tau = 1.0;
1234        let n = 200;
1235        let vacf: Vec<f64> = (0..n).map(|i| (-(i as f64) * 0.01 / tau).exp()).collect();
1236        let d = gk.diffusivity_from_vacf(&vacf);
1237        // ∫₀^∞ exp(-t/τ) dt / 3 ≈ τ/3
1238        assert!((d - tau / 3.0).abs() < 0.05);
1239    }
1240
1241    #[test]
1242    fn test_shear_viscosity_zero_temp() {
1243        let gk = GreenKubo::new(0.01, 0.0, 1.0);
1244        assert_eq!(gk.shear_viscosity(&[1.0, 0.5, 0.1]), 0.0);
1245    }
1246
1247    #[test]
1248    fn test_correlation_time() {
1249        let tau = 2.0;
1250        let dt = 0.1;
1251        let n = 100;
1252        let acf: Vec<f64> = (0..n).map(|i| (-(i as f64) * dt / tau).exp()).collect();
1253        let ct = GreenKubo::correlation_time(&acf, dt);
1254        // Should be close to tau
1255        assert!((ct - tau).abs() < 0.5);
1256    }
1257
1258    // ── EquationOfState ──────────────────────────────────────────────────────
1259
1260    #[test]
1261    fn test_vdw_ideal_limit() {
1262        // With a=0, b=0 → P = RT/V
1263        let eos = EquationOfState::new(8.314, 300.0);
1264        let v = 0.025; // ~1 mol at ~1 atm
1265        let p = eos.van_der_waals_pressure(v, 0.0, 0.0);
1266        assert!((p - 8.314 * 300.0 / v).abs() < 1e-6);
1267    }
1268
1269    #[test]
1270    fn test_vdw_critical_constants() {
1271        let (tc, pc, vc) = EquationOfState::vdw_critical_constants(0.364, 4.27e-5, 8.314);
1272        assert!(tc > 0.0 && pc > 0.0 && vc > 0.0);
1273    }
1274
1275    #[test]
1276    fn test_peng_robinson_pressure_finite() {
1277        let eos = EquationOfState::new(8.314, 300.0);
1278        let p = eos.peng_robinson_pressure(0.001, 0.4, 2.5e-5, 0.37, 190.0);
1279        assert!(p.is_finite());
1280    }
1281
1282    #[test]
1283    fn test_redlich_kwong_pressure_finite() {
1284        let eos = EquationOfState::new(8.314, 400.0);
1285        let p = eos.redlich_kwong_pressure(0.002, 1.0, 2.6e-5);
1286        assert!(p.is_finite() && p > 0.0);
1287    }
1288
1289    #[test]
1290    fn test_compressibility_factor_ideal_gas() {
1291        let eos = EquationOfState::new(8.314, 300.0);
1292        let v = 0.025;
1293        let p = eos.van_der_waals_pressure(v, 0.0, 0.0);
1294        let z = eos.compressibility_factor(p, v);
1295        assert!((z - 1.0).abs() < 1e-10);
1296    }
1297
1298    #[test]
1299    fn test_boyle_temperature_vdw() {
1300        let t_b = EquationOfState::boyle_temperature_vdw(0.364, 4.27e-5, 8.314);
1301        assert!(t_b > 0.0);
1302    }
1303
1304    // ── Fermi-Dirac / Bose-Einstein ──────────────────────────────────────────
1305
1306    #[test]
1307    fn test_fermi_dirac_at_mu() {
1308        // f(μ) = 0.5 for any T > 0
1309        assert!((fermi_dirac(1.0, 1.0, 1.0) - 0.5).abs() < 1e-12);
1310    }
1311
1312    #[test]
1313    fn test_fermi_dirac_below_mu() {
1314        assert!(fermi_dirac(0.0, 1.0, 1.0) > 0.5);
1315    }
1316
1317    #[test]
1318    fn test_fermi_dirac_zero_temp_below_mu() {
1319        assert_eq!(fermi_dirac(0.5, 1.0, 0.0), 1.0);
1320    }
1321
1322    #[test]
1323    fn test_fermi_dirac_zero_temp_above_mu() {
1324        assert_eq!(fermi_dirac(1.5, 1.0, 0.0), 0.0);
1325    }
1326
1327    #[test]
1328    fn test_bose_einstein_positive() {
1329        let n = bose_einstein(1.0, 0.5, 1.0);
1330        assert!(n > 0.0);
1331    }
1332
1333    #[test]
1334    fn test_bose_einstein_at_zero_argument() {
1335        // energy == mu → x=0, undefined → returns 0
1336        assert_eq!(bose_einstein(1.0, 1.0, 1.0), 0.0);
1337    }
1338
1339    #[test]
1340    fn test_planck_spectral_radiance_positive() {
1341        let b = planck_spectral_radiance(500e-9, 5778.0);
1342        assert!(b > 0.0);
1343    }
1344
1345    #[test]
1346    fn test_wien_peak_wavelength() {
1347        // Sun surface ~5778 K → peak ~502 nm
1348        let lam = wien_peak_wavelength(5778.0);
1349        assert!((lam - 5.02e-7).abs() < 1e-8);
1350    }
1351
1352    #[test]
1353    fn test_stefan_boltzmann() {
1354        // T=1 → σ W/m²
1355        let p = stefan_boltzmann_power(1.0);
1356        assert!((p - 5.670374419e-8).abs() < 1e-15);
1357    }
1358
1359    #[allow(clippy::too_many_arguments)]
1360    #[test]
1361    fn test_partition_function_degeneracy_scaling() {
1362        // Doubling all degeneracies should not change average energy
1363        let pf1 = PartitionFunction::canonical(vec![0.0, 1.0], vec![1.0, 1.0], 1.0);
1364        let pf2 = PartitionFunction::canonical(vec![0.0, 1.0], vec![2.0, 2.0], 1.0);
1365        let diff = (pf1.average_energy() - pf2.average_energy()).abs();
1366        assert!(diff < 1e-12);
1367    }
1368
1369    #[test]
1370    fn test_ising_delta_energy_flip_all_up() {
1371        let model = IsingModel::new_1d(4, 1.0, 0.0);
1372        // Flipping spin 0 when all up: ΔE = 2J * (sum_neighbors) = 2*1*(+1+1)=4
1373        let de = model.delta_energy(0);
1374        assert!((de - 4.0).abs() < 1e-12);
1375    }
1376
1377    #[test]
1378    fn test_phase_transition_a_coeff() {
1379        let pt = PhaseTransition::new(1.0, 2.0, 1.0, 0.0, 3.0);
1380        assert!((pt.a_coeff() - 1.0).abs() < 1e-12);
1381    }
1382
1383    #[test]
1384    fn test_gk_autocorrelation_empty() {
1385        let acf = GreenKubo::autocorrelation(&[]);
1386        assert!(acf.is_empty());
1387    }
1388
1389    #[test]
1390    fn test_electrical_conductivity_zero_volume() {
1391        let gk = GreenKubo::new(0.01, 1.0, 0.0);
1392        assert_eq!(gk.electrical_conductivity(&[1.0, 0.5]), 0.0);
1393    }
1394
1395    #[test]
1396    fn test_vdw_pressure_below_b_returns_inf() {
1397        let eos = EquationOfState::new(8.314, 300.0);
1398        let p = eos.van_der_waals_pressure(1e-6, 0.0, 0.01);
1399        assert_eq!(p, f64::INFINITY);
1400    }
1401
1402    #[test]
1403    fn test_mean_field_tc_2d() {
1404        let model = IsingModel::new_2d(4, 4, 1.0, 0.0);
1405        assert!((model.mean_field_tc_2d() - 4.0).abs() < 1e-12);
1406    }
1407}