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,609 @@
|
|
|
1
|
+
r"""The conical diffraction geometry in three dimensions, decided without a
|
|
2
|
+
toolkit.
|
|
3
|
+
|
|
4
|
+
The 2D view in :mod:`gratinglab.gui.diagram` is the projection down the groove
|
|
5
|
+
axis. It draws every azimuth truly and hides one thing completely: the
|
|
6
|
+
out-of-plane component :math:`k_z = k\cos\gamma`, which at
|
|
7
|
+
:math:`\gamma = 1.5°` is 99.97% of :math:`|k|`. This module is the view that
|
|
8
|
+
shows it.
|
|
9
|
+
|
|
10
|
+
Why a narrow cone still makes a good picture
|
|
11
|
+
============================================
|
|
12
|
+
|
|
13
|
+
At the reference geometry (315.15 nm, :math:`\alpha = 25°`,
|
|
14
|
+
:math:`\gamma = 1.5°`, :math:`\lambda = 3` nm) the propagating orders are:
|
|
15
|
+
|
|
16
|
+
=== ========== ============================================
|
|
17
|
+
m :math:`\beta_m` :math:`\hat{k}_m`
|
|
18
|
+
=== ========== ============================================
|
|
19
|
+
-1 −51.84° (−0.02058, +0.01617, **0.99966**)
|
|
20
|
+
0 −25.00° (−0.01106, +0.02372, **0.99966**)
|
|
21
|
+
+1 −3.38° (−0.00154, +0.02613, **0.99966**)
|
|
22
|
+
+2 +17.74° (+0.00798, +0.02493, **0.99966**)
|
|
23
|
+
+3 +41.94° (+0.01749, +0.01947, **0.99966**)
|
|
24
|
+
=== ========== ============================================
|
|
25
|
+
|
|
26
|
+
**Polar spread: exactly 0.0°. Azimuth spread: 93.8°.** The cone is a needle
|
|
27
|
+
*and* the fan around it is nearly a right angle. Both at once, and that
|
|
28
|
+
combination is precisely what a dispersion-plane drawing cannot convey.
|
|
29
|
+
|
|
30
|
+
So the default oblique view is not a degenerate picture. The grooves run along
|
|
31
|
+
:math:`\hat{z}` and the rays run at 1.5° to them: a beam *skimming along the
|
|
32
|
+
groove direction* at grazing incidence, which is exactly what distinguishes an
|
|
33
|
+
off-plane mount from a classical one. Looking down the cone axis instead, and
|
|
34
|
+
zooming uniformly to the rim, makes the 93.8° fan fully readable -- at true
|
|
35
|
+
scale, since uniform zoom is a similarity transform and preserves every angle.
|
|
36
|
+
|
|
37
|
+
**Nothing here exaggerates γ.** Legibility comes from viewpoint and
|
|
38
|
+
magnification only.
|
|
39
|
+
|
|
40
|
+
Two scales in one box
|
|
41
|
+
=====================
|
|
42
|
+
|
|
43
|
+
The grating patch is in nm (~630 nm across); the rays are unit vectors. There
|
|
44
|
+
is no physical ratio between them, so :attr:`Scene.scale_nm` records the one
|
|
45
|
+
chosen and a caption states it. The groove *cross-section* is exact and every
|
|
46
|
+
ray *direction* is exact; only the patch's size relative to the rays is a
|
|
47
|
+
drawing choice -- the same disclaimer the 2D view already carries for ray
|
|
48
|
+
length, narrowed to the one thing still arbitrary.
|
|
49
|
+
|
|
50
|
+
Why a separate module
|
|
51
|
+
=====================
|
|
52
|
+
|
|
53
|
+
:class:`~gratinglab.gui.diagram.Diagram` is 2D by construction: ``limits`` is
|
|
54
|
+
typed as two ranges and the widget unpacks it as such, and
|
|
55
|
+
:attr:`Arrow.azimuth` would silently keep returning a projection angle if a
|
|
56
|
+
``z`` were defaulted onto it. Adding a third dimension there would make the
|
|
57
|
+
widget branch on shape, which is what the pure/impure split exists to prevent.
|
|
58
|
+
This module reuses :func:`~gratinglab.gui.diagram.direction`,
|
|
59
|
+
:func:`~gratinglab.gui.diagram.order_marks`,
|
|
60
|
+
:func:`~gratinglab.gui.diagram.strike_point` and
|
|
61
|
+
:func:`~gratinglab.gui.diagram.facet_normal` rather than re-deriving anything.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
from dataclasses import dataclass
|
|
67
|
+
from typing import TYPE_CHECKING, Sequence
|
|
68
|
+
|
|
69
|
+
import numpy as np
|
|
70
|
+
from numpy.typing import NDArray
|
|
71
|
+
|
|
72
|
+
from ..geometry import blaze_direction
|
|
73
|
+
from .diagram import (
|
|
74
|
+
TAG_COLORS,
|
|
75
|
+
OrderMark,
|
|
76
|
+
direction,
|
|
77
|
+
facet_normal,
|
|
78
|
+
order_marks,
|
|
79
|
+
strike_point,
|
|
80
|
+
x_nm,
|
|
81
|
+
)
|
|
82
|
+
from .provenance import Line
|
|
83
|
+
|
|
84
|
+
if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
|
|
85
|
+
from ..illumination import Illumination
|
|
86
|
+
from ..problem import Problem
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"G_HAT",
|
|
90
|
+
"D_HAT",
|
|
91
|
+
"N_HAT",
|
|
92
|
+
"CONE_AXIS",
|
|
93
|
+
"TAG_COLORS",
|
|
94
|
+
"PRESET_VIEWS",
|
|
95
|
+
"RIM_PRESET",
|
|
96
|
+
"rim_box",
|
|
97
|
+
"Ray3D",
|
|
98
|
+
"Surface3D",
|
|
99
|
+
"Curve3D",
|
|
100
|
+
"Point3D",
|
|
101
|
+
"Scene",
|
|
102
|
+
"wave_vector",
|
|
103
|
+
"incident_vector",
|
|
104
|
+
"view_direction",
|
|
105
|
+
"build_scene",
|
|
106
|
+
"captions3d",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
#: `conventions.md` §3, forced by :math:`\hat{d} \times \hat{g} = \hat{n}`.
|
|
110
|
+
D_HAT = np.array([1.0, 0.0, 0.0])
|
|
111
|
+
N_HAT = np.array([0.0, 1.0, 0.0])
|
|
112
|
+
G_HAT = np.array([0.0, 0.0, -1.0])
|
|
113
|
+
|
|
114
|
+
#: The cone opens along :math:`-\hat{g}`, **not** along :math:`\hat{g}`. Every
|
|
115
|
+
#: wave vector carries :math:`k_z = +k\cos\gamma`, so at :math:`\gamma = 1.5°`
|
|
116
|
+
#: every ray lies 1.5° from :math:`+\hat{z}` and 178.5° from :math:`\hat{g}`.
|
|
117
|
+
#: Opening it the other way points every ray 180° out. See
|
|
118
|
+
#: ``docs/conventions.md`` §3 and ``docs/findings.md``.
|
|
119
|
+
CONE_AXIS = -G_HAT
|
|
120
|
+
|
|
121
|
+
#: Grating patch width, in ray-length units. The rays are unit vectors and the
|
|
122
|
+
#: patch is in nm; this is the ratio between those two scales, and it is a
|
|
123
|
+
#: drawing choice that a caption declares.
|
|
124
|
+
#:
|
|
125
|
+
#: Small on purpose. At gamma = 1.5 deg the rays lie within 1.5 deg of the
|
|
126
|
+
#: groove axis, so a patch as long as the rays swallows them; keeping it well
|
|
127
|
+
#: under a ray length lets the bundle emerge and be seen for what it is.
|
|
128
|
+
PATCH_SPAN: float = 0.5
|
|
129
|
+
|
|
130
|
+
#: Half-extent of the scene's bounding cube.
|
|
131
|
+
BOX_HALF: float = 1.15
|
|
132
|
+
|
|
133
|
+
#: How far out the rim-focus box reaches, in units of the rim radius
|
|
134
|
+
#: :math:`\sin\gamma`. 1.6 puts the rim circle across ~62% of the frame.
|
|
135
|
+
RIM_MARGIN: float = 1.6
|
|
136
|
+
|
|
137
|
+
#: Below this, focusing on the rim is not worth offering -- and at
|
|
138
|
+
#: :math:`\gamma = 90°` it would be a zoom *out*.
|
|
139
|
+
MIN_RIM_MAGNIFICATION: float = 1.5
|
|
140
|
+
|
|
141
|
+
#: The direction the camera looks **along**, in the physical
|
|
142
|
+
#: :math:`(\hat{d}, \hat{n}, -\hat{g})` frame of ``conventions.md`` §3.
|
|
143
|
+
#:
|
|
144
|
+
#: Physical, not matplotlib ``(elev, azim)``: the widget permutes the scene to
|
|
145
|
+
#: put :math:`\hat{n}` upward for display, so an ``(elev, azim)`` pair here
|
|
146
|
+
#: would no longer mean what its name says. The widget converts.
|
|
147
|
+
PRESET_VIEWS: dict[str, NDArray[np.float64]] = {
|
|
148
|
+
# Off-axis: the grazing bundle skimming along the grooves.
|
|
149
|
+
"Oblique": np.array([-0.42, -0.35, -0.84]),
|
|
150
|
+
# From +ẑ looking back, so every order projects onto a circle at its true
|
|
151
|
+
# azimuth. Paired with rim focus, this is where the fan is readable.
|
|
152
|
+
"Down the cone axis": G_HAT.copy(),
|
|
153
|
+
# The (n̂, ĝ) plane, where γ itself is the visible angle.
|
|
154
|
+
"Along d̂": -D_HAT.copy(),
|
|
155
|
+
# The grating face-on, grooves running across.
|
|
156
|
+
"Face n̂": -N_HAT.copy(),
|
|
157
|
+
}
|
|
158
|
+
PRESET_VIEWS = {
|
|
159
|
+
name: v / np.linalg.norm(v) for name, v in PRESET_VIEWS.items()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#: The preset whose whole point is the rim, so choosing it switches focus too.
|
|
163
|
+
RIM_PRESET = "Down the cone axis"
|
|
164
|
+
|
|
165
|
+
#: Mesh resolutions. Both surfaces are *ruled* -- the grating patch is the
|
|
166
|
+
#: cross-section extruded along ẑ, and every generator of a cone is straight --
|
|
167
|
+
#: so the second dimension is 2 and that is exact, not a sampling choice.
|
|
168
|
+
_PATCH_NX = {"fine": 241, "coarse": 61}
|
|
169
|
+
_CONE_NPHI = {"fine": 289, "coarse": 73}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class Ray3D:
|
|
174
|
+
"""A wave vector, drawn from ``origin`` along a **unit** ``direction``.
|
|
175
|
+
|
|
176
|
+
Unit, always. The 2D view draws every ray at one arbitrary length because
|
|
177
|
+
only azimuths carry meaning there; here the transverse extent *is* the
|
|
178
|
+
physics -- it is :math:`\\sin\\gamma`, and at 1.5° that is 0.026. A
|
|
179
|
+
renormalised ray would erase exactly the quantity this view exists to
|
|
180
|
+
show.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
origin: tuple[float, float, float]
|
|
184
|
+
direction: tuple[float, float, float]
|
|
185
|
+
length: float
|
|
186
|
+
tag: str
|
|
187
|
+
label: str | None = None
|
|
188
|
+
order: int | None = None
|
|
189
|
+
dashed: bool = False
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def head(self) -> tuple[float, float, float]:
|
|
193
|
+
o, d, s = np.asarray(self.origin), np.asarray(self.direction), self.length
|
|
194
|
+
return tuple(float(v) for v in o + s * d)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def azimuth(self) -> float:
|
|
198
|
+
"""Azimuth from :math:`\\hat{n}` toward :math:`\\hat{d}`, radians --
|
|
199
|
+
the convention every angle in §3 uses. Read back off the drawn vector,
|
|
200
|
+
which is what lets a test recover the grating equation from the
|
|
201
|
+
picture rather than recompute it the same way."""
|
|
202
|
+
return float(np.arctan2(self.direction[0], self.direction[1]))
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def polar_from_cone_axis(self) -> float:
|
|
206
|
+
"""Angle to :math:`-\\hat{g}`. Equals :math:`\\gamma` for every wave
|
|
207
|
+
vector, which is the cone property stated as one number."""
|
|
208
|
+
return float(np.arccos(np.dot(self.direction, CONE_AXIS)))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class Surface3D:
|
|
213
|
+
"""A ruled surface, as ``(n, 2)`` meshgrid arrays for ``plot_surface``."""
|
|
214
|
+
|
|
215
|
+
x: NDArray[np.float64]
|
|
216
|
+
y: NDArray[np.float64]
|
|
217
|
+
z: NDArray[np.float64]
|
|
218
|
+
tag: str
|
|
219
|
+
lod: str = "fine"
|
|
220
|
+
alpha: float = 1.0
|
|
221
|
+
label: str | None = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class Curve3D:
|
|
226
|
+
"""An open or closed 3D path, already sampled."""
|
|
227
|
+
|
|
228
|
+
x: NDArray[np.float64]
|
|
229
|
+
y: NDArray[np.float64]
|
|
230
|
+
z: NDArray[np.float64]
|
|
231
|
+
tag: str
|
|
232
|
+
lod: str = "fine"
|
|
233
|
+
dashed: bool = False
|
|
234
|
+
label: str | None = None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True)
|
|
238
|
+
class Point3D:
|
|
239
|
+
"""A point with a *named* glyph; the widget owns the name-to-marker map."""
|
|
240
|
+
|
|
241
|
+
x: float
|
|
242
|
+
y: float
|
|
243
|
+
z: float
|
|
244
|
+
glyph: str
|
|
245
|
+
tag: str
|
|
246
|
+
label: str | None = None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True, slots=True)
|
|
250
|
+
class Scene:
|
|
251
|
+
"""One geometry, at one wavelength, as things to draw in three dimensions."""
|
|
252
|
+
|
|
253
|
+
rays: tuple[Ray3D, ...]
|
|
254
|
+
surfaces: tuple[Surface3D, ...]
|
|
255
|
+
curves: tuple[Curve3D, ...]
|
|
256
|
+
points: tuple[Point3D, ...]
|
|
257
|
+
orders: tuple[OrderMark, ...]
|
|
258
|
+
captions: tuple[Line, ...]
|
|
259
|
+
title: str
|
|
260
|
+
limits: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
|
|
261
|
+
#: The same scene, framed on the cone rim. **Also a cube**, so moving
|
|
262
|
+
#: between the two is a uniform scale plus a translation -- a similarity
|
|
263
|
+
#: transform, which preserves every angle exactly. Nothing is distorted;
|
|
264
|
+
#: only which part of the geometry fills the frame changes.
|
|
265
|
+
rim_limits: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
|
|
266
|
+
#: How much closer that is, for the label on the canvas.
|
|
267
|
+
rim_magnification: float
|
|
268
|
+
#: Non-empty **exactly** when the rim focus is not worth offering, so a
|
|
269
|
+
#: caller puts *this* in a disabled control's tooltip rather than
|
|
270
|
+
#: inventing one. Same contract as ``blaze_jump``.
|
|
271
|
+
rim_reason: str
|
|
272
|
+
scale_nm: float
|
|
273
|
+
gamma: float
|
|
274
|
+
wavelength: float
|
|
275
|
+
blaze_order: int | None
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def rim_available(self) -> bool:
|
|
279
|
+
return not self.rim_reason
|
|
280
|
+
|
|
281
|
+
def ray(self, order: int) -> Ray3D:
|
|
282
|
+
"""The ray for one order. Raises if it has none -- an evanescent order
|
|
283
|
+
genuinely has no direction, and a default would invent one."""
|
|
284
|
+
for entry in self.rays:
|
|
285
|
+
if entry.order == order:
|
|
286
|
+
return entry
|
|
287
|
+
raise KeyError(f"order {order} has no ray in this scene")
|
|
288
|
+
|
|
289
|
+
def at(self, lod: str) -> tuple[object, ...]:
|
|
290
|
+
"""Every level-of-detail-bearing primitive at one level."""
|
|
291
|
+
return tuple(
|
|
292
|
+
item
|
|
293
|
+
for group in (self.surfaces, self.curves)
|
|
294
|
+
for item in group
|
|
295
|
+
if item.lod == lod
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# -- wave vectors ---------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def wave_vector(azimuth: float, gamma: float) -> NDArray[np.float64]:
|
|
303
|
+
r"""A unit wave vector at the given azimuth, on the cone of half-angle
|
|
304
|
+
:math:`\gamma`.
|
|
305
|
+
|
|
306
|
+
``conventions.md`` §3's :math:`\hat{k}_m`. The :math:`(x, y)` part is
|
|
307
|
+
literally :func:`~gratinglab.gui.diagram.direction` scaled by
|
|
308
|
+
:math:`\sin\gamma`, so the 2D and 3D views cannot disagree about a
|
|
309
|
+
direction; only :math:`+\cos\gamma\,\hat{z}` is new here, and that is
|
|
310
|
+
exactly the component the projection drops.
|
|
311
|
+
"""
|
|
312
|
+
return np.array([*(direction(azimuth) * np.sin(gamma)), np.cos(gamma)])
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def incident_vector(alpha: float, gamma: float) -> NDArray[np.float64]:
|
|
316
|
+
r""":math:`\hat{k}_i`, which travels *toward* the grating (negative
|
|
317
|
+
:math:`\hat{y}`). Equals ``Illumination.direction_cosines``."""
|
|
318
|
+
return np.array([*(-direction(alpha) * np.sin(gamma)), np.cos(gamma)])
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def rim_box(
|
|
322
|
+
gamma: float,
|
|
323
|
+
) -> tuple[
|
|
324
|
+
tuple[tuple[float, float], tuple[float, float], tuple[float, float]], float, str
|
|
325
|
+
]:
|
|
326
|
+
r"""``(limits, magnification, reason)`` for a view framed on the cone rim.
|
|
327
|
+
|
|
328
|
+
The rim is a circle of radius :math:`\sin\gamma` at height
|
|
329
|
+
:math:`\cos\gamma`, so the box is a cube centred there. At
|
|
330
|
+
:math:`\gamma = 1.5°` that is a 27x uniform magnification -- which is what
|
|
331
|
+
turns the 93.8° azimuth fan from a point cluster into something readable,
|
|
332
|
+
without touching a single angle.
|
|
333
|
+
|
|
334
|
+
``reason`` is non-empty **exactly** when the magnification is not worth
|
|
335
|
+
offering. At :math:`\gamma = 90°` the rim already *is* the whole scene and
|
|
336
|
+
focusing on it would zoom *out*; a control that did nothing would be the
|
|
337
|
+
mistake this project has named before.
|
|
338
|
+
"""
|
|
339
|
+
half = RIM_MARGIN * float(np.sin(gamma))
|
|
340
|
+
magnification = BOX_HALF / half if half > 0.0 else float("inf")
|
|
341
|
+
centre = float(np.cos(gamma))
|
|
342
|
+
|
|
343
|
+
limits = (
|
|
344
|
+
(-half, half),
|
|
345
|
+
(-half, half),
|
|
346
|
+
(centre - half, centre + half),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if magnification < MIN_RIM_MAGNIFICATION:
|
|
350
|
+
degrees = np.degrees(gamma)
|
|
351
|
+
if np.isclose(degrees, 90.0):
|
|
352
|
+
reason = (
|
|
353
|
+
"γ = 90°: the cone rim is the whole scene, so there is nothing "
|
|
354
|
+
"to zoom into."
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
reason = (
|
|
358
|
+
f"γ = {degrees:g}° already separates the orders; focusing on "
|
|
359
|
+
"the rim would change little."
|
|
360
|
+
)
|
|
361
|
+
return limits, magnification, reason
|
|
362
|
+
|
|
363
|
+
return limits, magnification, ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def view_direction(elev_deg: float, azim_deg: float) -> NDArray[np.float64]:
|
|
367
|
+
"""The unit vector a matplotlib 3D view looks *along*, from the camera
|
|
368
|
+
toward the origin's far side. Pure, so presets are testable without a
|
|
369
|
+
figure."""
|
|
370
|
+
elev, azim = np.radians(elev_deg), np.radians(azim_deg)
|
|
371
|
+
return np.array(
|
|
372
|
+
[
|
|
373
|
+
-np.cos(elev) * np.cos(azim),
|
|
374
|
+
-np.cos(elev) * np.sin(azim),
|
|
375
|
+
-np.sin(elev),
|
|
376
|
+
]
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# -- captions -------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def scene_title(gamma: float) -> str:
|
|
384
|
+
"""Always states γ, and always states that it is to scale."""
|
|
385
|
+
return f"Conical diffraction — γ = {np.degrees(gamma):g}°, to scale"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def captions3d(
|
|
389
|
+
problem: "Problem",
|
|
390
|
+
illumination: "Illumination",
|
|
391
|
+
wavelength: float,
|
|
392
|
+
marks: Sequence[OrderMark],
|
|
393
|
+
scale_nm: float,
|
|
394
|
+
) -> tuple[Line, ...]:
|
|
395
|
+
"""What the drawing is, and what in it is a drawing choice."""
|
|
396
|
+
live = sum(1 for m in marks if m.propagating)
|
|
397
|
+
dark = len(marks) - live
|
|
398
|
+
gamma = illumination.gamma
|
|
399
|
+
|
|
400
|
+
lines: list[Line] = [
|
|
401
|
+
Line(
|
|
402
|
+
f"λ = {wavelength:.3g} nm. {live} of {len(marks)} orders propagate "
|
|
403
|
+
f"and carry a ray; {dark} are evanescent, have no direction, and "
|
|
404
|
+
"appear on the sin β axis beside this view.\n"
|
|
405
|
+
),
|
|
406
|
+
Line(
|
|
407
|
+
"Every ray is a unit wave vector. Their directions, and the angles "
|
|
408
|
+
"between them, are exact.\n",
|
|
409
|
+
"dim",
|
|
410
|
+
),
|
|
411
|
+
Line(
|
|
412
|
+
f"The grating patch is in nm and the rays are unit vectors, so "
|
|
413
|
+
f"their relative size is a drawing choice ({scale_nm:.1f} nm per "
|
|
414
|
+
"scene unit). The groove cross-section itself is exact.\n",
|
|
415
|
+
"dim",
|
|
416
|
+
),
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
if illumination.is_in_plane:
|
|
420
|
+
lines.append(
|
|
421
|
+
Line(
|
|
422
|
+
"γ = 90°: k_z = 0, the cone degenerates to the dispersion "
|
|
423
|
+
"plane, and every ray lies in it. This view and the "
|
|
424
|
+
"cross-section beside it show the same geometry.\n",
|
|
425
|
+
"dim",
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
lines.append(
|
|
430
|
+
Line(
|
|
431
|
+
f"The cone opens along −ĝ, not ĝ. Every wave vector carries "
|
|
432
|
+
f"k_z = +k cos γ = {np.cos(gamma):.5f} k, so each ray lies "
|
|
433
|
+
f"{np.degrees(gamma):g}° from +ẑ and "
|
|
434
|
+
f"{180 - np.degrees(gamma):g}° from ĝ.\n",
|
|
435
|
+
"dim",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
lines.append(
|
|
439
|
+
Line(
|
|
440
|
+
"The orders differ in azimuth, never in polar angle — they all "
|
|
441
|
+
"sit on this one cone. That is why a cone this narrow can still "
|
|
442
|
+
"carry a wide fan.\n",
|
|
443
|
+
"dim",
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
lines.append(
|
|
448
|
+
Line(
|
|
449
|
+
"The groove profile has no z dependence, so the surface drawn is "
|
|
450
|
+
"the cross-section extruded exactly along ẑ. That is not an "
|
|
451
|
+
"approximation.\n",
|
|
452
|
+
"dim",
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return tuple(lines)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# -- the scene ------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _grating_patch(problem: "Problem", scale_nm: float, periods: int, lod: str):
|
|
462
|
+
"""The cross-section extruded along ẑ. Ruled, so two rows is exact."""
|
|
463
|
+
t = np.linspace(-periods, 0.0, _PATCH_NX[lod])
|
|
464
|
+
strike = np.asarray(strike_point(problem))
|
|
465
|
+
px = (x_nm(t, problem.period) - strike[0]) / scale_nm
|
|
466
|
+
py = (np.asarray(problem.height_nm(t)) - strike[1]) / scale_nm
|
|
467
|
+
half = 0.5 * PATCH_SPAN
|
|
468
|
+
|
|
469
|
+
return Surface3D(
|
|
470
|
+
x=np.column_stack([px, px]),
|
|
471
|
+
y=np.column_stack([py, py]),
|
|
472
|
+
z=np.column_stack([np.full_like(px, -half), np.full_like(px, half)]),
|
|
473
|
+
tag="surface",
|
|
474
|
+
lod=lod,
|
|
475
|
+
alpha=0.55,
|
|
476
|
+
label="grating",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _cone_mantle(gamma: float, lod: str) -> Surface3D:
|
|
481
|
+
"""The cone of half-angle γ about −ĝ. Ruled along its generators."""
|
|
482
|
+
phi = np.linspace(0.0, 2.0 * np.pi, _CONE_NPHI[lod])
|
|
483
|
+
rim = np.array([np.sin(gamma) * np.cos(phi), np.sin(gamma) * np.sin(phi)])
|
|
484
|
+
s = np.array([0.0, 1.0])
|
|
485
|
+
|
|
486
|
+
return Surface3D(
|
|
487
|
+
x=np.outer(rim[0], s),
|
|
488
|
+
y=np.outer(rim[1], s),
|
|
489
|
+
z=np.outer(np.full_like(phi, np.cos(gamma)), s),
|
|
490
|
+
tag="cone",
|
|
491
|
+
lod=lod,
|
|
492
|
+
alpha=0.10,
|
|
493
|
+
label="cone of diffraction",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _cone_rim(gamma: float, lod: str) -> Curve3D:
|
|
498
|
+
phi = np.linspace(0.0, 2.0 * np.pi, _CONE_NPHI[lod])
|
|
499
|
+
return Curve3D(
|
|
500
|
+
x=np.sin(gamma) * np.cos(phi),
|
|
501
|
+
y=np.sin(gamma) * np.sin(phi),
|
|
502
|
+
z=np.full_like(phi, np.cos(gamma)),
|
|
503
|
+
tag="cone",
|
|
504
|
+
lod=lod,
|
|
505
|
+
dashed=True,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def build_scene(
|
|
510
|
+
problem: "Problem",
|
|
511
|
+
illumination: "Illumination",
|
|
512
|
+
wavelength: float,
|
|
513
|
+
*,
|
|
514
|
+
periods: int = 2,
|
|
515
|
+
pad_orders: int = 2,
|
|
516
|
+
) -> Scene:
|
|
517
|
+
"""Everything one 3D drawing consists of."""
|
|
518
|
+
gamma = illumination.gamma
|
|
519
|
+
marks = order_marks(problem, illumination, wavelength, pad=pad_orders)
|
|
520
|
+
scale_nm = periods * problem.period / PATCH_SPAN
|
|
521
|
+
origin = (0.0, 0.0, 0.0)
|
|
522
|
+
rim_limits, rim_magnification, rim_reason = rim_box(gamma)
|
|
523
|
+
|
|
524
|
+
rays: list[Ray3D] = []
|
|
525
|
+
surfaces: list[Surface3D] = []
|
|
526
|
+
curves: list[Curve3D] = []
|
|
527
|
+
points: list[Point3D] = []
|
|
528
|
+
|
|
529
|
+
for lod in ("fine", "coarse"):
|
|
530
|
+
surfaces.append(_grating_patch(problem, scale_nm, periods, lod))
|
|
531
|
+
surfaces.append(_cone_mantle(gamma, lod))
|
|
532
|
+
curves.append(_cone_rim(gamma, lod))
|
|
533
|
+
|
|
534
|
+
# -- axes ------------------------------------------------------------
|
|
535
|
+
rays.append(Ray3D(origin, tuple(G_HAT), 0.85, "axis", label="ĝ (grooves)"))
|
|
536
|
+
rays.append(Ray3D(origin, tuple(CONE_AXIS), 1.0, "cone",
|
|
537
|
+
label="cone axis −ĝ", dashed=True))
|
|
538
|
+
rays.append(Ray3D(origin, tuple(D_HAT), 0.45, "axis", label="d̂"))
|
|
539
|
+
rays.append(Ray3D(origin, tuple(N_HAT), 0.45, "normal", label="n̂"))
|
|
540
|
+
|
|
541
|
+
nx, ny = facet_normal(problem)
|
|
542
|
+
rays.append(
|
|
543
|
+
Ray3D(origin, (float(nx), float(ny), 0.0), 0.5, "normal",
|
|
544
|
+
label="facet normal", dashed=True)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# -- the waves -------------------------------------------------------
|
|
548
|
+
incident = incident_vector(illumination.alpha, gamma)
|
|
549
|
+
rays.append(
|
|
550
|
+
Ray3D(
|
|
551
|
+
tuple(-incident), tuple(incident), 1.0, "incident", label="k̂ᵢ"
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
for mark in marks:
|
|
556
|
+
if mark.beta is None:
|
|
557
|
+
continue # no direction; it stays on the ladder beside this view
|
|
558
|
+
k = wave_vector(mark.beta, gamma)
|
|
559
|
+
rays.append(
|
|
560
|
+
Ray3D(
|
|
561
|
+
origin, tuple(k), 1.0,
|
|
562
|
+
"zero" if mark.order == 0 else "order",
|
|
563
|
+
# 3D text cannot dodge its neighbours, and every order tip is
|
|
564
|
+
# within 1.5 deg of the same point. Only m=0 is labelled here;
|
|
565
|
+
# the ladder beside this view names them all.
|
|
566
|
+
label=f"m={mark.order:+d}" if mark.order == 0 else None,
|
|
567
|
+
order=mark.order,
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
points.append(Point3D(*k, glyph="out-of-page", tag="cone"))
|
|
571
|
+
# Its shadow on the dispersion plane -- the 2D view, literally.
|
|
572
|
+
curves.append(
|
|
573
|
+
Curve3D(
|
|
574
|
+
np.array([0.0, k[0]]), np.array([0.0, k[1]]), np.zeros(2),
|
|
575
|
+
tag="axis", dashed=True,
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
blaze_angle = getattr(problem.profile, "blaze_angle", None)
|
|
580
|
+
if blaze_angle is not None:
|
|
581
|
+
beta_b = blaze_direction(np.radians(blaze_angle), illumination.alpha)
|
|
582
|
+
rays.append(
|
|
583
|
+
Ray3D(origin, tuple(wave_vector(beta_b, gamma)), 1.0, "blaze",
|
|
584
|
+
label=f"β_b = {np.degrees(beta_b):.1f}°")
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
points.append(Point3D(0.0, 0.0, 0.0, glyph="strike", tag="incident"))
|
|
588
|
+
|
|
589
|
+
return Scene(
|
|
590
|
+
rays=tuple(rays),
|
|
591
|
+
surfaces=tuple(surfaces),
|
|
592
|
+
curves=tuple(curves),
|
|
593
|
+
points=tuple(points),
|
|
594
|
+
orders=marks,
|
|
595
|
+
captions=captions3d(problem, illumination, wavelength, marks, scale_nm),
|
|
596
|
+
title=scene_title(gamma),
|
|
597
|
+
limits=(
|
|
598
|
+
(-BOX_HALF, BOX_HALF),
|
|
599
|
+
(-BOX_HALF, BOX_HALF),
|
|
600
|
+
(-BOX_HALF, BOX_HALF),
|
|
601
|
+
),
|
|
602
|
+
rim_limits=rim_limits,
|
|
603
|
+
rim_magnification=rim_magnification,
|
|
604
|
+
rim_reason=rim_reason,
|
|
605
|
+
scale_nm=scale_nm,
|
|
606
|
+
gamma=float(gamma),
|
|
607
|
+
wavelength=float(wavelength),
|
|
608
|
+
blaze_order=None,
|
|
609
|
+
)
|