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/export.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Getting out: the curves as data, and the figure as a script.
|
|
2
|
+
|
|
3
|
+
Three exports, and each answers a different question.
|
|
4
|
+
|
|
5
|
+
* `curves_csv` - what is on screen, as numbers. For a colleague, a
|
|
6
|
+
spreadsheet, or a plot in something else entirely.
|
|
7
|
+
* `driver_source` / `write_driver` - the arrangement as a
|
|
8
|
+
`DSC_Plotter.py` driver. **This panel is not trying to become the figure
|
|
9
|
+
engine.** The template already draws publication figures in matplotlib,
|
|
10
|
+
with tangents, shaded integrals, molecule images and a run record; what it
|
|
11
|
+
does not have is a way to arrange eight scans by hand. So the panel does
|
|
12
|
+
the arranging and hands the result over: the paths, the segments, the
|
|
13
|
+
colours and the offsets, written as the driver that reproduces them.
|
|
14
|
+
* `warnings_for` - what an export must not be allowed to hide. A figure drawn
|
|
15
|
+
while a scan was waiting for its molar mass, or with a scan scaled by a
|
|
16
|
+
factor, is a figure that misleads unless it says so. These lines are
|
|
17
|
+
printed AND stamped into the image: the blinking label can be ignored in
|
|
18
|
+
the window, so the export has to carry the notice with it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .. import branding
|
|
27
|
+
from . import figure as figure_module
|
|
28
|
+
from . import model
|
|
29
|
+
from . import labels
|
|
30
|
+
from . import numbers
|
|
31
|
+
from . import style
|
|
32
|
+
from . import units
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def warnings_for(doc, exo=True):
|
|
36
|
+
"""Everything an export has to admit to, as short lines.
|
|
37
|
+
|
|
38
|
+
`exo=False` leaves out the assumed exotherm direction: an image is not
|
|
39
|
+
stamped with it, the console and the driver are.
|
|
40
|
+
"""
|
|
41
|
+
out = []
|
|
42
|
+
for scan, missing in doc.scans_missing():
|
|
43
|
+
out.append("NO {}: {} is not drawn".format(missing.upper(),
|
|
44
|
+
scan.display_name()))
|
|
45
|
+
# A label that asks for a unit it cannot be given without a mass is a
|
|
46
|
+
# missing value, exactly like a per-mole axis without M.
|
|
47
|
+
for analysis, rendered in _rendered_labels(doc):
|
|
48
|
+
for message in rendered.missing():
|
|
49
|
+
out.append("{} ({} on {})".format(
|
|
50
|
+
message, analysis.model_name, analysis.scan.display_name()))
|
|
51
|
+
assumed = sorted({s.name for s in doc.samples
|
|
52
|
+
if s.exo_source == "assumed"})
|
|
53
|
+
if assumed and exo:
|
|
54
|
+
out.append("EXO DIRECTION ASSUMED (down) for: " + ", ".join(assumed))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def label_notes(doc):
|
|
59
|
+
"""What is odd about the analysis labels without being missing: a unit
|
|
60
|
+
the value cannot be put in, a number typed by hand. Printed on export,
|
|
61
|
+
not stamped: the figure itself draws the right thing."""
|
|
62
|
+
out = []
|
|
63
|
+
for analysis, rendered in _rendered_labels(doc):
|
|
64
|
+
for kind, message in rendered.problems:
|
|
65
|
+
if kind != "missing":
|
|
66
|
+
out.append("{} on {}: {}".format(
|
|
67
|
+
analysis.model_name, analysis.scan.display_name(),
|
|
68
|
+
message))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rendered_labels(doc):
|
|
73
|
+
return [(a, labels.render(a, doc)) for a in doc.visible_analyses()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def curves_csv(doc, path):
|
|
77
|
+
"""Every visible scan, in the unit on screen, one column pair per scan.
|
|
78
|
+
|
|
79
|
+
Column PAIRS rather than one shared x column, because two scans do not
|
|
80
|
+
share a temperature axis: they were sampled at their own points, and a
|
|
81
|
+
cooling scan runs the other way. Interpolating them onto a common grid
|
|
82
|
+
would be inventing data.
|
|
83
|
+
|
|
84
|
+
A mass scan (an SDT run's m%) is a pair like any other, its y named
|
|
85
|
+
"Mass". A flagged sample (NaN) is an EMPTY cell, as in TRIOS's own
|
|
86
|
+
export, never the text "nan".
|
|
87
|
+
"""
|
|
88
|
+
# The KEPT part of each: a truncated end is not part of the figure, so
|
|
89
|
+
# it is not part of its numbers either.
|
|
90
|
+
blocks = []
|
|
91
|
+
for scan in doc.visible_scans():
|
|
92
|
+
x, y = scan.kept_curve(doc.x_axis, doc.unit_for(scan), doc.exo,
|
|
93
|
+
doc.x_unit)
|
|
94
|
+
if x is None:
|
|
95
|
+
continue
|
|
96
|
+
blocks.append((scan, x, y, None))
|
|
97
|
+
if not blocks:
|
|
98
|
+
return None
|
|
99
|
+
columns, headers = [], []
|
|
100
|
+
x_label = ("Temperature/C" if doc.x_axis == model.AXIS_TEMPERATURE
|
|
101
|
+
else "Time/min")
|
|
102
|
+
for scan, x, y, _unused in blocks:
|
|
103
|
+
name = scan.display_name().replace(",", " ")
|
|
104
|
+
columns.append(x)
|
|
105
|
+
headers.append("{} {}".format(name, x_label))
|
|
106
|
+
if y is not None:
|
|
107
|
+
columns.append(y)
|
|
108
|
+
headers.append("{} {}/{}".format(
|
|
109
|
+
name, "Mass" if scan.is_mass
|
|
110
|
+
else "DTG" if scan.is_dtg else "HeatFlow",
|
|
111
|
+
doc.unit_for(scan)))
|
|
112
|
+
rows = max(len(c) for c in columns)
|
|
113
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
114
|
+
fh.write("# {} export\n".format(branding.APP_NAME))
|
|
115
|
+
for line in warnings_for(doc):
|
|
116
|
+
fh.write("# {}\n".format(line))
|
|
117
|
+
for scan, _x, _y, _weight in blocks:
|
|
118
|
+
fh.write("# {}: {}, mass {}, M {}, exo {} ({})\n".format(
|
|
119
|
+
scan.display_name(), scan.sample.path,
|
|
120
|
+
scan.sample.mass_text() or "unknown",
|
|
121
|
+
"{:g} g/mol".format(scan.molar_mass) if scan.molar_mass
|
|
122
|
+
else "not given",
|
|
123
|
+
scan.sample.exo, scan.sample.exo_source))
|
|
124
|
+
fh.write(",".join(headers) + "\n")
|
|
125
|
+
for i in range(rows):
|
|
126
|
+
cells = []
|
|
127
|
+
for column in columns:
|
|
128
|
+
cells.append(_cell(column[i]) if i < len(column) else "")
|
|
129
|
+
fh.write(",".join(cells) + "\n")
|
|
130
|
+
return path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cell(value):
|
|
134
|
+
"""A number as a CSV cell: `%.6g`, and empty for a flagged sample."""
|
|
135
|
+
value = float(value)
|
|
136
|
+
return "{:.6g}".format(value) if np.isfinite(value) else ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def driver_source(doc):
|
|
140
|
+
"""The `DSC_Plotter.py` driver that reproduces what is on screen.
|
|
141
|
+
|
|
142
|
+
A whole `def driver():` that the template runs as it stands: it reads the
|
|
143
|
+
files, builds the figure - at EXACTLY the panel's size, with the axes box
|
|
144
|
+
at the panel's margins, when the figure has a size - and draws the lines,
|
|
145
|
+
their truncation and offsets, the x range, the arrow and the style with
|
|
146
|
+
the panel's font sizes and axis sides.
|
|
147
|
+
|
|
148
|
+
The template has no `start_plot()` or `finish_plot()`, and `datas` is
|
|
149
|
+
defined here: a driver that called those or left `datas` undefined
|
|
150
|
+
stopped at its second line.
|
|
151
|
+
"""
|
|
152
|
+
paths, index = [], {}
|
|
153
|
+
for scan in doc.visible_scans():
|
|
154
|
+
key = os.path.normcase(scan.sample.path)
|
|
155
|
+
if key not in index:
|
|
156
|
+
index[key] = len(paths)
|
|
157
|
+
paths.append(scan.sample.path)
|
|
158
|
+
lines = []
|
|
159
|
+
lines.append("# Written by {}. Paste this over the DRIVER section"
|
|
160
|
+
.format(branding.APP_NAME))
|
|
161
|
+
lines.append("# of a DSC_Plotter.py made by `dsc -c .`, or run this file")
|
|
162
|
+
lines.append("# next to one.")
|
|
163
|
+
for warning in warnings_for(doc):
|
|
164
|
+
lines.append("# WARNING: {}".format(warning))
|
|
165
|
+
lines.append("")
|
|
166
|
+
lines.append("MANUAL = True")
|
|
167
|
+
lines.append("")
|
|
168
|
+
lines.append("")
|
|
169
|
+
lines.append("def driver():")
|
|
170
|
+
lines.append(" paths = [")
|
|
171
|
+
for path in paths:
|
|
172
|
+
lines.append(" r'{}',".format(path))
|
|
173
|
+
lines.append(" ]")
|
|
174
|
+
lines.append(" datas = {i: read_tri(HERE / p) "
|
|
175
|
+
"for i, p in enumerate(paths)}")
|
|
176
|
+
lines.append(" print_infos(datas)")
|
|
177
|
+
family = style.figure_value(doc, "font_family")
|
|
178
|
+
if family:
|
|
179
|
+
# Its own family first; any sans where the machine lacks it.
|
|
180
|
+
lines.append(" plt.rcParams['font.family'] = [{!r}, "
|
|
181
|
+
"'sans-serif']".format(str(family)))
|
|
182
|
+
lines.append("")
|
|
183
|
+
layout = getattr(doc, "figure", None)
|
|
184
|
+
if (layout is not None and layout.mode == figure_module.MODE_SIZE
|
|
185
|
+
and layout.is_valid()):
|
|
186
|
+
width_in, height_in = layout.inches()
|
|
187
|
+
per_inch = figure_module.PER_INCH[layout.unit]
|
|
188
|
+
left = layout.margin_left / per_inch
|
|
189
|
+
right = layout.margin_right / per_inch
|
|
190
|
+
top = layout.margin_top / per_inch
|
|
191
|
+
bottom = layout.margin_bottom / per_inch
|
|
192
|
+
lines.append(" # The panel's figure, exactly: {:g} x {:g} {}, the "
|
|
193
|
+
"axes box at its margins.".format(
|
|
194
|
+
layout.width, layout.height, layout.unit))
|
|
195
|
+
lines.append(" fig = plt.figure(figsize=({:.6f}, {:.6f}))".format(
|
|
196
|
+
width_in, height_in))
|
|
197
|
+
lines.append(" ax = fig.add_axes(({:.6f}, {:.6f}, {:.6f}, "
|
|
198
|
+
"{:.6f}))".format(
|
|
199
|
+
left / width_in, bottom / height_in,
|
|
200
|
+
(width_in - left - right) / width_in,
|
|
201
|
+
(height_in - top - bottom) / height_in))
|
|
202
|
+
lines.append(" settings['dpi'] = {:d}".format(int(layout.dpi)))
|
|
203
|
+
else:
|
|
204
|
+
lines.append(" fig, ax = plt.subplots(figsize=settings['figsize'], "
|
|
205
|
+
"layout='constrained')")
|
|
206
|
+
x_axis, y_axis = doc.axes["x"], doc.axes["y"]
|
|
207
|
+
lines.append(" settings['ticklabel_fontsize'] = {:g}".format(
|
|
208
|
+
float(style.value(doc, x_axis, "tick_size"))))
|
|
209
|
+
lines.append(" settings['xlabel_fontsize'] = {:g}".format(
|
|
210
|
+
float(style.value(doc, x_axis, "label_size"))))
|
|
211
|
+
lines.append(" settings['ylabel_fontsize'] = {:g}".format(
|
|
212
|
+
float(style.value(doc, y_axis, "label_size"))))
|
|
213
|
+
y_dim = {units.UNIT_MW: "'Q'", units.UNIT_W_G: "'Qn'",
|
|
214
|
+
units.UNIT_W_MOL: "'Qn'"}.get(doc.y_unit, "'Qn'")
|
|
215
|
+
x_dim = "'T'" if doc.x_axis == model.AXIS_TEMPERATURE else "'t'"
|
|
216
|
+
heat = [s for s in doc.visible_scans() if s.is_heat]
|
|
217
|
+
for scan in heat:
|
|
218
|
+
i = index[os.path.normcase(scan.sample.path)]
|
|
219
|
+
lines.append(" add_line(ax, datas, ({}, {}), color='{}', x={},"
|
|
220
|
+
" y={}, label={!r})".format(
|
|
221
|
+
i, scan.seg, scan.colour, x_dim, y_dim,
|
|
222
|
+
scan.display_name()))
|
|
223
|
+
for scan in heat:
|
|
224
|
+
if scan.is_truncated():
|
|
225
|
+
# The template's own function, with the same fractions: it
|
|
226
|
+
# slices x[int(n * x0):int(n * x1)] exactly as the panel does.
|
|
227
|
+
i = index[os.path.normcase(scan.sample.path)]
|
|
228
|
+
lines.append(" x_truncate(ax, datas, ({}, {}), x0={:.6g}, "
|
|
229
|
+
"x1={:.6g})".format(i, scan.seg, scan.keep[0],
|
|
230
|
+
scan.keep[1]))
|
|
231
|
+
for scan in heat:
|
|
232
|
+
if scan.offset:
|
|
233
|
+
i = index[os.path.normcase(scan.sample.path)]
|
|
234
|
+
lines.append(" y_offset(ax, datas, ({}, {}), {:.6g})".format(
|
|
235
|
+
i, scan.seg, scan.offset))
|
|
236
|
+
if doc.y_unit == units.UNIT_W_MOL:
|
|
237
|
+
lines.append(" # {} showed W/mol; the template's 'Qn' is"
|
|
238
|
+
.format(branding.APP_NAME))
|
|
239
|
+
lines.append(" # W/g, so each line still needs x M to match.")
|
|
240
|
+
lines.append(" ax.set_xlim({:.6g}, {:.6g})".format(*doc_view_x(doc)))
|
|
241
|
+
view_y = getattr(doc, "view_y_hint", None)
|
|
242
|
+
if view_y:
|
|
243
|
+
lines.append(" ax.set_ylim({:.6g}, {:.6g})".format(*view_y))
|
|
244
|
+
lines.extend(_weight_lines(doc, index, x_dim))
|
|
245
|
+
lines.extend(_offset_marker_lines(doc, index))
|
|
246
|
+
lines.extend(_arrow_lines(doc))
|
|
247
|
+
lines.append(" style(ax)")
|
|
248
|
+
lines.extend(_axis_lines(x_axis, "x"))
|
|
249
|
+
lines.extend(_axis_lines(y_axis, "y"))
|
|
250
|
+
lines.extend(_legend_lines(doc))
|
|
251
|
+
lines.extend(_label_lines(doc))
|
|
252
|
+
if getattr(doc, "images", None):
|
|
253
|
+
lines.append(" # {} picture(s) on the panel's figure are not "
|
|
254
|
+
"written here.".format(len(doc.images)))
|
|
255
|
+
if getattr(doc, "structures", None):
|
|
256
|
+
lines.append(" # {} structure(s) on the panel's figure are not "
|
|
257
|
+
"written here: {}".format(
|
|
258
|
+
len(doc.structures),
|
|
259
|
+
", ".join(m.smiles for m in doc.structures)))
|
|
260
|
+
lines.append(" out = HERE / 'dsc.{}'.format(settings['extension'])")
|
|
261
|
+
lines.append(" plt.savefig(out, dpi=settings['dpi'], "
|
|
262
|
+
"transparent=settings['transparent']) "
|
|
263
|
+
"if settings['silent'] else plt.show()")
|
|
264
|
+
lines.append("")
|
|
265
|
+
return "\n".join(lines) + "\n"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _weight_lines(doc, index, x_dim):
|
|
269
|
+
"""The weight curves of SDT/TGA runs on a second y axis, `ax2`, as the
|
|
270
|
+
panel draws them: the template's own `add_line` with the
|
|
271
|
+
reader's "Weight Change" (%) or "Weight" (mg), dashed, truncated like
|
|
272
|
+
their scan, at the panel's range and on its side."""
|
|
273
|
+
scans = [s for s in doc.visible_scans() if s.is_mass]
|
|
274
|
+
if not scans:
|
|
275
|
+
return []
|
|
276
|
+
dim = ("'Weight Change'" if doc.weight_unit == model.WEIGHT_PCT
|
|
277
|
+
else "'Weight'")
|
|
278
|
+
axis = doc.axes["y2"]
|
|
279
|
+
out = [" # The weight on a second axis. An SDT .tri reads right only",
|
|
280
|
+
" # with a reader from 2026-09-28 or later (flagged arrays).",
|
|
281
|
+
" ax2 = ax.twinx()"]
|
|
282
|
+
for scan in scans:
|
|
283
|
+
i = index[os.path.normcase(scan.sample.path)]
|
|
284
|
+
out.append(" add_line(ax2, datas, ({}, {}), color='{}', ls={!r}, "
|
|
285
|
+
"x={}, y={}, label={!r})".format(
|
|
286
|
+
i, scan.seg, scan.colour,
|
|
287
|
+
"-", x_dim, dim, scan.display_name()))
|
|
288
|
+
if scan.is_truncated():
|
|
289
|
+
out.append(" x_truncate(ax2, datas, ({}, {}), x0={:.6g}, "
|
|
290
|
+
"x1={:.6g})".format(i, scan.seg, scan.keep[0],
|
|
291
|
+
scan.keep[1]))
|
|
292
|
+
view = getattr(doc, "view_y2_hint", None)
|
|
293
|
+
if view:
|
|
294
|
+
out.append(" ax2.set_ylim({:.6g}, {:.6g})".format(*view))
|
|
295
|
+
out.append(" ax2.set_ylabel({!r}, fontsize={:g})".format(
|
|
296
|
+
mathtext(axis.caption(doc)), float(style.value(doc, axis,
|
|
297
|
+
"label_size"))))
|
|
298
|
+
out.append(" ax2.tick_params(labelsize={:g})".format(
|
|
299
|
+
float(style.value(doc, axis, "tick_size"))))
|
|
300
|
+
side = "right" if doc.axes["y"].side != "right" else "left"
|
|
301
|
+
if side == "left":
|
|
302
|
+
out.append(" ax2.yaxis.set_ticks_position('left')")
|
|
303
|
+
out.append(" ax2.yaxis.set_label_position('left')")
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _arrow_lines(doc):
|
|
308
|
+
"""The template's `add_exo_arrow` with the panel's dimensions, in the
|
|
309
|
+
points it takes them in; its total length `l` is an axes fraction, so it
|
|
310
|
+
is worked out from the axes' height in the driver itself."""
|
|
311
|
+
arrow = doc.arrow
|
|
312
|
+
if not arrow.visible:
|
|
313
|
+
return []
|
|
314
|
+
total = float(arrow.head_length) + float(arrow.tail_length)
|
|
315
|
+
return [
|
|
316
|
+
" add_exo_arrow(ax, width={:g}, headwidth={:g}, headlength={:g},"
|
|
317
|
+
.format(arrow.tail_width, arrow.head_width, arrow.head_length),
|
|
318
|
+
" l={:g} / (ax.get_position().height"
|
|
319
|
+
" * ax.figure.get_figheight() * 72.0))".format(total),
|
|
320
|
+
" ax.texts[-1].set_fontsize({:g})".format(
|
|
321
|
+
float(style.value(doc, arrow, "size"))),
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _offset_marker_lines(doc, index):
|
|
326
|
+
"""Each y-offset marker, as the template's `mark_spot` - the function
|
|
327
|
+
its `add_yoffset_markers` calls - with the panel's text, so the number
|
|
328
|
+
is written in the same format, at the temperature the panel points at
|
|
329
|
+
(`doc.marker_hint`, from the window)."""
|
|
330
|
+
if not doc.offset_markers or doc.x_axis != model.AXIS_TEMPERATURE:
|
|
331
|
+
return []
|
|
332
|
+
hint = getattr(doc, "marker_hint", None) or {}
|
|
333
|
+
out = []
|
|
334
|
+
for scan in doc.visible_scans():
|
|
335
|
+
marker = scan.marker
|
|
336
|
+
if not marker.visible:
|
|
337
|
+
continue
|
|
338
|
+
typed = (marker.at[1] if marker.at and marker.at[0] == "T"
|
|
339
|
+
else None)
|
|
340
|
+
celsius, yoff = hint.get(id(marker), (typed, None))
|
|
341
|
+
if celsius is None:
|
|
342
|
+
continue
|
|
343
|
+
i = index[os.path.normcase(scan.sample.path)]
|
|
344
|
+
text = numbers.write(float(scan.offset),
|
|
345
|
+
style.value(doc, marker, "number_format"),
|
|
346
|
+
numbers.OFFSET)
|
|
347
|
+
out.append(" mark_spot(ax, datas, ({}, {}), {:.6g}, {!r}, "
|
|
348
|
+
"yoff_label={:.4g}, flush='left',".format(
|
|
349
|
+
i, scan.seg, celsius, text,
|
|
350
|
+
-0.03 if yoff is None else yoff))
|
|
351
|
+
out.append(" color=(.1, .1, .1), fs={:g}, "
|
|
352
|
+
"arrowcolor=(.1, .1, .1))".format(
|
|
353
|
+
float(style.value(doc, marker, "size"))))
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
#: The panel's anchors as matplotlib's `loc`.
|
|
358
|
+
_LOC = {"top left": "upper left", "top": "upper center",
|
|
359
|
+
"top right": "upper right", "left": "center left",
|
|
360
|
+
"center": "center", "right": "center right",
|
|
361
|
+
"bottom left": "lower left", "bottom": "lower center",
|
|
362
|
+
"bottom right": "lower right"}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def mathtext(text):
|
|
366
|
+
r"""The panel's markup as matplotlib mathtext: `*T*` -> `$\mathit{T}$`,
|
|
367
|
+
`_{g}` -> `$_{\mathrm{g}}$`, `^{2}`, `\Delta` -> `$\Delta$`; what is
|
|
368
|
+
already between dollars is mathtext and stays as it is."""
|
|
369
|
+
import re
|
|
370
|
+
out = []
|
|
371
|
+
parts = re.split(r"(?<!\\)(\$[^$]*(?<!\\)\$)", str(text))
|
|
372
|
+
for part in parts:
|
|
373
|
+
if part.startswith("$") and part.endswith("$") and len(part) > 1:
|
|
374
|
+
out.append(part)
|
|
375
|
+
continue
|
|
376
|
+
# Symbols FIRST: the italics and scripts below write backslash
|
|
377
|
+
# commands of their own, which this must not turn over again.
|
|
378
|
+
part = re.sub(r"\\([A-Za-z]+)", lambda m: "$\\" + m.group(1) + "$",
|
|
379
|
+
part)
|
|
380
|
+
part = re.sub(r"\*([^*]+)\*",
|
|
381
|
+
lambda m: "$\\mathit{" + m.group(1).replace(" ", "\\ ")
|
|
382
|
+
+ "}$", part)
|
|
383
|
+
part = re.sub(r"([_^])\{([^}]*)\}",
|
|
384
|
+
lambda m: "$" + m.group(1) + "{\\mathrm{"
|
|
385
|
+
+ m.group(2).replace(" ", "\\ ") + "}}$", part)
|
|
386
|
+
out.append(part)
|
|
387
|
+
return "".join(out).replace("$$", "")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _ha_va(anchor):
|
|
391
|
+
anchor = str(anchor or "center")
|
|
392
|
+
ha = "left" if "left" in anchor else ("right" if "right" in anchor
|
|
393
|
+
else "center")
|
|
394
|
+
va = "top" if "top" in anchor else ("bottom" if "bottom" in anchor
|
|
395
|
+
else "center")
|
|
396
|
+
return ha, va
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _placed(doc, artist):
|
|
400
|
+
"""`(x, y, transform)` of an artist for matplotlib: axes fractions
|
|
401
|
+
(the panel's y runs from the TOP), or data - in degC, as the template
|
|
402
|
+
plots."""
|
|
403
|
+
# A label with a parent stands as far up as its scan has moved since
|
|
404
|
+
# it was placed (`TextLabel.follow`); in axes fractions that is the
|
|
405
|
+
# distance over the y range the figure is framed at.
|
|
406
|
+
follow = artist.follow() if hasattr(artist, "follow") else 0.0
|
|
407
|
+
# A label hanging from its curve is placed where the panel draws it,
|
|
408
|
+
# in data units (`MainWindow` hands the places over as a hint).
|
|
409
|
+
hung = (getattr(doc, "label_hints", None) or {}).get(id(artist))
|
|
410
|
+
if hung is not None:
|
|
411
|
+
x = float(hung[0])
|
|
412
|
+
if doc.x_axis == model.AXIS_TEMPERATURE:
|
|
413
|
+
x = float(units.to_celsius(x, doc.x_unit))
|
|
414
|
+
return x, float(hung[1]), "ax.transData"
|
|
415
|
+
if getattr(artist, "space", "relative") == model.SPACE_DATA:
|
|
416
|
+
x = float(artist.x)
|
|
417
|
+
if doc.x_axis == model.AXIS_TEMPERATURE:
|
|
418
|
+
x = float(units.to_celsius(x, doc.x_unit))
|
|
419
|
+
return x, float(artist.y) + follow, "ax.transData"
|
|
420
|
+
view_y = getattr(doc, "view_y_hint", None)
|
|
421
|
+
lift = (follow / (view_y[1] - view_y[0])
|
|
422
|
+
if follow and view_y and view_y[1] > view_y[0] else 0.0)
|
|
423
|
+
return float(artist.x), 1.0 - float(artist.y) + lift, "ax.transAxes"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _legend_lines(doc):
|
|
427
|
+
"""The panel's legend as `ax.legend`, at its place, with its size,
|
|
428
|
+
frame, sample length, spacing and line width. matplotlib turns no
|
|
429
|
+
legend, so a rotation is noted and left out."""
|
|
430
|
+
legend = doc.legend
|
|
431
|
+
if not legend.visible or not legend.entries(doc):
|
|
432
|
+
return []
|
|
433
|
+
x, y, transform = _placed(doc, legend)
|
|
434
|
+
size = float(style.value(doc, legend, "size"))
|
|
435
|
+
out = []
|
|
436
|
+
handles = ""
|
|
437
|
+
if any(entry.is_mass for entry, _text in legend.entries(doc)):
|
|
438
|
+
# The weight lines are on ax2: one legend for both axes.
|
|
439
|
+
out.append(" handles = (ax.get_legend_handles_labels()[0]"
|
|
440
|
+
" + ax2.get_legend_handles_labels()[0])")
|
|
441
|
+
handles = "handles=handles, "
|
|
442
|
+
out += [" leg = ax.legend({}loc={!r}, bbox_to_anchor=({:.4f}, {:.4f}),"
|
|
443
|
+
.format(handles, _LOC.get(legend.anchor, "center"), x, y),
|
|
444
|
+
" bbox_transform={}, frameon={}, fontsize={:g},"
|
|
445
|
+
.format(transform, bool(legend.show_frame), size),
|
|
446
|
+
" handlelength={:.3g}, labelspacing={:.3g})"
|
|
447
|
+
.format(float(legend.sample) * 0.75 / size,
|
|
448
|
+
max(0.0, float(legend.spacing) - 1.0) * 1.5)]
|
|
449
|
+
if legend.line_width:
|
|
450
|
+
out.append(" for line in leg.get_lines(): line.set_linewidth("
|
|
451
|
+
"{:g})".format(float(legend.line_width) * 0.75))
|
|
452
|
+
if legend.colour not in (None, "", "auto"):
|
|
453
|
+
out.append(" for text in leg.get_texts(): text.set_color({!r})"
|
|
454
|
+
.format(legend.colour))
|
|
455
|
+
if legend.rotation:
|
|
456
|
+
out.append(" # the panel's legend is turned {:g} degrees; "
|
|
457
|
+
"matplotlib does not turn legends".format(legend.rotation))
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _flush_ha(label):
|
|
462
|
+
"""matplotlib's `ha` for a label's flush (the panel's `_flush_of`)."""
|
|
463
|
+
chosen = getattr(label, "flush", None)
|
|
464
|
+
if chosen in ("left", "right", "center"):
|
|
465
|
+
return chosen
|
|
466
|
+
anchor = str(getattr(label, "anchor", "center"))
|
|
467
|
+
return ("left" if "left" in anchor else
|
|
468
|
+
"right" if "right" in anchor else "center")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _label_lines(doc):
|
|
472
|
+
"""Each label the user added, as `ax.text`: text (markup as mathtext),
|
|
473
|
+
place, anchor, size, weight, colour and rotation."""
|
|
474
|
+
out = []
|
|
475
|
+
for label in doc.labels:
|
|
476
|
+
if not label.visible:
|
|
477
|
+
continue
|
|
478
|
+
x, y, transform = _placed(doc, label)
|
|
479
|
+
ha, va = _ha_va(label.anchor)
|
|
480
|
+
hung = (getattr(doc, "label_hints", None) or {}).get(id(label))
|
|
481
|
+
tip = None
|
|
482
|
+
if hung is not None and len(hung) == 4:
|
|
483
|
+
# a note hanging from its curve: its flush edge over the point
|
|
484
|
+
tip = (hung[2], hung[3])
|
|
485
|
+
ha = _flush_ha(label)
|
|
486
|
+
va = "center"
|
|
487
|
+
colour = label.colour
|
|
488
|
+
if colour in (None, "", "auto"):
|
|
489
|
+
colour = label.scan.colour if label.scan is not None else "#1a1a1a"
|
|
490
|
+
if (getattr(label, "leader", None)
|
|
491
|
+
and doc.x_axis == model.AXIS_TEMPERATURE):
|
|
492
|
+
# A note: matplotlib's annotate, the arrow from the text to the
|
|
493
|
+
# point it names (in degC, as the template plots, and as far up
|
|
494
|
+
# as its scan has moved).
|
|
495
|
+
out.append(" ax.annotate({!r}, xy=({:.6g}, {:.6g}), "
|
|
496
|
+
"xycoords='data',".format(
|
|
497
|
+
mathtext(label.text),
|
|
498
|
+
float(tip[0] if tip else label.leader[0]),
|
|
499
|
+
float(tip[1] if tip else
|
|
500
|
+
label.leader[1] + label.follow())))
|
|
501
|
+
out.append(" xytext=({:.4f}, {:.4f}), "
|
|
502
|
+
"textcoords={!r},".format(
|
|
503
|
+
x, y, "axes fraction"
|
|
504
|
+
if transform == "ax.transAxes" else "data"))
|
|
505
|
+
out.append(" ha={!r}, va={!r}, fontsize={:g}, "
|
|
506
|
+
"color={!r},".format(
|
|
507
|
+
ha, va, float(style.value(doc, label, "size")),
|
|
508
|
+
colour))
|
|
509
|
+
out.append(" fontweight={!r}, rotation={:g},"
|
|
510
|
+
.format("bold" if label.bold else "normal",
|
|
511
|
+
float(label.rotation or 0.0)))
|
|
512
|
+
out.append(" arrowprops=dict(arrowstyle='-|>', "
|
|
513
|
+
"color={!r}, lw=0.8, shrinkA=2, shrinkB=0, "
|
|
514
|
+
"mutation_scale=8))".format(colour))
|
|
515
|
+
continue
|
|
516
|
+
out.append(" ax.text({:.4f}, {:.4f}, {!r}, transform={},".format(
|
|
517
|
+
x, y, mathtext(label.text), transform))
|
|
518
|
+
out.append(" ha={!r}, va={!r}, fontsize={:g}, color={!r},"
|
|
519
|
+
.format(ha, va, float(style.value(doc, label, "size")),
|
|
520
|
+
colour))
|
|
521
|
+
out.append(" fontweight={!r}, rotation={:g}, "
|
|
522
|
+
"rotation_mode='anchor')".format(
|
|
523
|
+
"bold" if label.bold else "normal",
|
|
524
|
+
float(label.rotation or 0.0)))
|
|
525
|
+
return out
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _axis_lines(axis, which):
|
|
529
|
+
"""matplotlib for an axis on its other side, without numbers or
|
|
530
|
+
without a caption - as the panel draws it."""
|
|
531
|
+
out = []
|
|
532
|
+
letter = which
|
|
533
|
+
far = "top" if which == "x" else "right"
|
|
534
|
+
near = "bottom" if which == "x" else "left"
|
|
535
|
+
if getattr(axis, "side", near) != near:
|
|
536
|
+
far = near
|
|
537
|
+
# The frame and the ticks as the panel draws them: the opposite spine,
|
|
538
|
+
# its ticks (no numbers), the steps and the lengths, in points.
|
|
539
|
+
if not getattr(axis, "mirror", True):
|
|
540
|
+
out.append(" ax.spines['{}'].set_visible(False)".format(far))
|
|
541
|
+
mirrored = getattr(axis, "mirror", True) and getattr(axis, "mirror_ticks",
|
|
542
|
+
True)
|
|
543
|
+
out.append(" ax.tick_params(axis='{}', which='both', {}={}, "
|
|
544
|
+
"direction='{}')".format(letter, far, mirrored,
|
|
545
|
+
"in" if axis.ticks_inward else "out"))
|
|
546
|
+
out.append(" ax.tick_params(axis='{}', which='major', length={:g})"
|
|
547
|
+
.format(letter, float(axis.tick_length) * 0.75))
|
|
548
|
+
out.append(" ax.tick_params(axis='{}', which='minor', length={:g})"
|
|
549
|
+
.format(letter, float(axis.minor_length) * 0.75))
|
|
550
|
+
if getattr(axis, "major_step", None):
|
|
551
|
+
out.append(" from matplotlib.ticker import MultipleLocator")
|
|
552
|
+
out.append(" ax.{}axis.set_major_locator(MultipleLocator({:g}))"
|
|
553
|
+
.format(letter, float(axis.major_step)))
|
|
554
|
+
if not axis.minor_ticks or int(axis.minor_count) < 2:
|
|
555
|
+
out.append(" from matplotlib.ticker import NullLocator")
|
|
556
|
+
out.append(" ax.{}axis.set_minor_locator(NullLocator())"
|
|
557
|
+
.format(letter))
|
|
558
|
+
else:
|
|
559
|
+
out.append(" ax.{}axis.set_minor_locator(AutoMinorLocator({:d}))"
|
|
560
|
+
.format(letter, int(axis.minor_count)))
|
|
561
|
+
spec = getattr(axis, "number_format", None)
|
|
562
|
+
if spec:
|
|
563
|
+
out.append(" from matplotlib.ticker import FormatStrFormatter")
|
|
564
|
+
out.append(" ax.{}axis.set_major_formatter(FormatStrFormatter("
|
|
565
|
+
"{!r}))".format(which, spec))
|
|
566
|
+
side = getattr(axis, "side", "bottom" if which == "x" else "left")
|
|
567
|
+
if which == "x" and side == "top":
|
|
568
|
+
out.append(" ax.xaxis.tick_top()")
|
|
569
|
+
out.append(" ax.xaxis.set_label_position('top')")
|
|
570
|
+
if which == "y" and side == "right":
|
|
571
|
+
out.append(" ax.yaxis.tick_right()")
|
|
572
|
+
out.append(" ax.yaxis.set_label_position('right')")
|
|
573
|
+
if not getattr(axis, "show_numbers", True):
|
|
574
|
+
names = (("labelbottom", "labeltop") if which == "x"
|
|
575
|
+
else ("labelleft", "labelright"))
|
|
576
|
+
out.append(" ax.tick_params(axis='{}', {}=False, {}=False)".format(
|
|
577
|
+
which, names[0], names[1]))
|
|
578
|
+
if not axis.visible:
|
|
579
|
+
out.append(" ax.set_{}label('')".format(which))
|
|
580
|
+
return out
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def doc_view_x(doc):
|
|
584
|
+
"""The x range the panel is showing, or the data range."""
|
|
585
|
+
view = getattr(doc, "view_x_hint", None)
|
|
586
|
+
if view:
|
|
587
|
+
return view
|
|
588
|
+
lo, hi = None, None
|
|
589
|
+
for scan in doc.visible_scans():
|
|
590
|
+
x, _y = scan.kept_curve(doc.x_axis, doc.unit_for(scan), doc.exo,
|
|
591
|
+
doc.x_unit)
|
|
592
|
+
if x is None or not len(x):
|
|
593
|
+
continue
|
|
594
|
+
lo = float(np.nanmin(x)) if lo is None else min(lo, float(np.nanmin(x)))
|
|
595
|
+
hi = float(np.nanmax(x)) if hi is None else max(hi, float(np.nanmax(x)))
|
|
596
|
+
return (lo if lo is not None else 0.0, hi if hi is not None else 1.0)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
_DRIVER = re.compile(r"^def driver\(\):.*?(?=^[^\s#])", re.S | re.M)
|
|
600
|
+
_MANUAL = re.compile(r"^MANUAL\s*=\s*(True|False)\s*$", re.M)
|
|
601
|
+
_PATHS = re.compile(r"^paths\s*=\s*\[.*?^\]\s*$", re.S | re.M)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def write_driver(doc, path):
|
|
605
|
+
"""Write the driver, as a whole template when one can be built.
|
|
606
|
+
|
|
607
|
+
With `achdsc` importable (the plotter package installed), this writes a
|
|
608
|
+
COMPLETE `DSC_Plotter.py`: the template, with the panel's paths, driver
|
|
609
|
+
and `MANUAL = True` spliced in, which runs as it stands. Without it, the
|
|
610
|
+
driver snippet is written on its own with a header saying where to paste
|
|
611
|
+
it. Either way nothing here imports matplotlib.
|
|
612
|
+
"""
|
|
613
|
+
source = driver_source(doc)
|
|
614
|
+
try:
|
|
615
|
+
from achdsc import cli as achdsc_cli
|
|
616
|
+
except Exception:
|
|
617
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
618
|
+
fh.write(source)
|
|
619
|
+
return path, "snippet"
|
|
620
|
+
try:
|
|
621
|
+
template = achdsc_cli.render_template([], os.path.dirname(path) or ".")
|
|
622
|
+
body = source.split("MANUAL = True", 1)[1]
|
|
623
|
+
# `re.sub` with a STRING replacement processes backslash escapes, and
|
|
624
|
+
# every one of these replacements carries Windows paths: `\U` in
|
|
625
|
+
# `C:\Users\...` raises "bad escape". A function replacement is
|
|
626
|
+
# substituted literally, which is what is wanted here.
|
|
627
|
+
template = _PATHS.sub(lambda _m: _paths_block(doc), template, count=1)
|
|
628
|
+
template = _MANUAL.sub(lambda _m: "MANUAL = True", template, count=1)
|
|
629
|
+
template = _DRIVER.sub(lambda _m: body.strip("\n") + "\n\n\n",
|
|
630
|
+
template, count=1)
|
|
631
|
+
except Exception:
|
|
632
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
633
|
+
fh.write(source)
|
|
634
|
+
return path, "snippet"
|
|
635
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
636
|
+
fh.write(template)
|
|
637
|
+
return path, "template"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _paths_block(doc):
|
|
641
|
+
paths = []
|
|
642
|
+
for scan in doc.visible_scans():
|
|
643
|
+
if scan.sample.path not in paths:
|
|
644
|
+
paths.append(scan.sample.path)
|
|
645
|
+
lines = ["paths = ["]
|
|
646
|
+
for path in paths:
|
|
647
|
+
lines.append(" r'{}',".format(path))
|
|
648
|
+
lines.append("]")
|
|
649
|
+
return "\n".join(lines)
|