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