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/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