Skip to main content

oxiphysics_materials/
plasticity.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Plasticity models for metals, soils, and general inelastic materials.
5//!
6//! Provides:
7//! - Isotropic hardening (linear and nonlinear)
8//! - Kinematic hardening (Prager model)
9//! - Combined isotropic-kinematic hardening (Chaboche)
10//! - J2 return mapping algorithm (radial return)
11//! - Drucker-Prager yield surface (geomaterials)
12//! - Modified Cam-Clay (critical state soil mechanics)
13//! - Plastic dissipation computation
14//! - Limit load (collapse load factor)
15
16// ─── Isotropic Hardening ──────────────────────────────────────────────────────
17
18/// Linear isotropic hardening law.
19///
20/// σ_y(ε_p) = σ_y0 + H * ε_p
21///
22/// where σ_y0 is the initial yield stress, H is the hardening modulus,
23/// and ε_p is the equivalent plastic strain.
24#[derive(Debug, Clone, Copy)]
25#[allow(dead_code)]
26pub struct IsotropicHardening {
27    /// Initial yield stress σ_y0 (Pa).
28    pub sigma_y0: f64,
29    /// Isotropic hardening modulus H (Pa).
30    pub hardening_modulus: f64,
31}
32
33#[allow(dead_code)]
34impl IsotropicHardening {
35    /// Create a new linear isotropic hardening model.
36    pub fn new(sigma_y0: f64, hardening_modulus: f64) -> Self {
37        Self {
38            sigma_y0,
39            hardening_modulus,
40        }
41    }
42
43    /// Current yield stress at equivalent plastic strain ε_p.
44    pub fn yield_stress(&self, eps_p: f64) -> f64 {
45        self.sigma_y0 + self.hardening_modulus * eps_p
46    }
47
48    /// Derivative of yield stress with respect to ε_p (= H for linear hardening).
49    pub fn d_yield_stress(&self) -> f64 {
50        self.hardening_modulus
51    }
52
53    /// Elastic perfectly-plastic (H = 0).
54    pub fn elastic_perfectly_plastic(sigma_y0: f64) -> Self {
55        Self::new(sigma_y0, 0.0)
56    }
57
58    /// Mild steel default (σ_y0 = 250 MPa, H = 2 GPa).
59    pub fn mild_steel() -> Self {
60        Self::new(250.0e6, 2.0e9)
61    }
62
63    /// Aluminium alloy (σ_y0 = 270 MPa, H = 1.5 GPa).
64    pub fn aluminium_alloy() -> Self {
65        Self::new(270.0e6, 1.5e9)
66    }
67}
68
69// ─── Nonlinear Isotropic Hardening (Voce) ─────────────────────────────────────
70
71/// Voce nonlinear isotropic (saturation) hardening.
72///
73/// σ_y(ε_p) = σ_∞ - (σ_∞ - σ_y0) * exp(-b * ε_p)
74///
75/// Stress saturates to σ_∞ as ε_p → ∞.
76#[derive(Debug, Clone, Copy)]
77#[allow(dead_code)]
78pub struct VoceHardening {
79    /// Initial yield stress (Pa).
80    pub sigma_y0: f64,
81    /// Saturation yield stress (Pa).
82    pub sigma_inf: f64,
83    /// Saturation rate b (dimensionless).
84    pub b: f64,
85}
86
87#[allow(dead_code)]
88impl VoceHardening {
89    /// Create a Voce hardening model.
90    pub fn new(sigma_y0: f64, sigma_inf: f64, b: f64) -> Self {
91        Self {
92            sigma_y0,
93            sigma_inf,
94            b,
95        }
96    }
97
98    /// Yield stress at equivalent plastic strain ε_p.
99    pub fn yield_stress(&self, eps_p: f64) -> f64 {
100        self.sigma_inf - (self.sigma_inf - self.sigma_y0) * (-self.b * eps_p).exp()
101    }
102
103    /// Derivative dσ_y/dε_p.
104    pub fn d_yield_stress(&self, eps_p: f64) -> f64 {
105        self.b * (self.sigma_inf - self.sigma_y0) * (-self.b * eps_p).exp()
106    }
107
108    /// Copper-like parameters (σ_y0 = 50 MPa, σ_∞ = 300 MPa, b = 10).
109    pub fn copper_like() -> Self {
110        Self::new(50.0e6, 300.0e6, 10.0)
111    }
112}
113
114// ─── Kinematic Hardening (Prager) ─────────────────────────────────────────────
115
116/// Linear kinematic (Prager) hardening model.
117///
118/// Backstress evolution: dα = C * dε_p
119///
120/// where C is the kinematic hardening modulus and α is the backstress.
121/// The yield function becomes: f(σ - α) - σ_y0 = 0.
122#[derive(Debug, Clone)]
123#[allow(dead_code)]
124pub struct PragerKinematic {
125    /// Initial yield stress σ_y0 (Pa).
126    pub sigma_y0: f64,
127    /// Kinematic hardening modulus C (Pa).
128    pub c_kinematic: f64,
129}
130
131#[allow(dead_code)]
132impl PragerKinematic {
133    /// Create a Prager kinematic hardening model.
134    pub fn new(sigma_y0: f64, c_kinematic: f64) -> Self {
135        Self {
136            sigma_y0,
137            c_kinematic,
138        }
139    }
140
141    /// Backstress increment Δα (Voigt notation: \[xx, yy, zz, xy, yz, xz\]).
142    ///
143    /// Δα = C * Δε_p
144    pub fn backstress_increment(&self, delta_ep: &[f64; 6]) -> [f64; 6] {
145        let c = self.c_kinematic;
146        [
147            c * delta_ep[0],
148            c * delta_ep[1],
149            c * delta_ep[2],
150            c * delta_ep[3],
151            c * delta_ep[4],
152            c * delta_ep[5],
153        ]
154    }
155
156    /// Effective stress: σ_eff = σ - α (shifted stress).
157    pub fn effective_stress(stress: &[f64; 6], backstress: &[f64; 6]) -> [f64; 6] {
158        [
159            stress[0] - backstress[0],
160            stress[1] - backstress[1],
161            stress[2] - backstress[2],
162            stress[3] - backstress[3],
163            stress[4] - backstress[4],
164            stress[5] - backstress[5],
165        ]
166    }
167
168    /// Von Mises norm of a stress/backstress tensor (Voigt notation).
169    pub fn von_mises_norm(s: &[f64; 6]) -> f64 {
170        let mean = (s[0] + s[1] + s[2]) / 3.0;
171        let dev = [s[0] - mean, s[1] - mean, s[2] - mean];
172        let j2 = 0.5 * (dev[0].powi(2) + dev[1].powi(2) + dev[2].powi(2))
173            + s[3].powi(2)
174            + s[4].powi(2)
175            + s[5].powi(2);
176        (3.0 * j2).sqrt() // = sqrt(3 * J2) = von Mises
177    }
178
179    /// Yield function f = ||σ - α||_vM - σ_y0.
180    pub fn yield_function(&self, stress: &[f64; 6], backstress: &[f64; 6]) -> f64 {
181        let s_eff = Self::effective_stress(stress, backstress);
182        Self::von_mises_norm(&s_eff) - self.sigma_y0
183    }
184}
185
186// ─── Chaboche Combined Hardening ──────────────────────────────────────────────
187
188/// Chaboche combined isotropic-kinematic hardening model.
189///
190/// Isotropic part: R(p) = Q*(1 - exp(-b*p)) where p = ∫|dε_p|
191/// Kinematic part: dα = C * dε_p - γ * α * dp  (Armstrong-Frederick rule)
192///
193/// Yield surface: f = ||σ - α||_vM - (σ_y0 + R) = 0
194#[derive(Debug, Clone)]
195#[allow(dead_code)]
196pub struct Chaboche {
197    /// Initial yield stress (Pa).
198    pub sigma_y0: f64,
199    /// Isotropic saturation stress Q (Pa).
200    pub q: f64,
201    /// Isotropic saturation rate b.
202    pub b_iso: f64,
203    /// Kinematic hardening modulus C (Pa).
204    pub c_kin: f64,
205    /// Kinematic recovery parameter γ (dimensionless).
206    pub gamma_kin: f64,
207}
208
209#[allow(dead_code)]
210impl Chaboche {
211    /// Create a Chaboche model.
212    pub fn new(sigma_y0: f64, q: f64, b_iso: f64, c_kin: f64, gamma_kin: f64) -> Self {
213        Self {
214            sigma_y0,
215            q,
216            b_iso,
217            c_kin,
218            gamma_kin,
219        }
220    }
221
222    /// Default steel parameters (representative values).
223    pub fn steel_default() -> Self {
224        Self::new(
225            250.0e6, // σ_y0
226            80.0e6,  // Q
227            15.0,    // b
228            50.0e9,  // C
229            500.0,   // γ
230        )
231    }
232
233    /// Isotropic hardening stress R(p) = Q*(1 - exp(-b*p)).
234    pub fn isotropic_stress(&self, p: f64) -> f64 {
235        self.q * (1.0 - (-self.b_iso * p).exp())
236    }
237
238    /// Current yield stress: σ_y0 + R(p).
239    pub fn yield_stress(&self, p: f64) -> f64 {
240        self.sigma_y0 + self.isotropic_stress(p)
241    }
242
243    /// Armstrong-Frederick backstress increment.
244    ///
245    /// Δα = C * n̂ * Δp - γ * α * Δp
246    /// where n̂ is the flow direction, Δp the plastic strain increment.
247    pub fn backstress_increment(
248        &self,
249        flow_dir: &[f64; 6],
250        alpha: &[f64; 6],
251        delta_p: f64,
252    ) -> [f64; 6] {
253        let mut da = [0.0f64; 6];
254        for i in 0..6 {
255            da[i] = (self.c_kin * flow_dir[i] - self.gamma_kin * alpha[i]) * delta_p;
256        }
257        da
258    }
259
260    /// Accumulated backstress norm ||α|| = sqrt(2/3 α:α).
261    pub fn backstress_norm(alpha: &[f64; 6]) -> f64 {
262        let sum = alpha[0].powi(2)
263            + alpha[1].powi(2)
264            + alpha[2].powi(2)
265            + 2.0 * (alpha[3].powi(2) + alpha[4].powi(2) + alpha[5].powi(2));
266        (2.0 / 3.0 * sum).sqrt()
267    }
268}
269
270// ─── J2 Return Mapping ────────────────────────────────────────────────────────
271
272/// J2 (von Mises) return mapping algorithm with isotropic hardening.
273///
274/// Given a trial elastic stress, compute the corrected plastic stress
275/// via the radial return algorithm.
276#[derive(Debug, Clone, Copy)]
277#[allow(dead_code)]
278pub struct J2ReturnMapping {
279    /// Shear modulus G (Pa).
280    pub shear_modulus: f64,
281    /// Bulk modulus K (Pa).
282    pub bulk_modulus: f64,
283    /// Initial yield stress (Pa).
284    pub sigma_y0: f64,
285    /// Isotropic hardening modulus H (Pa).
286    pub hardening_modulus: f64,
287}
288
289#[allow(dead_code)]
290impl J2ReturnMapping {
291    /// Create a J2 return mapping integrator.
292    pub fn new(
293        shear_modulus: f64,
294        bulk_modulus: f64,
295        sigma_y0: f64,
296        hardening_modulus: f64,
297    ) -> Self {
298        Self {
299            shear_modulus,
300            bulk_modulus,
301            sigma_y0,
302            hardening_modulus,
303        }
304    }
305
306    /// Create from Young's modulus and Poisson's ratio.
307    #[allow(non_snake_case)]
308    pub fn from_young_poisson(E: f64, nu: f64, sigma_y0: f64, h: f64) -> Self {
309        let g = E / (2.0 * (1.0 + nu));
310        let k = E / (3.0 * (1.0 - 2.0 * nu));
311        Self::new(g, k, sigma_y0, h)
312    }
313
314    /// Von Mises stress (√(3 J₂)) from full Voigt stress \[xx,yy,zz,xy,yz,xz\].
315    pub fn von_mises(stress: &[f64; 6]) -> f64 {
316        let mean = (stress[0] + stress[1] + stress[2]) / 3.0;
317        let dev = [stress[0] - mean, stress[1] - mean, stress[2] - mean];
318        let j2 = 0.5 * (dev[0].powi(2) + dev[1].powi(2) + dev[2].powi(2))
319            + stress[3].powi(2)
320            + stress[4].powi(2)
321            + stress[5].powi(2);
322        (3.0 * j2).sqrt()
323    }
324
325    /// Radial return mapping.
326    ///
327    /// Returns (updated_stress, delta_eps_p, delta_eps_p_equivalent).
328    pub fn return_map(&self, trial_stress: &[f64; 6], eps_p_bar: f64) -> ([f64; 6], [f64; 6], f64) {
329        let sigma_y = self.sigma_y0 + self.hardening_modulus * eps_p_bar;
330        let vm = Self::von_mises(trial_stress);
331
332        if vm <= sigma_y + f64::EPSILON * sigma_y {
333            // Elastic — no plastic correction
334            return (*trial_stress, [0.0; 6], 0.0);
335        }
336
337        // Plastic consistency parameter Δγ
338        let delta_gamma = (vm - sigma_y) / (3.0 * self.shear_modulus + self.hardening_modulus);
339
340        // Deviatoric trial stress
341        let p = (trial_stress[0] + trial_stress[1] + trial_stress[2]) / 3.0;
342        let dev_trial = [
343            trial_stress[0] - p,
344            trial_stress[1] - p,
345            trial_stress[2] - p,
346            trial_stress[3],
347            trial_stress[4],
348            trial_stress[5],
349        ];
350
351        // Scale factor for deviatoric correction
352        let scale = 1.0 - 3.0 * self.shear_modulus * delta_gamma / vm;
353
354        // Updated stress (pressure unchanged)
355        let updated_stress = [
356            p + dev_trial[0] * scale,
357            p + dev_trial[1] * scale,
358            p + dev_trial[2] * scale,
359            dev_trial[3] * scale,
360            dev_trial[4] * scale,
361            dev_trial[5] * scale,
362        ];
363
364        // Plastic strain increment (Δε_p = Δγ * n̂ where n̂ = dev/||dev||_vm)
365        let n_hat = [
366            dev_trial[0] / vm * 3.0 / 2.0,
367            dev_trial[1] / vm * 3.0 / 2.0,
368            dev_trial[2] / vm * 3.0 / 2.0,
369            dev_trial[3] / vm * 3.0 / 2.0,
370            dev_trial[4] / vm * 3.0 / 2.0,
371            dev_trial[5] / vm * 3.0 / 2.0,
372        ];
373        let delta_eps_p = [
374            delta_gamma * n_hat[0],
375            delta_gamma * n_hat[1],
376            delta_gamma * n_hat[2],
377            delta_gamma * n_hat[3],
378            delta_gamma * n_hat[4],
379            delta_gamma * n_hat[5],
380        ];
381
382        (updated_stress, delta_eps_p, delta_gamma)
383    }
384
385    /// Consistent tangent modulus (elastic-plastic, uniaxial).
386    ///
387    /// C_ep = 2G * \[1 - 3G*Δγ/q_trial - 3G/(3G+H)*q_trial/(q_trial)\]
388    /// Simplified scalar: E_ep = E*H/(E+H).
389    pub fn consistent_tangent_uniaxial(&self) -> f64 {
390        let e = 9.0 * self.bulk_modulus * self.shear_modulus
391            / (3.0 * self.bulk_modulus + self.shear_modulus);
392        e * self.hardening_modulus / (e + self.hardening_modulus)
393    }
394}
395
396// ─── Drucker-Prager ───────────────────────────────────────────────────────────
397
398/// Drucker-Prager yield surface for geomaterials (soil, rock, concrete).
399///
400/// Yield function: F = √J₂ + α·I₁ - k = 0
401///
402/// where:
403///   α = 2 sin φ / (√3 (3 - sin φ))   (outer cone, Mohr-Coulomb match in compression)
404///   k = 6 c cos φ / (√3 (3 - sin φ))
405///   I₁ = tr(σ), J₂ = ½ dev:dev
406#[derive(Debug, Clone, Copy)]
407#[allow(dead_code)]
408pub struct DruckerPragerModel {
409    /// Internal friction angle φ (radians).
410    pub friction_angle: f64,
411    /// Cohesion c (Pa).
412    pub cohesion: f64,
413    /// Shear modulus G (Pa).
414    pub shear_modulus: f64,
415    /// Bulk modulus K (Pa).
416    pub bulk_modulus: f64,
417}
418
419#[allow(dead_code)]
420impl DruckerPragerModel {
421    /// Create a Drucker-Prager model.
422    pub fn new(friction_angle: f64, cohesion: f64, shear_modulus: f64, bulk_modulus: f64) -> Self {
423        Self {
424            friction_angle,
425            cohesion,
426            shear_modulus,
427            bulk_modulus,
428        }
429    }
430
431    /// Create from φ (degrees), c (Pa), Young's modulus (Pa), Poisson's ratio.
432    #[allow(non_snake_case, clippy::too_many_arguments)]
433    pub fn from_mohr_coulomb_degrees(phi_deg: f64, cohesion: f64, E: f64, nu: f64) -> Self {
434        let phi = phi_deg.to_radians();
435        let g = E / (2.0 * (1.0 + nu));
436        let k = E / (3.0 * (1.0 - 2.0 * nu));
437        Self::new(phi, cohesion, g, k)
438    }
439
440    /// DP α parameter (friction / pressure coupling).
441    pub fn alpha_dp(&self) -> f64 {
442        let sin_phi = self.friction_angle.sin();
443        2.0 * sin_phi / (3.0_f64.sqrt() * (3.0 - sin_phi))
444    }
445
446    /// DP k parameter (cohesion term).
447    pub fn k_dp(&self) -> f64 {
448        let sin_phi = self.friction_angle.sin();
449        let cos_phi = self.friction_angle.cos();
450        6.0 * self.cohesion * cos_phi / (3.0_f64.sqrt() * (3.0 - sin_phi))
451    }
452
453    /// Second deviatoric stress invariant J₂.
454    fn j2(stress: &[f64; 6]) -> f64 {
455        let mean = (stress[0] + stress[1] + stress[2]) / 3.0;
456        let d = [stress[0] - mean, stress[1] - mean, stress[2] - mean];
457        0.5 * (d[0].powi(2) + d[1].powi(2) + d[2].powi(2))
458            + stress[3].powi(2)
459            + stress[4].powi(2)
460            + stress[5].powi(2)
461    }
462
463    /// Yield function value F. F > 0 indicates yielding.
464    pub fn yield_function(&self, stress: &[f64; 6]) -> f64 {
465        let i1 = stress[0] + stress[1] + stress[2];
466        let sqrt_j2 = Self::j2(stress).sqrt();
467        sqrt_j2 + self.alpha_dp() * i1 - self.k_dp()
468    }
469
470    /// Check if stress state is elastic (F ≤ 0).
471    pub fn is_elastic(&self, stress: &[f64; 6]) -> bool {
472        self.yield_function(stress) <= 0.0
473    }
474
475    /// Uniaxial tensile strength (from cohesion and friction angle).
476    ///
477    /// σ_t = 2c cos φ / (1 + sin φ)
478    pub fn uniaxial_tensile_strength(&self) -> f64 {
479        let sin_phi = self.friction_angle.sin();
480        let cos_phi = self.friction_angle.cos();
481        2.0 * self.cohesion * cos_phi / (1.0 + sin_phi)
482    }
483
484    /// Uniaxial compressive strength.
485    ///
486    /// σ_c = 2c cos φ / (1 - sin φ)
487    pub fn uniaxial_compressive_strength(&self) -> f64 {
488        let sin_phi = self.friction_angle.sin();
489        let cos_phi = self.friction_angle.cos();
490        2.0 * self.cohesion * cos_phi / (1.0 - sin_phi)
491    }
492}
493
494// ─── Modified Cam-Clay ────────────────────────────────────────────────────────
495
496/// Modified Cam-Clay (MCC) critical state soil mechanics model.
497///
498/// Yield surface: f = q² / M² + p'(p' - p'_c) = 0
499///
500/// where:
501///   p' = I₁/3 = mean effective stress
502///   q = √(3 J₂) = deviatoric stress (von Mises)
503///   M = critical state stress ratio q/p' at failure
504///   p'_c = pre-consolidation pressure (hardening variable)
505#[derive(Debug, Clone, Copy)]
506#[allow(dead_code)]
507pub struct ModifiedCamClay {
508    /// Critical state stress ratio M = q/p at critical state.
509    pub m: f64,
510    /// Swelling line slope κ in e-ln(p) space.
511    pub kappa: f64,
512    /// Normal consolidation line slope λ in e-ln(p) space.
513    pub lambda: f64,
514    /// Initial void ratio e₀.
515    pub e0: f64,
516    /// Poisson's ratio ν (for elastic stiffness).
517    pub poisson_ratio: f64,
518    /// Initial pre-consolidation pressure p'_c0 (Pa).
519    pub pc0: f64,
520}
521
522#[allow(dead_code)]
523impl ModifiedCamClay {
524    /// Create a Modified Cam-Clay model.
525    #[allow(clippy::too_many_arguments)]
526    pub fn new(m: f64, kappa: f64, lambda: f64, e0: f64, poisson_ratio: f64, pc0: f64) -> Self {
527        Self {
528            m,
529            kappa,
530            lambda,
531            e0,
532            poisson_ratio,
533            pc0,
534        }
535    }
536
537    /// Soft clay default parameters.
538    pub fn soft_clay() -> Self {
539        Self::new(0.9, 0.05, 0.2, 1.5, 0.3, 100.0e3)
540    }
541
542    /// Elastic bulk modulus K = (1+e₀) p' / κ.
543    pub fn bulk_modulus(&self, p_prime: f64) -> f64 {
544        (1.0 + self.e0) * p_prime / self.kappa
545    }
546
547    /// Elastic shear modulus from K and Poisson's ratio.
548    pub fn shear_modulus(&self, p_prime: f64) -> f64 {
549        let k = self.bulk_modulus(p_prime);
550        3.0 * k * (1.0 - 2.0 * self.poisson_ratio) / (2.0 * (1.0 + self.poisson_ratio))
551    }
552
553    /// Mean effective stress from stress vector (Voigt: \[xx,yy,zz,xy,yz,xz\]).
554    pub fn mean_stress(stress: &[f64; 6]) -> f64 {
555        (stress[0] + stress[1] + stress[2]) / 3.0
556    }
557
558    /// Deviatoric stress q = √(3 J₂).
559    pub fn deviatoric_stress(stress: &[f64; 6]) -> f64 {
560        let p = Self::mean_stress(stress);
561        let dev = [stress[0] - p, stress[1] - p, stress[2] - p];
562        let j2 = 0.5 * (dev[0].powi(2) + dev[1].powi(2) + dev[2].powi(2))
563            + stress[3].powi(2)
564            + stress[4].powi(2)
565            + stress[5].powi(2);
566        (3.0 * j2).sqrt()
567    }
568
569    /// Yield function f = q²/M² + p'(p' - p'_c).
570    pub fn yield_function(&self, stress: &[f64; 6], pc: f64) -> f64 {
571        let p = Self::mean_stress(stress);
572        let q = Self::deviatoric_stress(stress);
573        q * q / (self.m * self.m) + p * (p - pc)
574    }
575
576    /// Critical state (CSL) pressure at given void ratio.
577    ///
578    /// p_cs = p_c / 2 (at yield surface apex for MCC)
579    pub fn critical_state_pressure(&self, pc: f64) -> f64 {
580        pc / 2.0
581    }
582
583    /// Hardening: evolution of p'_c with plastic volumetric strain Δε_v_p.
584    ///
585    /// p'_c^new = p'_c * exp((1+e₀) * Δε_v_p / (λ - κ))
586    pub fn update_preconsolidation_pressure(&self, pc: f64, delta_eps_v_p: f64) -> f64 {
587        pc * ((1.0 + self.e0) * delta_eps_v_p / (self.lambda - self.kappa)).exp()
588    }
589
590    /// Compression index Cc ≈ λ * ln(10) (conventional oedometer measure).
591    pub fn compression_index(&self) -> f64 {
592        self.lambda * 10.0_f64.ln()
593    }
594}
595
596// ─── Plastic Dissipation ──────────────────────────────────────────────────────
597
598/// Compute plastic dissipation D_p = σ : dε_p.
599///
600/// Both stress and plastic strain increment in Voigt notation \[xx,yy,zz,xy,yz,xz\].
601/// Shear components have factor 2 for engineering notation (γ = 2 ε).
602#[allow(dead_code)]
603pub fn plastic_dissipation(stress: &[f64; 6], delta_eps_p: &[f64; 6]) -> f64 {
604    // Normal components
605    let normal =
606        stress[0] * delta_eps_p[0] + stress[1] * delta_eps_p[1] + stress[2] * delta_eps_p[2];
607    // Shear components (factor 2 for full tensor contraction)
608    let shear = 2.0
609        * (stress[3] * delta_eps_p[3] + stress[4] * delta_eps_p[4] + stress[5] * delta_eps_p[5]);
610    normal + shear
611}
612
613/// Cumulative plastic dissipation over a loading history.
614///
615/// Returns ∑ σ_n : Δε_p_n summed over the provided increments.
616#[allow(dead_code)]
617pub fn cumulative_dissipation(
618    stresses: &[[f64; 6]],
619    plastic_strain_increments: &[[f64; 6]],
620) -> f64 {
621    stresses
622        .iter()
623        .zip(plastic_strain_increments.iter())
624        .map(|(s, dep)| plastic_dissipation(s, dep))
625        .sum()
626}
627
628// ─── Limit Load (Collapse Load Factor) ────────────────────────────────────────
629
630/// Limit load factor estimation using the lower bound theorem.
631///
632/// For a structure carrying load F with yield stress σ_y and elastic stress σ_e,
633/// the lower bound collapse load factor is:
634///
635///   f_L = σ_y / max(σ_vonMises)
636///
637/// Returns the fraction of reference load at which yielding first occurs.
638#[allow(dead_code)]
639pub fn limit_load_factor_lower_bound(
640    elastic_stresses_voigt: &[[f64; 6]],
641    yield_stress: f64,
642) -> f64 {
643    if elastic_stresses_voigt.is_empty() || yield_stress <= 0.0 {
644        return 0.0;
645    }
646    let max_vm = elastic_stresses_voigt
647        .iter()
648        .map(von_mises_stress_voigt)
649        .fold(0.0_f64, f64::max);
650    if max_vm < f64::EPSILON {
651        return f64::INFINITY;
652    }
653    yield_stress / max_vm
654}
655
656/// Von Mises stress from Voigt notation \[xx, yy, zz, xy, yz, xz\].
657#[allow(dead_code)]
658pub fn von_mises_stress_voigt(s: &[f64; 6]) -> f64 {
659    let (sxx, syy, szz, sxy, syz, sxz) = (s[0], s[1], s[2], s[3], s[4], s[5]);
660    let term1 = (sxx - syy).powi(2) + (syy - szz).powi(2) + (szz - sxx).powi(2);
661    let term2 = 6.0 * (sxy.powi(2) + syz.powi(2) + sxz.powi(2));
662    ((term1 + term2) / 2.0).sqrt()
663}
664
665// ─── Power-Law Creep-Plasticity Interaction ───────────────────────────────────
666
667/// Power-law plasticity (Ramberg-Osgood type): ε_total = σ/E + (σ/σ_ref)^n / E.
668///
669/// Used to represent combined elastic-plastic behavior.
670#[derive(Debug, Clone, Copy)]
671#[allow(dead_code)]
672pub struct RambergOsgood {
673    /// Young's modulus E (Pa).
674    pub young_modulus: f64,
675    /// Yield reference stress σ_ref (Pa).
676    pub sigma_ref: f64,
677    /// Hardening exponent n (n ≥ 1, n=1 linear).
678    pub n: f64,
679    /// Offset coefficient α (often 3/7).
680    pub alpha: f64,
681}
682
683#[allow(dead_code)]
684impl RambergOsgood {
685    /// Create a Ramberg-Osgood model.
686    pub fn new(young_modulus: f64, sigma_ref: f64, n: f64, alpha: f64) -> Self {
687        Self {
688            young_modulus,
689            sigma_ref,
690            n,
691            alpha,
692        }
693    }
694
695    /// Total strain at given uniaxial stress σ:
696    ///
697    /// ε = σ/E + α * (σ/σ_ref)^n * σ_ref / E
698    pub fn total_strain(&self, sigma: f64) -> f64 {
699        sigma / self.young_modulus
700            + self.alpha * (sigma / self.sigma_ref).powf(self.n) * self.sigma_ref
701                / self.young_modulus
702    }
703
704    /// Plastic strain = total strain - elastic strain.
705    pub fn plastic_strain(&self, sigma: f64) -> f64 {
706        self.total_strain(sigma) - sigma / self.young_modulus
707    }
708
709    /// Secant modulus E_s = σ / ε_total.
710    pub fn secant_modulus(&self, sigma: f64) -> f64 {
711        let eps = self.total_strain(sigma);
712        if eps.abs() < f64::EPSILON {
713            return self.young_modulus;
714        }
715        sigma / eps
716    }
717
718    /// Tangent modulus E_t = dσ/dε (inverse of dε/dσ).
719    pub fn tangent_modulus(&self, sigma: f64) -> f64 {
720        let d_eps_d_sigma = 1.0 / self.young_modulus
721            + self.alpha * self.n / self.sigma_ref * (sigma / self.sigma_ref).powf(self.n - 1.0);
722        if d_eps_d_sigma.abs() < f64::EPSILON {
723            return self.young_modulus;
724        }
725        1.0 / d_eps_d_sigma
726    }
727}
728
729// ─── J2 Consistent Tangent Modulus ────────────────────────────────────────────
730
731/// Full 6×6 algorithmic (consistent) tangent modulus for J2 plasticity
732/// with isotropic hardening, in Voigt notation.
733///
734/// The consistent tangent ensures quadratic convergence in Newton-Raphson FEM.
735/// It is defined as:
736///
737///   C_ep = C_e - (6G²)/(3G+H) * (n⊗n) / σ_y_trial²
738///          - 2G * Δγ/q_tr * (I_dev - n⊗n)
739///
740/// where n = s_trial / q_tr is the unit deviatoric stress direction,
741/// Δγ is the plastic consistency parameter, G is the shear modulus,
742/// H is the hardening modulus, and I_dev is the deviatoric projector.
743///
744/// For the elastic step, returns the elastic stiffness C_e.
745#[allow(dead_code)]
746pub struct J2ConsistentTangent {
747    /// Shear modulus G (Pa).
748    pub shear_modulus: f64,
749    /// Bulk modulus K (Pa).
750    pub bulk_modulus: f64,
751    /// Isotropic hardening modulus H (Pa).
752    pub hardening_modulus: f64,
753}
754
755#[allow(dead_code)]
756impl J2ConsistentTangent {
757    /// Create a new J2 consistent tangent calculator.
758    pub fn new(shear_modulus: f64, bulk_modulus: f64, hardening_modulus: f64) -> Self {
759        Self {
760            shear_modulus,
761            bulk_modulus,
762            hardening_modulus,
763        }
764    }
765
766    /// Create from Young's modulus and Poisson's ratio.
767    #[allow(non_snake_case)]
768    pub fn from_young_poisson(E: f64, nu: f64, hardening_modulus: f64) -> Self {
769        let g = E / (2.0 * (1.0 + nu));
770        let k = E / (3.0 * (1.0 - 2.0 * nu));
771        Self::new(g, k, hardening_modulus)
772    }
773
774    /// Elastic 6×6 stiffness in Voigt notation (isotropic).
775    ///
776    /// Voigt order: \[σ_xx, σ_yy, σ_zz, σ_yz, σ_xz, σ_xy\].
777    pub fn elastic_stiffness(&self) -> [f64; 36] {
778        let g = self.shear_modulus;
779        let k = self.bulk_modulus;
780        let lam = k - 2.0 / 3.0 * g; // λ = K - 2/3 G
781        let mut c = [0.0_f64; 36];
782        // Normal-normal coupling
783        for i in 0..3 {
784            for j in 0..3 {
785                c[i * 6 + j] = lam;
786            }
787            c[i * 6 + i] += 2.0 * g;
788        }
789        // Shear diagonal
790        c[3 * 6 + 3] = g;
791        c[4 * 6 + 4] = g;
792        c[5 * 6 + 5] = g;
793        c
794    }
795
796    /// Compute the consistent (algorithmic) tangent modulus.
797    ///
798    /// Requires the trial stress, yield stress at start of increment, and
799    /// whether plastic flow occurred.
800    ///
801    /// # Arguments
802    /// * `trial_stress` — trial Voigt stress \[xx,yy,zz,yz,xz,xy\]
803    /// * `sigma_y` — current yield stress (before increment)
804    /// * `delta_gamma` — plastic consistency parameter (0 if elastic)
805    ///
806    /// # Returns
807    /// 6×6 consistent tangent (flat row-major).
808    pub fn compute_consistent_tangent(
809        &self,
810        trial_stress: &[f64; 6],
811        delta_gamma: f64,
812    ) -> [f64; 36] {
813        let g = self.shear_modulus;
814        let h = self.hardening_modulus;
815
816        // Von Mises norm of trial deviatoric stress
817        let p = (trial_stress[0] + trial_stress[1] + trial_stress[2]) / 3.0;
818        let dev = [
819            trial_stress[0] - p,
820            trial_stress[1] - p,
821            trial_stress[2] - p,
822            trial_stress[3],
823            trial_stress[4],
824            trial_stress[5],
825        ];
826        let q_tr = {
827            let j2 = 0.5 * (dev[0].powi(2) + dev[1].powi(2) + dev[2].powi(2))
828                + dev[3].powi(2)
829                + dev[4].powi(2)
830                + dev[5].powi(2);
831            (3.0 * j2).sqrt()
832        };
833
834        // If elastic (no plastic flow), return elastic stiffness
835        if delta_gamma < f64::EPSILON || q_tr < f64::EPSILON {
836            return self.elastic_stiffness();
837        }
838
839        // Flow direction n = sqrt(3/2) * s / q_tr  (unit deviatoric direction)
840        let scale = if q_tr > 1e-30 { 1.0 / q_tr } else { 0.0 };
841        let n: [f64; 6] = [
842            dev[0] * scale,
843            dev[1] * scale,
844            dev[2] * scale,
845            dev[3] * scale,
846            dev[4] * scale,
847            dev[5] * scale,
848        ];
849
850        // Start from elastic stiffness
851        let mut c = self.elastic_stiffness();
852
853        // Correction 1: -(6G²)/(3G+H) * (n⊗n)
854        let coeff1 = 6.0 * g * g / (3.0 * g + h);
855        for i in 0..6 {
856            for j in 0..6 {
857                c[i * 6 + j] -= coeff1 * n[i] * n[j];
858            }
859        }
860
861        // Correction 2: -2G * Δγ/q_tr * (I_dev - n⊗n)
862        // I_dev in Voigt: diagonal [2/3, 2/3, 2/3, 1, 1, 1] - off-diag [-1/3]
863        let coeff2 = 2.0 * g * delta_gamma / q_tr;
864        // I_dev diagonal components
865        let i_dev_diag = [2.0 / 3.0, 2.0 / 3.0, 2.0 / 3.0, 1.0, 1.0, 1.0];
866        for i in 0..3 {
867            for j in 0..3 {
868                let i_dev_ij = if i == j { i_dev_diag[i] } else { -1.0 / 3.0 };
869                c[i * 6 + j] -= coeff2 * (i_dev_ij - n[i] * n[j]);
870            }
871        }
872        for k in 3..6 {
873            c[k * 6 + k] -= coeff2 * (i_dev_diag[k] - n[k] * n[k]);
874        }
875
876        c
877    }
878}
879
880// ─── Drucker-Prager Apex Return Mapping ───────────────────────────────────────
881
882/// Apex return mapping for the Drucker-Prager yield surface.
883///
884/// When the trial stress is at or above the apex (hydrostatic tensile side),
885/// the stress must be mapped to the apex point, which is the intersection of
886/// the yield surface with the hydrostatic axis.
887///
888/// Apex stress: p_apex = k / (3α),  q_apex = 0
889///
890/// where α and k are the DP parameters.
891#[allow(dead_code)]
892pub struct DruckerPragerApexReturn {
893    /// DP friction parameter α.
894    pub alpha: f64,
895    /// DP cohesion parameter k (Pa).
896    pub k_dp: f64,
897    /// Bulk modulus K (Pa).
898    pub bulk_modulus: f64,
899    /// Shear modulus G (Pa).
900    pub shear_modulus: f64,
901}
902
903#[allow(dead_code)]
904impl DruckerPragerApexReturn {
905    /// Create from DP parameters.
906    pub fn new(alpha: f64, k_dp: f64, bulk_modulus: f64, shear_modulus: f64) -> Self {
907        Self {
908            alpha,
909            k_dp,
910            bulk_modulus,
911            shear_modulus,
912        }
913    }
914
915    /// Create from DruckerPragerModel.
916    pub fn from_dp_model(dp: &DruckerPragerModel) -> Self {
917        Self::new(dp.alpha_dp(), dp.k_dp(), dp.bulk_modulus, dp.shear_modulus)
918    }
919
920    /// Mean apex pressure p_apex = k / (3α).
921    pub fn apex_pressure(&self) -> f64 {
922        if self.alpha.abs() < f64::EPSILON {
923            return 0.0;
924        }
925        self.k_dp / (3.0 * self.alpha)
926    }
927
928    /// Check whether the trial stress state lies above the apex.
929    ///
930    /// The trial mean stress is compared to p_apex. If p < p_apex (hydrostatic
931    /// tension above apex), the apex return is required.
932    pub fn needs_apex_return(&self, trial_stress: &[f64; 6]) -> bool {
933        let p = (trial_stress[0] + trial_stress[1] + trial_stress[2]) / 3.0;
934        p < self.apex_pressure()
935    }
936
937    /// Perform apex return: map trial stress to the apex.
938    ///
939    /// Returns the corrected stress state (hydrostatic, q = 0).
940    pub fn compute_apex_return(&self, _trial_stress: &[f64; 6]) -> [f64; 6] {
941        let p_apex = self.apex_pressure();
942        [p_apex, p_apex, p_apex, 0.0, 0.0, 0.0]
943    }
944
945    /// Compute the plastic multiplier needed for apex return.
946    ///
947    /// From consistency: f(σ_apex) = 0 → Δλ = (√J2_tr + α * I1_tr - k) / (3αK + G)
948    pub fn compute_apex_multiplier(&self, trial_stress: &[f64; 6]) -> f64 {
949        let i1 = trial_stress[0] + trial_stress[1] + trial_stress[2];
950        let p = i1 / 3.0;
951        let dev = [
952            trial_stress[0] - p,
953            trial_stress[1] - p,
954            trial_stress[2] - p,
955        ];
956        let j2 = 0.5 * (dev[0].powi(2) + dev[1].powi(2) + dev[2].powi(2))
957            + trial_stress[3].powi(2)
958            + trial_stress[4].powi(2)
959            + trial_stress[5].powi(2);
960        let sqrt_j2 = j2.sqrt();
961        let f_tr = sqrt_j2 + self.alpha * i1 - self.k_dp;
962        let denom = 3.0 * self.alpha * self.bulk_modulus + self.shear_modulus;
963        if denom.abs() < f64::EPSILON {
964            return 0.0;
965        }
966        f_tr / denom
967    }
968}
969
970// ─── Cam-Clay Preconsolidation Evolution ──────────────────────────────────────
971
972/// Cam-Clay preconsolidation pressure evolution model.
973///
974/// Models the expansion of the yield surface as the soil is loaded beyond
975/// its current consolidation state. The preconsolidation pressure p'_c
976/// hardening is governed by plastic volumetric strain.
977///
978/// Includes:
979/// - Elastic wall (swelling line)
980/// - Normal consolidation line (NCL)
981/// - Critical state line (CSL)
982#[allow(dead_code)]
983#[derive(Debug, Clone, Copy)]
984pub struct CamClayPreconsolidation {
985    /// Compression index Cc (slope of NCL in e-log₁₀p space).
986    pub cc: f64,
987    /// Swelling index Cs (slope of elastic line in e-log₁₀p space).
988    pub cs: f64,
989    /// Initial void ratio e0.
990    pub e0: f64,
991    /// Initial preconsolidation pressure p'_c0 (Pa).
992    pub pc0: f64,
993}
994
995#[allow(dead_code)]
996impl CamClayPreconsolidation {
997    /// Create a new Cam-Clay preconsolidation model.
998    pub fn new(cc: f64, cs: f64, e0: f64, pc0: f64) -> Self {
999        Self { cc, cs, e0, pc0 }
1000    }
1001
1002    /// Lightly overconsolidated clay default (Cc=0.5, Cs=0.05).
1003    pub fn soft_clay() -> Self {
1004        Self::new(0.5, 0.05, 1.2, 100.0e3)
1005    }
1006
1007    /// Lambda parameter: λ = Cc / ln(10).
1008    pub fn lambda(&self) -> f64 {
1009        self.cc / 10.0_f64.ln()
1010    }
1011
1012    /// Kappa parameter: κ = Cs / ln(10).
1013    pub fn kappa(&self) -> f64 {
1014        self.cs / 10.0_f64.ln()
1015    }
1016
1017    /// Update preconsolidation pressure after plastic volumetric strain increment.
1018    ///
1019    /// p'_c_new = p'_c * exp((1 + e0) * Δε_v_p / (λ - κ))
1020    pub fn compute_preconsolidation(&self, pc_old: f64, delta_eps_v_p: f64) -> f64 {
1021        let lambda = self.lambda();
1022        let kappa = self.kappa();
1023        if (lambda - kappa).abs() < f64::EPSILON {
1024            return pc_old;
1025        }
1026        pc_old * ((1.0 + self.e0) * delta_eps_v_p / (lambda - kappa)).exp()
1027    }
1028
1029    /// Void ratio on the normal consolidation line at pressure p.
1030    ///
1031    /// e_NCL(p) = e_N - Cc * log10(p / p_ref) where e_N = e0 at p = pc0.
1032    pub fn void_ratio_ncl(&self, p: f64) -> f64 {
1033        self.e0 - self.cc * (p / self.pc0).log10()
1034    }
1035
1036    /// Void ratio on the swelling line at pressure p, given current state (pc, e_curr).
1037    ///
1038    /// e_sw(p) = e_curr - Cs * log10(p / p0)
1039    pub fn void_ratio_swelling(&self, p: f64, p0: f64, e_curr: f64) -> f64 {
1040        e_curr - self.cs * (p / p0).log10()
1041    }
1042
1043    /// Overconsolidation ratio OCR = p'_c / p'.
1044    pub fn ocr(&self, p_prime: f64, pc: f64) -> f64 {
1045        if p_prime.abs() < f64::EPSILON {
1046            return 1.0;
1047        }
1048        pc / p_prime
1049    }
1050
1051    /// Critical state mean stress for the current preconsolidation.
1052    ///
1053    /// p'_cs = p'_c / 2 (for Modified Cam-Clay).
1054    pub fn critical_state_pressure(&self, pc: f64) -> f64 {
1055        pc / 2.0
1056    }
1057}
1058
1059// ─── Tests ────────────────────────────────────────────────────────────────────
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    // ── IsotropicHardening tests ───────────────────────────────────────────────
1066
1067    #[test]
1068    fn test_isotropic_hardening_at_zero_strain() {
1069        let h = IsotropicHardening::mild_steel();
1070        let sy = h.yield_stress(0.0);
1071        assert!((sy - 250.0e6).abs() < 1.0, "σ_y(0) = {sy}");
1072    }
1073
1074    #[test]
1075    fn test_isotropic_hardening_increases_with_strain() {
1076        let h = IsotropicHardening::mild_steel();
1077        let sy0 = h.yield_stress(0.0);
1078        let sy1 = h.yield_stress(0.01);
1079        assert!(
1080            sy1 > sy0,
1081            "yield stress should increase with plastic strain"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_isotropic_hardening_linear_rate() {
1087        let h = IsotropicHardening::new(200.0e6, 1.0e9);
1088        let eps_p = 0.005;
1089        let sy = h.yield_stress(eps_p);
1090        let expected = 200.0e6 + 1.0e9 * eps_p;
1091        assert!(
1092            (sy - expected).abs() < 1.0,
1093            "linear hardening: {sy} vs {expected}"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_isotropic_hardening_derivative_is_h() {
1099        let h = IsotropicHardening::new(200.0e6, 5.0e9);
1100        assert!((h.d_yield_stress() - 5.0e9).abs() < 1.0);
1101    }
1102
1103    #[test]
1104    fn test_elastic_perfectly_plastic_constant_yield() {
1105        let h = IsotropicHardening::elastic_perfectly_plastic(300.0e6);
1106        assert!((h.yield_stress(0.0) - h.yield_stress(1.0)).abs() < 1.0);
1107    }
1108
1109    // ── VoceHardening tests ────────────────────────────────────────────────────
1110
1111    #[test]
1112    fn test_voce_at_zero_strain() {
1113        let v = VoceHardening::copper_like();
1114        let sy = v.yield_stress(0.0);
1115        assert!((sy - 50.0e6).abs() < 1.0, "Voce σ_y(0) = {sy}");
1116    }
1117
1118    #[test]
1119    fn test_voce_saturates_at_large_strain() {
1120        let v = VoceHardening::copper_like();
1121        let sy = v.yield_stress(100.0);
1122        assert!(
1123            (sy - v.sigma_inf).abs() / v.sigma_inf < 0.001,
1124            "Voce should saturate: {sy}"
1125        );
1126    }
1127
1128    #[test]
1129    fn test_voce_derivative_positive_at_small_strain() {
1130        let v = VoceHardening::new(100.0e6, 400.0e6, 5.0);
1131        assert!(v.d_yield_stress(0.01) > 0.0, "dσ_y/dε_p should be positive");
1132    }
1133
1134    #[test]
1135    fn test_voce_derivative_decreases_with_strain() {
1136        let v = VoceHardening::new(100.0e6, 400.0e6, 5.0);
1137        let d1 = v.d_yield_stress(0.0);
1138        let d2 = v.d_yield_stress(0.1);
1139        assert!(d2 < d1, "hardening rate should decrease with strain");
1140    }
1141
1142    // ── PragerKinematic tests ──────────────────────────────────────────────────
1143
1144    #[test]
1145    fn test_prager_backstress_increment_direction() {
1146        let pk = PragerKinematic::new(250.0e6, 10.0e9);
1147        let dep = [0.001, -0.0005, -0.0005, 0.0, 0.0, 0.0];
1148        let da = pk.backstress_increment(&dep);
1149        assert!((da[0] - 10.0e9 * 0.001).abs() < 1.0, "Δα_xx");
1150    }
1151
1152    #[test]
1153    fn test_prager_von_mises_norm_uniaxial() {
1154        // Uniaxial stress: σ_xx = 300 MPa
1155        let s = [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1156        let vm = PragerKinematic::von_mises_norm(&s);
1157        assert!((vm - 300.0e6).abs() < 1.0, "VM norm for uniaxial: {vm}");
1158    }
1159
1160    #[test]
1161    fn test_prager_yield_function_below_yield() {
1162        let pk = PragerKinematic::new(300.0e6, 10.0e9);
1163        let stress = [100.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1164        let backstress = [0.0; 6];
1165        let f = pk.yield_function(&stress, &backstress);
1166        assert!(f < 0.0, "Below yield: f = {f}");
1167    }
1168
1169    #[test]
1170    fn test_prager_effective_stress_with_backstress() {
1171        let stress = [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1172        let backstress = [50.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1173        let s_eff = PragerKinematic::effective_stress(&stress, &backstress);
1174        assert!((s_eff[0] - 250.0e6).abs() < 1.0, "σ_eff_xx = {}", s_eff[0]);
1175    }
1176
1177    // ── Chaboche tests ─────────────────────────────────────────────────────────
1178
1179    #[test]
1180    fn test_chaboche_isotropic_stress_zero_at_zero_strain() {
1181        let c = Chaboche::steel_default();
1182        assert!(c.isotropic_stress(0.0).abs() < 1.0);
1183    }
1184
1185    #[test]
1186    fn test_chaboche_isotropic_stress_saturates() {
1187        let c = Chaboche::steel_default();
1188        let r_large = c.isotropic_stress(1000.0);
1189        assert!(
1190            (r_large - c.q).abs() / c.q < 0.001,
1191            "Chaboche R should saturate: {r_large}"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_chaboche_yield_stress_increases_with_p() {
1197        let c = Chaboche::steel_default();
1198        let sy0 = c.yield_stress(0.0);
1199        let sy1 = c.yield_stress(0.01);
1200        assert!(sy1 > sy0, "yield stress should increase with p");
1201    }
1202
1203    #[test]
1204    fn test_chaboche_backstress_norm_positive() {
1205        let alpha = [50.0e6, -20.0e6, -30.0e6, 5.0e6, 0.0, 0.0];
1206        let norm = Chaboche::backstress_norm(&alpha);
1207        assert!(norm > 0.0, "backstress norm should be positive: {norm}");
1208    }
1209
1210    #[test]
1211    fn test_chaboche_backstress_increment_sign() {
1212        let c = Chaboche::steel_default();
1213        let flow_dir = [1.0, -0.5, -0.5, 0.0, 0.0, 0.0];
1214        let alpha = [0.0; 6];
1215        let da = c.backstress_increment(&flow_dir, &alpha, 0.001);
1216        // With α=0, Δα = C * n̂ * Δp > 0 for positive flow direction
1217        assert!(
1218            da[0] > 0.0,
1219            "backstress increment xx should be positive: {}",
1220            da[0]
1221        );
1222    }
1223
1224    // ── J2ReturnMapping tests ──────────────────────────────────────────────────
1225
1226    #[test]
1227    fn test_j2_return_map_elastic() {
1228        let rm = J2ReturnMapping::from_young_poisson(200.0e9, 0.3, 250.0e6, 2.0e9);
1229        let trial = [100.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1230        let (stress, _dep, delta_gamma) = rm.return_map(&trial, 0.0);
1231        assert!(
1232            delta_gamma < f64::EPSILON,
1233            "Should be elastic: Δγ={delta_gamma}"
1234        );
1235        assert!(
1236            (stress[0] - 100.0e6).abs() < 1.0,
1237            "stress unchanged: {}",
1238            stress[0]
1239        );
1240    }
1241
1242    #[test]
1243    fn test_j2_return_map_plastic() {
1244        let rm = J2ReturnMapping::from_young_poisson(200.0e9, 0.3, 250.0e6, 2.0e9);
1245        let trial = [500.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1246        let (stress, _dep, delta_gamma) = rm.return_map(&trial, 0.0);
1247        assert!(delta_gamma > 0.0, "Should yield: Δγ={delta_gamma}");
1248        // After return, von Mises should equal updated yield stress
1249        let vm = J2ReturnMapping::von_mises(&stress);
1250        let sy_updated = 250.0e6 + 2.0e9 * delta_gamma;
1251        assert!(
1252            (vm - sy_updated).abs() / sy_updated < 0.001,
1253            "VM after return: {vm} vs σ_y: {sy_updated}"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_j2_von_mises_uniaxial() {
1259        let s = [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1260        let vm = J2ReturnMapping::von_mises(&s);
1261        assert!((vm - 300.0e6).abs() < 1.0, "VM uniaxial: {vm}");
1262    }
1263
1264    #[test]
1265    fn test_j2_consistent_tangent_less_than_elastic() {
1266        let rm = J2ReturnMapping::from_young_poisson(200.0e9, 0.3, 250.0e6, 2.0e9);
1267        let c_ep = rm.consistent_tangent_uniaxial();
1268        let e =
1269            9.0 * rm.bulk_modulus * rm.shear_modulus / (3.0 * rm.bulk_modulus + rm.shear_modulus);
1270        assert!(c_ep < e, "C_ep should be < E");
1271        assert!(c_ep > 0.0, "C_ep should be positive");
1272    }
1273
1274    // ── DruckerPragerModel tests ───────────────────────────────────────────────
1275
1276    #[test]
1277    fn test_drucker_prager_cohesion_only_yield_stress() {
1278        // φ = 0: purely cohesive, α_dp = 0, k_dp = 2c/√3
1279        let dp = DruckerPragerModel::from_mohr_coulomb_degrees(0.0, 1.0e6, 30.0e9, 0.3);
1280        let k = dp.k_dp();
1281        let expected = 2.0 * 1.0e6 / 3.0_f64.sqrt();
1282        assert!(
1283            (k - expected).abs() / expected < 0.01,
1284            "k_dp = {k} vs {expected}"
1285        );
1286    }
1287
1288    #[test]
1289    fn test_drucker_prager_hydrostatic_elastic() {
1290        let dp = DruckerPragerModel::from_mohr_coulomb_degrees(30.0, 1.0e6, 30.0e9, 0.3);
1291        // Large hydrostatic compression: I1 << 0, J2 = 0 → F = α*I1 - k < 0
1292        let stress = [-10.0e6, -10.0e6, -10.0e6, 0.0, 0.0, 0.0];
1293        assert!(
1294            dp.is_elastic(&stress),
1295            "Deep hydrostatic compression should be elastic"
1296        );
1297    }
1298
1299    #[test]
1300    fn test_drucker_prager_tensile_strength_positive() {
1301        let dp = DruckerPragerModel::from_mohr_coulomb_degrees(30.0, 500.0e3, 10.0e9, 0.3);
1302        let st = dp.uniaxial_tensile_strength();
1303        assert!(st > 0.0, "tensile strength should be positive: {st}");
1304    }
1305
1306    #[test]
1307    fn test_drucker_prager_compressive_strength_gt_tensile() {
1308        let dp = DruckerPragerModel::from_mohr_coulomb_degrees(30.0, 500.0e3, 10.0e9, 0.3);
1309        let st = dp.uniaxial_tensile_strength();
1310        let sc = dp.uniaxial_compressive_strength();
1311        assert!(sc > st, "compressive > tensile for φ>0: sc={sc}, st={st}");
1312    }
1313
1314    // ── ModifiedCamClay tests ──────────────────────────────────────────────────
1315
1316    #[test]
1317    fn test_mcc_bulk_modulus_increases_with_pressure() {
1318        let mcc = ModifiedCamClay::soft_clay();
1319        let k1 = mcc.bulk_modulus(50.0e3);
1320        let k2 = mcc.bulk_modulus(100.0e3);
1321        assert!(k2 > k1, "K should increase with p'");
1322    }
1323
1324    #[test]
1325    fn test_mcc_yield_function_on_yield_surface() {
1326        let mcc = ModifiedCamClay::soft_clay();
1327        let pc = mcc.pc0;
1328        // At the right apex: q=0, p'=p'_c → f = 0/M² + p'_c*(p'_c - p'_c) = 0
1329        // Stress [pc, pc, pc, 0, 0, 0] → mean p' = pc, q = 0
1330        let stress_apex = [pc, pc, pc, 0.0, 0.0, 0.0];
1331        let f = mcc.yield_function(&stress_apex, pc);
1332        assert!(
1333            f.abs() < 1.0,
1334            "MCC yield f at right apex should be ~0, got {f}"
1335        );
1336    }
1337
1338    #[test]
1339    fn test_mcc_hardening_increases_pc_under_compression() {
1340        let mcc = ModifiedCamClay::soft_clay();
1341        let pc_new = mcc.update_preconsolidation_pressure(mcc.pc0, 0.01);
1342        assert!(
1343            pc_new > mcc.pc0,
1344            "pc should increase under plastic volumetric compression"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_mcc_compression_index_positive() {
1350        let mcc = ModifiedCamClay::soft_clay();
1351        assert!(mcc.compression_index() > 0.0);
1352    }
1353
1354    #[test]
1355    fn test_mcc_critical_state_pressure_half_pc() {
1356        let mcc = ModifiedCamClay::soft_clay();
1357        let p_cs = mcc.critical_state_pressure(mcc.pc0);
1358        assert!((p_cs - mcc.pc0 / 2.0).abs() < 1e-6);
1359    }
1360
1361    // ── Plastic dissipation tests ──────────────────────────────────────────────
1362
1363    #[test]
1364    fn test_plastic_dissipation_positive() {
1365        let stress = [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1366        let dep = [0.001, -0.0005, -0.0005, 0.0, 0.0, 0.0];
1367        let d = plastic_dissipation(&stress, &dep);
1368        assert!(d > 0.0, "plastic dissipation should be positive: {d}");
1369    }
1370
1371    #[test]
1372    fn test_plastic_dissipation_zero_for_zero_strain() {
1373        let stress = [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1374        let dep = [0.0; 6];
1375        let d = plastic_dissipation(&stress, &dep);
1376        assert!(d.abs() < 1e-10, "zero strain → zero dissipation: {d}");
1377    }
1378
1379    #[test]
1380    fn test_cumulative_dissipation() {
1381        let stresses = vec![
1382            [300.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64],
1383            [310.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64],
1384        ];
1385        let deps = vec![
1386            [0.001, -0.0005, -0.0005, 0.0, 0.0, 0.0_f64],
1387            [0.001, -0.0005, -0.0005, 0.0, 0.0, 0.0_f64],
1388        ];
1389        let d_total = cumulative_dissipation(&stresses, &deps);
1390        assert!(d_total > 0.0, "cumulative dissipation should be positive");
1391    }
1392
1393    // ── Limit load tests ───────────────────────────────────────────────────────
1394
1395    #[test]
1396    fn test_limit_load_factor_below_yield() {
1397        // Single element with σ_xx = 100 MPa, yield = 250 MPa → factor = 2.5
1398        let stresses = vec![[100.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64]];
1399        let f = limit_load_factor_lower_bound(&stresses, 250.0e6);
1400        assert!((f - 2.5).abs() < 0.01, "limit load factor: {f}");
1401    }
1402
1403    #[test]
1404    fn test_limit_load_factor_multiple_elements() {
1405        let stresses = vec![
1406            [100.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64],
1407            [200.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64], // governing
1408        ];
1409        let f = limit_load_factor_lower_bound(&stresses, 250.0e6);
1410        assert!((f - 1.25).abs() < 0.01, "limit load factor: {f}");
1411    }
1412
1413    #[test]
1414    fn test_limit_load_empty_returns_zero() {
1415        let f = limit_load_factor_lower_bound(&[], 250.0e6);
1416        assert_eq!(f, 0.0);
1417    }
1418
1419    #[test]
1420    fn test_von_mises_voigt_uniaxial() {
1421        let s = [200.0e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1422        let vm = von_mises_stress_voigt(&s);
1423        assert!((vm - 200.0e6).abs() < 1.0, "VM uniaxial: {vm}");
1424    }
1425
1426    #[test]
1427    fn test_von_mises_voigt_hydrostatic_zero() {
1428        // Hydrostatic stress → J2 = 0 → VM = 0
1429        let s = [100.0e6, 100.0e6, 100.0e6, 0.0, 0.0, 0.0];
1430        let vm = von_mises_stress_voigt(&s);
1431        assert!(vm.abs() < 1.0, "VM hydrostatic should be ~0: {vm}");
1432    }
1433
1434    // ── RambergOsgood tests ────────────────────────────────────────────────────
1435
1436    #[test]
1437    fn test_ramberg_osgood_elastic_at_small_stress() {
1438        let ro = RambergOsgood::new(200.0e9, 400.0e6, 5.0, 3.0 / 7.0);
1439        let sigma = 1.0e6; // very small
1440        let eps = ro.total_strain(sigma);
1441        let eps_elastic = sigma / ro.young_modulus;
1442        assert!(
1443            (eps - eps_elastic).abs() / eps_elastic < 0.01,
1444            "small stress should be nearly elastic"
1445        );
1446    }
1447
1448    #[test]
1449    fn test_ramberg_osgood_plastic_strain_zero_at_zero_stress() {
1450        let ro = RambergOsgood::new(200.0e9, 400.0e6, 5.0, 3.0 / 7.0);
1451        assert!(ro.plastic_strain(0.0).abs() < f64::EPSILON);
1452    }
1453
1454    #[test]
1455    fn test_ramberg_osgood_tangent_modulus_less_than_elastic() {
1456        let ro = RambergOsgood::new(200.0e9, 400.0e6, 5.0, 3.0 / 7.0);
1457        let et = ro.tangent_modulus(ro.sigma_ref);
1458        assert!(
1459            et < ro.young_modulus,
1460            "tangent should be < E at reference stress"
1461        );
1462        assert!(et > 0.0, "tangent should be positive");
1463    }
1464
1465    #[test]
1466    fn test_ramberg_osgood_secant_less_than_elastic() {
1467        let ro = RambergOsgood::new(200.0e9, 400.0e6, 5.0, 3.0 / 7.0);
1468        let es = ro.secant_modulus(ro.sigma_ref);
1469        assert!(
1470            es < ro.young_modulus,
1471            "secant modulus at ref stress should be < E"
1472        );
1473    }
1474
1475    // ── J2ConsistentTangent tests ──────────────────────────────────────────────
1476
1477    /// Elastic step: consistent tangent equals elastic stiffness.
1478    #[test]
1479    fn test_j2_consistent_tangent_elastic_step() {
1480        let ct = J2ConsistentTangent::from_young_poisson(200.0e9, 0.3, 2.0e9);
1481        let trial = [100.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64];
1482        let c = ct.compute_consistent_tangent(&trial, 0.0);
1483        let ce = ct.elastic_stiffness();
1484        for i in 0..36 {
1485            assert!(
1486                (c[i] - ce[i]).abs() < 1.0,
1487                "Elastic tangent mismatch at {i}: {} vs {}",
1488                c[i],
1489                ce[i]
1490            );
1491        }
1492    }
1493
1494    /// Elastic stiffness: C11 = K + 4G/3 for isotropic.
1495    #[test]
1496    fn test_j2_consistent_tangent_c11() {
1497        let e = 200.0e9_f64;
1498        let nu = 0.3_f64;
1499        let ct = J2ConsistentTangent::from_young_poisson(e, nu, 2.0e9);
1500        let c = ct.elastic_stiffness();
1501        let k = e / (3.0 * (1.0 - 2.0 * nu));
1502        let g = e / (2.0 * (1.0 + nu));
1503        let c11 = k + 4.0 / 3.0 * g;
1504        assert!((c[0] - c11).abs() / c11 < 1e-8, "C11: {} vs {}", c[0], c11);
1505    }
1506
1507    /// Elastic stiffness: C12 = K - 2G/3 for isotropic.
1508    #[test]
1509    fn test_j2_consistent_tangent_c12() {
1510        let e = 200.0e9_f64;
1511        let nu = 0.3_f64;
1512        let ct = J2ConsistentTangent::from_young_poisson(e, nu, 2.0e9);
1513        let c = ct.elastic_stiffness();
1514        let k = e / (3.0 * (1.0 - 2.0 * nu));
1515        let g = e / (2.0 * (1.0 + nu));
1516        let c12 = k - 2.0 / 3.0 * g;
1517        assert!(
1518            (c[1] - c12).abs() / c12.abs() < 1e-8,
1519            "C12: {} vs {}",
1520            c[1],
1521            c12
1522        );
1523    }
1524
1525    /// Plastic step: consistent tangent differs from elastic stiffness.
1526    #[test]
1527    fn test_j2_consistent_tangent_plastic_step_differs() {
1528        let ct = J2ConsistentTangent::from_young_poisson(200.0e9, 0.3, 2.0e9);
1529        // Uniaxial trial stress well beyond yield (so delta_gamma > 0)
1530        let trial = [500.0e6, 0.0, 0.0, 0.0, 0.0, 0.0_f64];
1531        let delta_gamma = 1.0e-4;
1532        let c_ep = ct.compute_consistent_tangent(&trial, delta_gamma);
1533        let c_e = ct.elastic_stiffness();
1534        let diff: f64 = c_ep
1535            .iter()
1536            .zip(c_e.iter())
1537            .map(|(a, b)| (a - b).abs())
1538            .sum();
1539        assert!(
1540            diff > 1.0,
1541            "Plastic tangent should differ from elastic: total diff = {diff}"
1542        );
1543    }
1544
1545    // ── DruckerPragerApexReturn tests ─────────────────────────────────────────
1546
1547    /// Apex pressure: p_apex = k / (3α).
1548    #[test]
1549    fn test_dp_apex_pressure() {
1550        let phi = 30.0_f64.to_radians();
1551        let cohesion = 50.0e3_f64;
1552        let e = 50.0e6_f64;
1553        let nu = 0.3_f64;
1554        let dp = DruckerPragerModel::new(
1555            phi,
1556            cohesion,
1557            e / (2.0 * (1.0 + nu)),
1558            e / (3.0 * (1.0 - 2.0 * nu)),
1559        );
1560        let apr = DruckerPragerApexReturn::from_dp_model(&dp);
1561        let p_apex = apr.apex_pressure();
1562        let expected = dp.k_dp() / (3.0 * dp.alpha_dp());
1563        assert!(
1564            (p_apex - expected).abs() / expected < 1e-10,
1565            "Apex pressure: {} vs {}",
1566            p_apex,
1567            expected
1568        );
1569    }
1570
1571    /// Apex return stress has zero deviatoric part.
1572    #[test]
1573    fn test_dp_apex_return_zero_deviatoric() {
1574        let apr = DruckerPragerApexReturn::new(0.2, 100.0e3, 30.0e6, 15.0e6);
1575        let trial = [-200.0e3, -100.0e3, -50.0e3, 10.0e3, 0.0, 0.0];
1576        let s_apex = apr.compute_apex_return(&trial);
1577        // All three normal components should equal p_apex
1578        assert!(
1579            (s_apex[0] - s_apex[1]).abs() < f64::EPSILON,
1580            "Apex return should be hydrostatic"
1581        );
1582        assert!(
1583            (s_apex[1] - s_apex[2]).abs() < f64::EPSILON,
1584            "Apex return should be hydrostatic"
1585        );
1586        // All shear components zero
1587        assert_eq!(s_apex[3], 0.0);
1588    }
1589
1590    // ── CamClayPreconsolidation tests ─────────────────────────────────────────
1591
1592    /// Preconsolidation increases with compressive volumetric plastic strain.
1593    #[test]
1594    fn test_cam_clay_preconsolidation_increases_under_compression() {
1595        let cc = CamClayPreconsolidation::soft_clay();
1596        let pc_new = cc.compute_preconsolidation(cc.pc0, 0.01);
1597        assert!(
1598            pc_new > cc.pc0,
1599            "Preconsolidation should increase under compression"
1600        );
1601    }
1602
1603    /// Zero plastic strain → unchanged preconsolidation.
1604    #[test]
1605    fn test_cam_clay_preconsolidation_zero_strain_unchanged() {
1606        let cc = CamClayPreconsolidation::soft_clay();
1607        let pc_new = cc.compute_preconsolidation(cc.pc0, 0.0);
1608        assert!(
1609            (pc_new - cc.pc0).abs() / cc.pc0 < 1e-12,
1610            "Zero plastic strain: pc should not change: {} vs {}",
1611            pc_new,
1612            cc.pc0
1613        );
1614    }
1615
1616    /// OCR = 1 when p' equals pc (normally consolidated).
1617    #[test]
1618    fn test_cam_clay_ocr_normally_consolidated() {
1619        let cc = CamClayPreconsolidation::soft_clay();
1620        let ocr = cc.ocr(cc.pc0, cc.pc0);
1621        assert!(
1622            (ocr - 1.0).abs() < f64::EPSILON,
1623            "OCR should be 1 at p' = pc: {ocr}"
1624        );
1625    }
1626
1627    /// Critical state pressure = pc / 2.
1628    #[test]
1629    fn test_cam_clay_critical_state_pressure() {
1630        let cc = CamClayPreconsolidation::soft_clay();
1631        let pcs = cc.critical_state_pressure(cc.pc0);
1632        assert!(
1633            (pcs - cc.pc0 / 2.0).abs() < f64::EPSILON,
1634            "p'_cs should be pc/2: {pcs}"
1635        );
1636    }
1637}