lpbf-map 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lpbf_map/__init__.py +12 -0
- lpbf_map/database/Al6061.json +10 -0
- lpbf_map/database/MoNbTaW.json +9 -0
- lpbf_map/database/NiTi_Sheikh.json +10 -0
- lpbf_map/database/Ti64.json +9 -0
- lpbf_map/database/__init__.py +1 -0
- lpbf_map/defects/__init__.py +22 -0
- lpbf_map/defects/balling.py +20 -0
- lpbf_map/defects/balling_yadroitsev.py +23 -0
- lpbf_map/defects/base.py +37 -0
- lpbf_map/defects/keyhole.py +18 -0
- lpbf_map/defects/keyhole_gan.py +30 -0
- lpbf_map/defects/keyhole_geometric.py +16 -0
- lpbf_map/defects/keyhole_king.py +28 -0
- lpbf_map/defects/lof.py +25 -0
- lpbf_map/defects/lof_depth_ratio.py +23 -0
- lpbf_map/materials.py +182 -0
- lpbf_map/meltpool.py +560 -0
- lpbf_map/meltpool_plots.py +308 -0
- lpbf_map/parameters.py +117 -0
- lpbf_map/printability.py +298 -0
- lpbf_map/printability_plots.py +269 -0
- lpbf_map/units.py +48 -0
- lpbf_map-0.1.0.dist-info/METADATA +322 -0
- lpbf_map-0.1.0.dist-info/RECORD +28 -0
- lpbf_map-0.1.0.dist-info/WHEEL +5 -0
- lpbf_map-0.1.0.dist-info/licenses/LICENSE +674 -0
- lpbf_map-0.1.0.dist-info/top_level.txt +1 -0
lpbf_map/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
L-PBF Printability Space
|
|
3
|
+
A predictive analytical modeling library for L-PBF processing maps.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
from .materials import Material
|
|
9
|
+
from .parameters import ProcessParameters
|
|
10
|
+
from .meltpool import MeltPool
|
|
11
|
+
from .printability import PrintabilitySpace
|
|
12
|
+
from .units import PARAMETER_UNITS, format_parameter_label, get_parameter_formatting
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Database module for material JSON properties
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .base import DefectCriterion, DefectSuite
|
|
2
|
+
from .balling import BallingPlateauRayleighCriterion
|
|
3
|
+
from .keyhole import KeyholeCriterion
|
|
4
|
+
from .lof import LackOfFusionCriterion
|
|
5
|
+
from .balling_yadroitsev import BallingYadroitsevCriterion
|
|
6
|
+
from .keyhole_king import KeyholeKingCriterion
|
|
7
|
+
from .keyhole_gan import KeyholeGanCriterion
|
|
8
|
+
from .keyhole_geometric import KeyholeGeometricCriterion
|
|
9
|
+
from .lof_depth_ratio import LackOfFusionDepthRatioCriterion
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DefectCriterion",
|
|
13
|
+
"DefectSuite",
|
|
14
|
+
"BallingPlateauRayleighCriterion",
|
|
15
|
+
"KeyholeCriterion",
|
|
16
|
+
"LackOfFusionCriterion",
|
|
17
|
+
"BallingYadroitsevCriterion",
|
|
18
|
+
"KeyholeKingCriterion",
|
|
19
|
+
"KeyholeGanCriterion",
|
|
20
|
+
"KeyholeGeometricCriterion",
|
|
21
|
+
"LackOfFusionDepthRatioCriterion"
|
|
22
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .base import DefectCriterion
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from ..meltpool import MeltPool
|
|
4
|
+
|
|
5
|
+
class BallingPlateauRayleighCriterion(DefectCriterion):
|
|
6
|
+
"""
|
|
7
|
+
Plateau-Rayleigh capillary instability criterion for Balling.
|
|
8
|
+
Returns True if the melt track length-to-width ratio exceeds the threshold.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, threshold: float = 2.3):
|
|
11
|
+
self.threshold = threshold
|
|
12
|
+
|
|
13
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
14
|
+
length = float(melt_pool.length[idx] if melt_pool.is_vectorized else melt_pool.length)
|
|
15
|
+
width = float(melt_pool.width[idx] if melt_pool.is_vectorized else melt_pool.width)
|
|
16
|
+
|
|
17
|
+
if width == 0:
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
return (length / width) >= self.threshold
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import DefectCriterion
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from ..meltpool import MeltPool
|
|
4
|
+
|
|
5
|
+
class BallingYadroitsevCriterion(DefectCriterion):
|
|
6
|
+
"""
|
|
7
|
+
Yadroitsev criterion for Balling.
|
|
8
|
+
Returns True if the melt track width is too narrow compared to the layer thickness.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, threshold: float = 1.0):
|
|
11
|
+
self.threshold = threshold
|
|
12
|
+
|
|
13
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
14
|
+
width = float(melt_pool.width[idx] if melt_pool.is_vectorized else melt_pool.width)
|
|
15
|
+
layer_thickness = melt_pool.get_property('layer_thickness', idx)
|
|
16
|
+
|
|
17
|
+
if layer_thickness is None:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if width == 0:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
return (width / layer_thickness) < self.threshold
|
lpbf_map/defects/base.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Tuple, Any
|
|
3
|
+
|
|
4
|
+
class DefectCriterion(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for a parametric defect.
|
|
7
|
+
"""
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def check(self, melt_pool: Any, idx: Tuple[int, ...]) -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Evaluates whether the defect occurs at the specific N-dimensional grid coordinate.
|
|
12
|
+
"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class DefectSuite:
|
|
16
|
+
"""
|
|
17
|
+
Container for prioritizing and executing DefectCriteria over a MeltPool.
|
|
18
|
+
"""
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.criteria = []
|
|
21
|
+
|
|
22
|
+
def add(self, priority_id: int, criterion: DefectCriterion):
|
|
23
|
+
"""
|
|
24
|
+
Registers a defect with a priority ID. Lower ID = higher priority.
|
|
25
|
+
"""
|
|
26
|
+
self.criteria.append((priority_id, criterion))
|
|
27
|
+
# Sort by priority ID (lowest first)
|
|
28
|
+
self.criteria.sort(key=lambda x: x[0])
|
|
29
|
+
|
|
30
|
+
def evaluate(self, melt_pool: Any, idx: Tuple[int, ...]) -> int:
|
|
31
|
+
"""
|
|
32
|
+
Checks all registered criteria at the given index. Returns the first matching defect ID, or 0 if Safe.
|
|
33
|
+
"""
|
|
34
|
+
for priority_id, criterion in self.criteria:
|
|
35
|
+
if criterion.check(melt_pool, idx):
|
|
36
|
+
return priority_id
|
|
37
|
+
return 0 # Safe Zone
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .base import DefectCriterion
|
|
2
|
+
|
|
3
|
+
class KeyholeCriterion(DefectCriterion):
|
|
4
|
+
"""
|
|
5
|
+
Geometric Ratio criterion for Keyhole porosity.
|
|
6
|
+
Returns True if the melt pool is too deep relative to its width (W/D < threshold).
|
|
7
|
+
"""
|
|
8
|
+
def __init__(self, threshold: float = 2.3):
|
|
9
|
+
self.threshold = threshold
|
|
10
|
+
|
|
11
|
+
def check(self, melt_pool, idx: tuple) -> bool:
|
|
12
|
+
width = float(melt_pool.width[idx])
|
|
13
|
+
depth = float(melt_pool.depth[idx])
|
|
14
|
+
|
|
15
|
+
if depth == 0:
|
|
16
|
+
return False # No depth means no keyhole
|
|
17
|
+
|
|
18
|
+
return (width / depth) < self.threshold
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
from .base import DefectCriterion
|
|
5
|
+
from ..meltpool import MeltPool
|
|
6
|
+
|
|
7
|
+
class KeyholeGanCriterion(DefectCriterion):
|
|
8
|
+
"""
|
|
9
|
+
Gan universal keyhole criterion based on normalized enthalpy.
|
|
10
|
+
Returns True if the normalized enthalpy exceeds the empirical threshold of 6.0.
|
|
11
|
+
"""
|
|
12
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
13
|
+
absorptivity = melt_pool.get_property('absorptivity', idx)
|
|
14
|
+
density = melt_pool.get_property('density', idx)
|
|
15
|
+
specific_heat = melt_pool.get_property('specific_heat', idx)
|
|
16
|
+
melting_temperature = melt_pool.get_property('melting_temperature', idx)
|
|
17
|
+
thermal_diffusivity = melt_pool.get_property('thermal_diffusivity', idx)
|
|
18
|
+
|
|
19
|
+
laser_power = melt_pool.get_property('laser_power', idx)
|
|
20
|
+
scan_speed = melt_pool.get_property('scan_speed', idx)
|
|
21
|
+
beam_radius = melt_pool.get_property('beam_radius', idx)
|
|
22
|
+
ambient_temperature = melt_pool.get_property('ambient_temperature', idx)
|
|
23
|
+
|
|
24
|
+
normalized_enthalpy = (absorptivity * laser_power) / (
|
|
25
|
+
(melting_temperature - ambient_temperature)
|
|
26
|
+
* np.pi * density * specific_heat
|
|
27
|
+
* np.sqrt(thermal_diffusivity * scan_speed * beam_radius**3)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return normalized_enthalpy > 6.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .base import DefectCriterion
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from ..meltpool import MeltPool
|
|
4
|
+
|
|
5
|
+
class KeyholeGeometricCriterion(DefectCriterion):
|
|
6
|
+
"""
|
|
7
|
+
Geometric Ratio criterion for Keyhole porosity.
|
|
8
|
+
Returns True if the melt pool is too deep relative to its width (W/D < threshold).
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, threshold: float = 2.0):
|
|
11
|
+
self.threshold = threshold
|
|
12
|
+
|
|
13
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
14
|
+
depth = float(melt_pool.depth[idx] if melt_pool.is_vectorized else melt_pool.depth)
|
|
15
|
+
beam_radius = melt_pool.get_property('beam_radius', idx)
|
|
16
|
+
return depth > beam_radius * self.threshold
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
from .base import DefectCriterion
|
|
5
|
+
from ..meltpool import MeltPool
|
|
6
|
+
|
|
7
|
+
class KeyholeKingCriterion(DefectCriterion):
|
|
8
|
+
"""
|
|
9
|
+
King keyhole criterion based on conduction-to-keyhole transition.
|
|
10
|
+
"""
|
|
11
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
12
|
+
absorptivity = melt_pool.get_property('absorptivity', idx)
|
|
13
|
+
melting_temperature = melt_pool.get_property('melting_temperature', idx)
|
|
14
|
+
ambient_temperature = melt_pool.get_property('ambient_temperature', idx)
|
|
15
|
+
thermal_conductivity = melt_pool.get_property('thermal_conductivity', idx)
|
|
16
|
+
boiling_temperature = melt_pool.get_property('boiling_temperature', idx)
|
|
17
|
+
|
|
18
|
+
laser_power = melt_pool.get_property('laser_power', idx)
|
|
19
|
+
beam_radius = melt_pool.get_property('beam_radius', idx)
|
|
20
|
+
|
|
21
|
+
threshold_power = (
|
|
22
|
+
(melting_temperature - ambient_temperature)
|
|
23
|
+
* np.pi * thermal_conductivity * beam_radius
|
|
24
|
+
/ absorptivity
|
|
25
|
+
)
|
|
26
|
+
threshold_power *= (boiling_temperature / melting_temperature)
|
|
27
|
+
|
|
28
|
+
return laser_power > threshold_power
|
lpbf_map/defects/lof.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from .base import DefectCriterion
|
|
3
|
+
|
|
4
|
+
class LackOfFusionCriterion(DefectCriterion):
|
|
5
|
+
"""
|
|
6
|
+
Geometric Overlap criterion for Lack of Fusion.
|
|
7
|
+
Returns True if voids form between adjacent tracks.
|
|
8
|
+
"""
|
|
9
|
+
def check(self, melt_pool, idx: tuple) -> bool:
|
|
10
|
+
depth = float(melt_pool.depth[idx] if melt_pool.is_vectorized else melt_pool.depth)
|
|
11
|
+
width = float(melt_pool.width[idx] if melt_pool.is_vectorized else melt_pool.width)
|
|
12
|
+
|
|
13
|
+
layer_thickness = melt_pool.get_property('layer_thickness', idx)
|
|
14
|
+
hatch_spacing = melt_pool.get_property('hatch_spacing', idx)
|
|
15
|
+
|
|
16
|
+
if layer_thickness is None or hatch_spacing is None:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
if depth <= 1e-9 or width <= 1e-9:
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
if (hatch_spacing/width)**2 >= 1:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
return layer_thickness > depth * np.sqrt(1 - (hatch_spacing/width)**2)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import DefectCriterion
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from ..meltpool import MeltPool
|
|
4
|
+
|
|
5
|
+
class LackOfFusionDepthRatioCriterion(DefectCriterion):
|
|
6
|
+
"""
|
|
7
|
+
Depth-to-Layer-Thickness Ratio criterion for Lack of Fusion.
|
|
8
|
+
Returns True if the melt pool depth does not sufficiently penetrate the layer thickness.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, threshold: float = 1.5):
|
|
11
|
+
self.threshold = threshold
|
|
12
|
+
|
|
13
|
+
def check(self, melt_pool: MeltPool, idx: Tuple[int, ...]) -> bool:
|
|
14
|
+
depth = float(melt_pool.depth[idx] if melt_pool.is_vectorized else melt_pool.depth)
|
|
15
|
+
layer_thickness = melt_pool.get_property('layer_thickness', idx)
|
|
16
|
+
|
|
17
|
+
if layer_thickness is None:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if layer_thickness == 0:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
return (depth / layer_thickness) < self.threshold
|
lpbf_map/materials.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
import numpy as np
|
|
5
|
+
import importlib.resources
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Material:
|
|
9
|
+
"""
|
|
10
|
+
Represents the thermophysical blueprint of an alloy for L-PBF modeling.
|
|
11
|
+
Uses strict SI units (kg, m, s, W, K).
|
|
12
|
+
|
|
13
|
+
All numeric properties are internally cast to NumPy arrays on initialisation,
|
|
14
|
+
so that single-value materials and multi-value 'material sweeps' (e.g. a
|
|
15
|
+
thermal_conductivity sweep) share a unified API and always expose `.shape`.
|
|
16
|
+
"""
|
|
17
|
+
name: str
|
|
18
|
+
density: Union[float, np.ndarray] # rho [kg/m^3]
|
|
19
|
+
specific_heat: Union[float, np.ndarray] # C_p [J/(kg*K)]
|
|
20
|
+
thermal_conductivity: Union[float, np.ndarray] # k [W/(m*K)]
|
|
21
|
+
melting_temperature: Union[float, np.ndarray] # T_m [K]
|
|
22
|
+
boiling_temperature: Union[float, np.ndarray] # T_b [K]
|
|
23
|
+
absorptivity: Union[float, np.ndarray] # A [dimensionless]
|
|
24
|
+
thermal_diffusivity: Optional[Union[float, np.ndarray]] = None # alpha [m^2/s]
|
|
25
|
+
electrical_resistivity: Optional[Union[float, np.ndarray]] = None # rho_e [Ohm*m]
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
"""
|
|
29
|
+
Casts all numeric properties to NumPy arrays and validates physical limits.
|
|
30
|
+
Auto-calculates thermal diffusivity if it was not explicitly provided.
|
|
31
|
+
"""
|
|
32
|
+
# --- NumPy casting: ensures every property exposes .shape uniformly ---
|
|
33
|
+
self.density = np.asarray(self.density, dtype=float)
|
|
34
|
+
self.specific_heat = np.asarray(self.specific_heat, dtype=float)
|
|
35
|
+
self.thermal_conductivity = np.asarray(self.thermal_conductivity, dtype=float)
|
|
36
|
+
self.melting_temperature = np.asarray(self.melting_temperature, dtype=float)
|
|
37
|
+
self.boiling_temperature = np.asarray(self.boiling_temperature, dtype=float)
|
|
38
|
+
self.absorptivity = np.asarray(self.absorptivity, dtype=float)
|
|
39
|
+
if self.thermal_diffusivity is not None:
|
|
40
|
+
self.thermal_diffusivity = np.asarray(self.thermal_diffusivity, dtype=float)
|
|
41
|
+
if self.electrical_resistivity is not None:
|
|
42
|
+
self.electrical_resistivity = np.asarray(self.electrical_resistivity, dtype=float)
|
|
43
|
+
|
|
44
|
+
# --- Physical validation ---
|
|
45
|
+
if np.any(self.density <= 0):
|
|
46
|
+
raise ValueError(f"Density must be > 0. Got {self.density}")
|
|
47
|
+
if np.any(self.specific_heat <= 0):
|
|
48
|
+
raise ValueError(f"Specific heat must be > 0. Got {self.specific_heat}")
|
|
49
|
+
if np.any(self.thermal_conductivity <= 0):
|
|
50
|
+
raise ValueError(f"Thermal conductivity must be > 0. Got {self.thermal_conductivity}")
|
|
51
|
+
|
|
52
|
+
# --- Derived property: alpha = k / (rho * C_p) ---
|
|
53
|
+
if self.thermal_diffusivity is None:
|
|
54
|
+
self.thermal_diffusivity = self.thermal_conductivity / (self.density * self.specific_heat)
|
|
55
|
+
|
|
56
|
+
def calculate_absorptivity(self, wavelength: Union[float, np.ndarray], method: str = 'hagen-rubens') -> None:
|
|
57
|
+
"""
|
|
58
|
+
Computes and updates `self.absorptivity` from a theoretical model.
|
|
59
|
+
|
|
60
|
+
By default uses the Hagen-Rubens approximation, which requires
|
|
61
|
+
`self.electrical_resistivity` to be set on this Material object.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
wavelength: Laser wavelength in metres (m). Can be a scalar or array
|
|
65
|
+
matching the parameter sweep.
|
|
66
|
+
method: The analytical model to apply. Currently supported:
|
|
67
|
+
- 'hagen-rubens': Simplified Drude model,
|
|
68
|
+
A ≈ 0.365 * sqrt(electrical_resistivity / wavelength).
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If `electrical_resistivity` is None when using 'hagen-rubens'.
|
|
72
|
+
ValueError: If an unsupported method name is given.
|
|
73
|
+
"""
|
|
74
|
+
method = method.lower()
|
|
75
|
+
valid_methods = ['hagen_rubens', 'gusarov_smurov', 'boley_hex', 'boley_gaussian', 'boley_bimodal']
|
|
76
|
+
|
|
77
|
+
if method not in valid_methods:
|
|
78
|
+
raise ValueError(f"Unknown absorptivity method: '{method}'. Supported: {valid_methods}.")
|
|
79
|
+
|
|
80
|
+
if self.electrical_resistivity is None:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Method '{method}' requires 'electrical_resistivity' to be set on the Material."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
wavelength = np.asarray(wavelength, dtype=float)
|
|
86
|
+
|
|
87
|
+
# Base solid absorptivity via Hagen-Rubens
|
|
88
|
+
As = 0.365 * np.sqrt(self.electrical_resistivity / wavelength)
|
|
89
|
+
|
|
90
|
+
if method == 'hagen_rubens':
|
|
91
|
+
absorptivity_raw = As
|
|
92
|
+
elif method == 'gusarov_smurov':
|
|
93
|
+
absorptivity_raw = (2 * np.sqrt(As)) / (1 + np.sqrt(As))
|
|
94
|
+
elif method == 'boley_hex':
|
|
95
|
+
absorptivity_raw = 0.0889 + 2.73 * As - 5.06 * (As**2) + 4.29 * (As**3)
|
|
96
|
+
elif method == 'boley_gaussian':
|
|
97
|
+
absorptivity_raw = 0.0413 + 2.89 * As - 5.36 * (As**2) + 4.50 * (As**3)
|
|
98
|
+
elif method == 'boley_bimodal':
|
|
99
|
+
absorptivity_raw = 0.104 + 2.39 * As - 3.31 * (As**2) + 2.20 * (As**3)
|
|
100
|
+
|
|
101
|
+
# Physical constraint: absorptivity cannot exceed 1.0 (100%)
|
|
102
|
+
self.absorptivity = np.minimum(absorptivity_raw, 1.0)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_library(cls, material_name: str) -> "Material":
|
|
106
|
+
"""
|
|
107
|
+
Factory method that loads a material from the native PyPI packaged JSON library.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
material_name: Name of the material file (e.g. 'Ti64' for Ti64.json)
|
|
111
|
+
"""
|
|
112
|
+
if not material_name.endswith('.json'):
|
|
113
|
+
filename = f"{material_name}.json"
|
|
114
|
+
else:
|
|
115
|
+
filename = material_name
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Using importlib.resources to guarantee resolution even when pip-installed
|
|
119
|
+
with importlib.resources.open_text('lpbf_map.database', filename) as f:
|
|
120
|
+
data = json.load(f)
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
name=data.get("name", material_name),
|
|
124
|
+
density=data.get("density", data.get("rho")),
|
|
125
|
+
specific_heat=data.get("specific_heat", data.get("C_p")),
|
|
126
|
+
thermal_conductivity=data.get("thermal_conductivity", data.get("k")),
|
|
127
|
+
melting_temperature=data.get("melting_temperature", data.get("T_m")),
|
|
128
|
+
boiling_temperature=data.get("boiling_temperature", data.get("T_b")),
|
|
129
|
+
absorptivity=data.get("absorptivity", data.get("A")),
|
|
130
|
+
thermal_diffusivity=data.get("thermal_diffusivity", data.get("alpha")),
|
|
131
|
+
electrical_resistivity=data.get("electrical_resistivity", data.get("rho_e"))
|
|
132
|
+
)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
raise FileNotFoundError(f"Material {filename} not found in native database.")
|
|
135
|
+
except KeyError as e:
|
|
136
|
+
raise ValueError(f"Material JSON {filename} is missing required field: {e}")
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_dict(cls, data: dict, name: str = "Custom") -> "Material":
|
|
140
|
+
"""
|
|
141
|
+
Helper method to create a material from an existing dictionary of old keys.
|
|
142
|
+
"""
|
|
143
|
+
return cls(
|
|
144
|
+
name=name,
|
|
145
|
+
density=data.get("density", data.get("rho")),
|
|
146
|
+
specific_heat=data.get("specific_heat", data.get("C_p")),
|
|
147
|
+
thermal_conductivity=data.get("thermal_conductivity", data.get("k")),
|
|
148
|
+
melting_temperature=data.get("melting_temperature", data.get("T_m")),
|
|
149
|
+
boiling_temperature=data.get("boiling_temperature", data.get("T_b")),
|
|
150
|
+
absorptivity=data.get("absorptivity", data.get("A")),
|
|
151
|
+
thermal_diffusivity=data.get("thermal_diffusivity", data.get("alpha")),
|
|
152
|
+
electrical_resistivity=data.get("electrical_resistivity", data.get("rho_e"))
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def plot_absorptivity_models(self, wavelengths: np.ndarray = None, save_path: str = None):
|
|
156
|
+
"""
|
|
157
|
+
Evaluates and plots all 5 mathematical models of absorptivity over a range of wavelengths.
|
|
158
|
+
"""
|
|
159
|
+
import matplotlib.pyplot as plt
|
|
160
|
+
if wavelengths is None:
|
|
161
|
+
wavelengths = np.linspace(0.5e-6, 2.0e-6, 50)
|
|
162
|
+
|
|
163
|
+
models = ['hagen_rubens', 'gusarov_smurov', 'boley_hex', 'boley_gaussian', 'boley_bimodal']
|
|
164
|
+
labels = ['Hagen-Rubens', 'Gusarov-Smurov', 'Boley (Hexagonal)', 'Boley (Gaussian)', 'Boley (Bimodal)']
|
|
165
|
+
|
|
166
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
167
|
+
cmap = plt.cm.inferno
|
|
168
|
+
colors = [cmap(i) for i in np.linspace(0.15, 0.85, len(models))]
|
|
169
|
+
for m, lbl, color in zip(models, labels, colors):
|
|
170
|
+
self.calculate_absorptivity(wavelengths, method=m)
|
|
171
|
+
ax.plot(wavelengths * 1e9, self.absorptivity * 100, label=lbl, linewidth=2, color=color)
|
|
172
|
+
|
|
173
|
+
ax.set_title(f"Absorptivity Models for {self.name}", fontsize=14, fontweight='bold')
|
|
174
|
+
ax.set_xlabel("Wavelength (nm)", fontsize=12)
|
|
175
|
+
ax.set_ylabel("Absorptivity (%)", fontsize=12)
|
|
176
|
+
ax.grid(True, linestyle='--', alpha=0.7)
|
|
177
|
+
ax.legend()
|
|
178
|
+
plt.tight_layout()
|
|
179
|
+
|
|
180
|
+
if save_path:
|
|
181
|
+
plt.savefig(save_path, dpi=300, bbox_inches='tight')
|
|
182
|
+
return fig, ax
|