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/loader.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Turning a path into a `Sample`: the reader, plus what it does not tell us.
|
|
2
|
+
|
|
3
|
+
UI-free. The threading that keeps a five-file drop from freezing the window
|
|
4
|
+
lives in `ui/loading.py`; everything here is a plain function, so it can be
|
|
5
|
+
tested against a real file without a window.
|
|
6
|
+
|
|
7
|
+
**The exotherm direction is read from the file, not assumed.** A DSC trace
|
|
8
|
+
means the opposite thing upside down, and TRIOS stores its own convention:
|
|
9
|
+
|
|
10
|
+
* a `.txt` export writes `Exotherm Direction Down` in its header;
|
|
11
|
+
* a `.tri` carries TRIOS's audit trail, where changing the setting leaves the
|
|
12
|
+
sentence `Sample - Exotherm changed to 'Exo Down' from '0'` (seen in a
|
|
13
|
+
TRIOS 6.0 run). The last such sentence is the setting that was in force.
|
|
14
|
+
|
|
15
|
+
Neither is guaranteed to be there - a TRIOS 5.1.1 run may have no audit
|
|
16
|
+
sentence at all - so the fallback is "down", which is the TA default and what
|
|
17
|
+
every file seen so far was recorded under. The fallback is REPORTED rather than
|
|
18
|
+
silent (`Sample.exo_source`), because a file recorded the other way round would
|
|
19
|
+
otherwise produce a figure that is upside down and says nothing about it.
|
|
20
|
+
|
|
21
|
+
Open item, and the cheapest way to settle it: an indium calibration run. Its
|
|
22
|
+
melting peak is unambiguously endothermic, so which way it points in the
|
|
23
|
+
stored arrays proves what the convention in them is, once and for all.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import io
|
|
27
|
+
import contextlib
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
from . import model
|
|
32
|
+
from . import trios_io
|
|
33
|
+
from . import units
|
|
34
|
+
|
|
35
|
+
#: What the open dialog and a drop will accept.
|
|
36
|
+
READABLE = (".tri", ".txt")
|
|
37
|
+
|
|
38
|
+
#: TRIOS's audit sentence, and the export header line.
|
|
39
|
+
_AUDIT = re.compile(rb"Exotherm changed to '(Exo|Endo) (Up|Down)'",
|
|
40
|
+
re.IGNORECASE)
|
|
41
|
+
_HEADER = re.compile(r"^Exotherm\s+Direction\s*[\t:]\s*(Up|Down)\s*$",
|
|
42
|
+
re.IGNORECASE | re.MULTILINE)
|
|
43
|
+
|
|
44
|
+
#: How much of a `.tri` is searched for the audit trail. It sits in the
|
|
45
|
+
#: document region at the END of the file, so the tail is where to look, and
|
|
46
|
+
#: a bounded read keeps this cheap on a 40 MB file.
|
|
47
|
+
_TAIL_BYTES = 4_000_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReadError(Exception):
|
|
51
|
+
"""A file that cannot be read as a TRIOS measurement."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reader_origin():
|
|
55
|
+
"""Which TRIOS reader is in use, for the About box: the program's own
|
|
56
|
+
(`core/trios_io.py`)."""
|
|
57
|
+
return "this program's own"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def looks_readable(path):
|
|
61
|
+
"""Could this path be a TRIOS file? Extension only, deliberately loose.
|
|
62
|
+
|
|
63
|
+
The reader is the only thing that can really tell, so this exists to keep
|
|
64
|
+
an obvious mis-drop out. Anything that gets past it and turns out not to
|
|
65
|
+
be a measurement is refused by `read_sample` with a reason, which is a
|
|
66
|
+
better answer than a drop that silently does nothing.
|
|
67
|
+
"""
|
|
68
|
+
return (os.path.isfile(str(path))
|
|
69
|
+
and os.path.splitext(str(path))[1].lower() in READABLE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_exotherm(path):
|
|
73
|
+
"""`(direction, source)` for the file: what the arrays are in.
|
|
74
|
+
|
|
75
|
+
`source` is "export header", "audit trail" or "assumed", and the window
|
|
76
|
+
shows it, because the difference between knowing and assuming is the
|
|
77
|
+
difference between a figure and an upside-down figure.
|
|
78
|
+
"""
|
|
79
|
+
path = str(path)
|
|
80
|
+
ext = os.path.splitext(path)[1].lower()
|
|
81
|
+
try:
|
|
82
|
+
if ext == ".txt":
|
|
83
|
+
with open(path, "r", encoding="latin-1", errors="replace") as fh:
|
|
84
|
+
head = fh.read(200_000)
|
|
85
|
+
match = _HEADER.search(head)
|
|
86
|
+
if match:
|
|
87
|
+
return match.group(1).lower(), "export header"
|
|
88
|
+
else:
|
|
89
|
+
size = os.path.getsize(path)
|
|
90
|
+
with open(path, "rb") as fh:
|
|
91
|
+
if size > _TAIL_BYTES:
|
|
92
|
+
fh.seek(size - _TAIL_BYTES)
|
|
93
|
+
raw = fh.read()
|
|
94
|
+
found = list(_AUDIT.finditer(raw))
|
|
95
|
+
if found:
|
|
96
|
+
word, direction = (found[-1].group(1).decode().lower(),
|
|
97
|
+
found[-1].group(2).decode().lower())
|
|
98
|
+
# The audit sentence names a LABEL ("Exo Down", "Endo Up"),
|
|
99
|
+
# which is the same pair of words the arrow carries, so the
|
|
100
|
+
# same function turns it into a direction.
|
|
101
|
+
return units.orientation(word, direction), "audit trail"
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
return units.EXO_DOWN, "assumed"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_sample(path):
|
|
108
|
+
"""Read one file into a `Sample`, keeping what the reader said.
|
|
109
|
+
|
|
110
|
+
The reader prints its warnings (a partial segment identified by shape, an
|
|
111
|
+
analysis that could not be attributed). Under a windowed entry point
|
|
112
|
+
there is no console for those to reach, and they are exactly the lines
|
|
113
|
+
somebody needs to see, so they are captured onto the sample and shown in
|
|
114
|
+
the note line instead of being thrown away.
|
|
115
|
+
"""
|
|
116
|
+
path = str(path)
|
|
117
|
+
if not os.path.isfile(path):
|
|
118
|
+
raise ReadError("no such file: {}".format(os.path.basename(path)))
|
|
119
|
+
chatter = io.StringIO()
|
|
120
|
+
try:
|
|
121
|
+
with contextlib.redirect_stdout(chatter):
|
|
122
|
+
data = trios_io.read_tri(path)
|
|
123
|
+
except ReadError:
|
|
124
|
+
raise
|
|
125
|
+
except Exception as exc: # the reader raises many kinds
|
|
126
|
+
raise ReadError("{}: {}".format(type(exc).__name__, exc))
|
|
127
|
+
if not (data or {}).get("numdata"):
|
|
128
|
+
raise ReadError("no measured segments in {}".format(
|
|
129
|
+
os.path.basename(path)))
|
|
130
|
+
exo, source = detect_exotherm(path)
|
|
131
|
+
sample = model.Sample(path, data, exo=exo, exo_source=source)
|
|
132
|
+
sample.note = "\n".join(
|
|
133
|
+
line for line in chatter.getvalue().splitlines() if line.strip())
|
|
134
|
+
return sample
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sibling_export(path):
|
|
138
|
+
"""A TRIOS `.txt` export of the same run, beside the `.tri`, or None.
|
|
139
|
+
|
|
140
|
+
Worth finding when a `.tri` segment records no heat flow: the export of
|
|
141
|
+
the same run is the other place a heat flow can come from, and
|
|
142
|
+
reconstructing one from the raw sensors is not an option (on the indium
|
|
143
|
+
calibration run it was 7 % out in heat flow and 0.29 K out in
|
|
144
|
+
temperature, worse than the calibration is judged by). That indium ramp
|
|
145
|
+
was the case this was written for, and it turned out to be RECORDED, in
|
|
146
|
+
arrays with a flags list (TRI-FORMAT.md section 3) that the reader once
|
|
147
|
+
did not read; no segment of the files tested lacks a heat flow now, but
|
|
148
|
+
the mechanism stays for one that does.
|
|
149
|
+
"""
|
|
150
|
+
folder = os.path.dirname(os.path.abspath(str(path)))
|
|
151
|
+
stem = os.path.splitext(os.path.basename(str(path)))[0].lower()
|
|
152
|
+
try:
|
|
153
|
+
names = os.listdir(folder)
|
|
154
|
+
except OSError:
|
|
155
|
+
return None
|
|
156
|
+
for name in names:
|
|
157
|
+
base, ext = os.path.splitext(name)
|
|
158
|
+
if ext.lower() == ".txt" and base.lower() == stem:
|
|
159
|
+
return os.path.join(folder, name)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def segments_without_heat_flow(sample):
|
|
164
|
+
"""Segment numbers (as TRIOS counts them) that record no heat flow."""
|
|
165
|
+
out = []
|
|
166
|
+
for index, step in enumerate((sample.data or {}).get("numdata", [])):
|
|
167
|
+
dims = step.get("dims") or []
|
|
168
|
+
if "Heat Flow" not in dims and "Heat Flow (Normalized)" not in dims:
|
|
169
|
+
out.append(index + 1)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def summary(sample):
|
|
174
|
+
"""One line about a file that was just opened, for the note line."""
|
|
175
|
+
bits = ["{}: {} segments".format(sample.name, sample.segment_count())]
|
|
176
|
+
if sample.mass_g:
|
|
177
|
+
# An SDT run has no sample-size field: the reader derives the mass
|
|
178
|
+
# from Weight / Weight Change, and an inference says so.
|
|
179
|
+
head = (sample.data or {}).get("head", {}) or {}
|
|
180
|
+
derived = head.get("mass_source") == "derived from the weight"
|
|
181
|
+
bits.append("{:g} mg{}".format(
|
|
182
|
+
sample.mass_g * 1000.0,
|
|
183
|
+
" (derived from the weight)" if derived else ""))
|
|
184
|
+
else:
|
|
185
|
+
bits.append("NO SAMPLE MASS")
|
|
186
|
+
count = sum(len(sample.analyses_for(seg))
|
|
187
|
+
for seg in range(sample.segment_count()))
|
|
188
|
+
if count:
|
|
189
|
+
bits.append("{} stored analyses".format(count))
|
|
190
|
+
if sample.exo_source == "assumed":
|
|
191
|
+
bits.append("exo direction assumed to be down")
|
|
192
|
+
else:
|
|
193
|
+
bits.append("exo {} ({})".format(sample.exo, sample.exo_source))
|
|
194
|
+
gaps = segments_without_heat_flow(sample)
|
|
195
|
+
if gaps:
|
|
196
|
+
bits.append("segment {} records no heat flow".format(
|
|
197
|
+
", ".join(str(g) for g in gaps)))
|
|
198
|
+
export = sibling_export(sample.path)
|
|
199
|
+
if export:
|
|
200
|
+
bits.append("open {} instead, TRIOS derives it there".format(
|
|
201
|
+
os.path.basename(export)))
|
|
202
|
+
return ", ".join(bits)
|
dscpanel/core/log.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The log: what the program did, and every error, in a file.
|
|
2
|
+
|
|
3
|
+
Started from its shortcut the program has no console, so an error had
|
|
4
|
+
nowhere to go - and PySide6 turns an exception inside a Qt slot into the
|
|
5
|
+
end of the process unless an excepthook takes it. `install` fixes both: the
|
|
6
|
+
hook writes the traceback here and the program carries on (the work is
|
|
7
|
+
still open), and `faulthandler` writes the stack of a hard crash - one in
|
|
8
|
+
Qt itself - into a file of its own beside it.
|
|
9
|
+
|
|
10
|
+
The files live with the preferences (`branding.app_dir()`); Help > Open the
|
|
11
|
+
log folder shows them. Rotating: a megabyte, two old ones kept.
|
|
12
|
+
|
|
13
|
+
The hook reaches an error in a SLOT only. One inside a method Qt calls by
|
|
14
|
+
itself - `mouseMoveEvent`, `paintEvent`, `event` - ends the process in
|
|
15
|
+
PySide6 6.11 without asking `sys.excepthook`: an access violation, and not
|
|
16
|
+
a line in the log (dragging a label whose curve was hidden did that).
|
|
17
|
+
Every UI module therefore ends with `guard_classes`, which wraps those
|
|
18
|
+
methods so that such an error goes the same way as a slot's.
|
|
19
|
+
|
|
20
|
+
UI-free. `on_error` is how the window hears about an error it should show.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import faulthandler
|
|
24
|
+
import functools
|
|
25
|
+
import inspect
|
|
26
|
+
import logging
|
|
27
|
+
import logging.handlers
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import traceback
|
|
33
|
+
|
|
34
|
+
from .. import branding
|
|
35
|
+
|
|
36
|
+
#: The program's logger. Modules log through `logging.getLogger(NAME)`;
|
|
37
|
+
#: without `install` (tests, tools) nothing is written anywhere.
|
|
38
|
+
NAME = "dscpanel"
|
|
39
|
+
LOGGER = logging.getLogger(NAME)
|
|
40
|
+
|
|
41
|
+
#: Called with a one-line summary after an error was logged, or None.
|
|
42
|
+
on_error = None
|
|
43
|
+
|
|
44
|
+
_crash_file = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def path():
|
|
48
|
+
"""The log file."""
|
|
49
|
+
return os.path.join(branding.app_dir(), branding.EXE_NAME + ".log")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def install(target=None):
|
|
53
|
+
"""Log to `target` (the default file), catch every unhandled error and
|
|
54
|
+
every hard crash. Returns the file's path; never raises - a program
|
|
55
|
+
that cannot write its log should still start."""
|
|
56
|
+
global _crash_file
|
|
57
|
+
target = target or path()
|
|
58
|
+
try:
|
|
59
|
+
folder = os.path.dirname(target)
|
|
60
|
+
if folder and not os.path.isdir(folder):
|
|
61
|
+
os.makedirs(folder)
|
|
62
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
63
|
+
target, maxBytes=1000000, backupCount=2, encoding="utf-8")
|
|
64
|
+
handler.setFormatter(logging.Formatter(
|
|
65
|
+
"%(asctime)s %(levelname)s %(message)s"))
|
|
66
|
+
LOGGER.addHandler(handler)
|
|
67
|
+
LOGGER.setLevel(logging.INFO)
|
|
68
|
+
_crash_file = open(target + ".crash", "a", encoding="utf-8")
|
|
69
|
+
faulthandler.enable(_crash_file)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
sys.excepthook = _hook
|
|
73
|
+
from .. import __version__
|
|
74
|
+
LOGGER.info("%s %s started: Python %s on %s", branding.APP_NAME,
|
|
75
|
+
__version__, platform.python_version(), platform.platform())
|
|
76
|
+
return target
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _hook(kind, value, tb):
|
|
80
|
+
"""Every unhandled error: its traceback into the log, a line to the
|
|
81
|
+
window. The program carries on."""
|
|
82
|
+
text = "".join(traceback.format_exception(kind, value, tb))
|
|
83
|
+
LOGGER.error("Unhandled %s: %s\n%s", kind.__name__, value, text)
|
|
84
|
+
if sys.__stderr__ is not None:
|
|
85
|
+
try:
|
|
86
|
+
sys.__stderr__.write(text)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
if on_error is not None:
|
|
90
|
+
try:
|
|
91
|
+
on_error("{}: {}".format(kind.__name__, value))
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: The methods Qt calls by itself that `guard_classes` wraps besides every
|
|
97
|
+
#: `...Event`, and what each hands back to Qt when it failed.
|
|
98
|
+
HANDLERS = {"event": True, "eventFilter": False, "nativeEvent": (False, 0),
|
|
99
|
+
"paint": None, "accept": None, "reject": None, "done": None}
|
|
100
|
+
|
|
101
|
+
#: The last error a guard reported and when: the same one again within
|
|
102
|
+
#: `QUIET_S` is not reported again, so a paint that fails on every frame
|
|
103
|
+
#: (and the flash that reports it, which paints) does not fill the log.
|
|
104
|
+
_last_guarded = [None, 0.0]
|
|
105
|
+
QUIET_S = 5.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def guarded(function, fallback=None):
|
|
109
|
+
"""`function`, with an error inside it logged and survived WHILE the
|
|
110
|
+
hook is installed (the program); otherwise (tests, tools) raised as
|
|
111
|
+
ever, so a test still fails on it."""
|
|
112
|
+
@functools.wraps(function)
|
|
113
|
+
def handler(*args, **kwargs):
|
|
114
|
+
try:
|
|
115
|
+
return function(*args, **kwargs)
|
|
116
|
+
except Exception:
|
|
117
|
+
if sys.excepthook is not _hook:
|
|
118
|
+
raise
|
|
119
|
+
kind, value, tb = sys.exc_info()
|
|
120
|
+
key = (function.__qualname__, kind, str(value))
|
|
121
|
+
now = time.monotonic()
|
|
122
|
+
if key != _last_guarded[0] or now - _last_guarded[1] > QUIET_S:
|
|
123
|
+
_hook(kind, value, tb)
|
|
124
|
+
_last_guarded[0], _last_guarded[1] = key, now
|
|
125
|
+
return fallback
|
|
126
|
+
handler.guarded = True
|
|
127
|
+
return handler
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def guard_classes(namespace, module):
|
|
131
|
+
"""Wrap the Qt handlers of every class `module` defines (`guarded`):
|
|
132
|
+
each `...Event` method and the ones in `HANDLERS`. A UI module ends
|
|
133
|
+
with `log.guard_classes(globals(), __name__)`; a test checks that none
|
|
134
|
+
is missed. Returns how many were wrapped."""
|
|
135
|
+
count = 0
|
|
136
|
+
for value in list(namespace.values()):
|
|
137
|
+
if not inspect.isclass(value) or value.__module__ != module:
|
|
138
|
+
continue
|
|
139
|
+
for name, attr in list(vars(value).items()):
|
|
140
|
+
if not (name.endswith("Event") or name in HANDLERS):
|
|
141
|
+
continue
|
|
142
|
+
if not inspect.isfunction(attr) or getattr(attr, "guarded",
|
|
143
|
+
False):
|
|
144
|
+
continue
|
|
145
|
+
setattr(value, name, guarded(attr, HANDLERS.get(name)))
|
|
146
|
+
count += 1
|
|
147
|
+
return count
|