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,1144 @@
|
|
|
1
|
+
r"""Scalar (Kirchhoff / thin-element) diffraction efficiency.
|
|
2
|
+
|
|
3
|
+
Implemented as the **general Fourier integral**, not as the closed-form sawtooth
|
|
4
|
+
result:
|
|
5
|
+
|
|
6
|
+
.. math::
|
|
7
|
+
G_m = \int_0^1 e^{i\Phi_m(t)}\, e^{-2\pi i m t}\,dt, \qquad
|
|
8
|
+
\Phi_m(t) = k\,g(t)\,\sin\gamma\,\left[\cos\alpha + \cos\beta_m\right]
|
|
9
|
+
|
|
10
|
+
where ``t`` is position normalised to the period and ``g(t)`` is the groove
|
|
11
|
+
height in nm. Efficiency is :math:`\mathscr{E}_m = |G_m|^2`.
|
|
12
|
+
|
|
13
|
+
Two consequences of doing it this way are worth stating plainly.
|
|
14
|
+
|
|
15
|
+
**It accepts any profile**, including a measured AFM boundary, which the closed
|
|
16
|
+
forms cannot. That is what lets a scalar calculation and a PCGrate run be driven
|
|
17
|
+
from the identical geometry.
|
|
18
|
+
|
|
19
|
+
**The closed forms become tests, not code paths.** The Appendix D results --
|
|
20
|
+
sawtooth ``sinc²(φ/2 − mπ)``, square wave, sinusoid -- are exact answers we
|
|
21
|
+
already trust, so they check the numerics rather than duplicating them.
|
|
22
|
+
|
|
23
|
+
**The phase depends on the order** through :math:`\cos\beta_m` (ISSI eq. 15;
|
|
24
|
+
thesis Appendix-D.tex:651), so this is one integral per order rather than a
|
|
25
|
+
single FFT. That choice is deliberate and carries a known cost, because no
|
|
26
|
+
scalar formulation can satisfy all three of the following at once:
|
|
27
|
+
|
|
28
|
+
================== ================================== =========================
|
|
29
|
+
Property order-dependent (this) beta-free alternative
|
|
30
|
+
================== ================================== =========================
|
|
31
|
+
Energy, sum <= 1 violated, up to 1.61 measured exact (Parseval)
|
|
32
|
+
Reciprocity exact, 1e-17 violated, 0.44 measured
|
|
33
|
+
Blaze direction exact at every alpha exact at Littrow only
|
|
34
|
+
================== ================================== =========================
|
|
35
|
+
|
|
36
|
+
The conflict is structural: reciprocity *requires* the phase to be symmetric
|
|
37
|
+
under :math:`\alpha \leftrightarrow \beta_m`, which forces both angles in;
|
|
38
|
+
Parseval *requires* a single fixed function, which forbids :math:`\beta_m`.
|
|
39
|
+
Physical optics is reciprocal but not energy-conserving; the pure
|
|
40
|
+
transmittance-function picture is the reverse.
|
|
41
|
+
|
|
42
|
+
**Reciprocity is the invariant this solver keeps.** The energy deviation is
|
|
43
|
+
therefore expected, is reported rather than hidden, and is never renormalised
|
|
44
|
+
away. It is not an implementation defect: in the shallow-groove limit the sum
|
|
45
|
+
is 1.00000 exactly, degrading smoothly as groove depth grows (0.0001 -> 1.00000,
|
|
46
|
+
0.10 -> 0.92902). The deviation scales with **phase excursion across the
|
|
47
|
+
groove** -- depth relative to wavelength, hence working diffraction order --
|
|
48
|
+
not with lambda/period or the number of propagating orders.
|
|
49
|
+
|
|
50
|
+
``docs/theory/scalar.md`` §5 carries the full derivation and evidence;
|
|
51
|
+
``docs/conventions.md`` §9 is the reference for every formula here.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import time
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
from typing import TYPE_CHECKING, Literal
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
from numpy.typing import ArrayLike, NDArray
|
|
62
|
+
|
|
63
|
+
from ..geometry import (
|
|
64
|
+
beta,
|
|
65
|
+
cos_beta,
|
|
66
|
+
facet_graze,
|
|
67
|
+
flux_obliquity,
|
|
68
|
+
horizon_weights,
|
|
69
|
+
interference_factor,
|
|
70
|
+
is_propagating,
|
|
71
|
+
order_range,
|
|
72
|
+
sin_beta,
|
|
73
|
+
sin_facet_graze,
|
|
74
|
+
)
|
|
75
|
+
from ..illumination import Illumination
|
|
76
|
+
from ..problem import Problem
|
|
77
|
+
from ..result import EfficiencyScan, Provenance
|
|
78
|
+
from ..materials.fresnel import RoughnessModel, amplitude, reflectivity
|
|
79
|
+
from .base import Capabilities, Progress, register
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
|
|
82
|
+
from ..materials import OpticalConstants
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"ReflectivityModel",
|
|
86
|
+
"ScalarSolver",
|
|
87
|
+
"Visibility",
|
|
88
|
+
"interference_factor",
|
|
89
|
+
"scalar",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
#: How reflectivity is resolved across the groove cycle. See
|
|
93
|
+
#: :class:`ScalarSolver.solve` and ``docs/theory/scalar.md`` section 9.
|
|
94
|
+
ReflectivityModel = Literal["local", "average", "facet"]
|
|
95
|
+
|
|
96
|
+
#: Which shadows the visibility masks see. ``"facet-normal"`` is the local
|
|
97
|
+
#: orientation test alone; ``"horizon"`` adds the shadows one part of the
|
|
98
|
+
#: groove casts on another. See :class:`ScalarSolver.solve` and
|
|
99
|
+
#: ``docs/theory/scalar.md`` section 9.
|
|
100
|
+
Visibility = Literal["facet-normal", "horizon"]
|
|
101
|
+
|
|
102
|
+
#: Above this s/p reflectivity split, the unowned groove-TE/TM to facet-s/p
|
|
103
|
+
#: polarization mapping stops being free. Below it, any basis rotation moves
|
|
104
|
+
#: the unpolarized mean by at most half the split -- under 1% -- which is why
|
|
105
|
+
#: the mapping is documented rather than implemented; see
|
|
106
|
+
#: ``docs/theory/scalar.md`` section 9 and ``docs/findings.md``.
|
|
107
|
+
_SP_SPLIT_WARN = 0.02
|
|
108
|
+
|
|
109
|
+
#: Above this ratio of the **reduced** wavelength lambda/sin(gamma) to the
|
|
110
|
+
#: period, scalar theory is losing validity. In-plane this is plain
|
|
111
|
+
#: lambda/period; in a conical mount the reduction is what matters, because
|
|
112
|
+
#: the transverse problem the grooves actually pose lives at the reduced
|
|
113
|
+
#: wavelength (M&P eq. 4.65 -- the same decoupling the integral solver is
|
|
114
|
+
#: built on). Judged on lambda/period alone, the flagship off-plane geometry
|
|
115
|
+
#: reads 0.007 while its effective ratio is 0.32.
|
|
116
|
+
_REDUCED_RATIO_WARN = 0.1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# interference_factor lived here until the resolving-power milestone moved it
|
|
120
|
+
# to gratinglab.geometry, where its consumers outside the solver stack can
|
|
121
|
+
# reach it without importing a solver. Re-exported above (and kept in __all__)
|
|
122
|
+
# so `gratinglab.solvers.scalar.interference_factor` keeps resolving.
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ScalarSolver:
|
|
126
|
+
"""Kirchhoff scalar diffraction from a surface-relief grating."""
|
|
127
|
+
|
|
128
|
+
capabilities = Capabilities(
|
|
129
|
+
name="scalar",
|
|
130
|
+
conical=True,
|
|
131
|
+
# Scalar theory neglects polarization, but "the TE efficiency" is still
|
|
132
|
+
# a question it can answer -- the answer just happens to equal the TM
|
|
133
|
+
# one. That is an approximation to record, not an unsupported
|
|
134
|
+
# configuration to refuse, so every polarization is accepted and the
|
|
135
|
+
# degeneracy is reported on the provenance instead.
|
|
136
|
+
polarizations=("TE", "TM", "unpolarized"),
|
|
137
|
+
accuracy_knob="quadrature_points",
|
|
138
|
+
rigorous=False,
|
|
139
|
+
handles_undercut=False,
|
|
140
|
+
reports_progress=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def solve(
|
|
144
|
+
self,
|
|
145
|
+
problem: Problem,
|
|
146
|
+
illumination: Illumination,
|
|
147
|
+
wavelengths: ArrayLike,
|
|
148
|
+
*,
|
|
149
|
+
quadrature_points: int = 2048,
|
|
150
|
+
roughness_model: "RoughnessModel" = "nevot-croce",
|
|
151
|
+
reflectivity_model: "ReflectivityModel" = "local",
|
|
152
|
+
visibility: "Visibility" = "facet-normal",
|
|
153
|
+
progress: "Progress | None" = None,
|
|
154
|
+
) -> EfficiencyScan:
|
|
155
|
+
r"""Efficiency over a wavelength scan.
|
|
156
|
+
|
|
157
|
+
Efficiency is :math:`\mathscr{E}_m = O_m |G_m|^2` -- the norm-squared
|
|
158
|
+
Fourier coefficient times the **symmetric** flux obliquity
|
|
159
|
+
:math:`4\cos\alpha\cos\beta_m/(\cos\alpha+\cos\beta_m)^2`, and **no
|
|
160
|
+
renormalisation**. Thesis Appendix D carries the *asymmetric* obliquity
|
|
161
|
+
:math:`\cos\beta_m/\cos\alpha`, which breaks reciprocity, and a
|
|
162
|
+
renormalisation to :math:`\sum_m \mathscr{E}_m = 1`, which destroys the
|
|
163
|
+
model's own error signal; both of those are removed. See
|
|
164
|
+
:func:`~gratinglab.geometry.flux_obliquity` for why a flux factor
|
|
165
|
+
nonetheless belongs, and ``tests/test_perturbation.py`` for the
|
|
166
|
+
independent theory that fixes which one.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
quadrature_points
|
|
171
|
+
Samples per period for the Fourier integral. The integrand is
|
|
172
|
+
periodic, so the rectangle rule converges spectrally and the
|
|
173
|
+
default is generous. Must exceed twice the highest order to satisfy
|
|
174
|
+
Nyquist; this is checked.
|
|
175
|
+
roughness_model
|
|
176
|
+
How ``Problem.roughness`` damps the reflectivity: ``"nevot-croce"``
|
|
177
|
+
(default), ``"debye-waller"``, or ``"none"``. Both are legitimate
|
|
178
|
+
and they differ, so the choice is exposed rather than buried --
|
|
179
|
+
Debye-Waller is the more pessimistic near the critical angle
|
|
180
|
+
because it knows nothing about the transmitted wave. Ignored
|
|
181
|
+
without a coating, since there is no reflectivity to damp.
|
|
182
|
+
reflectivity_model
|
|
183
|
+
How reflectivity is resolved **across the groove cycle**. Ignored
|
|
184
|
+
without a coating.
|
|
185
|
+
|
|
186
|
+
``"local"`` (default) evaluates the complex Fresnel amplitude at
|
|
187
|
+
every quadrature point, from the local facet tilt, and carries it
|
|
188
|
+
*inside* the diffraction integral. A groove whose reflectivity
|
|
189
|
+
varies across the cycle is an amplitude grating as well as a phase
|
|
190
|
+
grating, so this makes reflectivity **order-dependent** -- see the
|
|
191
|
+
note below, which corrects the module docstring's earlier claim
|
|
192
|
+
that no such mechanism exists.
|
|
193
|
+
|
|
194
|
+
``"average"`` takes the groove-cycle mean of the *intensity*,
|
|
195
|
+
:math:`\langle R(\zeta(t))\rangle`, and applies it as one factor
|
|
196
|
+
per wavelength. It sees the shadowing and the varying local angle
|
|
197
|
+
but not the interference between them, so it stays
|
|
198
|
+
order-independent -- and, like ``"facet"``, it **breaks
|
|
199
|
+
reciprocity**, because its :math:`\zeta(t)` is built from
|
|
200
|
+
:math:`\alpha` alone. Resolving the groove is not what repairs
|
|
201
|
+
reciprocity; symmetrising in the exit direction is, and only
|
|
202
|
+
``"local"`` does that. Offered as the diagnostic that separates
|
|
203
|
+
shadowing from interference, not as a physical model.
|
|
204
|
+
|
|
205
|
+
``"facet"`` is the M15 treatment: one :math:`R` per wavelength at
|
|
206
|
+
the active-facet angle, applied to every order alike. Kept so a
|
|
207
|
+
prior result can be reproduced exactly, and so the size of the
|
|
208
|
+
change is measurable rather than asserted.
|
|
209
|
+
|
|
210
|
+
Both resolved models zero the reflection where a facet is
|
|
211
|
+
back-facing, in either direction. That is honest geometry and it
|
|
212
|
+
can drive a weak order to exactly zero; when it does, the order is
|
|
213
|
+
named in a provenance warning rather than left to look like a
|
|
214
|
+
passing-off.
|
|
215
|
+
visibility
|
|
216
|
+
Which shadows the masks above can see.
|
|
217
|
+
|
|
218
|
+
``"facet-normal"`` (default) is the local orientation test alone:
|
|
219
|
+
a point is shadowed iff its own facet is turned away from the
|
|
220
|
+
direction in question. That misses **cast** shadows -- the groove
|
|
221
|
+
apex blocking surface beyond the trough that faces the ray
|
|
222
|
+
perfectly well -- which on the reference sawtooth is a fraction
|
|
223
|
+
of a percent of the period on the incident side but 10-50% per
|
|
224
|
+
order on the exit side, moving the blaze order by ~-30%
|
|
225
|
+
(``docs/findings.md``).
|
|
226
|
+
|
|
227
|
+
``"horizon"`` adds them, via
|
|
228
|
+
:func:`~gratinglab.geometry.horizon_visible` in the transverse
|
|
229
|
+
plane, for the incident direction and each diffracted one.
|
|
230
|
+
Occlusion along a straight ray is the same from either end, so
|
|
231
|
+
this keeps the :math:`\alpha \leftrightarrow \beta_m` symmetry
|
|
232
|
+
and reciprocity survives. It is ray optics: near passing-off it
|
|
233
|
+
overestimates blocking because diffraction bends around the apex
|
|
234
|
+
(the vanishing flux obliquity already suppresses those orders).
|
|
235
|
+
Kept opt-in for now so existing results reproduce bit-for-bit;
|
|
236
|
+
like the geometric-mean weight it awaits validation against a
|
|
237
|
+
rigorous finite-conductivity method.
|
|
238
|
+
|
|
239
|
+
With ``"horizon"`` and **no coating**, the masks are applied at
|
|
240
|
+
unit amplitude inside the integral -- geometric shadowing on an
|
|
241
|
+
otherwise perfect reflector, the configuration directly
|
|
242
|
+
comparable to the integral solver on deep grooves. The default
|
|
243
|
+
uncoated run remains the untouched pure phase integral. The
|
|
244
|
+
``"average"`` model sees the horizon only on the incident side
|
|
245
|
+
(its exit side is order-blind by construction), and
|
|
246
|
+
``"facet"`` -- which has no masks at all -- refuses the
|
|
247
|
+
combination rather than ignoring it.
|
|
248
|
+
progress
|
|
249
|
+
Called ``(0, n)`` before the first wavelength and ``(k, n)`` after
|
|
250
|
+
each one, per the contract in
|
|
251
|
+
:class:`~gratinglab.solvers.base.Solver`. Exceptions from it are
|
|
252
|
+
**not** caught, which is what lets a caller raise
|
|
253
|
+
:class:`~gratinglab.solvers.base.SolveCancelled` and actually stop
|
|
254
|
+
this loop. A scan cancelled that way returns nothing at all: a
|
|
255
|
+
partially filled efficiency array with the remaining rows still
|
|
256
|
+
zero is indistinguishable from a scan whose orders all passed off,
|
|
257
|
+
and handing one back would be the silent-wrong-number failure this
|
|
258
|
+
project is arranged against.
|
|
259
|
+
|
|
260
|
+
Notes
|
|
261
|
+
-----
|
|
262
|
+
Summed efficiency will not equal 1, and can exceed it. That is expected
|
|
263
|
+
-- see the module docstring for the three-way tension that makes it
|
|
264
|
+
unavoidable, and why reciprocity is the invariant kept instead. The sum
|
|
265
|
+
is **reported, never rescaled**: renormalising by it (as Appendix D
|
|
266
|
+
does) would erase the model's own signal about how far it has strayed
|
|
267
|
+
from its regime.
|
|
268
|
+
"""
|
|
269
|
+
self.capabilities.check(problem, illumination)
|
|
270
|
+
# Before any work. An unknown material must fail loudly rather than
|
|
271
|
+
# produce a scan that quietly means something else -- see
|
|
272
|
+
# `_resolve_coating`.
|
|
273
|
+
coating = _resolve_coating(problem)
|
|
274
|
+
|
|
275
|
+
wavelengths = np.atleast_1d(np.asarray(wavelengths, dtype=np.float64))
|
|
276
|
+
if wavelengths.ndim != 1:
|
|
277
|
+
raise ValueError("wavelengths must be one-dimensional")
|
|
278
|
+
if (wavelengths <= 0).any():
|
|
279
|
+
raise ValueError("wavelengths must be positive")
|
|
280
|
+
if quadrature_points < 16:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"quadrature_points must be at least 16, got {quadrature_points}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
started = time.perf_counter()
|
|
286
|
+
|
|
287
|
+
sin_alpha = illumination.sin_alpha
|
|
288
|
+
cos_alpha = illumination.cos_alpha
|
|
289
|
+
sin_gamma = illumination.sin_gamma
|
|
290
|
+
|
|
291
|
+
# One column per order, spanning every order that propagates anywhere
|
|
292
|
+
# in the scan, so column j means order j at every wavelength.
|
|
293
|
+
all_orders = _spanning_orders(
|
|
294
|
+
wavelengths, problem.period, sin_alpha, sin_gamma
|
|
295
|
+
)
|
|
296
|
+
max_order = int(np.abs(all_orders).max())
|
|
297
|
+
if quadrature_points <= 2 * max_order:
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"quadrature_points={quadrature_points} cannot resolve order "
|
|
300
|
+
f"{max_order}; Nyquist needs more than {2 * max_order}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
t = np.linspace(0.0, 1.0, quadrature_points, endpoint=False)
|
|
304
|
+
height = np.asarray(problem.height_nm(t), dtype=np.float64)
|
|
305
|
+
# exp(-2*pi*i*m*t) for every order, reused at every wavelength.
|
|
306
|
+
kernel = np.exp(-2j * np.pi * all_orders[:, None] * t[None, :])
|
|
307
|
+
|
|
308
|
+
reflection = _build_reflection(
|
|
309
|
+
problem, illumination, coating, reflectivity_model, visibility, t, height
|
|
310
|
+
)
|
|
311
|
+
resolved = coating is not None and reflection.model != "facet"
|
|
312
|
+
|
|
313
|
+
efficiency = np.zeros((len(wavelengths), len(all_orders)))
|
|
314
|
+
propagating = np.zeros_like(efficiency, dtype=bool)
|
|
315
|
+
# One scale factor per wavelength for the two order-independent models.
|
|
316
|
+
scale = np.ones(len(wavelengths))
|
|
317
|
+
|
|
318
|
+
for row, wavelength in enumerate(wavelengths):
|
|
319
|
+
if progress is not None:
|
|
320
|
+
# At the *top* of the row it is about to compute, so `row` is
|
|
321
|
+
# the count already finished. That makes the first call
|
|
322
|
+
# (0, n) -- before any work, as the contract requires -- and
|
|
323
|
+
# puts a cancellation point ahead of every wavelength rather
|
|
324
|
+
# than behind it. The `continue` below cannot skip it.
|
|
325
|
+
progress(row, len(wavelengths))
|
|
326
|
+
|
|
327
|
+
sines = sin_beta(
|
|
328
|
+
all_orders, wavelength, problem.period, sin_alpha, sin_gamma
|
|
329
|
+
)
|
|
330
|
+
live = is_propagating(sines)
|
|
331
|
+
if not live.any():
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
cosines = cos_beta(sines[live])
|
|
335
|
+
# Phi_m(t) = k g(t) sin(gamma) [cos(alpha) + cos(beta_m)]
|
|
336
|
+
#
|
|
337
|
+
# Symmetric under alpha <-> beta_m, which is exactly what makes the
|
|
338
|
+
# result reciprocal -- and exactly what stops the G_m from being
|
|
339
|
+
# Fourier coefficients of any single function. See the module
|
|
340
|
+
# docstring; that trade is deliberate.
|
|
341
|
+
phase = (2.0 * np.pi / wavelength) * height * sin_gamma
|
|
342
|
+
phi = phase[None, :] * (cos_alpha + cosines)[:, None]
|
|
343
|
+
|
|
344
|
+
integrand = np.exp(1j * phi) * kernel[live]
|
|
345
|
+
# Norm squared of the coefficient, times the flux projection.
|
|
346
|
+
# Still no renormalisation by the sum -- that remains an error.
|
|
347
|
+
#
|
|
348
|
+
# The obliquity factor is *symmetric*, 4 c_a c_b / (c_a + c_b)^2,
|
|
349
|
+
# not the c_b / c_a of thesis Appendix D. That distinction is the
|
|
350
|
+
# whole point: the asymmetric form breaks reciprocity, the
|
|
351
|
+
# symmetric one does not, and only the symmetric one reproduces
|
|
352
|
+
# first-order perturbation theory in the shallow limit. See
|
|
353
|
+
# `geometry.flux_obliquity` and `tests/test_perturbation.py`.
|
|
354
|
+
obliquity = flux_obliquity(cos_alpha, cosines)
|
|
355
|
+
|
|
356
|
+
if resolved and reflection.model == "local":
|
|
357
|
+
values = obliquity * _local_reflected_efficiency(
|
|
358
|
+
integrand,
|
|
359
|
+
reflection,
|
|
360
|
+
coating,
|
|
361
|
+
wavelength,
|
|
362
|
+
beta(sines[live]),
|
|
363
|
+
all_orders[live],
|
|
364
|
+
illumination,
|
|
365
|
+
problem.roughness,
|
|
366
|
+
roughness_model,
|
|
367
|
+
)
|
|
368
|
+
elif coating is None and reflection.visibility == "horizon":
|
|
369
|
+
# Geometric shadowing on a perfect reflector: the masks at
|
|
370
|
+
# unit amplitude, inside the integral. This is the uncoated
|
|
371
|
+
# run made comparable to the integral solver on grooves deep
|
|
372
|
+
# enough to shadow themselves.
|
|
373
|
+
values = obliquity * _shadow_masked_efficiency(
|
|
374
|
+
integrand,
|
|
375
|
+
reflection,
|
|
376
|
+
beta(sines[live]),
|
|
377
|
+
all_orders[live],
|
|
378
|
+
illumination,
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
values = obliquity * np.abs(np.mean(integrand, axis=1)) ** 2
|
|
382
|
+
if resolved: # "average"
|
|
383
|
+
scale[row] = _groove_average_reflectivity(
|
|
384
|
+
reflection,
|
|
385
|
+
coating,
|
|
386
|
+
wavelength,
|
|
387
|
+
problem.roughness,
|
|
388
|
+
roughness_model,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
efficiency[row, live] = values
|
|
392
|
+
propagating[row, live] = True
|
|
393
|
+
|
|
394
|
+
if progress is not None:
|
|
395
|
+
progress(len(wavelengths), len(wavelengths))
|
|
396
|
+
|
|
397
|
+
if coating is not None and reflection.model == "facet":
|
|
398
|
+
# The M15 treatment, kept verbatim: one factor per wavelength,
|
|
399
|
+
# applied to every order alike. It is an approximation whose size is
|
|
400
|
+
# now measurable rather than assumed -- `reflectivity_model="local"`
|
|
401
|
+
# is the same run with the groove resolved.
|
|
402
|
+
scale = reflectivity(
|
|
403
|
+
coating.n(wavelengths),
|
|
404
|
+
reflection.graze,
|
|
405
|
+
# Unpolarized deliberately, whatever the illumination says.
|
|
406
|
+
# These s and p are facet-local and `Illumination.polarization`
|
|
407
|
+
# is groove-referenced (conventions.md 7) -- different frames,
|
|
408
|
+
# and in a conical mount the mapping is not the identity.
|
|
409
|
+
# Resolving polarization here would be false precision on a
|
|
410
|
+
# model that already reports TE and TM as identical, which the
|
|
411
|
+
# solver warns about a few lines below.
|
|
412
|
+
polarization="unpolarized",
|
|
413
|
+
roughness_nm=problem.roughness,
|
|
414
|
+
wavelength_nm=wavelengths,
|
|
415
|
+
model=roughness_model,
|
|
416
|
+
)
|
|
417
|
+
efficiency = efficiency * np.asarray(scale)[:, None]
|
|
418
|
+
|
|
419
|
+
return EfficiencyScan(
|
|
420
|
+
wavelengths=wavelengths,
|
|
421
|
+
orders=all_orders,
|
|
422
|
+
efficiency=efficiency,
|
|
423
|
+
propagating=propagating,
|
|
424
|
+
provenance=self._provenance(
|
|
425
|
+
problem,
|
|
426
|
+
illumination,
|
|
427
|
+
wavelengths,
|
|
428
|
+
quadrature_points,
|
|
429
|
+
efficiency,
|
|
430
|
+
propagating,
|
|
431
|
+
time.perf_counter() - started,
|
|
432
|
+
coating,
|
|
433
|
+
reflection,
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def _provenance(
|
|
438
|
+
self,
|
|
439
|
+
problem: Problem,
|
|
440
|
+
illumination: Illumination,
|
|
441
|
+
wavelengths: NDArray[np.float64],
|
|
442
|
+
quadrature_points: int,
|
|
443
|
+
efficiency: NDArray[np.float64],
|
|
444
|
+
propagating: NDArray[np.bool_],
|
|
445
|
+
elapsed: float,
|
|
446
|
+
coating: "OpticalConstants | None" = None,
|
|
447
|
+
reflection: "_Reflection | None" = None,
|
|
448
|
+
) -> Provenance:
|
|
449
|
+
"""Record the run, including every validity guard it tripped."""
|
|
450
|
+
from .. import __version__
|
|
451
|
+
|
|
452
|
+
if reflection is None: # pragma: no cover - defensive
|
|
453
|
+
reflection = _Reflection(model="facet", graze=0.0, basis="none")
|
|
454
|
+
graze = reflection.guard_graze
|
|
455
|
+
warnings: list[str] = []
|
|
456
|
+
|
|
457
|
+
# The *reduced* ratio, not lambda/period. A conical problem decouples
|
|
458
|
+
# into an in-plane one at lambda/sin(gamma), so "wavelength small
|
|
459
|
+
# against the structure" has to be judged after the reduction --
|
|
460
|
+
# otherwise the guard passes exactly the extreme off-plane mounts it
|
|
461
|
+
# exists for. tests/test_cross_method.py holds lambda/period fixed and
|
|
462
|
+
# closes the cone to show the scalar-vs-integral discrepancy growing;
|
|
463
|
+
# that measurement is what moved sin(gamma) into this line.
|
|
464
|
+
ratio = float(
|
|
465
|
+
wavelengths.max() / (problem.period * illumination.sin_gamma)
|
|
466
|
+
)
|
|
467
|
+
if ratio > _REDUCED_RATIO_WARN:
|
|
468
|
+
warnings.append(
|
|
469
|
+
f"reduced ratio lambda/(period sin gamma) reaches {ratio:.3g}; "
|
|
470
|
+
"scalar theory needs the wavelength small against the "
|
|
471
|
+
"structure, and in a conical mount that comparison happens at "
|
|
472
|
+
"the reduced wavelength lambda/sin(gamma), so orderwise "
|
|
473
|
+
f"accuracy degrades above {_REDUCED_RATIO_WARN} even where "
|
|
474
|
+
"lambda/period looks safe. Order positions and the blaze "
|
|
475
|
+
"envelope remain useful intuition here; per-order numbers "
|
|
476
|
+
"should be cross-checked against a rigorous method -- see "
|
|
477
|
+
"docs/theory/scalar.md section 7"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if problem.roughness > 0:
|
|
481
|
+
# Fraunhofer smoothness criterion, ISSI section 4. Uses the same
|
|
482
|
+
# zeta the reflectivity does, rather than recomputing it for blazed
|
|
483
|
+
# profiles only -- an optically rough surface is rough whatever
|
|
484
|
+
# shape its grooves are, and this used to skip every profile
|
|
485
|
+
# without a `blaze_angle`.
|
|
486
|
+
threshold = 32.0 * np.sin(graze) * problem.roughness
|
|
487
|
+
if wavelengths.min() < threshold:
|
|
488
|
+
warnings.append(
|
|
489
|
+
f"Fraunhofer smoothness criterion violated below "
|
|
490
|
+
f"{threshold:.4g} nm (32 sin(zeta) sigma); the surface is "
|
|
491
|
+
"not optically smooth there"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if coating is not None:
|
|
495
|
+
# Total external reflection, at last. `docs/theory/scalar.md`
|
|
496
|
+
# section 7 has carried this row as "needs a materials layer to
|
|
497
|
+
# evaluate" since it was written; the layer is here.
|
|
498
|
+
#
|
|
499
|
+
# Only with a coating: theta_c comes from the decrement, so without
|
|
500
|
+
# optical constants there is nothing to compare against. That is
|
|
501
|
+
# not a validity concern to warn about, it is a check that does not
|
|
502
|
+
# apply -- the M8 distinction.
|
|
503
|
+
critical = coating.critical_angle(wavelengths)
|
|
504
|
+
past = graze > critical
|
|
505
|
+
if past.any():
|
|
506
|
+
lam = wavelengths[past]
|
|
507
|
+
warnings.append(
|
|
508
|
+
f"facet graze {np.degrees(graze):.4g} deg exceeds the "
|
|
509
|
+
f"critical angle for {coating.name} over "
|
|
510
|
+
f"{lam.min():.4g}-{lam.max():.4g} nm "
|
|
511
|
+
f"(theta_c falls to {np.degrees(critical[past].min()):.4g} "
|
|
512
|
+
"deg there); reflectivity collapses above it, so these "
|
|
513
|
+
"wavelengths are outside the regime a grazing-incidence "
|
|
514
|
+
"design operates in -- see docs/theory/scalar.md section 7"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# The waived polarization mapping, priced. Every reflectivity
|
|
518
|
+
# model here averages facet-local s and p 50/50 because the
|
|
519
|
+
# groove-referenced TE/TM -> facet s/p rotation is deliberately
|
|
520
|
+
# unowned (see materials/fresnel.py). That waiver costs at most
|
|
521
|
+
# half the s/p split, which at the grazes this project runs is
|
|
522
|
+
# under 0.5% -- but it is a measured bound, not a law, so the one
|
|
523
|
+
# angle the guards already track is checked and the waiver is
|
|
524
|
+
# reported exactly when it stops being free.
|
|
525
|
+
split = _sp_split(coating, graze, wavelengths)
|
|
526
|
+
if split > _SP_SPLIT_WARN:
|
|
527
|
+
warnings.append(
|
|
528
|
+
f"s and p reflectivities differ by {100 * split:.3g}% at "
|
|
529
|
+
f"the guarded graze ({np.degrees(graze):.4g} deg); the "
|
|
530
|
+
"50/50 s/p average used here stands in for a "
|
|
531
|
+
"groove-TE/TM to facet-s/p mapping nobody carries, which "
|
|
532
|
+
"is free only while the two are degenerate. Read "
|
|
533
|
+
"polarization-sensitive conclusions from a rigorous "
|
|
534
|
+
"method -- see docs/theory/scalar.md section 9"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if reflection.brewster:
|
|
538
|
+
warnings.append(
|
|
539
|
+
"the p-polarized amplitude passes through zero somewhere on "
|
|
540
|
+
"the lit groove (Brewster), where its phase jumps by pi; the "
|
|
541
|
+
"groove-resolved reflectivity model symmetrises with a "
|
|
542
|
+
"geometric mean and does not carry that jump cleanly. Use "
|
|
543
|
+
"reflectivity_model='average' or 'facet' here, or read this "
|
|
544
|
+
"result as indicative -- see docs/theory/scalar.md section 9"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# No coating is the normal default mode, not a validity concern -- it
|
|
548
|
+
# is reported via notes["normalization"] below, not as a warning.
|
|
549
|
+
# There is nothing wrong with a run that has not been given a coating;
|
|
550
|
+
# warnings are reserved for cases where the model is being pushed
|
|
551
|
+
# outside conditions it can actually answer for.
|
|
552
|
+
|
|
553
|
+
if reflection.suppressed:
|
|
554
|
+
# An order at exactly zero must never be mistaken for one that
|
|
555
|
+
# passed off. Naming it is the whole difference between a
|
|
556
|
+
# modelled result and a silent one. Outside the coating block
|
|
557
|
+
# deliberately: the uncoated horizon mode suppresses orders too.
|
|
558
|
+
named = ", ".join(str(m) for m in sorted(reflection.suppressed))
|
|
559
|
+
mechanism = (
|
|
560
|
+
"the local facet normal"
|
|
561
|
+
if reflection.visibility == "facet-normal"
|
|
562
|
+
else "the local facet normal and the cast-shadow horizon"
|
|
563
|
+
)
|
|
564
|
+
warnings.append(
|
|
565
|
+
f"order(s) {named} are zero at one or more wavelengths in "
|
|
566
|
+
f"this scan because no part of the groove faces both the "
|
|
567
|
+
f"incident and the diffracted direction; "
|
|
568
|
+
f"{100 * reflection.shadowed_fraction:.3g}% of the period "
|
|
569
|
+
f"is shadowed from the beam to begin with. This is geometric "
|
|
570
|
+
f"shadowing by {mechanism}, not passing-off -- "
|
|
571
|
+
"`propagating` still marks these orders live. Scalar theory "
|
|
572
|
+
"has no diffraction into a shadowed direction, so zero is "
|
|
573
|
+
"the model's answer rather than the physical one"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if illumination.polarization != "unpolarized":
|
|
577
|
+
warnings.append(
|
|
578
|
+
f"result is labelled {illumination.polarization} but scalar "
|
|
579
|
+
"theory neglects polarization; TE and TM are identical here"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Report, never rescale. How far an approximate theory strays from a
|
|
583
|
+
# conservation law is information, not something to hide -- and
|
|
584
|
+
# renormalising by the sum (thesis Appendix D) would destroy exactly
|
|
585
|
+
# that information.
|
|
586
|
+
totals = np.where(propagating, efficiency, 0.0).sum(axis=1)
|
|
587
|
+
deviation = float(np.abs(totals - 1.0).max())
|
|
588
|
+
if deviation > 0.01:
|
|
589
|
+
worst = float(totals[np.argmax(np.abs(totals - 1.0))])
|
|
590
|
+
direction = "above" if worst > 1.0 else "below"
|
|
591
|
+
warnings.append(
|
|
592
|
+
f"summed efficiency reaches {worst:.4f}, {100 * deviation:.1f}% "
|
|
593
|
+
f"{direction} unity. Expected: the thin-element approximation "
|
|
594
|
+
"for a reflection grating does not conserve energy, because the "
|
|
595
|
+
"phase depends on the exit direction and the coefficients are "
|
|
596
|
+
"therefore not a Parseval pair. Kept that way because the "
|
|
597
|
+
"energy-conserving alternative violates Lorentz reciprocity. "
|
|
598
|
+
"The deviation grows with phase excursion across the groove "
|
|
599
|
+
"(depth/wavelength, hence working order), and vanishes in the "
|
|
600
|
+
"shallow-groove limit -- see docs/theory/scalar.md section 5"
|
|
601
|
+
+ (
|
|
602
|
+
". Note this run is absolute, so part of the deficit is "
|
|
603
|
+
"ordinary absorption in the coating rather than the "
|
|
604
|
+
"approximation straying"
|
|
605
|
+
if coating is not None and worst < 1.0
|
|
606
|
+
else ""
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
return Provenance(
|
|
611
|
+
method="scalar",
|
|
612
|
+
version=__version__,
|
|
613
|
+
truncation=quadrature_points,
|
|
614
|
+
# A closed-form quadrature that has not been swept is still not a
|
|
615
|
+
# demonstrated convergence. The harness sets this.
|
|
616
|
+
converged=None,
|
|
617
|
+
wall_time_s=elapsed,
|
|
618
|
+
warnings=tuple(warnings),
|
|
619
|
+
notes={
|
|
620
|
+
# Keyed on whether a reflectivity was actually applied, never
|
|
621
|
+
# on whether a coating was *named*. `Problem.coating` used to
|
|
622
|
+
# be a free-form string nothing read, so setting it to anything
|
|
623
|
+
# at all relabelled a result as "absolute" while leaving every
|
|
624
|
+
# number unchanged. M15-D is what makes this ever say
|
|
625
|
+
# "absolute"; until then a named coating is recorded and not
|
|
626
|
+
# yet used, which is what this reports.
|
|
627
|
+
"normalization": "absolute" if coating is not None else "relative",
|
|
628
|
+
**(
|
|
629
|
+
{
|
|
630
|
+
"coating": f"{coating.name} ({coating.source})",
|
|
631
|
+
"reflectivity_model": reflection.model,
|
|
632
|
+
"reflectivity_graze": reflection.basis,
|
|
633
|
+
}
|
|
634
|
+
if coating is not None
|
|
635
|
+
else {}
|
|
636
|
+
),
|
|
637
|
+
**(
|
|
638
|
+
# Whenever visibility masks actually participated --
|
|
639
|
+
# groove-resolved reflectivity, or the uncoated horizon
|
|
640
|
+
# mode. A "facet" run has no masks and gets neither key.
|
|
641
|
+
{
|
|
642
|
+
"visibility": reflection.visibility,
|
|
643
|
+
"shadowed_fraction": reflection.shadowed_fraction,
|
|
644
|
+
}
|
|
645
|
+
if reflection.masked
|
|
646
|
+
else {}
|
|
647
|
+
),
|
|
648
|
+
"alpha_deg": illumination.alpha_deg,
|
|
649
|
+
"gamma_deg": illumination.gamma_deg,
|
|
650
|
+
},
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@dataclass
|
|
655
|
+
class _Reflection:
|
|
656
|
+
r"""Everything the reflectivity treatment knows about one solve.
|
|
657
|
+
|
|
658
|
+
Built once, before the wavelength loop, because the geometry it holds --
|
|
659
|
+
which parts of the groove face the light, and at what local angle -- depends
|
|
660
|
+
on :math:`\alpha` and the profile but not on wavelength or order. Only the
|
|
661
|
+
optical constants vary down the scan.
|
|
662
|
+
|
|
663
|
+
Carries the provenance material too, so the reporting and the arithmetic
|
|
664
|
+
cannot drift apart: ``shadowed_fraction`` and ``suppressed`` are what the
|
|
665
|
+
solve actually did, not a second estimate of it.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
model: ReflectivityModel
|
|
669
|
+
#: Representative graze for the validity guards, radians.
|
|
670
|
+
graze: float
|
|
671
|
+
basis: str
|
|
672
|
+
visibility: "Visibility" = "facet-normal"
|
|
673
|
+
#: Local graze across the visible groove, radians. Empty when unused.
|
|
674
|
+
local_graze: NDArray[np.float64] = field(default_factory=lambda: np.array([]))
|
|
675
|
+
#: sin(zeta) toward the incident direction, per quadrature point, unclipped.
|
|
676
|
+
sin_graze_in: NDArray[np.float64] = field(default_factory=lambda: np.array([]))
|
|
677
|
+
tilt: NDArray[np.float64] = field(default_factory=lambda: np.array([]))
|
|
678
|
+
#: Incident-side quadrature weights: binary orientation under
|
|
679
|
+
#: "facet-normal", sub-cell shadow boundaries from
|
|
680
|
+
#: :func:`~gratinglab.geometry.horizon_weights` under "horizon". Zero
|
|
681
|
+
#: exactly where the beam cannot reach.
|
|
682
|
+
weight_in: NDArray[np.float64] = field(default_factory=lambda: np.array([]))
|
|
683
|
+
#: Grid geometry for the exit-side horizon scans. Empty when unused.
|
|
684
|
+
height_nm: NDArray[np.float64] = field(default_factory=lambda: np.array([]))
|
|
685
|
+
period: float = 0.0
|
|
686
|
+
shadowed_fraction: float = 0.0
|
|
687
|
+
#: Orders driven to zero purely by the exit-direction visibility mask.
|
|
688
|
+
suppressed: set[int] = field(default_factory=set)
|
|
689
|
+
brewster: bool = False
|
|
690
|
+
|
|
691
|
+
@property
|
|
692
|
+
def visible_in(self) -> NDArray[np.bool_]:
|
|
693
|
+
return self.weight_in > 0.0
|
|
694
|
+
|
|
695
|
+
@property
|
|
696
|
+
def masked(self) -> bool:
|
|
697
|
+
"""Did visibility masks participate in the solve at all?"""
|
|
698
|
+
return self.sin_graze_in.size > 0
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def guard_graze(self) -> float:
|
|
702
|
+
"""The angle the validity guards should be measured against.
|
|
703
|
+
|
|
704
|
+
The **worst** local graze when the model resolves the groove, because
|
|
705
|
+
total external reflection fails first wherever the surface is steepest
|
|
706
|
+
toward the beam; the single facet angle when the model only knows one.
|
|
707
|
+
A guard should describe the model that ran.
|
|
708
|
+
"""
|
|
709
|
+
if self.model == "facet" or self.local_graze.size == 0:
|
|
710
|
+
return self.graze
|
|
711
|
+
return float(self.local_graze.max())
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _facet_tilt(profile, t: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
715
|
+
r"""Local facet tilt :math:`\delta(t)`, radians, from the groove slope.
|
|
716
|
+
|
|
717
|
+
:math:`\tan\delta = +\,dy/dt` in normalised units. **The sign is the whole
|
|
718
|
+
content of this function.** ``docs/findings.md`` records that the profile
|
|
719
|
+
parameter runs against the periodicity direction, which makes the opposite
|
|
720
|
+
sign look equally plausible; it is wrong. The check that settles it is that
|
|
721
|
+
an ideal sawtooth must come back with :math:`\delta` equal to its own blaze
|
|
722
|
+
angle everywhere, so that ``sin_facet_graze`` reproduces
|
|
723
|
+
:func:`~gratinglab.geometry.facet_graze` exactly. At
|
|
724
|
+
:math:`\gamma = 1.25°,\ \alpha = 19.99°` on a 29.5° blaze the right sign
|
|
725
|
+
gives 1.2328° and the wrong one gives 0.8119°, and both look like angles.
|
|
726
|
+
|
|
727
|
+
Uses the profile's analytic slope where it has one. ``Blazed`` with a
|
|
728
|
+
vertical anti-blaze facet and ``Lamellar`` both *refuse* to supply one --
|
|
729
|
+
correctly, since the C-method cannot represent a discontinuity -- so this
|
|
730
|
+
falls back to a periodic central difference on ``height``. That is
|
|
731
|
+
legitimate here in a way it would not be there: a vertical wall has zero
|
|
732
|
+
horizontal extent, so it carries zero weight in an integral over ``dt``, and
|
|
733
|
+
the finite-difference smear touches the two samples either side of the
|
|
734
|
+
discontinuity and shrinks with the grid.
|
|
735
|
+
"""
|
|
736
|
+
from ..profiles import ProfileRepresentationError
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
slope = np.asarray(profile.slope(t), dtype=np.float64)
|
|
740
|
+
except ProfileRepresentationError:
|
|
741
|
+
# Requires the uniform periodic grid `solve` builds; it is the only
|
|
742
|
+
# caller, and a non-uniform grid would silently mis-scale the result.
|
|
743
|
+
step = 1.0 / len(t)
|
|
744
|
+
heights = np.asarray(profile.height(t), dtype=np.float64)
|
|
745
|
+
slope = (np.roll(heights, -1) - np.roll(heights, 1)) / (2.0 * step)
|
|
746
|
+
return np.arctan(slope)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _build_reflection(
|
|
750
|
+
problem: Problem,
|
|
751
|
+
illumination: Illumination,
|
|
752
|
+
coating: "OpticalConstants | None",
|
|
753
|
+
model: "ReflectivityModel",
|
|
754
|
+
visibility: "Visibility",
|
|
755
|
+
t: NDArray[np.float64],
|
|
756
|
+
height: NDArray[np.float64],
|
|
757
|
+
) -> _Reflection:
|
|
758
|
+
"""Resolve the groove geometry the reflectivity treatment will use."""
|
|
759
|
+
if model not in ("local", "average", "facet"):
|
|
760
|
+
raise ValueError(
|
|
761
|
+
f"reflectivity_model must be 'local', 'average' or 'facet', got "
|
|
762
|
+
f"{model!r}"
|
|
763
|
+
)
|
|
764
|
+
if visibility not in ("facet-normal", "horizon"):
|
|
765
|
+
raise ValueError(
|
|
766
|
+
f"visibility must be 'facet-normal' or 'horizon', got {visibility!r}"
|
|
767
|
+
)
|
|
768
|
+
if visibility == "horizon" and coating is not None and model == "facet":
|
|
769
|
+
# Accepting this and ignoring the horizon would be the silent wrong
|
|
770
|
+
# answer this project is arranged against.
|
|
771
|
+
raise ValueError(
|
|
772
|
+
"visibility='horizon' cannot act under reflectivity_model='facet': "
|
|
773
|
+
"that model applies one reflectivity to the whole groove and "
|
|
774
|
+
"carries no per-point masks for a horizon to narrow. Use 'local' "
|
|
775
|
+
"or 'average', or keep the default visibility."
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
graze, basis = _reflecting_graze(problem, illumination)
|
|
779
|
+
reflection = _Reflection(
|
|
780
|
+
model=model, graze=graze, basis=basis, visibility=visibility
|
|
781
|
+
)
|
|
782
|
+
resolved = coating is not None and model != "facet"
|
|
783
|
+
if not resolved and visibility != "horizon":
|
|
784
|
+
# Nothing to resolve. Without a coating there is no reflectivity at all,
|
|
785
|
+
# and `facet` is the model that deliberately declines to look.
|
|
786
|
+
return reflection
|
|
787
|
+
|
|
788
|
+
tilt = _facet_tilt(problem.profile, t)
|
|
789
|
+
sin_in = sin_facet_graze(illumination.gamma, tilt, illumination.alpha)
|
|
790
|
+
|
|
791
|
+
reflection.tilt = tilt
|
|
792
|
+
reflection.sin_graze_in = sin_in
|
|
793
|
+
if visibility == "horizon":
|
|
794
|
+
# The horizon alone. A back-facing point has a falling ray-adapted
|
|
795
|
+
# height, which puts it under the running horizon, so the orientation
|
|
796
|
+
# test is a subset of this one -- and the horizon's boundaries are
|
|
797
|
+
# geometric crossings `horizon_weights` places at sub-cell precision,
|
|
798
|
+
# where the orientation function just jumps at profile corners.
|
|
799
|
+
weight_in = horizon_weights(
|
|
800
|
+
height, problem.period, illumination.alpha
|
|
801
|
+
)
|
|
802
|
+
reflection.height_nm = height
|
|
803
|
+
reflection.period = problem.period
|
|
804
|
+
else:
|
|
805
|
+
# Binary, deliberately: the orientation function jumps at profile
|
|
806
|
+
# corners, where a linear crossing estimate is biased rather than
|
|
807
|
+
# sharp, and bit-for-bit reproducibility is this mode's purpose.
|
|
808
|
+
weight_in = (sin_in > 0.0).astype(np.float64)
|
|
809
|
+
reflection.weight_in = weight_in
|
|
810
|
+
|
|
811
|
+
visible = reflection.visible_in
|
|
812
|
+
reflection.shadowed_fraction = float(1.0 - weight_in.mean())
|
|
813
|
+
reflection.local_graze = np.arcsin(np.clip(sin_in[visible], -1.0, 1.0))
|
|
814
|
+
if visible.any():
|
|
815
|
+
reflection.basis = (
|
|
816
|
+
f"groove cycle resolved, "
|
|
817
|
+
f"{np.degrees(reflection.local_graze.min()):.4g}"
|
|
818
|
+
f"-{np.degrees(reflection.local_graze.max()):.4g} deg over the "
|
|
819
|
+
f"{100 * (1 - reflection.shadowed_fraction):.3g}% of the period "
|
|
820
|
+
"that faces the beam"
|
|
821
|
+
)
|
|
822
|
+
else: # pragma: no cover - needs a groove the beam cannot see at all
|
|
823
|
+
reflection.basis = "groove cycle resolved, fully shadowed"
|
|
824
|
+
return reflection
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _groove_average_reflectivity(
|
|
828
|
+
reflection: _Reflection,
|
|
829
|
+
coating: "OpticalConstants",
|
|
830
|
+
wavelength: float,
|
|
831
|
+
roughness_nm: float,
|
|
832
|
+
roughness_model: "RoughnessModel",
|
|
833
|
+
) -> float:
|
|
834
|
+
r""":math:`\langle R(\zeta(t))\rangle` over the whole period.
|
|
835
|
+
|
|
836
|
+
Over the **whole** period, not just the lit part: a shadowed facet reflects
|
|
837
|
+
nothing, and averaging only over what is visible would quietly delete the
|
|
838
|
+
shadowing this model exists to see. Under ``"horizon"`` the shadow
|
|
839
|
+
boundary enters at sub-cell precision through the mask weights
|
|
840
|
+
(:func:`~gratinglab.geometry.horizon_weights`).
|
|
841
|
+
"""
|
|
842
|
+
weights = reflection.weight_in
|
|
843
|
+
graze = np.arcsin(np.clip(reflection.sin_graze_in, -1.0, 1.0))
|
|
844
|
+
local = reflectivity(
|
|
845
|
+
coating.n(wavelength),
|
|
846
|
+
graze,
|
|
847
|
+
polarization="unpolarized",
|
|
848
|
+
roughness_nm=roughness_nm,
|
|
849
|
+
wavelength_nm=wavelength,
|
|
850
|
+
model=roughness_model,
|
|
851
|
+
)
|
|
852
|
+
return float(np.mean(np.where(weights > 0.0, local, 0.0) * weights))
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _local_reflected_efficiency(
|
|
856
|
+
integrand: NDArray[np.complex128],
|
|
857
|
+
reflection: _Reflection,
|
|
858
|
+
coating: "OpticalConstants",
|
|
859
|
+
wavelength: float,
|
|
860
|
+
betas: NDArray[np.float64],
|
|
861
|
+
orders: NDArray[np.int64],
|
|
862
|
+
illumination: Illumination,
|
|
863
|
+
roughness_nm: float,
|
|
864
|
+
roughness_model: "RoughnessModel",
|
|
865
|
+
) -> NDArray[np.float64]:
|
|
866
|
+
r"""Reflection resolved across the groove, carried inside the integral.
|
|
867
|
+
|
|
868
|
+
.. math::
|
|
869
|
+
\mathscr{E}_m = \tfrac{1}{2}\left(
|
|
870
|
+
\left|\int r_s(t)\,e^{i\Phi_m(t)}e^{-2\pi i m t}dt\right|^2 +
|
|
871
|
+
\left|\int r_p(t)\,\cdots\right|^2\right)
|
|
872
|
+
|
|
873
|
+
**Why a geometric mean.** The obvious local coefficient,
|
|
874
|
+
:math:`r(\zeta_i(t))`, depends on :math:`\alpha` alone, which destroys the
|
|
875
|
+
:math:`\alpha\leftrightarrow\beta_m` symmetry and breaks Lorentz
|
|
876
|
+
reciprocity -- by 82% on the reference geometry, measured. Weighting a facet
|
|
877
|
+
by both the angle it receives at and the angle it emits at,
|
|
878
|
+
|
|
879
|
+
.. math:: r^{\text{gm}}(t) = \sqrt{r(\zeta_i(t))}\,\sqrt{r(\zeta_{d,m}(t))}
|
|
880
|
+
|
|
881
|
+
is symmetric by construction, holds reciprocity to :math:`6\times10^{-15}`,
|
|
882
|
+
and collapses to plain :math:`r(\zeta)` wherever the facet is at specular --
|
|
883
|
+
so a perfectly blazed groove is unaffected by the change.
|
|
884
|
+
|
|
885
|
+
**The square roots are taken separately**, rather than as
|
|
886
|
+
:math:`\sqrt{r_i r_d}`, so that the only discontinuities left in the weight
|
|
887
|
+
are the ones :math:`r` itself has.
|
|
888
|
+
|
|
889
|
+
How much that is worth was measured rather than assumed, and it is less than
|
|
890
|
+
it first appears. Where the product's argument wraps *uniformly* across the
|
|
891
|
+
groove -- which is the grazing case, since :math:`\arg r` is then nearly the
|
|
892
|
+
same at every :math:`t` -- the two forms differ by a **global** sign, and a
|
|
893
|
+
global sign cancels out of :math:`|\int\cdots|^2`. On Au at 1-6 nm they
|
|
894
|
+
agree to :math:`10^{-15}`.
|
|
895
|
+
|
|
896
|
+
They part company only where the wrap is *non-uniform*: near normal
|
|
897
|
+
incidence on a deep groove, where :math:`\arg r_p` sweeps through the
|
|
898
|
+
Brewster jump and different parts of the same groove land on opposite sides
|
|
899
|
+
of the cut. There the difference reaches 12% -- on orders carrying
|
|
900
|
+
:math:`10^{-9}`, and in a regime `_brewster_crossing` already reports as
|
|
901
|
+
outside what this symmetrisation can carry.
|
|
902
|
+
|
|
903
|
+
So this is the cheap defensive choice, not a load-bearing one. Stated that
|
|
904
|
+
way because the first draft of this docstring claimed the product wrapped in
|
|
905
|
+
the grazing regime and that the separate roots were therefore essential;
|
|
906
|
+
the measurement says otherwise.
|
|
907
|
+
"""
|
|
908
|
+
n_c = coating.n(wavelength)
|
|
909
|
+
common = dict(
|
|
910
|
+
roughness_nm=roughness_nm, wavelength_nm=wavelength, model=roughness_model
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
graze_in = np.arcsin(np.clip(reflection.sin_graze_in, -1.0, 1.0))
|
|
914
|
+
r_s_in, r_p_in = amplitude(n_c, graze_in, **common)
|
|
915
|
+
|
|
916
|
+
sin_out = sin_facet_graze(
|
|
917
|
+
illumination.gamma, reflection.tilt[None, :], betas[:, None]
|
|
918
|
+
)
|
|
919
|
+
# Minimum, not product: the incident and exit shadows share boundaries
|
|
920
|
+
# (every direction's shadow starts at the same apex corner), and where
|
|
921
|
+
# two same-side boundaries land in one cell the lit overlap is the
|
|
922
|
+
# smaller of the two sub-cell weights -- multiplying them would count
|
|
923
|
+
# the shared boundary twice and put an O(1/n) error on the blaze order.
|
|
924
|
+
coverage = np.minimum(
|
|
925
|
+
reflection.weight_in[None, :], _exit_weights(reflection, betas, sin_out)
|
|
926
|
+
)
|
|
927
|
+
visible = coverage > 0.0
|
|
928
|
+
graze_out = np.arcsin(np.clip(sin_out, -1.0, 1.0))
|
|
929
|
+
r_s_out, r_p_out = amplitude(n_c, graze_out, **common)
|
|
930
|
+
|
|
931
|
+
zero = np.zeros((), dtype=np.complex128)
|
|
932
|
+
w_s = np.where(visible, np.sqrt(r_s_in)[None, :] * np.sqrt(r_s_out), zero) * coverage
|
|
933
|
+
w_p = np.where(visible, np.sqrt(r_p_in)[None, :] * np.sqrt(r_p_out), zero) * coverage
|
|
934
|
+
|
|
935
|
+
# An order no facet can radiate into is zero for a geometric reason, not
|
|
936
|
+
# because it passed off. Record it so the provenance can say which.
|
|
937
|
+
reflection.suppressed.update(
|
|
938
|
+
int(m) for m, lit in zip(orders, visible.any(axis=1)) if not lit
|
|
939
|
+
)
|
|
940
|
+
reflection.brewster |= _brewster_crossing(r_p_in[reflection.visible_in])
|
|
941
|
+
|
|
942
|
+
return 0.5 * (
|
|
943
|
+
np.abs(np.mean(w_s * integrand, axis=1)) ** 2
|
|
944
|
+
+ np.abs(np.mean(w_p * integrand, axis=1)) ** 2
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _exit_weights(
|
|
949
|
+
reflection: _Reflection,
|
|
950
|
+
betas: NDArray[np.float64],
|
|
951
|
+
sin_out: NDArray[np.float64],
|
|
952
|
+
) -> NDArray[np.float64]:
|
|
953
|
+
"""Exit-side mask weights, one row per order: orientation, and under
|
|
954
|
+
``"horizon"`` the cast shadows toward each diffracted direction.
|
|
955
|
+
|
|
956
|
+
The same scans the incident side ran, at each :math:`\\beta_m` --
|
|
957
|
+
occlusion along a straight ray reads the same from either end, which is
|
|
958
|
+
what keeps the horizon model reciprocal.
|
|
959
|
+
"""
|
|
960
|
+
if reflection.visibility == "horizon":
|
|
961
|
+
# The horizon alone, for the same two reasons as the incident side:
|
|
962
|
+
# it subsumes the orientation test, and its boundaries are geometric
|
|
963
|
+
# crossings rather than jumps at profile corners.
|
|
964
|
+
return np.stack(
|
|
965
|
+
[
|
|
966
|
+
horizon_weights(
|
|
967
|
+
reflection.height_nm, reflection.period, float(b)
|
|
968
|
+
)
|
|
969
|
+
for b in betas
|
|
970
|
+
]
|
|
971
|
+
)
|
|
972
|
+
return (sin_out > 0.0).astype(np.float64)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _shadow_masked_efficiency(
|
|
976
|
+
integrand: NDArray[np.complex128],
|
|
977
|
+
reflection: _Reflection,
|
|
978
|
+
betas: NDArray[np.float64],
|
|
979
|
+
orders: NDArray[np.int64],
|
|
980
|
+
illumination: Illumination,
|
|
981
|
+
) -> NDArray[np.float64]:
|
|
982
|
+
r"""The visibility masks at unit amplitude: shadowing without a material.
|
|
983
|
+
|
|
984
|
+
The uncoated ``visibility="horizon"`` branch. Same geometry as the
|
|
985
|
+
``"local"`` model -- a point contributes only if it faces, and is not
|
|
986
|
+
cast-shadowed from, both the incident and the diffracted direction -- but
|
|
987
|
+
with :math:`r \equiv 1` in place of a Fresnel amplitude. That makes it a
|
|
988
|
+
perfect reflector with shadows, the configuration a perfect-conductor
|
|
989
|
+
cross-check against the integral solver actually wants on grooves deep
|
|
990
|
+
enough to shadow themselves.
|
|
991
|
+
"""
|
|
992
|
+
sin_out = sin_facet_graze(
|
|
993
|
+
illumination.gamma, reflection.tilt[None, :], betas[:, None]
|
|
994
|
+
)
|
|
995
|
+
# Minimum, not product -- see `_local_reflected_efficiency`.
|
|
996
|
+
coverage = np.minimum(
|
|
997
|
+
reflection.weight_in[None, :], _exit_weights(reflection, betas, sin_out)
|
|
998
|
+
)
|
|
999
|
+
visible = coverage > 0.0
|
|
1000
|
+
|
|
1001
|
+
reflection.suppressed.update(
|
|
1002
|
+
int(m) for m, lit in zip(orders, visible.any(axis=1)) if not lit
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
return np.abs(np.mean(np.where(visible, integrand, 0.0) * coverage, axis=1)) ** 2
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _sp_split(
|
|
1009
|
+
coating: "OpticalConstants",
|
|
1010
|
+
graze: float,
|
|
1011
|
+
wavelengths: NDArray[np.float64],
|
|
1012
|
+
) -> float:
|
|
1013
|
+
r"""Worst fractional s/p intensity split, :math:`|R_s - R_p| / R_s`.
|
|
1014
|
+
|
|
1015
|
+
Evaluated at the guarded graze and the scan's wavelength extremes only --
|
|
1016
|
+
the split grows monotonically with graze in this regime, so the angle the
|
|
1017
|
+
validity guards already watch is the right single point, and two
|
|
1018
|
+
wavelengths bound a table that varies smoothly between them. Roughness is
|
|
1019
|
+
left out deliberately: the damping factors apply to s and p alike and
|
|
1020
|
+
would cancel from the ratio.
|
|
1021
|
+
"""
|
|
1022
|
+
split = 0.0
|
|
1023
|
+
for wavelength in (float(wavelengths.min()), float(wavelengths.max())):
|
|
1024
|
+
r_s, r_p = amplitude(coating.n(wavelength), graze)
|
|
1025
|
+
big, small = np.abs(r_s) ** 2, np.abs(r_p) ** 2
|
|
1026
|
+
if big > 0.0:
|
|
1027
|
+
split = max(split, float(np.abs(big - small) / big))
|
|
1028
|
+
return split
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _brewster_crossing(r_p: NDArray[np.complex128]) -> bool:
|
|
1032
|
+
r"""Does :math:`r_p` pass through zero somewhere on the lit groove?
|
|
1033
|
+
|
|
1034
|
+
At Brewster's angle the p amplitude changes sign, so its phase jumps by
|
|
1035
|
+
:math:`\pi` and the half-phase the geometric mean takes jumps by
|
|
1036
|
+
:math:`\pi/2`. The jump is real physics, but the geometric-mean
|
|
1037
|
+
symmetrisation is not built to carry it, so the result is reported as
|
|
1038
|
+
suspect rather than quietly returned.
|
|
1039
|
+
|
|
1040
|
+
Only reachable with steep grooves near normal incidence -- at the grazing
|
|
1041
|
+
angles this project is aimed at, :math:`r_p` stays well away from zero.
|
|
1042
|
+
"""
|
|
1043
|
+
if r_p.size == 0:
|
|
1044
|
+
return False
|
|
1045
|
+
magnitude = np.abs(r_p)
|
|
1046
|
+
return bool(magnitude.min() < 0.05 * magnitude.max())
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _reflecting_graze(problem: Problem, illumination: Illumination):
|
|
1050
|
+
r"""``(zeta, description)`` -- the angle the surface reflects at.
|
|
1051
|
+
|
|
1052
|
+
A blazed profile has one flat active facet tilted by ``blaze_angle``, and
|
|
1053
|
+
:math:`\sin\zeta = \sin\gamma\,\cos(\delta - \alpha)` is exact for it.
|
|
1054
|
+
|
|
1055
|
+
Nothing else has a single facet angle, so the reflection is evaluated on
|
|
1056
|
+
the **mean surface** -- which is `facet_graze` with a zero tilt, not a
|
|
1057
|
+
separate formula:
|
|
1058
|
+
|
|
1059
|
+
>>> facet_graze(gamma, 0.0, alpha) # doctest: +SKIP
|
|
1060
|
+
... # == arcsin(|k_i . n|)
|
|
1061
|
+
|
|
1062
|
+
How good that is depends on the profile, and the description says which:
|
|
1063
|
+
|
|
1064
|
+
- **Lamellar** tops and bottoms genuinely *are* parallel to the mean
|
|
1065
|
+
surface, so this is exact for them. What it omits is the vertical walls,
|
|
1066
|
+
which at grazing incidence are nearly edge-on.
|
|
1067
|
+
- **Sinusoidal** and measured profiles have a local slope that varies
|
|
1068
|
+
across the groove, so the mean is an approximation and is recorded as
|
|
1069
|
+
one.
|
|
1070
|
+
|
|
1071
|
+
A measured profile is the one case that can escape this. If it arrived from
|
|
1072
|
+
metrology with a fitted ``blaze_angle`` it takes the exact facet branch
|
|
1073
|
+
above, marked ``fitted`` rather than ``declared``. Without one it falls back
|
|
1074
|
+
to the mean surface -- which for a sawtooth at grazing incidence is wrong by
|
|
1075
|
+
the whole blaze angle, so the fallback is a real loss and not a formality.
|
|
1076
|
+
|
|
1077
|
+
The alternative -- refusing any profile without a facet angle -- would be
|
|
1078
|
+
worse than approximating: a sinusoid at grazing incidence does reflect,
|
|
1079
|
+
and a solver that declines to say how much has not become more honest.
|
|
1080
|
+
"""
|
|
1081
|
+
kind = type(problem.profile).__name__
|
|
1082
|
+
|
|
1083
|
+
blaze_angle = getattr(problem.profile, "blaze_angle", None)
|
|
1084
|
+
if blaze_angle is not None:
|
|
1085
|
+
# A measured profile may carry a fitted facet angle. The geometry is
|
|
1086
|
+
# used identically either way -- but the reader is entitled to know
|
|
1087
|
+
# whether the tilt was specified or estimated, because only one of
|
|
1088
|
+
# those has an uncertainty attached to it.
|
|
1089
|
+
origin = "fitted" if kind == "FromProfileData" else "declared"
|
|
1090
|
+
return (
|
|
1091
|
+
facet_graze(illumination.gamma, np.radians(blaze_angle), illumination.alpha),
|
|
1092
|
+
f"active facet, {blaze_angle:g} deg tilt ({origin})",
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
exact = kind == "Lamellar"
|
|
1096
|
+
return (
|
|
1097
|
+
facet_graze(illumination.gamma, 0.0, illumination.alpha),
|
|
1098
|
+
f"mean surface ({kind} has no single facet angle; "
|
|
1099
|
+
+ (
|
|
1100
|
+
"exact for its flat tops and bottoms, omits the vertical walls)"
|
|
1101
|
+
if exact
|
|
1102
|
+
else "the local slope varies across the groove, so this is an "
|
|
1103
|
+
"approximation)"
|
|
1104
|
+
),
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _resolve_coating(problem: Problem) -> "OpticalConstants | None":
|
|
1109
|
+
"""Turn ``Problem.coating`` into optical constants, or refuse.
|
|
1110
|
+
|
|
1111
|
+
``coating`` is a material *name*, so that a benchmark case stays a small
|
|
1112
|
+
serialisable file rather than embedding a 500-row table. Resolving it here
|
|
1113
|
+
rather than in ``Problem.__init__`` keeps a saved case loadable on a
|
|
1114
|
+
machine whose vendored tables have been trimmed -- it only has to resolve
|
|
1115
|
+
when someone actually solves.
|
|
1116
|
+
|
|
1117
|
+
An unknown name **raises**. It is the whole point: the string used to be
|
|
1118
|
+
read by nothing, so ``coating="unobtanium"`` silently relabelled a result
|
|
1119
|
+
as absolute. A name that cannot be resolved is a question this solver
|
|
1120
|
+
cannot answer, not a default to fall back on.
|
|
1121
|
+
"""
|
|
1122
|
+
if problem.coating is None:
|
|
1123
|
+
return None
|
|
1124
|
+
from ..materials import lookup
|
|
1125
|
+
|
|
1126
|
+
return lookup(problem.coating)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _spanning_orders(
|
|
1130
|
+
wavelengths: NDArray[np.float64],
|
|
1131
|
+
period: float,
|
|
1132
|
+
sin_alpha: float,
|
|
1133
|
+
sin_gamma: float,
|
|
1134
|
+
) -> NDArray[np.int64]:
|
|
1135
|
+
"""Every order propagating at any wavelength in the scan, as one range.
|
|
1136
|
+
|
|
1137
|
+
The shortest wavelength admits the most orders, so it bounds the set.
|
|
1138
|
+
"""
|
|
1139
|
+
widest = order_range(float(wavelengths.min()), period, sin_alpha, sin_gamma)
|
|
1140
|
+
return widest
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
#: The registered singleton.
|
|
1144
|
+
scalar = register(ScalarSolver())
|