gratinglab 0.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.
- gratinglab/__init__.py +35 -0
- gratinglab/checks.py +263 -0
- gratinglab/compare.py +233 -0
- gratinglab/convergence.py +455 -0
- gratinglab/geometry.py +483 -0
- gratinglab/gui/__init__.py +0 -0
- gratinglab/gui/app.py +80 -0
- gratinglab/gui/diagram.py +699 -0
- gratinglab/gui/diagram3d.py +609 -0
- gratinglab/gui/docs.py +189 -0
- gratinglab/gui/icon.py +101 -0
- gratinglab/gui/integral_options.py +219 -0
- gratinglab/gui/mathtext.py +209 -0
- gratinglab/gui/orders.py +152 -0
- gratinglab/gui/provenance.py +273 -0
- gratinglab/gui/qt/__init__.py +20 -0
- gratinglab/gui/qt/geometry_panel.py +239 -0
- gratinglab/gui/qt/geometry_tab.py +387 -0
- gratinglab/gui/qt/integral_tab.py +447 -0
- gratinglab/gui/qt/main_window.py +514 -0
- gratinglab/gui/qt/profile_plot_panel.py +67 -0
- gratinglab/gui/qt/scalar_tab.py +498 -0
- gratinglab/gui/qt/scene3d_panel.py +347 -0
- gratinglab/gui/qt/setup_tab.py +215 -0
- gratinglab/gui/qt/sizing.py +25 -0
- gratinglab/gui/qt/theory_viewer.py +89 -0
- gratinglab/gui/qt/worker.py +203 -0
- gratinglab/gui/richtext.py +463 -0
- gratinglab/gui/scalar_options.py +139 -0
- gratinglab/gui/state.py +379 -0
- gratinglab/illumination.py +212 -0
- gratinglab/io/__init__.py +0 -0
- gratinglab/io/efficiency_table.py +382 -0
- gratinglab/io/ggp.py +248 -0
- gratinglab/materials/__init__.py +218 -0
- gratinglab/materials/data/SOURCES.md +53 -0
- gratinglab/materials/fresnel.py +282 -0
- gratinglab/materials/optical.py +319 -0
- gratinglab/materials/refractiveindex.py +306 -0
- gratinglab/metrology/__init__.py +121 -0
- gratinglab/metrology/analyzer.py +875 -0
- gratinglab/metrology/boundary/__init__.py +27 -0
- gratinglab/metrology/boundary/average.py +143 -0
- gratinglab/metrology/boundary/pipeline.py +211 -0
- gratinglab/metrology/cli.py +52 -0
- gratinglab/metrology/config.py +170 -0
- gratinglab/metrology/core/__init__.py +23 -0
- gratinglab/metrology/core/analysis.py +315 -0
- gratinglab/metrology/core/image_flatten.py +131 -0
- gratinglab/metrology/core/processing.py +344 -0
- gratinglab/metrology/core/tip.py +233 -0
- gratinglab/metrology/data/__init__.py +16 -0
- gratinglab/metrology/data/aggregation.py +193 -0
- gratinglab/metrology/gui/__init__.py +56 -0
- gratinglab/metrology/gui/icon.py +136 -0
- gratinglab/metrology/gui/qt/__init__.py +19 -0
- gratinglab/metrology/gui/qt/app.py +28 -0
- gratinglab/metrology/gui/qt/boundary_view.py +324 -0
- gratinglab/metrology/gui/qt/canvas.py +151 -0
- gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
- gratinglab/metrology/gui/qt/import_view.py +380 -0
- gratinglab/metrology/gui/qt/main_window.py +398 -0
- gratinglab/metrology/gui/qt/wiki_view.py +99 -0
- gratinglab/metrology/gui/qt/worker.py +59 -0
- gratinglab/metrology/gui/state.py +168 -0
- gratinglab/metrology/io/__init__.py +8 -0
- gratinglab/metrology/io/file_io.py +253 -0
- gratinglab/metrology/io/metrics.py +38 -0
- gratinglab/metrology/io/spm.py +210 -0
- gratinglab/metrology/settings.py +216 -0
- gratinglab/metrology/stats/__init__.py +16 -0
- gratinglab/metrology/stats/analysis.py +329 -0
- gratinglab/metrology/stats/icc.py +128 -0
- gratinglab/metrology/visualization/__init__.py +16 -0
- gratinglab/metrology/visualization/diagnostics.py +111 -0
- gratinglab/metrology/visualization/profiles.py +234 -0
- gratinglab/metrology/visualization/statistics.py +412 -0
- gratinglab/metrology/wiki/__init__.py +91 -0
- gratinglab/metrology/wiki/facet-fitting.md +95 -0
- gratinglab/metrology/wiki/file-formats.md +116 -0
- gratinglab/metrology/wiki/flattening.md +105 -0
- gratinglab/metrology/wiki/icc-correction.md +111 -0
- gratinglab/metrology/wiki/overview.md +51 -0
- gratinglab/metrology/wiki/reading-outputs.md +154 -0
- gratinglab/metrology/wiki/row-groups.md +75 -0
- gratinglab/metrology/wiki/tip-correction.md +101 -0
- gratinglab/metrology/workflows.py +383 -0
- gratinglab/problem.py +79 -0
- gratinglab/profiles.py +531 -0
- gratinglab/resolution.py +198 -0
- gratinglab/result.py +268 -0
- gratinglab/solvers/__init__.py +34 -0
- gratinglab/solvers/base.py +207 -0
- gratinglab/solvers/integral/__init__.py +406 -0
- gratinglab/solvers/integral/_boundary.py +50 -0
- gratinglab/solvers/integral/_core.py +126 -0
- gratinglab/solvers/integral/_finite.py +396 -0
- gratinglab/solvers/integral/_greens.py +234 -0
- gratinglab/solvers/integral/_kernels.py +308 -0
- gratinglab/solvers/integral/_nystrom.py +474 -0
- gratinglab/solvers/scalar.py +1144 -0
- gratinglab-0.1.0.dist-info/METADATA +319 -0
- gratinglab-0.1.0.dist-info/RECORD +107 -0
- gratinglab-0.1.0.dist-info/WHEEL +5 -0
- gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
- gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
- gratinglab-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""The grating geometry: period, profile, mount, wavelengths.
|
|
2
|
+
|
|
3
|
+
Extracted from `main_window.py`'s original `_build_inputs`, minus scalar's own
|
|
4
|
+
"Scalar solver" groupbox and its Solve/Cancel/Export controls -- those are
|
|
5
|
+
solver-specific and stay wherever a solver's own tab lives (`ScalarTab`,
|
|
6
|
+
eventually alongside whatever RCWA's tab turns out to need). This panel is
|
|
7
|
+
shared: it is built once, not once per solver, because a `Problem` and an
|
|
8
|
+
`Illumination` mean the same thing regardless of which solver is about to
|
|
9
|
+
consume them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import Signal
|
|
18
|
+
from PySide6.QtWidgets import (
|
|
19
|
+
QComboBox,
|
|
20
|
+
QFileDialog,
|
|
21
|
+
QFormLayout,
|
|
22
|
+
QGroupBox,
|
|
23
|
+
QLabel,
|
|
24
|
+
QLineEdit,
|
|
25
|
+
QPushButton,
|
|
26
|
+
QVBoxLayout,
|
|
27
|
+
QWidget,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from ..state import ANGLE_LABELS, MOUNTS, PROFILE_FIELDS, PROFILE_KINDS, FormState
|
|
31
|
+
|
|
32
|
+
__all__ = ["GeometryPanel"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GeometryPanel(QWidget):
|
|
36
|
+
"""Grating, mount and wavelength inputs -- the persistent left sidebar.
|
|
37
|
+
|
|
38
|
+
Not wrapped in its own scroll area: how this panel is embedded (today,
|
|
39
|
+
stacked above a solver's own controls under one shared scroller) is the
|
|
40
|
+
caller's decision, not this widget's.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
#: Pressing Enter in a field, or finishing a "Load profile…" pick. This
|
|
44
|
+
#: panel does not know what a solve is, only that something changed
|
|
45
|
+
#: enough that one might be wanted; `MainWindow` connects it to `solve()`.
|
|
46
|
+
solve_requested = Signal()
|
|
47
|
+
|
|
48
|
+
#: The form was edited *by a user*. Deliberately distinct from
|
|
49
|
+
#: `solve_requested`: Enter still means solve, and this means only that
|
|
50
|
+
#: the geometry drawing is now stale. `MainWindow` debounces it and
|
|
51
|
+
#: redraws; nothing here reaches a solver.
|
|
52
|
+
#:
|
|
53
|
+
#: Wired to `textEdited`/`activated`, never `textChanged`/
|
|
54
|
+
#: `currentTextChanged`, because those also fire on programmatic
|
|
55
|
+
#: `setText`/`setCurrentText` -- which is how every test and
|
|
56
|
+
#: `_on_mount_change` set fields. Only a real edit should schedule work.
|
|
57
|
+
changed = Signal()
|
|
58
|
+
|
|
59
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
60
|
+
super().__init__(parent)
|
|
61
|
+
self._fields: dict[str, QWidget] = {}
|
|
62
|
+
self._build()
|
|
63
|
+
self._on_mount_change()
|
|
64
|
+
self._on_profile_change()
|
|
65
|
+
self._check_fields_match_formstate()
|
|
66
|
+
|
|
67
|
+
def _build(self) -> None:
|
|
68
|
+
defaults = FormState()
|
|
69
|
+
column = QVBoxLayout(self)
|
|
70
|
+
column.setContentsMargins(0, 0, 0, 0)
|
|
71
|
+
|
|
72
|
+
def entry(form: QFormLayout, label: str, key: str) -> QLineEdit:
|
|
73
|
+
widget = QLineEdit(getattr(defaults, key))
|
|
74
|
+
widget.returnPressed.connect(self.solve_requested)
|
|
75
|
+
widget.textEdited.connect(self.changed)
|
|
76
|
+
self._fields[key] = widget
|
|
77
|
+
form.addRow(label, widget)
|
|
78
|
+
return widget
|
|
79
|
+
|
|
80
|
+
def combo(form: QFormLayout, label: str, key: str, values, on_change) -> QComboBox:
|
|
81
|
+
widget = QComboBox()
|
|
82
|
+
widget.addItems(list(values))
|
|
83
|
+
widget.setCurrentText(getattr(defaults, key))
|
|
84
|
+
widget.currentTextChanged.connect(lambda _t: on_change())
|
|
85
|
+
widget.activated.connect(lambda _i: self.changed.emit())
|
|
86
|
+
self._fields[key] = widget
|
|
87
|
+
form.addRow(label, widget)
|
|
88
|
+
return widget
|
|
89
|
+
|
|
90
|
+
grating = QGroupBox("Grating")
|
|
91
|
+
grating_form = self._grating_form = QFormLayout(grating)
|
|
92
|
+
entry(grating_form, "Period (nm)", "period")
|
|
93
|
+
combo(grating_form, "Profile", "profile_kind", PROFILE_KINDS,
|
|
94
|
+
self._on_profile_change)
|
|
95
|
+
|
|
96
|
+
self._profile_rows: dict[str, QWidget] = {}
|
|
97
|
+
for key, label in (
|
|
98
|
+
("blaze_angle", "Blaze δ (deg)"),
|
|
99
|
+
("antiblaze_angle", "Anti-blaze (deg)"),
|
|
100
|
+
("depth_fraction", "Depth / period"),
|
|
101
|
+
("duty_cycle", "Duty cycle"),
|
|
102
|
+
):
|
|
103
|
+
self._profile_rows[key] = entry(grating_form, label, key)
|
|
104
|
+
|
|
105
|
+
# Not a visible row: the path is set by the file dialog, and shown by
|
|
106
|
+
# the label beneath it. It still has to be a field, because
|
|
107
|
+
# read_form() builds FormState from exactly this dict.
|
|
108
|
+
path_field = QLineEdit("")
|
|
109
|
+
path_field.hide()
|
|
110
|
+
self._fields["profile_path"] = path_field
|
|
111
|
+
|
|
112
|
+
self._path_label = QLabel("")
|
|
113
|
+
self._path_label.setWordWrap(True)
|
|
114
|
+
self._path_label.setStyleSheet("color: #555")
|
|
115
|
+
self._load_button = QPushButton("Load profile…")
|
|
116
|
+
self._load_button.clicked.connect(self.load_profile)
|
|
117
|
+
grating_form.addRow(self._load_button)
|
|
118
|
+
grating_form.addRow(self._path_label)
|
|
119
|
+
|
|
120
|
+
mount = QGroupBox("Mount")
|
|
121
|
+
mount_form = QFormLayout(mount)
|
|
122
|
+
combo(mount_form, "Geometry", "mount", MOUNTS, self._on_mount_change)
|
|
123
|
+
self._angle_labels: dict[str, QLabel] = {}
|
|
124
|
+
for key in ("alpha", "gamma"):
|
|
125
|
+
widget = QLineEdit(getattr(defaults, key))
|
|
126
|
+
widget.returnPressed.connect(self.solve_requested)
|
|
127
|
+
widget.textEdited.connect(self.changed)
|
|
128
|
+
self._fields[key] = widget
|
|
129
|
+
label = QLabel("")
|
|
130
|
+
mount_form.addRow(label, widget)
|
|
131
|
+
self._angle_labels[key] = label
|
|
132
|
+
|
|
133
|
+
surface = QGroupBox("Surface")
|
|
134
|
+
surface_form = QFormLayout(surface)
|
|
135
|
+
# A dropdown, not a text field: the set of materials is finite and
|
|
136
|
+
# known, and a free-form string is what let `coating` mean nothing for
|
|
137
|
+
# four milestones. Empty entry first, because no coating is the
|
|
138
|
+
# ordinary default and gives relative efficiency -- a correct answer to
|
|
139
|
+
# a different question, not a missing input.
|
|
140
|
+
from ...materials import available
|
|
141
|
+
|
|
142
|
+
coating = QComboBox()
|
|
143
|
+
coating.addItem("(none)", userData="")
|
|
144
|
+
coating.setToolTip(
|
|
145
|
+
"No coating gives relative efficiency -- a correct answer to a "
|
|
146
|
+
"different question, not a missing input. Naming a material "
|
|
147
|
+
"makes the result absolute: the Scalar tab applies its Fresnel "
|
|
148
|
+
"reflectivity, and the Integral tab reads its complex index when "
|
|
149
|
+
'Conductivity is "tabulated" (and ignores it when "perfect"). '
|
|
150
|
+
"The provenance panel says which you got."
|
|
151
|
+
)
|
|
152
|
+
for name in available():
|
|
153
|
+
coating.addItem(name, userData=name)
|
|
154
|
+
coating.activated.connect(lambda _i: self.changed.emit())
|
|
155
|
+
self._fields["coating"] = coating
|
|
156
|
+
surface_form.addRow("Coating", coating)
|
|
157
|
+
|
|
158
|
+
entry(surface_form, "Roughness σ (nm)", "roughness")
|
|
159
|
+
|
|
160
|
+
scan = QGroupBox("Wavelengths (nm)")
|
|
161
|
+
scan_form = QFormLayout(scan)
|
|
162
|
+
entry(scan_form, "Start", "wavelength_start")
|
|
163
|
+
entry(scan_form, "Stop", "wavelength_stop")
|
|
164
|
+
entry(scan_form, "Points", "wavelength_count")
|
|
165
|
+
|
|
166
|
+
for group in (grating, mount, surface, scan):
|
|
167
|
+
column.addWidget(group)
|
|
168
|
+
column.addStretch(1)
|
|
169
|
+
|
|
170
|
+
def _check_fields_match_formstate(self) -> None:
|
|
171
|
+
"""Refuse to open rather than fail later on one code path.
|
|
172
|
+
|
|
173
|
+
`read_form` builds a `FormState` by keyword from this dict, so a
|
|
174
|
+
renamed or forgotten field is a TypeError at solve time -- visible
|
|
175
|
+
only to whoever presses Solve. Checking here makes it immediate and
|
|
176
|
+
says which field.
|
|
177
|
+
"""
|
|
178
|
+
declared = {f.name for f in dataclasses.fields(FormState)}
|
|
179
|
+
if self._fields.keys() != declared:
|
|
180
|
+
raise AssertionError(
|
|
181
|
+
"geometry fields do not match FormState: "
|
|
182
|
+
f"missing {sorted(declared - self._fields.keys())}, "
|
|
183
|
+
f"extra {sorted(self._fields.keys() - declared)}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _on_profile_change(self) -> None:
|
|
187
|
+
"""Show only the parameters the selected profile actually uses."""
|
|
188
|
+
needed = PROFILE_FIELDS.get(
|
|
189
|
+
self._fields["profile_kind"].currentText(), frozenset()
|
|
190
|
+
)
|
|
191
|
+
for key, widget in self._profile_rows.items():
|
|
192
|
+
# setRowVisible hides the label with the field. Hiding the field
|
|
193
|
+
# alone would leave a caption for a control that is not there.
|
|
194
|
+
self._grating_form.setRowVisible(widget, key in needed)
|
|
195
|
+
from_file = "profile_path" in needed
|
|
196
|
+
self._load_button.setVisible(from_file)
|
|
197
|
+
self._path_label.setVisible(from_file)
|
|
198
|
+
|
|
199
|
+
def _on_mount_change(self) -> None:
|
|
200
|
+
"""Relabel the two angle fields; the mount decides what they mean."""
|
|
201
|
+
primary, secondary = ANGLE_LABELS[self._fields["mount"].currentText()]
|
|
202
|
+
self._angle_labels["alpha"].setText(primary)
|
|
203
|
+
self._angle_labels["alpha"].show()
|
|
204
|
+
self._fields["alpha"].show()
|
|
205
|
+
|
|
206
|
+
if secondary is None:
|
|
207
|
+
self._angle_labels["gamma"].hide()
|
|
208
|
+
self._fields["gamma"].hide()
|
|
209
|
+
else:
|
|
210
|
+
self._angle_labels["gamma"].setText(secondary)
|
|
211
|
+
self._angle_labels["gamma"].show()
|
|
212
|
+
self._fields["gamma"].show()
|
|
213
|
+
|
|
214
|
+
def read_form(self) -> FormState:
|
|
215
|
+
return FormState(**{k: _value(w) for k, w in self._fields.items()})
|
|
216
|
+
|
|
217
|
+
def load_profile(self) -> None:
|
|
218
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
219
|
+
self, "Load boundary profile", "", "PCGrate boundary (*.ggp);;All files (*)"
|
|
220
|
+
)
|
|
221
|
+
if path:
|
|
222
|
+
self._fields["profile_path"].setText(path)
|
|
223
|
+
self._path_label.setText(Path(path).name)
|
|
224
|
+
self.changed.emit()
|
|
225
|
+
self.solve_requested.emit()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _value(widget: QWidget) -> str:
|
|
229
|
+
"""The *value* of an input, whichever kind it is.
|
|
230
|
+
|
|
231
|
+
A combo's value is its `userData` when it has one, not its label. The
|
|
232
|
+
coating combo shows "(none — relative efficiency)" for the empty choice,
|
|
233
|
+
and `FormState` wants "" -- putting the label in would make the empty
|
|
234
|
+
coating an unknown material name.
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(widget, QComboBox):
|
|
237
|
+
data = widget.currentData()
|
|
238
|
+
return widget.currentText() if data is None else str(data)
|
|
239
|
+
return widget.text()
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""The Grating Geometry tab: what the grating and the beams actually look like.
|
|
2
|
+
|
|
3
|
+
Thin, like every widget here. :mod:`gratinglab.gui.diagram` decides what to
|
|
4
|
+
draw -- every ray direction, every cutoff, every colour tag, every caption --
|
|
5
|
+
and this class only positions the result. It computes no angle, makes no
|
|
6
|
+
comparison against 1.0, and picks no colour, so a bug in this file misplaces a
|
|
7
|
+
line rather than drawing wrong physics.
|
|
8
|
+
|
|
9
|
+
Not a solver tab: no ``name`` attribute, no ``solve_requested``, no
|
|
10
|
+
``build_options``. `MainWindow._solve_active_tab` reads ``getattr(widget,
|
|
11
|
+
"name", None)`` and therefore skips it, the same way it skips
|
|
12
|
+
:class:`~.setup_tab.SetupTab`. Nothing here needs a solver -- a groove's shape
|
|
13
|
+
and the directions light leaves in are geometry, and geometry is already known
|
|
14
|
+
the moment the form parses.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import Qt
|
|
20
|
+
from PySide6.QtWidgets import (
|
|
21
|
+
QGroupBox,
|
|
22
|
+
QHBoxLayout,
|
|
23
|
+
QLabel,
|
|
24
|
+
QPushButton,
|
|
25
|
+
QSlider,
|
|
26
|
+
QSplitter,
|
|
27
|
+
QTextEdit,
|
|
28
|
+
QVBoxLayout,
|
|
29
|
+
QWidget,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from .. import diagram as diagram_module
|
|
33
|
+
from .. import diagram3d as diagram3d_module
|
|
34
|
+
from .. import provenance
|
|
35
|
+
from .profile_plot_panel import ProfilePlotPanel
|
|
36
|
+
from .scene3d_panel import Scene3DPanel
|
|
37
|
+
|
|
38
|
+
__all__ = ["GeometryTab"]
|
|
39
|
+
|
|
40
|
+
#: Named glyph -> matplotlib marker. The diagram module names what a point
|
|
41
|
+
#: *is*; this is the only place that decides what it looks like.
|
|
42
|
+
_GLYPHS = {
|
|
43
|
+
"out-of-page": "o",
|
|
44
|
+
"strike": "o",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_PANEL_TITLES = {
|
|
48
|
+
"main": "Down the groove axis ĝ",
|
|
49
|
+
"ladder": "sin β — every order, propagating or not",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GeometryTab(QWidget):
|
|
54
|
+
"""The grating, the beams, and what the drawing cannot show."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
57
|
+
super().__init__(parent)
|
|
58
|
+
self._parsed = None
|
|
59
|
+
self._diagram = None
|
|
60
|
+
self._scene = None
|
|
61
|
+
self._needs_paint = False
|
|
62
|
+
self._build()
|
|
63
|
+
|
|
64
|
+
def showEvent(self, event) -> None: # noqa: N802 - Qt's spelling
|
|
65
|
+
"""Paint on becoming visible, if a redraw happened while hidden.
|
|
66
|
+
|
|
67
|
+
See `_redraw`: the figure is the expensive part and there is no point
|
|
68
|
+
rendering it into a tab nobody is looking at.
|
|
69
|
+
"""
|
|
70
|
+
super().showEvent(event)
|
|
71
|
+
if self._needs_paint and self._diagram is not None:
|
|
72
|
+
self._paint(self._diagram)
|
|
73
|
+
self._needs_paint = False
|
|
74
|
+
|
|
75
|
+
# -- construction ------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _build(self) -> None:
|
|
78
|
+
"""Hero on the left, supporting panels on the right, controls beneath.
|
|
79
|
+
|
|
80
|
+
Controls sit in a bottom *strip* rather than the right *column* they
|
|
81
|
+
had before. Once the profile plot moved in here the tab became
|
|
82
|
+
width-constrained rather than height-constrained, and a 320 px column
|
|
83
|
+
was spending the scarce dimension; a ~170 px strip spends the one we
|
|
84
|
+
now have to spare, handing that width back to the plots.
|
|
85
|
+
"""
|
|
86
|
+
hero = self._build_hero()
|
|
87
|
+
self.profile_panel = ProfilePlotPanel()
|
|
88
|
+
|
|
89
|
+
side = QSplitter(Qt.Orientation.Vertical)
|
|
90
|
+
side.addWidget(self._build_plots())
|
|
91
|
+
side.addWidget(self.profile_panel)
|
|
92
|
+
side.setStretchFactor(0, 1)
|
|
93
|
+
side.setStretchFactor(1, 1)
|
|
94
|
+
side.setSizes([360, 229])
|
|
95
|
+
side.setMinimumWidth(280)
|
|
96
|
+
self.profile_panel.setMinimumHeight(150)
|
|
97
|
+
|
|
98
|
+
body = QSplitter(Qt.Orientation.Horizontal)
|
|
99
|
+
body.addWidget(hero)
|
|
100
|
+
body.addWidget(side)
|
|
101
|
+
body.setStretchFactor(0, 2)
|
|
102
|
+
body.setStretchFactor(1, 1)
|
|
103
|
+
body.setSizes([578, 290])
|
|
104
|
+
|
|
105
|
+
outer = QSplitter(Qt.Orientation.Vertical)
|
|
106
|
+
outer.addWidget(body)
|
|
107
|
+
outer.addWidget(self._build_controls())
|
|
108
|
+
outer.setStretchFactor(0, 1)
|
|
109
|
+
outer.setStretchFactor(1, 0)
|
|
110
|
+
outer.setSizes([593, 170])
|
|
111
|
+
|
|
112
|
+
layout = QVBoxLayout(self)
|
|
113
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
114
|
+
layout.addWidget(outer)
|
|
115
|
+
|
|
116
|
+
def _build_hero(self) -> QWidget:
|
|
117
|
+
"""The 3D conical view -- the one thing a dispersion-plane drawing
|
|
118
|
+
cannot show."""
|
|
119
|
+
self._scene3d = Scene3DPanel()
|
|
120
|
+
self._scene3d.setMinimumWidth(380)
|
|
121
|
+
self._scene3d.setMinimumHeight(320)
|
|
122
|
+
return self._scene3d
|
|
123
|
+
|
|
124
|
+
def _build_plots(self) -> QWidget:
|
|
125
|
+
"""The 2D panels, beside the 3D one: the dispersion-plane
|
|
126
|
+
cross-section (where the groove facets are legible) and the sin β
|
|
127
|
+
ladder (where an evanescent order still appears)."""
|
|
128
|
+
from matplotlib.backends.backend_qtagg import (
|
|
129
|
+
FigureCanvasQTAgg,
|
|
130
|
+
NavigationToolbar2QT,
|
|
131
|
+
)
|
|
132
|
+
from matplotlib.figure import Figure
|
|
133
|
+
|
|
134
|
+
# Two panels since M13-I: the γ sliver retired when the 3D view began
|
|
135
|
+
# drawing the cone itself. Its row goes to the cross-section, which is
|
|
136
|
+
# the one that has angles to be read.
|
|
137
|
+
self._figure = Figure(figsize=(4, 6), layout="constrained")
|
|
138
|
+
grid = self._figure.add_gridspec(2, 1, height_ratios=[3.0, 1.0])
|
|
139
|
+
self._axes = {
|
|
140
|
+
"main": self._figure.add_subplot(grid[0, 0]),
|
|
141
|
+
"ladder": self._figure.add_subplot(grid[1, 0]),
|
|
142
|
+
}
|
|
143
|
+
self._canvas = FigureCanvasQTAgg(self._figure)
|
|
144
|
+
|
|
145
|
+
panel = QWidget()
|
|
146
|
+
panel.setMinimumHeight(200)
|
|
147
|
+
layout = QVBoxLayout(panel)
|
|
148
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
149
|
+
layout.addWidget(NavigationToolbar2QT(self._canvas, panel))
|
|
150
|
+
layout.addWidget(self._canvas)
|
|
151
|
+
return panel
|
|
152
|
+
|
|
153
|
+
def _build_controls(self) -> QWidget:
|
|
154
|
+
strip = QWidget()
|
|
155
|
+
strip.setMinimumHeight(150)
|
|
156
|
+
strip.setMaximumHeight(240)
|
|
157
|
+
row = QHBoxLayout(strip)
|
|
158
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
159
|
+
row.addWidget(self._build_wavelength_group(), 2)
|
|
160
|
+
row.addWidget(self._build_caption_group(), 3)
|
|
161
|
+
return strip
|
|
162
|
+
|
|
163
|
+
def _build_wavelength_group(self) -> QWidget:
|
|
164
|
+
group = QGroupBox("Wavelength")
|
|
165
|
+
layout = QVBoxLayout(group)
|
|
166
|
+
|
|
167
|
+
self._wavelength_label = QLabel("—")
|
|
168
|
+
layout.addWidget(self._wavelength_label)
|
|
169
|
+
|
|
170
|
+
self._slider = QSlider(Qt.Orientation.Horizontal)
|
|
171
|
+
self._slider.setMinimum(0)
|
|
172
|
+
self._slider.setMaximum(0)
|
|
173
|
+
self._slider.valueChanged.connect(self._on_slider_moved)
|
|
174
|
+
layout.addWidget(self._slider)
|
|
175
|
+
|
|
176
|
+
self._blaze_button = QPushButton("Jump to blaze λ")
|
|
177
|
+
self._blaze_button.clicked.connect(self._jump_to_blaze)
|
|
178
|
+
row = QHBoxLayout()
|
|
179
|
+
row.addWidget(self._blaze_button)
|
|
180
|
+
layout.addLayout(row)
|
|
181
|
+
return group
|
|
182
|
+
|
|
183
|
+
def _build_caption_group(self) -> QWidget:
|
|
184
|
+
group = QGroupBox("What this shows")
|
|
185
|
+
layout = QVBoxLayout(group)
|
|
186
|
+
self._captions = QTextEdit()
|
|
187
|
+
self._captions.setReadOnly(True)
|
|
188
|
+
layout.addWidget(self._captions)
|
|
189
|
+
return group
|
|
190
|
+
|
|
191
|
+
# -- the contract MainWindow uses --------------------------------
|
|
192
|
+
|
|
193
|
+
def show_geometry(self, parsed) -> None:
|
|
194
|
+
"""Draw a parsed geometry. Needs no solver and no solve."""
|
|
195
|
+
self._parsed = parsed
|
|
196
|
+
count = len(parsed.wavelengths)
|
|
197
|
+
|
|
198
|
+
# Keep the slider's position across a redraw where the grid is the
|
|
199
|
+
# same size; a wavelength that jumped on every keystroke would make
|
|
200
|
+
# the picture unreadable while typing.
|
|
201
|
+
index = self._slider.value() if self._slider.maximum() == count - 1 else count // 2
|
|
202
|
+
|
|
203
|
+
self._slider.blockSignals(True)
|
|
204
|
+
self._slider.setMaximum(max(0, count - 1))
|
|
205
|
+
self._slider.setValue(min(index, count - 1))
|
|
206
|
+
self._slider.blockSignals(False)
|
|
207
|
+
|
|
208
|
+
self._sync_blaze_button()
|
|
209
|
+
self.profile_panel.draw(parsed)
|
|
210
|
+
self._redraw()
|
|
211
|
+
|
|
212
|
+
def show_pending(self, errors) -> None:
|
|
213
|
+
"""The form does not parse yet. Keep the last good drawing.
|
|
214
|
+
|
|
215
|
+
A form mid-edit is not a form with an error: ``"2."`` and ``"-"`` each
|
|
216
|
+
fail to parse for exactly one keystroke, and blanking the canvas or
|
|
217
|
+
reddening a label for them would style ordinary typing as a problem.
|
|
218
|
+
So the picture stays, and a `dim` line says plainly that it is one
|
|
219
|
+
edit behind -- never `warn`, never `bad`, which are what the panel
|
|
220
|
+
reserves for something actually wrong.
|
|
221
|
+
|
|
222
|
+
Errors are counted, not listed: naming a half-typed field would be the
|
|
223
|
+
same accusation in smaller type.
|
|
224
|
+
"""
|
|
225
|
+
if self._parsed is None:
|
|
226
|
+
return # nothing good to keep; the tab has never drawn
|
|
227
|
+
count = len(errors)
|
|
228
|
+
note = provenance.Line(
|
|
229
|
+
# Trailing newline: `to_html` concatenates runs, so a line that
|
|
230
|
+
# does not end one runs into the caption beneath it.
|
|
231
|
+
f"waiting for {count} field{'' if count == 1 else 's'} to finish — "
|
|
232
|
+
"the drawing below is from the last complete geometry\n",
|
|
233
|
+
"dim",
|
|
234
|
+
)
|
|
235
|
+
self._captions.setHtml(
|
|
236
|
+
provenance.to_html(
|
|
237
|
+
(note,) + self._scene.captions + self._diagram.captions
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# -- reactions ----------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _on_slider_moved(self, _value: int) -> None:
|
|
244
|
+
"""Moving the slider redraws. It never solves -- the geometry is
|
|
245
|
+
already known, and nothing here depends on a solver."""
|
|
246
|
+
self._redraw()
|
|
247
|
+
|
|
248
|
+
def _jump_to_blaze(self) -> None:
|
|
249
|
+
if self._parsed is None:
|
|
250
|
+
return
|
|
251
|
+
index, _ = diagram_module.blaze_jump(
|
|
252
|
+
self._parsed.wavelengths, self._parsed.problem, self._parsed.illumination
|
|
253
|
+
)
|
|
254
|
+
if index is not None:
|
|
255
|
+
self._slider.setValue(index)
|
|
256
|
+
|
|
257
|
+
def _sync_blaze_button(self) -> None:
|
|
258
|
+
"""Enabled when it can act; disabled *with its reason* when it cannot.
|
|
259
|
+
|
|
260
|
+
A disabled control that explains itself is honest. An enabled one that
|
|
261
|
+
silently does nothing is the mistake this project already made once
|
|
262
|
+
and named -- see `docs/theory/scalar.md` §5.
|
|
263
|
+
"""
|
|
264
|
+
index, reason = diagram_module.blaze_jump(
|
|
265
|
+
self._parsed.wavelengths, self._parsed.problem, self._parsed.illumination
|
|
266
|
+
)
|
|
267
|
+
self._blaze_button.setEnabled(index is not None)
|
|
268
|
+
self._blaze_button.setToolTip(reason)
|
|
269
|
+
|
|
270
|
+
# -- drawing ------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _redraw(self) -> None:
|
|
273
|
+
"""Rebuild the diagram; paint it only if anyone can see it.
|
|
274
|
+
|
|
275
|
+
Building is ~1 ms of numpy and always runs, so `_diagram` is always
|
|
276
|
+
current and a caller (or a test) can read it without a window. Painting
|
|
277
|
+
is ~50 ms of matplotlib, so it waits for `showEvent` when the tab is
|
|
278
|
+
hidden -- rendering a figure into a tab nobody is looking at is time
|
|
279
|
+
spent for no one.
|
|
280
|
+
"""
|
|
281
|
+
if self._parsed is None:
|
|
282
|
+
return
|
|
283
|
+
wavelength = float(self._parsed.wavelengths[self._slider.value()])
|
|
284
|
+
self._wavelength_label.setText(f"λ = {wavelength:.4g} nm")
|
|
285
|
+
|
|
286
|
+
self._diagram = diagram_module.build(
|
|
287
|
+
self._parsed.problem, self._parsed.illumination, wavelength
|
|
288
|
+
)
|
|
289
|
+
self._scene = diagram3d_module.build_scene(
|
|
290
|
+
self._parsed.problem, self._parsed.illumination, wavelength
|
|
291
|
+
)
|
|
292
|
+
# Cheap, and it is the part a reader needs even before the picture
|
|
293
|
+
# arrives, so it is never deferred. The 3D scene's captions carry what
|
|
294
|
+
# the projection cannot show, so both sets appear.
|
|
295
|
+
self._captions.setHtml(
|
|
296
|
+
provenance.to_html(self._scene.captions + self._diagram.captions)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if self.isVisible():
|
|
300
|
+
self._paint(self._diagram)
|
|
301
|
+
self._needs_paint = False
|
|
302
|
+
else:
|
|
303
|
+
self._needs_paint = True
|
|
304
|
+
|
|
305
|
+
def _paint(self, drawing) -> None:
|
|
306
|
+
"""Position what `diagram` decided. No trig, no cutoffs, no colours."""
|
|
307
|
+
colors = diagram_module.TAG_COLORS
|
|
308
|
+
self._scene3d.show_scene(self._scene)
|
|
309
|
+
|
|
310
|
+
for name, axes in self._axes.items():
|
|
311
|
+
axes.clear()
|
|
312
|
+
axes.set_title(_PANEL_TITLES[name], fontsize=9)
|
|
313
|
+
|
|
314
|
+
for path in drawing.paths:
|
|
315
|
+
axes = self._axes[path.panel]
|
|
316
|
+
axes.plot(
|
|
317
|
+
path.x, path.y,
|
|
318
|
+
color=colors[path.tag],
|
|
319
|
+
lw=1.6,
|
|
320
|
+
ls="--" if path.dashed else "-",
|
|
321
|
+
label=path.label,
|
|
322
|
+
)
|
|
323
|
+
if path.fill_to is not None:
|
|
324
|
+
axes.fill_between(path.x, path.fill_to, path.y,
|
|
325
|
+
color=colors[path.tag], alpha=0.12)
|
|
326
|
+
|
|
327
|
+
for arrow in drawing.arrows:
|
|
328
|
+
axes = self._axes[arrow.panel]
|
|
329
|
+
axes.annotate(
|
|
330
|
+
"",
|
|
331
|
+
xy=(arrow.x1, arrow.y1),
|
|
332
|
+
xytext=(arrow.x0, arrow.y0),
|
|
333
|
+
arrowprops={
|
|
334
|
+
"arrowstyle": "-|>",
|
|
335
|
+
"color": colors[arrow.tag],
|
|
336
|
+
"lw": 1.5,
|
|
337
|
+
"ls": "--" if arrow.dashed else "-",
|
|
338
|
+
"shrinkA": 0,
|
|
339
|
+
"shrinkB": 0,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
if arrow.label:
|
|
343
|
+
axes.annotate(
|
|
344
|
+
arrow.label,
|
|
345
|
+
xy=(arrow.x1, arrow.y1),
|
|
346
|
+
fontsize=7,
|
|
347
|
+
color=colors[arrow.tag],
|
|
348
|
+
ha="center",
|
|
349
|
+
va="bottom",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
for marker in drawing.markers:
|
|
353
|
+
axes = self._axes[marker.panel]
|
|
354
|
+
axes.plot(
|
|
355
|
+
marker.x, marker.y,
|
|
356
|
+
_GLYPHS[marker.glyph],
|
|
357
|
+
color=colors[marker.tag],
|
|
358
|
+
ms=4 if marker.glyph == "out-of-page" else 5,
|
|
359
|
+
mfc="none" if marker.glyph == "out-of-page" else colors[marker.tag],
|
|
360
|
+
)
|
|
361
|
+
if marker.label:
|
|
362
|
+
axes.annotate(
|
|
363
|
+
marker.label,
|
|
364
|
+
xy=(marker.x, marker.y),
|
|
365
|
+
xytext=(0, 7),
|
|
366
|
+
textcoords="offset points",
|
|
367
|
+
fontsize=6,
|
|
368
|
+
color=colors[marker.tag],
|
|
369
|
+
ha="center",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
for name, ((x0, x1), (y0, y1)) in drawing.limits.items():
|
|
373
|
+
axes = self._axes[name]
|
|
374
|
+
axes.set_xlim(x0, x1)
|
|
375
|
+
axes.set_ylim(y0, y1)
|
|
376
|
+
|
|
377
|
+
# Equal aspect on the panel whose angles are meant to be true. The
|
|
378
|
+
# ladder is a number line and has no aspect to preserve.
|
|
379
|
+
self._axes["main"].set_aspect("equal", adjustable="box")
|
|
380
|
+
self._axes["main"].set_xlabel("position along d̂ (nm)", fontsize=8)
|
|
381
|
+
self._axes["main"].set_ylabel("height (nm)", fontsize=8)
|
|
382
|
+
self._axes["ladder"].set_yticks([])
|
|
383
|
+
for axes in self._axes.values():
|
|
384
|
+
axes.tick_params(labelsize=7)
|
|
385
|
+
self._axes["main"].grid(alpha=0.15)
|
|
386
|
+
|
|
387
|
+
self._canvas.draw_idle()
|