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.
@@ -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