Skip to main content

oxiphysics_python/
materials_api.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Materials API for Python interop.
5//!
6//! Provides a comprehensive set of material models for structural, thermal,
7//! acoustic, and fatigue analysis. All types use plain `f64` and `Vec`f64`
8//! — no nalgebra — for easy FFI transmission.
9
10#![allow(missing_docs)]
11#![allow(dead_code)]
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// PyElasticMaterial
17// ---------------------------------------------------------------------------
18
19/// Linear elastic isotropic material.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PyElasticMaterial {
22    /// Young's modulus E (Pa).
23    pub youngs_modulus: f64,
24    /// Poisson's ratio ν (dimensionless, 0..0.5).
25    pub poisson_ratio: f64,
26    /// Mass density ρ (kg/m³).
27    pub density: f64,
28}
29
30impl PyElasticMaterial {
31    /// Create a new linear elastic material.
32    pub fn new(youngs_modulus: f64, poisson_ratio: f64, density: f64) -> Self {
33        Self {
34            youngs_modulus,
35            poisson_ratio,
36            density,
37        }
38    }
39
40    /// Compute the Cauchy stress vector `\[σxx, σyy, τxy\]` from the
41    /// engineering strain vector `\[εxx, εyy, γxy\]` using plane-stress
42    /// constitutive relations.
43    pub fn stress_from_strain(&self, strain: [f64; 3]) -> [f64; 3] {
44        let e = self.youngs_modulus;
45        let nu = self.poisson_ratio;
46        let c = e / (1.0 - nu * nu);
47        [
48            c * (strain[0] + nu * strain[1]),
49            c * (nu * strain[0] + strain[1]),
50            c * (1.0 - nu) * 0.5 * strain[2],
51        ]
52    }
53
54    /// Compute the elastic strain energy density (J/m³) given a strain vector.
55    pub fn strain_energy_density(&self, strain: [f64; 3]) -> f64 {
56        let stress = self.stress_from_strain(strain);
57        0.5 * (stress[0] * strain[0] + stress[1] * strain[1] + stress[2] * strain[2])
58    }
59
60    /// Shear modulus G derived from E and ν.
61    pub fn shear_modulus(&self) -> f64 {
62        self.youngs_modulus / (2.0 * (1.0 + self.poisson_ratio))
63    }
64
65    /// Bulk modulus K derived from E and ν.
66    pub fn bulk_modulus(&self) -> f64 {
67        self.youngs_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio))
68    }
69}
70
71impl Default for PyElasticMaterial {
72    fn default() -> Self {
73        // Steel-like defaults
74        Self::new(200e9, 0.3, 7850.0)
75    }
76}
77
78// ---------------------------------------------------------------------------
79// PyHyperelasticMaterial
80// ---------------------------------------------------------------------------
81
82/// Hyperelastic material supporting NeoHookean and Mooney–Rivlin models.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PyHyperelasticMaterial {
85    /// First Mooney–Rivlin constant C₁ (also the NeoHookean shear parameter).
86    pub c1: f64,
87    /// Second Mooney–Rivlin constant C₂.
88    pub c2: f64,
89    /// Bulk modulus κ (Pa).
90    pub bulk_modulus: f64,
91}
92
93impl PyHyperelasticMaterial {
94    /// Create a new hyperelastic material.
95    pub fn new(c1: f64, c2: f64, bulk_modulus: f64) -> Self {
96        Self {
97            c1,
98            c2,
99            bulk_modulus,
100        }
101    }
102
103    /// NeoHookean strain energy density W = C₁(Ī₁ − 3) + κ/2 (J − 1)².
104    ///
105    /// `i1_bar` is the first invariant of the isochoric right Cauchy–Green
106    /// tensor; `j` is the volumetric Jacobian.
107    pub fn strain_energy_neo_hookean(&self, i1_bar: f64, j: f64) -> f64 {
108        self.c1 * (i1_bar - 3.0) + 0.5 * self.bulk_modulus * (j - 1.0).powi(2)
109    }
110
111    /// Mooney–Rivlin strain energy density W = C₁(Ī₁ − 3) + C₂(Ī₂ − 3) + κ/2 (J − 1)².
112    ///
113    /// `i1_bar` and `i2_bar` are the first and second invariants of the
114    /// isochoric right Cauchy–Green tensor; `j` is the volumetric Jacobian.
115    pub fn strain_energy_mooney_rivlin(&self, i1_bar: f64, i2_bar: f64, j: f64) -> f64 {
116        self.c1 * (i1_bar - 3.0)
117            + self.c2 * (i2_bar - 3.0)
118            + 0.5 * self.bulk_modulus * (j - 1.0).powi(2)
119    }
120
121    /// Initial (small-strain) shear modulus μ = 2(C₁ + C₂).
122    pub fn initial_shear_modulus(&self) -> f64 {
123        2.0 * (self.c1 + self.c2)
124    }
125}
126
127impl Default for PyHyperelasticMaterial {
128    fn default() -> Self {
129        // Soft rubber-like defaults
130        Self::new(0.4e6, 0.1e6, 2.0e9)
131    }
132}
133
134// ---------------------------------------------------------------------------
135// PyPlasticMaterial
136// ---------------------------------------------------------------------------
137
138/// Elastoplastic material with linear isotropic hardening (von Mises).
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PyPlasticMaterial {
141    /// Initial yield stress σ_y (Pa).
142    pub yield_stress: f64,
143    /// Isotropic hardening modulus H (Pa).
144    pub hardening_modulus: f64,
145    /// Young's modulus E (Pa) — needed for elastic predictor.
146    pub youngs_modulus: f64,
147}
148
149impl PyPlasticMaterial {
150    /// Create a new plastic material.
151    pub fn new(yield_stress: f64, hardening_modulus: f64, youngs_modulus: f64) -> Self {
152        Self {
153            yield_stress,
154            hardening_modulus,
155            youngs_modulus,
156        }
157    }
158
159    /// Von Mises yield function f(σ_eq, α) = σ_eq − (σ_y + H α).
160    ///
161    /// Returns a positive value when yielding; zero or negative when elastic.
162    pub fn yield_function(&self, equivalent_stress: f64, accumulated_plastic_strain: f64) -> f64 {
163        let hardened_yield =
164            self.yield_stress + self.hardening_modulus * accumulated_plastic_strain;
165        equivalent_stress - hardened_yield
166    }
167
168    /// Current yield stress given the accumulated plastic strain.
169    pub fn current_yield_stress(&self, accumulated_plastic_strain: f64) -> f64 {
170        self.yield_stress + self.hardening_modulus * accumulated_plastic_strain
171    }
172}
173
174impl Default for PyPlasticMaterial {
175    fn default() -> Self {
176        Self::new(250e6, 10e9, 200e9)
177    }
178}
179
180// ---------------------------------------------------------------------------
181// PyFatigueMaterial
182// ---------------------------------------------------------------------------
183
184/// Fatigue material described by the Basquin (S–N) power law.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct PyFatigueMaterial {
187    /// Basquin coefficient A (stress intercept at N = 1).
188    pub basquin_coefficient: f64,
189    /// Basquin exponent b (negative slope of the S–N curve).
190    pub basquin_exponent: f64,
191    /// Endurance limit σ_e (Pa).  Below this stress fatigue life is infinite.
192    pub endurance_limit: f64,
193}
194
195impl PyFatigueMaterial {
196    /// Create a new fatigue material.
197    pub fn new(basquin_coefficient: f64, basquin_exponent: f64, endurance_limit: f64) -> Self {
198        Self {
199            basquin_coefficient,
200            basquin_exponent,
201            endurance_limit,
202        }
203    }
204
205    /// Estimate cycles to failure N_f for a given stress amplitude σ_a (Pa).
206    ///
207    /// Returns `f64::INFINITY` when σ_a ≤ endurance_limit.
208    /// Uses the Basquin relation: N_f = (A / σ_a)^(1/b).
209    pub fn cycles_to_failure(&self, stress_amplitude: f64) -> f64 {
210        if stress_amplitude <= self.endurance_limit {
211            return f64::INFINITY;
212        }
213        // N_f = (A / σ_a)^(1/b)
214        (self.basquin_coefficient / stress_amplitude).powf(1.0 / self.basquin_exponent)
215    }
216
217    /// Miner's rule damage increment for one block of `n_cycles` at `stress_amplitude`.
218    pub fn miner_damage(&self, n_cycles: f64, stress_amplitude: f64) -> f64 {
219        let nf = self.cycles_to_failure(stress_amplitude);
220        if nf.is_infinite() { 0.0 } else { n_cycles / nf }
221    }
222}
223
224impl Default for PyFatigueMaterial {
225    fn default() -> Self {
226        // Representative steel values
227        Self::new(900e6, 0.1, 200e6)
228    }
229}
230
231// ---------------------------------------------------------------------------
232// PyThermalMaterial
233// ---------------------------------------------------------------------------
234
235/// Isotropic thermal material.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct PyThermalMaterial {
238    /// Thermal conductivity λ (W/m·K).
239    pub conductivity: f64,
240    /// Specific heat capacity c_p (J/kg·K).
241    pub specific_heat: f64,
242    /// Mass density ρ (kg/m³).
243    pub density: f64,
244}
245
246impl PyThermalMaterial {
247    /// Create a new thermal material.
248    pub fn new(conductivity: f64, specific_heat: f64, density: f64) -> Self {
249        Self {
250            conductivity,
251            specific_heat,
252            density,
253        }
254    }
255
256    /// Thermal diffusivity α = λ / (ρ c_p)  (m²/s).
257    pub fn thermal_diffusivity(&self) -> f64 {
258        self.conductivity / (self.density * self.specific_heat)
259    }
260
261    /// Heat flux magnitude q = −λ ‖∇T‖  (W/m²) for a given temperature gradient magnitude.
262    pub fn heat_flux(&self, grad_t_magnitude: f64) -> f64 {
263        self.conductivity * grad_t_magnitude
264    }
265}
266
267impl Default for PyThermalMaterial {
268    fn default() -> Self {
269        // Steel-like thermal properties
270        Self::new(50.0, 480.0, 7850.0)
271    }
272}
273
274// ---------------------------------------------------------------------------
275// PyViscoelasticMaterial
276// ---------------------------------------------------------------------------
277
278/// Viscoelastic material described by a generalised Maxwell (Prony series) model.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct PyViscoelasticMaterial {
281    /// Long-term (equilibrium) modulus E∞ (Pa).
282    pub equilibrium_modulus: f64,
283    /// Prony series amplitudes (modulus contributions E_i).
284    pub prony_moduli: Vec<f64>,
285    /// Prony series relaxation times τ_i (s).
286    pub prony_times: Vec<f64>,
287}
288
289impl PyViscoelasticMaterial {
290    /// Create a new viscoelastic material.
291    ///
292    /// `prony_moduli` and `prony_times` must have equal length.
293    pub fn new(equilibrium_modulus: f64, prony_moduli: Vec<f64>, prony_times: Vec<f64>) -> Self {
294        Self {
295            equilibrium_modulus,
296            prony_moduli,
297            prony_times,
298        }
299    }
300
301    /// Relaxation modulus E(t) = E∞ + Σ E_i exp(−t/τ_i).
302    pub fn relaxation_modulus(&self, t: f64) -> f64 {
303        let sum: f64 = self
304            .prony_moduli
305            .iter()
306            .zip(self.prony_times.iter())
307            .map(|(&ei, &ti)| ei * (-t / ti).exp())
308            .sum();
309        self.equilibrium_modulus + sum
310    }
311
312    /// Instantaneous (glassy) modulus E(0) = E∞ + Σ E_i.
313    pub fn glassy_modulus(&self) -> f64 {
314        self.equilibrium_modulus + self.prony_moduli.iter().sum::<f64>()
315    }
316}
317
318impl Default for PyViscoelasticMaterial {
319    fn default() -> Self {
320        Self::new(1e6, vec![2e6, 1e6], vec![0.1, 1.0])
321    }
322}
323
324// ---------------------------------------------------------------------------
325// PyDamageMaterial
326// ---------------------------------------------------------------------------
327
328/// Continuum damage mechanics material (isotropic scalar damage).
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct PyDamageMaterial {
331    /// Critical strain energy release rate G_c (J/m³).
332    pub critical_energy_release: f64,
333    /// Softening slope (negative, in Pa per unit damage).
334    pub softening_slope: f64,
335    /// Undamaged Young's modulus E₀ (Pa).
336    pub undamaged_modulus: f64,
337}
338
339impl PyDamageMaterial {
340    /// Create a new damage material.
341    pub fn new(critical_energy_release: f64, softening_slope: f64, undamaged_modulus: f64) -> Self {
342        Self {
343            critical_energy_release,
344            softening_slope,
345            undamaged_modulus,
346        }
347    }
348
349    /// Scalar damage variable d ∈ [0, 1] for a given strain energy density.
350    ///
351    /// d = 0 → undamaged; d = 1 → fully failed.
352    pub fn damage_variable(&self, strain_energy: f64) -> f64 {
353        if strain_energy <= 0.0 {
354            return 0.0;
355        }
356        let d = (strain_energy - self.critical_energy_release)
357            / (self.softening_slope.abs() + strain_energy);
358        d.clamp(0.0, 1.0)
359    }
360
361    /// Effective (damaged) stiffness E_eff = (1 − d) E₀.
362    pub fn effective_modulus(&self, strain_energy: f64) -> f64 {
363        let d = self.damage_variable(strain_energy);
364        (1.0 - d) * self.undamaged_modulus
365    }
366}
367
368impl Default for PyDamageMaterial {
369    fn default() -> Self {
370        Self::new(1000.0, 1e8, 200e9)
371    }
372}
373
374// ---------------------------------------------------------------------------
375// PyCreepMaterial
376// ---------------------------------------------------------------------------
377
378/// Creep material described by Norton's power-law creep model.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PyCreepMaterial {
381    /// Norton creep coefficient A (1/s · Pa^{-n}).
382    pub norton_coefficient: f64,
383    /// Norton creep exponent n (dimensionless).
384    pub norton_exponent: f64,
385    /// Activation energy Q divided by universal gas constant R (K).
386    pub activation_temperature: f64,
387}
388
389impl PyCreepMaterial {
390    /// Create a new creep material.
391    pub fn new(norton_coefficient: f64, norton_exponent: f64, activation_temperature: f64) -> Self {
392        Self {
393            norton_coefficient,
394            norton_exponent,
395            activation_temperature,
396        }
397    }
398
399    /// Secondary creep rate dε/dt = A σ^n exp(−Q/RT).
400    ///
401    /// `stress` in Pa, `temp` in K.
402    pub fn creep_rate(&self, stress: f64, temp: f64) -> f64 {
403        let arrhenius = (-self.activation_temperature / temp).exp();
404        self.norton_coefficient * stress.powf(self.norton_exponent) * arrhenius
405    }
406
407    /// Minimum creep rate (at reference temperature 293 K).
408    pub fn reference_creep_rate(&self, stress: f64) -> f64 {
409        self.creep_rate(stress, 293.0)
410    }
411}
412
413impl Default for PyCreepMaterial {
414    fn default() -> Self {
415        // Representative high-temperature steel
416        Self::new(1e-26, 5.0, 15_000.0)
417    }
418}
419
420// ---------------------------------------------------------------------------
421// PyAcousticMaterial
422// ---------------------------------------------------------------------------
423
424/// Acoustic (fluid) material.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct PyAcousticMaterial {
427    /// Bulk modulus κ (Pa).
428    pub bulk_modulus: f64,
429    /// Mass density ρ (kg/m³).
430    pub density: f64,
431}
432
433impl PyAcousticMaterial {
434    /// Create a new acoustic material.
435    pub fn new(bulk_modulus: f64, density: f64) -> Self {
436        Self {
437            bulk_modulus,
438            density,
439        }
440    }
441
442    /// Speed of sound c = sqrt(κ / ρ)  (m/s).
443    pub fn speed_of_sound(&self) -> f64 {
444        (self.bulk_modulus / self.density).sqrt()
445    }
446
447    /// Acoustic impedance Z = ρ c  (Pa·s/m = rayl).
448    pub fn impedance(&self) -> f64 {
449        self.density * self.speed_of_sound()
450    }
451
452    /// Reflection coefficient at an interface with another acoustic material.
453    pub fn reflection_coefficient(&self, other: &PyAcousticMaterial) -> f64 {
454        let z1 = self.impedance();
455        let z2 = other.impedance();
456        (z2 - z1) / (z2 + z1)
457    }
458}
459
460impl Default for PyAcousticMaterial {
461    fn default() -> Self {
462        // Water at 20 °C
463        Self::new(2.2e9, 998.0)
464    }
465}
466
467// ---------------------------------------------------------------------------
468// PyCompositeMaterial
469// ---------------------------------------------------------------------------
470
471/// Composite material with Voigt, Reuss, and Hill mixture rules.
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct PyCompositeMaterial {
474    /// Modulus of phase 1 (matrix) E₁ (Pa).
475    pub modulus1: f64,
476    /// Modulus of phase 2 (fiber/particle) E₂ (Pa).
477    pub modulus2: f64,
478    /// Volume fraction of phase 1 (0..1).
479    pub volume_fraction1: f64,
480}
481
482impl PyCompositeMaterial {
483    /// Create a new composite material.
484    pub fn new(modulus1: f64, modulus2: f64, volume_fraction1: f64) -> Self {
485        Self {
486            modulus1,
487            modulus2,
488            volume_fraction1,
489        }
490    }
491
492    /// Voigt (isostrain) upper-bound modulus.
493    pub fn voigt_modulus(&self, vf1: f64, e1: f64, e2: f64) -> f64 {
494        vf1 * e1 + (1.0 - vf1) * e2
495    }
496
497    /// Reuss (isostress) lower-bound modulus.
498    pub fn reuss_modulus(&self, vf1: f64, e1: f64, e2: f64) -> f64 {
499        let vf2 = 1.0 - vf1;
500        if vf1 / e1 + vf2 / e2 == 0.0 {
501            0.0
502        } else {
503            1.0 / (vf1 / e1 + vf2 / e2)
504        }
505    }
506
507    /// Hill (arithmetic mean of Voigt and Reuss) modulus.
508    pub fn hill_modulus(&self) -> f64 {
509        let v = self.voigt_modulus(self.volume_fraction1, self.modulus1, self.modulus2);
510        let r = self.reuss_modulus(self.volume_fraction1, self.modulus1, self.modulus2);
511        0.5 * (v + r)
512    }
513
514    /// Hashin–Shtrikman lower bound modulus (spherical inclusions).
515    pub fn hashin_shtrikman_lower(&self) -> f64 {
516        let vf2 = 1.0 - self.volume_fraction1;
517        let e1 = self.modulus1.min(self.modulus2);
518        let e2 = self.modulus1.max(self.modulus2);
519        e1 + vf2 / (1.0 / (e2 - e1) + self.volume_fraction1 / (3.0 * e1))
520    }
521}
522
523impl Default for PyCompositeMaterial {
524    fn default() -> Self {
525        Self::new(70e9, 200e9, 0.6)
526    }
527}
528
529// ---------------------------------------------------------------------------
530// Registration helper
531// ---------------------------------------------------------------------------
532
533/// Register all material classes into a Python sub-module named `"materials"`.
534///
535/// This is a no-op placeholder that documents the intended PyO3 registration
536/// point. When PyO3 is enabled as a dependency the body should call
537/// `m.add_class::`PyElasticMaterial`()` etc.
538pub fn register_materials_module(_m: &str) {
539    // Placeholder: actual PyO3 registration would happen here.
540}
541
542// ---------------------------------------------------------------------------
543// Tests
544// ---------------------------------------------------------------------------
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    // --- PyElasticMaterial ---
551
552    #[test]
553    fn test_elastic_new() {
554        let mat = PyElasticMaterial::new(200e9, 0.3, 7850.0);
555        assert_eq!(mat.youngs_modulus, 200e9);
556        assert_eq!(mat.poisson_ratio, 0.3);
557        assert_eq!(mat.density, 7850.0);
558    }
559
560    #[test]
561    fn test_elastic_default() {
562        let mat = PyElasticMaterial::default();
563        assert!(mat.youngs_modulus > 0.0);
564    }
565
566    #[test]
567    fn test_elastic_shear_modulus() {
568        let mat = PyElasticMaterial::new(200e9, 0.3, 7850.0);
569        let g = mat.shear_modulus();
570        // G = E / (2(1+nu)) = 200e9 / 2.6 ≈ 76.9 GPa
571        assert!((g - 76.923e9).abs() < 1e8);
572    }
573
574    #[test]
575    fn test_elastic_bulk_modulus() {
576        let mat = PyElasticMaterial::new(200e9, 0.3, 7850.0);
577        let k = mat.bulk_modulus();
578        // K = E / (3(1-2nu)) = 200e9 / 1.2 ≈ 166.7 GPa
579        assert!((k - 166.667e9).abs() < 1e9);
580    }
581
582    #[test]
583    fn test_elastic_stress_from_strain_uniaxial() {
584        let mat = PyElasticMaterial::new(200e9, 0.3, 7850.0);
585        let strain = [1e-3, 0.0, 0.0];
586        let stress = mat.stress_from_strain(strain);
587        // σxx = E/(1-nu²) * εxx ≈ 219.78 MPa
588        assert!(stress[0] > 0.0);
589        assert!(stress[2].abs() < 1.0);
590    }
591
592    #[test]
593    fn test_elastic_strain_energy_positive() {
594        let mat = PyElasticMaterial::new(200e9, 0.3, 7850.0);
595        let e = mat.strain_energy_density([1e-3, 0.0, 0.0]);
596        assert!(e > 0.0);
597    }
598
599    #[test]
600    fn test_elastic_zero_strain_zero_stress() {
601        let mat = PyElasticMaterial::default();
602        let s = mat.stress_from_strain([0.0, 0.0, 0.0]);
603        assert_eq!(s, [0.0, 0.0, 0.0]);
604    }
605
606    // --- PyHyperelasticMaterial ---
607
608    #[test]
609    fn test_hyperelastic_new() {
610        let mat = PyHyperelasticMaterial::new(0.4e6, 0.1e6, 2.0e9);
611        assert_eq!(mat.c1, 0.4e6);
612        assert_eq!(mat.c2, 0.1e6);
613        assert_eq!(mat.bulk_modulus, 2.0e9);
614    }
615
616    #[test]
617    fn test_hyperelastic_neo_hookean_zero() {
618        let mat = PyHyperelasticMaterial::default();
619        // At identity deformation: Ī₁ = 3, J = 1 → W = 0
620        let w = mat.strain_energy_neo_hookean(3.0, 1.0);
621        assert!(w.abs() < 1e-10);
622    }
623
624    #[test]
625    fn test_hyperelastic_mooney_rivlin_zero() {
626        let mat = PyHyperelasticMaterial::default();
627        let w = mat.strain_energy_mooney_rivlin(3.0, 3.0, 1.0);
628        assert!(w.abs() < 1e-10);
629    }
630
631    #[test]
632    fn test_hyperelastic_neo_hookean_positive_stretch() {
633        let mat = PyHyperelasticMaterial::new(0.4e6, 0.0, 2.0e9);
634        let w = mat.strain_energy_neo_hookean(4.0, 1.0);
635        assert!(w > 0.0);
636    }
637
638    #[test]
639    fn test_hyperelastic_initial_shear_modulus() {
640        let mat = PyHyperelasticMaterial::new(0.4e6, 0.1e6, 2.0e9);
641        let mu = mat.initial_shear_modulus();
642        assert!((mu - 1.0e6).abs() < 1.0);
643    }
644
645    #[test]
646    fn test_hyperelastic_default() {
647        let mat = PyHyperelasticMaterial::default();
648        assert!(mat.c1 > 0.0);
649        assert!(mat.bulk_modulus > 0.0);
650    }
651
652    // --- PyPlasticMaterial ---
653
654    #[test]
655    fn test_plastic_new() {
656        let mat = PyPlasticMaterial::new(250e6, 10e9, 200e9);
657        assert_eq!(mat.yield_stress, 250e6);
658    }
659
660    #[test]
661    fn test_plastic_yield_function_elastic() {
662        let mat = PyPlasticMaterial::new(250e6, 10e9, 200e9);
663        let f = mat.yield_function(200e6, 0.0);
664        assert!(f < 0.0);
665    }
666
667    #[test]
668    fn test_plastic_yield_function_yielding() {
669        let mat = PyPlasticMaterial::new(250e6, 10e9, 200e9);
670        let f = mat.yield_function(300e6, 0.0);
671        assert!(f > 0.0);
672    }
673
674    #[test]
675    fn test_plastic_hardening_increases_yield() {
676        let mat = PyPlasticMaterial::new(250e6, 10e9, 200e9);
677        let y0 = mat.current_yield_stress(0.0);
678        let y1 = mat.current_yield_stress(0.01);
679        assert!(y1 > y0);
680    }
681
682    #[test]
683    fn test_plastic_default() {
684        let mat = PyPlasticMaterial::default();
685        assert!(mat.yield_stress > 0.0);
686    }
687
688    // --- PyFatigueMaterial ---
689
690    #[test]
691    fn test_fatigue_new() {
692        let mat = PyFatigueMaterial::new(900e6, 0.1, 200e6);
693        assert_eq!(mat.basquin_coefficient, 900e6);
694    }
695
696    #[test]
697    fn test_fatigue_below_endurance_infinite_life() {
698        let mat = PyFatigueMaterial::default();
699        let n = mat.cycles_to_failure(100e6); // below 200 MPa endurance limit
700        assert!(n.is_infinite());
701    }
702
703    #[test]
704    fn test_fatigue_above_endurance_finite_life() {
705        let mat = PyFatigueMaterial::default();
706        let n = mat.cycles_to_failure(500e6);
707        assert!(n.is_finite() && n > 0.0);
708    }
709
710    #[test]
711    fn test_fatigue_miner_damage_zero_below_endurance() {
712        let mat = PyFatigueMaterial::default();
713        let d = mat.miner_damage(1000.0, 100e6);
714        assert_eq!(d, 0.0);
715    }
716
717    #[test]
718    fn test_fatigue_miner_damage_positive_above_endurance() {
719        let mat = PyFatigueMaterial::default();
720        let d = mat.miner_damage(1000.0, 500e6);
721        assert!(d > 0.0);
722    }
723
724    #[test]
725    fn test_fatigue_default() {
726        let mat = PyFatigueMaterial::default();
727        assert!(mat.endurance_limit > 0.0);
728    }
729
730    // --- PyThermalMaterial ---
731
732    #[test]
733    fn test_thermal_new() {
734        let mat = PyThermalMaterial::new(50.0, 480.0, 7850.0);
735        assert_eq!(mat.conductivity, 50.0);
736    }
737
738    #[test]
739    fn test_thermal_diffusivity() {
740        let mat = PyThermalMaterial::new(50.0, 480.0, 7850.0);
741        let alpha = mat.thermal_diffusivity();
742        assert!(alpha > 0.0);
743        // α = 50 / (7850 * 480) ≈ 1.328e-5 m²/s
744        assert!((alpha - 1.328e-5).abs() < 1e-7);
745    }
746
747    #[test]
748    fn test_thermal_heat_flux() {
749        let mat = PyThermalMaterial::default();
750        let q = mat.heat_flux(10.0); // 10 K/m gradient
751        assert!((q - 500.0).abs() < 1e-6);
752    }
753
754    #[test]
755    fn test_thermal_default() {
756        let mat = PyThermalMaterial::default();
757        assert!(mat.specific_heat > 0.0);
758    }
759
760    // --- PyViscoelasticMaterial ---
761
762    #[test]
763    fn test_viscoelastic_new() {
764        let mat = PyViscoelasticMaterial::new(1e6, vec![2e6], vec![0.1]);
765        assert_eq!(mat.equilibrium_modulus, 1e6);
766    }
767
768    #[test]
769    fn test_viscoelastic_relaxation_t0() {
770        let mat = PyViscoelasticMaterial::default();
771        let e0 = mat.relaxation_modulus(0.0);
772        let eg = mat.glassy_modulus();
773        assert!((e0 - eg).abs() < 1.0);
774    }
775
776    #[test]
777    fn test_viscoelastic_relaxation_large_t() {
778        let mat = PyViscoelasticMaterial::default();
779        let einf = mat.relaxation_modulus(1e12);
780        assert!((einf - mat.equilibrium_modulus).abs() < 1.0);
781    }
782
783    #[test]
784    fn test_viscoelastic_glassy_modulus() {
785        let mat = PyViscoelasticMaterial::new(1e6, vec![2e6, 1e6], vec![0.1, 1.0]);
786        assert!((mat.glassy_modulus() - 4e6).abs() < 1.0);
787    }
788
789    #[test]
790    fn test_viscoelastic_default() {
791        let mat = PyViscoelasticMaterial::default();
792        assert!(mat.prony_moduli.len() == mat.prony_times.len());
793    }
794
795    // --- PyDamageMaterial ---
796
797    #[test]
798    fn test_damage_new() {
799        let mat = PyDamageMaterial::new(1000.0, 1e8, 200e9);
800        assert_eq!(mat.critical_energy_release, 1000.0);
801    }
802
803    #[test]
804    fn test_damage_zero_strain_energy() {
805        let mat = PyDamageMaterial::default();
806        assert_eq!(mat.damage_variable(0.0), 0.0);
807    }
808
809    #[test]
810    fn test_damage_below_critical() {
811        let mat = PyDamageMaterial::new(1000.0, 1e8, 200e9);
812        assert_eq!(mat.damage_variable(500.0), 0.0);
813    }
814
815    #[test]
816    fn test_damage_above_critical() {
817        let mat = PyDamageMaterial::new(100.0, 1e8, 200e9);
818        let d = mat.damage_variable(1000.0);
819        assert!(d > 0.0 && d <= 1.0);
820    }
821
822    #[test]
823    fn test_damage_effective_modulus_undamaged() {
824        let mat = PyDamageMaterial::new(1000.0, 1e8, 200e9);
825        let e = mat.effective_modulus(0.0);
826        assert!((e - 200e9).abs() < 1.0);
827    }
828
829    #[test]
830    fn test_damage_default() {
831        let mat = PyDamageMaterial::default();
832        assert!(mat.undamaged_modulus > 0.0);
833    }
834
835    // --- PyCreepMaterial ---
836
837    #[test]
838    fn test_creep_new() {
839        let mat = PyCreepMaterial::new(1e-26, 5.0, 15_000.0);
840        assert_eq!(mat.norton_exponent, 5.0);
841    }
842
843    #[test]
844    fn test_creep_rate_positive() {
845        let mat = PyCreepMaterial::default();
846        let rate = mat.creep_rate(100e6, 800.0);
847        assert!(rate >= 0.0);
848    }
849
850    #[test]
851    fn test_creep_rate_increases_with_stress() {
852        let mat = PyCreepMaterial::default();
853        let r1 = mat.creep_rate(100e6, 800.0);
854        let r2 = mat.creep_rate(200e6, 800.0);
855        assert!(r2 > r1);
856    }
857
858    #[test]
859    fn test_creep_rate_decreases_with_temperature_drop() {
860        let mat = PyCreepMaterial::default();
861        let r_hot = mat.creep_rate(100e6, 1000.0);
862        let r_cold = mat.creep_rate(100e6, 500.0);
863        assert!(r_hot > r_cold);
864    }
865
866    #[test]
867    fn test_creep_reference_rate() {
868        let mat = PyCreepMaterial::default();
869        let r = mat.reference_creep_rate(100e6);
870        assert!(r >= 0.0);
871    }
872
873    // --- PyAcousticMaterial ---
874
875    #[test]
876    fn test_acoustic_new() {
877        let mat = PyAcousticMaterial::new(2.2e9, 998.0);
878        assert_eq!(mat.density, 998.0);
879    }
880
881    #[test]
882    fn test_acoustic_speed_of_sound_water() {
883        let mat = PyAcousticMaterial::default();
884        let c = mat.speed_of_sound();
885        // Water: c ≈ 1484 m/s
886        assert!((c - 1484.0).abs() < 10.0);
887    }
888
889    #[test]
890    fn test_acoustic_impedance_positive() {
891        let mat = PyAcousticMaterial::default();
892        assert!(mat.impedance() > 0.0);
893    }
894
895    #[test]
896    fn test_acoustic_reflection_same_material() {
897        let mat = PyAcousticMaterial::default();
898        let r = mat.reflection_coefficient(&mat.clone());
899        assert!(r.abs() < 1e-10);
900    }
901
902    #[test]
903    fn test_acoustic_default() {
904        let mat = PyAcousticMaterial::default();
905        assert!(mat.bulk_modulus > 0.0);
906    }
907
908    // --- PyCompositeMaterial ---
909
910    #[test]
911    fn test_composite_new() {
912        let mat = PyCompositeMaterial::new(70e9, 200e9, 0.6);
913        assert_eq!(mat.volume_fraction1, 0.6);
914    }
915
916    #[test]
917    fn test_composite_voigt_bounds() {
918        let mat = PyCompositeMaterial::default();
919        let v = mat.voigt_modulus(0.6, 70e9, 200e9);
920        assert!((70e9..=200e9).contains(&v));
921    }
922
923    #[test]
924    fn test_composite_reuss_bounds() {
925        let mat = PyCompositeMaterial::default();
926        let r = mat.reuss_modulus(0.6, 70e9, 200e9);
927        assert!((70e9..=200e9).contains(&r));
928    }
929
930    #[test]
931    fn test_composite_voigt_ge_reuss() {
932        let mat = PyCompositeMaterial::default();
933        let v = mat.voigt_modulus(0.6, 70e9, 200e9);
934        let r = mat.reuss_modulus(0.6, 70e9, 200e9);
935        assert!(v >= r);
936    }
937
938    #[test]
939    fn test_composite_hill_between_voigt_reuss() {
940        let mat = PyCompositeMaterial::default();
941        let h = mat.hill_modulus();
942        let v = mat.voigt_modulus(mat.volume_fraction1, mat.modulus1, mat.modulus2);
943        let r = mat.reuss_modulus(mat.volume_fraction1, mat.modulus1, mat.modulus2);
944        assert!(h >= r && h <= v);
945    }
946
947    #[test]
948    fn test_composite_hashin_shtrikman_lower_positive() {
949        let mat = PyCompositeMaterial::default();
950        let hs = mat.hashin_shtrikman_lower();
951        assert!(hs > 0.0);
952    }
953
954    #[test]
955    fn test_composite_pure_phase1() {
956        let mat = PyCompositeMaterial::new(70e9, 200e9, 1.0);
957        let v = mat.voigt_modulus(1.0, 70e9, 200e9);
958        assert!((v - 70e9).abs() < 1.0);
959    }
960
961    #[test]
962    fn test_composite_default() {
963        let mat = PyCompositeMaterial::default();
964        assert!(mat.modulus1 > 0.0 && mat.modulus2 > 0.0);
965    }
966
967    // --- register helper ---
968
969    #[test]
970    fn test_register_materials_module_no_panic() {
971        register_materials_module("materials");
972    }
973}