1#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24use std::f64::consts::PI;
25
26pub const KB: f64 = 1.380_649e-23;
32
33pub const R_GAS: f64 = 8.314_462;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum AmProcess {
43 Slm,
45 Dmls,
47 Ebm,
49 BinderJetting,
51 Fdm,
53 Ded,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum MetalAlloy {
60 Ti6Al4V,
62 Steel316L,
64 AlSi10Mg,
66 In718,
68 MaragingSteel,
70 CoCr,
72 Copper,
74}
75
76#[derive(Debug, Clone)]
82pub struct PbfMaterial {
83 pub alloy: MetalAlloy,
85 pub density: f64,
87 pub thermal_conductivity: f64,
89 pub specific_heat: f64,
91 pub latent_heat_fusion: f64,
93 pub solidus_temp: f64,
95 pub liquidus_temp: f64,
97 pub absorptivity: f64,
99 pub elastic_modulus: f64,
101 pub poisson_ratio: f64,
103 pub yield_strength: f64,
105 pub tensile_strength: f64,
107 pub thermal_expansion: f64,
109 pub packing_fraction: f64,
111}
112
113impl PbfMaterial {
114 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 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 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 pub fn thermal_diffusivity(&self) -> f64 {
176 self.thermal_conductivity / (self.density * self.specific_heat)
177 }
178
179 pub fn melting_range(&self) -> f64 {
181 (self.liquidus_temp - self.solidus_temp).max(0.0)
182 }
183}
184
185#[derive(Debug, Clone)]
191pub struct PbfProcessParams {
192 pub power: f64,
194 pub scan_speed: f64,
196 pub hatch_spacing: f64,
198 pub layer_thickness: f64,
200 pub spot_radius: f64,
202 pub preheat_temp: f64,
204 pub rotation_angle_deg: f64,
206}
207
208impl PbfProcessParams {
209 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 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 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#[derive(Debug, Clone)]
246pub struct MeltPoolGeometry {
247 pub half_width: f64,
249 pub half_length: f64,
251 pub depth: f64,
253}
254
255impl MeltPoolGeometry {
256 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 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; Self {
276 half_width,
277 half_length,
278 depth,
279 }
280 }
281
282 pub fn volume(&self) -> f64 {
284 (4.0 / 3.0) * PI * self.half_width * self.half_length * self.depth
285 }
286
287 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#[derive(Debug, Clone)]
303pub struct ThermalGradient {
304 pub gradient_magnitude: f64,
306 pub cooling_rate: f64,
308 pub solidification_rate: f64,
310}
311
312impl ThermalGradient {
313 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 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 pub fn g_times_r(&self) -> f64 {
339 self.gradient_magnitude * self.solidification_rate
340 }
341
342 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#[derive(Debug, Clone)]
358pub struct ResidualStressModel {
359 pub elastic_modulus: f64,
361 pub thermal_expansion: f64,
363 pub yield_strength_hot: f64,
365 pub poisson_ratio: f64,
367}
368
369impl ResidualStressModel {
370 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 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 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 pub fn von_mises_biaxial(&self, sigma_inplane: f64) -> f64 {
419 sigma_inplane.abs()
420 }
421}
422
423#[derive(Debug, Clone)]
429pub struct PorosityModel {
430 pub e0: f64,
432 pub yield_strength0: f64,
434 pub tensile_strength0: f64,
436 pub density0: f64,
438}
439
440impl PorosityModel {
441 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 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 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 pub fn effective_density(&self, porosity: f64) -> f64 {
468 self.density0 * (1.0 - porosity.clamp(0.0, 1.0))
469 }
470
471 pub fn relative_density(&self, porosity: f64) -> f64 {
473 1.0 - porosity.clamp(0.0, 1.0)
474 }
475
476 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#[derive(Debug, Clone)]
493pub struct GrainGrowthModel {
494 pub k0: f64,
496 pub activation_energy: f64,
498 pub initial_diameter: f64,
500}
501
502impl GrainGrowthModel {
503 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 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 pub fn columnar_aspect_ratio(&self, g_over_r: f64) -> f64 {
523 (0.01 * g_over_r + 1.0).min(20.0)
525 }
526}
527
528#[derive(Debug, Clone)]
530pub struct MartensiticModel {
531 pub ms_temp: f64,
533 pub mf_temp: f64,
535 pub max_martensite_fraction: f64,
537 pub km_coefficient: f64,
539}
540
541impl MartensiticModel {
542 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 pub fn steel_316l_martensite() -> Self {
554 Self {
555 ms_temp: 233.0, mf_temp: 123.0, max_martensite_fraction: 0.30,
558 km_coefficient: 0.033,
559 }
560 }
561
562 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 pub fn strength_contribution(&self, temp_k: f64, martensite_strength: f64) -> f64 {
578 self.martensite_fraction(temp_k) * martensite_strength
579 }
580}
581
582#[derive(Debug, Clone)]
588pub struct BinderJettingMaterial {
589 pub green_porosity: f64,
591 pub sintered_porosity: f64,
593 pub linear_shrinkage: f64,
595 pub binder_fraction: f64,
597 pub sintering_temp: f64,
599 pub sintering_time: f64,
601 pub dense_yield_strength: f64,
603 pub dense_elastic_modulus: f64,
605}
606
607impl BinderJettingMaterial {
608 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 pub fn volumetric_shrinkage(&self) -> f64 {
624 1.0 - (1.0 - self.linear_shrinkage).powi(3)
625 }
626
627 pub fn relative_density(&self) -> f64 {
629 1.0 - self.sintered_porosity
630 }
631
632 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 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#[derive(Debug, Clone)]
652pub struct FdmPolymerMaterial {
653 pub name: String,
655 pub density: f64,
657 pub tensile_strength_xy: f64,
659 pub tensile_strength_z: f64,
661 pub elastic_modulus_xy: f64,
663 pub elastic_modulus_z: f64,
665 pub elongation_xy: f64,
667 pub elongation_z: f64,
669 pub glass_transition_temp: f64,
671 pub layer_adhesion_strength: f64,
673 pub layer_height: f64,
675 pub raster_width: f64,
677 pub air_gap: f64,
679 pub raster_angle_deg: f64,
681}
682
683impl FdmPolymerMaterial {
684 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 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 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 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 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#[derive(Debug, Clone)]
758pub struct SupportMaterial {
759 pub density_fraction: f64,
761 pub modulus_fraction: f64,
763 pub removal_force: f64,
765 pub support_type: SupportType,
767}
768
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771pub enum SupportType {
772 Solid,
774 Lattice,
776 TreeLike,
778 Soluble,
780 PowderBed,
782}
783
784impl SupportMaterial {
785 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 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 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#[derive(Debug, Clone)]
824pub struct PspLinkage {
825 pub material: PbfMaterial,
827 pub process: PbfProcessParams,
829 pub estimated_porosity: f64,
831}
832
833impl PspLinkage {
834 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 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 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 pub fn thermal_gradient(&self, ambient_temp: f64) -> ThermalGradient {
862 ThermalGradient::from_rosenthal(&self.material, &self.process, ambient_temp)
863 }
864
865 pub fn melt_pool(&self, ambient_temp: f64) -> MeltPoolGeometry {
867 MeltPoolGeometry::eagar_tsai(&self.material, &self.process, ambient_temp)
868 }
869
870 pub fn relative_density(&self) -> f64 {
872 1.0 - self.estimated_porosity
873 }
874}
875
876pub fn estimate_porosity_from_energy_density(energy_density_j_per_m3: f64, _density: f64) -> f64 {
881 let ev = energy_density_j_per_m3 * 1e-9;
883 let ev_opt = 70.0_f64;
885 let delta = (ev - ev_opt).abs() / ev_opt;
886 let base = 0.001_f64;
888 (base + 0.5 * delta * delta).min(0.30)
889}
890
891#[derive(Debug, Clone)]
897pub struct ScanStrategyEffect {
898 pub rotation_angle_deg: f64,
900 pub texture_coefficient: f64,
902 pub residual_stress_factor: f64,
904 pub relative_density: f64,
906}
907
908impl ScanStrategyEffect {
909 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 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 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 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 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#[derive(Debug, Clone)]
967pub struct AmSurfaceRoughness {
968 pub layer_thickness: f64,
970 pub powder_d50: f64,
972 pub build_angle_deg: f64,
974 pub spot_radius: f64,
976}
977
978impl AmSurfaceRoughness {
979 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 pub fn ra_powder_adhesion(&self) -> f64 {
993 self.powder_d50 / 4.0
994 }
995
996 pub fn ra_total(&self) -> f64 {
998 (self.ra_staircase().powi(2) + self.ra_powder_adhesion().powi(2)).sqrt()
999 }
1000
1001 pub fn ra_total_um(&self) -> f64 {
1003 self.ra_total() * 1e6
1004 }
1005
1006 pub fn fatigue_reduction_factor(&self) -> f64 {
1010 let ra_um = self.ra_total_um();
1011 1.0 + 0.06 * ra_um
1013 }
1014}
1015
1016#[derive(Debug, Clone)]
1022pub struct In718Properties {
1023 pub gamma_prime_fraction: f64,
1025 pub gamma_double_prime_fraction: f64,
1027 pub delta_fraction: f64,
1029 pub creep_coefficient: f64,
1031 pub creep_exponent: f64,
1033 pub creep_activation_energy: f64,
1035}
1036
1037impl In718Properties {
1038 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 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 pub fn precipitation_strengthening(&self) -> f64 {
1066 let c = 2000e6; c * (self.gamma_prime_fraction + self.gamma_double_prime_fraction).sqrt()
1068 }
1069
1070 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#[derive(Debug, Clone)]
1086pub struct ProcessWindow {
1087 pub ev_min: f64,
1089 pub ev_max: f64,
1091 pub ev_optimal: f64,
1093}
1094
1095impl ProcessWindow {
1096 pub fn ti6al4v_slm() -> Self {
1098 Self {
1099 ev_min: 50e9,
1100 ev_max: 130e9,
1101 ev_optimal: 75e9,
1102 }
1103 }
1104
1105 pub fn steel_316l_slm() -> Self {
1107 Self {
1108 ev_min: 45e9,
1109 ev_max: 120e9,
1110 ev_optimal: 70e9,
1111 }
1112 }
1113
1114 pub fn is_in_window(&self, ev: f64) -> bool {
1116 ev >= self.ev_min && ev <= self.ev_max
1117 }
1118
1119 pub fn estimated_relative_density(&self, ev: f64) -> f64 {
1121 if ev < self.ev_min {
1122 let frac = ev / self.ev_min;
1124 0.80 + 0.18 * frac
1125 } else if ev > self.ev_max {
1126 let excess = (ev - self.ev_max) / self.ev_max;
1128 0.999 - 0.15 * excess * excess
1129 } else {
1130 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
1138pub 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
1149pub 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#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 #[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 #[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 #[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, ¶ms, 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, ¶ms, 300.0);
1284 assert!(mp.aspect_ratio() >= 1.0);
1285 }
1286
1287 #[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, ¶ms, 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, ¶ms, 300.0);
1319 assert!(tg.g_times_r() > 0.0);
1320 }
1321
1322 #[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); 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}