Skip to main content

oxiphysics_materials/phase_transform/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use std::f64::consts::E;
6
7// Types are re-exported via mod.rs; only import in test module where needed.
8
9/// Gas constant R (J / mol K).
10pub(super) const R_GAS: f64 = 8.314_462_618;
11/// Avrami (JMAK) isothermal transformation fraction.
12///
13/// X = 1 - exp(-k * t^n)
14///
15/// # Arguments
16/// * `time` - Time (s).
17/// * `n`    - Avrami exponent.
18/// * `k`    - Rate constant (s^-n).
19#[allow(dead_code)]
20pub fn avrami_transformation(time: f64, n: f64, k: f64) -> f64 {
21    if time <= 0.0 {
22        return 0.0;
23    }
24    1.0 - E.powf(-k * time.powf(n))
25}
26/// Estimate transformation time on a CCT (continuous cooling transformation)
27/// diagram via linear interpolation in log-time space.
28///
29/// # Arguments
30/// * `t`       - Temperature of interest (K).
31/// * `t_start` - Start temperature of the CCT nose (K).
32/// * `t_end`   - End temperature of the CCT nose (K).
33/// * `time_0`  - Log-interpolation time at T_start (s).
34/// * `time_f`  - Log-interpolation time at T_end (s).
35/// * `t_ref`   - Reference temperature for interpolation (K).
36#[allow(dead_code)]
37#[allow(clippy::too_many_arguments)]
38pub fn cct_diagram_transformation_time(
39    t: f64,
40    t_start: f64,
41    t_end: f64,
42    time_0: f64,
43    time_f: f64,
44    _t_ref: f64,
45) -> f64 {
46    if (t_end - t_start).abs() < 1e-15 {
47        return time_0;
48    }
49    let frac = (t - t_start) / (t_end - t_start);
50    let frac = frac.clamp(0.0, 1.0);
51    let log_t0 = time_0.max(1e-30).ln();
52    let log_tf = time_f.max(1e-30).ln();
53    (log_t0 + frac * (log_tf - log_t0)).exp()
54}
55/// Landau free energy: F(φ) = a/2 * φ^2 - b/4 * φ^4.
56///
57/// This is a quartic double-well potential when b > 0 and a < 0.
58///
59/// # Arguments
60/// * `phi` - Order parameter φ.
61/// * `a`   - Coefficient of the φ^2 term (negative for broken symmetry).
62/// * `b`   - Coefficient of the φ^4 term (positive for stability).
63#[allow(dead_code)]
64pub fn landau_free_energy(phi: f64, a: f64, b: f64) -> f64 {
65    a / 2.0 * phi * phi - b / 4.0 * phi.powi(4)
66}
67/// Multi-component lever rule for ternary alloy A-B-C.
68///
69/// Given overall composition (x_b, x_c), and end-member compositions of
70/// two co-existing phases α and β, returns the fraction of the β phase.
71#[allow(dead_code)]
72pub fn ternary_lever_rule(
73    x_b_overall: f64,
74    x_c_overall: f64,
75    x_b_alpha: f64,
76    x_c_alpha: f64,
77    x_b_beta: f64,
78    x_c_beta: f64,
79) -> f64 {
80    let db = x_b_beta - x_b_alpha;
81    let dc = x_c_beta - x_c_alpha;
82    let len2 = db * db + dc * dc;
83    if len2 < 1e-60 {
84        return 0.0;
85    }
86    let f = ((x_b_overall - x_b_alpha) * db + (x_c_overall - x_c_alpha) * dc) / len2;
87    f.clamp(0.0, 1.0)
88}
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::AusteniteMartensiteKinetics;
93    use crate::BainiteTransformation;
94    use crate::BrinsonModel;
95    use crate::CahnHilliardField;
96    use crate::CalphadModel;
97    use crate::CctDiagram;
98    use crate::ClausiusClapeyron;
99
100    use crate::JmakExtended;
101    use crate::JmatProModel;
102    use crate::JohnsonMehlAvramiKolmogorov;
103    use crate::KoistinenMarburger;
104    use crate::LandauFreeEnergy;
105    use crate::LatentHeatModel;
106    use crate::LeverRule;
107    use crate::MartensiteTransformation;
108    use crate::PearliteTransformation;
109    use crate::PhaseDependentProperties;
110    use crate::PhaseFieldModel2D;
111    use crate::PhaseFieldOrder;
112    use crate::PhaseFractionEvolution;
113    use crate::PhaseTransformType;
114
115    use crate::SpinocalDecomposition;
116
117    use crate::TransformationPlasticity;
118    use crate::TripEffect;
119
120    use crate::TttDiagram;
121
122    use crate::VolumeFractionTracker;
123    #[test]
124    fn test_landau_order_param_zero_above_tc() {
125        let landau = LandauFreeEnergy::new(1.0, 2.0, 500.0);
126        let eta = landau.equilibrium_order_parameter(600.0);
127        assert!(eta.abs() < 1.0e-12, "Expected eta=0 above Tc, got {eta}");
128    }
129    #[test]
130    fn test_landau_order_param_positive_below_tc() {
131        let landau = LandauFreeEnergy::new(1.0, 2.0, 500.0);
132        let eta = landau.equilibrium_order_parameter(400.0);
133        assert!(eta > 0.0, "Expected eta>0 below Tc, got {eta}");
134        let expected = (50.0_f64).sqrt();
135        assert!(
136            (eta - expected).abs() < 1.0e-10,
137            "Expected eta={expected}, got {eta}"
138        );
139    }
140    #[test]
141    fn test_martensite_fraction_increases_below_ms() {
142        let mt = MartensiteTransformation::new(500.0, 300.0, 550.0, 700.0, 0.011, 0.02);
143        let f1 = mt.martensite_fraction_koistinen_marburger(490.0);
144        let f2 = mt.martensite_fraction_koistinen_marburger(400.0);
145        assert!(f2 > f1, "Fraction should increase as T decreases below Ms");
146    }
147    #[test]
148    fn test_martensite_fraction_at_mf() {
149        let mt = MartensiteTransformation::new(500.0, 200.0, 550.0, 700.0, 0.05, 0.02);
150        let f = mt.martensite_fraction_koistinen_marburger(200.0);
151        assert!(f > 0.99, "Fraction at Mf should be close to 1, got {f}");
152    }
153    #[test]
154    fn test_jmak_fraction_at_zero() {
155        let jmak = JohnsonMehlAvramiKolmogorov::new(0.01, 2.0);
156        assert_eq!(jmak.fraction_transformed(0.0), 0.0);
157    }
158    #[test]
159    fn test_jmak_fraction_increases_with_time() {
160        let jmak = JohnsonMehlAvramiKolmogorov::new(0.01, 2.0);
161        let f1 = jmak.fraction_transformed(1.0);
162        let f2 = jmak.fraction_transformed(5.0);
163        let f3 = jmak.fraction_transformed(10.0);
164        assert!(f1 < f2, "JMAK fraction must increase with time");
165        assert!(f2 < f3, "JMAK fraction must increase with time");
166    }
167    #[test]
168    fn test_calphad_mixing_enthalpy_pure_components() {
169        let model = CalphadModel::new(-5000.0, -8000.0, 2000.0, 500.0);
170        let h0 = model.mixing_enthalpy(0.0);
171        let h1 = model.mixing_enthalpy(1.0);
172        assert!(h0.abs() < 1.0e-12, "H_mix at x_B=0 should be 0, got {h0}");
173        assert!(h1.abs() < 1.0e-12, "H_mix at x_B=1 should be 0, got {h1}");
174    }
175    #[test]
176    fn test_phase_field_solid_fraction_increases_after_step() {
177        let nx = 32;
178        let ny = 32;
179        let mut pf = PhaseFieldModel2D::new(nx, ny, 2.0, 1.0, 100.0);
180        for t in pf.temperature.iter_mut() {
181            *t = 1000.0;
182        }
183        pf.initialize_seed(16.0, 16.0, 4.0);
184        let f0 = pf.solid_fraction();
185        for _ in 0..10 {
186            pf.step(1.0e-4, 1.0);
187        }
188        let f1 = pf.solid_fraction();
189        assert!(
190            f1 >= f0,
191            "Solid fraction should not decrease after solidification steps: f0={f0}, f1={f1}"
192        );
193    }
194    #[test]
195    fn test_km_fraction_zero_at_ms() {
196        let km = KoistinenMarburger::new(500.0, 0.011);
197        assert!((km.fraction(500.0)).abs() < 1e-12);
198    }
199    #[test]
200    fn test_km_fraction_increases_below_ms() {
201        let km = KoistinenMarburger::new(500.0, 0.011);
202        let f1 = km.fraction(490.0);
203        let f2 = km.fraction(450.0);
204        assert!(f2 > f1, "fraction increases as T decreases");
205    }
206    #[test]
207    fn test_km_temp_for_fraction_roundtrip() {
208        let km = KoistinenMarburger::new(500.0, 0.011);
209        let t = km.temperature_for_fraction(0.5);
210        let f = km.fraction(t);
211        assert!((f - 0.5).abs() < 1e-9, "roundtrip: expected 0.5 got {f}");
212    }
213    #[test]
214    fn test_austenite_martensite_kinetics_cooling() {
215        let mut k = AusteniteMartensiteKinetics::new(500.0, 0.011, 550.0, 700.0, 600.0);
216        k.step(450.0);
217        assert!(k.f_martensite > 0.0, "martensite should form on cooling");
218        assert!(k.f_austenite() < 1.0);
219    }
220    #[test]
221    fn test_austenite_martensite_kinetics_heating_dissolves() {
222        let mut k = AusteniteMartensiteKinetics::new(500.0, 0.011, 550.0, 700.0, 600.0);
223        k.step(400.0);
224        let fm_before = k.f_martensite;
225        k.step(700.0);
226        assert!(
227            k.f_martensite < fm_before,
228            "martensite should dissolve on heating"
229        );
230        assert!(
231            k.f_martensite.abs() < 1e-9,
232            "should be fully austenitic above Af"
233        );
234    }
235    #[test]
236    fn test_trip_strain_increment_positive() {
237        let mut trip = TripEffect::new(1e-5);
238        let de = trip.trip_strain_increment(200e6, 0.3, 0.1);
239        assert!(de > 0.0);
240        assert!((trip.accumulated_strain - de).abs() < 1e-15);
241    }
242    #[test]
243    fn test_trip_no_increment_for_negative_df() {
244        let mut trip = TripEffect::new(1e-5);
245        let de = trip.trip_strain_increment(200e6, 0.3, -0.1);
246        assert_eq!(de, 0.0);
247    }
248    #[test]
249    fn test_trip_reset() {
250        let mut trip = TripEffect::new(1e-5);
251        trip.trip_strain_increment(200e6, 0.3, 0.1);
252        trip.reset();
253        assert_eq!(trip.accumulated_strain, 0.0);
254    }
255    #[test]
256    fn test_brinson_effective_modulus_austenite() {
257        let b = BrinsonModel::new(70e9, 30e9, 300.0, 250.0, 340.0, 380.0, 0.05);
258        assert!((b.effective_modulus() - 70e9).abs() < 1.0);
259    }
260    #[test]
261    fn test_brinson_update_temperature_below_mf() {
262        let mut b = BrinsonModel::new(70e9, 30e9, 300.0, 200.0, 340.0, 380.0, 0.05);
263        b.update_temperature(150.0);
264        assert!(
265            (b.xi_t - 1.0).abs() < 1e-9,
266            "should be fully martensitic below Mf"
267        );
268    }
269    #[test]
270    fn test_brinson_update_temperature_above_af() {
271        let mut b = BrinsonModel::new(70e9, 30e9, 300.0, 200.0, 340.0, 380.0, 0.05);
272        b.update_temperature(150.0);
273        b.update_temperature(400.0);
274        assert!(b.xi_t.abs() < 1e-9, "should be fully austenitic above Af");
275    }
276    #[test]
277    fn test_transformation_plasticity_increment() {
278        let mut tp = TransformationPlasticity::new(0.01, 300e6);
279        let de = tp.increment(100e6, 0.1);
280        assert!(de > 0.0);
281        assert!((tp.total_strain() - de).abs() < 1e-15);
282    }
283    #[test]
284    fn test_transformation_plasticity_no_dissolution_increment() {
285        let mut tp = TransformationPlasticity::new(0.01, 300e6);
286        let de = tp.increment(100e6, -0.01);
287        assert_eq!(de, 0.0);
288    }
289    #[test]
290    fn test_phase_fraction_evolution_record_and_query() {
291        let mut evo = PhaseFractionEvolution::new(3);
292        evo.record(0.0, &[1.0, 0.0, 0.0]);
293        evo.record(1.0, &[0.5, 0.3, 0.2]);
294        assert!((evo.current(0) - 0.5).abs() < 1e-12);
295        assert!((evo.current(1) - 0.3).abs() < 1e-12);
296        assert!((evo.current(2) - 0.2).abs() < 1e-12);
297    }
298    #[test]
299    fn test_phase_fraction_evolution_rate() {
300        let mut evo = PhaseFractionEvolution::new(2);
301        evo.record(0.0, &[1.0, 0.0]);
302        evo.record(2.0, &[0.6, 0.4]);
303        let rate_0 = evo.rate(0);
304        let rate_1 = evo.rate(1);
305        assert!((rate_0 + 0.2).abs() < 1e-9, "phase 0 rate = {rate_0}");
306        assert!((rate_1 - 0.2).abs() < 1e-9, "phase 1 rate = {rate_1}");
307    }
308    #[test]
309    fn test_phase_fraction_total() {
310        let mut evo = PhaseFractionEvolution::new(3);
311        evo.record(0.0, &[0.5, 0.3, 0.2]);
312        assert!((evo.total_fraction() - 1.0).abs() < 1e-12);
313    }
314    #[test]
315    fn test_koistinen_marburger_at_ms_gives_zero() {
316        let xm = JmatProModel::martensite_fraction_koistinen_marburger(500.0, 500.0);
317        assert!(xm.abs() < 1e-12, "X_m at T=Ms should be 0, got {xm}");
318    }
319    #[test]
320    fn test_koistinen_marburger_increases_below_ms() {
321        let xm1 = JmatProModel::martensite_fraction_koistinen_marburger(490.0, 500.0);
322        let xm2 = JmatProModel::martensite_fraction_koistinen_marburger(450.0, 500.0);
323        assert!(
324            xm2 > xm1,
325            "Martensite fraction should increase as T decreases below Ms"
326        );
327    }
328    #[test]
329    fn test_avrami_at_t_zero_gives_zero() {
330        let x = avrami_transformation(0.0, 2.0, 0.01);
331        assert_eq!(x, 0.0, "Avrami at t=0 should be 0");
332    }
333    #[test]
334    fn test_avrami_increases_with_time() {
335        let x1 = avrami_transformation(1.0, 2.0, 0.01);
336        let x2 = avrami_transformation(5.0, 2.0, 0.01);
337        let x3 = avrami_transformation(10.0, 2.0, 0.01);
338        assert!(
339            x1 < x2 && x2 < x3,
340            "Avrami fraction should increase with time"
341        );
342    }
343    #[test]
344    fn test_allen_cahn_reduces_free_energy() {
345        let mut pf = PhaseFieldOrder::new(1);
346        pf.phi[0] = 0.5;
347        let phi_initial = pf.phi[0];
348        let df_dphi = [1.0];
349        pf.update_allen_cahn(0.1, 1.0, &df_dphi);
350        assert!(
351            pf.phi[0] < phi_initial,
352            "Allen-Cahn should decrease φ when dF/dφ > 0"
353        );
354    }
355    #[test]
356    fn test_cahn_hilliard_conserves_mass() {
357        let nx = 32;
358        let c0 = 0.5;
359        let mut ch = CahnHilliardField::new(nx, c0);
360        let total_before = ch.total_concentration();
361        ch.update(0.01, 1.0, 1.0);
362        let total_after = ch.total_concentration();
363        assert!(
364            (total_after - total_before).abs() < 1e-10,
365            "Cahn-Hilliard should conserve total concentration: {total_before} → {total_after}"
366        );
367    }
368    #[test]
369    fn test_landau_free_energy_at_zero_phi() {
370        let f = landau_free_energy(0.0, -1.0, 1.0);
371        assert_eq!(f, 0.0, "F(φ=0) should be 0");
372    }
373    #[test]
374    fn test_landau_free_energy_double_well() {
375        let a = -1.0_f64;
376        let b = 1.0_f64;
377        let phi_test = 0.5_f64;
378        let f_pos = landau_free_energy(phi_test, a, b);
379        let f_neg = landau_free_energy(-phi_test, a, b);
380        assert!(
381            (f_pos - f_neg).abs() < 1e-12,
382            "Landau free energy should be even: {f_pos} vs {f_neg}"
383        );
384    }
385    #[test]
386    fn test_phase_transform_type_variants() {
387        let t1 = PhaseTransformType::Martensitic;
388        let t2 = PhaseTransformType::Bainitic;
389        assert_ne!(t1, t2);
390        assert_eq!(
391            PhaseTransformType::Austenitic,
392            PhaseTransformType::Austenitic
393        );
394    }
395    #[test]
396    fn test_jmat_pro_model_range() {
397        let model = JmatProModel::new(500.0, 300.0, 600.0);
398        assert!(
399            model.in_martensite_range(400.0),
400            "400K should be in martensite range [300, 500]"
401        );
402        assert!(
403            !model.in_martensite_range(200.0),
404            "200K should be below martensite range"
405        );
406        assert!(
407            model.above_bainite_start(650.0),
408            "650K should be above bainite start 600K"
409        );
410    }
411    #[test]
412    fn test_bainite_fraction_zero_at_time_zero() {
413        let bt = BainiteTransformation::new(2.0, 1e-3, 80_000.0, 750.0, 500.0);
414        assert_eq!(bt.fraction_at(0.0, 650.0), 0.0);
415    }
416    #[test]
417    fn test_bainite_no_fraction_above_bs() {
418        let bt = BainiteTransformation::new(2.0, 1e-3, 80_000.0, 750.0, 500.0);
419        assert_eq!(bt.fraction_at(1000.0, 800.0), 0.0, "no bainite above B_s");
420    }
421    #[test]
422    fn test_bainite_fraction_increases_with_time() {
423        let bt = BainiteTransformation::new(2.0, 1e-3, 80_000.0, 750.0, 500.0);
424        let f1 = bt.fraction_at(10.0, 650.0);
425        let f2 = bt.fraction_at(100.0, 650.0);
426        assert!(
427            f2 > f1,
428            "bainite fraction must increase with time: {f1} → {f2}"
429        );
430    }
431    #[test]
432    fn test_bainite_rate_constant_increases_with_temperature() {
433        let bt = BainiteTransformation::new(2.0, 1e-3, 50_000.0, 800.0, 400.0);
434        let k_low = bt.rate_constant(500.0);
435        let k_high = bt.rate_constant(700.0);
436        assert!(k_high > k_low, "k should increase with temperature");
437    }
438    #[test]
439    fn test_bainite_nose_temperature() {
440        let bt = BainiteTransformation::new(2.0, 1e-3, 80_000.0, 750.0, 550.0);
441        assert!(
442            (bt.nose_temperature() - 650.0).abs() < 1e-10,
443            "nose at midpoint of Bs–Bf"
444        );
445    }
446    #[test]
447    fn test_bainite_transformation_rate_positive() {
448        let bt = BainiteTransformation::new(2.0, 1e-3, 80_000.0, 750.0, 500.0);
449        let rate = bt.transformation_rate(50.0, 650.0);
450        assert!(
451            rate > 0.0,
452            "transformation rate should be positive, got {rate}"
453        );
454    }
455    #[test]
456    fn test_pearlite_fraction_zero_above_ae1() {
457        let pt = PearliteTransformation::new(2.5, 1e-4, 90_000.0, 700.0, 1000.0);
458        assert_eq!(
459            pt.fraction_at(1000.0, 1100.0),
460            0.0,
461            "no pearlite above A_e1"
462        );
463    }
464    #[test]
465    fn test_pearlite_fraction_zero_below_ps() {
466        let pt = PearliteTransformation::new(2.5, 1e-4, 90_000.0, 700.0, 1000.0);
467        assert_eq!(pt.fraction_at(1000.0, 600.0), 0.0, "no pearlite below P_s");
468    }
469    #[test]
470    fn test_pearlite_fraction_increases_with_time() {
471        let pt = PearliteTransformation::new(2.5, 1e-4, 50_000.0, 600.0, 1000.0);
472        let f1 = pt.fraction_at(10.0, 800.0);
473        let f2 = pt.fraction_at(100.0, 800.0);
474        assert!(f2 > f1, "pearlite fraction must increase with time");
475    }
476    #[test]
477    fn test_pearlite_max_fraction_capped_at_one() {
478        let pt = PearliteTransformation::new(2.5, 1.0, 1.0, 400.0, 1200.0);
479        let f = pt.max_fraction_at(1e10, 800.0);
480        assert!(f <= 1.0 + 1e-12, "fraction must not exceed 1.0, got {f}");
481    }
482    #[test]
483    fn test_pearlite_incubation_time_positive() {
484        let pt = PearliteTransformation::new(2.5, 1e-4, 50_000.0, 600.0, 1000.0);
485        let t_inc = pt.incubation_time(800.0);
486        assert!(
487            t_inc > 0.0,
488            "incubation time should be positive, got {t_inc}"
489        );
490    }
491    #[test]
492    fn test_cct_martensite_at_high_rate() {
493        let cct = CctDiagram::new(1.0, 5.0, 50.0, 500.0, 820.0, 1.0);
494        let micro = cct.predict_microstructure(100.0);
495        assert_eq!(micro, "martensite", "fast cooling → martensite");
496    }
497    #[test]
498    fn test_cct_pearlite_at_low_rate() {
499        let cct = CctDiagram::new(2.0, 5.0, 50.0, 500.0, 820.0, 1.0);
500        let micro = cct.predict_microstructure(0.5);
501        assert_eq!(micro, "pearlite", "slow cooling → pearlite");
502    }
503    #[test]
504    fn test_cct_bainite_intermediate_rate() {
505        let cct = CctDiagram::new(1.0, 5.0, 50.0, 500.0, 820.0, 1.0);
506        let micro = cct.predict_microstructure(8.0);
507        assert_eq!(micro, "bainite", "intermediate rate → bainite");
508    }
509    #[test]
510    fn test_cct_martensite_fraction_at_critical_rate() {
511        let cct = CctDiagram::new(1.0, 5.0, 50.0, 500.0, 820.0, 1.0);
512        let fm = cct.martensite_fraction(50.0);
513        assert!(
514            (fm - 1.0).abs() < 1e-12,
515            "at critical rate, full martensite"
516        );
517    }
518    #[test]
519    fn test_cct_martensite_fraction_zero_at_slow_rate() {
520        let cct = CctDiagram::new(1.0, 5.0, 50.0, 500.0, 820.0, 1.0);
521        let fm = cct.martensite_fraction(0.1);
522        assert!(fm < 1e-12, "slow cooling → no martensite");
523    }
524    #[test]
525    fn test_ttt_start_time_infinite_above_ae1() {
526        let ttt = TttDiagram::new(820.0, 1.0, 1000.0, 500.0, 750.0);
527        let t = ttt.start_time(1100.0);
528        assert!(t.is_infinite(), "no transformation above A_e1");
529    }
530    #[test]
531    fn test_ttt_start_time_infinite_at_ms() {
532        let ttt = TttDiagram::new(820.0, 1.0, 1000.0, 500.0, 750.0);
533        let t = ttt.start_time(500.0);
534        assert!(t.is_infinite(), "no austenite transformation at Ms");
535    }
536    #[test]
537    fn test_ttt_bainite_range() {
538        let ttt = TttDiagram::new(820.0, 1.0, 1000.0, 500.0, 750.0);
539        let (low, high) = ttt.bainite_range();
540        assert_eq!(low, 500.0);
541        assert_eq!(high, 750.0);
542    }
543    #[test]
544    fn test_ttt_pearlite_range() {
545        let ttt = TttDiagram::new(820.0, 1.0, 1000.0, 500.0, 750.0);
546        let (low, high) = ttt.pearlite_range();
547        assert_eq!(low, 820.0);
548        assert_eq!(high, 1000.0);
549    }
550    #[test]
551    fn test_ttt_transformation_avoided_fast_cooling() {
552        let ttt = TttDiagram::new(820.0, 10.0, 1000.0, 500.0, 750.0);
553        assert!(
554            ttt.transformation_avoided(1000.0),
555            "fast quench avoids transformation"
556        );
557    }
558    #[test]
559    fn test_volume_fraction_tracker_initial_state() {
560        let tracker = VolumeFractionTracker::new(vec![
561            "austenite".into(),
562            "martensite".into(),
563            "bainite".into(),
564        ]);
565        assert!(
566            (tracker.fraction(0) - 1.0).abs() < 1e-12,
567            "start fully austenitic"
568        );
569        assert!(tracker.fraction(1).abs() < 1e-12);
570        assert!(tracker.fraction(2).abs() < 1e-12);
571    }
572    #[test]
573    fn test_volume_fraction_tracker_update() {
574        let mut tracker = VolumeFractionTracker::new(vec!["austenite".into(), "martensite".into()]);
575        tracker.update(vec![0.3, 0.7], 10.0, 450.0);
576        assert!((tracker.fraction(0) - 0.3).abs() < 1e-12);
577        assert!((tracker.fraction(1) - 0.7).abs() < 1e-12);
578    }
579    #[test]
580    fn test_volume_fraction_tracker_total() {
581        let mut tracker = VolumeFractionTracker::new(vec!["a".into(), "m".into(), "b".into()]);
582        tracker.update(vec![0.5, 0.3, 0.2], 5.0, 600.0);
583        assert!((tracker.total() - 1.0).abs() < 1e-12);
584    }
585    #[test]
586    fn test_volume_fraction_dominant_phase() {
587        let mut tracker = VolumeFractionTracker::new(vec![
588            "austenite".into(),
589            "martensite".into(),
590            "bainite".into(),
591        ]);
592        tracker.update(vec![0.1, 0.8, 0.1], 20.0, 420.0);
593        assert_eq!(tracker.dominant_phase(), 1, "martensite should dominate");
594    }
595    #[test]
596    fn test_volume_fraction_history_recorded() {
597        let mut tracker = VolumeFractionTracker::new(vec!["a".into(), "m".into()]);
598        tracker.update(vec![0.8, 0.2], 1.0, 700.0);
599        tracker.update(vec![0.5, 0.5], 2.0, 600.0);
600        assert_eq!(tracker.history.len(), 2);
601    }
602    #[test]
603    fn test_phase_props_fully_martensitic() {
604        let props = PhaseDependentProperties::medium_carbon_steel();
605        let fractions = [1.0_f64, 0.0, 0.0, 0.0];
606        let e = props.effective_youngs_modulus(&fractions);
607        assert!(
608            (e - 205e9).abs() < 1.0,
609            "fully martensitic E = 205 GPa, got {e}"
610        );
611        let sy = props.effective_yield_strength(&fractions);
612        assert!(
613            (sy - 1500e6).abs() < 1.0,
614            "martensitic yield = 1500 MPa, got {sy}"
615        );
616    }
617    #[test]
618    fn test_phase_props_rule_of_mixtures() {
619        let props = PhaseDependentProperties::medium_carbon_steel();
620        let fractions = [0.5_f64, 0.5, 0.0, 0.0];
621        let e = props.effective_youngs_modulus(&fractions);
622        let expected = 0.5 * 205e9 + 0.5 * 200e9;
623        assert!((e - expected).abs() < 1.0, "rule of mixtures E, got {e}");
624    }
625    #[test]
626    fn test_phase_props_hardness_increases_with_martensite() {
627        let props = PhaseDependentProperties::medium_carbon_steel();
628        let f_martensite = [1.0_f64, 0.0, 0.0, 0.0];
629        let f_ferrite = [0.0_f64, 0.0, 1.0, 0.0];
630        let hv_m = props.effective_hardness(&f_martensite);
631        let hv_f = props.effective_hardness(&f_ferrite);
632        assert!(
633            hv_m > hv_f,
634            "martensite harder than ferrite: {hv_m} vs {hv_f}"
635        );
636    }
637    #[test]
638    fn test_phase_props_uts_from_hardness() {
639        let props = PhaseDependentProperties::medium_carbon_steel();
640        let fractions = [1.0_f64, 0.0, 0.0, 0.0];
641        let uts = props.uts_from_hardness(&fractions);
642        let expected = 3.45e6 * 700.0;
643        assert!((uts - expected).abs() < 1.0, "UTS from hardness, got {uts}");
644    }
645    #[test]
646    fn test_clausius_clapeyron_water_ice_negative_slope() {
647        let cc = ClausiusClapeyron::water_ice();
648        let slope = cc.slope(cc.t0);
649        assert!(
650            slope < 0.0,
651            "Water/ice dP/dT should be negative, got {slope}"
652        );
653    }
654    #[test]
655    fn test_clausius_clapeyron_melting_temperature_increases_with_pressure_for_normal_substance() {
656        let cc = ClausiusClapeyron::new(6000.0, 300.0, 101325.0, 1.0e-6);
657        let t_low = cc.melting_temperature(101325.0);
658        let t_high = cc.melting_temperature(1.0e6);
659        assert!(t_high > t_low, "For ΔV>0, higher P → higher T_m");
660    }
661    #[test]
662    fn test_clausius_clapeyron_equilibrium_pressure_at_t0_equals_p0() {
663        let cc = ClausiusClapeyron::water_ice();
664        let p = cc.equilibrium_pressure(cc.t0);
665        assert!(
666            (p - cc.p0).abs() < 1e-6,
667            "At Tâ‚€, equilibrium pressure = Pâ‚€, got {p}"
668        );
669    }
670    #[test]
671    fn test_clausius_clapeyron_latent_heat_at_reference() {
672        let cc = ClausiusClapeyron::water_ice();
673        let l = cc.latent_heat_at(cc.t0, 0.0);
674        assert!((l - cc.latent_heat).abs() < 1e-10, "L at Tâ‚€ = L, got {l}");
675    }
676    #[test]
677    fn test_clausius_clapeyron_latent_heat_increases_with_delta_cp_positive() {
678        let cc = ClausiusClapeyron::new(6000.0, 300.0, 101325.0, 1.0e-6);
679        let l0 = cc.latent_heat_at(300.0, 10.0);
680        let l1 = cc.latent_heat_at(350.0, 10.0);
681        assert!(l1 > l0, "L should increase with T when ΔCp > 0");
682    }
683    #[test]
684    fn test_clausius_clapeyron_slope_zero_at_zero_delta_v() {
685        let cc = ClausiusClapeyron::new(6000.0, 300.0, 101325.0, 0.0);
686        let slope = cc.slope(300.0);
687        assert_eq!(slope, 0.0, "Zero ΔV → zero slope");
688    }
689    #[test]
690    fn test_clausius_clapeyron_roundtrip_pressure_temperature() {
691        let cc = ClausiusClapeyron::new(6000.0, 300.0, 101325.0, 1.0e-6);
692        let t_test = 310.0_f64;
693        let p = cc.equilibrium_pressure(t_test);
694        let t_back = cc.melting_temperature(p);
695        assert!(
696            (t_back - t_test).abs() < 1e-6,
697            "P→T roundtrip: {t_back} vs {t_test}"
698        );
699    }
700    #[test]
701    fn test_lever_rule_at_alpha_composition() {
702        let lr = LeverRule::new(0.1, 0.4);
703        let f_beta = lr.beta_fraction(0.1);
704        assert!(f_beta.abs() < 1e-10, "At x_α, f_β = 0, got {f_beta}");
705    }
706    #[test]
707    fn test_lever_rule_at_beta_composition() {
708        let lr = LeverRule::new(0.1, 0.4);
709        let f_beta = lr.beta_fraction(0.4);
710        assert!(
711            (f_beta - 1.0).abs() < 1e-10,
712            "At x_β, f_β = 1, got {f_beta}"
713        );
714    }
715    #[test]
716    fn test_lever_rule_at_midpoint() {
717        let lr = LeverRule::new(0.1, 0.3);
718        let f_beta = lr.beta_fraction(0.2);
719        assert!(
720            (f_beta - 0.5).abs() < 1e-10,
721            "At midpoint, f_β = 0.5, got {f_beta}"
722        );
723    }
724    #[test]
725    fn test_lever_rule_fractions_sum_to_one() {
726        let lr = LeverRule::new(0.05, 0.25);
727        let x = 0.15;
728        let fa = lr.alpha_fraction(x);
729        let fb = lr.beta_fraction(x);
730        assert!(
731            (fa + fb - 1.0).abs() < 1e-10,
732            "Phase fractions must sum to 1"
733        );
734    }
735    #[test]
736    fn test_lever_rule_is_two_phase() {
737        let lr = LeverRule::new(0.1, 0.4);
738        assert!(lr.is_two_phase(0.2), "x=0.2 is in two-phase region");
739        assert!(!lr.is_two_phase(0.05), "x=0.05 is outside two-phase region");
740        assert!(!lr.is_two_phase(0.5), "x=0.5 is outside two-phase region");
741    }
742    #[test]
743    fn test_lever_rule_tie_line_length() {
744        let lr = LeverRule::new(0.1, 0.4);
745        assert!((lr.tie_line_length() - 0.3).abs() < 1e-10, "Tie line = 0.3");
746    }
747    #[test]
748    fn test_lever_rule_clamped_outside_range() {
749        let lr = LeverRule::new(0.1, 0.4);
750        assert_eq!(lr.beta_fraction(0.0), 0.0, "Below α: f_β = 0");
751        assert_eq!(lr.beta_fraction(0.5), 1.0, "Above β: f_β = 1");
752    }
753    #[test]
754    fn test_ternary_lever_rule_at_alpha() {
755        let f = ternary_lever_rule(0.1, 0.05, 0.1, 0.05, 0.3, 0.1);
756        assert!(f.abs() < 1e-10, "At α, f_β = 0");
757    }
758    #[test]
759    fn test_ternary_lever_rule_at_beta() {
760        let f = ternary_lever_rule(0.3, 0.1, 0.1, 0.05, 0.3, 0.1);
761        assert!((f - 1.0).abs() < 1e-10, "At β, f_β = 1");
762    }
763    #[test]
764    fn test_spinodal_f_double_prime_positive_at_low_omega() {
765        let spd = SpinocalDecomposition::new(1000.0, 1e-10, 1e-5);
766        let f2 = spd.f_double_prime(0.5, 1000.0);
767        assert!(f2 > 0.0, "f'' > 0 for stable mixture, got {f2}");
768    }
769    #[test]
770    fn test_spinodal_f_double_prime_negative_at_high_omega() {
771        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
772        let f2 = spd.f_double_prime(0.5, 300.0);
773        assert!(f2 < 0.0, "f'' < 0 in spinodal region, got {f2}");
774    }
775    #[test]
776    fn test_spinodal_is_spinodal_at_center_high_omega() {
777        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
778        assert!(
779            spd.is_spinodal(0.5, 300.0),
780            "c=0.5 should be in spinodal at high Ω"
781        );
782    }
783    #[test]
784    fn test_spinodal_boundary_symmetric() {
785        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
786        if let Some((c_low, c_high)) = spd.spinodal_boundary(300.0) {
787            assert!(
788                (c_low + c_high - 1.0).abs() < 1e-10,
789                "Spinodal boundary symmetric about 0.5"
790            );
791            assert!(c_low < 0.5 && c_high > 0.5, "Boundary straddles 0.5");
792        } else {
793            panic!("Expected spinodal boundary at T=300");
794        }
795    }
796    #[test]
797    fn test_spinodal_no_boundary_above_critical_temperature() {
798        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
799        let tc = spd.critical_temperature();
800        let result = spd.spinodal_boundary(tc * 1.5);
801        assert!(result.is_none(), "No spinodal above T_c");
802    }
803    #[test]
804    fn test_spinodal_critical_temperature_formula() {
805        let omega = 40000.0_f64;
806        let spd = SpinocalDecomposition::new(omega, 1e-10, 1e-5);
807        let tc = spd.critical_temperature();
808        let r = 8.314_462_618;
809        let expected = omega / (2.0 * r);
810        assert!(
811            (tc - expected).abs() / expected < 1e-10,
812            "T_c formula: {tc}"
813        );
814    }
815    #[test]
816    fn test_spinodal_fastest_wavelength_exists_in_spinodal() {
817        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
818        let lambda = spd.fastest_growing_wavelength(0.5, 300.0);
819        assert!(
820            lambda.is_some(),
821            "Should have fastest wavelength in spinodal region"
822        );
823        assert!(lambda.unwrap() > 0.0, "Wavelength must be positive");
824    }
825    #[test]
826    fn test_spinodal_amplification_positive_in_spinodal() {
827        let spd = SpinocalDecomposition::new(50000.0, 1e-10, 1e-5);
828        if let Some(lambda) = spd.fastest_growing_wavelength(0.5, 300.0) {
829            let q = 2.0 * std::f64::consts::PI / lambda;
830            let r = spd.amplification_factor(q, 0.5, 300.0, 1.0e-20);
831            assert!(
832                r > 0.0,
833                "Amplification factor should be positive at fastest q, got {r}"
834            );
835        }
836    }
837    #[test]
838    fn test_latent_heat_volumetric_steel() {
839        let lh = LatentHeatModel::steel_solidification();
840        let v = lh.volumetric_latent_heat();
841        let expected = 271_000.0 * 7800.0;
842        assert!(
843            (v - expected).abs() < 1.0,
844            "Volumetric latent heat steel: {v}"
845        );
846    }
847    #[test]
848    fn test_latent_heat_enthalpy_release_full_transformation() {
849        let lh = LatentHeatModel::aluminum_solidification();
850        let h = lh.enthalpy_release(1.0);
851        assert!(
852            (h - lh.volumetric_latent_heat()).abs() < 1.0,
853            "Full transformation releases all latent heat"
854        );
855    }
856    #[test]
857    fn test_latent_heat_enthalpy_release_half_transformation() {
858        let lh = LatentHeatModel::steel_solidification();
859        let h_half = lh.enthalpy_release(0.5);
860        let h_full = lh.enthalpy_release(1.0);
861        assert!(
862            (h_half - 0.5 * h_full).abs() < 1.0,
863            "Half transform → half latent heat"
864        );
865    }
866    #[test]
867    fn test_latent_heat_adiabatic_rise_positive() {
868        let lh = LatentHeatModel::steel_solidification();
869        let dt = lh.adiabatic_temperature_rise(1.0, 500.0);
870        assert!(dt > 0.0, "Adiabatic temperature rise should be positive");
871    }
872    #[test]
873    fn test_latent_heat_adiabatic_rise_formula() {
874        let lh = LatentHeatModel::new(300_000.0, 8000.0);
875        let cp = 500.0;
876        let dt = lh.adiabatic_temperature_rise(1.0, cp);
877        let expected = 300_000.0 / cp;
878        assert!(
879            (dt - expected).abs() < 1e-6,
880            "ΔT = L/Cp: {dt} vs {expected}"
881        );
882    }
883    #[test]
884    fn test_latent_heat_fraction_released() {
885        let lh = LatentHeatModel::steel_solidification();
886        let df = lh.fraction_released(0.2, 0.8);
887        assert!(
888            (df - 0.6).abs() < 1e-10,
889            "Fraction released = 0.6, got {df}"
890        );
891    }
892    #[test]
893    fn test_latent_heat_zero_cp_returns_zero() {
894        let lh = LatentHeatModel::steel_solidification();
895        let dt = lh.adiabatic_temperature_rise(1.0, 0.0);
896        assert_eq!(dt, 0.0, "Zero Cp → zero ΔT");
897    }
898    #[test]
899    fn test_jmak_extended_fraction_zero_at_time_zero() {
900        let jmak = JmakExtended::new(1e-10, 2.0, 80_000.0, 1.0);
901        assert_eq!(jmak.fraction(0.0, 900.0), 0.0, "f(0) = 0");
902    }
903    #[test]
904    fn test_jmak_extended_fraction_approaches_one() {
905        let jmak = JmakExtended::new(1e-5, 2.0, 1.0, 1.0);
906        let f = jmak.fraction(1e10, 1000.0);
907        assert!((f - 1.0).abs() < 1e-6, "f → 1 at large t, got {f}");
908    }
909    #[test]
910    fn test_jmak_extended_fraction_monotonically_increasing() {
911        let jmak = JmakExtended::new(1e-10, 2.0, 50_000.0, 1.0);
912        let f1 = jmak.fraction(10.0, 900.0);
913        let f2 = jmak.fraction(100.0, 900.0);
914        let f3 = jmak.fraction(1000.0, 900.0);
915        assert!(f2 > f1 && f3 > f2, "JMAK fraction must increase");
916    }
917    #[test]
918    fn test_jmak_extended_higher_temp_faster_transform() {
919        let jmak = JmakExtended::new(1e-10, 2.0, 80_000.0, 1.0);
920        let f_low = jmak.fraction(100.0, 700.0);
921        let f_high = jmak.fraction(100.0, 1200.0);
922        assert!(f_high > f_low, "Higher T → faster transformation");
923    }
924    #[test]
925    fn test_jmak_extended_time_to_fraction_roundtrip() {
926        let jmak = JmakExtended::new(1e-10, 2.0, 50_000.0, 1.0);
927        let target = 0.5;
928        let t = jmak.time_to_fraction(target, 1000.0);
929        let f_back = jmak.fraction(t, 1000.0);
930        assert!(
931            (f_back - target).abs() < 1e-6,
932            "JMAK roundtrip: {f_back} vs {target}"
933        );
934    }
935    #[test]
936    fn test_jmak_extended_ttt_start_before_finish() {
937        let jmak = JmakExtended::new(1e-10, 2.0, 50_000.0, 1.0);
938        let t1_pct = jmak.time_to_fraction(0.01, 1000.0);
939        let t99_pct = jmak.time_to_fraction(0.99, 1000.0);
940        assert!(
941            t1_pct.is_finite() && t99_pct.is_finite(),
942            "Times must be finite"
943        );
944        assert!(
945            t99_pct > t1_pct,
946            "99% transform takes longer than 1%: t1={t1_pct}, t99={t99_pct}"
947        );
948    }
949    #[test]
950    fn test_jmak_extended_transformation_rate_positive() {
951        let jmak = JmakExtended::new(1e-10, 2.0, 50_000.0, 1.0);
952        let rate = jmak.transformation_rate(100.0, 1000.0);
953        assert!(
954            rate > 0.0,
955            "Transformation rate must be positive, got {rate}"
956        );
957    }
958    #[test]
959    fn test_jmak_extended_impingement_reduces_fraction() {
960        let jmak_full = JmakExtended::new(1e-10, 2.0, 50_000.0, 1.0);
961        let jmak_partial = JmakExtended::new(1e-10, 2.0, 50_000.0, 0.5);
962        let f_full = jmak_full.fraction(1000.0, 1000.0);
963        let f_partial = jmak_partial.fraction(1000.0, 1000.0);
964        assert!(
965            f_partial < f_full,
966            "Lower impingement → lower fraction at same time"
967        );
968    }
969}
970#[cfg(test)]
971mod tests_new {
972
973    use crate::EutecticTransformation;
974
975    use crate::ScheilSolidification;
976
977    use crate::StressAidedMartensite;
978
979    use crate::TttCcurve;
980
981    use crate::TttExtended;
982
983    #[test]
984    fn test_scheil_liquid_composition_at_zero_fraction() {
985        let sch = ScheilSolidification::new(4.0, 0.17);
986        let c_l = sch.liquid_composition(0.0);
987        assert!((c_l - 4.0).abs() < 1e-6, "C_L(f_s=0) = C_0, got {c_l}");
988    }
989    #[test]
990    fn test_scheil_liquid_composition_increases_with_fs() {
991        let sch = ScheilSolidification::new(4.0, 0.17);
992        let c_l0 = sch.liquid_composition(0.0);
993        let c_l5 = sch.liquid_composition(0.5);
994        assert!(
995            c_l5 > c_l0,
996            "C_L increases with f_s for k < 1: {c_l0} → {c_l5}"
997        );
998    }
999    #[test]
1000    fn test_scheil_solid_front_composition_proportional_to_liquid() {
1001        let sch = ScheilSolidification::al_cu();
1002        let f_s = 0.3;
1003        let c_l = sch.liquid_composition(f_s);
1004        let c_s = sch.solid_composition_at_front(f_s);
1005        assert!(
1006            (c_s - sch.k_partition * c_l).abs() < 1e-10,
1007            "C_S* = k * C_L"
1008        );
1009    }
1010    #[test]
1011    fn test_scheil_mean_solid_composition_less_than_c0() {
1012        let sch = ScheilSolidification::new(4.0, 0.17);
1013        let c_s_mean = sch.mean_solid_composition(0.5);
1014        assert!(
1015            c_s_mean < sch.c0,
1016            "Mean solid composition < C_0 for k < 1: {c_s_mean}"
1017        );
1018    }
1019    #[test]
1020    fn test_scheil_eutectic_solid_fraction_increases_toward_one() {
1021        let sch = ScheilSolidification::al_cu();
1022        if let Some(f_s) = sch.eutectic_solid_fraction(33.0) {
1023            assert!(f_s > 0.0 && f_s < 1.0, "Eutectic f_s in (0,1): {f_s}");
1024        }
1025    }
1026    #[test]
1027    fn test_scheil_solidification_range() {
1028        let sch = ScheilSolidification::new(4.0, 0.17);
1029        let (t_l, t_e) = sch.solidification_range(933.0, -3.4, 33.0);
1030        assert!(t_l > t_e, "T_liquidus > T_eutectic: {t_l} > {t_e}");
1031    }
1032    #[test]
1033    fn test_scheil_fe_c_liquid_composition() {
1034        let sch = ScheilSolidification::fe_c();
1035        let c_l = sch.liquid_composition(0.3);
1036        assert!(c_l > sch.c0, "Fe-C: C_L increases with solidification");
1037    }
1038    #[test]
1039    fn test_ttt_extended_martensite_fraction_at_ms() {
1040        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1041        let fm = ttt.martensite_fraction(500.0);
1042        assert!(fm.abs() < 1e-10, "No martensite at Ms exactly, got {fm}");
1043    }
1044    #[test]
1045    fn test_ttt_extended_martensite_increases_below_ms() {
1046        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1047        let fm1 = ttt.martensite_fraction(490.0);
1048        let fm2 = ttt.martensite_fraction(400.0);
1049        assert!(fm2 > fm1, "More martensite at lower T: {fm1} vs {fm2}");
1050    }
1051    #[test]
1052    fn test_ttt_extended_pearlite_zero_above_ae1() {
1053        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1054        let fp = ttt.pearlite_fraction(1e6, 1100.0);
1055        assert!(fp.abs() < 1e-12, "No pearlite above A_e1");
1056    }
1057    #[test]
1058    fn test_ttt_extended_bainite_fraction_increases_with_time() {
1059        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1060        let fb1 = ttt.bainite_fraction(1.0, 650.0);
1061        let fb2 = ttt.bainite_fraction(100.0, 650.0);
1062        assert!(
1063            fb2 >= fb1,
1064            "Bainite fraction non-decreasing with time: {fb1} vs {fb2}"
1065        );
1066    }
1067    #[test]
1068    fn test_ttt_extended_dominant_product_austenite_above_ae1() {
1069        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1070        let product = ttt.dominant_product(1.0, 1100.0);
1071        assert_eq!(product, "austenite", "Above A_e1 → austenite");
1072    }
1073    #[test]
1074    fn test_ttt_extended_dominant_product_martensite_below_ms() {
1075        let ttt = TttExtended::new(820.0, 1.0, 650.0, 0.5, 500.0, 1000.0, 3.0, 2.5);
1076        let product = ttt.dominant_product(1.0, 400.0);
1077        assert_eq!(product, "martensite", "Below Ms → martensite");
1078    }
1079    #[test]
1080    fn test_eutectic_alpha_plus_beta_fractions_sum_to_one() {
1081        let eut = EutecticTransformation::al_si();
1082        let fa = eut.alpha_fraction();
1083        let fb = eut.beta_fraction();
1084        assert!(
1085            (fa + fb - 1.0).abs() < 1e-10,
1086            "Phase fractions sum to 1: {fa} + {fb}"
1087        );
1088    }
1089    #[test]
1090    fn test_eutectic_al_si_alpha_fraction_in_range() {
1091        let eut = EutecticTransformation::al_si();
1092        let fa = eut.alpha_fraction();
1093        assert!(
1094            fa > 0.0 && fa < 1.0,
1095            "Al-Si α fraction should be in (0,1): {fa}"
1096        );
1097    }
1098    #[test]
1099    fn test_eutectic_lamellar_spacing_decreases_with_velocity() {
1100        let eut = EutecticTransformation::al_si();
1101        let lam_slow = eut.lamellar_spacing(1e-6);
1102        let lam_fast = eut.lamellar_spacing(1e-4);
1103        assert!(
1104            lam_fast < lam_slow,
1105            "Faster growth → finer lamellar spacing"
1106        );
1107    }
1108    #[test]
1109    fn test_eutectic_growth_velocity_decreases_with_spacing() {
1110        let eut = EutecticTransformation::al_si();
1111        let v_fine = eut.growth_velocity(1e-7);
1112        let v_coarse = eut.growth_velocity(1e-6);
1113        assert!(v_fine > v_coarse, "Finer spacing → higher velocity");
1114    }
1115    #[test]
1116    fn test_eutectic_spacing_velocity_product_constant() {
1117        let eut = EutecticTransformation::al_si();
1118        let v = 1e-5;
1119        let lam = eut.lamellar_spacing(v);
1120        let product = lam * lam * v;
1121        assert!(
1122            (product - eut.k_jh).abs() / eut.k_jh < 1e-6,
1123            "λ²·v = K_JH: {product} vs {}",
1124            eut.k_jh
1125        );
1126    }
1127    #[test]
1128    fn test_eutectic_is_eutectic_composition() {
1129        let eut = EutecticTransformation::al_si();
1130        assert!(
1131            eut.is_eutectic_composition(eut.c_eutectic, 0.01),
1132            "Exact eutectic composition matches"
1133        );
1134        assert!(
1135            !eut.is_eutectic_composition(5.0, 0.01),
1136            "Non-eutectic composition does not match"
1137        );
1138    }
1139    #[test]
1140    fn test_ttt_ccurve_minimum_time_at_nose() {
1141        let ccurve = TttCcurve::new(820.0, 1.0, 0.001, 3.0);
1142        let tau_nose = ccurve.incubation_time(820.0);
1143        let tau_off = ccurve.incubation_time(700.0);
1144        assert!(
1145            tau_off > tau_nose,
1146            "Incubation time minimum at nose, τ_nose={tau_nose}, τ_off={tau_off}"
1147        );
1148    }
1149    #[test]
1150    fn test_ttt_ccurve_fraction_monotonically_increasing() {
1151        let ccurve = TttCcurve::new(820.0, 1.0, 0.001, 3.0);
1152        let f1 = ccurve.fraction(1.0, 820.0);
1153        let f2 = ccurve.fraction(10.0, 820.0);
1154        let f3 = ccurve.fraction(100.0, 820.0);
1155        assert!(f2 > f1 && f3 > f2, "Fraction increases with time");
1156    }
1157    #[test]
1158    fn test_ttt_ccurve_time_to_fraction_roundtrip() {
1159        let ccurve = TttCcurve::new(820.0, 1.0, 0.001, 3.0);
1160        let target = 0.5;
1161        let t = ccurve.time_to_fraction(target, 820.0);
1162        let f_back = ccurve.fraction(t, 820.0);
1163        assert!(
1164            (f_back - target).abs() < 1e-6,
1165            "TTT C-curve roundtrip: {f_back} vs {target}"
1166        );
1167    }
1168    #[test]
1169    fn test_ttt_ccurve_nose_temperature_correct() {
1170        let ccurve = TttCcurve::new(820.0, 1.0, 0.001, 3.0);
1171        assert!((ccurve.nose_temperature() - 820.0).abs() < 1e-10);
1172    }
1173    #[test]
1174    fn test_stress_aided_ms_shifts_with_stress() {
1175        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1176        let ms_no_stress = sam.ms_temperature_under_stress(0.0);
1177        let ms_stressed = sam.ms_temperature_under_stress(100.0e6);
1178        assert_eq!(ms_no_stress, 500.0, "No-stress Ms = 500 K");
1179        assert!(ms_stressed != ms_no_stress, "Ms shifts with stress");
1180    }
1181    #[test]
1182    fn test_stress_aided_shear_band_fraction_zero_at_zero_strain() {
1183        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1184        assert_eq!(sam.shear_band_fraction(0.0), 0.0);
1185    }
1186    #[test]
1187    fn test_stress_aided_shear_band_fraction_increases_with_strain() {
1188        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1189        let f1 = sam.shear_band_fraction(0.1);
1190        let f2 = sam.shear_band_fraction(0.5);
1191        assert!(f2 > f1, "Shear band fraction increases with strain");
1192    }
1193    #[test]
1194    fn test_stress_aided_martensite_from_strain_increases() {
1195        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1196        let fm1 = sam.martensite_fraction_from_strain(0.1);
1197        let fm2 = sam.martensite_fraction_from_strain(0.5);
1198        assert!(
1199            fm2 > fm1,
1200            "Martensite fraction increases with strain: {fm1} vs {fm2}"
1201        );
1202    }
1203    #[test]
1204    fn test_stress_aided_transformation_mode_thermal_below_ms() {
1205        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1206        let mode = sam.transformation_mode(400.0, 0.0);
1207        assert_eq!(mode, "thermal", "Below Msâ‚€: thermal transformation");
1208    }
1209    #[test]
1210    fn test_stress_aided_transformation_mode_none_above_both() {
1211        let sam = StressAidedMartensite::new(500.0, 1.0e-7, 4.0, 4.5, 4.0);
1212        let mode = sam.transformation_mode(600.0, 0.0);
1213        assert_eq!(mode, "none", "Above Ms(σ): no transformation");
1214    }
1215}