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