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
@@ -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()
@@ -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")