Skip to main content

oxiphysics_materials/thermal/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::functions::R_GAS;
6#[allow(unused_imports)]
7use super::functions::*;
8
9/// Phase change (melting/solidification) model with latent heat.
10///
11/// Implements the enthalpy method for solving phase change problems.
12/// The apparent heat capacity is increased in the mushy zone to account
13/// for the latent heat release/absorption.
14pub struct PhaseChangeModel {
15    /// Liquidus temperature (melting point or end of mushy zone) \[K\].
16    pub t_liquidus: f64,
17    /// Solidus temperature (start of mushy zone) \[K\].
18    pub t_solidus: f64,
19    /// Latent heat of fusion L \[J/kg\].
20    pub latent_heat: f64,
21    /// Solid-phase specific heat \[J/(kg·K)\].
22    pub cp_solid: f64,
23    /// Liquid-phase specific heat \[J/(kg·K)\].
24    pub cp_liquid: f64,
25    /// Material density \[kg/m³\].
26    pub density: f64,
27}
28impl PhaseChangeModel {
29    /// Create a new phase change model.
30    #[allow(clippy::too_many_arguments)]
31    pub fn new(
32        t_solidus: f64,
33        t_liquidus: f64,
34        latent_heat: f64,
35        cp_solid: f64,
36        cp_liquid: f64,
37        density: f64,
38    ) -> Self {
39        assert!(t_liquidus >= t_solidus, "t_liquidus must be >= t_solidus");
40        Self {
41            t_liquidus,
42            t_solidus,
43            latent_heat,
44            cp_solid,
45            cp_liquid,
46            density,
47        }
48    }
49    /// Liquid fraction at temperature T (linear in mushy zone).
50    pub fn liquid_fraction(&self, temp: f64) -> f64 {
51        if temp <= self.t_solidus {
52            0.0
53        } else if temp >= self.t_liquidus {
54            1.0
55        } else {
56            (temp - self.t_solidus) / (self.t_liquidus - self.t_solidus)
57        }
58    }
59    /// Apparent (effective) specific heat in the enthalpy method \[J/(kg·K)\].
60    ///
61    /// In the mushy zone, cp_eff = cp + L/(T_liq - T_sol).
62    pub fn apparent_specific_heat(&self, temp: f64) -> f64 {
63        let fl = self.liquid_fraction(temp);
64        let cp_mix = fl * self.cp_liquid + (1.0 - fl) * self.cp_solid;
65        if temp > self.t_solidus && temp < self.t_liquidus {
66            let dt = (self.t_liquidus - self.t_solidus).max(f64::EPSILON);
67            cp_mix + self.latent_heat / dt
68        } else {
69            cp_mix
70        }
71    }
72    /// Specific enthalpy h(T) relative to T = 0.
73    pub fn enthalpy(&self, temp: f64) -> f64 {
74        if temp <= self.t_solidus {
75            self.cp_solid * temp
76        } else if temp >= self.t_liquidus {
77            self.cp_solid * self.t_solidus
78                + self.latent_heat
79                + self.cp_liquid * (temp - self.t_liquidus)
80        } else {
81            let fl = self.liquid_fraction(temp);
82            self.cp_solid * self.t_solidus
83                + fl * self.latent_heat
84                + (fl * self.cp_liquid + (1.0 - fl) * self.cp_solid) * (temp - self.t_solidus)
85        }
86    }
87    /// Thermal conductivity (interpolated between solid and liquid).
88    pub fn conductivity(&self, k_solid: f64, k_liquid: f64, temp: f64) -> f64 {
89        let fl = self.liquid_fraction(temp);
90        fl * k_liquid + (1.0 - fl) * k_solid
91    }
92}
93/// Coupled thermal-stress analysis for a 1D rod under thermal loading.
94///
95/// Combines 1D conduction with thermal stress calculation at each node.
96pub struct ThermalStressCoupling {
97    /// Number of nodes.
98    pub n_nodes: usize,
99    /// Node temperatures \[K\].
100    pub temperature: Vec<f64>,
101    /// Reference temperature (stress-free state) \[K\].
102    pub t_ref: f64,
103    /// Thermal expansion coefficient \[1/K\].
104    pub alpha: f64,
105    /// Young's modulus \[Pa\].
106    pub young_modulus: f64,
107    /// Poisson's ratio.
108    pub poisson_ratio: f64,
109}
110impl ThermalStressCoupling {
111    /// Create a new coupled analysis with uniform initial temperature.
112    pub fn new(
113        n_nodes: usize,
114        t_init: f64,
115        t_ref: f64,
116        alpha: f64,
117        young_modulus: f64,
118        poisson_ratio: f64,
119    ) -> Self {
120        Self {
121            n_nodes,
122            temperature: vec![t_init; n_nodes],
123            t_ref,
124            alpha,
125            young_modulus,
126            poisson_ratio,
127        }
128    }
129    /// Thermal strain at node i: ε_th = α·(T - T_ref).
130    pub fn thermal_strain(&self, node: usize) -> f64 {
131        self.alpha * (self.temperature[node] - self.t_ref)
132    }
133    /// Thermal stress at node i for fully constrained expansion \[Pa\].
134    ///
135    /// σ = -E·α·(T - T_ref) / (1 - ν)  (plane-stress)
136    pub fn thermal_stress(&self, node: usize) -> f64 {
137        -self.young_modulus * self.alpha * (self.temperature[node] - self.t_ref)
138            / (1.0 - self.poisson_ratio)
139    }
140    /// Maximum thermal stress magnitude across all nodes \[Pa\].
141    pub fn max_thermal_stress(&self) -> f64 {
142        (0..self.n_nodes)
143            .map(|i| self.thermal_stress(i).abs())
144            .fold(0.0_f64, f64::max)
145    }
146    /// Check if any node exceeds the fracture stress \[Pa\].
147    pub fn has_fracture(&self, fracture_stress: f64) -> bool {
148        self.max_thermal_stress() > fracture_stress
149    }
150}
151/// Heat-affected zone (HAZ) size model for welding processes.
152///
153/// Based on Rosenthal's solution for a moving heat source:
154/// The HAZ boundary is defined where the peak temperature equals
155/// a critical temperature (e.g., Ac1 for steel).
156///
157/// HAZ width: w ≈ (Q / (π·ρ·cp·v)) * (1/T_crit - 1/T_melt)  (thick plate)
158#[allow(dead_code)]
159#[derive(Debug, Clone)]
160pub struct HeatAffectedZone {
161    /// Heat input Q \[J/m\] (= Power / welding speed).
162    pub heat_input: f64,
163    /// Material density \[kg/m³\].
164    pub rho: f64,
165    /// Specific heat capacity \[J/(kg·K)\].
166    pub cp: f64,
167    /// Thermal conductivity \[W/(m·K)\].
168    pub k: f64,
169    /// Welding speed v \[m/s\].
170    pub welding_speed: f64,
171    /// Preheat / ambient temperature T_0 \[K\].
172    pub t_preheat: f64,
173    /// Melting temperature T_melt \[K\].
174    pub t_melt: f64,
175}
176impl HeatAffectedZone {
177    /// Create a new HAZ model.
178    #[allow(clippy::too_many_arguments)]
179    pub fn new(
180        heat_input: f64,
181        rho: f64,
182        cp: f64,
183        k: f64,
184        welding_speed: f64,
185        t_preheat: f64,
186        t_melt: f64,
187    ) -> Self {
188        Self {
189            heat_input,
190            rho,
191            cp,
192            k,
193            welding_speed,
194            t_preheat,
195            t_melt,
196        }
197    }
198    /// Thermal diffusivity α = k / (ρ·cp) \[m²/s\].
199    pub fn diffusivity(&self) -> f64 {
200        self.k / (self.rho * self.cp)
201    }
202    /// Peak temperature at distance r from weld centre (Rosenthal, thin plate).
203    ///
204    /// T_peak = T_0 + Q / (π·ρ·cp·t_peak) * exp(-r²/(4·α·t_peak))
205    ///
206    /// Simplified thick-plate peak temperature at lateral distance y from weld line:
207    /// T_peak ≈ T_0 + (Q / (2·π·k)) · (v / (2·π·α)) · exp(-v·y / (2·α))
208    pub fn peak_temperature_thick_plate(&self, y: f64) -> f64 {
209        let alpha = self.diffusivity();
210        let coeff = self.heat_input * self.welding_speed / (2.0 * std::f64::consts::PI * alpha);
211        self.t_preheat
212            + coeff / (2.0 * std::f64::consts::PI) * (-self.welding_speed * y / (2.0 * alpha)).exp()
213    }
214    /// Estimated HAZ half-width \[m\] for a given critical temperature T_crit \[K\].
215    ///
216    /// Uses Rosenthal thick-plate formula: the HAZ extends to where
217    /// T_peak = T_crit.
218    pub fn haz_halfwidth(&self, t_crit: f64) -> f64 {
219        let alpha = self.diffusivity();
220        if t_crit <= self.t_preheat {
221            return f64::INFINITY;
222        }
223        let dt = t_crit - self.t_preheat;
224        self.heat_input / (std::f64::consts::E * std::f64::consts::PI * self.rho * self.cp * dt)
225            * (alpha / self.welding_speed).sqrt()
226    }
227    /// Cooling rate at the weld centreline at T_crit \[K/s\].
228    ///
229    /// For thick plates: dT/dt = 2·π·k·(T_crit - T_0)² / Q
230    pub fn cooling_rate_centreline(&self, t_crit: f64) -> f64 {
231        2.0 * std::f64::consts::PI * self.k * (t_crit - self.t_preheat).powi(2) / self.heat_input
232    }
233    /// Check if the HAZ would cause martensite formation given a critical
234    /// cooling rate \[K/s\] for martensite.
235    pub fn forms_martensite(&self, critical_cooling_rate: f64, t_crit: f64) -> bool {
236        self.cooling_rate_centreline(t_crit) >= critical_cooling_rate
237    }
238}
239/// Johnson-Cook constitutive model for thermoviscoplastic flow stress.
240///
241/// σ = (A + B·ε^n) · (1 + C·ln(ε̇/ε̇₀)) · (1 - T*^m)
242///
243/// where T* = (T - T_room) / (T_melt - T_room) is the homologous temperature.
244///
245/// Reference: Johnson & Cook (1983).
246#[allow(dead_code)]
247#[derive(Debug, Clone)]
248pub struct JohnsonCookModel {
249    /// Yield stress A \[Pa\].
250    pub a: f64,
251    /// Strain hardening coefficient B \[Pa\].
252    pub b: f64,
253    /// Strain hardening exponent n (dimensionless).
254    pub n: f64,
255    /// Strain rate sensitivity C (dimensionless).
256    pub c: f64,
257    /// Thermal softening exponent m (dimensionless).
258    pub m: f64,
259    /// Reference strain rate ε̇₀ \[1/s\].
260    pub eps_dot0: f64,
261    /// Room temperature T_room \[K\].
262    pub t_room: f64,
263    /// Melt temperature T_melt \[K\].
264    pub t_melt: f64,
265}
266impl JohnsonCookModel {
267    /// Create a new Johnson-Cook model.
268    #[allow(clippy::too_many_arguments)]
269    pub fn new(
270        a: f64,
271        b: f64,
272        n: f64,
273        c: f64,
274        m: f64,
275        eps_dot0: f64,
276        t_room: f64,
277        t_melt: f64,
278    ) -> Self {
279        Self {
280            a,
281            b,
282            n,
283            c,
284            m,
285            eps_dot0,
286            t_room,
287            t_melt,
288        }
289    }
290    /// Typical AISI 4340 steel parameters.
291    pub fn aisi_4340() -> Self {
292        Self::new(792e6, 510e6, 0.26, 0.014, 1.03, 1.0, 293.0, 1793.0)
293    }
294    /// Homologous temperature T* ∈ \[0, 1\].
295    pub fn homologous_temperature(&self, temperature: f64) -> f64 {
296        if temperature <= self.t_room {
297            return 0.0;
298        }
299        if temperature >= self.t_melt {
300            return 1.0;
301        }
302        (temperature - self.t_room) / (self.t_melt - self.t_room)
303    }
304    /// Flow stress σ \[Pa\].
305    ///
306    /// # Arguments
307    /// * `eps`     - Equivalent plastic strain (dimensionless, ≥ 0)
308    /// * `eps_dot` - Equivalent plastic strain rate \[1/s\]
309    /// * `temp`    - Current temperature \[K\]
310    #[allow(clippy::too_many_arguments)]
311    #[allow(non_snake_case)]
312    pub fn flow_stress(&self, eps: f64, eps_dot: f64, temp: f64) -> f64 {
313        let term_A = self.a + self.b * eps.max(0.0).powf(self.n);
314        let eps_dot_ratio = (eps_dot / self.eps_dot0).max(1.0);
315        let term_B = 1.0 + self.c * eps_dot_ratio.ln();
316        let t_star = self.homologous_temperature(temp);
317        let term_C = 1.0 - t_star.powf(self.m);
318        term_A * term_B * term_C
319    }
320    /// Isothermal flow stress (room temperature, reference strain rate).
321    pub fn isothermal_flow_stress(&self, eps: f64) -> f64 {
322        self.flow_stress(eps, self.eps_dot0, self.t_room)
323    }
324    /// Strain-rate sensitivity ratio: σ(ε̇) / σ(ε̇₀) at given strain and temperature.
325    pub fn strain_rate_sensitivity(&self, eps_dot: f64, eps: f64, temp: f64) -> f64 {
326        let sig_ref = self.flow_stress(eps, self.eps_dot0, temp);
327        if sig_ref < f64::EPSILON {
328            return 1.0;
329        }
330        self.flow_stress(eps, eps_dot, temp) / sig_ref
331    }
332}
333/// Thermal interface (Kapitza) resistance between two materials.
334///
335/// Heat flux across interface: q = delta_T / R_k
336/// where R_k is the thermal interface resistance (m^2*K/W).
337pub struct ThermalInterfaceResistance {
338    /// Interface resistance R_k (m^2*K/W).
339    pub resistance: f64,
340}
341impl ThermalInterfaceResistance {
342    /// Create a new thermal interface resistance.
343    pub fn new(resistance: f64) -> Self {
344        Self { resistance }
345    }
346    /// Typical metal-metal interface: R ~ 1e-4 m^2*K/W.
347    pub fn metal_metal() -> Self {
348        Self::new(1e-4)
349    }
350    /// Typical metal-ceramic interface: R ~ 1e-3 m^2*K/W.
351    pub fn metal_ceramic() -> Self {
352        Self::new(1e-3)
353    }
354    /// Typical semiconductor interface: R ~ 1e-8 m^2*K/W.
355    pub fn semiconductor() -> Self {
356        Self::new(1e-8)
357    }
358    /// Heat flux across the interface for a given temperature drop.
359    pub fn heat_flux(&self, delta_t: f64) -> f64 {
360        delta_t / self.resistance
361    }
362    /// Temperature drop for a given heat flux.
363    pub fn temperature_drop(&self, heat_flux: f64) -> f64 {
364        heat_flux * self.resistance
365    }
366    /// Effective conductance (1/R_k) in W/(m^2*K).
367    pub fn conductance(&self) -> f64 {
368        1.0 / self.resistance
369    }
370}
371/// 3×3 thermal conductivity tensor for anisotropic materials.
372///
373/// The heat flux vector q = -K · ∇T where K is the conductivity tensor.
374/// For orthotropic materials K is diagonal in the principal axes.
375pub struct ThermalConductivityTensor {
376    /// Row-major 3×3 conductivity tensor \[W/(m·K)\].
377    pub k: [[f64; 3]; 3],
378}
379impl ThermalConductivityTensor {
380    /// Create from a full 3×3 matrix.
381    pub fn new(k: [[f64; 3]; 3]) -> Self {
382        Self { k }
383    }
384    /// Create an isotropic tensor: K = k · I.
385    pub fn isotropic(k: f64) -> Self {
386        Self {
387            k: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]],
388        }
389    }
390    /// Create a diagonal (orthotropic) tensor from principal conductivities.
391    pub fn orthotropic(kx: f64, ky: f64, kz: f64) -> Self {
392        Self {
393            k: [[kx, 0.0, 0.0], [0.0, ky, 0.0], [0.0, 0.0, kz]],
394        }
395    }
396    /// Compute heat flux vector q = -K · ∇T.
397    ///
398    /// # Arguments
399    /// * `grad_t` — temperature gradient ∇T \[K/m\]
400    ///
401    /// Returns heat flux \[W/m²\].
402    #[allow(clippy::needless_range_loop)]
403    pub fn heat_flux(&self, grad_t: [f64; 3]) -> [f64; 3] {
404        let mut q = [0.0_f64; 3];
405        for i in 0..3 {
406            for j in 0..3 {
407                q[i] -= self.k[i][j] * grad_t[j];
408            }
409        }
410        q
411    }
412    /// Effective (geometric mean) conductivity for isotropic comparison.
413    pub fn effective_conductivity(&self) -> f64 {
414        (self.k[0][0] * self.k[1][1] * self.k[2][2]).powf(1.0 / 3.0)
415    }
416    /// Check if the tensor is symmetric (required for physical validity).
417    pub fn is_symmetric(&self, tol: f64) -> bool {
418        for i in 0..3 {
419            for j in 0..3 {
420                if (self.k[i][j] - self.k[j][i]).abs() > tol {
421                    return false;
422                }
423            }
424        }
425        true
426    }
427}
428/// Ablation model for surface recession under extreme heat flux.
429///
430/// Models the recession of a material surface when the surface temperature
431/// exceeds the ablation (sublimation or vaporization) threshold.
432/// The recession rate is governed by the Arrhenius-like expression.
433pub struct AblationModel {
434    /// Ablation threshold temperature T_ab \[K\].
435    pub t_ablation: f64,
436    /// Heat of ablation (enthalpy of vaporization) L_ab \[J/kg\].
437    pub heat_of_ablation: f64,
438    /// Material density \[kg/m³\].
439    pub density: f64,
440    /// Pre-exponential factor A \[m/s\].
441    pub arrhenius_a: f64,
442    /// Activation temperature T_act = Ea/(R) \[K\].
443    pub activation_temp: f64,
444}
445impl AblationModel {
446    /// Create a new ablation model.
447    pub fn new(
448        t_ablation: f64,
449        heat_of_ablation: f64,
450        density: f64,
451        arrhenius_a: f64,
452        activation_temp: f64,
453    ) -> Self {
454        Self {
455            t_ablation,
456            heat_of_ablation,
457            density,
458            arrhenius_a,
459            activation_temp,
460        }
461    }
462    /// Surface recession rate \[m/s\] at temperature T.
463    ///
464    /// r = A * exp(-T_act / T)  for T > T_ab, else 0
465    pub fn recession_rate(&self, temp: f64) -> f64 {
466        if temp <= self.t_ablation {
467            return 0.0;
468        }
469        self.arrhenius_a * (-self.activation_temp / temp).exp()
470    }
471    /// Mass loss rate \[kg/(m²·s)\] = density * recession_rate.
472    pub fn mass_loss_rate(&self, temp: f64) -> f64 {
473        self.density * self.recession_rate(temp)
474    }
475    /// Blowing heat flux \[W/m²\] absorbed by ablation = L_ab * mdot.
476    pub fn ablative_heat_flux(&self, temp: f64) -> f64 {
477        self.heat_of_ablation * self.mass_loss_rate(temp)
478    }
479    /// Net surface heat flux after ablative cooling \[W/m²\].
480    ///
481    /// q_net = q_applied - q_ablation
482    pub fn net_heat_flux(&self, applied_flux: f64, temp: f64) -> f64 {
483        applied_flux - self.ablative_heat_flux(temp)
484    }
485}
486/// Enthalpy method for melting and solidification problems.
487///
488/// The total enthalpy H at temperature T includes sensible and latent heat:
489///
490/// H(T) = ρ·cp·T + ρ·L·f_l(T)
491///
492/// where f_l is the liquid fraction. The temperature-update is performed
493/// by inverting H(T) given the updated enthalpy.
494#[allow(dead_code)]
495#[derive(Debug, Clone)]
496pub struct EnthalpyMethod {
497    /// Density \[kg/m³\].
498    pub rho: f64,
499    /// Specific heat capacity \[J/(kg·K)\].
500    pub cp: f64,
501    /// Latent heat of fusion L \[J/kg\].
502    pub latent_heat: f64,
503    /// Solidus temperature T_s \[K\].
504    pub t_solidus: f64,
505    /// Liquidus temperature T_l \[K\].
506    pub t_liquidus: f64,
507    /// Current temperature \[K\].
508    pub temperature: f64,
509    /// Current enthalpy \[J/m³\].
510    pub enthalpy: f64,
511}
512impl EnthalpyMethod {
513    /// Create a new enthalpy method model.
514    pub fn new(rho: f64, cp: f64, latent_heat: f64, t_solidus: f64, t_liquidus: f64) -> Self {
515        let t0 = t_solidus - 1.0;
516        let h0 = rho * cp * t0;
517        Self {
518            rho,
519            cp,
520            latent_heat,
521            t_solidus,
522            t_liquidus,
523            temperature: t0,
524            enthalpy: h0,
525        }
526    }
527    /// Liquid fraction as a linear function of temperature in mushy zone.
528    pub fn liquid_fraction(&self, temp: f64) -> f64 {
529        if temp <= self.t_solidus {
530            0.0
531        } else if temp >= self.t_liquidus {
532            1.0
533        } else {
534            (temp - self.t_solidus) / (self.t_liquidus - self.t_solidus)
535        }
536    }
537    /// Total enthalpy H(T) \[J/m³\].
538    pub fn enthalpy_at(&self, temp: f64) -> f64 {
539        self.rho * self.cp * temp + self.rho * self.latent_heat * self.liquid_fraction(temp)
540    }
541    /// Invert enthalpy to temperature (Newton-Raphson, max 50 iterations).
542    pub fn temperature_from_enthalpy(&self, h: f64) -> f64 {
543        let mut t = h / (self.rho * self.cp);
544        for _ in 0..50 {
545            let h_t = self.enthalpy_at(t);
546            let dh_dt = self.effective_cp(t);
547            let dt = (h - h_t) / dh_dt.max(f64::EPSILON);
548            t += dt;
549            if dt.abs() < 1e-6 {
550                break;
551            }
552        }
553        t
554    }
555    /// Effective (apparent) specific heat cp_eff \[J/(kg·K)\] including latent heat.
556    pub fn effective_cp(&self, temp: f64) -> f64 {
557        if temp > self.t_solidus && temp < self.t_liquidus {
558            let dfl_dt = 1.0 / (self.t_liquidus - self.t_solidus);
559            self.rho * (self.cp + self.latent_heat * dfl_dt)
560        } else {
561            self.rho * self.cp
562        }
563    }
564    /// Apply a heat flux q \[W/m³\] over time step dt \[s\].
565    pub fn apply_heat_flux(&mut self, q: f64, dt: f64) {
566        self.enthalpy += q * dt;
567        self.temperature = self.temperature_from_enthalpy(self.enthalpy);
568    }
569    /// Check if material is currently in the mushy zone.
570    pub fn is_mushy(&self) -> bool {
571        self.temperature > self.t_solidus && self.temperature < self.t_liquidus
572    }
573}
574/// Temperature-dependent specific heat (piecewise-linear tabulation).
575pub struct TemperatureDependentSpecificHeat {
576    /// Temperature sample points \[K\].
577    pub temps: Vec<f64>,
578    /// Specific heat values at each temperature \[J/(kg*K)\].
579    pub values: Vec<f64>,
580}
581impl TemperatureDependentSpecificHeat {
582    /// Create a new table.
583    pub fn new(temps: Vec<f64>, values: Vec<f64>) -> Self {
584        assert_eq!(temps.len(), values.len());
585        Self { temps, values }
586    }
587    /// Linearly interpolate specific heat at `temperature`.
588    pub fn cp_at(&self, temperature: f64) -> f64 {
589        let n = self.temps.len();
590        if n == 0 {
591            return 0.0;
592        }
593        if n == 1 || temperature <= self.temps[0] {
594            return self.values[0];
595        }
596        if temperature >= self.temps[n - 1] {
597            return self.values[n - 1];
598        }
599        let idx = self
600            .temps
601            .partition_point(|&t| t <= temperature)
602            .saturating_sub(1);
603        let t0 = self.temps[idx];
604        let t1 = self.temps[idx + 1];
605        let c0 = self.values[idx];
606        let c1 = self.values[idx + 1];
607        let frac = (temperature - t0) / (t1 - t0);
608        c0 + frac * (c1 - c0)
609    }
610}
611/// Thermal shock resistance parameters.
612///
613/// R  = sigma_f * (1 - nu) / (E * alpha)        -- maximum delta_T without fracture
614/// R' = R * k                                     -- with heat conduction
615/// R'' = sigma_f^2 * (1 - nu) / (E * alpha^2)    -- thermal stress damage resistance
616pub struct ThermalShockResistance {
617    /// Fracture strength sigma_f (Pa).
618    pub fracture_strength: f64,
619    /// Young's modulus E (Pa).
620    pub young_modulus: f64,
621    /// Poisson's ratio.
622    pub poisson_ratio: f64,
623    /// Coefficient of thermal expansion (1/K).
624    pub thermal_expansion: f64,
625    /// Thermal conductivity (W/(m*K)).
626    pub thermal_conductivity: f64,
627}
628impl ThermalShockResistance {
629    /// Create a new ThermalShockResistance calculator.
630    #[allow(clippy::too_many_arguments)]
631    pub fn new(
632        fracture_strength: f64,
633        young_modulus: f64,
634        poisson_ratio: f64,
635        thermal_expansion: f64,
636        thermal_conductivity: f64,
637    ) -> Self {
638        Self {
639            fracture_strength,
640            young_modulus,
641            poisson_ratio,
642            thermal_expansion,
643            thermal_conductivity,
644        }
645    }
646    /// First thermal shock resistance parameter R (K).
647    ///
648    /// Maximum temperature change the material can withstand.
649    pub fn r_parameter(&self) -> f64 {
650        self.fracture_strength * (1.0 - self.poisson_ratio)
651            / (self.young_modulus * self.thermal_expansion)
652    }
653    /// Second parameter R' = R * k (W/m).
654    pub fn r_prime(&self) -> f64 {
655        self.r_parameter() * self.thermal_conductivity
656    }
657    /// Third parameter R'' = sigma_f^2 * (1-nu) / (E * alpha^2) (Pa*K).
658    pub fn r_double_prime(&self) -> f64 {
659        self.fracture_strength * self.fracture_strength * (1.0 - self.poisson_ratio)
660            / (self.young_modulus * self.thermal_expansion * self.thermal_expansion)
661    }
662    /// Check if a given temperature change will cause thermal shock failure.
663    pub fn will_fail(&self, delta_t: f64) -> bool {
664        delta_t.abs() > self.r_parameter()
665    }
666}
667/// Temperature-dependent thermal conductivity (piecewise-linear tabulation).
668pub struct TemperatureDependentConductivity {
669    /// Temperature sample points \[K\].
670    pub temps: Vec<f64>,
671    /// Conductivity values at each temperature \[W/(m*K)\].
672    pub conductivities: Vec<f64>,
673}
674impl TemperatureDependentConductivity {
675    /// Create a new table.
676    pub fn new(temps: Vec<f64>, conductivities: Vec<f64>) -> Self {
677        assert_eq!(temps.len(), conductivities.len());
678        Self {
679            temps,
680            conductivities,
681        }
682    }
683    /// Linearly interpolate conductivity at `temperature`.
684    pub fn conductivity_at(&self, temperature: f64) -> f64 {
685        let n = self.temps.len();
686        if n == 0 {
687            return 0.0;
688        }
689        if n == 1 || temperature <= self.temps[0] {
690            return self.conductivities[0];
691        }
692        if temperature >= self.temps[n - 1] {
693            return self.conductivities[n - 1];
694        }
695        let idx = self
696            .temps
697            .partition_point(|&t| t <= temperature)
698            .saturating_sub(1);
699        let t0 = self.temps[idx];
700        let t1 = self.temps[idx + 1];
701        let k0 = self.conductivities[idx];
702        let k1 = self.conductivities[idx + 1];
703        let frac = (temperature - t0) / (t1 - t0);
704        k0 + frac * (k1 - k0)
705    }
706    /// Average conductivity over \[t_low, t_high\] using the trapezoidal rule.
707    pub fn mean_conductivity(&self, t_low: f64, t_high: f64) -> f64 {
708        if (t_high - t_low).abs() < f64::EPSILON {
709            return self.conductivity_at(t_low);
710        }
711        let mut pts: Vec<f64> = vec![t_low];
712        for &t in &self.temps {
713            if t > t_low && t < t_high {
714                pts.push(t);
715            }
716        }
717        pts.push(t_high);
718        pts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
719        pts.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON);
720        let mut integral = 0.0;
721        for i in 0..pts.len() - 1 {
722            let ta = pts[i];
723            let tb = pts[i + 1];
724            let ka = self.conductivity_at(ta);
725            let kb = self.conductivity_at(tb);
726            integral += 0.5 * (ka + kb) * (tb - ta);
727        }
728        integral / (t_high - t_low)
729    }
730}
731/// Debye specific heat model.
732///
733/// Cv = 9*N*k_B * (T/Theta_D)^3 * integral_0^{Theta_D/T} x^4*e^x/(e^x - 1)^2 dx
734///
735/// In the high-T limit: Cv -> 3*N*k_B (Dulong-Petit).
736/// In the low-T limit: Cv ~ T^3.
737pub struct DebyeModel {
738    /// Debye temperature Theta_D (K).
739    pub theta_d: f64,
740    /// Number of atoms per formula unit (for molar heat capacity).
741    pub n_atoms: f64,
742}
743impl DebyeModel {
744    /// Create a new Debye model.
745    pub fn new(theta_d: f64, n_atoms: f64) -> Self {
746        Self { theta_d, n_atoms }
747    }
748    /// Molar heat capacity Cv (J/(mol*K)) at temperature T.
749    ///
750    /// Uses numerical integration with Simpson's rule.
751    pub fn molar_cv(&self, temperature: f64) -> f64 {
752        if temperature < 1e-10 {
753            return 0.0;
754        }
755        let x_max = self.theta_d / temperature;
756        let n_steps = 200;
757        let dx = x_max / (n_steps as f64);
758        let integrand = |x: f64| -> f64 {
759            if x < 1e-12 {
760                return 0.0;
761            }
762            if x > 500.0 {
763                return 0.0;
764            }
765            let ex = x.exp();
766            x.powi(4) * ex / (ex - 1.0).powi(2)
767        };
768        let mut sum = integrand(0.0) + integrand(x_max);
769        for i in 1..n_steps {
770            let x = i as f64 * dx;
771            let weight = if i % 2 == 0 { 2.0 } else { 4.0 };
772            sum += weight * integrand(x);
773        }
774        let integral = sum * dx / 3.0;
775        let ratio = temperature / self.theta_d;
776        9.0 * self.n_atoms * R_GAS * ratio.powi(3) * integral
777    }
778    /// Dulong-Petit limit: Cv = 3*n*R.
779    pub fn dulong_petit(&self) -> f64 {
780        3.0 * self.n_atoms * R_GAS
781    }
782}
783/// Thermal expansion model computing volumetric and anisotropic strain.
784///
785/// For isotropic materials the volumetric thermal strain is simply
786/// ΔV/V = 3 α ΔT, where α is the linear thermal expansion coefficient.
787/// For orthotropic/anisotropic materials separate coefficients are used
788/// along each principal axis.
789#[allow(dead_code)]
790#[derive(Debug, Clone)]
791pub struct ThermalExpansion {
792    /// Linear thermal expansion coefficient along x-axis \[1/K\].
793    pub alpha_x: f64,
794    /// Linear thermal expansion coefficient along y-axis \[1/K\].
795    pub alpha_y: f64,
796    /// Linear thermal expansion coefficient along z-axis \[1/K\].
797    pub alpha_z: f64,
798    /// Reference temperature T_ref \[K\] at which the body is stress-free.
799    pub t_ref: f64,
800}
801#[allow(dead_code)]
802impl ThermalExpansion {
803    /// Create an **isotropic** thermal expansion model.
804    ///
805    /// # Arguments
806    /// * `alpha` - Isotropic linear CTE \[1/K\]
807    /// * `t_ref` - Stress-free reference temperature \[K\]
808    pub fn isotropic(alpha: f64, t_ref: f64) -> Self {
809        Self {
810            alpha_x: alpha,
811            alpha_y: alpha,
812            alpha_z: alpha,
813            t_ref,
814        }
815    }
816    /// Create an **orthotropic** thermal expansion model.
817    ///
818    /// # Arguments
819    /// * `alpha_x`, `alpha_y`, `alpha_z` - Principal CTEs \[1/K\]
820    /// * `t_ref` - Stress-free reference temperature \[K\]
821    pub fn orthotropic(alpha_x: f64, alpha_y: f64, alpha_z: f64, t_ref: f64) -> Self {
822        Self {
823            alpha_x,
824            alpha_y,
825            alpha_z,
826            t_ref,
827        }
828    }
829    /// Linear strain along x: ε_x = αₓ · ΔT.
830    pub fn strain_x(&self, temperature: f64) -> f64 {
831        self.alpha_x * (temperature - self.t_ref)
832    }
833    /// Linear strain along y: ε_y = αᵧ · ΔT.
834    pub fn strain_y(&self, temperature: f64) -> f64 {
835        self.alpha_y * (temperature - self.t_ref)
836    }
837    /// Linear strain along z: ε_z = α_z · ΔT.
838    pub fn strain_z(&self, temperature: f64) -> f64 {
839        self.alpha_z * (temperature - self.t_ref)
840    }
841    /// Compute the volumetric thermal strain ΔV/V = ε_x + ε_y + ε_z.
842    ///
843    /// For isotropic materials: ΔV/V = 3 α ΔT (exact for small strains).
844    /// For orthotropic materials: ΔV/V = (α_x + α_y + α_z) ΔT.
845    ///
846    /// The exact large-deformation volumetric strain is:
847    /// ΔV/V = (1 + ε_x)(1 + ε_y)(1 + ε_z) − 1
848    /// but the linearised form is used here as it is sufficient for most
849    /// engineering temperature ranges (|αΔT| << 1).
850    ///
851    /// # Arguments
852    /// * `temperature` - Current temperature T \[K\]
853    ///
854    /// # Returns
855    /// Volumetric strain ΔV/V \[dimensionless\]
856    pub fn compute_volumetric_strain(&self, temperature: f64) -> f64 {
857        let delta_t = temperature - self.t_ref;
858        (self.alpha_x + self.alpha_y + self.alpha_z) * delta_t
859    }
860    /// Exact (large-deformation) volumetric strain J − 1.
861    ///
862    /// J = (1 + ε_x)(1 + ε_y)(1 + ε_z)
863    pub fn compute_volumetric_strain_exact(&self, temperature: f64) -> f64 {
864        let ex = 1.0 + self.strain_x(temperature);
865        let ey = 1.0 + self.strain_y(temperature);
866        let ez = 1.0 + self.strain_z(temperature);
867        ex * ey * ez - 1.0
868    }
869    /// Thermal dilatation tensor as \[ε_x, ε_y, ε_z\].
870    pub fn strain_tensor(&self, temperature: f64) -> [f64; 3] {
871        [
872            self.strain_x(temperature),
873            self.strain_y(temperature),
874            self.strain_z(temperature),
875        ]
876    }
877}
878/// Extended thermal shock resistance parameter calculations.
879///
880/// Beyond the basic R = k·σ_f / (E·α) parameter (already in ThermalShockResistance),
881/// this struct adds:
882/// - R'' (energy-based, including fracture toughness)
883/// - Hasselman parameter R''''
884/// - Thermal shock damage resistance
885#[allow(dead_code)]
886#[derive(Debug, Clone)]
887pub struct ThermalShockParam {
888    /// Fracture strength \[Pa\].
889    pub sigma_f: f64,
890    /// Young's modulus \[Pa\].
891    pub young_modulus: f64,
892    /// Poisson's ratio.
893    pub nu: f64,
894    /// Thermal expansion coefficient \[1/K\].
895    pub alpha: f64,
896    /// Thermal conductivity \[W/(m·K)\].
897    pub k: f64,
898    /// Fracture toughness K_Ic \[Pa·√m\].
899    pub k_ic: f64,
900}
901impl ThermalShockParam {
902    /// Create a new thermal shock parameter set.
903    #[allow(clippy::too_many_arguments)]
904    pub fn new(sigma_f: f64, young_modulus: f64, nu: f64, alpha: f64, k: f64, k_ic: f64) -> Self {
905        Self {
906            sigma_f,
907            young_modulus,
908            nu,
909            alpha,
910            k,
911            k_ic,
912        }
913    }
914    /// First thermal shock resistance parameter R \[K\]:
915    /// R = σ_f · (1-ν) / (E·α)
916    pub fn r_first(&self) -> f64 {
917        self.sigma_f * (1.0 - self.nu) / (self.young_modulus * self.alpha)
918    }
919    /// Second parameter R' \[W/m\]:
920    /// R' = k · σ_f · (1-ν) / (E·α)
921    pub fn r_second(&self) -> f64 {
922        self.k * self.r_first()
923    }
924    /// Third parameter R'' \[J/m\]:
925    /// R'' = G_f / (E·α²) where G_f = K_Ic² / E
926    pub fn r_third(&self) -> f64 {
927        let g_f = self.k_ic * self.k_ic / self.young_modulus;
928        g_f / (self.young_modulus * self.alpha * self.alpha)
929    }
930    /// Hasselman unified thermal shock parameter \[K\]:
931    /// R'''' = E · G_f / (σ_f² · (1-ν))
932    pub fn r_hasselman(&self) -> f64 {
933        let g_f = self.k_ic * self.k_ic / self.young_modulus;
934        self.young_modulus * g_f / (self.sigma_f * self.sigma_f * (1.0 - self.nu))
935    }
936}
937/// Einstein specific heat model.
938///
939/// Cv = 3*N*R * (Theta_E/T)^2 * exp(Theta_E/T) / (exp(Theta_E/T) - 1)^2
940pub struct EinsteinModel {
941    /// Einstein temperature Theta_E (K).
942    pub theta_e: f64,
943    /// Number of atoms per formula unit.
944    pub n_atoms: f64,
945}
946impl EinsteinModel {
947    /// Create a new Einstein model.
948    pub fn new(theta_e: f64, n_atoms: f64) -> Self {
949        Self { theta_e, n_atoms }
950    }
951    /// Molar heat capacity Cv (J/(mol*K)) at temperature T.
952    pub fn molar_cv(&self, temperature: f64) -> f64 {
953        if temperature < 1e-10 {
954            return 0.0;
955        }
956        let x = self.theta_e / temperature;
957        if x > 500.0 {
958            return 0.0;
959        }
960        let ex = x.exp();
961        3.0 * self.n_atoms * R_GAS * x * x * ex / (ex - 1.0).powi(2)
962    }
963}
964/// Fourier heat conduction in 1D via explicit finite differences.
965pub struct HeatConduction1D {
966    /// Number of nodes (including boundary nodes).
967    pub n_nodes: usize,
968    /// Domain length \[m\].
969    pub length: f64,
970    /// Current nodal temperatures \[K\].
971    pub temperature: Vec<f64>,
972    /// Material properties.
973    pub material: ThermalMaterial,
974}
975impl HeatConduction1D {
976    /// Construct a uniform-temperature rod.
977    pub fn new(n_nodes: usize, length: f64, t_init: f64, material: ThermalMaterial) -> Self {
978        assert!(n_nodes >= 2, "need at least 2 nodes");
979        Self {
980            n_nodes,
981            length,
982            temperature: vec![t_init; n_nodes],
983            material,
984        }
985    }
986    /// Set Dirichlet BCs at both ends.
987    pub fn set_temperature_bc(&mut self, t_left: f64, t_right: f64) {
988        self.temperature[0] = t_left;
989        self.temperature[self.n_nodes - 1] = t_right;
990    }
991    /// Explicit (forward-Euler) time step.
992    pub fn step_explicit(&mut self, dt: f64) -> bool {
993        let dx = self.length / (self.n_nodes - 1) as f64;
994        let alpha = self.material.diffusivity();
995        let r = alpha * dt / (dx * dx);
996        if r > 0.5 {
997            return false;
998        }
999        let old = self.temperature.clone();
1000        for i in 1..self.n_nodes - 1 {
1001            self.temperature[i] = old[i] + r * (old[i + 1] - 2.0 * old[i] + old[i - 1]);
1002        }
1003        true
1004    }
1005    /// Explicit step with volumetric heat source Q (W/m^3).
1006    pub fn step_explicit_with_source(&mut self, dt: f64, source: f64) -> bool {
1007        let dx = self.length / (self.n_nodes - 1) as f64;
1008        let alpha = self.material.diffusivity();
1009        let r = alpha * dt / (dx * dx);
1010        if r > 0.5 {
1011            return false;
1012        }
1013        let old = self.temperature.clone();
1014        let rho_cp = self.material.volumetric_heat_capacity();
1015        for i in 1..self.n_nodes - 1 {
1016            self.temperature[i] =
1017                old[i] + r * (old[i + 1] - 2.0 * old[i] + old[i - 1]) + source * dt / rho_cp;
1018        }
1019        true
1020    }
1021    /// Maximum stable time step: dx^2 / (2 * alpha).
1022    pub fn critical_dt(&self) -> f64 {
1023        let dx = self.length / (self.n_nodes - 1) as f64;
1024        let alpha = self.material.diffusivity();
1025        (dx * dx) / (2.0 * alpha)
1026    }
1027    /// Exact steady-state temperature profile (linear between BCs).
1028    pub fn steady_state_temperature(&self, t_left: f64, t_right: f64) -> Vec<f64> {
1029        (0..self.n_nodes)
1030            .map(|i| {
1031                let frac = i as f64 / (self.n_nodes - 1) as f64;
1032                t_left + frac * (t_right - t_left)
1033            })
1034            .collect()
1035    }
1036    /// Heat flux at each interior node via central finite differences.
1037    pub fn heat_flux(&self) -> Vec<f64> {
1038        let dx = self.length / (self.n_nodes - 1) as f64;
1039        let k = self.material.thermal_conductivity;
1040        (1..self.n_nodes - 1)
1041            .map(|i| {
1042                let dt_dx = (self.temperature[i + 1] - self.temperature[i - 1]) / (2.0 * dx);
1043                -k * dt_dx
1044            })
1045            .collect()
1046    }
1047    /// Total heat stored in the rod (integral of rho*cp*T*dx).
1048    pub fn total_heat_content(&self) -> f64 {
1049        let dx = self.length / (self.n_nodes - 1) as f64;
1050        let rho_cp = self.material.volumetric_heat_capacity();
1051        self.temperature.iter().map(|&t| rho_cp * t * dx).sum()
1052    }
1053}
1054/// Isotropic thermal material.
1055pub struct ThermalMaterial {
1056    /// Thermal conductivity \[W/(m*K)\]
1057    pub thermal_conductivity: f64,
1058    /// Specific heat capacity \[J/(kg*K)\]
1059    pub specific_heat: f64,
1060    /// Density \[kg/m^3\]
1061    pub density: f64,
1062    /// Linear coefficient of thermal expansion \[1/K\]
1063    pub thermal_expansion: f64,
1064}
1065impl ThermalMaterial {
1066    /// Create a new `ThermalMaterial`.
1067    pub fn new(k: f64, cp: f64, rho: f64, alpha: f64) -> Self {
1068        Self {
1069            thermal_conductivity: k,
1070            specific_heat: cp,
1071            density: rho,
1072            thermal_expansion: alpha,
1073        }
1074    }
1075    /// Structural steel: k=50, cp=500, rho=7850, alpha=12e-6.
1076    pub fn steel() -> Self {
1077        Self::new(50.0, 500.0, 7850.0, 12e-6)
1078    }
1079    /// Aluminium alloy: k=237, cp=900, rho=2700, alpha=23e-6.
1080    pub fn aluminum() -> Self {
1081        Self::new(237.0, 900.0, 2700.0, 23e-6)
1082    }
1083    /// Copper: k=401, cp=385, rho=8960, alpha=17e-6.
1084    pub fn copper() -> Self {
1085        Self::new(401.0, 385.0, 8960.0, 17e-6)
1086    }
1087    /// Concrete: k=1.5, cp=880, rho=2300, alpha=10e-6.
1088    pub fn concrete() -> Self {
1089        Self::new(1.5, 880.0, 2300.0, 10e-6)
1090    }
1091    /// Silicon carbide (SiC): k=120, cp=750, rho=3210, alpha=4.0e-6.
1092    pub fn silicon_carbide() -> Self {
1093        Self::new(120.0, 750.0, 3210.0, 4.0e-6)
1094    }
1095    /// Alumina (Al2O3): k=30, cp=880, rho=3950, alpha=8.0e-6.
1096    pub fn alumina() -> Self {
1097        Self::new(30.0, 880.0, 3950.0, 8.0e-6)
1098    }
1099    /// Thermal diffusivity alpha = k / (rho * cp)  \[m^2/s\].
1100    pub fn diffusivity(&self) -> f64 {
1101        self.thermal_conductivity / (self.density * self.specific_heat)
1102    }
1103    /// Thermal effusivity e = sqrt(k * rho * cp)  \[J/(m^2*K*s^0.5)\].
1104    pub fn effusivity(&self) -> f64 {
1105        (self.thermal_conductivity * self.density * self.specific_heat).sqrt()
1106    }
1107    /// Free thermal expansion strain for temperature change delta_t.
1108    pub fn thermal_strain(&self, delta_t: f64) -> f64 {
1109        self.thermal_expansion * delta_t
1110    }
1111    /// Thermal stress for fully constrained expansion (plane-stress).
1112    ///
1113    /// sigma = -E * alpha * delta_T / (1 - nu)
1114    pub fn thermal_stress(&self, delta_t: f64, young_modulus: f64, poisson_ratio: f64) -> f64 {
1115        -young_modulus * self.thermal_expansion * delta_t / (1.0 - poisson_ratio)
1116    }
1117    /// Volumetric heat capacity rho * cp \[J/(m^3*K)\].
1118    pub fn volumetric_heat_capacity(&self) -> f64 {
1119        self.density * self.specific_heat
1120    }
1121    /// Thermal penetration depth at time t.
1122    ///
1123    /// delta = sqrt(4 * alpha * t)
1124    pub fn penetration_depth(&self, t: f64) -> f64 {
1125        (4.0 * self.diffusivity() * t).sqrt()
1126    }
1127    /// Compute the first thermal shock resistance parameter R \[K\].
1128    ///
1129    /// R = σ_f · (1 − ν) / (E · α)
1130    ///
1131    /// R is the maximum temperature change the material can sustain without
1132    /// fracture under fully constrained thermal expansion. Higher R indicates
1133    /// better thermal shock resistance.
1134    ///
1135    /// # Arguments
1136    /// * `fracture_strength` - Tensile fracture strength σ_f \[Pa\]
1137    /// * `young_modulus`     - Young's modulus E \[Pa\]
1138    /// * `poisson_ratio`     - Poisson's ratio ν
1139    ///
1140    /// # Returns
1141    /// Thermal shock resistance R \[K\]
1142    pub fn compute_thermal_shock_resistance(
1143        &self,
1144        fracture_strength: f64,
1145        young_modulus: f64,
1146        poisson_ratio: f64,
1147    ) -> f64 {
1148        fracture_strength * (1.0 - poisson_ratio) / (young_modulus * self.thermal_expansion)
1149    }
1150    /// Estimate the electronic contribution to thermal conductivity via
1151    /// the Wiedemann-Franz law.
1152    ///
1153    /// κ_el = L · T / ρ_e
1154    ///
1155    /// where L = 2.44 × 10⁻⁸ W·Ω/K² is the Lorenz number and ρ_e is the
1156    /// electrical resistivity. This gives the electron-mediated thermal
1157    /// conductivity, which dominates in metals.
1158    ///
1159    /// # Arguments
1160    /// * `temperature`           - Absolute temperature T \[K\]
1161    /// * `electrical_resistivity` - Electrical resistivity ρ_e \[Ω·m\]
1162    ///
1163    /// # Returns
1164    /// Electronic thermal conductivity κ_el \[W/(m·K)\]
1165    pub fn compute_wiedemann_franz(&self, temperature: f64, electrical_resistivity: f64) -> f64 {
1166        const LORENZ: f64 = 2.4427e-8_f64;
1167        LORENZ * temperature / electrical_resistivity
1168    }
1169}
1170/// Newtonian cooling -- lumped capacitance model.
1171pub struct NewtonianCooling {
1172    /// Mass \[kg\].
1173    pub mass: f64,
1174    /// Specific heat \[J/(kg*K)\].
1175    pub specific_heat: f64,
1176    /// Convective heat-transfer coefficient \[W/(m^2*K)\].
1177    pub heat_transfer_coeff: f64,
1178    /// Surface area \[m^2\].
1179    pub surface_area: f64,
1180    /// Current temperature \[K\].
1181    pub temperature: f64,
1182}
1183impl NewtonianCooling {
1184    /// Construct a new lumped-capacitance object.
1185    pub fn new(mass: f64, cp: f64, h: f64, area: f64, t_init: f64) -> Self {
1186        Self {
1187            mass,
1188            specific_heat: cp,
1189            heat_transfer_coeff: h,
1190            surface_area: area,
1191            temperature: t_init,
1192        }
1193    }
1194    /// Time constant tau = m * cp / (h * A) \[s\].
1195    pub fn time_constant(&self) -> f64 {
1196        self.mass * self.specific_heat / (self.heat_transfer_coeff * self.surface_area)
1197    }
1198    /// Temperature at time `time` given ambient `t_inf`.
1199    pub fn temperature_at_time(&self, t_inf: f64, time: f64) -> f64 {
1200        let tau = self.time_constant();
1201        t_inf + (self.temperature - t_inf) * (-time / tau).exp()
1202    }
1203    /// Advance the temperature by one Euler step of size `dt`.
1204    pub fn step(&mut self, t_inf: f64, dt: f64) {
1205        let tau = self.time_constant();
1206        self.temperature += -dt / tau * (self.temperature - t_inf);
1207    }
1208    /// Time needed to reach `t_target` given ambient `t_inf`.
1209    pub fn time_to_temperature(&self, t_inf: f64, t_target: f64) -> Option<f64> {
1210        let tau = self.time_constant();
1211        let denom = self.temperature - t_inf;
1212        if denom.abs() < f64::EPSILON {
1213            return None;
1214        }
1215        let ratio = (t_target - t_inf) / denom;
1216        if ratio <= 0.0 {
1217            return None;
1218        }
1219        Some(-tau * ratio.ln())
1220    }
1221    /// Biot number: Bi = h * L / k (characteristic length L).
1222    ///
1223    /// For a sphere, L = radius/3.
1224    /// Lumped capacitance is valid when Bi < 0.1.
1225    pub fn biot_number(&self, characteristic_length: f64, thermal_conductivity: f64) -> f64 {
1226        self.heat_transfer_coeff * characteristic_length / thermal_conductivity
1227    }
1228}
1229/// Thermal fatigue model using the Coffin-Manson relationship
1230/// with temperature-range dependency.
1231///
1232/// The plastic strain range Δε_p depends on the temperature range ΔT:
1233/// Δε_p = α · ΔT · E_correction
1234///
1235/// Coffin-Manson: N_f = C / Δε_p^β
1236#[allow(dead_code)]
1237#[derive(Debug, Clone)]
1238pub struct ThermalFatigue {
1239    /// Coffin-Manson ductility coefficient C.
1240    pub c_cm: f64,
1241    /// Coffin-Manson exponent β (typically 0.5..0.7).
1242    pub beta: f64,
1243    /// Thermal expansion coefficient \[1/K\].
1244    pub alpha: f64,
1245    /// Young's modulus \[Pa\].
1246    pub young_modulus: f64,
1247    /// Accumulated Miner's damage D.
1248    pub damage: f64,
1249}
1250impl ThermalFatigue {
1251    /// Create a new thermal fatigue model.
1252    pub fn new(c_cm: f64, beta: f64, alpha: f64, young_modulus: f64) -> Self {
1253        Self {
1254            c_cm,
1255            beta,
1256            alpha,
1257            young_modulus,
1258            damage: 0.0,
1259        }
1260    }
1261    /// Plastic strain range for a given temperature range ΔT \[K\].
1262    ///
1263    /// Δε_p = α · ΔT  (simplified, assuming full inelastic constraint)
1264    pub fn plastic_strain_range(&self, delta_t: f64) -> f64 {
1265        self.alpha * delta_t.abs()
1266    }
1267    /// Fatigue life N_f for a given temperature range ΔT.
1268    ///
1269    /// N_f = C / Δε_p^β
1270    pub fn fatigue_life(&self, delta_t: f64) -> f64 {
1271        let deps = self.plastic_strain_range(delta_t);
1272        if deps < f64::EPSILON {
1273            return f64::INFINITY;
1274        }
1275        self.c_cm / deps.powf(self.beta)
1276    }
1277    /// Accumulate damage for `n_cycles` at temperature range ΔT.
1278    pub fn accumulate(&mut self, delta_t: f64, n_cycles: f64) {
1279        let n_f = self.fatigue_life(delta_t);
1280        if n_f.is_finite() {
1281            self.damage = (self.damage + n_cycles / n_f).min(1.0);
1282        }
1283    }
1284    /// Check if thermal fatigue failure has occurred (D ≥ 1).
1285    pub fn is_failed(&self) -> bool {
1286        self.damage >= 1.0 - f64::EPSILON
1287    }
1288    /// Temperature range causing failure in N cycles.
1289    ///
1290    /// Invert: ΔT = Δε_p / α,  Δε_p = (C/N)^(1/β)
1291    pub fn critical_delta_t(&self, n_cycles: f64) -> f64 {
1292        if n_cycles <= 0.0 {
1293            return 0.0;
1294        }
1295        let deps = (self.c_cm / n_cycles).powf(1.0 / self.beta);
1296        deps / self.alpha
1297    }
1298}