#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
use std::f64::consts::PI;
const DEFAULT_CC_SLOPE: f64 = 6.5e6;
const DEFAULT_LATENT_HEAT: f64 = 24_000.0;
const DEFAULT_DENSITY: f64 = 6450.0;
const DEFAULT_SPECIFIC_HEAT: f64 = 837.0;
const EPS: f64 = 1.0e-15;
#[derive(Debug, Clone, Copy)]
pub struct TransformationTemperatures {
pub ms: f64,
pub mf: f64,
pub as_: f64,
pub af: f64,
}
impl TransformationTemperatures {
pub fn new(ms: f64, mf: f64, as_: f64, af: f64) -> Self {
Self { ms, mf, as_, af }
}
pub fn shifted(&self, stress: f64, cm: f64, ca: f64) -> Self {
let dt_m = if cm.abs() > EPS { stress / cm } else { 0.0 };
let dt_a = if ca.abs() > EPS { stress / ca } else { 0.0 };
Self {
ms: self.ms + dt_m,
mf: self.mf + dt_m,
as_: self.as_ + dt_a,
af: self.af + dt_a,
}
}
pub fn martensite_range(&self) -> f64 {
(self.ms - self.mf).abs()
}
pub fn austenite_range(&self) -> f64 {
(self.af - self.as_).abs()
}
pub fn hysteresis(&self) -> f64 {
self.af - self.ms
}
}
#[derive(Debug, Clone, Copy)]
pub struct SmaPhaseState {
pub xi: f64,
pub xi_s: f64,
pub xi_t: f64,
pub temperature: f64,
pub stress: f64,
pub strain: f64,
pub transformation_strain: f64,
}
impl SmaPhaseState {
pub fn new(temperature: f64) -> Self {
Self {
xi: 0.0,
xi_s: 0.0,
xi_t: 0.0,
temperature,
stress: 0.0,
strain: 0.0,
transformation_strain: 0.0,
}
}
pub fn fully_martensite(temperature: f64) -> Self {
Self {
xi: 1.0,
xi_s: 0.0,
xi_t: 1.0,
temperature,
stress: 0.0,
strain: 0.0,
transformation_strain: 0.0,
}
}
pub fn fully_austenite(temperature: f64) -> Self {
Self::new(temperature)
}
}
#[derive(Debug, Clone)]
pub struct TanakaModel {
pub temps: TransformationTemperatures,
pub a_m: f64,
pub b_m: f64,
pub a_a: f64,
pub b_a: f64,
pub h_max: f64,
pub e_a: f64,
pub e_m: f64,
pub theta: f64,
}
impl TanakaModel {
pub fn new(
temps: TransformationTemperatures,
cm: f64,
ca: f64,
h_max: f64,
e_a: f64,
e_m: f64,
theta: f64,
) -> Self {
let dm = temps.ms - temps.mf;
let da = temps.af - temps.as_;
let a_m = if dm.abs() > EPS {
-2.0 * (0.01_f64).ln() / dm
} else {
0.0
};
let a_a = if da.abs() > EPS {
-2.0 * (0.01_f64).ln() / da
} else {
0.0
};
let b_m = if cm.abs() > EPS { -a_m / cm } else { 0.0 };
let b_a = if ca.abs() > EPS { -a_a / ca } else { 0.0 };
Self {
temps,
a_m,
b_m,
a_a,
b_a,
h_max,
e_a,
e_m,
theta,
}
}
pub fn forward_fraction(&self, temp: f64, stress: f64) -> f64 {
let arg = self.a_m * (self.temps.ms - temp) + self.b_m * stress;
(1.0 - (-arg).exp()).clamp(0.0, 1.0)
}
pub fn reverse_fraction(&self, temp: f64, stress: f64) -> f64 {
let arg = self.a_a * (self.temps.as_ - temp) + self.b_a * stress;
arg.exp().clamp(0.0, 1.0)
}
pub fn phase_fraction(&self, temp: f64, stress: f64) -> f64 {
let shifted = self
.temps
.shifted(stress, DEFAULT_CC_SLOPE, DEFAULT_CC_SLOPE);
if temp <= shifted.ms {
self.forward_fraction(temp, stress)
} else if temp >= shifted.as_ {
self.reverse_fraction(temp, stress)
} else {
self.forward_fraction(temp, stress)
}
}
pub fn effective_modulus(&self, xi: f64) -> f64 {
self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
}
pub fn stress_response(&self, strain: f64, temp: f64, xi: f64, temp_ref: f64) -> f64 {
let e = self.effective_modulus(xi);
e * (strain - self.h_max * xi) + self.theta * (temp - temp_ref)
}
}
#[derive(Debug, Clone)]
pub struct LiangRogersModel {
pub temps: TransformationTemperatures,
pub a_m: f64,
pub b_m: f64,
pub a_a: f64,
pub b_a: f64,
pub h_max: f64,
pub e_a: f64,
pub e_m: f64,
pub theta: f64,
}
impl LiangRogersModel {
pub fn new(
temps: TransformationTemperatures,
cm: f64,
ca: f64,
h_max: f64,
e_a: f64,
e_m: f64,
theta: f64,
) -> Self {
let dm = temps.ms - temps.mf;
let da = temps.af - temps.as_;
let a_m = if dm.abs() > EPS { PI / dm } else { 0.0 };
let a_a = if da.abs() > EPS { PI / da } else { 0.0 };
let b_m = if cm.abs() > EPS { -a_m / cm } else { 0.0 };
let b_a = if ca.abs() > EPS { -a_a / ca } else { 0.0 };
Self {
temps,
a_m,
b_m,
a_a,
b_a,
h_max,
e_a,
e_m,
theta,
}
}
pub fn forward_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
let arg = self.a_m * (temp - self.temps.mf) + self.b_m * stress;
let cos_val = arg.cos().clamp(-1.0, 1.0);
let xi = (1.0 - xi_prev) / 2.0 * cos_val + (1.0 + xi_prev) / 2.0;
xi.clamp(0.0, 1.0)
}
pub fn reverse_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
let arg = self.a_a * (temp - self.temps.as_) + self.b_a * stress;
let cos_val = arg.cos().clamp(-1.0, 1.0);
let xi = xi_prev / 2.0 * (cos_val + 1.0);
xi.clamp(0.0, 1.0)
}
pub fn phase_fraction(&self, temp: f64, stress: f64, xi_prev: f64) -> f64 {
let shifted = self
.temps
.shifted(stress, DEFAULT_CC_SLOPE, DEFAULT_CC_SLOPE);
if temp <= shifted.ms && temp >= shifted.mf {
self.forward_fraction(temp, stress, xi_prev)
} else if temp >= shifted.as_ && temp <= shifted.af {
self.reverse_fraction(temp, stress, xi_prev)
} else if temp < shifted.mf {
1.0
} else if temp > shifted.af {
0.0
} else {
xi_prev
}
}
pub fn effective_modulus(&self, xi: f64) -> f64 {
self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
}
pub fn stress_response(&self, strain: f64, temp: f64, xi: f64, temp_ref: f64) -> f64 {
let e = self.effective_modulus(xi);
e * (strain - self.h_max * xi) + self.theta * (temp - temp_ref)
}
}
#[derive(Debug, Clone)]
pub struct BrinsonModel {
pub temps: TransformationTemperatures,
pub cm: f64,
pub ca: f64,
pub h_max: f64,
pub e_a: f64,
pub e_m: f64,
pub theta: f64,
pub sigma_crit_s: f64,
pub sigma_crit_f: f64,
}
impl BrinsonModel {
pub fn new(
temps: TransformationTemperatures,
cm: f64,
ca: f64,
h_max: f64,
e_a: f64,
e_m: f64,
theta: f64,
sigma_crit_s: f64,
sigma_crit_f: f64,
) -> Self {
Self {
temps,
cm,
ca,
h_max,
e_a,
e_m,
theta,
sigma_crit_s,
sigma_crit_f,
}
}
pub fn effective_modulus(&self, xi: f64) -> f64 {
self.e_a + xi.clamp(0.0, 1.0) * (self.e_m - self.e_a)
}
pub fn critical_stresses_at_temp(&self, temp: f64) -> (f64, f64) {
let sigma_ms = if temp > self.temps.ms {
self.cm * (temp - self.temps.ms) + self.sigma_crit_s
} else {
self.sigma_crit_s
};
let sigma_mf = if temp > self.temps.ms {
self.cm * (temp - self.temps.ms) + self.sigma_crit_f
} else {
self.sigma_crit_f
};
(sigma_ms, sigma_mf)
}
pub fn xi_s_loading(&self, temp: f64, stress: f64, xi_s_prev: f64, _xi_t_prev: f64) -> f64 {
let (sig_s, sig_f) = self.critical_stresses_at_temp(temp);
if stress < sig_s || stress > sig_f {
return xi_s_prev;
}
let dsig = sig_f - sig_s;
if dsig.abs() < EPS {
return xi_s_prev;
}
let cos_val = (PI / dsig * (stress - sig_f)).cos();
let xi_s = (1.0 - xi_s_prev) / 2.0 * cos_val + (1.0 + xi_s_prev) / 2.0;
xi_s.clamp(0.0, 1.0)
}
pub fn xi_t_cooling(&self, temp: f64, stress: f64, xi_t_prev: f64) -> f64 {
let shifted = self.temps.shifted(stress, self.cm, self.ca);
if temp > shifted.ms || temp < shifted.mf {
return xi_t_prev;
}
let dm = shifted.ms - shifted.mf;
if dm.abs() < EPS {
return xi_t_prev;
}
let cos_val = (PI * (temp - shifted.mf) / dm).cos();
let xi_t = (1.0 - xi_t_prev) / 2.0 * cos_val + (1.0 + xi_t_prev) / 2.0;
xi_t.clamp(0.0, 1.0)
}
pub fn reverse_fractions(
&self,
temp: f64,
stress: f64,
xi_s_prev: f64,
xi_t_prev: f64,
) -> (f64, f64) {
let shifted = self.temps.shifted(stress, self.cm, self.ca);
if temp < shifted.as_ || temp > shifted.af {
return (xi_s_prev, xi_t_prev);
}
let da = shifted.af - shifted.as_;
if da.abs() < EPS {
return (xi_s_prev, xi_t_prev);
}
let xi_prev = xi_s_prev + xi_t_prev;
let cos_val = (PI * (temp - shifted.as_) / da).cos();
let xi_new = xi_prev / 2.0 * (cos_val + 1.0);
let xi_new = xi_new.clamp(0.0, 1.0);
let ratio = if xi_prev > EPS { xi_new / xi_prev } else { 0.0 };
(xi_s_prev * ratio, xi_t_prev * ratio)
}
pub fn stress_response(
&self,
strain: f64,
xi_s: f64,
xi_t: f64,
temp: f64,
temp_ref: f64,
) -> f64 {
let xi = (xi_s + xi_t).clamp(0.0, 1.0);
let e = self.effective_modulus(xi);
e * (strain - self.h_max * xi_s) + self.theta * (temp - temp_ref)
}
pub fn update_state(&self, state: &mut SmaPhaseState, strain_new: f64, temp_new: f64) {
let _temp_old = state.temperature;
let stress_est = self.stress_response(
strain_new,
state.xi_s,
state.xi_t,
temp_new,
state.temperature,
);
let shifted = self.temps.shifted(stress_est, self.cm, self.ca);
if temp_new <= shifted.ms && temp_new >= shifted.mf {
let xi_s_new = self.xi_s_loading(temp_new, stress_est, state.xi_s, state.xi_t);
let xi_t_new = self.xi_t_cooling(temp_new, stress_est, state.xi_t);
state.xi_s = xi_s_new;
state.xi_t = xi_t_new;
} else if temp_new >= shifted.as_ && temp_new <= shifted.af {
let (xi_s_new, xi_t_new) =
self.reverse_fractions(temp_new, stress_est, state.xi_s, state.xi_t);
state.xi_s = xi_s_new;
state.xi_t = xi_t_new;
}
state.xi = (state.xi_s + state.xi_t).clamp(0.0, 1.0);
state.strain = strain_new;
state.temperature = temp_new;
state.transformation_strain = self.h_max * state.xi_s;
state.stress = self.stress_response(strain_new, state.xi_s, state.xi_t, temp_new, temp_new);
}
}
#[derive(Debug, Clone)]
pub struct SuperelasticCurveGenerator {
pub model: BrinsonModel,
pub temperature: f64,
pub resolution: usize,
}
impl SuperelasticCurveGenerator {
pub fn new(model: BrinsonModel, temperature: f64, resolution: usize) -> Self {
Self {
model,
temperature,
resolution,
}
}
pub fn loading_curve(&self, max_strain: f64) -> Vec<(f64, f64)> {
let n = self.resolution.max(2);
let mut result = Vec::with_capacity(n);
let mut state = SmaPhaseState::new(self.temperature);
for i in 0..n {
let eps = max_strain * (i as f64) / (n as f64 - 1.0);
self.model.update_state(&mut state, eps, self.temperature);
result.push((eps, state.stress));
}
result
}
pub fn unloading_curve(&self, state_at_peak: &SmaPhaseState) -> Vec<(f64, f64)> {
let n = self.resolution.max(2);
let mut result = Vec::with_capacity(n);
let peak_strain = state_at_peak.strain;
let mut state = *state_at_peak;
for i in 0..n {
let eps = peak_strain * (1.0 - (i as f64) / (n as f64 - 1.0));
self.model.update_state(&mut state, eps, self.temperature);
result.push((eps, state.stress.max(0.0)));
}
result
}
pub fn full_loop(&self, max_strain: f64) -> Vec<(f64, f64)> {
let loading = self.loading_curve(max_strain);
let last_state = {
let mut s = SmaPhaseState::new(self.temperature);
for &(eps, _) in &loading {
self.model.update_state(&mut s, eps, self.temperature);
}
s
};
let unloading = self.unloading_curve(&last_state);
let mut result = loading;
result.extend(unloading);
result
}
}
#[derive(Debug, Clone)]
pub struct ShapeMemoryEffect {
pub model: BrinsonModel,
pub deform_temperature: f64,
pub recovery_temperature: f64,
}
impl ShapeMemoryEffect {
pub fn new(model: BrinsonModel, deform_temp: f64, recovery_temp: f64) -> Self {
Self {
model,
deform_temperature: deform_temp,
recovery_temperature: recovery_temp,
}
}
pub fn simulate_recovery(&self, applied_strain: f64) -> (f64, f64) {
let mut state = SmaPhaseState::fully_martensite(self.deform_temperature);
self.model
.update_state(&mut state, applied_strain, self.deform_temperature);
let residual_before = state.transformation_strain;
self.model
.update_state(&mut state, 0.0, self.deform_temperature);
let _strain_after_unload = state.strain;
self.model
.update_state(&mut state, 0.0, self.recovery_temperature);
let residual_after = state.transformation_strain;
(residual_before, residual_after)
}
pub fn recovery_ratio(&self, applied_strain: f64) -> f64 {
let (before, after) = self.simulate_recovery(applied_strain);
if before.abs() < EPS {
return 0.0;
}
((before - after) / before).clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone)]
pub struct TwoWayShapeMemory {
pub model: BrinsonModel,
pub two_way_fraction: f64,
pub training_cycles: u32,
pub max_two_way_fraction: f64,
pub saturation_rate: f64,
}
impl TwoWayShapeMemory {
pub fn new(model: BrinsonModel) -> Self {
Self {
model,
two_way_fraction: 0.0,
training_cycles: 0,
max_two_way_fraction: 0.4,
saturation_rate: 0.1,
}
}
pub fn train_cycle(&mut self) {
self.training_cycles += 1;
let n = self.training_cycles as f64;
self.two_way_fraction =
self.max_two_way_fraction * (1.0 - (-self.saturation_rate * n).exp());
}
pub fn cooling_strain(&self, temp: f64) -> f64 {
let xi = if temp <= self.model.temps.mf {
1.0
} else if temp >= self.model.temps.ms {
0.0
} else {
let dm = self.model.temps.ms - self.model.temps.mf;
if dm.abs() < EPS {
0.0
} else {
0.5 * (PI * (temp - self.model.temps.mf) / dm).cos() + 0.5
}
};
self.two_way_fraction * self.model.h_max * xi
}
pub fn heating_strain(&self, temp: f64) -> f64 {
let xi = if temp >= self.model.temps.af {
0.0
} else if temp <= self.model.temps.as_ {
1.0
} else {
let da = self.model.temps.af - self.model.temps.as_;
if da.abs() < EPS {
0.0
} else {
0.5 * (PI * (temp - self.model.temps.as_) / da).cos() + 0.5
}
};
self.two_way_fraction * self.model.h_max * xi
}
}
#[derive(Debug, Clone)]
pub struct TrainingEffect {
pub cycles: u32,
pub residual_strains: Vec<f64>,
pub plateau_stresses: Vec<f64>,
pub base_residual: f64,
pub saturated_residual: f64,
pub rate_residual: f64,
pub base_plateau: f64,
pub plateau_drop_per_decade: f64,
}
impl TrainingEffect {
pub fn new() -> Self {
Self {
cycles: 0,
residual_strains: Vec::new(),
plateau_stresses: Vec::new(),
base_residual: 0.005,
saturated_residual: 0.02,
rate_residual: 0.05,
base_plateau: 500.0e6,
plateau_drop_per_decade: 20.0e6,
}
}
}
impl Default for TrainingEffect {
fn default() -> Self {
Self::new()
}
}
impl TrainingEffect {
pub fn add_cycle(&mut self) {
self.cycles += 1;
let n = self.cycles as f64;
let eps_r =
self.saturated_residual * (1.0 - (-self.rate_residual * n).exp()) + self.base_residual;
self.residual_strains.push(eps_r);
let sigma_p = self.base_plateau - self.plateau_drop_per_decade * n.ln().max(0.0);
self.plateau_stresses.push(sigma_p.max(0.0));
}
pub fn current_residual(&self) -> f64 {
self.residual_strains.last().copied().unwrap_or(0.0)
}
pub fn current_plateau_stress(&self) -> f64 {
self.plateau_stresses
.last()
.copied()
.unwrap_or(self.base_plateau)
}
}
#[derive(Debug, Clone)]
pub struct ThermomechanicalCoupling {
pub density: f64,
pub specific_heat: f64,
pub latent_heat: f64,
pub h_conv: f64,
pub sa_ratio: f64,
pub ambient_temp: f64,
}
impl ThermomechanicalCoupling {
pub fn new() -> Self {
Self {
density: DEFAULT_DENSITY,
specific_heat: DEFAULT_SPECIFIC_HEAT,
latent_heat: DEFAULT_LATENT_HEAT,
h_conv: 10.0,
sa_ratio: 200.0,
ambient_temp: 300.0,
}
}
}
impl Default for ThermomechanicalCoupling {
fn default() -> Self {
Self::new()
}
}
impl ThermomechanicalCoupling {
pub fn temperature_increment(&self, d_xi: f64) -> f64 {
-self.latent_heat / self.specific_heat * d_xi
}
pub fn update_temperature(&self, temp: f64, d_xi: f64, dt: f64) -> f64 {
let q_latent = -self.latent_heat * self.density * d_xi / dt;
let q_conv = self.h_conv * self.sa_ratio * (self.ambient_temp - temp);
let dt_temp = (q_latent + q_conv) / (self.density * self.specific_heat) * dt;
temp + dt_temp
}
pub fn adiabatic_temperature_change(&self, d_xi: f64) -> f64 {
self.temperature_increment(d_xi)
}
}
#[derive(Debug, Clone, Copy)]
pub struct ClausiusClapeyron {
pub cm: f64,
pub ca: f64,
}
impl ClausiusClapeyron {
pub fn symmetric(slope: f64) -> Self {
Self {
cm: slope,
ca: slope,
}
}
pub fn new(cm: f64, ca: f64) -> Self {
Self { cm, ca }
}
pub fn stress_shift_martensite(&self, delta_t: f64) -> f64 {
self.cm * delta_t
}
pub fn stress_shift_austenite(&self, delta_t: f64) -> f64 {
self.ca * delta_t
}
pub fn temp_shift_martensite(&self, stress: f64) -> f64 {
if self.cm.abs() > EPS {
stress / self.cm
} else {
0.0
}
}
pub fn temp_shift_austenite(&self, stress: f64) -> f64 {
if self.ca.abs() > EPS {
stress / self.ca
} else {
0.0
}
}
}
#[derive(Debug, Clone)]
pub struct NiTiPreset;
impl NiTiPreset {
pub fn temperatures() -> TransformationTemperatures {
TransformationTemperatures::new(291.0, 271.0, 295.0, 315.0)
}
pub fn brinson() -> BrinsonModel {
BrinsonModel::new(
Self::temperatures(),
6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6, 100.0e6, 170.0e6, )
}
pub fn tanaka() -> TanakaModel {
let temps = Self::temperatures();
TanakaModel::new(temps, 6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6)
}
pub fn liang_rogers() -> LiangRogersModel {
let temps = Self::temperatures();
LiangRogersModel::new(temps, 6.5e6, 6.5e6, 0.067, 67.0e9, 26.3e9, 0.55e6)
}
pub fn clausius_clapeyron() -> ClausiusClapeyron {
ClausiusClapeyron::symmetric(6.5e6)
}
pub fn thermomechanical() -> ThermomechanicalCoupling {
ThermomechanicalCoupling {
density: 6450.0,
specific_heat: 837.0,
latent_heat: 24_000.0,
h_conv: 10.0,
sa_ratio: 200.0,
ambient_temp: 300.0,
}
}
}
#[derive(Debug, Clone)]
pub struct CuAlNiPreset;
impl CuAlNiPreset {
pub fn temperatures() -> TransformationTemperatures {
TransformationTemperatures::new(291.0, 253.0, 295.0, 323.0)
}
pub fn brinson() -> BrinsonModel {
BrinsonModel::new(
Self::temperatures(),
4.5e6, 4.5e6, 0.05, 85.0e9, 70.0e9, 0.4e6, 80.0e6, 150.0e6, )
}
pub fn tanaka() -> TanakaModel {
TanakaModel::new(
Self::temperatures(),
4.5e6,
4.5e6,
0.05,
85.0e9,
70.0e9,
0.4e6,
)
}
pub fn clausius_clapeyron() -> ClausiusClapeyron {
ClausiusClapeyron::symmetric(4.5e6)
}
}
#[derive(Debug, Clone)]
pub struct CuZnAlPreset;
impl CuZnAlPreset {
pub fn temperatures() -> TransformationTemperatures {
TransformationTemperatures::new(280.0, 255.0, 295.0, 310.0)
}
pub fn brinson() -> BrinsonModel {
BrinsonModel::new(
Self::temperatures(),
5.0e6, 5.0e6, 0.04, 72.0e9, 52.0e9, 0.35e6, 90.0e6, 160.0e6, )
}
pub fn tanaka() -> TanakaModel {
TanakaModel::new(
Self::temperatures(),
5.0e6,
5.0e6,
0.04,
72.0e9,
52.0e9,
0.35e6,
)
}
pub fn clausius_clapeyron() -> ClausiusClapeyron {
ClausiusClapeyron::symmetric(5.0e6)
}
}
pub fn hysteresis_energy(loading: &[(f64, f64)], unloading: &[(f64, f64)]) -> f64 {
let area_load = trapz_area(loading);
let area_unload = trapz_area(unloading);
(area_load - area_unload).abs()
}
#[allow(dead_code)]
fn trapz_area(curve: &[(f64, f64)]) -> f64 {
if curve.len() < 2 {
return 0.0;
}
let mut area = 0.0;
for w in curve.windows(2) {
let (x0, y0) = w[0];
let (x1, y1) = w[1];
area += 0.5 * (y0 + y1) * (x1 - x0);
}
area.abs()
}
#[allow(dead_code)]
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + t * (b - a)
}
pub fn temperature_sweep_recovery(
model: &BrinsonModel,
initial_strain: f64,
t_start: f64,
t_end: f64,
n_steps: usize,
) -> Vec<(f64, f64)> {
let n = n_steps.max(2);
let mut result = Vec::with_capacity(n);
let mut state = SmaPhaseState::fully_martensite(t_start);
state.strain = initial_strain;
state.transformation_strain = model.h_max;
for i in 0..n {
let t_frac = i as f64 / (n as f64 - 1.0);
let temp = t_start + t_frac * (t_end - t_start);
model.update_state(&mut state, 0.0, temp);
result.push((temp, state.transformation_strain));
}
result
}
pub fn secant_modulus(strain: f64, stress: f64) -> f64 {
if strain.abs() < EPS {
0.0
} else {
stress / strain
}
}
pub fn tangent_modulus(strain0: f64, stress0: f64, strain1: f64, stress1: f64) -> f64 {
let de = strain1 - strain0;
if de.abs() < EPS {
0.0
} else {
(stress1 - stress0) / de
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1.0e-6;
fn niti_brinson() -> BrinsonModel {
NiTiPreset::brinson()
}
fn niti_temps() -> TransformationTemperatures {
NiTiPreset::temperatures()
}
#[test]
fn test_transformation_temperatures_basic() {
let tt = niti_temps();
assert!(tt.ms > tt.mf, "Ms > Mf required");
assert!(tt.af > tt.as_, "Af > As required");
assert!(tt.hysteresis() > 0.0, "Hysteresis should be positive");
}
#[test]
fn test_temperature_shift() {
let tt = niti_temps();
let stress = 100.0e6; let shifted = tt.shifted(stress, 6.5e6, 6.5e6);
let expected_shift = 100.0e6 / 6.5e6;
assert!((shifted.ms - tt.ms - expected_shift).abs() < TOL);
assert!((shifted.af - tt.af - expected_shift).abs() < TOL);
}
#[test]
fn test_tanaka_full_martensite() {
let model = NiTiPreset::tanaka();
let xi = model.forward_fraction(200.0, 0.0); assert!(xi > 0.99, "Should be nearly fully martensite, got {xi}");
}
#[test]
fn test_tanaka_full_austenite() {
let model = NiTiPreset::tanaka();
let xi = model.reverse_fraction(400.0, 0.0); assert!(xi < 0.01, "Should be nearly fully austenite, got {xi}");
}
#[test]
fn test_tanaka_modulus_austenite() {
let model = NiTiPreset::tanaka();
let e = model.effective_modulus(0.0);
assert!((e - 67.0e9).abs() < TOL, "E at xi=0 should be E_A");
}
#[test]
fn test_tanaka_modulus_martensite() {
let model = NiTiPreset::tanaka();
let e = model.effective_modulus(1.0);
assert!((e - 26.3e9).abs() < TOL, "E at xi=1 should be E_M");
}
#[test]
fn test_liang_rogers_mf() {
let model = NiTiPreset::liang_rogers();
let xi = model.phase_fraction(model.temps.mf, 0.0, 0.0);
assert!(
(xi - 1.0).abs() < 0.01,
"At Mf, xi should be ~1.0, got {xi}"
);
}
#[test]
fn test_liang_rogers_above_af() {
let model = NiTiPreset::liang_rogers();
let xi = model.phase_fraction(model.temps.af + 50.0, 0.0, 1.0);
assert!(xi < 0.01, "Above Af, xi should be ~0, got {xi}");
}
#[test]
fn test_liang_rogers_reverse_at_af() {
let model = NiTiPreset::liang_rogers();
let xi = model.reverse_fraction(model.temps.af, 0.0, 1.0);
assert!(xi < 0.05, "At Af with xi_prev=1, xi should be ~0, got {xi}");
}
#[test]
fn test_brinson_modulus_interpolation() {
let model = niti_brinson();
let e_half = model.effective_modulus(0.5);
let expected = 0.5 * 67.0e9 + 0.5 * 26.3e9;
assert!(
(e_half - expected).abs() < TOL,
"E at xi=0.5 should be average, got {e_half}"
);
}
#[test]
fn test_brinson_critical_stress_vs_temp() {
let model = niti_brinson();
let (sig_s_low, _) = model.critical_stresses_at_temp(model.temps.ms);
let (sig_s_high, _) = model.critical_stresses_at_temp(model.temps.ms + 20.0);
assert!(
sig_s_high > sig_s_low,
"Critical stress should increase with T"
);
}
#[test]
fn test_brinson_zero_stress_at_origin() {
let model = niti_brinson();
let sigma = model.stress_response(0.0, 0.0, 0.0, 300.0, 300.0);
assert!(
sigma.abs() < TOL,
"Stress at origin should be zero, got {sigma}"
);
}
#[test]
fn test_brinson_state_update_cooling() {
let model = niti_brinson();
let mut state = SmaPhaseState::new(model.temps.af + 10.0);
let target_temp = (model.temps.mf + model.temps.ms) / 2.0;
model.update_state(&mut state, 0.001, target_temp);
assert!(
state.xi > 0.0,
"xi should increase during cooling, got {}",
state.xi
);
}
#[test]
fn test_superelastic_loading_nonempty() {
let model = niti_brinson();
let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 50);
let curve = curve_gen.loading_curve(0.06);
assert!(!curve.is_empty(), "Loading curve should be non-empty");
assert_eq!(curve.len(), 50);
}
#[test]
fn test_superelastic_loading_trend() {
let model = niti_brinson();
let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 100);
let curve = curve_gen.loading_curve(0.05);
let first_nonzero = curve.iter().find(|(_, s)| *s > 0.0);
let last = curve.last().unwrap();
if let Some(first) = first_nonzero {
assert!(
last.1 >= first.1,
"Final stress should exceed initial: first={:.6}, last={:.6}",
first.1,
last.1
);
}
}
#[test]
fn test_superelastic_full_loop_length() {
let model = niti_brinson();
let curve_gen = SuperelasticCurveGenerator::new(model, 350.0, 30);
let loop_curve = curve_gen.full_loop(0.05);
assert!(
loop_curve.len() > 30,
"Full loop should have loading + unloading points"
);
}
#[test]
fn test_sme_recovery_ratio_nonneg() {
let model = niti_brinson();
let sme = ShapeMemoryEffect::new(model, 250.0, 350.0);
let ratio = sme.recovery_ratio(0.05);
assert!(ratio >= 0.0, "Recovery ratio should be >= 0, got {ratio}");
}
#[test]
fn test_twsm_training() {
let model = niti_brinson();
let mut twsm = TwoWayShapeMemory::new(model);
assert!(twsm.two_way_fraction < TOL, "Initially should be ~0");
for _ in 0..20 {
twsm.train_cycle();
}
assert!(
twsm.two_way_fraction > 0.1,
"After training, fraction should grow, got {}",
twsm.two_way_fraction
);
}
#[test]
fn test_twsm_cooling_strain() {
let model = niti_brinson();
let mut twsm = TwoWayShapeMemory::new(model.clone());
for _ in 0..50 {
twsm.train_cycle();
}
let eps = twsm.cooling_strain(model.temps.mf - 10.0);
assert!(eps > 0.0, "Cooling strain should be > 0, got {eps}");
}
#[test]
fn test_twsm_heating_above_af() {
let model = niti_brinson();
let mut twsm = TwoWayShapeMemory::new(model.clone());
for _ in 0..10 {
twsm.train_cycle();
}
let eps = twsm.heating_strain(model.temps.af + 20.0);
assert!(
eps.abs() < TOL,
"Heating strain above Af should be ~0, got {eps}"
);
}
#[test]
fn test_training_residual_accumulates() {
let mut te = TrainingEffect::new();
te.add_cycle();
let r1 = te.current_residual();
for _ in 0..50 {
te.add_cycle();
}
let r51 = te.current_residual();
assert!(r51 > r1, "Residual should grow: r1={r1}, r51={r51}");
}
#[test]
fn test_training_plateau_decreases() {
let mut te = TrainingEffect::new();
te.add_cycle();
let s1 = te.current_plateau_stress();
for _ in 0..100 {
te.add_cycle();
}
let s101 = te.current_plateau_stress();
assert!(
s101 < s1,
"Plateau stress should decrease: s1={s1}, s101={s101}"
);
}
#[test]
fn test_thermo_forward_heats() {
let tmc = NiTiPreset::thermomechanical();
let dt = tmc.temperature_increment(0.5); assert!(dt.abs() > 0.0, "Temperature change should be nonzero");
}
#[test]
fn test_thermo_no_change() {
let tmc = NiTiPreset::thermomechanical();
let dt = tmc.temperature_increment(0.0);
assert!(dt.abs() < TOL, "No phase change → no dT");
}
#[test]
fn test_cc_symmetric() {
let cc = ClausiusClapeyron::symmetric(6.5e6);
assert!(
(cc.cm - cc.ca).abs() < TOL,
"Symmetric slopes should be equal"
);
}
#[test]
fn test_cc_stress_shift() {
let cc = ClausiusClapeyron::new(6.5e6, 7.0e6);
let ds = cc.stress_shift_martensite(10.0);
let expected = 6.5e6 * 10.0;
assert!((ds - expected).abs() < TOL, "Stress shift incorrect");
}
#[test]
fn test_cualni_preset() {
let model = CuAlNiPreset::brinson();
assert!(model.e_a > model.e_m, "E_A > E_M for CuAlNi");
assert!(model.h_max > 0.0, "h_max should be positive");
}
#[test]
fn test_cuznal_preset() {
let model = CuZnAlPreset::brinson();
let tt = CuZnAlPreset::temperatures();
assert!(tt.af > tt.ms, "Af > Ms required");
assert!(model.h_max > 0.0);
}
#[test]
fn test_hysteresis_energy_zero() {
let curve = vec![(0.0, 0.0), (0.01, 100.0e6), (0.02, 200.0e6)];
let e = hysteresis_energy(&curve, &curve);
assert!(e < TOL, "Same loading/unloading → zero hysteresis");
}
#[test]
fn test_hysteresis_energy_rectangle() {
let loading = vec![(0.0, 100.0), (1.0, 100.0)];
let unloading = vec![(0.0, 50.0), (1.0, 50.0)];
let e = hysteresis_energy(&loading, &unloading);
assert!(
(e - 50.0).abs() < TOL,
"Rectangle hysteresis should be 50.0, got {e}"
);
}
#[test]
fn test_secant_modulus() {
let m = secant_modulus(0.01, 700.0e6);
assert!((m - 70.0e9).abs() < 1.0, "Secant modulus should be E");
}
#[test]
fn test_tangent_modulus() {
let m = tangent_modulus(0.01, 700.0e6, 0.02, 1400.0e6);
assert!((m - 70.0e9).abs() < 1.0, "Tangent modulus should be 70 GPa");
}
#[test]
fn test_temp_sweep_length() {
let model = niti_brinson();
let sweep = temperature_sweep_recovery(&model, 0.05, 250.0, 350.0, 20);
assert_eq!(sweep.len(), 20);
}
#[test]
fn test_brinson_reverse_decreases() {
let model = niti_brinson();
let mid_temp = (model.temps.as_ + model.temps.af) / 2.0;
let (xs, xt) = model.reverse_fractions(mid_temp, 0.0, 0.5, 0.5);
assert!(xs + xt < 1.0, "Reverse should decrease total xi");
}
#[test]
fn test_phase_state_full_martensite() {
let state = SmaPhaseState::fully_martensite(250.0);
assert!((state.xi - 1.0).abs() < TOL);
assert!((state.xi_t - 1.0).abs() < TOL);
}
}