Skip to main content

oxiphysics_materials/
multiphysics.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Coupled multiphysics material models.
5//!
6//! Provides constitutive models coupling mechanical, thermal, electrical,
7//! magnetic, electrochemical, and radiation physics including operator
8//! splitting and staggered Newton solvers.
9
10#![allow(dead_code)]
11
12// ─────────────────────────────────────────────────────────────────────────────
13// ThermoMechanical — thermal stress coupling
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Thermo-mechanical coupling: thermal stress and heat generation from deformation.
17///
18/// σ = C : (ε - α * ΔT * I), coupled with ρ*Cp*dT/dt = k*∇²T + σ:dε/dt.
19#[derive(Debug, Clone)]
20pub struct ThermoMechanical {
21    /// Young's modulus E (Pa).
22    pub youngs_modulus: f64,
23    /// Poisson's ratio ν.
24    pub poisson_ratio: f64,
25    /// Coefficient of thermal expansion α (1/K).
26    pub cte: f64,
27    /// Thermal conductivity k (W/m/K).
28    pub thermal_conductivity: f64,
29    /// Density ρ (kg/m³).
30    pub density: f64,
31    /// Specific heat capacity Cp (J/kg/K).
32    pub specific_heat: f64,
33    /// Reference temperature T0 (K).
34    pub t_ref: f64,
35}
36
37impl ThermoMechanical {
38    /// Creates a thermo-mechanical model for steel.
39    pub fn steel() -> Self {
40        Self {
41            youngs_modulus: 200e9,
42            poisson_ratio: 0.3,
43            cte: 12e-6,
44            thermal_conductivity: 50.0,
45            density: 7850.0,
46            specific_heat: 500.0,
47            t_ref: 293.15,
48        }
49    }
50
51    /// Creates a custom thermo-mechanical material.
52    #[allow(clippy::too_many_arguments)]
53    pub fn new(
54        youngs_modulus: f64,
55        poisson_ratio: f64,
56        cte: f64,
57        thermal_conductivity: f64,
58        density: f64,
59        specific_heat: f64,
60        t_ref: f64,
61    ) -> Self {
62        Self {
63            youngs_modulus,
64            poisson_ratio,
65            cte,
66            thermal_conductivity,
67            density,
68            specific_heat,
69            t_ref,
70        }
71    }
72
73    /// Thermal strain: ε_th = α * ΔT.
74    pub fn thermal_strain(&self, temperature: f64) -> f64 {
75        self.cte * (temperature - self.t_ref)
76    }
77
78    /// Uniaxial thermal stress: σ = E * α * ΔT (constrained expansion).
79    pub fn thermal_stress_uniaxial(&self, temperature: f64) -> f64 {
80        -self.youngs_modulus * self.thermal_strain(temperature)
81    }
82
83    /// Thermal diffusivity α_d = k / (ρ * Cp) (m²/s).
84    pub fn thermal_diffusivity(&self) -> f64 {
85        self.thermal_conductivity / (self.density * self.specific_heat)
86    }
87
88    /// Lamé parameter λ.
89    pub fn lame_lambda(&self) -> f64 {
90        self.youngs_modulus * self.poisson_ratio
91            / ((1.0 + self.poisson_ratio) * (1.0 - 2.0 * self.poisson_ratio))
92    }
93
94    /// Lamé parameter μ (shear modulus).
95    pub fn lame_mu(&self) -> f64 {
96        self.youngs_modulus / (2.0 * (1.0 + self.poisson_ratio))
97    }
98
99    /// Hydrostatic thermal stress (3D isotropic): σ_v = -3K*α*ΔT.
100    pub fn hydrostatic_thermal_stress(&self, temperature: f64) -> f64 {
101        let k_bulk = self.youngs_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio));
102        -3.0 * k_bulk * self.cte * (temperature - self.t_ref)
103    }
104
105    /// Thermoelastic dissipation rate per unit volume (Gough-Joule effect).
106    pub fn thermoelastic_dissipation(&self, strain_rate: f64, temperature: f64) -> f64 {
107        let k_bulk = self.youngs_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio));
108        -3.0 * k_bulk * self.cte * temperature * strain_rate
109    }
110}
111
112// ─────────────────────────────────────────────────────────────────────────────
113// PiezoElectric — piezoelectric coupling
114// ─────────────────────────────────────────────────────────────────────────────
115
116/// Piezoelectric constitutive model.
117///
118/// Couples mechanical strain and electric displacement via the d-matrix:
119/// S = sE : T + d^t : E_field;  D = d : T + ε^T : E_field.
120#[derive(Debug, Clone)]
121pub struct PiezoElectric {
122    /// Mechanical compliance at constant E-field \[s11, s12, s33, s44\] (Pa^-1).
123    pub compliance: [f64; 4],
124    /// Piezoelectric d-matrix coefficients \[d31, d33, d15\] (C/N = m/V).
125    pub d_matrix: [f64; 3],
126    /// Permittivity at constant stress \[ε11, ε33\] (F/m).
127    pub permittivity: [f64; 2],
128    /// Material name.
129    pub name: String,
130}
131
132impl PiezoElectric {
133    /// Creates a model for PZT-5H (lead zirconate titanate).
134    pub fn pzt5h() -> Self {
135        Self {
136            compliance: [16.5e-12, -4.78e-12, 20.7e-12, 43.5e-12],
137            d_matrix: [-274e-12, 593e-12, 741e-12],
138            permittivity: [3130.0 * 8.854e-12, 3400.0 * 8.854e-12],
139            name: "PZT-5H".to_string(),
140        }
141    }
142
143    /// Creates a model for PVDF (polyvinylidene fluoride).
144    pub fn pvdf() -> Self {
145        Self {
146            compliance: [365e-12, -145e-12, 472e-12, 1600e-12],
147            d_matrix: [23e-12, -33e-12, -27e-12],
148            permittivity: [12.0 * 8.854e-12, 12.0 * 8.854e-12],
149            name: "PVDF".to_string(),
150        }
151    }
152
153    /// Direct piezoelectric effect: charge density D3 = d33 * T3.
154    pub fn direct_effect_d33(&self, stress_33: f64) -> f64 {
155        self.d_matrix[1] * stress_33
156    }
157
158    /// Converse piezoelectric effect: strain S3 = d33 * E3.
159    pub fn converse_effect_d33(&self, e_field_3: f64) -> f64 {
160        self.d_matrix[1] * e_field_3
161    }
162
163    /// Electromechanical coupling coefficient k33.
164    pub fn coupling_k33(&self) -> f64 {
165        let d33 = self.d_matrix[1];
166        let s33 = self.compliance[2];
167        let eps33 = self.permittivity[1];
168        d33 / (s33 * eps33).sqrt()
169    }
170
171    /// Open-circuit resonance shift due to piezoelectric stiffening.
172    pub fn stiffened_modulus_33(&self) -> f64 {
173        let d33 = self.d_matrix[1];
174        let s33 = self.compliance[2];
175        let eps33 = self.permittivity[1];
176        (s33 - d33 * d33 / eps33).recip()
177    }
178}
179
180// ─────────────────────────────────────────────────────────────────────────────
181// Magnetostrictive — Joule magnetostriction and Villari effect
182// ─────────────────────────────────────────────────────────────────────────────
183
184/// Magnetostrictive material model (Joule and Villari effects).
185///
186/// Models Terfenol-D-like materials with magnetization curve and Preisach hysteresis.
187#[derive(Debug, Clone)]
188pub struct Magnetostrictive {
189    /// Saturation magnetostriction λ_s (dimensionless, e.g. 1750e-6 for Terfenol-D).
190    pub lambda_s: f64,
191    /// Saturation magnetization M_s (A/m).
192    pub m_saturation: f64,
193    /// Young's modulus at H=0 (Pa).
194    pub youngs_modulus: f64,
195    /// Magnetomechanical coupling coefficient d (m/A).
196    pub d_coeff: f64,
197    /// Coercive field Hc (A/m) for hysteresis.
198    pub coercive_field: f64,
199}
200
201impl Magnetostrictive {
202    /// Creates a Terfenol-D model.
203    pub fn terfenol_d() -> Self {
204        Self {
205            lambda_s: 1750e-6,
206            m_saturation: 7.65e5,
207            youngs_modulus: 30e9,
208            d_coeff: 1.67e-8,
209            coercive_field: 10e3,
210        }
211    }
212
213    /// Joule magnetostriction: ε = (3/2) * λ_s * (M/M_s)^2.
214    pub fn magnetostriction(&self, magnetization: f64) -> f64 {
215        let m_norm = (magnetization / self.m_saturation).clamp(-1.0, 1.0);
216        1.5 * self.lambda_s * m_norm * m_norm
217    }
218
219    /// Villari effect: change in magnetization due to stress.
220    ///
221    /// dM/dσ ≈ d * H_applied at constant field.
222    pub fn villari_effect(&self, stress: f64, h_field: f64) -> f64 {
223        // Simplified: dM ≈ d * σ * (dB/dH) at operating point
224        self.d_coeff * stress * h_field / self.coercive_field
225    }
226
227    /// Preisach-like hysteresis: simple scalar Preisach model.
228    ///
229    /// Returns the magnetization for a triangular hysteron with thresholds (α, β).
230    pub fn preisach_hysteron(&self, h: f64, alpha: f64, beta: f64, state: bool) -> bool {
231        if h > alpha {
232            true
233        } else if h < beta {
234            false
235        } else {
236            state
237        }
238    }
239
240    /// Magnetization curve (Langevin function approximation).
241    pub fn magnetization_curve(&self, h_field: f64) -> f64 {
242        let a = self.coercive_field;
243        let x = h_field / a;
244        // Brillouin/Langevin approximation
245        self.m_saturation * x.tanh()
246    }
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// ElectrochemicalMaterial — battery electrode material
251// ─────────────────────────────────────────────────────────────────────────────
252
253/// Electrochemical material model for battery electrodes.
254///
255/// Butler-Volmer kinetics, diffusion, and intercalation stress.
256#[derive(Debug, Clone)]
257pub struct ElectrochemicalMaterial {
258    /// Exchange current density i0 (A/m²).
259    pub exchange_current_density: f64,
260    /// Charge transfer coefficient α (0 to 1).
261    pub alpha: f64,
262    /// Diffusion coefficient D (m²/s).
263    pub diffusion_coefficient: f64,
264    /// Partial molar volume Ω (m³/mol).
265    pub partial_molar_volume: f64,
266    /// Young's modulus (Pa).
267    pub youngs_modulus: f64,
268    /// Poisson's ratio.
269    pub poisson_ratio: f64,
270    /// Faraday constant.
271    pub faraday: f64,
272}
273
274impl ElectrochemicalMaterial {
275    /// Faraday constant (C/mol).
276    const F: f64 = 96485.0;
277    /// Gas constant (J/mol/K).
278    const R: f64 = 8.314;
279
280    /// Creates a lithium graphite anode model.
281    pub fn lithium_graphite() -> Self {
282        Self {
283            exchange_current_density: 2.0,
284            alpha: 0.5,
285            diffusion_coefficient: 3.9e-14,
286            partial_molar_volume: 3.497e-6,
287            youngs_modulus: 10e9,
288            poisson_ratio: 0.3,
289            faraday: Self::F,
290        }
291    }
292
293    /// Butler-Volmer current density: j = i0 * \[exp(α*F*η/RT) - exp(-(1-α)*F*η/RT)\].
294    pub fn butler_volmer(&self, overpotential: f64, temperature: f64) -> f64 {
295        let f_rt = Self::F / (Self::R * temperature);
296        self.exchange_current_density
297            * ((self.alpha * f_rt * overpotential).exp()
298                - (-(1.0 - self.alpha) * f_rt * overpotential).exp())
299    }
300
301    /// Linear diffusion flux (1D, Fick's first law): J = -D * dc/dx.
302    pub fn diffusion_flux(&self, concentration_gradient: f64) -> f64 {
303        -self.diffusion_coefficient * concentration_gradient
304    }
305
306    /// Intercalation stress (hydrostatic): σ_h = -Ω * E / (3 * (1-ν)) * (c - c_avg).
307    pub fn intercalation_stress(&self, concentration: f64, c_avg: f64) -> f64 {
308        let prefactor =
309            self.partial_molar_volume * self.youngs_modulus / (3.0 * (1.0 - self.poisson_ratio));
310        -prefactor * (concentration - c_avg)
311    }
312
313    /// Open-circuit potential shift due to concentration (Nernst-like).
314    pub fn nernst_shift(&self, c_norm: f64, temperature: f64) -> f64 {
315        let c = c_norm.clamp(1e-10, 1.0 - 1e-10);
316        Self::R * temperature / Self::F * (c / (1.0 - c)).ln()
317    }
318}
319
320// ─────────────────────────────────────────────────────────────────────────────
321// PoroElastic — Biot consolidation
322// ─────────────────────────────────────────────────────────────────────────────
323
324/// Poroelastic material: Biot consolidation theory.
325///
326/// Couples effective stress with pore pressure: σ_eff = σ_total - b * p * I.
327#[derive(Debug, Clone)]
328pub struct PoroElastic {
329    /// Drained Young's modulus E (Pa).
330    pub youngs_modulus: f64,
331    /// Poisson's ratio ν (drained).
332    pub poisson_ratio: f64,
333    /// Biot coefficient b (0 to 1).
334    pub biot_coefficient: f64,
335    /// Permeability k (m²).
336    pub permeability: f64,
337    /// Fluid viscosity μ (Pa·s).
338    pub fluid_viscosity: f64,
339    /// Biot modulus M (Pa).
340    pub biot_modulus: f64,
341}
342
343impl PoroElastic {
344    /// Creates a saturated clay model.
345    pub fn saturated_clay() -> Self {
346        Self {
347            youngs_modulus: 20e6,
348            poisson_ratio: 0.35,
349            biot_coefficient: 0.9,
350            permeability: 1e-13,
351            fluid_viscosity: 1e-3,
352            biot_modulus: 1e9,
353        }
354    }
355
356    /// Creates a sandstone model.
357    pub fn sandstone() -> Self {
358        Self {
359            youngs_modulus: 15e9,
360            poisson_ratio: 0.25,
361            biot_coefficient: 0.7,
362            permeability: 1e-13,
363            fluid_viscosity: 1e-3,
364            biot_modulus: 20e9,
365        }
366    }
367
368    /// Consolidation coefficient cv = k * M / μ (m²/s).
369    pub fn consolidation_coefficient(&self) -> f64 {
370        self.permeability * self.biot_modulus / self.fluid_viscosity
371    }
372
373    /// Effective stress: σ_eff = σ_total - b * p.
374    pub fn effective_stress(&self, total_stress: f64, pore_pressure: f64) -> f64 {
375        total_stress - self.biot_coefficient * pore_pressure
376    }
377
378    /// Darcy velocity: q = -(k/μ) * ∇p.
379    pub fn darcy_velocity(&self, pressure_gradient: f64) -> f64 {
380        -(self.permeability / self.fluid_viscosity) * pressure_gradient
381    }
382
383    /// Skempton coefficient B = b / (b + φ * M * (1/Kf - 1/Ks)).
384    ///
385    /// Approximate: B ≈ b*M / (K_drained + b²*M).
386    pub fn skempton_coefficient(&self) -> f64 {
387        let k_bulk = self.youngs_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio));
388        let b = self.biot_coefficient;
389        let m = self.biot_modulus;
390        b * m / (k_bulk + b * b * m)
391    }
392}
393
394// ─────────────────────────────────────────────────────────────────────────────
395// SwellingMaterial — hygroscopic and gel swelling
396// ─────────────────────────────────────────────────────────────────────────────
397
398/// Swelling material: hygroscopic expansion, osmotic pressure, gel (Flory-Rehner).
399#[derive(Debug, Clone)]
400pub struct SwellingMaterial {
401    /// Hygroscopic expansion coefficient β_h (strain per unit relative humidity).
402    pub hygroscopic_coeff: f64,
403    /// Osmotic pressure coefficient (Pa per mol/m³ concentration difference).
404    pub osmotic_coeff: f64,
405    /// Flory-Rehner cross-link density (mol/m³).
406    pub crosslink_density: f64,
407    /// Flory-Huggins parameter χ.
408    pub flory_chi: f64,
409    /// Temperature (K).
410    pub temperature: f64,
411}
412
413impl SwellingMaterial {
414    /// Gas constant (J/mol/K).
415    const R: f64 = 8.314;
416
417    /// Creates a polyacrylamide hydrogel model.
418    pub fn hydrogel() -> Self {
419        Self {
420            hygroscopic_coeff: 0.002,
421            osmotic_coeff: 2479.0, // RT at 25°C = 2479 J/mol
422            crosslink_density: 100.0,
423            flory_chi: 0.5,
424            temperature: 298.15,
425        }
426    }
427
428    /// Hygroscopic swelling strain: ε = β_h * ΔRH.
429    pub fn hygroscopic_strain(&self, delta_rh: f64) -> f64 {
430        self.hygroscopic_coeff * delta_rh
431    }
432
433    /// Osmotic pressure (van't Hoff): Π = c * R * T.
434    pub fn osmotic_pressure(&self, concentration: f64) -> f64 {
435        concentration * Self::R * self.temperature
436    }
437
438    /// Flory-Rehner elastic free energy: ΔF_el = (3/2) * ν * kT * (λ² - 1 - ln λ).
439    ///
440    /// λ = swelling ratio.
441    pub fn flory_rehner_elastic_energy(&self, swelling_ratio: f64) -> f64 {
442        let nu = self.crosslink_density;
443        let kt = Self::R * self.temperature / 6.022e23;
444        1.5 * nu * kt * (swelling_ratio * swelling_ratio - 1.0 - swelling_ratio.ln())
445    }
446
447    /// Total swelling driving force: mixing + elastic.
448    pub fn swelling_equilibrium_condition(&self, phi: f64) -> f64 {
449        // dF_mix/dφ + dF_el/dφ = 0 at equilibrium
450        let phi = phi.clamp(1e-5, 1.0 - 1e-5);
451        let mixing = (1.0 - phi).ln() + phi + self.flory_chi * phi * phi;
452        let elastic = self.crosslink_density * (phi.powf(1.0 / 3.0) - 0.5 * phi);
453        mixing + elastic
454    }
455}
456
457// ─────────────────────────────────────────────────────────────────────────────
458// RadiationDamage — displacement per atom and void swelling
459// ─────────────────────────────────────────────────────────────────────────────
460
461/// Radiation damage material model.
462///
463/// Tracks displacement per atom (dpa), void swelling, embrittlement, and He bubble growth.
464#[derive(Debug, Clone)]
465pub struct RadiationDamage {
466    /// Displacement threshold energy Ed (eV).
467    pub threshold_energy_ev: f64,
468    /// Atomic density n (atoms/m³).
469    pub atomic_density: f64,
470    /// Initial yield strength σy0 (Pa).
471    pub yield_strength_initial: f64,
472    /// Accumulated dpa (displacement per atom).
473    pub dpa: f64,
474    /// Void swelling fraction (dimensionless).
475    pub void_fraction: f64,
476    /// Helium concentration (appm).
477    pub helium_conc_appm: f64,
478}
479
480impl RadiationDamage {
481    /// Creates a stainless steel 316L radiation damage model.
482    pub fn ss316l() -> Self {
483        Self {
484            threshold_energy_ev: 40.0,
485            atomic_density: 8.5e28,
486            yield_strength_initial: 220e6,
487            dpa: 0.0,
488            void_fraction: 0.0,
489            helium_conc_appm: 0.0,
490        }
491    }
492
493    /// Irradiation dose in dpa from neutron flux and cross-section.
494    ///
495    /// dpa = φ * σ_d * t / (n * Ed * 2).
496    pub fn compute_dpa(&self, flux: f64, cross_section: f64, time: f64) -> f64 {
497        flux * cross_section * time
498            / (self.atomic_density * 2.0 * self.threshold_energy_ev * 1.6e-19)
499    }
500
501    /// Yield strength increase due to irradiation hardening.
502    ///
503    /// Δσy ≈ α * μ * b * sqrt(N_d), where N_d ~ dpa density (simplified to Δσy = k * sqrt(dpa)).
504    pub fn irradiation_hardening(&self, k_hardening: f64) -> f64 {
505        k_hardening * self.dpa.sqrt()
506    }
507
508    /// Total yield strength (initial + hardening).
509    pub fn yield_strength(&self, k_hardening: f64) -> f64 {
510        self.yield_strength_initial + self.irradiation_hardening(k_hardening)
511    }
512
513    /// Void swelling rate: dS/ddpa ≈ A * exp(-B/T) (temperature-dependent).
514    pub fn void_swelling_rate(&self, temperature_k: f64, a_coeff: f64, b_coeff: f64) -> f64 {
515        a_coeff * (-b_coeff / temperature_k).exp()
516    }
517
518    /// Helium bubble growth: r ~ (3 * C_He / (4π * n_b))^(1/3).
519    pub fn bubble_radius(&self, bubble_density: f64) -> f64 {
520        let c_he = self.helium_conc_appm * 1e-6 * self.atomic_density;
521        (3.0 * c_he / (4.0 * std::f64::consts::PI * bubble_density)).cbrt()
522    }
523
524    /// Ductile-to-brittle transition temperature shift (DBTT shift ~ 20°C/dpa).
525    pub fn dbtt_shift_k(&self) -> f64 {
526        20.0 * self.dpa
527    }
528}
529
530// ─────────────────────────────────────────────────────────────────────────────
531// PhaseTransformMaterial — shape memory alloy
532// ─────────────────────────────────────────────────────────────────────────────
533
534/// Shape memory alloy (SMA) phase transformation material.
535///
536/// Implements the Tanaka model for martensite fraction and pseudoelasticity.
537#[derive(Debug, Clone)]
538pub struct PhaseTransformMaterial {
539    /// Young's modulus of austenite EA (Pa).
540    pub ea: f64,
541    /// Young's modulus of martensite EM (Pa).
542    pub em: f64,
543    /// Maximum transformation strain εL.
544    pub max_strain: f64,
545    /// Austenite start temperature As (K).
546    pub as_temp: f64,
547    /// Austenite finish temperature Af (K).
548    pub af_temp: f64,
549    /// Martensite start temperature Ms (K).
550    pub ms_temp: f64,
551    /// Martensite finish temperature Mf (K).
552    pub mf_temp: f64,
553    /// Current martensite volume fraction ξ ∈ \[0, 1\].
554    pub xi: f64,
555}
556
557impl PhaseTransformMaterial {
558    /// Creates a Nitinol (NiTi) SMA model.
559    pub fn nitinol() -> Self {
560        Self {
561            ea: 75e9,
562            em: 30e9,
563            max_strain: 0.08,
564            as_temp: 291.0,
565            af_temp: 307.0,
566            ms_temp: 291.0,
567            mf_temp: 271.0,
568            xi: 0.0,
569        }
570    }
571
572    /// Effective Young's modulus (mixture rule): E = EA + ξ*(EM - EA).
573    pub fn effective_modulus(&self) -> f64 {
574        self.ea + self.xi * (self.em - self.ea)
575    }
576
577    /// Martensite fraction from temperature (cooling, Tanaka model).
578    pub fn martensite_fraction_cooling(&self, temperature: f64) -> f64 {
579        if temperature >= self.ms_temp {
580            0.0
581        } else if temperature <= self.mf_temp {
582            1.0
583        } else {
584            let b_m = std::f64::consts::PI / (self.ms_temp - self.mf_temp);
585            0.5 * (1.0 - (b_m * (temperature - (self.ms_temp + self.mf_temp) / 2.0)).cos())
586        }
587    }
588
589    /// Martensite fraction from temperature (heating, Tanaka model).
590    pub fn martensite_fraction_heating(&self, temperature: f64) -> f64 {
591        if temperature >= self.af_temp {
592            0.0
593        } else if temperature <= self.as_temp {
594            self.xi // no change if below As
595        } else {
596            let b_a = std::f64::consts::PI / (self.af_temp - self.as_temp);
597            self.xi * 0.5 * (1.0 + (b_a * (temperature - self.as_temp)).cos())
598        }
599    }
600
601    /// Pseudoelastic transformation stress (at constant T).
602    pub fn transformation_stress(&self, strain: f64) -> f64 {
603        let eps = strain.clamp(0.0, self.max_strain);
604        let xi_new = eps / self.max_strain;
605        let e_eff = self.ea + xi_new * (self.em - self.ea);
606        e_eff * (eps - xi_new * self.max_strain)
607    }
608}
609
610// ─────────────────────────────────────────────────────────────────────────────
611// ElectroMagnetic — electromagnetic material properties
612// ─────────────────────────────────────────────────────────────────────────────
613
614/// Electromagnetic material properties: permeability, losses, skin depth.
615#[derive(Debug, Clone)]
616pub struct ElectroMagnetic {
617    /// Relative permeability μr.
618    pub rel_permeability: f64,
619    /// Electrical conductivity σ (S/m).
620    pub conductivity: f64,
621    /// Steinmetz coefficient k_h for hysteresis loss.
622    pub steinmetz_k_h: f64,
623    /// Steinmetz exponent n.
624    pub steinmetz_n: f64,
625    /// Eddy current coefficient k_e.
626    pub eddy_current_k_e: f64,
627    /// Operating frequency (Hz).
628    pub frequency: f64,
629}
630
631impl ElectroMagnetic {
632    /// Permeability of free space μ0 (H/m).
633    const MU0: f64 = 1.256_637_062e-6;
634
635    /// Creates a silicon steel model (electrical steel).
636    pub fn silicon_steel() -> Self {
637        Self {
638            rel_permeability: 5000.0,
639            conductivity: 2e6,
640            steinmetz_k_h: 40.0,
641            steinmetz_n: 1.8,
642            eddy_current_k_e: 0.5,
643            frequency: 50.0,
644        }
645    }
646
647    /// Absolute permeability μ = μ0 * μr (H/m).
648    pub fn permeability(&self) -> f64 {
649        Self::MU0 * self.rel_permeability
650    }
651
652    /// Skin depth δ = sqrt(2 / (ω * σ * μ)) (m).
653    pub fn skin_depth(&self) -> f64 {
654        let omega = 2.0 * std::f64::consts::PI * self.frequency;
655        (2.0 / (omega * self.conductivity * self.permeability())).sqrt()
656    }
657
658    /// Hysteresis loss (Steinmetz): P_h = k_h * f * B^n (W/m³ per cycle normalized).
659    pub fn hysteresis_loss(&self, b_max: f64) -> f64 {
660        self.steinmetz_k_h * self.frequency * b_max.powf(self.steinmetz_n)
661    }
662
663    /// Eddy current loss: P_e = k_e * f² * B² (W/m³).
664    pub fn eddy_current_loss(&self, b_max: f64) -> f64 {
665        self.eddy_current_k_e * self.frequency.powi(2) * b_max.powi(2)
666    }
667
668    /// Total core loss: P_total = P_h + P_e.
669    pub fn total_core_loss(&self, b_max: f64) -> f64 {
670        self.hysteresis_loss(b_max) + self.eddy_current_loss(b_max)
671    }
672
673    /// Inductance per unit length for a solenoid (H/m).
674    pub fn inductance_per_length(&self, n_turns_per_m: f64, area_m2: f64) -> f64 {
675        self.permeability() * n_turns_per_m * n_turns_per_m * area_m2
676    }
677}
678
679// ─────────────────────────────────────────────────────────────────────────────
680// CoupledSolver — multiphysics coupling strategies
681// ─────────────────────────────────────────────────────────────────────────────
682
683/// Multiphysics coupled solver strategies.
684///
685/// Provides operator splitting, staggered scheme, and monolithic Newton iteration.
686pub struct CoupledSolver {
687    /// Convergence tolerance.
688    pub tolerance: f64,
689    /// Maximum iterations.
690    pub max_iter: usize,
691    /// Relaxation parameter ω for successive substitution (0 < ω ≤ 1).
692    pub relaxation: f64,
693}
694
695impl CoupledSolver {
696    /// Creates a new coupled solver.
697    pub fn new(tolerance: f64, max_iter: usize, relaxation: f64) -> Self {
698        Self {
699            tolerance,
700            max_iter,
701            relaxation,
702        }
703    }
704
705    /// Operator splitting: alternately solve field A then field B.
706    ///
707    /// `solve_a(b) -> a` and `solve_b(a) -> b` are the individual field solvers.
708    /// Returns the converged (a, b) pair and number of iterations.
709    pub fn operator_split<FA, FB>(
710        &self,
711        a0: f64,
712        b0: f64,
713        solve_a: FA,
714        solve_b: FB,
715    ) -> (f64, f64, usize)
716    where
717        FA: Fn(f64) -> f64,
718        FB: Fn(f64) -> f64,
719    {
720        let mut a = a0;
721        let mut b = b0;
722        for iter in 0..self.max_iter {
723            let a_new = solve_a(b);
724            let b_new = solve_b(a_new);
725            let err = ((a_new - a).powi(2) + (b_new - b).powi(2)).sqrt();
726            a = a * (1.0 - self.relaxation) + a_new * self.relaxation;
727            b = b * (1.0 - self.relaxation) + b_new * self.relaxation;
728            if err < self.tolerance {
729                return (a, b, iter + 1);
730            }
731        }
732        (a, b, self.max_iter)
733    }
734
735    /// Staggered scheme: sequential solve with fixed-point iteration.
736    ///
737    /// Returns converged state vector and iteration count.
738    pub fn staggered<F>(&self, state0: &[f64], step_fn: F) -> (Vec<f64>, usize)
739    where
740        F: Fn(&[f64]) -> Vec<f64>,
741    {
742        let mut state = state0.to_vec();
743        for iter in 0..self.max_iter {
744            let state_new = step_fn(&state);
745            let err = state
746                .iter()
747                .zip(state_new.iter())
748                .map(|(a, b)| (a - b).powi(2))
749                .sum::<f64>()
750                .sqrt();
751            // Relaxed update
752            let state_relaxed: Vec<f64> = state
753                .iter()
754                .zip(state_new.iter())
755                .map(|(a, b)| a * (1.0 - self.relaxation) + b * self.relaxation)
756                .collect();
757            state = state_relaxed;
758            if err < self.tolerance {
759                return (state, iter + 1);
760            }
761        }
762        (state, self.max_iter)
763    }
764
765    /// Monolithic Newton iteration for a coupled residual R(x) = 0.
766    ///
767    /// Uses finite-difference Jacobian and direct solve via Gaussian elimination.
768    pub fn monolithic_newton<F>(&self, x0: &[f64], residual: F) -> (Vec<f64>, usize)
769    where
770        F: Fn(&[f64]) -> Vec<f64>,
771    {
772        let n = x0.len();
773        let mut x = x0.to_vec();
774        let eps = 1e-7;
775
776        for iter in 0..self.max_iter {
777            let r = residual(&x);
778            let r_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
779            if r_norm < self.tolerance {
780                return (x, iter);
781            }
782            // Finite-difference Jacobian J_ij = (R_i(x + eps*e_j) - R_i(x)) / eps
783            let mut j = vec![0.0_f64; n * n];
784            for j_col in 0..n {
785                let mut x_pert = x.clone();
786                x_pert[j_col] += eps;
787                let r_pert = residual(&x_pert);
788                for i in 0..n {
789                    j[i * n + j_col] = (r_pert[i] - r[i]) / eps;
790                }
791            }
792            // Solve J * dx = -r using Gaussian elimination
793            let dx = self.gaussian_solve(&j, &r.iter().map(|&v| -v).collect::<Vec<_>>(), n);
794            for i in 0..n {
795                x[i] += self.relaxation * dx[i];
796            }
797        }
798        (x, self.max_iter)
799    }
800
801    /// Gaussian elimination for Ax = b.
802    fn gaussian_solve(&self, a: &[f64], b: &[f64], n: usize) -> Vec<f64> {
803        let mut aug: Vec<f64> = (0..n)
804            .flat_map(|i| {
805                let mut row: Vec<f64> = a[i * n..(i + 1) * n].to_vec();
806                row.push(b[i]);
807                row
808            })
809            .collect();
810
811        for col in 0..n {
812            // Find pivot
813            let mut max_row = col;
814            let mut max_val = aug[col * (n + 1) + col].abs();
815            for row in col + 1..n {
816                let v = aug[row * (n + 1) + col].abs();
817                if v > max_val {
818                    max_val = v;
819                    max_row = row;
820                }
821            }
822            // Swap rows
823            for j in 0..=n {
824                aug.swap(col * (n + 1) + j, max_row * (n + 1) + j);
825            }
826            let pivot = aug[col * (n + 1) + col];
827            if pivot.abs() < 1e-15 {
828                continue;
829            }
830            for row in col + 1..n {
831                let factor = aug[row * (n + 1) + col] / pivot;
832                for j in col..=n {
833                    let v = aug[col * (n + 1) + j];
834                    aug[row * (n + 1) + j] -= factor * v;
835                }
836            }
837        }
838
839        // Back substitution
840        let mut x = vec![0.0_f64; n];
841        for i in (0..n).rev() {
842            let mut sum = aug[i * (n + 1) + n];
843            for j in i + 1..n {
844                sum -= aug[i * (n + 1) + j] * x[j];
845            }
846            let diag = aug[i * (n + 1) + i];
847            x[i] = if diag.abs() > 1e-15 { sum / diag } else { 0.0 };
848        }
849        x
850    }
851}
852
853// ─────────────────────────────────────────────────────────────────────────────
854// Tests
855// ─────────────────────────────────────────────────────────────────────────────
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn test_thermo_mechanical_thermal_strain() {
863        let mat = ThermoMechanical::steel();
864        let eps = mat.thermal_strain(393.15); // 120°C
865        assert!((eps - 12e-6 * 100.0).abs() < 1e-15);
866    }
867
868    #[test]
869    fn test_thermo_mechanical_diffusivity() {
870        let mat = ThermoMechanical::steel();
871        let diff = mat.thermal_diffusivity();
872        assert!((diff - 50.0 / (7850.0 * 500.0)).abs() < 1e-15);
873    }
874
875    #[test]
876    fn test_thermo_mechanical_lame() {
877        let mat = ThermoMechanical::steel();
878        assert!(mat.lame_lambda() > 0.0);
879        assert!(mat.lame_mu() > 0.0);
880    }
881
882    #[test]
883    fn test_thermo_mechanical_hydrostatic_stress() {
884        let mat = ThermoMechanical::steel();
885        let sigma = mat.hydrostatic_thermal_stress(393.15);
886        assert!(sigma < 0.0); // compression for constrained heating
887    }
888
889    #[test]
890    fn test_piezo_pzt5h_coupling() {
891        let pz = PiezoElectric::pzt5h();
892        let k33 = pz.coupling_k33();
893        assert!(k33 > 0.0 && k33 < 1.0);
894    }
895
896    #[test]
897    fn test_piezo_direct_effect() {
898        let pz = PiezoElectric::pzt5h();
899        let d = pz.direct_effect_d33(1e6); // 1 MPa
900        assert!(d.abs() > 0.0);
901    }
902
903    #[test]
904    fn test_piezo_converse_effect() {
905        let pz = PiezoElectric::pzt5h();
906        let s = pz.converse_effect_d33(1e6); // 1 MV/m
907        assert!(s.abs() > 0.0);
908    }
909
910    #[test]
911    fn test_piezo_pvdf_name() {
912        let pz = PiezoElectric::pvdf();
913        assert_eq!(pz.name, "PVDF");
914    }
915
916    #[test]
917    fn test_magnetostrictive_magnetostriction() {
918        let ms = Magnetostrictive::terfenol_d();
919        let eps = ms.magnetostriction(ms.m_saturation);
920        assert!((eps - 1.5 * ms.lambda_s).abs() < 1e-15);
921    }
922
923    #[test]
924    fn test_magnetostrictive_curve() {
925        let ms = Magnetostrictive::terfenol_d();
926        let m = ms.magnetization_curve(0.0);
927        assert!(m.abs() < 1e-10); // at H=0, M≈0
928    }
929
930    #[test]
931    fn test_electrochemical_butler_volmer() {
932        let ec = ElectrochemicalMaterial::lithium_graphite();
933        let j = ec.butler_volmer(0.0, 298.0);
934        assert!(j.abs() < 1e-10); // at zero overpotential, j=0
935    }
936
937    #[test]
938    fn test_electrochemical_butler_volmer_positive_eta() {
939        let ec = ElectrochemicalMaterial::lithium_graphite();
940        let j = ec.butler_volmer(0.05, 298.0);
941        assert!(j > 0.0); // anodic
942    }
943
944    #[test]
945    fn test_electrochemical_intercalation_stress() {
946        let ec = ElectrochemicalMaterial::lithium_graphite();
947        let sigma = ec.intercalation_stress(1000.0, 800.0);
948        assert!(sigma < 0.0); // higher than avg → compressive
949    }
950
951    #[test]
952    fn test_poroelastic_consolidation_coeff() {
953        let pe = PoroElastic::saturated_clay();
954        let cv = pe.consolidation_coefficient();
955        assert!(cv > 0.0);
956    }
957
958    #[test]
959    fn test_poroelastic_effective_stress() {
960        let pe = PoroElastic::saturated_clay();
961        let sigma_eff = pe.effective_stress(-100e3, 50e3);
962        // -100kPa - 0.9*50kPa = -145kPa
963        assert!((sigma_eff - (-100e3 - 0.9 * 50e3)).abs() < 1.0);
964    }
965
966    #[test]
967    fn test_poroelastic_darcy_velocity() {
968        let pe = PoroElastic::saturated_clay();
969        let q = pe.darcy_velocity(1000.0); // pressure gradient 1000 Pa/m
970        assert!(q < 0.0); // flows in direction of decreasing pressure
971    }
972
973    #[test]
974    fn test_swelling_hygroscopic_strain() {
975        let sw = SwellingMaterial::hydrogel();
976        let eps = sw.hygroscopic_strain(0.5); // 50% RH increase
977        assert!((eps - 0.002 * 0.5).abs() < 1e-15);
978    }
979
980    #[test]
981    fn test_swelling_osmotic_pressure() {
982        let sw = SwellingMaterial::hydrogel();
983        let pi = sw.osmotic_pressure(1.0); // 1 mol/m³
984        assert!((pi - 1.0 * 8.314 * 298.15).abs() < 1.0);
985    }
986
987    #[test]
988    fn test_radiation_damage_dpa() {
989        let rd = RadiationDamage::ss316l();
990        let dpa = rd.compute_dpa(1e15, 1e-28, 3.15e7); // 1 year
991        assert!(dpa > 0.0);
992    }
993
994    #[test]
995    fn test_radiation_damage_hardening() {
996        let mut rd = RadiationDamage::ss316l();
997        rd.dpa = 10.0;
998        let delta_sy = rd.irradiation_hardening(100e6);
999        assert!((delta_sy - 100e6 * 10.0_f64.sqrt()).abs() < 1.0);
1000    }
1001
1002    #[test]
1003    fn test_radiation_damage_dbtt() {
1004        let mut rd = RadiationDamage::ss316l();
1005        rd.dpa = 5.0;
1006        let shift = rd.dbtt_shift_k();
1007        assert!((shift - 100.0).abs() < 1e-10);
1008    }
1009
1010    #[test]
1011    fn test_phase_transform_martensite_fraction() {
1012        let sma = PhaseTransformMaterial::nitinol();
1013        let xi = sma.martensite_fraction_cooling(261.0); // below Mf
1014        assert!((xi - 1.0).abs() < 1e-10);
1015        let xi2 = sma.martensite_fraction_cooling(310.0); // above Ms
1016        assert!(xi2.abs() < 1e-10);
1017    }
1018
1019    #[test]
1020    fn test_phase_transform_effective_modulus() {
1021        let mut sma = PhaseTransformMaterial::nitinol();
1022        sma.xi = 0.0;
1023        assert!((sma.effective_modulus() - sma.ea).abs() < 1.0);
1024        sma.xi = 1.0;
1025        assert!((sma.effective_modulus() - sma.em).abs() < 1.0);
1026    }
1027
1028    #[test]
1029    fn test_electromagnetic_skin_depth() {
1030        let em = ElectroMagnetic::silicon_steel();
1031        let delta = em.skin_depth();
1032        assert!(delta > 0.0 && delta < 1.0); // mm range for steel at 50 Hz
1033    }
1034
1035    #[test]
1036    fn test_electromagnetic_hysteresis_loss() {
1037        let em = ElectroMagnetic::silicon_steel();
1038        let ph = em.hysteresis_loss(1.5); // 1.5 T
1039        assert!(ph > 0.0);
1040    }
1041
1042    #[test]
1043    fn test_electromagnetic_total_loss_increases_with_b() {
1044        let em = ElectroMagnetic::silicon_steel();
1045        let p1 = em.total_core_loss(1.0);
1046        let p2 = em.total_core_loss(1.5);
1047        assert!(p2 > p1);
1048    }
1049
1050    #[test]
1051    fn test_coupled_solver_operator_split() {
1052        let solver = CoupledSolver::new(1e-6, 100, 1.0);
1053        // Fixed point: a = b + 1, b = a - 1  => converged at any a
1054        let (a, b, _iter) = solver.operator_split(0.0, 0.0, |b| b + 1.0, |a| a - 1.0);
1055        assert!((a - b - 1.0).abs() < 1e-5);
1056    }
1057
1058    #[test]
1059    fn test_coupled_solver_staggered() {
1060        let solver = CoupledSolver::new(1e-8, 100, 0.5);
1061        let (state, _iter) = solver.staggered(&[1.0, 1.0], |s| vec![s[0] * 0.5, s[1] * 0.5]);
1062        // Should converge toward [0, 0]
1063        assert!(state[0].abs() < 0.01);
1064        assert!(state[1].abs() < 0.01);
1065    }
1066
1067    #[test]
1068    fn test_coupled_solver_newton_linear() {
1069        let solver = CoupledSolver::new(1e-10, 20, 1.0);
1070        // R(x) = x - 3 => x* = 3
1071        let (x, _) = solver.monolithic_newton(&[0.0], |s| vec![s[0] - 3.0]);
1072        assert!((x[0] - 3.0).abs() < 1e-6);
1073    }
1074}