triplot 1.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.
- dscpanel/__init__.py +4 -0
- dscpanel/__main__.py +155 -0
- dscpanel/branding.py +142 -0
- dscpanel/core/__init__.py +0 -0
- dscpanel/core/arrange.py +143 -0
- dscpanel/core/chem.py +252 -0
- dscpanel/core/dtg.py +135 -0
- dscpanel/core/export.py +649 -0
- dscpanel/core/figure.py +171 -0
- dscpanel/core/labels.py +430 -0
- dscpanel/core/loader.py +202 -0
- dscpanel/core/log.py +147 -0
- dscpanel/core/measure.py +636 -0
- dscpanel/core/model.py +1993 -0
- dscpanel/core/molar.py +344 -0
- dscpanel/core/numbers.py +208 -0
- dscpanel/core/ops.py +161 -0
- dscpanel/core/presets.py +312 -0
- dscpanel/core/profile.py +15 -0
- dscpanel/core/session.py +667 -0
- dscpanel/core/shades.py +54 -0
- dscpanel/core/style.py +528 -0
- dscpanel/core/trios_analysis.py +636 -0
- dscpanel/core/trios_io.py +1311 -0
- dscpanel/core/undo.py +230 -0
- dscpanel/core/units.py +220 -0
- dscpanel/register.py +284 -0
- dscpanel/ui/__init__.py +0 -0
- dscpanel/ui/appearance.py +146 -0
- dscpanel/ui/colour.py +629 -0
- dscpanel/ui/dialogs.py +3639 -0
- dscpanel/ui/loading.py +95 -0
- dscpanel/ui/numbox.py +103 -0
- dscpanel/ui/outliner.py +818 -0
- dscpanel/ui/palette.py +193 -0
- dscpanel/ui/plot.py +8349 -0
- dscpanel/ui/settings.py +256 -0
- dscpanel/ui/window.py +4129 -0
- triplot-1.1.0.dist-info/METADATA +315 -0
- triplot-1.1.0.dist-info/RECORD +44 -0
- triplot-1.1.0.dist-info/WHEEL +5 -0
- triplot-1.1.0.dist-info/entry_points.txt +5 -0
- triplot-1.1.0.dist-info/licenses/LICENSE +22 -0
- triplot-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# The TRIOS analyses, recomputed in Python. The format is docs/TRI-FORMAT.md;
|
|
2
|
+
# fix them in this file.
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
trios_analysis.py -- the TRIOS analyses, recomputed in Python.
|
|
6
|
+
|
|
7
|
+
Every routine here takes plain arrays and returns a dict of results, so a
|
|
8
|
+
figure can be annotated without going through the TRIOS software at all. Each
|
|
9
|
+
one is validated against TRIOS's own stored numbers in ``validate()`` at the
|
|
10
|
+
bottom -- run this file to see the comparison.
|
|
11
|
+
|
|
12
|
+
Conventions, and the one thing that is easy to get wrong
|
|
13
|
+
--------------------------------------------------------
|
|
14
|
+
``t`` is time in minutes, ``T`` temperature in °C, ``Q`` the normalized heat
|
|
15
|
+
flow in W/g (i.e. J s^-1 g^-1). Endothermic-up or exothermic-up only changes
|
|
16
|
+
signs, never the algebra.
|
|
17
|
+
|
|
18
|
+
**Peak Integration integrates over TIME; Area under the curve integrates over
|
|
19
|
+
TEMPERATURE.** That asymmetry is real and is visible in TRIOS's own units:
|
|
20
|
+
|
|
21
|
+
Enthalpy J/g = ∫ Q dt (W/g · s)
|
|
22
|
+
Area under the curve °C·W/g = ∫ Q dT
|
|
23
|
+
|
|
24
|
+
Swapping them yields a number that looks entirely plausible and is wrong by a
|
|
25
|
+
factor of the heating rate. Time must be in *seconds* for the enthalpy, so the
|
|
26
|
+
minute-based ``t`` is scaled inside the function.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
# helpers
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
def _trapz(y, x):
|
|
37
|
+
fn = getattr(np, 'trapezoid', None) or np.trapz
|
|
38
|
+
return float(fn(y, x))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _window(T, x0, x1):
|
|
42
|
+
"""Boolean mask for the cursor window [x0, x1], order-independent."""
|
|
43
|
+
lo, hi = (x0, x1) if x0 <= x1 else (x1, x0)
|
|
44
|
+
return (T >= lo) & (T <= hi)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _at(T, Q, x):
|
|
48
|
+
"""Q interpolated at temperature x (T need not be sorted)."""
|
|
49
|
+
o = np.argsort(T)
|
|
50
|
+
return float(np.interp(x, np.asarray(T)[o], np.asarray(Q)[o]))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _smooth(y, k):
|
|
54
|
+
"""Centred moving average, edges left alone."""
|
|
55
|
+
k = max(3, int(k) | 1)
|
|
56
|
+
if y.size < 3 * k:
|
|
57
|
+
return y.astype(float)
|
|
58
|
+
out = np.convolve(y, np.ones(k) / k, mode='same')
|
|
59
|
+
out[:k] = y[:k]
|
|
60
|
+
out[-k:] = y[-k:]
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tangent(T, Q, idx, half):
|
|
65
|
+
"""Least-squares tangent (slope, intercept) around sample ``idx``.
|
|
66
|
+
|
|
67
|
+
A local fit rather than a two-point difference: DSC traces are noisy enough
|
|
68
|
+
that a difference quotient wanders by several K when it is extrapolated far
|
|
69
|
+
enough to intersect another tangent."""
|
|
70
|
+
i0, i1 = max(0, idx - half), min(len(T), idx + half + 1)
|
|
71
|
+
if i1 - i0 < 3:
|
|
72
|
+
return None
|
|
73
|
+
m, b = np.polyfit(T[i0:i1], Q[i0:i1], 1)
|
|
74
|
+
return float(m), float(b)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _inflection(T, Q, half):
|
|
78
|
+
"""Index of the steepest point (|dQ/dT| maximum), away from the edges."""
|
|
79
|
+
if len(T) < 2 * half + 5:
|
|
80
|
+
return len(T) // 2
|
|
81
|
+
d = np.gradient(_smooth(Q, max(3, len(T) // 40)), T)
|
|
82
|
+
inner = slice(half, len(T) - half)
|
|
83
|
+
return int(np.argmax(np.abs(d[inner]))) + half
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cross(l1, l2):
|
|
87
|
+
"""x where two lines (slope, intercept) meet."""
|
|
88
|
+
(m1, b1), (m2, b2) = l1, l2
|
|
89
|
+
if abs(m1 - m2) < 1e-15:
|
|
90
|
+
return float('nan')
|
|
91
|
+
return float((b2 - b1) / (m1 - m2))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _on(line, x):
|
|
95
|
+
"""The line (slope, intercept) at x."""
|
|
96
|
+
return float(line[0] * x + line[1])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _onset_construction(T, Q, l_cur, l_inf, x_cursor, x_transition, x,
|
|
100
|
+
kind='onset'):
|
|
101
|
+
"""The construction in TRIOS's own geometry: three [x, y] points.
|
|
102
|
+
|
|
103
|
+
As a .tri stores it (TRI-FORMAT.md section 5, checked on every onset
|
|
104
|
+
and endset record on the development machine): the FLAT point, at the
|
|
105
|
+
flat cursor on the baseline tangent; the INTERSECTION, which is the
|
|
106
|
+
result; and the FAR point, where the inflection tangent reaches the
|
|
107
|
+
curve's height at the transition cursor. An onset lists them flat,
|
|
108
|
+
intersection, far; an endset far, intersection, flat - mirrored, as
|
|
109
|
+
TRIOS stores it. P0-P1 and P1-P2 are then the two tangents.
|
|
110
|
+
|
|
111
|
+
An inflection tangent too flat to reach that height inside one cursor
|
|
112
|
+
separation of the transition cursor ends AT the transition cursor
|
|
113
|
+
instead: a far point a long way off is not a construction anybody drew.
|
|
114
|
+
"""
|
|
115
|
+
flat = [float(x_cursor), _on(l_cur, x_cursor)]
|
|
116
|
+
cross = [float(x), _on(l_cur, x)]
|
|
117
|
+
level = _at(T, Q, x_transition)
|
|
118
|
+
slope, intercept = l_inf
|
|
119
|
+
far_x = float('nan')
|
|
120
|
+
if slope != 0.0:
|
|
121
|
+
far_x = (level - intercept) / slope
|
|
122
|
+
reach = abs(float(x_transition) - float(x_cursor))
|
|
123
|
+
if np.isfinite(far_x) and abs(far_x - x_transition) <= reach:
|
|
124
|
+
far = [float(far_x), float(level)]
|
|
125
|
+
else:
|
|
126
|
+
far = [float(x_transition), _on(l_inf, x_transition)]
|
|
127
|
+
return [far, cross, flat] if kind == 'endset' else [flat, cross, far]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Tier 1 -- the ones you actually quote in a paper
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
def peak_integration(t, T, Q, x0, x1, baseline='linear'):
|
|
134
|
+
"""Enthalpy of a peak between two cursors -- TRIOS "Peak Integration".
|
|
135
|
+
|
|
136
|
+
A straight baseline is drawn between the curve values at the two cursors
|
|
137
|
+
and the enclosed area is integrated **over time**:
|
|
138
|
+
|
|
139
|
+
ΔH [J/g] = ∫ (Q − Q_base) dt with t in seconds
|
|
140
|
+
|
|
141
|
+
Returns enthalpy (J/g, signed), |enthalpy|, peak temperature (the extremum
|
|
142
|
+
of the baseline-corrected signal) and the baseline endpoints.
|
|
143
|
+
"""
|
|
144
|
+
t, T, Q = map(np.asarray, (t, T, Q))
|
|
145
|
+
m = _window(T, x0, x1)
|
|
146
|
+
if m.sum() < 3:
|
|
147
|
+
return {}
|
|
148
|
+
ts, Ts, Qs = t[m] * 60.0, T[m], Q[m] # minutes -> seconds
|
|
149
|
+
if baseline == 'linear':
|
|
150
|
+
base = np.interp(ts, [ts[0], ts[-1]], [Qs[0], Qs[-1]])
|
|
151
|
+
else:
|
|
152
|
+
base = np.zeros_like(Qs)
|
|
153
|
+
corr = Qs - base
|
|
154
|
+
H = _trapz(corr, ts)
|
|
155
|
+
k = int(np.argmax(np.abs(corr)))
|
|
156
|
+
return {'Enthalpy (normalized)': H,
|
|
157
|
+
'Enthalpy |.|': abs(H),
|
|
158
|
+
'Peak temperature': float(Ts[k]),
|
|
159
|
+
'Baseline cursor x': (float(x0), float(x1)),
|
|
160
|
+
'baseline': (float(Qs[0]), float(Qs[-1]))}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def onset_point(T, Q, x_cursor, x_transition, kind='onset',
|
|
164
|
+
tangent_frac=0.10, smooth_frac=1 / 40, pad_frac=0.15):
|
|
165
|
+
"""Tangent-construction onset (or endset) -- TRIOS "Onset/Endset point".
|
|
166
|
+
|
|
167
|
+
Intersects the tangent taken at the cursor (the flat side) with the tangent
|
|
168
|
+
at the inflection of the transition:
|
|
169
|
+
|
|
170
|
+
T_onset = (b_infl − b_cur) / (m_cur − m_infl)
|
|
171
|
+
|
|
172
|
+
The FLAT side is whichever cursor comes first: ``x_cursor``. For an
|
|
173
|
+
onset that is the cursor met first in acquisition order, for an endset
|
|
174
|
+
the one met last - on a cooling scan the high one and the low one. The
|
|
175
|
+
caller decides; ``kind`` only changes the reported key and the order of
|
|
176
|
+
``construction``, TRIOS's three points (see ``_onset_construction``).
|
|
177
|
+
|
|
178
|
+
The construction is the standard one (ASTM E537/E793; ISO 11357-2 for a
|
|
179
|
+
glass transition). What a standard leaves open -- and what every vendor
|
|
180
|
+
fixes silently -- are the three numbers below. They are arguments here so
|
|
181
|
+
that a reported onset can state them:
|
|
182
|
+
|
|
183
|
+
tangent_frac : half-width of each least-squares tangent fit, as a fraction
|
|
184
|
+
of the cursor separation. 0.10 means each tangent is fitted
|
|
185
|
+
over +/-10 % of the window around its anchor point.
|
|
186
|
+
smooth_frac : width of the centred moving average applied before
|
|
187
|
+
differentiating, as a fraction of the number of points in
|
|
188
|
+
the window. 1/40 by default.
|
|
189
|
+
pad_frac : how far outside the cursors data is included, so a tangent
|
|
190
|
+
fitted at a cursor is not truncated by the window edge.
|
|
191
|
+
|
|
192
|
+
Accuracy against TRIOS with these defaults is ~0.2-0.5 K on a clean step
|
|
193
|
+
(worse on a broad, shallow one). Do not use it to decide *which* scan an
|
|
194
|
+
analysis belongs to -- repeat scans differ by less than that.
|
|
195
|
+
"""
|
|
196
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
197
|
+
lo, hi = min(x_cursor, x_transition), max(x_cursor, x_transition)
|
|
198
|
+
span = hi - lo
|
|
199
|
+
m = _window(T, lo - pad_frac * span, hi + pad_frac * span)
|
|
200
|
+
if m.sum() < 12:
|
|
201
|
+
return {}
|
|
202
|
+
o = np.argsort(T[m])
|
|
203
|
+
Tw, Qw = T[m][o], Q[m][o]
|
|
204
|
+
Qs = _smooth(Qw, max(3, int(len(Tw) * smooth_frac)))
|
|
205
|
+
step = max(np.median(np.diff(Tw)), 1e-9)
|
|
206
|
+
half = max(2, int(tangent_frac * span / step))
|
|
207
|
+
|
|
208
|
+
i_cur = int(np.argmin(np.abs(Tw - x_cursor)))
|
|
209
|
+
inner = np.where((Tw >= lo) & (Tw <= hi))[0]
|
|
210
|
+
if inner.size < 5:
|
|
211
|
+
return {}
|
|
212
|
+
i_inf = inner[0] + _inflection(Tw[inner], Qs[inner], min(half, inner.size // 4))
|
|
213
|
+
l_cur, l_inf = _tangent(Tw, Qs, i_cur, half), _tangent(Tw, Qs, i_inf, half)
|
|
214
|
+
if not l_cur or not l_inf:
|
|
215
|
+
return {}
|
|
216
|
+
x = _cross(l_cur, l_inf)
|
|
217
|
+
key = 'Endset x' if kind == 'endset' else 'Onset x'
|
|
218
|
+
out = {key: x, 'tangent_cursor': l_cur, 'tangent_inflection': l_inf,
|
|
219
|
+
'inflection T': float(Tw[i_inf]),
|
|
220
|
+
'params': {'tangent_frac': tangent_frac,
|
|
221
|
+
'smooth_frac': smooth_frac, 'pad_frac': pad_frac}}
|
|
222
|
+
if np.isfinite(x):
|
|
223
|
+
out['construction'] = _onset_construction(
|
|
224
|
+
Tw, Qw, l_cur, l_inf, x_cursor, x_transition, x, kind)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def glass_transition(T, Q, x_onset, x_end, midpoint='half height',
|
|
229
|
+
tangent_frac=0.10, smooth_frac=1 / 40,
|
|
230
|
+
pad_frac=0.20):
|
|
231
|
+
"""Tg by the three-tangent construction -- TRIOS "Glass transition".
|
|
232
|
+
|
|
233
|
+
Tangents at the two cursors bracket the step; the inflection tangent runs
|
|
234
|
+
through the steepest point between them. Onset and end are the two
|
|
235
|
+
intersections, and the midpoint is where the curve reaches half the step
|
|
236
|
+
height measured between the extrapolated cursor tangents.
|
|
237
|
+
|
|
238
|
+
Returns onset / midpoint / end temperatures and the step height ΔCp-like
|
|
239
|
+
change in W/g (divide by the heating rate in K/s for a true ΔCp).
|
|
240
|
+
|
|
241
|
+
``x_onset`` is the cursor met FIRST in acquisition order and ``x_end``
|
|
242
|
+
the one met last, so on a cooling scan the onset is the high side: the
|
|
243
|
+
onset is where the tangent at ``x_onset`` meets the inflection tangent,
|
|
244
|
+
and the step height is measured from the onset's baseline to the end's.
|
|
245
|
+
On a heating scan (the only kind a TRIOS Tg on the development machine
|
|
246
|
+
was made on) that is the lower cursor first, as before.
|
|
247
|
+
|
|
248
|
+
``construction`` is TRIOS's own four points (TRI-FORMAT.md section 5):
|
|
249
|
+
the onset cursor on its tangent, the onset, the end, the end cursor on
|
|
250
|
+
its tangent - so the three tangents are P0-P1, P1-P2 and P2-P3.
|
|
251
|
+
"""
|
|
252
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
253
|
+
lo, hi = min(x_onset, x_end), max(x_onset, x_end)
|
|
254
|
+
span = hi - lo
|
|
255
|
+
m = _window(T, lo - pad_frac * span, hi + pad_frac * span)
|
|
256
|
+
if m.sum() < 12:
|
|
257
|
+
return {}
|
|
258
|
+
o = np.argsort(T[m])
|
|
259
|
+
Tw, Qw = T[m][o], Q[m][o]
|
|
260
|
+
Qs = _smooth(Qw, max(3, int(len(Tw) * smooth_frac)))
|
|
261
|
+
step = max(np.median(np.diff(Tw)), 1e-9)
|
|
262
|
+
half = max(2, int(tangent_frac * span / step))
|
|
263
|
+
|
|
264
|
+
i_on = int(np.argmin(np.abs(Tw - x_onset)))
|
|
265
|
+
i_end = int(np.argmin(np.abs(Tw - x_end)))
|
|
266
|
+
inner = np.where((Tw >= lo) & (Tw <= hi))[0]
|
|
267
|
+
i_inf = inner[0] + _inflection(Tw[inner], Qs[inner], min(half, inner.size // 4))
|
|
268
|
+
|
|
269
|
+
l_on, l_end = _tangent(Tw, Qs, i_on, half), _tangent(Tw, Qs, i_end, half)
|
|
270
|
+
l_in = _tangent(Tw, Qs, i_inf, half)
|
|
271
|
+
if not (l_on and l_end and l_in):
|
|
272
|
+
return {}
|
|
273
|
+
T_on, T_end = _cross(l_on, l_in), _cross(l_end, l_in)
|
|
274
|
+
|
|
275
|
+
# half-height: where the curve sits midway between the two cursor tangents
|
|
276
|
+
yl = l_on[0] * Tw + l_on[1]
|
|
277
|
+
yh = l_end[0] * Tw + l_end[1]
|
|
278
|
+
frac = (Qs - yl) / np.where(np.abs(yh - yl) < 1e-15, np.nan, yh - yl)
|
|
279
|
+
seg = inner
|
|
280
|
+
j = seg[int(np.argmin(np.abs(frac[seg] - 0.5)))]
|
|
281
|
+
out = {'Onset x': T_on, 'Midpoint': float(Tw[j]), 'End x': T_end,
|
|
282
|
+
'Step height': float(yh[j] - yl[j]),
|
|
283
|
+
'Inflection': float(Tw[i_inf]),
|
|
284
|
+
# the construction itself, (slope, intercept) each, for drawing
|
|
285
|
+
'tangent_onset': l_on, 'tangent_end': l_end,
|
|
286
|
+
'tangent_inflection': l_in,
|
|
287
|
+
'params': {'tangent_frac': tangent_frac,
|
|
288
|
+
'smooth_frac': smooth_frac, 'pad_frac': pad_frac,
|
|
289
|
+
'midpoint': midpoint}}
|
|
290
|
+
if np.isfinite(T_on) and np.isfinite(T_end):
|
|
291
|
+
out['construction'] = [
|
|
292
|
+
[float(x_onset), _on(l_on, x_onset)], [T_on, _on(l_in, T_on)],
|
|
293
|
+
[T_end, _on(l_in, T_end)], [float(x_end), _on(l_end, x_end)]]
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def signal_change(T, Q, x0, x1, mass_g=None):
|
|
298
|
+
"""Δy between two cursors -- TRIOS "Signal change".
|
|
299
|
+
|
|
300
|
+
Measured in ACQUISITION order, i.e. later sample minus earlier sample, not
|
|
301
|
+
higher temperature minus lower. On a cooling segment the two differ in
|
|
302
|
+
sign, and TRIOS reports the acquisition-order value: on the reference file
|
|
303
|
+
it gives +0.11798 W/g for a segment whose temperature-ordered difference is
|
|
304
|
+
-0.11796.
|
|
305
|
+
"""
|
|
306
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
307
|
+
m = _window(T, x0, x1)
|
|
308
|
+
if m.sum() < 2:
|
|
309
|
+
return {}
|
|
310
|
+
idx = np.where(m)[0]
|
|
311
|
+
d = float(Q[idx[-1]] - Q[idx[0]])
|
|
312
|
+
out = {'Change (normalized)': d,
|
|
313
|
+
'from': (float(T[idx[0]]), float(Q[idx[0]])),
|
|
314
|
+
'to': (float(T[idx[-1]]), float(Q[idx[-1]]))}
|
|
315
|
+
if mass_g:
|
|
316
|
+
out['Change'] = d * mass_g * 1000.0 # W/g -> mW
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def methods_sentence(result, what='onset'):
|
|
321
|
+
"""A sentence describing exactly how a result was obtained.
|
|
322
|
+
|
|
323
|
+
Meant for a methods section or SI: it states the construction and the fit
|
|
324
|
+
parameters that produced the number, which is the part a reader cannot
|
|
325
|
+
recover from a vendor's software.
|
|
326
|
+
|
|
327
|
+
>>> r = onset_point(T, Q, 53.4, 68.1)
|
|
328
|
+
>>> print(methods_sentence(r))
|
|
329
|
+
"""
|
|
330
|
+
p = result.get('params', {})
|
|
331
|
+
tf = p.get('tangent_frac', 0.10) * 100
|
|
332
|
+
sf = p.get('smooth_frac', 1 / 40)
|
|
333
|
+
if what == 'tg':
|
|
334
|
+
return (
|
|
335
|
+
'The glass transition was determined from the heat-flow curve by '
|
|
336
|
+
'the three-tangent construction (ISO 11357-2). Tangents to the '
|
|
337
|
+
'pre- and post-transition baselines and to the point of inflection '
|
|
338
|
+
'were obtained by least-squares fits over +/-{:.0f} % of the '
|
|
339
|
+
'cursor separation, after smoothing with a centred moving average '
|
|
340
|
+
'of 1/{:.0f} of the points in the evaluation window; the '
|
|
341
|
+
'inflection was taken as the extremum of dQ/dT. The reported Tg is '
|
|
342
|
+
'the half-height midpoint between the extrapolated baselines.'
|
|
343
|
+
).format(tf, 1 / sf)
|
|
344
|
+
return (
|
|
345
|
+
'Onset temperatures were determined as the intersection of the '
|
|
346
|
+
'extrapolated baseline tangent with the tangent at the point of '
|
|
347
|
+
'inflection (cf. ASTM E537/E793). Both tangents were obtained by '
|
|
348
|
+
'least-squares fits over +/-{:.0f} % of the cursor separation, after '
|
|
349
|
+
'smoothing with a centred moving average of 1/{:.0f} of the points in '
|
|
350
|
+
'the evaluation window; the inflection was taken as the extremum of '
|
|
351
|
+
'dQ/dT.'
|
|
352
|
+
).format(tf, 1 / sf)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# --------------------------------------------------------------------------- #
|
|
356
|
+
# Tier 2 -- cheap, occasionally handy
|
|
357
|
+
# --------------------------------------------------------------------------- #
|
|
358
|
+
def peak_height(T, Q, x0, x1):
|
|
359
|
+
"""Largest deviation from the straight baseline joining the two cursors."""
|
|
360
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
361
|
+
m = _window(T, x0, x1)
|
|
362
|
+
if m.sum() < 3:
|
|
363
|
+
return {}
|
|
364
|
+
o = np.argsort(T[m])
|
|
365
|
+
Tw, Qw = T[m][o], Q[m][o]
|
|
366
|
+
base = np.interp(Tw, [Tw[0], Tw[-1]], [Qw[0], Qw[-1]])
|
|
367
|
+
k = int(np.argmax(np.abs(Qw - base)))
|
|
368
|
+
return {'Peak height': float(abs(Qw[k] - base[k])),
|
|
369
|
+
'Peak temperature': float(Tw[k])}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def signal_extremum(T, Q, x0, x1, which='min'):
|
|
373
|
+
"""TRIOS "Signal min" / "Signal max" inside the cursor window."""
|
|
374
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
375
|
+
m = _window(T, x0, x1)
|
|
376
|
+
if m.sum() == 0:
|
|
377
|
+
return {}
|
|
378
|
+
Qw, Tw = Q[m], T[m]
|
|
379
|
+
k = int(np.argmin(Qw) if which == 'min' else np.argmax(Qw))
|
|
380
|
+
return {f'{which.capitalize()} y': float(Qw[k]), 'at T': float(Tw[k])}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def curve_y_at_x(T, Q, x):
|
|
384
|
+
"""TRIOS "Curve Y at X" -- interpolated read-out."""
|
|
385
|
+
return {'X': float(x), 'Y': _at(T, Q, x)}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def curve_x_at_y(T, Q, y, x_hint=None):
|
|
389
|
+
"""TRIOS "Curve X at Y" -- first crossing of the level ``y``.
|
|
390
|
+
|
|
391
|
+
With ``x_hint`` the crossing nearest that temperature is returned, which is
|
|
392
|
+
what the TRIOS cursor does; a DSC trace usually crosses a level many times.
|
|
393
|
+
"""
|
|
394
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
395
|
+
o = np.argsort(T)
|
|
396
|
+
Ts, Qs = T[o], Q[o]
|
|
397
|
+
s = np.sign(Qs - y)
|
|
398
|
+
idx = np.where(np.diff(s) != 0)[0]
|
|
399
|
+
if idx.size == 0:
|
|
400
|
+
return {}
|
|
401
|
+
xs = []
|
|
402
|
+
for i in idx:
|
|
403
|
+
q0, q1 = Qs[i], Qs[i + 1]
|
|
404
|
+
f = 0.0 if q1 == q0 else (y - q0) / (q1 - q0)
|
|
405
|
+
xs.append(Ts[i] + f * (Ts[i + 1] - Ts[i]))
|
|
406
|
+
xs = np.array(xs)
|
|
407
|
+
x = xs[int(np.argmin(np.abs(xs - x_hint)))] if x_hint is not None else xs[0]
|
|
408
|
+
return {'X': float(x), 'Y': float(y), 'all crossings': xs}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def statistics(T, Q, x0, x1):
|
|
412
|
+
"""TRIOS "Statistics" over the cursor window (population variance)."""
|
|
413
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
414
|
+
m = _window(T, x0, x1)
|
|
415
|
+
if m.sum() < 2:
|
|
416
|
+
return {}
|
|
417
|
+
y = Q[m]
|
|
418
|
+
mean = float(y.mean())
|
|
419
|
+
var = float(y.var()) # population, matches TRIOS
|
|
420
|
+
sd = float(np.sqrt(var))
|
|
421
|
+
return {'Max y': float(y.max()), 'Min y': float(y.min()), 'Mean y': mean,
|
|
422
|
+
'Variance': var, 'Standard Deviation': sd,
|
|
423
|
+
'Relative Standard Deviation': sd / mean if mean else float('nan')}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def area_under_curve(T, Q, x0, x1):
|
|
427
|
+
"""TRIOS "Area under the curve": ∫ Q dT, in °C·W/g.
|
|
428
|
+
|
|
429
|
+
Note the integration variable -- this one is over temperature, unlike
|
|
430
|
+
peak_integration which is over time."""
|
|
431
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
432
|
+
m = _window(T, x0, x1)
|
|
433
|
+
if m.sum() < 2:
|
|
434
|
+
return {}
|
|
435
|
+
o = np.argsort(T[m])
|
|
436
|
+
a = _trapz(Q[m][o], T[m][o])
|
|
437
|
+
return {'Area': a, 'Area (unsigned)': abs(a)}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def polynomial(T, Q, x0, x1, terms=3):
|
|
441
|
+
"""Least-squares polynomial over the window -- TRIOS "Polynomial".
|
|
442
|
+
|
|
443
|
+
``terms`` counts coefficients, so terms=3 is a quadratic a0 + a1x + a2x².
|
|
444
|
+
Returns the coefficients in TRIOS's ascending order plus R²."""
|
|
445
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
446
|
+
m = _window(T, x0, x1)
|
|
447
|
+
if m.sum() < terms:
|
|
448
|
+
return {}
|
|
449
|
+
Tw, Qw = T[m], Q[m]
|
|
450
|
+
c = np.polyfit(Tw, Qw, terms - 1) # descending
|
|
451
|
+
fit = np.polyval(c, Tw)
|
|
452
|
+
ss_res = float(((Qw - fit) ** 2).sum())
|
|
453
|
+
ss_tot = float(((Qw - Qw.mean()) ** 2).sum())
|
|
454
|
+
out = {f'a{i}': float(v) for i, v in enumerate(c[::-1])}
|
|
455
|
+
out['R2'] = 1 - ss_res / ss_tot if ss_tot else float('nan')
|
|
456
|
+
return out
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def running_integral(t, T, Q, x0, x1, baseline='linear'):
|
|
460
|
+
"""Cumulative enthalpy through the window -- TRIOS "Running Integral".
|
|
461
|
+
|
|
462
|
+
Returns the T grid and the running ∫(Q − base) dt in J/g, plus the total,
|
|
463
|
+
so it can be drawn as a second curve."""
|
|
464
|
+
t, T, Q = map(np.asarray, (t, T, Q))
|
|
465
|
+
m = _window(T, x0, x1)
|
|
466
|
+
if m.sum() < 3:
|
|
467
|
+
return {}
|
|
468
|
+
ts, Ts, Qs = t[m] * 60.0, T[m], Q[m]
|
|
469
|
+
base = (np.interp(ts, [ts[0], ts[-1]], [Qs[0], Qs[-1]])
|
|
470
|
+
if baseline == 'linear' else np.zeros_like(Qs))
|
|
471
|
+
corr = Qs - base
|
|
472
|
+
cum = np.concatenate([[0.0], np.cumsum(np.diff(ts) * (corr[1:] + corr[:-1]) / 2)])
|
|
473
|
+
return {'T': Ts, 'integral': cum,
|
|
474
|
+
'Enthalpy (Normalized) Integral': float(cum[-1])}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def find_peaks(T, Q, x0, x1, n=1, prominence_frac=0.05):
|
|
478
|
+
"""Local maxima of |Q − baseline| in the window -- TRIOS "Find peaks"."""
|
|
479
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
480
|
+
m = _window(T, x0, x1)
|
|
481
|
+
if m.sum() < 5:
|
|
482
|
+
return {}
|
|
483
|
+
o = np.argsort(T[m])
|
|
484
|
+
Tw, Qw = T[m][o], Q[m][o]
|
|
485
|
+
base = np.interp(Tw, [Tw[0], Tw[-1]], [Qw[0], Qw[-1]])
|
|
486
|
+
c = Qw - base
|
|
487
|
+
thr = prominence_frac * np.abs(c).max()
|
|
488
|
+
loc = np.where((c[1:-1] >= c[:-2]) & (c[1:-1] > c[2:]) & (c[1:-1] > thr))[0] + 1
|
|
489
|
+
# TRIOS numbers its peaks along x, so X1 is the FIRST qualifying peak in
|
|
490
|
+
# the window, not the tallest one.
|
|
491
|
+
loc = np.sort(loc)[:n]
|
|
492
|
+
return {f'X{i+1}': float(Tw[k]) for i, k in enumerate(loc)} | \
|
|
493
|
+
{f'Y{i+1}': float(Qw[k]) for i, k in enumerate(loc)}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def oxidation_temperature(T, Q, x_cursor, x_transition, threshold=0.0):
|
|
497
|
+
"""TRIOS "Oxidation temperature": tangent onset plus a signal threshold.
|
|
498
|
+
|
|
499
|
+
The onset is the usual tangent construction; the oxidation temperature is
|
|
500
|
+
where the signal first exceeds ``threshold`` after it."""
|
|
501
|
+
res = onset_point(T, Q, x_cursor, x_transition)
|
|
502
|
+
T, Q = np.asarray(T), np.asarray(Q)
|
|
503
|
+
m = _window(T, x_cursor, x_transition)
|
|
504
|
+
o = np.argsort(T[m])
|
|
505
|
+
Tw, Qw = T[m][o], Q[m][o]
|
|
506
|
+
# TRIOS reports the transition cursor itself as the oxidation temperature
|
|
507
|
+
# (125.9705 for a cursor at 125.971 on the reference file); the tangent
|
|
508
|
+
# construction supplies the onset, and the threshold only gates whether the
|
|
509
|
+
# signal ever qualifies.
|
|
510
|
+
over = np.where(Qw > threshold)[0]
|
|
511
|
+
res['Oxidation Temperature'] = float(x_transition) if over.size else float('nan')
|
|
512
|
+
if 'Onset x' in res:
|
|
513
|
+
res['Onset y'] = _at(T, Q, res['Onset x'])
|
|
514
|
+
return res
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# --------------------------------------------------------------------------- #
|
|
518
|
+
# validation against TRIOS's own numbers
|
|
519
|
+
# --------------------------------------------------------------------------- #
|
|
520
|
+
def validate(tri_or_txt, export_txt, verbose=True):
|
|
521
|
+
"""Recompute every analysis found in a TRIOS export and compare.
|
|
522
|
+
|
|
523
|
+
Because an analysis names only its *step program* (three segments can share
|
|
524
|
+
one), each is evaluated on every segment and the closest match reported --
|
|
525
|
+
that tests the mathematics, independent of scan attribution.
|
|
526
|
+
"""
|
|
527
|
+
import trios_io as tio
|
|
528
|
+
|
|
529
|
+
# Curves from the binary (verified against the export to 5e-5 C), the
|
|
530
|
+
# analyses from the Full text export -- the binary's own analysis records
|
|
531
|
+
# are only partly decoded, and this way the maths is checked against
|
|
532
|
+
# TRIOS's numbers rather than against my own reading of them.
|
|
533
|
+
curves = tio.read_tri(tri_or_txt)
|
|
534
|
+
data = tio.read_tri(export_txt)
|
|
535
|
+
if not data.get('analyses'):
|
|
536
|
+
data = {'analyses': {}}
|
|
537
|
+
segs = []
|
|
538
|
+
for d in curves['numdata']:
|
|
539
|
+
i = {k: n for n, k in enumerate(d['dims'])}
|
|
540
|
+
if not {'Time', 'Temperature', 'Heat Flow (Normalized)'} <= set(i):
|
|
541
|
+
continue
|
|
542
|
+
segs.append((d['nums'][:, i['Time']], d['nums'][:, i['Temperature']],
|
|
543
|
+
d['nums'][:, i['Heat Flow (Normalized)']]))
|
|
544
|
+
|
|
545
|
+
def num(s):
|
|
546
|
+
try:
|
|
547
|
+
return float(str(s).split(' ')[0].replace(',', '.'))
|
|
548
|
+
except (ValueError, AttributeError):
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
rows = []
|
|
552
|
+
for prog, models in data['analyses'].items():
|
|
553
|
+
for model, lst in models.items():
|
|
554
|
+
for a in lst:
|
|
555
|
+
cur = [num(a[k]) for k in a if k.rstrip('0123456789') in
|
|
556
|
+
('Cursor x', 'Baseline cursor x', 'Onset cursor x',
|
|
557
|
+
'Transition cursor x', 'End cursor x')]
|
|
558
|
+
cur = [c for c in cur if c is not None]
|
|
559
|
+
best = None
|
|
560
|
+
for (t, T, Q) in segs:
|
|
561
|
+
try:
|
|
562
|
+
got = _dispatch(model, t, T, Q, cur, a)
|
|
563
|
+
except Exception:
|
|
564
|
+
continue
|
|
565
|
+
if not got:
|
|
566
|
+
continue
|
|
567
|
+
for key, ref in _expected(model, a, num).items():
|
|
568
|
+
if key not in got or ref is None:
|
|
569
|
+
continue
|
|
570
|
+
err = abs(got[key] - ref)
|
|
571
|
+
rel = err / max(abs(ref), 1e-12)
|
|
572
|
+
if best is None or rel < best[3]:
|
|
573
|
+
best = (key, ref, got[key], rel)
|
|
574
|
+
if best:
|
|
575
|
+
rows.append((model, *best))
|
|
576
|
+
|
|
577
|
+
if verbose:
|
|
578
|
+
print(f"{'model':30s} {'field':26s} {'TRIOS':>12s} {'python':>12s} {'rel err':>10s}")
|
|
579
|
+
print('-' * 96)
|
|
580
|
+
for model, key, ref, got, rel in rows:
|
|
581
|
+
flag = 'ok' if rel < 0.02 else ('close' if rel < 0.1 else 'CHECK')
|
|
582
|
+
print(f"{model:30.30s} {key:26.26s} {ref:12.5g} {got:12.5g} "
|
|
583
|
+
f"{rel:9.2%} {flag}")
|
|
584
|
+
return rows
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _expected(model, a, num):
|
|
588
|
+
keys = ('Enthalpy (normalized)', 'Peak temperature', 'Onset x', 'Endset x',
|
|
589
|
+
'Midpoint', 'Peak height', 'Min y', 'Max y', 'Mean y', 'Variance',
|
|
590
|
+
'Standard Deviation', 'Relative Standard Deviation', 'Y', 'X',
|
|
591
|
+
'Change (normalized)', 'Area', 'Area (unsigned)', 'a0', 'a1', 'a2',
|
|
592
|
+
'Enthalpy (Normalized) Integral', 'X1', 'Y1', 'Oxidation Temperature')
|
|
593
|
+
return {k: num(a[k]) for k in keys if k in a}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _dispatch(model, t, T, Q, cur, a):
|
|
597
|
+
c0 = cur[0] if len(cur) > 0 else None
|
|
598
|
+
c1 = cur[1] if len(cur) > 1 else None
|
|
599
|
+
if model.startswith('Peak Integration'):
|
|
600
|
+
return peak_integration(t, T, Q, c0, c1)
|
|
601
|
+
if model == 'Onset point':
|
|
602
|
+
return onset_point(T, Q, c0, c1)
|
|
603
|
+
if model == 'Endset point':
|
|
604
|
+
return onset_point(T, Q, c1, c0, kind='endset')
|
|
605
|
+
if model == 'Glass transition':
|
|
606
|
+
return glass_transition(T, Q, c0, c1)
|
|
607
|
+
if model == 'Peak height':
|
|
608
|
+
return peak_height(T, Q, c0, c1)
|
|
609
|
+
if model == 'Signal min':
|
|
610
|
+
return signal_extremum(T, Q, c0, c1, 'min')
|
|
611
|
+
if model == 'Signal max':
|
|
612
|
+
return signal_extremum(T, Q, c0, c1, 'max')
|
|
613
|
+
if model == 'Signal change':
|
|
614
|
+
return signal_change(T, Q, c0, c1)
|
|
615
|
+
if model == 'Curve Y at X':
|
|
616
|
+
return curve_y_at_x(T, Q, c0)
|
|
617
|
+
if model == 'Curve X at Y':
|
|
618
|
+
return curve_y_at_x(T, Q, c0)
|
|
619
|
+
if model == 'Statistics':
|
|
620
|
+
return statistics(T, Q, c0, c1)
|
|
621
|
+
if model == 'Polynomial':
|
|
622
|
+
return polynomial(T, Q, c0, c1, terms=3)
|
|
623
|
+
if model == 'Running Integral':
|
|
624
|
+
return running_integral(t, T, Q, c0, c1)
|
|
625
|
+
if model == 'Area under the curve':
|
|
626
|
+
return area_under_curve(T, Q, c0, c1)
|
|
627
|
+
if model == 'Find peaks':
|
|
628
|
+
return find_peaks(T, Q, c0, c1)
|
|
629
|
+
if model == 'Oxidation temperature':
|
|
630
|
+
return oxidation_temperature(T, Q, c0, c1)
|
|
631
|
+
return {}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
if __name__ == '__main__':
|
|
635
|
+
import sys
|
|
636
|
+
validate(*sys.argv[1:3])
|