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
gratinglab/geometry.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Core grating geometry: the generalized grating equation and order bookkeeping.
|
|
2
|
+
|
|
3
|
+
All angles in this module are in **radians**. Degrees are converted once, at the
|
|
4
|
+
public API boundary (see :mod:`gratinglab.illumination`).
|
|
5
|
+
|
|
6
|
+
Symbols follow ``docs/conventions.md`` -- in particular ``period`` and ``depth``
|
|
7
|
+
are always spelled out, because the two primary source references use ``d`` to
|
|
8
|
+
mean opposite things.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import ArrayLike, NDArray
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"sin_beta",
|
|
18
|
+
"beta",
|
|
19
|
+
"cos_beta",
|
|
20
|
+
"order_range",
|
|
21
|
+
"is_propagating",
|
|
22
|
+
"flux_obliquity",
|
|
23
|
+
"facet_graze",
|
|
24
|
+
"sin_facet_graze",
|
|
25
|
+
"horizon_clearance",
|
|
26
|
+
"horizon_visible",
|
|
27
|
+
"horizon_weights",
|
|
28
|
+
"blaze_direction",
|
|
29
|
+
"blaze_wavelength",
|
|
30
|
+
"interference_factor",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sin_beta(
|
|
35
|
+
order: ArrayLike,
|
|
36
|
+
wavelength: float,
|
|
37
|
+
period: float,
|
|
38
|
+
sin_alpha: float,
|
|
39
|
+
sin_gamma: float = 1.0,
|
|
40
|
+
) -> NDArray[np.float64]:
|
|
41
|
+
r"""Return :math:`\sin\beta_m` from the generalized grating equation.
|
|
42
|
+
|
|
43
|
+
.. math::
|
|
44
|
+
\sin\alpha + \sin\beta_m = \frac{m\lambda}{p \sin\gamma}
|
|
45
|
+
|
|
46
|
+
``sin_gamma = 1`` is the in-plane (classical) case. Values with
|
|
47
|
+
``|sin_beta| > 1`` correspond to evanescent orders; they are returned as-is
|
|
48
|
+
rather than clipped, so callers can test them with :func:`is_propagating`.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
order
|
|
53
|
+
Diffraction order index :math:`m`. Scalar or array.
|
|
54
|
+
wavelength, period
|
|
55
|
+
Both in nm (any consistent unit works -- only the ratio enters).
|
|
56
|
+
sin_alpha
|
|
57
|
+
Sine of the azimuthal incidence angle.
|
|
58
|
+
sin_gamma
|
|
59
|
+
Sine of the half-cone angle. Must be > 0.
|
|
60
|
+
"""
|
|
61
|
+
if period <= 0:
|
|
62
|
+
raise ValueError(f"period must be positive, got {period}")
|
|
63
|
+
if wavelength <= 0:
|
|
64
|
+
raise ValueError(f"wavelength must be positive, got {wavelength}")
|
|
65
|
+
if not 0 < sin_gamma <= 1:
|
|
66
|
+
raise ValueError(f"sin_gamma must lie in (0, 1], got {sin_gamma}")
|
|
67
|
+
|
|
68
|
+
m = np.asarray(order, dtype=np.float64)
|
|
69
|
+
return m * wavelength / (period * sin_gamma) - sin_alpha
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_propagating(sin_beta_m: ArrayLike) -> NDArray[np.bool_]:
|
|
73
|
+
"""True where an order propagates, i.e. :math:`|\\sin\\beta_m| \\le 1`."""
|
|
74
|
+
return np.abs(np.asarray(sin_beta_m, dtype=np.float64)) <= 1.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cos_beta(sin_beta_m: ArrayLike, transmitted: bool = False) -> NDArray[np.float64]:
|
|
78
|
+
r"""Return :math:`\cos\beta_m`, signed by branch.
|
|
79
|
+
|
|
80
|
+
Reflected orders have :math:`\cos\beta_m > 0`; transmitted orders have
|
|
81
|
+
:math:`\cos\beta_m < 0` (``docs/conventions.md`` §4 -- we do not flip the
|
|
82
|
+
sign of :math:`\sin\beta_m` for transmission).
|
|
83
|
+
|
|
84
|
+
Evanescent orders yield ``nan``; they carry no propagation direction.
|
|
85
|
+
"""
|
|
86
|
+
s = np.asarray(sin_beta_m, dtype=np.float64)
|
|
87
|
+
with np.errstate(invalid="ignore"):
|
|
88
|
+
c = np.sqrt(1.0 - s**2)
|
|
89
|
+
return -c if transmitted else c
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def beta(sin_beta_m: ArrayLike, transmitted: bool = False) -> NDArray[np.float64]:
|
|
93
|
+
r"""Return :math:`\beta_m` in radians, on the correct branch.
|
|
94
|
+
|
|
95
|
+
Reflected orders land in :math:`[-\pi/2, \pi/2]`. Transmitted orders are
|
|
96
|
+
reflected through :math:`\pi/2` so that :math:`\cos\beta_m < 0`.
|
|
97
|
+
"""
|
|
98
|
+
s = np.asarray(sin_beta_m, dtype=np.float64)
|
|
99
|
+
with np.errstate(invalid="ignore"):
|
|
100
|
+
b = np.arcsin(np.where(np.abs(s) <= 1.0, s, np.nan))
|
|
101
|
+
return np.pi - b if transmitted else b
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def order_range(
|
|
105
|
+
wavelength: float,
|
|
106
|
+
period: float,
|
|
107
|
+
sin_alpha: float,
|
|
108
|
+
sin_gamma: float = 1.0,
|
|
109
|
+
) -> NDArray[np.int64]:
|
|
110
|
+
r"""Return every propagating order index, ascending.
|
|
111
|
+
|
|
112
|
+
Solves :math:`|\sin\alpha + m\lambda/(p\sin\gamma)| \le 1` for integer
|
|
113
|
+
:math:`m`. Always includes ``m = 0``.
|
|
114
|
+
|
|
115
|
+
The result is contiguous, so it is safe to use as a column index basis --
|
|
116
|
+
but do not: order mapping should come from an explicit index array, never
|
|
117
|
+
from a positional offset (``docs/conventions.md`` §4).
|
|
118
|
+
"""
|
|
119
|
+
scale = period * sin_gamma / wavelength
|
|
120
|
+
lo = int(np.ceil((sin_alpha - 1.0) * scale))
|
|
121
|
+
hi = int(np.floor((sin_alpha + 1.0) * scale))
|
|
122
|
+
|
|
123
|
+
# Guard the closed-interval endpoints against floating-point drift: an order
|
|
124
|
+
# exactly at grazing (|sin_beta| == 1) is a passing-off point and belongs in
|
|
125
|
+
# the set, but rounding can push it either way.
|
|
126
|
+
while lo <= hi and not is_propagating(
|
|
127
|
+
sin_beta(lo, wavelength, period, sin_alpha, sin_gamma)
|
|
128
|
+
):
|
|
129
|
+
lo += 1
|
|
130
|
+
while hi >= lo and not is_propagating(
|
|
131
|
+
sin_beta(hi, wavelength, period, sin_alpha, sin_gamma)
|
|
132
|
+
):
|
|
133
|
+
hi -= 1
|
|
134
|
+
|
|
135
|
+
return np.arange(lo, hi + 1, dtype=np.int64)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def flux_obliquity(cos_alpha: ArrayLike, cos_beta_m: ArrayLike) -> NDArray[np.float64]:
|
|
139
|
+
r"""Flux projection factor for a diffracted order.
|
|
140
|
+
|
|
141
|
+
.. math::
|
|
142
|
+
O_m = \frac{4\cos\alpha\,\cos\beta_m}{(\cos\alpha + \cos\beta_m)^2}
|
|
143
|
+
|
|
144
|
+
Multiplying :math:`|G_m|^2` by this is what makes scalar theory agree with
|
|
145
|
+
**first-order Rayleigh perturbation theory** in the shallow-groove limit,
|
|
146
|
+
where both are valid and must therefore give the same answer. Perturbation
|
|
147
|
+
theory says :math:`\eta_m = 4k_{z,0}k_{z,m}|\hat{g}_m|^2`; the bare Fourier
|
|
148
|
+
coefficient gives :math:`k_{z,0}` and :math:`k_{z,m}` only through their
|
|
149
|
+
*sum*, and this is exactly the ratio between the two. See
|
|
150
|
+
``tests/test_perturbation.py``, which derives it from two closed forms with
|
|
151
|
+
no solver in the loop.
|
|
152
|
+
|
|
153
|
+
Three properties, all load-bearing:
|
|
154
|
+
|
|
155
|
+
- **Symmetric** under :math:`\alpha \leftrightarrow \beta_m`, so Lorentz
|
|
156
|
+
reciprocity survives it untouched. That is what makes it admissible at
|
|
157
|
+
all: the obliquity factor of thesis Appendix D,
|
|
158
|
+
:math:`\cos\beta_m/\cos\alpha`, is the *asymmetric* one and breaks
|
|
159
|
+
reciprocity, which is why ``docs/theory/scalar.md`` rejected it. The
|
|
160
|
+
rejection was right; the conclusion that no flux factor belongs was not.
|
|
161
|
+
- **At most 1**, by AM-GM, with equality exactly when
|
|
162
|
+
:math:`\cos\alpha = \cos\beta_m` -- Littrow, specular, and every
|
|
163
|
+
:math:`m = 0` (where :math:`\beta_0 = -\alpha`). So it can only reduce an
|
|
164
|
+
efficiency, and it leaves the shallow-groove energy identity alone.
|
|
165
|
+
- **Vanishes at grazing exit**, :math:`\cos\beta_m \to 0`. An order about to
|
|
166
|
+
pass off carries no flux through a plane parallel to the surface, and the
|
|
167
|
+
unfactored :math:`|G_m|^2` claimed it did.
|
|
168
|
+
|
|
169
|
+
Both arguments are cosines, not angles -- the callers already have them from
|
|
170
|
+
:func:`cos_beta` and :attr:`Illumination.cos_alpha`, and converting to
|
|
171
|
+
angles and back would be a chance to lose a sign.
|
|
172
|
+
"""
|
|
173
|
+
cos_alpha = np.asarray(cos_alpha, dtype=np.float64)
|
|
174
|
+
cos_beta_m = np.asarray(cos_beta_m, dtype=np.float64)
|
|
175
|
+
return 4.0 * cos_alpha * cos_beta_m / (cos_alpha + cos_beta_m) ** 2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def sin_facet_graze(
|
|
179
|
+
gamma: float, tilt: ArrayLike, angle: ArrayLike
|
|
180
|
+
) -> NDArray[np.float64]:
|
|
181
|
+
r"""":math:`\sin\zeta = \sin\gamma\,\cos(\text{tilt} - \text{angle})`, unclipped.
|
|
182
|
+
|
|
183
|
+
The vector form of :func:`facet_graze`, and deliberately **not** wrapped in
|
|
184
|
+
``arcsin``. Two reasons:
|
|
185
|
+
|
|
186
|
+
- A **negative** value is the meaningful signal that the facet is turned
|
|
187
|
+
away from the direction in question -- back-facing, and contributing
|
|
188
|
+
nothing. ``arcsin`` of a negative number is a perfectly good angle and
|
|
189
|
+
would hide that; clipping to zero would hide it too. A caller resolving
|
|
190
|
+
reflectivity across a groove tests this sign to build its visibility mask.
|
|
191
|
+
- ``tilt`` varies point by point along a groove, so this is called on whole
|
|
192
|
+
arrays where :func:`facet_graze` takes scalars.
|
|
193
|
+
|
|
194
|
+
``angle`` is :math:`\alpha` for the incident direction and :math:`\beta_m`
|
|
195
|
+
for a diffracted one. It is spelled generically because the two uses are the
|
|
196
|
+
same formula: reflection off a tilted facet does not care which way the
|
|
197
|
+
light is going, which is the symmetry that keeps a groove-resolved
|
|
198
|
+
reflectivity model reciprocal.
|
|
199
|
+
"""
|
|
200
|
+
tilt = np.asarray(tilt, dtype=np.float64)
|
|
201
|
+
angle = np.asarray(angle, dtype=np.float64)
|
|
202
|
+
return np.sin(gamma) * np.cos(tilt - angle)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def horizon_visible(
|
|
206
|
+
height_nm: ArrayLike, period: float, angle: float
|
|
207
|
+
) -> NDArray[np.bool_]:
|
|
208
|
+
r"""Which points on the groove a ray at ``angle`` can actually reach.
|
|
209
|
+
|
|
210
|
+
The mask for **cast** shadows -- the ones the groove apex throws across
|
|
211
|
+
the trough onto surface that faces the ray perfectly well. The local
|
|
212
|
+
orientation test (:func:`sin_facet_graze` ``> 0``) sees only
|
|
213
|
+
*self*-shadowing, a facet turned away from the ray; on the reference
|
|
214
|
+
sawtooth at its working angles the two differ by a fraction of a percent
|
|
215
|
+
of the period on the incident side and by 10-50% on the exit side, which
|
|
216
|
+
is why this function exists (``docs/findings.md``).
|
|
217
|
+
|
|
218
|
+
**The half-cone angle is absent, deliberately.** The surface is invariant
|
|
219
|
+
along the grooves, so a 3D ray occludes exactly as its projection into the
|
|
220
|
+
transverse plane does, and ``angle`` is the transverse azimuth --
|
|
221
|
+
:math:`\alpha` for the incident direction, :math:`\beta_m` for a
|
|
222
|
+
diffracted one, measured from the normal. :math:`\gamma` scales the ray's
|
|
223
|
+
groove-parallel component, which slides *along* the invariant direction
|
|
224
|
+
and can neither create nor remove an occlusion.
|
|
225
|
+
|
|
226
|
+
**The travel direction is the trap.** The profile parameter runs against
|
|
227
|
+
the dispersion direction (``docs/findings.md``, "The profile parameter
|
|
228
|
+
runs backwards"; conventions section 3), so a ray with ``angle > 0``
|
|
229
|
+
travels toward **+t** and its occluders sit at *smaller* ``t``. Getting
|
|
230
|
+
this backwards does not fail loudly -- it reports shadows on the wrong
|
|
231
|
+
facet, which on a sawtooth mostly coincides with the self-shadowing mask
|
|
232
|
+
and looks plausible. The anchor that pins it: on an ideal sawtooth with
|
|
233
|
+
blaze slope :math:`s_b`, anti-blaze slope :math:`s_a` and apex at
|
|
234
|
+
:math:`t_a`, a ray at ``angle`` :math:`> \operatorname{arccot} s_a`
|
|
235
|
+
(19.5 deg for the 29.5/70.5 reference groove) throws a shadow of width
|
|
236
|
+
|
|
237
|
+
.. math::
|
|
238
|
+
\Delta = (1 - t_a)\,\frac{s_a - s_r}{s_b + s_r}, \qquad
|
|
239
|
+
s_r = \cot(\text{angle})
|
|
240
|
+
|
|
241
|
+
past the trough onto the blaze facet, and ``tests/test_scalar.py``
|
|
242
|
+
derives that independently and checks it here.
|
|
243
|
+
|
|
244
|
+
The scan itself is the classic running-horizon argument, exact and
|
|
245
|
+
:math:`O(n)`: with :math:`u(t) = g(t) + p\,\cot|\theta|\,t` (sign of the
|
|
246
|
+
linear term following the travel direction), a point is lit iff
|
|
247
|
+
:math:`u` at that point matches the running maximum of :math:`u` over
|
|
248
|
+
everything upstream of it. Upstream whole periods enter in closed form --
|
|
249
|
+
each sits exactly :math:`p\cot|\theta|` lower than the last, so only the
|
|
250
|
+
nearest one can ever compete -- which is what removes any need to tile.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
height_nm
|
|
255
|
+
Groove height in nm on a **uniform** grid over one period,
|
|
256
|
+
``t = arange(n)/n``. Uniformity is load-bearing: the linear term is
|
|
257
|
+
built from the index.
|
|
258
|
+
period
|
|
259
|
+
Groove period in nm -- the same unit as ``height_nm``, because the
|
|
260
|
+
ray's run-to-drop ratio compares the two directly.
|
|
261
|
+
angle
|
|
262
|
+
Transverse azimuth of the ray in radians, from the grating normal.
|
|
263
|
+
Must be a propagating direction, :math:`|\theta| \le \pi/2`; exactly
|
|
264
|
+
0 is straight down and shadows nothing.
|
|
265
|
+
"""
|
|
266
|
+
return horizon_clearance(height_nm, period, angle) >= 0.0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def horizon_clearance(
|
|
270
|
+
height_nm: ArrayLike, period: float, angle: float
|
|
271
|
+
) -> NDArray[np.float64]:
|
|
272
|
+
r"""Signed height (nm) of each point above the ray horizon at ``angle``.
|
|
273
|
+
|
|
274
|
+
The continuous function behind :func:`horizon_visible`: how far each
|
|
275
|
+
point sits above (positive, lit) or below (negative, cast-shadowed) the
|
|
276
|
+
highest upstream obstruction, measured along the ray direction. Exposed
|
|
277
|
+
separately because a quadrature that wants sub-cell shadow boundaries
|
|
278
|
+
needs the crossing, and a boolean mask has already thrown it away --
|
|
279
|
+
the sign of the clearance IS the mask.
|
|
280
|
+
|
|
281
|
+
The horizon here is **exclusive** -- built from strictly-upstream points
|
|
282
|
+
-- so the clearance is genuinely positive on lit terrain (each lit point
|
|
283
|
+
clears the point before it) rather than identically zero, and crosses
|
|
284
|
+
zero transversally at a shadow boundary. ``clearance >= 0`` is exactly
|
|
285
|
+
the inclusive-prefix mask of :func:`horizon_visible`, whose docstring
|
|
286
|
+
carries the derivation, the sign conventions, and the closed-form anchor.
|
|
287
|
+
"""
|
|
288
|
+
height_nm = np.asarray(height_nm, dtype=np.float64)
|
|
289
|
+
n = len(height_nm)
|
|
290
|
+
if angle == 0.0:
|
|
291
|
+
# Straight down: no upstream, nothing to clear.
|
|
292
|
+
return np.full(n, np.inf)
|
|
293
|
+
|
|
294
|
+
t = np.arange(n) / n
|
|
295
|
+
cot = 1.0 / np.tan(abs(angle))
|
|
296
|
+
if angle > 0.0:
|
|
297
|
+
u = height_nm + cot * t * period
|
|
298
|
+
inclusive = np.maximum.accumulate(u)
|
|
299
|
+
upstream = np.concatenate(([-np.inf], inclusive[:-1]))
|
|
300
|
+
else:
|
|
301
|
+
# The mirror case: travel toward -t, occluders at larger t, and the
|
|
302
|
+
# linear term flips sign with the run direction.
|
|
303
|
+
u = height_nm - cot * t * period
|
|
304
|
+
inclusive = np.maximum.accumulate(u[::-1])[::-1]
|
|
305
|
+
upstream = np.concatenate((inclusive[1:], [-np.inf]))
|
|
306
|
+
# Every complete upstream period is a copy of u shifted down by one
|
|
307
|
+
# period's drop, so the nearest bounds them all.
|
|
308
|
+
horizon = np.maximum(upstream, u.max() - cot * period)
|
|
309
|
+
return u - horizon
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def horizon_weights(
|
|
313
|
+
height_nm: ArrayLike, period: float, angle: float
|
|
314
|
+
) -> NDArray[np.float64]:
|
|
315
|
+
r"""Quadrature weights for the lit region, with sub-cell shadow boundaries.
|
|
316
|
+
|
|
317
|
+
The weighted counterpart of :func:`horizon_visible`. A binary mask rounds
|
|
318
|
+
every shadow boundary to the nearest grid point, which puts an
|
|
319
|
+
:math:`O(1)` error in one cell per boundary and drags an integral over
|
|
320
|
+
the lit region down to :math:`O(n^{-1})` convergence. Here each boundary
|
|
321
|
+
is located *inside* its cell and the nearest lit-facet sample absorbs the
|
|
322
|
+
sub-cell lit length: interior samples keep weight 1 or 0, and a boundary
|
|
323
|
+
sample carries :math:`\tfrac12 + x` of a cell, where :math:`x` is the
|
|
324
|
+
crossing's offset from it. Shadow-side samples are never weighted -- past
|
|
325
|
+
the boundary the integrand's value is the occluded facet's, and no
|
|
326
|
+
fraction of it belongs in a lit-region integral.
|
|
327
|
+
|
|
328
|
+
The boundary locations come from the geometry, not from interpolating
|
|
329
|
+
the mask, and three facts make them exact for **polygonal profiles**
|
|
330
|
+
(``Blazed``, ``Lamellar``, and every measured boundary) and
|
|
331
|
+
:math:`O(n^{-2})`-accurate for smooth ones -- each learned by measuring
|
|
332
|
+
a failed simpler scheme, see ``docs/findings.md``:
|
|
333
|
+
|
|
334
|
+
- A shadow *begins* at a crest of the ray-adapted height
|
|
335
|
+
:math:`u = g + p\cot|\theta|\,t`, usually a profile corner that sits
|
|
336
|
+
between samples. The corner is recovered by intersecting the secant
|
|
337
|
+
through the two samples on each side -- and the lit-side pair must be
|
|
338
|
+
``(i-2, i-1)``, because the last discretely-lit sample can already
|
|
339
|
+
sit past the corner (its running-max status can come from an
|
|
340
|
+
increment that spans the crest).
|
|
341
|
+
- Every shadow stretch is occluded by **its own entry crest**: a lit
|
|
342
|
+
point is at its running maximum, so nothing upstream stands higher.
|
|
343
|
+
The stretch therefore *ends* where :math:`u` climbs back to the entry
|
|
344
|
+
corner's height, extrapolated from the last two shadow samples.
|
|
345
|
+
- A stretch that wraps through :math:`t = 0` has its level reduced by
|
|
346
|
+
one period's run :math:`p\cot|\theta|`, exactly as whole upstream
|
|
347
|
+
periods enter the mask.
|
|
348
|
+
|
|
349
|
+
Negative ``angle`` mirrors the profile, runs the same scan, and mirrors
|
|
350
|
+
back. Same arguments and conventions as :func:`horizon_visible`; the sum
|
|
351
|
+
of weights over :math:`n` is the lit fraction of the period, to machine
|
|
352
|
+
precision on a polygon.
|
|
353
|
+
"""
|
|
354
|
+
height_nm = np.asarray(height_nm, dtype=np.float64)
|
|
355
|
+
n = len(height_nm)
|
|
356
|
+
if angle == 0.0:
|
|
357
|
+
return np.ones(n)
|
|
358
|
+
if angle < 0.0:
|
|
359
|
+
return horizon_weights(height_nm[::-1], period, -angle)[::-1]
|
|
360
|
+
|
|
361
|
+
t = np.arange(n) / n
|
|
362
|
+
cot = 1.0 / np.tan(angle)
|
|
363
|
+
u = height_nm + cot * t * period
|
|
364
|
+
inclusive = np.maximum.accumulate(u)
|
|
365
|
+
upstream = np.concatenate(([-np.inf], inclusive[:-1]))
|
|
366
|
+
lit = u >= np.maximum(upstream, u.max() - cot * period)
|
|
367
|
+
|
|
368
|
+
weights = lit.astype(np.float64)
|
|
369
|
+
if lit.all() or not lit.any():
|
|
370
|
+
return weights
|
|
371
|
+
|
|
372
|
+
entries = np.flatnonzero(lit & ~np.roll(lit, -1))
|
|
373
|
+
exits = np.flatnonzero(~lit & np.roll(lit, -1))
|
|
374
|
+
|
|
375
|
+
levels: dict[int, float] = {}
|
|
376
|
+
for i in entries:
|
|
377
|
+
lit_slope = u[i - 1] - u[i - 2]
|
|
378
|
+
shadow_slope = u[(i + 2) % n] - u[(i + 1) % n]
|
|
379
|
+
if lit_slope > shadow_slope:
|
|
380
|
+
# Corner offset from sample i, in cells, in (-1, 1].
|
|
381
|
+
x = (u[(i + 1) % n] - shadow_slope - u[i - 1] - lit_slope) / (
|
|
382
|
+
lit_slope - shadow_slope
|
|
383
|
+
)
|
|
384
|
+
x = float(np.clip(x, -1.0, 1.0))
|
|
385
|
+
peak = u[i - 1] + lit_slope * (x + 1.0)
|
|
386
|
+
else: # pragma: no cover - a crest the secants cannot see
|
|
387
|
+
x, peak = 0.5, float(u[i])
|
|
388
|
+
levels[int(i)] = peak
|
|
389
|
+
if x >= 0.0:
|
|
390
|
+
weights[i] = min(0.5 + x, 1.5)
|
|
391
|
+
else:
|
|
392
|
+
# The corner sits left of the last discretely-lit sample, whose
|
|
393
|
+
# value is therefore the shadow facet's: exclude it and let the
|
|
394
|
+
# sample before it absorb the whole boundary stretch.
|
|
395
|
+
weights[i] = 0.0
|
|
396
|
+
weights[i - 1] = max(1.5 + x, 0.0)
|
|
397
|
+
|
|
398
|
+
for j in exits:
|
|
399
|
+
i = int(entries[np.argmin((j - entries) % n)])
|
|
400
|
+
level = levels[i]
|
|
401
|
+
if j < i:
|
|
402
|
+
level -= cot * period
|
|
403
|
+
slope = u[j] - u[j - 1]
|
|
404
|
+
if slope > 0.0:
|
|
405
|
+
x = 1.0 - (level - u[j]) / slope
|
|
406
|
+
else: # pragma: no cover - a one-sample shadow gives no slope
|
|
407
|
+
x = 0.5
|
|
408
|
+
x = float(np.clip(x, -0.5, 1.0))
|
|
409
|
+
weights[(j + 1) % n] = max(0.5 + x, 0.0)
|
|
410
|
+
|
|
411
|
+
return weights
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def facet_graze(gamma: float, blaze_angle: float, alpha: float) -> float:
|
|
415
|
+
r"""Graze angle onto a sawtooth groove facet, in radians.
|
|
416
|
+
|
|
417
|
+
.. math::
|
|
418
|
+
\sin\zeta = \sin\gamma \, \cos(\delta - \alpha)
|
|
419
|
+
|
|
420
|
+
This is the angle that must stay below the critical angle for total
|
|
421
|
+
external reflection, :math:`\theta_c \approx \sqrt{2\delta_n}`.
|
|
422
|
+
|
|
423
|
+
The scalar case of :func:`sin_facet_graze`, and defined in terms of it so
|
|
424
|
+
there is one formula rather than two that have to be kept agreeing.
|
|
425
|
+
"""
|
|
426
|
+
return float(np.arcsin(sin_facet_graze(gamma, blaze_angle, alpha)))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def blaze_direction(blaze_angle: float, alpha: float) -> float:
|
|
430
|
+
r"""Azimuthal direction of peak blaze response, :math:`\beta_b = 2\delta - \alpha`.
|
|
431
|
+
|
|
432
|
+
Equivalent to specular reflection from a mirror tilted at the facet angle.
|
|
433
|
+
"""
|
|
434
|
+
return 2.0 * blaze_angle - alpha
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def blaze_wavelength(
|
|
438
|
+
order: ArrayLike,
|
|
439
|
+
period: float,
|
|
440
|
+
blaze_angle: float,
|
|
441
|
+
alpha: float,
|
|
442
|
+
gamma: float,
|
|
443
|
+
) -> NDArray[np.float64]:
|
|
444
|
+
r"""Blaze wavelength for the given order(s), in the units of ``period``.
|
|
445
|
+
|
|
446
|
+
.. math::
|
|
447
|
+
m \lambda_b = 2 p \sin\zeta \, \sin\delta,
|
|
448
|
+
\qquad \sin\zeta = \sin\gamma \cos(\delta - \alpha)
|
|
449
|
+
|
|
450
|
+
This is ISSI eq. following (15). It agrees exactly with the thesis form
|
|
451
|
+
:math:`\lambda_b = 2 p \sin\gamma \sin\delta \cos(\delta-\alpha)/m`
|
|
452
|
+
(Appendix-D.tex:666) -- the two were cross-checked and match.
|
|
453
|
+
|
|
454
|
+
``order = 0`` yields ``inf``: zeroth order has no blaze condition.
|
|
455
|
+
"""
|
|
456
|
+
m = np.asarray(order, dtype=np.float64)
|
|
457
|
+
zeta = facet_graze(gamma, blaze_angle, alpha)
|
|
458
|
+
numerator = 2.0 * period * np.sin(zeta) * np.sin(blaze_angle)
|
|
459
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
460
|
+
return np.where(m != 0, numerator / m, np.inf)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def interference_factor(s: ArrayLike, n_grooves: int) -> NDArray[np.float64]:
|
|
464
|
+
r"""The finite-:math:`N` grating interference function, ISSI eq. (8).
|
|
465
|
+
|
|
466
|
+
.. math::
|
|
467
|
+
\left[\frac{\sin(Ns)}{N\sin(s)}\right]^2, \qquad
|
|
468
|
+
s = \left[\sin\alpha + \sin\beta\right]\sin\gamma\,\frac{p\pi}{\lambda}
|
|
469
|
+
|
|
470
|
+
**This is deliberately not applied to order efficiencies.** At an exact
|
|
471
|
+
diffraction order ``s = mπ`` and the factor equals 1, so multiplying by it
|
|
472
|
+
would change nothing. Its role is the *angular line shape* around each
|
|
473
|
+
order -- the link to resolving power, which is a different question from
|
|
474
|
+
how much power lands in order ``m``. :mod:`gratinglab.resolution` is the
|
|
475
|
+
consumer that asks it.
|
|
476
|
+
"""
|
|
477
|
+
s = np.asarray(s, dtype=np.float64)
|
|
478
|
+
numerator = np.sin(n_grooves * s)
|
|
479
|
+
denominator = n_grooves * np.sin(s)
|
|
480
|
+
# s -> m*pi is removable: the ratio tends to +/-1, so the square tends to 1.
|
|
481
|
+
near_order = np.isclose(np.sin(s), 0.0, atol=1e-12)
|
|
482
|
+
ratio = np.where(near_order, 1.0, numerator / np.where(near_order, 1.0, denominator))
|
|
483
|
+
return ratio**2
|
|
File without changes
|
gratinglab/gui/app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Console-script entry point.
|
|
2
|
+
|
|
3
|
+
The rest of the window lives in :mod:`gratinglab.gui.qt.main_window` and its
|
|
4
|
+
siblings. This module stays separate, and its PySide6 import stays deferred
|
|
5
|
+
into :func:`_require_qt`, so that ``import gratinglab.gui.app`` -- and by
|
|
6
|
+
extension ``import gratinglab`` -- is safe on an install that never asked for
|
|
7
|
+
the ``gui`` extra. The rest of :mod:`gratinglab.gui.qt` gets that same
|
|
8
|
+
property from never being imported except from in here or from a test that
|
|
9
|
+
already guarded for PySide6's absence.
|
|
10
|
+
|
|
11
|
+
Run with ``gratinglab-gui``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
_QT_HELP = """\
|
|
19
|
+
gratinglab-gui needs PySide6, which this install does not have.
|
|
20
|
+
|
|
21
|
+
pip install -e ".[gui]" (from a checkout)
|
|
22
|
+
pip install gratinglab[gui] (once published)
|
|
23
|
+
|
|
24
|
+
Everything except the GUI works without it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_qt():
|
|
29
|
+
"""Import PySide6 with an explanation instead of a traceback."""
|
|
30
|
+
try:
|
|
31
|
+
from PySide6.QtWidgets import QApplication
|
|
32
|
+
except ImportError as exc: # pragma: no cover - depends on the install
|
|
33
|
+
raise SystemExit(_QT_HELP) from exc
|
|
34
|
+
return QApplication
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bring_to_front(window) -> None:
|
|
38
|
+
"""Put the window in front on launch.
|
|
39
|
+
|
|
40
|
+
There used to be an `osascript`/System Events fallback here, inherited
|
|
41
|
+
from the Tk window. M10-H measured what it actually bought, and the answer
|
|
42
|
+
was nothing:
|
|
43
|
+
|
|
44
|
+
============================ ============= ==============
|
|
45
|
+
Launched from with the hack without it
|
|
46
|
+
============================ ============= ==============
|
|
47
|
+
``GratingLab.app`` front front
|
|
48
|
+
``gratinglab-gui`` in a shell not front not front
|
|
49
|
+
============================ ============= ==============
|
|
50
|
+
|
|
51
|
+
The bundle is what does the work: it carries a real `CFBundleIdentifier`,
|
|
52
|
+
so macOS treats the process as an application ("GratingLab", not
|
|
53
|
+
"python") and LaunchServices activates it. From a bare shell the process
|
|
54
|
+
has no such identity and stays behind -- and the AppleScript does not
|
|
55
|
+
rescue it, because driving System Events needs Accessibility permission
|
|
56
|
+
the launching terminal has usually never been granted.
|
|
57
|
+
|
|
58
|
+
So it was a `subprocess.run` on every launch, wrapped in a bare `except`,
|
|
59
|
+
that silently no-opped in the one case it existed for. Removed rather than
|
|
60
|
+
kept as a talisman. The supported fix for the shell path is to launch the
|
|
61
|
+
bundle, which `tools/make_app.py` builds and the README points at.
|
|
62
|
+
"""
|
|
63
|
+
window.raise_()
|
|
64
|
+
window.activateWindow()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> int:
|
|
68
|
+
"""Console-script entry point."""
|
|
69
|
+
QApplication = _require_qt()
|
|
70
|
+
from .qt.main_window import MainWindow
|
|
71
|
+
|
|
72
|
+
app = QApplication(sys.argv)
|
|
73
|
+
window = MainWindow()
|
|
74
|
+
window.show()
|
|
75
|
+
_bring_to_front(window)
|
|
76
|
+
return app.exec()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__": # pragma: no cover
|
|
80
|
+
sys.exit(main())
|