Skip to main content

oxiphysics_materials/
geological.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Geological and geomechanical material models: soils, rocks, sand,
5//! permafrost, seabed sediments, and granular pressure models.
6
7#![allow(dead_code)]
8#![allow(clippy::too_many_arguments)]
9
10use std::f64::consts::PI;
11
12// ─────────────────────────────────────────────────────────────────────────────
13// Soil Model — Mohr-Coulomb
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Soil model using the Mohr-Coulomb yield criterion.
17///
18/// Models elastic-perfectly-plastic soil behavior with friction and cohesion.
19#[derive(Debug, Clone)]
20pub struct SoilModel {
21    /// Cohesion c \[Pa\].
22    pub cohesion: f64,
23    /// Internal friction angle φ \[radians\].
24    pub friction_angle: f64,
25    /// Dilatancy angle ψ \[radians\].
26    pub dilatancy_angle: f64,
27    /// Young's modulus E \[Pa\].
28    pub young_modulus: f64,
29    /// Poisson's ratio ν.
30    pub poisson_ratio: f64,
31}
32
33impl SoilModel {
34    /// Create a new Mohr-Coulomb soil model.
35    pub fn new(
36        cohesion: f64,
37        friction_angle_deg: f64,
38        dilatancy_angle_deg: f64,
39        young_modulus: f64,
40        poisson_ratio: f64,
41    ) -> Self {
42        Self {
43            cohesion,
44            friction_angle: friction_angle_deg.to_radians(),
45            dilatancy_angle: dilatancy_angle_deg.to_radians(),
46            young_modulus,
47            poisson_ratio,
48        }
49    }
50
51    /// Typical medium-density sand.
52    pub fn medium_sand() -> Self {
53        Self::new(0.0, 32.0, 5.0, 30e6, 0.3)
54    }
55
56    /// Typical stiff clay.
57    pub fn stiff_clay() -> Self {
58        Self::new(60e3, 22.0, 0.0, 80e6, 0.35)
59    }
60
61    /// Mohr-Coulomb yield function F(σ1, σ3).
62    ///
63    /// F = (σ1 - σ3) - 2*c*cos(φ) - (σ1 + σ3)*sin(φ)
64    /// F < 0: elastic; F = 0: on yield surface.
65    pub fn yield_function(&self, sigma1: f64, sigma3: f64) -> f64 {
66        let phi = self.friction_angle;
67        (sigma1 - sigma3) - 2.0 * self.cohesion * phi.cos() - (sigma1 + sigma3) * phi.sin()
68    }
69
70    /// Check if the stress state is yielding.
71    pub fn is_yielding(&self, sigma1: f64, sigma3: f64) -> bool {
72        self.yield_function(sigma1, sigma3) >= 0.0
73    }
74
75    /// Plastic multiplier Δλ for a given incremental plastic strain magnitude.
76    ///
77    /// Δλ = Δεp / (1 + sin(ψ))
78    pub fn plastic_multiplier(&self, d_eps_p: f64) -> f64 {
79        d_eps_p / (1.0 + self.dilatancy_angle.sin())
80    }
81
82    /// Critical state line (CSL) deviator stress q as a function of mean stress p \[Pa\].
83    ///
84    /// q = M_cs * p  where M_cs = 6*sin(φ) / (3 - sin(φ)) for compression.
85    pub fn critical_state_line_q(&self, p: f64) -> f64 {
86        let phi = self.friction_angle;
87        let m_cs = 6.0 * phi.sin() / (3.0 - phi.sin());
88        m_cs * p
89    }
90
91    /// Bulk modulus K = E / (3*(1-2ν)) \[Pa\].
92    pub fn bulk_modulus(&self) -> f64 {
93        self.young_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio))
94    }
95
96    /// Shear modulus G = E / (2*(1+ν)) \[Pa\].
97    pub fn shear_modulus(&self) -> f64 {
98        self.young_modulus / (2.0 * (1.0 + self.poisson_ratio))
99    }
100
101    /// Uniaxial compressive strength (unconfined) \[Pa\].
102    ///
103    /// qu = 2*c*cos(φ) / (1 - sin(φ))
104    pub fn ucs(&self) -> f64 {
105        let phi = self.friction_angle;
106        2.0 * self.cohesion * phi.cos() / (1.0 - phi.sin())
107    }
108
109    /// Ko (coefficient of earth pressure at rest) via Jaky's formula.
110    ///
111    /// Ko = 1 - sin(φ)
112    pub fn k0_jaky(&self) -> f64 {
113        1.0 - self.friction_angle.sin()
114    }
115}
116
117// ─────────────────────────────────────────────────────────────────────────────
118// Modified Cam-Clay Model
119// ─────────────────────────────────────────────────────────────────────────────
120
121/// Modified Cam-Clay elastoplastic model for clays.
122///
123/// Captures volumetric hardening and critical state behavior.
124#[derive(Debug, Clone)]
125pub struct CamClayModel {
126    /// Slope of CSL in q-p' space.
127    pub m: f64,
128    /// Compression index (slope of NCL in e-ln p' space).
129    pub lambda: f64,
130    /// Swelling index (slope of URL in e-ln p' space).
131    pub kappa: f64,
132    /// Specific volume at p'=1 kPa on CSL: N.
133    pub n_value: f64,
134    /// Initial void ratio.
135    pub e0: f64,
136    /// Initial preconsolidation pressure p'c0 \[Pa\].
137    pub p_c0: f64,
138}
139
140impl CamClayModel {
141    /// Create a new Modified Cam-Clay model.
142    pub fn new(m: f64, lambda: f64, kappa: f64, n_value: f64, e0: f64, p_c0: f64) -> Self {
143        Self {
144            m,
145            lambda,
146            kappa,
147            n_value,
148            e0,
149            p_c0,
150        }
151    }
152
153    /// Typical normally consolidated kaolin clay.
154    pub fn kaolin_ncc() -> Self {
155        Self::new(0.92, 0.26, 0.05, 3.0, 1.5, 100e3)
156    }
157
158    /// Void ratio on CSL at mean stress p' \[Pa\].
159    ///
160    /// e_cs = N - λ * ln(p'/1 kPa)
161    pub fn e_cs(&self, p: f64) -> f64 {
162        self.n_value - self.lambda * (p / 1e3).ln()
163    }
164
165    /// Modified Cam-Clay yield surface value F(p, q, p0).
166    ///
167    /// F = q^2 / M^2 + p*(p - p0)  ; F < 0: elastic
168    pub fn yield_surface_p0(&self, p: f64, q: f64) -> f64 {
169        q * q / (self.m * self.m) + p * (p - self.p_c0)
170    }
171
172    /// Check if on or outside yield surface.
173    pub fn is_yielding(&self, p: f64, q: f64) -> bool {
174        self.yield_surface_p0(p, q) >= 0.0
175    }
176
177    /// Preconsolidation pressure from current void ratio.
178    ///
179    /// p0 = exp((N - e) / λ) \[Pa\] (×1 kPa reference)
180    pub fn preconsolidation_pressure(&self, void_ratio: f64) -> f64 {
181        let ln_p = (self.n_value - void_ratio) / self.lambda;
182        1e3 * ln_p.exp()
183    }
184
185    /// Elastic volumetric strain increment for a change in mean stress dp at current p.
186    ///
187    /// dεv_e = (κ / (1 + e0)) * dp / p
188    pub fn volumetric_strain_elastic(&self, dp: f64, p: f64) -> f64 {
189        if p <= 0.0 {
190            return 0.0;
191        }
192        (self.kappa / (1.0 + self.e0)) * dp / p
193    }
194
195    /// Plastic volumetric strain increment (hardening law).
196    ///
197    /// dεv_p = ((λ - κ) / (1 + e0)) * dp0 / p0
198    pub fn volumetric_strain_plastic(&self, dp0: f64, p0: f64) -> f64 {
199        if p0 <= 0.0 {
200            return 0.0;
201        }
202        ((self.lambda - self.kappa) / (1.0 + self.e0)) * dp0 / p0
203    }
204
205    /// Critical state mean stress at current void ratio.
206    pub fn critical_state_p(&self, void_ratio: f64) -> f64 {
207        let exp_arg = (self.n_value - void_ratio) / self.lambda;
208        1e3 * exp_arg.exp()
209    }
210}
211
212// ─────────────────────────────────────────────────────────────────────────────
213// Rock Model — Hoek-Brown Criterion
214// ─────────────────────────────────────────────────────────────────────────────
215
216/// Rock mass model using the generalized Hoek-Brown failure criterion.
217#[derive(Debug, Clone)]
218pub struct RockModel {
219    /// Uniaxial compressive strength of intact rock UCS \[Pa\].
220    pub ucs: f64,
221    /// Intact rock parameter mi \[-\].
222    pub mi: f64,
223    /// Geological strength index GSI \[-\], 0–100.
224    pub gsi: f64,
225    /// Disturbance factor D \[-\], 0 (undisturbed) to 1 (heavily blasted).
226    pub d: f64,
227}
228
229impl RockModel {
230    /// Create a new Hoek-Brown rock mass model.
231    pub fn new(ucs: f64, mi: f64, gsi: f64, d: f64) -> Self {
232        Self { ucs, mi, gsi, d }
233    }
234
235    /// Typical granite (hard, good rock mass).
236    pub fn granite() -> Self {
237        Self::new(200e6, 33.0, 75.0, 0.0)
238    }
239
240    /// Typical shale (weak, moderate rock mass).
241    pub fn shale() -> Self {
242        Self::new(20e6, 8.0, 40.0, 0.2)
243    }
244
245    /// Reduced m_b parameter for rock mass.
246    ///
247    /// mb = mi * exp((GSI - 100) / (28 - 14D))
248    pub fn mb_parameter(&self) -> f64 {
249        self.mi * ((self.gsi - 100.0) / (28.0 - 14.0 * self.d)).exp()
250    }
251
252    /// Rock mass s parameter (intercept of HB criterion).
253    ///
254    /// s = exp((GSI - 100) / (9 - 3D))
255    pub fn s_parameter(&self) -> f64 {
256        ((self.gsi - 100.0) / (9.0 - 3.0 * self.d)).exp()
257    }
258
259    /// Rock mass a parameter (shape exponent).
260    ///
261    /// a = 0.5 + (1/6)*(exp(-GSI/15) - exp(-20/3))
262    pub fn a_parameter(&self) -> f64 {
263        0.5 + (1.0 / 6.0) * ((-self.gsi / 15.0).exp() - (-20.0_f64 / 3.0).exp())
264    }
265
266    /// Hoek-Brown yield check: returns true if (σ1, σ3) exceeds HB envelope.
267    ///
268    /// σ1 ≥ σ3 + UCS*(mb*σ3/UCS + s)^a  → yielding
269    pub fn yield_hoek_brown(&self, sigma1: f64, sigma3: f64) -> bool {
270        let mb = self.mb_parameter();
271        let s = self.s_parameter();
272        let a = self.a_parameter();
273        let rhs = sigma3 + self.ucs * (mb * sigma3 / self.ucs + s).max(0.0).powf(a);
274        sigma1 >= rhs
275    }
276
277    /// Uniaxial compressive strength of the rock mass \[Pa\].
278    ///
279    /// σ_cm = UCS * s^a
280    pub fn uniaxial_compressive_strength_rock(&self) -> f64 {
281        let s = self.s_parameter();
282        let a = self.a_parameter();
283        self.ucs * s.powf(a)
284    }
285
286    /// Tensile strength of rock mass \[Pa\].
287    ///
288    /// σ_t = -s * UCS / mb
289    pub fn tensile_strength_rock(&self) -> f64 {
290        let mb = self.mb_parameter();
291        let s = self.s_parameter();
292        -(s * self.ucs) / mb
293    }
294
295    /// Rock mass deformation modulus \[Pa\] (Hoek et al. 2002).
296    ///
297    /// Em = (1 - D/2)*sqrt(UCS/100)*10^((GSI-10)/40) \[GPa\] → Pa
298    pub fn deformation_modulus(&self) -> f64 {
299        let factor = 1.0 - self.d / 2.0;
300        let ucs_mpa = self.ucs / 1e6;
301        let em_gpa = factor * (ucs_mpa / 100.0).sqrt() * 10.0_f64.powf((self.gsi - 10.0) / 40.0);
302        em_gpa * 1e9
303    }
304}
305
306// ─────────────────────────────────────────────────────────────────────────────
307// Sand Model
308// ─────────────────────────────────────────────────────────────────────────────
309
310/// Sand model with relative density and dilatancy (Bolton's approach).
311#[derive(Debug, Clone)]
312pub struct SandModel {
313    /// Relative density Dr \[-\], 0 (loose) to 1 (dense).
314    pub relative_density: f64,
315    /// Critical state friction angle φ_cs \[radians\].
316    pub critical_state_friction_angle: f64,
317    /// Mean particle diameter d50 \[m\].
318    pub d50: f64,
319    /// Specific gravity Gs \[-\].
320    pub specific_gravity: f64,
321}
322
323impl SandModel {
324    /// Create a new sand model.
325    pub fn new(
326        relative_density: f64,
327        critical_state_friction_angle_deg: f64,
328        d50: f64,
329        specific_gravity: f64,
330    ) -> Self {
331        Self {
332            relative_density,
333            critical_state_friction_angle: critical_state_friction_angle_deg.to_radians(),
334            d50,
335            specific_gravity,
336        }
337    }
338
339    /// Typical medium dense quartz sand.
340    pub fn medium_dense_quartz() -> Self {
341        Self::new(0.6, 32.0, 0.3e-3, 2.65)
342    }
343
344    /// Loose sand.
345    pub fn loose_sand() -> Self {
346        Self::new(0.3, 30.0, 0.2e-3, 2.65)
347    }
348
349    /// Peak friction angle \[radians\] using Bolton's simplified formula.
350    ///
351    /// φ_p = φ_cs + 5 * Ir  (radians, Ir = dilatancy index)
352    pub fn peak_friction_angle(&self, dr: f64) -> f64 {
353        let ir = self.dilatancy_index(dr, 100.0);
354        self.critical_state_friction_angle + 5.0_f64.to_radians() * ir
355    }
356
357    /// Bolton dilatancy index Ir = Dr*(Q - ln(p/kPa)) - R.
358    ///
359    /// Q = 10 (quartz), R = 1.
360    pub fn dilatancy_index(&self, dr: f64, _stress_level: f64) -> f64 {
361        let q = 10.0;
362        let r = 1.0;
363        let p_kpa = 100.0_f64; // reference stress
364        (dr * (q - (p_kpa).ln()) - r).max(0.0)
365    }
366
367    /// Bolton's dilatancy formula with explicit pressure.
368    ///
369    /// Ir = Dr*(Q - ln(p_kPa)) - R, clamped to \[0, ∞)
370    pub fn bolton_dilatancy(&self, dr: f64, p_kpa: f64) -> f64 {
371        let q = 10.0;
372        let r = 1.0;
373        if p_kpa <= 0.0 {
374            return 0.0;
375        }
376        (dr * (q - p_kpa.ln()) - r).max(0.0)
377    }
378
379    /// Maximum void ratio (loosest packing) — approximate.
380    pub fn e_max(&self) -> f64 {
381        0.9 // typical for medium sand
382    }
383
384    /// Minimum void ratio (densest packing) — approximate.
385    pub fn e_min(&self) -> f64 {
386        0.6 // typical for medium sand
387    }
388
389    /// Current void ratio from relative density.
390    pub fn current_void_ratio(&self) -> f64 {
391        self.e_max() - self.relative_density * (self.e_max() - self.e_min())
392    }
393
394    /// Dry unit weight \[kN/m^3\].
395    pub fn dry_unit_weight(&self) -> f64 {
396        let e = self.current_void_ratio();
397        self.specific_gravity * 9.81 / (1.0 + e)
398    }
399}
400
401// ─────────────────────────────────────────────────────────────────────────────
402// Permafrost Model
403// ─────────────────────────────────────────────────────────────────────────────
404
405/// Permafrost material model with temperature-dependent properties.
406///
407/// Models unfrozen water content, ice saturation, and thermal/mechanical properties.
408#[derive(Debug, Clone)]
409pub struct PermafrostModel {
410    /// Soil type identifier.
411    pub soil_type: PermafrostSoilType,
412    /// Total water content (mass fraction).
413    pub total_water_content: f64,
414    /// Soil dry density \[kg/m^3\].
415    pub dry_density: f64,
416    /// Mineral thermal conductivity λ_s \[W/(m·K)\].
417    pub lambda_solid: f64,
418}
419
420/// Permafrost soil type classification.
421#[derive(Debug, Clone, Copy, PartialEq)]
422pub enum PermafrostSoilType {
423    /// Silty clay permafrost.
424    SiltyClay,
425    /// Sandy permafrost.
426    Sand,
427    /// Gravelly permafrost.
428    Gravel,
429}
430
431impl PermafrostModel {
432    /// Create a new permafrost model.
433    pub fn new(
434        soil_type: PermafrostSoilType,
435        total_water_content: f64,
436        dry_density: f64,
437        lambda_solid: f64,
438    ) -> Self {
439        Self {
440            soil_type,
441            total_water_content,
442            dry_density,
443            lambda_solid,
444        }
445    }
446
447    /// Typical Siberian silty permafrost.
448    pub fn siberian_silt() -> Self {
449        Self::new(PermafrostSoilType::SiltyClay, 0.35, 1400.0, 2.5)
450    }
451
452    /// Unfrozen water content θ_u(T) \[m^3/m^3\] using power law.
453    ///
454    /// θ_u = α * |T|^(-β) for T < 0°C; total water content for T ≥ 0°C.
455    pub fn unfrozen_water_content(&self, t_celsius: f64) -> f64 {
456        if t_celsius >= 0.0 {
457            return self.total_water_content;
458        }
459        let (alpha, beta) = match self.soil_type {
460            PermafrostSoilType::SiltyClay => (0.45, 0.6),
461            PermafrostSoilType::Sand => (0.08, 0.5),
462            PermafrostSoilType::Gravel => (0.05, 0.4),
463        };
464        let t_abs = (-t_celsius).max(1e-6);
465        (alpha * t_abs.powf(-beta)).min(self.total_water_content)
466    }
467
468    /// Ice saturation S_i at temperature T \[°C\].
469    pub fn ice_saturation(&self, t_celsius: f64) -> f64 {
470        if t_celsius >= 0.0 {
471            return 0.0;
472        }
473        let theta_u = self.unfrozen_water_content(t_celsius);
474        let theta_i = (self.total_water_content - theta_u).max(0.0);
475        if self.total_water_content > 0.0 {
476            theta_i / self.total_water_content
477        } else {
478            0.0
479        }
480    }
481
482    /// Bulk modulus of frozen soil \[Pa\] — increases with decreasing temperature.
483    ///
484    /// K_frozen ≈ K_unfrozen * (1 + 5*Si) where Si is ice saturation.
485    pub fn bulk_modulus_frozen(&self, t_celsius: f64) -> f64 {
486        let k_base = 1.5e8; // typical unfrozen bulk modulus Pa
487        let si = self.ice_saturation(t_celsius);
488        k_base * (1.0 + 5.0 * si)
489    }
490
491    /// Thermal conductivity λ \[W/(m·K)\] using geometric mean model.
492    ///
493    /// λ = λ_s^(1-n) * λ_w^(θ_u) * λ_i^(θ_i)
494    pub fn thermal_conductivity(&self, theta_u: f64) -> f64 {
495        let n = 0.4; // porosity
496        let lambda_water = 0.56_f64;
497        let lambda_ice = 2.2_f64;
498        let theta_i = (n - theta_u).max(0.0);
499        self.lambda_solid.powf(1.0 - n) * lambda_water.powf(theta_u) * lambda_ice.powf(theta_i)
500    }
501
502    /// Heat capacity \[J/(m^3·K)\] — weighted by volume fractions.
503    pub fn volumetric_heat_capacity(&self, t_celsius: f64) -> f64 {
504        let theta_u = self.unfrozen_water_content(t_celsius);
505        let theta_i = (self.total_water_content - theta_u).max(0.0);
506        let n = 0.4;
507        // Solid: ~2.0e6, Water: 4.18e6, Ice: 1.9e6 J/(m^3 K)
508        2.0e6 * (1.0 - n) + 4.18e6 * theta_u + 1.9e6 * theta_i
509    }
510}
511
512// ─────────────────────────────────────────────────────────────────────────────
513// Seabed Sediment Model
514// ─────────────────────────────────────────────────────────────────────────────
515
516/// Seabed sediment model for offshore geotechnical applications.
517///
518/// Includes undrained shear strength profiles and consolidation characteristics.
519#[derive(Debug, Clone)]
520pub struct SeabedSedimentModel {
521    /// Undrained shear strength at mudline (z=0) \[Pa\].
522    pub su0: f64,
523    /// Strength gradient with depth \[Pa/m\].
524    pub k_su: f64,
525    /// Compression index Cc \[-\].
526    pub compression_index_cc: f64,
527    /// Recompression index Cr \[-\].
528    pub recompression_index_cr: f64,
529    /// Coefficient of consolidation Cv \[m^2/s\].
530    pub cv: f64,
531    /// Initial void ratio e0 \[-\].
532    pub e0: f64,
533    /// Saturated unit weight γ_sat \[kN/m^3\].
534    pub gamma_sat: f64,
535}
536
537impl SeabedSedimentModel {
538    /// Create a new seabed sediment model.
539    pub fn new(
540        su0: f64,
541        k_su: f64,
542        compression_index_cc: f64,
543        recompression_index_cr: f64,
544        cv: f64,
545        e0: f64,
546        gamma_sat: f64,
547    ) -> Self {
548        Self {
549            su0,
550            k_su,
551            compression_index_cc,
552            recompression_index_cr,
553            cv,
554            e0,
555            gamma_sat,
556        }
557    }
558
559    /// Typical soft marine clay (Gulf of Mexico).
560    pub fn gulf_of_mexico_clay() -> Self {
561        Self::new(5e3, 1.5e3, 0.7, 0.07, 2e-8, 2.8, 16.0)
562    }
563
564    /// Undrained shear strength Su at depth z \[m\] \[Pa\].
565    ///
566    /// Su(z) = su0 + k_su * z
567    pub fn undrained_shear_strength(&self, depth_m: f64) -> f64 {
568        self.su0 + self.k_su * depth_m
569    }
570
571    /// Primary consolidation settlement ΔS \[m\].
572    ///
573    /// ΔS = (Cc / (1 + e0)) * H * log10(σ_vf / σ_v0)
574    pub fn settlement_primary(&self, sigma_v0: f64, sigma_vf: f64, h: f64, e0: f64) -> f64 {
575        if sigma_v0 <= 0.0 || sigma_vf <= sigma_v0 {
576            return 0.0;
577        }
578        (self.compression_index_cc / (1.0 + e0)) * h * (sigma_vf / sigma_v0).log10()
579    }
580
581    /// Time to reach a given degree of consolidation U (Terzaghi 1-D).
582    ///
583    /// T_v = Cv * t / H_dr^2, solved for t:
584    /// t = T_v * H_dr^2 / Cv
585    pub fn time_to_consolidation(&self, cv: f64, h_dr: f64, u: f64) -> f64 {
586        // Time factor T_v from degree of consolidation U
587        let t_v = if u <= 0.6 {
588            PI * PI * u * u / 4.0
589        } else {
590            -0.933 * (1.0 - u).log10() - 0.085
591        };
592        t_v * h_dr * h_dr / cv
593    }
594
595    /// Overconsolidation ratio OCR at depth z given preconsolidation pressure.
596    pub fn ocr(&self, sigma_v: f64, sigma_p: f64) -> f64 {
597        if sigma_v <= 0.0 {
598            return 1.0;
599        }
600        sigma_p / sigma_v
601    }
602
603    /// Pore pressure ratio ru = u / σ'v.
604    pub fn pore_pressure_ratio(&self, u_excess: f64, sigma_v_eff: f64) -> f64 {
605        if sigma_v_eff <= 0.0 {
606            return 0.0;
607        }
608        u_excess / sigma_v_eff
609    }
610}
611
612// ─────────────────────────────────────────────────────────────────────────────
613// Granular Pressure Model — Hertz-Mindlin Contact
614// ─────────────────────────────────────────────────────────────────────────────
615
616/// Granular pressure model using Hertz-Mindlin contact theory.
617///
618/// Models pressure-dependent stiffness in granular assemblies (e.g., sand).
619#[derive(Debug, Clone)]
620pub struct GranularPressureModel {
621    /// Shear modulus of grain material G \[Pa\].
622    pub grain_shear_modulus: f64,
623    /// Poisson's ratio of grain material ν.
624    pub grain_poisson_ratio: f64,
625    /// Mean grain radius R \[m\].
626    pub mean_grain_radius: f64,
627    /// Current void ratio e \[-\].
628    pub void_ratio: f64,
629}
630
631impl GranularPressureModel {
632    /// Create a new granular pressure model.
633    pub fn new(
634        grain_shear_modulus: f64,
635        grain_poisson_ratio: f64,
636        mean_grain_radius: f64,
637        void_ratio: f64,
638    ) -> Self {
639        Self {
640            grain_shear_modulus,
641            grain_poisson_ratio,
642            mean_grain_radius,
643            void_ratio,
644        }
645    }
646
647    /// Typical quartz sand grains.
648    pub fn quartz_sand() -> Self {
649        Self::new(44e9, 0.07, 0.15e-3, 0.65)
650    }
651
652    /// Hertz-Mindlin contact stiffnesses (Kn, Kt) for two identical spheres.
653    ///
654    /// Kn = (4/3) * G_eff * sqrt(R_eff * δ)  \[N/m\]
655    /// Kt = 8 * G_eff * sqrt(R_eff * δ)       \[N/m\]
656    ///
657    /// Returns (Kn, Kt).
658    pub fn hertz_mindlin_stiffness(&self, g: f64, nu: f64, r_eff: f64, sigma_n: f64) -> (f64, f64) {
659        let g_eff = g / (2.0 * (1.0 - nu));
660        // Normal overlap from Hertz theory: σ_n = Kn_HM / A * force
661        // Contact stiffness from pressure: use Walton-Braun formulation
662        let r_eff_m = r_eff;
663        // δ from σ_n: F = (4/3)*G_eff*sqrt(R)*δ^(3/2) → δ = (3F/(4*G_eff*sqrt(R)))^(2/3)
664        // F ≈ σ_n * A, A ≈ π*R^2 per grain
665        let f_n = sigma_n * PI * r_eff_m * r_eff_m;
666        let delta = (3.0 * f_n / (4.0 * g_eff * r_eff_m.sqrt()))
667            .max(0.0)
668            .powf(2.0 / 3.0);
669        let kn = (4.0 / 3.0) * g_eff * (r_eff_m * delta).sqrt();
670        let kt = 8.0 * g_eff * (r_eff_m * delta).sqrt();
671        (kn, kt)
672    }
673
674    /// Coordination number Z from void ratio (empirical).
675    ///
676    /// Z = 10.726 - 11.469 * e (for e ∈ \[0.5, 0.9\])
677    pub fn coordination_number(&self, void_ratio: f64) -> f64 {
678        (10.726 - 11.469 * void_ratio).max(3.0)
679    }
680
681    /// Pack (granular assembly) elastic shear modulus \[Pa\].
682    ///
683    /// Uses Walton (1987) effective medium theory:
684    /// G_pack = (5-4ν)/(5*(2-ν)) * (3*Z^2*(1-e)^2*G^2/(2*π^2*(1+e)^2))^(1/3) * p^(1/3)
685    pub fn pack_stiffness(&self, g: f64, nu: f64, e: f64, sigma: f64) -> f64 {
686        let z = self.coordination_number(e);
687        let factor = (5.0 - 4.0 * nu) / (5.0 * (2.0 - nu));
688        let inner =
689            3.0 * z * z * (1.0 - e) * (1.0 - e) * g * g / (2.0 * PI * PI * (1.0 + e) * (1.0 + e));
690        factor * inner.powf(1.0 / 3.0) * sigma.abs().powf(1.0 / 3.0)
691    }
692
693    /// Velocity of shear waves in granular pack \[m/s\].
694    pub fn shear_wave_velocity(&self, rho_bulk: f64, sigma: f64) -> f64 {
695        let g_pack = self.pack_stiffness(
696            self.grain_shear_modulus,
697            self.grain_poisson_ratio,
698            self.void_ratio,
699            sigma,
700        );
701        (g_pack / rho_bulk).sqrt()
702    }
703}
704
705// ─────────────────────────────────────────────────────────────────────────────
706// Loess Model
707// ─────────────────────────────────────────────────────────────────────────────
708
709/// Loess (wind-deposited silty soil) model with collapsible behavior.
710#[derive(Debug, Clone)]
711pub struct LoessModel {
712    /// Natural void ratio.
713    pub natural_void_ratio: f64,
714    /// Liquid limit LL \[%\].
715    pub liquid_limit: f64,
716    /// Plastic limit PL \[%\].
717    pub plastic_limit: f64,
718    /// Natural water content w \[%\].
719    pub water_content: f64,
720    /// Saturated hydraulic conductivity \[m/s\].
721    pub k_sat: f64,
722}
723
724impl LoessModel {
725    /// Create a new loess model.
726    pub fn new(
727        natural_void_ratio: f64,
728        liquid_limit: f64,
729        plastic_limit: f64,
730        water_content: f64,
731        k_sat: f64,
732    ) -> Self {
733        Self {
734            natural_void_ratio,
735            liquid_limit,
736            plastic_limit,
737            water_content,
738            k_sat,
739        }
740    }
741
742    /// Typical Chinese Loess Plateau loess.
743    pub fn chinese_loess() -> Self {
744        Self::new(1.05, 28.0, 16.0, 12.0, 1e-6)
745    }
746
747    /// Plasticity index PI = LL - PL.
748    pub fn plasticity_index(&self) -> f64 {
749        self.liquid_limit - self.plastic_limit
750    }
751
752    /// Liquidity index LI = (w - PL) / PI.
753    pub fn liquidity_index(&self) -> f64 {
754        let pi = self.plasticity_index();
755        if pi <= 0.0 {
756            return 0.0;
757        }
758        (self.water_content - self.plastic_limit) / pi
759    }
760
761    /// Collapsibility coefficient δs (coefficient of subsidence).
762    ///
763    /// δs ≈ (e_p - e_s) / (1 + e0)  where e_p (natural) and e_s (saturated at same pressure).
764    pub fn collapsibility_coefficient(&self, e_saturated: f64) -> f64 {
765        (self.natural_void_ratio - e_saturated) / (1.0 + self.natural_void_ratio)
766    }
767}
768
769// ─────────────────────────────────────────────────────────────────────────────
770// Rockfill Dam Material
771// ─────────────────────────────────────────────────────────────────────────────
772
773/// Rockfill and embankment dam core material model (Duncan-Chang hyperbolic).
774#[derive(Debug, Clone)]
775pub struct RockfillModel {
776    /// Tangential modulus number K \[-\].
777    pub k_modulus: f64,
778    /// Modulus exponent n \[-\].
779    pub n_exponent: f64,
780    /// Failure ratio Rf \[-\] (≈ 0.7–0.9).
781    pub rf: f64,
782    /// Cohesion c \[Pa\].
783    pub cohesion: f64,
784    /// Friction angle φ \[radians\].
785    pub friction_angle: f64,
786    /// Atmospheric pressure pa \[Pa\].
787    pub pa: f64,
788}
789
790impl RockfillModel {
791    /// Create a new rockfill model.
792    pub fn new(
793        k_modulus: f64,
794        n_exponent: f64,
795        rf: f64,
796        cohesion: f64,
797        friction_angle_deg: f64,
798    ) -> Self {
799        Self {
800            k_modulus,
801            n_exponent,
802            rf,
803            cohesion,
804            friction_angle: friction_angle_deg.to_radians(),
805            pa: 101325.0,
806        }
807    }
808
809    /// Typical hard rockfill (quartzite).
810    pub fn hard_quartzite_rockfill() -> Self {
811        Self::new(800.0, 0.4, 0.8, 0.0, 40.0)
812    }
813
814    /// Initial tangent modulus Ei \[Pa\] (Duncan-Chang).
815    ///
816    /// Ei = K * pa * (σ3/pa)^n
817    pub fn initial_tangent_modulus(&self, sigma3: f64) -> f64 {
818        self.k_modulus * self.pa * (sigma3 / self.pa).abs().powf(self.n_exponent)
819    }
820
821    /// Deviatoric stress at failure (Mohr-Coulomb).
822    ///
823    /// (σ1 - σ3)_f = 2*(c*cos(φ) + σ3*sin(φ)) / (1 - sin(φ))
824    pub fn deviatoric_stress_at_failure(&self, sigma3: f64) -> f64 {
825        let phi = self.friction_angle;
826        2.0 * (self.cohesion * phi.cos() + sigma3 * phi.sin()) / (1.0 - phi.sin())
827    }
828
829    /// Tangent modulus Et \[Pa\] from current stress ratio.
830    ///
831    /// Et = Ei * (1 - Rf * (σ1 - σ3) / (σ1 - σ3)_f)^2
832    pub fn tangent_modulus(&self, sigma1: f64, sigma3: f64) -> f64 {
833        let ei = self.initial_tangent_modulus(sigma3);
834        let delta_f = self.deviatoric_stress_at_failure(sigma3);
835        if delta_f <= 0.0 {
836            return ei;
837        }
838        let delta = sigma1 - sigma3;
839        let ratio = self.rf * delta / delta_f;
840        let factor = (1.0 - ratio).max(0.0);
841        ei * factor * factor
842    }
843}
844
845// ─────────────────────────────────────────────────────────────────────────────
846// Tests
847// ─────────────────────────────────────────────────────────────────────────────
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    // ── SoilModel ─────────────────────────────────────────────────────────
854
855    #[test]
856    fn test_soil_yield_function_elastic() {
857        let soil = SoilModel::medium_sand();
858        // Small stresses should be elastic
859        let f = soil.yield_function(50e3, 100e3); // σ1 < σ3 (not realistic but tests sign)
860        // With σ1 < σ3 the yield function should be negative
861        assert!(f < 0.0, "Should be elastic when σ1 < σ3: {f}");
862    }
863
864    #[test]
865    fn test_soil_yield_function_at_failure() {
866        let soil = SoilModel::stiff_clay();
867        // Compute σ1 that causes failure for σ3 = 100 kPa
868        let sigma3 = 100e3;
869        let phi = soil.friction_angle;
870        let c = soil.cohesion;
871        // (σ1-σ3) = 2c*cos(φ) + (σ1+σ3)*sin(φ) → solve for σ1
872        // σ1*(1-sin(φ)) = 2c*cos(φ) + σ3*(1+sin(φ))
873        let sigma1 = (2.0 * c * phi.cos() + sigma3 * (1.0 + phi.sin())) / (1.0 - phi.sin());
874        let f = soil.yield_function(sigma1, sigma3);
875        assert!(
876            f.abs() < 1e-6 * sigma1,
877            "Yield function should be ~0 at failure: {f}"
878        );
879    }
880
881    #[test]
882    fn test_soil_bulk_modulus() {
883        let soil = SoilModel::medium_sand();
884        let k = soil.bulk_modulus();
885        assert!(k > 0.0, "Bulk modulus must be positive");
886    }
887
888    #[test]
889    fn test_soil_shear_modulus() {
890        let soil = SoilModel::medium_sand();
891        let g = soil.shear_modulus();
892        assert!(g > 0.0);
893    }
894
895    #[test]
896    fn test_soil_ucs() {
897        let soil = SoilModel::stiff_clay();
898        let qu = soil.ucs();
899        // For c=60kPa, φ=22°: qu = 2*60000*cos(22°)/(1-sin(22°)) ≈ 180 kPa
900        assert!(qu > 100e3 && qu < 300e3, "UCS {qu} Pa out of range");
901    }
902
903    #[test]
904    fn test_soil_k0() {
905        let soil = SoilModel::medium_sand();
906        let k0 = soil.k0_jaky();
907        assert!(k0 > 0.0 && k0 < 1.0, "Ko must be in (0,1): {k0}");
908    }
909
910    #[test]
911    fn test_soil_critical_state_q() {
912        let soil = SoilModel::medium_sand();
913        let q = soil.critical_state_line_q(200e3);
914        assert!(q > 0.0);
915    }
916
917    #[test]
918    fn test_soil_plastic_multiplier() {
919        let soil = SoilModel::medium_sand();
920        let lambda = soil.plastic_multiplier(0.01);
921        assert!(lambda > 0.0 && lambda < 0.01);
922    }
923
924    // ── CamClayModel ──────────────────────────────────────────────────────
925
926    #[test]
927    fn test_camclay_e_cs() {
928        let model = CamClayModel::kaolin_ncc();
929        let e = model.e_cs(100e3); // p = 100 kPa
930        assert!(e > 0.0, "Void ratio on CSL must be positive: {e}");
931    }
932
933    #[test]
934    fn test_camclay_yield_surface_inside() {
935        let model = CamClayModel::kaolin_ncc();
936        // Inside yield surface (isotropic state well below preconsolidation)
937        let f = model.yield_surface_p0(50e3, 0.0);
938        assert!(f < 0.0, "Should be inside yield surface at p=50kPa<p0: {f}");
939    }
940
941    #[test]
942    fn test_camclay_yield_surface_on() {
943        let model = CamClayModel::kaolin_ncc();
944        // On yield surface: q=0, p=p0
945        let f = model.yield_surface_p0(model.p_c0, 0.0);
946        assert!(
947            f.abs() < 1e-6 * model.p_c0 * model.p_c0,
948            "On yield surface: {f}"
949        );
950    }
951
952    #[test]
953    fn test_camclay_preconsolidation_pressure() {
954        let model = CamClayModel::kaolin_ncc();
955        let p0 = model.preconsolidation_pressure(model.e0);
956        assert!(p0 > 0.0, "Preconsolidation pressure must be positive: {p0}");
957    }
958
959    #[test]
960    fn test_camclay_elastic_volumetric_strain() {
961        let model = CamClayModel::kaolin_ncc();
962        let deps = model.volumetric_strain_elastic(10e3, 100e3);
963        assert!(deps > 0.0, "Elastic strain must be positive");
964    }
965
966    #[test]
967    fn test_camclay_plastic_strain() {
968        let model = CamClayModel::kaolin_ncc();
969        let deps = model.volumetric_strain_plastic(10e3, 100e3);
970        assert!(deps > 0.0);
971    }
972
973    // ── RockModel ─────────────────────────────────────────────────────────
974
975    #[test]
976    fn test_rock_mb_granite() {
977        let rock = RockModel::granite();
978        let mb = rock.mb_parameter();
979        assert!(mb > 0.0 && mb < rock.mi, "mb < mi: {mb}");
980    }
981
982    #[test]
983    fn test_rock_s_parameter() {
984        let rock = RockModel::granite();
985        let s = rock.s_parameter();
986        assert!(s > 0.0 && s <= 1.0, "s ∈ (0,1]: {s}");
987    }
988
989    #[test]
990    fn test_rock_a_parameter() {
991        let rock = RockModel::granite();
992        let a = rock.a_parameter();
993        assert!((0.5..0.65).contains(&a), "a ≈ 0.5 for intact rock: {a}");
994    }
995
996    #[test]
997    fn test_rock_ucs() {
998        let rock = RockModel::granite();
999        let ucs_mass = rock.uniaxial_compressive_strength_rock();
1000        assert!(
1001            ucs_mass > 0.0 && ucs_mass <= rock.ucs,
1002            "Rock mass UCS ≤ intact UCS"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_rock_tensile_strength() {
1008        let rock = RockModel::granite();
1009        let t = rock.tensile_strength_rock();
1010        assert!(
1011            t < 0.0,
1012            "Tensile strength should be negative (tension): {t}"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_rock_hoek_brown_yield() {
1018        let rock = RockModel::granite();
1019        // At sigma3=0, the HB strength = ucs_mass; sigma1 = 2*ucs_mass should yield
1020        let sigma3 = 0.0;
1021        let ucs_mass = rock.uniaxial_compressive_strength_rock();
1022        let sigma1 = ucs_mass * 2.0;
1023        let yielding = rock.yield_hoek_brown(sigma1, sigma3);
1024        assert!(
1025            yielding,
1026            "Should yield at sigma1=2*ucs_mass, sigma3=0: ucs_mass={ucs_mass:.3e}"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_rock_deformation_modulus() {
1032        let rock = RockModel::granite();
1033        let em = rock.deformation_modulus();
1034        assert!(em > 0.0, "Deformation modulus must be positive");
1035    }
1036
1037    #[test]
1038    fn test_rock_shale_params() {
1039        let rock = RockModel::shale();
1040        let mb = rock.mb_parameter();
1041        let mb_granite = RockModel::granite().mb_parameter();
1042        assert!(mb < mb_granite, "Shale has lower mb than granite");
1043    }
1044
1045    // ── SandModel ─────────────────────────────────────────────────────────
1046
1047    #[test]
1048    fn test_sand_peak_friction_angle() {
1049        let sand = SandModel::medium_dense_quartz();
1050        let phi_p = sand.peak_friction_angle(0.6);
1051        assert!(phi_p >= sand.critical_state_friction_angle, "Peak φ ≥ CS φ");
1052    }
1053
1054    #[test]
1055    fn test_sand_dilatancy_index_positive() {
1056        let sand = SandModel::medium_dense_quartz();
1057        let ir = sand.dilatancy_index(0.7, 100.0);
1058        assert!(ir >= 0.0, "Dilatancy index non-negative");
1059    }
1060
1061    #[test]
1062    fn test_sand_bolton_dilatancy() {
1063        let sand = SandModel::medium_dense_quartz();
1064        let ir = sand.bolton_dilatancy(0.6, 100.0);
1065        assert!(ir >= 0.0);
1066    }
1067
1068    #[test]
1069    fn test_sand_void_ratio() {
1070        let sand = SandModel::medium_dense_quartz();
1071        let e = sand.current_void_ratio();
1072        assert!(
1073            e >= sand.e_min() && e <= sand.e_max(),
1074            "Void ratio out of range: {e}"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_sand_dry_unit_weight() {
1080        let sand = SandModel::medium_dense_quartz();
1081        let gd = sand.dry_unit_weight();
1082        assert!(
1083            gd > 0.0 && gd < 30.0,
1084            "Dry unit weight {gd} kN/m^3 out of range"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_sand_loose_has_lower_dr() {
1090        let loose = SandModel::loose_sand();
1091        let dense = SandModel::medium_dense_quartz();
1092        assert!(loose.relative_density < dense.relative_density);
1093    }
1094
1095    // ── PermafrostModel ───────────────────────────────────────────────────
1096
1097    #[test]
1098    fn test_permafrost_unfrozen_content_above_zero() {
1099        let pf = PermafrostModel::siberian_silt();
1100        let theta = pf.unfrozen_water_content(5.0);
1101        assert_eq!(
1102            theta, pf.total_water_content,
1103            "Above 0°C: all water unfrozen"
1104        );
1105    }
1106
1107    #[test]
1108    fn test_permafrost_unfrozen_content_frozen() {
1109        let pf = PermafrostModel::siberian_silt();
1110        let theta = pf.unfrozen_water_content(-10.0);
1111        assert!(
1112            theta < pf.total_water_content,
1113            "Below 0°C: some water frozen"
1114        );
1115        assert!(theta >= 0.0);
1116    }
1117
1118    #[test]
1119    fn test_permafrost_ice_saturation_above_zero() {
1120        let pf = PermafrostModel::siberian_silt();
1121        let si = pf.ice_saturation(5.0);
1122        assert_eq!(si, 0.0, "No ice above 0°C");
1123    }
1124
1125    #[test]
1126    fn test_permafrost_ice_saturation_frozen() {
1127        let pf = PermafrostModel::siberian_silt();
1128        let si = pf.ice_saturation(-20.0);
1129        assert!(si > 0.0 && si <= 1.0, "Ice saturation in (0,1]: {si}");
1130    }
1131
1132    #[test]
1133    fn test_permafrost_bulk_modulus_increases_with_cooling() {
1134        let pf = PermafrostModel::siberian_silt();
1135        let k_warm = pf.bulk_modulus_frozen(-1.0);
1136        let k_cold = pf.bulk_modulus_frozen(-20.0);
1137        assert!(k_cold > k_warm, "Frozen soil stiffens with cooling");
1138    }
1139
1140    #[test]
1141    fn test_permafrost_thermal_conductivity() {
1142        let pf = PermafrostModel::siberian_silt();
1143        let lam = pf.thermal_conductivity(0.1);
1144        assert!(
1145            lam > 0.0 && lam < 10.0,
1146            "Conductivity {lam} W/(mK) out of range"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_permafrost_heat_capacity() {
1152        let pf = PermafrostModel::siberian_silt();
1153        let c = pf.volumetric_heat_capacity(-10.0);
1154        assert!(
1155            c > 1e5 && c < 5e6,
1156            "Heat capacity {c} J/(m^3 K) out of range"
1157        );
1158    }
1159
1160    // ── SeabedSedimentModel ───────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_seabed_su_at_mudline() {
1164        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1165        let su = sed.undrained_shear_strength(0.0);
1166        assert_eq!(su, sed.su0, "At z=0, Su = su0");
1167    }
1168
1169    #[test]
1170    fn test_seabed_su_increases_with_depth() {
1171        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1172        let su1 = sed.undrained_shear_strength(5.0);
1173        let su2 = sed.undrained_shear_strength(10.0);
1174        assert!(su2 > su1, "Su increases with depth");
1175    }
1176
1177    #[test]
1178    fn test_seabed_settlement_positive() {
1179        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1180        let ds = sed.settlement_primary(50e3, 100e3, 10.0, sed.e0);
1181        assert!(ds > 0.0, "Settlement must be positive");
1182    }
1183
1184    #[test]
1185    fn test_seabed_settlement_zero_when_no_loading() {
1186        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1187        let ds = sed.settlement_primary(50e3, 50e3, 10.0, sed.e0);
1188        assert_eq!(ds, 0.0, "No settlement without additional load");
1189    }
1190
1191    #[test]
1192    fn test_seabed_time_consolidation() {
1193        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1194        let t = sed.time_to_consolidation(2e-8, 5.0, 0.5);
1195        assert!(t > 0.0, "Consolidation time must be positive");
1196    }
1197
1198    #[test]
1199    fn test_seabed_ocr() {
1200        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1201        let ocr = sed.ocr(50e3, 100e3);
1202        assert!((ocr - 2.0).abs() < 1e-10, "OCR = 2 for sigma_p = 2*sigma_v");
1203    }
1204
1205    // ── GranularPressureModel ─────────────────────────────────────────────
1206
1207    #[test]
1208    fn test_granular_hertz_mindlin_stiffness() {
1209        let model = GranularPressureModel::quartz_sand();
1210        let (kn, kt) = model.hertz_mindlin_stiffness(44e9, 0.07, 0.15e-3, 100e3);
1211        assert!(kn > 0.0, "Normal stiffness must be positive: {kn}");
1212        assert!(kt > 0.0, "Tangential stiffness must be positive: {kt}");
1213    }
1214
1215    #[test]
1216    fn test_granular_hertz_mindlin_kn_gt_kt_ratio() {
1217        let model = GranularPressureModel::quartz_sand();
1218        let (kn, kt) = model.hertz_mindlin_stiffness(44e9, 0.07, 0.15e-3, 100e3);
1219        // kt/kn = 6 from HM theory
1220        let ratio = kt / kn;
1221        assert!((ratio - 6.0).abs() < 1e-6, "kt/kn should be 6: {ratio}");
1222    }
1223
1224    #[test]
1225    fn test_granular_coordination_number() {
1226        let model = GranularPressureModel::quartz_sand();
1227        let z = model.coordination_number(0.65);
1228        assert!(z > 3.0 && z < 12.0, "Coordination number {z} out of range");
1229    }
1230
1231    #[test]
1232    fn test_granular_pack_stiffness_positive() {
1233        let model = GranularPressureModel::quartz_sand();
1234        let g_pack = model.pack_stiffness(44e9, 0.07, 0.65, 100e3);
1235        assert!(g_pack > 0.0, "Pack stiffness must be positive");
1236    }
1237
1238    #[test]
1239    fn test_granular_pack_stiffness_pressure_dependent() {
1240        let model = GranularPressureModel::quartz_sand();
1241        let g1 = model.pack_stiffness(44e9, 0.07, 0.65, 100e3);
1242        let g2 = model.pack_stiffness(44e9, 0.07, 0.65, 400e3);
1243        assert!(g2 > g1, "Pack stiffness increases with pressure");
1244    }
1245
1246    #[test]
1247    fn test_granular_shear_wave_velocity() {
1248        let model = GranularPressureModel::quartz_sand();
1249        let rho = 1600.0; // kg/m^3
1250        let vs = model.shear_wave_velocity(rho, 100e3);
1251        assert!(vs > 0.0 && vs < 2000.0, "Vs {vs} m/s out of range");
1252    }
1253
1254    // ── LoessModel ────────────────────────────────────────────────────────
1255
1256    #[test]
1257    fn test_loess_plasticity_index() {
1258        let loess = LoessModel::chinese_loess();
1259        let pi = loess.plasticity_index();
1260        assert!((pi - 12.0).abs() < 1e-10, "PI = LL - PL = 12");
1261    }
1262
1263    #[test]
1264    fn test_loess_liquidity_index() {
1265        let loess = LoessModel::chinese_loess();
1266        let li = loess.liquidity_index();
1267        // LI = (12 - 16)/12 = -4/12 < 0 (stiff)
1268        assert!(li < 0.0, "LI should be negative for stiff loess");
1269    }
1270
1271    #[test]
1272    fn test_loess_collapsibility() {
1273        let loess = LoessModel::chinese_loess();
1274        let delta_s = loess.collapsibility_coefficient(0.85);
1275        assert!(delta_s > 0.0, "Collapsibility must be positive");
1276    }
1277
1278    // ── RockfillModel ─────────────────────────────────────────────────────
1279
1280    #[test]
1281    fn test_rockfill_initial_modulus() {
1282        let rf = RockfillModel::hard_quartzite_rockfill();
1283        let ei = rf.initial_tangent_modulus(200e3);
1284        assert!(ei > 0.0, "Initial modulus must be positive");
1285    }
1286
1287    #[test]
1288    fn test_rockfill_tangent_modulus_decreases() {
1289        let rf = RockfillModel::hard_quartzite_rockfill();
1290        let sigma3 = 200e3;
1291        let ei = rf.initial_tangent_modulus(sigma3);
1292        let delta_f = rf.deviatoric_stress_at_failure(sigma3);
1293        // At half failure stress
1294        let sigma1_half = sigma3 + 0.5 * delta_f;
1295        let et = rf.tangent_modulus(sigma1_half, sigma3);
1296        assert!(et < ei, "Tangent modulus decreases with shear");
1297    }
1298
1299    #[test]
1300    fn test_rockfill_deviatoric_stress_at_failure_positive() {
1301        let rf = RockfillModel::hard_quartzite_rockfill();
1302        let dsf = rf.deviatoric_stress_at_failure(200e3);
1303        assert!(dsf > 0.0);
1304    }
1305
1306    // ── Cross-model consistency tests ─────────────────────────────────────
1307
1308    #[test]
1309    fn test_camclay_e_cs_decreases_with_pressure() {
1310        let model = CamClayModel::kaolin_ncc();
1311        let e1 = model.e_cs(100e3);
1312        let e2 = model.e_cs(1000e3);
1313        assert!(e2 < e1, "Void ratio on CSL decreases with pressure");
1314    }
1315
1316    #[test]
1317    fn test_rock_granite_stronger_than_shale() {
1318        let granite = RockModel::granite();
1319        let shale = RockModel::shale();
1320        assert!(
1321            granite.uniaxial_compressive_strength_rock()
1322                > shale.uniaxial_compressive_strength_rock(),
1323            "Granite stronger than shale"
1324        );
1325    }
1326
1327    #[test]
1328    fn test_soil_clay_has_cohesion() {
1329        let clay = SoilModel::stiff_clay();
1330        assert!(clay.cohesion > 0.0, "Clay has cohesion");
1331    }
1332
1333    #[test]
1334    fn test_sand_has_no_cohesion() {
1335        let sand = SoilModel::medium_sand();
1336        assert_eq!(sand.cohesion, 0.0, "Sand has no cohesion");
1337    }
1338
1339    #[test]
1340    fn test_seabed_pore_pressure_ratio() {
1341        let sed = SeabedSedimentModel::gulf_of_mexico_clay();
1342        let ru = sed.pore_pressure_ratio(20e3, 100e3);
1343        assert!((ru - 0.2).abs() < 1e-10);
1344    }
1345}