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
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())