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/profiles.py ADDED
@@ -0,0 +1,531 @@
1
+ """Groove profiles, in the four representations the solver methods need.
2
+
3
+ **Profiles are normalised.** Both the coordinate ``t`` and the returned height
4
+ are expressed in units of the period, so a profile is a pure *shape* and the
5
+ period lives once, on :class:`~gratinglab.problem.Problem`. A solver that needs
6
+ physical height multiplies by ``problem.period``. This also matches PCGrate's
7
+ ``.ggp`` format, which stores ``Period: 1`` and normalised points.
8
+
9
+ The four methods want genuinely different geometry from the same profile:
10
+
11
+ ================= ==========================================================
12
+ Method Representation
13
+ ================= ==========================================================
14
+ Scalar :meth:`Profile.height` -- the groove phase function
15
+ RCWA :meth:`Profile.slice_layers` -- piecewise-constant slabs
16
+ C-method :meth:`Profile.slope` -- smooth dy/dt; no vertical facets
17
+ Integral :meth:`Profile.boundary` -- parametrised curve with
18
+ outward normals and arc-length quadrature weights
19
+ ================= ==========================================================
20
+
21
+ Designing all four now is deliberate. Retrofitting normals and arc lengths onto
22
+ a shipped API once the integral method arrives would churn every profile type.
23
+
24
+ **A profile that cannot supply a representation raises.** ``Blazed`` with a
25
+ vertical anti-blaze facet has no finite slope there, so :meth:`Blazed.slope`
26
+ refuses rather than returning a large number that would quietly poison a
27
+ C-method solve.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from typing import Protocol, runtime_checkable
34
+
35
+ import numpy as np
36
+ from numpy.typing import ArrayLike, NDArray
37
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
38
+
39
+ __all__ = [
40
+ "Profile",
41
+ "Layer",
42
+ "BoundaryCurve",
43
+ "ProfileRepresentationError",
44
+ "Blazed",
45
+ "Lamellar",
46
+ "Sinusoidal",
47
+ "FromProfileData",
48
+ ]
49
+
50
+
51
+ class ProfileRepresentationError(NotImplementedError):
52
+ """A profile cannot be expressed in the representation a solver asked for.
53
+
54
+ Raised rather than approximated: a C-method solve fed a silently smoothed
55
+ vertical facet produces a plausible, wrong answer.
56
+ """
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class Layer:
61
+ """One piecewise-constant slab of an RCWA discretisation.
62
+
63
+ ``intervals`` lists the ``(t_start, t_end)`` spans, normalised to the
64
+ period, where *grating material* occupies this slab. The complement is the
65
+ incident medium.
66
+ """
67
+
68
+ y_lower: float
69
+ y_upper: float
70
+ intervals: tuple[tuple[float, float], ...]
71
+
72
+ @property
73
+ def thickness(self) -> float:
74
+ return self.y_upper - self.y_lower
75
+
76
+ @property
77
+ def fill_factor(self) -> float:
78
+ """Fraction of the period occupied by grating material."""
79
+ return sum(end - start for start, end in self.intervals)
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class BoundaryCurve:
84
+ """A parametrised boundary over one period, for integral-equation solvers.
85
+
86
+ Attributes
87
+ ----------
88
+ t, y
89
+ Points along the profile, normalised to the period.
90
+ nx, ny
91
+ Outward unit normal, pointing into the incident medium. ``ny > 0``
92
+ wherever the boundary is a height function; a vertical facet has a
93
+ horizontal normal (``ny = 0``), and an undercut portion can have
94
+ ``ny < 0``. Never inward.
95
+ ds
96
+ Arc-length weight for each point, for quadrature. Sums to the total
97
+ profile arc length over one period. Points are spaced at equal arc
98
+ length, which the integral method's rectangular quadrature rule
99
+ needs for its spectral accuracy.
100
+ """
101
+
102
+ t: NDArray[np.float64]
103
+ y: NDArray[np.float64]
104
+ nx: NDArray[np.float64]
105
+ ny: NDArray[np.float64]
106
+ ds: NDArray[np.float64]
107
+
108
+ @property
109
+ def arc_length(self) -> float:
110
+ return float(self.ds.sum())
111
+
112
+
113
+ @runtime_checkable
114
+ class Profile(Protocol):
115
+ """The interface every groove profile provides."""
116
+
117
+ @property
118
+ def depth(self) -> float:
119
+ """Peak-to-valley height, in units of the period."""
120
+
121
+ def height(self, t: ArrayLike) -> NDArray[np.float64]:
122
+ """Profile height at normalised position ``t``, periodic in ``t``."""
123
+
124
+ def slope(self, t: ArrayLike) -> NDArray[np.float64]:
125
+ """dy/dt. Raises :class:`ProfileRepresentationError` if not finite."""
126
+
127
+ def slice_layers(self, n: int) -> tuple[Layer, ...]:
128
+ """Piecewise-constant slabs, bottom to top."""
129
+
130
+ def boundary(self, n: int) -> BoundaryCurve:
131
+ """Parametrised boundary with outward normals and arc-length weights."""
132
+
133
+ def is_single_valued(self) -> bool:
134
+ """False for undercut profiles, which RCWA tolerates and C-method cannot."""
135
+
136
+
137
+ class _BaseProfile(BaseModel):
138
+ """Shared machinery. Concrete profiles supply ``depth`` and ``height``."""
139
+
140
+ model_config = ConfigDict(frozen=True, extra="forbid")
141
+
142
+ def height(self, t: ArrayLike) -> NDArray[np.float64]: # pragma: no cover
143
+ raise NotImplementedError
144
+
145
+ @property
146
+ def depth(self) -> float: # pragma: no cover
147
+ raise NotImplementedError
148
+
149
+ def slope(self, t: ArrayLike) -> NDArray[np.float64]:
150
+ """Numerical dy/dt by central difference.
151
+
152
+ Subclasses with an analytic derivative should override; subclasses with
153
+ a discontinuity must raise :class:`ProfileRepresentationError`.
154
+ """
155
+ t = np.mod(np.asarray(t, dtype=np.float64), 1.0)
156
+ h = 1e-7
157
+ return (self.height(t + h) - self.height(t - h)) / (2 * h)
158
+
159
+ def is_single_valued(self) -> bool:
160
+ """True unless a subclass models an undercut."""
161
+ return True
162
+
163
+ def slice_layers(self, n: int) -> tuple[Layer, ...]:
164
+ """Horizontal slabs, bottom to top.
165
+
166
+ Each slab records where ``height(t)`` reaches at least the slab's
167
+ mid-height, which is the standard staircase approximation RCWA uses.
168
+ Accuracy improves with ``n``; the convergence harness sweeps it.
169
+ """
170
+ if n < 1:
171
+ raise ValueError(f"need at least one layer, got {n}")
172
+ if not self.is_single_valued():
173
+ raise ProfileRepresentationError(
174
+ "cannot slice an undercut profile into horizontal layers by "
175
+ "height alone; the boundary is not a function of t"
176
+ )
177
+
178
+ edges = np.linspace(0.0, self.depth, n + 1)
179
+ # Sample finely so interval edges are located to better than a layer.
180
+ samples = max(2048, 64 * n)
181
+ t = np.linspace(0.0, 1.0, samples, endpoint=False)
182
+ y = self.height(t)
183
+
184
+ layers = []
185
+ for lower, upper in zip(edges[:-1], edges[1:]):
186
+ inside = y >= 0.5 * (lower + upper)
187
+ layers.append(
188
+ Layer(
189
+ y_lower=float(lower),
190
+ y_upper=float(upper),
191
+ intervals=_runs_to_intervals(inside, t),
192
+ )
193
+ )
194
+ return tuple(layers)
195
+
196
+ def boundary(self, n: int) -> BoundaryCurve:
197
+ """Boundary resampled at equal arc length, with outward normals.
198
+
199
+ Samples ``height`` finely and resamples the polyline at equal arc
200
+ length -- the node spacing the integral method's rectangular
201
+ quadrature rule needs. Profiles whose geometry is an exact polygon
202
+ (:class:`Blazed`, :class:`Lamellar`) override with their vertices so
203
+ facet corners and vertical segments carry no sampling error.
204
+ """
205
+ if n < 3:
206
+ raise ValueError(f"need at least 3 boundary points, got {n}")
207
+ fine = max(4096, 8 * n)
208
+ t = np.linspace(0.0, 1.0, fine, endpoint=False)
209
+ return _polyline_boundary(t, self.height(t), n)
210
+
211
+
212
+ def _polyline_boundary(
213
+ x: NDArray[np.float64], y: NDArray[np.float64], n: int
214
+ ) -> BoundaryCurve:
215
+ """Equal-arc-length boundary samples of a polyline spanning one period.
216
+
217
+ ``x`` and ``y`` are the open polygon vertices, normalised to the period;
218
+ the curve is closed by continuing to ``(x[0] + 1, y[0])``, so a trailing
219
+ vertical segment (a lamellar sidewall, an ideal sawtooth's anti-blaze
220
+ facet) is genuine geometry rather than a steep approximation. Handles
221
+ boundaries that double back in ``x`` (undercut), which is why every
222
+ ``boundary()`` implementation funnels through here.
223
+ """
224
+ if n < 3:
225
+ raise ValueError(f"need at least 3 boundary points, got {n}")
226
+
227
+ # Close the polygon: the curve continues into the next period.
228
+ tx = np.append(x, x[0] + 1.0)
229
+ ty = np.append(y, y[0])
230
+
231
+ segment = np.hypot(np.diff(tx), np.diff(ty))
232
+ cumulative = np.concatenate(([0.0], np.cumsum(segment)))
233
+ total = cumulative[-1]
234
+ if total <= 0:
235
+ raise ValueError("profile has zero arc length")
236
+
237
+ stations = np.linspace(0.0, total, n, endpoint=False)
238
+ xs = np.interp(stations, cumulative, tx)
239
+ ys = np.interp(stations, cumulative, ty)
240
+
241
+ # Periodic central differences; x wraps by exactly one period.
242
+ dx = np.roll(xs, -1) - np.roll(xs, 1)
243
+ dx[0] += 1.0
244
+ dx[-1] += 1.0
245
+ dy = np.roll(ys, -1) - np.roll(ys, 1)
246
+ norm = np.hypot(dx, dy)
247
+
248
+ # Tangent (dx, dy) rotated +90 deg is (-dy, dx), which points into the
249
+ # incident medium for a curve traversed in the +t direction.
250
+ forward = np.hypot(np.roll(xs, -1) - xs, np.roll(ys, -1) - ys)
251
+ forward[-1] = np.hypot(xs[0] + 1.0 - xs[-1], ys[0] - ys[-1])
252
+ backward = np.roll(forward, 1)
253
+
254
+ return BoundaryCurve(
255
+ t=xs,
256
+ y=ys,
257
+ nx=-dy / norm,
258
+ ny=dx / norm,
259
+ ds=0.5 * (forward + backward),
260
+ )
261
+
262
+
263
+ def _runs_to_intervals(
264
+ mask: NDArray[np.bool_], t: NDArray[np.float64]
265
+ ) -> tuple[tuple[float, float], ...]:
266
+ """Contiguous True runs of ``mask`` as ``(start, end)`` spans in ``t``.
267
+
268
+ Runs that wrap the period boundary are merged, since the profile is
269
+ periodic and a split run would misrepresent a single feature as two.
270
+ """
271
+ if not mask.any():
272
+ return ()
273
+ if mask.all():
274
+ return ((0.0, 1.0),)
275
+
276
+ step = 1.0 / len(t)
277
+ edges = np.flatnonzero(np.diff(mask.astype(np.int8)))
278
+ starts = [i + 1 for i in edges if not mask[i]]
279
+ ends = [i + 1 for i in edges if mask[i]]
280
+
281
+ if mask[0]:
282
+ starts.insert(0, 0)
283
+ if mask[-1]:
284
+ ends.append(len(mask))
285
+
286
+ spans = [(t[s], t[e - 1] + step) for s, e in zip(starts, ends)]
287
+
288
+ # Merge a run touching t=0 with one touching t=1: they are one feature.
289
+ if len(spans) > 1 and spans[0][0] == 0.0 and spans[-1][1] >= 1.0:
290
+ spans = [(spans[-1][0] - 1.0, spans[0][1])] + spans[1:-1]
291
+
292
+ return tuple((float(a), float(b)) for a, b in spans)
293
+
294
+
295
+ class Blazed(_BaseProfile):
296
+ """Sawtooth groove, the workhorse of blazed reflection gratings.
297
+
298
+ ``depth = 1 / (cot(blaze) + cot(antiblaze))`` in units of the period, which
299
+ reduces to ``tan(blaze)`` for the ideal sawtooth with a vertical anti-blaze
300
+ facet (``docs/conventions.md`` 9).
301
+
302
+ >>> round(Blazed(blaze_angle=30.0, antiblaze_angle=80.0).depth, 4)
303
+ 0.5241
304
+ """
305
+
306
+ blaze_angle: float = Field(gt=0.0, lt=90.0, description="Active facet angle, deg")
307
+ antiblaze_angle: float = Field(
308
+ default=90.0, gt=0.0, le=90.0, description="Opposite facet angle, deg"
309
+ )
310
+
311
+ @property
312
+ def _runs(self) -> tuple[float, float]:
313
+ """Horizontal extent of each facet, normalised. Sums to 1."""
314
+ cot_b = 1.0 / np.tan(np.radians(self.blaze_angle))
315
+ cot_a = 1.0 / np.tan(np.radians(self.antiblaze_angle))
316
+ total = cot_b + cot_a
317
+ return cot_b / total, cot_a / total
318
+
319
+ @property
320
+ def depth(self) -> float:
321
+ cot_b = 1.0 / np.tan(np.radians(self.blaze_angle))
322
+ cot_a = 1.0 / np.tan(np.radians(self.antiblaze_angle))
323
+ return 1.0 / (cot_b + cot_a)
324
+
325
+ @property
326
+ def apex(self) -> float:
327
+ """Normalised position of the facet crest."""
328
+ return self._runs[0]
329
+
330
+ @property
331
+ def has_vertical_facet(self) -> bool:
332
+ return bool(np.isclose(self.antiblaze_angle, 90.0))
333
+
334
+ def height(self, t: ArrayLike) -> NDArray[np.float64]:
335
+ t = np.mod(np.asarray(t, dtype=np.float64), 1.0)
336
+ apex, depth = self.apex, self.depth
337
+ rising = depth * t / apex
338
+ if self.has_vertical_facet:
339
+ return np.where(t < 1.0, rising, 0.0)
340
+ falling = depth * (1.0 - t) / (1.0 - apex)
341
+ return np.where(t <= apex, rising, falling)
342
+
343
+ def slope(self, t: ArrayLike) -> NDArray[np.float64]:
344
+ if self.has_vertical_facet:
345
+ raise ProfileRepresentationError(
346
+ f"{type(self).__name__} with antiblaze_angle=90 has a vertical "
347
+ "facet and no finite slope there. The C-method cannot represent "
348
+ "it; use a slightly shallower antiblaze_angle, or a method that "
349
+ "handles discontinuities (RCWA, integral)."
350
+ )
351
+ t = np.mod(np.asarray(t, dtype=np.float64), 1.0)
352
+ up = np.tan(np.radians(self.blaze_angle))
353
+ down = -np.tan(np.radians(self.antiblaze_angle))
354
+ return np.where(t <= self.apex, up, down)
355
+
356
+ def boundary(self, n: int) -> BoundaryCurve:
357
+ """Exact sawtooth polygon.
358
+
359
+ A vertical anti-blaze facet is the closing segment of the polygon --
360
+ genuine geometry a height function cannot express.
361
+ """
362
+ if self.has_vertical_facet:
363
+ x, y = np.array([0.0, 1.0]), np.array([0.0, self.depth])
364
+ else:
365
+ x, y = np.array([0.0, self.apex]), np.array([0.0, self.depth])
366
+ return _polyline_boundary(x, y, n)
367
+
368
+
369
+ class Lamellar(_BaseProfile):
370
+ """Rectangular (binary, laminar) groove."""
371
+
372
+ depth_fraction: float = Field(gt=0.0, description="Depth / period")
373
+ duty_cycle: float = Field(
374
+ default=0.5, gt=0.0, lt=1.0, description="Fraction of the period at full height"
375
+ )
376
+
377
+ @property
378
+ def depth(self) -> float:
379
+ return self.depth_fraction
380
+
381
+ def height(self, t: ArrayLike) -> NDArray[np.float64]:
382
+ t = np.mod(np.asarray(t, dtype=np.float64), 1.0)
383
+ return np.where(t < self.duty_cycle, self.depth, 0.0)
384
+
385
+ def slope(self, t: ArrayLike) -> NDArray[np.float64]:
386
+ raise ProfileRepresentationError(
387
+ "Lamellar has vertical sidewalls and no finite slope. The C-method "
388
+ "cannot represent it; use RCWA or the integral method."
389
+ )
390
+
391
+ def boundary(self, n: int) -> BoundaryCurve:
392
+ """Exact rectangular polygon; both sidewalls are genuine vertical segments."""
393
+ d, w = self.depth, self.duty_cycle
394
+ x = np.array([0.0, w, w, 1.0])
395
+ y = np.array([d, d, 0.0, 0.0])
396
+ return _polyline_boundary(x, y, n)
397
+
398
+
399
+ class Sinusoidal(_BaseProfile):
400
+ """Holographic (sinusoidal) groove -- smooth, so C-method friendly."""
401
+
402
+ depth_fraction: float = Field(gt=0.0, description="Peak-to-valley / period")
403
+
404
+ @property
405
+ def depth(self) -> float:
406
+ return self.depth_fraction
407
+
408
+ def height(self, t: ArrayLike) -> NDArray[np.float64]:
409
+ t = np.asarray(t, dtype=np.float64)
410
+ return 0.5 * self.depth * (1.0 - np.cos(2.0 * np.pi * t))
411
+
412
+ def slope(self, t: ArrayLike) -> NDArray[np.float64]:
413
+ t = np.asarray(t, dtype=np.float64)
414
+ return np.pi * self.depth * np.sin(2.0 * np.pi * t)
415
+
416
+
417
+ class FromProfileData(_BaseProfile):
418
+ """A measured profile -- AFM, or PCGrate's ``.ggp`` polygonal border.
419
+
420
+ Points are normalised: ``t`` spans one period on ``[0, 1]`` and ``y`` is in
421
+ units of the period. Use :meth:`from_measurements` to normalise raw nm data.
422
+
423
+ The profile is stored as given and interpolated linearly, matching how
424
+ PCGrate treats a polygonal border, so a scalar calculation and a PCGrate
425
+ run can be driven from the identical geometry.
426
+
427
+ **Undercut profiles are representable.** If ``t`` doubles back, the boundary
428
+ is not a function of position and :meth:`height` and
429
+ :meth:`~_BaseProfile.slice_layers` refuse -- but :meth:`boundary` still
430
+ works, because an integral-equation solver parametrises the curve rather
431
+ than assuming a height function. That asymmetry is real physics, not a
432
+ limitation of this class: RCWA and scalar theory genuinely cannot represent
433
+ an undercut, and the integral method genuinely can.
434
+
435
+ ``blaze_angle`` is optional and means something different here than it does
436
+ on :class:`Blazed`. There it is a *definition* -- the facet is that angle
437
+ because it was specified so. Here it is a *measurement*: somebody fitted a
438
+ line to a real facet and got a number with an uncertainty. Both feed
439
+ ``solvers.scalar._reflecting_graze`` identically, and the provenance string
440
+ says which one it had, because a reader deciding whether to trust an
441
+ absolute efficiency needs to know.
442
+
443
+ Leaving it ``None`` is not neutral. The reflection then falls back to the
444
+ mean surface -- a zero-tilt facet -- which for a sawtooth measured at
445
+ grazing incidence is the wrong plane by the whole blaze angle. The default
446
+ is ``None`` because most measured profiles genuinely arrive without a fit,
447
+ not because ``None`` is the better answer.
448
+ """
449
+
450
+ t: tuple[float, ...]
451
+ y: tuple[float, ...]
452
+ blaze_angle: float | None = None
453
+
454
+ @model_validator(mode="after")
455
+ def _check_points(self) -> "FromProfileData":
456
+ if len(self.t) != len(self.y):
457
+ raise ValueError(f"t has {len(self.t)} points, y has {len(self.y)}")
458
+ if len(self.t) < 3:
459
+ raise ValueError(f"need at least 3 points, got {len(self.t)}")
460
+ t = np.asarray(self.t)
461
+ if t.min() < 0.0 or t.max() > 1.0:
462
+ raise ValueError(f"t must lie in [0, 1], got [{t.min()}, {t.max()}]")
463
+ if self.blaze_angle is not None and not 0.0 < self.blaze_angle < 90.0:
464
+ # The same open interval `Blazed` enforces. A fit that lands outside
465
+ # it is not a shallow facet, it is a fit that found the wrong facet
466
+ # or the wrong sign convention, and passing it on as geometry would
467
+ # produce a confident, wrong reflectivity.
468
+ raise ValueError(
469
+ f"blaze_angle must lie in (0, 90) degrees, got {self.blaze_angle}"
470
+ )
471
+ return self
472
+
473
+ @classmethod
474
+ def from_measurements(
475
+ cls,
476
+ x_nm: ArrayLike,
477
+ y_nm: ArrayLike,
478
+ period_nm: float,
479
+ *,
480
+ blaze_angle: float | None = None,
481
+ ) -> "FromProfileData":
482
+ """Normalise a measured profile in nm, referencing its minimum to zero.
483
+
484
+ Pass ``blaze_angle`` when the same measurement also produced a facet
485
+ fit; see the class docstring for why it is worth carrying.
486
+ """
487
+ x = np.asarray(x_nm, dtype=np.float64)
488
+ y = np.asarray(y_nm, dtype=np.float64)
489
+ if period_nm <= 0:
490
+ raise ValueError(f"period_nm must be positive, got {period_nm}")
491
+ return cls(
492
+ t=tuple((x - x.min()) / period_nm),
493
+ y=tuple((y - y.min()) / period_nm),
494
+ blaze_angle=blaze_angle,
495
+ )
496
+
497
+ @property
498
+ def depth(self) -> float:
499
+ return float(np.ptp(np.asarray(self.y)))
500
+
501
+ def height(self, t: ArrayLike) -> NDArray[np.float64]:
502
+ if not self.is_single_valued():
503
+ raise ProfileRepresentationError(
504
+ "this profile is undercut -- its boundary doubles back in t, so "
505
+ "height is not a function of position. Use boundary() with an "
506
+ "integral-equation solver; scalar theory and RCWA cannot "
507
+ "represent an undercut."
508
+ )
509
+ query = np.mod(np.asarray(t, dtype=np.float64), 1.0)
510
+ return np.interp(query, np.asarray(self.t), np.asarray(self.y), period=1.0)
511
+
512
+ def is_single_valued(self) -> bool:
513
+ """A measured boundary that doubles back in ``t`` is undercut."""
514
+ return bool((np.diff(np.asarray(self.t)) >= 0).all())
515
+
516
+ @property
517
+ def apex(self) -> float:
518
+ """Normalised position of the highest measured point."""
519
+ return float(np.asarray(self.t)[int(np.argmax(np.asarray(self.y)))])
520
+
521
+ def boundary(self, n: int) -> BoundaryCurve:
522
+ """Resample the stored polygon at equal arc length.
523
+
524
+ Works from the stored points directly rather than through ``height``,
525
+ which is what lets this handle an undercut boundary.
526
+ """
527
+ return _polyline_boundary(
528
+ np.asarray(self.t, dtype=np.float64),
529
+ np.asarray(self.y, dtype=np.float64),
530
+ n,
531
+ )