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,667 @@
1
+ """Saving and reopening an arrangement.
2
+
3
+ A figure made of eight scans is half an hour of dragging, colouring and
4
+ typing molar masses, and it is worth nothing if it cannot be reopened. So the
5
+ arrangement is a file: which measurements, which segments of them, where each
6
+ one sits, what colour it is, what M it was given, where the arrow is.
7
+
8
+ **The measurements themselves are NOT copied in.** A session stores paths and
9
+ reads the files again, which keeps a session small and keeps one copy of the
10
+ data on disk - and means a re-run of the same sample, saved over the same
11
+ path, is picked up by reopening the session. A file that has moved is
12
+ reported by name instead of failing the whole load, because losing seven
13
+ scans over one missing path would be the worst possible behaviour.
14
+
15
+ UI-free: `load` takes the reader as an argument, so this module never imports
16
+ the reader or a window and is testable with a stub.
17
+ """
18
+
19
+ import json
20
+ import os
21
+
22
+ from . import dtg
23
+ from . import figure as figure_module
24
+ from . import labels
25
+ from . import measure
26
+ from . import model
27
+ from . import numbers
28
+ from . import style
29
+ from . import units
30
+
31
+ FORMAT = "dscpanel-session"
32
+ #: 2: sizes are None until chosen (the house style fills them in), the
33
+ #: figure carries its own `style`, and analyses have a `flush`.
34
+ #: 3: an axis's `label_gap` is measured from its NUMBERS (it was from the
35
+ #: axis line for x and from the window's edge for y), None until chosen.
36
+ VERSION = 8
37
+
38
+
39
+ def view_to_state(view):
40
+ """The framing as plain data (lists), or None."""
41
+ if not view:
42
+ return None
43
+ return {"x": list(view["x"]) if view.get("x") else None,
44
+ "y": list(view["y"]) if view.get("y") else None,
45
+ "y2": list(view["y2"]) if view.get("y2") else None,
46
+ # The weight range is a number of % or of mg: which, beside it
47
+ # (the plot restores it only in that unit). A range saved
48
+ # without it never came back.
49
+ "y2_unit": view.get("y2_unit") if view.get("y2") else None,
50
+ "context": list(view.get("context") or ())}
51
+
52
+
53
+ def _view_from(saved):
54
+ """The framing back as the plot keeps it (tuples), or None."""
55
+ if not isinstance(saved, dict):
56
+ return None
57
+ try:
58
+ return {"x": tuple(float(v) for v in saved["x"]) if saved.get("x")
59
+ else None,
60
+ "y": tuple(float(v) for v in saved["y"]) if saved.get("y")
61
+ else None,
62
+ "y2": tuple(float(v) for v in saved["y2"])
63
+ if saved.get("y2") else None,
64
+ "y2_unit": (saved.get("y2_unit")
65
+ if saved.get("y2_unit") in model.WEIGHT_UNITS
66
+ else None),
67
+ "context": tuple(saved.get("context") or ())}
68
+ except (TypeError, ValueError, KeyError):
69
+ return None
70
+
71
+
72
+ def _marker_at(value):
73
+ """A stored marker place: None, ("i", sample) or ("T", celsius)."""
74
+ try:
75
+ kind, number = value
76
+ if kind == "i":
77
+ return ("i", int(number))
78
+ if kind == "T":
79
+ return ("T", float(number))
80
+ except (TypeError, ValueError):
81
+ pass
82
+ return None
83
+
84
+
85
+ def _number_or_none(value):
86
+ try:
87
+ return None if value is None else float(value)
88
+ except (TypeError, ValueError):
89
+ return None
90
+
91
+
92
+ def _chosen(value, key, version):
93
+ """A stored size, or None where the file did not really choose it.
94
+
95
+ A version-1 file wrote EVERY size, chosen or not, because there was no
96
+ house style to fall back on. Read literally, each of those would pin the
97
+ object and the user's defaults would never reach a figure saved before
98
+ they existed. So in a version-1 file a value equal to the built-in one is
99
+ read as "not chosen" - it is what the object carried when nobody had
100
+ touched it. Later files store None themselves.
101
+ """
102
+ setting = style.BY_KEY[key]
103
+ value = setting.clean(value)
104
+ if (value is not None and version < 2
105
+ and value == _VERSION_1_DEFAULTS.get(key)):
106
+ return None
107
+ return value
108
+
109
+
110
+ #: What every object carried in a version-1 file when nobody had touched it:
111
+ #: the built-ins of THAT time, not today's, which have since moved.
112
+ _VERSION_1_DEFAULTS = {"analysis_size": 9.0, "caption_size": 10.0,
113
+ "tick_size": 8.0, "legend_size": 9.0,
114
+ "label_size": 10.0, "line_width": 1.0}
115
+
116
+
117
+ def _analysis_state(analysis):
118
+ """One analysis: how it is drawn, and - if it was made HERE - how to
119
+ make it again.
120
+
121
+ A file's own analyses come back with the file, so only their styling is
122
+ stored, matched up again by `key`. One measured in the panel exists
123
+ nowhere else, so its model and its two cursors are stored too, and it is
124
+ RECOMPUTED from the re-read file on load - the same rule as the curves:
125
+ a session keeps the arrangement, never a copy of the numbers.
126
+ """
127
+ state = {"key": analysis.key(), "visible": analysis.visible,
128
+ "colour": analysis.colour, "label": analysis.label,
129
+ "label_dy": analysis.label_dy, "shade": analysis.shade,
130
+ "shading": analysis.shading,
131
+ "label_size": analysis.label_size, "flush": analysis.flush,
132
+ "show_interval": analysis.show_interval,
133
+ "interval_size": analysis.interval_size,
134
+ "construction": analysis.construction,
135
+ "show_peak": analysis.show_peak,
136
+ "number_format": analysis.number_format,
137
+ "unit": analysis.unit,
138
+ "label_at": analysis.label_at, "z": analysis.z,
139
+ "attribution": analysis.attribution,
140
+ "source": analysis.source}
141
+ if analysis.source == "panel":
142
+ state["model"] = analysis.model_name
143
+ state["cursors"] = analysis.cursors()
144
+ state["span"] = list(analysis.span) if analysis.span else None
145
+ return state
146
+
147
+
148
+ def _restore_analysis(analysis, saved, version):
149
+ analysis.visible = bool(saved.get("visible", False))
150
+ analysis.colour = saved.get("colour", "auto")
151
+ analysis.label = saved.get("label")
152
+ stored_dy = saved.get("label_dy", analysis.label_dy)
153
+ analysis.label_dy = None if stored_dy is None else float(stored_dy)
154
+ analysis.shade = bool(saved.get("shade", True))
155
+ shading = saved.get("shading")
156
+ analysis.shading = shading if shading in style.SHADINGS else None
157
+ peak = saved.get("show_peak")
158
+ analysis.show_peak = peak if peak in style.PEAKS else None
159
+ analysis.label_size = _chosen(saved.get("label_size"), "analysis_size",
160
+ version)
161
+ flush = saved.get("flush")
162
+ analysis.flush = flush if flush in style.FLUSHES else None
163
+ analysis.show_interval = bool(saved.get("show_interval", True))
164
+ analysis.interval_size = style.BY_KEY["interval_tick"].clean(
165
+ saved.get("interval_size"))
166
+ if "construction" in saved:
167
+ lines = saved.get("construction")
168
+ analysis.construction = lines if lines in style.LINES else None
169
+ else:
170
+ # Saved before an onset's lines had a switch of their own:
171
+ # "Show interval markers" was the dashes AND the lines, so one saved
172
+ # with it off had no lines either, and opens that way. No version
173
+ # bump: the missing key says it.
174
+ analysis.construction = (None if analysis.show_interval
175
+ else style.LINES_NONE)
176
+ if analysis.attribution != model.CURVE_NOT_STATED:
177
+ # What the FILE does not say stays unsaid, whatever a session
178
+ # written before that was known recorded.
179
+ analysis.attribution = saved.get("attribution", analysis.attribution)
180
+ # With its unit, if it names one (`%.0f degF`): a unit-less read
181
+ # dropped such a format on every reopen.
182
+ analysis.number_format = labels.normalise_format(
183
+ saved.get("number_format"))
184
+ analysis.label_at = _number_or_none(saved.get("label_at"))
185
+ analysis.z = _number_or_none(saved.get("z"))
186
+ unit = labels.canonical_unit(saved.get("unit"))
187
+ analysis.unit = (unit if unit in labels.units_of(analysis.quantity)
188
+ else None)
189
+ if version < 5 and measure.is_legacy_label(analysis.label):
190
+ # A panel analysis was once GIVEN a label with its number written
191
+ # in ("*T*_{onset} = 61.1 degC"), and a file before version 5 can
192
+ # still hold one whose number went stale. Nobody typed that shape,
193
+ # so it goes back to the default template, whose number follows the
194
+ # measurement; any other label stays the user's.
195
+ analysis.label = None
196
+
197
+
198
+ def to_state(doc):
199
+ """The document as plain data, ready for `json.dump`."""
200
+ samples = []
201
+ for sample in doc.samples:
202
+ samples.append({
203
+ "path": sample.path,
204
+ "molar_mass": sample.molar_mass,
205
+ "exo": sample.exo,
206
+ "exo_source": sample.exo_source,
207
+ "title": sample.title,
208
+ "composition": sample.composition,
209
+ })
210
+ scans = []
211
+ for scan in doc.scans:
212
+ scans.append({
213
+ "path": scan.sample.path,
214
+ "seg": scan.seg,
215
+ "signal": scan.signal,
216
+ "dtg_window": scan.dtg_window if scan.is_dtg else None,
217
+ "colour": scan.colour,
218
+ "offset": scan.offset,
219
+ "line_width": scan.line_width,
220
+ "keep": list(scan.keep),
221
+ "label": scan.label,
222
+ "visible": scan.visible,
223
+ "analyses": [_analysis_state(a) for a in scan.analysis_objects],
224
+ "z": scan.z,
225
+ "marker": {"z": scan.marker.z,"at": (list(scan.marker.at) if scan.marker.at
226
+ else None),
227
+ "dy": scan.marker.dy,
228
+ "number_format": scan.marker.number_format,
229
+ "size": scan.marker.size,
230
+ "colour": scan.marker.colour,
231
+ "visible": scan.marker.visible},
232
+ })
233
+ arrow = doc.arrow
234
+ axes = {}
235
+ for which, axis in doc.axes.items():
236
+ axes[which] = {"label": axis.label, "show_grid": axis.show_grid,
237
+ "minor_ticks": axis.minor_ticks,
238
+ "ticks_inward": axis.ticks_inward,
239
+ "label_size": axis.label_size,
240
+ "tick_size": axis.tick_size,
241
+ "label_along": axis.label_along,
242
+ "number_format": axis.number_format,
243
+ "mirror": axis.mirror,
244
+ "mirror_ticks": axis.mirror_ticks,
245
+ "major_step": axis.major_step,
246
+ "minor_count": axis.minor_count,
247
+ "tick_length": axis.tick_length,
248
+ "minor_length": axis.minor_length,
249
+ "side": axis.side,
250
+ "show_numbers": axis.show_numbers,
251
+ "visible": axis.visible,
252
+ "label_gap": axis.label_gap,
253
+ "lock": (list(axis.lock) if axis.lock else None),
254
+ "lock_context": (list(axis.lock_context)
255
+ if axis.lock_context else None),
256
+ "hidden_numbers": [float(v) for v in
257
+ axis.hidden_numbers or ()],
258
+ "hidden_context": (list(axis.hidden_context)
259
+ if axis.hidden_numbers
260
+ and axis.hidden_context else None)}
261
+ labels = []
262
+ for lb in doc.labels:
263
+ labels.append({"text": lb.text, "x": lb.x, "y": lb.y,
264
+ "colour": lb.colour, "size": lb.size, "bold": lb.bold,
265
+ "visible": lb.visible, "space": lb.space,
266
+ "anchor": lb.anchor, "rotation": lb.rotation,
267
+ "z": lb.z,
268
+ "scan": (None if lb.scan is None
269
+ else [lb.scan.sample.path, lb.scan.seg]),
270
+ "parent_offset": lb.parent_offset,
271
+ "at": (list(lb.at) if lb.at else None),
272
+ "dx": lb.dx, "dy": lb.dy,
273
+ "leader": lb.leader,
274
+ "leader_from": lb.leader_from,
275
+ "leader_colour": lb.leader_colour,
276
+ "flush": lb.flush,
277
+ "vline": lb.vline, "line_dashed": lb.line_dashed})
278
+ return {
279
+ "format": FORMAT,
280
+ "version": VERSION,
281
+ "axes": axes,
282
+ "labels": labels,
283
+ "structures": [{"smiles": m.smiles, "atoms": m.atoms,
284
+ "bonds": m.bonds, "x": m.x, "y": m.y,
285
+ "space": m.space, "anchor": m.anchor,
286
+ "rotation": m.rotation, "z": m.z,
287
+ "visible": m.visible, "colour": m.colour,
288
+ "bond_length": m.bond_length,
289
+ "bond_width": m.bond_width,
290
+ "label_size": m.label_size,
291
+ "upright_labels": m.upright_labels,
292
+ "label_font": m.label_font,
293
+ "colour_by_element": m.colour_by_element}
294
+ for m in doc.structures],
295
+ "images": [{"png": im.png, "x": im.x, "y": im.y,
296
+ "space": im.space, "anchor": im.anchor,
297
+ "rotation": im.rotation, "width": im.width,
298
+ "z": im.z, "visible": im.visible,
299
+ "mirror_h": im.mirror_h, "mirror_v": im.mirror_v}
300
+ for im in doc.images],
301
+ "x_axis": doc.x_axis,
302
+ "x_unit": doc.x_unit,
303
+ "y_unit": doc.y_unit,
304
+ "weight_unit": doc.weight_unit,
305
+ "dtg_unit": doc.dtg_unit,
306
+ "theme": doc.theme,
307
+ "background": doc.background,
308
+ "follow_zoom": bool(doc.follow_zoom),
309
+ "offset_markers": bool(doc.offset_markers),
310
+ "view": view_to_state(doc.view),
311
+ "style": doc.style.chosen(),
312
+ "figure": doc.figure.to_state(),
313
+ "figure_grown": dict(doc.figure.grown),
314
+ "legend": {"visible": doc.legend.visible, "size": doc.legend.size,
315
+ "show_frame": doc.legend.show_frame,
316
+ "sample": doc.legend.sample,
317
+ "spacing": doc.legend.spacing,
318
+ "colour": doc.legend.colour, "x": doc.legend.x,
319
+ "rotation": doc.legend.rotation, "z": doc.legend.z,
320
+ "line_width": doc.legend.line_width,
321
+ "y": doc.legend.y, "space": doc.legend.space,
322
+ "anchor": doc.legend.anchor},
323
+ "arrow": {"word": arrow.word, "direction": arrow.direction,
324
+ "x": arrow.x, "y": arrow.y,
325
+ "head_length": arrow.head_length,
326
+ "head_width": arrow.head_width,
327
+ "tail_width": arrow.tail_width,
328
+ "tail_length": arrow.tail_length,
329
+ "lock": arrow.lock, "size": arrow.size, "z": arrow.z,
330
+ "colour": arrow.colour, "visible": arrow.visible,
331
+ "space": arrow.space, "anchor": arrow.anchor},
332
+ "samples": samples,
333
+ "scans": scans,
334
+ }
335
+
336
+
337
+ def save(doc, path):
338
+ """Write the session. The document remembers where it went."""
339
+ state = to_state(doc)
340
+ with open(path, "w", encoding="utf-8") as fh:
341
+ json.dump(state, fh, indent=1)
342
+ doc.path = str(path)
343
+ return path
344
+
345
+
346
+ def load(path, read_sample):
347
+ """Rebuild a document from a session file.
348
+
349
+ Returns `(document, problems)`. `problems` names every file that could not
350
+ be read, so the window can say so in one line and still show everything
351
+ else.
352
+ """
353
+ with open(path, "r", encoding="utf-8") as fh:
354
+ state = json.load(fh)
355
+ if state.get("format") != FORMAT:
356
+ raise ValueError("not a {} file".format(FORMAT))
357
+ doc = model.Document()
358
+ version = int(state.get("version", 1) or 1)
359
+ #: Which version it was read from: before 7, a decorator's place was a
360
+ #: fraction of the VIEW, not of the home frame (`PlotWidget.rehome`).
361
+ doc.loaded_version = version
362
+ problems = []
363
+ by_path = {}
364
+ saved_style = dict(state.get("style") or {})
365
+ if version < 6:
366
+ # The fit margins were percent of the data's range until version 6.
367
+ style.convert_old_fit(saved_style)
368
+ for key, raw in saved_style.items():
369
+ if key in style.BY_KEY and style.BY_KEY[key].figure:
370
+ setattr(doc.style, key, style.BY_KEY[key].clean(raw))
371
+ # A session keeps its OWN figure size; one saved before there was such
372
+ # a thing follows the window, as it did then - not today's default.
373
+ doc.figure = figure_module.FigureLayout().load_state(
374
+ state.get("figure") or {"mode": figure_module.MODE_WINDOW})
375
+ doc.figure.grown = figure_module.clean_grown(state.get("figure_grown"))
376
+ for entry in state.get("samples", []):
377
+ try:
378
+ sample = read_sample(entry["path"])
379
+ except Exception as exc:
380
+ problems.append("{}: {}".format(
381
+ os.path.basename(entry.get("path", "?")), exc))
382
+ continue
383
+ if entry.get("molar_mass"):
384
+ sample.molar_mass = float(entry["molar_mass"])
385
+ if entry.get("exo") in (units.EXO_DOWN, units.EXO_UP):
386
+ sample.exo = entry["exo"]
387
+ sample.exo_source = entry.get("exo_source", sample.exo_source)
388
+ title = entry.get("title")
389
+ sample.title = str(title) if title else None
390
+ made_of = entry.get("composition")
391
+ sample.composition = str(made_of) if made_of else None
392
+ doc.samples.append(sample)
393
+ by_path[os.path.normcase(sample.path)] = sample
394
+ # An older session kept a scan's weight as a dashed extra on it; a
395
+ # weight shown there opens as a mass scan of its own, AFTER the saved
396
+ # scans (labels name their scan by its place in the list).
397
+ legacy_mass = []
398
+ for entry in state.get("scans", []):
399
+ sample = by_path.get(os.path.normcase(entry.get("path", "")))
400
+ if sample is None:
401
+ continue
402
+ seg = int(entry.get("seg", 0))
403
+ if seg >= sample.segment_count():
404
+ problems.append("{}: segment {} is gone".format(sample.name,
405
+ seg + 1))
406
+ continue
407
+ signal = (entry.get("signal") if entry.get("signal") in model.SIGNALS
408
+ else model.SIGNAL_HEAT)
409
+ scan = model.Scan(doc._next_id(), sample, seg,
410
+ entry.get("colour")
411
+ or model.PALETTE[len(doc.scans) % len(model.PALETTE)],
412
+ signal)
413
+ scan.offset = float(entry.get("offset", 0.0))
414
+ window = _number_or_none(entry.get("dtg_window"))
415
+ if window is not None and window >= 0:
416
+ scan.dtg_window = window
417
+ scan.line_width = _chosen(entry.get("line_width"), "line_width",
418
+ version)
419
+ keep = entry.get("keep") or (0.0, 1.0)
420
+ try:
421
+ start, end = float(keep[0]), float(keep[1])
422
+ except (TypeError, ValueError, IndexError):
423
+ start, end = 0.0, 1.0
424
+ if 0.0 <= start < end <= 1.0:
425
+ scan.keep = (start, end)
426
+ scan.label = entry.get("label")
427
+ scan.visible = bool(entry.get("visible", True))
428
+ weight = entry.get("weight")
429
+ if (isinstance(weight, dict) and weight.get("visible", True)
430
+ and signal == model.SIGNAL_HEAT and scan.has_weight()):
431
+ legacy_mass.append((sample, seg, scan.colour))
432
+ stored = entry.get("analyses") or []
433
+ wanted = {item.get("key"): item for item in stored
434
+ if item.get("source") != "panel"}
435
+ for analysis in scan.analysis_objects:
436
+ saved = wanted.get(analysis.key())
437
+ if saved:
438
+ _restore_analysis(analysis, saved, version)
439
+ for saved in stored:
440
+ if saved.get("source") != "panel":
441
+ continue
442
+ cursors = saved.get("cursors") or []
443
+ made = None
444
+ if len(cursors) == 2:
445
+ made = measure.run(saved.get("model", ""), scan,
446
+ float(cursors[0]), float(cursors[1]),
447
+ span=saved.get("span"))
448
+ if made is None:
449
+ problems.append("{}: {} could not be measured again".format(
450
+ scan.display_name(), saved.get("model", "an analysis")))
451
+ continue
452
+ _restore_analysis(made, saved, version)
453
+ # A scan has no molar mass of its own: one an older session gave
454
+ # a scan goes to its file, if the file had none.
455
+ own = _number_or_none(entry.get("molar_mass_override"))
456
+ if own and own > 0 and not sample.molar_mass:
457
+ sample.molar_mass = float(own)
458
+ scan.z = _number_or_none(entry.get("z"))
459
+ marker = entry.get("marker") or {}
460
+ scan.marker.at = _marker_at(marker.get("at"))
461
+ scan.marker.number_format = labels.normalise_format(
462
+ marker.get("number_format"))
463
+ scan.marker.dy = _number_or_none(marker.get("dy"))
464
+ scan.marker.size = _chosen(marker.get("size"), "offset_marker_size",
465
+ version)
466
+ scan.marker.colour = marker.get("colour", "auto")
467
+ scan.marker.visible = bool(marker.get("visible", True))
468
+ scan.marker.z = _number_or_none(marker.get("z"))
469
+ sample.scans.append(scan)
470
+ doc.scans.append(scan)
471
+ for sample, seg, colour in legacy_mass:
472
+ if any(s.sample is sample and s.seg == seg and s.is_mass
473
+ for s in doc.scans):
474
+ continue
475
+ mass = model.Scan(doc._next_id(), sample, seg, colour,
476
+ model.SIGNAL_MASS)
477
+ sample.scans.append(mass)
478
+ doc.scans.append(mass)
479
+ arrow = state.get("arrow") or {}
480
+ doc.arrow.word = arrow.get("word", doc.arrow.word)
481
+ doc.arrow.direction = arrow.get("direction", doc.arrow.direction)
482
+ doc.arrow.x = float(arrow.get("x", doc.arrow.x))
483
+ doc.arrow.y = float(arrow.get("y", doc.arrow.y))
484
+ # Its dimensions are in points. An older session's `length`
485
+ # (a fraction of the plot height) has no honest conversion, so the
486
+ # template's proportions are what it opens with.
487
+ for name in ("head_length", "head_width", "tail_width", "tail_length"):
488
+ value = _number_or_none(arrow.get(name))
489
+ if value is not None and value > 0:
490
+ setattr(doc.arrow, name, value)
491
+ if arrow.get("lock") in model.ARROW_LOCKS:
492
+ doc.arrow.lock = arrow["lock"]
493
+ doc.arrow.size = _chosen(arrow.get("size"), "arrow_size", version)
494
+ doc.arrow.z = _number_or_none(arrow.get("z"))
495
+ doc.arrow.colour = arrow.get("colour", doc.arrow.colour)
496
+ doc.arrow.visible = bool(arrow.get("visible", True))
497
+ doc.arrow.space = arrow.get("space", doc.arrow.space)
498
+ doc.arrow.anchor = arrow.get("anchor", doc.arrow.anchor)
499
+ for which, saved in (state.get("axes") or {}).items():
500
+ axis = doc.axes.get(which)
501
+ if axis is None:
502
+ continue
503
+ for name, value in saved.items():
504
+ if hasattr(axis, name):
505
+ setattr(axis, name, value)
506
+ axis.number_format = numbers.normalise(saved.get("number_format"))
507
+ step = _number_or_none(saved.get("major_step"))
508
+ axis.major_step = step if step and step > 0 else None
509
+ try:
510
+ axis.minor_count = max(1, int(saved.get("minor_count", 5)))
511
+ except (TypeError, ValueError):
512
+ axis.minor_count = 5
513
+ for name, default in (("tick_length", 7.0), ("minor_length", 3.0)):
514
+ value = _number_or_none(saved.get(name))
515
+ setattr(axis, name, value if value is not None and value >= 0
516
+ else default)
517
+ axis.mirror = bool(saved.get("mirror", True))
518
+ axis.mirror_ticks = bool(saved.get("mirror_ticks", True))
519
+ axis.label_size = _chosen(saved.get("label_size"), "caption_size",
520
+ version)
521
+ # Before version 3 the gap was measured from somewhere else, so an
522
+ # old number would put the caption in the wrong place: dropped.
523
+ axis.label_gap = (style.BY_KEY["caption_gap"].clean(
524
+ saved.get("label_gap")) if version >= 3 else None)
525
+ if saved.get("side") not in (("bottom", "top") if which == "x"
526
+ else ("left", "right")):
527
+ axis.side = "bottom" if which == "x" else "left"
528
+ axis.tick_size = _chosen(saved.get("tick_size"), "tick_size", version)
529
+ lock = saved.get("lock")
530
+ try:
531
+ lock = [float(lock[0]), float(lock[1])] if lock else None
532
+ except (TypeError, ValueError, IndexError):
533
+ lock = None
534
+ axis.lock = lock if lock and lock[1] > lock[0] else None
535
+ context = saved.get("lock_context")
536
+ axis.lock_context = (list(context) if axis.lock and
537
+ isinstance(context, list) else None)
538
+ hidden = []
539
+ for value in saved.get("hidden_numbers") or ():
540
+ value = _number_or_none(value)
541
+ if value is not None and value == value and abs(value) != \
542
+ float("inf"):
543
+ hidden.append(value)
544
+ context = saved.get("hidden_context")
545
+ axis.hidden_context = (list(context) if hidden
546
+ and isinstance(context, list) else None)
547
+ axis.hidden_numbers = hidden if axis.hidden_context else []
548
+ for saved in state.get("labels") or []:
549
+ owner = None
550
+ owned = saved.get("scan")
551
+ if owned:
552
+ for scan in doc.scans:
553
+ if (os.path.normcase(scan.sample.path)
554
+ == os.path.normcase(owned[0])
555
+ and scan.seg == owned[1]):
556
+ owner = scan
557
+ break
558
+ label = doc.add_label(saved.get("text", "Label"),
559
+ float(saved.get("x", 0.5)),
560
+ float(saved.get("y", 0.5)), owner)
561
+ label.colour = saved.get("colour", "auto")
562
+ label.size = _chosen(saved.get("size"), "label_size", version)
563
+ label.bold = bool(saved.get("bold", False))
564
+ label.visible = bool(saved.get("visible", True))
565
+ label.space = saved.get("space", label.space)
566
+ label.anchor = saved.get("anchor", label.anchor)
567
+ label.rotation = float(_number_or_none(saved.get("rotation")) or 0.0)
568
+ label.z = _number_or_none(saved.get("z"))
569
+ # Where its scan stood when it was placed. A session from before
570
+ # labels followed their scan has none: it stays where it was drawn.
571
+ followed = _number_or_none(saved.get("parent_offset"))
572
+ if owner is not None and followed is not None:
573
+ label.parent_offset = followed
574
+ # A note's arrow: the point it names, [degC, heat flow].
575
+ leader = saved.get("leader")
576
+ if (isinstance(leader, (list, tuple)) and len(leader) == 2
577
+ and all(_number_or_none(v) is not None for v in leader)):
578
+ label.leader = [float(leader[0]), float(leader[1])]
579
+ start = saved.get("leader_from", "auto")
580
+ label.leader_from = start if start in model.ANCHORS else "auto"
581
+ label.leader_colour = saved.get("leader_colour") or "auto"
582
+ flush = saved.get("flush")
583
+ label.flush = flush if flush in ("left", "right", "center") else None
584
+ label.vline = _number_or_none(saved.get("vline"))
585
+ # Hanging from its curve; an older one is attached
586
+ # where it is drawn once the session is on screen.
587
+ at = saved.get("at")
588
+ if (owner is not None and isinstance(at, (list, tuple))
589
+ and len(at) == 2 and at[0] == "i"
590
+ and _number_or_none(at[1]) is not None):
591
+ label.at = ("i", int(at[1]))
592
+ label.dx = float(_number_or_none(saved.get("dx")) or 0.0)
593
+ label.dy = _number_or_none(saved.get("dy"))
594
+ label.line_dashed = bool(saved.get("line_dashed", True))
595
+ for saved in state.get("structures") or []:
596
+ if not saved.get("atoms"):
597
+ continue
598
+ structure = model.MoleculeArtist(
599
+ doc._next_id(), saved.get("smiles", ""),
600
+ {"atoms": saved["atoms"], "bonds": saved.get("bonds", [])},
601
+ float(saved.get("x", 0.5)), float(saved.get("y", 0.5)))
602
+ for name in ("space", "anchor", "colour"):
603
+ if saved.get(name) is not None:
604
+ setattr(structure, name, saved[name])
605
+ for name in ("rotation", "bond_length", "bond_width", "label_size"):
606
+ value = _number_or_none(saved.get(name))
607
+ if value is not None:
608
+ setattr(structure, name, value)
609
+ structure.z = _number_or_none(saved.get("z"))
610
+ structure.visible = bool(saved.get("visible", True))
611
+ structure.upright_labels = bool(saved.get("upright_labels", True))
612
+ structure.label_font = saved.get("label_font") or None
613
+ structure.colour_by_element = bool(saved.get("colour_by_element",
614
+ False))
615
+ doc.structures.append(structure)
616
+ for saved in state.get("images") or []:
617
+ if not saved.get("png"):
618
+ continue
619
+ image = model.ImageArtist(doc._next_id(), saved["png"],
620
+ float(saved.get("x", 0.5)),
621
+ float(saved.get("y", 0.5)),
622
+ float(saved.get("width", 160.0)))
623
+ image.space = saved.get("space", image.space)
624
+ image.anchor = saved.get("anchor", image.anchor)
625
+ image.rotation = float(_number_or_none(saved.get("rotation")) or 0.0)
626
+ image.z = _number_or_none(saved.get("z"))
627
+ image.visible = bool(saved.get("visible", True))
628
+ image.mirror_h = bool(saved.get("mirror_h", False))
629
+ image.mirror_v = bool(saved.get("mirror_v", False))
630
+ doc.images.append(image)
631
+ for name, value in (state.get("legend") or {}).items():
632
+ if hasattr(doc.legend, name):
633
+ setattr(doc.legend, name, value)
634
+ doc.legend.rotation = float(
635
+ _number_or_none((state.get("legend") or {}).get("rotation")) or 0.0)
636
+ doc.legend.z = _number_or_none((state.get("legend") or {}).get("z"))
637
+ if version < 5:
638
+ # The frame was on by default before version 5, so an older file's
639
+ # True was the default and not a choice; it follows the new one.
640
+ doc.legend.show_frame = False
641
+ doc.legend.size = _chosen((state.get("legend") or {}).get("size"),
642
+ "legend_size", version)
643
+ doc.x_axis = state.get("x_axis", doc.x_axis)
644
+ if state.get("x_unit") in units.TEMPERATURE_UNITS:
645
+ doc.x_unit = state["x_unit"]
646
+ doc.y_unit = state.get("y_unit", doc.y_unit)
647
+ if state.get("weight_unit") in model.WEIGHT_UNITS:
648
+ doc.weight_unit = state["weight_unit"]
649
+ if state.get("dtg_unit") in dtg.UNITS:
650
+ doc.dtg_unit = state["dtg_unit"]
651
+ doc.theme = state.get("theme", doc.theme)
652
+ background = state.get("background")
653
+ # Version 7 placed decorators in the home frame (they followed the
654
+ # zoom, the only way then); 8 says which. The window converts a
655
+ # version-7 figure's places to the page's once it is on screen.
656
+ doc.follow_zoom = bool(state.get("follow_zoom", False))
657
+ doc.background = (str(background) if isinstance(background, str)
658
+ and background.startswith("#") else None)
659
+ doc.offset_markers = bool(state.get("offset_markers", False))
660
+ doc.view = _view_from(state.get("view"))
661
+ if doc.view and doc.view.get("y2") and not doc.view.get("y2_unit"):
662
+ # Written with the range and not its unit: it was the weight unit
663
+ # saved with it (a change of unit fits the range
664
+ # again, so a range is always in the unit in force).
665
+ doc.view["y2_unit"] = doc.weight_unit
666
+ doc.path = str(path)
667
+ return doc, problems