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/model.py
ADDED
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
"""What the window is looking at: samples, scans, and the drawn objects.
|
|
2
|
+
|
|
3
|
+
UI-free. Everything the plot draws is an OBJECT with properties, because that
|
|
4
|
+
is what makes the Blender-style handling possible: a selection is a set of
|
|
5
|
+
objects, a transform writes a property, the outliner lists them, the F3
|
|
6
|
+
operators act on whichever ones are selected, and the undo stack records the
|
|
7
|
+
property that changed. A scan that is "just an array the plot happens to
|
|
8
|
+
hold" can be none of those things.
|
|
9
|
+
|
|
10
|
+
Three kinds exist so far:
|
|
11
|
+
|
|
12
|
+
* `Scan` - one segment of one file. The unit of everything: a `.tri` holds
|
|
13
|
+
a heating ramp, a cooling ramp and usually five more, and they
|
|
14
|
+
are compared individually rather than as a file.
|
|
15
|
+
* `HeatFlowArrow` - the exo/endo arrow. An object rather than a decoration,
|
|
16
|
+
so it can be dragged, hidden and right-clicked like anything
|
|
17
|
+
else, and so the convention it states is stored in one place.
|
|
18
|
+
* `Sample` - not drawn. The FILE a scan came from: its mass, its molar mass,
|
|
19
|
+
and the exotherm direction it was recorded under. Properties
|
|
20
|
+
that belong to the substance live here and are inherited by its
|
|
21
|
+
scans, because a molar mass typed once should not be typed again
|
|
22
|
+
for the second heating of the same sample.
|
|
23
|
+
|
|
24
|
+
`Document` owns them, and owns the two choices that apply to everything at
|
|
25
|
+
once: what the x axis is, and what unit the y axis is in.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from . import dtg as dtg_module
|
|
35
|
+
from . import figure as figure_module
|
|
36
|
+
from . import style
|
|
37
|
+
from . import units
|
|
38
|
+
|
|
39
|
+
#: Trace colours, in the order scans are added. Chosen to stay apart on a dark
|
|
40
|
+
#: ground and to survive being printed in grey.
|
|
41
|
+
PALETTE = ("#6ea8ff", "#ffb04e", "#7fd08a", "#e07b7b", "#c79bef",
|
|
42
|
+
"#4fd0c8", "#d8d16a", "#f08ac0")
|
|
43
|
+
|
|
44
|
+
AXIS_TEMPERATURE = "Temperature"
|
|
45
|
+
AXIS_TIME = "Time"
|
|
46
|
+
|
|
47
|
+
#: What the second y axis shows of an SDT or TGA run's weight: the percentage
|
|
48
|
+
#: of the sample mass TRIOS records ("Weight Change"), or milligrams.
|
|
49
|
+
WEIGHT_PCT = "%"
|
|
50
|
+
WEIGHT_MG = "mg"
|
|
51
|
+
WEIGHT_UNITS = (WEIGHT_PCT, WEIGHT_MG)
|
|
52
|
+
AXES = (AXIS_TEMPERATURE, AXIS_TIME)
|
|
53
|
+
|
|
54
|
+
#: What a scan draws of its segment. An SDT run records a heat flow AND a
|
|
55
|
+
#: mass, and each is a scan of its own (so the mass can be shown alone,
|
|
56
|
+
#: with no heat flow y axis at all): its own tick, offset, colour, label
|
|
57
|
+
#: and analyses. A mass scan is drawn against the mass axis
|
|
58
|
+
#: (`Document.axes["y2"]`) in `Document.weight_unit`.
|
|
59
|
+
SIGNAL_HEAT = "heat flow"
|
|
60
|
+
SIGNAL_MASS = "mass"
|
|
61
|
+
#: The derivative of the m% curve (`core/dtg.py`), a scan of its own like the
|
|
62
|
+
#: mass. It is drawn on the y axis the heat flow otherwise has
|
|
63
|
+
#: (`Document.y_signal`): while one is shown, that axis is the DTG's, and a
|
|
64
|
+
#: heat flow shown beside it has no axis to be drawn on.
|
|
65
|
+
SIGNAL_DTG = "dtg"
|
|
66
|
+
SIGNALS = (SIGNAL_HEAT, SIGNAL_MASS, SIGNAL_DTG)
|
|
67
|
+
#: The order of one segment's curves in the outliner, and so in a stack:
|
|
68
|
+
#: the mass (the main curve of an SDT run), its DTG, then the heat flow.
|
|
69
|
+
SIGNAL_ROWS = (SIGNAL_MASS, SIGNAL_DTG, SIGNAL_HEAT)
|
|
70
|
+
|
|
71
|
+
AXIS_LABEL = {
|
|
72
|
+
AXIS_TEMPERATURE: "Temperature / °C",
|
|
73
|
+
AXIS_TIME: "Time / min",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#: How a scan's own direction is decided: the net temperature change over the
|
|
77
|
+
#: segment, in kelvin. Below this the segment is called isothermal, which is
|
|
78
|
+
#: what an Equilibrate step is even though it still drifts a little.
|
|
79
|
+
ISOTHERMAL_K = 1.0
|
|
80
|
+
|
|
81
|
+
_NUMBER = re.compile(r"[-+]?\d+(?:[.,]\d+)?(?:[eE][-+]?\d+)?")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def number(value):
|
|
85
|
+
"""The number inside a TRIOS analysis field, or None.
|
|
86
|
+
|
|
87
|
+
The reader hands analyses back as they are written in the file, so a
|
|
88
|
+
cursor position arrives as `'58,4977 °C'` - a German decimal comma
|
|
89
|
+
and a unit. Every consumer here wants a float, and every one of them
|
|
90
|
+
getting this wrong in its own way is how a plot ends up with an onset at
|
|
91
|
+
zero.
|
|
92
|
+
"""
|
|
93
|
+
if value is None:
|
|
94
|
+
return None
|
|
95
|
+
if isinstance(value, (int, float)):
|
|
96
|
+
return float(value)
|
|
97
|
+
match = _NUMBER.search(str(value))
|
|
98
|
+
if not match:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return float(match.group(0).replace(",", "."))
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Obj(object):
|
|
107
|
+
"""Anything the window can select, hide, drag or right-click."""
|
|
108
|
+
|
|
109
|
+
kind = "object"
|
|
110
|
+
|
|
111
|
+
def __init__(self, oid, name=""):
|
|
112
|
+
self.id = int(oid)
|
|
113
|
+
self.name = str(name)
|
|
114
|
+
#: Drawn or not. An undoable property, so hiding is a step.
|
|
115
|
+
self.visible = True
|
|
116
|
+
#: NOT undoable and NOT saved: a selection is where the hands are,
|
|
117
|
+
#: not a decision about the figure.
|
|
118
|
+
self.selected = False
|
|
119
|
+
#: Where it is drawn in the stack of the figure, or None for its
|
|
120
|
+
#: kind's place (`z_of`): higher is on top.
|
|
121
|
+
self.z = None
|
|
122
|
+
|
|
123
|
+
def __repr__(self):
|
|
124
|
+
return "{}({!r})".format(type(self).__name__, self.name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
#: The drawing order of each kind while nobody has chosen one: curves at
|
|
128
|
+
#: the bottom, then what is drawn on them, then the figure's furniture.
|
|
129
|
+
KIND_Z = {"scan": 0.0, "analysis": 10.0,
|
|
130
|
+
"offset_marker": 20.0,
|
|
131
|
+
"arrow": 30.0, "legend": 40.0, "image": 45.0, "molecule": 46.0,
|
|
132
|
+
"label": 50.0}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def z_of(obj):
|
|
136
|
+
"""Where `obj` is drawn in the stack: its own z, or its kind's."""
|
|
137
|
+
own = getattr(obj, "z", None)
|
|
138
|
+
return float(own) if own is not None else KIND_Z.get(
|
|
139
|
+
getattr(obj, "kind", ""), 0.0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Sample(object):
|
|
143
|
+
"""One TRIOS file: the substance, not a curve.
|
|
144
|
+
|
|
145
|
+
`molar_mass` is None until somebody types it. That is the point - see
|
|
146
|
+
`core/units.py`: there is no defensible default, so the program carries
|
|
147
|
+
the absence around rather than inventing a number, and the window makes
|
|
148
|
+
the absence visible.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, path, data, exo=units.EXO_DOWN, exo_source="assumed"):
|
|
152
|
+
self.path = str(path)
|
|
153
|
+
self.data = data
|
|
154
|
+
_trim_empty_ends(data)
|
|
155
|
+
head = (data or {}).get("head", {}) or {}
|
|
156
|
+
#: The file's own name, without the extension: what the outliner and
|
|
157
|
+
#: a scan's default label say. Runs of one sample saved as "x.tri",
|
|
158
|
+
#: "x(1).tri" share their sample name.
|
|
159
|
+
self.file_name = os.path.splitext(os.path.basename(path))[0]
|
|
160
|
+
#: The sample name TRIOS stored in the file (shown as a tooltip).
|
|
161
|
+
self.sample_name = (head.get("samplename")
|
|
162
|
+
or head.get("Filename")
|
|
163
|
+
or self.file_name)
|
|
164
|
+
self.instrument = head.get("instrumenttype", "")
|
|
165
|
+
self.run_date = head.get("rundate", "")
|
|
166
|
+
self.mass_g = _mass_g(head)
|
|
167
|
+
#: Grams per mole, typed by the user. Inherited by this sample's scans
|
|
168
|
+
#: unless one of them overrides it.
|
|
169
|
+
self.molar_mass = None
|
|
170
|
+
#: The exotherm direction the FILE was recorded under, and where that
|
|
171
|
+
#: came from ("audit trail", "export header", "assumed"). The arrays
|
|
172
|
+
#: are in this convention, so a display in the opposite one is a sign
|
|
173
|
+
#: flip - and a file recorded the other way round is the one case
|
|
174
|
+
#: where assuming would silently invert a figure.
|
|
175
|
+
self.exo = exo
|
|
176
|
+
self.exo_source = exo_source
|
|
177
|
+
#: Anything the reader printed while reading this file.
|
|
178
|
+
self.note = ""
|
|
179
|
+
self.scans = []
|
|
180
|
+
#: What its molar mass was worked out from in the calculator (a
|
|
181
|
+
#: formula, a SMILES or a composition), or None.
|
|
182
|
+
self.composition = None
|
|
183
|
+
#: The name the user gave it (F2 in the outliner), or None for the
|
|
184
|
+
#: file's own. Its curves' names, the legend and exports follow it.
|
|
185
|
+
self.title = None
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def name(self):
|
|
189
|
+
return self.title or self.file_name
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def mass_source(self):
|
|
193
|
+
"""Where the sample mass came from: "recorded" (the file's own
|
|
194
|
+
field, or an export's header), "derived from the weight" (an SDT
|
|
195
|
+
run: the reader's Weight / Weight Change, an inference however
|
|
196
|
+
exact), or None when there is no mass."""
|
|
197
|
+
if not self.mass_g:
|
|
198
|
+
return None
|
|
199
|
+
head = (self.data or {}).get("head", {}) or {}
|
|
200
|
+
return head.get("mass_source") or "recorded"
|
|
201
|
+
|
|
202
|
+
def mass_text(self):
|
|
203
|
+
""""21.5473 mg (derived from the weight)", "8 mg", or None."""
|
|
204
|
+
if not self.mass_g:
|
|
205
|
+
return None
|
|
206
|
+
derived = self.mass_source == "derived from the weight"
|
|
207
|
+
return "{:g} mg{}".format(self.mass_g * 1000.0,
|
|
208
|
+
" (derived from the weight)"
|
|
209
|
+
if derived else "")
|
|
210
|
+
|
|
211
|
+
def segment_count(self):
|
|
212
|
+
return len((self.data or {}).get("numdata", []))
|
|
213
|
+
|
|
214
|
+
def analyses_for(self, seg):
|
|
215
|
+
"""Every stored analysis that belongs to segment `seg` (0-based).
|
|
216
|
+
|
|
217
|
+
Two traps, both of which draw an analysis on the wrong scan while
|
|
218
|
+
looking perfectly plausible:
|
|
219
|
+
|
|
220
|
+
* **`segment` in the reader's output is ONE-BASED** (`trios_io` writes
|
|
221
|
+
`j + 1`, to match the number TRIOS shows). Comparing it to a
|
|
222
|
+
0-based index puts every onset on the next scan down - a cooling
|
|
223
|
+
run, where it is not obviously wrong until somebody quotes it.
|
|
224
|
+
* **A `.txt` export has no `segment` at all.** Its analyses are keyed
|
|
225
|
+
by the step NAME, and three segments of a run routinely share one.
|
|
226
|
+
So such an analysis is OFFERED under every segment whose program
|
|
227
|
+
carries that name, marked "by step name", and the user attributes
|
|
228
|
+
it by showing it on the scan it belongs to (an analysis is off
|
|
229
|
+
until ticked, and it is ticked on a scan the user picked, so the
|
|
230
|
+
choice is the attribution). Giving it to the first segment with
|
|
231
|
+
the name meant the second heating's onset could not be found from
|
|
232
|
+
the second heating.
|
|
233
|
+
"""
|
|
234
|
+
out = []
|
|
235
|
+
blocks = (self.data or {}).get("analyses", {}) or {}
|
|
236
|
+
numdata = (self.data or {}).get("numdata", [])
|
|
237
|
+
if seg >= len(numdata):
|
|
238
|
+
return out
|
|
239
|
+
prog = str(numdata[seg].get("prog", ""))
|
|
240
|
+
stem = prog.rsplit(" #", 1)[0]
|
|
241
|
+
for key, models in blocks.items():
|
|
242
|
+
for model_name, entries in models.items():
|
|
243
|
+
for entry in entries:
|
|
244
|
+
number_ = entry.get("segment")
|
|
245
|
+
if number_ is not None:
|
|
246
|
+
if int(number_) - 1 != seg:
|
|
247
|
+
continue
|
|
248
|
+
item = dict(entry)
|
|
249
|
+
else:
|
|
250
|
+
if key not in (prog, stem):
|
|
251
|
+
continue
|
|
252
|
+
item = dict(entry)
|
|
253
|
+
item["attribution"] = "by step name"
|
|
254
|
+
item.setdefault("Model", model_name)
|
|
255
|
+
out.append(item)
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _trim_empty_ends(data):
|
|
260
|
+
"""Drop the TRAILING samples of a segment whose temperature or recorded
|
|
261
|
+
heat flow holds no measurement: the flagged tail of a run's last
|
|
262
|
+
segment, which the reader returns as NaN (TRI-FORMAT.md section 3 - a
|
|
263
|
+
DSC25's Temperature 5 and Heat Flow 35 samples, an SDT650's 25).
|
|
264
|
+
|
|
265
|
+
Off the END only, so every sample keeps its index counted from the
|
|
266
|
+
segment's start: a session stores sample spans, and a marker's sample
|
|
267
|
+
and `trace.first` count from there too. Trimming the start as well
|
|
268
|
+
would shift every index of a run that flags its FIRST samples (some
|
|
269
|
+
DSC25 runs do, in segment 1). NaN at the start or in the middle
|
|
270
|
+
stays, and whatever reads the arrays has to skip it; the curve is drawn
|
|
271
|
+
broken there. The heat flow is the one `Scan.heat_flow` reads: watts
|
|
272
|
+
when the file has them, else the normalised one of a `.txt` export."""
|
|
273
|
+
for step in (data or {}).get("numdata", []) or []:
|
|
274
|
+
dims = step.get("dims") or []
|
|
275
|
+
nums = step.get("nums")
|
|
276
|
+
if nums is None or not len(nums):
|
|
277
|
+
continue
|
|
278
|
+
flow = ("Heat Flow" if "Heat Flow" in dims
|
|
279
|
+
else "Heat Flow (Normalized)")
|
|
280
|
+
columns = [dims.index(name) for name in ("Temperature", flow)
|
|
281
|
+
if name in dims]
|
|
282
|
+
if not columns:
|
|
283
|
+
continue
|
|
284
|
+
with np.errstate(invalid="ignore"):
|
|
285
|
+
good = np.all(np.isfinite(
|
|
286
|
+
np.asarray(nums[:, columns], dtype=float)), axis=1)
|
|
287
|
+
measured = np.flatnonzero(good)
|
|
288
|
+
if not len(measured):
|
|
289
|
+
continue
|
|
290
|
+
last = int(measured[-1])
|
|
291
|
+
if last < len(nums) - 1:
|
|
292
|
+
step["nums"] = nums[:last + 1]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _measured(values):
|
|
296
|
+
"""True when `values` holds at least one measured (finite) sample."""
|
|
297
|
+
if values is None or not len(values):
|
|
298
|
+
return False
|
|
299
|
+
with np.errstate(invalid="ignore"):
|
|
300
|
+
return bool(np.isfinite(np.asarray(values, dtype=float)).any())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _mass_g(head):
|
|
304
|
+
"""Sample mass in grams from the reader's header, or None.
|
|
305
|
+
|
|
306
|
+
`samplesize` is in milligrams and may carry a German decimal comma; the
|
|
307
|
+
reader also writes a formatted `Sample Mass`. Either will do, and neither
|
|
308
|
+
is guaranteed.
|
|
309
|
+
"""
|
|
310
|
+
for key in ("Sample Mass", "samplesize"):
|
|
311
|
+
value = number(head.get(key))
|
|
312
|
+
# A mass that is not positive is none: dividing by one turns a curve
|
|
313
|
+
# upside down under an exo arrow that still says it is the right
|
|
314
|
+
# way up (real runs whose balance read -99.9 mg).
|
|
315
|
+
if value and value > 0:
|
|
316
|
+
return value / 1000.0
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Scan(Obj):
|
|
321
|
+
"""One segment of one file: a curve with a place in the stack."""
|
|
322
|
+
|
|
323
|
+
kind = "scan"
|
|
324
|
+
|
|
325
|
+
def __init__(self, oid, sample, seg, colour, signal=SIGNAL_HEAT):
|
|
326
|
+
Obj.__init__(self, oid, "")
|
|
327
|
+
self.sample = sample
|
|
328
|
+
self.seg = int(seg)
|
|
329
|
+
self.colour = str(colour)
|
|
330
|
+
#: `SIGNAL_HEAT` or `SIGNAL_MASS`: which of the segment's curves this
|
|
331
|
+
#: scan is. Fixed for its life; a segment's other curve is another
|
|
332
|
+
#: scan.
|
|
333
|
+
self.signal = signal if signal in SIGNALS else SIGNAL_HEAT
|
|
334
|
+
#: A DTG's smoothing window, in kelvin of the ramp (`core/dtg.py`).
|
|
335
|
+
self.dtg_window = dtg_module.WINDOW_K
|
|
336
|
+
#: Vertical placement, in the unit the y axis is currently showing.
|
|
337
|
+
#: Continuous, dragged with the mouse or typed after G - never a slot
|
|
338
|
+
#: in a stacking order: DSC scans sit where they are put.
|
|
339
|
+
self.offset = 0.0
|
|
340
|
+
#: None follows the house style (`core/style.py`); read it through
|
|
341
|
+
#: `style.value`, never directly.
|
|
342
|
+
self.line_width = None
|
|
343
|
+
#: Which part of the segment is DRAWN, as fractions of its samples
|
|
344
|
+
#: counted from the start: the DSC_Plotter template's `x_truncate`
|
|
345
|
+
#: (`x0=0.01` hides the first 1 %), so the driver export repeats it
|
|
346
|
+
#: exactly. By POSITION ALONG THE CURVE and never by temperature: a
|
|
347
|
+
#: segment's temperature doubles back at its start and runs backwards
|
|
348
|
+
#: when cooling, so a temperature window cuts every branch at once.
|
|
349
|
+
#: The hidden ends are left out of the fit, the picking, arranging,
|
|
350
|
+
#: exports and analyses, and drawn dashed only on hover.
|
|
351
|
+
self.keep = (0.0, 1.0)
|
|
352
|
+
#: None means "use the program string", which is what it says in
|
|
353
|
+
#: TRIOS. A typed one wins.
|
|
354
|
+
self.label = None
|
|
355
|
+
#: The analyses drawn on this scan, as objects. Built from the file
|
|
356
|
+
#: the first time they are asked for; see `analysis_objects`.
|
|
357
|
+
self._analyses = None
|
|
358
|
+
#: Its y-offset marker (the template's `add_yoffset_markers`), drawn
|
|
359
|
+
#: while the figure's markers are switched on.
|
|
360
|
+
self.marker = OffsetMarker(oid, self)
|
|
361
|
+
self._cache_key = None
|
|
362
|
+
self._cache = None
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------- identity
|
|
365
|
+
@property
|
|
366
|
+
def step(self):
|
|
367
|
+
"""The reader's record for this segment."""
|
|
368
|
+
return self.sample.data["numdata"][self.seg]
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def program(self):
|
|
372
|
+
"""The TRIOS program string, with German decimal commas fixed."""
|
|
373
|
+
return str(self.step.get("prog", "")).replace(",", ".")
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def molar_mass(self):
|
|
377
|
+
"""The molar mass in force: the SAMPLE's. Every scan of a file has
|
|
378
|
+
the same one (there is no way to prove otherwise), so a scan has no
|
|
379
|
+
override of its own."""
|
|
380
|
+
return self.sample.molar_mass
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def is_mass(self):
|
|
384
|
+
return self.signal == SIGNAL_MASS
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def is_dtg(self):
|
|
388
|
+
return self.signal == SIGNAL_DTG
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def is_heat(self):
|
|
392
|
+
return self.signal == SIGNAL_HEAT
|
|
393
|
+
|
|
394
|
+
def display_name(self):
|
|
395
|
+
"""What the label beside the curve says."""
|
|
396
|
+
if self.label:
|
|
397
|
+
return str(self.label)
|
|
398
|
+
return "{} {}{}".format(self.sample.name, self.short_program(),
|
|
399
|
+
" mass" if self.is_mass
|
|
400
|
+
else " DTG" if self.is_dtg else "")
|
|
401
|
+
|
|
402
|
+
def short_program(self):
|
|
403
|
+
""""#3 heat 10 K/min" - the segment number, what it does, how fast.
|
|
404
|
+
|
|
405
|
+
The program string says what was ASKED for ("Ramp 10.00 C/min to
|
|
406
|
+
250.000 C"); the direction says what the sample actually did, which is
|
|
407
|
+
not the same thing for the final segment of a run that started from a
|
|
408
|
+
passive cool. The number is the segment index as TRIOS counts it, so
|
|
409
|
+
it matches what the operator sees in TRIOS.
|
|
410
|
+
"""
|
|
411
|
+
prog = self.program
|
|
412
|
+
index = "#{}".format(self.seg + 1)
|
|
413
|
+
rate = _rate(prog)
|
|
414
|
+
if self.temperature() is None:
|
|
415
|
+
# No temperature recorded for this segment, so what the sample
|
|
416
|
+
# DID cannot be measured. Say what was ASKED for instead of
|
|
417
|
+
# calling a 50 K/min ramp isothermal, which is what happens when
|
|
418
|
+
# an unmeasurable direction defaults to "iso".
|
|
419
|
+
verb = prog.split()[0].lower() if prog.split() else "segment"
|
|
420
|
+
return ("{} {} {:g} K/min".format(index, verb, rate) if rate
|
|
421
|
+
else "{} {}".format(index, verb))
|
|
422
|
+
move = self.direction()
|
|
423
|
+
if move == "iso":
|
|
424
|
+
target = number(prog.split("to")[-1]) if "to" in prog else None
|
|
425
|
+
temp = self.temperature()
|
|
426
|
+
if temp is not None:
|
|
427
|
+
temp = temp[np.isfinite(temp)] # flagged samples
|
|
428
|
+
value = target if target is not None else (
|
|
429
|
+
float(np.mean(temp)) if temp is not None and len(temp) else None)
|
|
430
|
+
return ("{} iso {:.0f} °C".format(index, value)
|
|
431
|
+
if value is not None else "{} iso".format(index))
|
|
432
|
+
word = "heat" if move == "up" else "cool"
|
|
433
|
+
if rate:
|
|
434
|
+
return "{} {} {:g} K/min".format(index, word, rate)
|
|
435
|
+
return "{} {}".format(index, word)
|
|
436
|
+
|
|
437
|
+
def direction(self):
|
|
438
|
+
""""up", "down" or "iso", from the temperature the sample reached -
|
|
439
|
+
between the first and the last MEASURED sample: a run can flag its
|
|
440
|
+
first samples (NaN), and NaN compared with anything called a heating
|
|
441
|
+
ramp "cool" (an indium check run)."""
|
|
442
|
+
temp = self.temperature()
|
|
443
|
+
if temp is not None:
|
|
444
|
+
temp = temp[np.isfinite(temp)]
|
|
445
|
+
if temp is None or len(temp) < 2:
|
|
446
|
+
return "iso"
|
|
447
|
+
change = float(temp[-1]) - float(temp[0])
|
|
448
|
+
if abs(change) < ISOTHERMAL_K:
|
|
449
|
+
return "iso"
|
|
450
|
+
return "up" if change > 0 else "down"
|
|
451
|
+
|
|
452
|
+
# ----------------------------------------------------------------- data
|
|
453
|
+
def _column(self, name):
|
|
454
|
+
step = self.step
|
|
455
|
+
dims = step.get("dims") or []
|
|
456
|
+
if name not in dims:
|
|
457
|
+
return None
|
|
458
|
+
return step["nums"][:, dims.index(name)]
|
|
459
|
+
|
|
460
|
+
def temperature(self):
|
|
461
|
+
return self._column("Temperature")
|
|
462
|
+
|
|
463
|
+
def time_min(self):
|
|
464
|
+
return self._column("Time")
|
|
465
|
+
|
|
466
|
+
def heat_flow(self):
|
|
467
|
+
"""`(values, base_unit)` for the heat flow as the FILE stored it.
|
|
468
|
+
|
|
469
|
+
A `.tri` stores watts; a TRIOS `.txt` export stores only "Heat Flow
|
|
470
|
+
(Normalized)" in W/g. Converting the export back to watts would need
|
|
471
|
+
the mass, which the export does not always carry - and then a file
|
|
472
|
+
that is already in the unit the axis wants could not be drawn in it.
|
|
473
|
+
So the base travels with the values and `core/units.py` works out what
|
|
474
|
+
is still needed.
|
|
475
|
+
|
|
476
|
+
`(None, None)` when the segment records no heat flow at all. An
|
|
477
|
+
indium calibration run's ramp was thought to be one until its
|
|
478
|
+
flagged arrays were read (TRI-FORMAT.md section 3); no real file
|
|
479
|
+
read so far is one, but a segment can still lack a signal.
|
|
480
|
+
"""
|
|
481
|
+
watts = self._column("Heat Flow")
|
|
482
|
+
if watts is not None:
|
|
483
|
+
return watts, units.BASE_UNIT
|
|
484
|
+
normalised = self._column("Heat Flow (Normalized)")
|
|
485
|
+
if normalised is not None:
|
|
486
|
+
return normalised, units.UNIT_W_G
|
|
487
|
+
return None, None
|
|
488
|
+
|
|
489
|
+
def _weight_column(self, unit):
|
|
490
|
+
"""The segment's own weight column in `unit` ("%" or "mg"), or None.
|
|
491
|
+
|
|
492
|
+
Decided by the column's UNIT wherever the step states one, and by
|
|
493
|
+
the reader's name only where it does not: "Weight" is mg, "Weight
|
|
494
|
+
Change" is % (TRIOS's signal list). A TRIOS export calls its
|
|
495
|
+
percentage "Weight" too, with "%" beside it, and reading that by the
|
|
496
|
+
name drew 99.7 % as 99.7 mg and as 462 % of a 21.5 mg sample. A
|
|
497
|
+
"Weight Change" in mg is a CHANGE of weight, which is neither."""
|
|
498
|
+
step = self.step
|
|
499
|
+
dims = step.get("dims") or []
|
|
500
|
+
stated = list(step.get("units") or [])
|
|
501
|
+
for index, name in enumerate(dims):
|
|
502
|
+
if name not in ("Weight", "Weight Change"):
|
|
503
|
+
continue
|
|
504
|
+
said = (str(stated[index]).strip() if index < len(stated)
|
|
505
|
+
and stated[index] else "")
|
|
506
|
+
if said == "%":
|
|
507
|
+
kind = WEIGHT_PCT
|
|
508
|
+
elif said == "mg":
|
|
509
|
+
kind = WEIGHT_MG if name == "Weight" else None
|
|
510
|
+
elif said:
|
|
511
|
+
kind = None
|
|
512
|
+
else:
|
|
513
|
+
kind = WEIGHT_PCT if name == "Weight Change" else WEIGHT_MG
|
|
514
|
+
if kind == unit:
|
|
515
|
+
return step["nums"][:, index]
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
def has_weight(self):
|
|
519
|
+
"""True when this segment recorded a weight: an SDT or TGA run."""
|
|
520
|
+
return (self._weight_column(WEIGHT_PCT) is not None
|
|
521
|
+
or self._weight_column(WEIGHT_MG) is not None)
|
|
522
|
+
|
|
523
|
+
def weight_values(self, unit=WEIGHT_PCT):
|
|
524
|
+
"""The weight in `unit` ("%" of the sample mass, or "mg"), or None
|
|
525
|
+
when that needs a sample mass there is not (`weight_missing_for`
|
|
526
|
+
says which).
|
|
527
|
+
|
|
528
|
+
Each unit is the file's own column where it recorded one. The other
|
|
529
|
+
is made from it with the sample mass, and without one there is none -
|
|
530
|
+
never a percentage of some other reference (golden rule 4). A
|
|
531
|
+
percentage recorded BESIDE the milligrams also needs the mass: the
|
|
532
|
+
reader finds none exactly when Weight / Weight Change is no single
|
|
533
|
+
positive mass (TRI-FORMAT.md section 3b), and then the percentage is
|
|
534
|
+
of a reference nobody knows - on some real runs a negative one, which
|
|
535
|
+
turns the weight loss the percentage shows into a gain."""
|
|
536
|
+
mass = self.sample.mass_g
|
|
537
|
+
percent = self._weight_column(WEIGHT_PCT)
|
|
538
|
+
grams = self._weight_column(WEIGHT_MG)
|
|
539
|
+
if unit == WEIGHT_MG:
|
|
540
|
+
if grams is not None:
|
|
541
|
+
return grams
|
|
542
|
+
if percent is not None and mass:
|
|
543
|
+
return percent / 100.0 * (float(mass) * 1000.0)
|
|
544
|
+
return None
|
|
545
|
+
if percent is not None and (mass or grams is None):
|
|
546
|
+
return percent
|
|
547
|
+
if grams is not None and mass:
|
|
548
|
+
return grams / (float(mass) * 1000.0) * 100.0
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def weight_missing_for(self, unit, axis=None):
|
|
552
|
+
"""What stops this scan's weight being drawn in `unit` against
|
|
553
|
+
`axis`, or None - `missing_for` for the weight: "weight in this
|
|
554
|
+
segment" (none recorded, or every sample flagged), "temperature in
|
|
555
|
+
this segment" (an isothermal that recorded none), or "sample mass"
|
|
556
|
+
(mg from a percentage, or a percentage whose mass is unknown)."""
|
|
557
|
+
if not self.has_weight():
|
|
558
|
+
return "weight in this segment"
|
|
559
|
+
recorded = [c for c in (self._weight_column(WEIGHT_PCT),
|
|
560
|
+
self._weight_column(WEIGHT_MG))
|
|
561
|
+
if c is not None]
|
|
562
|
+
if not any(_measured(c) for c in recorded):
|
|
563
|
+
return "weight in this segment"
|
|
564
|
+
if axis is not None and not _measured(self.x_values(axis)):
|
|
565
|
+
return "{} in this segment".format(axis.lower())
|
|
566
|
+
if self.weight_values(unit) is None:
|
|
567
|
+
return "sample mass"
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
def weight_curve(self, axis, unit=WEIGHT_PCT, x_unit=units.TEMP_C):
|
|
571
|
+
"""`(x, w)` of the weight, the KEPT samples only (like `kept_curve`),
|
|
572
|
+
or `(None, None)` when it cannot be drawn (`weight_missing_for`)."""
|
|
573
|
+
if self.weight_missing_for(unit, axis) is not None:
|
|
574
|
+
return None, None
|
|
575
|
+
x = self.x_values(axis)
|
|
576
|
+
weight = self.weight_values(unit)
|
|
577
|
+
if axis == AXIS_TEMPERATURE:
|
|
578
|
+
x = units.from_celsius(x, x_unit)
|
|
579
|
+
k0, k1 = self.kept_range(len(x))
|
|
580
|
+
return x[k0:k1], weight[k0:k1]
|
|
581
|
+
|
|
582
|
+
def weight_hidden(self, axis, unit=WEIGHT_PCT, x_unit=units.TEMP_C):
|
|
583
|
+
"""The weight's truncated ends as `[(x, w), ...]`, each overlapping
|
|
584
|
+
the kept part by one sample (like a trace's `hidden`)."""
|
|
585
|
+
if self.weight_missing_for(unit, axis) is not None:
|
|
586
|
+
return []
|
|
587
|
+
x = self.x_values(axis)
|
|
588
|
+
weight = self.weight_values(unit)
|
|
589
|
+
if axis == AXIS_TEMPERATURE:
|
|
590
|
+
x = units.from_celsius(x, x_unit)
|
|
591
|
+
k0, k1 = self.kept_range(len(x))
|
|
592
|
+
out = []
|
|
593
|
+
if k0 > 0:
|
|
594
|
+
out.append((x[:k0 + 1], weight[:k0 + 1]))
|
|
595
|
+
if k1 < len(x):
|
|
596
|
+
out.append((x[k1 - 1:], weight[k1 - 1:]))
|
|
597
|
+
return out
|
|
598
|
+
|
|
599
|
+
def dtg_values(self, unit=dtg_module.PER_DEGREE):
|
|
600
|
+
"""The DTG in `unit` (%/degC or %/min), one value per sample, from
|
|
601
|
+
the segment's m%; None when it cannot be worked out
|
|
602
|
+
(`dtg_missing_for`)."""
|
|
603
|
+
return dtg_module.dtg(self.time_min(), self.temperature(),
|
|
604
|
+
self.weight_values(WEIGHT_PCT), unit,
|
|
605
|
+
self.dtg_window)
|
|
606
|
+
|
|
607
|
+
def dtg_missing_for(self, unit, axis=None):
|
|
608
|
+
"""What stops this scan's DTG being drawn in `unit`, or None."""
|
|
609
|
+
if not self.has_weight():
|
|
610
|
+
return "weight in this segment"
|
|
611
|
+
if self.weight_values(WEIGHT_PCT) is None:
|
|
612
|
+
return "sample mass"
|
|
613
|
+
if axis is not None and not _measured(self.x_values(axis)):
|
|
614
|
+
return "{} in this segment".format(axis.lower())
|
|
615
|
+
return dtg_module.missing(self.time_min(), self.temperature(),
|
|
616
|
+
self.weight_values(WEIGHT_PCT), unit)
|
|
617
|
+
|
|
618
|
+
def heating_rate(self):
|
|
619
|
+
"""The segment's fitted heating rate, K/min, or None."""
|
|
620
|
+
return dtg_module.heating_rate(self.time_min(), self.temperature())
|
|
621
|
+
|
|
622
|
+
def heat_flow_w(self):
|
|
623
|
+
"""Heat flow in WATTS, or None when that needs a mass there is not."""
|
|
624
|
+
values, base = self.heat_flow()
|
|
625
|
+
if values is None:
|
|
626
|
+
return None
|
|
627
|
+
if base == units.BASE_UNIT:
|
|
628
|
+
return values
|
|
629
|
+
return (values * float(self.sample.mass_g)
|
|
630
|
+
if self.sample.mass_g else None)
|
|
631
|
+
|
|
632
|
+
def x_values(self, axis):
|
|
633
|
+
return (self.temperature() if axis == AXIS_TEMPERATURE
|
|
634
|
+
else self.time_min())
|
|
635
|
+
|
|
636
|
+
def missing_for(self, unit, axis=None):
|
|
637
|
+
"""What stops this scan being drawn, or None.
|
|
638
|
+
|
|
639
|
+
Reports a missing SIGNAL as readily as a missing number, so a segment
|
|
640
|
+
the instrument recorded without a heat flow is flagged in the plot the
|
|
641
|
+
same way a scan waiting for its molar mass is - rather than quietly
|
|
642
|
+
being absent, which is the one outcome that misleads. `unit` is the
|
|
643
|
+
scan's own axis's (`Document.unit_for`): a mass scan's is "%" or
|
|
644
|
+
"mg".
|
|
645
|
+
"""
|
|
646
|
+
if self.is_mass:
|
|
647
|
+
return self.weight_missing_for(unit, axis)
|
|
648
|
+
if self.is_dtg:
|
|
649
|
+
return self.dtg_missing_for(unit, axis)
|
|
650
|
+
values, base = self.heat_flow()
|
|
651
|
+
# A column whose every sample is flagged (NaN) is no signal either:
|
|
652
|
+
# a range made of it is NaN, and a NaN range made `_nice_step` raise
|
|
653
|
+
# inside paintEvent - an abort, not a message.
|
|
654
|
+
if values is None or not _measured(values):
|
|
655
|
+
return "heat flow in this segment"
|
|
656
|
+
if axis is not None and not _measured(self.x_values(axis)):
|
|
657
|
+
return "{} in this segment".format(axis.lower())
|
|
658
|
+
return units.missing(unit, base, self.sample.mass_g, self.molar_mass)
|
|
659
|
+
|
|
660
|
+
def factor(self, unit):
|
|
661
|
+
"""What one unit of the stored signal is in `unit`, or None - what
|
|
662
|
+
an offset converts by when the axis changes unit. A mass scan's base
|
|
663
|
+
is the milligram: "%" is 100 / the sample mass."""
|
|
664
|
+
if self.is_mass:
|
|
665
|
+
if unit == WEIGHT_MG:
|
|
666
|
+
return 1.0
|
|
667
|
+
mass = self.sample.mass_g
|
|
668
|
+
return 100.0 / (float(mass) * 1000.0) if mass else None
|
|
669
|
+
if self.is_dtg:
|
|
670
|
+
return dtg_module.factor(unit, self.heating_rate())
|
|
671
|
+
_values, base = self.heat_flow()
|
|
672
|
+
if base is None:
|
|
673
|
+
return None
|
|
674
|
+
return units.factor(unit, base, self.sample.mass_g,
|
|
675
|
+
self.molar_mass)[0]
|
|
676
|
+
|
|
677
|
+
def curve(self, axis, unit, exo, x_unit=units.TEMP_C):
|
|
678
|
+
"""`(x, y)` ready to draw: converted, flipped, scaled, offset.
|
|
679
|
+
|
|
680
|
+
Returns `(None, None)` when the scan cannot be drawn in this unit -
|
|
681
|
+
no mass, no molar mass - rather than substituting anything. The window
|
|
682
|
+
then draws the scan's ABSENCE (see `ui/plot.py`), which is the honest
|
|
683
|
+
picture: a scan that is waiting for a molar mass must be visible as
|
|
684
|
+
such, not quietly plotted wrong.
|
|
685
|
+
"""
|
|
686
|
+
key = (axis, unit, exo, self.offset, x_unit,
|
|
687
|
+
self.sample.mass_g, self.molar_mass, self.sample.exo,
|
|
688
|
+
self.dtg_window if self.is_dtg else None)
|
|
689
|
+
if self._cache_key == key:
|
|
690
|
+
return self._cache
|
|
691
|
+
x = self.x_values(axis)
|
|
692
|
+
if x is not None and axis == AXIS_TEMPERATURE:
|
|
693
|
+
x = units.from_celsius(x, x_unit)
|
|
694
|
+
if self.is_mass:
|
|
695
|
+
values, base = self.weight_values(unit), unit
|
|
696
|
+
elif self.is_dtg:
|
|
697
|
+
values = (self.dtg_values(unit)
|
|
698
|
+
if self.dtg_missing_for(unit) is None else None)
|
|
699
|
+
base = unit
|
|
700
|
+
else:
|
|
701
|
+
values, base = self.heat_flow()
|
|
702
|
+
y = None
|
|
703
|
+
if _measured(x) and _measured(values):
|
|
704
|
+
y = self._on_axes(values, base, unit, exo)
|
|
705
|
+
if y is None:
|
|
706
|
+
self._cache_key, self._cache = key, (None, None)
|
|
707
|
+
return self._cache
|
|
708
|
+
self._cache_key, self._cache = key, (x, y)
|
|
709
|
+
return self._cache
|
|
710
|
+
|
|
711
|
+
def _on_axes(self, values, base, unit, exo):
|
|
712
|
+
"""Heat flow `values`, stored in `base` ("W" or "W/g"), as this
|
|
713
|
+
scan's y shows it in `unit`: converted, flipped to the figure's exo
|
|
714
|
+
direction, offset. None when the unit needs a number that is not
|
|
715
|
+
there (`units.factor`) - never a substitute.
|
|
716
|
+
|
|
717
|
+
The ONE place this is done: the curve goes through it, and so does
|
|
718
|
+
anything drawn at the curve's heat flow (`axes_points`, a tangent
|
|
719
|
+
construction), so a point taken off the curve lands on it in every
|
|
720
|
+
unit."""
|
|
721
|
+
if base is None:
|
|
722
|
+
return None
|
|
723
|
+
if self.is_dtg:
|
|
724
|
+
# Never flipped by the exotherm's direction either; the unit is
|
|
725
|
+
# the one it was worked out in (`dtg_values`).
|
|
726
|
+
if base == unit:
|
|
727
|
+
return values + float(self.offset)
|
|
728
|
+
old = dtg_module.factor(base, self.heating_rate())
|
|
729
|
+
new = dtg_module.factor(unit, self.heating_rate())
|
|
730
|
+
if not old or not new:
|
|
731
|
+
return None
|
|
732
|
+
return values * (new / old) + float(self.offset)
|
|
733
|
+
if self.is_mass:
|
|
734
|
+
# A mass is never flipped by the exotherm's direction; `base`
|
|
735
|
+
# is "%" or "mg", converted with the sample mass when it is not
|
|
736
|
+
# the axis's.
|
|
737
|
+
if base == unit:
|
|
738
|
+
return values + float(self.offset)
|
|
739
|
+
mass = self.sample.mass_g
|
|
740
|
+
if not mass:
|
|
741
|
+
return None
|
|
742
|
+
mg = float(mass) * 1000.0
|
|
743
|
+
scale = mg / 100.0 if base == WEIGHT_PCT else 100.0 / mg
|
|
744
|
+
return values * scale + float(self.offset)
|
|
745
|
+
scale = units.factor(unit, base, self.sample.mass_g,
|
|
746
|
+
self.molar_mass)[0]
|
|
747
|
+
if scale is None:
|
|
748
|
+
return None
|
|
749
|
+
# The arrays are in the FILE's convention, so a flip is needed only
|
|
750
|
+
# when the figure is drawn in the other one.
|
|
751
|
+
sign = 1.0 if exo == self.sample.exo else -1.0
|
|
752
|
+
return values * (scale * sign) + float(self.offset)
|
|
753
|
+
|
|
754
|
+
def axes_points(self, points, base, unit, exo, x_unit=units.TEMP_C):
|
|
755
|
+
"""`(x, y)` arrays for `points` - `[[degC, heat flow in base], ...]`,
|
|
756
|
+
a tangent construction - on the temperature axis and this scan's y,
|
|
757
|
+
exactly as `curve` maps the scan's own samples. `(None, None)` when
|
|
758
|
+
the unit needs a sample or molar mass this scan does not have."""
|
|
759
|
+
if points is None or not len(points):
|
|
760
|
+
return None, None
|
|
761
|
+
array = np.asarray(points, dtype=float).reshape(-1, 2)
|
|
762
|
+
y = self._on_axes(array[:, 1], base, unit, exo)
|
|
763
|
+
if y is None:
|
|
764
|
+
return None, None
|
|
765
|
+
return units.from_celsius(array[:, 0], x_unit), y
|
|
766
|
+
|
|
767
|
+
def kept_range(self, count):
|
|
768
|
+
"""`(k0, k1)`: the slice of `count` samples that is drawn.
|
|
769
|
+
|
|
770
|
+
The template's own arithmetic (`x_truncate` takes `x[k0:k1]` with
|
|
771
|
+
`k = int(n * fraction)`), so the panel and the driver it exports hide
|
|
772
|
+
the same samples; at least two are always kept.
|
|
773
|
+
"""
|
|
774
|
+
start, end = self.keep
|
|
775
|
+
k0, k1 = sorted([int(count * float(start)), int(count * float(end))])
|
|
776
|
+
k0 = max(0, min(k0, count))
|
|
777
|
+
k1 = max(min(count, k0 + 2), min(k1, count))
|
|
778
|
+
return k0, k1
|
|
779
|
+
|
|
780
|
+
def is_truncated(self):
|
|
781
|
+
return tuple(self.keep) != (0.0, 1.0)
|
|
782
|
+
|
|
783
|
+
def kept_curve(self, axis, unit, exo, x_unit=units.TEMP_C):
|
|
784
|
+
"""`curve` without the hidden ends: what is drawn, fitted, arranged
|
|
785
|
+
and exported."""
|
|
786
|
+
x, y = self.curve(axis, unit, exo, x_unit)
|
|
787
|
+
if x is None:
|
|
788
|
+
return x, y
|
|
789
|
+
k0, k1 = self.kept_range(len(x))
|
|
790
|
+
return x[k0:k1], y[k0:k1]
|
|
791
|
+
|
|
792
|
+
def baseline_y(self, axis, unit, exo):
|
|
793
|
+
"""Where this scan's zero sits on screen: its offset, plus nothing.
|
|
794
|
+
|
|
795
|
+
The offset arrow is drawn from here, and the number it shows is this
|
|
796
|
+
number, so the two cannot disagree.
|
|
797
|
+
"""
|
|
798
|
+
return float(self.offset)
|
|
799
|
+
|
|
800
|
+
def analyses(self):
|
|
801
|
+
"""The raw analysis records the file holds for this segment."""
|
|
802
|
+
return self.sample.analyses_for(self.seg)
|
|
803
|
+
|
|
804
|
+
@property
|
|
805
|
+
def analysis_objects(self):
|
|
806
|
+
"""`Analysis` objects for this scan, built once and then kept.
|
|
807
|
+
|
|
808
|
+
Built lazily because a scan that is never shown never needs them, and
|
|
809
|
+
kept because they carry state the user sets: which are visible, their
|
|
810
|
+
colours, and any that were moved here from another scan.
|
|
811
|
+
"""
|
|
812
|
+
if self._analyses is None:
|
|
813
|
+
self._analyses = []
|
|
814
|
+
# A DTG is worked out here; no analysis of the file is its.
|
|
815
|
+
for entry in ([] if self.is_dtg else self.analyses()):
|
|
816
|
+
attribution = entry.get("attribution") or "cached curve"
|
|
817
|
+
curve = _analysed_curve(entry, self.has_weight())
|
|
818
|
+
# A file's analysis goes to the scan of the curve it was
|
|
819
|
+
# made on: an SDT run's onsets of mass loss are the MASS
|
|
820
|
+
# scan's, never drawn at the heat flow.
|
|
821
|
+
if curve == "weight" and not self.is_mass:
|
|
822
|
+
continue
|
|
823
|
+
if curve != "weight" and self.is_mass:
|
|
824
|
+
continue
|
|
825
|
+
if curve is None:
|
|
826
|
+
# A run with a heat flow AND a weight, and the file does
|
|
827
|
+
# not say which this was made on: offered on the heat
|
|
828
|
+
# flow, never as certain (dashed, a question mark).
|
|
829
|
+
attribution = CURVE_NOT_STATED
|
|
830
|
+
self._analyses.append(Analysis(
|
|
831
|
+
id(entry) % 1000000, self, entry.get("Model", "analysis"),
|
|
832
|
+
entry, source="file", attribution=attribution))
|
|
833
|
+
return self._analyses
|
|
834
|
+
|
|
835
|
+
def visible_analyses(self):
|
|
836
|
+
return [a for a in self.analysis_objects if a.visible]
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
#: The attribution of a stored analysis on an SDT run whose record does not
|
|
840
|
+
#: say which curve it was made on (a `.txt` export's onset without an
|
|
841
|
+
#: "Analysed variables" line): never certain, whoever shows it.
|
|
842
|
+
CURVE_NOT_STATED = "curve not stated"
|
|
843
|
+
|
|
844
|
+
#: What the reader's `variable` calls the weight (TRIOS's Weight (%) is the
|
|
845
|
+
#: reader's "Weight Change").
|
|
846
|
+
WEIGHT_VARIABLES = ("Weight Change", "Weight")
|
|
847
|
+
|
|
848
|
+
def _analysed_curve(entry, has_weight):
|
|
849
|
+
""""weight", "heat flow", or None when a run with both does not say.
|
|
850
|
+
|
|
851
|
+
The reader decodes the analysed variable from a `.tri` record and from
|
|
852
|
+
an export's "Analysed variables" line. Where it gives none, a DSC run
|
|
853
|
+
has only the one curve, and an integration's result (J/g) is a heat
|
|
854
|
+
flow's whatever the file says."""
|
|
855
|
+
variable = entry.get("variable")
|
|
856
|
+
if variable in WEIGHT_VARIABLES:
|
|
857
|
+
return "weight"
|
|
858
|
+
if variable or not has_weight:
|
|
859
|
+
return "heat flow"
|
|
860
|
+
if "Integration" in str(entry.get("Model", "")):
|
|
861
|
+
return "heat flow"
|
|
862
|
+
return None
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _rate(prog):
|
|
866
|
+
"""The heating rate in K/min out of a program string, or None."""
|
|
867
|
+
match = re.search(r"([-+]?\d+(?:[.,]\d+)?)\s*°?C\s*/\s*min", prog)
|
|
868
|
+
return number(match.group(1)) if match else None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
#: The analysis models whose results this program understands well enough to
|
|
872
|
+
#: draw a number for. The rest are kept, listed and switchable, but they can
|
|
873
|
+
#: only be drawn at their cursor.
|
|
874
|
+
DECODED_MODELS = ("Onset point", "Endset point", "Peak Integration",
|
|
875
|
+
"Glass transition")
|
|
876
|
+
|
|
877
|
+
#: The models whose RESULT is a temperature on the curve, drawn with their
|
|
878
|
+
#: tangent construction (or chords from the interval's bounds to it).
|
|
879
|
+
POINT_MODELS = ("Onset point", "Endset point", "Glass transition")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class Analysis(Obj):
|
|
883
|
+
"""One analysis, as an object that can be shown, hidden and edited.
|
|
884
|
+
|
|
885
|
+
An analysis is NOT a property of a scan, it is a thing on the figure, and
|
|
886
|
+
it has to be one here for two reasons.
|
|
887
|
+
|
|
888
|
+
* **It is switched on and off individually.** They are off when a file
|
|
889
|
+
opens (a run carries a dozen and a figure wants one or two), and each
|
|
890
|
+
is ticked on in the outliner or in the scan's settings.
|
|
891
|
+
* **Its attribution is not always certain.** A `.tri` ties an analysis to
|
|
892
|
+
the scan it was run on through the cached curve, which is exact. A
|
|
893
|
+
`.txt` export only names the STEP, and three segments of a run share a
|
|
894
|
+
name - so there the attachment is a guess and the user has to be able
|
|
895
|
+
to move it. `source` and `attribution` say which case this is, and
|
|
896
|
+
`reassign` is how it is corrected.
|
|
897
|
+
|
|
898
|
+
Analyses computed IN the panel will be the same class with
|
|
899
|
+
`source = "panel"`; nothing here assumes the numbers came from a file.
|
|
900
|
+
"""
|
|
901
|
+
|
|
902
|
+
kind = "analysis"
|
|
903
|
+
|
|
904
|
+
def __init__(self, oid, scan, model_name, fields, source="file",
|
|
905
|
+
attribution="cached curve"):
|
|
906
|
+
Obj.__init__(self, oid, model_name)
|
|
907
|
+
self.scan = scan
|
|
908
|
+
self.model_name = str(model_name)
|
|
909
|
+
self.fields = dict(fields or {})
|
|
910
|
+
#: TRIOS's own tangent construction for a `.tri`'s onset, endset or
|
|
911
|
+
#: glass transition: [[x degC, y], ...], three points (four for a
|
|
912
|
+
#: Tg), y in the unit of the reader's column `fields["variable"]`
|
|
913
|
+
#: names. None for everything else. Taken OUT of `fields`, which are
|
|
914
|
+
#: text and are listed as results (`labels.results` would show the
|
|
915
|
+
#: first number of the list).
|
|
916
|
+
self.stored_construction = self.fields.pop("construction", None)
|
|
917
|
+
self.source = source
|
|
918
|
+
self.attribution = attribution
|
|
919
|
+
#: OFF when a file opens. A DSC run routinely carries a dozen stored
|
|
920
|
+
#: analyses and a figure wants one or two of them.
|
|
921
|
+
self.visible = False
|
|
922
|
+
#: "auto" follows the scan's colour.
|
|
923
|
+
self.colour = "auto"
|
|
924
|
+
#: The label's TEMPLATE, or None for the default one of its kind:
|
|
925
|
+
#: the user's words, with `{}` where the measured value goes
|
|
926
|
+
#: (`core/labels.py`). It never holds the number itself.
|
|
927
|
+
self.label = None
|
|
928
|
+
#: The two SAMPLE INDICES (in the segment's own arrays) an analysis
|
|
929
|
+
#: made by dragging along the curve was measured between, or None -
|
|
930
|
+
#: a file's analyses, and cursors typed as temperatures. A
|
|
931
|
+
#: temperature does not name a point on a curve that doubles back;
|
|
932
|
+
#: an index does, so this is what the measurement is made on.
|
|
933
|
+
self.span = None
|
|
934
|
+
#: How far from the curve the label sits, in pixels, with the arrow
|
|
935
|
+
#: drawn between the two. Dragging the analysis changes this and
|
|
936
|
+
#: nothing else: the movement is locked vertically, so a label can
|
|
937
|
+
#: never wander off the feature it labels.
|
|
938
|
+
#:
|
|
939
|
+
#: None means "whichever side the peak is not on", so a negative
|
|
940
|
+
#: integral labels from below and its arrow does not cross the
|
|
941
|
+
#: shading. A drag replaces it with a number, because that is a
|
|
942
|
+
#: decision rather than a default.
|
|
943
|
+
self.label_dy = None
|
|
944
|
+
#: Shade the integrated area for a peak integration, as the template
|
|
945
|
+
#: does. Meaningless for the other models, and ignored there.
|
|
946
|
+
self.shade = True
|
|
947
|
+
#: `style.SHADINGS`: translucent, or opaque in the colour the
|
|
948
|
+
#: translucent fill makes over the page.
|
|
949
|
+
#: None follows the house style; read it through `style.value`.
|
|
950
|
+
self.shading = None
|
|
951
|
+
#: `style.PEAKS`: the peak temperature after an integration's
|
|
952
|
+
#: enthalpy in its label ("on"), or not; None follows the house
|
|
953
|
+
#: style. `{Tp}` in a label puts it anywhere (`core/labels.py`).
|
|
954
|
+
self.show_peak = None
|
|
955
|
+
#: Half the length of its interval's dashes, figure units; None
|
|
956
|
+
#: follows the house style ("Interval marks").
|
|
957
|
+
self.interval_size = None
|
|
958
|
+
#: The dashes at the two ends of the interval, so the figure says
|
|
959
|
+
#: which interval an analysis covers. The dashes only: the lines of
|
|
960
|
+
#: an onset, endset or Tg are `construction`.
|
|
961
|
+
self.show_interval = True
|
|
962
|
+
#: The unit its number is shown in, or None for the axes' (J/g on a
|
|
963
|
+
#: W/g axis, kJ/mol on a W/mol one). A unit written after `{}` in
|
|
964
|
+
#: the label still wins (`labels.render`).
|
|
965
|
+
self.unit = None
|
|
966
|
+
#: The lines of an onset, endset or glass transition
|
|
967
|
+
#: (`marks_a_point`): "tangents" (the tangent construction, TRIOS's
|
|
968
|
+
#: own for a `.tri`'s analysis, see `measure.tangent_points`),
|
|
969
|
+
#: "chords" (straight lines bound -> point -> bound) or
|
|
970
|
+
#: "none"; None follows the house style (`core/style.py`,
|
|
971
|
+
#: tangents built in). Read it through `style.value`.
|
|
972
|
+
self.construction = None
|
|
973
|
+
#: `measure.tangent_points`'s memo: (what it depends on, result).
|
|
974
|
+
self._tangent_memo = None
|
|
975
|
+
#: Point size for the label, or None for the house style's (see
|
|
976
|
+
#: `core/style.py`). Read it through `style.value`.
|
|
977
|
+
self.label_size = None
|
|
978
|
+
#: Which edge of the label sits on its leader arrow - the template's
|
|
979
|
+
#: `flush`: "left", "center", "right", or None for the house style,
|
|
980
|
+
#: whose own default follows the analysis kind.
|
|
981
|
+
self.flush = None
|
|
982
|
+
#: How its number is written (`core/numbers.py`), or None for the
|
|
983
|
+
#: house style's - whole degrees for a temperature, three
|
|
984
|
+
#: significant figures for anything else.
|
|
985
|
+
self.number_format = None
|
|
986
|
+
#: For an integration, WHERE along its interval the label's arrow
|
|
987
|
+
#: meets the curve (degC), or None for the peak. G, then X, slides
|
|
988
|
+
#: it; it never leaves the interval.
|
|
989
|
+
self.label_at = None
|
|
990
|
+
|
|
991
|
+
@property
|
|
992
|
+
def decoded(self):
|
|
993
|
+
"""True when this model's result fields are understood."""
|
|
994
|
+
return any(name in self.model_name for name in DECODED_MODELS)
|
|
995
|
+
|
|
996
|
+
@property
|
|
997
|
+
def marks_a_point(self):
|
|
998
|
+
"""True when the result IS a temperature on the curve.
|
|
999
|
+
|
|
1000
|
+
An onset, an endset, a glass transition's midpoint - as opposed to an
|
|
1001
|
+
area (integration) or a height. These get LINES as well as the
|
|
1002
|
+
interval's dashes (`construction`): the tangent construction, or
|
|
1003
|
+
chords from each bound of the interval to the result point.
|
|
1004
|
+
"""
|
|
1005
|
+
return any(name in self.model_name for name in POINT_MODELS)
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
def certain(self):
|
|
1009
|
+
"""True when there is no doubt which scan this belongs to.
|
|
1010
|
+
|
|
1011
|
+
Either the file tied it to its scan through the cached curve, or it
|
|
1012
|
+
was measured HERE, on that scan, which is as certain as it gets. Only
|
|
1013
|
+
an analysis inherited from a source that names the step and not the
|
|
1014
|
+
segment - a `.txt` export - is a guess.
|
|
1015
|
+
"""
|
|
1016
|
+
# One offered "by step name" is attributed by the user SHOWING it on
|
|
1017
|
+
# a scan: it is off until ticked, and ticked on the scan they picked.
|
|
1018
|
+
return (self.source == "panel"
|
|
1019
|
+
or self.attribution in ("cached curve", "moved by hand")
|
|
1020
|
+
or (self.attribution == "by step name" and self.visible))
|
|
1021
|
+
|
|
1022
|
+
def value(self):
|
|
1023
|
+
"""The temperature this analysis is drawn at, or None."""
|
|
1024
|
+
for key in ("Midpoint", "Onset x", "Endset x", "Peak temperature",
|
|
1025
|
+
"Cursor x", "Onset cursor x", "Baseline cursor x"):
|
|
1026
|
+
found = number(self.fields.get(key))
|
|
1027
|
+
if found is not None:
|
|
1028
|
+
return found
|
|
1029
|
+
return None
|
|
1030
|
+
|
|
1031
|
+
@property
|
|
1032
|
+
def slides(self):
|
|
1033
|
+
"""True for a kind whose label may slide along its interval: an
|
|
1034
|
+
integration, which labels an area rather than a point."""
|
|
1035
|
+
return "Integration" in self.model_name
|
|
1036
|
+
|
|
1037
|
+
@property
|
|
1038
|
+
def quantity(self):
|
|
1039
|
+
"""What its number is: "temperature", "enthalpy" or "heat flow"."""
|
|
1040
|
+
from . import labels
|
|
1041
|
+
return labels.quantity_of(self.model_name)
|
|
1042
|
+
|
|
1043
|
+
def summary(self, doc=None):
|
|
1044
|
+
"""The label as drawn: its template with the value filled in, in
|
|
1045
|
+
the units of `doc`'s axes (Celsius and W/g without one)."""
|
|
1046
|
+
from . import labels
|
|
1047
|
+
return labels.render(self, doc).text
|
|
1048
|
+
|
|
1049
|
+
def cursors(self):
|
|
1050
|
+
"""The two cursor temperatures this analysis was made from, in degC.
|
|
1051
|
+
|
|
1052
|
+
What a double-click needs to put the gizmos back where they were.
|
|
1053
|
+
Empty when the model stores something else - the cursor NAMES differ
|
|
1054
|
+
per model, which is why this is a table rather than two lookups.
|
|
1055
|
+
"""
|
|
1056
|
+
pairs = (("Onset cursor x", "Transition cursor x"),
|
|
1057
|
+
("Onset cursor x", "End cursor x"),
|
|
1058
|
+
("Baseline cursor x", "Baseline cursor x1"),
|
|
1059
|
+
("Cursor x", "Cursor x1"))
|
|
1060
|
+
for first, second in pairs:
|
|
1061
|
+
low = number(self.fields.get(first))
|
|
1062
|
+
high = number(self.fields.get(second))
|
|
1063
|
+
if low is not None and high is not None:
|
|
1064
|
+
return [low, high]
|
|
1065
|
+
return []
|
|
1066
|
+
|
|
1067
|
+
def key(self):
|
|
1068
|
+
"""A stable identity for the session file.
|
|
1069
|
+
|
|
1070
|
+
The model plus its cursor positions: two analyses of the same kind on
|
|
1071
|
+
one scan differ in where their cursors are, and those are the numbers
|
|
1072
|
+
the file stores rather than anything this program invented.
|
|
1073
|
+
"""
|
|
1074
|
+
cursors = []
|
|
1075
|
+
for name in ("Onset cursor x", "Transition cursor x", "End cursor x",
|
|
1076
|
+
"Baseline cursor x", "Baseline cursor x1", "Cursor x",
|
|
1077
|
+
"Cursor x1"):
|
|
1078
|
+
found = number(self.fields.get(name))
|
|
1079
|
+
if found is not None:
|
|
1080
|
+
cursors.append("{:.4f}".format(found))
|
|
1081
|
+
return "|".join([self.model_name] + cursors)
|
|
1082
|
+
|
|
1083
|
+
def reassign(self, scan):
|
|
1084
|
+
"""Draw this analysis on another scan, and remember that it was moved.
|
|
1085
|
+
|
|
1086
|
+
Only sensible within one sample - an analysis belongs to a run - and
|
|
1087
|
+
the caller keeps it to that. `attribution` becomes "moved by hand",
|
|
1088
|
+
so nothing later claims the file said so.
|
|
1089
|
+
"""
|
|
1090
|
+
if scan is self.scan:
|
|
1091
|
+
return self
|
|
1092
|
+
if self in self.scan.analysis_objects:
|
|
1093
|
+
self.scan.analysis_objects.remove(self)
|
|
1094
|
+
self.scan = scan
|
|
1095
|
+
scan.analysis_objects.append(self)
|
|
1096
|
+
self.attribution = "moved by hand"
|
|
1097
|
+
return self
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
#: Where an artist's position is measured in.
|
|
1101
|
+
SPACE_RELATIVE = "relative" # fractions of the plot, 0..1
|
|
1102
|
+
SPACE_DATA = "data" # the axes' own units
|
|
1103
|
+
|
|
1104
|
+
#: The nine points of an artist that can sit on its position.
|
|
1105
|
+
ANCHORS = ("top left", "top", "top right",
|
|
1106
|
+
"left", "center", "right",
|
|
1107
|
+
"bottom left", "bottom", "bottom right")
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
class Artist(Obj):
|
|
1111
|
+
"""Anything drawn on the figure that is not data.
|
|
1112
|
+
|
|
1113
|
+
The arrow, a caption, and whatever joins them - a scale bar, a molecule
|
|
1114
|
+
image, a leader note. They have nothing in common with a scan and
|
|
1115
|
+
everything in common with each other, so the common part lives here:
|
|
1116
|
+
|
|
1117
|
+
* a POSITION, in one of two spaces. `relative` is a fraction of the plot,
|
|
1118
|
+
which keeps an artist in the same corner whatever the view does;
|
|
1119
|
+
`data` pins it to a temperature and a heat flow, which is what a note
|
|
1120
|
+
about a peak wants. The settings offer both and convert between them,
|
|
1121
|
+
so switching does not move anything.
|
|
1122
|
+
* an ANCHOR: which of the artist's own nine points sits on that position.
|
|
1123
|
+
A caption anchored `left` grows to the right as its text changes; one
|
|
1124
|
+
anchored `center` grows both ways.
|
|
1125
|
+
* a COLOUR, "auto" meaning the theme's ink.
|
|
1126
|
+
|
|
1127
|
+
What each KIND allows beyond that is a class flag rather than a property,
|
|
1128
|
+
because it is a fact about the artist and not a setting: an arrow has no
|
|
1129
|
+
meaningful rotation, a scale bar will want length, a molecule image will
|
|
1130
|
+
want scale. `can_rotate` and `can_scale` are the two that exist so far;
|
|
1131
|
+
the dialogs read them, so a new artist declares its capabilities and gets
|
|
1132
|
+
the right fields.
|
|
1133
|
+
"""
|
|
1134
|
+
|
|
1135
|
+
kind = "artist"
|
|
1136
|
+
can_rotate = False
|
|
1137
|
+
can_scale = False
|
|
1138
|
+
|
|
1139
|
+
def __init__(self, oid, name="", x=0.5, y=0.5):
|
|
1140
|
+
Obj.__init__(self, oid, name)
|
|
1141
|
+
self.x = float(x)
|
|
1142
|
+
self.y = float(y)
|
|
1143
|
+
self.space = SPACE_RELATIVE
|
|
1144
|
+
self.anchor = "center"
|
|
1145
|
+
self.colour = "auto"
|
|
1146
|
+
#: Degrees, counter-clockwise, about the anchor point; only for a
|
|
1147
|
+
#: kind that `can_rotate` (R).
|
|
1148
|
+
self.rotation = 0.0
|
|
1149
|
+
|
|
1150
|
+
def position(self):
|
|
1151
|
+
return (float(self.x), float(self.y))
|
|
1152
|
+
|
|
1153
|
+
def set_position(self, x, y):
|
|
1154
|
+
self.x, self.y = float(x), float(y)
|
|
1155
|
+
return self
|
|
1156
|
+
|
|
1157
|
+
def anchor_offsets(self):
|
|
1158
|
+
"""`(fx, fy)` in 0..1: which point of the artist sits on the position.
|
|
1159
|
+
|
|
1160
|
+
0 is left/top and 1 is right/bottom, so a box of width w and height h
|
|
1161
|
+
is drawn at `x - fx * w`, `y - fy * h`.
|
|
1162
|
+
"""
|
|
1163
|
+
anchor = self.anchor if self.anchor in ANCHORS else "center"
|
|
1164
|
+
fx = 0.5
|
|
1165
|
+
fy = 0.5
|
|
1166
|
+
if "left" in anchor:
|
|
1167
|
+
fx = 0.0
|
|
1168
|
+
elif "right" in anchor:
|
|
1169
|
+
fx = 1.0
|
|
1170
|
+
if "top" in anchor:
|
|
1171
|
+
fy = 0.0
|
|
1172
|
+
elif "bottom" in anchor:
|
|
1173
|
+
fy = 1.0
|
|
1174
|
+
return fx, fy
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
class Axis(Obj):
|
|
1178
|
+
"""An axis, as an object with its own settings.
|
|
1179
|
+
|
|
1180
|
+
Double-clicking the numbers or the caption opens this rather than a
|
|
1181
|
+
global "plot settings" page, because an axis is a thing on the figure and
|
|
1182
|
+
everything else on the figure works that way.
|
|
1183
|
+
|
|
1184
|
+
The defaults are the DSC_Plotter template's `style()`: ticks pointing IN,
|
|
1185
|
+
minor ticks between them, no grid at all.
|
|
1186
|
+
"""
|
|
1187
|
+
|
|
1188
|
+
kind = "axis"
|
|
1189
|
+
|
|
1190
|
+
def __init__(self, oid, which):
|
|
1191
|
+
Obj.__init__(self, oid, "{} axis".format(which.upper()))
|
|
1192
|
+
self.which = which # "x", "y", or "y2" (the weight)
|
|
1193
|
+
#: A LOCKED range ("Lock current framing"):
|
|
1194
|
+
#: `[low, high]` that F and an unframed view return to instead of
|
|
1195
|
+
#: the fit, or None. Kept with what it was measured in
|
|
1196
|
+
#: (`lock_context`, `PlotWidget.axis_context`): a range in W/g says
|
|
1197
|
+
#: nothing about an mW axis, and is then not used.
|
|
1198
|
+
self.lock = None
|
|
1199
|
+
self.lock_context = None
|
|
1200
|
+
#: None means "say what is on this axis", which follows the unit.
|
|
1201
|
+
self.label = None
|
|
1202
|
+
self.show_grid = False
|
|
1203
|
+
self.minor_ticks = True
|
|
1204
|
+
self.ticks_inward = True
|
|
1205
|
+
#: Which side of the axes box this axis is drawn on - its line, its
|
|
1206
|
+
#: ticks, its numbers and its caption: "bottom" or "top" for x,
|
|
1207
|
+
#: "left" or "right" for y.
|
|
1208
|
+
self.side = "bottom" if which == "x" else "left"
|
|
1209
|
+
#: The numbers can be hidden - a stack of offset scans often shows
|
|
1210
|
+
#: no y numbers at all. The caption is hidden with `visible`.
|
|
1211
|
+
self.show_numbers = True
|
|
1212
|
+
#: Both None until chosen: the house style decides (`core/style.py`).
|
|
1213
|
+
self.label_size = None
|
|
1214
|
+
self.tick_size = None
|
|
1215
|
+
#: Where the caption sits ALONG the axis, as a fraction, and how far
|
|
1216
|
+
#: from it in pixels. Both are clamped to the margin outside the plot
|
|
1217
|
+
#: (see `PlotWidget`), so a caption cannot be dragged over the data.
|
|
1218
|
+
self.label_along = 0.5
|
|
1219
|
+
#: Pixels between the axis's NUMBERS and its caption, or None for the
|
|
1220
|
+
#: house style's `caption_gap`. Dragging the caption sets it.
|
|
1221
|
+
self.label_gap = None
|
|
1222
|
+
#: How its numbers are written (`core/numbers.py`), or None for
|
|
1223
|
+
#: "as few digits as the tick spacing needs".
|
|
1224
|
+
self.number_format = None
|
|
1225
|
+
#: Numbers NOT written, as values in the axis's unit (their ticks
|
|
1226
|
+
#: stay): the 50 at the very corner of the box that needs a margin
|
|
1227
|
+
#: of its own. Kept with what they were chosen in
|
|
1228
|
+
#: (`hidden_context`, like `lock_context`): a 50 hidden in degC is
|
|
1229
|
+
#: not a 50 in K. Always REPLACED, never changed in place, or a
|
|
1230
|
+
#: settings window's snapshot would change with it.
|
|
1231
|
+
self.hidden_numbers = []
|
|
1232
|
+
self.hidden_context = None
|
|
1233
|
+
#: A line on the OPPOSITE side of the axes box, closing the frame,
|
|
1234
|
+
#: and ticks on it (no numbers): Origin's look, and the default.
|
|
1235
|
+
self.mirror = True
|
|
1236
|
+
self.mirror_ticks = True
|
|
1237
|
+
#: The numbered ticks' spacing in the axis's unit, or None for a
|
|
1238
|
+
#: round number that fits (matplotlib's MultipleLocator vs auto).
|
|
1239
|
+
self.major_step = None
|
|
1240
|
+
#: Minor intervals per major one (AutoMinorLocator(n)); 1 is none.
|
|
1241
|
+
self.minor_count = 5
|
|
1242
|
+
#: Tick lengths, in figure units (96 per inch).
|
|
1243
|
+
self.tick_length = 7.0
|
|
1244
|
+
self.minor_length = 3.0
|
|
1245
|
+
|
|
1246
|
+
def caption(self, doc):
|
|
1247
|
+
"""What the caption says: the user's text, or the axis's own.
|
|
1248
|
+
|
|
1249
|
+
`*` marks italic, so a default caption sets the QUANTITY SYMBOL
|
|
1250
|
+
cursive and leaves the unit upright. `T` is a variable and every
|
|
1251
|
+
convention worth following sets those in italic - it is also what the
|
|
1252
|
+
template's `$T \\quad / \\quad \\mathrm{degC}$` produces.
|
|
1253
|
+
"""
|
|
1254
|
+
if self.label:
|
|
1255
|
+
return str(self.label)
|
|
1256
|
+
if self.which == "x":
|
|
1257
|
+
if doc.x_axis != AXIS_TEMPERATURE:
|
|
1258
|
+
return "*t* / min"
|
|
1259
|
+
return "*T* / {}".format(units.TEMPERATURE_LABEL.get(
|
|
1260
|
+
getattr(doc, "x_unit", units.TEMP_C), "°C"))
|
|
1261
|
+
if self.which == "y2":
|
|
1262
|
+
return "*m* / {}".format(getattr(doc, "weight_unit",
|
|
1263
|
+
WEIGHT_PCT))
|
|
1264
|
+
if doc.y_signal() == SIGNAL_DTG:
|
|
1265
|
+
return "DTG / {}".format(doc.dtg_unit)
|
|
1266
|
+
return "Heat Flow / {}".format(doc.y_unit)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
class TextLabel(Artist):
|
|
1270
|
+
"""A caption the user put on the figure, and can move and retype.
|
|
1271
|
+
|
|
1272
|
+
Distinct from the name that appears beside a hovered curve: that is a
|
|
1273
|
+
readout, it comes and goes with the cursor, and it is not part of the
|
|
1274
|
+
figure. This is part of the figure.
|
|
1275
|
+
|
|
1276
|
+
Scalable - its point size IS its scale - and not rotatable: rotated text
|
|
1277
|
+
on a DSC figure is the y caption's job, and that belongs to the axis.
|
|
1278
|
+
"""
|
|
1279
|
+
|
|
1280
|
+
kind = "label"
|
|
1281
|
+
can_scale = True
|
|
1282
|
+
can_rotate = True
|
|
1283
|
+
|
|
1284
|
+
def __init__(self, oid, text="Label", x=0.5, y=0.5, scan=None):
|
|
1285
|
+
Artist.__init__(self, oid, "Label", x, y)
|
|
1286
|
+
self.text = str(text)
|
|
1287
|
+
#: None follows the house style (`core/style.py`).
|
|
1288
|
+
self.size = None
|
|
1289
|
+
self.bold = False
|
|
1290
|
+
#: The scan this label belongs to - its PARENT - or None for a free
|
|
1291
|
+
#: one. An owned label takes that scan's colour while its own is
|
|
1292
|
+
#: "auto", is listed under it in the outliner, goes when the scan
|
|
1293
|
+
#: goes, and MOVES WITH IT (a parenting operation): see
|
|
1294
|
+
#: `parent_offset`.
|
|
1295
|
+
self.scan = scan
|
|
1296
|
+
#: The scan's offset when the label's position was last set, in the
|
|
1297
|
+
#: axis unit. The label is drawn `scan.offset - parent_offset` higher,
|
|
1298
|
+
#: so it follows every later offset change without its stored place
|
|
1299
|
+
#: being rewritten - and parenting keeps it where it is (Blender's
|
|
1300
|
+
#: "keep transform"). None for a free label.
|
|
1301
|
+
self.parent_offset = (float(scan.offset) if scan is not None
|
|
1302
|
+
else None)
|
|
1303
|
+
#: A NOTE's leader arrow: `[celsius, heat flow]`, the point it points
|
|
1304
|
+
#: at - the temperature in degC like every stored temperature, the heat
|
|
1305
|
+
#: flow in the axis unit - or None for a plain label. With a parent it
|
|
1306
|
+
#: follows the scan like the text does (stored at `parent_offset`).
|
|
1307
|
+
self.leader = None
|
|
1308
|
+
#: Where on the text's box the arrow starts: "auto" (the edge
|
|
1309
|
+
#: nearest the point) or one of `ANCHORS`.
|
|
1310
|
+
self.leader_from = "auto"
|
|
1311
|
+
#: The arrow's own colour, or "auto" for the text's.
|
|
1312
|
+
self.leader_colour = "auto"
|
|
1313
|
+
#: How its lines line up: "left", "right", "center", or None for by
|
|
1314
|
+
#: the side of its anchor. Ctrl+L / R / M set it.
|
|
1315
|
+
self.flush = None
|
|
1316
|
+
#: A MARKER LINE: the temperature, in degC, of a vertical line across
|
|
1317
|
+
#: the axes that this label sits on, turned upright on a background box
|
|
1318
|
+
#: - or None for an ordinary label. Its `y` is still its place along
|
|
1319
|
+
#: the line; its `x` follows the line.
|
|
1320
|
+
self.vline = None
|
|
1321
|
+
#: The line dashed (`ls='--'`) or solid.
|
|
1322
|
+
self.line_dashed = True
|
|
1323
|
+
#: A label that belongs to a scan HANGS FROM ITS CURVE like an
|
|
1324
|
+
#: analysis label and its arrow: `at` is the sample, `("i", n)` in
|
|
1325
|
+
#: the segment's own numbering, and `dx`, `dy` are
|
|
1326
|
+
#: figure units from that point to the label's anchor (up is
|
|
1327
|
+
#: negative). A note's arrow drops straight onto the point, so its
|
|
1328
|
+
#: `dx` is 0. None until attached (`PlotWidget.attach`): a label
|
|
1329
|
+
#: from an older session is placed as it was until then.
|
|
1330
|
+
self.at = None
|
|
1331
|
+
self.dx = 0.0
|
|
1332
|
+
self.dy = None
|
|
1333
|
+
|
|
1334
|
+
@property
|
|
1335
|
+
def is_vline(self):
|
|
1336
|
+
return self.vline is not None
|
|
1337
|
+
|
|
1338
|
+
@property
|
|
1339
|
+
def attached(self):
|
|
1340
|
+
"""True when it hangs from its scan's curve (`at`)."""
|
|
1341
|
+
return (self.scan is not None and self.vline is None
|
|
1342
|
+
and self.at is not None)
|
|
1343
|
+
|
|
1344
|
+
def follow(self):
|
|
1345
|
+
"""How far its scan has moved since the label was placed, in the
|
|
1346
|
+
axis unit: 0.0 for a free label and for one hanging from its curve
|
|
1347
|
+
(the curve carries it)."""
|
|
1348
|
+
if (self.scan is None or self.parent_offset is None
|
|
1349
|
+
or self.at is not None):
|
|
1350
|
+
return 0.0
|
|
1351
|
+
return float(self.scan.offset) - float(self.parent_offset)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
class Legend(Artist):
|
|
1355
|
+
"""Which colour is which scan, in a corner of the figure.
|
|
1356
|
+
|
|
1357
|
+
A matplotlib figure made with the template carries one, and nothing
|
|
1358
|
+
else here stands in for it: the names beside the curves are a READOUT,
|
|
1359
|
+
they come and go with the cursor, and a figure that leaves the program
|
|
1360
|
+
needs the key written into it.
|
|
1361
|
+
|
|
1362
|
+
An artist like the rest, so it is dragged, anchored, coloured and hidden
|
|
1363
|
+
the same way. Off by default: a stack of three scans is often clearer
|
|
1364
|
+
without one, and turning it on is a tick.
|
|
1365
|
+
"""
|
|
1366
|
+
|
|
1367
|
+
kind = "legend"
|
|
1368
|
+
can_scale = True
|
|
1369
|
+
can_rotate = True
|
|
1370
|
+
|
|
1371
|
+
def __init__(self, oid):
|
|
1372
|
+
Artist.__init__(self, oid, "Legend", 0.02, 0.98)
|
|
1373
|
+
self.anchor = "bottom left"
|
|
1374
|
+
#: Off until asked for.
|
|
1375
|
+
self.visible = False
|
|
1376
|
+
#: None follows the house style (`core/style.py`).
|
|
1377
|
+
self.size = None
|
|
1378
|
+
#: A box behind it. Off by default, as the template's
|
|
1379
|
+
#: `frameon=False`.
|
|
1380
|
+
self.show_frame = False
|
|
1381
|
+
#: Length of the colour sample in front of each name, in pixels.
|
|
1382
|
+
self.sample = 22.0
|
|
1383
|
+
#: Space between rows, as a multiple of the line height.
|
|
1384
|
+
self.spacing = 1.25
|
|
1385
|
+
#: The colour samples' line width, or None for each scan's own.
|
|
1386
|
+
self.line_width = None
|
|
1387
|
+
|
|
1388
|
+
def entries(self, doc):
|
|
1389
|
+
"""`[(scan, text), ...]` for the scans that are drawn, heat flow
|
|
1390
|
+
and mass alike.
|
|
1391
|
+
|
|
1392
|
+
A scan's own label wins over its program name, which is what makes
|
|
1393
|
+
the legend say "second heating" when that is what the curve was
|
|
1394
|
+
renamed to. A scan that cannot be drawn has no line to stand for
|
|
1395
|
+
and says so on the plot instead.
|
|
1396
|
+
"""
|
|
1397
|
+
return [(scan, scan.display_name()) for scan in doc.visible_scans()
|
|
1398
|
+
if not scan.missing_for(doc.unit_for(scan), doc.x_axis)]
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
class OffsetMarker(Obj):
|
|
1402
|
+
"""The template's `add_yoffset_markers` for one scan, as an object.
|
|
1403
|
+
|
|
1404
|
+
`+0.5` under the curve with a small arrow up to it: the scan's offset,
|
|
1405
|
+
in the axis's unit. Selected, moved (alone or with the rest of the
|
|
1406
|
+
selection) and given its own size like any label; drawn while the
|
|
1407
|
+
figure's markers are on (`Document.offset_markers`).
|
|
1408
|
+
"""
|
|
1409
|
+
|
|
1410
|
+
kind = "offset_marker"
|
|
1411
|
+
|
|
1412
|
+
def __init__(self, oid, scan):
|
|
1413
|
+
Obj.__init__(self, oid, "Offset marker")
|
|
1414
|
+
self.scan = scan
|
|
1415
|
+
#: Where it points on the curve. None: the left end of the part of
|
|
1416
|
+
#: the curve that is SHOWN - kept and inside the view - which is
|
|
1417
|
+
#: tight against the y axis wherever the curve reaches it.
|
|
1418
|
+
#: `("i", n)`: sample n of the segment, which is how a point on a
|
|
1419
|
+
#: curve that doubles back is named (a drag stores this).
|
|
1420
|
+
#: `("T", celsius)`: the shown sample nearest that temperature (a
|
|
1421
|
+
#: typed one; what lines several markers up in one column).
|
|
1422
|
+
self.at = None
|
|
1423
|
+
#: How far below the curve the text starts, in figure units, or None
|
|
1424
|
+
#: for the template's 3 % of the plot height.
|
|
1425
|
+
self.dy = None
|
|
1426
|
+
#: Point size, or None for the house style's.
|
|
1427
|
+
self.size = None
|
|
1428
|
+
#: "auto" is the theme's ink.
|
|
1429
|
+
self.colour = "auto"
|
|
1430
|
+
#: How the offset is written, or None for the house style's (one
|
|
1431
|
+
#: decimal and a sign, as the template writes it).
|
|
1432
|
+
self.number_format = None
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
class ImageArtist(Artist):
|
|
1436
|
+
"""A picture on the figure, pasted or dropped: a structure, a photo of
|
|
1437
|
+
the pan. Furniture, not data - moved, scaled (S), rotated (R), layered
|
|
1438
|
+
and aligned like any artist, never measured."""
|
|
1439
|
+
|
|
1440
|
+
kind = "image"
|
|
1441
|
+
can_scale = True
|
|
1442
|
+
can_rotate = True
|
|
1443
|
+
|
|
1444
|
+
def __init__(self, oid, png, x=0.5, y=0.5, width=160.0):
|
|
1445
|
+
Artist.__init__(self, oid, "Image", x, y)
|
|
1446
|
+
#: The picture as PNG, base64 text: what the session stores, so a
|
|
1447
|
+
#: figure never depends on a file that may move.
|
|
1448
|
+
self.png = str(png)
|
|
1449
|
+
#: Mirrored left-right and / or top-bottom (Ctrl+Shift+H / V),
|
|
1450
|
+
#: applied when it is drawn.
|
|
1451
|
+
self.mirror_h = False
|
|
1452
|
+
self.mirror_v = False
|
|
1453
|
+
#: How wide it is drawn, in figure units; the height keeps the
|
|
1454
|
+
#: picture's own proportions.
|
|
1455
|
+
self.width = float(width)
|
|
1456
|
+
#: The decoded picture, made by the plot when first drawn.
|
|
1457
|
+
self._pixels = None
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
class MoleculeArtist(Artist):
|
|
1461
|
+
"""A skeletal structure, from a pasted SMILES.
|
|
1462
|
+
|
|
1463
|
+
Drawn by the plot as lines and text - vector in every export - from the
|
|
1464
|
+
layout `core/chem.py` made, which is STORED here, so the figure opens
|
|
1465
|
+
without RDKit. Its sizes are the ACS 1996 document style's: bonds 0.2
|
|
1466
|
+
inch long and 0.6 pt wide, labels at 10 pt, double bonds 18 % apart.
|
|
1467
|
+
Rotated, its labels stay upright unless `upright_labels` is off.
|
|
1468
|
+
"""
|
|
1469
|
+
|
|
1470
|
+
kind = "molecule"
|
|
1471
|
+
can_scale = True
|
|
1472
|
+
can_rotate = True
|
|
1473
|
+
|
|
1474
|
+
def __init__(self, oid, smiles, drawing, x=0.5, y=0.5):
|
|
1475
|
+
Artist.__init__(self, oid, "Structure", x, y)
|
|
1476
|
+
self.smiles = str(smiles)
|
|
1477
|
+
#: `core.chem.layout`: atoms and bonds, in bond lengths, y up.
|
|
1478
|
+
self.atoms = list((drawing or {}).get("atoms", []))
|
|
1479
|
+
self.bonds = list((drawing or {}).get("bonds", []))
|
|
1480
|
+
#: Bond length and bond width in figure units (96 per inch), label
|
|
1481
|
+
#: size in points.
|
|
1482
|
+
self.bond_length = 19.2
|
|
1483
|
+
self.bond_width = 0.8
|
|
1484
|
+
self.label_size = 10.0
|
|
1485
|
+
#: Keep the element labels upright when the structure is rotated.
|
|
1486
|
+
self.upright_labels = True
|
|
1487
|
+
#: The element labels' font family, or None for the house style's
|
|
1488
|
+
#: (`structure_font`: Arial Rounded MT built in).
|
|
1489
|
+
self.label_font = None
|
|
1490
|
+
#: Colour each element label by its element (N blue, O red...);
|
|
1491
|
+
#: the bonds keep the structure's colour. On for a new structure;
|
|
1492
|
+
#: an older session keeps what it had.
|
|
1493
|
+
self.colour_by_element = True
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
#: The heat-flow arrow's own proportions by default: the DSC_Plotter
|
|
1497
|
+
#: template's `add_exo_arrow`, in POINTS (tail 4.5 wide, head 13 wide and
|
|
1498
|
+
#: 9 long, the tail 0.9 of the head long), so the panel and the published
|
|
1499
|
+
#: figure draw the same arrow.
|
|
1500
|
+
ARROW_HEAD_LENGTH = 9.0
|
|
1501
|
+
ARROW_HEAD_WIDTH = 13.0
|
|
1502
|
+
ARROW_TAIL_WIDTH = 4.5
|
|
1503
|
+
ARROW_TAIL_LENGTH = 0.9 * ARROW_HEAD_LENGTH
|
|
1504
|
+
#: What the tip angle and the head width are kept at when the other two
|
|
1505
|
+
#: head dimensions change: "angle", "width", or None (neither).
|
|
1506
|
+
ARROW_LOCKS = ("angle", "width")
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
class HeatFlowArrow(Artist):
|
|
1510
|
+
"""The exo (or endo) arrow, as a draggable object.
|
|
1511
|
+
|
|
1512
|
+
It carries the CONVENTION, not just a picture of one: `word` and
|
|
1513
|
+
`direction` decide which way the data is drawn, through
|
|
1514
|
+
`units.orientation`. Relabelling it "endo up" leaves the curves alone
|
|
1515
|
+
because that means the same thing as "exo down"; relabelling it "exo up"
|
|
1516
|
+
flips them, and the y axis with them. That rule is the only way a
|
|
1517
|
+
figure's arrow cannot end up contradicting its data.
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
kind = "arrow"
|
|
1521
|
+
#: An arrow that says "exo down" cannot be rotated without lying; it
|
|
1522
|
+
#: can be SCALED (S), which scales its head, tail and text together.
|
|
1523
|
+
can_rotate = False
|
|
1524
|
+
can_scale = True
|
|
1525
|
+
|
|
1526
|
+
def __init__(self, oid, word=units.WORD_EXO, direction=units.EXO_DOWN):
|
|
1527
|
+
Artist.__init__(self, oid, "Heat-flow arrow", 0.045, 0.5)
|
|
1528
|
+
self.word = word
|
|
1529
|
+
self.direction = direction
|
|
1530
|
+
#: Its dimensions in POINTS, like the template's arguments. The
|
|
1531
|
+
#: head's length, width and tip angle are tied - two decide the
|
|
1532
|
+
#: third - so the angle is not stored: see `tip_angle` and `lock`.
|
|
1533
|
+
self.head_length = ARROW_HEAD_LENGTH
|
|
1534
|
+
self.head_width = ARROW_HEAD_WIDTH
|
|
1535
|
+
self.tail_width = ARROW_TAIL_WIDTH
|
|
1536
|
+
self.tail_length = ARROW_TAIL_LENGTH
|
|
1537
|
+
#: Which of the tip angle and the head width stays put while the
|
|
1538
|
+
#: other head dimensions change: "angle", "width" or None.
|
|
1539
|
+
self.lock = None
|
|
1540
|
+
#: The text's point size, or None for the house style's.
|
|
1541
|
+
self.size = None
|
|
1542
|
+
|
|
1543
|
+
# ------------------------------------------------------------- the head
|
|
1544
|
+
@property
|
|
1545
|
+
def tip_angle(self):
|
|
1546
|
+
"""The angle at the point, in degrees: 2 atan(w / 2 / l)."""
|
|
1547
|
+
return math.degrees(2.0 * math.atan2(float(self.head_width) / 2.0,
|
|
1548
|
+
float(self.head_length)))
|
|
1549
|
+
|
|
1550
|
+
def head_for_length(self, length):
|
|
1551
|
+
"""`(head_length, head_width)` once the head is made `length` long.
|
|
1552
|
+
|
|
1553
|
+
The width follows only when the ANGLE is locked; otherwise it stays
|
|
1554
|
+
and the angle is what changes."""
|
|
1555
|
+
length = max(0.1, float(length))
|
|
1556
|
+
if self.lock == "angle":
|
|
1557
|
+
half = math.radians(self.tip_angle) / 2.0
|
|
1558
|
+
return length, 2.0 * length * math.tan(half)
|
|
1559
|
+
return length, float(self.head_width)
|
|
1560
|
+
|
|
1561
|
+
def head_for_width(self, width):
|
|
1562
|
+
"""`(head_length, head_width)` once the head is made `width` wide.
|
|
1563
|
+
With the angle locked the length follows; otherwise the angle does."""
|
|
1564
|
+
width = max(0.1, float(width))
|
|
1565
|
+
if self.lock == "angle":
|
|
1566
|
+
half = math.radians(self.tip_angle) / 2.0
|
|
1567
|
+
return width / 2.0 / math.tan(half), width
|
|
1568
|
+
return float(self.head_length), width
|
|
1569
|
+
|
|
1570
|
+
def head_for_angle(self, degrees):
|
|
1571
|
+
"""`(head_length, head_width)` for a tip angle of `degrees`. With the
|
|
1572
|
+
width locked the length follows; otherwise the width does."""
|
|
1573
|
+
half = math.radians(min(170.0, max(5.0, float(degrees)))) / 2.0
|
|
1574
|
+
if self.lock == "width":
|
|
1575
|
+
return float(self.head_width) / 2.0 / math.tan(half), float(self.head_width)
|
|
1576
|
+
return float(self.head_length), 2.0 * float(self.head_length) * math.tan(half)
|
|
1577
|
+
|
|
1578
|
+
@property
|
|
1579
|
+
def orientation(self):
|
|
1580
|
+
"""Which way exotherms point, whatever the label says."""
|
|
1581
|
+
return units.orientation(self.word, self.direction)
|
|
1582
|
+
|
|
1583
|
+
def text(self):
|
|
1584
|
+
return "{}\n{}".format(self.word.capitalize(),
|
|
1585
|
+
self.direction.capitalize())
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
class Document(object):
|
|
1589
|
+
"""The samples, the objects, and the two choices that apply to all of it."""
|
|
1590
|
+
|
|
1591
|
+
def __init__(self):
|
|
1592
|
+
self.samples = []
|
|
1593
|
+
self.scans = []
|
|
1594
|
+
self.arrow = HeatFlowArrow(self._next_id())
|
|
1595
|
+
#: The key, off until it is asked for.
|
|
1596
|
+
self.legend = Legend(self._next_id())
|
|
1597
|
+
#: The two axes, as objects with their own settings.
|
|
1598
|
+
self.axes = {"x": Axis(self._next_id(), "x"),
|
|
1599
|
+
"y": Axis(self._next_id(), "y"),
|
|
1600
|
+
"y2": Axis(self._next_id(), "y2")}
|
|
1601
|
+
# The mass axis of SDT/TGA runs. It takes the MAIN side (the heat
|
|
1602
|
+
# flow axis's `side`) whenever a mass scan is drawn, and the heat
|
|
1603
|
+
# flow, if drawn too, goes to the other. Its settings are otherwise
|
|
1604
|
+
# its own.
|
|
1605
|
+
self.axes["y2"].name = "Mass axis"
|
|
1606
|
+
#: What the mass axis shows: "%" of the sample mass, or "mg".
|
|
1607
|
+
self.weight_unit = WEIGHT_PCT
|
|
1608
|
+
#: What a DTG is drawn in: %/degC or %/min (`core/dtg.py`).
|
|
1609
|
+
self.dtg_unit = dtg_module.PER_DEGREE
|
|
1610
|
+
#: Captions the user has added. Free objects, not tied to a scan.
|
|
1611
|
+
self.labels = []
|
|
1612
|
+
#: Pictures pasted or dropped onto the figure (`ImageArtist`).
|
|
1613
|
+
self.images = []
|
|
1614
|
+
#: Skeletal structures pasted as SMILES (`MoleculeArtist`).
|
|
1615
|
+
self.structures = []
|
|
1616
|
+
self.x_axis = AXIS_TEMPERATURE
|
|
1617
|
+
#: Which temperature scale the x axis is DRAWN in. The data stays in
|
|
1618
|
+
#: Celsius, which is all TRIOS stores; this is a display conversion
|
|
1619
|
+
#: (see `core/units.py`), applied to the curves and to every stored
|
|
1620
|
+
#: analysis cursor alike.
|
|
1621
|
+
self.x_unit = units.TEMP_C
|
|
1622
|
+
self.y_unit = units.UNIT_W_G
|
|
1623
|
+
#: Which palette the window draws in. A NAME rather than colours, so
|
|
1624
|
+
#: the model stays UI-free and `ui/plot.py` owns what the name means.
|
|
1625
|
+
#: "blender-default" is the dark screen theme; "light" is the one
|
|
1626
|
+
#: every export uses whatever this says.
|
|
1627
|
+
self.theme = "blender-default"
|
|
1628
|
+
#: The page's colour, or None for the theme's (white and the
|
|
1629
|
+
#: theme's one click away). The ink follows it: a light page is
|
|
1630
|
+
#: drawn with the light theme's.
|
|
1631
|
+
self.background = None
|
|
1632
|
+
#: Decorators placed on the page MOVE WITH THE DATA when zoomed
|
|
1633
|
+
#: (their places fractions of the home frame, `PlotWidget.
|
|
1634
|
+
#: rel_to_px`) - or, False, stay where they are on the page: the
|
|
1635
|
+
#: default, because an arrow or a structure that follows the zoom
|
|
1636
|
+
#: is easily lost. An F3 toggle, per figure, saved.
|
|
1637
|
+
self.follow_zoom = False
|
|
1638
|
+
#: This figure's own sizes and alignments, between an object's and
|
|
1639
|
+
#: the user's defaults. Saved with the session; see `core/style.py`.
|
|
1640
|
+
self.style = style.FigureStyle()
|
|
1641
|
+
#: The figure's size and the place of its axes box (`core/figure.py`):
|
|
1642
|
+
#: free with the window, a fixed aspect ratio, or exact. A new figure
|
|
1643
|
+
#: starts from the user's default, when they have set one.
|
|
1644
|
+
self.figure = style.figure_default() or figure_module.FigureLayout()
|
|
1645
|
+
#: The framing, as the plot keeps it (`PlotWidget.view_state`):
|
|
1646
|
+
#: `{"x": (lo, hi) or None, "y": ..., "context": (axis, unit, ...)}`,
|
|
1647
|
+
#: or None for "fitted". Part of the figure, so saved with it: a
|
|
1648
|
+
#: y range narrowed to show a peak's label is a decision.
|
|
1649
|
+
self.view = None
|
|
1650
|
+
#: The template's `add_yoffset_markers`: every drawn scan labelled
|
|
1651
|
+
#: with its y offset (`+0.5`), each its own object (`Scan.marker`).
|
|
1652
|
+
#: Off until asked for; a tuning aid that can go into the figure.
|
|
1653
|
+
self.offset_markers = False
|
|
1654
|
+
self.path = "" # the session file, once saved
|
|
1655
|
+
self._next = 100
|
|
1656
|
+
|
|
1657
|
+
# ------------------------------------------------------------------ ids
|
|
1658
|
+
def _next_id(self):
|
|
1659
|
+
value = getattr(self, "_next", 100)
|
|
1660
|
+
self._next = value + 1
|
|
1661
|
+
return value
|
|
1662
|
+
|
|
1663
|
+
# -------------------------------------------------------------- content
|
|
1664
|
+
def objects(self):
|
|
1665
|
+
"""Everything selectable, in draw order (later is on top)."""
|
|
1666
|
+
markers = ([scan.marker for scan in self.scans]
|
|
1667
|
+
if self.offset_markers else [])
|
|
1668
|
+
return (list(self.scans) + self.analyses() + markers
|
|
1669
|
+
+ list(self.labels) + list(self.images)
|
|
1670
|
+
+ list(self.structures)
|
|
1671
|
+
+ list(self.axes.values()) + [self.arrow, self.legend])
|
|
1672
|
+
|
|
1673
|
+
def add_label(self, text="Label", x=0.5, y=0.5, scan=None):
|
|
1674
|
+
label = TextLabel(self._next_id(), text, x, y, scan)
|
|
1675
|
+
self.labels.append(label)
|
|
1676
|
+
return label
|
|
1677
|
+
|
|
1678
|
+
def labels_for(self, scan):
|
|
1679
|
+
"""The labels that belong to one scan."""
|
|
1680
|
+
return [label for label in self.labels if label.scan is scan]
|
|
1681
|
+
|
|
1682
|
+
def labels_of_removed(self, scan):
|
|
1683
|
+
"""Take a scan's labels off with it, and hand them back.
|
|
1684
|
+
|
|
1685
|
+
Returned rather than dropped, so the command that removed the scan
|
|
1686
|
+
can put them back when it is undone.
|
|
1687
|
+
"""
|
|
1688
|
+
owned = self.labels_for(scan)
|
|
1689
|
+
self.labels = [label for label in self.labels
|
|
1690
|
+
if label.scan is not scan]
|
|
1691
|
+
return owned
|
|
1692
|
+
|
|
1693
|
+
def remove_label(self, label):
|
|
1694
|
+
if label in self.labels:
|
|
1695
|
+
self.labels.remove(label)
|
|
1696
|
+
return label
|
|
1697
|
+
|
|
1698
|
+
def analyses(self):
|
|
1699
|
+
"""Every analysis object on every scan."""
|
|
1700
|
+
return [a for scan in self.scans for a in scan.analysis_objects]
|
|
1701
|
+
|
|
1702
|
+
def visible_analyses(self):
|
|
1703
|
+
return [a for scan in self.scans if scan.visible
|
|
1704
|
+
for a in scan.visible_analyses()]
|
|
1705
|
+
|
|
1706
|
+
def add_sample(self, sample, segments=None):
|
|
1707
|
+
"""Add a file and make scans for the segments named (or the default).
|
|
1708
|
+
|
|
1709
|
+
The default follows the rule the DSC_Plotter template already uses:
|
|
1710
|
+
one file shows every segment, several files show the first heating
|
|
1711
|
+
scan of each. Applied by the caller, which is the only place that
|
|
1712
|
+
knows how many files are open - see `default_segments`.
|
|
1713
|
+
"""
|
|
1714
|
+
self.samples.append(sample)
|
|
1715
|
+
chosen = (range(sample.segment_count()) if segments is None
|
|
1716
|
+
else segments)
|
|
1717
|
+
made = []
|
|
1718
|
+
for seg in chosen:
|
|
1719
|
+
# A segment is its number (the heat flow) or (number, signal).
|
|
1720
|
+
seg, signal = (seg if isinstance(seg, tuple)
|
|
1721
|
+
else (seg, SIGNAL_HEAT))
|
|
1722
|
+
scan = Scan(self._next_id(), sample,
|
|
1723
|
+
seg, PALETTE[len(self.scans) % len(PALETTE)], signal)
|
|
1724
|
+
sample.scans.append(scan)
|
|
1725
|
+
self.scans.append(scan)
|
|
1726
|
+
made.append(scan)
|
|
1727
|
+
return made
|
|
1728
|
+
|
|
1729
|
+
def detach_scan(self, scan):
|
|
1730
|
+
"""Take a scan off the plot, KEEPING its sample.
|
|
1731
|
+
|
|
1732
|
+
The sample stays even when its last scan goes, which is what makes
|
|
1733
|
+
removal undoable and what keeps the file's other segments reachable
|
|
1734
|
+
in the outliner: a file is open until it is closed, whether or not
|
|
1735
|
+
any of its segments is currently drawn.
|
|
1736
|
+
"""
|
|
1737
|
+
if scan in self.scans:
|
|
1738
|
+
self.scans.remove(scan)
|
|
1739
|
+
if scan in scan.sample.scans:
|
|
1740
|
+
scan.sample.scans.remove(scan)
|
|
1741
|
+
|
|
1742
|
+
def insert_scan(self, scan, index=None):
|
|
1743
|
+
"""Put a scan back where it was (or at the end)."""
|
|
1744
|
+
if scan in self.scans:
|
|
1745
|
+
return scan
|
|
1746
|
+
if index is None or index > len(self.scans):
|
|
1747
|
+
index = len(self.scans)
|
|
1748
|
+
self.scans.insert(index, scan)
|
|
1749
|
+
if scan not in scan.sample.scans:
|
|
1750
|
+
scan.sample.scans.append(scan)
|
|
1751
|
+
scan.sample.scans.sort(key=lambda s: s.seg)
|
|
1752
|
+
if scan.sample not in self.samples:
|
|
1753
|
+
self.samples.append(scan.sample)
|
|
1754
|
+
return scan
|
|
1755
|
+
|
|
1756
|
+
def remove_scan(self, scan):
|
|
1757
|
+
"""Detach a scan and forget its sample if nothing else uses it."""
|
|
1758
|
+
self.detach_scan(scan)
|
|
1759
|
+
if not scan.sample.scans and scan.sample in self.samples:
|
|
1760
|
+
self.samples.remove(scan.sample)
|
|
1761
|
+
|
|
1762
|
+
def close_sample(self, sample):
|
|
1763
|
+
"""Forget a file entirely: its scans and the sample itself."""
|
|
1764
|
+
for scan in list(sample.scans):
|
|
1765
|
+
self.detach_scan(scan)
|
|
1766
|
+
if sample in self.samples:
|
|
1767
|
+
self.samples.remove(sample)
|
|
1768
|
+
|
|
1769
|
+
def sample_for(self, path):
|
|
1770
|
+
for sample in self.samples:
|
|
1771
|
+
if os.path.normcase(sample.path) == os.path.normcase(str(path)):
|
|
1772
|
+
return sample
|
|
1773
|
+
return None
|
|
1774
|
+
|
|
1775
|
+
# ------------------------------------------------------------ selection
|
|
1776
|
+
def selected(self):
|
|
1777
|
+
return [obj for obj in self.objects() if obj.selected]
|
|
1778
|
+
|
|
1779
|
+
def selected_scans(self):
|
|
1780
|
+
return [s for s in self.scans if s.selected]
|
|
1781
|
+
|
|
1782
|
+
def select_only(self, objs):
|
|
1783
|
+
wanted = set(id(o) for o in (objs or ()))
|
|
1784
|
+
for obj in self.objects():
|
|
1785
|
+
obj.selected = id(obj) in wanted
|
|
1786
|
+
|
|
1787
|
+
def select_all(self, on=True):
|
|
1788
|
+
"""Everything, or nothing. "Everything" leaves the axes out: they
|
|
1789
|
+
are the frame, not something to move or restyle with the rest."""
|
|
1790
|
+
for obj in self.objects():
|
|
1791
|
+
obj.selected = bool(on) and not isinstance(obj, Axis)
|
|
1792
|
+
|
|
1793
|
+
# ---------------------------------------------------------------- state
|
|
1794
|
+
@property
|
|
1795
|
+
def exo(self):
|
|
1796
|
+
"""Which way exotherms point in this figure, from the arrow."""
|
|
1797
|
+
return self.arrow.orientation
|
|
1798
|
+
|
|
1799
|
+
def visible_scans(self):
|
|
1800
|
+
return [s for s in self.scans if s.visible]
|
|
1801
|
+
|
|
1802
|
+
# ---------------------------------------------------------- the order
|
|
1803
|
+
# The OUTLINER's order is the figure's: files top to bottom as listed
|
|
1804
|
+
# (dragged into place), a file's curves in its row order. S and "Stack
|
|
1805
|
+
# evenly" stack in it, the top of the list at the top of the stack; the
|
|
1806
|
+
# legend lists in it.
|
|
1807
|
+
def outliner_key(self, scan):
|
|
1808
|
+
"""Where `scan` stands in the outliner: file, segment, curve."""
|
|
1809
|
+
sample = scan.sample
|
|
1810
|
+
at = (self.samples.index(sample) if sample in self.samples
|
|
1811
|
+
else len(self.samples))
|
|
1812
|
+
row = (SIGNAL_ROWS.index(scan.signal) if scan.signal in SIGNAL_ROWS
|
|
1813
|
+
else len(SIGNAL_ROWS))
|
|
1814
|
+
return (at, int(scan.seg), row)
|
|
1815
|
+
|
|
1816
|
+
def in_outliner_order(self, scans):
|
|
1817
|
+
"""`scans`, top of the outliner first."""
|
|
1818
|
+
return sorted(scans, key=self.outliner_key)
|
|
1819
|
+
|
|
1820
|
+
def set_sample_order(self, samples):
|
|
1821
|
+
"""The files in this order (all of them, each once), and the scans
|
|
1822
|
+
with them, so the legend and everything else that lists them
|
|
1823
|
+
agrees with the outliner."""
|
|
1824
|
+
if sorted(map(id, samples)) != sorted(map(id, self.samples)):
|
|
1825
|
+
raise ValueError("not an order of this figure's files")
|
|
1826
|
+
self.samples = list(samples)
|
|
1827
|
+
self.scans.sort(key=self.outliner_key)
|
|
1828
|
+
|
|
1829
|
+
def unit_for(self, scan):
|
|
1830
|
+
"""The unit a scan is drawn in: the mass axis's for a mass scan,
|
|
1831
|
+
the DTG's for a DTG, the heat flow axis's otherwise."""
|
|
1832
|
+
if scan.is_mass:
|
|
1833
|
+
return self.weight_unit
|
|
1834
|
+
if getattr(scan, "is_dtg", False):
|
|
1835
|
+
return self.dtg_unit
|
|
1836
|
+
return self.y_unit
|
|
1837
|
+
|
|
1838
|
+
def shows(self, signal):
|
|
1839
|
+
"""True while a scan of `signal` is switched on."""
|
|
1840
|
+
return any(s.visible and s.signal == signal for s in self.scans)
|
|
1841
|
+
|
|
1842
|
+
def y_signal(self):
|
|
1843
|
+
"""What the y axis (`axes["y"]`) shows: the DTG while one is shown,
|
|
1844
|
+
else the heat flow. One axis, one quantity: a heat flow shown
|
|
1845
|
+
beside a DTG is reported as having no axis (`axis_missing`)."""
|
|
1846
|
+
return SIGNAL_DTG if self.shows(SIGNAL_DTG) else SIGNAL_HEAT
|
|
1847
|
+
|
|
1848
|
+
def y_axis_unit(self):
|
|
1849
|
+
"""The unit of the y axis: the DTG's while it shows one."""
|
|
1850
|
+
return (self.dtg_unit if self.y_signal() == SIGNAL_DTG
|
|
1851
|
+
else self.y_unit)
|
|
1852
|
+
|
|
1853
|
+
def axis_missing(self, scan):
|
|
1854
|
+
"""What stops `scan` being drawn for want of an AXIS, or None: a
|
|
1855
|
+
heat flow while the y axis is the DTG's."""
|
|
1856
|
+
if scan.is_heat and self.y_signal() == SIGNAL_DTG:
|
|
1857
|
+
return "axis (the y axis shows the DTG)"
|
|
1858
|
+
return None
|
|
1859
|
+
|
|
1860
|
+
def scans_missing(self, unit=None):
|
|
1861
|
+
"""Scans that cannot be drawn in the current unit, and why.
|
|
1862
|
+
|
|
1863
|
+
`[(scan, "molar mass"), ...]`. The window blinks these, the outliner
|
|
1864
|
+
marks them, and an export refuses to go out quietly with one in it.
|
|
1865
|
+
`unit` replaces the heat flow's unit (a mass scan keeps its own).
|
|
1866
|
+
"""
|
|
1867
|
+
out = []
|
|
1868
|
+
for scan in self.scans:
|
|
1869
|
+
if not scan.visible:
|
|
1870
|
+
continue
|
|
1871
|
+
own = (unit or self.y_unit) if scan.is_heat else self.unit_for(
|
|
1872
|
+
scan)
|
|
1873
|
+
missing = (self.axis_missing(scan)
|
|
1874
|
+
or scan.missing_for(own, self.x_axis))
|
|
1875
|
+
if missing:
|
|
1876
|
+
out.append((scan, missing))
|
|
1877
|
+
return out
|
|
1878
|
+
|
|
1879
|
+
def set_unit(self, unit):
|
|
1880
|
+
"""Change the y unit, carrying every offset across with it.
|
|
1881
|
+
|
|
1882
|
+
Each scan's offset moves by ITS OWN conversion factor, so a stack
|
|
1883
|
+
keeps its shape in W/g to mW (one factor for everybody) and genuinely
|
|
1884
|
+
rearranges on a per-mole axis (a factor per sample). The second one
|
|
1885
|
+
looks like a bug and is not: two samples of different molar mass
|
|
1886
|
+
really are in a different relationship once the axis counts moles.
|
|
1887
|
+
"""
|
|
1888
|
+
if unit == self.y_unit:
|
|
1889
|
+
return []
|
|
1890
|
+
changes = self._convert_offsets(
|
|
1891
|
+
[s for s in self.scans if s.is_heat], self.y_unit, unit)
|
|
1892
|
+
self.y_unit = unit
|
|
1893
|
+
return changes
|
|
1894
|
+
|
|
1895
|
+
def set_dtg_unit(self, unit):
|
|
1896
|
+
"""`set_unit` for a DTG (%/degC or %/min): the DTG scans' offsets
|
|
1897
|
+
convert by each segment's heating rate."""
|
|
1898
|
+
if unit not in dtg_module.UNITS or unit == self.dtg_unit:
|
|
1899
|
+
return []
|
|
1900
|
+
changes = self._convert_offsets(
|
|
1901
|
+
[s for s in self.scans if s.is_dtg], self.dtg_unit, unit)
|
|
1902
|
+
self.dtg_unit = unit
|
|
1903
|
+
return changes
|
|
1904
|
+
|
|
1905
|
+
def set_weight_unit(self, unit):
|
|
1906
|
+
"""`set_unit` for the mass axis ("%" or "mg"): the mass scans'
|
|
1907
|
+
offsets and their labels' records convert by the sample mass."""
|
|
1908
|
+
if unit not in WEIGHT_UNITS or unit == self.weight_unit:
|
|
1909
|
+
return []
|
|
1910
|
+
changes = self._convert_offsets(
|
|
1911
|
+
[s for s in self.scans if s.is_mass], self.weight_unit, unit)
|
|
1912
|
+
self.weight_unit = unit
|
|
1913
|
+
return changes
|
|
1914
|
+
|
|
1915
|
+
def _convert_offsets(self, scans, before, after):
|
|
1916
|
+
"""The changes that carry `scans`' offsets, and the offsets and
|
|
1917
|
+
note tips of the labels that belong to them, from unit `before` to
|
|
1918
|
+
`after`."""
|
|
1919
|
+
changes = []
|
|
1920
|
+
chosen = set(id(s) for s in scans)
|
|
1921
|
+
for scan in scans:
|
|
1922
|
+
old = scan.factor(before)
|
|
1923
|
+
new = scan.factor(after)
|
|
1924
|
+
if old and new and scan.offset:
|
|
1925
|
+
changes.append((scan, "offset",
|
|
1926
|
+
units.convert_offset(old, new, scan.offset)))
|
|
1927
|
+
# A label's record of its scan's offset is in the same unit, and
|
|
1928
|
+
# converts with it, or every owned label would jump on a unit change.
|
|
1929
|
+
for label in self.labels:
|
|
1930
|
+
if label.scan is None or id(label.scan) not in chosen:
|
|
1931
|
+
continue
|
|
1932
|
+
if not label.parent_offset:
|
|
1933
|
+
continue
|
|
1934
|
+
old = label.scan.factor(before)
|
|
1935
|
+
new = label.scan.factor(after)
|
|
1936
|
+
if old and new:
|
|
1937
|
+
changes.append((label, "parent_offset", units.convert_offset(
|
|
1938
|
+
old, new, label.parent_offset)))
|
|
1939
|
+
# A note on a scan points at a height of that scan's curve, which
|
|
1940
|
+
# converts with it. (A free note's point has no sample mass to
|
|
1941
|
+
# convert by, like any artist placed in data units.)
|
|
1942
|
+
for label in self.labels:
|
|
1943
|
+
if label.scan is None or id(label.scan) not in chosen:
|
|
1944
|
+
continue
|
|
1945
|
+
if not label.leader:
|
|
1946
|
+
continue
|
|
1947
|
+
old = label.scan.factor(before)
|
|
1948
|
+
new = label.scan.factor(after)
|
|
1949
|
+
if old and new:
|
|
1950
|
+
changes.append((label, "leader", [
|
|
1951
|
+
label.leader[0],
|
|
1952
|
+
units.convert_offset(old, new, label.leader[1])]))
|
|
1953
|
+
return changes
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def default_segments(sample, file_count=1):
|
|
1957
|
+
"""Which segments a newly opened file starts with: the first heating one.
|
|
1958
|
+
|
|
1959
|
+
Always one scan, whether it is the first file or the fifth. The template
|
|
1960
|
+
shows every segment of a lone file, and that is right for a quick look at
|
|
1961
|
+
one run; this panel is for STACKED comparisons, where seven curves from
|
|
1962
|
+
the first file and one from each of the others is a mess to undo by hand.
|
|
1963
|
+
|
|
1964
|
+
Everything else is one tick away in the outliner, which lists every
|
|
1965
|
+
segment of every open file. An SDT run opens with the MASS of its first
|
|
1966
|
+
heating: in SDT data the m% curve is the main result, and the heat flow
|
|
1967
|
+
usually a bonus.
|
|
1968
|
+
"""
|
|
1969
|
+
seg = first_upscan(sample)
|
|
1970
|
+
numdata = (sample.data or {}).get("numdata", [])
|
|
1971
|
+
if seg < len(numdata) and _records_mass(numdata[seg]):
|
|
1972
|
+
return [(seg, SIGNAL_MASS)]
|
|
1973
|
+
return [seg]
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def _records_mass(step):
|
|
1977
|
+
"""True when a segment recorded a weight (either column)."""
|
|
1978
|
+
dims = step.get("dims") or []
|
|
1979
|
+
return "Weight" in dims or "Weight Change" in dims
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def first_upscan(sample):
|
|
1983
|
+
"""The first segment whose temperature ends above where it started
|
|
1984
|
+
(measured samples only, like `Scan.direction`)."""
|
|
1985
|
+
for seg, step in enumerate((sample.data or {}).get("numdata", [])):
|
|
1986
|
+
dims = step.get("dims") or []
|
|
1987
|
+
if "Temperature" not in dims:
|
|
1988
|
+
continue
|
|
1989
|
+
temp = step["nums"][:, dims.index("Temperature")]
|
|
1990
|
+
temp = temp[np.isfinite(temp)]
|
|
1991
|
+
if len(temp) > 1 and float(temp[-1]) - float(temp[0]) > ISOTHERMAL_K:
|
|
1992
|
+
return seg
|
|
1993
|
+
return 0
|