Source code for ta.temperature_program
"""Multi-segment temperature programs for thermal-analysis experiments.
A :class:`TemperatureProgram` describes the furnace temperature as a
piecewise-linear function of time, built from a sequence of
:class:`TemperatureSegment` objects (ramps, holds, and cooling segments).
"""
from __future__ import annotations
import bisect
from dataclasses import dataclass
[docs]
@dataclass(frozen=True)
class TemperatureSegment:
"""One segment of a temperature program.
Parameters
----------
rate_C_per_min : float
Heating rate [deg C / min]. Positive = heating, negative =
cooling, zero = isothermal hold.
T_target_C : float
Target temperature [deg C] for ramp segments. For isothermal
holds this should equal the current temperature (it is stored
for documentation but not used to compute the profile).
hold_min : float
Duration of an isothermal hold [min]. Only used when
*rate_C_per_min* is zero.
"""
rate_C_per_min: float
T_target_C: float
hold_min: float = 0.0
[docs]
class TemperatureProgram:
"""Piecewise-linear furnace temperature profile.
The program starts at *T_initial_C* and executes each segment in
order. Ramp segments heat or cool at a constant rate until the
target temperature is reached; hold segments keep the temperature
constant for a specified duration.
Parameters
----------
T_initial_C : float
Starting temperature [deg C].
segments : list[TemperatureSegment]
Ordered list of program segments.
Examples
--------
Ramp from 50 to 200 at 10 C/min, hold 30 min, ramp to 600 at 5 C/min:
>>> prog = TemperatureProgram(50.0, [
... TemperatureSegment(10.0, 200.0),
... TemperatureSegment(0.0, 200.0, hold_min=30.0),
... TemperatureSegment(5.0, 600.0),
... ])
"""
def __init__(
self,
T_initial_C: float,
segments: list[TemperatureSegment],
) -> None:
if not segments:
raise ValueError("At least one segment is required")
self._T_initial_C = T_initial_C
self._segments = list(segments)
# Pre-compute boundary arrays for fast lookup.
# _t_bounds[i] = cumulative time [s] at the START of segment i
# _T_bounds_K[i] = temperature [K] at the START of segment i
# _rates_K_per_s[i] = rate [K/s] during segment i (0 for holds)
t_bounds: list[float] = [0.0]
T_bounds_K: list[float] = [T_initial_C + 273.15]
rates_K_per_s: list[float] = []
T_cur_K = T_initial_C + 273.15
t_cur = 0.0
for i, seg in enumerate(segments):
if seg.rate_C_per_min == 0.0:
# Isothermal hold
if seg.hold_min <= 0:
raise ValueError(
f"Segment {i}: isothermal hold requires hold_min > 0"
)
duration_s = seg.hold_min * 60.0
rates_K_per_s.append(0.0)
t_cur += duration_s
# Temperature unchanged
else:
# Ramp (heating or cooling)
T_target_K = seg.T_target_C + 273.15
direction = T_target_K - T_cur_K
if abs(direction) < 1e-10:
raise ValueError(
f"Segment {i}: T_target equals current temperature; "
f"use rate_C_per_min=0 for an isothermal hold"
)
if direction > 0 and seg.rate_C_per_min < 0:
raise ValueError(
f"Segment {i}: rate is negative but "
f"T_target ({seg.T_target_C}) > T_current "
f"({T_cur_K - 273.15:.2f})"
)
if direction < 0 and seg.rate_C_per_min > 0:
raise ValueError(
f"Segment {i}: rate is positive but "
f"T_target ({seg.T_target_C}) < T_current "
f"({T_cur_K - 273.15:.2f})"
)
rate_K_per_s = seg.rate_C_per_min / 60.0
duration_s = abs(direction) / abs(rate_K_per_s)
rates_K_per_s.append(rate_K_per_s)
t_cur += duration_s
T_cur_K = T_target_K
t_bounds.append(t_cur)
T_bounds_K.append(T_cur_K)
self._t_bounds = t_bounds
self._T_bounds_K = T_bounds_K
self._rates_K_per_s = rates_K_per_s
self._total_time_s = t_cur
# ------------------------------------------------------------------
# Factories
# ------------------------------------------------------------------
[docs]
@classmethod
def single_ramp(
cls,
T_initial_C: float,
T_final_C: float,
rate_C_per_min: float,
) -> TemperatureProgram:
"""Create a program consisting of a single linear ramp.
This reproduces the legacy ``TASimulator`` behaviour.
"""
return cls(T_initial_C, [TemperatureSegment(rate_C_per_min, T_final_C)])
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def T_initial_C(self) -> float:
"""Starting temperature [deg C]."""
return self._T_initial_C
@property
def T_final_C(self) -> float:
"""Temperature at the end of the last segment [deg C]."""
return self._T_bounds_K[-1] - 273.15
@property
def total_time_s(self) -> float:
"""Total duration of the program [s]."""
return self._total_time_s
@property
def segments(self) -> list[TemperatureSegment]:
"""Copy of the segment list."""
return list(self._segments)
@property
def segment_boundaries(self) -> list[tuple[float, float]]:
"""``(time_s, temperature_C)`` at each segment boundary.
Includes the initial point and the end of every segment,
so the length is ``len(segments) + 1``.
"""
return [
(t, T_K - 273.15)
for t, T_K in zip(self._t_bounds, self._T_bounds_K)
]
# ------------------------------------------------------------------
# Temperature lookup
# ------------------------------------------------------------------
[docs]
def T_furnace_K(self, t: float) -> float:
"""Furnace temperature [K] at time *t* [s]."""
if t <= 0.0:
return self._T_bounds_K[0]
if t >= self._total_time_s:
return self._T_bounds_K[-1]
idx = bisect.bisect_right(self._t_bounds, t) - 1
idx = max(0, min(idx, len(self._segments) - 1))
t_seg = t - self._t_bounds[idx]
return self._T_bounds_K[idx] + self._rates_K_per_s[idx] * t_seg
[docs]
def T_furnace_C(self, t: float) -> float:
"""Furnace temperature [deg C] at time *t* [s]."""
return self.T_furnace_K(t) - 273.15