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,699 @@
|
|
|
1
|
+
r"""What a grating geometry looks like, decided without a toolkit.
|
|
2
|
+
|
|
3
|
+
Returns drawable primitives -- polylines, arrows, markers, tagged captions --
|
|
4
|
+
in physical coordinates (nm, in the :math:`(\hat{d}, \hat{n})` frame of
|
|
5
|
+
``docs/conventions.md`` §3). The widget positions them and colours them by tag;
|
|
6
|
+
it computes no angle, no direction and no cutoff, so a bug there misplaces a
|
|
7
|
+
line rather than drawing wrong physics. Same split, and the same reason, as
|
|
8
|
+
:mod:`gratinglab.gui.provenance`: which line is red is a correctness question.
|
|
9
|
+
|
|
10
|
+
This module **arranges**; it never re-derives. Every angle comes from
|
|
11
|
+
:mod:`gratinglab.geometry`, every height from :meth:`Problem.height_nm`.
|
|
12
|
+
|
|
13
|
+
Three panels, because the physics needs three
|
|
14
|
+
=============================================
|
|
15
|
+
|
|
16
|
+
**main** -- the view *down the groove axis* :math:`\hat{g}`, i.e. the
|
|
17
|
+
projection onto the :math:`\hat{n}`-:math:`\hat{d}` plane. In that projection
|
|
18
|
+
:math:`\mathbf{k}_i` and every :math:`\mathbf{k}_m` have the same projected
|
|
19
|
+
length :math:`k\sin\gamma`, and their azimuths are exactly :math:`\alpha` and
|
|
20
|
+
:math:`\beta_m`. Two things follow, and they are why this is a good view rather
|
|
21
|
+
than a compromise:
|
|
22
|
+
|
|
23
|
+
1. Every propagating order lands on one circle centred at the strike point.
|
|
24
|
+
That circle *is* the diffraction cone, seen end-on -- so "the mount is
|
|
25
|
+
conical" becomes a picture rather than a caption.
|
|
26
|
+
2. :math:`\alpha`, :math:`\beta_m`, :math:`\delta` and :math:`\beta_b` are all
|
|
27
|
+
azimuths in this same plane, drawn true at any :math:`\gamma`.
|
|
28
|
+
|
|
29
|
+
**ladder** -- every order on a :math:`\sin\beta` axis. The main panel can only
|
|
30
|
+
draw an order that has a direction; an evanescent one has none, and
|
|
31
|
+
``conventions.md`` §4 forbids dropping it ("retained in the record, never
|
|
32
|
+
silently dropped, never NaN"). So an evanescent order sits here, outside the
|
|
33
|
+
:math:`[-1, 1]` propagating window, with no ray attached. Omitting it from the
|
|
34
|
+
picture would be exactly the silent drop that section exists to prevent.
|
|
35
|
+
|
|
36
|
+
There used to be a third panel here, drawing :math:`\gamma` as a true-to-scale
|
|
37
|
+
sliver. It existed because the main panel is a projection: :math:`k_z =
|
|
38
|
+
k\cos\gamma` is perpendicular to it and invisible, and at :math:`\gamma = 1.5°`
|
|
39
|
+
that is 99.97% of :math:`|k|`, so a reader who sees rays at 25° from the normal
|
|
40
|
+
could conclude the mount is not grazing. :mod:`gratinglab.gui.diagram3d` now
|
|
41
|
+
draws the actual cone, with :math:`\gamma` as a real angle in a real scene
|
|
42
|
+
rather than a stand-in beside one -- so the sliver was retired (M13-I). Two
|
|
43
|
+
drawings of one angle are two answers to one question. The captions still say
|
|
44
|
+
what this projection cannot show, and now point at the view that can.
|
|
45
|
+
|
|
46
|
+
What is deliberately *not* drawn
|
|
47
|
+
================================
|
|
48
|
+
|
|
49
|
+
:math:`\zeta`, the facet graze angle, is **text only**.
|
|
50
|
+
:func:`~gratinglab.geometry.facet_graze` is the three-dimensional angle between
|
|
51
|
+
:math:`\hat{k}_i` and the facet *plane*, :math:`\sin\zeta = \sin\gamma
|
|
52
|
+
\cos(\delta-\alpha)`. It is **not** the angle between the projected ray and the
|
|
53
|
+
facet line in this drawing -- 1.495° against 85.5° at the reference geometry.
|
|
54
|
+
An arc labelled :math:`\zeta` would be the worst lie available here.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from typing import TYPE_CHECKING, Iterable, Sequence
|
|
61
|
+
|
|
62
|
+
import numpy as np
|
|
63
|
+
from numpy.typing import ArrayLike, NDArray
|
|
64
|
+
|
|
65
|
+
from ..geometry import (
|
|
66
|
+
blaze_direction,
|
|
67
|
+
blaze_wavelength,
|
|
68
|
+
facet_graze,
|
|
69
|
+
is_propagating,
|
|
70
|
+
order_range,
|
|
71
|
+
sin_beta,
|
|
72
|
+
)
|
|
73
|
+
from .provenance import Line
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
|
|
76
|
+
from ..illumination import Illumination
|
|
77
|
+
from ..problem import Problem
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"T_AXIS_SIGN",
|
|
81
|
+
"TAG_COLORS",
|
|
82
|
+
"MAX_ORDER_LABELS",
|
|
83
|
+
"Polyline",
|
|
84
|
+
"Arrow",
|
|
85
|
+
"Marker",
|
|
86
|
+
"OrderMark",
|
|
87
|
+
"Diagram",
|
|
88
|
+
"build",
|
|
89
|
+
"x_nm",
|
|
90
|
+
"direction",
|
|
91
|
+
"order_span",
|
|
92
|
+
"order_marks",
|
|
93
|
+
"strike_point",
|
|
94
|
+
"facet_normal",
|
|
95
|
+
"label_orders",
|
|
96
|
+
"captions",
|
|
97
|
+
"nearest_index",
|
|
98
|
+
"blaze_targets",
|
|
99
|
+
"blaze_jump",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
#: Which way the profile parameter runs along the dispersion axis.
|
|
103
|
+
#:
|
|
104
|
+
#: ``Profile.height(t)`` is a shape in ``t``; :mod:`gratinglab.profiles` never
|
|
105
|
+
#: says which physical direction ``+t`` points. The wave conventions do fix it:
|
|
106
|
+
#: ``blaze_direction`` gives :math:`\beta_b = 2\delta - \alpha`, which requires
|
|
107
|
+
#: the active facet's outward normal at azimuth :math:`+\delta`, while
|
|
108
|
+
#: ``Blazed.height`` rises with ``t``, putting it at :math:`-\delta` in ``t``.
|
|
109
|
+
#:
|
|
110
|
+
#: Nothing before this module drew a profile and a ray in the same frame, so
|
|
111
|
+
#: nothing before it had to know. See ``docs/conventions.md`` §3 and
|
|
112
|
+
#: ``tests/test_geometry.py::TestFacetHandedness``.
|
|
113
|
+
T_AXIS_SIGN: float = -1.0
|
|
114
|
+
|
|
115
|
+
#: Tag -> colour, exactly as :data:`gratinglab.gui.provenance.TAG_COLORS`
|
|
116
|
+
#: works: this module decides what a thing *is*, the widget looks up what that
|
|
117
|
+
#: colour is.
|
|
118
|
+
TAG_COLORS: dict[str, str] = {
|
|
119
|
+
"surface": "#1f3b73", # the blue ProfilePlotPanel already uses
|
|
120
|
+
"facet": "#2d5aa8", # the active facet, picked out of the surface
|
|
121
|
+
"incident": "#8fa3c8", # icon.py's incident-ray grey-blue
|
|
122
|
+
"zero": "#666666",
|
|
123
|
+
"order": "#4dc4ff",
|
|
124
|
+
"blaze": "#ffc233",
|
|
125
|
+
"normal": "#0a7d28",
|
|
126
|
+
"cone": "#999999",
|
|
127
|
+
"evanescent": "#b00020",
|
|
128
|
+
"axis": "#888888",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#: At most this many order arrows carry a label before thinning starts. An
|
|
132
|
+
#: X-ray geometry at the short end of a scan propagates sixteen orders; sixteen
|
|
133
|
+
#: labels is a smear. The **label** is what gets dropped -- never the arrow,
|
|
134
|
+
#: which would be a silent omission -- and a caption reports the true total.
|
|
135
|
+
MAX_ORDER_LABELS = 7
|
|
136
|
+
|
|
137
|
+
#: Ray length, in periods. Arbitrary: only directions carry meaning, and a
|
|
138
|
+
#: caption says so.
|
|
139
|
+
_RAY_PERIODS = 1.15
|
|
140
|
+
|
|
141
|
+
#: Points used to sample the surface and the cone arc.
|
|
142
|
+
_SURFACE_POINTS = 400
|
|
143
|
+
_CONE_POINTS = 181
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class Polyline:
|
|
148
|
+
"""An open path, already sampled.
|
|
149
|
+
|
|
150
|
+
Arcs and angle wedges are polylines too: a ``matplotlib.patches.Arc`` takes
|
|
151
|
+
degrees counter-clockwise from ``+x``, which is a *different* angle
|
|
152
|
+
convention from this project's azimuth-from-:math:`\\hat{n}`, and that
|
|
153
|
+
conversion does not belong in a widget.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
x: NDArray[np.float64]
|
|
157
|
+
y: NDArray[np.float64]
|
|
158
|
+
tag: str
|
|
159
|
+
panel: str = "main"
|
|
160
|
+
fill_to: float | None = None
|
|
161
|
+
dashed: bool = False
|
|
162
|
+
label: str | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class Arrow:
|
|
167
|
+
"""A directed segment, tail to head, in absolute coordinates.
|
|
168
|
+
|
|
169
|
+
Endpoints rather than angle-and-length on purpose: the widget then converts
|
|
170
|
+
nothing, and a sign error has nowhere to hide in the untested layer.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
x0: float
|
|
174
|
+
y0: float
|
|
175
|
+
x1: float
|
|
176
|
+
y1: float
|
|
177
|
+
tag: str
|
|
178
|
+
panel: str = "main"
|
|
179
|
+
label: str | None = None
|
|
180
|
+
order: int | None = None
|
|
181
|
+
dashed: bool = False
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def azimuth(self) -> float:
|
|
185
|
+
"""Direction as an azimuth from :math:`\\hat{n}` toward :math:`\\hat{d}`,
|
|
186
|
+
radians. Derived for tests; the widget never needs it."""
|
|
187
|
+
return float(np.arctan2(self.x1 - self.x0, self.y1 - self.y0))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class Marker:
|
|
192
|
+
"""A point with a *named* glyph.
|
|
193
|
+
|
|
194
|
+
``glyph`` is one of ``"out-of-page"``, ``"strike"``. Named, not styled: the
|
|
195
|
+
widget owns the mapping from name to marker.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
x: float
|
|
199
|
+
y: float
|
|
200
|
+
glyph: str
|
|
201
|
+
tag: str
|
|
202
|
+
panel: str = "main"
|
|
203
|
+
label: str | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class OrderMark:
|
|
208
|
+
"""One diffraction order, on the :math:`\\sin\\beta` ladder.
|
|
209
|
+
|
|
210
|
+
Present whether or not it propagates. ``sin_beta`` is unclipped, straight
|
|
211
|
+
from :func:`~gratinglab.geometry.sin_beta`, so an evanescent order reads
|
|
212
|
+
:math:`|\\sin\\beta| > 1` and lands visibly outside the window rather than
|
|
213
|
+
vanishing.
|
|
214
|
+
|
|
215
|
+
``beta`` is ``None`` **exactly** when the order is evanescent -- not
|
|
216
|
+
``nan``, not ``0.0``. An evanescent order has no direction, and a number
|
|
217
|
+
here would invite one.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
order: int
|
|
221
|
+
sin_beta: float
|
|
222
|
+
beta: float | None
|
|
223
|
+
propagating: bool
|
|
224
|
+
is_blaze_order: bool
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class Diagram:
|
|
229
|
+
"""One geometry, at one wavelength, as things to draw."""
|
|
230
|
+
|
|
231
|
+
paths: tuple[Polyline, ...]
|
|
232
|
+
arrows: tuple[Arrow, ...]
|
|
233
|
+
markers: tuple[Marker, ...]
|
|
234
|
+
orders: tuple[OrderMark, ...]
|
|
235
|
+
captions: tuple[Line, ...]
|
|
236
|
+
limits: dict[str, tuple[tuple[float, float], tuple[float, float]]]
|
|
237
|
+
wavelength: float
|
|
238
|
+
blaze_order: int | None
|
|
239
|
+
|
|
240
|
+
def mark(self, order: int) -> OrderMark:
|
|
241
|
+
"""The :class:`OrderMark` for one order. Raises if it is not present --
|
|
242
|
+
an order that should be in the record and isn't is a bug, not a
|
|
243
|
+
default."""
|
|
244
|
+
for entry in self.orders:
|
|
245
|
+
if entry.order == order:
|
|
246
|
+
return entry
|
|
247
|
+
raise KeyError(f"order {order} is not in this diagram")
|
|
248
|
+
|
|
249
|
+
def on(self, panel: str) -> tuple[object, ...]:
|
|
250
|
+
"""Every primitive belonging to one panel, for the widget's dispatch."""
|
|
251
|
+
return tuple(
|
|
252
|
+
item
|
|
253
|
+
for group in (self.paths, self.arrows, self.markers)
|
|
254
|
+
for item in group
|
|
255
|
+
if item.panel == panel
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# -- coordinates ---------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def x_nm(t: ArrayLike, period: float) -> NDArray[np.float64]:
|
|
263
|
+
"""Normalised profile position to physical position along :math:`\\hat{d}`,
|
|
264
|
+
in nm. See :data:`T_AXIS_SIGN`."""
|
|
265
|
+
return T_AXIS_SIGN * np.asarray(t, dtype=np.float64) * period
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def direction(azimuth: float) -> NDArray[np.float64]:
|
|
269
|
+
"""Unit vector at an azimuth measured from :math:`\\hat{n}` toward
|
|
270
|
+
:math:`\\hat{d}` -- the convention every angle in §3 uses."""
|
|
271
|
+
return np.array([np.sin(azimuth), np.cos(azimuth)])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# -- orders --------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def order_span(
|
|
278
|
+
wavelength: float,
|
|
279
|
+
period: float,
|
|
280
|
+
sin_alpha: float,
|
|
281
|
+
sin_gamma: float = 1.0,
|
|
282
|
+
*,
|
|
283
|
+
pad: int = 2,
|
|
284
|
+
) -> NDArray[np.int64]:
|
|
285
|
+
"""Every propagating order, plus ``pad`` evanescent ones on each side.
|
|
286
|
+
|
|
287
|
+
:func:`~gratinglab.geometry.order_range` returns exactly the propagating
|
|
288
|
+
set, which is the right answer for a solver and the wrong one for a
|
|
289
|
+
picture: an order that has just passed off leaves no trace, and the passing
|
|
290
|
+
off is the thing worth watching.
|
|
291
|
+
"""
|
|
292
|
+
live = order_range(wavelength, period, sin_alpha, sin_gamma)
|
|
293
|
+
return np.arange(int(live.min()) - pad, int(live.max()) + pad + 1, dtype=np.int64)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def order_marks(
|
|
297
|
+
problem: "Problem",
|
|
298
|
+
illumination: "Illumination",
|
|
299
|
+
wavelength: float,
|
|
300
|
+
*,
|
|
301
|
+
pad: int = 2,
|
|
302
|
+
) -> tuple[OrderMark, ...]:
|
|
303
|
+
"""Describe every order in the span, propagating or not."""
|
|
304
|
+
orders = order_span(
|
|
305
|
+
wavelength,
|
|
306
|
+
problem.period,
|
|
307
|
+
illumination.sin_alpha,
|
|
308
|
+
illumination.sin_gamma,
|
|
309
|
+
pad=pad,
|
|
310
|
+
)
|
|
311
|
+
values = sin_beta(
|
|
312
|
+
orders,
|
|
313
|
+
wavelength,
|
|
314
|
+
problem.period,
|
|
315
|
+
illumination.sin_alpha,
|
|
316
|
+
illumination.sin_gamma,
|
|
317
|
+
)
|
|
318
|
+
live = is_propagating(values)
|
|
319
|
+
blaze = _blaze_order(problem, illumination, wavelength)
|
|
320
|
+
|
|
321
|
+
return tuple(
|
|
322
|
+
OrderMark(
|
|
323
|
+
order=int(m),
|
|
324
|
+
sin_beta=float(s),
|
|
325
|
+
beta=float(np.arcsin(s)) if bool(p) else None,
|
|
326
|
+
propagating=bool(p),
|
|
327
|
+
is_blaze_order=(blaze is not None and int(m) == blaze),
|
|
328
|
+
)
|
|
329
|
+
for m, s, p in zip(orders, values, live)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def label_orders(
|
|
334
|
+
marks: Sequence[OrderMark], *, limit: int = MAX_ORDER_LABELS
|
|
335
|
+
) -> frozenset[int]:
|
|
336
|
+
"""Which orders get a text label.
|
|
337
|
+
|
|
338
|
+
Order 0 and the blaze order always, then the rest by descending
|
|
339
|
+
:math:`|\\sin\\beta|` proximity to the centre, so labels thin from the
|
|
340
|
+
crowded edges inward. Only labels are dropped; every propagating order
|
|
341
|
+
keeps its arrow.
|
|
342
|
+
"""
|
|
343
|
+
live = [m for m in marks if m.propagating]
|
|
344
|
+
always = {m.order for m in live if m.order == 0 or m.is_blaze_order}
|
|
345
|
+
rest = sorted(
|
|
346
|
+
(m for m in live if m.order not in always), key=lambda m: abs(m.sin_beta)
|
|
347
|
+
)
|
|
348
|
+
room = max(0, limit - len(always))
|
|
349
|
+
return frozenset(always | {m.order for m in rest[:room]})
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# -- the grating itself ---------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _active_facet_t(problem: "Problem") -> tuple[float, float]:
|
|
356
|
+
"""The ``t`` span of the facet the beam is drawn striking, within the
|
|
357
|
+
period that occupies the middle of the window.
|
|
358
|
+
|
|
359
|
+
For a sawtooth that is the active (blaze) facet, ``[0, apex]``. For every
|
|
360
|
+
other profile there is no distinguished facet, so the anchor is the crest
|
|
361
|
+
-- the ray *directions* are the physics, and the origin is a visual anchor
|
|
362
|
+
that a caption already declares arbitrary.
|
|
363
|
+
"""
|
|
364
|
+
profile = problem.profile
|
|
365
|
+
apex = getattr(profile, "apex", None)
|
|
366
|
+
if apex is None:
|
|
367
|
+
t = np.linspace(0.0, 1.0, 512, endpoint=False)
|
|
368
|
+
apex = float(t[int(np.argmax(profile.height(t)))])
|
|
369
|
+
return -1.0 + 0.0, -1.0 + float(apex)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def strike_point(problem: "Problem") -> tuple[float, float]:
|
|
373
|
+
"""Where the incident ray is drawn meeting the grating, in nm."""
|
|
374
|
+
start, end = _active_facet_t(problem)
|
|
375
|
+
t = 0.5 * (start + end)
|
|
376
|
+
return float(x_nm(t, problem.period)), float(problem.height_nm(t))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def facet_normal(problem: "Problem") -> tuple[float, float]:
|
|
380
|
+
"""Outward unit normal of the struck facet, in the physical frame.
|
|
381
|
+
|
|
382
|
+
Taken from :meth:`Profile.boundary`, which already returns outward normals
|
|
383
|
+
(``ny > 0``), with the ``x`` component flipped by :data:`T_AXIS_SIGN` --
|
|
384
|
+
the normal is a direction in ``t``-space and this frame runs the other way.
|
|
385
|
+
"""
|
|
386
|
+
start, end = _active_facet_t(problem)
|
|
387
|
+
t_mid = np.mod(0.5 * (start + end), 1.0)
|
|
388
|
+
|
|
389
|
+
curve = problem.profile.boundary(512)
|
|
390
|
+
index = int(np.argmin(np.abs(curve.t - t_mid)))
|
|
391
|
+
nx = T_AXIS_SIGN * float(curve.nx[index])
|
|
392
|
+
ny = float(curve.ny[index])
|
|
393
|
+
norm = float(np.hypot(nx, ny))
|
|
394
|
+
return nx / norm, ny / norm
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _blaze_order(
|
|
398
|
+
problem: "Problem", illumination: "Illumination", wavelength: float
|
|
399
|
+
) -> int | None:
|
|
400
|
+
"""The order whose blaze wavelength is nearest the one being drawn, if the
|
|
401
|
+
profile has a blaze angle at all."""
|
|
402
|
+
targets = blaze_targets(problem, illumination)
|
|
403
|
+
if not targets:
|
|
404
|
+
return None
|
|
405
|
+
order, _ = min(targets, key=lambda pair: abs(pair[1] - wavelength))
|
|
406
|
+
return order
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# -- wavelength selection -------------------------------------------------
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def nearest_index(wavelengths: NDArray[np.float64], target: float) -> int:
|
|
413
|
+
"""Index of the scan point closest to ``target``."""
|
|
414
|
+
return int(np.argmin(np.abs(np.asarray(wavelengths, dtype=np.float64) - target)))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def blaze_targets(
|
|
418
|
+
problem: "Problem",
|
|
419
|
+
illumination: "Illumination",
|
|
420
|
+
orders: Iterable[int] = range(1, 9),
|
|
421
|
+
) -> tuple[tuple[int, float], ...]:
|
|
422
|
+
"""``(order, blaze wavelength)`` pairs, or empty for a profile with no
|
|
423
|
+
blaze angle."""
|
|
424
|
+
blaze_angle = getattr(problem.profile, "blaze_angle", None)
|
|
425
|
+
if blaze_angle is None:
|
|
426
|
+
return ()
|
|
427
|
+
delta = np.radians(blaze_angle)
|
|
428
|
+
return tuple(
|
|
429
|
+
(
|
|
430
|
+
int(m),
|
|
431
|
+
float(
|
|
432
|
+
blaze_wavelength(
|
|
433
|
+
m, problem.period, delta, illumination.alpha, illumination.gamma
|
|
434
|
+
)
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
for m in orders
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def blaze_jump(
|
|
442
|
+
wavelengths: NDArray[np.float64],
|
|
443
|
+
problem: "Problem",
|
|
444
|
+
illumination: "Illumination",
|
|
445
|
+
) -> tuple[int | None, str]:
|
|
446
|
+
"""``(index to jump to, reason)`` for a "go to the blaze wavelength"
|
|
447
|
+
control.
|
|
448
|
+
|
|
449
|
+
The reason is non-empty **exactly** when the index is ``None``, so a caller
|
|
450
|
+
puts *this* text in a disabled button's tooltip instead of inventing one --
|
|
451
|
+
or leaving a dead control that says nothing. A disabled control that
|
|
452
|
+
explains itself is honest; an enabled one that silently does nothing is
|
|
453
|
+
not.
|
|
454
|
+
"""
|
|
455
|
+
targets = blaze_targets(problem, illumination)
|
|
456
|
+
if not targets:
|
|
457
|
+
kind = type(problem.profile).__name__
|
|
458
|
+
return None, f"{kind} has no blaze angle, so no blaze wavelength."
|
|
459
|
+
|
|
460
|
+
lo = float(np.min(wavelengths))
|
|
461
|
+
hi = float(np.max(wavelengths))
|
|
462
|
+
inside = [(m, lam) for m, lam in targets if lo <= lam <= hi]
|
|
463
|
+
if inside:
|
|
464
|
+
order, lam = min(inside, key=lambda pair: pair[0])
|
|
465
|
+
return nearest_index(wavelengths, lam), ""
|
|
466
|
+
|
|
467
|
+
nearby = ", ".join(f"{lam:.2f} nm (m={m})" for m, lam in targets[:3])
|
|
468
|
+
return None, (
|
|
469
|
+
f"No blaze wavelength falls inside the {lo:.2f}-{hi:.2f} nm scan: {nearby}."
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# -- captions -------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def captions(
|
|
477
|
+
problem: "Problem",
|
|
478
|
+
illumination: "Illumination",
|
|
479
|
+
wavelength: float,
|
|
480
|
+
marks: Sequence[OrderMark],
|
|
481
|
+
) -> tuple[Line, ...]:
|
|
482
|
+
"""What the drawing cannot show, said in words.
|
|
483
|
+
|
|
484
|
+
Every one of these exists because the picture is true but partial, and a
|
|
485
|
+
partial truth left unlabelled is how a reader talks themselves into the
|
|
486
|
+
wrong geometry.
|
|
487
|
+
"""
|
|
488
|
+
live = sum(1 for m in marks if m.propagating)
|
|
489
|
+
dark = len(marks) - live
|
|
490
|
+
lines: list[Line] = [
|
|
491
|
+
Line(
|
|
492
|
+
f"λ = {wavelength:.3g} nm. {live} of {len(marks)} orders shown "
|
|
493
|
+
f"propagate; {dark} are evanescent and appear on the sin β axis "
|
|
494
|
+
"with no ray, having no direction.\n"
|
|
495
|
+
),
|
|
496
|
+
Line(
|
|
497
|
+
"View along the groove axis ĝ. Angles in this plane — α, β_m, δ, "
|
|
498
|
+
"β_b — are true, and the groove cross-section is to scale.\n",
|
|
499
|
+
"dim",
|
|
500
|
+
),
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
if illumination.is_in_plane:
|
|
504
|
+
lines.append(
|
|
505
|
+
Line(
|
|
506
|
+
"γ = 90°: this is the in-plane case. k_z = 0, so this diagram "
|
|
507
|
+
"is the whole geometry, not a projection.\n",
|
|
508
|
+
"dim",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
else:
|
|
512
|
+
cos_gamma = float(np.cos(illumination.gamma))
|
|
513
|
+
lines.append(
|
|
514
|
+
Line(
|
|
515
|
+
f"γ = {illumination.gamma_deg:g}°: every ray also carries "
|
|
516
|
+
f"k_z = k cos γ = {cos_gamma:.5f} k out of the page, "
|
|
517
|
+
"identically for all orders. It is perpendicular to this "
|
|
518
|
+
"projection and so cannot appear in it — the 3D view beside "
|
|
519
|
+
"it draws γ as a real angle.\n",
|
|
520
|
+
"dim",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
blaze_angle = getattr(problem.profile, "blaze_angle", None)
|
|
525
|
+
if blaze_angle is None:
|
|
526
|
+
lines.append(
|
|
527
|
+
Line(
|
|
528
|
+
f"{type(problem.profile).__name__} has no blaze angle, so no "
|
|
529
|
+
"blaze direction is drawn.\n",
|
|
530
|
+
"dim",
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
else:
|
|
534
|
+
zeta = facet_graze(
|
|
535
|
+
illumination.gamma, np.radians(blaze_angle), illumination.alpha
|
|
536
|
+
)
|
|
537
|
+
lines.append(
|
|
538
|
+
Line(
|
|
539
|
+
f"ζ = {np.degrees(zeta):.3g}° is the graze angle onto the facet "
|
|
540
|
+
"in three dimensions. It is not an angle in this plane and is "
|
|
541
|
+
"not drawn.\n",
|
|
542
|
+
"dim",
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
labelled = label_orders(marks)
|
|
547
|
+
if labelled != {m.order for m in marks if m.propagating}:
|
|
548
|
+
lines.append(
|
|
549
|
+
Line(
|
|
550
|
+
f"{len(labelled)} of {live} propagating orders are labelled; "
|
|
551
|
+
"every one is still drawn.\n",
|
|
552
|
+
"dim",
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
lines.append(
|
|
557
|
+
Line("Ray lengths are arbitrary; only their directions carry meaning.\n", "dim")
|
|
558
|
+
)
|
|
559
|
+
return tuple(lines)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# -- the whole drawing ----------------------------------------------------
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def build(
|
|
566
|
+
problem: "Problem",
|
|
567
|
+
illumination: "Illumination",
|
|
568
|
+
wavelength: float,
|
|
569
|
+
*,
|
|
570
|
+
periods: int = 2,
|
|
571
|
+
pad_orders: int = 2,
|
|
572
|
+
) -> Diagram:
|
|
573
|
+
"""Everything one drawing consists of. The only entry point a widget needs."""
|
|
574
|
+
period = problem.period
|
|
575
|
+
marks = order_marks(problem, illumination, wavelength, pad=pad_orders)
|
|
576
|
+
labelled = label_orders(marks)
|
|
577
|
+
ray = _RAY_PERIODS * period
|
|
578
|
+
|
|
579
|
+
strike = np.array(strike_point(problem))
|
|
580
|
+
paths: list[Polyline] = []
|
|
581
|
+
arrows: list[Arrow] = []
|
|
582
|
+
markers: list[Marker] = []
|
|
583
|
+
|
|
584
|
+
# -- the grating surface, over `periods` periods --------------------
|
|
585
|
+
t = np.linspace(-periods, 0.0, _SURFACE_POINTS)
|
|
586
|
+
surface_x = x_nm(t, period)
|
|
587
|
+
surface_y = np.asarray(problem.height_nm(t), dtype=np.float64)
|
|
588
|
+
floor = float(surface_y.min()) - 0.35 * period
|
|
589
|
+
paths.append(
|
|
590
|
+
Polyline(surface_x, surface_y, "surface", fill_to=floor, label="grating")
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# The struck facet, picked out of the surface so the eye finds it.
|
|
594
|
+
start, end = _active_facet_t(problem)
|
|
595
|
+
facet_t = np.linspace(start, end, 64)
|
|
596
|
+
paths.append(
|
|
597
|
+
Polyline(x_nm(facet_t, period), np.asarray(problem.height_nm(facet_t)), "facet")
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# -- incident ray ----------------------------------------------------
|
|
601
|
+
# k_i projects to (-sin a, -cos a): it *travels* that way, so the tail sits
|
|
602
|
+
# back along the opposite direction and the head is the strike point.
|
|
603
|
+
incoming = direction(illumination.alpha)
|
|
604
|
+
tail = strike + ray * incoming
|
|
605
|
+
arrows.append(
|
|
606
|
+
Arrow(
|
|
607
|
+
float(tail[0]), float(tail[1]), float(strike[0]), float(strike[1]),
|
|
608
|
+
"incident", label="incident",
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
markers.append(Marker(float(strike[0]), float(strike[1]), "strike", "incident"))
|
|
612
|
+
|
|
613
|
+
# -- the diffraction cone, seen end-on -------------------------------
|
|
614
|
+
# Every propagating order has the same projected length, so they all land
|
|
615
|
+
# on this circle. That is what makes the mount's conical nature visible.
|
|
616
|
+
cone_az = np.linspace(-np.pi / 2, np.pi / 2, _CONE_POINTS)
|
|
617
|
+
paths.append(
|
|
618
|
+
Polyline(
|
|
619
|
+
strike[0] + ray * np.sin(cone_az),
|
|
620
|
+
strike[1] + ray * np.cos(cone_az),
|
|
621
|
+
"cone",
|
|
622
|
+
dashed=True,
|
|
623
|
+
label="cone of diffraction",
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# -- one arrow per propagating order ---------------------------------
|
|
628
|
+
for mark in marks:
|
|
629
|
+
if mark.beta is None:
|
|
630
|
+
continue # no direction; it lives on the ladder instead
|
|
631
|
+
head = strike + ray * direction(mark.beta)
|
|
632
|
+
arrows.append(
|
|
633
|
+
Arrow(
|
|
634
|
+
float(strike[0]), float(strike[1]), float(head[0]), float(head[1]),
|
|
635
|
+
"zero" if mark.order == 0 else "order",
|
|
636
|
+
label=f"m={mark.order:+d}" if mark.order in labelled else None,
|
|
637
|
+
order=mark.order,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
markers.append(Marker(float(head[0]), float(head[1]), "out-of-page", "cone"))
|
|
641
|
+
|
|
642
|
+
# -- facet normal and blaze direction --------------------------------
|
|
643
|
+
normal = np.array(facet_normal(problem))
|
|
644
|
+
normal_head = strike + 0.55 * ray * normal
|
|
645
|
+
arrows.append(
|
|
646
|
+
Arrow(
|
|
647
|
+
float(strike[0]), float(strike[1]),
|
|
648
|
+
float(normal_head[0]), float(normal_head[1]),
|
|
649
|
+
"normal", label="facet normal", dashed=True,
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
blaze_angle = getattr(problem.profile, "blaze_angle", None)
|
|
654
|
+
if blaze_angle is not None:
|
|
655
|
+
beta_b = blaze_direction(np.radians(blaze_angle), illumination.alpha)
|
|
656
|
+
head = strike + ray * direction(beta_b)
|
|
657
|
+
arrows.append(
|
|
658
|
+
Arrow(
|
|
659
|
+
float(strike[0]), float(strike[1]), float(head[0]), float(head[1]),
|
|
660
|
+
"blaze", label=f"β_b = {np.degrees(beta_b):.1f}°",
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# -- the sin(beta) ladder --------------------------------------------
|
|
665
|
+
# The propagating window, so an evanescent order is visibly outside it
|
|
666
|
+
# rather than merely absent.
|
|
667
|
+
paths.append(
|
|
668
|
+
Polyline(
|
|
669
|
+
np.array([-1.0, 1.0]), np.array([0.0, 0.0]), "axis", panel="ladder",
|
|
670
|
+
label="propagating window",
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
for mark in marks:
|
|
674
|
+
tag = "zero" if mark.order == 0 else ("order" if mark.propagating else "evanescent")
|
|
675
|
+
markers.append(
|
|
676
|
+
Marker(mark.sin_beta, 0.0, "strike", tag, panel="ladder",
|
|
677
|
+
label=f"{mark.order:+d}")
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
span = periods * period
|
|
681
|
+
pad = 0.12 * span
|
|
682
|
+
limits = {
|
|
683
|
+
"main": (
|
|
684
|
+
(float(surface_x.min()) - pad, float(surface_x.max()) + pad),
|
|
685
|
+
(floor, float(strike[1]) + ray + pad),
|
|
686
|
+
),
|
|
687
|
+
"ladder": ((-1.6, 1.6), (-1.0, 1.0)),
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return Diagram(
|
|
691
|
+
paths=tuple(paths),
|
|
692
|
+
arrows=tuple(arrows),
|
|
693
|
+
markers=tuple(markers),
|
|
694
|
+
orders=marks,
|
|
695
|
+
captions=captions(problem, illumination, wavelength, marks),
|
|
696
|
+
limits=limits,
|
|
697
|
+
wavelength=float(wavelength),
|
|
698
|
+
blaze_order=_blaze_order(problem, illumination, wavelength),
|
|
699
|
+
)
|