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
dscpanel/core/measure.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Analyses computed HERE, from two cursors on one scan.
|
|
2
|
+
|
|
3
|
+
UI-free: the window collects the two temperatures with its gizmos and calls
|
|
4
|
+
`run`; everything below is arithmetic, and it is the SAME arithmetic the
|
|
5
|
+
reader uses for the analyses TRIOS stored, because both go through
|
|
6
|
+
`trios_analysis`. An onset made in this panel and an onset read out of a
|
|
7
|
+
`.tri` are then the same kind of thing, drawn by the same code, and neither
|
|
8
|
+
is a second implementation of the other.
|
|
9
|
+
|
|
10
|
+
What each model needs is declared in `MODELS`, so the quick-select list, the
|
|
11
|
+
enabling rules and the dispatch all read one table.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import collections
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from . import model
|
|
19
|
+
from . import style
|
|
20
|
+
from . import trios_analysis
|
|
21
|
+
from . import units
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Measurement(object):
|
|
25
|
+
"""One entry in the quick-select list.
|
|
26
|
+
|
|
27
|
+
`title` is what the list shows - the short word somebody is looking for,
|
|
28
|
+
"Onset" and "Integration" rather than the TRIOS model string - and `name`
|
|
29
|
+
is the model as the file writes it, which is what everything downstream
|
|
30
|
+
keys on. What the finished analysis's label says is `core/labels.py`'s
|
|
31
|
+
business: a template per kind, with the measured value filled in.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, name, run, needs=2, note="", title="", label="",
|
|
35
|
+
signals=(model.SIGNAL_HEAT,)):
|
|
36
|
+
self.name = name
|
|
37
|
+
self.title = title or name
|
|
38
|
+
self.label = label
|
|
39
|
+
self.run = run
|
|
40
|
+
#: How many cursors it takes: two for an interval, one for a value
|
|
41
|
+
#: at a point (the mass at a temperature, which stores its one
|
|
42
|
+
#: temperature as both cursors).
|
|
43
|
+
self.needs = needs
|
|
44
|
+
self.note = note
|
|
45
|
+
#: Which curves it is offered on: a heat flow's, a mass's.
|
|
46
|
+
self.signals = tuple(signals)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _series(scan, span=None):
|
|
50
|
+
"""`(time_min, temperature, heat_flow_per_gram)` for a scan, or None.
|
|
51
|
+
|
|
52
|
+
In the file's own units, ALWAYS: minutes, degrees Celsius and W/g,
|
|
53
|
+
whatever the axes happen to be showing. An analysis is about the
|
|
54
|
+
measurement, so it is computed in the measurement's units and converted
|
|
55
|
+
for display like every stored one.
|
|
56
|
+
|
|
57
|
+
Time in MINUTES because that is what `trios_analysis` takes - it converts
|
|
58
|
+
to seconds itself. Handing it seconds gives an enthalpy sixty times too
|
|
59
|
+
large, which is exactly as wrong as it sounds and looks entirely
|
|
60
|
+
plausible on screen.
|
|
61
|
+
|
|
62
|
+
MEASURED samples only: a flagged sample is NaN (TRI-FORMAT.md section
|
|
63
|
+
3; a DSC run's last segment ends in some, a few runs start with some),
|
|
64
|
+
and one NaN in a least-squares window makes every tangent NaN. They are
|
|
65
|
+
dropped after the slicing, so the kept range and the span still count
|
|
66
|
+
in the segment's own indices.
|
|
67
|
+
"""
|
|
68
|
+
temperature = scan.temperature()
|
|
69
|
+
minutes = scan.time_min()
|
|
70
|
+
if getattr(scan, "is_mass", False):
|
|
71
|
+
# A MASS scan is measured on its mass: % of the sample mass where
|
|
72
|
+
# the file has it, else the recorded mg (`series_unit`).
|
|
73
|
+
values = scan.weight_values(model.WEIGHT_PCT)
|
|
74
|
+
if values is None:
|
|
75
|
+
values = scan.weight_values(model.WEIGHT_MG)
|
|
76
|
+
if temperature is None or values is None:
|
|
77
|
+
return None
|
|
78
|
+
else:
|
|
79
|
+
values, base = scan.heat_flow()
|
|
80
|
+
if temperature is None or values is None:
|
|
81
|
+
return None
|
|
82
|
+
if base != "W/g":
|
|
83
|
+
if not scan.sample.mass_g:
|
|
84
|
+
return None
|
|
85
|
+
values = values / float(scan.sample.mass_g)
|
|
86
|
+
if minutes is None:
|
|
87
|
+
minutes = np.arange(len(values), dtype=float)
|
|
88
|
+
# Only what is DRAWN: a truncated end is gone from the analyses too, and
|
|
89
|
+
# a span (the samples a drag ran between) narrows it to exactly those.
|
|
90
|
+
lo, hi = scan.kept_range(len(values))
|
|
91
|
+
if span is not None:
|
|
92
|
+
lo = max(lo, int(min(span)))
|
|
93
|
+
hi = min(hi, int(max(span)) + 1)
|
|
94
|
+
if hi - lo < 3:
|
|
95
|
+
return None
|
|
96
|
+
minutes, temperature, values = (minutes[lo:hi], temperature[lo:hi],
|
|
97
|
+
values[lo:hi])
|
|
98
|
+
with np.errstate(invalid="ignore"):
|
|
99
|
+
measured = (np.isfinite(minutes) & np.isfinite(temperature)
|
|
100
|
+
& np.isfinite(values))
|
|
101
|
+
if measured.sum() < 3:
|
|
102
|
+
return None
|
|
103
|
+
if not measured.all():
|
|
104
|
+
minutes, temperature, values = (minutes[measured],
|
|
105
|
+
temperature[measured],
|
|
106
|
+
values[measured])
|
|
107
|
+
return minutes, temperature, values
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def series_unit(scan):
|
|
111
|
+
"""The unit `_series` measures in: W/g for a heat flow, % (or the
|
|
112
|
+
recorded mg, where the file has no percentage it can trust) for a
|
|
113
|
+
mass."""
|
|
114
|
+
if getattr(scan, "is_mass", False):
|
|
115
|
+
return (model.WEIGHT_PCT
|
|
116
|
+
if scan.weight_values(model.WEIGHT_PCT) is not None
|
|
117
|
+
else model.WEIGHT_MG)
|
|
118
|
+
return units.UNIT_W_G
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def acquisition_order(scan, x0, x1, span=None):
|
|
122
|
+
"""`(earlier, later)`: two cursor temperatures in the order the
|
|
123
|
+
instrument MET them.
|
|
124
|
+
|
|
125
|
+
What decides which side of a transition is its flat side: an onset's
|
|
126
|
+
baseline is the one BEFORE it, an endset's the one after, and on a
|
|
127
|
+
cooling scan "before" is the high temperature. Sorting the cursors and
|
|
128
|
+
taking the low one as flat made the panel's endset the onset under
|
|
129
|
+
another name, and put a cooling onset's baseline on the far side.
|
|
130
|
+
|
|
131
|
+
Measured along the curve (`span`, two sample indices), the samples say
|
|
132
|
+
it; otherwise the scan's direction does. An isothermal or unmeasurable
|
|
133
|
+
one counts as heating.
|
|
134
|
+
"""
|
|
135
|
+
low, high = sorted((float(x0), float(x1)))
|
|
136
|
+
span = clean_span(span)
|
|
137
|
+
temperature = scan.temperature()
|
|
138
|
+
if span is not None and temperature is not None \
|
|
139
|
+
and span[1] < len(temperature):
|
|
140
|
+
first = float(temperature[span[0]])
|
|
141
|
+
last = float(temperature[span[1]])
|
|
142
|
+
if np.isfinite(first) and np.isfinite(last) and first != last:
|
|
143
|
+
return (low, high) if first < last else (high, low)
|
|
144
|
+
if scan.direction() == "down":
|
|
145
|
+
return high, low
|
|
146
|
+
return low, high
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def flat_and_transition(scan, x0, x1, kind="onset", span=None):
|
|
150
|
+
"""`(flat, transition)` cursors of an onset or an endset: the flat one
|
|
151
|
+
is the earlier for an onset and the later for an endset."""
|
|
152
|
+
earlier, later = acquisition_order(scan, x0, x1, span)
|
|
153
|
+
return (later, earlier) if kind == "endset" else (earlier, later)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def onset(scan, x0, x1, kind="onset", span=None):
|
|
157
|
+
"""An onset or endset between the cursors `x0 <= x1`.
|
|
158
|
+
|
|
159
|
+
The fields keep the cursors low first, as every panel analysis does
|
|
160
|
+
(the gizmos and the Start / End fields read them in that order); which
|
|
161
|
+
one is FLAT is worked out here, by acquisition order, and again by
|
|
162
|
+
`tangent_points` - never taken from the field names.
|
|
163
|
+
"""
|
|
164
|
+
series = _series(scan, span)
|
|
165
|
+
if series is None:
|
|
166
|
+
return None
|
|
167
|
+
_t, temperature, flow = series
|
|
168
|
+
flat, transition = flat_and_transition(scan, x0, x1, kind, span)
|
|
169
|
+
result = trios_analysis.onset_point(temperature, flow, flat, transition,
|
|
170
|
+
kind=kind)
|
|
171
|
+
key = "Endset x" if kind == "endset" else "Onset x"
|
|
172
|
+
if key not in result or not np.isfinite(result[key]):
|
|
173
|
+
return None
|
|
174
|
+
return {"Model": "Endset point" if kind == "endset" else "Onset point",
|
|
175
|
+
"Onset cursor x": "{:.4f} °C".format(x0),
|
|
176
|
+
"Transition cursor x": "{:.4f} °C".format(x1),
|
|
177
|
+
key: "{:.4f} °C".format(result[key])}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def endset(scan, x0, x1, span=None):
|
|
181
|
+
return onset(scan, x0, x1, kind="endset", span=span)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def integrate(scan, x0, x1, span=None):
|
|
185
|
+
series = _series(scan, span)
|
|
186
|
+
if series is None:
|
|
187
|
+
return None
|
|
188
|
+
minutes, temperature, flow = series
|
|
189
|
+
result = trios_analysis.peak_integration(minutes, temperature, flow,
|
|
190
|
+
x0, x1)
|
|
191
|
+
if not result:
|
|
192
|
+
return None
|
|
193
|
+
return {"Model": "Peak Integration (enthalpy)",
|
|
194
|
+
"Baseline cursor x": "{:.4f} °C".format(x0),
|
|
195
|
+
"Baseline cursor x1": "{:.4f} °C".format(x1),
|
|
196
|
+
"Enthalpy (normalized)": "{:.4f} J/g".format(
|
|
197
|
+
abs(result["Enthalpy (normalized)"])),
|
|
198
|
+
"Peak temperature": "{:.4f} °C".format(
|
|
199
|
+
result["Peak temperature"])}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def glass_transition(scan, x0, x1, span=None):
|
|
203
|
+
series = _series(scan, span)
|
|
204
|
+
if series is None:
|
|
205
|
+
return None
|
|
206
|
+
_t, temperature, flow = series
|
|
207
|
+
# The onset is the side met FIRST: the high one on a cooling scan.
|
|
208
|
+
earlier, later = acquisition_order(scan, x0, x1, span)
|
|
209
|
+
result = trios_analysis.glass_transition(temperature, flow, earlier,
|
|
210
|
+
later)
|
|
211
|
+
if not result or "Midpoint" not in result:
|
|
212
|
+
return None
|
|
213
|
+
return {"Model": "Glass transition",
|
|
214
|
+
"Onset cursor x": "{:.4f} °C".format(x0),
|
|
215
|
+
"End cursor x": "{:.4f} °C".format(x1),
|
|
216
|
+
"Onset x": "{:.4f} °C".format(result["Onset x"]),
|
|
217
|
+
"End x": "{:.4f} °C".format(result["End x"]),
|
|
218
|
+
"Step height": "{:.4f} W/g".format(result["Step height"]),
|
|
219
|
+
"Midpoint": "{:.4f} °C".format(result["Midpoint"])}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def signal_change(scan, x0, x1, span=None):
|
|
223
|
+
series = _series(scan, span)
|
|
224
|
+
if series is None:
|
|
225
|
+
return None
|
|
226
|
+
_t, temperature, flow = series
|
|
227
|
+
result = trios_analysis.signal_change(temperature, flow, x0, x1)
|
|
228
|
+
if not result:
|
|
229
|
+
return None
|
|
230
|
+
first = next(iter(result.items()))
|
|
231
|
+
return {"Model": "Signal change",
|
|
232
|
+
"Cursor x": "{:.4f} °C".format(x0),
|
|
233
|
+
"Cursor x1": "{:.4f} °C".format(x1),
|
|
234
|
+
first[0]: "{:.4f}".format(first[1])
|
|
235
|
+
if isinstance(first[1], float) else str(first[1])}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def peak_height(scan, x0, x1, span=None):
|
|
239
|
+
series = _series(scan, span)
|
|
240
|
+
if series is None:
|
|
241
|
+
return None
|
|
242
|
+
_t, temperature, flow = series
|
|
243
|
+
result = trios_analysis.peak_height(temperature, flow, x0, x1)
|
|
244
|
+
if not result:
|
|
245
|
+
return None
|
|
246
|
+
out = {"Model": "Peak height",
|
|
247
|
+
"Cursor x": "{:.4f} °C".format(x0),
|
|
248
|
+
"Cursor x1": "{:.4f} °C".format(x1)}
|
|
249
|
+
for key, value in result.items():
|
|
250
|
+
out[key] = ("{:.4f}".format(value) if isinstance(value, float)
|
|
251
|
+
else str(value))
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
MASS_AT = "Mass at temperature"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def mass_at(scan, x0, x1=None, span=None):
|
|
259
|
+
"""The mass of a MASS scan at one temperature (the template's
|
|
260
|
+
`add_annot`): the first MEASURED sample at or past `x0` degC
|
|
261
|
+
in the order the run went (heating: the first at or above it), its
|
|
262
|
+
temperature, and its mass in % and in mg where the file has them. The
|
|
263
|
+
offset is not in it: this is the measurement."""
|
|
264
|
+
if not getattr(scan, "is_mass", False):
|
|
265
|
+
return None
|
|
266
|
+
temperature = scan.temperature()
|
|
267
|
+
if temperature is None or not len(temperature):
|
|
268
|
+
return None
|
|
269
|
+
percent = scan.weight_values(model.WEIGHT_PCT)
|
|
270
|
+
grams = scan.weight_values(model.WEIGHT_MG)
|
|
271
|
+
if percent is None and grams is None:
|
|
272
|
+
return None
|
|
273
|
+
lo, hi = scan.kept_range(len(temperature))
|
|
274
|
+
temp = np.asarray(temperature, dtype=float)
|
|
275
|
+
known = np.zeros(len(temp), dtype=bool)
|
|
276
|
+
for column in (percent, grams):
|
|
277
|
+
if column is not None:
|
|
278
|
+
with np.errstate(invalid="ignore"):
|
|
279
|
+
known |= np.isfinite(np.asarray(column, dtype=float))
|
|
280
|
+
with np.errstate(invalid="ignore"):
|
|
281
|
+
known &= np.isfinite(temp)
|
|
282
|
+
if scan.direction() == "down":
|
|
283
|
+
past = temp <= float(x0)
|
|
284
|
+
else:
|
|
285
|
+
past = temp >= float(x0)
|
|
286
|
+
index = np.flatnonzero(known & past)
|
|
287
|
+
index = index[(index >= lo) & (index < hi)]
|
|
288
|
+
if not len(index):
|
|
289
|
+
return None
|
|
290
|
+
k = int(index[0])
|
|
291
|
+
at = "{:.4f} \u00b0C".format(float(temp[k]))
|
|
292
|
+
out = {"Model": MASS_AT, "Cursor x": at, "Cursor x1": at,
|
|
293
|
+
"Sample": str(k)}
|
|
294
|
+
if percent is not None and np.isfinite(percent[k]):
|
|
295
|
+
out["Mass"] = "{:.4f} %".format(float(percent[k]))
|
|
296
|
+
if grams is not None and np.isfinite(grams[k]):
|
|
297
|
+
out["Mass (mg)"] = "{:.5f} mg".format(float(grams[k]))
|
|
298
|
+
if "Mass" not in out and "Mass (mg)" not in out:
|
|
299
|
+
return None
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
#: The quick-select list, in the order it is offered. Onset first because it
|
|
304
|
+
#: is what a Tg run is analysed with, then the integral, then the rest.
|
|
305
|
+
MODELS = (
|
|
306
|
+
Measurement("Onset point", onset, title="Onset",
|
|
307
|
+
note="tangent from the flat part to the transition",
|
|
308
|
+
signals=(model.SIGNAL_HEAT, model.SIGNAL_MASS)),
|
|
309
|
+
Measurement("Peak Integration (enthalpy)", integrate, title="Integration",
|
|
310
|
+
note="area against a linear baseline"),
|
|
311
|
+
Measurement("Glass transition", glass_transition,
|
|
312
|
+
title="Glass transition",
|
|
313
|
+
note="onset, midpoint and end of the step"),
|
|
314
|
+
Measurement("Endset point", endset, title="Endset",
|
|
315
|
+
note="the tangent construction, from the other side",
|
|
316
|
+
signals=(model.SIGNAL_HEAT, model.SIGNAL_MASS)),
|
|
317
|
+
Measurement("Peak height", peak_height, title="Peak height",
|
|
318
|
+
note="height above the baseline between the cursors"),
|
|
319
|
+
Measurement("Signal change", signal_change, title="Signal change",
|
|
320
|
+
note="how much the signal moved between the cursors"),
|
|
321
|
+
Measurement(MASS_AT, mass_at, needs=1, title="Mass at temperature",
|
|
322
|
+
note="the m% at one temperature",
|
|
323
|
+
signals=(model.SIGNAL_MASS,)),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def models_for(scan):
|
|
328
|
+
"""The models offered on `scan`'s curve: a heat flow's or a mass's."""
|
|
329
|
+
signal = getattr(scan, "signal", model.SIGNAL_HEAT)
|
|
330
|
+
return [entry for entry in MODELS if signal in entry.signals]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def by_name(name):
|
|
334
|
+
for entry in MODELS:
|
|
335
|
+
if entry.name == name:
|
|
336
|
+
return entry
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def run(name, scan, x0, x1, span=None):
|
|
341
|
+
"""Compute one analysis and attach it to `scan`, or return None.
|
|
342
|
+
|
|
343
|
+
The cursors arrive in DEGREES CELSIUS, whatever the axis is showing; the
|
|
344
|
+
caller converts. The result is an `Analysis` with `source = "panel"` and
|
|
345
|
+
an attribution that says it was made here, so a figure can always say
|
|
346
|
+
which numbers came out of the instrument and which out of this program.
|
|
347
|
+
"""
|
|
348
|
+
entry = by_name(name)
|
|
349
|
+
fields = compute(name, scan, x0, x1, span)
|
|
350
|
+
if entry is None or not fields:
|
|
351
|
+
return None
|
|
352
|
+
analysis = model.Analysis(id(fields) % 1000000, scan,
|
|
353
|
+
fields.get("Model", name), fields,
|
|
354
|
+
source="panel", attribution="measured here")
|
|
355
|
+
analysis.span = clean_span(span)
|
|
356
|
+
analysis.visible = True
|
|
357
|
+
if entry.needs == 1:
|
|
358
|
+
# A value at a point: no interval to mark.
|
|
359
|
+
analysis.show_interval = False
|
|
360
|
+
# No label of its own: the default template of its kind, whose `{}` is
|
|
361
|
+
# always the current measurement (`core/labels.py`).
|
|
362
|
+
analysis.label = None
|
|
363
|
+
scan.analysis_objects.append(analysis)
|
|
364
|
+
return analysis
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def clean_span(span):
|
|
368
|
+
"""Two sample indices, lowest first, or None."""
|
|
369
|
+
if not span or len(span) != 2 or None in tuple(span):
|
|
370
|
+
return None
|
|
371
|
+
low, high = sorted(int(i) for i in span)
|
|
372
|
+
return (low, high) if high > low else None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def compute(name, scan, x0, x1, span=None):
|
|
376
|
+
"""The result fields of one analysis between two cursors, or None.
|
|
377
|
+
|
|
378
|
+
`run` without making an object: what moving an existing analysis's
|
|
379
|
+
interval needs, so the analysis is updated IN PLACE and whatever holds it
|
|
380
|
+
(its open settings, the outliner, the selection) keeps holding it.
|
|
381
|
+
"""
|
|
382
|
+
entry = by_name(name)
|
|
383
|
+
if entry is None:
|
|
384
|
+
return None
|
|
385
|
+
low, high = (x0, x1) if x0 <= x1 else (x1, x0)
|
|
386
|
+
try:
|
|
387
|
+
fields = entry.run(scan, low, high, span=clean_span(span))
|
|
388
|
+
except Exception:
|
|
389
|
+
return None
|
|
390
|
+
return fields or None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
#: What `tangent_points` returns. `points` is `[[degC, y], ...]` - three for
|
|
394
|
+
#: an onset or endset, four for a glass transition, in TRIOS's order - or
|
|
395
|
+
#: None; `base` is the unit of y, as `units.factor` takes it ("W/g", or "W"
|
|
396
|
+
#: for a file analysis made on the raw heat flow); `reason` says in one
|
|
397
|
+
#: short line why there are no points, for the analysis window.
|
|
398
|
+
Construction = collections.namedtuple("Construction", "points base reason")
|
|
399
|
+
|
|
400
|
+
#: The reasons, one short line each, as the settings say them.
|
|
401
|
+
NO_TANGENTS_EXPORT = "A TRIOS export stores no tangents: drawn as chords."
|
|
402
|
+
NO_TANGENTS_FILE = "The file stores no tangents for it: drawn as chords."
|
|
403
|
+
ON_THE_WEIGHT = "Made on the weight curve: drawn as chords."
|
|
404
|
+
NOT_FITTED = "No tangents could be fitted here: drawn as chords."
|
|
405
|
+
DEGENERATE = ("The tangents cross far outside the interval: drawn as "
|
|
406
|
+
"chords.")
|
|
407
|
+
|
|
408
|
+
#: The unit a stored construction's y is in, by the reader's `variable`.
|
|
409
|
+
_BASE_OF = {"Heat Flow (Normalized)": units.UNIT_W_G,
|
|
410
|
+
"Heat Flow": units.BASE_UNIT,
|
|
411
|
+
# An onset of mass loss, made on the weight (the reader's name
|
|
412
|
+
# for TRIOS's Weight (%) is "Weight Change"): drawn on the MASS
|
|
413
|
+
# scan it belongs to.
|
|
414
|
+
"Weight Change": model.WEIGHT_PCT,
|
|
415
|
+
"Weight": model.WEIGHT_MG}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def tangent_points(analysis, scan=None):
|
|
419
|
+
"""The tangent construction of an onset, endset or Tg: a `Construction`.
|
|
420
|
+
|
|
421
|
+
Whose construction it is follows who made the number:
|
|
422
|
+
|
|
423
|
+
* **a `.tri`'s own analysis** draws TRIOS's STORED points (the reader's
|
|
424
|
+
`construction`, `Analysis.stored_construction`), never a Python
|
|
425
|
+
recomputation: those meet at the number TRIOS reported, and a Python
|
|
426
|
+
fit misses it by up to a few K.
|
|
427
|
+
* **one made here** draws the Python construction on its own cursors
|
|
428
|
+
and span - the same fit its number came from, so again they meet.
|
|
429
|
+
* **a `.txt` export's** has no points (the export stores none), and a
|
|
430
|
+
Python construction whose tangents cross far outside the interval
|
|
431
|
+
(near-parallel lines) is not drawn either: `points` is None and
|
|
432
|
+
`reason` says why, and the window draws chords.
|
|
433
|
+
|
|
434
|
+
Recomputed only when something it depends on changes: the model, the
|
|
435
|
+
cursors, the span, the scan's kept range and its sample mass.
|
|
436
|
+
"""
|
|
437
|
+
scan = scan if scan is not None else analysis.scan
|
|
438
|
+
if not analysis.marks_a_point:
|
|
439
|
+
return Construction(None, None, "")
|
|
440
|
+
if analysis.source != "panel":
|
|
441
|
+
return _stored_construction(analysis, scan)
|
|
442
|
+
key = (analysis.model_name, tuple(analysis.cursors()),
|
|
443
|
+
tuple(analysis.span) if analysis.span else None,
|
|
444
|
+
tuple(scan.keep), scan.sample.mass_g, id(scan))
|
|
445
|
+
memo = getattr(analysis, "_tangent_memo", None)
|
|
446
|
+
if memo is not None and memo[0] == key:
|
|
447
|
+
return memo[1]
|
|
448
|
+
found = _python_construction(analysis, scan)
|
|
449
|
+
analysis._tangent_memo = (key, found)
|
|
450
|
+
return found
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def lines_note(analysis, doc=None):
|
|
454
|
+
"""Why an analysis that is to be drawn with tangents gets chords, in one
|
|
455
|
+
short line, or "" - for its settings. Besides `tangent_points`'
|
|
456
|
+
reasons, the one the axes add: points in W/g on an mW axis need the
|
|
457
|
+
sample mass, which is never made up."""
|
|
458
|
+
if not analysis.marks_a_point or style.value(
|
|
459
|
+
doc, analysis, "construction") != style.LINES_TANGENTS:
|
|
460
|
+
return ""
|
|
461
|
+
found = tangent_points(analysis)
|
|
462
|
+
if not found.points:
|
|
463
|
+
return found.reason
|
|
464
|
+
scan = analysis.scan
|
|
465
|
+
if getattr(scan, "is_mass", False):
|
|
466
|
+
unit = getattr(doc, "weight_unit", model.WEIGHT_PCT)
|
|
467
|
+
missing = (None if unit == found.base or scan.sample.mass_g
|
|
468
|
+
else "sample mass")
|
|
469
|
+
else:
|
|
470
|
+
unit = getattr(doc, "y_unit", units.UNIT_W_G)
|
|
471
|
+
missing = units.factor(unit, found.base, scan.sample.mass_g,
|
|
472
|
+
scan.molar_mass)[1]
|
|
473
|
+
if missing:
|
|
474
|
+
return "Drawing them needs the {}: drawn as chords.".format(missing)
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _stored_construction(analysis, scan):
|
|
479
|
+
points = analysis.stored_construction
|
|
480
|
+
if not points:
|
|
481
|
+
export = str(scan.sample.path).lower().endswith(".txt")
|
|
482
|
+
return Construction(None, None, NO_TANGENTS_EXPORT if export
|
|
483
|
+
else NO_TANGENTS_FILE)
|
|
484
|
+
variable = analysis.fields.get("variable")
|
|
485
|
+
base = _BASE_OF.get(variable)
|
|
486
|
+
on_mass = base in (model.WEIGHT_PCT, model.WEIGHT_MG)
|
|
487
|
+
if base is None or on_mass != bool(getattr(scan, "is_mass", False)):
|
|
488
|
+
# A construction in % has no place on heat-flow axes, nor one in
|
|
489
|
+
# W/g on a mass's.
|
|
490
|
+
return Construction(None, None, ON_THE_WEIGHT
|
|
491
|
+
if on_mass else NO_TANGENTS_FILE)
|
|
492
|
+
wanted = 4 if "Glass" in analysis.model_name else 3
|
|
493
|
+
try:
|
|
494
|
+
cleaned = [[float(x), float(y)] for x, y in points]
|
|
495
|
+
except (TypeError, ValueError):
|
|
496
|
+
cleaned = []
|
|
497
|
+
if len(cleaned) != wanted or not np.all(np.isfinite(cleaned)):
|
|
498
|
+
return Construction(None, None, NO_TANGENTS_FILE)
|
|
499
|
+
return Construction(cleaned, base, "")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _python_construction(analysis, scan):
|
|
503
|
+
cursors = analysis.cursors()
|
|
504
|
+
if len(cursors) != 2:
|
|
505
|
+
return Construction(None, None, NOT_FITTED)
|
|
506
|
+
series = _series(scan, clean_span(analysis.span))
|
|
507
|
+
if series is None:
|
|
508
|
+
return Construction(None, None, NOT_FITTED)
|
|
509
|
+
_t, temperature, flow = series
|
|
510
|
+
name = analysis.model_name
|
|
511
|
+
try:
|
|
512
|
+
with np.errstate(all="ignore"):
|
|
513
|
+
if "Glass" in name:
|
|
514
|
+
earlier, later = acquisition_order(scan, cursors[0],
|
|
515
|
+
cursors[1], analysis.span)
|
|
516
|
+
result = trios_analysis.glass_transition(
|
|
517
|
+
temperature, flow, earlier, later)
|
|
518
|
+
crossings = (result.get("Onset x"), result.get("End x"))
|
|
519
|
+
else:
|
|
520
|
+
kind = "endset" if "Endset" in name else "onset"
|
|
521
|
+
flat, transition = flat_and_transition(
|
|
522
|
+
scan, cursors[0], cursors[1], kind, analysis.span)
|
|
523
|
+
result = trios_analysis.onset_point(
|
|
524
|
+
temperature, flow, flat, transition, kind=kind)
|
|
525
|
+
crossings = (result.get("Endset x" if kind == "endset"
|
|
526
|
+
else "Onset x"),)
|
|
527
|
+
except (ValueError, IndexError, FloatingPointError, ZeroDivisionError,
|
|
528
|
+
np.linalg.LinAlgError):
|
|
529
|
+
return Construction(None, None, NOT_FITTED)
|
|
530
|
+
if not result or any(c is None for c in crossings):
|
|
531
|
+
return Construction(None, None, NOT_FITTED)
|
|
532
|
+
# Near-parallel tangents meet a long way off, or not at all: more than
|
|
533
|
+
# one interval's width outside the interval is not a construction.
|
|
534
|
+
low, high = sorted(cursors)
|
|
535
|
+
width = high - low
|
|
536
|
+
if any(not np.isfinite(c) or c < low - width or c > high + width
|
|
537
|
+
for c in crossings):
|
|
538
|
+
return Construction(None, None, DEGENERATE)
|
|
539
|
+
points = result.get("construction")
|
|
540
|
+
if not points or not np.all(np.isfinite(points)):
|
|
541
|
+
return Construction(None, None, NOT_FITTED)
|
|
542
|
+
return Construction([[float(x), float(y)] for x, y in points],
|
|
543
|
+
series_unit(scan), "")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def relabelled(analysis, fields):
|
|
547
|
+
"""The label an analysis carries once its fields become `fields`: the
|
|
548
|
+
same one. A label is a template (`core/labels.py`) and its `{}` is the
|
|
549
|
+
measurement, so a re-measured analysis cannot keep an old number."""
|
|
550
|
+
return analysis.label
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def legacy_label(analysis):
|
|
554
|
+
"""The label an older panel analysis was GIVEN, number and all
|
|
555
|
+
("*T*_{onset} = 61.1 degC"), or None.
|
|
556
|
+
|
|
557
|
+
Only for reading old sessions: a label equal to this was never the
|
|
558
|
+
user's words, so it is dropped for the default template, whose number
|
|
559
|
+
follows the measurement. One the user changed is kept as theirs.
|
|
560
|
+
"""
|
|
561
|
+
entry = by_name(analysis.model_name)
|
|
562
|
+
if entry is None:
|
|
563
|
+
return None
|
|
564
|
+
value = analysis.value()
|
|
565
|
+
if value is None:
|
|
566
|
+
return None
|
|
567
|
+
unit = "\u00b0C"
|
|
568
|
+
if "Integration" in entry.name:
|
|
569
|
+
value = model.number(analysis.fields.get("Enthalpy (normalized)"))
|
|
570
|
+
unit = "J/g"
|
|
571
|
+
elif "height" in entry.name.lower() or "change" in entry.name.lower():
|
|
572
|
+
for key, text in analysis.fields.items():
|
|
573
|
+
if key in ("Model", "Cursor x", "Cursor x1"):
|
|
574
|
+
continue
|
|
575
|
+
number = model.number(text)
|
|
576
|
+
if number is not None:
|
|
577
|
+
value, unit = number, "W/g"
|
|
578
|
+
break
|
|
579
|
+
if value is None:
|
|
580
|
+
return None
|
|
581
|
+
return _LEGACY.get(entry.name, "") % (value, unit) if _LEGACY.get(
|
|
582
|
+
entry.name) else None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
#: The pre-round-15 captions, for `legacy_label` alone.
|
|
586
|
+
_LEGACY = {
|
|
587
|
+
"Onset point": "*T*_{onset} = %.1f %s",
|
|
588
|
+
"Peak Integration (enthalpy)": "\\Delta*H* = %.3f %s",
|
|
589
|
+
"Glass transition": "*T*_{g} = %.1f %s",
|
|
590
|
+
"Endset point": "*T*_{endset} = %.1f %s",
|
|
591
|
+
"Peak height": "*q*_{peak} = %.3f %s",
|
|
592
|
+
"Signal change": "\\Delta*q* = %.3f %s",
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def walk_to(values, start, target, lo=0, hi=None, slack=0.5):
|
|
597
|
+
"""The index reached walking ALONG `values` from `start` towards the
|
|
598
|
+
value `target`, either way, inside `[lo, hi)`: the one that gets closest
|
|
599
|
+
before the curve turns away for good.
|
|
600
|
+
|
|
601
|
+
How a typed cursor temperature becomes a SAMPLE on a curve that doubles
|
|
602
|
+
back: the walk stays on the branch the cursor is on, where looking the
|
|
603
|
+
temperature up would take whichever branch comes first. A temperature
|
|
604
|
+
jitters sample to sample, so going back by less than `slack` degrees
|
|
605
|
+
is walked through.
|
|
606
|
+
"""
|
|
607
|
+
hi = len(values) if hi is None else int(hi)
|
|
608
|
+
lo = int(lo)
|
|
609
|
+
start = int(min(max(int(start), lo), hi - 1))
|
|
610
|
+
best = start
|
|
611
|
+
for step in (1, -1):
|
|
612
|
+
j = start
|
|
613
|
+
while lo <= j + step < hi:
|
|
614
|
+
j += step
|
|
615
|
+
gap = abs(float(values[j]) - target)
|
|
616
|
+
if gap < abs(float(values[best]) - target):
|
|
617
|
+
best = j
|
|
618
|
+
elif gap > abs(float(values[best]) - target) + slack:
|
|
619
|
+
break
|
|
620
|
+
return best
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def is_legacy_label(label):
|
|
624
|
+
"""True for a caption in the exact shape the panel once GENERATED -
|
|
625
|
+
its default words, a number, the old unit - whatever the
|
|
626
|
+
number. Such a label was never typed, and its number may be stale."""
|
|
627
|
+
import re
|
|
628
|
+
if not label:
|
|
629
|
+
return False
|
|
630
|
+
for template in _LEGACY.values():
|
|
631
|
+
prefix = template.split("%")[0]
|
|
632
|
+
pattern = (re.escape(prefix)
|
|
633
|
+
+ r"-?\d+(?:\.\d+)? (?:°C|J/g|W/g)$")
|
|
634
|
+
if re.match(pattern, str(label)):
|
|
635
|
+
return True
|
|
636
|
+
return False
|