Skip to main content

oxiphysics_materials/
geomaterial_models.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Geomaterial constitutive models.
5//!
6//! Implements advanced constitutive models for soils, rocks, and special
7//! geomaterials:
8//!
9//! - [`ModifiedCamClay`]: critical-state elasto-plastic model (Roscoe & Burland).
10//! - [`MohrCoulombModel`]: linear Mohr-Coulomb failure with tension cut-off.
11//! - [`CapPlasticityModel`]: Drucker-Prager / Cap model for soil compaction.
12//! - [`HoekBrownRock`]: empirical strength criterion for jointed rock masses.
13//! - [`KozenyCarmanPermeability`]: permeability from void ratio and particle size.
14//! - [`TerzaghiConsolidation`]: 1-D consolidation (pore-pressure dissipation).
15//! - [`SwellingClayModel`]: expansive clay with suction-dependent swelling strain.
16//! - [`LiquefactionCriteria`]: simplified cyclic-stress-ratio screening.
17//! - [`RockfillModel`]: nonlinear strength and stiffness for coarse fill.
18//! - [`FrozenSoilModel`]: temperature-dependent strength and creep for frozen ground.
19//!
20//! References
21//! ----------
22//! Roscoe, K. H. & Burland, J. B. (1968) *On the generalised stress–strain
23//! behaviour of "wet" clay*, Cambridge.
24//! Hoek, E. & Brown, E. T. (1997) *Int. J. Rock Mech.* 34(8), 1165–1186.
25//! Kozeny, J. (1927); Carman, P. C. (1937,1956).
26//! Terzaghi, K. (1943) *Theoretical Soil Mechanics*, Wiley.
27
28#![allow(dead_code)]
29#![allow(clippy::too_many_arguments)]
30
31use std::f64::consts::PI;
32
33// ---------------------------------------------------------------------------
34// Constants
35// ---------------------------------------------------------------------------
36
37/// Gravitational acceleration \[m s^-2\].
38const G_ACC: f64 = 9.81;
39
40/// Unit weight of water \[kN m^-3\].
41const GAMMA_W: f64 = 9.81;
42
43/// Absolute zero offset for frozen soil temperature \[C\].
44const ABS_ZERO_C: f64 = -273.15;
45
46// ---------------------------------------------------------------------------
47// Small helpers
48// ---------------------------------------------------------------------------
49
50/// Clamp a value to \[lo, hi\].
51#[inline]
52fn clamp_f64(v: f64, lo: f64, hi: f64) -> f64 {
53    if v < lo {
54        lo
55    } else if v > hi {
56        hi
57    } else {
58        v
59    }
60}
61
62/// Mean effective stress from principal stresses.
63#[inline]
64fn mean_stress(s1: f64, s2: f64, s3: f64) -> f64 {
65    (s1 + s2 + s3) / 3.0
66}
67
68/// Deviatoric stress invariant q from principal stresses.
69#[inline]
70fn deviatoric_q(s1: f64, s2: f64, s3: f64) -> f64 {
71    let t1 = (s1 - s2).powi(2);
72    let t2 = (s2 - s3).powi(2);
73    let t3 = (s3 - s1).powi(2);
74    ((t1 + t2 + t3) / 2.0).sqrt()
75}
76
77// ===========================================================================
78// Modified Cam-Clay
79// ===========================================================================
80
81/// Modified Cam-Clay critical-state model.
82///
83/// The yield surface in (p', q) space is:
84///
85///   q^2 / M^2 + p' (p' - p_c) = 0
86///
87/// where M is the critical-state stress ratio and p_c is the
88/// pre-consolidation pressure (hardening variable).
89#[derive(Debug, Clone)]
90pub struct ModifiedCamClay {
91    /// Slope of critical state line M.
92    pub m_slope: f64,
93    /// Slope of normal compression line lambda.
94    pub lambda: f64,
95    /// Slope of swelling/recompression line kappa.
96    pub kappa: f64,
97    /// Initial pre-consolidation pressure p_c0 \[Pa\].
98    pub pc0: f64,
99    /// Current pre-consolidation pressure p_c \[Pa\].
100    pub pc: f64,
101    /// Initial specific volume v0.
102    pub v0: f64,
103    /// Poisson's ratio for elastic unloading.
104    pub poisson: f64,
105}
106
107impl ModifiedCamClay {
108    /// Create a new Modified Cam-Clay model.
109    pub fn new(m_slope: f64, lambda: f64, kappa: f64, pc0: f64, v0: f64, poisson: f64) -> Self {
110        Self {
111            m_slope,
112            lambda,
113            kappa,
114            pc0,
115            pc: pc0,
116            v0,
117            poisson,
118        }
119    }
120
121    /// Evaluate the yield function f(p', q).
122    ///
123    /// Returns < 0 inside the yield surface (elastic), 0 on the surface,
124    /// > 0 outside (inadmissible before return mapping).
125    pub fn yield_function(&self, p: f64, q: f64) -> f64 {
126        q * q / (self.m_slope * self.m_slope) + p * (p - self.pc)
127    }
128
129    /// Check whether the stress state is on or outside the yield surface.
130    pub fn is_yielding(&self, p: f64, q: f64) -> bool {
131        self.yield_function(p, q) >= -1e-12
132    }
133
134    /// Elastic bulk modulus K from current mean stress.
135    pub fn bulk_modulus(&self, p: f64) -> f64 {
136        self.v0 * p / self.kappa
137    }
138
139    /// Elastic shear modulus G (derived from K and nu).
140    pub fn shear_modulus(&self, p: f64) -> f64 {
141        let k = self.bulk_modulus(p);
142        3.0 * k * (1.0 - 2.0 * self.poisson) / (2.0 * (1.0 + self.poisson))
143    }
144
145    /// Harden: update pre-consolidation pressure from plastic volumetric
146    /// strain increment d_eps_v_p (positive = compression).
147    pub fn harden(&mut self, d_eps_v_p: f64) {
148        let factor = self.v0 / (self.lambda - self.kappa);
149        self.pc *= (factor * d_eps_v_p).exp();
150    }
151
152    /// Stress ratio eta = q / p'.
153    pub fn stress_ratio(p: f64, q: f64) -> f64 {
154        if p.abs() < 1e-15 { 0.0 } else { q / p }
155    }
156
157    /// Over-consolidation ratio OCR = p_c / p'.
158    pub fn ocr(&self, p: f64) -> f64 {
159        if p.abs() < 1e-15 {
160            f64::INFINITY
161        } else {
162            self.pc / p
163        }
164    }
165
166    /// Volumetric strain on the normal compression line from p'_0 to p'.
167    pub fn ncl_volumetric_strain(&self, p0: f64, p: f64) -> f64 {
168        if p0 <= 0.0 || p <= 0.0 {
169            return 0.0;
170        }
171        (self.lambda / self.v0) * (p / p0).ln()
172    }
173
174    /// Elastic (swelling-line) volumetric strain from p0 to p.
175    pub fn elastic_volumetric_strain(&self, p0: f64, p: f64) -> f64 {
176        if p0 <= 0.0 || p <= 0.0 {
177            return 0.0;
178        }
179        (self.kappa / self.v0) * (p / p0).ln()
180    }
181
182    /// Size of the yield ellipse along the p-axis (= pc).
183    pub fn yield_surface_size(&self) -> f64 {
184        self.pc
185    }
186
187    /// Critical-state mean stress for given q.
188    pub fn critical_state_p(&self, q: f64) -> f64 {
189        q / self.m_slope
190    }
191
192    /// Reset to initial pre-consolidation.
193    pub fn reset(&mut self) {
194        self.pc = self.pc0;
195    }
196}
197
198// ===========================================================================
199// Mohr-Coulomb
200// ===========================================================================
201
202/// Linear Mohr-Coulomb failure criterion with optional tension cut-off.
203///
204/// tau_f = c + sigma_n * tan(phi)
205#[derive(Debug, Clone)]
206pub struct MohrCoulombModel {
207    /// Cohesion c \[Pa\].
208    pub cohesion: f64,
209    /// Friction angle phi \[radians\].
210    pub friction_angle: f64,
211    /// Dilation angle psi \[radians\].
212    pub dilation_angle: f64,
213    /// Tensile strength sigma_t \[Pa\] (positive value, used as cut-off).
214    pub tensile_strength: f64,
215}
216
217impl MohrCoulombModel {
218    /// Construct from cohesion \[Pa\], friction angle \[deg\], dilation angle \[deg\],
219    /// and tensile strength \[Pa\].
220    pub fn new(cohesion: f64, friction_deg: f64, dilation_deg: f64, tensile_strength: f64) -> Self {
221        Self {
222            cohesion,
223            friction_angle: friction_deg.to_radians(),
224            dilation_angle: dilation_deg.to_radians(),
225            tensile_strength,
226        }
227    }
228
229    /// Shear strength at given normal stress sigma_n (positive = compressive).
230    pub fn shear_strength(&self, sigma_n: f64) -> f64 {
231        self.cohesion + sigma_n * self.friction_angle.tan()
232    }
233
234    /// Yield function value: f = |tau| - c - sigma_n * tan(phi).
235    /// Negative means elastic.
236    pub fn yield_function(&self, sigma_n: f64, tau: f64) -> f64 {
237        tau.abs() - self.shear_strength(sigma_n)
238    }
239
240    /// Check failure given principal stresses sigma_1 >= sigma_3.
241    pub fn is_failed_principals(&self, sigma1: f64, sigma3: f64) -> bool {
242        let sin_phi = self.friction_angle.sin();
243        let lhs = (sigma1 - sigma3) / 2.0;
244        let rhs = (sigma1 + sigma3) / 2.0 * sin_phi + self.cohesion * self.friction_angle.cos();
245        lhs >= rhs - 1e-12
246    }
247
248    /// Maximum principal stress difference at failure (confined by sigma3).
249    pub fn max_deviator(&self, sigma3: f64) -> f64 {
250        let sin_phi = self.friction_angle.sin();
251        let _cos_phi = self.friction_angle.cos();
252        let n_phi = (1.0 + sin_phi) / (1.0 - sin_phi);
253        2.0 * self.cohesion * n_phi.sqrt() + sigma3 * (n_phi - 1.0)
254    }
255
256    /// Check tension cut-off: fails if sigma3 < -tensile_strength.
257    pub fn tension_cutoff(&self, sigma3: f64) -> bool {
258        sigma3 < -self.tensile_strength
259    }
260
261    /// Plastic potential dilation factor d_eps_v / d_gamma.
262    pub fn dilation_factor(&self) -> f64 {
263        self.dilation_angle.sin()
264    }
265
266    /// Equivalent friction angle from cohesion and UCS.
267    pub fn from_ucs(ucs: f64, cohesion: f64) -> f64 {
268        // sigma_c = 2 c cos(phi) / (1 - sin(phi))
269        // Solve iteratively with Newton.
270        let mut phi = 30.0_f64.to_radians();
271        for _ in 0..20 {
272            let sp = phi.sin();
273            let cp = phi.cos();
274            let f_val = ucs * (1.0 - sp) - 2.0 * cohesion * cp;
275            let df = -ucs * cp + 2.0 * cohesion * sp;
276            if df.abs() < 1e-30 {
277                break;
278            }
279            phi -= f_val / df;
280            phi = clamp_f64(phi, 0.001, PI / 2.0 - 0.001);
281        }
282        phi
283    }
284}
285
286// ===========================================================================
287// Cap Plasticity
288// ===========================================================================
289
290/// Cap plasticity model (Drucker-Prager shear surface + elliptical cap).
291///
292/// Used for modelling compactive yielding in soils, concrete, and powders.
293#[derive(Debug, Clone)]
294pub struct CapPlasticityModel {
295    /// Drucker-Prager friction parameter alpha.
296    pub alpha: f64,
297    /// Drucker-Prager cohesion parameter k.
298    pub k_cohesion: f64,
299    /// Cap eccentricity R (ratio of ellipse axes).
300    pub cap_r: f64,
301    /// Hardening parameter: initial cap position X_0 (hydrostatic yield).
302    pub x0: f64,
303    /// Current cap position X.
304    pub x_cap: f64,
305    /// Hardening modulus W (relates plastic vol strain to cap movement).
306    pub hardening_w: f64,
307    /// Cap shape parameter D.
308    pub hardening_d: f64,
309}
310
311impl CapPlasticityModel {
312    /// Construct a new cap plasticity model.
313    pub fn new(
314        alpha: f64,
315        k_cohesion: f64,
316        cap_r: f64,
317        x0: f64,
318        hardening_w: f64,
319        hardening_d: f64,
320    ) -> Self {
321        Self {
322            alpha,
323            k_cohesion,
324            cap_r,
325            x0,
326            x_cap: x0,
327            hardening_w,
328            hardening_d,
329        }
330    }
331
332    /// Drucker-Prager shear yield function: f_s = q - alpha * p - k.
333    pub fn shear_yield(&self, p: f64, q: f64) -> f64 {
334        q - self.alpha * p - self.k_cohesion
335    }
336
337    /// Cap yield function (elliptical): f_c = sqrt((p - L)^2 + (Rq)^2) - (X - L).
338    /// L = X - R * (alpha * X + k).
339    pub fn cap_yield(&self, p: f64, q: f64) -> f64 {
340        let l = self.cap_l();
341        let rq = self.cap_r * q;
342        let dp = p - l;
343        (dp * dp + rq * rq).sqrt() - (self.x_cap - l)
344    }
345
346    /// Intersection of DP line and hydrostatic axis: L.
347    fn cap_l(&self) -> f64 {
348        self.x_cap - self.cap_r * (self.alpha * self.x_cap + self.k_cohesion)
349    }
350
351    /// Check if stress state is elastic.
352    pub fn is_elastic(&self, p: f64, q: f64) -> bool {
353        self.shear_yield(p, q) < 1e-12 && self.cap_yield(p, q) < 1e-12
354    }
355
356    /// Harden cap from plastic volumetric strain increment.
357    pub fn harden_cap(&mut self, d_eps_v_p: f64) {
358        // X moves according to exponential hardening
359        let eps_total = self.current_plastic_vol_strain() + d_eps_v_p;
360        self.x_cap = self.x0
361            - (1.0 / self.hardening_d) * (1.0 - eps_total / self.hardening_w).ln().max(-50.0);
362    }
363
364    /// Current accumulated plastic volumetric strain from cap position.
365    pub fn current_plastic_vol_strain(&self) -> f64 {
366        self.hardening_w * (1.0 - (-self.hardening_d * (self.x_cap - self.x0)).exp())
367    }
368
369    /// Reset cap to initial position.
370    pub fn reset(&mut self) {
371        self.x_cap = self.x0;
372    }
373}
374
375// ===========================================================================
376// Hoek-Brown Rock Criterion
377// ===========================================================================
378
379/// Generalised Hoek-Brown rock-mass strength criterion.
380///
381/// sigma_1 = sigma_3 + sigma_ci * (m_b * sigma_3 / sigma_ci + s)^a
382#[derive(Debug, Clone)]
383pub struct HoekBrownRock {
384    /// Intact uniaxial compressive strength sigma_ci \[Pa\].
385    pub sigma_ci: f64,
386    /// Material constant m_i (intact rock).
387    pub m_i: f64,
388    /// Geological Strength Index GSI \[0..100\].
389    pub gsi: f64,
390    /// Disturbance factor D \[0..1\].
391    pub disturbance: f64,
392    /// Derived: m_b.
393    pub m_b: f64,
394    /// Derived: s.
395    pub s: f64,
396    /// Derived: a.
397    pub a: f64,
398}
399
400impl HoekBrownRock {
401    /// Build from intact rock parameters and GSI.
402    pub fn new(sigma_ci: f64, m_i: f64, gsi: f64, disturbance: f64) -> Self {
403        let m_b = m_i * ((gsi - 100.0) / (28.0 - 14.0 * disturbance)).exp();
404        let s = ((gsi - 100.0) / (9.0 - 3.0 * disturbance)).exp();
405        let a = 0.5 + ((-gsi / 15.0_f64).exp() - (-20.0 / 3.0_f64).exp()) / 6.0;
406        Self {
407            sigma_ci,
408            m_i,
409            gsi,
410            disturbance,
411            m_b,
412            s,
413            a,
414        }
415    }
416
417    /// Major principal stress at failure given confining stress sigma3 >= 0.
418    pub fn sigma1_at_failure(&self, sigma3: f64) -> f64 {
419        let ratio = sigma3 / self.sigma_ci;
420        let inner = self.m_b * ratio + self.s;
421        if inner <= 0.0 {
422            sigma3
423        } else {
424            sigma3 + self.sigma_ci * inner.powf(self.a)
425        }
426    }
427
428    /// Uniaxial compressive strength of the rock mass.
429    pub fn ucs_rock_mass(&self) -> f64 {
430        self.sigma_ci * self.s.powf(self.a)
431    }
432
433    /// Tensile strength estimate (sigma_3 where sigma_1 = sigma_3).
434    pub fn tensile_strength(&self) -> f64 {
435        -self.s * self.sigma_ci / self.m_b
436    }
437
438    /// Equivalent Mohr-Coulomb cohesion and friction angle for a given
439    /// confining stress range \[sig3_min, sig3_max\].
440    ///
441    /// Returns (cohesion \[Pa\], friction_angle \[radians\]).
442    pub fn equivalent_mc(&self, sig3_min: f64, sig3_max: f64) -> (f64, f64) {
443        let n = 10;
444        let ds = (sig3_max - sig3_min) / n as f64;
445        let mut sum_tau = 0.0;
446        let mut sum_sigma = 0.0;
447        let mut sum_st = 0.0;
448        let mut sum_s2 = 0.0;
449        let nf = (n + 1) as f64;
450        for i in 0..=n {
451            let s3 = sig3_min + i as f64 * ds;
452            let s1 = self.sigma1_at_failure(s3);
453            let sigma_n = (s1 + s3) / 2.0;
454            let tau = (s1 - s3) / 2.0;
455            sum_sigma += sigma_n;
456            sum_tau += tau;
457            sum_st += sigma_n * tau;
458            sum_s2 += sigma_n * sigma_n;
459        }
460        let denom = nf * sum_s2 - sum_sigma * sum_sigma;
461        if denom.abs() < 1e-30 {
462            return (0.0, 0.0);
463        }
464        let tan_phi = (nf * sum_st - sum_sigma * sum_tau) / denom;
465        let c = (sum_tau - tan_phi * sum_sigma) / nf;
466        (c.max(0.0), tan_phi.atan().max(0.0))
467    }
468
469    /// Deformation modulus E_rm \[Pa\] (Hoek-Diederichs 2006).
470    pub fn deformation_modulus(&self) -> f64 {
471        let e_i = self.sigma_ci * 1000.0; // rough estimate
472        e_i * (0.02
473            + (1.0 - self.disturbance / 2.0)
474                / (1.0 + ((60.0 + 15.0 * self.disturbance - self.gsi) / 11.0).exp()))
475    }
476}
477
478// ===========================================================================
479// Kozeny-Carman Permeability
480// ===========================================================================
481
482/// Kozeny-Carman permeability model.
483///
484/// k = C * (e^3 / (1 + e)) * d10^2
485#[derive(Debug, Clone)]
486pub struct KozenyCarmanPermeability {
487    /// Shape/tortuosity factor C.
488    pub shape_factor: f64,
489    /// Effective grain diameter d10 \[m\].
490    pub d10: f64,
491    /// Dynamic viscosity of pore fluid \[Pa s\].
492    pub viscosity: f64,
493}
494
495impl KozenyCarmanPermeability {
496    /// Create a Kozeny-Carman model with given shape factor, d10, viscosity.
497    pub fn new(shape_factor: f64, d10: f64, viscosity: f64) -> Self {
498        Self {
499            shape_factor,
500            d10,
501            viscosity,
502        }
503    }
504
505    /// Default model for water at 20 C with standard shape factor.
506    pub fn default_water(d10: f64) -> Self {
507        Self::new(1.0 / 180.0, d10, 1.002e-3)
508    }
509
510    /// Intrinsic permeability k \[m^2\] for void ratio e.
511    pub fn intrinsic_permeability(&self, e: f64) -> f64 {
512        if e <= 0.0 {
513            return 0.0;
514        }
515        self.shape_factor * (e.powi(3) / (1.0 + e)) * self.d10.powi(2)
516    }
517
518    /// Hydraulic conductivity K \[m/s\] for void ratio e.
519    pub fn hydraulic_conductivity(&self, e: f64) -> f64 {
520        let k_intr = self.intrinsic_permeability(e);
521        k_intr * GAMMA_W * 1000.0 / self.viscosity // gamma_w in N/m3
522    }
523
524    /// Ratio of permeability at e1 vs e0.
525    pub fn permeability_ratio(&self, e0: f64, e1: f64) -> f64 {
526        if e0 <= 0.0 {
527            return 0.0;
528        }
529        let k0 = self.intrinsic_permeability(e0);
530        let k1 = self.intrinsic_permeability(e1);
531        if k0.abs() < 1e-30 { 0.0 } else { k1 / k0 }
532    }
533
534    /// Porosity n from void ratio e.
535    pub fn porosity(e: f64) -> f64 {
536        e / (1.0 + e)
537    }
538
539    /// Void ratio from porosity n.
540    pub fn void_ratio_from_porosity(n: f64) -> f64 {
541        if n >= 1.0 {
542            return f64::INFINITY;
543        }
544        n / (1.0 - n)
545    }
546}
547
548// ===========================================================================
549// Terzaghi 1-D Consolidation
550// ===========================================================================
551
552/// Terzaghi one-dimensional consolidation model.
553///
554/// Solves the 1-D diffusion equation for excess pore-water pressure:
555///   du/dt = c_v * d2u/dz2
556#[derive(Debug, Clone)]
557pub struct TerzaghiConsolidation {
558    /// Coefficient of consolidation c_v \[m^2/s\].
559    pub cv: f64,
560    /// Drainage path length H_dr \[m\].
561    pub h_dr: f64,
562    /// Initial excess pore pressure u_0 \[Pa\].
563    pub u0: f64,
564}
565
566impl TerzaghiConsolidation {
567    /// Create a Terzaghi consolidation model.
568    pub fn new(cv: f64, h_dr: f64, u0: f64) -> Self {
569        Self { cv, h_dr, u0 }
570    }
571
572    /// Time factor T_v = c_v * t / H_dr^2.
573    pub fn time_factor(&self, t: f64) -> f64 {
574        self.cv * t / (self.h_dr * self.h_dr)
575    }
576
577    /// Degree of consolidation U(T_v) using series approximation.
578    pub fn degree_of_consolidation(&self, t: f64) -> f64 {
579        let tv = self.time_factor(t);
580        if tv <= 0.0 {
581            return 0.0;
582        }
583        // Series solution: U = 1 - sum_{m=0}^{N} (2/M^2) exp(-M^2 Tv)
584        // where M = pi/2 * (2m+1)
585        let mut u = 0.0;
586        for m in 0..50 {
587            let big_m = PI / 2.0 * (2.0 * m as f64 + 1.0);
588            let term = (2.0 / (big_m * big_m)) * (-big_m * big_m * tv).exp();
589            u += term;
590            if term.abs() < 1e-15 {
591                break;
592            }
593        }
594        clamp_f64(1.0 - u, 0.0, 1.0)
595    }
596
597    /// Excess pore pressure at depth z and time t (double drainage).
598    pub fn pore_pressure(&self, z: f64, t: f64) -> f64 {
599        let tv = self.time_factor(t);
600        if tv <= 0.0 {
601            return self.u0;
602        }
603        let mut u = 0.0;
604        for m in 0..50 {
605            let big_m = PI / 2.0 * (2.0 * m as f64 + 1.0);
606            let term = (2.0 * self.u0 / big_m)
607                * (big_m * z / self.h_dr).sin()
608                * (-big_m * big_m * tv).exp();
609            u += term;
610            if term.abs() < 1e-15 {
611                break;
612            }
613        }
614        clamp_f64(u, 0.0, self.u0)
615    }
616
617    /// Settlement at time t: s(t) = U(t) * s_final.
618    pub fn settlement(&self, t: f64, s_final: f64) -> f64 {
619        self.degree_of_consolidation(t) * s_final
620    }
621
622    /// Time for a given degree of consolidation U (0..1).
623    /// Uses the approximate formula: t = T_v * H_dr^2 / c_v.
624    pub fn time_for_consolidation(&self, u_target: f64) -> f64 {
625        let u_c = clamp_f64(u_target, 0.0, 0.999);
626        // Approximate Tv from U
627        let tv = if u_c < 0.6 {
628            PI / 4.0 * u_c * u_c
629        } else {
630            -0.9332 * (1.0 - u_c).ln() - 0.0851
631        };
632        tv * self.h_dr * self.h_dr / self.cv
633    }
634
635    /// Coefficient of volume compressibility m_v from compression index Cc,
636    /// initial void ratio e0, and stress increment.
637    pub fn mv_from_cc(cc: f64, e0: f64, sigma0: f64, delta_sigma: f64) -> f64 {
638        if delta_sigma <= 0.0 || sigma0 <= 0.0 {
639            return 0.0;
640        }
641        let de = cc * ((sigma0 + delta_sigma) / sigma0).log10();
642        de / ((1.0 + e0) * delta_sigma)
643    }
644}
645
646// ===========================================================================
647// Swelling Clay Model
648// ===========================================================================
649
650/// Expansive (swelling) clay model with suction-dependent volumetric strain.
651///
652/// Models swell/shrink behaviour as a function of matric suction change.
653#[derive(Debug, Clone)]
654pub struct SwellingClayModel {
655    /// Swelling index C_s (slope of e vs log(suction) on wetting).
656    pub swelling_index: f64,
657    /// Shrinkage limit suction \[Pa\].
658    pub shrinkage_limit_suction: f64,
659    /// Initial void ratio.
660    pub e0: f64,
661    /// Initial matric suction \[Pa\].
662    pub suction0: f64,
663    /// Maximum swelling strain (cap).
664    pub max_swell_strain: f64,
665}
666
667impl SwellingClayModel {
668    /// Create a new swelling clay model.
669    pub fn new(
670        swelling_index: f64,
671        shrinkage_limit_suction: f64,
672        e0: f64,
673        suction0: f64,
674        max_swell_strain: f64,
675    ) -> Self {
676        Self {
677            swelling_index,
678            shrinkage_limit_suction,
679            e0,
680            suction0,
681            max_swell_strain,
682        }
683    }
684
685    /// Volumetric swelling strain for suction change from suction0 to s_new.
686    /// Negative suction change (wetting) -> positive swell strain.
687    pub fn volumetric_strain(&self, s_new: f64) -> f64 {
688        if s_new <= 0.0 || self.suction0 <= 0.0 {
689            return 0.0;
690        }
691        let de = -self.swelling_index * (s_new / self.suction0).log10();
692        let eps_v = de / (1.0 + self.e0);
693        clamp_f64(eps_v, -self.max_swell_strain, self.max_swell_strain)
694    }
695
696    /// Void ratio at new suction level.
697    pub fn void_ratio_at(&self, s_new: f64) -> f64 {
698        if s_new <= 0.0 || self.suction0 <= 0.0 {
699            return self.e0;
700        }
701        let de = -self.swelling_index * (s_new / self.suction0).log10();
702        (self.e0 + de).max(0.0)
703    }
704
705    /// Swell pressure estimate \[Pa\]: suction at which soil starts to swell
706    /// if loaded at given vertical stress.
707    pub fn swell_pressure(&self) -> f64 {
708        // Simplified: swell pressure ~ suction at which de = 0 starting from
709        // shrinkage limit.
710        self.shrinkage_limit_suction
711    }
712
713    /// Check if shrinkage limit is reached.
714    pub fn is_at_shrinkage_limit(&self, suction: f64) -> bool {
715        suction >= self.shrinkage_limit_suction
716    }
717
718    /// Linear swell potential from plasticity index (empirical).
719    /// Returns percent swell.
720    pub fn linear_swell_from_pi(plasticity_index: f64) -> f64 {
721        // Seed et al. (1962): swell% ~ 2.16e-3 * PI^2.44
722        2.16e-3 * plasticity_index.powf(2.44)
723    }
724}
725
726// ===========================================================================
727// Liquefaction Criteria
728// ===========================================================================
729
730/// Simplified liquefaction screening based on cyclic stress ratio.
731///
732/// Uses the Seed-Idriss simplified procedure for evaluating liquefaction
733/// potential under earthquake loading.
734#[derive(Debug, Clone)]
735pub struct LiquefactionCriteria {
736    /// Earthquake magnitude M_w.
737    pub magnitude: f64,
738    /// Peak ground acceleration a_max \[g\].
739    pub pga: f64,
740    /// Total vertical stress at depth \[Pa\].
741    pub sigma_v: f64,
742    /// Effective vertical stress at depth \[Pa\].
743    pub sigma_v_eff: f64,
744    /// SPT blow count N1_60 (corrected).
745    pub n1_60: f64,
746    /// Fines content \[%\].
747    pub fines_content: f64,
748}
749
750impl LiquefactionCriteria {
751    /// Create liquefaction criteria for a given site.
752    pub fn new(
753        magnitude: f64,
754        pga: f64,
755        sigma_v: f64,
756        sigma_v_eff: f64,
757        n1_60: f64,
758        fines_content: f64,
759    ) -> Self {
760        Self {
761            magnitude,
762            pga,
763            sigma_v,
764            sigma_v_eff,
765            n1_60,
766            fines_content,
767        }
768    }
769
770    /// Cyclic stress ratio CSR (Seed-Idriss).
771    pub fn csr(&self) -> f64 {
772        if self.sigma_v_eff <= 0.0 {
773            return 0.0;
774        }
775        let rd = self.depth_reduction_factor();
776        0.65 * (self.sigma_v / self.sigma_v_eff) * self.pga * rd
777    }
778
779    /// Depth reduction factor r_d (simplified linear approximation).
780    fn depth_reduction_factor(&self) -> f64 {
781        // Use sigma_v / gamma to estimate depth (assume gamma ~ 18 kN/m3)
782        let _depth = self.sigma_v / 18_000.0;
783        let z = self.sigma_v / 18_000.0;
784        if z <= 9.15 {
785            1.0 - 0.00765 * z
786        } else if z <= 23.0 {
787            1.174 - 0.0267 * z
788        } else {
789            0.5 // conservative
790        }
791    }
792
793    /// Cyclic resistance ratio CRR from corrected SPT (clean sand).
794    pub fn crr_clean_sand(&self) -> f64 {
795        let n = self.n1_60_cs();
796        if n >= 30.0 {
797            return f64::INFINITY; // non-liquefiable
798        }
799        // Youd & Idriss (2001) curve
800        let a = n / 34.8;
801        let b = n / 135.0;
802        let c = 50.0 / (10.0 * n + 45.0).powi(2);
803        1.0 / (a + b + c) * 0.048 + 0.004 * n
804    }
805
806    /// Clean-sand equivalent N1_60_cs (fines correction).
807    pub fn n1_60_cs(&self) -> f64 {
808        let fc = self.fines_content;
809        let alpha = if fc <= 5.0 {
810            0.0
811        } else if fc >= 35.0 {
812            5.0
813        } else {
814            (fc - 5.0) / 6.0
815        };
816        let beta = if fc <= 5.0 {
817            1.0
818        } else if fc >= 35.0 {
819            1.2
820        } else {
821            1.0 + 0.2 * (fc - 5.0) / 30.0
822        };
823        alpha + beta * self.n1_60
824    }
825
826    /// Magnitude scaling factor MSF.
827    pub fn magnitude_scaling_factor(&self) -> f64 {
828        10.0_f64.powf(2.24) / self.magnitude.powf(2.56)
829    }
830
831    /// Factor of safety against liquefaction.
832    pub fn factor_of_safety(&self) -> f64 {
833        let csr = self.csr();
834        if csr <= 0.0 {
835            return f64::INFINITY;
836        }
837        let crr = self.crr_clean_sand();
838        let msf = self.magnitude_scaling_factor();
839        crr * msf / csr
840    }
841
842    /// Is the site liquefiable? (FoS < 1.0).
843    pub fn is_liquefiable(&self) -> bool {
844        self.factor_of_safety() < 1.0
845    }
846}
847
848// ===========================================================================
849// Rockfill Model
850// ===========================================================================
851
852/// Nonlinear rockfill material model.
853///
854/// Models coarse granular fill with stress-dependent friction angle and
855/// stiffness (e.g., for earth/rock dams).
856#[derive(Debug, Clone)]
857pub struct RockfillModel {
858    /// Base friction angle at reference stress \[radians\].
859    pub phi_base: f64,
860    /// Friction angle reduction parameter delta_phi.
861    pub delta_phi: f64,
862    /// Reference confining stress for phi reduction \[Pa\].
863    pub sigma_ref: f64,
864    /// Modulus number K_mod (Janbu).
865    pub modulus_number: f64,
866    /// Modulus exponent n_mod (Janbu).
867    pub modulus_exponent: f64,
868    /// Atmospheric pressure for normalization \[Pa\].
869    pub p_atm: f64,
870    /// Maximum void ratio (loose state).
871    pub e_max: f64,
872    /// Minimum void ratio (dense state).
873    pub e_min: f64,
874}
875
876impl RockfillModel {
877    /// Create a new rockfill model.
878    pub fn new(
879        phi_base_deg: f64,
880        delta_phi_deg: f64,
881        sigma_ref: f64,
882        modulus_number: f64,
883        modulus_exponent: f64,
884    ) -> Self {
885        Self {
886            phi_base: phi_base_deg.to_radians(),
887            delta_phi: delta_phi_deg.to_radians(),
888            sigma_ref,
889            modulus_number,
890            modulus_exponent,
891            p_atm: 101_325.0,
892            e_max: 0.9,
893            e_min: 0.4,
894        }
895    }
896
897    /// Stress-dependent friction angle \[radians\] at confining pressure sigma3.
898    pub fn friction_angle(&self, sigma3: f64) -> f64 {
899        if sigma3 <= 0.0 {
900            return self.phi_base;
901        }
902        let reduction = self.delta_phi * (sigma3 / self.sigma_ref).log10().max(0.0);
903        (self.phi_base - reduction).max(0.1_f64.to_radians())
904    }
905
906    /// Janbu modulus E \[Pa\] at given confining stress.
907    pub fn youngs_modulus(&self, sigma3: f64) -> f64 {
908        if sigma3 <= 0.0 {
909            return self.modulus_number * self.p_atm;
910        }
911        self.modulus_number * self.p_atm * (sigma3 / self.p_atm).powf(self.modulus_exponent)
912    }
913
914    /// Shear strength at confining pressure.
915    pub fn shear_strength(&self, sigma3: f64) -> f64 {
916        sigma3 * self.friction_angle(sigma3).tan()
917    }
918
919    /// Relative density from void ratio.
920    pub fn relative_density(&self, e: f64) -> f64 {
921        if (self.e_max - self.e_min).abs() < 1e-15 {
922            return 0.5;
923        }
924        clamp_f64((self.e_max - e) / (self.e_max - self.e_min), 0.0, 1.0)
925    }
926
927    /// Breakage index: fraction of particles crushed (simplified).
928    pub fn breakage_index(&self, sigma3: f64) -> f64 {
929        // Empirical: B = a * log(sigma3 / p_atm)
930        if sigma3 <= self.p_atm {
931            return 0.0;
932        }
933        let b = 0.15 * (sigma3 / self.p_atm).log10();
934        clamp_f64(b, 0.0, 1.0)
935    }
936}
937
938// ===========================================================================
939// Frozen Soil Model
940// ===========================================================================
941
942/// Temperature-dependent frozen soil model.
943///
944/// Accounts for ice-bonding strength and creep at sub-zero temperatures.
945#[derive(Debug, Clone)]
946pub struct FrozenSoilModel {
947    /// Unfrozen soil cohesion \[Pa\].
948    pub c_unfrozen: f64,
949    /// Unfrozen friction angle \[radians\].
950    pub phi_unfrozen: f64,
951    /// Ice bonding strength coefficient \[Pa / C\].
952    pub ice_strength_coeff: f64,
953    /// Creep exponent n.
954    pub creep_n: f64,
955    /// Creep reference rate A \[1/s\].
956    pub creep_a: f64,
957    /// Temperature at which freezing starts \[C\].
958    pub freeze_temp: f64,
959    /// Unfrozen water content model parameter alpha.
960    pub unfrozen_alpha: f64,
961    /// Unfrozen water content model parameter beta.
962    pub unfrozen_beta: f64,
963}
964
965impl FrozenSoilModel {
966    /// Create a new frozen soil model.
967    pub fn new(
968        c_unfrozen: f64,
969        phi_unfrozen_deg: f64,
970        ice_strength_coeff: f64,
971        creep_n: f64,
972        creep_a: f64,
973    ) -> Self {
974        Self {
975            c_unfrozen,
976            phi_unfrozen: phi_unfrozen_deg.to_radians(),
977            ice_strength_coeff,
978            creep_n,
979            creep_a,
980            freeze_temp: 0.0,
981            unfrozen_alpha: 0.1,
982            unfrozen_beta: -0.5,
983        }
984    }
985
986    /// Total cohesion at temperature T \[C\].
987    pub fn cohesion(&self, temp_c: f64) -> f64 {
988        if temp_c >= self.freeze_temp {
989            return self.c_unfrozen;
990        }
991        let dt = self.freeze_temp - temp_c;
992        self.c_unfrozen + self.ice_strength_coeff * dt
993    }
994
995    /// Friction angle \[radians\] at temperature T \[C\].
996    /// Ice bonding slightly reduces effective friction at very low temperatures.
997    pub fn friction_angle(&self, temp_c: f64) -> f64 {
998        if temp_c >= self.freeze_temp {
999            return self.phi_unfrozen;
1000        }
1001        let dt = self.freeze_temp - temp_c;
1002        // Slight reduction at extreme cold
1003        (self.phi_unfrozen - 0.005 * dt).max(5.0_f64.to_radians())
1004    }
1005
1006    /// Uniaxial compressive strength at temperature T \[C\].
1007    pub fn ucs(&self, temp_c: f64) -> f64 {
1008        let c = self.cohesion(temp_c);
1009        let phi = self.friction_angle(temp_c);
1010        2.0 * c * phi.cos() / (1.0 - phi.sin())
1011    }
1012
1013    /// Creep strain rate \[1/s\] at deviatoric stress q and temperature T.
1014    pub fn creep_rate(&self, q: f64, temp_c: f64) -> f64 {
1015        if temp_c >= self.freeze_temp || q <= 0.0 {
1016            return 0.0;
1017        }
1018        // Arrhenius-type temperature dependence
1019        let dt = (self.freeze_temp - temp_c).max(0.01);
1020        let temp_factor = (-0.05 * dt).exp(); // simplified
1021        self.creep_a * (q / 1e6).powf(self.creep_n) * temp_factor
1022    }
1023
1024    /// Unfrozen water content at temperature T \[C\].
1025    pub fn unfrozen_water_content(&self, temp_c: f64) -> f64 {
1026        if temp_c >= self.freeze_temp {
1027            return 1.0; // all water is liquid
1028        }
1029        let dt = (self.freeze_temp - temp_c).max(0.01);
1030        clamp_f64(self.unfrozen_alpha * dt.powf(self.unfrozen_beta), 0.0, 1.0)
1031    }
1032
1033    /// Thermal conductivity \[W/(m K)\] using geometric mean of frozen/unfrozen.
1034    pub fn thermal_conductivity(&self, temp_c: f64, k_soil: f64, k_ice: f64, k_water: f64) -> f64 {
1035        let wu = self.unfrozen_water_content(temp_c);
1036        let wi = 1.0 - wu;
1037        // Geometric mean: k = k_soil^(1-n) * k_water^(n*wu) * k_ice^(n*wi)
1038        // Simplified: assume porosity n = 0.4
1039        let n = 0.4;
1040        k_soil.powf(1.0 - n) * k_water.powf(n * wu) * k_ice.powf(n * wi)
1041    }
1042
1043    /// Is the soil frozen at temperature T?
1044    pub fn is_frozen(&self, temp_c: f64) -> bool {
1045        temp_c < self.freeze_temp
1046    }
1047}
1048
1049// ===========================================================================
1050// Additional helpers
1051// ===========================================================================
1052
1053/// Compute Drucker-Prager parameters from Mohr-Coulomb (inner cone).
1054///
1055/// Returns (alpha, k) for the DP criterion: q = alpha * p + k.
1056pub fn mohr_coulomb_to_drucker_prager(cohesion: f64, friction_deg: f64) -> (f64, f64) {
1057    let phi = friction_deg.to_radians();
1058    let sin_phi = phi.sin();
1059    let cos_phi = phi.cos();
1060    let alpha = 6.0 * sin_phi / (3.0 - sin_phi);
1061    let k = 6.0 * cohesion * cos_phi / (3.0 - sin_phi);
1062    (alpha, k)
1063}
1064
1065/// Compute effective stress from total stress and pore pressure.
1066pub fn effective_stress(total_stress: f64, pore_pressure: f64) -> f64 {
1067    total_stress - pore_pressure
1068}
1069
1070/// Compute void ratio from dry density and specific gravity.
1071pub fn void_ratio_from_density(dry_density: f64, specific_gravity: f64) -> f64 {
1072    let rho_w = 1000.0; // kg/m3
1073    specific_gravity * rho_w / dry_density - 1.0
1074}
1075
1076/// Compute compression index Cc from liquid limit (Terzaghi & Peck).
1077pub fn compression_index_from_ll(liquid_limit: f64) -> f64 {
1078    0.009 * (liquid_limit - 10.0)
1079}
1080
1081/// Compute coefficient of earth pressure at rest K0 (Jaky).
1082pub fn k0_jaky(friction_deg: f64) -> f64 {
1083    1.0 - friction_deg.to_radians().sin()
1084}
1085
1086/// Compute bearing capacity (Terzaghi) for strip footing on cohesive soil.
1087///
1088/// q_ult = c * Nc + gamma * D * Nq + 0.5 * gamma * B * Ngamma
1089pub fn terzaghi_bearing_capacity(
1090    cohesion: f64,
1091    friction_deg: f64,
1092    gamma: f64,
1093    depth: f64,
1094    width: f64,
1095) -> f64 {
1096    let phi = friction_deg.to_radians();
1097    let tan_phi = phi.tan();
1098    // Bearing capacity factors (approximate)
1099    let nq = if phi.abs() < 1e-10 {
1100        1.0
1101    } else {
1102        (PI * tan_phi).exp() * (PI / 4.0 + phi / 2.0).tan().powi(2)
1103    };
1104    let nc = if phi.abs() < 1e-10 {
1105        5.14
1106    } else {
1107        (nq - 1.0) / tan_phi
1108    };
1109    let ngamma = 2.0 * (nq + 1.0) * tan_phi;
1110    cohesion * nc + gamma * depth * nq + 0.5 * gamma * width * ngamma
1111}
1112
1113/// Rankine active earth pressure coefficient.
1114pub fn ka_rankine(friction_deg: f64) -> f64 {
1115    let phi = friction_deg.to_radians();
1116    let t = (PI / 4.0 - phi / 2.0).tan();
1117    t * t
1118}
1119
1120/// Rankine passive earth pressure coefficient.
1121pub fn kp_rankine(friction_deg: f64) -> f64 {
1122    let phi = friction_deg.to_radians();
1123    let t = (PI / 4.0 + phi / 2.0).tan();
1124    t * t
1125}
1126
1127/// Specific surface area from grain size (spherical particles).
1128pub fn specific_surface(d: f64) -> f64 {
1129    if d <= 0.0 {
1130        return 0.0;
1131    }
1132    6.0 / d
1133}
1134
1135// ===========================================================================
1136// Tests
1137// ===========================================================================
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    const TOL: f64 = 1e-6;
1144
1145    // ---- Modified Cam-Clay ----
1146
1147    #[test]
1148    fn test_mcc_yield_inside() {
1149        let mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1150        // p = 100, q = 0 -> well inside
1151        let f = mcc.yield_function(100.0, 0.0);
1152        assert!(f < 0.0, "Should be inside yield surface, f = {f}");
1153    }
1154
1155    #[test]
1156    fn test_mcc_yield_on_surface() {
1157        let mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1158        // On the ellipse apex: p = pc, q = 0 -> f = pc*(pc-pc) = 0
1159        let f = mcc.yield_function(200.0, 0.0);
1160        assert!(f.abs() < TOL, "Should be on yield surface, f = {f}");
1161    }
1162
1163    #[test]
1164    fn test_mcc_yield_outside() {
1165        let mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1166        // Large q at small p
1167        let f = mcc.yield_function(50.0, 200.0);
1168        assert!(f > 0.0, "Should be outside yield surface, f = {f}");
1169    }
1170
1171    #[test]
1172    fn test_mcc_hardening() {
1173        let mut mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1174        let old_pc = mcc.pc;
1175        mcc.harden(0.01);
1176        assert!(mcc.pc > old_pc, "pc should increase after compression");
1177    }
1178
1179    #[test]
1180    fn test_mcc_ocr() {
1181        let mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1182        let ocr = mcc.ocr(100.0);
1183        assert!((ocr - 2.0).abs() < TOL, "OCR should be 2.0, got {ocr}");
1184    }
1185
1186    #[test]
1187    fn test_mcc_bulk_modulus() {
1188        let mcc = ModifiedCamClay::new(1.0, 0.1, 0.02, 200.0, 2.0, 0.3);
1189        let k = mcc.bulk_modulus(100.0);
1190        // K = v0 * p / kappa = 2.0 * 100.0 / 0.02 = 10000
1191        assert!((k - 10_000.0).abs() < TOL, "K should be 10000, got {k}");
1192    }
1193
1194    // ---- Mohr-Coulomb ----
1195
1196    #[test]
1197    fn test_mc_shear_strength() {
1198        let mc = MohrCoulombModel::new(50_000.0, 30.0, 0.0, 0.0);
1199        let tau = mc.shear_strength(100_000.0);
1200        let expected = 50_000.0 + 100_000.0 * 30.0_f64.to_radians().tan();
1201        assert!(
1202            (tau - expected).abs() < 1.0,
1203            "tau = {tau}, expected = {expected}"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_mc_failure_check() {
1209        let mc = MohrCoulombModel::new(0.0, 30.0, 0.0, 0.0);
1210        // At failure: sigma1 = sigma3 * Nφ
1211        let n_phi = (1.0 + 30.0_f64.to_radians().sin()) / (1.0 - 30.0_f64.to_radians().sin());
1212        let sigma3 = 100_000.0;
1213        let sigma1 = sigma3 * n_phi;
1214        assert!(
1215            mc.is_failed_principals(sigma1, sigma3),
1216            "Should be at failure"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_mc_elastic_state() {
1222        let mc = MohrCoulombModel::new(100_000.0, 30.0, 0.0, 0.0);
1223        assert!(
1224            !mc.is_failed_principals(150_000.0, 100_000.0),
1225            "Should be elastic with high cohesion"
1226        );
1227    }
1228
1229    // ---- Cap Plasticity ----
1230
1231    #[test]
1232    fn test_cap_elastic() {
1233        // Use positive p convention. alpha=0.5, k=50000, R=0.5, X0=-200000
1234        // shear: q < alpha*p + k = 0.5*p + 50000
1235        // cap L = X - R*(alpha*X + k) = -200000 - 0.5*(0.5*(-200000)+50000)
1236        //       = -200000 - 0.5*(-50000) = -200000 + 25000 = -175000
1237        // cap: sqrt((p - L)^2 + (Rq)^2) < (X - L) = (-200000 - (-175000)) = -25000
1238        // That's negative, so let's use more reasonable params.
1239        let cap = CapPlasticityModel::new(0.3, 20_000.0, 0.5, 200_000.0, 0.1, 0.001);
1240        // L = 200000 - 0.5*(0.3*200000 + 20000) = 200000 - 0.5*80000 = 160000
1241        // X - L = 40000
1242        // At p=180000, q=5000: shear = 5000 - 0.3*180000 - 20000 = 5000 - 54000 - 20000 < 0 OK
1243        // cap: sqrt((180000-160000)^2 + (0.5*5000)^2) = sqrt(400e6 + 6.25e6) ~ 20156 < 40000 OK
1244        assert!(cap.is_elastic(180_000.0, 5_000.0), "Should be elastic");
1245    }
1246
1247    #[test]
1248    fn test_cap_shear_yield() {
1249        let cap = CapPlasticityModel::new(0.5, 50_000.0, 0.5, -200_000.0, 0.1, 0.001);
1250        let f = cap.shear_yield(-10_000.0, 200_000.0);
1251        assert!(f > 0.0, "Should exceed shear yield, f = {f}");
1252    }
1253
1254    #[test]
1255    fn test_cap_hardening() {
1256        let mut cap = CapPlasticityModel::new(0.5, 50_000.0, 0.5, -200_000.0, 0.1, 0.001);
1257        let old_x = cap.x_cap;
1258        cap.harden_cap(0.01);
1259        assert!(
1260            (cap.x_cap - old_x).abs() > 1e-10,
1261            "Cap should move after hardening"
1262        );
1263    }
1264
1265    // ---- Hoek-Brown ----
1266
1267    #[test]
1268    fn test_hb_ucs_intact() {
1269        // GSI = 100, D = 0 -> m_b = m_i, s = 1, a ~ 0.5
1270        let hb = HoekBrownRock::new(100e6, 25.0, 100.0, 0.0);
1271        let ucs = hb.ucs_rock_mass();
1272        // s^a ~ 1^0.5 = 1, so UCS_rm ~ sigma_ci
1273        assert!(
1274            (ucs - 100e6).abs() / 100e6 < 0.05,
1275            "UCS_rm should be ~ sigma_ci for GSI=100, got {ucs}"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_hb_sigma1_at_failure() {
1281        let hb = HoekBrownRock::new(100e6, 25.0, 75.0, 0.0);
1282        let s1 = hb.sigma1_at_failure(10e6);
1283        assert!(s1 > 10e6, "sigma1 at failure must exceed sigma3");
1284    }
1285
1286    #[test]
1287    fn test_hb_tensile_strength() {
1288        let hb = HoekBrownRock::new(100e6, 25.0, 75.0, 0.0);
1289        let ts = hb.tensile_strength();
1290        assert!(ts < 0.0, "Tensile strength should be negative (tension)");
1291    }
1292
1293    // ---- Kozeny-Carman ----
1294
1295    #[test]
1296    fn test_kc_permeability_increases_with_e() {
1297        let kc = KozenyCarmanPermeability::default_water(0.001);
1298        let k1 = kc.intrinsic_permeability(0.5);
1299        let k2 = kc.intrinsic_permeability(1.0);
1300        assert!(
1301            k2 > k1,
1302            "Permeability should increase with void ratio: k1={k1}, k2={k2}"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_kc_zero_void_ratio() {
1308        let kc = KozenyCarmanPermeability::default_water(0.001);
1309        let k = kc.intrinsic_permeability(0.0);
1310        assert!(k.abs() < TOL, "Zero void ratio -> zero permeability");
1311    }
1312
1313    #[test]
1314    fn test_kc_porosity_conversion() {
1315        let n = KozenyCarmanPermeability::porosity(1.0);
1316        assert!((n - 0.5).abs() < TOL, "e=1 -> n=0.5, got {n}");
1317        let e = KozenyCarmanPermeability::void_ratio_from_porosity(0.5);
1318        assert!((e - 1.0).abs() < TOL, "n=0.5 -> e=1.0, got {e}");
1319    }
1320
1321    // ---- Terzaghi Consolidation ----
1322
1323    #[test]
1324    fn test_terzaghi_initial() {
1325        let tc = TerzaghiConsolidation::new(1e-7, 5.0, 100_000.0);
1326        let u = tc.degree_of_consolidation(0.0);
1327        assert!(u.abs() < TOL, "U at t=0 should be 0, got {u}");
1328    }
1329
1330    #[test]
1331    fn test_terzaghi_long_time() {
1332        let tc = TerzaghiConsolidation::new(1e-7, 1.0, 100_000.0);
1333        // Very long time
1334        let u = tc.degree_of_consolidation(1e12);
1335        assert!(
1336            (u - 1.0).abs() < 0.01,
1337            "U should approach 1.0 at long time, got {u}"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_terzaghi_settlement() {
1343        let tc = TerzaghiConsolidation::new(1e-7, 1.0, 100_000.0);
1344        let s = tc.settlement(1e12, 0.5);
1345        assert!(
1346            (s - 0.5).abs() < 0.01,
1347            "Final settlement should be 0.5 m, got {s}"
1348        );
1349    }
1350
1351    // ---- Swelling Clay ----
1352
1353    #[test]
1354    fn test_swell_wetting() {
1355        let sc = SwellingClayModel::new(0.1, 1e6, 0.8, 500_000.0, 0.2);
1356        // Wetting: reduce suction from 500 kPa to 100 kPa
1357        let eps = sc.volumetric_strain(100_000.0);
1358        assert!(eps > 0.0, "Wetting should cause positive swell, got {eps}");
1359    }
1360
1361    #[test]
1362    fn test_swell_drying() {
1363        let sc = SwellingClayModel::new(0.1, 1e6, 0.8, 500_000.0, 0.2);
1364        // Drying: increase suction
1365        let eps = sc.volumetric_strain(1_000_000.0);
1366        assert!(eps < 0.0, "Drying should cause shrinkage, got {eps}");
1367    }
1368
1369    // ---- Liquefaction ----
1370
1371    #[test]
1372    fn test_liquefaction_csr() {
1373        let liq = LiquefactionCriteria::new(7.5, 0.3, 100_000.0, 60_000.0, 10.0, 5.0);
1374        let csr = liq.csr();
1375        assert!(csr > 0.0, "CSR should be positive, got {csr}");
1376        assert!(csr < 1.0, "CSR should be < 1 for typical conditions");
1377    }
1378
1379    #[test]
1380    fn test_liquefaction_dense_sand() {
1381        // Dense sand with high N1_60 should not liquefy
1382        let liq = LiquefactionCriteria::new(7.5, 0.15, 100_000.0, 80_000.0, 35.0, 5.0);
1383        assert!(
1384            !liq.is_liquefiable(),
1385            "Dense sand (N1_60=35) should not liquefy"
1386        );
1387    }
1388
1389    // ---- Rockfill ----
1390
1391    #[test]
1392    fn test_rockfill_phi_reduction() {
1393        let rf = RockfillModel::new(45.0, 8.0, 100_000.0, 500.0, 0.5);
1394        let phi_low = rf.friction_angle(100_000.0);
1395        let phi_high = rf.friction_angle(1_000_000.0);
1396        assert!(
1397            phi_high < phi_low,
1398            "Friction should decrease with stress: low={phi_low:.4}, high={phi_high:.4}"
1399        );
1400    }
1401
1402    #[test]
1403    fn test_rockfill_modulus() {
1404        let rf = RockfillModel::new(45.0, 8.0, 100_000.0, 500.0, 0.5);
1405        let e1 = rf.youngs_modulus(100_000.0);
1406        let e2 = rf.youngs_modulus(400_000.0);
1407        assert!(e2 > e1, "Modulus should increase with stress");
1408    }
1409
1410    // ---- Frozen Soil ----
1411
1412    #[test]
1413    fn test_frozen_cohesion_increase() {
1414        let fs = FrozenSoilModel::new(20_000.0, 25.0, 50_000.0, 3.0, 1e-12);
1415        let c_warm = fs.cohesion(5.0); // unfrozen
1416        let c_cold = fs.cohesion(-10.0); // frozen
1417        assert!(
1418            c_cold > c_warm,
1419            "Cohesion should increase when frozen: warm={c_warm}, cold={c_cold}"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_frozen_ucs() {
1425        let fs = FrozenSoilModel::new(20_000.0, 25.0, 50_000.0, 3.0, 1e-12);
1426        let ucs_warm = fs.ucs(5.0);
1427        let ucs_cold = fs.ucs(-20.0);
1428        assert!(
1429            ucs_cold > ucs_warm,
1430            "UCS should increase when frozen: warm={ucs_warm}, cold={ucs_cold}"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_frozen_creep_rate() {
1436        let fs = FrozenSoilModel::new(20_000.0, 25.0, 50_000.0, 3.0, 1e-12);
1437        let rate = fs.creep_rate(1e6, -5.0);
1438        assert!(rate > 0.0, "Creep rate should be positive at sub-zero temp");
1439    }
1440
1441    #[test]
1442    fn test_frozen_no_creep_above_zero() {
1443        let fs = FrozenSoilModel::new(20_000.0, 25.0, 50_000.0, 3.0, 1e-12);
1444        let rate = fs.creep_rate(1e6, 5.0);
1445        assert!(rate.abs() < TOL, "No creep above freezing, got {rate}");
1446    }
1447
1448    // ---- Helper functions ----
1449
1450    #[test]
1451    fn test_dp_conversion() {
1452        let (alpha, k) = mohr_coulomb_to_drucker_prager(50_000.0, 30.0);
1453        assert!(alpha > 0.0, "alpha should be positive");
1454        assert!(k > 0.0, "k should be positive");
1455    }
1456
1457    #[test]
1458    fn test_effective_stress() {
1459        let es = effective_stress(200_000.0, 50_000.0);
1460        assert!(
1461            (es - 150_000.0).abs() < TOL,
1462            "Effective stress should be 150 kPa, got {es}"
1463        );
1464    }
1465
1466    #[test]
1467    fn test_k0_jaky_value() {
1468        let k0 = k0_jaky(30.0);
1469        let expected = 1.0 - 30.0_f64.to_radians().sin();
1470        assert!(
1471            (k0 - expected).abs() < TOL,
1472            "K0 = {k0}, expected {expected}"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_bearing_capacity_positive() {
1478        let q = terzaghi_bearing_capacity(50_000.0, 30.0, 18_000.0, 1.0, 2.0);
1479        assert!(q > 0.0, "Bearing capacity should be positive, got {q}");
1480    }
1481
1482    #[test]
1483    fn test_rankine_ka_kp_product() {
1484        let ka = ka_rankine(30.0);
1485        let kp = kp_rankine(30.0);
1486        // Ka * Kp = 1.0
1487        assert!(
1488            (ka * kp - 1.0).abs() < 0.01,
1489            "Ka * Kp should be ~1.0, got {:.6}",
1490            ka * kp
1491        );
1492    }
1493
1494    #[test]
1495    fn test_mean_deviatoric_stress() {
1496        let p = mean_stress(300.0, 200.0, 100.0);
1497        assert!((p - 200.0).abs() < TOL, "Mean stress should be 200");
1498        let q = deviatoric_q(300.0, 200.0, 100.0);
1499        // q = sqrt(0.5*((300-200)^2 + (200-100)^2 + (100-300)^2))
1500        // = sqrt(0.5*(10000 + 10000 + 40000)) = sqrt(30000) ~ 173.2
1501        let expected = (30000.0_f64).sqrt();
1502        assert!((q - expected).abs() < 0.1, "q = {q}, expected ~ {expected}");
1503    }
1504}