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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
r"""Rendering LaTeX math from a theory page, without a markdown dependency.
|
|
2
|
+
|
|
3
|
+
A theory page's `$$\Phi_m(x) = ...$$` should not reach a reader as literal
|
|
4
|
+
source, backslashes and all. This module is the pure half of the fix:
|
|
5
|
+
splitting a markdown string into text and math spans, and rasterizing math
|
|
6
|
+
spans to PNG bytes via :func:`matplotlib.mathtext.math_to_image`, which is
|
|
7
|
+
already a dependency (it is what draws every axis label in the plots) and
|
|
8
|
+
needs no live display to run -- the whole pipeline down to PNG bytes is
|
|
9
|
+
headlessly testable. Only the final step of turning those bytes into an
|
|
10
|
+
embeddable image lives in :mod:`gratinglab.gui.richtext` and
|
|
11
|
+
:mod:`gratinglab.gui.qt.theory_viewer`, where it needs a real Qt document.
|
|
12
|
+
|
|
13
|
+
Two things worth knowing before touching this file:
|
|
14
|
+
|
|
15
|
+
**`\boxed{...}` is not supported by mathtext** (confirmed by testing --
|
|
16
|
+
it raises `ValueError` wrapping a `ParseFatalException`). `scalar.md`'s two
|
|
17
|
+
headline equations use it. :func:`strip_boxed` removes the wrapper by counting
|
|
18
|
+
braces rather than by regex, because the boxed content itself contains nested
|
|
19
|
+
braces (`\boxed{\;G_m = \int_0^1 e^{i\Phi_m(t)}\, ...\;}`) and a naive
|
|
20
|
+
`\boxed\{(.*)\}` pattern would either stop at the first inner `}` or, greedy,
|
|
21
|
+
swallow past the intended end. The visual rectangle is not reproduced --
|
|
22
|
+
mathtext has no boxed macro, and rebuilding one would mean splitting a single
|
|
23
|
+
display equation into multiple sub-images for a cosmetic rule. The equation
|
|
24
|
+
still renders correctly and reads as its own centered line, which is the part
|
|
25
|
+
that matters.
|
|
26
|
+
|
|
27
|
+
**A render failure degrades, it does not crash the viewer.** `render_math_png`
|
|
28
|
+
returns `None` rather than propagating the parser's exception, so one
|
|
29
|
+
unsupported macro in a future edit shows as visible raw source for that one
|
|
30
|
+
span -- diagnosable at a glance -- rather than taking down the whole page.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
__all__ = ["Segment", "split_segments", "strip_boxed", "render_math_png"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Segment:
|
|
43
|
+
"""One piece of a theory page: either plain text or a math span.
|
|
44
|
+
|
|
45
|
+
Attributes
|
|
46
|
+
----------
|
|
47
|
+
kind
|
|
48
|
+
``"text"`` or ``"math"``.
|
|
49
|
+
content
|
|
50
|
+
For ``"text"``, the literal markdown text. For ``"math"``, the LaTeX
|
|
51
|
+
source with its `$` or `$$` delimiters already stripped.
|
|
52
|
+
display
|
|
53
|
+
Only meaningful for ``"math"``: ``True`` for `$$...$$` (its own
|
|
54
|
+
centered line), ``False`` for inline `$...$`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
kind: Literal["text", "math"]
|
|
58
|
+
content: str
|
|
59
|
+
display: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def split_segments(markdown: str) -> tuple[Segment, ...]:
|
|
63
|
+
r"""Split markdown into alternating text and math segments.
|
|
64
|
+
|
|
65
|
+
Recognises `$$...$$` (display) and `$...$` (inline). A backslash-escaped
|
|
66
|
+
`\$` is never a delimiter and is emitted as a literal `$` in the
|
|
67
|
+
surrounding text segment. An odd, unterminated `$` (or `$$`) at the end of
|
|
68
|
+
the string is treated as literal text rather than raising -- a markdown
|
|
69
|
+
source with a typo should degrade to showing the stray dollar sign, not
|
|
70
|
+
crash the viewer that is trying to fix rendering problems.
|
|
71
|
+
"""
|
|
72
|
+
segments: list[Segment] = []
|
|
73
|
+
buffer: list[str] = []
|
|
74
|
+
i, n = 0, len(markdown)
|
|
75
|
+
|
|
76
|
+
def flush_text() -> None:
|
|
77
|
+
if buffer:
|
|
78
|
+
segments.append(Segment(kind="text", content="".join(buffer)))
|
|
79
|
+
buffer.clear()
|
|
80
|
+
|
|
81
|
+
while i < n:
|
|
82
|
+
char = markdown[i]
|
|
83
|
+
|
|
84
|
+
if char == "\\" and i + 1 < n and markdown[i + 1] == "$":
|
|
85
|
+
buffer.append("$")
|
|
86
|
+
i += 2
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if char == "$":
|
|
90
|
+
display = markdown[i : i + 2] == "$$"
|
|
91
|
+
delimiter = "$$" if display else "$"
|
|
92
|
+
start = i + len(delimiter)
|
|
93
|
+
end = _find_unescaped(markdown, delimiter, start)
|
|
94
|
+
if end == -1:
|
|
95
|
+
# No closing delimiter: treat the rest as literal text rather
|
|
96
|
+
# than silently dropping content or raising.
|
|
97
|
+
buffer.append(markdown[i:])
|
|
98
|
+
i = n
|
|
99
|
+
continue
|
|
100
|
+
flush_text()
|
|
101
|
+
segments.append(
|
|
102
|
+
Segment(kind="math", content=markdown[start:end], display=display)
|
|
103
|
+
)
|
|
104
|
+
i = end + len(delimiter)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
buffer.append(char)
|
|
108
|
+
i += 1
|
|
109
|
+
|
|
110
|
+
flush_text()
|
|
111
|
+
return tuple(segments)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_unescaped(text: str, delimiter: str, start: int) -> int:
|
|
115
|
+
"""Index of the next ``delimiter`` in ``text`` from ``start``, skipping
|
|
116
|
+
escaped occurrences. -1 if none."""
|
|
117
|
+
i = start
|
|
118
|
+
while True:
|
|
119
|
+
i = text.find(delimiter, i)
|
|
120
|
+
if i == -1:
|
|
121
|
+
return -1
|
|
122
|
+
if i > 0 and text[i - 1] == "\\":
|
|
123
|
+
i += len(delimiter)
|
|
124
|
+
continue
|
|
125
|
+
return i
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def strip_boxed(latex: str) -> str:
|
|
129
|
+
r"""Remove every ``\boxed{...}`` wrapper, keeping its inner content.
|
|
130
|
+
|
|
131
|
+
Brace-counting, not regex: the content inside ``\boxed{}`` routinely
|
|
132
|
+
contains its own nested braces (subscripts, ``\left\{`` groups, fractions),
|
|
133
|
+
so a pattern like ``\\boxed\{(.*)\}`` cannot reliably find the matching
|
|
134
|
+
close brace. Handles more than one ``\boxed{}`` in the same string, as in
|
|
135
|
+
the side-by-side pair in `scalar.md` section 3.
|
|
136
|
+
|
|
137
|
+
A ``\boxed`` with no matching close brace is left as-is -- the string is
|
|
138
|
+
passed through unchanged from that point, rather than raising on malformed
|
|
139
|
+
input this function did not create.
|
|
140
|
+
"""
|
|
141
|
+
marker = "\\boxed{"
|
|
142
|
+
result: list[str] = []
|
|
143
|
+
i, n = 0, len(latex)
|
|
144
|
+
|
|
145
|
+
while i < n:
|
|
146
|
+
start = latex.find(marker, i)
|
|
147
|
+
if start == -1:
|
|
148
|
+
result.append(latex[i:])
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
result.append(latex[i:start])
|
|
152
|
+
depth = 1
|
|
153
|
+
j = start + len(marker)
|
|
154
|
+
while j < n and depth > 0:
|
|
155
|
+
if latex[j] == "{":
|
|
156
|
+
depth += 1
|
|
157
|
+
elif latex[j] == "}":
|
|
158
|
+
depth -= 1
|
|
159
|
+
j += 1
|
|
160
|
+
|
|
161
|
+
if depth != 0:
|
|
162
|
+
# No matching close brace: not well-formed \boxed{}; leave the
|
|
163
|
+
# remainder untouched rather than guessing.
|
|
164
|
+
result.append(latex[start:])
|
|
165
|
+
i = n
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
result.append(latex[start + len(marker) : j - 1])
|
|
169
|
+
i = j
|
|
170
|
+
|
|
171
|
+
return "".join(result)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_math_png(
|
|
175
|
+
latex: str, *, dpi: int = 140, color: str = "black"
|
|
176
|
+
) -> bytes | None:
|
|
177
|
+
r"""Rasterize a LaTeX string (no `$` delimiters) to PNG bytes.
|
|
178
|
+
|
|
179
|
+
Returns ``None`` on any parser failure -- an unsupported macro, malformed
|
|
180
|
+
LaTeX -- instead of raising, so the caller can fall back to showing the raw
|
|
181
|
+
source for that one span. ``\boxed{}`` wrappers are stripped first (see
|
|
182
|
+
module docstring); everything else the project's docs use
|
|
183
|
+
(`\operatorname`, `\text`, `\qquad`, Greek letters, `\left[...\right]`,
|
|
184
|
+
sub/superscripts) is supported by mathtext directly, confirmed by testing.
|
|
185
|
+
|
|
186
|
+
A display equation split across source lines for readability (e.g. a long
|
|
187
|
+
``$$...$$`` wrapped onto a second, indented line) carries a literal
|
|
188
|
+
newline and leading whitespace into its LaTeX source once markdown
|
|
189
|
+
delimiters are stripped. Ordinary TeX ignores such whitespace, but
|
|
190
|
+
mathtext's parser does not tolerate an embedded ``\n`` -- confirmed by
|
|
191
|
+
testing -- so any run of whitespace is collapsed to a single space before
|
|
192
|
+
parsing, matching what plain LaTeX would do with the same source.
|
|
193
|
+
"""
|
|
194
|
+
import io
|
|
195
|
+
import re
|
|
196
|
+
|
|
197
|
+
import matplotlib
|
|
198
|
+
|
|
199
|
+
matplotlib.use("Agg") # no display needed; safe to call repeatedly
|
|
200
|
+
from matplotlib.mathtext import math_to_image
|
|
201
|
+
|
|
202
|
+
normalized = re.sub(r"\s+", " ", strip_boxed(latex)).strip()
|
|
203
|
+
|
|
204
|
+
buffer = io.BytesIO()
|
|
205
|
+
try:
|
|
206
|
+
math_to_image(f"${normalized}$", buffer, dpi=dpi, format="png", color=color)
|
|
207
|
+
except ValueError:
|
|
208
|
+
return None
|
|
209
|
+
return buffer.getvalue()
|
gratinglab/gui/orders.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Which diffraction orders the efficiency plot draws.
|
|
2
|
+
|
|
3
|
+
Previously one line in the widget layer::
|
|
4
|
+
|
|
5
|
+
if values.max() < 1e-4:
|
|
6
|
+
continue
|
|
7
|
+
|
|
8
|
+
An untested, unoverridable, invisible rule: an order below the threshold
|
|
9
|
+
vanished from the plot with nothing to say it had. That is the wrong shape for
|
|
10
|
+
this project -- the same objection as renormalising away an energy defect. The
|
|
11
|
+
threshold survives as a *default*, because it is a sensible one, but it is now
|
|
12
|
+
listed, labelled with the peak it was judged on, and overridable.
|
|
13
|
+
|
|
14
|
+
The logic lives here rather than in the panel so the default can be tested
|
|
15
|
+
without a window, and so the rule is written down somewhere a reader will find
|
|
16
|
+
it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Iterable, Sequence
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
|
|
26
|
+
from ..result import EfficiencyScan
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"OrderSummary",
|
|
30
|
+
"DEFAULT_THRESHOLD",
|
|
31
|
+
"DEFAULT_LIMIT",
|
|
32
|
+
"summarize",
|
|
33
|
+
"default_visible",
|
|
34
|
+
"carry_over",
|
|
35
|
+
"describe",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
#: Peak efficiency below which an order is hidden by default. Inherited from
|
|
39
|
+
#: the rule it replaces, so the plot looks the same on first open.
|
|
40
|
+
DEFAULT_THRESHOLD = 1e-4
|
|
41
|
+
|
|
42
|
+
#: At most this many curves by default, largest peaks first. New, and the part
|
|
43
|
+
#: that actually protects the plot: an X-ray geometry can propagate hundreds of
|
|
44
|
+
#: orders, and the old code's only defence was a legend that grew columns.
|
|
45
|
+
DEFAULT_LIMIT = 20
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class OrderSummary:
|
|
50
|
+
"""One diffraction order, as the visibility panel needs to describe it.
|
|
51
|
+
|
|
52
|
+
Attributes
|
|
53
|
+
----------
|
|
54
|
+
order
|
|
55
|
+
The diffraction order `m`.
|
|
56
|
+
column
|
|
57
|
+
Its column in ``scan.efficiency``. Carried explicitly so a caller never
|
|
58
|
+
has to search ``scan.orders`` again, and cannot get the mapping wrong.
|
|
59
|
+
peak
|
|
60
|
+
Largest efficiency over the scanned wavelengths. What the default rule
|
|
61
|
+
judges, and what the panel shows next to each entry.
|
|
62
|
+
ever_propagating
|
|
63
|
+
Whether the order propagates at any wavelength in the scan. An order
|
|
64
|
+
that is evanescent throughout has nothing to plot; it is listed anyway,
|
|
65
|
+
greyed, because "this order exists and carries nothing" is information.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
order: int
|
|
69
|
+
column: int
|
|
70
|
+
peak: float
|
|
71
|
+
ever_propagating: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def summarize(scan: "EfficiencyScan") -> tuple[OrderSummary, ...]:
|
|
75
|
+
"""Describe every order in a scan, in the scan's own order."""
|
|
76
|
+
peaks = scan.efficiency.max(axis=0)
|
|
77
|
+
propagating = scan.propagating.any(axis=0)
|
|
78
|
+
return tuple(
|
|
79
|
+
OrderSummary(
|
|
80
|
+
order=int(order),
|
|
81
|
+
column=column,
|
|
82
|
+
peak=float(peaks[column]),
|
|
83
|
+
ever_propagating=bool(propagating[column]),
|
|
84
|
+
)
|
|
85
|
+
for column, order in enumerate(scan.orders)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def default_visible(
|
|
90
|
+
summaries: Sequence[OrderSummary],
|
|
91
|
+
*,
|
|
92
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
93
|
+
limit: int = DEFAULT_LIMIT,
|
|
94
|
+
) -> frozenset[int]:
|
|
95
|
+
"""The orders worth drawing before the user says otherwise.
|
|
96
|
+
|
|
97
|
+
Two rules, in this sequence: peak at or above ``threshold``, then the
|
|
98
|
+
``limit`` largest of those. Ties are broken by order index so the result is
|
|
99
|
+
deterministic -- a plot that redrew differently for the same scan would be
|
|
100
|
+
its own small bug.
|
|
101
|
+
|
|
102
|
+
Returning a set of *orders* rather than columns means the choice survives a
|
|
103
|
+
re-solve that changes how many orders propagate, which is what
|
|
104
|
+
:func:`carry_over` relies on.
|
|
105
|
+
"""
|
|
106
|
+
candidates = [s for s in summaries if s.peak >= threshold]
|
|
107
|
+
candidates.sort(key=lambda s: (-s.peak, s.order))
|
|
108
|
+
return frozenset(s.order for s in candidates[:limit])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def carry_over(
|
|
112
|
+
previous_visible: Iterable[int],
|
|
113
|
+
previous_orders: Iterable[int],
|
|
114
|
+
summaries: Sequence[OrderSummary],
|
|
115
|
+
*,
|
|
116
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
117
|
+
limit: int = DEFAULT_LIMIT,
|
|
118
|
+
) -> frozenset[int]:
|
|
119
|
+
"""Reapply a user's choices to a freshly solved scan.
|
|
120
|
+
|
|
121
|
+
Nudging the blaze angle and re-solving must not silently re-check the
|
|
122
|
+
orders someone deliberately unchecked. So: an order that existed before
|
|
123
|
+
keeps whatever it was, shown or hidden; an order that has only just
|
|
124
|
+
appeared gets the default rule; an order that no longer exists is dropped
|
|
125
|
+
rather than remembered, since a stale selection would resurrect on the next
|
|
126
|
+
unrelated re-solve.
|
|
127
|
+
|
|
128
|
+
``previous_orders`` -- every order the last scan had, not just the visible
|
|
129
|
+
ones -- is what separates "hidden on purpose" from "not seen before". With
|
|
130
|
+
only the visible set the two are indistinguishable, and an unchecked order
|
|
131
|
+
would silently re-check itself on the next solve.
|
|
132
|
+
|
|
133
|
+
Pass empty iterables for the first solve; everything is then new and the
|
|
134
|
+
result is exactly :func:`default_visible`.
|
|
135
|
+
"""
|
|
136
|
+
seen_before = frozenset(previous_orders)
|
|
137
|
+
still_exists = {s.order for s in summaries}
|
|
138
|
+
kept = frozenset(previous_visible) & still_exists
|
|
139
|
+
fresh = [s for s in summaries if s.order not in seen_before]
|
|
140
|
+
return kept | default_visible(fresh, threshold=threshold, limit=limit)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def describe(summary: OrderSummary) -> str:
|
|
144
|
+
"""One entry's label, e.g. ``m=+3 peak 0.4127``.
|
|
145
|
+
|
|
146
|
+
Signed, always, because the sign of the order is the physics -- `+3` and
|
|
147
|
+
`-3` diffract to opposite sides -- and an unsigned `3` in a list next to
|
|
148
|
+
`-3` reads as ambiguous rather than positive.
|
|
149
|
+
"""
|
|
150
|
+
if not summary.ever_propagating:
|
|
151
|
+
return f"m={summary.order:+d} evanescent"
|
|
152
|
+
return f"m={summary.order:+d} peak {summary.peak:.4f}"
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""What the provenance panel says, decided without a toolkit.
|
|
2
|
+
|
|
3
|
+
The panel is the reason the GUI is worth having rather than a toy: it is where
|
|
4
|
+
a result declares how far it can be trusted. That makes *which line gets which
|
|
5
|
+
colour* a correctness question, not a styling one, and correctness questions do
|
|
6
|
+
not belong in a widget.
|
|
7
|
+
|
|
8
|
+
So this module owns the decision and returns :class:`Line` tuples; the widget
|
|
9
|
+
layer only paints them. The rule the tests exist to protect is that **things
|
|
10
|
+
which are not wrong must not look wrong**: ``converged is None`` means "not yet
|
|
11
|
+
checked", true for every solver until the convergence harness lands, and a
|
|
12
|
+
coating-free run giving relative efficiency is the correct default. Both once
|
|
13
|
+
rendered identically to a real problem, which is how a fully successful run
|
|
14
|
+
could read as broken. The convergence harness exists now, so ``None`` means
|
|
15
|
+
"this solve did not sweep" rather than "nothing can" -- still not a failure.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Sequence
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
|
|
25
|
+
from ..checks import EnergyReport
|
|
26
|
+
from ..result import EfficiencyScan
|
|
27
|
+
from .state import FieldError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Line",
|
|
31
|
+
"TAG_COLORS",
|
|
32
|
+
"provenance_lines",
|
|
33
|
+
"error_lines",
|
|
34
|
+
"to_html",
|
|
35
|
+
"about_text",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Line:
|
|
41
|
+
"""One run of panel text and the tag that colours it.
|
|
42
|
+
|
|
43
|
+
A ``tag`` of ``None`` is ordinary body text. Lines are emitted in reading
|
|
44
|
+
order and a single visual line may be several ``Line`` objects, so that a
|
|
45
|
+
neutral label can introduce a coloured value -- ``"energy balance: "``
|
|
46
|
+
dimmed, then the range in green or red.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
text: str
|
|
50
|
+
tag: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Only four, and their meanings are load-bearing. ``dim`` is for facts about
|
|
54
|
+
#: the run, ``ok``/``bad`` for a verdict that was actually reached, and ``warn``
|
|
55
|
+
#: for a validity guard the solver itself raised.
|
|
56
|
+
TAG_COLORS: dict[str, str] = {
|
|
57
|
+
"warn": "#a5370d",
|
|
58
|
+
"bad": "#b00020",
|
|
59
|
+
"ok": "#0a7d28",
|
|
60
|
+
"dim": "#666666",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#: Tags that read as "something is wrong here". The invariant this module
|
|
64
|
+
#: exists to protect is about which text may carry one.
|
|
65
|
+
ALARM_TAGS = frozenset({"warn", "bad"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def provenance_lines(
|
|
69
|
+
scan: "EfficiencyScan",
|
|
70
|
+
energy: "EnergyReport",
|
|
71
|
+
lambda_over_period: float,
|
|
72
|
+
*,
|
|
73
|
+
cancelled: bool = False,
|
|
74
|
+
) -> tuple[Line, ...]:
|
|
75
|
+
"""Everything the panel says about one completed solve.
|
|
76
|
+
|
|
77
|
+
``lambda_over_period`` is passed in rather than a parsed form, so this
|
|
78
|
+
function needs nothing from the GUI and is trivial to call from a test.
|
|
79
|
+
|
|
80
|
+
``cancelled`` marks the case where the user asked to stop and the result on
|
|
81
|
+
screen is the *previous* one. The solve really does stop now -- the solver
|
|
82
|
+
checks at every wavelength -- so this no longer has to hedge about work
|
|
83
|
+
continuing in the background.
|
|
84
|
+
"""
|
|
85
|
+
provenance = scan.provenance
|
|
86
|
+
lines: list[Line] = []
|
|
87
|
+
|
|
88
|
+
truncation = provenance.truncation
|
|
89
|
+
knob = (
|
|
90
|
+
f"{truncation} {_knob_label(provenance.method)} · "
|
|
91
|
+
if truncation is not None
|
|
92
|
+
else ""
|
|
93
|
+
)
|
|
94
|
+
lines.append(
|
|
95
|
+
Line(
|
|
96
|
+
f"{provenance.method} {provenance.version} · "
|
|
97
|
+
f"{knob}"
|
|
98
|
+
f"{_milliseconds(provenance.wall_time_s)} · "
|
|
99
|
+
f"λ/period = {lambda_over_period:.4g}\n"
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if cancelled:
|
|
104
|
+
# It used to say the calculation was "still finishing", which was true
|
|
105
|
+
# then and would be a lie now: the solver checks for cancellation at
|
|
106
|
+
# every wavelength and raises out of its own loop.
|
|
107
|
+
lines.append(
|
|
108
|
+
Line(
|
|
109
|
+
"cancelled — the calculation stopped; showing the previous "
|
|
110
|
+
"result\n",
|
|
111
|
+
"dim",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
lines.append(Line("convergence: ", "dim"))
|
|
116
|
+
study = provenance.notes.get("convergence")
|
|
117
|
+
if provenance.converged is None:
|
|
118
|
+
# Not a failure. `gratinglab.convergence` exists, but a plain solve
|
|
119
|
+
# does not run it -- a sweep costs several solves and is the caller's
|
|
120
|
+
# decision, not a hidden tax on pressing Solve. So this stays the
|
|
121
|
+
# ordinary state and must not be styled as alarming.
|
|
122
|
+
lines.append(Line("not checked — see gratinglab.convergence\n", "dim"))
|
|
123
|
+
elif provenance.converged:
|
|
124
|
+
lines.append(Line("yes", "ok"))
|
|
125
|
+
lines.append(Line(_evidence(study), "dim"))
|
|
126
|
+
else:
|
|
127
|
+
lines.append(Line("NO", "bad"))
|
|
128
|
+
lines.append(Line(_evidence(study), "dim"))
|
|
129
|
+
|
|
130
|
+
normalization = provenance.notes.get("normalization")
|
|
131
|
+
if normalization:
|
|
132
|
+
# The detail comes from whether a coating was *resolved*, which is a
|
|
133
|
+
# different question from whether its reflectivity was applied -- so
|
|
134
|
+
# "relative (Au ...)" is a real and honest state, meaning the material
|
|
135
|
+
# is known and not yet used. Deriving the wording from `normalization`
|
|
136
|
+
# alone would report "no coating" for it.
|
|
137
|
+
# Two keys because two solvers mean two things by it: scalar's
|
|
138
|
+
# `coating` is the material whose Fresnel amplitude was applied, the
|
|
139
|
+
# integral solver's `material` is the one its boundary condition read.
|
|
140
|
+
# Either is the honest detail; neither present means none was used.
|
|
141
|
+
detail = (
|
|
142
|
+
provenance.notes.get("coating")
|
|
143
|
+
or provenance.notes.get("material")
|
|
144
|
+
or "no coating"
|
|
145
|
+
)
|
|
146
|
+
lines.append(Line("normalization: ", "dim"))
|
|
147
|
+
lines.append(Line(f"{normalization} ({detail})\n", "dim"))
|
|
148
|
+
|
|
149
|
+
if scan.absorption is not None:
|
|
150
|
+
# A physical result, not a problem: `dim`, never an alarm tag. It also
|
|
151
|
+
# explains the deficit in Σ below, which would otherwise read as a
|
|
152
|
+
# method losing power it never lost.
|
|
153
|
+
lines.append(Line("absorption: ", "dim"))
|
|
154
|
+
lines.append(
|
|
155
|
+
Line(
|
|
156
|
+
f"A ∈ [{scan.absorption.min():.4f}, "
|
|
157
|
+
f"{scan.absorption.max():.4f}]\n",
|
|
158
|
+
"dim",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# `check_energy_balance` folds absorption into the total when a scan
|
|
163
|
+
# carries one, so the conserved quantity is R + A there. Naming it Σ
|
|
164
|
+
# anyway would collide with the Σ curve on the plot, which is R alone.
|
|
165
|
+
conserved = "Σ + A" if scan.absorption is not None else "Σ"
|
|
166
|
+
lines.append(Line("energy balance: ", "dim"))
|
|
167
|
+
lines.append(
|
|
168
|
+
Line(
|
|
169
|
+
f"{conserved} ∈ [{energy.total.min():.4f}, {energy.total.max():.4f}]"
|
|
170
|
+
f"{'' if energy.passed else ' — EXCEEDS UNITY'}\n",
|
|
171
|
+
"ok" if energy.passed else "bad",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
for warning in provenance.warnings:
|
|
176
|
+
lines.append(Line(f" ⚠ {warning}\n", "warn"))
|
|
177
|
+
|
|
178
|
+
return tuple(lines)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _knob_label(method: str) -> str:
|
|
182
|
+
"""What this method's truncation count is a count *of*.
|
|
183
|
+
|
|
184
|
+
Read off the registry rather than a table kept here, so a solver added
|
|
185
|
+
later names its own knob without editing this module -- the header said
|
|
186
|
+
"quadrature pts" for every method, which was wrong for the integral
|
|
187
|
+
solver's boundary points from the day that tab existed. Falls back for
|
|
188
|
+
imported reference data, whose `method` need not be a registered solver
|
|
189
|
+
at all.
|
|
190
|
+
"""
|
|
191
|
+
from ..solvers.base import get_solver
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
knob = get_solver(method).capabilities.accuracy_knob
|
|
195
|
+
except (KeyError, ImportError):
|
|
196
|
+
return "truncation"
|
|
197
|
+
return knob.replace("_", " ") if knob else "truncation"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _evidence(study: Any) -> str:
|
|
201
|
+
"""What a convergence verdict rests on, in one dim trailing clause.
|
|
202
|
+
|
|
203
|
+
A bare "yes" is a claim; the knob value and the swept range are what let a
|
|
204
|
+
reader check it -- and `converged_at` is the actionable half, since it is
|
|
205
|
+
usually cheaper than the value it took to prove it. Degrades to a bare
|
|
206
|
+
newline for a verdict that arrived without its study, rather than
|
|
207
|
+
inventing detail for it.
|
|
208
|
+
"""
|
|
209
|
+
if not isinstance(study, dict):
|
|
210
|
+
return "\n"
|
|
211
|
+
knob = study.get("knob", "accuracy")
|
|
212
|
+
at, values = study.get("converged_at"), study.get("values") or []
|
|
213
|
+
swept = f", swept to {values[-1]}" if values else ""
|
|
214
|
+
if at is None:
|
|
215
|
+
return f" — {knob} still moving{swept}\n"
|
|
216
|
+
return f" — {knob}={at} is enough{swept}\n"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def error_lines(errors: Sequence["FieldError"]) -> tuple[Line, ...]:
|
|
220
|
+
"""The panel's other job: say which fields stopped the solve."""
|
|
221
|
+
lines = [Line(f"{len(errors)} field(s) need attention\n", "bad")]
|
|
222
|
+
lines.extend(Line(f" • {e.field}: {e.message}\n", "bad") for e in errors)
|
|
223
|
+
return tuple(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def solving_lines(method: str, wavelength_count: int) -> tuple[Line, ...]:
|
|
227
|
+
"""Shown while a solve is in flight, in place of a stale verdict."""
|
|
228
|
+
return (
|
|
229
|
+
Line(f"solving — {method}, {wavelength_count} wavelengths…\n", "dim"),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def to_html(lines: Sequence[Line]) -> str:
|
|
234
|
+
"""Render lines as the small HTML subset Qt's rich text engine accepts.
|
|
235
|
+
|
|
236
|
+
Escaped, so a solver warning containing ``<`` or ``&`` -- and the docs do
|
|
237
|
+
carry ampersands -- cannot silently become markup or vanish.
|
|
238
|
+
"""
|
|
239
|
+
parts: list[str] = []
|
|
240
|
+
for line in lines:
|
|
241
|
+
body = html.escape(line.text).replace("\n", "<br>")
|
|
242
|
+
color = TAG_COLORS.get(line.tag) if line.tag else None
|
|
243
|
+
parts.append(f'<span style="color:{color}">{body}</span>' if color else body)
|
|
244
|
+
return f'<div style="white-space:pre-wrap">{"".join(parts)}</div>'
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def alarming_text(lines: Sequence[Line]) -> str:
|
|
248
|
+
"""Every character styled as a problem, concatenated.
|
|
249
|
+
|
|
250
|
+
Exists for the tests that assert something is *not* flagged, which is
|
|
251
|
+
otherwise awkward to phrase and was previously done by walking Tk tag
|
|
252
|
+
index pairs.
|
|
253
|
+
"""
|
|
254
|
+
return "".join(line.text for line in lines if line.tag in ALARM_TAGS)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def about_text(version: str) -> str:
|
|
258
|
+
"""The About box, as a string so it can be asserted without a dialog."""
|
|
259
|
+
return (
|
|
260
|
+
"GratingLab\n"
|
|
261
|
+
f"Version {version}\n\n"
|
|
262
|
+
"An open comparison platform for grating efficiency analysis: "
|
|
263
|
+
"scalar, RCWA, C-method and integral-method solvers driven from "
|
|
264
|
+
"one problem spec.\n\n"
|
|
265
|
+
"BSD-3-Clause."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _milliseconds(wall_time_s: Any) -> str:
|
|
270
|
+
"""Wall time for the header, tolerant of a solver that recorded none."""
|
|
271
|
+
if wall_time_s is None:
|
|
272
|
+
return "time not recorded"
|
|
273
|
+
return f"{wall_time_s * 1e3:.0f} ms"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The widget layer, and the only part of the GUI allowed to import a toolkit.
|
|
2
|
+
|
|
3
|
+
That boundary is a package boundary rather than a convention so a test can
|
|
4
|
+
enforce it: `tests/test_gui_qt.py` walks every module under `gui/` outside this
|
|
5
|
+
subpackage and fails if PySide6 appears. The drift it prevents is the ordinary
|
|
6
|
+
kind -- someone needing "just a QColor" in a module that was pure.
|
|
7
|
+
|
|
8
|
+
Everything here should be thin. If a question can be answered without a window
|
|
9
|
+
it belongs in `gui/state.py`, `gui/provenance.py`, `gui/richtext.py` or
|
|
10
|
+
`gui/orders.py`, all of which are pure and tested headlessly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
# matplotlib's Qt shim picks a binding from sys.modules first and QT_API
|
|
18
|
+
# second. Without this, a stray PyQt5 in the environment would win and its
|
|
19
|
+
# canvas would refuse to parent into a PySide6 window.
|
|
20
|
+
os.environ.setdefault("QT_API", "PySide6")
|