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,699 @@
1
+ r"""What a grating geometry looks like, decided without a toolkit.
2
+
3
+ Returns drawable primitives -- polylines, arrows, markers, tagged captions --
4
+ in physical coordinates (nm, in the :math:`(\hat{d}, \hat{n})` frame of
5
+ ``docs/conventions.md`` §3). The widget positions them and colours them by tag;
6
+ it computes no angle, no direction and no cutoff, so a bug there misplaces a
7
+ line rather than drawing wrong physics. Same split, and the same reason, as
8
+ :mod:`gratinglab.gui.provenance`: which line is red is a correctness question.
9
+
10
+ This module **arranges**; it never re-derives. Every angle comes from
11
+ :mod:`gratinglab.geometry`, every height from :meth:`Problem.height_nm`.
12
+
13
+ Three panels, because the physics needs three
14
+ =============================================
15
+
16
+ **main** -- the view *down the groove axis* :math:`\hat{g}`, i.e. the
17
+ projection onto the :math:`\hat{n}`-:math:`\hat{d}` plane. In that projection
18
+ :math:`\mathbf{k}_i` and every :math:`\mathbf{k}_m` have the same projected
19
+ length :math:`k\sin\gamma`, and their azimuths are exactly :math:`\alpha` and
20
+ :math:`\beta_m`. Two things follow, and they are why this is a good view rather
21
+ than a compromise:
22
+
23
+ 1. Every propagating order lands on one circle centred at the strike point.
24
+ That circle *is* the diffraction cone, seen end-on -- so "the mount is
25
+ conical" becomes a picture rather than a caption.
26
+ 2. :math:`\alpha`, :math:`\beta_m`, :math:`\delta` and :math:`\beta_b` are all
27
+ azimuths in this same plane, drawn true at any :math:`\gamma`.
28
+
29
+ **ladder** -- every order on a :math:`\sin\beta` axis. The main panel can only
30
+ draw an order that has a direction; an evanescent one has none, and
31
+ ``conventions.md`` §4 forbids dropping it ("retained in the record, never
32
+ silently dropped, never NaN"). So an evanescent order sits here, outside the
33
+ :math:`[-1, 1]` propagating window, with no ray attached. Omitting it from the
34
+ picture would be exactly the silent drop that section exists to prevent.
35
+
36
+ There used to be a third panel here, drawing :math:`\gamma` as a true-to-scale
37
+ sliver. It existed because the main panel is a projection: :math:`k_z =
38
+ k\cos\gamma` is perpendicular to it and invisible, and at :math:`\gamma = 1.5°`
39
+ that is 99.97% of :math:`|k|`, so a reader who sees rays at 25° from the normal
40
+ could conclude the mount is not grazing. :mod:`gratinglab.gui.diagram3d` now
41
+ draws the actual cone, with :math:`\gamma` as a real angle in a real scene
42
+ rather than a stand-in beside one -- so the sliver was retired (M13-I). Two
43
+ drawings of one angle are two answers to one question. The captions still say
44
+ what this projection cannot show, and now point at the view that can.
45
+
46
+ What is deliberately *not* drawn
47
+ ================================
48
+
49
+ :math:`\zeta`, the facet graze angle, is **text only**.
50
+ :func:`~gratinglab.geometry.facet_graze` is the three-dimensional angle between
51
+ :math:`\hat{k}_i` and the facet *plane*, :math:`\sin\zeta = \sin\gamma
52
+ \cos(\delta-\alpha)`. It is **not** the angle between the projected ray and the
53
+ facet line in this drawing -- 1.495° against 85.5° at the reference geometry.
54
+ An arc labelled :math:`\zeta` would be the worst lie available here.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from dataclasses import dataclass
60
+ from typing import TYPE_CHECKING, Iterable, Sequence
61
+
62
+ import numpy as np
63
+ from numpy.typing import ArrayLike, NDArray
64
+
65
+ from ..geometry import (
66
+ blaze_direction,
67
+ blaze_wavelength,
68
+ facet_graze,
69
+ is_propagating,
70
+ order_range,
71
+ sin_beta,
72
+ )
73
+ from .provenance import Line
74
+
75
+ if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
76
+ from ..illumination import Illumination
77
+ from ..problem import Problem
78
+
79
+ __all__ = [
80
+ "T_AXIS_SIGN",
81
+ "TAG_COLORS",
82
+ "MAX_ORDER_LABELS",
83
+ "Polyline",
84
+ "Arrow",
85
+ "Marker",
86
+ "OrderMark",
87
+ "Diagram",
88
+ "build",
89
+ "x_nm",
90
+ "direction",
91
+ "order_span",
92
+ "order_marks",
93
+ "strike_point",
94
+ "facet_normal",
95
+ "label_orders",
96
+ "captions",
97
+ "nearest_index",
98
+ "blaze_targets",
99
+ "blaze_jump",
100
+ ]
101
+
102
+ #: Which way the profile parameter runs along the dispersion axis.
103
+ #:
104
+ #: ``Profile.height(t)`` is a shape in ``t``; :mod:`gratinglab.profiles` never
105
+ #: says which physical direction ``+t`` points. The wave conventions do fix it:
106
+ #: ``blaze_direction`` gives :math:`\beta_b = 2\delta - \alpha`, which requires
107
+ #: the active facet's outward normal at azimuth :math:`+\delta`, while
108
+ #: ``Blazed.height`` rises with ``t``, putting it at :math:`-\delta` in ``t``.
109
+ #:
110
+ #: Nothing before this module drew a profile and a ray in the same frame, so
111
+ #: nothing before it had to know. See ``docs/conventions.md`` §3 and
112
+ #: ``tests/test_geometry.py::TestFacetHandedness``.
113
+ T_AXIS_SIGN: float = -1.0
114
+
115
+ #: Tag -> colour, exactly as :data:`gratinglab.gui.provenance.TAG_COLORS`
116
+ #: works: this module decides what a thing *is*, the widget looks up what that
117
+ #: colour is.
118
+ TAG_COLORS: dict[str, str] = {
119
+ "surface": "#1f3b73", # the blue ProfilePlotPanel already uses
120
+ "facet": "#2d5aa8", # the active facet, picked out of the surface
121
+ "incident": "#8fa3c8", # icon.py's incident-ray grey-blue
122
+ "zero": "#666666",
123
+ "order": "#4dc4ff",
124
+ "blaze": "#ffc233",
125
+ "normal": "#0a7d28",
126
+ "cone": "#999999",
127
+ "evanescent": "#b00020",
128
+ "axis": "#888888",
129
+ }
130
+
131
+ #: At most this many order arrows carry a label before thinning starts. An
132
+ #: X-ray geometry at the short end of a scan propagates sixteen orders; sixteen
133
+ #: labels is a smear. The **label** is what gets dropped -- never the arrow,
134
+ #: which would be a silent omission -- and a caption reports the true total.
135
+ MAX_ORDER_LABELS = 7
136
+
137
+ #: Ray length, in periods. Arbitrary: only directions carry meaning, and a
138
+ #: caption says so.
139
+ _RAY_PERIODS = 1.15
140
+
141
+ #: Points used to sample the surface and the cone arc.
142
+ _SURFACE_POINTS = 400
143
+ _CONE_POINTS = 181
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class Polyline:
148
+ """An open path, already sampled.
149
+
150
+ Arcs and angle wedges are polylines too: a ``matplotlib.patches.Arc`` takes
151
+ degrees counter-clockwise from ``+x``, which is a *different* angle
152
+ convention from this project's azimuth-from-:math:`\\hat{n}`, and that
153
+ conversion does not belong in a widget.
154
+ """
155
+
156
+ x: NDArray[np.float64]
157
+ y: NDArray[np.float64]
158
+ tag: str
159
+ panel: str = "main"
160
+ fill_to: float | None = None
161
+ dashed: bool = False
162
+ label: str | None = None
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class Arrow:
167
+ """A directed segment, tail to head, in absolute coordinates.
168
+
169
+ Endpoints rather than angle-and-length on purpose: the widget then converts
170
+ nothing, and a sign error has nowhere to hide in the untested layer.
171
+ """
172
+
173
+ x0: float
174
+ y0: float
175
+ x1: float
176
+ y1: float
177
+ tag: str
178
+ panel: str = "main"
179
+ label: str | None = None
180
+ order: int | None = None
181
+ dashed: bool = False
182
+
183
+ @property
184
+ def azimuth(self) -> float:
185
+ """Direction as an azimuth from :math:`\\hat{n}` toward :math:`\\hat{d}`,
186
+ radians. Derived for tests; the widget never needs it."""
187
+ return float(np.arctan2(self.x1 - self.x0, self.y1 - self.y0))
188
+
189
+
190
+ @dataclass(frozen=True, slots=True)
191
+ class Marker:
192
+ """A point with a *named* glyph.
193
+
194
+ ``glyph`` is one of ``"out-of-page"``, ``"strike"``. Named, not styled: the
195
+ widget owns the mapping from name to marker.
196
+ """
197
+
198
+ x: float
199
+ y: float
200
+ glyph: str
201
+ tag: str
202
+ panel: str = "main"
203
+ label: str | None = None
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class OrderMark:
208
+ """One diffraction order, on the :math:`\\sin\\beta` ladder.
209
+
210
+ Present whether or not it propagates. ``sin_beta`` is unclipped, straight
211
+ from :func:`~gratinglab.geometry.sin_beta`, so an evanescent order reads
212
+ :math:`|\\sin\\beta| > 1` and lands visibly outside the window rather than
213
+ vanishing.
214
+
215
+ ``beta`` is ``None`` **exactly** when the order is evanescent -- not
216
+ ``nan``, not ``0.0``. An evanescent order has no direction, and a number
217
+ here would invite one.
218
+ """
219
+
220
+ order: int
221
+ sin_beta: float
222
+ beta: float | None
223
+ propagating: bool
224
+ is_blaze_order: bool
225
+
226
+
227
+ @dataclass(frozen=True, slots=True)
228
+ class Diagram:
229
+ """One geometry, at one wavelength, as things to draw."""
230
+
231
+ paths: tuple[Polyline, ...]
232
+ arrows: tuple[Arrow, ...]
233
+ markers: tuple[Marker, ...]
234
+ orders: tuple[OrderMark, ...]
235
+ captions: tuple[Line, ...]
236
+ limits: dict[str, tuple[tuple[float, float], tuple[float, float]]]
237
+ wavelength: float
238
+ blaze_order: int | None
239
+
240
+ def mark(self, order: int) -> OrderMark:
241
+ """The :class:`OrderMark` for one order. Raises if it is not present --
242
+ an order that should be in the record and isn't is a bug, not a
243
+ default."""
244
+ for entry in self.orders:
245
+ if entry.order == order:
246
+ return entry
247
+ raise KeyError(f"order {order} is not in this diagram")
248
+
249
+ def on(self, panel: str) -> tuple[object, ...]:
250
+ """Every primitive belonging to one panel, for the widget's dispatch."""
251
+ return tuple(
252
+ item
253
+ for group in (self.paths, self.arrows, self.markers)
254
+ for item in group
255
+ if item.panel == panel
256
+ )
257
+
258
+
259
+ # -- coordinates ---------------------------------------------------------
260
+
261
+
262
+ def x_nm(t: ArrayLike, period: float) -> NDArray[np.float64]:
263
+ """Normalised profile position to physical position along :math:`\\hat{d}`,
264
+ in nm. See :data:`T_AXIS_SIGN`."""
265
+ return T_AXIS_SIGN * np.asarray(t, dtype=np.float64) * period
266
+
267
+
268
+ def direction(azimuth: float) -> NDArray[np.float64]:
269
+ """Unit vector at an azimuth measured from :math:`\\hat{n}` toward
270
+ :math:`\\hat{d}` -- the convention every angle in §3 uses."""
271
+ return np.array([np.sin(azimuth), np.cos(azimuth)])
272
+
273
+
274
+ # -- orders --------------------------------------------------------------
275
+
276
+
277
+ def order_span(
278
+ wavelength: float,
279
+ period: float,
280
+ sin_alpha: float,
281
+ sin_gamma: float = 1.0,
282
+ *,
283
+ pad: int = 2,
284
+ ) -> NDArray[np.int64]:
285
+ """Every propagating order, plus ``pad`` evanescent ones on each side.
286
+
287
+ :func:`~gratinglab.geometry.order_range` returns exactly the propagating
288
+ set, which is the right answer for a solver and the wrong one for a
289
+ picture: an order that has just passed off leaves no trace, and the passing
290
+ off is the thing worth watching.
291
+ """
292
+ live = order_range(wavelength, period, sin_alpha, sin_gamma)
293
+ return np.arange(int(live.min()) - pad, int(live.max()) + pad + 1, dtype=np.int64)
294
+
295
+
296
+ def order_marks(
297
+ problem: "Problem",
298
+ illumination: "Illumination",
299
+ wavelength: float,
300
+ *,
301
+ pad: int = 2,
302
+ ) -> tuple[OrderMark, ...]:
303
+ """Describe every order in the span, propagating or not."""
304
+ orders = order_span(
305
+ wavelength,
306
+ problem.period,
307
+ illumination.sin_alpha,
308
+ illumination.sin_gamma,
309
+ pad=pad,
310
+ )
311
+ values = sin_beta(
312
+ orders,
313
+ wavelength,
314
+ problem.period,
315
+ illumination.sin_alpha,
316
+ illumination.sin_gamma,
317
+ )
318
+ live = is_propagating(values)
319
+ blaze = _blaze_order(problem, illumination, wavelength)
320
+
321
+ return tuple(
322
+ OrderMark(
323
+ order=int(m),
324
+ sin_beta=float(s),
325
+ beta=float(np.arcsin(s)) if bool(p) else None,
326
+ propagating=bool(p),
327
+ is_blaze_order=(blaze is not None and int(m) == blaze),
328
+ )
329
+ for m, s, p in zip(orders, values, live)
330
+ )
331
+
332
+
333
+ def label_orders(
334
+ marks: Sequence[OrderMark], *, limit: int = MAX_ORDER_LABELS
335
+ ) -> frozenset[int]:
336
+ """Which orders get a text label.
337
+
338
+ Order 0 and the blaze order always, then the rest by descending
339
+ :math:`|\\sin\\beta|` proximity to the centre, so labels thin from the
340
+ crowded edges inward. Only labels are dropped; every propagating order
341
+ keeps its arrow.
342
+ """
343
+ live = [m for m in marks if m.propagating]
344
+ always = {m.order for m in live if m.order == 0 or m.is_blaze_order}
345
+ rest = sorted(
346
+ (m for m in live if m.order not in always), key=lambda m: abs(m.sin_beta)
347
+ )
348
+ room = max(0, limit - len(always))
349
+ return frozenset(always | {m.order for m in rest[:room]})
350
+
351
+
352
+ # -- the grating itself ---------------------------------------------------
353
+
354
+
355
+ def _active_facet_t(problem: "Problem") -> tuple[float, float]:
356
+ """The ``t`` span of the facet the beam is drawn striking, within the
357
+ period that occupies the middle of the window.
358
+
359
+ For a sawtooth that is the active (blaze) facet, ``[0, apex]``. For every
360
+ other profile there is no distinguished facet, so the anchor is the crest
361
+ -- the ray *directions* are the physics, and the origin is a visual anchor
362
+ that a caption already declares arbitrary.
363
+ """
364
+ profile = problem.profile
365
+ apex = getattr(profile, "apex", None)
366
+ if apex is None:
367
+ t = np.linspace(0.0, 1.0, 512, endpoint=False)
368
+ apex = float(t[int(np.argmax(profile.height(t)))])
369
+ return -1.0 + 0.0, -1.0 + float(apex)
370
+
371
+
372
+ def strike_point(problem: "Problem") -> tuple[float, float]:
373
+ """Where the incident ray is drawn meeting the grating, in nm."""
374
+ start, end = _active_facet_t(problem)
375
+ t = 0.5 * (start + end)
376
+ return float(x_nm(t, problem.period)), float(problem.height_nm(t))
377
+
378
+
379
+ def facet_normal(problem: "Problem") -> tuple[float, float]:
380
+ """Outward unit normal of the struck facet, in the physical frame.
381
+
382
+ Taken from :meth:`Profile.boundary`, which already returns outward normals
383
+ (``ny > 0``), with the ``x`` component flipped by :data:`T_AXIS_SIGN` --
384
+ the normal is a direction in ``t``-space and this frame runs the other way.
385
+ """
386
+ start, end = _active_facet_t(problem)
387
+ t_mid = np.mod(0.5 * (start + end), 1.0)
388
+
389
+ curve = problem.profile.boundary(512)
390
+ index = int(np.argmin(np.abs(curve.t - t_mid)))
391
+ nx = T_AXIS_SIGN * float(curve.nx[index])
392
+ ny = float(curve.ny[index])
393
+ norm = float(np.hypot(nx, ny))
394
+ return nx / norm, ny / norm
395
+
396
+
397
+ def _blaze_order(
398
+ problem: "Problem", illumination: "Illumination", wavelength: float
399
+ ) -> int | None:
400
+ """The order whose blaze wavelength is nearest the one being drawn, if the
401
+ profile has a blaze angle at all."""
402
+ targets = blaze_targets(problem, illumination)
403
+ if not targets:
404
+ return None
405
+ order, _ = min(targets, key=lambda pair: abs(pair[1] - wavelength))
406
+ return order
407
+
408
+
409
+ # -- wavelength selection -------------------------------------------------
410
+
411
+
412
+ def nearest_index(wavelengths: NDArray[np.float64], target: float) -> int:
413
+ """Index of the scan point closest to ``target``."""
414
+ return int(np.argmin(np.abs(np.asarray(wavelengths, dtype=np.float64) - target)))
415
+
416
+
417
+ def blaze_targets(
418
+ problem: "Problem",
419
+ illumination: "Illumination",
420
+ orders: Iterable[int] = range(1, 9),
421
+ ) -> tuple[tuple[int, float], ...]:
422
+ """``(order, blaze wavelength)`` pairs, or empty for a profile with no
423
+ blaze angle."""
424
+ blaze_angle = getattr(problem.profile, "blaze_angle", None)
425
+ if blaze_angle is None:
426
+ return ()
427
+ delta = np.radians(blaze_angle)
428
+ return tuple(
429
+ (
430
+ int(m),
431
+ float(
432
+ blaze_wavelength(
433
+ m, problem.period, delta, illumination.alpha, illumination.gamma
434
+ )
435
+ ),
436
+ )
437
+ for m in orders
438
+ )
439
+
440
+
441
+ def blaze_jump(
442
+ wavelengths: NDArray[np.float64],
443
+ problem: "Problem",
444
+ illumination: "Illumination",
445
+ ) -> tuple[int | None, str]:
446
+ """``(index to jump to, reason)`` for a "go to the blaze wavelength"
447
+ control.
448
+
449
+ The reason is non-empty **exactly** when the index is ``None``, so a caller
450
+ puts *this* text in a disabled button's tooltip instead of inventing one --
451
+ or leaving a dead control that says nothing. A disabled control that
452
+ explains itself is honest; an enabled one that silently does nothing is
453
+ not.
454
+ """
455
+ targets = blaze_targets(problem, illumination)
456
+ if not targets:
457
+ kind = type(problem.profile).__name__
458
+ return None, f"{kind} has no blaze angle, so no blaze wavelength."
459
+
460
+ lo = float(np.min(wavelengths))
461
+ hi = float(np.max(wavelengths))
462
+ inside = [(m, lam) for m, lam in targets if lo <= lam <= hi]
463
+ if inside:
464
+ order, lam = min(inside, key=lambda pair: pair[0])
465
+ return nearest_index(wavelengths, lam), ""
466
+
467
+ nearby = ", ".join(f"{lam:.2f} nm (m={m})" for m, lam in targets[:3])
468
+ return None, (
469
+ f"No blaze wavelength falls inside the {lo:.2f}-{hi:.2f} nm scan: {nearby}."
470
+ )
471
+
472
+
473
+ # -- captions -------------------------------------------------------------
474
+
475
+
476
+ def captions(
477
+ problem: "Problem",
478
+ illumination: "Illumination",
479
+ wavelength: float,
480
+ marks: Sequence[OrderMark],
481
+ ) -> tuple[Line, ...]:
482
+ """What the drawing cannot show, said in words.
483
+
484
+ Every one of these exists because the picture is true but partial, and a
485
+ partial truth left unlabelled is how a reader talks themselves into the
486
+ wrong geometry.
487
+ """
488
+ live = sum(1 for m in marks if m.propagating)
489
+ dark = len(marks) - live
490
+ lines: list[Line] = [
491
+ Line(
492
+ f"λ = {wavelength:.3g} nm. {live} of {len(marks)} orders shown "
493
+ f"propagate; {dark} are evanescent and appear on the sin β axis "
494
+ "with no ray, having no direction.\n"
495
+ ),
496
+ Line(
497
+ "View along the groove axis ĝ. Angles in this plane — α, β_m, δ, "
498
+ "β_b — are true, and the groove cross-section is to scale.\n",
499
+ "dim",
500
+ ),
501
+ ]
502
+
503
+ if illumination.is_in_plane:
504
+ lines.append(
505
+ Line(
506
+ "γ = 90°: this is the in-plane case. k_z = 0, so this diagram "
507
+ "is the whole geometry, not a projection.\n",
508
+ "dim",
509
+ )
510
+ )
511
+ else:
512
+ cos_gamma = float(np.cos(illumination.gamma))
513
+ lines.append(
514
+ Line(
515
+ f"γ = {illumination.gamma_deg:g}°: every ray also carries "
516
+ f"k_z = k cos γ = {cos_gamma:.5f} k out of the page, "
517
+ "identically for all orders. It is perpendicular to this "
518
+ "projection and so cannot appear in it — the 3D view beside "
519
+ "it draws γ as a real angle.\n",
520
+ "dim",
521
+ )
522
+ )
523
+
524
+ blaze_angle = getattr(problem.profile, "blaze_angle", None)
525
+ if blaze_angle is None:
526
+ lines.append(
527
+ Line(
528
+ f"{type(problem.profile).__name__} has no blaze angle, so no "
529
+ "blaze direction is drawn.\n",
530
+ "dim",
531
+ )
532
+ )
533
+ else:
534
+ zeta = facet_graze(
535
+ illumination.gamma, np.radians(blaze_angle), illumination.alpha
536
+ )
537
+ lines.append(
538
+ Line(
539
+ f"ζ = {np.degrees(zeta):.3g}° is the graze angle onto the facet "
540
+ "in three dimensions. It is not an angle in this plane and is "
541
+ "not drawn.\n",
542
+ "dim",
543
+ )
544
+ )
545
+
546
+ labelled = label_orders(marks)
547
+ if labelled != {m.order for m in marks if m.propagating}:
548
+ lines.append(
549
+ Line(
550
+ f"{len(labelled)} of {live} propagating orders are labelled; "
551
+ "every one is still drawn.\n",
552
+ "dim",
553
+ )
554
+ )
555
+
556
+ lines.append(
557
+ Line("Ray lengths are arbitrary; only their directions carry meaning.\n", "dim")
558
+ )
559
+ return tuple(lines)
560
+
561
+
562
+ # -- the whole drawing ----------------------------------------------------
563
+
564
+
565
+ def build(
566
+ problem: "Problem",
567
+ illumination: "Illumination",
568
+ wavelength: float,
569
+ *,
570
+ periods: int = 2,
571
+ pad_orders: int = 2,
572
+ ) -> Diagram:
573
+ """Everything one drawing consists of. The only entry point a widget needs."""
574
+ period = problem.period
575
+ marks = order_marks(problem, illumination, wavelength, pad=pad_orders)
576
+ labelled = label_orders(marks)
577
+ ray = _RAY_PERIODS * period
578
+
579
+ strike = np.array(strike_point(problem))
580
+ paths: list[Polyline] = []
581
+ arrows: list[Arrow] = []
582
+ markers: list[Marker] = []
583
+
584
+ # -- the grating surface, over `periods` periods --------------------
585
+ t = np.linspace(-periods, 0.0, _SURFACE_POINTS)
586
+ surface_x = x_nm(t, period)
587
+ surface_y = np.asarray(problem.height_nm(t), dtype=np.float64)
588
+ floor = float(surface_y.min()) - 0.35 * period
589
+ paths.append(
590
+ Polyline(surface_x, surface_y, "surface", fill_to=floor, label="grating")
591
+ )
592
+
593
+ # The struck facet, picked out of the surface so the eye finds it.
594
+ start, end = _active_facet_t(problem)
595
+ facet_t = np.linspace(start, end, 64)
596
+ paths.append(
597
+ Polyline(x_nm(facet_t, period), np.asarray(problem.height_nm(facet_t)), "facet")
598
+ )
599
+
600
+ # -- incident ray ----------------------------------------------------
601
+ # k_i projects to (-sin a, -cos a): it *travels* that way, so the tail sits
602
+ # back along the opposite direction and the head is the strike point.
603
+ incoming = direction(illumination.alpha)
604
+ tail = strike + ray * incoming
605
+ arrows.append(
606
+ Arrow(
607
+ float(tail[0]), float(tail[1]), float(strike[0]), float(strike[1]),
608
+ "incident", label="incident",
609
+ )
610
+ )
611
+ markers.append(Marker(float(strike[0]), float(strike[1]), "strike", "incident"))
612
+
613
+ # -- the diffraction cone, seen end-on -------------------------------
614
+ # Every propagating order has the same projected length, so they all land
615
+ # on this circle. That is what makes the mount's conical nature visible.
616
+ cone_az = np.linspace(-np.pi / 2, np.pi / 2, _CONE_POINTS)
617
+ paths.append(
618
+ Polyline(
619
+ strike[0] + ray * np.sin(cone_az),
620
+ strike[1] + ray * np.cos(cone_az),
621
+ "cone",
622
+ dashed=True,
623
+ label="cone of diffraction",
624
+ )
625
+ )
626
+
627
+ # -- one arrow per propagating order ---------------------------------
628
+ for mark in marks:
629
+ if mark.beta is None:
630
+ continue # no direction; it lives on the ladder instead
631
+ head = strike + ray * direction(mark.beta)
632
+ arrows.append(
633
+ Arrow(
634
+ float(strike[0]), float(strike[1]), float(head[0]), float(head[1]),
635
+ "zero" if mark.order == 0 else "order",
636
+ label=f"m={mark.order:+d}" if mark.order in labelled else None,
637
+ order=mark.order,
638
+ )
639
+ )
640
+ markers.append(Marker(float(head[0]), float(head[1]), "out-of-page", "cone"))
641
+
642
+ # -- facet normal and blaze direction --------------------------------
643
+ normal = np.array(facet_normal(problem))
644
+ normal_head = strike + 0.55 * ray * normal
645
+ arrows.append(
646
+ Arrow(
647
+ float(strike[0]), float(strike[1]),
648
+ float(normal_head[0]), float(normal_head[1]),
649
+ "normal", label="facet normal", dashed=True,
650
+ )
651
+ )
652
+
653
+ blaze_angle = getattr(problem.profile, "blaze_angle", None)
654
+ if blaze_angle is not None:
655
+ beta_b = blaze_direction(np.radians(blaze_angle), illumination.alpha)
656
+ head = strike + ray * direction(beta_b)
657
+ arrows.append(
658
+ Arrow(
659
+ float(strike[0]), float(strike[1]), float(head[0]), float(head[1]),
660
+ "blaze", label=f"β_b = {np.degrees(beta_b):.1f}°",
661
+ )
662
+ )
663
+
664
+ # -- the sin(beta) ladder --------------------------------------------
665
+ # The propagating window, so an evanescent order is visibly outside it
666
+ # rather than merely absent.
667
+ paths.append(
668
+ Polyline(
669
+ np.array([-1.0, 1.0]), np.array([0.0, 0.0]), "axis", panel="ladder",
670
+ label="propagating window",
671
+ )
672
+ )
673
+ for mark in marks:
674
+ tag = "zero" if mark.order == 0 else ("order" if mark.propagating else "evanescent")
675
+ markers.append(
676
+ Marker(mark.sin_beta, 0.0, "strike", tag, panel="ladder",
677
+ label=f"{mark.order:+d}")
678
+ )
679
+
680
+ span = periods * period
681
+ pad = 0.12 * span
682
+ limits = {
683
+ "main": (
684
+ (float(surface_x.min()) - pad, float(surface_x.max()) + pad),
685
+ (floor, float(strike[1]) + ray + pad),
686
+ ),
687
+ "ladder": ((-1.6, 1.6), (-1.0, 1.0)),
688
+ }
689
+
690
+ return Diagram(
691
+ paths=tuple(paths),
692
+ arrows=tuple(arrows),
693
+ markers=tuple(markers),
694
+ orders=marks,
695
+ captions=captions(problem, illumination, wavelength, marks),
696
+ limits=limits,
697
+ wavelength=float(wavelength),
698
+ blaze_order=_blaze_order(problem, illumination, wavelength),
699
+ )