1#![allow(dead_code)]
21#![allow(clippy::too_many_arguments)]
22
23use std::f64::consts::PI;
24
25const DEFAULT_CC_SLOPE: f64 = 6.5e6;
31
32const DEFAULT_LATENT_HEAT: f64 = 24_000.0;
34
35const DEFAULT_DENSITY: f64 = 6450.0;
37
38const DEFAULT_SPECIFIC_HEAT: f64 = 837.0;
40
41const EPS: f64 = 1.0e-15;
43
44#[derive(Debug, Clone, Copy)]
52pub struct TransformationTemperatures {
53 pub ms: f64,
55 pub mf: f64,
57 pub as_: f64,
59 pub af: f64,
61}
62
63impl TransformationTemperatures {
64 pub fn new(ms: f64, mf: f64, as_: f64, af: f64) -> Self {
66 Self { ms, mf, as_, af }
67 }
68
69 pub fn shifted(&self, stress: f64, cm: f64, ca: f64) -> Self {
73 let dt_m = if cm.abs() > EPS { stress / cm } else { 0.0 };
74 let dt_a = if ca.abs() > EPS { stress / ca } else { 0.0 };
75 Self {
76 ms: self.ms + dt_m,
77 mf: self.mf + dt_m,
78 as_: self.as_ + dt_a,
79 af: self.af + dt_a,
80 }
81 }
82
83 pub fn martensite_range(&self) -> f64 {
85 (self.ms - self.mf).abs()
86 }
87
88 pub fn austenite_range(&self) -> f64 {
90 (self.af - self.as_).abs()
91 }
92
93 pub fn hysteresis(&self) -> f64 {
95 self.af - self.ms
96 }
97}
98
99#[derive(Debug, Clone, Copy)]
105pub struct SmaPhaseState {
106 pub xi: f64,
108 pub xi_s: f64,
110 pub xi_t: f64,
112 pub temperature: f64,
114 pub stress: f64,
116 pub strain: f64,
118 pub transformation_strain: f64,
120}
121
122impl SmaPhaseState {
123 pub fn new(temperature: f64) -> Self {
125 Self {
126 xi: 0.0,
127 xi_s: 0.0,
128 xi_t: 0.0,
129 temperature,
130 stress: 0.0,
131 strain: 0.0,
132 transformation_strain: 0.0,
133 }
134 }
135
136 pub fn fully_martensite(temperature: f64) -> Self {
138 Self {
139 xi: 1.0,
140 xi_s: 0.0,
141 xi_t: 1.0,
142 temperature,
143 stress: 0.0,
144 strain: 0.0,
145 transformation_strain: 0.0,
146 }
147 }
148
149 pub fn fully_austenite(temperature: f64) -> Self {
151 Self::new(temperature)
152 }
153}
154
155#[derive(Debug, Clone)]
169pub struct TanakaModel {
170 pub temps: TransformationTemperatures,
172 pub a_m: f64,
174 pub b_m: f64,
176 pub a_a: f64,
178 pub b_a: f64,
180 pub h_max: f64,
182 pub e_a: f64,
184 pub e_m: f64,
186 pub theta: f64,
188}
189
190impl TanakaModel {
191 pub fn new(
202 temps: TransformationTemperatures,
203 cm: f64,
204 ca: f64,
205 h_max: f64,
206 e_a: f64,
207 e_m: f64,
208 theta: f64,
209 ) -> Self {
210 let dm = temps.ms - temps.mf;
211 let da = temps.af - temps.as_;
212 let a_m = if dm.abs() > EPS {
213 -2.0 * (0.01_f64).ln() / dm
214 } else {
215 0.0
216 };
217 let a_a = if da.abs() > EPS {
218 -2.0 * (0.01_f64).ln() / da
219 } else {
220 0.0
221 };
222 let b_m = if cm.abs() > EPS { -a_m / cm } else { 0.0 };
223 let b_a = if ca.abs() > EPS { -a_a / ca } else { 0.0 };
224 Self {
225 temps,
226 a_m,
227 b_m,
228 a_a,
229 b_a,
230 h_max,
231 e_a,
232 e_m,
233 theta,
234 }
235 }
236
237 pub fn forward_fraction(&self, temp: f64, stress: f64) -> f64 {
239 let arg = self.a_m * (self.temps.ms - temp) + self.b_m * stress;
240 (1.0 - (-arg).exp()).clamp(0.0, 1.0)
241 }
242
243 pub fn reverse_fraction(&self, temp: f64, stress: f64) -> f64 {
245 let arg = self.a_a * (self.temps.as_ - temp) + self.b_a * stress;
246 arg.exp().clamp(0.0, 1.0)
247 }
248
249 pub fn phase_fraction(&self, temp: f64, stress: f64) -> f64 {
254 let shifted = self
255 .temps
256 .shifted(stress, DEFAULT_CC_SLOPE, DEFAULT_CC_SLOPE);
257 if temp <= shifted.ms {
258 self.forward_fraction(temp, stress)
259 } else if temp >= shifted.as_ {
260 self.reverse_fraction(temp, stress)
261 } else {
262 self.forward_fraction(temp, stress)
264 }
265 }
266
267 pub fn effective_modulus(&self, xi: f64) -> f64 {
269 self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
270 }
271
272 pub fn stress_response(&self, strain: f64, temp: f64, xi: f64, temp_ref: f64) -> f64 {
274 let e = self.effective_modulus(xi);
275 e * (strain - self.h_max * xi) + self.theta * (temp - temp_ref)
276 }
277}
278
279#[derive(Debug, Clone)]
292pub struct LiangRogersModel {
293 pub temps: TransformationTemperatures,
295 pub a_m: f64,
297 pub b_m: f64,
299 pub a_a: f64,
301 pub b_a: f64,
303 pub h_max: f64,
305 pub e_a: f64,
307 pub e_m: f64,
309 pub theta: f64,
311}
312
313impl LiangRogersModel {
314 pub fn new(
316 temps: TransformationTemperatures,
317 cm: f64,
318 ca: f64,
319 h_max: f64,
320 e_a: f64,
321 e_m: f64,
322 theta: f64,
323 ) -> Self {
324 let dm = temps.ms - temps.mf;
325 let da = temps.af - temps.as_;
326 let a_m = if dm.abs() > EPS { PI / dm } else { 0.0 };
327 let a_a = if da.abs() > EPS { PI / da } else { 0.0 };
328 let b_m = if cm.abs() > EPS { -a_m / cm } else { 0.0 };
329 let b_a = if ca.abs() > EPS { -a_a / ca } else { 0.0 };
330 Self {
331 temps,
332 a_m,
333 b_m,
334 a_a,
335 b_a,
336 h_max,
337 e_a,
338 e_m,
339 theta,
340 }
341 }
342
343 pub fn forward_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
345 let arg = self.a_m * (temp - self.temps.mf) + self.b_m * stress;
346 let cos_val = arg.cos().clamp(-1.0, 1.0);
347 let xi = (1.0 - xi_prev) / 2.0 * cos_val + (1.0 + xi_prev) / 2.0;
348 xi.clamp(0.0, 1.0)
349 }
350
351 pub fn reverse_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
353 let arg = self.a_a * (temp - self.temps.as_) + self.b_a * stress;
354 let cos_val = arg.cos().clamp(-1.0, 1.0);
355 let xi = xi_prev / 2.0 * (cos_val + 1.0);
356 xi.clamp(0.0, 1.0)
357 }
358
359 pub fn phase_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
361 let shifted = self
362 .temps
363 .shifted(stress, DEFAULT_CC_SLOPE, DEFAULT_CC_SLOPE);
364 if temp <= shifted.ms && temp >= shifted.mf {
365 self.forward_fraction(temp, stress, xi_prev)
366 } else if temp >= shifted.as_ && temp <= shifted.af {
367 self.reverse_fraction(temp, stress, xi_prev)
368 } else if temp < shifted.mf {
369 1.0
370 } else if temp > shifted.af {
371 0.0
372 } else {
373 xi_prev
374 }
375 }
376
377 pub fn effective_modulus(&self, xi: f64) -> f64 {
379 self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
380 }
381
382 pub fn stress_response(&self, strain: f64, temp: f64, xi: f64, temp_ref: f64) -> f64 {
384 let e = self.effective_modulus(xi);
385 e * (strain - self.h_max * xi) + self.theta * (temp - temp_ref)
386 }
387}
388
389#[derive(Debug, Clone)]
401pub struct BrinsonModel {
402 pub temps: TransformationTemperatures,
404 pub cm: f64,
406 pub ca: f64,
408 pub h_max: f64,
410 pub e_a: f64,
412 pub e_m: f64,
414 pub theta: f64,
416 pub sigma_crit_s: f64,
418 pub sigma_crit_f: f64,
420}
421
422impl BrinsonModel {
423 pub fn new(
425 temps: TransformationTemperatures,
426 cm: f64,
427 ca: f64,
428 h_max: f64,
429 e_a: f64,
430 e_m: f64,
431 theta: f64,
432 sigma_crit_s: f64,
433 sigma_crit_f: f64,
434 ) -> Self {
435 Self {
436 temps,
437 cm,
438 ca,
439 h_max,
440 e_a,
441 e_m,
442 theta,
443 sigma_crit_s,
444 sigma_crit_f,
445 }
446 }
447
448 pub fn effective_modulus(&self, xi: f64) -> f64 {
450 self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
451 }
452
453 pub fn critical_stresses_at_temp(&self, temp: f64) -> (f64, f64) {
458 let sigma_ms = if temp > self.temps.ms {
459 self.cm * (temp - self.temps.ms) + self.sigma_crit_s
460 } else {
461 self.sigma_crit_s
462 };
463 let sigma_mf = if temp > self.temps.ms {
464 self.cm * (temp - self.temps.ms) + self.sigma_crit_f
465 } else {
466 self.sigma_crit_f
467 };
468 (sigma_ms, sigma_mf)
469 }
470
471 pub fn xi_s_loading(&self, temp: f64, stress: f64, xi_s_prev: f64, _xi_t_prev: f64) -> f64 {
476 let (sig_s, sig_f) = self.critical_stresses_at_temp(temp);
477 if stress < sig_s || stress > sig_f {
478 return xi_s_prev;
479 }
480 let dsig = sig_f - sig_s;
481 if dsig.abs() < EPS {
482 return xi_s_prev;
483 }
484 let cos_val = (PI / dsig * (stress - sig_f)).cos();
485 let xi_s = (1.0 - xi_s_prev) / 2.0 * cos_val + (1.0 + xi_s_prev) / 2.0;
486 xi_s.clamp(0.0, 1.0)
487 }
488
489 pub fn xi_t_cooling(&self, temp: f64, stress: f64, xi_t_prev: f64) -> f64 {
491 let shifted = self.temps.shifted(stress, self.cm, self.ca);
492 if temp > shifted.ms || temp < shifted.mf {
493 return xi_t_prev;
494 }
495 let dm = shifted.ms - shifted.mf;
496 if dm.abs() < EPS {
497 return xi_t_prev;
498 }
499 let cos_val = (PI * (temp - shifted.mf) / dm).cos();
500 let xi_t = (1.0 - xi_t_prev) / 2.0 * cos_val + (1.0 + xi_t_prev) / 2.0;
501 xi_t.clamp(0.0, 1.0)
502 }
503
504 pub fn reverse_fractions(
506 &self,
507 temp: f64,
508 stress: f64,
509 xi_s_prev: f64,
510 xi_t_prev: f64,
511 ) -> (f64, f64) {
512 let shifted = self.temps.shifted(stress, self.cm, self.ca);
513 if temp < shifted.as_ || temp > shifted.af {
514 return (xi_s_prev, xi_t_prev);
515 }
516 let da = shifted.af - shifted.as_;
517 if da.abs() < EPS {
518 return (xi_s_prev, xi_t_prev);
519 }
520 let xi_prev = xi_s_prev + xi_t_prev;
521 let cos_val = (PI * (temp - shifted.as_) / da).cos();
522 let xi_new = xi_prev / 2.0 * (cos_val + 1.0);
523 let xi_new = xi_new.clamp(0.0, 1.0);
524 let ratio = if xi_prev > EPS { xi_new / xi_prev } else { 0.0 };
526 (xi_s_prev * ratio, xi_t_prev * ratio)
527 }
528
529 pub fn stress_response(
531 &self,
532 strain: f64,
533 xi_s: f64,
534 xi_t: f64,
535 temp: f64,
536 temp_ref: f64,
537 ) -> f64 {
538 let xi = (xi_s + xi_t).clamp(0.0, 1.0);
539 let e = self.effective_modulus(xi);
540 e * (strain - self.h_max * xi_s) + self.theta * (temp - temp_ref)
541 }
542
543 pub fn update_state(&self, state: &mut SmaPhaseState, strain_new: f64, temp_new: f64) {
545 let _temp_old = state.temperature;
546 let stress_est = self.stress_response(
547 strain_new,
548 state.xi_s,
549 state.xi_t,
550 temp_new,
551 state.temperature,
552 );
553
554 let shifted = self.temps.shifted(stress_est, self.cm, self.ca);
556 if temp_new <= shifted.ms && temp_new >= shifted.mf {
557 let xi_s_new = self.xi_s_loading(temp_new, stress_est, state.xi_s, state.xi_t);
559 let xi_t_new = self.xi_t_cooling(temp_new, stress_est, state.xi_t);
560 state.xi_s = xi_s_new;
561 state.xi_t = xi_t_new;
562 } else if temp_new >= shifted.as_ && temp_new <= shifted.af {
563 let (xi_s_new, xi_t_new) =
565 self.reverse_fractions(temp_new, stress_est, state.xi_s, state.xi_t);
566 state.xi_s = xi_s_new;
567 state.xi_t = xi_t_new;
568 }
569
570 state.xi = (state.xi_s + state.xi_t).clamp(0.0, 1.0);
571 state.strain = strain_new;
572 state.temperature = temp_new;
573 state.transformation_strain = self.h_max * state.xi_s;
574 state.stress = self.stress_response(strain_new, state.xi_s, state.xi_t, temp_new, temp_new);
575 }
576}
577
578#[derive(Debug, Clone)]
591pub struct SuperelasticCurveGenerator {
592 pub model: BrinsonModel,
594 pub temperature: f64,
596 pub resolution: usize,
598}
599
600impl SuperelasticCurveGenerator {
601 pub fn new(model: BrinsonModel, temperature: f64, resolution: usize) -> Self {
603 Self {
604 model,
605 temperature,
606 resolution,
607 }
608 }
609
610 pub fn loading_curve(&self, max_strain: f64) -> Vec<(f64, f64)> {
614 let n = self.resolution.max(2);
615 let mut result = Vec::with_capacity(n);
616 let mut state = SmaPhaseState::new(self.temperature);
617 for i in 0..n {
618 let eps = max_strain * (i as f64) / (n as f64 - 1.0);
619 self.model.update_state(&mut state, eps, self.temperature);
620 result.push((eps, state.stress));
621 }
622 result
623 }
624
625 pub fn unloading_curve(&self, state_at_peak: &SmaPhaseState) -> Vec<(f64, f64)> {
627 let n = self.resolution.max(2);
628 let mut result = Vec::with_capacity(n);
629 let peak_strain = state_at_peak.strain;
630 let mut state = *state_at_peak;
631 for i in 0..n {
632 let eps = peak_strain * (1.0 - (i as f64) / (n as f64 - 1.0));
633 self.model.update_state(&mut state, eps, self.temperature);
635 result.push((eps, state.stress.max(0.0)));
636 }
637 result
638 }
639
640 pub fn full_loop(&self, max_strain: f64) -> Vec<(f64, f64)> {
642 let loading = self.loading_curve(max_strain);
643 let last_state = {
644 let mut s = SmaPhaseState::new(self.temperature);
645 for &(eps, _) in &loading {
646 self.model.update_state(&mut s, eps, self.temperature);
647 }
648 s
649 };
650 let unloading = self.unloading_curve(&last_state);
651 let mut result = loading;
652 result.extend(unloading);
653 result
654 }
655}
656
657#[derive(Debug, Clone)]
666pub struct ShapeMemoryEffect {
667 pub model: BrinsonModel,
669 pub deform_temperature: f64,
671 pub recovery_temperature: f64,
673}
674
675impl ShapeMemoryEffect {
676 pub fn new(model: BrinsonModel, deform_temp: f64, recovery_temp: f64) -> Self {
678 Self {
679 model,
680 deform_temperature: deform_temp,
681 recovery_temperature: recovery_temp,
682 }
683 }
684
685 pub fn simulate_recovery(&self, applied_strain: f64) -> (f64, f64) {
689 let mut state = SmaPhaseState::fully_martensite(self.deform_temperature);
691 self.model
692 .update_state(&mut state, applied_strain, self.deform_temperature);
693 let residual_before = state.transformation_strain;
694
695 self.model
697 .update_state(&mut state, 0.0, self.deform_temperature);
698 let _strain_after_unload = state.strain;
699
700 self.model
702 .update_state(&mut state, 0.0, self.recovery_temperature);
703 let residual_after = state.transformation_strain;
704
705 (residual_before, residual_after)
706 }
707
708 pub fn recovery_ratio(&self, applied_strain: f64) -> f64 {
710 let (before, after) = self.simulate_recovery(applied_strain);
711 if before.abs() < EPS {
712 return 0.0;
713 }
714 ((before - after) / before).clamp(0.0, 1.0)
715 }
716}
717
718#[derive(Debug, Clone)]
728pub struct TwoWayShapeMemory {
729 pub model: BrinsonModel,
731 pub two_way_fraction: f64,
733 pub training_cycles: u32,
735 pub max_two_way_fraction: f64,
737 pub saturation_rate: f64,
739}
740
741impl TwoWayShapeMemory {
742 pub fn new(model: BrinsonModel) -> Self {
744 Self {
745 model,
746 two_way_fraction: 0.0,
747 training_cycles: 0,
748 max_two_way_fraction: 0.4,
749 saturation_rate: 0.1,
750 }
751 }
752
753 pub fn train_cycle(&mut self) {
755 self.training_cycles += 1;
756 let n = self.training_cycles as f64;
758 self.two_way_fraction =
759 self.max_two_way_fraction * (1.0 - (-self.saturation_rate * n).exp());
760 }
761
762 pub fn cooling_strain(&self, temp: f64) -> f64 {
764 let xi = if temp <= self.model.temps.mf {
765 1.0
766 } else if temp >= self.model.temps.ms {
767 0.0
768 } else {
769 let dm = self.model.temps.ms - self.model.temps.mf;
770 if dm.abs() < EPS {
771 0.0
772 } else {
773 0.5 * (PI * (temp - self.model.temps.mf) / dm).cos() + 0.5
774 }
775 };
776 self.two_way_fraction * self.model.h_max * xi
777 }
778
779 pub fn heating_strain(&self, temp: f64) -> f64 {
781 let xi = if temp >= self.model.temps.af {
782 0.0
783 } else if temp <= self.model.temps.as_ {
784 1.0
785 } else {
786 let da = self.model.temps.af - self.model.temps.as_;
787 if da.abs() < EPS {
788 0.0
789 } else {
790 0.5 * (PI * (temp - self.model.temps.as_) / da).cos() + 0.5
791 }
792 };
793 self.two_way_fraction * self.model.h_max * xi
794 }
795}
796
797#[derive(Debug, Clone)]
809pub struct TrainingEffect {
810 pub cycles: u32,
812 pub residual_strains: Vec<f64>,
814 pub plateau_stresses: Vec<f64>,
816 pub base_residual: f64,
818 pub saturated_residual: f64,
820 pub rate_residual: f64,
822 pub base_plateau: f64,
824 pub plateau_drop_per_decade: f64,
826}
827
828impl TrainingEffect {
829 pub fn new() -> Self {
831 Self {
832 cycles: 0,
833 residual_strains: Vec::new(),
834 plateau_stresses: Vec::new(),
835 base_residual: 0.005,
836 saturated_residual: 0.02,
837 rate_residual: 0.05,
838 base_plateau: 500.0e6,
839 plateau_drop_per_decade: 20.0e6,
840 }
841 }
842}
843impl Default for TrainingEffect {
844 fn default() -> Self {
845 Self::new()
846 }
847}
848impl TrainingEffect {
849 pub fn add_cycle(&mut self) {
851 self.cycles += 1;
852 let n = self.cycles as f64;
853 let eps_r =
855 self.saturated_residual * (1.0 - (-self.rate_residual * n).exp()) + self.base_residual;
856 self.residual_strains.push(eps_r);
857 let sigma_p = self.base_plateau - self.plateau_drop_per_decade * n.ln().max(0.0);
859 self.plateau_stresses.push(sigma_p.max(0.0));
860 }
861
862 pub fn current_residual(&self) -> f64 {
864 self.residual_strains.last().copied().unwrap_or(0.0)
865 }
866
867 pub fn current_plateau_stress(&self) -> f64 {
869 self.plateau_stresses
870 .last()
871 .copied()
872 .unwrap_or(self.base_plateau)
873 }
874}
875
876#[derive(Debug, Clone)]
887pub struct ThermomechanicalCoupling {
888 pub density: f64,
890 pub specific_heat: f64,
892 pub latent_heat: f64,
894 pub h_conv: f64,
896 pub sa_ratio: f64,
898 pub ambient_temp: f64,
900}
901
902impl ThermomechanicalCoupling {
903 pub fn new() -> Self {
905 Self {
906 density: DEFAULT_DENSITY,
907 specific_heat: DEFAULT_SPECIFIC_HEAT,
908 latent_heat: DEFAULT_LATENT_HEAT,
909 h_conv: 10.0,
910 sa_ratio: 200.0,
911 ambient_temp: 300.0,
912 }
913 }
914}
915impl Default for ThermomechanicalCoupling {
916 fn default() -> Self {
917 Self::new()
918 }
919}
920impl ThermomechanicalCoupling {
921 pub fn temperature_increment(&self, d_xi: f64) -> f64 {
926 -self.latent_heat / self.specific_heat * d_xi
927 }
928
929 pub fn update_temperature(&self, temp: f64, d_xi: f64, dt: f64) -> f64 {
931 let q_latent = -self.latent_heat * self.density * d_xi / dt;
932 let q_conv = self.h_conv * self.sa_ratio * (self.ambient_temp - temp);
933 let dt_temp = (q_latent + q_conv) / (self.density * self.specific_heat) * dt;
934 temp + dt_temp
935 }
936
937 pub fn adiabatic_temperature_change(&self, d_xi: f64) -> f64 {
939 self.temperature_increment(d_xi)
940 }
941}
942
943#[derive(Debug, Clone, Copy)]
952pub struct ClausiusClapeyron {
953 pub cm: f64,
955 pub ca: f64,
957}
958
959impl ClausiusClapeyron {
960 pub fn symmetric(slope: f64) -> Self {
962 Self {
963 cm: slope,
964 ca: slope,
965 }
966 }
967
968 pub fn new(cm: f64, ca: f64) -> Self {
970 Self { cm, ca }
971 }
972
973 pub fn stress_shift_martensite(&self, delta_t: f64) -> f64 {
975 self.cm * delta_t
976 }
977
978 pub fn stress_shift_austenite(&self, delta_t: f64) -> f64 {
980 self.ca * delta_t
981 }
982
983 pub fn temp_shift_martensite(&self, stress: f64) -> f64 {
985 if self.cm.abs() > EPS {
986 stress / self.cm
987 } else {
988 0.0
989 }
990 }
991
992 pub fn temp_shift_austenite(&self, stress: f64) -> f64 {
994 if self.ca.abs() > EPS {
995 stress / self.ca
996 } else {
997 0.0
998 }
999 }
1000}
1001
1002#[derive(Debug, Clone)]
1008pub struct NiTiPreset;
1009
1010impl NiTiPreset {
1011 pub fn temperatures() -> TransformationTemperatures {
1013 TransformationTemperatures::new(291.0, 271.0, 295.0, 315.0)
1014 }
1015
1016 pub fn brinson() -> BrinsonModel {
1018 BrinsonModel::new(
1019 Self::temperatures(),
1020 6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6, 100.0e6, 170.0e6, )
1029 }
1030
1031 pub fn tanaka() -> TanakaModel {
1033 let temps = Self::temperatures();
1034 TanakaModel::new(temps, 6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6)
1035 }
1036
1037 pub fn liang_rogers() -> LiangRogersModel {
1039 let temps = Self::temperatures();
1040 LiangRogersModel::new(temps, 6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6)
1041 }
1042
1043 pub fn clausius_clapeyron() -> ClausiusClapeyron {
1045 ClausiusClapeyron::symmetric(6.5e6)
1046 }
1047
1048 pub fn thermomechanical() -> ThermomechanicalCoupling {
1050 ThermomechanicalCoupling {
1051 density: 6450.0,
1052 specific_heat: 837.0,
1053 latent_heat: 24_000.0,
1054 h_conv: 10.0,
1055 sa_ratio: 200.0,
1056 ambient_temp: 300.0,
1057 }
1058 }
1059}
1060
1061#[derive(Debug, Clone)]
1063pub struct CuAlNiPreset;
1064
1065impl CuAlNiPreset {
1066 pub fn temperatures() -> TransformationTemperatures {
1068 TransformationTemperatures::new(291.0, 253.0, 295.0, 323.0)
1069 }
1070
1071 pub fn brinson() -> BrinsonModel {
1073 BrinsonModel::new(
1074 Self::temperatures(),
1075 4.5e6, 4.5e6, 0.05, 85.0e9, 70.0e9, 0.4e6, 80.0e6, 150.0e6, )
1084 }
1085
1086 pub fn tanaka() -> TanakaModel {
1088 TanakaModel::new(
1089 Self::temperatures(),
1090 4.5e6,
1091 4.5e6,
1092 0.05,
1093 85.0e9,
1094 70.0e9,
1095 0.4e6,
1096 )
1097 }
1098
1099 pub fn clausius_clapeyron() -> ClausiusClapeyron {
1101 ClausiusClapeyron::symmetric(4.5e6)
1102 }
1103}
1104
1105#[derive(Debug, Clone)]
1107pub struct CuZnAlPreset;
1108
1109impl CuZnAlPreset {
1110 pub fn temperatures() -> TransformationTemperatures {
1112 TransformationTemperatures::new(280.0, 255.0, 295.0, 310.0)
1113 }
1114
1115 pub fn brinson() -> BrinsonModel {
1117 BrinsonModel::new(
1118 Self::temperatures(),
1119 5.0e6, 5.0e6, 0.04, 72.0e9, 52.0e9, 0.35e6, 90.0e6, 160.0e6, )
1128 }
1129
1130 pub fn tanaka() -> TanakaModel {
1132 TanakaModel::new(
1133 Self::temperatures(),
1134 5.0e6,
1135 5.0e6,
1136 0.04,
1137 72.0e9,
1138 52.0e9,
1139 0.35e6,
1140 )
1141 }
1142
1143 pub fn clausius_clapeyron() -> ClausiusClapeyron {
1145 ClausiusClapeyron::symmetric(5.0e6)
1146 }
1147}
1148
1149pub fn hysteresis_energy(loading: &[(f64, f64)], unloading: &[(f64, f64)]) -> f64 {
1157 let area_load = trapz_area(loading);
1158 let area_unload = trapz_area(unloading);
1159 (area_load - area_unload).abs()
1160}
1161
1162#[allow(dead_code)]
1164fn trapz_area(curve: &[(f64, f64)]) -> f64 {
1165 if curve.len() < 2 {
1166 return 0.0;
1167 }
1168 let mut area = 0.0;
1169 for w in curve.windows(2) {
1170 let (x0, y0) = w[0];
1171 let (x1, y1) = w[1];
1172 area += 0.5 * (y0 + y1) * (x1 - x0);
1173 }
1174 area.abs()
1175}
1176
1177#[allow(dead_code)]
1179fn lerp(a: f64, b: f64, t: f64) -> f64 {
1180 a + t * (b - a)
1181}
1182
1183pub fn temperature_sweep_recovery(
1188 model: &BrinsonModel,
1189 initial_strain: f64,
1190 t_start: f64,
1191 t_end: f64,
1192 n_steps: usize,
1193) -> Vec<(f64, f64)> {
1194 let n = n_steps.max(2);
1195 let mut result = Vec::with_capacity(n);
1196 let mut state = SmaPhaseState::fully_martensite(t_start);
1197 state.strain = initial_strain;
1198 state.transformation_strain = model.h_max;
1199
1200 for i in 0..n {
1201 let t_frac = i as f64 / (n as f64 - 1.0);
1202 let temp = t_start + t_frac * (t_end - t_start);
1203 model.update_state(&mut state, 0.0, temp);
1204 result.push((temp, state.transformation_strain));
1205 }
1206 result
1207}
1208
1209pub fn secant_modulus(strain: f64, stress: f64) -> f64 {
1211 if strain.abs() < EPS {
1212 0.0
1213 } else {
1214 stress / strain
1215 }
1216}
1217
1218pub fn tangent_modulus(strain0: f64, stress0: f64, strain1: f64, stress1: f64) -> f64 {
1220 let de = strain1 - strain0;
1221 if de.abs() < EPS {
1222 0.0
1223 } else {
1224 (stress1 - stress0) / de
1225 }
1226}
1227
1228#[cfg(test)]
1233mod tests {
1234 use super::*;
1235
1236 const TOL: f64 = 1.0e-6;
1237
1238 fn niti_brinson() -> BrinsonModel {
1240 NiTiPreset::brinson()
1241 }
1242
1243 fn niti_temps() -> TransformationTemperatures {
1245 NiTiPreset::temperatures()
1246 }
1247
1248 #[test]
1250 fn test_transformation_temperatures_basic() {
1251 let tt = niti_temps();
1252 assert!(tt.ms > tt.mf, "Ms > Mf required");
1253 assert!(tt.af > tt.as_, "Af > As required");
1254 assert!(tt.hysteresis() > 0.0, "Hysteresis should be positive");
1255 }
1256
1257 #[test]
1259 fn test_temperature_shift() {
1260 let tt = niti_temps();
1261 let stress = 100.0e6; let shifted = tt.shifted(stress, 6.5e6, 6.5e6);
1263 let expected_shift = 100.0e6 / 6.5e6;
1264 assert!((shifted.ms - tt.ms - expected_shift).abs() < TOL);
1265 assert!((shifted.af - tt.af - expected_shift).abs() < TOL);
1266 }
1267
1268 #[test]
1270 fn test_tanaka_full_martensite() {
1271 let model = NiTiPreset::tanaka();
1272 let xi = model.forward_fraction(200.0, 0.0); assert!(xi > 0.99, "Should be nearly fully martensite, got {xi}");
1274 }
1275
1276 #[test]
1278 fn test_tanaka_full_austenite() {
1279 let model = NiTiPreset::tanaka();
1280 let xi = model.reverse_fraction(400.0, 0.0); assert!(xi < 0.01, "Should be nearly fully austenite, got {xi}");
1282 }
1283
1284 #[test]
1286 fn test_tanaka_modulus_austenite() {
1287 let model = NiTiPreset::tanaka();
1288 let e = model.effective_modulus(0.0);
1289 assert!((e - 67.0e9).abs() < TOL, "E at xi=0 should be E_A");
1290 }
1291
1292 #[test]
1294 fn test_tanaka_modulus_martensite() {
1295 let model = NiTiPreset::tanaka();
1296 let e = model.effective_modulus(1.0);
1297 assert!((e - 26.3e9).abs() < TOL, "E at xi=1 should be E_M");
1298 }
1299
1300 #[test]
1302 fn test_liang_rogers_mf() {
1303 let model = NiTiPreset::liang_rogers();
1304 let xi = model.phase_fraction(model.temps.mf, 0.0, 0.0);
1305 assert!(
1306 (xi - 1.0).abs() < 0.01,
1307 "At Mf, xi should be ~1.0, got {xi}"
1308 );
1309 }
1310
1311 #[test]
1313 fn test_liang_rogers_above_af() {
1314 let model = NiTiPreset::liang_rogers();
1315 let xi = model.phase_fraction(model.temps.af + 50.0, 0.0, 1.0);
1316 assert!(xi < 0.01, "Above Af, xi should be ~0, got {xi}");
1317 }
1318
1319 #[test]
1321 fn test_liang_rogers_reverse_at_af() {
1322 let model = NiTiPreset::liang_rogers();
1323 let xi = model.reverse_fraction(model.temps.af, 0.0, 1.0);
1324 assert!(xi < 0.05, "At Af with xi_prev=1, xi should be ~0, got {xi}");
1325 }
1326
1327 #[test]
1329 fn test_brinson_modulus_interpolation() {
1330 let model = niti_brinson();
1331 let e_half = model.effective_modulus(0.5);
1332 let expected = 0.5 * 67.0e9 + 0.5 * 26.3e9;
1333 assert!(
1334 (e_half - expected).abs() < TOL,
1335 "E at xi=0.5 should be average, got {e_half}"
1336 );
1337 }
1338
1339 #[test]
1341 fn test_brinson_critical_stress_vs_temp() {
1342 let model = niti_brinson();
1343 let (sig_s_low, _) = model.critical_stresses_at_temp(model.temps.ms);
1344 let (sig_s_high, _) = model.critical_stresses_at_temp(model.temps.ms + 20.0);
1345 assert!(
1346 sig_s_high > sig_s_low,
1347 "Critical stress should increase with T"
1348 );
1349 }
1350
1351 #[test]
1353 fn test_brinson_zero_stress_at_origin() {
1354 let model = niti_brinson();
1355 let sigma = model.stress_response(0.0, 0.0, 0.0, 300.0, 300.0);
1356 assert!(
1357 sigma.abs() < TOL,
1358 "Stress at origin should be zero, got {sigma}"
1359 );
1360 }
1361
1362 #[test]
1364 fn test_brinson_state_update_cooling() {
1365 let model = niti_brinson();
1366 let mut state = SmaPhaseState::new(model.temps.af + 10.0);
1367 let target_temp = (model.temps.mf + model.temps.ms) / 2.0;
1369 model.update_state(&mut state, 0.001, target_temp);
1370 assert!(
1371 state.xi > 0.0,
1372 "xi should increase during cooling, got {}",
1373 state.xi
1374 );
1375 }
1376
1377 #[test]
1379 fn test_superelastic_loading_nonempty() {
1380 let model = niti_brinson();
1381 let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 50);
1382 let curve = curve_gen.loading_curve(0.06);
1383 assert!(!curve.is_empty(), "Loading curve should be non-empty");
1384 assert_eq!(curve.len(), 50);
1385 }
1386
1387 #[test]
1389 fn test_superelastic_loading_trend() {
1390 let model = niti_brinson();
1391 let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 100);
1392 let curve = curve_gen.loading_curve(0.05);
1393 let first_nonzero = curve.iter().find(|(_, s)| *s > 0.0);
1395 let last = curve.last().unwrap();
1396 if let Some(first) = first_nonzero {
1397 assert!(
1398 last.1 >= first.1,
1399 "Final stress should exceed initial: first={:.6}, last={:.6}",
1400 first.1,
1401 last.1
1402 );
1403 }
1404 }
1405
1406 #[test]
1408 fn test_superelastic_full_loop_length() {
1409 let model = niti_brinson();
1410 let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 30);
1411 let loop_curve = curve_gen.full_loop(0.05);
1412 assert!(
1413 loop_curve.len() > 30,
1414 "Full loop should have loading + unloading points"
1415 );
1416 }
1417
1418 #[test]
1420 fn test_sme_recovery_ratio_nonneg() {
1421 let model = niti_brinson();
1422 let sme = ShapeMemoryEffect::new(model, 250.0, 350.0);
1423 let ratio = sme.recovery_ratio(0.05);
1424 assert!(ratio >= 0.0, "Recovery ratio should be >= 0, got {ratio}");
1425 }
1426
1427 #[test]
1429 fn test_twsm_training() {
1430 let model = niti_brinson();
1431 let mut twsm = TwoWayShapeMemory::new(model);
1432 assert!(twsm.two_way_fraction < TOL, "Initially should be ~0");
1433 for _ in 0..20 {
1434 twsm.train_cycle();
1435 }
1436 assert!(
1437 twsm.two_way_fraction > 0.1,
1438 "After training, fraction should grow, got {}",
1439 twsm.two_way_fraction
1440 );
1441 }
1442
1443 #[test]
1445 fn test_twsm_cooling_strain() {
1446 let model = niti_brinson();
1447 let mut twsm = TwoWayShapeMemory::new(model.clone());
1448 for _ in 0..50 {
1449 twsm.train_cycle();
1450 }
1451 let eps = twsm.cooling_strain(model.temps.mf - 10.0);
1452 assert!(eps > 0.0, "Cooling strain should be > 0, got {eps}");
1453 }
1454
1455 #[test]
1457 fn test_twsm_heating_above_af() {
1458 let model = niti_brinson();
1459 let mut twsm = TwoWayShapeMemory::new(model.clone());
1460 for _ in 0..10 {
1461 twsm.train_cycle();
1462 }
1463 let eps = twsm.heating_strain(model.temps.af + 20.0);
1464 assert!(
1465 eps.abs() < TOL,
1466 "Heating strain above Af should be ~0, got {eps}"
1467 );
1468 }
1469
1470 #[test]
1472 fn test_training_residual_accumulates() {
1473 let mut te = TrainingEffect::new();
1474 te.add_cycle();
1475 let r1 = te.current_residual();
1476 for _ in 0..50 {
1477 te.add_cycle();
1478 }
1479 let r51 = te.current_residual();
1480 assert!(r51 > r1, "Residual should grow: r1={r1}, r51={r51}");
1481 }
1482
1483 #[test]
1485 fn test_training_plateau_decreases() {
1486 let mut te = TrainingEffect::new();
1487 te.add_cycle();
1488 let s1 = te.current_plateau_stress();
1489 for _ in 0..100 {
1490 te.add_cycle();
1491 }
1492 let s101 = te.current_plateau_stress();
1493 assert!(
1494 s101 < s1,
1495 "Plateau stress should decrease: s1={s1}, s101={s101}"
1496 );
1497 }
1498
1499 #[test]
1501 fn test_thermo_forward_heats() {
1502 let tmc = NiTiPreset::thermomechanical();
1503 let dt = tmc.temperature_increment(0.5); assert!(dt.abs() > 0.0, "Temperature change should be nonzero");
1511 }
1512
1513 #[test]
1515 fn test_thermo_no_change() {
1516 let tmc = NiTiPreset::thermomechanical();
1517 let dt = tmc.temperature_increment(0.0);
1518 assert!(dt.abs() < TOL, "No phase change → no dT");
1519 }
1520
1521 #[test]
1523 fn test_cc_symmetric() {
1524 let cc = ClausiusClapeyron::symmetric(6.5e6);
1525 assert!(
1526 (cc.cm - cc.ca).abs() < TOL,
1527 "Symmetric slopes should be equal"
1528 );
1529 }
1530
1531 #[test]
1533 fn test_cc_stress_shift() {
1534 let cc = ClausiusClapeyron::new(6.5e6, 7.0e6);
1535 let ds = cc.stress_shift_martensite(10.0);
1536 let expected = 6.5e6 * 10.0;
1537 assert!((ds - expected).abs() < TOL, "Stress shift incorrect");
1538 }
1539
1540 #[test]
1542 fn test_cualni_preset() {
1543 let model = CuAlNiPreset::brinson();
1544 assert!(model.e_a > model.e_m, "E_A > E_M for CuAlNi");
1545 assert!(model.h_max > 0.0, "h_max should be positive");
1546 }
1547
1548 #[test]
1550 fn test_cuznal_preset() {
1551 let model = CuZnAlPreset::brinson();
1552 let tt = CuZnAlPreset::temperatures();
1553 assert!(tt.af > tt.ms, "Af > Ms required");
1554 assert!(model.h_max > 0.0);
1555 }
1556
1557 #[test]
1559 fn test_hysteresis_energy_zero() {
1560 let curve = vec![(0.0, 0.0), (0.01, 100.0e6), (0.02, 200.0e6)];
1561 let e = hysteresis_energy(&curve, &curve);
1562 assert!(e < TOL, "Same loading/unloading → zero hysteresis");
1563 }
1564
1565 #[test]
1567 fn test_hysteresis_energy_rectangle() {
1568 let loading = vec![(0.0, 100.0), (1.0, 100.0)];
1569 let unloading = vec![(0.0, 50.0), (1.0, 50.0)];
1570 let e = hysteresis_energy(&loading, &unloading);
1571 assert!(
1572 (e - 50.0).abs() < TOL,
1573 "Rectangle hysteresis should be 50.0, got {e}"
1574 );
1575 }
1576
1577 #[test]
1579 fn test_secant_modulus() {
1580 let m = secant_modulus(0.01, 700.0e6);
1581 assert!((m - 70.0e9).abs() < 1.0, "Secant modulus should be E");
1582 }
1583
1584 #[test]
1586 fn test_tangent_modulus() {
1587 let m = tangent_modulus(0.01, 700.0e6, 0.02, 1400.0e6);
1588 assert!((m - 70.0e9).abs() < 1.0, "Tangent modulus should be 70 GPa");
1589 }
1590
1591 #[test]
1593 fn test_temp_sweep_length() {
1594 let model = niti_brinson();
1595 let sweep = temperature_sweep_recovery(&model, 0.05, 250.0, 350.0, 20);
1596 assert_eq!(sweep.len(), 20);
1597 }
1598
1599 #[test]
1601 fn test_brinson_reverse_decreases() {
1602 let model = niti_brinson();
1603 let mid_temp = (model.temps.as_ + model.temps.af) / 2.0;
1604 let (xs, xt) = model.reverse_fractions(mid_temp, 0.0, 0.5, 0.5);
1605 assert!(xs + xt < 1.0, "Reverse should decrease total xi");
1606 }
1607
1608 #[test]
1610 fn test_phase_state_full_martensite() {
1611 let state = SmaPhaseState::fully_martensite(250.0);
1612 assert!((state.xi - 1.0).abs() < TOL);
1613 assert!((state.xi_t - 1.0).abs() < TOL);
1614 }
1615}