Skip to main content

oxiphysics_materials/
additive_manufacturing_materials.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Additive manufacturing material models.
5//!
6//! This module covers the process-structure-property (PSP) chain for key AM
7//! technologies:
8//!
9//! - **Powder bed fusion** (SLM / DMLS): [`PbfMaterial`], [`PbfProcessParams`],
10//!   [`MeltPoolGeometry`], thermal gradient and residual stress computation.
11//! - **Porosity effects**: [`PorosityModel`] — Ramakrishnan-Arunachalam,
12//!   Mori-Tanaka, Coble-Kingery relationships.
13//! - **Microstructure evolution**: [`GrainGrowthModel`], [`MartensiticModel`].
14//! - **Binder jetting**: [`BinderJettingMaterial`], sintering shrinkage.
15//! - **FDM polymers**: [`FdmPolymerMaterial`] — anisotropy, layer adhesion.
16//! - **Support structures**: [`SupportMaterial`].
17//! - **PSP linkage**: [`PspLinkage`] combining all above.
18//! - **Scan strategy effects**: [`ScanStrategyEffect`].
19//! - **Surface roughness**: [`AmSurfaceRoughness`].
20
21#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24use std::f64::consts::PI;
25
26// ---------------------------------------------------------------------------
27// Constants
28// ---------------------------------------------------------------------------
29
30/// Boltzmann constant (J/K).
31pub const KB: f64 = 1.380_649e-23;
32
33/// Gas constant (J/(mol·K)).
34pub const R_GAS: f64 = 8.314_462;
35
36// ---------------------------------------------------------------------------
37// Common enums
38// ---------------------------------------------------------------------------
39
40/// AM process technology.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum AmProcess {
43    /// Selective laser melting (SLM) / laser powder bed fusion (LPBF).
44    Slm,
45    /// Direct metal laser sintering (DMLS).
46    Dmls,
47    /// Electron beam melting (EBM).
48    Ebm,
49    /// Binder jetting (BJ).
50    BinderJetting,
51    /// Fused deposition modelling (FDM) / fused filament fabrication (FFF).
52    Fdm,
53    /// Directed energy deposition (DED).
54    Ded,
55}
56
57/// Metal alloy system commonly used in powder bed fusion.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum MetalAlloy {
60    /// Ti-6Al-4V (Grade 5 titanium).
61    Ti6Al4V,
62    /// 316L stainless steel.
63    Steel316L,
64    /// AlSi10Mg aluminium alloy.
65    AlSi10Mg,
66    /// IN718 nickel superalloy.
67    In718,
68    /// Maraging steel (1.2709).
69    MaragingSteel,
70    /// Cobalt-chromium (CoCr).
71    CoCr,
72    /// Copper (Cu).
73    Copper,
74}
75
76// ---------------------------------------------------------------------------
77// Powder Bed Fusion Material
78// ---------------------------------------------------------------------------
79
80/// Intrinsic material properties relevant to PBF processing.
81#[derive(Debug, Clone)]
82pub struct PbfMaterial {
83    /// Alloy type.
84    pub alloy: MetalAlloy,
85    /// Density of fully dense material (kg/m³).
86    pub density: f64,
87    /// Thermal conductivity at room temperature (W/(m·K)).
88    pub thermal_conductivity: f64,
89    /// Specific heat capacity (J/(kg·K)).
90    pub specific_heat: f64,
91    /// Latent heat of fusion (J/kg).
92    pub latent_heat_fusion: f64,
93    /// Solidus temperature (K).
94    pub solidus_temp: f64,
95    /// Liquidus temperature (K).
96    pub liquidus_temp: f64,
97    /// Absorptivity for the process laser wavelength (0–1).
98    pub absorptivity: f64,
99    /// Young's modulus at room temperature (Pa).
100    pub elastic_modulus: f64,
101    /// Poisson's ratio.
102    pub poisson_ratio: f64,
103    /// Yield strength at room temperature (Pa).
104    pub yield_strength: f64,
105    /// Tensile strength at room temperature (Pa).
106    pub tensile_strength: f64,
107    /// Thermal expansion coefficient (1/K).
108    pub thermal_expansion: f64,
109    /// Powder bed packing fraction (0–1).
110    pub packing_fraction: f64,
111}
112
113impl PbfMaterial {
114    /// Pre-defined Ti-6Al-4V properties for SLM.
115    pub fn ti6al4v_slm() -> Self {
116        Self {
117            alloy: MetalAlloy::Ti6Al4V,
118            density: 4_430.0,
119            thermal_conductivity: 6.7,
120            specific_heat: 560.0,
121            latent_heat_fusion: 286_000.0,
122            solidus_temp: 1878.0,
123            liquidus_temp: 1928.0,
124            absorptivity: 0.35,
125            elastic_modulus: 114e9,
126            poisson_ratio: 0.342,
127            yield_strength: 930e6,
128            tensile_strength: 1_000e6,
129            thermal_expansion: 8.6e-6,
130            packing_fraction: 0.60,
131        }
132    }
133
134    /// Pre-defined 316L stainless steel properties for SLM.
135    pub fn steel_316l_slm() -> Self {
136        Self {
137            alloy: MetalAlloy::Steel316L,
138            density: 7_990.0,
139            thermal_conductivity: 16.3,
140            specific_heat: 500.0,
141            latent_heat_fusion: 272_000.0,
142            solidus_temp: 1648.0,
143            liquidus_temp: 1673.0,
144            absorptivity: 0.40,
145            elastic_modulus: 193e9,
146            poisson_ratio: 0.290,
147            yield_strength: 530e6,
148            tensile_strength: 680e6,
149            thermal_expansion: 16.0e-6,
150            packing_fraction: 0.62,
151        }
152    }
153
154    /// Pre-defined AlSi10Mg properties for SLM.
155    pub fn alsi10mg_slm() -> Self {
156        Self {
157            alloy: MetalAlloy::AlSi10Mg,
158            density: 2_680.0,
159            thermal_conductivity: 130.0,
160            specific_heat: 910.0,
161            latent_heat_fusion: 396_000.0,
162            solidus_temp: 833.0,
163            liquidus_temp: 868.0,
164            absorptivity: 0.09,
165            elastic_modulus: 72e9,
166            poisson_ratio: 0.330,
167            yield_strength: 240e6,
168            tensile_strength: 330e6,
169            thermal_expansion: 21.0e-6,
170            packing_fraction: 0.62,
171        }
172    }
173
174    /// Thermal diffusivity α = k / (ρ c_p) (m²/s).
175    pub fn thermal_diffusivity(&self) -> f64 {
176        self.thermal_conductivity / (self.density * self.specific_heat)
177    }
178
179    /// Melting range ΔT = T_liq − T_sol (K).
180    pub fn melting_range(&self) -> f64 {
181        (self.liquidus_temp - self.solidus_temp).max(0.0)
182    }
183}
184
185// ---------------------------------------------------------------------------
186// PBF Process Parameters
187// ---------------------------------------------------------------------------
188
189/// Laser / electron beam process parameters for powder bed fusion.
190#[derive(Debug, Clone)]
191pub struct PbfProcessParams {
192    /// Laser power (W).
193    pub power: f64,
194    /// Scan speed (m/s).
195    pub scan_speed: f64,
196    /// Hatch spacing (m).
197    pub hatch_spacing: f64,
198    /// Layer thickness (m).
199    pub layer_thickness: f64,
200    /// Laser spot radius (1/e² radius) (m).
201    pub spot_radius: f64,
202    /// Preheat temperature of the build plate (K).
203    pub preheat_temp: f64,
204    /// Scan strategy rotation angle between layers (degrees).
205    pub rotation_angle_deg: f64,
206}
207
208impl PbfProcessParams {
209    /// Volumetric energy density (J/m³): `E_v = P / (v * h * t)`.
210    pub fn volumetric_energy_density(&self) -> f64 {
211        let denom = self.scan_speed * self.hatch_spacing * self.layer_thickness;
212        if denom < 1e-30 {
213            f64::INFINITY
214        } else {
215            self.power / denom
216        }
217    }
218
219    /// Linear energy density (J/m): `E_l = P / v`.
220    pub fn linear_energy_density(&self) -> f64 {
221        if self.scan_speed < 1e-12 {
222            f64::INFINITY
223        } else {
224            self.power / self.scan_speed
225        }
226    }
227
228    /// Interaction time of laser with powder (s): `t_int = 2*r / v`.
229    pub fn interaction_time(&self) -> f64 {
230        if self.scan_speed < 1e-12 {
231            f64::INFINITY
232        } else {
233            2.0 * self.spot_radius / self.scan_speed
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Melt Pool Geometry (Rosenthal / Goldak approximation)
240// ---------------------------------------------------------------------------
241
242/// Estimated melt pool geometry from process parameters and material properties.
243///
244/// Based on the Eagar-Tsai analytical model (simplified).
245#[derive(Debug, Clone)]
246pub struct MeltPoolGeometry {
247    /// Melt pool half-width (m).
248    pub half_width: f64,
249    /// Melt pool half-length (m) (in scan direction).
250    pub half_length: f64,
251    /// Melt pool depth (m).
252    pub depth: f64,
253}
254
255impl MeltPoolGeometry {
256    /// Estimate melt pool geometry using the simplified Eagar-Tsai model.
257    ///
258    /// # Arguments
259    /// * `mat` — Material properties.
260    /// * `params` — Process parameters.
261    /// * `ambient_temp` — Ambient/preheat temperature (K).
262    pub fn eagar_tsai(mat: &PbfMaterial, params: &PbfProcessParams, ambient_temp: f64) -> Self {
263        let alpha = mat.thermal_diffusivity();
264        let delta_t = mat.liquidus_temp - ambient_temp;
265        let q = mat.absorptivity * params.power;
266        let v = params.scan_speed;
267        let sigma = params.spot_radius;
268        // Characteristic length scales
269        let r_char = ((2.0 * q * alpha) / (PI * mat.thermal_conductivity * v * delta_t))
270            .sqrt()
271            .max(sigma);
272        let half_width = r_char;
273        let half_length = r_char * (1.0 + v * r_char / (2.0 * alpha)).min(5.0);
274        let depth = r_char * 0.5; // simplified: half the width
275        Self {
276            half_width,
277            half_length,
278            depth,
279        }
280    }
281
282    /// Melt pool volume (m³), approximated as an ellipsoid.
283    pub fn volume(&self) -> f64 {
284        (4.0 / 3.0) * PI * self.half_width * self.half_length * self.depth
285    }
286
287    /// Aspect ratio (length / width).
288    pub fn aspect_ratio(&self) -> f64 {
289        if self.half_width < 1e-20 {
290            1.0
291        } else {
292            self.half_length / self.half_width
293        }
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Thermal Gradient and Cooling Rate
299// ---------------------------------------------------------------------------
300
301/// Thermal gradient and cooling rate estimates for AM.
302#[derive(Debug, Clone)]
303pub struct ThermalGradient {
304    /// Thermal gradient magnitude at the melt pool boundary (K/m).
305    pub gradient_magnitude: f64,
306    /// Cooling rate at the melt pool boundary (K/s).
307    pub cooling_rate: f64,
308    /// Solidification rate (m/s).
309    pub solidification_rate: f64,
310}
311
312impl ThermalGradient {
313    /// Estimate thermal conditions from Rosenthal point-source solution.
314    ///
315    /// # Arguments
316    /// * `mat` — Material properties.
317    /// * `params` — Process parameters.
318    /// * `ambient_temp` — Ambient temperature (K).
319    pub fn from_rosenthal(mat: &PbfMaterial, params: &PbfProcessParams, ambient_temp: f64) -> Self {
320        let alpha = mat.thermal_diffusivity();
321        let v = params.scan_speed;
322        let q = mat.absorptivity * params.power;
323        let k = mat.thermal_conductivity;
324        let r = params.spot_radius.max(1e-6);
325        // Temperature at melt-pool edge (Rosenthal approximation)
326        let t_peak = ambient_temp + q / (2.0 * PI * k * r) * (-v * r / (2.0 * alpha)).exp();
327        let gradient_magnitude = (t_peak - ambient_temp) / r;
328        let cooling_rate = gradient_magnitude * v;
329        let solidification_rate = v;
330        Self {
331            gradient_magnitude,
332            cooling_rate,
333            solidification_rate,
334        }
335    }
336
337    /// G·R product (K/s) — controls solidification microstructure.
338    pub fn g_times_r(&self) -> f64 {
339        self.gradient_magnitude * self.solidification_rate
340    }
341
342    /// G/R ratio (K·s/m²) — controls morphology (planar → cellular → dendritic).
343    pub fn g_over_r(&self) -> f64 {
344        if self.solidification_rate < 1e-12 {
345            f64::INFINITY
346        } else {
347            self.gradient_magnitude / self.solidification_rate
348        }
349    }
350}
351
352// ---------------------------------------------------------------------------
353// Residual Stress from Thermal Gradients
354// ---------------------------------------------------------------------------
355
356/// Residual stress model for PBF (temperature-gradient mechanism, TGM).
357#[derive(Debug, Clone)]
358pub struct ResidualStressModel {
359    /// Elastic modulus (Pa).
360    pub elastic_modulus: f64,
361    /// Thermal expansion coefficient (1/K).
362    pub thermal_expansion: f64,
363    /// Yield strength at elevated temperature (Pa).
364    pub yield_strength_hot: f64,
365    /// Poisson's ratio.
366    pub poisson_ratio: f64,
367}
368
369impl ResidualStressModel {
370    /// Build from a `PbfMaterial`.
371    pub fn from_material(mat: &PbfMaterial) -> Self {
372        Self {
373            elastic_modulus: mat.elastic_modulus,
374            thermal_expansion: mat.thermal_expansion,
375            yield_strength_hot: mat.yield_strength * 0.5,
376            poisson_ratio: mat.poisson_ratio,
377        }
378    }
379
380    /// Estimate peak residual stress using the temperature-gradient mechanism.
381    ///
382    /// σ_res ≈ min(E α ΔT / (1 - ν), σ_y_hot)
383    ///
384    /// # Arguments
385    /// * `delta_t` — Temperature difference across the heated layer (K).
386    pub fn peak_residual_stress(&self, delta_t: f64) -> f64 {
387        let elastic_stress =
388            self.elastic_modulus * self.thermal_expansion * delta_t / (1.0 - self.poisson_ratio);
389        elastic_stress.min(self.yield_strength_hot)
390    }
391
392    /// Estimate part distortion (curvature) from residual stress bilayer model.
393    ///
394    /// Uses Stoney's formula: κ = 6 σ t_f / (E_s t_s²)
395    ///
396    /// # Arguments
397    /// * `sigma` — Residual stress in film (Pa).
398    /// * `film_thickness` — Deposited layer thickness (m).
399    /// * `substrate_thickness` — Substrate/part thickness (m).
400    pub fn stoney_curvature(
401        &self,
402        sigma: f64,
403        film_thickness: f64,
404        substrate_thickness: f64,
405    ) -> f64 {
406        let es = self.elastic_modulus / (1.0 - self.poisson_ratio * self.poisson_ratio);
407        let ts2 = substrate_thickness * substrate_thickness;
408        if ts2 < 1e-30 {
409            0.0
410        } else {
411            6.0 * sigma * film_thickness / (es * ts2)
412        }
413    }
414
415    /// Von Mises equivalent residual stress from in-plane biaxial state.
416    ///
417    /// For biaxial state σ_x = σ_y = σ:  σ_VM = σ.
418    pub fn von_mises_biaxial(&self, sigma_inplane: f64) -> f64 {
419        sigma_inplane.abs()
420    }
421}
422
423// ---------------------------------------------------------------------------
424// Porosity Effects on Mechanical Properties
425// ---------------------------------------------------------------------------
426
427/// Porosity model for relating relative density to effective properties.
428#[derive(Debug, Clone)]
429pub struct PorosityModel {
430    /// Fully dense Young's modulus E₀ (Pa).
431    pub e0: f64,
432    /// Fully dense yield strength σ_y0 (Pa).
433    pub yield_strength0: f64,
434    /// Fully dense tensile strength σ_u0 (Pa).
435    pub tensile_strength0: f64,
436    /// Fully dense density ρ₀ (kg/m³).
437    pub density0: f64,
438}
439
440impl PorosityModel {
441    /// Effective Young's modulus using Ramakrishnan-Arunachalam model.
442    ///
443    /// E(p) = E₀ (1 - p)² / (1 + β p)  where β ≈ 2.
444    pub fn elastic_modulus(&self, porosity: f64) -> f64 {
445        let beta = 2.0;
446        let p = porosity.clamp(0.0, 0.9999);
447        self.e0 * (1.0 - p).powi(2) / (1.0 + beta * p)
448    }
449
450    /// Effective yield strength using power-law scaling.
451    ///
452    /// σ_y(p) = σ_y0 (1 - p)^n  with n ≈ 2.
453    pub fn yield_strength(&self, porosity: f64) -> f64 {
454        let n = 2.0;
455        let p = porosity.clamp(0.0, 0.9999);
456        self.yield_strength0 * (1.0 - p).powf(n)
457    }
458
459    /// Effective tensile strength.
460    pub fn tensile_strength(&self, porosity: f64) -> f64 {
461        let n = 1.8;
462        let p = porosity.clamp(0.0, 0.9999);
463        self.tensile_strength0 * (1.0 - p).powf(n)
464    }
465
466    /// Effective density.
467    pub fn effective_density(&self, porosity: f64) -> f64 {
468        self.density0 * (1.0 - porosity.clamp(0.0, 1.0))
469    }
470
471    /// Relative density (1 − porosity).
472    pub fn relative_density(&self, porosity: f64) -> f64 {
473        1.0 - porosity.clamp(0.0, 1.0)
474    }
475
476    /// Fatigue strength reduction factor due to porosity.
477    ///
478    /// Uses empirical Murakami √area model: Kt ≈ 1 + 2 √(πp/4)
479    pub fn fatigue_strength_reduction(&self, porosity: f64) -> f64 {
480        let p = porosity.clamp(0.0, 0.5);
481        1.0 + 2.0 * (PI * p / 4.0).sqrt()
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Microstructure Evolution
487// ---------------------------------------------------------------------------
488
489/// Grain growth model for AM (isothermal / anisothermal).
490///
491/// Uses the parabolic grain growth law: D² − D₀² = K₀ t exp(−Q / RT).
492#[derive(Debug, Clone)]
493pub struct GrainGrowthModel {
494    /// Pre-exponential factor K₀ (m²/s).
495    pub k0: f64,
496    /// Activation energy for grain boundary migration Q (J/mol).
497    pub activation_energy: f64,
498    /// Initial grain diameter D₀ (m).
499    pub initial_diameter: f64,
500}
501
502impl GrainGrowthModel {
503    /// Typical values for Ti-6Al-4V β-grain growth.
504    pub fn ti6al4v_beta() -> Self {
505        Self {
506            k0: 7.0e-8,
507            activation_energy: 175_000.0,
508            initial_diameter: 50e-6,
509        }
510    }
511
512    /// Grain diameter after isothermal annealing for time `t` at temperature `T` (K).
513    pub fn grain_diameter_isothermal(&self, temp_k: f64, time_s: f64) -> f64 {
514        let keff = self.k0 * (-self.activation_energy / (R_GAS * temp_k)).exp();
515        let d2 = self.initial_diameter.powi(2) + keff * time_s;
516        d2.max(0.0).sqrt()
517    }
518
519    /// Mean grain aspect ratio after directional solidification.
520    ///
521    /// In PBF columnar grains grow with aspect ratio AR ≈ G/R × const.
522    pub fn columnar_aspect_ratio(&self, g_over_r: f64) -> f64 {
523        // Empirical: AR ≈ 0.01 * (G/R) + 1; clamp to reasonable range
524        (0.01 * g_over_r + 1.0).min(20.0)
525    }
526}
527
528/// Martensitic transformation model for Ti alloys and steels in AM.
529#[derive(Debug, Clone)]
530pub struct MartensiticModel {
531    /// Martensite start temperature M_s (K).
532    pub ms_temp: f64,
533    /// Martensite finish temperature M_f (K).
534    pub mf_temp: f64,
535    /// Maximum volume fraction of martensite (0–1).
536    pub max_martensite_fraction: f64,
537    /// Koistinen-Marburger coefficient b (K⁻¹).
538    pub km_coefficient: f64,
539}
540
541impl MartensiticModel {
542    /// Ti-6Al-4V α' martensite (hexagonal).
543    pub fn ti6al4v_martensite() -> Self {
544        Self {
545            ms_temp: 875.0,
546            mf_temp: 500.0,
547            max_martensite_fraction: 1.0,
548            km_coefficient: 0.011,
549        }
550    }
551
552    /// 316L steel martensite (not typical at RT, Ms < 0).
553    pub fn steel_316l_martensite() -> Self {
554        Self {
555            ms_temp: 233.0, // −40 °C
556            mf_temp: 123.0, // −150 °C
557            max_martensite_fraction: 0.30,
558            km_coefficient: 0.033,
559        }
560    }
561
562    /// Martensite volume fraction at temperature `T` (K), Koistinen-Marburger equation.
563    ///
564    /// f_m = f_max * (1 − exp(−b * (M_s − T))) for T < M_s; else 0.
565    pub fn martensite_fraction(&self, temp_k: f64) -> f64 {
566        if temp_k >= self.ms_temp {
567            return 0.0;
568        }
569        let delta_t = self.ms_temp - temp_k;
570        let f = self.max_martensite_fraction * (1.0 - (-self.km_coefficient * delta_t).exp());
571        f.min(self.max_martensite_fraction)
572    }
573
574    /// Strength contribution from martensite (Hall-Petch-like).
575    ///
576    /// Δσ ≈ σ_α_prime * f_m  where σ_α_prime is the intrinsic martensite strength.
577    pub fn strength_contribution(&self, temp_k: f64, martensite_strength: f64) -> f64 {
578        self.martensite_fraction(temp_k) * martensite_strength
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Binder Jetting Material
584// ---------------------------------------------------------------------------
585
586/// Material model for binder jetting (BJ) process.
587#[derive(Debug, Clone)]
588pub struct BinderJettingMaterial {
589    /// Green body porosity after printing (0–1).
590    pub green_porosity: f64,
591    /// Target sintered porosity after sintering (0–1).
592    pub sintered_porosity: f64,
593    /// Linear shrinkage during sintering (0–1).
594    pub linear_shrinkage: f64,
595    /// Binder content (volume fraction).
596    pub binder_fraction: f64,
597    /// Sintering temperature (K).
598    pub sintering_temp: f64,
599    /// Sintering time (s).
600    pub sintering_time: f64,
601    /// Fully dense yield strength (Pa).
602    pub dense_yield_strength: f64,
603    /// Fully dense elastic modulus (Pa).
604    pub dense_elastic_modulus: f64,
605}
606
607impl BinderJettingMaterial {
608    /// Typical 316L stainless steel BJ parameters.
609    pub fn steel_316l_bj() -> Self {
610        Self {
611            green_porosity: 0.40,
612            sintered_porosity: 0.02,
613            linear_shrinkage: 0.18,
614            binder_fraction: 0.35,
615            sintering_temp: 1593.0,
616            sintering_time: 3600.0 * 6.0,
617            dense_yield_strength: 530e6,
618            dense_elastic_modulus: 193e9,
619        }
620    }
621
622    /// Volumetric shrinkage from green to sintered state.
623    pub fn volumetric_shrinkage(&self) -> f64 {
624        1.0 - (1.0 - self.linear_shrinkage).powi(3)
625    }
626
627    /// Relative density of the sintered part.
628    pub fn relative_density(&self) -> f64 {
629        1.0 - self.sintered_porosity
630    }
631
632    /// Effective yield strength using power-law porosity correction.
633    pub fn effective_yield_strength(&self) -> f64 {
634        let p = self.sintered_porosity;
635        self.dense_yield_strength * (1.0 - p).powf(2.0)
636    }
637
638    /// Effective elastic modulus using Ramakrishnan-Arunachalam model.
639    pub fn effective_elastic_modulus(&self) -> f64 {
640        let beta = 2.0;
641        let p = self.sintered_porosity;
642        self.dense_elastic_modulus * (1.0 - p).powi(2) / (1.0 + beta * p)
643    }
644}
645
646// ---------------------------------------------------------------------------
647// FDM Polymer Material
648// ---------------------------------------------------------------------------
649
650/// FDM/FFF polymer material with directional (anisotropic) properties.
651#[derive(Debug, Clone)]
652pub struct FdmPolymerMaterial {
653    /// Polymer name/grade.
654    pub name: String,
655    /// Density (kg/m³).
656    pub density: f64,
657    /// In-layer (XY) tensile strength (Pa).
658    pub tensile_strength_xy: f64,
659    /// Through-layer (Z) tensile strength (Pa).
660    pub tensile_strength_z: f64,
661    /// In-layer Young's modulus (Pa).
662    pub elastic_modulus_xy: f64,
663    /// Through-layer Young's modulus (Pa).
664    pub elastic_modulus_z: f64,
665    /// In-layer elongation at break (0–1).
666    pub elongation_xy: f64,
667    /// Through-layer elongation at break (0–1).
668    pub elongation_z: f64,
669    /// Glass transition temperature (K).
670    pub glass_transition_temp: f64,
671    /// Layer adhesion strength (Pa) — inter-layer bond strength.
672    pub layer_adhesion_strength: f64,
673    /// Layer height (m).
674    pub layer_height: f64,
675    /// Raster width (m).
676    pub raster_width: f64,
677    /// Air gap between rasters (m, can be negative for overlap).
678    pub air_gap: f64,
679    /// Raster angle (degrees from X-axis).
680    pub raster_angle_deg: f64,
681}
682
683impl FdmPolymerMaterial {
684    /// Generic PLA-like material.
685    pub fn pla_generic() -> Self {
686        Self {
687            name: "PLA".to_string(),
688            density: 1_240.0,
689            tensile_strength_xy: 60e6,
690            tensile_strength_z: 35e6,
691            elastic_modulus_xy: 3_500e6,
692            elastic_modulus_z: 2_800e6,
693            elongation_xy: 0.04,
694            elongation_z: 0.02,
695            glass_transition_temp: 333.0,
696            layer_adhesion_strength: 30e6,
697            layer_height: 0.2e-3,
698            raster_width: 0.4e-3,
699            air_gap: 0.0,
700            raster_angle_deg: 45.0,
701        }
702    }
703
704    /// Generic PEEK material (high-performance polymer).
705    pub fn peek_generic() -> Self {
706        Self {
707            name: "PEEK".to_string(),
708            density: 1_310.0,
709            tensile_strength_xy: 100e6,
710            tensile_strength_z: 60e6,
711            elastic_modulus_xy: 4_000e6,
712            elastic_modulus_z: 3_200e6,
713            elongation_xy: 0.03,
714            elongation_z: 0.02,
715            glass_transition_temp: 416.0,
716            layer_adhesion_strength: 50e6,
717            layer_height: 0.15e-3,
718            raster_width: 0.4e-3,
719            air_gap: -0.05e-3,
720            raster_angle_deg: 0.0,
721        }
722    }
723
724    /// Anisotropy ratio: Z-strength / XY-strength.
725    pub fn anisotropy_ratio(&self) -> f64 {
726        if self.tensile_strength_xy < 1e-12 {
727            1.0
728        } else {
729            self.tensile_strength_z / self.tensile_strength_xy
730        }
731    }
732
733    /// Estimate effective tensile strength at a build angle `theta_deg`
734    /// using a simple cosine interpolation.
735    pub fn tensile_strength_at_angle(&self, theta_deg: f64) -> f64 {
736        let theta = theta_deg.to_radians();
737        let cos2 = theta.cos().powi(2);
738        let sin2 = theta.sin().powi(2);
739        cos2 * self.tensile_strength_xy + sin2 * self.tensile_strength_z
740    }
741
742    /// Estimated void volume fraction from air gap geometry.
743    pub fn void_fraction(&self) -> f64 {
744        if self.raster_width < 1e-12 || self.layer_height < 1e-12 {
745            return 0.0;
746        }
747        let effective_gap = self.air_gap / self.raster_width;
748        effective_gap.clamp(0.0, 1.0)
749    }
750}
751
752// ---------------------------------------------------------------------------
753// Support Structure Material
754// ---------------------------------------------------------------------------
755
756/// Material model for support structures in AM.
757#[derive(Debug, Clone)]
758pub struct SupportMaterial {
759    /// Density scaling relative to bulk (0–1).
760    pub density_fraction: f64,
761    /// Elastic modulus fraction relative to bulk (0–1).
762    pub modulus_fraction: f64,
763    /// Critical force to remove support (N/m²).
764    pub removal_force: f64,
765    /// Support structure type.
766    pub support_type: SupportType,
767}
768
769/// Type of AM support structure.
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771pub enum SupportType {
772    /// Solid block support.
773    Solid,
774    /// Lattice / sparse support.
775    Lattice,
776    /// Cone-tree support.
777    TreeLike,
778    /// Water-soluble polymer support (FDM).
779    Soluble,
780    /// Powder-bed support (no structure needed in PBF).
781    PowderBed,
782}
783
784impl SupportMaterial {
785    /// Typical metal PBF block support.
786    pub fn metal_block_support() -> Self {
787        Self {
788            density_fraction: 0.5,
789            modulus_fraction: 0.4,
790            removal_force: 1e6,
791            support_type: SupportType::Solid,
792        }
793    }
794
795    /// FDM soluble support (e.g. PVA).
796    pub fn fdm_soluble() -> Self {
797        Self {
798            density_fraction: 0.8,
799            modulus_fraction: 0.3,
800            removal_force: 0.5e6,
801            support_type: SupportType::Soluble,
802        }
803    }
804
805    /// Estimate thermal resistance added by support block.
806    ///
807    /// R_thermal = h / (k_support * A)
808    pub fn thermal_resistance(&self, height: f64, area: f64, bulk_conductivity: f64) -> f64 {
809        let k = bulk_conductivity * self.modulus_fraction;
810        if k < 1e-12 || area < 1e-20 {
811            f64::INFINITY
812        } else {
813            height / (k * area)
814        }
815    }
816}
817
818// ---------------------------------------------------------------------------
819// Process-Structure-Property (PSP) Linkage
820// ---------------------------------------------------------------------------
821
822/// PSP linkage: combines process parameters → structure descriptors → properties.
823#[derive(Debug, Clone)]
824pub struct PspLinkage {
825    /// Material system.
826    pub material: PbfMaterial,
827    /// Process parameters.
828    pub process: PbfProcessParams,
829    /// Estimated porosity (0–1) from process window.
830    pub estimated_porosity: f64,
831}
832
833impl PspLinkage {
834    /// Construct PSP from material and process parameters.
835    pub fn new(material: PbfMaterial, process: PbfProcessParams) -> Self {
836        let porosity = estimate_porosity_from_energy_density(
837            process.volumetric_energy_density(),
838            material.density,
839        );
840        Self {
841            material,
842            process,
843            estimated_porosity: porosity,
844        }
845    }
846
847    /// Effective yield strength incorporating porosity.
848    pub fn effective_yield_strength(&self) -> f64 {
849        let p = self.estimated_porosity;
850        self.material.yield_strength * (1.0 - p).powf(2.0)
851    }
852
853    /// Effective elastic modulus incorporating porosity.
854    pub fn effective_elastic_modulus(&self) -> f64 {
855        let beta = 2.0;
856        let p = self.estimated_porosity;
857        self.material.elastic_modulus * (1.0 - p).powi(2) / (1.0 + beta * p)
858    }
859
860    /// Thermal gradient estimate.
861    pub fn thermal_gradient(&self, ambient_temp: f64) -> ThermalGradient {
862        ThermalGradient::from_rosenthal(&self.material, &self.process, ambient_temp)
863    }
864
865    /// Melt pool geometry estimate.
866    pub fn melt_pool(&self, ambient_temp: f64) -> MeltPoolGeometry {
867        MeltPoolGeometry::eagar_tsai(&self.material, &self.process, ambient_temp)
868    }
869
870    /// Estimated relative density (1 − porosity).
871    pub fn relative_density(&self) -> f64 {
872        1.0 - self.estimated_porosity
873    }
874}
875
876/// Estimate porosity from volumetric energy density using a sigmoid curve.
877///
878/// At optimal energy density (~60–80 J/mm³) porosity is minimal (~0.1%).
879/// Too low → lack of fusion; too high → keyhole porosity.
880pub fn estimate_porosity_from_energy_density(energy_density_j_per_m3: f64, _density: f64) -> f64 {
881    // Convert to J/mm³
882    let ev = energy_density_j_per_m3 * 1e-9;
883    // Optimal energy density for most metals ≈ 70 J/mm³
884    let ev_opt = 70.0_f64;
885    let delta = (ev - ev_opt).abs() / ev_opt;
886    // Porosity rises parabolically from ~0.1% at optimum
887    let base = 0.001_f64;
888    (base + 0.5 * delta * delta).min(0.30)
889}
890
891// ---------------------------------------------------------------------------
892// Scan Strategy Effects
893// ---------------------------------------------------------------------------
894
895/// Effect of scan strategy on texture and residual stress.
896#[derive(Debug, Clone)]
897pub struct ScanStrategyEffect {
898    /// Rotation angle between consecutive layers (degrees).
899    pub rotation_angle_deg: f64,
900    /// Estimated texture coefficient (0 = random, 1 = fully textured).
901    pub texture_coefficient: f64,
902    /// Residual stress scaling factor relative to unidirectional scanning.
903    pub residual_stress_factor: f64,
904    /// Estimated relative density achievable with this strategy.
905    pub relative_density: f64,
906}
907
908impl ScanStrategyEffect {
909    /// Effects for unidirectional (0° rotation) scanning.
910    pub fn unidirectional() -> Self {
911        Self {
912            rotation_angle_deg: 0.0,
913            texture_coefficient: 0.85,
914            residual_stress_factor: 1.0,
915            relative_density: 0.993,
916        }
917    }
918
919    /// Effects for 67° rotation (common in SLM).
920    pub fn rotating_67() -> Self {
921        Self {
922            rotation_angle_deg: 67.0,
923            texture_coefficient: 0.35,
924            residual_stress_factor: 0.65,
925            relative_density: 0.997,
926        }
927    }
928
929    /// Effects for 90° alternating scanning.
930    pub fn alternating_90() -> Self {
931        Self {
932            rotation_angle_deg: 90.0,
933            texture_coefficient: 0.50,
934            residual_stress_factor: 0.75,
935            relative_density: 0.995,
936        }
937    }
938
939    /// Effects for island/checkerboard scanning.
940    pub fn island() -> Self {
941        Self {
942            rotation_angle_deg: 90.0,
943            texture_coefficient: 0.40,
944            residual_stress_factor: 0.60,
945            relative_density: 0.996,
946        }
947    }
948
949    /// Estimate the number of unique scan orientations in N layers.
950    pub fn unique_orientations_in_n_layers(&self, n_layers: usize) -> usize {
951        if self.rotation_angle_deg < 1e-6 {
952            return 1;
953        }
954        let period = (360.0 / self.rotation_angle_deg).round() as usize;
955        period.min(n_layers)
956    }
957}
958
959// ---------------------------------------------------------------------------
960// Surface Roughness from AM
961// ---------------------------------------------------------------------------
962
963/// AM surface roughness model.
964///
965/// Covers staircase effect, spattering, and powder adhesion contributions.
966#[derive(Debug, Clone)]
967pub struct AmSurfaceRoughness {
968    /// Layer thickness (m).
969    pub layer_thickness: f64,
970    /// Powder particle diameter D50 (m).
971    pub powder_d50: f64,
972    /// Build angle relative to horizontal (degrees, 0 = flat top, 90 = vertical wall).
973    pub build_angle_deg: f64,
974    /// Laser spot radius (m).
975    pub spot_radius: f64,
976}
977
978impl AmSurfaceRoughness {
979    /// Staircase roughness Ra from layer thickness and build angle.
980    ///
981    /// Ra_staircase ≈ (t / 4) |cos θ| / sin θ   for θ in (0°, 90°)
982    pub fn ra_staircase(&self) -> f64 {
983        let theta = self.build_angle_deg.to_radians();
984        let sin_t = theta.sin().max(1e-6);
985        let cos_t = theta.cos().abs();
986        self.layer_thickness / 4.0 * cos_t / sin_t
987    }
988
989    /// Contribution from partially sintered/attached powder particles.
990    ///
991    /// Ra_powder ≈ D50 / 4
992    pub fn ra_powder_adhesion(&self) -> f64 {
993        self.powder_d50 / 4.0
994    }
995
996    /// Total estimated surface roughness Ra (m).
997    pub fn ra_total(&self) -> f64 {
998        (self.ra_staircase().powi(2) + self.ra_powder_adhesion().powi(2)).sqrt()
999    }
1000
1001    /// Roughness in micrometres.
1002    pub fn ra_total_um(&self) -> f64 {
1003        self.ra_total() * 1e6
1004    }
1005
1006    /// Estimate fatigue life reduction factor due to surface roughness.
1007    ///
1008    /// Uses modified Goodman: Kf ≈ 1 + q * (Kt - 1) where q = notch sensitivity.
1009    pub fn fatigue_reduction_factor(&self) -> f64 {
1010        let ra_um = self.ra_total_um();
1011        // Empirical: Kf ≈ 1 + 0.06 * Ra_um for metals
1012        1.0 + 0.06 * ra_um
1013    }
1014}
1015
1016// ---------------------------------------------------------------------------
1017// IN718 Specific Model
1018// ---------------------------------------------------------------------------
1019
1020/// IN718 nickel superalloy properties for SLM/EBM.
1021#[derive(Debug, Clone)]
1022pub struct In718Properties {
1023    /// Gamma-prime (γ') volume fraction.
1024    pub gamma_prime_fraction: f64,
1025    /// Gamma-double-prime (γ'') volume fraction.
1026    pub gamma_double_prime_fraction: f64,
1027    /// Delta phase volume fraction.
1028    pub delta_fraction: f64,
1029    /// Creep coefficient (power-law).
1030    pub creep_coefficient: f64,
1031    /// Creep exponent n.
1032    pub creep_exponent: f64,
1033    /// Creep activation energy (J/mol).
1034    pub creep_activation_energy: f64,
1035}
1036
1037impl In718Properties {
1038    /// Typical as-built SLM IN718 properties.
1039    pub fn as_built_slm() -> Self {
1040        Self {
1041            gamma_prime_fraction: 0.03,
1042            gamma_double_prime_fraction: 0.12,
1043            delta_fraction: 0.01,
1044            creep_coefficient: 2.5e-24,
1045            creep_exponent: 5.2,
1046            creep_activation_energy: 285_000.0,
1047        }
1048    }
1049
1050    /// Typical heat-treated IN718 properties (solution annealed + aged).
1051    pub fn heat_treated() -> Self {
1052        Self {
1053            gamma_prime_fraction: 0.05,
1054            gamma_double_prime_fraction: 0.18,
1055            delta_fraction: 0.005,
1056            creep_coefficient: 1.5e-24,
1057            creep_exponent: 5.2,
1058            creep_activation_energy: 290_000.0,
1059        }
1060    }
1061
1062    /// Estimate yield strength contribution from precipitates (Hall-Petch-like).
1063    ///
1064    /// Δσ ≈ M G b √ρ  simplified to ≈ C * (f_prime + f_doubleprime)^0.5
1065    pub fn precipitation_strengthening(&self) -> f64 {
1066        let c = 2000e6; // empirical constant
1067        c * (self.gamma_prime_fraction + self.gamma_double_prime_fraction).sqrt()
1068    }
1069
1070    /// Minimum creep rate (s⁻¹) at stress σ and temperature T.
1071    ///
1072    /// Norton power law: ε̇ = A σⁿ exp(-Q / RT).
1073    pub fn creep_rate(&self, stress_pa: f64, temp_k: f64) -> f64 {
1074        self.creep_coefficient
1075            * stress_pa.powf(self.creep_exponent)
1076            * (-self.creep_activation_energy / (R_GAS * temp_k)).exp()
1077    }
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Energy Density Parameter Space
1082// ---------------------------------------------------------------------------
1083
1084/// Process window analysis based on volumetric energy density.
1085#[derive(Debug, Clone)]
1086pub struct ProcessWindow {
1087    /// Minimum energy density for full melting (J/m³).
1088    pub ev_min: f64,
1089    /// Maximum energy density before keyhole porosity (J/m³).
1090    pub ev_max: f64,
1091    /// Optimal energy density for maximum density (J/m³).
1092    pub ev_optimal: f64,
1093}
1094
1095impl ProcessWindow {
1096    /// Default process window for Ti-6Al-4V SLM (J/mm³ converted to J/m³).
1097    pub fn ti6al4v_slm() -> Self {
1098        Self {
1099            ev_min: 50e9,
1100            ev_max: 130e9,
1101            ev_optimal: 75e9,
1102        }
1103    }
1104
1105    /// Default process window for 316L SLM.
1106    pub fn steel_316l_slm() -> Self {
1107        Self {
1108            ev_min: 45e9,
1109            ev_max: 120e9,
1110            ev_optimal: 70e9,
1111        }
1112    }
1113
1114    /// Check whether given energy density is within the process window.
1115    pub fn is_in_window(&self, ev: f64) -> bool {
1116        ev >= self.ev_min && ev <= self.ev_max
1117    }
1118
1119    /// Estimated relative density at energy density `ev`.
1120    pub fn estimated_relative_density(&self, ev: f64) -> f64 {
1121        if ev < self.ev_min {
1122            // Lack-of-fusion regime
1123            let frac = ev / self.ev_min;
1124            0.80 + 0.18 * frac
1125        } else if ev > self.ev_max {
1126            // Keyhole regime
1127            let excess = (ev - self.ev_max) / self.ev_max;
1128            0.999 - 0.15 * excess * excess
1129        } else {
1130            // In-window
1131            let x = (ev - self.ev_optimal).abs() / (self.ev_max - self.ev_min);
1132            0.999 - 0.05 * x * x
1133        }
1134        .clamp(0.0, 1.0)
1135    }
1136}
1137
1138// ---------------------------------------------------------------------------
1139// Hardness Prediction
1140// ---------------------------------------------------------------------------
1141
1142/// Vickers hardness (HV) from relative density and microstructure.
1143///
1144/// Simple linear model: HV = HV_dense * (1 − k_p * porosity)
1145pub fn vickers_hardness(hv_dense: f64, porosity: f64, k_p: f64) -> f64 {
1146    hv_dense * (1.0 - k_p * porosity.clamp(0.0, 1.0))
1147}
1148
1149// ---------------------------------------------------------------------------
1150// Thermal Stress from Build Simulation
1151// ---------------------------------------------------------------------------
1152
1153/// Layer-by-layer thermal stress accumulation.
1154///
1155/// Each deposited layer imposes a thermal strain `α ΔT` on the underlying body.
1156/// The cumulative residual stress is computed via a simple analytical laminate model.
1157pub fn cumulative_residual_stress(
1158    layer_temps: &[f64],
1159    ambient_temp: f64,
1160    thermal_expansion: f64,
1161    elastic_modulus: f64,
1162    poisson_ratio: f64,
1163) -> Vec<f64> {
1164    layer_temps
1165        .iter()
1166        .map(|&t| {
1167            let delta_t = (t - ambient_temp).abs();
1168
1169            elastic_modulus * thermal_expansion * delta_t / (1.0 - poisson_ratio)
1170        })
1171        .collect()
1172}
1173
1174// ---------------------------------------------------------------------------
1175// Tests
1176// ---------------------------------------------------------------------------
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    // --- PbfMaterial ---
1183
1184    #[test]
1185    fn test_ti6al4v_thermal_diffusivity_positive() {
1186        let m = PbfMaterial::ti6al4v_slm();
1187        assert!(m.thermal_diffusivity() > 0.0);
1188    }
1189
1190    #[test]
1191    fn test_ti6al4v_melting_range_positive() {
1192        let m = PbfMaterial::ti6al4v_slm();
1193        assert!(m.melting_range() > 0.0);
1194    }
1195
1196    #[test]
1197    fn test_steel_density_reasonable() {
1198        let m = PbfMaterial::steel_316l_slm();
1199        assert!(m.density > 7_000.0 && m.density < 9_000.0);
1200    }
1201
1202    #[test]
1203    fn test_alsi10mg_absorptivity_range() {
1204        let m = PbfMaterial::alsi10mg_slm();
1205        assert!(m.absorptivity > 0.0 && m.absorptivity <= 1.0);
1206    }
1207
1208    // --- PbfProcessParams ---
1209
1210    #[test]
1211    fn test_volumetric_energy_density_positive() {
1212        let p = PbfProcessParams {
1213            power: 200.0,
1214            scan_speed: 0.8,
1215            hatch_spacing: 110e-6,
1216            layer_thickness: 30e-6,
1217            spot_radius: 35e-6,
1218            preheat_temp: 300.0,
1219            rotation_angle_deg: 67.0,
1220        };
1221        let ev = p.volumetric_energy_density();
1222        assert!(ev > 0.0 && ev.is_finite());
1223    }
1224
1225    #[test]
1226    fn test_linear_energy_density_consistent() {
1227        let p = PbfProcessParams {
1228            power: 200.0,
1229            scan_speed: 1.0,
1230            hatch_spacing: 100e-6,
1231            layer_thickness: 30e-6,
1232            spot_radius: 35e-6,
1233            preheat_temp: 300.0,
1234            rotation_angle_deg: 67.0,
1235        };
1236        assert!((p.linear_energy_density() - 200.0).abs() < 1e-6);
1237    }
1238
1239    #[test]
1240    fn test_interaction_time_positive() {
1241        let p = PbfProcessParams {
1242            power: 200.0,
1243            scan_speed: 1.0,
1244            hatch_spacing: 100e-6,
1245            layer_thickness: 30e-6,
1246            spot_radius: 35e-6,
1247            preheat_temp: 300.0,
1248            rotation_angle_deg: 67.0,
1249        };
1250        assert!(p.interaction_time() > 0.0);
1251    }
1252
1253    // --- MeltPoolGeometry ---
1254
1255    #[test]
1256    fn test_melt_pool_volume_positive() {
1257        let mat = PbfMaterial::ti6al4v_slm();
1258        let params = PbfProcessParams {
1259            power: 200.0,
1260            scan_speed: 0.5,
1261            hatch_spacing: 110e-6,
1262            layer_thickness: 30e-6,
1263            spot_radius: 35e-6,
1264            preheat_temp: 300.0,
1265            rotation_angle_deg: 67.0,
1266        };
1267        let mp = MeltPoolGeometry::eagar_tsai(&mat, &params, 300.0);
1268        assert!(mp.volume() > 0.0);
1269    }
1270
1271    #[test]
1272    fn test_melt_pool_aspect_ratio_ge_1() {
1273        let mat = PbfMaterial::ti6al4v_slm();
1274        let params = PbfProcessParams {
1275            power: 200.0,
1276            scan_speed: 0.5,
1277            hatch_spacing: 110e-6,
1278            layer_thickness: 30e-6,
1279            spot_radius: 35e-6,
1280            preheat_temp: 300.0,
1281            rotation_angle_deg: 67.0,
1282        };
1283        let mp = MeltPoolGeometry::eagar_tsai(&mat, &params, 300.0);
1284        assert!(mp.aspect_ratio() >= 1.0);
1285    }
1286
1287    // --- ThermalGradient ---
1288
1289    #[test]
1290    fn test_thermal_gradient_positive() {
1291        let mat = PbfMaterial::ti6al4v_slm();
1292        let params = PbfProcessParams {
1293            power: 200.0,
1294            scan_speed: 0.5,
1295            hatch_spacing: 110e-6,
1296            layer_thickness: 30e-6,
1297            spot_radius: 35e-6,
1298            preheat_temp: 300.0,
1299            rotation_angle_deg: 67.0,
1300        };
1301        let tg = ThermalGradient::from_rosenthal(&mat, &params, 300.0);
1302        assert!(tg.gradient_magnitude > 0.0);
1303        assert!(tg.cooling_rate > 0.0);
1304    }
1305
1306    #[test]
1307    fn test_g_times_r_positive() {
1308        let mat = PbfMaterial::ti6al4v_slm();
1309        let params = PbfProcessParams {
1310            power: 200.0,
1311            scan_speed: 0.5,
1312            hatch_spacing: 110e-6,
1313            layer_thickness: 30e-6,
1314            spot_radius: 35e-6,
1315            preheat_temp: 300.0,
1316            rotation_angle_deg: 67.0,
1317        };
1318        let tg = ThermalGradient::from_rosenthal(&mat, &params, 300.0);
1319        assert!(tg.g_times_r() > 0.0);
1320    }
1321
1322    // --- ResidualStressModel ---
1323
1324    #[test]
1325    fn test_residual_stress_nonnegative() {
1326        let mat = PbfMaterial::ti6al4v_slm();
1327        let rs = ResidualStressModel::from_material(&mat);
1328        let sigma = rs.peak_residual_stress(500.0);
1329        assert!(sigma >= 0.0);
1330    }
1331
1332    #[test]
1333    fn test_residual_stress_capped_at_yield() {
1334        let mat = PbfMaterial::ti6al4v_slm();
1335        let rs = ResidualStressModel::from_material(&mat);
1336        let sigma = rs.peak_residual_stress(10_000.0); // very large delta T
1337        assert!(sigma <= rs.yield_strength_hot + 1.0);
1338    }
1339
1340    #[test]
1341    fn test_stoney_curvature_increases_with_stress() {
1342        let mat = PbfMaterial::ti6al4v_slm();
1343        let rs = ResidualStressModel::from_material(&mat);
1344        let k1 = rs.stoney_curvature(100e6, 30e-6, 10e-3);
1345        let k2 = rs.stoney_curvature(200e6, 30e-6, 10e-3);
1346        assert!(k2 > k1);
1347    }
1348
1349    // --- PorosityModel ---
1350
1351    #[test]
1352    fn test_porosity_zero_gives_full_properties() {
1353        let pm = PorosityModel {
1354            e0: 114e9,
1355            yield_strength0: 930e6,
1356            tensile_strength0: 1000e6,
1357            density0: 4430.0,
1358        };
1359        assert!((pm.elastic_modulus(0.0) - pm.e0).abs() < 1e-3 * pm.e0);
1360        assert!((pm.yield_strength(0.0) - pm.yield_strength0).abs() < 1e-6);
1361    }
1362
1363    #[test]
1364    fn test_porosity_modulus_decreasing() {
1365        let pm = PorosityModel {
1366            e0: 114e9,
1367            yield_strength0: 930e6,
1368            tensile_strength0: 1000e6,
1369            density0: 4430.0,
1370        };
1371        let e1 = pm.elastic_modulus(0.01);
1372        let e2 = pm.elastic_modulus(0.05);
1373        assert!(e1 > e2);
1374    }
1375
1376    #[test]
1377    fn test_fatigue_reduction_ge_1() {
1378        let pm = PorosityModel {
1379            e0: 114e9,
1380            yield_strength0: 930e6,
1381            tensile_strength0: 1000e6,
1382            density0: 4430.0,
1383        };
1384        assert!(pm.fatigue_strength_reduction(0.02) >= 1.0);
1385    }
1386
1387    // --- GrainGrowthModel ---
1388
1389    #[test]
1390    fn test_grain_growth_increases_with_time() {
1391        let gg = GrainGrowthModel::ti6al4v_beta();
1392        let d1 = gg.grain_diameter_isothermal(1200.0, 60.0);
1393        let d2 = gg.grain_diameter_isothermal(1200.0, 3600.0);
1394        assert!(d2 > d1);
1395    }
1396
1397    #[test]
1398    fn test_grain_growth_initial_diameter_nonnegative() {
1399        let gg = GrainGrowthModel::ti6al4v_beta();
1400        let d = gg.grain_diameter_isothermal(300.0, 0.0);
1401        assert!(d >= 0.0);
1402    }
1403
1404    // --- MartensiticModel ---
1405
1406    #[test]
1407    fn test_martensite_below_ms() {
1408        let m = MartensiticModel::ti6al4v_martensite();
1409        let f = m.martensite_fraction(600.0);
1410        assert!(f > 0.0 && f <= 1.0);
1411    }
1412
1413    #[test]
1414    fn test_martensite_above_ms_is_zero() {
1415        let m = MartensiticModel::ti6al4v_martensite();
1416        let f = m.martensite_fraction(1000.0);
1417        assert_eq!(f, 0.0);
1418    }
1419
1420    #[test]
1421    fn test_martensite_increases_cooling() {
1422        let m = MartensiticModel::ti6al4v_martensite();
1423        let f1 = m.martensite_fraction(800.0);
1424        let f2 = m.martensite_fraction(600.0);
1425        assert!(f2 > f1);
1426    }
1427
1428    // --- BinderJettingMaterial ---
1429
1430    #[test]
1431    fn test_bj_volumetric_shrinkage_positive() {
1432        let bj = BinderJettingMaterial::steel_316l_bj();
1433        assert!(bj.volumetric_shrinkage() > 0.0);
1434    }
1435
1436    #[test]
1437    fn test_bj_relative_density_high() {
1438        let bj = BinderJettingMaterial::steel_316l_bj();
1439        assert!(bj.relative_density() > 0.95);
1440    }
1441
1442    #[test]
1443    fn test_bj_effective_yield_strength_positive() {
1444        let bj = BinderJettingMaterial::steel_316l_bj();
1445        assert!(bj.effective_yield_strength() > 0.0);
1446    }
1447
1448    // --- FdmPolymerMaterial ---
1449
1450    #[test]
1451    fn test_fdm_anisotropy_ratio_range() {
1452        let fdm = FdmPolymerMaterial::pla_generic();
1453        let r = fdm.anisotropy_ratio();
1454        assert!(r > 0.0 && r <= 1.0);
1455    }
1456
1457    #[test]
1458    fn test_fdm_tensile_at_0_equals_xy() {
1459        let fdm = FdmPolymerMaterial::pla_generic();
1460        let sigma = fdm.tensile_strength_at_angle(0.0);
1461        assert!((sigma - fdm.tensile_strength_xy).abs() < 1.0);
1462    }
1463
1464    #[test]
1465    fn test_fdm_tensile_at_90_equals_z() {
1466        let fdm = FdmPolymerMaterial::pla_generic();
1467        let sigma = fdm.tensile_strength_at_angle(90.0);
1468        assert!((sigma - fdm.tensile_strength_z).abs() < 1.0);
1469    }
1470
1471    // --- ScanStrategyEffect ---
1472
1473    #[test]
1474    fn test_scan_67_lower_texture_than_unidirectional() {
1475        let uni = ScanStrategyEffect::unidirectional();
1476        let rot = ScanStrategyEffect::rotating_67();
1477        assert!(rot.texture_coefficient < uni.texture_coefficient);
1478    }
1479
1480    #[test]
1481    fn test_scan_67_lower_stress_than_unidirectional() {
1482        let uni = ScanStrategyEffect::unidirectional();
1483        let rot = ScanStrategyEffect::rotating_67();
1484        assert!(rot.residual_stress_factor < uni.residual_stress_factor);
1485    }
1486
1487    #[test]
1488    fn test_unique_orientations_unidirectional() {
1489        let uni = ScanStrategyEffect::unidirectional();
1490        assert_eq!(uni.unique_orientations_in_n_layers(100), 1);
1491    }
1492
1493    // --- AmSurfaceRoughness ---
1494
1495    #[test]
1496    fn test_surface_roughness_ra_positive() {
1497        let r = AmSurfaceRoughness {
1498            layer_thickness: 30e-6,
1499            powder_d50: 30e-6,
1500            build_angle_deg: 45.0,
1501            spot_radius: 35e-6,
1502        };
1503        assert!(r.ra_total() > 0.0);
1504    }
1505
1506    #[test]
1507    fn test_surface_roughness_increases_with_layer_thickness() {
1508        let r1 = AmSurfaceRoughness {
1509            layer_thickness: 30e-6,
1510            powder_d50: 30e-6,
1511            build_angle_deg: 45.0,
1512            spot_radius: 35e-6,
1513        };
1514        let r2 = AmSurfaceRoughness {
1515            layer_thickness: 60e-6,
1516            powder_d50: 30e-6,
1517            build_angle_deg: 45.0,
1518            spot_radius: 35e-6,
1519        };
1520        assert!(r2.ra_total() > r1.ra_total());
1521    }
1522
1523    #[test]
1524    fn test_surface_fatigue_reduction_ge_1() {
1525        let r = AmSurfaceRoughness {
1526            layer_thickness: 30e-6,
1527            powder_d50: 30e-6,
1528            build_angle_deg: 45.0,
1529            spot_radius: 35e-6,
1530        };
1531        assert!(r.fatigue_reduction_factor() >= 1.0);
1532    }
1533
1534    // --- PSP linkage ---
1535
1536    #[test]
1537    fn test_psp_relative_density_in_range() {
1538        let mat = PbfMaterial::ti6al4v_slm();
1539        let params = PbfProcessParams {
1540            power: 200.0,
1541            scan_speed: 0.7,
1542            hatch_spacing: 110e-6,
1543            layer_thickness: 30e-6,
1544            spot_radius: 35e-6,
1545            preheat_temp: 300.0,
1546            rotation_angle_deg: 67.0,
1547        };
1548        let psp = PspLinkage::new(mat, params);
1549        let rd = psp.relative_density();
1550        assert!(rd > 0.0 && rd <= 1.0);
1551    }
1552
1553    #[test]
1554    fn test_psp_effective_yield_positive() {
1555        let mat = PbfMaterial::ti6al4v_slm();
1556        let params = PbfProcessParams {
1557            power: 200.0,
1558            scan_speed: 0.7,
1559            hatch_spacing: 110e-6,
1560            layer_thickness: 30e-6,
1561            spot_radius: 35e-6,
1562            preheat_temp: 300.0,
1563            rotation_angle_deg: 67.0,
1564        };
1565        let psp = PspLinkage::new(mat, params);
1566        assert!(psp.effective_yield_strength() > 0.0);
1567    }
1568
1569    // --- ProcessWindow ---
1570
1571    #[test]
1572    fn test_process_window_in_range() {
1573        let pw = ProcessWindow::ti6al4v_slm();
1574        assert!(pw.is_in_window(pw.ev_optimal));
1575    }
1576
1577    #[test]
1578    fn test_process_window_out_of_range_low() {
1579        let pw = ProcessWindow::ti6al4v_slm();
1580        assert!(!pw.is_in_window(pw.ev_min * 0.5));
1581    }
1582
1583    #[test]
1584    fn test_relative_density_at_optimal_near_1() {
1585        let pw = ProcessWindow::ti6al4v_slm();
1586        let rd = pw.estimated_relative_density(pw.ev_optimal);
1587        assert!(rd > 0.99);
1588    }
1589
1590    // --- In718 ---
1591
1592    #[test]
1593    fn test_in718_precipitation_strengthening_positive() {
1594        let props = In718Properties::as_built_slm();
1595        assert!(props.precipitation_strengthening() > 0.0);
1596    }
1597
1598    #[test]
1599    fn test_in718_creep_rate_positive() {
1600        let props = In718Properties::heat_treated();
1601        let cr = props.creep_rate(500e6, 923.0);
1602        assert!(cr > 0.0);
1603    }
1604
1605    // --- Miscellaneous ---
1606
1607    #[test]
1608    fn test_vickers_hardness_decreases_with_porosity() {
1609        let hv1 = vickers_hardness(350.0, 0.0, 3.0);
1610        let hv2 = vickers_hardness(350.0, 0.05, 3.0);
1611        assert!(hv1 > hv2);
1612    }
1613
1614    #[test]
1615    fn test_cumulative_residual_stress_count() {
1616        let temps = vec![1000.0, 900.0, 800.0];
1617        let stresses = cumulative_residual_stress(&temps, 300.0, 8.6e-6, 114e9, 0.342);
1618        assert_eq!(stresses.len(), 3);
1619    }
1620
1621    #[test]
1622    fn test_estimate_porosity_in_window_is_low() {
1623        let p = estimate_porosity_from_energy_density(70e9, 4430.0);
1624        assert!(p < 0.05);
1625    }
1626
1627    #[test]
1628    fn test_support_thermal_resistance_finite() {
1629        let s = SupportMaterial::metal_block_support();
1630        let r = s.thermal_resistance(5e-3, 1e-4, 20.0);
1631        assert!(r.is_finite() && r > 0.0);
1632    }
1633}