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.
Files changed (107) hide show
  1. gratinglab/__init__.py +35 -0
  2. gratinglab/checks.py +263 -0
  3. gratinglab/compare.py +233 -0
  4. gratinglab/convergence.py +455 -0
  5. gratinglab/geometry.py +483 -0
  6. gratinglab/gui/__init__.py +0 -0
  7. gratinglab/gui/app.py +80 -0
  8. gratinglab/gui/diagram.py +699 -0
  9. gratinglab/gui/diagram3d.py +609 -0
  10. gratinglab/gui/docs.py +189 -0
  11. gratinglab/gui/icon.py +101 -0
  12. gratinglab/gui/integral_options.py +219 -0
  13. gratinglab/gui/mathtext.py +209 -0
  14. gratinglab/gui/orders.py +152 -0
  15. gratinglab/gui/provenance.py +273 -0
  16. gratinglab/gui/qt/__init__.py +20 -0
  17. gratinglab/gui/qt/geometry_panel.py +239 -0
  18. gratinglab/gui/qt/geometry_tab.py +387 -0
  19. gratinglab/gui/qt/integral_tab.py +447 -0
  20. gratinglab/gui/qt/main_window.py +514 -0
  21. gratinglab/gui/qt/profile_plot_panel.py +67 -0
  22. gratinglab/gui/qt/scalar_tab.py +498 -0
  23. gratinglab/gui/qt/scene3d_panel.py +347 -0
  24. gratinglab/gui/qt/setup_tab.py +215 -0
  25. gratinglab/gui/qt/sizing.py +25 -0
  26. gratinglab/gui/qt/theory_viewer.py +89 -0
  27. gratinglab/gui/qt/worker.py +203 -0
  28. gratinglab/gui/richtext.py +463 -0
  29. gratinglab/gui/scalar_options.py +139 -0
  30. gratinglab/gui/state.py +379 -0
  31. gratinglab/illumination.py +212 -0
  32. gratinglab/io/__init__.py +0 -0
  33. gratinglab/io/efficiency_table.py +382 -0
  34. gratinglab/io/ggp.py +248 -0
  35. gratinglab/materials/__init__.py +218 -0
  36. gratinglab/materials/data/SOURCES.md +53 -0
  37. gratinglab/materials/fresnel.py +282 -0
  38. gratinglab/materials/optical.py +319 -0
  39. gratinglab/materials/refractiveindex.py +306 -0
  40. gratinglab/metrology/__init__.py +121 -0
  41. gratinglab/metrology/analyzer.py +875 -0
  42. gratinglab/metrology/boundary/__init__.py +27 -0
  43. gratinglab/metrology/boundary/average.py +143 -0
  44. gratinglab/metrology/boundary/pipeline.py +211 -0
  45. gratinglab/metrology/cli.py +52 -0
  46. gratinglab/metrology/config.py +170 -0
  47. gratinglab/metrology/core/__init__.py +23 -0
  48. gratinglab/metrology/core/analysis.py +315 -0
  49. gratinglab/metrology/core/image_flatten.py +131 -0
  50. gratinglab/metrology/core/processing.py +344 -0
  51. gratinglab/metrology/core/tip.py +233 -0
  52. gratinglab/metrology/data/__init__.py +16 -0
  53. gratinglab/metrology/data/aggregation.py +193 -0
  54. gratinglab/metrology/gui/__init__.py +56 -0
  55. gratinglab/metrology/gui/icon.py +136 -0
  56. gratinglab/metrology/gui/qt/__init__.py +19 -0
  57. gratinglab/metrology/gui/qt/app.py +28 -0
  58. gratinglab/metrology/gui/qt/boundary_view.py +324 -0
  59. gratinglab/metrology/gui/qt/canvas.py +151 -0
  60. gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
  61. gratinglab/metrology/gui/qt/import_view.py +380 -0
  62. gratinglab/metrology/gui/qt/main_window.py +398 -0
  63. gratinglab/metrology/gui/qt/wiki_view.py +99 -0
  64. gratinglab/metrology/gui/qt/worker.py +59 -0
  65. gratinglab/metrology/gui/state.py +168 -0
  66. gratinglab/metrology/io/__init__.py +8 -0
  67. gratinglab/metrology/io/file_io.py +253 -0
  68. gratinglab/metrology/io/metrics.py +38 -0
  69. gratinglab/metrology/io/spm.py +210 -0
  70. gratinglab/metrology/settings.py +216 -0
  71. gratinglab/metrology/stats/__init__.py +16 -0
  72. gratinglab/metrology/stats/analysis.py +329 -0
  73. gratinglab/metrology/stats/icc.py +128 -0
  74. gratinglab/metrology/visualization/__init__.py +16 -0
  75. gratinglab/metrology/visualization/diagnostics.py +111 -0
  76. gratinglab/metrology/visualization/profiles.py +234 -0
  77. gratinglab/metrology/visualization/statistics.py +412 -0
  78. gratinglab/metrology/wiki/__init__.py +91 -0
  79. gratinglab/metrology/wiki/facet-fitting.md +95 -0
  80. gratinglab/metrology/wiki/file-formats.md +116 -0
  81. gratinglab/metrology/wiki/flattening.md +105 -0
  82. gratinglab/metrology/wiki/icc-correction.md +111 -0
  83. gratinglab/metrology/wiki/overview.md +51 -0
  84. gratinglab/metrology/wiki/reading-outputs.md +154 -0
  85. gratinglab/metrology/wiki/row-groups.md +75 -0
  86. gratinglab/metrology/wiki/tip-correction.md +101 -0
  87. gratinglab/metrology/workflows.py +383 -0
  88. gratinglab/problem.py +79 -0
  89. gratinglab/profiles.py +531 -0
  90. gratinglab/resolution.py +198 -0
  91. gratinglab/result.py +268 -0
  92. gratinglab/solvers/__init__.py +34 -0
  93. gratinglab/solvers/base.py +207 -0
  94. gratinglab/solvers/integral/__init__.py +406 -0
  95. gratinglab/solvers/integral/_boundary.py +50 -0
  96. gratinglab/solvers/integral/_core.py +126 -0
  97. gratinglab/solvers/integral/_finite.py +396 -0
  98. gratinglab/solvers/integral/_greens.py +234 -0
  99. gratinglab/solvers/integral/_kernels.py +308 -0
  100. gratinglab/solvers/integral/_nystrom.py +474 -0
  101. gratinglab/solvers/scalar.py +1144 -0
  102. gratinglab-0.1.0.dist-info/METADATA +319 -0
  103. gratinglab-0.1.0.dist-info/RECORD +107 -0
  104. gratinglab-0.1.0.dist-info/WHEEL +5 -0
  105. gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
  106. gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
  107. 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())