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}