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/__init__.py
ADDED
dscpanel/__main__.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The entry point: `triplot`, `triplot-gui`, or `python -m dscpanel`.
|
|
2
|
+
|
|
3
|
+
Files named on the command line are opened, so the program can be the "open
|
|
4
|
+
with" target for a `.tri` and can be started from a data folder with
|
|
5
|
+
`triplot *.tri`.
|
|
6
|
+
|
|
7
|
+
Two subcommands sit beside that, and neither of them starts a window:
|
|
8
|
+
`register` puts the program in the Start Menu (and `--remove` takes it out
|
|
9
|
+
again), and `alias` gives it whatever name the user would rather type. Both
|
|
10
|
+
are opt-in and reversible; see `register.py` for why that matters.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from . import __version__, branding
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#: Words that mean "do not open a window". Checked BEFORE argparse, because a
|
|
20
|
+
#: subcommand and an optional list of file paths cannot both be positional:
|
|
21
|
+
#: `paths` with nargs="*" swallows the subcommand name and argparse then
|
|
22
|
+
#: rejects its arguments ("invalid choice: 'dscp'").
|
|
23
|
+
COMMANDS = ("register", "alias")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser():
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog=branding.EXE_NAME,
|
|
29
|
+
description="{} - {}".format(branding.APP_NAME, branding.DESCRIPTION),
|
|
30
|
+
epilog="subcommands: {} (each takes --help)".format(
|
|
31
|
+
", ".join(COMMANDS)))
|
|
32
|
+
parser.add_argument("paths", nargs="*",
|
|
33
|
+
help="TRIOS .tri or .txt files, or a saved session")
|
|
34
|
+
parser.add_argument("--version", action="version",
|
|
35
|
+
version="{} {}".format(branding.APP_NAME, __version__))
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_command_parser():
|
|
40
|
+
parser = argparse.ArgumentParser(prog=branding.EXE_NAME)
|
|
41
|
+
subs = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
reg = subs.add_parser(
|
|
44
|
+
"register", help="put {} in the Start Menu".format(branding.APP_NAME))
|
|
45
|
+
reg.add_argument("--remove", action="store_true",
|
|
46
|
+
help="take it out again")
|
|
47
|
+
reg.add_argument("--desktop", action="store_true",
|
|
48
|
+
help="a desktop shortcut as well (Windows)")
|
|
49
|
+
reg.add_argument("--clean-legacy", action="store_true",
|
|
50
|
+
help="remove what this program's older names left behind")
|
|
51
|
+
reg.add_argument("--list", action="store_true",
|
|
52
|
+
help="show what is registered")
|
|
53
|
+
reg.add_argument("--dry-run", action="store_true",
|
|
54
|
+
help="say what would happen and change nothing")
|
|
55
|
+
|
|
56
|
+
ali = subs.add_parser("alias",
|
|
57
|
+
help="start the program under another name too")
|
|
58
|
+
ali.add_argument("name", nargs="?", help="the name you would rather type")
|
|
59
|
+
ali.add_argument("--remove", action="store_true", help="take it away")
|
|
60
|
+
ali.add_argument("--list", action="store_true", help="show the aliases")
|
|
61
|
+
ali.add_argument("--dry-run", action="store_true")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _register(args):
|
|
66
|
+
from . import register
|
|
67
|
+
if args.list:
|
|
68
|
+
for line in register.describe():
|
|
69
|
+
print(line)
|
|
70
|
+
return 0
|
|
71
|
+
if args.clean_legacy:
|
|
72
|
+
removed = register.clean_legacy(dry_run=args.dry_run)
|
|
73
|
+
print("would remove:" if args.dry_run else "removed:",
|
|
74
|
+
len(removed), "item(s)")
|
|
75
|
+
for path in removed:
|
|
76
|
+
print(" ", path)
|
|
77
|
+
return 0
|
|
78
|
+
if args.remove:
|
|
79
|
+
removed = register.unregister(dry_run=args.dry_run)
|
|
80
|
+
print("would remove:" if args.dry_run else "removed:", len(removed))
|
|
81
|
+
for path in removed:
|
|
82
|
+
print(" ", path)
|
|
83
|
+
return 0
|
|
84
|
+
made = register.register(desktop=args.desktop, dry_run=args.dry_run)
|
|
85
|
+
print("would write:" if args.dry_run else "wrote:")
|
|
86
|
+
for kind, path in made:
|
|
87
|
+
print(" {:<14} {}".format(kind, path))
|
|
88
|
+
print("started by: {}".format(" ".join(register.launcher())))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _alias(args):
|
|
93
|
+
from . import register
|
|
94
|
+
if args.list or not args.name:
|
|
95
|
+
found = register.aliases()
|
|
96
|
+
print("\n".join(found) if found
|
|
97
|
+
else "no aliases (try: {} alias tp)".format(branding.EXE_NAME))
|
|
98
|
+
return 0
|
|
99
|
+
path = register.alias(args.name, remove=args.remove, dry_run=args.dry_run)
|
|
100
|
+
verb = "would remove" if (args.remove and args.dry_run) else (
|
|
101
|
+
"removed" if args.remove else
|
|
102
|
+
("would write" if args.dry_run else "wrote"))
|
|
103
|
+
print("{} {}".format(verb, path))
|
|
104
|
+
if not args.remove and not args.dry_run:
|
|
105
|
+
print("open a new terminal, then: {}".format(args.name))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv=None):
|
|
110
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
111
|
+
# The first start under a new name brings the old name's preferences,
|
|
112
|
+
# presets and registration manifest along (`branding.py`), BEFORE the
|
|
113
|
+
# log, the style or `register --clean-legacy` read that folder.
|
|
114
|
+
branding.adopt_legacy_dir()
|
|
115
|
+
if argv and argv[0] in COMMANDS:
|
|
116
|
+
args = build_command_parser().parse_args(argv)
|
|
117
|
+
return _register(args) if args.command == "register" else _alias(args)
|
|
118
|
+
args = build_parser().parse_args(argv)
|
|
119
|
+
# Imported HERE rather than at the top, so `--version`, `register` and
|
|
120
|
+
# `alias` do not pay for Qt - which is most of the start-up time.
|
|
121
|
+
from PySide6.QtWidgets import QApplication
|
|
122
|
+
from .core import log, style
|
|
123
|
+
from .ui.window import MainWindow
|
|
124
|
+
|
|
125
|
+
# The log first: an error from here on goes into it, and one inside a Qt
|
|
126
|
+
# slot no longer ends the program (`core/log.py`).
|
|
127
|
+
log.install()
|
|
128
|
+
|
|
129
|
+
# The user's house style (Edit > Settings). Loaded HERE and not by the
|
|
130
|
+
# window, so a window made by a test or a tool never reads the defaults
|
|
131
|
+
# of whoever happens to run it.
|
|
132
|
+
style.load_preferences()
|
|
133
|
+
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
134
|
+
app.setApplicationName(branding.APP_NAME)
|
|
135
|
+
app.setOrganizationName(branding.SETTINGS_ORG)
|
|
136
|
+
app.setApplicationDisplayName(branding.APP_NAME)
|
|
137
|
+
window = MainWindow()
|
|
138
|
+
log.on_error = window.show_error
|
|
139
|
+
# Where it was last time; maximized the first time.
|
|
140
|
+
if window.restore_layout():
|
|
141
|
+
window.show()
|
|
142
|
+
else:
|
|
143
|
+
window.showMaximized()
|
|
144
|
+
sessions = [p for p in args.paths
|
|
145
|
+
if str(p).lower().endswith(branding.SESSION_EXT)]
|
|
146
|
+
if sessions:
|
|
147
|
+
window.open_session(sessions[0])
|
|
148
|
+
others = [p for p in args.paths if p not in sessions]
|
|
149
|
+
if others:
|
|
150
|
+
window.open_files(others)
|
|
151
|
+
return app.exec()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
dscpanel/branding.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Every place the program says its own name, in ONE module.
|
|
2
|
+
|
|
3
|
+
The name was expected to change, and it did: "DSC-Panel" was the working
|
|
4
|
+
title until 1.1.0, the first release on PyPI, as Triplot. A
|
|
5
|
+
name that is spelled out in twenty places is a name nobody dares to change.
|
|
6
|
+
So:
|
|
7
|
+
|
|
8
|
+
* the window title, the Start Menu shortcut, the file-type id, the settings
|
|
9
|
+
folder and the manifest of what was registered all read from here;
|
|
10
|
+
* `LEGACY_NAMES` lists every name this program has been called before, so a
|
|
11
|
+
release under a new name can take the OLD name's shortcuts, file type and
|
|
12
|
+
aliases away. That is the whole deregistration plan: the new version knows
|
|
13
|
+
what the old one installed, because the old name is still written down.
|
|
14
|
+
|
|
15
|
+
What a rename costs, with this module in place:
|
|
16
|
+
|
|
17
|
+
1. change `APP_NAME`, `EXE_NAME`, `PROG_ID` and `SETTINGS_ORG`/`SETTINGS_APP`
|
|
18
|
+
here, and add the outgoing values to `LEGACY_NAMES`;
|
|
19
|
+
2. change the two entry-point lines in `pyproject.toml` (and the
|
|
20
|
+
distribution's name there, if it goes too);
|
|
21
|
+
3. uninstall the old distribution (this removes the old command), install
|
|
22
|
+
the new one;
|
|
23
|
+
4. run `<new command> register --clean-legacy`, which reads `LEGACY_NAMES`
|
|
24
|
+
and the registration manifest and removes what the old name left behind.
|
|
25
|
+
|
|
26
|
+
The user's folder (`app_dir`: preferences, presets, the manifest) is named
|
|
27
|
+
after the program too, so the first start under a new name copies the old
|
|
28
|
+
one's across (`adopt_legacy_dir`) - the house style, the presets and the
|
|
29
|
+
window's place survive, and so does the manifest the cleanup reads.
|
|
30
|
+
|
|
31
|
+
Nothing else in the program contains the name as a literal. A test enforces
|
|
32
|
+
that (`tests/test_branding.py`).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
#: What the program calls itself in the window title and the Start Menu.
|
|
39
|
+
APP_NAME = "Triplot"
|
|
40
|
+
|
|
41
|
+
#: The command, and the base name of every shortcut and shim that is created.
|
|
42
|
+
EXE_NAME = "triplot"
|
|
43
|
+
|
|
44
|
+
#: The windowed entry point, which a shortcut prefers so no console appears.
|
|
45
|
+
EXE_NAME_GUI = "triplot-gui"
|
|
46
|
+
|
|
47
|
+
#: The Explorer file-type id for the session file, under HKCU\Software\Classes.
|
|
48
|
+
PROG_ID = "Triplot.Session"
|
|
49
|
+
|
|
50
|
+
#: The session file's extension. Kept through the rename to Triplot: it names
|
|
51
|
+
#: what the file holds (a DSC figure), and every saved figure has it.
|
|
52
|
+
SESSION_EXT = ".dscpanel"
|
|
53
|
+
|
|
54
|
+
#: A style preset's extension (`core/presets.py`): JSON inside, named so a
|
|
55
|
+
#: dropped one is known for what it is.
|
|
56
|
+
PRESET_EXT = ".dscstyle"
|
|
57
|
+
|
|
58
|
+
#: QSettings coordinates. Changing these forgets the user's window geometry,
|
|
59
|
+
#: which is why they are written down rather than derived from APP_NAME.
|
|
60
|
+
SETTINGS_ORG = "ACH"
|
|
61
|
+
SETTINGS_APP = "Triplot"
|
|
62
|
+
|
|
63
|
+
#: Names this program has gone by. (name, exe, prog_id) for each, oldest
|
|
64
|
+
#: first. `register --clean-legacy` walks this list and removes anything it
|
|
65
|
+
#: finds: Start Menu shortcuts, desktop shortcuts, PATH shims and the file
|
|
66
|
+
#: association. Add the outgoing values here as part of a rename; never
|
|
67
|
+
#: remove an entry, because somebody's machine may still be carrying it.
|
|
68
|
+
LEGACY_NAMES = (
|
|
69
|
+
("DSC-Panel", "dsc-panel", "DscPanel.Session"), # until 1.0.0
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: A one-line description, for shortcut tooltips and the About box.
|
|
73
|
+
DESCRIPTION = "Stacked DSC scans from TRIOS files"
|
|
74
|
+
|
|
75
|
+
#: The program's public page, for the About box. It carries the name, so it
|
|
76
|
+
#: is written here and nowhere else (and changes with a rename).
|
|
77
|
+
HOMEPAGE = "https://pypi.org/project/triplot/"
|
|
78
|
+
|
|
79
|
+
#: The copyright line from LICENSE, for the About box.
|
|
80
|
+
COPYRIGHT = ("Christian Nelle (@p3rAsperaAdAstra), AG Henke, "
|
|
81
|
+
"TU Dortmund. MIT licence.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def app_dir(app_name=None, exe_name=None):
|
|
85
|
+
"""Where the registration manifest and any user data live.
|
|
86
|
+
|
|
87
|
+
Per user, never per install, so a reinstall does not lose track of the
|
|
88
|
+
shortcuts the last install made. With a name, the folder a program of
|
|
89
|
+
that name used (a `LEGACY_NAMES` entry's first two).
|
|
90
|
+
"""
|
|
91
|
+
app_name = app_name or APP_NAME
|
|
92
|
+
exe_name = exe_name or EXE_NAME
|
|
93
|
+
if sys.platform == "win32":
|
|
94
|
+
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
|
95
|
+
return os.path.join(base, app_name)
|
|
96
|
+
if sys.platform == "darwin":
|
|
97
|
+
return os.path.expanduser(
|
|
98
|
+
"~/Library/Application Support/" + app_name)
|
|
99
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
100
|
+
return os.path.join(base, exe_name)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def adopt_legacy_dir():
|
|
104
|
+
"""Copy the newest older name's folder to `app_dir()`, the first time.
|
|
105
|
+
|
|
106
|
+
Only when `app_dir()` does not exist yet: once it does, it is this
|
|
107
|
+
name's own and nothing is copied over it. The old folder stays where it
|
|
108
|
+
is (an older install may still be using it); its logs are not copied,
|
|
109
|
+
because a log is named after the program that wrote it. Returns the
|
|
110
|
+
folder copied from, or None. Never raises: a copy that fails leaves the
|
|
111
|
+
program starting with the built-in style, as on a new machine.
|
|
112
|
+
"""
|
|
113
|
+
import shutil
|
|
114
|
+
target = app_dir()
|
|
115
|
+
try:
|
|
116
|
+
if os.path.exists(target):
|
|
117
|
+
return None
|
|
118
|
+
for app_name, exe_name, _prog_id in reversed(LEGACY_NAMES):
|
|
119
|
+
source = app_dir(app_name, exe_name)
|
|
120
|
+
if os.path.isdir(source) and source != target:
|
|
121
|
+
shutil.copytree(source, target, ignore=shutil.ignore_patterns(
|
|
122
|
+
"*.log", "*.log.*"))
|
|
123
|
+
return source
|
|
124
|
+
except (OSError, shutil.Error):
|
|
125
|
+
return None
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def manifest_path():
|
|
130
|
+
"""The file listing what this program has registered on this machine.
|
|
131
|
+
|
|
132
|
+
Written by `register`, read by `register --remove` and
|
|
133
|
+
`register --clean-legacy`. It exists so that removal takes away exactly
|
|
134
|
+
what was added, rather than guessing at paths a future version might get
|
|
135
|
+
wrong.
|
|
136
|
+
"""
|
|
137
|
+
return os.path.join(app_dir(), "registration.json")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def window_title(subject=""):
|
|
141
|
+
"""`APP_NAME`, with the open session or file in front of it."""
|
|
142
|
+
return "{} - {}".format(subject, APP_NAME) if subject else APP_NAME
|
|
File without changes
|
dscpanel/core/arrange.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Arranging scans: the operators that move several at once.
|
|
2
|
+
|
|
3
|
+
UI-free, and every function returns CHANGES - `[(scan, "offset", value), ...]`
|
|
4
|
+
- rather than applying them. The window pushes that list through the undo
|
|
5
|
+
stack, so "distribute evenly" is one Ctrl+Z like every other action, and these
|
|
6
|
+
functions stay testable without a window.
|
|
7
|
+
|
|
8
|
+
The baseline alignment is the one piece of real arithmetic. It is the
|
|
9
|
+
`y_align` from the DSC_Plotter template, with the numerical machinery taken
|
|
10
|
+
out: minimising `sum w (ref - (curve + c))^2` over one number `c` has a
|
|
11
|
+
closed form, `c = weighted mean of (ref - curve)`, so the template's
|
|
12
|
+
`scipy.optimize.minimize_scalar` per curve is not needed and neither is
|
|
13
|
+
scipy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _monotonic(x, y):
|
|
20
|
+
"""`(x, y)` sorted and de-duplicated, ready for `np.interp`.
|
|
21
|
+
|
|
22
|
+
A DSC segment is not sorted in temperature - it reverses at the ends, and
|
|
23
|
+
a cooling scan runs backwards - so anything that interpolates has to sort
|
|
24
|
+
first. Duplicate x values are averaged rather than dropped, because
|
|
25
|
+
dropping one silently prefers whichever sample came first.
|
|
26
|
+
|
|
27
|
+
A flagged sample (NaN in either) is left out: `np.interp` over one gives
|
|
28
|
+
NaN, and a NaN offset is a curve drawn nowhere and saved so (review
|
|
29
|
+
F11).
|
|
30
|
+
"""
|
|
31
|
+
x = np.asarray(x, dtype=float)
|
|
32
|
+
y = np.asarray(y, dtype=float)
|
|
33
|
+
measured = np.isfinite(x) & np.isfinite(y)
|
|
34
|
+
x, y = x[measured], y[measured]
|
|
35
|
+
order = np.argsort(x, kind="stable")
|
|
36
|
+
x, y = x[order], y[order]
|
|
37
|
+
unique, index, inverse = np.unique(x, return_index=True,
|
|
38
|
+
return_inverse=True)
|
|
39
|
+
if len(unique) != len(x):
|
|
40
|
+
counts = np.bincount(inverse)
|
|
41
|
+
y = np.bincount(inverse, weights=y) / np.maximum(counts, 1)
|
|
42
|
+
x = unique
|
|
43
|
+
return x, y
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def align_to(reference, scans, curve_of, window=(0.0, 1.0), points=2000):
|
|
47
|
+
"""Offsets that lay each scan on top of `reference` where they overlap.
|
|
48
|
+
|
|
49
|
+
`curve_of(scan)` gives `(x, y)` as drawn. `window` is the part of the
|
|
50
|
+
common x range the curves should agree over, as fractions - the default
|
|
51
|
+
is all of it, and `(0, 0.4)` is the template's "match the start", which
|
|
52
|
+
is what you want when the interesting difference is at the end.
|
|
53
|
+
|
|
54
|
+
A scan with no overlap is left where it is rather than moved to a
|
|
55
|
+
meaningless place.
|
|
56
|
+
"""
|
|
57
|
+
ref_x, ref_y = curve_of(reference)
|
|
58
|
+
if ref_x is None or len(ref_x) < 2:
|
|
59
|
+
return []
|
|
60
|
+
ref_x, ref_y = _monotonic(ref_x, ref_y)
|
|
61
|
+
if len(ref_x) < 2:
|
|
62
|
+
return []
|
|
63
|
+
changes = []
|
|
64
|
+
for scan in scans:
|
|
65
|
+
if scan is reference:
|
|
66
|
+
continue
|
|
67
|
+
x, y = curve_of(scan)
|
|
68
|
+
if x is None or len(x) < 2:
|
|
69
|
+
continue
|
|
70
|
+
x, y = _monotonic(x, y)
|
|
71
|
+
if len(x) < 2:
|
|
72
|
+
continue
|
|
73
|
+
lo = max(float(ref_x[0]), float(x[0]))
|
|
74
|
+
hi = min(float(ref_x[-1]), float(x[-1]))
|
|
75
|
+
if hi <= lo:
|
|
76
|
+
continue
|
|
77
|
+
grid = np.linspace(lo, hi, int(points))
|
|
78
|
+
a = np.interp(grid, ref_x, ref_y)
|
|
79
|
+
b = np.interp(grid, x, y)
|
|
80
|
+
frac = (grid - lo) / max(hi - lo, 1e-12)
|
|
81
|
+
weight = np.where((frac >= window[0]) & (frac <= window[1]), 1.0, 0.1)
|
|
82
|
+
shift = float(np.sum(weight * (a - b)) / np.sum(weight))
|
|
83
|
+
changes.append((scan, "offset", float(scan.offset) + shift))
|
|
84
|
+
return changes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def stack(scans, step, first=0.0):
|
|
88
|
+
"""Give the scans offsets `first`, `first + step`, ... in list order.
|
|
89
|
+
|
|
90
|
+
The order is the one the caller hands over, which is the outliner's -
|
|
91
|
+
what you see top to bottom is what gets stacked top to bottom.
|
|
92
|
+
"""
|
|
93
|
+
changes = []
|
|
94
|
+
for i, scan in enumerate(scans):
|
|
95
|
+
changes.append((scan, "offset", float(first) + i * float(step)))
|
|
96
|
+
return changes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def distribute(scans):
|
|
100
|
+
"""Even spacing between the topmost and bottommost scan, order kept.
|
|
101
|
+
|
|
102
|
+
The two ends stay where they are - somebody put them there - and
|
|
103
|
+
everything between them is spread evenly, which is the gesture you want
|
|
104
|
+
after dragging eight scans roughly into place.
|
|
105
|
+
"""
|
|
106
|
+
scans = [s for s in scans]
|
|
107
|
+
if len(scans) < 3:
|
|
108
|
+
return []
|
|
109
|
+
ordered = sorted(scans, key=lambda s: s.offset)
|
|
110
|
+
lo = float(ordered[0].offset)
|
|
111
|
+
hi = float(ordered[-1].offset)
|
|
112
|
+
if hi == lo:
|
|
113
|
+
return []
|
|
114
|
+
step = (hi - lo) / (len(ordered) - 1)
|
|
115
|
+
changes = []
|
|
116
|
+
for i, scan in enumerate(ordered):
|
|
117
|
+
value = lo + i * step
|
|
118
|
+
if abs(value - scan.offset) > 1e-12:
|
|
119
|
+
changes.append((scan, "offset", value))
|
|
120
|
+
return changes
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def suggested_step(scans, curve_of, fraction=0.6):
|
|
124
|
+
"""A sensible vertical spacing for a stack, in the current unit.
|
|
125
|
+
|
|
126
|
+
The tallest scan's own span times `fraction`: enough that curves do not
|
|
127
|
+
sit on top of one another, not so much that the stack runs off the plot.
|
|
128
|
+
Measured rather than guessed, because a spacing that is right in W/g is
|
|
129
|
+
wrong by a factor of a thousand in mW.
|
|
130
|
+
"""
|
|
131
|
+
spans = []
|
|
132
|
+
for scan in scans:
|
|
133
|
+
x, y = curve_of(scan)
|
|
134
|
+
if y is None or not len(y):
|
|
135
|
+
continue
|
|
136
|
+
y = np.asarray(y, dtype=float)
|
|
137
|
+
y = y[np.isfinite(y)]
|
|
138
|
+
if not len(y):
|
|
139
|
+
continue
|
|
140
|
+
spans.append(float(np.max(y) - np.min(y)))
|
|
141
|
+
if not spans:
|
|
142
|
+
return 0.0
|
|
143
|
+
return max(spans) * float(fraction)
|