Skip to main content

oxiphysics_materials/
shape_memory.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shape memory alloy (SMA) materials — Nitinol, Cu-Zn-Al, and related alloys.
5//!
6//! Provides constitutive models for shape memory alloys based on the
7//! Brinson–Liang–Rogers thermomechanical framework, Clausius-Clapeyron
8//! thermoelastic martensite theory, pseudoelastic loading/unloading, and
9//! two-way shape memory effect.
10
11#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14use std::f64::consts::PI;
15
16// ─────────────────────────────────────────────────────────────────────────────
17// ShapeMemoryAlloy
18// ─────────────────────────────────────────────────────────────────────────────
19
20/// Thermomechanical model of a shape memory alloy.
21///
22/// Transformation temperatures are in kelvin (K); stresses in pascal (Pa);
23/// elastic moduli in pascal (Pa); strains dimensionless.
24///
25/// The martensite fraction ξ ∈ \[0, 1\] describes the phase state:
26/// * ξ = 1 → fully martensitic
27/// * ξ = 0 → fully austenitic
28#[derive(Debug, Clone)]
29pub struct ShapeMemoryAlloy {
30    /// Martensite start temperature (Ms) in K.
31    pub ms: f64,
32    /// Martensite finish temperature (Mf) in K.
33    pub mf: f64,
34    /// Austenite start temperature (As) in K.
35    pub as_: f64,
36    /// Austenite finish temperature (Af) in K.
37    pub af: f64,
38    /// Elastic modulus of the austenite phase in Pa.
39    pub e_austenite: f64,
40    /// Elastic modulus of the martensite phase in Pa.
41    pub e_martensite: f64,
42    /// Maximum recoverable (transformation) strain ε_L (dimensionless).
43    pub h_max: f64,
44}
45
46impl ShapeMemoryAlloy {
47    /// Create a new SMA model with the given transformation temperatures and
48    /// elastic moduli.
49    ///
50    /// # Parameters
51    /// * `ms` – martensite start temperature \[K\]
52    /// * `mf` – martensite finish temperature \[K\]
53    /// * `as_` – austenite start temperature \[K\]
54    /// * `af` – austenite finish temperature \[K\]
55    /// * `e_austenite` – austenite elastic modulus \[Pa\]
56    /// * `e_martensite` – martensite elastic modulus \[Pa\]
57    /// * `h_max` – maximum transformation strain
58    pub fn new(
59        ms: f64,
60        mf: f64,
61        as_: f64,
62        af: f64,
63        e_austenite: f64,
64        e_martensite: f64,
65        h_max: f64,
66    ) -> Self {
67        Self {
68            ms,
69            mf,
70            as_,
71            af,
72            e_austenite,
73            e_martensite,
74            h_max,
75        }
76    }
77
78    /// Create a standard Nitinol (NiTi) SMA with typical reported parameters.
79    ///
80    /// * Ms = 291 K, Mf = 273 K, As = 307 K, Af = 325 K
81    /// * E_A = 75 GPa, E_M = 28 GPa, ε_L = 0.08
82    pub fn new_nitinol() -> Self {
83        Self::new(
84            291.0,  // Ms
85            273.0,  // Mf
86            307.0,  // As
87            325.0,  // Af
88            75.0e9, // E_austenite
89            28.0e9, // E_martensite
90            0.08,   // h_max
91        )
92    }
93
94    /// Alias for [`new_nitinol`](Self::new_nitinol).
95    pub fn nitinol() -> Self {
96        Self::new_nitinol()
97    }
98
99    /// Martensite volume fraction ξ for the given temperature and stress.
100    ///
101    /// Uses the Liang-Rogers cosine model:
102    /// * Cooling (martensitic): ξ = 0.5 · cos(π · (T − Mf)/(Ms − Mf)) + 0.5
103    /// * Heating (austenitic): ξ = 0.5 · cos(π · (T − As)/(Af − As)) + 0.5  (then 1 − value)
104    ///
105    /// The stress shifts the transformation temperatures via the
106    /// Clausius-Clapeyron slope (~10 MPa/K for NiTi).
107    pub fn phase_fraction(&self, temp: f64, stress: f64) -> f64 {
108        // Clausius-Clapeyron stress correction (MPa/K → K shift)
109        let cc_slope = 10.0e6; // Pa/K
110        let dt = stress / cc_slope;
111
112        let ms = self.ms + dt;
113        let mf = self.mf + dt;
114        let as_ = self.as_ + dt;
115        let af = self.af + dt;
116
117        if temp <= mf {
118            // Fully martensitic
119            1.0_f64
120        } else if temp <= ms {
121            // Martensitic transformation
122            0.5 * (PI * (temp - mf) / (ms - mf)).cos() + 0.5
123        } else if temp < as_ {
124            // Stable mixed region between Ms and As
125            1.0_f64
126        } else if temp <= af {
127            // Austenitic transformation
128            1.0 - (0.5 * (PI * (temp - as_) / (af - as_)).cos() + 0.5)
129        } else {
130            // Fully austenitic
131            0.0_f64
132        }
133    }
134
135    /// Effective elastic modulus at a given martensite fraction ξ.
136    ///
137    /// Uses the rule of mixtures: `E(ξ) = E_A + ξ · (E_M − E_A)`.
138    pub fn elastic_modulus(&self, xi: f64) -> f64 {
139        self.e_austenite + xi.clamp(0.0, 1.0) * (self.e_martensite - self.e_austenite)
140    }
141
142    /// One-dimensional constitutive stress response (Pa) for a given mechanical
143    /// strain and temperature.
144    ///
145    /// `σ = E(ξ) · ε − E(ξ) · ε_L · ξ`
146    pub fn constitutive_response(&self, strain: f64, temp: f64) -> f64 {
147        let xi = self.phase_fraction(temp, 0.0);
148        let e = self.elastic_modulus(xi);
149        e * strain - e * self.h_max * xi
150    }
151
152    /// Recoverable strain between two martensite fraction states.
153    ///
154    /// `Δε = h_max · (ξ_start − ξ_end)`
155    pub fn recovery_strain(&self, xi_start: f64, xi_end: f64) -> f64 {
156        self.h_max * (xi_start - xi_end)
157    }
158
159    /// Critical transformation stress at a given temperature.
160    ///
161    /// Uses the Clausius-Clapeyron relation:
162    /// `σ_cr = C_M · (T − Ms)` for T > Ms (stress-induced martensite).
163    /// Returns 0 for T ≤ Ms.
164    pub fn critical_stress(&self, temp: f64) -> f64 {
165        let cm = 10.0e6; // Pa/K, typical NiTi value
166        if temp > self.ms {
167            cm * (temp - self.ms)
168        } else {
169            0.0
170        }
171    }
172}
173
174// ─────────────────────────────────────────────────────────────────────────────
175// BraisbirdAuricchioModel
176// ─────────────────────────────────────────────────────────────────────────────
177
178/// Brinson–Auricchio 3D SMA constitutive model parameters.
179///
180/// Encodes the forward and reverse transformation stress slopes
181/// (Ca for austenite, Cm for martensite) in Pa/K.
182#[derive(Debug, Clone)]
183pub struct BraisbirdAuricchioModel {
184    /// Stress influence coefficient for austenite transformation in Pa/K.
185    pub ca: f64,
186    /// Stress influence coefficient for martensite transformation in Pa/K.
187    pub cm: f64,
188    /// Reference temperature above which austenite is stable \[K\].
189    pub t0: f64,
190    /// Plateau stress offset at the reference temperature \[Pa\].
191    pub sigma0: f64,
192}
193
194impl BraisbirdAuricchioModel {
195    /// Create a new Brinson-Auricchio model.
196    pub fn new(ca: f64, cm: f64, t0: f64, sigma0: f64) -> Self {
197        Self { ca, cm, t0, sigma0 }
198    }
199
200    /// Standard NiTi Brinson-Auricchio parameters.
201    pub fn nitinol() -> Self {
202        Self::new(13.0e6, 8.0e6, 291.0, 100.0e6)
203    }
204
205    /// Forward transformation (austenite → martensite) critical stress.
206    ///
207    /// `σ_fwd = σ₀ + Cm · (T − T₀)`
208    pub fn forward_transformation_stress(&self) -> f64 {
209        self.sigma0
210    }
211
212    /// Reverse transformation (martensite → austenite) critical stress.
213    ///
214    /// The reverse (austenite) plateau stress is lower than the forward
215    /// plateau.  It is estimated as `σ₀ · Cm / Ca` — when Ca > Cm (as in
216    /// typical NiTi), this gives a value smaller than `σ₀`.
217    pub fn reverse_transformation_stress(&self) -> f64 {
218        if self.ca.abs() < 1e-15 {
219            return 0.0;
220        }
221        self.sigma0 * (self.cm / self.ca)
222    }
223}
224
225// ─────────────────────────────────────────────────────────────────────────────
226// ThermoelasticMartensite
227// ─────────────────────────────────────────────────────────────────────────────
228
229/// Thermoelastic martensite model for Clausius-Clapeyron analysis.
230///
231/// Characterises the thermodynamic driving force for martensitic
232/// transformation in terms of the transformation strain and entropy change.
233#[derive(Debug, Clone)]
234pub struct ThermoelasticMartensite {
235    /// Transformation strain ε_L (dimensionless).
236    pub epsilon_l: f64,
237    /// Entropy change per unit volume ΔS \[J/(m³·K)\].
238    pub delta_s: f64,
239}
240
241impl ThermoelasticMartensite {
242    /// Create a new thermoelastic martensite model.
243    pub fn new(epsilon_l: f64, delta_s: f64) -> Self {
244        Self { epsilon_l, delta_s }
245    }
246
247    /// Standard NiTi thermoelastic martensite parameters.
248    pub fn nitinol() -> Self {
249        // ε_L = 0.08; ΔS ≈ −0.22 J/(cm³·K) ≈ −2.2e5 J/(m³·K)
250        Self::new(0.08, -2.2e5)
251    }
252
253    /// Clausius-Clapeyron slope dσ/dT \[Pa/K\].
254    ///
255    /// From thermodynamic equilibrium:
256    /// `dσ/dT = −ΔS / ε_L`
257    pub fn clausius_clapeyron(&self) -> f64 {
258        if self.epsilon_l.abs() < 1e-15 {
259            return 0.0;
260        }
261        -self.delta_s / self.epsilon_l
262    }
263}
264
265// ─────────────────────────────────────────────────────────────────────────────
266// SMAPseudoelasticity
267// ─────────────────────────────────────────────────────────────────────────────
268
269/// Pseudoelastic (superelastic) behaviour of a shape memory alloy.
270///
271/// Describes the stress-strain loop with forward and reverse transformation
272/// plateau stresses and the resulting hysteresis.
273#[derive(Debug, Clone)]
274pub struct SMAPseudoelasticity {
275    /// Upper plateau (loading) stress in Pa.
276    pub sigma_f: f64,
277    /// Lower plateau (unloading) stress in Pa.
278    pub sigma_r: f64,
279    /// Maximum transformation strain ε_L.
280    pub epsilon_l: f64,
281    /// Elastic modulus (austenite) in Pa.
282    pub e_a: f64,
283}
284
285impl SMAPseudoelasticity {
286    /// Create a pseudoelastic model.
287    pub fn new(sigma_f: f64, sigma_r: f64, epsilon_l: f64, e_a: f64) -> Self {
288        Self {
289            sigma_f,
290            sigma_r,
291            epsilon_l,
292            e_a,
293        }
294    }
295
296    /// Standard Nitinol pseudoelastic parameters at 310 K.
297    pub fn nitinol_room_temp() -> Self {
298        Self::new(500.0e6, 200.0e6, 0.06, 75.0e9)
299    }
300
301    /// Forward transformation (loading) plateau stress in Pa.
302    pub fn loading_plateau_stress(&self) -> f64 {
303        self.sigma_f
304    }
305
306    /// Reverse transformation (unloading) plateau stress in Pa.
307    pub fn unloading_plateau_stress(&self) -> f64 {
308        self.sigma_r
309    }
310
311    /// Mechanical energy dissipated per cycle (hysteresis area) in J/m³.
312    ///
313    /// Area of the stress-strain hysteresis loop:
314    /// `W = (σ_f − σ_r) · ε_L`
315    pub fn hysteresis_area(&self) -> f64 {
316        (self.sigma_f - self.sigma_r) * self.epsilon_l
317    }
318
319    /// Stress-strain response during loading at fractional strain `eps / eps_L`.
320    ///
321    /// Returns stress in Pa.  Assumes linear elastic loading up to σ_f,
322    /// then a flat plateau at σ_f until full transformation.
323    pub fn loading_response(&self, eps: f64) -> f64 {
324        let eps_onset = self.sigma_f / self.e_a;
325        if eps <= eps_onset {
326            self.e_a * eps
327        } else {
328            self.sigma_f
329        }
330    }
331
332    /// Stress-strain response during unloading from full transformation.
333    ///
334    /// Returns stress in Pa.
335    pub fn unloading_response(&self, eps: f64) -> f64 {
336        // At full transformation strain eps_L the stress is sigma_f.
337        // On unloading, elastic until sigma_r, then reverse plateau.
338        let eps_end = self.epsilon_l;
339        let eps_reverse_end = (self.sigma_f - self.sigma_r) / self.e_a;
340        let eps_elastic_end = eps_end - eps_reverse_end;
341        if eps >= eps_elastic_end {
342            // Elastic unloading from plateau
343            self.sigma_f - self.e_a * (eps_end - eps)
344        } else {
345            // Reverse transformation plateau
346            self.sigma_r.max(0.0)
347        }
348    }
349}
350
351// ─────────────────────────────────────────────────────────────────────────────
352// TwoWaySMA
353// ─────────────────────────────────────────────────────────────────────────────
354
355/// Two-way shape memory effect (TWSME) model.
356///
357/// After repeated thermomechanical training cycles, SMAs develop an intrinsic
358/// martensite preferred orientation that allows them to actuate bidirectionally
359/// without external stress.
360#[derive(Debug, Clone)]
361pub struct TwoWaySMA {
362    /// Number of completed thermomechanical training cycles.
363    pub training_cycles: usize,
364    /// Maximum trained strain achievable (dimensionless).
365    pub max_trained_strain: f64,
366}
367
368impl TwoWaySMA {
369    /// Create a new two-way SMA model.
370    pub fn new(training_cycles: usize, max_trained_strain: f64) -> Self {
371        Self {
372            training_cycles,
373            max_trained_strain,
374        }
375    }
376
377    /// Standard Nitinol two-way model.
378    pub fn nitinol() -> Self {
379        Self::new(0, 0.04) // typical max TWSME strain ~4%
380    }
381
382    /// Add training cycles and return new total.
383    pub fn train(&mut self, cycles: usize) {
384        self.training_cycles += cycles;
385    }
386
387    /// Recoverable trained strain after `training_cycles` cycles.
388    ///
389    /// The TWSME strain saturates with an empirical logarithmic law:
390    /// `ε_trained = ε_max · (1 − exp(−n / 10))`
391    /// where n is the number of training cycles.
392    pub fn trained_strain(&self) -> f64 {
393        let n = self.training_cycles as f64;
394        self.max_trained_strain * (1.0 - (-n / 10.0).exp())
395    }
396
397    /// Fraction of maximum trained strain achieved.
398    pub fn saturation_fraction(&self) -> f64 {
399        if self.max_trained_strain.abs() < 1e-15 {
400            return 0.0;
401        }
402        self.trained_strain() / self.max_trained_strain
403    }
404}
405
406// ─────────────────────────────────────────────────────────────────────────────
407// Nitinol phase diagram
408// ─────────────────────────────────────────────────────────────────────────────
409
410/// Return the four characteristic transformation temperatures for NiTi Nitinol.
411///
412/// The return value is `[(label_temp, stress_free_temp); 4]` for
413/// `[Ms, Mf, As, Af]` in kelvin at zero stress.
414///
415/// Values are typical for equiatomic Nitinol near room temperature.
416pub fn nitinol_phase_diagram() -> [(f64, f64); 4] {
417    [
418        (1.0, 291.0), // Ms  — martensite start
419        (2.0, 273.0), // Mf  — martensite finish
420        (3.0, 307.0), // As  — austenite start
421        (4.0, 325.0), // Af  — austenite finish
422    ]
423}
424
425// ─────────────────────────────────────────────────────────────────────────────
426// Helper: smoothstep between two phases
427// ─────────────────────────────────────────────────────────────────────────────
428
429/// Smooth cosine interpolation used in transformation models, ∈ \[0, 1\].
430fn cosine_interpolate(x: f64) -> f64 {
431    0.5 * (1.0 - (PI * x).cos())
432}
433
434/// Effective thermal conductivity of an SMA biphasic mixture \[W/(m·K)\].
435///
436/// Uses Voigt (rule of mixtures) averaging:
437/// `k = ξ · k_M + (1 − ξ) · k_A`
438pub fn thermal_conductivity(xi: f64, k_martensite: f64, k_austenite: f64) -> f64 {
439    let xi = xi.clamp(0.0, 1.0);
440    xi * k_martensite + (1.0 - xi) * k_austenite
441}
442
443// ─────────────────────────────────────────────────────────────────────────────
444// Unit tests
445// ─────────────────────────────────────────────────────────────────────────────
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    // ── ShapeMemoryAlloy ─────────────────────────────────────────────────────
452
453    #[test]
454    fn test_nitinol_temperatures() {
455        let sma = ShapeMemoryAlloy::new_nitinol();
456        assert!(sma.ms > sma.mf, "Ms must be above Mf");
457        assert!(sma.af > sma.as_, "Af must be above As");
458        assert!(sma.as_ > sma.ms, "As must be above Ms for NiTi");
459    }
460
461    #[test]
462    fn test_phase_fraction_fully_martensitic() {
463        let sma = ShapeMemoryAlloy::new_nitinol();
464        // Below Mf: fully martensitic
465        let xi = sma.phase_fraction(250.0, 0.0);
466        assert!(
467            (xi - 1.0).abs() < 1e-10,
468            "should be xi=1 below Mf, got {xi}"
469        );
470    }
471
472    #[test]
473    fn test_phase_fraction_fully_austenitic() {
474        let sma = ShapeMemoryAlloy::new_nitinol();
475        // Above Af: fully austenitic
476        let xi = sma.phase_fraction(340.0, 0.0);
477        assert!(xi < 0.01, "should be xi≈0 above Af, got {xi}");
478    }
479
480    #[test]
481    fn test_phase_fraction_transition_region() {
482        let sma = ShapeMemoryAlloy::new_nitinol();
483        // Between Mf and Ms: partially transformed
484        let xi = sma.phase_fraction(282.0, 0.0);
485        assert!(
486            xi > 0.0 && xi < 1.0,
487            "xi should be in (0,1) in transformation region, got {xi}"
488        );
489    }
490
491    #[test]
492    fn test_elastic_modulus_pure_austenite() {
493        let sma = ShapeMemoryAlloy::new_nitinol();
494        let e = sma.elastic_modulus(0.0);
495        assert!(
496            (e - sma.e_austenite).abs() < 1.0,
497            "pure austenite modulus wrong"
498        );
499    }
500
501    #[test]
502    fn test_elastic_modulus_pure_martensite() {
503        let sma = ShapeMemoryAlloy::new_nitinol();
504        let e = sma.elastic_modulus(1.0);
505        assert!(
506            (e - sma.e_martensite).abs() < 1.0,
507            "pure martensite modulus wrong"
508        );
509    }
510
511    #[test]
512    fn test_elastic_modulus_mixed() {
513        let sma = ShapeMemoryAlloy::new_nitinol();
514        let e = sma.elastic_modulus(0.5);
515        let expected = 0.5 * (sma.e_austenite + sma.e_martensite);
516        assert!((e - expected).abs() < 1.0, "mixed modulus wrong");
517    }
518
519    #[test]
520    fn test_constitutive_response_sign() {
521        let sma = ShapeMemoryAlloy::new_nitinol();
522        // Positive strain at high temperature (austenitic) should give positive stress
523        let sigma = sma.constitutive_response(0.01, 350.0);
524        assert!(
525            sigma > 0.0,
526            "positive strain should give positive stress in austenite"
527        );
528    }
529
530    #[test]
531    fn test_recovery_strain_positive() {
532        let sma = ShapeMemoryAlloy::new_nitinol();
533        let eps = sma.recovery_strain(1.0, 0.0);
534        assert!(
535            (eps - sma.h_max).abs() < 1e-10,
536            "full phase recovery should give h_max"
537        );
538    }
539
540    #[test]
541    fn test_recovery_strain_zero_change() {
542        let sma = ShapeMemoryAlloy::new_nitinol();
543        let eps = sma.recovery_strain(0.5, 0.5);
544        assert!(eps.abs() < 1e-10, "no phase change → zero recovery strain");
545    }
546
547    #[test]
548    fn test_critical_stress_above_ms() {
549        let sma = ShapeMemoryAlloy::new_nitinol();
550        // At T > Ms the critical stress should be positive
551        let sigma = sma.critical_stress(310.0);
552        assert!(sigma > 0.0, "critical stress should be positive above Ms");
553    }
554
555    #[test]
556    fn test_critical_stress_below_ms() {
557        let sma = ShapeMemoryAlloy::new_nitinol();
558        let sigma = sma.critical_stress(250.0);
559        assert!(sigma == 0.0, "critical stress should be zero below Ms");
560    }
561
562    // ── BraisbirdAuricchioModel ───────────────────────────────────────────────
563
564    #[test]
565    fn test_braisbird_forward_stress() {
566        let m = BraisbirdAuricchioModel::nitinol();
567        let sf = m.forward_transformation_stress();
568        assert!(sf > 0.0, "forward transformation stress must be positive");
569    }
570
571    #[test]
572    fn test_braisbird_reverse_stress() {
573        let m = BraisbirdAuricchioModel::nitinol();
574        let sr = m.reverse_transformation_stress();
575        let sf = m.forward_transformation_stress();
576        // Reverse stress should be less than forward for typical SMAs
577        assert!(sr <= sf, "reverse stress should not exceed forward stress");
578    }
579
580    #[test]
581    fn test_braisbird_nitinol_ca_cm() {
582        let m = BraisbirdAuricchioModel::nitinol();
583        assert!(m.ca > 0.0);
584        assert!(m.cm > 0.0);
585    }
586
587    // ── ThermoelasticMartensite ───────────────────────────────────────────────
588
589    #[test]
590    fn test_clausius_clapeyron_sign() {
591        let tm = ThermoelasticMartensite::nitinol();
592        let ds_dt = tm.clausius_clapeyron();
593        // For NiTi, dσ/dT > 0 (stress increases with temperature above Ms)
594        assert!(
595            ds_dt > 0.0,
596            "Clausius-Clapeyron slope should be positive for NiTi, got {ds_dt}"
597        );
598    }
599
600    #[test]
601    fn test_clausius_clapeyron_magnitude() {
602        let tm = ThermoelasticMartensite::nitinol();
603        let ds_dt = tm.clausius_clapeyron();
604        // Typical NiTi: ~6-8 MPa/K
605        assert!(
606            ds_dt > 1.0e6 && ds_dt < 50.0e6,
607            "CC slope should be in the MPa/K range, got {ds_dt}"
608        );
609    }
610
611    #[test]
612    fn test_clausius_clapeyron_zero_strain() {
613        let tm = ThermoelasticMartensite::new(0.0, -2.2e5);
614        assert_eq!(tm.clausius_clapeyron(), 0.0);
615    }
616
617    // ── SMAPseudoelasticity ───────────────────────────────────────────────────
618
619    #[test]
620    fn test_pseudoelastic_loading_plateau() {
621        let pe = SMAPseudoelasticity::nitinol_room_temp();
622        assert!((pe.loading_plateau_stress() - 500.0e6).abs() < 1.0);
623    }
624
625    #[test]
626    fn test_pseudoelastic_unloading_plateau() {
627        let pe = SMAPseudoelasticity::nitinol_room_temp();
628        assert!((pe.unloading_plateau_stress() - 200.0e6).abs() < 1.0);
629    }
630
631    #[test]
632    fn test_pseudoelastic_hysteresis_positive() {
633        let pe = SMAPseudoelasticity::nitinol_room_temp();
634        let area = pe.hysteresis_area();
635        assert!(area > 0.0, "hysteresis area should be positive");
636    }
637
638    #[test]
639    fn test_pseudoelastic_hysteresis_magnitude() {
640        let pe = SMAPseudoelasticity::nitinol_room_temp();
641        // (500e6 - 200e6) * 0.06 = 18e6 J/m³
642        let expected = (500.0e6 - 200.0e6) * 0.06;
643        assert!((pe.hysteresis_area() - expected).abs() < 1.0);
644    }
645
646    #[test]
647    fn test_pseudoelastic_loading_elastic() {
648        let pe = SMAPseudoelasticity::nitinol_room_temp();
649        // Small strain: elastic regime
650        let eps = 0.001;
651        let sigma = pe.loading_response(eps);
652        let expected = pe.e_a * eps;
653        assert!((sigma - expected).abs() / expected < 1e-9);
654    }
655
656    #[test]
657    fn test_pseudoelastic_loading_plateau_region() {
658        let pe = SMAPseudoelasticity::nitinol_room_temp();
659        // Large strain: plateau
660        let eps = 0.05;
661        let sigma = pe.loading_response(eps);
662        assert!((sigma - pe.sigma_f).abs() < 1.0);
663    }
664
665    // ── TwoWaySMA ────────────────────────────────────────────────────────────
666
667    #[test]
668    fn test_twsma_zero_cycles() {
669        let sma = TwoWaySMA::new(0, 0.04);
670        assert!(sma.trained_strain() < 1e-10, "no training → zero strain");
671    }
672
673    #[test]
674    fn test_twsma_increases_with_cycles() {
675        let sma1 = TwoWaySMA::new(10, 0.04);
676        let sma2 = TwoWaySMA::new(50, 0.04);
677        assert!(
678            sma2.trained_strain() > sma1.trained_strain(),
679            "more training cycles should give more strain"
680        );
681    }
682
683    #[test]
684    fn test_twsma_saturates() {
685        let sma = TwoWaySMA::new(1000, 0.04);
686        // After many cycles, should be very close to max
687        assert!(
688            sma.saturation_fraction() > 0.99,
689            "should saturate near 1.0 after many cycles"
690        );
691    }
692
693    #[test]
694    fn test_twsma_train_method() {
695        let mut sma = TwoWaySMA::new(0, 0.04);
696        sma.train(20);
697        assert_eq!(sma.training_cycles, 20);
698        let eps = sma.trained_strain();
699        assert!(eps > 0.0);
700    }
701
702    // ── Nitinol phase diagram ────────────────────────────────────────────────
703
704    #[test]
705    fn test_nitinol_phase_diagram_temps() {
706        let pd = nitinol_phase_diagram();
707        // Check order: Mf < Ms < As < Af
708        let mf = pd[1].1;
709        let ms = pd[0].1;
710        let as_ = pd[2].1;
711        let af = pd[3].1;
712        assert!(mf < ms, "Mf should be below Ms");
713        assert!(ms < as_, "Ms should be below As");
714        assert!(as_ < af, "As should be below Af");
715    }
716
717    #[test]
718    fn test_nitinol_phase_diagram_length() {
719        assert_eq!(nitinol_phase_diagram().len(), 4);
720    }
721
722    // ── Helper functions ─────────────────────────────────────────────────────
723
724    #[test]
725    fn test_cosine_interpolate_bounds() {
726        assert!((cosine_interpolate(0.0) - 0.0).abs() < 1e-10);
727        assert!((cosine_interpolate(1.0) - 1.0).abs() < 1e-10);
728        assert!((cosine_interpolate(0.5) - 0.5).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_thermal_conductivity_pure_phases() {
733        let k = thermal_conductivity(0.0, 10.0, 20.0);
734        assert!((k - 20.0).abs() < 1e-10, "pure austenite should give k_A");
735        let k = thermal_conductivity(1.0, 10.0, 20.0);
736        assert!((k - 10.0).abs() < 1e-10, "pure martensite should give k_M");
737    }
738
739    #[test]
740    fn test_thermal_conductivity_mixed() {
741        let k = thermal_conductivity(0.5, 10.0, 20.0);
742        assert!((k - 15.0).abs() < 1e-10, "mixed should give average");
743    }
744
745    #[test]
746    fn test_thermal_conductivity_clamps() {
747        // xi > 1 should clamp to 1
748        let k = thermal_conductivity(2.0, 10.0, 20.0);
749        assert!((k - 10.0).abs() < 1e-10);
750        // xi < 0 should clamp to 0
751        let k2 = thermal_conductivity(-1.0, 10.0, 20.0);
752        assert!((k2 - 20.0).abs() < 1e-10);
753    }
754}