pyroll-phase-transformation-model 3.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.
- pyroll/phase_transformation_model/__init__.py +43 -0
- pyroll/phase_transformation_model/microstructure.py +275 -0
- pyroll/phase_transformation_model/phase_transformations/__init__.py +6 -0
- pyroll/phase_transformation_model/phase_transformations/bainite.py +29 -0
- pyroll/phase_transformation_model/phase_transformations/base.py +52 -0
- pyroll/phase_transformation_model/phase_transformations/diffusional_phase_transformation.py +248 -0
- pyroll/phase_transformation_model/phase_transformations/ferrite.py +29 -0
- pyroll/phase_transformation_model/phase_transformations/martensite.py +51 -0
- pyroll/phase_transformation_model/phase_transformations/perlite.py +29 -0
- pyroll/phase_transformation_model/profile.py +165 -0
- pyroll/phase_transformation_model/sigmoidal_functions/__init__.py +3 -0
- pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_base.py +89 -0
- pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_i.py +18 -0
- pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_s.py +18 -0
- pyroll/phase_transformation_model/transformation/__init__.py +8 -0
- pyroll/phase_transformation_model/transformation/hardness.py +44 -0
- pyroll/phase_transformation_model/transformation/mechanical_properties.py +65 -0
- pyroll/phase_transformation_model/transformation/transformation_solver.py +341 -0
- pyroll_phase_transformation_model-3.1.0.dist-info/METADATA +32 -0
- pyroll_phase_transformation_model-3.1.0.dist-info/RECORD +22 -0
- pyroll_phase_transformation_model-3.1.0.dist-info/WHEEL +4 -0
- pyroll_phase_transformation_model-3.1.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
VERSION = "3.1.0"
|
|
2
|
+
|
|
3
|
+
from . import profile # noqa: F401 (registers profile hooks)
|
|
4
|
+
from . import microstructure # noqa: F401 (registers result hooks)
|
|
5
|
+
|
|
6
|
+
from .sigmoidal_functions import S, I
|
|
7
|
+
from .phase_transformations import (
|
|
8
|
+
Martensite,
|
|
9
|
+
Bainite,
|
|
10
|
+
Ferrite,
|
|
11
|
+
Perlite,
|
|
12
|
+
DiffusionalPhaseTransformation,
|
|
13
|
+
)
|
|
14
|
+
from .transformation import (
|
|
15
|
+
PhaseTransformationSolver,
|
|
16
|
+
vickers_hardness_martensite,
|
|
17
|
+
vickers_hardness_bainite,
|
|
18
|
+
vickers_hardness_ferrite_perlite,
|
|
19
|
+
tensile_strength_from_hardness,
|
|
20
|
+
elongation_a50,
|
|
21
|
+
reduction_of_area,
|
|
22
|
+
impact_energy_ku,
|
|
23
|
+
)
|
|
24
|
+
from .microstructure import Config
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"S",
|
|
28
|
+
"I",
|
|
29
|
+
"Martensite",
|
|
30
|
+
"Bainite",
|
|
31
|
+
"Ferrite",
|
|
32
|
+
"Perlite",
|
|
33
|
+
"DiffusionalPhaseTransformation",
|
|
34
|
+
"PhaseTransformationSolver",
|
|
35
|
+
"vickers_hardness_martensite",
|
|
36
|
+
"vickers_hardness_bainite",
|
|
37
|
+
"vickers_hardness_ferrite_perlite",
|
|
38
|
+
"tensile_strength_from_hardness",
|
|
39
|
+
"elongation_a50",
|
|
40
|
+
"reduction_of_area",
|
|
41
|
+
"impact_energy_ku",
|
|
42
|
+
"Config",
|
|
43
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Integration of the phase-transformation model into the PyRoll hook system.
|
|
2
|
+
|
|
3
|
+
The standalone :class:`~.transformation.PhaseTransformationSolver` works on a
|
|
4
|
+
thermal cycle ``T(t)``. Inside a PyRoll process that cycle is not given
|
|
5
|
+
explicitly; it has to be reconstructed from the solved unit tree. As the bar
|
|
6
|
+
flows through the sequence, every unit's out-profile carries the accumulated
|
|
7
|
+
time :pyattr:`~pyroll.core.Profile.t` and its :pyattr:`temperature`. Cooling
|
|
8
|
+
units (:class:`~pyroll.core.Transport`, :class:`~pyroll.core.CoolingPipe`,
|
|
9
|
+
:class:`~pyroll.core.Spooler`) are :class:`~pyroll.core.DiskElementUnit`\\ s, so
|
|
10
|
+
their disk elements provide the fine-grained ``(t, T)`` samples that resolve the
|
|
11
|
+
cooling rates the kinetics depend on.
|
|
12
|
+
|
|
13
|
+
This module walks that tree, assembles the thermal history up to and including
|
|
14
|
+
any out-profile, and exposes the resulting microstructure as profile hooks:
|
|
15
|
+
|
|
16
|
+
* :pyattr:`microstructure_composition` – bulk phase fractions (core hook).
|
|
17
|
+
* :pyattr:`ring_microstructure_compositions` – per-ring phase fractions, using
|
|
18
|
+
the ``ring_temperatures`` of :mod:`pyroll.ring_model_thermal`.
|
|
19
|
+
* :pyattr:`vickers_hardness` – overall Maynier hardness (core hook).
|
|
20
|
+
* :pyattr:`mechanical_properties` – tensile strength, elongation, etc.
|
|
21
|
+
* :pyattr:`thermal_history` / :pyattr:`ring_thermal_histories` – the
|
|
22
|
+
reconstructed ``T(t)`` cycles, mainly for inspection.
|
|
23
|
+
|
|
24
|
+
All result hooks are **lazy**: they are computed (and cached) only when accessed,
|
|
25
|
+
e.g. ``sequence[i].out_profile.microstructure_composition``. They are
|
|
26
|
+
intentionally not registered as root hooks, because root hooks are re-evaluated
|
|
27
|
+
on every convergence iteration of every unit, which would make the per-step
|
|
28
|
+
solver prohibitively expensive. To have them appear in reports, add the desired
|
|
29
|
+
hook to ``pyroll.core.root_hooks`` in your own configuration.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from pyroll.core import Unit, Hook, config
|
|
36
|
+
|
|
37
|
+
from .transformation import PhaseTransformationSolver
|
|
38
|
+
|
|
39
|
+
#: Phase columns produced by the solver, in report order.
|
|
40
|
+
_PHASES = ("ferrite", "perlite", "bainite", "martensite", "austenite")
|
|
41
|
+
|
|
42
|
+
#: Key under which the computed solver frame is cached on a profile instance.
|
|
43
|
+
_FRAME_CACHE = "_ptm_frame"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@config("PYROLL_PHASE_TRANSFORMATION_MODEL")
|
|
47
|
+
class Config:
|
|
48
|
+
THERMAL_HISTORY_SAMPLES = 1000
|
|
49
|
+
"""Number of uniform time samples the thermal history is resampled to
|
|
50
|
+
before it is handed to the kinetics solver. The solver assumes a uniform
|
|
51
|
+
time grid, while the reconstructed history is non-uniform; this is the grid
|
|
52
|
+
it is interpolated onto. Higher values are more accurate but slower."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@Unit.OutProfile.extension_class
|
|
56
|
+
class PhaseTransformationOutProfile(Unit.OutProfile):
|
|
57
|
+
"""Result hooks added to every unit out-profile."""
|
|
58
|
+
|
|
59
|
+
thermal_history = Hook[dict]()
|
|
60
|
+
"""The reconstructed scalar thermal cycle up to this profile as a dict with
|
|
61
|
+
the keys ``t`` (time in s) and ``T`` (temperature in K), each a 1-D array."""
|
|
62
|
+
|
|
63
|
+
ring_thermal_histories = Hook[np.ndarray]()
|
|
64
|
+
"""The reconstructed per-ring thermal cycles up to this profile as a 2-D
|
|
65
|
+
array of shape ``(n_samples, n_rings)`` aligned with
|
|
66
|
+
:pyattr:`~pyroll.ring_model.RingProfile.rings`. Available only if ring
|
|
67
|
+
temperatures are present (``pyroll-ring-model-thermal`` in use)."""
|
|
68
|
+
|
|
69
|
+
ring_microstructure_compositions = Hook[np.ndarray]()
|
|
70
|
+
"""Per-ring phase composition: an object array of ``{phase: fraction}``
|
|
71
|
+
dicts, one per ring from core to surface. Available only if ring
|
|
72
|
+
temperatures are present."""
|
|
73
|
+
|
|
74
|
+
mechanical_properties = Hook[dict]()
|
|
75
|
+
"""Estimated mechanical properties derived from the final microstructure
|
|
76
|
+
(``HV``, ``ym``, ``Rm``, ``A50``, ``Z``, ``KU``)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Thermal-history reconstruction (plain helpers, return None when unavailable)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _root_unit(unit: Unit) -> Unit:
|
|
84
|
+
"""Climb to the outermost unit (typically the :class:`PassSequence`)."""
|
|
85
|
+
while unit.parent is not None:
|
|
86
|
+
unit = unit.parent
|
|
87
|
+
return unit
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _leaf_out_profiles(unit: Unit):
|
|
91
|
+
"""Yield the out-profiles of all leaf units in solution order.
|
|
92
|
+
|
|
93
|
+
Cooling units are subdivided into disk elements; those disks are the leaves
|
|
94
|
+
and provide the fine ``(t, T)`` resolution. Units without subunits (e.g.
|
|
95
|
+
roll passes) contribute their own out-profile.
|
|
96
|
+
"""
|
|
97
|
+
subunits = unit.subunits
|
|
98
|
+
if subunits:
|
|
99
|
+
for sub in subunits:
|
|
100
|
+
yield from _leaf_out_profiles(sub)
|
|
101
|
+
elif unit.out_profile is not None:
|
|
102
|
+
yield unit.out_profile
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ring_temperatures(profile):
|
|
106
|
+
if profile.has_set_or_cached("ring_temperatures"):
|
|
107
|
+
value = profile.ring_temperatures
|
|
108
|
+
if value is not None:
|
|
109
|
+
return np.asarray(value, dtype=float)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _collect_history(target):
|
|
114
|
+
"""Collect ``(t, temperature, ring_temperatures)`` samples up to ``target``.
|
|
115
|
+
|
|
116
|
+
Returns a list of ``(t, T, ring_T_or_None)`` tuples ordered by time,
|
|
117
|
+
starting from the first in-profile of the sequence and ending at ``target``.
|
|
118
|
+
Profiles later than ``target`` (downstream, or not yet solved) are excluded
|
|
119
|
+
via the ``t`` filter.
|
|
120
|
+
"""
|
|
121
|
+
root = _root_unit(target.unit)
|
|
122
|
+
t_target = target.t
|
|
123
|
+
|
|
124
|
+
samples = []
|
|
125
|
+
|
|
126
|
+
start = root.in_profile
|
|
127
|
+
if start is not None and start.has_value("t") and start.has_value("temperature"):
|
|
128
|
+
samples.append((float(start.t), float(start.temperature), _ring_temperatures(start)))
|
|
129
|
+
|
|
130
|
+
for profile in _leaf_out_profiles(root):
|
|
131
|
+
if profile.t <= t_target + 1e-9:
|
|
132
|
+
samples.append(
|
|
133
|
+
(float(profile.t), float(profile.temperature), _ring_temperatures(profile))
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
samples.sort(key=lambda s: s[0])
|
|
137
|
+
return samples
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _scalar_history(target):
|
|
141
|
+
"""Return ``(time, temperature)`` arrays of the bulk cycle, or ``None``."""
|
|
142
|
+
samples = _collect_history(target)
|
|
143
|
+
if len(samples) < 2:
|
|
144
|
+
return None
|
|
145
|
+
times = np.array([s[0] for s in samples], dtype=float)
|
|
146
|
+
temperatures = np.array([s[1] for s in samples], dtype=float)
|
|
147
|
+
return times, temperatures
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _ring_history(target):
|
|
151
|
+
"""Return ``(time, ring_temperatures_2d)`` of the per-ring cycles, or ``None``."""
|
|
152
|
+
samples = _collect_history(target)
|
|
153
|
+
if len(samples) < 2 or any(s[2] is None for s in samples):
|
|
154
|
+
return None
|
|
155
|
+
if len({len(s[2]) for s in samples}) != 1:
|
|
156
|
+
return None
|
|
157
|
+
times = np.array([s[0] for s in samples], dtype=float)
|
|
158
|
+
ring_temperatures = np.array([s[2] for s in samples], dtype=float)
|
|
159
|
+
return times, ring_temperatures
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Kinetics evaluation
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _solve_cycle(solver, profile, times, temperatures, n):
|
|
167
|
+
"""Run the kinetics solver on a single ``T(t)`` cycle.
|
|
168
|
+
|
|
169
|
+
Resamples ``(times, temperatures)`` onto the uniform grid the solver
|
|
170
|
+
expects and short-circuits to a fully austenitic frame when the cycle never
|
|
171
|
+
cools below the ferrite-start temperature, sparing the per-step root solve
|
|
172
|
+
for the still-hot part of the line.
|
|
173
|
+
"""
|
|
174
|
+
times = np.asarray(times, dtype=float)
|
|
175
|
+
temperatures = np.asarray(temperatures, dtype=float)
|
|
176
|
+
unique_times, order = np.unique(times, return_index=True)
|
|
177
|
+
grid = np.linspace(unique_times[0], unique_times[-1], n)
|
|
178
|
+
grid_temperatures = np.interp(grid, unique_times, temperatures[order])
|
|
179
|
+
|
|
180
|
+
if np.nanmin(grid_temperatures) >= profile.ae3_temperature:
|
|
181
|
+
frame = pd.DataFrame({"t": grid, "T": grid_temperatures})
|
|
182
|
+
for name in ("ferrite", "perlite", "bainite", "martensite"):
|
|
183
|
+
frame[name] = 0.0
|
|
184
|
+
frame["austenite"] = 1.0
|
|
185
|
+
return frame
|
|
186
|
+
|
|
187
|
+
return solver.get_transformed_fraction(grid, grid_temperatures)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _final_composition(frame: pd.DataFrame) -> dict:
|
|
191
|
+
final = frame.iloc[-1]
|
|
192
|
+
return {name: float(final[name]) for name in _PHASES}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _bulk_frame(profile):
|
|
196
|
+
"""The solver frame for the bulk thermal history, cached on the profile."""
|
|
197
|
+
cached = profile.__dict__.get(_FRAME_CACHE)
|
|
198
|
+
if cached is not None:
|
|
199
|
+
return cached
|
|
200
|
+
history = _scalar_history(profile)
|
|
201
|
+
if history is None:
|
|
202
|
+
return None
|
|
203
|
+
solver = PhaseTransformationSolver(profile)
|
|
204
|
+
frame = _solve_cycle(solver, profile, history[0], history[1], Config.THERMAL_HISTORY_SAMPLES)
|
|
205
|
+
profile.__dict__[_FRAME_CACHE] = frame
|
|
206
|
+
return frame
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Hooks
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
@Unit.OutProfile.thermal_history
|
|
214
|
+
def thermal_history(self):
|
|
215
|
+
history = _scalar_history(self)
|
|
216
|
+
if history is None:
|
|
217
|
+
return None
|
|
218
|
+
return {"t": history[0], "T": history[1]}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@Unit.OutProfile.ring_thermal_histories
|
|
222
|
+
def ring_thermal_histories(self):
|
|
223
|
+
history = _ring_history(self)
|
|
224
|
+
if history is None:
|
|
225
|
+
return None
|
|
226
|
+
return history[1]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@Unit.OutProfile.microstructure_composition
|
|
230
|
+
def microstructure_composition(self):
|
|
231
|
+
frame = _bulk_frame(self)
|
|
232
|
+
if frame is None:
|
|
233
|
+
return None
|
|
234
|
+
return _final_composition(frame)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@Unit.OutProfile.ring_microstructure_compositions
|
|
238
|
+
def ring_microstructure_compositions(self):
|
|
239
|
+
history = _ring_history(self)
|
|
240
|
+
if history is None:
|
|
241
|
+
return None
|
|
242
|
+
times, ring_temperatures = history
|
|
243
|
+
|
|
244
|
+
# The kinetics inputs (transformation temperatures, composition factors,
|
|
245
|
+
# grain size) are profile-level and identical for every ring; only the
|
|
246
|
+
# temperature path differs, so one solver instance is reused.
|
|
247
|
+
solver = PhaseTransformationSolver(self)
|
|
248
|
+
n = Config.THERMAL_HISTORY_SAMPLES
|
|
249
|
+
|
|
250
|
+
n_rings = ring_temperatures.shape[1]
|
|
251
|
+
result = np.empty(n_rings, dtype=object)
|
|
252
|
+
for i in range(n_rings):
|
|
253
|
+
frame = _solve_cycle(solver, self, times, ring_temperatures[:, i], n)
|
|
254
|
+
result[i] = _final_composition(frame)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@Unit.OutProfile.vickers_hardness
|
|
259
|
+
def vickers_hardness(self):
|
|
260
|
+
frame = _bulk_frame(self)
|
|
261
|
+
if frame is None:
|
|
262
|
+
return None
|
|
263
|
+
hardness = PhaseTransformationSolver(self).estimate_hardness(frame)
|
|
264
|
+
if not np.isfinite(hardness):
|
|
265
|
+
return None
|
|
266
|
+
return float(hardness)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@Unit.OutProfile.mechanical_properties
|
|
270
|
+
def mechanical_properties(self):
|
|
271
|
+
frame = _bulk_frame(self)
|
|
272
|
+
if frame is None:
|
|
273
|
+
return None
|
|
274
|
+
properties = PhaseTransformationSolver(self).estimate_mechanical_properties(frame)
|
|
275
|
+
return {key: float(value) for key, value in properties.items()}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .diffusional_phase_transformation import DiffusionalPhaseTransformation
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Bainite(DiffusionalPhaseTransformation):
|
|
5
|
+
"""
|
|
6
|
+
Austenite to bainite phase transformation
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
name = "bainite"
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def grain_size_coefficient(self):
|
|
13
|
+
return 0.29
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def diffusion_coefficient(self):
|
|
17
|
+
return 2
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def transformation_start_temperature(self):
|
|
21
|
+
return self.profile.bainite_start_temperature
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def transformation_finish_temperature(self):
|
|
25
|
+
return self.profile.martensite_start_temperature
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def composition_factor(self):
|
|
29
|
+
return self.profile.bainite_kinetics
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pyroll.core as pr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PhaseTransformation(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for an austenite-decomposition product.
|
|
10
|
+
|
|
11
|
+
A phase only has to know how to evaluate the fraction transformed along a
|
|
12
|
+
thermal history ``T(t)``. Diffusional products (ferrite, pearlite,
|
|
13
|
+
bainite) additionally provide isothermal kinetics and live in
|
|
14
|
+
:class:`DiffusionalPhaseTransformation`; athermal products (martensite)
|
|
15
|
+
implement :meth:`get_transformed_fraction` directly.
|
|
16
|
+
|
|
17
|
+
Attributes
|
|
18
|
+
----------
|
|
19
|
+
name : str
|
|
20
|
+
Identifier of the phase. It is also the column key used in the result
|
|
21
|
+
frames produced by the solver and the colour key used by the diagram
|
|
22
|
+
utilities, so it must be unique across all registered phases.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
def __init__(self, profile: pr.Profile):
|
|
28
|
+
self.profile: pr.Profile = profile
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_transformed_fraction(
|
|
32
|
+
self, time: np.ndarray, temperature: np.ndarray, n: int = 1000
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Calculate the transformed fraction for a given thermal cycle ``T(t)``.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
time : np.ndarray
|
|
40
|
+
Time in seconds.
|
|
41
|
+
temperature : np.ndarray
|
|
42
|
+
Temperatures at the instants of ``time`` in Kelvin.
|
|
43
|
+
n : int, optional
|
|
44
|
+
Number of points at which the transformed fraction is evaluated.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
time, temperature, fraction : tuple of np.ndarray
|
|
49
|
+
The (possibly resampled) thermal cycle and the transformed phase
|
|
50
|
+
fraction evaluated along it.
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pyroll.core as pr
|
|
6
|
+
|
|
7
|
+
from .base import PhaseTransformation
|
|
8
|
+
from ..sigmoidal_functions import S
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DiffusionalPhaseTransformation(PhaseTransformation):
|
|
12
|
+
"""
|
|
13
|
+
Base class for diffusional (nucleation-and-growth) phase transformations.
|
|
14
|
+
|
|
15
|
+
Implements the modified Kirkaldy-Venugopalan kinetics as parametrised by
|
|
16
|
+
Li et al. (1998). The isothermal time to reach a transformed fraction
|
|
17
|
+
``X`` at temperature ``T`` is
|
|
18
|
+
|
|
19
|
+
.. math::
|
|
20
|
+
|
|
21
|
+
\\tau(X, T) = S(X) \\cdot
|
|
22
|
+
\\frac{C}{2^{g\\,G}\\,\\Delta T^{n}\\,\\exp(-Q / R T)}
|
|
23
|
+
|
|
24
|
+
where :math:`C` is the composition factor, :math:`g` the grain-size
|
|
25
|
+
coefficient, :math:`G` the ASTM grain-size number, :math:`\\Delta T` the
|
|
26
|
+
undercooling below the transformation-start temperature, :math:`n` the
|
|
27
|
+
diffusion exponent, :math:`Q` the activation energy and :math:`S(X)` the
|
|
28
|
+
sigmoidal reaction-rate term.
|
|
29
|
+
|
|
30
|
+
Concrete subclasses (ferrite, pearlite, bainite) only declare the
|
|
31
|
+
composition factor, the grain-size and diffusion coefficients and the
|
|
32
|
+
start/finish temperatures.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def composition_factor(self):
|
|
38
|
+
"""Composition factor for calculating the transformation time."""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def transformation_start_temperature(self):
|
|
44
|
+
"""Start temperature for the phase transformation."""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def transformation_finish_temperature(self):
|
|
50
|
+
"""Finish temperature for the phase transformation."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def grain_size_coefficient(self):
|
|
56
|
+
"""Empirical constant representing the influence of grain size."""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def diffusion_coefficient(self):
|
|
62
|
+
"""
|
|
63
|
+
Exponent of the undercooling set by the effective diffusion mechanism
|
|
64
|
+
(``2`` for volume and ``3`` for boundary diffusion).
|
|
65
|
+
"""
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def activation_energy(self):
|
|
70
|
+
"""Activation energy of the diffusional reaction in J/mol."""
|
|
71
|
+
return 27500 * 4.184
|
|
72
|
+
|
|
73
|
+
def get_transformation_factor(self, temperature: np.ndarray):
|
|
74
|
+
"""
|
|
75
|
+
Calculate the transformation factor for a given temperature.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
temperature : np.ndarray
|
|
80
|
+
Temperature array in Kelvin.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
transformation_factor : np.ndarray
|
|
85
|
+
Transformation factor with the same shape as ``temperature``.
|
|
86
|
+
"""
|
|
87
|
+
undercooling = self.transformation_start_temperature - temperature
|
|
88
|
+
|
|
89
|
+
return self.composition_factor / (
|
|
90
|
+
2 ** (self.grain_size_coefficient * self.profile.astm_grain_size_number)
|
|
91
|
+
* undercooling**self.diffusion_coefficient
|
|
92
|
+
* np.exp(
|
|
93
|
+
-self.activation_energy
|
|
94
|
+
/ (pr.Config.UNIVERSAL_GAS_CONSTANT * temperature)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def get_transformation_time(
|
|
99
|
+
self, temperature: np.ndarray, transformed_fraction: np.ndarray
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Calculate the time necessary for the material to reach a given
|
|
103
|
+
transformed fraction at a temperature.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
temperature : np.ndarray
|
|
108
|
+
Temperature in Kelvin.
|
|
109
|
+
transformed_fraction : np.ndarray
|
|
110
|
+
Transformed fraction with the same shape as ``temperature``.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
transformation_time : np.ndarray
|
|
115
|
+
Transformation time with the same shape as ``temperature``.
|
|
116
|
+
"""
|
|
117
|
+
return S(transformed_fraction) * self.get_transformation_factor(temperature)
|
|
118
|
+
|
|
119
|
+
def get_transformation_temperature(
|
|
120
|
+
self,
|
|
121
|
+
initial_temperature: float,
|
|
122
|
+
final_temperature: float,
|
|
123
|
+
cooling_rate: Union[float, np.ndarray],
|
|
124
|
+
transformed_fraction: Union[float, np.ndarray],
|
|
125
|
+
temperature_increment: Union[float, int] = 1,
|
|
126
|
+
):
|
|
127
|
+
"""
|
|
128
|
+
Calculate the temperature at which a transformed fraction is reached
|
|
129
|
+
during continuous cooling, using Scheil's additivity rule.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
initial_temperature : float
|
|
134
|
+
Initial temperature in Kelvin.
|
|
135
|
+
final_temperature : float
|
|
136
|
+
Final temperature in Kelvin.
|
|
137
|
+
cooling_rate : float or np.ndarray
|
|
138
|
+
Cooling rate(s). May be provided as an array.
|
|
139
|
+
transformed_fraction : float or np.ndarray
|
|
140
|
+
Transformed fraction. If iterable it must have the same shape as
|
|
141
|
+
``cooling_rate``.
|
|
142
|
+
temperature_increment : int or float, optional
|
|
143
|
+
Increment for the temperature integration grid.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
transformation_temperature : float or np.ndarray
|
|
148
|
+
Transformation temperature with the same shape as ``cooling_rate``.
|
|
149
|
+
"""
|
|
150
|
+
time_increment = temperature_increment / np.array(cooling_rate)
|
|
151
|
+
number_of_cooling_rates = (
|
|
152
|
+
np.size(time_increment) if np.ndim(time_increment) > 0 else 1
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
temperature_profile = np.arange(
|
|
156
|
+
initial_temperature, final_temperature, -temperature_increment
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Scheil additivity: accumulate dt / transformation_factor over the
|
|
160
|
+
# cooling path and compare against S(f). The transformation reaches the
|
|
161
|
+
# fraction f once the accumulated sum exceeds S(f).
|
|
162
|
+
nucleation_time = np.zeros(
|
|
163
|
+
(number_of_cooling_rates, len(temperature_profile)), dtype=float
|
|
164
|
+
)
|
|
165
|
+
below_transformation_start_temperature = (
|
|
166
|
+
temperature_profile < self.transformation_start_temperature
|
|
167
|
+
)
|
|
168
|
+
nucleation_time[:, below_transformation_start_temperature] = np.outer(
|
|
169
|
+
time_increment,
|
|
170
|
+
1
|
|
171
|
+
/ self.get_transformation_factor(
|
|
172
|
+
temperature_profile[below_transformation_start_temperature]
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
nucleation_time = nucleation_time.cumsum(axis=1)
|
|
176
|
+
|
|
177
|
+
transformation_temperature = np.full(
|
|
178
|
+
number_of_cooling_rates, np.nan, dtype=float
|
|
179
|
+
)
|
|
180
|
+
sigmoidal_of_transformed_fraction = S(transformed_fraction)
|
|
181
|
+
for i, accumulated_time in enumerate(nucleation_time):
|
|
182
|
+
(reached,) = np.where(
|
|
183
|
+
accumulated_time >= sigmoidal_of_transformed_fraction
|
|
184
|
+
)
|
|
185
|
+
if len(reached) > 0:
|
|
186
|
+
transformation_temperature[i] = temperature_profile[reached[0]]
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
float(transformation_temperature[0])
|
|
190
|
+
if number_of_cooling_rates == 1
|
|
191
|
+
else transformation_temperature
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def get_transformed_fraction(
|
|
195
|
+
self, time: np.ndarray, temperature: np.ndarray, n: int = 1000
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Calculate the transformed fraction for a given thermal cycle ``T(t)``.
|
|
199
|
+
|
|
200
|
+
The transformation is only active between the start and finish
|
|
201
|
+
temperatures of the phase. The accumulated nucleation time is built up
|
|
202
|
+
over the cooling path and inverted through ``S`` to obtain the
|
|
203
|
+
fraction (clamped to ``[0, 1]`` outside the bounds of ``S``).
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
time : np.ndarray
|
|
208
|
+
Time in seconds.
|
|
209
|
+
temperature : np.ndarray
|
|
210
|
+
Temperatures at the instants of ``time`` in Kelvin.
|
|
211
|
+
n : int, optional
|
|
212
|
+
Number of points used to derive the integration time increment.
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
time, temperature, fraction : tuple of np.ndarray
|
|
217
|
+
The thermal cycle and the transformed phase fraction along it.
|
|
218
|
+
"""
|
|
219
|
+
time_increment = (max(time) - min(time)) / (n - 1)
|
|
220
|
+
nucleation_time = np.zeros(time.shape, dtype=float)
|
|
221
|
+
phase_fraction = np.zeros(temperature.shape, dtype=float)
|
|
222
|
+
|
|
223
|
+
temperatures_in_phase_range = (
|
|
224
|
+
temperature < self.transformation_start_temperature
|
|
225
|
+
) & (temperature > self.transformation_finish_temperature)
|
|
226
|
+
|
|
227
|
+
if not np.any(temperatures_in_phase_range):
|
|
228
|
+
return time, temperature, phase_fraction
|
|
229
|
+
|
|
230
|
+
nucleation_time[temperatures_in_phase_range] = (
|
|
231
|
+
time_increment
|
|
232
|
+
/ self.get_transformation_factor(temperature[temperatures_in_phase_range])
|
|
233
|
+
)
|
|
234
|
+
nucleation_time = nucleation_time.cumsum()
|
|
235
|
+
if temperature[0] < self.transformation_start_temperature:
|
|
236
|
+
# Account for the fraction already transformed at time[0].
|
|
237
|
+
nucleation_time += min(time) / self.get_transformation_factor(
|
|
238
|
+
temperature[0]
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Invert S only where the accumulated time lies within its range;
|
|
242
|
+
# clamp to the fully-untransformed / fully-transformed limits outside.
|
|
243
|
+
within_bounds = (nucleation_time >= S.ymin) & (nucleation_time <= S.ymax)
|
|
244
|
+
if np.any(within_bounds):
|
|
245
|
+
phase_fraction[within_bounds] = S.inverse(nucleation_time[within_bounds])
|
|
246
|
+
phase_fraction[nucleation_time > S.ymax] = 1
|
|
247
|
+
|
|
248
|
+
return time, temperature, phase_fraction
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .diffusional_phase_transformation import DiffusionalPhaseTransformation
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Ferrite(DiffusionalPhaseTransformation):
|
|
5
|
+
"""
|
|
6
|
+
Austenite to ferrite phase transformation
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
name = "ferrite"
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def grain_size_coefficient(self):
|
|
13
|
+
return 0.41
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def diffusion_coefficient(self):
|
|
17
|
+
return 3
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def transformation_start_temperature(self):
|
|
21
|
+
return self.profile.ae3_temperature
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def transformation_finish_temperature(self):
|
|
25
|
+
return self.profile.bainite_start_temperature
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def composition_factor(self):
|
|
29
|
+
return self.profile.ferrite_kinetics
|