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/gui/docs.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Locating each solver's theory page.
|
|
2
|
+
|
|
3
|
+
Pure and toolkit-free, like :mod:`gratinglab.gui.state`, because this is the one
|
|
4
|
+
piece of "which page goes with which solver" logic worth testing on its own.
|
|
5
|
+
|
|
6
|
+
``docs/theory/`` lives at the **repo root**, outside ``src/gratinglab/``, so it
|
|
7
|
+
ships in a development checkout but not in a built wheel. That is a real
|
|
8
|
+
limitation of a pre-alpha, locally-run tool -- not something to paper over by
|
|
9
|
+
duplicating markdown into package data before there is a second solver to
|
|
10
|
+
justify it. :func:`theory_pages` degrades to an explanatory, unavailable entry
|
|
11
|
+
rather than raising when the directory cannot be found.
|
|
12
|
+
|
|
13
|
+
Every *registered* solver gets an entry, available or not, so the Help menu
|
|
14
|
+
always reflects what solvers exist. A solver with no page yet still shows up
|
|
15
|
+
and says so, rather than silently vanishing from the menu until someone
|
|
16
|
+
remembers to write it.
|
|
17
|
+
|
|
18
|
+
Alongside the per-solver pages, :func:`general_pages` returns entries that are
|
|
19
|
+
not tied to any solver -- currently just the normative ``conventions.md``,
|
|
20
|
+
which every solver's page cross-references for shared geometry (the
|
|
21
|
+
generalized grating equation, the angle conventions) rather than duplicating
|
|
22
|
+
it. Kept as a separate function rather than folded into :func:`theory_pages`
|
|
23
|
+
so that function's existing contract -- one entry per registered solver, no
|
|
24
|
+
more, no less -- stays exactly true; a test asserts it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from ..solvers.base import available_solvers, get_solver
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"TheoryPage",
|
|
36
|
+
"find_theory_root",
|
|
37
|
+
"theory_pages",
|
|
38
|
+
"general_pages",
|
|
39
|
+
"display_title",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Human-readable titles for known solvers. Falls back to the bare registry
|
|
43
|
+
#: name for anything not listed here, so a new solver still gets a menu entry
|
|
44
|
+
#: before anyone remembers to add a nicer title.
|
|
45
|
+
_TITLES = {
|
|
46
|
+
"scalar": "Scalar (Kirchhoff)",
|
|
47
|
+
"integral": "Integral (boundary integral equation)",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#: Non-solver reference pages: (key, title, filename relative to docs/).
|
|
51
|
+
_GENERAL_PAGES = (
|
|
52
|
+
("conventions", "Grating Geometry & Conventions", "conventions.md"),
|
|
53
|
+
# Not a solver page -- nothing here approximates a wave equation -- but an
|
|
54
|
+
# efficiency computed from a measured profile inherits every assumption
|
|
55
|
+
# made while producing that profile, and those are invisible in the curve.
|
|
56
|
+
# It belongs beside the conventions for the same reason they do.
|
|
57
|
+
("metrology", "Groove Metrology", "theory/metrology.md"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
#: How many parent directories to search before giving up. gui/docs.py sits at
|
|
61
|
+
#: src/gratinglab/gui/, four levels below a repo root that holds docs/theory/,
|
|
62
|
+
#: so this comfortably covers the dev-checkout case with room to spare.
|
|
63
|
+
_MAX_SEARCH_DEPTH = 6
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class TheoryPage:
|
|
68
|
+
"""One Help-menu entry.
|
|
69
|
+
|
|
70
|
+
Attributes
|
|
71
|
+
----------
|
|
72
|
+
name
|
|
73
|
+
Solver registry key, e.g. ``"scalar"``.
|
|
74
|
+
title
|
|
75
|
+
Display title for the menu.
|
|
76
|
+
available
|
|
77
|
+
Whether an actual page was found.
|
|
78
|
+
path
|
|
79
|
+
The theory file, if found.
|
|
80
|
+
text
|
|
81
|
+
The page content if available, otherwise a short explanation of why
|
|
82
|
+
not -- always something the viewer can show, never ``None``.
|
|
83
|
+
rigorous
|
|
84
|
+
The solver's own ``Capabilities.rigorous``. Used to decide whether the
|
|
85
|
+
viewer prefixes an approximate-method banner.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
title: str
|
|
90
|
+
available: bool
|
|
91
|
+
path: Path | None
|
|
92
|
+
text: str
|
|
93
|
+
rigorous: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def display_title(name: str) -> str:
|
|
97
|
+
"""A solver's human-readable name, e.g. ``"scalar"`` -> ``"Scalar
|
|
98
|
+
(Kirchhoff)"``.
|
|
99
|
+
|
|
100
|
+
Public so both the Help menu and a solver's own tab label read the same
|
|
101
|
+
name -- there is exactly one place that decides what a solver is called,
|
|
102
|
+
not two that could quietly drift apart.
|
|
103
|
+
"""
|
|
104
|
+
return _TITLES.get(name, name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def find_theory_root(start: Path | None = None) -> Path | None:
|
|
108
|
+
"""Search upward from ``start`` for a ``docs/theory`` directory.
|
|
109
|
+
|
|
110
|
+
Returns ``None`` rather than raising if none is found within
|
|
111
|
+
:data:`_MAX_SEARCH_DEPTH` levels -- e.g. running from an installed wheel,
|
|
112
|
+
where ``docs/`` was never packaged.
|
|
113
|
+
"""
|
|
114
|
+
here = (start or Path(__file__)).resolve()
|
|
115
|
+
for candidate in [here, *here.parents][: _MAX_SEARCH_DEPTH + 1]:
|
|
116
|
+
theory = candidate / "docs" / "theory"
|
|
117
|
+
if theory.is_dir():
|
|
118
|
+
return theory
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_page(
|
|
123
|
+
name: str, title: str, path: Path | None, *, rigorous: bool, not_written: str
|
|
124
|
+
) -> TheoryPage:
|
|
125
|
+
"""Build one entry, handling the three cases every page can be in.
|
|
126
|
+
|
|
127
|
+
Shared by :func:`theory_pages` and :func:`general_pages` so the "docs/ not
|
|
128
|
+
bundled" and "file missing" fallback text stays consistent between them.
|
|
129
|
+
"""
|
|
130
|
+
if path is None:
|
|
131
|
+
return TheoryPage(
|
|
132
|
+
name=name, title=title, available=False, path=None,
|
|
133
|
+
text=(
|
|
134
|
+
"Documentation is not bundled with this installation "
|
|
135
|
+
"(docs/ ships in a development checkout, not in a built "
|
|
136
|
+
f"package). See the project repository for {not_written}."
|
|
137
|
+
),
|
|
138
|
+
rigorous=rigorous,
|
|
139
|
+
)
|
|
140
|
+
if path.is_file():
|
|
141
|
+
return TheoryPage(
|
|
142
|
+
name=name, title=title, available=True, path=path,
|
|
143
|
+
text=path.read_text(encoding="utf-8"), rigorous=rigorous,
|
|
144
|
+
)
|
|
145
|
+
return TheoryPage(
|
|
146
|
+
name=name, title=title, available=False, path=path,
|
|
147
|
+
text=f"No page has been written yet for '{name}' ({not_written}).",
|
|
148
|
+
rigorous=rigorous,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def theory_pages() -> tuple[TheoryPage, ...]:
|
|
153
|
+
"""One entry per registered solver, in registration order.
|
|
154
|
+
|
|
155
|
+
Exactly one entry per name in ``available_solvers()`` -- a solver with no
|
|
156
|
+
page yet still appears, marked unavailable, rather than being silently
|
|
157
|
+
omitted until someone remembers to write it.
|
|
158
|
+
"""
|
|
159
|
+
root = find_theory_root()
|
|
160
|
+
return tuple(
|
|
161
|
+
_load_page(
|
|
162
|
+
name,
|
|
163
|
+
display_title(name),
|
|
164
|
+
None if root is None else root / f"{name}.md",
|
|
165
|
+
rigorous=get_solver(name).capabilities.rigorous,
|
|
166
|
+
not_written=f"docs/theory/{name}.md",
|
|
167
|
+
)
|
|
168
|
+
for name in available_solvers()
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def general_pages() -> tuple[TheoryPage, ...]:
|
|
173
|
+
"""Reference pages not tied to any one solver.
|
|
174
|
+
|
|
175
|
+
``rigorous=True`` throughout: these are geometry/convention references,
|
|
176
|
+
not a solver's approximation, so the viewer's approximate-method banner
|
|
177
|
+
never applies to them.
|
|
178
|
+
"""
|
|
179
|
+
root = find_theory_root()
|
|
180
|
+
return tuple(
|
|
181
|
+
_load_page(
|
|
182
|
+
name,
|
|
183
|
+
title,
|
|
184
|
+
None if root is None else root.parent / filename,
|
|
185
|
+
rigorous=True,
|
|
186
|
+
not_written=f"docs/{filename}",
|
|
187
|
+
)
|
|
188
|
+
for name, title, filename in _GENERAL_PAGES
|
|
189
|
+
)
|
gratinglab/gui/icon.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The app icon, drawn rather than stored.
|
|
2
|
+
|
|
3
|
+
Rendered from matplotlib because this machine has no SVG rasteriser
|
|
4
|
+
(``rsvg-convert`` / ``inkscape``), and generating the artwork keeps it
|
|
5
|
+
reproducible and editable in the same language as the rest of the project.
|
|
6
|
+
|
|
7
|
+
The motif is a blazed sawtooth with diffracted orders fanning out of it -- the
|
|
8
|
+
thing this software computes. Deliberately few, thick elements so it stays
|
|
9
|
+
legible at 16 px, which is where most icons fail.
|
|
10
|
+
|
|
11
|
+
``tools/make_icon.py`` turns this into a macOS ``.icns``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
__all__ = ["render", "BACKGROUND", "FACET", "ORDER_COLORS"]
|
|
19
|
+
|
|
20
|
+
#: Deep blue-black, so the spectrum reads against it at any size.
|
|
21
|
+
BACKGROUND = "#101a2e"
|
|
22
|
+
FACET = "#e8ecf5"
|
|
23
|
+
|
|
24
|
+
#: Long to short wavelength, matching the fan direction. Four rather than six:
|
|
25
|
+
#: at 16 px, six strokes blur into an unreadable smear, four stay distinct.
|
|
26
|
+
ORDER_COLORS = ("#ff4d4d", "#ffc233", "#4ade80", "#4dc4ff")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render(path: str | Path, size: int = 1024) -> Path:
|
|
30
|
+
"""Draw the icon to a square PNG.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
size
|
|
35
|
+
Pixels per side. 1024 is the largest macOS asks for; every smaller
|
|
36
|
+
variant is downsampled from it.
|
|
37
|
+
"""
|
|
38
|
+
import matplotlib
|
|
39
|
+
|
|
40
|
+
matplotlib.use("Agg")
|
|
41
|
+
import numpy as np
|
|
42
|
+
from matplotlib.figure import Figure
|
|
43
|
+
from matplotlib.patches import FancyBboxPatch, Polygon
|
|
44
|
+
|
|
45
|
+
figure = Figure(figsize=(size / 100, size / 100), dpi=100)
|
|
46
|
+
axes = figure.add_axes((0, 0, 1, 1))
|
|
47
|
+
axes.set_xlim(0, 1)
|
|
48
|
+
axes.set_ylim(0, 1)
|
|
49
|
+
axes.axis("off")
|
|
50
|
+
|
|
51
|
+
# Rounded-square plate, inset like a macOS app icon.
|
|
52
|
+
axes.add_patch(
|
|
53
|
+
FancyBboxPatch(
|
|
54
|
+
(0.06, 0.06), 0.88, 0.88,
|
|
55
|
+
boxstyle="round,pad=0,rounding_size=0.20",
|
|
56
|
+
facecolor=BACKGROUND, edgecolor="none",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Diffracted orders fanning from the blaze point. Drawn before the grating
|
|
61
|
+
# so the facets sit cleanly on top.
|
|
62
|
+
#
|
|
63
|
+
# Everything here is sized for the 16 px case: thick strokes, wide angular
|
|
64
|
+
# separation, few elements. A finer design looks better at 512 and turns to
|
|
65
|
+
# mush in the Finder list view, which is where an icon earns its keep.
|
|
66
|
+
origin = (0.26, 0.42)
|
|
67
|
+
for index, color in enumerate(ORDER_COLORS):
|
|
68
|
+
angle = np.radians(26 + index * 19.0)
|
|
69
|
+
axes.plot(
|
|
70
|
+
[origin[0], origin[0] + 0.62 * np.cos(angle)],
|
|
71
|
+
[origin[1], origin[1] + 0.62 * np.sin(angle)],
|
|
72
|
+
color=color, lw=size * 0.030, solid_capstyle="round",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Incident ray, arriving at a shallow graze from the left.
|
|
76
|
+
axes.plot(
|
|
77
|
+
[0.10, origin[0]], [0.60, origin[1]],
|
|
78
|
+
color="#8fa3c8", lw=size * 0.022, solid_capstyle="round",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Two big sawtooth teeth. Three read better at 512 px but disappear at 16;
|
|
82
|
+
# two survive the downsample and still say "blazed grating".
|
|
83
|
+
baseline, height = 0.16, 0.26
|
|
84
|
+
left, width = 0.14, 0.36
|
|
85
|
+
for tooth in range(2):
|
|
86
|
+
start = left + tooth * width
|
|
87
|
+
axes.add_patch(
|
|
88
|
+
Polygon(
|
|
89
|
+
[
|
|
90
|
+
(start, baseline),
|
|
91
|
+
(start + width * 0.82, baseline + height),
|
|
92
|
+
(start + width * 0.82, baseline),
|
|
93
|
+
],
|
|
94
|
+
closed=True, facecolor=FACET, edgecolor="none",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
path = Path(path)
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
figure.savefig(path, dpi=100, facecolor="none", transparent=True)
|
|
101
|
+
return path
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The integral solver's own form: boundary condition, points, and their guards.
|
|
2
|
+
|
|
3
|
+
The second solver's options module, made the way ``scalar_options.py`` said a
|
|
4
|
+
second one should be: its own file, its own frozen state, its own
|
|
5
|
+
``build_options`` raising :class:`~gratinglab.gui.state.FormErrors`, no
|
|
6
|
+
toolkit import anywhere.
|
|
7
|
+
|
|
8
|
+
Two knobs now, because the solver has two. ``conductivity`` chooses the
|
|
9
|
+
boundary condition -- ``"perfect"`` (relative efficiency, no material) or
|
|
10
|
+
``"tabulated"`` (the finite-conductivity system of Goray & Schmidt 2010,
|
|
11
|
+
absolute efficiency, absorption recorded). The mesh guard follows that
|
|
12
|
+
choice: the boundary field oscillates at the *reduced* wavelength
|
|
13
|
+
``lambda / sin(gamma)`` on the vacuum side, and under ``"tabulated"`` also at
|
|
14
|
+
``lambda / |sqrt(n^2 - cos^2 gamma)|` inside the material, which for anything
|
|
15
|
+
but a soft-X-ray index is the shorter of the two. Both floors mirror the
|
|
16
|
+
solver's own refusals (``solvers/integral``); validating here too means the
|
|
17
|
+
error lands on the field that caused it, alongside every other form error,
|
|
18
|
+
instead of surfacing as a ValueError from the worker thread.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, replace
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numpy.typing import NDArray
|
|
28
|
+
|
|
29
|
+
from ..illumination import Illumination
|
|
30
|
+
from ..problem import Problem
|
|
31
|
+
from ..solvers.integral import _POINTS_PER_WAVELENGTH
|
|
32
|
+
from .state import FieldError, FormErrors, _number
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CONDUCTIVITY_MODES",
|
|
36
|
+
"CONDUCTIVITY_NOTES",
|
|
37
|
+
"IntegralOptionsState",
|
|
38
|
+
"build_options",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: Boundary sampling used only to estimate the arc length for the guards.
|
|
42
|
+
#: Coarse is fine -- arc length converges long before the solve does.
|
|
43
|
+
_ARC_SAMPLES = 512
|
|
44
|
+
|
|
45
|
+
#: Selectable boundary conditions, in the order a form should offer them. The
|
|
46
|
+
#: first is the solver's own default, so a freshly opened window and a bare
|
|
47
|
+
#: ``integral.solve`` call agree.
|
|
48
|
+
CONDUCTIVITY_MODES = ("perfect", "tabulated")
|
|
49
|
+
|
|
50
|
+
#: What each mode means, in the words the tab shows beneath the selector. Kept
|
|
51
|
+
#: here rather than in the widget for the reason `provenance.py` gives: what a
|
|
52
|
+
#: panel *claims about a result* is a correctness question, and the old static
|
|
53
|
+
#: label went stale the moment the solver grew a second boundary condition.
|
|
54
|
+
CONDUCTIVITY_NOTES = {
|
|
55
|
+
"perfect": (
|
|
56
|
+
"Perfectly conducting boundary: efficiencies are relative to a "
|
|
57
|
+
"perfect reflector and sum to 1. A coating named in the geometry "
|
|
58
|
+
"panel is not consulted (the provenance panel says so)."
|
|
59
|
+
),
|
|
60
|
+
"tabulated": (
|
|
61
|
+
"Finite conductivity (Goray & Schmidt 2010): the coating named in "
|
|
62
|
+
"the geometry panel is read as a complex index at every wavelength. "
|
|
63
|
+
"Efficiencies are absolute, absorption is recorded, and R + A = 1. "
|
|
64
|
+
"Costs roughly twice as much per wavelength, on a finer mesh."
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class IntegralOptionsState:
|
|
71
|
+
"""Integral's own form fields, exactly as typed."""
|
|
72
|
+
|
|
73
|
+
boundary_points: str = "400"
|
|
74
|
+
conductivity: str = CONDUCTIVITY_MODES[0]
|
|
75
|
+
|
|
76
|
+
def with_field(self, name: str, value: Any) -> "IntegralOptionsState":
|
|
77
|
+
"""Return a copy with one field changed."""
|
|
78
|
+
return replace(self, **{name: value})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_options(
|
|
82
|
+
problem: Problem,
|
|
83
|
+
illumination: Illumination,
|
|
84
|
+
wavelengths: NDArray[np.float64],
|
|
85
|
+
form: IntegralOptionsState,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Parse the integral options and check the mesh can resolve the field.
|
|
88
|
+
|
|
89
|
+
Raises
|
|
90
|
+
------
|
|
91
|
+
FormErrors
|
|
92
|
+
Same contract as :func:`gratinglab.gui.state.build`.
|
|
93
|
+
"""
|
|
94
|
+
errors: list[FieldError] = []
|
|
95
|
+
points = _number(
|
|
96
|
+
form.boundary_points, "boundary_points", errors, minimum=32, integer=True
|
|
97
|
+
)
|
|
98
|
+
# Validated here rather than left to the solver so a bad value surfaces on
|
|
99
|
+
# the field that produced it rather than as an UnsupportedConfiguration
|
|
100
|
+
# from a worker thread.
|
|
101
|
+
if form.conductivity not in CONDUCTIVITY_MODES:
|
|
102
|
+
errors.append(
|
|
103
|
+
FieldError(
|
|
104
|
+
"conductivity",
|
|
105
|
+
f"must be one of {', '.join(CONDUCTIVITY_MODES)}",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
if errors:
|
|
109
|
+
raise FormErrors(tuple(errors))
|
|
110
|
+
assert points is not None
|
|
111
|
+
|
|
112
|
+
arc_length = (
|
|
113
|
+
problem.profile.boundary(_ARC_SAMPLES).arc_length * problem.period
|
|
114
|
+
)
|
|
115
|
+
reduced = float(wavelengths.min()) / illumination.sin_gamma
|
|
116
|
+
needed = int(np.ceil(_POINTS_PER_WAVELENGTH * arc_length / reduced))
|
|
117
|
+
message = (
|
|
118
|
+
f"needs at least {needed} to give "
|
|
119
|
+
f"{_POINTS_PER_WAVELENGTH:g} nodes per transverse "
|
|
120
|
+
f"wavelength ({reduced:.4g} nm) along the "
|
|
121
|
+
f"{arc_length:.4g} nm boundary"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if form.conductivity == "tabulated":
|
|
125
|
+
# Sequential prerequisites, not parallel checks: there is no index to
|
|
126
|
+
# range-check without a material, and no metal-side floor without an
|
|
127
|
+
# index. So these short-circuit rather than accumulating.
|
|
128
|
+
name, indices, error = _tabulated_index(problem, wavelengths)
|
|
129
|
+
if error is not None:
|
|
130
|
+
raise FormErrors((error,))
|
|
131
|
+
assert name is not None and indices is not None
|
|
132
|
+
metal_needed, metal_message = _metal_floor(
|
|
133
|
+
name, indices, illumination, wavelengths, arc_length
|
|
134
|
+
)
|
|
135
|
+
# One error on one field: whichever side of the boundary binds harder.
|
|
136
|
+
# Reporting both would put two messages on `boundary_points` that a
|
|
137
|
+
# single number answers.
|
|
138
|
+
if metal_needed > needed:
|
|
139
|
+
needed, message = metal_needed, metal_message
|
|
140
|
+
|
|
141
|
+
if points < needed:
|
|
142
|
+
raise FormErrors((FieldError("boundary_points", message),))
|
|
143
|
+
|
|
144
|
+
return {"boundary_points": int(points), "conductivity": form.conductivity}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tabulated_index(
|
|
148
|
+
problem: Problem, wavelengths: NDArray[np.float64]
|
|
149
|
+
) -> "tuple[str | None, NDArray[np.complex128] | None, FieldError | None]":
|
|
150
|
+
"""Resolve the material the tabulated boundary condition reads.
|
|
151
|
+
|
|
152
|
+
Mirrors ``solvers.integral._resolve_index``, but returns a
|
|
153
|
+
:class:`~gratinglab.gui.state.FieldError` instead of raising, so a missing
|
|
154
|
+
or unusable material lands on a form field rather than arriving as a
|
|
155
|
+
traceback from the worker thread.
|
|
156
|
+
|
|
157
|
+
``lookup`` is called without an :class:`UnknownMaterial` guard on purpose:
|
|
158
|
+
:func:`gratinglab.gui.state.build` has already rejected any coating name
|
|
159
|
+
that is not in the library, so that branch is unreachable from the window
|
|
160
|
+
and a handler for it would be untested dead code.
|
|
161
|
+
"""
|
|
162
|
+
from ..materials import lookup
|
|
163
|
+
|
|
164
|
+
name = problem.coating or problem.substrate
|
|
165
|
+
if name is None:
|
|
166
|
+
return None, None, FieldError(
|
|
167
|
+
"coating",
|
|
168
|
+
'conductivity="tabulated" reads a complex index at every '
|
|
169
|
+
"wavelength; name a coating in the geometry panel, or set "
|
|
170
|
+
'conductivity to "perfect" (which needs no material).',
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
table = lookup(name)
|
|
174
|
+
if not table.covers(wavelengths).all():
|
|
175
|
+
low, high = table.range_nm
|
|
176
|
+
return name, None, FieldError(
|
|
177
|
+
"wavelength_start",
|
|
178
|
+
f"{name} is tabulated over {low:.4g}-{high:.4g} nm; this scan "
|
|
179
|
+
f"runs {wavelengths.min():.4g}-{wavelengths.max():.4g} nm. "
|
|
180
|
+
"Extrapolating an optical constant is refused, so shorten the "
|
|
181
|
+
"scan or install a table that covers it.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
indices = np.asarray(table.n(wavelengths), dtype=np.complex128)
|
|
185
|
+
if (indices.imag < 0.0).any():
|
|
186
|
+
return name, None, FieldError(
|
|
187
|
+
"coating",
|
|
188
|
+
f"{name} tabulates a negative absorption (gain) inside this "
|
|
189
|
+
"scan; the transmission problem loses uniqueness there (Goray & "
|
|
190
|
+
"Schmidt 2010, section 2.B).",
|
|
191
|
+
)
|
|
192
|
+
return name, indices, None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _metal_floor(
|
|
196
|
+
name: str,
|
|
197
|
+
indices: NDArray[np.complex128],
|
|
198
|
+
illumination: Illumination,
|
|
199
|
+
wavelengths: NDArray[np.float64],
|
|
200
|
+
arc_length: float,
|
|
201
|
+
) -> tuple[int, str]:
|
|
202
|
+
"""The mesh floor the material side imposes, and what to say about it.
|
|
203
|
+
|
|
204
|
+
Same formula as the solver's own guard, so the two numbers agree: the
|
|
205
|
+
field inside a conductive material oscillates and decays on the scale
|
|
206
|
+
``lambda / |sqrt(n^2 - cos^2 gamma)|``. For the shipped soft-X-ray tables
|
|
207
|
+
``|n|`` is within a hair of 1 and this barely moves; for a visible-light
|
|
208
|
+
index of a few it is the binding constraint by a wide margin.
|
|
209
|
+
"""
|
|
210
|
+
cos_gamma = float(np.sqrt(1.0 - illumination.sin_gamma**2))
|
|
211
|
+
factors = np.abs(np.sqrt(indices**2 - cos_gamma**2))
|
|
212
|
+
reduced = float((wavelengths / factors).min())
|
|
213
|
+
needed = int(np.ceil(_POINTS_PER_WAVELENGTH * arc_length / reduced))
|
|
214
|
+
return needed, (
|
|
215
|
+
f"needs at least {needed} with coating {name}: the field inside a "
|
|
216
|
+
f"conductive material oscillates and decays on the scale lambda/|n| "
|
|
217
|
+
f"({reduced:.4g} nm here), and the mesh must resolve it along the "
|
|
218
|
+
f'{arc_length:.4g} nm boundary. Raise it, or use conductivity="perfect".'
|
|
219
|
+
)
|