Skip to main content

oxiphysics_materials/
polymer_physics.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Polymer physics — chain models and rubber elasticity.
5//!
6//! Provides the Freely Jointed Chain (FJC), Worm-Like Chain (WLC),
7//! Rouse/Zimm relaxation times, rubber elasticity (neo-Hookean), and
8//! Flory-Huggins mixing thermodynamics.
9
10#![allow(dead_code)]
11#![allow(clippy::too_many_arguments)]
12
13use std::f64::consts::PI;
14
15// ---------------------------------------------------------------------------
16// 1. Langevin function and inverse
17// ---------------------------------------------------------------------------
18
19/// Langevin function L(x) = coth(x) − 1/x.
20///
21/// Returns 0 for x ≈ 0 (using the linear approximation x/3) to avoid
22/// division by zero.
23pub fn langevin_function(x: f64) -> f64 {
24    if x.abs() < 1e-6 {
25        x / 3.0
26    } else {
27        x.cosh() / x.sinh() - 1.0 / x
28    }
29}
30
31/// Padé approximant for the inverse Langevin function.
32///
33/// Approximation: L⁻¹(y) ≈ y·(3 − y²) / (1 − y²), valid for |y| < 1.
34pub fn inverse_langevin_approx(y: f64) -> f64 {
35    let y2 = y * y;
36    y * (3.0 - y2) / (1.0 - y2)
37}
38
39// ---------------------------------------------------------------------------
40// 2. Freely Jointed Chain (FJC)
41// ---------------------------------------------------------------------------
42
43/// Freely Jointed Chain (FJC) model.
44///
45/// Describes a polymer as `n_segments` rigid links of equal length
46/// `segment_length` joined by freely-rotating joints.
47#[derive(Debug, Clone)]
48pub struct FreelyJointedChain {
49    /// Number of Kuhn segments.
50    pub n_segments: usize,
51    /// Kuhn segment length (m).
52    pub segment_length: f64,
53}
54
55impl FreelyJointedChain {
56    /// Create a new FJC model with `n` segments of length `l`.
57    pub fn new(n: usize, l: f64) -> Self {
58        Self {
59            n_segments: n,
60            segment_length: l,
61        }
62    }
63
64    /// Contour (maximum extended) length L = n·b.
65    pub fn contour_length(&self) -> f64 {
66        self.n_segments as f64 * self.segment_length
67    }
68
69    /// Root-mean-square end-to-end distance ⟨r²⟩^½ = b·√n.
70    pub fn end_to_end_rms(&self) -> f64 {
71        self.segment_length * (self.n_segments as f64).sqrt()
72    }
73
74    /// Force required to extend the chain to fractional extension `x = r/L`.
75    ///
76    /// Uses the inverse Langevin approximation: f = (kT / b) · L⁻¹(x).
77    ///
78    /// # Arguments
79    /// * `x`  – fractional extension r/L in (0, 1).
80    /// * `kt` – thermal energy k_B T (J).
81    pub fn force_extension(&self, x: f64, kt: f64) -> f64 {
82        let x_clamped = x.clamp(1e-6, 1.0 - 1e-6);
83        (kt / self.segment_length) * inverse_langevin_approx(x_clamped)
84    }
85
86    /// Langevin function evaluated at `x`.
87    pub fn langevin(x: f64) -> f64 {
88        langevin_function(x)
89    }
90}
91
92// ---------------------------------------------------------------------------
93// 3. Worm-Like Chain (WLC)
94// ---------------------------------------------------------------------------
95
96/// Worm-Like Chain (WLC) model — semiflexible polymer.
97///
98/// Described by persistence length `lp` and contour length `lc`.
99#[derive(Debug, Clone)]
100pub struct WormLikeChain {
101    /// Persistence length (m).
102    pub persistence_length: f64,
103    /// Contour length (m).
104    pub contour_length: f64,
105}
106
107impl WormLikeChain {
108    /// Create a new WLC model.
109    pub fn new(lp: f64, lc: f64) -> Self {
110        Self {
111            persistence_length: lp,
112            contour_length: lc,
113        }
114    }
115
116    /// RMS end-to-end distance for the WLC: ⟨r²⟩^½ = √(2·lp·lc).
117    ///
118    /// This is the Gaussian limit; always less than contour length.
119    pub fn end_to_end_rms(&self) -> f64 {
120        (2.0 * self.persistence_length * self.contour_length).sqrt()
121    }
122
123    /// Force at fractional extension `r/lc` via the Marko-Siggia interpolation.
124    ///
125    /// f = (kT / lp) · \[ 1/(4(1−x)²) − 1/4 + x \]  where x = extension/lc.
126    ///
127    /// # Arguments
128    /// * `extension` – end-to-end distance (m), must be < contour_length.
129    /// * `kt`        – thermal energy k_B T (J).
130    pub fn force_extension(&self, extension: f64, kt: f64) -> f64 {
131        let lc = self.contour_length;
132        let x = (extension / lc).clamp(0.0, 1.0 - 1e-6);
133        let factor = kt / self.persistence_length;
134        factor * (0.25 / (1.0 - x).powi(2) - 0.25 + x)
135    }
136}
137
138// ---------------------------------------------------------------------------
139// 4. Rouse and Zimm relaxation times
140// ---------------------------------------------------------------------------
141
142/// Rouse relaxation time τ_R = n² · b² · η / (3π² · kT).
143///
144/// # Arguments
145/// * `n`   – number of Rouse beads.
146/// * `eta` – solvent viscosity (Pa·s).
147/// * `b`   – bead spacing / Kuhn length (m).
148/// * `kt`  – thermal energy k_B T (J).
149pub fn rouse_relaxation_time(n: usize, eta: f64, b: f64, kt: f64) -> f64 {
150    let n_f = n as f64;
151    n_f * n_f * b * b * eta / (3.0 * PI * PI * kt)
152}
153
154/// Zimm relaxation time τ_Z ≈ η · Rg³ / kT.
155///
156/// # Arguments
157/// * `n`   – number of beads (used to scale Rg if desired; here Rg is explicit).
158/// * `eta` – solvent viscosity (Pa·s).
159/// * `rg`  – radius of gyration (m).
160/// * `kt`  – thermal energy k_B T (J).
161pub fn zimm_relaxation_time(_n: usize, eta: f64, rg: f64, kt: f64) -> f64 {
162    eta * rg.powi(3) / kt
163}
164
165// ---------------------------------------------------------------------------
166// 5. Rubber elasticity (neo-Hookean)
167// ---------------------------------------------------------------------------
168
169/// Neo-Hookean rubber elasticity model.
170///
171/// Models a crosslinked elastomer network in terms of chain density,
172/// thermal energy, and crosslink density.
173#[derive(Debug, Clone)]
174pub struct RubberElasticity {
175    /// Number of network chains per unit volume (m⁻³).
176    pub n_chains: f64,
177    /// Thermal energy k_B T (J).
178    pub kt: f64,
179    /// Crosslink density ν (m⁻³); used in bulk modulus estimate.
180    pub crosslink_density: f64,
181}
182
183impl RubberElasticity {
184    /// Create a new rubber elasticity model.
185    pub fn new(n_chains: f64, kt: f64, crosslink_density: f64) -> Self {
186        Self {
187            n_chains,
188            kt,
189            crosslink_density,
190        }
191    }
192
193    /// Shear modulus G = n·kT  (Pa).
194    pub fn shear_modulus(&self) -> f64 {
195        self.n_chains * self.kt
196    }
197
198    /// Bulk modulus K ≈ (2/3)·G + ν·kT (compressibility contribution).
199    pub fn bulk_modulus(&self) -> f64 {
200        let g = self.shear_modulus();
201        2.0 / 3.0 * g + self.crosslink_density * self.kt
202    }
203
204    /// Strain energy density W = (G/2)·(λ₁² + λ₂² + λ₃² − 3)  for uniaxial
205    /// stretch λ (incompressible: λ₂ = λ₃ = 1/√λ).
206    ///
207    /// # Arguments
208    /// * `lambda` – uniaxial stretch ratio (≥ 1 for extension).
209    pub fn strain_energy(&self, lambda: f64) -> f64 {
210        let g = self.shear_modulus();
211        // I1 = λ² + 2/λ for incompressible uniaxial deformation.
212        let i1 = lambda * lambda + 2.0 / lambda;
213        g / 2.0 * (i1 - 3.0)
214    }
215
216    /// Nominal (1st Piola-Kirchhoff) stress for uniaxial stretch λ.
217    ///
218    /// σ = G·(λ − 1/λ²).
219    pub fn stress_stretch(lambda: f64) -> f64 {
220        // G factor removed — returns normalised stress / G.
221        lambda - 1.0 / (lambda * lambda)
222    }
223}
224
225// ---------------------------------------------------------------------------
226// 6. Flory-Huggins mixing thermodynamics
227// ---------------------------------------------------------------------------
228
229/// Flory-Huggins free energy of mixing per lattice site (units of kT).
230///
231/// ΔF/(nkT) = φ·ln(φ)/N₁ + (1−φ)·ln(1−φ)/N₂ + χ·φ·(1−φ)
232///
233/// # Arguments
234/// * `phi`  – volume fraction of component 1.
235/// * `n`    – degree of polymerisation of component 1 (use 1 for small molecule).
236/// * `chi`  – Flory-Huggins interaction parameter.
237pub fn flory_huggins_free_energy(phi: f64, n: usize, chi: f64) -> f64 {
238    let phi = phi.clamp(1e-10, 1.0 - 1e-10);
239    let n_f = n as f64;
240    phi * phi.ln() / n_f + (1.0 - phi) * (1.0 - phi).ln() + chi * phi * (1.0 - phi)
241}
242
243/// Spinodal compositions for a symmetric blend (N₁ = N₂ = N).
244///
245/// Spinodal: d²ΔF/dφ² = 0  ⟹  1/(N·φ) + 1/(N·(1−φ)) − 2χ = 0.
246/// Returns (φ_low, φ_high) spinodal points.
247///
248/// # Arguments
249/// * `chi` – Flory-Huggins parameter.
250/// * `n1`  – degree of polymerisation of component 1.
251/// * `n2`  – degree of polymerisation of component 2.
252pub fn spinodal_composition(chi: f64, n1: usize, n2: usize) -> (f64, f64) {
253    // Numerically scan for spinodal points (d²F/dφ² = 0).
254    let n1_f = n1 as f64;
255    let n2_f = n2 as f64;
256    let d2f = |phi: f64| -> f64 {
257        let phi = phi.clamp(1e-10, 1.0 - 1e-10);
258        1.0 / (n1_f * phi) + 1.0 / (n2_f * (1.0 - phi)) - 2.0 * chi
259    };
260    // Search in (0, 0.5] and [0.5, 1).
261    let find_root = |a: f64, b: f64| -> f64 {
262        let mut lo = a;
263        let mut hi = b;
264        for _ in 0..60 {
265            let mid = 0.5 * (lo + hi);
266            if d2f(lo) * d2f(mid) <= 0.0 {
267                hi = mid;
268            } else {
269                lo = mid;
270            }
271        }
272        0.5 * (lo + hi)
273    };
274
275    let chi_crit = chi_critical(n1, n2);
276    if chi <= chi_crit {
277        // No spinodal – return symmetric midpoint.
278        (0.5, 0.5)
279    } else {
280        let phi_low = find_root(1e-6, 0.5 - 1e-6);
281        let phi_high = find_root(0.5 + 1e-6, 1.0 - 1e-6);
282        (phi_low, phi_high)
283    }
284}
285
286/// Critical Flory-Huggins parameter χ_c = (1/√N₁ + 1/√N₂)² / 2.
287///
288/// Phase separation occurs when χ > χ_c.
289pub fn chi_critical(n1: usize, n2: usize) -> f64 {
290    let term = 1.0 / (n1 as f64).sqrt() + 1.0 / (n2 as f64).sqrt();
291    term * term / 2.0
292}
293
294// ---------------------------------------------------------------------------
295// 7. Additional polymer formulae
296// ---------------------------------------------------------------------------
297
298/// Ideal-chain radius of gyration Rg = b·√(N/6).
299///
300/// # Arguments
301/// * `n` – number of segments.
302/// * `b` – segment length (m).
303pub fn radius_of_gyration_ideal(n: usize, b: f64) -> f64 {
304    b * ((n as f64) / 6.0).sqrt()
305}
306
307/// Mark-Houwink intrinsic viscosity \[η\] = K · M^α.
308///
309/// # Arguments
310/// * `k`     – Mark-Houwink K constant (mL/g).
311/// * `alpha` – Mark-Houwink exponent.
312/// * `mw`    – molecular weight (g/mol).
313pub fn polymer_viscosity_mark_houwink(k: f64, alpha: f64, mw: f64) -> f64 {
314    k * mw.powf(alpha)
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    const EPS: f64 = 1e-9;
326
327    // ── FJC ─────────────────────────────────────────────────────────────────
328
329    #[test]
330    fn test_fjc_contour_length() {
331        let fjc = FreelyJointedChain::new(100, 0.38e-9);
332        let expected = 100.0 * 0.38e-9;
333        assert!((fjc.contour_length() - expected).abs() < EPS);
334    }
335
336    #[test]
337    fn test_fjc_end_to_end_rms_scales_sqrt_n() {
338        // r_rms = b * sqrt(n), so ratio r_rms / b = sqrt(n)
339        let b = 0.38e-9_f64;
340        let fjc100 = FreelyJointedChain::new(100, b);
341        let fjc400 = FreelyJointedChain::new(400, b);
342        let ratio = fjc400.end_to_end_rms() / fjc100.end_to_end_rms();
343        assert!((ratio - 2.0).abs() < 1e-10, "ratio={ratio}");
344    }
345
346    #[test]
347    fn test_fjc_rms_smaller_than_contour() {
348        let fjc = FreelyJointedChain::new(1000, 1e-9);
349        assert!(fjc.end_to_end_rms() < fjc.contour_length());
350    }
351
352    #[test]
353    fn test_fjc_force_extension_positive() {
354        let fjc = FreelyJointedChain::new(100, 1e-9);
355        let f = fjc.force_extension(0.5, 4.1e-21);
356        assert!(f > 0.0, "force should be positive, got {f}");
357    }
358
359    #[test]
360    fn test_fjc_force_extension_increases() {
361        let fjc = FreelyJointedChain::new(100, 1e-9);
362        let kt = 4.1e-21_f64;
363        let f1 = fjc.force_extension(0.3, kt);
364        let f2 = fjc.force_extension(0.8, kt);
365        assert!(f2 > f1, "force should increase with extension");
366    }
367
368    // ── Langevin function ────────────────────────────────────────────────────
369
370    #[test]
371    fn test_langevin_zero() {
372        // L(0) = 0 via L'Hopital
373        assert!(langevin_function(0.0).abs() < 1e-6);
374    }
375
376    #[test]
377    fn test_langevin_approaches_one_for_large_x() {
378        // L(x) → 1 as x → ∞; at x=100 the value is very close to 1.
379        let val = langevin_function(100.0);
380        assert!(
381            val > 0.98 && val <= 1.0,
382            "L(100)={val} should be in (0.98, 1]"
383        );
384    }
385
386    #[test]
387    fn test_langevin_positive_for_positive_x() {
388        assert!(langevin_function(1.0) > 0.0);
389    }
390
391    #[test]
392    fn test_langevin_odd_function() {
393        let val_pos = langevin_function(2.0);
394        let val_neg = langevin_function(-2.0);
395        assert!((val_pos + val_neg).abs() < EPS, "L should be odd");
396    }
397
398    #[test]
399    fn test_langevin_less_than_one() {
400        assert!(langevin_function(5.0) < 1.0);
401    }
402
403    // ── Inverse Langevin ────────────────────────────────────────────────────
404
405    #[test]
406    fn test_inverse_langevin_near_zero() {
407        // L⁻¹(0) = 0
408        assert!(inverse_langevin_approx(0.0).abs() < EPS);
409    }
410
411    #[test]
412    fn test_inverse_langevin_increases() {
413        let v1 = inverse_langevin_approx(0.3);
414        let v2 = inverse_langevin_approx(0.6);
415        assert!(v2 > v1);
416    }
417
418    #[test]
419    fn test_inverse_langevin_large_for_y_near_one() {
420        let v = inverse_langevin_approx(0.99);
421        assert!(v > 10.0, "inverse Langevin near 1 should be large, got {v}");
422    }
423
424    // ── WLC ─────────────────────────────────────────────────────────────────
425
426    #[test]
427    fn test_wlc_end_to_end_rms_less_than_contour() {
428        let wlc = WormLikeChain::new(50e-9, 1000e-9);
429        assert!(wlc.end_to_end_rms() < wlc.contour_length);
430    }
431
432    #[test]
433    fn test_wlc_end_to_end_rms_formula() {
434        let lp = 50e-9_f64;
435        let lc = 1000e-9_f64;
436        let wlc = WormLikeChain::new(lp, lc);
437        let expected = (2.0 * lp * lc).sqrt();
438        assert!((wlc.end_to_end_rms() - expected).abs() < EPS);
439    }
440
441    #[test]
442    fn test_wlc_force_extension_positive() {
443        let wlc = WormLikeChain::new(50e-9, 1000e-9);
444        let f = wlc.force_extension(500e-9, 4.1e-21);
445        assert!(f > 0.0, "WLC force should be positive, got {f}");
446    }
447
448    #[test]
449    fn test_wlc_force_extension_increases() {
450        let wlc = WormLikeChain::new(50e-9, 1000e-9);
451        let kt = 4.1e-21_f64;
452        let f1 = wlc.force_extension(200e-9, kt);
453        let f2 = wlc.force_extension(900e-9, kt);
454        assert!(f2 > f1, "WLC force should increase with extension");
455    }
456
457    // ── Relaxation times ────────────────────────────────────────────────────
458
459    #[test]
460    fn test_rouse_relaxation_scales_n_squared() {
461        let eta = 1e-3_f64;
462        let b = 1e-9_f64;
463        let kt = 4.1e-21_f64;
464        let t100 = rouse_relaxation_time(100, eta, b, kt);
465        let t200 = rouse_relaxation_time(200, eta, b, kt);
466        let ratio = t200 / t100;
467        assert!((ratio - 4.0).abs() < 1e-9, "ratio={ratio}");
468    }
469
470    #[test]
471    fn test_rouse_relaxation_positive() {
472        let t = rouse_relaxation_time(50, 1e-3, 1e-9, 4.1e-21);
473        assert!(t > 0.0);
474    }
475
476    #[test]
477    fn test_zimm_relaxation_positive() {
478        let t = zimm_relaxation_time(100, 1e-3, 10e-9, 4.1e-21);
479        assert!(t > 0.0);
480    }
481
482    #[test]
483    fn test_zimm_relaxation_scales_rg_cubed() {
484        let eta = 1e-3_f64;
485        let kt = 4.1e-21_f64;
486        let t1 = zimm_relaxation_time(100, eta, 10e-9, kt);
487        let t2 = zimm_relaxation_time(100, eta, 20e-9, kt);
488        let ratio = t2 / t1;
489        assert!((ratio - 8.0).abs() < 1e-9, "ratio={ratio}");
490    }
491
492    // ── Rubber elasticity ────────────────────────────────────────────────────
493
494    #[test]
495    fn test_rubber_shear_modulus_equals_n_kt() {
496        let n = 1e25_f64;
497        let kt = 4.1e-21_f64;
498        let re = RubberElasticity::new(n, kt, 1e22);
499        let g = re.shear_modulus();
500        assert!((g - n * kt).abs() < 1e-6, "G={g}, expected n*kT={}", n * kt);
501    }
502
503    #[test]
504    fn test_rubber_bulk_modulus_positive() {
505        let re = RubberElasticity::new(1e25, 4.1e-21, 1e22);
506        assert!(re.bulk_modulus() > 0.0);
507    }
508
509    #[test]
510    fn test_rubber_strain_energy_zero_at_lambda_one() {
511        let re = RubberElasticity::new(1e25, 4.1e-21, 1e22);
512        let w = re.strain_energy(1.0);
513        assert!(w.abs() < 1e-10, "W at λ=1 should be 0, got {w}");
514    }
515
516    #[test]
517    fn test_rubber_strain_energy_positive_for_stretch() {
518        let re = RubberElasticity::new(1e25, 4.1e-21, 1e22);
519        assert!(re.strain_energy(2.0) > 0.0);
520    }
521
522    #[test]
523    fn test_rubber_stress_stretch_formula() {
524        // At λ=1: stress = 1 - 1 = 0
525        assert!(RubberElasticity::stress_stretch(1.0).abs() < EPS);
526        // At λ=2: stress = 2 - 0.25 = 1.75
527        let expected = 2.0 - 1.0 / 4.0;
528        assert!((RubberElasticity::stress_stretch(2.0) - expected).abs() < EPS);
529    }
530
531    // ── Flory-Huggins ───────────────────────────────────────────────────────
532
533    #[test]
534    fn test_flory_huggins_symmetric_at_half() {
535        // Symmetric blend (n1 = n2): free energy should be the same at phi and 1-phi.
536        let fh_half = flory_huggins_free_energy(0.5, 100, 0.0);
537        let fh_half2 = flory_huggins_free_energy(0.5, 100, 0.0);
538        assert!((fh_half - fh_half2).abs() < EPS);
539    }
540
541    #[test]
542    fn test_flory_huggins_chi_zero_is_entropy_only() {
543        // With chi=0 the interaction term vanishes
544        let phi = 0.3_f64;
545        let n = 50_usize;
546        let expected = phi * phi.ln() / n as f64 + (1.0 - phi) * (1.0 - phi).ln();
547        let val = flory_huggins_free_energy(phi, n, 0.0);
548        assert!((val - expected).abs() < EPS);
549    }
550
551    #[test]
552    fn test_flory_huggins_negative_for_mixing_favorable() {
553        // chi=0, purely entropic mixing is always negative (mixing lowers free energy)
554        assert!(flory_huggins_free_energy(0.3, 1, 0.0) < 0.0);
555    }
556
557    // ── chi_critical ────────────────────────────────────────────────────────
558
559    #[test]
560    fn test_chi_critical_decreases_with_n() {
561        let chi_n10 = chi_critical(10, 10);
562        let chi_n100 = chi_critical(100, 100);
563        assert!(chi_n100 < chi_n10, "chi_c should decrease with N");
564    }
565
566    #[test]
567    fn test_chi_critical_symmetric_blend() {
568        // For symmetric blend: chi_c = (2/sqrt(N))^2 / 2 = 2/N
569        let n = 100_usize;
570        let expected = 2.0 / n as f64;
571        let got = chi_critical(n, n);
572        assert!(
573            (got - expected).abs() < 1e-10,
574            "chi_c={got}, expected={expected}"
575        );
576    }
577
578    #[test]
579    fn test_spinodal_below_critical_chi_returns_midpoint() {
580        let chi_c = chi_critical(100, 100);
581        let (lo, hi) = spinodal_composition(chi_c * 0.5, 100, 100);
582        assert!((lo - 0.5).abs() < 1e-6);
583        assert!((hi - 0.5).abs() < 1e-6);
584    }
585
586    #[test]
587    fn test_spinodal_above_critical_chi_gives_two_points() {
588        let chi_c = chi_critical(100, 100);
589        let (lo, hi) = spinodal_composition(chi_c * 2.0, 100, 100);
590        assert!(lo < 0.5, "low spinodal should be < 0.5, got {lo}");
591        assert!(hi > 0.5, "high spinodal should be > 0.5, got {hi}");
592    }
593
594    // ── radius_of_gyration_ideal ────────────────────────────────────────────
595
596    #[test]
597    fn test_radius_of_gyration_formula() {
598        let n = 100_usize;
599        let b = 1e-9_f64;
600        let expected = b * ((n as f64) / 6.0).sqrt();
601        let got = radius_of_gyration_ideal(n, b);
602        assert!((got - expected).abs() < EPS);
603    }
604
605    #[test]
606    fn test_radius_of_gyration_positive() {
607        assert!(radius_of_gyration_ideal(100, 1e-9) > 0.0);
608    }
609
610    #[test]
611    fn test_radius_of_gyration_scales_sqrt_n() {
612        let b = 1e-9_f64;
613        let r100 = radius_of_gyration_ideal(100, b);
614        let r400 = radius_of_gyration_ideal(400, b);
615        let ratio = r400 / r100;
616        assert!((ratio - 2.0).abs() < 1e-10, "ratio={ratio}");
617    }
618
619    // ── Mark-Houwink ────────────────────────────────────────────────────────
620
621    #[test]
622    fn test_mark_houwink_formula() {
623        let k = 1e-4_f64;
624        let alpha = 0.7_f64;
625        let mw = 1e6_f64;
626        let expected = k * mw.powf(alpha);
627        let got = polymer_viscosity_mark_houwink(k, alpha, mw);
628        assert!((got - expected).abs() < EPS);
629    }
630
631    #[test]
632    fn test_mark_houwink_increases_with_mw() {
633        let v1 = polymer_viscosity_mark_houwink(1e-4, 0.7, 1e5);
634        let v2 = polymer_viscosity_mark_houwink(1e-4, 0.7, 1e6);
635        assert!(v2 > v1);
636    }
637
638    // ── FJC langevin static method ───────────────────────────────────────────
639
640    #[test]
641    fn test_fjc_langevin_static() {
642        let v = FreelyJointedChain::langevin(1.0);
643        assert!(v > 0.0 && v < 1.0);
644    }
645
646    // ── edge / boundary ─────────────────────────────────────────────────────
647
648    #[test]
649    fn test_fjc_single_segment() {
650        let fjc = FreelyJointedChain::new(1, 1e-9);
651        assert!((fjc.contour_length() - 1e-9).abs() < EPS);
652        assert!((fjc.end_to_end_rms() - 1e-9).abs() < EPS);
653    }
654
655    #[test]
656    fn test_wlc_rod_limit_lp_much_greater_than_lc() {
657        // When lp >> lc the chain behaves as a rod: r_rms ≈ sqrt(2*lp*lc)
658        let lp = 1.0_f64; // 1 m persistence length
659        let lc = 1e-6_f64; // very short
660        let wlc = WormLikeChain::new(lp, lc);
661        let rms = wlc.end_to_end_rms();
662        let expected = (2.0 * lp * lc).sqrt();
663        assert!((rms - expected).abs() < EPS);
664    }
665
666    #[test]
667    fn test_rubber_stress_zero_at_lambda_one_normalised() {
668        // Normalised stress σ/G = λ − 1/λ² = 0 at λ=1
669        assert!(RubberElasticity::stress_stretch(1.0).abs() < EPS);
670    }
671}