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/figure.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The figure's size: free with the window, a fixed aspect ratio, or EXACT.
|
|
2
|
+
|
|
3
|
+
The case in point: a stack of first up-scans in one session file,
|
|
4
|
+
second up-scans in another, both dropped into Word side by side - and they
|
|
5
|
+
have to be the same size, with their axes boxes the same size and in the
|
|
6
|
+
same place, without fiddling. That rules out any layout that sizes itself:
|
|
7
|
+
|
|
8
|
+
* the figure has a PHYSICAL size, width x height in cm or inches;
|
|
9
|
+
* its MARGINS are physical too, and they fix the axes box exactly - the
|
|
10
|
+
numbers and the captions live inside them. A margin that sized itself to
|
|
11
|
+
the widest tick label would make "10.25" and "0.5" two different boxes.
|
|
12
|
+
|
|
13
|
+
The drawing is laid out in a space of `DESIGN_DPI` units per inch - the
|
|
14
|
+
logical DPI Qt uses for fonts on screen and in a QImage - so a 12 pt font is
|
|
15
|
+
12/72 inch in the figure, on screen (scaled to fit the pane) and in every
|
|
16
|
+
export. Lengths here are kept in the chosen unit and converted when the unit
|
|
17
|
+
changes, so switching cm <-> in never moves anything.
|
|
18
|
+
|
|
19
|
+
UI-free: plain attributes, so the undo stack and the session file treat it
|
|
20
|
+
like any other object.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
MODE_WINDOW = "window" # the figure is whatever the pane is
|
|
24
|
+
MODE_ASPECT = "aspect" # the pane shows it at a fixed width : height
|
|
25
|
+
MODE_SIZE = "size" # exact physical size and margins
|
|
26
|
+
MODES = (MODE_WINDOW, MODE_ASPECT, MODE_SIZE)
|
|
27
|
+
MODE_TITLES = {MODE_WINDOW: "follow the window",
|
|
28
|
+
MODE_ASPECT: "a fixed aspect ratio",
|
|
29
|
+
MODE_SIZE: "an exact size"}
|
|
30
|
+
|
|
31
|
+
UNIT_CM = "cm"
|
|
32
|
+
UNIT_IN = "in"
|
|
33
|
+
UNITS = (UNIT_CM, UNIT_IN)
|
|
34
|
+
PER_INCH = {UNIT_CM: 2.54, UNIT_IN: 1.0}
|
|
35
|
+
|
|
36
|
+
#: Drawing units per inch: Qt's logical DPI for fonts on screen and in a
|
|
37
|
+
#: QImage (checked at 150 % display scaling: 96, with the device
|
|
38
|
+
#: pixel ratio doing the rest). An SVG generator defaults to 72 and is set to
|
|
39
|
+
#: this explicitly on export.
|
|
40
|
+
DESIGN_DPI = 96.0
|
|
41
|
+
|
|
42
|
+
#: The lengths a layout holds, all in its `unit`.
|
|
43
|
+
LENGTHS = ("width", "height", "margin_left", "margin_right", "margin_top",
|
|
44
|
+
"margin_bottom")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FigureLayout(object):
|
|
48
|
+
"""How big the figure is, and where its axes box sits inside it."""
|
|
49
|
+
|
|
50
|
+
kind = "figure"
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.mode = MODE_WINDOW
|
|
54
|
+
self.unit = UNIT_CM
|
|
55
|
+
# A single-column journal figure, with room for 12 pt numbers and a
|
|
56
|
+
# 14 pt caption on the left and below.
|
|
57
|
+
self.width = 8.5
|
|
58
|
+
self.height = 6.5
|
|
59
|
+
self.margin_left = 1.9
|
|
60
|
+
self.margin_right = 0.3
|
|
61
|
+
self.margin_top = 0.3
|
|
62
|
+
self.margin_bottom = 1.5
|
|
63
|
+
#: Width : height for MODE_ASPECT.
|
|
64
|
+
self.aspect_w = 4.0
|
|
65
|
+
self.aspect_h = 3.0
|
|
66
|
+
#: Pixels per inch of a PNG export. An SVG is exact at any size.
|
|
67
|
+
self.dpi = 600
|
|
68
|
+
#: Room the PROGRAM grew a margin by for an axis that appeared on
|
|
69
|
+
#: its side (`MainWindow._room_for_axes`): `{side: [before, after]}`
|
|
70
|
+
#: in `unit`. When that axis goes, the margin goes back to `before`
|
|
71
|
+
#: (never below what is still drawn there) - unless it was set by
|
|
72
|
+
#: hand since, when it is no longer `after`. Not part of a
|
|
73
|
+
#: preset.
|
|
74
|
+
self.grown = {}
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------ lengths
|
|
77
|
+
def to_px(self, value):
|
|
78
|
+
"""A length in this layout's unit, in drawing units."""
|
|
79
|
+
return float(value) / PER_INCH[self.unit] * DESIGN_DPI
|
|
80
|
+
|
|
81
|
+
def size_px(self):
|
|
82
|
+
"""`(width, height)` of the whole figure in drawing units."""
|
|
83
|
+
return self.to_px(self.width), self.to_px(self.height)
|
|
84
|
+
|
|
85
|
+
def margins_px(self):
|
|
86
|
+
"""`(left, right, top, bottom)` in drawing units."""
|
|
87
|
+
return (self.to_px(self.margin_left), self.to_px(self.margin_right),
|
|
88
|
+
self.to_px(self.margin_top), self.to_px(self.margin_bottom))
|
|
89
|
+
|
|
90
|
+
def axes_size(self):
|
|
91
|
+
"""`(width, height)` of the axes box, in the layout's unit."""
|
|
92
|
+
return (self.width - self.margin_left - self.margin_right,
|
|
93
|
+
self.height - self.margin_top - self.margin_bottom)
|
|
94
|
+
|
|
95
|
+
def set_axes_size(self, width, height):
|
|
96
|
+
"""Size the figure so its axes box is `width` x `height`, margins
|
|
97
|
+
unchanged."""
|
|
98
|
+
self.width = float(width) + self.margin_left + self.margin_right
|
|
99
|
+
self.height = float(height) + self.margin_top + self.margin_bottom
|
|
100
|
+
|
|
101
|
+
def inches(self):
|
|
102
|
+
return (self.width / PER_INCH[self.unit],
|
|
103
|
+
self.height / PER_INCH[self.unit])
|
|
104
|
+
|
|
105
|
+
def set_unit(self, unit):
|
|
106
|
+
"""Change the unit, CONVERTING every length so nothing moves."""
|
|
107
|
+
if unit not in UNITS or unit == self.unit:
|
|
108
|
+
return
|
|
109
|
+
factor = PER_INCH[unit] / PER_INCH[self.unit]
|
|
110
|
+
for name in LENGTHS:
|
|
111
|
+
setattr(self, name, getattr(self, name) * factor)
|
|
112
|
+
self.grown = dict((side, [pair[0] * factor, pair[1] * factor])
|
|
113
|
+
for side, pair in self.grown.items())
|
|
114
|
+
self.unit = unit
|
|
115
|
+
|
|
116
|
+
def is_valid(self):
|
|
117
|
+
axes_w, axes_h = self.axes_size()
|
|
118
|
+
return (min(self.width, self.height) > 0 and axes_w > 0
|
|
119
|
+
and axes_h > 0 and min(getattr(self, n) for n in LENGTHS) >= 0
|
|
120
|
+
and self.aspect_w > 0 and self.aspect_h > 0 and self.dpi > 0)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------- state
|
|
123
|
+
FIELDS = ("mode", "unit") + LENGTHS + ("aspect_w", "aspect_h", "dpi")
|
|
124
|
+
|
|
125
|
+
def to_state(self):
|
|
126
|
+
return dict((name, getattr(self, name)) for name in self.FIELDS)
|
|
127
|
+
|
|
128
|
+
def load_state(self, state):
|
|
129
|
+
"""Take what is legal from a saved state; keep the rest."""
|
|
130
|
+
if not isinstance(state, dict):
|
|
131
|
+
return self
|
|
132
|
+
if state.get("mode") in MODES:
|
|
133
|
+
self.mode = state["mode"]
|
|
134
|
+
if state.get("unit") in UNITS:
|
|
135
|
+
self.unit = state["unit"]
|
|
136
|
+
for name in LENGTHS + ("aspect_w", "aspect_h"):
|
|
137
|
+
try:
|
|
138
|
+
value = float(state[name])
|
|
139
|
+
except (KeyError, TypeError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
if value >= 0:
|
|
142
|
+
setattr(self, name, value)
|
|
143
|
+
try:
|
|
144
|
+
self.dpi = max(30, min(2400, int(state.get("dpi", self.dpi))))
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
pass
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def copy(self):
|
|
150
|
+
made = FigureLayout().load_state(self.to_state())
|
|
151
|
+
made.grown = clean_grown(self.grown)
|
|
152
|
+
return made
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: The page's four margins, as `FigureLayout` names them.
|
|
156
|
+
MARGIN_SIDES = ("left", "right", "top", "bottom")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def clean_grown(value):
|
|
160
|
+
"""A `FigureLayout.grown` read from a file: what is legal of it."""
|
|
161
|
+
out = {}
|
|
162
|
+
if not isinstance(value, dict):
|
|
163
|
+
return out
|
|
164
|
+
for side, pair in value.items():
|
|
165
|
+
try:
|
|
166
|
+
before, after = float(pair[0]), float(pair[1])
|
|
167
|
+
except (TypeError, ValueError, IndexError, KeyError):
|
|
168
|
+
continue
|
|
169
|
+
if side in MARGIN_SIDES and 0 <= before <= after:
|
|
170
|
+
out[side] = [before, after]
|
|
171
|
+
return out
|
dscpanel/core/labels.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""What an analysis label SAYS: the words are the user's, the number is not.
|
|
2
|
+
|
|
3
|
+
A label is a TEMPLATE. `{}` is the measured value, filled in by the program
|
|
4
|
+
every time the label is drawn; everything else is the user's text, with the
|
|
5
|
+
figure markup (`*T*` italic, `_{on}` subscript, `\\Delta`). The default
|
|
6
|
+
templates are `*T*_{on} = {}`, `*T*_{end} = {}`, `*T*_{g} = {}` and
|
|
7
|
+
`\\Delta*H* = {}`.
|
|
8
|
+
|
|
9
|
+
The rules, and why each one is where the program takes control away:
|
|
10
|
+
|
|
11
|
+
1. **The number is never typed.** It reaches a label only through `{}`, so
|
|
12
|
+
re-measuring, switching the axis to Kelvin, or giving the sample its
|
|
13
|
+
molar mass can never leave a stale number on the figure. (Before this,
|
|
14
|
+
a label stored its number as text and had to be rewritten on every
|
|
15
|
+
change - and a typed one never was.)
|
|
16
|
+
2. **A unit is a CONVERSION, not a relabelling** - after `{}`, or in the
|
|
17
|
+
number format (`%.0f degF`). `{} degF` on an onset shows the onset in
|
|
18
|
+
Fahrenheit; `{} kJ/mol` on an enthalpy multiplies by the molar mass. The
|
|
19
|
+
label then says a different unit from the axes but the same thing: it
|
|
20
|
+
still stands at the right place on the curve. With no unit the value
|
|
21
|
+
follows the axes: temperatures in the x axis's unit, an enthalpy per
|
|
22
|
+
mole when the y axis is per mole. Units are read in any case, and
|
|
23
|
+
`F`, `C`, `degF` mean degrees.
|
|
24
|
+
3. **A unit the quantity cannot be converted into is refused**: `{} J/g` on
|
|
25
|
+
a temperature is drawn in the temperature's own unit, and the settings
|
|
26
|
+
say why. A number is never drawn beside a unit it is not in.
|
|
27
|
+
4. **A conversion that needs what is missing shows `?`**: `{} kJ/mol`
|
|
28
|
+
without a molar mass is `? kJ/mol`, and every export says NO MOLAR MASS -
|
|
29
|
+
the same rule as a per-mole axis. Nothing is assumed.
|
|
30
|
+
5. **The digits are the user's** (`core/numbers.py`): rounding changes how
|
|
31
|
+
exact a number looks, never what it is, and the right precision depends
|
|
32
|
+
on the instrument and the claim - which the program does not know.
|
|
33
|
+
6. **Free text is free.** A number typed by hand ("lit. 148 degC") is
|
|
34
|
+
allowed, because it may be a reference value; but one next to a unit of
|
|
35
|
+
the analysed quantity is flagged in the settings as not the measurement.
|
|
36
|
+
|
|
37
|
+
UI-free, like the rest of `core/`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import re
|
|
41
|
+
|
|
42
|
+
from . import numbers
|
|
43
|
+
from . import units
|
|
44
|
+
|
|
45
|
+
TEMPERATURE = "temperature"
|
|
46
|
+
ENTHALPY = "enthalpy"
|
|
47
|
+
HEAT_FLOW = "heat flow"
|
|
48
|
+
#: An SDT run's m% at a temperature: "%" of the sample mass, or
|
|
49
|
+
#: "mg" - which needs the sample mass unless the file recorded the mg.
|
|
50
|
+
MASS = "mass"
|
|
51
|
+
|
|
52
|
+
#: The template an analysis carries until somebody writes their own, by
|
|
53
|
+
#: model. Checked in order: a glass transition's fields also carry an
|
|
54
|
+
#: "Onset x".
|
|
55
|
+
DEFAULTS = (
|
|
56
|
+
# The bare number: "99%".
|
|
57
|
+
("Mass at", "{}"),
|
|
58
|
+
("Glass", "*T*_{g} = {}"),
|
|
59
|
+
("Endset", "*T*_{end} = {}"),
|
|
60
|
+
("Onset", "*T*_{on} = {}"),
|
|
61
|
+
("Integration", "\\Delta*H* = {}"),
|
|
62
|
+
("Peak height", "*q*_{peak} = {}"),
|
|
63
|
+
("Signal change", "\\Delta*q* = {}"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_DEG = "°"
|
|
67
|
+
|
|
68
|
+
#: Every unit a label may ask for, per quantity, as a factor from the base
|
|
69
|
+
#: unit (degC, J/g, W/g) and what that factor needs.
|
|
70
|
+
#: `(factor, needs)`: needs is None, "mass" (grams) or "molar" (g/mol).
|
|
71
|
+
_ENTHALPY_UNITS = {
|
|
72
|
+
"J/g": (1.0, None), "mJ/mg": (1.0, None), "kJ/g": (1e-3, None),
|
|
73
|
+
"J/mol": (1.0, "molar"), "kJ/mol": (1e-3, "molar"),
|
|
74
|
+
"J": (1.0, "mass"), "mJ": (1e3, "mass"), "kJ": (1e-3, "mass"),
|
|
75
|
+
}
|
|
76
|
+
_HEAT_FLOW_UNITS = {
|
|
77
|
+
"W/g": (1.0, None), "mW/mg": (1.0, None),
|
|
78
|
+
"W": (1.0, "mass"), "mW": (1e3, "mass"),
|
|
79
|
+
"W/mol": (1.0, "molar"), "mW/mol": (1e3, "molar"),
|
|
80
|
+
}
|
|
81
|
+
_TEMPERATURE_UNITS = {
|
|
82
|
+
_DEG + "C": units.TEMP_C, "degC": units.TEMP_C,
|
|
83
|
+
_DEG + "F": units.TEMP_F, "degF": units.TEMP_F,
|
|
84
|
+
"K": units.TEMP_K,
|
|
85
|
+
}
|
|
86
|
+
#: From percent of the sample mass: mg = % / 100 x the mass in mg, which is
|
|
87
|
+
#: 10 x the mass in grams.
|
|
88
|
+
_MASS_UNITS = {"%": (1.0, None), "mg": (10.0, "mass")}
|
|
89
|
+
_BY_QUANTITY = {TEMPERATURE: _TEMPERATURE_UNITS, ENTHALPY: _ENTHALPY_UNITS,
|
|
90
|
+
HEAT_FLOW: _HEAT_FLOW_UNITS, MASS: _MASS_UNITS}
|
|
91
|
+
|
|
92
|
+
_ALL_UNITS = sorted(set(_ENTHALPY_UNITS) | set(_HEAT_FLOW_UNITS)
|
|
93
|
+
| set(_TEMPERATURE_UNITS) | set(_MASS_UNITS),
|
|
94
|
+
key=len, reverse=True)
|
|
95
|
+
#: Other spellings of the temperature units, lower case.
|
|
96
|
+
_ALIASES = {"c": _DEG + "C", "f": _DEG + "F", "k": "K",
|
|
97
|
+
_DEG + "k": "K", "degk": "K"}
|
|
98
|
+
_UNIT = "(?:{})(?![A-Za-z/])".format(
|
|
99
|
+
"|".join(re.escape(u) for u in _ALL_UNITS))
|
|
100
|
+
#: In a label, after `{}`: the known units in any case, and C / F.
|
|
101
|
+
_LABEL_UNIT = "(?i:{}|[cf])(?![A-Za-z/])".format(
|
|
102
|
+
"|".join(re.escape(u) for u in _ALL_UNITS))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def canonical_unit(text):
|
|
106
|
+
"""A unit as the program writes it ("degF" -> the degree-F sign,
|
|
107
|
+
"kj/mol" -> "kJ/mol"), or None for anything it does not know."""
|
|
108
|
+
token = str(text or "").strip().replace(" ", "")
|
|
109
|
+
lowered = token.lower()
|
|
110
|
+
if lowered in _ALIASES:
|
|
111
|
+
return _ALIASES[lowered]
|
|
112
|
+
for unit in _ALL_UNITS:
|
|
113
|
+
if unit.lower() == lowered:
|
|
114
|
+
return (_TEMPERATURE_UNITS.get(unit) and
|
|
115
|
+
units.TEMPERATURE_LABEL[_TEMPERATURE_UNITS[unit]]) or unit
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normalise_format(spec):
|
|
120
|
+
"""A number format that may carry a unit (`%.0f degF`), canonical."""
|
|
121
|
+
return numbers.normalise(spec, canonical_unit)
|
|
122
|
+
#: `{}`, and the unit right after it if one was written. Not `_{}` or
|
|
123
|
+
#: `^{}`, which are markup (an empty subscript).
|
|
124
|
+
_PLACEHOLDER = re.compile(r"(?<![_^])\{\}(?:(\s*)(" + _LABEL_UNIT + "))?")
|
|
125
|
+
_TYPED = re.compile(r"(?<![\w.])[-+]?\d+(?:[.,]\d+)?\s*(" + _UNIT + ")")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Rendered(object):
|
|
129
|
+
"""A label as drawn: its text and what is wrong with it.
|
|
130
|
+
|
|
131
|
+
`problems` is `[(kind, message), ...]`: "missing" (a value that cannot
|
|
132
|
+
be given without a molar or sample mass - exports say so), "unit" (a
|
|
133
|
+
unit the quantity cannot be put in) and "typed" (a number written by
|
|
134
|
+
hand beside a unit of the quantity: not the measurement).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, text, problems=(), value_text=""):
|
|
138
|
+
self.text = text
|
|
139
|
+
self.problems = list(problems)
|
|
140
|
+
self.value_text = value_text
|
|
141
|
+
|
|
142
|
+
def missing(self):
|
|
143
|
+
return [m for kind, m in self.problems if kind == "missing"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def quantity_of(model_name):
|
|
147
|
+
"""What an analysis of this model reports: a temperature, an enthalpy
|
|
148
|
+
or a heat flow."""
|
|
149
|
+
name = str(model_name)
|
|
150
|
+
if "Integration" in name:
|
|
151
|
+
return ENTHALPY
|
|
152
|
+
lowered = name.lower()
|
|
153
|
+
if lowered.startswith("mass at"):
|
|
154
|
+
return MASS
|
|
155
|
+
if "height" in lowered or "change" in lowered:
|
|
156
|
+
return HEAT_FLOW
|
|
157
|
+
return TEMPERATURE
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def default_template(analysis):
|
|
161
|
+
for word, template in DEFAULTS:
|
|
162
|
+
if word in analysis.model_name:
|
|
163
|
+
return template
|
|
164
|
+
return "%s = {}" % analysis.model_name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def template_of(analysis):
|
|
168
|
+
return (analysis.label if analysis.label is not None
|
|
169
|
+
else default_template(analysis))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def result(analysis):
|
|
173
|
+
"""`(value, quantity)` in the base unit (degC, J/g, W/g), value None
|
|
174
|
+
when the analysis carries no decoded number."""
|
|
175
|
+
from .model import number
|
|
176
|
+
name = analysis.model_name
|
|
177
|
+
fields = analysis.fields
|
|
178
|
+
quantity = quantity_of(name)
|
|
179
|
+
if quantity == ENTHALPY:
|
|
180
|
+
return number(fields.get("Enthalpy (normalized)")), quantity
|
|
181
|
+
if quantity == MASS:
|
|
182
|
+
return number(fields.get("Mass")), quantity
|
|
183
|
+
if quantity == HEAT_FLOW:
|
|
184
|
+
for key, text in fields.items():
|
|
185
|
+
if key in ("Model", "Cursor x", "Cursor x1", "segment", "prog",
|
|
186
|
+
"attribution"):
|
|
187
|
+
continue
|
|
188
|
+
value = number(text)
|
|
189
|
+
if value is None:
|
|
190
|
+
continue
|
|
191
|
+
if "mW" in str(text) and "/" not in str(text):
|
|
192
|
+
mass = _mass(analysis)
|
|
193
|
+
return ((value / 1000.0 / mass) if mass else None), quantity
|
|
194
|
+
return value, quantity
|
|
195
|
+
return None, quantity
|
|
196
|
+
if "Glass" in name:
|
|
197
|
+
return number(fields.get("Midpoint")), quantity
|
|
198
|
+
if "Endset" in name:
|
|
199
|
+
return number(fields.get("Endset x")), quantity
|
|
200
|
+
if "Onset" in name:
|
|
201
|
+
return number(fields.get("Onset x")), quantity
|
|
202
|
+
return analysis.value(), quantity
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def units_of(quantity):
|
|
206
|
+
"""The units a result of `quantity` can be shown in, in the order a
|
|
207
|
+
list offers them."""
|
|
208
|
+
return {TEMPERATURE: [_DEG + "C", "K", _DEG + "F"],
|
|
209
|
+
ENTHALPY: ["J/g", "kJ/mol", "J/mol", "kJ/g", "J", "mJ"],
|
|
210
|
+
HEAT_FLOW: ["W/g", "mW", "W/mol", "mW/mol", "W"],
|
|
211
|
+
MASS: ["%", "mg"]}.get(quantity, [])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def natural_unit(quantity, doc):
|
|
215
|
+
"""The unit a value takes when the label names none: the axes'."""
|
|
216
|
+
if quantity == TEMPERATURE:
|
|
217
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
218
|
+
return units.TEMPERATURE_LABEL.get(unit, _DEG + "C")
|
|
219
|
+
if quantity == MASS:
|
|
220
|
+
return getattr(doc, "weight_unit", "%") if doc else "%"
|
|
221
|
+
y_unit = getattr(doc, "y_unit", units.UNIT_W_G) if doc else units.UNIT_W_G
|
|
222
|
+
if quantity == ENTHALPY:
|
|
223
|
+
return "kJ/mol" if y_unit == units.UNIT_W_MOL else "J/g"
|
|
224
|
+
return y_unit
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def number_format(analysis, doc):
|
|
228
|
+
from . import style
|
|
229
|
+
return style.value(doc, analysis, "number_format")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
#: An integration's peak temperature, where a label asks for it.
|
|
233
|
+
_PEAK = re.compile(r"\{Tp\}")
|
|
234
|
+
#: What "Tp shown" adds after an integration's own words.
|
|
235
|
+
PEAK_TEMPLATE = "*T*_{p} = {Tp}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def peak_text(analysis, doc=None):
|
|
239
|
+
"""An integration's peak temperature, written in the axes' temperature
|
|
240
|
+
unit with the house temperature format ("124 \u00b0C"), or "?"."""
|
|
241
|
+
from . import style
|
|
242
|
+
from .model import number
|
|
243
|
+
value = number(getattr(analysis, "fields", {}).get("Peak temperature"))
|
|
244
|
+
if value is None:
|
|
245
|
+
return "? " + natural_unit(TEMPERATURE, doc)
|
|
246
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
247
|
+
spec = (style.figure_value(doc, "temperature_format") if doc is not None
|
|
248
|
+
else style.preference("temperature_format"))
|
|
249
|
+
converted = float(units.from_celsius(value, unit))
|
|
250
|
+
return "{} {}".format(numbers.write(converted, spec, numbers.TEMPERATURE),
|
|
251
|
+
units.TEMPERATURE_LABEL.get(unit, unit))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def shows_peak(analysis, doc=None):
|
|
255
|
+
"""True when an integration's label is to give its Tp as well."""
|
|
256
|
+
from . import style
|
|
257
|
+
if "Integration" not in getattr(analysis, "model_name", ""):
|
|
258
|
+
return False
|
|
259
|
+
return style.value(doc, analysis, "show_peak") == style.PEAK_ON
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def render(analysis, doc=None):
|
|
263
|
+
"""The label's text, with every `{}` filled in, and its problems."""
|
|
264
|
+
template = template_of(analysis)
|
|
265
|
+
# Tp: where the label says `{Tp}`, else after it when asked for.
|
|
266
|
+
if shows_peak(analysis, doc) and not _PEAK.search(template):
|
|
267
|
+
template = template + ", " + PEAK_TEMPLATE
|
|
268
|
+
value, quantity = result(analysis)
|
|
269
|
+
spec = number_format(analysis, doc)
|
|
270
|
+
problems = []
|
|
271
|
+
wanted = _BY_QUANTITY[quantity]
|
|
272
|
+
natural = natural_unit(quantity, doc)
|
|
273
|
+
# The format's unit, if it names one this quantity can be in. The
|
|
274
|
+
# house style's formats cover several quantities (an enthalpy and a
|
|
275
|
+
# peak height share one), so a unit that does not fit is simply not
|
|
276
|
+
# for this one; a format chosen for THIS analysis is held to it.
|
|
277
|
+
format_unit = numbers.split(spec)[1]
|
|
278
|
+
if format_unit is not None and canonical_unit(format_unit) not in wanted:
|
|
279
|
+
if analysis.number_format is not None:
|
|
280
|
+
problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
|
|
281
|
+
.format(format_unit, quantity, natural)))
|
|
282
|
+
format_unit = None
|
|
283
|
+
# The analysis's own unit, chosen in its settings (J/mol or kJ/mol
|
|
284
|
+
# could not be set any other way).
|
|
285
|
+
own = canonical_unit(getattr(analysis, "unit", None))
|
|
286
|
+
if own is not None and own not in wanted:
|
|
287
|
+
problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
|
|
288
|
+
.format(own, quantity, natural)))
|
|
289
|
+
own = None
|
|
290
|
+
default = own or format_unit or natural
|
|
291
|
+
shown = []
|
|
292
|
+
|
|
293
|
+
def fill(match):
|
|
294
|
+
typed = canonical_unit(match.group(2)) if match.group(2) else None
|
|
295
|
+
unit = typed or default
|
|
296
|
+
if typed and typed not in wanted:
|
|
297
|
+
problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
|
|
298
|
+
.format(match.group(2), quantity,
|
|
299
|
+
default)))
|
|
300
|
+
unit = default
|
|
301
|
+
text = _in_unit(analysis, value, quantity, unit, spec, problems)
|
|
302
|
+
shown.append(text)
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
text = _PLACEHOLDER.sub(fill, template)
|
|
306
|
+
if _PEAK.search(text):
|
|
307
|
+
text = _PEAK.sub(lambda _m: peak_text(analysis, doc), text)
|
|
308
|
+
if analysis.label is not None:
|
|
309
|
+
for match in _TYPED.finditer(
|
|
310
|
+
_PEAK.sub("", _PLACEHOLDER.sub("", template))):
|
|
311
|
+
if canonical_unit(match.group(1)) in wanted:
|
|
312
|
+
problems.append(("typed", "'{}' is typed by hand, not the "
|
|
313
|
+
"measurement; {{}} shows the "
|
|
314
|
+
"measured value".format(
|
|
315
|
+
match.group(0).strip())))
|
|
316
|
+
return Rendered(text, problems, shown[0] if shown else "")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _in_unit(analysis, value, quantity, unit, spec, problems):
|
|
320
|
+
"""`value` (base unit) converted to `unit` and written, with the unit."""
|
|
321
|
+
if quantity == MASS:
|
|
322
|
+
# The milligrams the file recorded are used as they are: a run
|
|
323
|
+
# whose percentage has no known reference (a negative recorded
|
|
324
|
+
# weight) still has its mg (TRI-FORMAT.md section 3b).
|
|
325
|
+
from .model import number
|
|
326
|
+
if unit == "mg":
|
|
327
|
+
recorded = number(getattr(analysis, "fields", {}).get(
|
|
328
|
+
"Mass (mg)"))
|
|
329
|
+
if recorded is not None:
|
|
330
|
+
return "{} mg".format(numbers.write(recorded, spec,
|
|
331
|
+
numbers.MASS))
|
|
332
|
+
if value is None:
|
|
333
|
+
return "? " + unit
|
|
334
|
+
if value is None:
|
|
335
|
+
return "? " + unit
|
|
336
|
+
if quantity == TEMPERATURE:
|
|
337
|
+
target = _TEMPERATURE_UNITS.get(unit, units.TEMP_C)
|
|
338
|
+
converted = float(units.from_celsius(value, target))
|
|
339
|
+
return "{} {}".format(numbers.write(converted, spec,
|
|
340
|
+
numbers.TEMPERATURE),
|
|
341
|
+
units.TEMPERATURE_LABEL.get(target, unit))
|
|
342
|
+
table = _BY_QUANTITY[quantity]
|
|
343
|
+
factor, needs = table.get(unit, (1.0, None))
|
|
344
|
+
if needs == "molar":
|
|
345
|
+
molar = getattr(getattr(analysis, "scan", None), "molar_mass", None)
|
|
346
|
+
if not molar:
|
|
347
|
+
problems.append(("missing", "NO MOLAR MASS: {} cannot be given "
|
|
348
|
+
"in {}".format(quantity, unit)))
|
|
349
|
+
return "? " + unit
|
|
350
|
+
factor *= float(molar)
|
|
351
|
+
elif needs == "mass":
|
|
352
|
+
mass = _mass(analysis)
|
|
353
|
+
if not mass:
|
|
354
|
+
problems.append(("missing", "NO SAMPLE MASS: {} cannot be given "
|
|
355
|
+
"in {}".format(quantity, unit)))
|
|
356
|
+
return "? " + unit
|
|
357
|
+
factor *= float(mass)
|
|
358
|
+
fallback = numbers.MASS if quantity == MASS else numbers.VALUE
|
|
359
|
+
return "{} {}".format(numbers.write(value * factor, spec, fallback),
|
|
360
|
+
unit)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _mass(analysis):
|
|
364
|
+
scan = getattr(analysis, "scan", None)
|
|
365
|
+
sample = getattr(scan, "sample", None)
|
|
366
|
+
return getattr(sample, "mass_g", None)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def convert_heat_flow(value, from_unit, to_unit, scan):
|
|
370
|
+
"""A heat flow (an offset, a peak height) from one unit to another, or
|
|
371
|
+
None when the conversion needs a mass the scan does not have."""
|
|
372
|
+
table = _HEAT_FLOW_UNITS
|
|
373
|
+
source, target = canonical_unit(from_unit), canonical_unit(to_unit)
|
|
374
|
+
if source not in table or target not in table:
|
|
375
|
+
return None
|
|
376
|
+
factors = []
|
|
377
|
+
for unit in (source, target):
|
|
378
|
+
factor, needs = table[unit]
|
|
379
|
+
if needs == "molar":
|
|
380
|
+
molar = getattr(scan, "molar_mass", None)
|
|
381
|
+
if not molar:
|
|
382
|
+
return None
|
|
383
|
+
factor *= float(molar)
|
|
384
|
+
elif needs == "mass":
|
|
385
|
+
mass = getattr(getattr(scan, "sample", None), "mass_g", None)
|
|
386
|
+
if not mass:
|
|
387
|
+
return None
|
|
388
|
+
factor *= float(mass)
|
|
389
|
+
factors.append(factor)
|
|
390
|
+
return float(value) / factors[0] * factors[1]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
_PLAIN_NAMES = {"Enthalpy (normalized)": "Enthalpy"}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def results(analysis, doc=None):
|
|
397
|
+
"""`[(name, text), ...]`: every result an analysis carries beyond its
|
|
398
|
+
cursors, named plainly and written in the figure's units and formats -
|
|
399
|
+
what the settings show under "Results"."""
|
|
400
|
+
from .model import number
|
|
401
|
+
from . import style
|
|
402
|
+
out = []
|
|
403
|
+
skip = ("Model", "segment", "prog", "attribution")
|
|
404
|
+
for key, text in analysis.fields.items():
|
|
405
|
+
if key in skip or "cursor" in key.lower():
|
|
406
|
+
continue
|
|
407
|
+
value = number(text)
|
|
408
|
+
if value is None:
|
|
409
|
+
continue
|
|
410
|
+
# Plain names, not TRIOS's field names ("Onset x", "Enthalpy
|
|
411
|
+
# (normalized)"), which mean nothing without the file format.
|
|
412
|
+
name = key[:-2] if key.endswith(" x") else key
|
|
413
|
+
name = _PLAIN_NAMES.get(name, name)
|
|
414
|
+
raw = str(text)
|
|
415
|
+
if _DEG + "C" in raw or "degC" in raw or key.endswith(" x") \
|
|
416
|
+
or key in ("Midpoint", "Peak temperature"):
|
|
417
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
418
|
+
shown = numbers.write(units.from_celsius(value, unit),
|
|
419
|
+
style.figure_value(doc,
|
|
420
|
+
"temperature_format"),
|
|
421
|
+
numbers.TEMPERATURE)
|
|
422
|
+
out.append((name, "{} {}".format(
|
|
423
|
+
shown, units.TEMPERATURE_LABEL.get(unit, unit))))
|
|
424
|
+
continue
|
|
425
|
+
found = re.search(r"[A-Za-z%/]+(?:/[A-Za-z]+)?\s*$", raw)
|
|
426
|
+
unit = found.group(0).strip() if found else ""
|
|
427
|
+
shown = numbers.write(value, style.figure_value(doc, "value_format"),
|
|
428
|
+
numbers.VALUE)
|
|
429
|
+
out.append((name, "{} {}".format(shown, unit).strip()))
|
|
430
|
+
return out
|