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.
@@ -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])