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/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
|
+
)
|