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/undo.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Undo and redo over object properties.
|
|
2
|
+
|
|
3
|
+
Built in from the first round rather than added later, because everything in
|
|
4
|
+
this program is "change a property of an object" - an offset dragged, a colour
|
|
5
|
+
picked, a molar mass typed, a scan hidden - and a transform system that has to
|
|
6
|
+
grow an undo stack afterwards is a rewrite of the transform system.
|
|
7
|
+
|
|
8
|
+
**A gesture is one entry.** Dragging a scan emits a property change per mouse
|
|
9
|
+
move; thirty of them in a drag must not be thirty undo steps. So a command
|
|
10
|
+
knows how to `merge` with the one before it, and the stack merges while a
|
|
11
|
+
gesture is live (`push(..., merge=True)`) and stops merging when the gesture
|
|
12
|
+
ends (`seal`). That is the same rule Blender uses: one Ctrl+Z per action, not
|
|
13
|
+
per frame.
|
|
14
|
+
|
|
15
|
+
UI-free: an object is anything with settable attributes, so this is testable
|
|
16
|
+
without a window.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command(object):
|
|
21
|
+
"""One undoable change. Subclasses implement `undo` and `redo`."""
|
|
22
|
+
|
|
23
|
+
#: What the menu entry says: "Undo move scan".
|
|
24
|
+
label = "change"
|
|
25
|
+
|
|
26
|
+
def undo(self):
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def redo(self):
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def merge(self, other):
|
|
33
|
+
"""Absorb `other`, which happened after this one. True if absorbed."""
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SetProps(Command):
|
|
38
|
+
"""Set named attributes on objects, remembering what they were.
|
|
39
|
+
|
|
40
|
+
Takes a list of `(obj, name, new_value)` and reads the old values itself,
|
|
41
|
+
so a caller cannot forget to snapshot one. Objects are held by reference:
|
|
42
|
+
an undo stack over deleted objects would resurrect them half-connected,
|
|
43
|
+
so deletion is its own command and deleted objects are kept alive by it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, changes, label="change"):
|
|
47
|
+
self.label = label
|
|
48
|
+
self._entries = []
|
|
49
|
+
for obj, name, new in changes:
|
|
50
|
+
self._entries.append((obj, name, getattr(obj, name), new))
|
|
51
|
+
self.redo()
|
|
52
|
+
|
|
53
|
+
def undo(self):
|
|
54
|
+
for obj, name, old, _new in self._entries:
|
|
55
|
+
setattr(obj, name, old)
|
|
56
|
+
|
|
57
|
+
def redo(self):
|
|
58
|
+
for obj, name, _old, new in self._entries:
|
|
59
|
+
setattr(obj, name, new)
|
|
60
|
+
|
|
61
|
+
def merge(self, other):
|
|
62
|
+
"""Absorb a later change to the SAME properties of the same objects.
|
|
63
|
+
|
|
64
|
+
Same set, same order, same attribute names: a drag is one command
|
|
65
|
+
whose end value keeps moving. A different set means a different
|
|
66
|
+
action, which deserves its own step even mid-gesture.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(other, SetProps) or other.label != self.label:
|
|
69
|
+
return False
|
|
70
|
+
if len(other._entries) != len(self._entries):
|
|
71
|
+
return False
|
|
72
|
+
merged = []
|
|
73
|
+
for mine, theirs in zip(self._entries, other._entries):
|
|
74
|
+
if mine[0] is not theirs[0] or mine[1] != theirs[1]:
|
|
75
|
+
return False
|
|
76
|
+
merged.append((mine[0], mine[1], mine[2], theirs[3]))
|
|
77
|
+
self._entries = merged
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CallCommand(Command):
|
|
82
|
+
"""An undoable action given as two callables, for the odd case that is
|
|
83
|
+
not a property set (adding an object, reordering a list)."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, do, undo, label="change"):
|
|
86
|
+
self.label = label
|
|
87
|
+
self._do, self._undo = do, undo
|
|
88
|
+
self._do()
|
|
89
|
+
|
|
90
|
+
def undo(self):
|
|
91
|
+
self._undo()
|
|
92
|
+
|
|
93
|
+
def redo(self):
|
|
94
|
+
self._do()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GroupCommand(Command):
|
|
98
|
+
"""Several commands as ONE step - a sweep across the outliner's boxes,
|
|
99
|
+
which shows some scans and puts others on the plot."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, label="change"):
|
|
102
|
+
self.label = label
|
|
103
|
+
self.commands = []
|
|
104
|
+
|
|
105
|
+
def undo(self):
|
|
106
|
+
for command in reversed(self.commands):
|
|
107
|
+
command.undo()
|
|
108
|
+
|
|
109
|
+
def redo(self):
|
|
110
|
+
for command in self.commands:
|
|
111
|
+
command.redo()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UndoStack(object):
|
|
115
|
+
"""The usual two lists, plus merging and a change signal.
|
|
116
|
+
|
|
117
|
+
`on_change` is a plain callable rather than a Qt signal, so the stack
|
|
118
|
+
stays UI-free; the window hands it a method that refreshes the menu and
|
|
119
|
+
redraws.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, on_change=None, limit=200):
|
|
123
|
+
self._done = []
|
|
124
|
+
self._undone = []
|
|
125
|
+
self._limit = int(limit)
|
|
126
|
+
self._open = False # a gesture is live, so merging is allowed
|
|
127
|
+
self._group = None # commands being gathered into one step
|
|
128
|
+
self.on_change = on_change
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------- gestures
|
|
131
|
+
def begin(self):
|
|
132
|
+
"""Start a gesture: the commands pushed until `seal` may merge."""
|
|
133
|
+
self._open = True
|
|
134
|
+
|
|
135
|
+
def seal(self):
|
|
136
|
+
"""End a gesture. The next push starts a new undo step."""
|
|
137
|
+
self._open = False
|
|
138
|
+
|
|
139
|
+
def begin_group(self, label="change"):
|
|
140
|
+
"""Gather every command pushed from now on into ONE step."""
|
|
141
|
+
if self._group is None:
|
|
142
|
+
self._group = GroupCommand(label)
|
|
143
|
+
|
|
144
|
+
def end_group(self):
|
|
145
|
+
"""Close the gathering; what it gathered is one step (if any)."""
|
|
146
|
+
group, self._group = self._group, None
|
|
147
|
+
if group is None or not group.commands:
|
|
148
|
+
return None
|
|
149
|
+
if len(group.commands) == 1:
|
|
150
|
+
group = group.commands[0]
|
|
151
|
+
self._done.append(group)
|
|
152
|
+
del self._done[:max(0, len(self._done) - self._limit)]
|
|
153
|
+
self._undone = []
|
|
154
|
+
self._changed()
|
|
155
|
+
return group
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------- stack
|
|
158
|
+
def push(self, command):
|
|
159
|
+
"""Add a command that has ALREADY been applied.
|
|
160
|
+
|
|
161
|
+
Commands apply themselves in `__init__` (see `SetProps`), because a
|
|
162
|
+
command that is built and then separately applied is one somebody
|
|
163
|
+
forgets to apply.
|
|
164
|
+
"""
|
|
165
|
+
if self._group is not None:
|
|
166
|
+
# Already applied; kept for the one step `end_group` makes.
|
|
167
|
+
self._group.commands.append(command)
|
|
168
|
+
self._undone = []
|
|
169
|
+
self._changed()
|
|
170
|
+
return command
|
|
171
|
+
if self._open and self._done and self._done[-1].merge(command):
|
|
172
|
+
self._undone = []
|
|
173
|
+
self._changed()
|
|
174
|
+
return self._done[-1]
|
|
175
|
+
self._done.append(command)
|
|
176
|
+
del self._done[:max(0, len(self._done) - self._limit)]
|
|
177
|
+
self._undone = []
|
|
178
|
+
self._changed()
|
|
179
|
+
return command
|
|
180
|
+
|
|
181
|
+
def set_props(self, changes, label="change"):
|
|
182
|
+
"""The common case: `[(obj, name, value), ...]` as one step."""
|
|
183
|
+
changes = [c for c in changes if getattr(c[0], c[1]) != c[2]]
|
|
184
|
+
if not changes:
|
|
185
|
+
return None
|
|
186
|
+
return self.push(SetProps(changes, label))
|
|
187
|
+
|
|
188
|
+
def undo(self):
|
|
189
|
+
if not self._done:
|
|
190
|
+
return False
|
|
191
|
+
command = self._done.pop()
|
|
192
|
+
command.undo()
|
|
193
|
+
self._undone.append(command)
|
|
194
|
+
self._open = False
|
|
195
|
+
self._changed()
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
def redo(self):
|
|
199
|
+
if not self._undone:
|
|
200
|
+
return False
|
|
201
|
+
command = self._undone.pop()
|
|
202
|
+
command.redo()
|
|
203
|
+
self._done.append(command)
|
|
204
|
+
self._open = False
|
|
205
|
+
self._changed()
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def clear(self):
|
|
209
|
+
self._done, self._undone, self._open = [], [], False
|
|
210
|
+
self._changed()
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------- state
|
|
213
|
+
def can_undo(self):
|
|
214
|
+
return bool(self._done)
|
|
215
|
+
|
|
216
|
+
def can_redo(self):
|
|
217
|
+
return bool(self._undone)
|
|
218
|
+
|
|
219
|
+
def undo_label(self):
|
|
220
|
+
return self._done[-1].label if self._done else ""
|
|
221
|
+
|
|
222
|
+
def redo_label(self):
|
|
223
|
+
return self._undone[-1].label if self._undone else ""
|
|
224
|
+
|
|
225
|
+
def depth(self):
|
|
226
|
+
return len(self._done)
|
|
227
|
+
|
|
228
|
+
def _changed(self):
|
|
229
|
+
if self.on_change is not None:
|
|
230
|
+
self.on_change()
|
dscpanel/core/units.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""What the y axis means, and what a scan needs before it can say so.
|
|
2
|
+
|
|
3
|
+
UI-free on purpose: everything here is arithmetic and naming, so it can be
|
|
4
|
+
tested without a window.
|
|
5
|
+
|
|
6
|
+
**A scan is never normalised to itself.** This is a statement about DSC rather
|
|
7
|
+
than a preference: a diffractogram or an IR spectrum can defensibly be scaled
|
|
8
|
+
to its own maximum, because the quantity of interest is where the features are.
|
|
9
|
+
A DSC trace carries a baseline that depends on the sample mass, the pan, the
|
|
10
|
+
heating rate and the sensor, and scaling each trace by its own extremum would
|
|
11
|
+
silently make those differences disappear - which is exactly the comparison a
|
|
12
|
+
stack of DSC scans is for. So every conversion here is a PHYSICAL one: watts,
|
|
13
|
+
watts per gram, watts per mole. Nothing divides by a curve's own maximum
|
|
14
|
+
anywhere in this program.
|
|
15
|
+
|
|
16
|
+
**Per mole is opt-in and cannot be guessed.** Heat flow is a power, so W/mol
|
|
17
|
+
is (W/g) x (g/mol) and needs the molar mass of the substance. There is no
|
|
18
|
+
sensible default for M: a wrong one gives a plot that looks perfectly
|
|
19
|
+
reasonable and is wrong by whatever factor. So a scan without M is not put on
|
|
20
|
+
a per-mole axis quietly - see `missing` below, and `ui/plot.py` for what the
|
|
21
|
+
window does about it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from . import numbers
|
|
27
|
+
|
|
28
|
+
#: The base every conversion starts from: heat flow in WATTS, as stored in the
|
|
29
|
+
#: file. The reader also offers "Heat Flow (Normalized)" in W/g, which is the
|
|
30
|
+
#: same number divided by the mass it also stores; going through watts keeps
|
|
31
|
+
#: one path instead of two.
|
|
32
|
+
BASE_UNIT = "W"
|
|
33
|
+
|
|
34
|
+
UNIT_MW = "mW"
|
|
35
|
+
UNIT_W_G = "W/g"
|
|
36
|
+
UNIT_W_MOL = "W/mol"
|
|
37
|
+
|
|
38
|
+
#: In menu order. mW is the raw instrument signal, W/g is what almost every
|
|
39
|
+
#: DSC figure uses, W/mol is the opt-in per-mole axis.
|
|
40
|
+
UNITS = (UNIT_MW, UNIT_W_G, UNIT_W_MOL)
|
|
41
|
+
|
|
42
|
+
#: What each unit needs before a scan can be drawn in it.
|
|
43
|
+
NEEDS_MASS = (UNIT_W_G, UNIT_W_MOL)
|
|
44
|
+
NEEDS_MOLAR_MASS = (UNIT_W_MOL,)
|
|
45
|
+
|
|
46
|
+
AXIS_LABEL = {
|
|
47
|
+
UNIT_MW: "Heat flow / mW",
|
|
48
|
+
UNIT_W_G: "Heat flow / (W/g)",
|
|
49
|
+
UNIT_W_MOL: "Heat flow / (W/mol)",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#: The enthalpy a peak integration reports, per unit choice. An integral over
|
|
53
|
+
#: time turns W into J, so the per-mole one is kJ/mol by convention rather
|
|
54
|
+
#: than J/mol, which is the unit thermochemistry is quoted in.
|
|
55
|
+
ENTHALPY_LABEL = {
|
|
56
|
+
UNIT_MW: "mJ",
|
|
57
|
+
UNIT_W_G: "J/g",
|
|
58
|
+
UNIT_W_MOL: "kJ/mol",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Which exothermic direction the y axis points in.
|
|
62
|
+
EXO_DOWN = "down"
|
|
63
|
+
EXO_UP = "up"
|
|
64
|
+
|
|
65
|
+
#: The word on the heat-flow arrow. "exo down" and "endo up" describe the SAME
|
|
66
|
+
#: orientation and differ only in which direction the arrow names, which is
|
|
67
|
+
#: why the arrow carries both a word and a direction.
|
|
68
|
+
WORD_EXO = "exo"
|
|
69
|
+
WORD_ENDO = "endo"
|
|
70
|
+
|
|
71
|
+
ARROW_WORDS = (WORD_EXO, WORD_ENDO)
|
|
72
|
+
ARROW_DIRECTIONS = (EXO_DOWN, EXO_UP)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def orientation(word, direction):
|
|
76
|
+
"""The exothermic direction meant by an arrow labelled `word` `direction`.
|
|
77
|
+
|
|
78
|
+
"Exo down" and "endo up" are the same picture; so are "exo up" and "endo
|
|
79
|
+
down". Changing the arrow between two labels that mean the same thing
|
|
80
|
+
must not flip the data, and changing it between labels that mean opposite
|
|
81
|
+
things must - which is what this function is for.
|
|
82
|
+
"""
|
|
83
|
+
same = (word == WORD_EXO) == (direction == EXO_DOWN)
|
|
84
|
+
return EXO_DOWN if same else EXO_UP
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def factor(unit, base=BASE_UNIT, mass_g=None, molar_mass=None):
|
|
88
|
+
"""`(factor, missing)` to multiply a scan's stored heat flow by.
|
|
89
|
+
|
|
90
|
+
`base` is what the FILE stored: watts (a `.tri`) or watts per gram (a
|
|
91
|
+
TRIOS `.txt` export, which writes "Heat Flow (Normalized)" and no raw
|
|
92
|
+
signal). Which one it is decides what a conversion still needs, and
|
|
93
|
+
getting that wrong is how a perfectly readable export ends up refusing to
|
|
94
|
+
draw: from W/g, a per-mole axis needs only the molar mass, and it is the
|
|
95
|
+
mW axis that needs the sample mass.
|
|
96
|
+
|
|
97
|
+
`missing` is None when the conversion is possible, or the name of what it
|
|
98
|
+
needs - "sample mass" or "molar mass". This function never substitutes a
|
|
99
|
+
value of its own, because a substituted mass is a wrong plot that looks
|
|
100
|
+
right.
|
|
101
|
+
"""
|
|
102
|
+
if unit not in UNITS:
|
|
103
|
+
raise ValueError("unknown unit: {!r}".format(unit))
|
|
104
|
+
if base == UNIT_W_G:
|
|
105
|
+
if unit == UNIT_W_G:
|
|
106
|
+
return 1.0, None
|
|
107
|
+
if unit == UNIT_MW:
|
|
108
|
+
if not mass_g:
|
|
109
|
+
return None, "sample mass"
|
|
110
|
+
return 1000.0 * float(mass_g), None
|
|
111
|
+
if not molar_mass:
|
|
112
|
+
return None, "molar mass"
|
|
113
|
+
return float(molar_mass), None
|
|
114
|
+
if unit == UNIT_MW:
|
|
115
|
+
return 1000.0, None
|
|
116
|
+
if not mass_g:
|
|
117
|
+
return None, "sample mass"
|
|
118
|
+
if unit == UNIT_W_G:
|
|
119
|
+
return 1.0 / float(mass_g), None
|
|
120
|
+
if not molar_mass:
|
|
121
|
+
return None, "molar mass"
|
|
122
|
+
# W/g times g/mol: the mass cancels and a mole is left.
|
|
123
|
+
return float(molar_mass) / float(mass_g), None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def missing(unit, base=BASE_UNIT, mass_g=None, molar_mass=None):
|
|
127
|
+
"""What stops this scan being drawn in `unit`, or None."""
|
|
128
|
+
return factor(unit, base, mass_g, molar_mass)[1]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def convert_offset(old_factor, new_factor, offset):
|
|
132
|
+
"""An offset in display units, carried across a change of unit.
|
|
133
|
+
|
|
134
|
+
An offset is stored in the unit it was dragged in, so that the number the
|
|
135
|
+
offset arrow shows is the number the axis speaks. Changing the unit would
|
|
136
|
+
otherwise leave a stack collapsed on top of itself (W/g to mW is a factor
|
|
137
|
+
of 1000). Each scan carries its OWN factor across, because the factor
|
|
138
|
+
depends on its mass and molar mass - so a per-mole axis genuinely
|
|
139
|
+
rearranges a stack, which is a true statement about the data and not a
|
|
140
|
+
bug.
|
|
141
|
+
"""
|
|
142
|
+
if not old_factor or not new_factor:
|
|
143
|
+
return offset
|
|
144
|
+
return float(offset) * (float(new_factor) / float(old_factor))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def enthalpy_factor(unit, base=BASE_UNIT, mass_g=None, molar_mass=None):
|
|
148
|
+
"""`(factor, missing)` from an integral to `ENTHALPY_LABEL`.
|
|
149
|
+
|
|
150
|
+
The integral is of the stored heat flow over time in seconds, so it
|
|
151
|
+
arrives in joules from a watts base and in J/g from a per-gram one.
|
|
152
|
+
"""
|
|
153
|
+
scale, want = factor(unit, base, mass_g, molar_mass)
|
|
154
|
+
if scale is None:
|
|
155
|
+
return None, want
|
|
156
|
+
# Per mole is quoted in kJ/mol rather than J/mol, which is the only place
|
|
157
|
+
# the enthalpy scaling differs from the axis scaling.
|
|
158
|
+
return (scale / 1000.0 if unit == UNIT_W_MOL else scale), None
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------- x axis
|
|
161
|
+
#: The temperature scales the x axis can be drawn in. The FILE is always in
|
|
162
|
+
#: degrees Celsius - TRIOS stores nothing else - so these are display
|
|
163
|
+
#: conversions applied on the way out, and every stored analysis cursor goes
|
|
164
|
+
#: through the same two functions.
|
|
165
|
+
TEMP_C = "degC"
|
|
166
|
+
TEMP_K = "K"
|
|
167
|
+
TEMP_F = "degF"
|
|
168
|
+
TEMPERATURE_UNITS = (TEMP_C, TEMP_K, TEMP_F)
|
|
169
|
+
|
|
170
|
+
TEMPERATURE_LABEL = {TEMP_C: "\u00b0C", TEMP_K: "K", TEMP_F: "\u00b0F"}
|
|
171
|
+
|
|
172
|
+
#: Absolute zero, and the two numbers that make Fahrenheit what it is.
|
|
173
|
+
KELVIN_OFFSET = 273.15
|
|
174
|
+
F_SCALE = 9.0 / 5.0
|
|
175
|
+
F_OFFSET = 32.0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def from_celsius(values, unit):
|
|
179
|
+
"""Celsius (what the file holds) to whatever the axis is showing."""
|
|
180
|
+
if unit == TEMP_K:
|
|
181
|
+
return values + KELVIN_OFFSET
|
|
182
|
+
if unit == TEMP_F:
|
|
183
|
+
return values * F_SCALE + F_OFFSET
|
|
184
|
+
return values
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def to_celsius(values, unit):
|
|
188
|
+
"""The inverse: what the user typed, back to the file's own scale."""
|
|
189
|
+
if unit == TEMP_K:
|
|
190
|
+
return values - KELVIN_OFFSET
|
|
191
|
+
if unit == TEMP_F:
|
|
192
|
+
return (values - F_OFFSET) / F_SCALE
|
|
193
|
+
return values
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
#: A typed temperature: a number, and optionally its unit - C, F or K, with
|
|
197
|
+
#: or without a degree sign or "deg", in any case. "98" is in `unit`. The
|
|
198
|
+
#: number may be a sum: "98+5 K" (`numbers.evaluate`).
|
|
199
|
+
_TYPED_TEMPERATURE = re.compile(
|
|
200
|
+
r"^\s*([-+0-9.,*/() \t]*?[0-9.)])\s*"
|
|
201
|
+
r"((?:\u00b0|deg)?\s*[cfk])?\s*$", re.I)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_temperature(text, unit=TEMP_C):
|
|
205
|
+
"""A typed temperature in degrees CELSIUS, or None if it is not one.
|
|
206
|
+
|
|
207
|
+
`98` is 98 of `unit` (the axis's); `98 F`, `98degf`, `371 K`, `98 \u00b0c`
|
|
208
|
+
say their own unit and are converted. A comma is a decimal point.
|
|
209
|
+
"""
|
|
210
|
+
match = _TYPED_TEMPERATURE.match(str(text or ""))
|
|
211
|
+
if not match:
|
|
212
|
+
return None
|
|
213
|
+
value = numbers.evaluate(match.group(1))
|
|
214
|
+
if value is None:
|
|
215
|
+
return None
|
|
216
|
+
token = (match.group(2) or "").lower()
|
|
217
|
+
for noise in ("\u00b0", "deg", " "):
|
|
218
|
+
token = token.replace(noise, "")
|
|
219
|
+
typed = {"c": TEMP_C, "f": TEMP_F, "k": TEMP_K}.get(token, unit)
|
|
220
|
+
return float(to_celsius(value, typed))
|