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