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.
Files changed (22) hide show
  1. pyroll/phase_transformation_model/__init__.py +43 -0
  2. pyroll/phase_transformation_model/microstructure.py +275 -0
  3. pyroll/phase_transformation_model/phase_transformations/__init__.py +6 -0
  4. pyroll/phase_transformation_model/phase_transformations/bainite.py +29 -0
  5. pyroll/phase_transformation_model/phase_transformations/base.py +52 -0
  6. pyroll/phase_transformation_model/phase_transformations/diffusional_phase_transformation.py +248 -0
  7. pyroll/phase_transformation_model/phase_transformations/ferrite.py +29 -0
  8. pyroll/phase_transformation_model/phase_transformations/martensite.py +51 -0
  9. pyroll/phase_transformation_model/phase_transformations/perlite.py +29 -0
  10. pyroll/phase_transformation_model/profile.py +165 -0
  11. pyroll/phase_transformation_model/sigmoidal_functions/__init__.py +3 -0
  12. pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_base.py +89 -0
  13. pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_i.py +18 -0
  14. pyroll/phase_transformation_model/sigmoidal_functions/sigmoidal_function_s.py +18 -0
  15. pyroll/phase_transformation_model/transformation/__init__.py +8 -0
  16. pyroll/phase_transformation_model/transformation/hardness.py +44 -0
  17. pyroll/phase_transformation_model/transformation/mechanical_properties.py +65 -0
  18. pyroll/phase_transformation_model/transformation/transformation_solver.py +341 -0
  19. pyroll_phase_transformation_model-3.1.0.dist-info/METADATA +32 -0
  20. pyroll_phase_transformation_model-3.1.0.dist-info/RECORD +22 -0
  21. pyroll_phase_transformation_model-3.1.0.dist-info/WHEEL +4 -0
  22. 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,6 @@
1
+ from .base import PhaseTransformation
2
+ from .diffusional_phase_transformation import DiffusionalPhaseTransformation
3
+ from .bainite import Bainite
4
+ from .ferrite import Ferrite
5
+ from .perlite import Perlite
6
+ from .martensite import Martensite
@@ -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