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,447 @@
1
+ """The integral solver's own tab: its knob, its result, its provenance.
2
+
3
+ Deliberately a parallel construction of ``scalar_tab.py`` rather than a
4
+ shared base class: the roadmap's own warning is that every solver seam was
5
+ designed against a sample of one, and the way to learn the actual common
6
+ shape is to build the second tab and *then* extract what proved common. The
7
+ duplication between these two files is that evidence, kept visible.
8
+
9
+ What genuinely differs from scalar: the options group (the boundary
10
+ condition, and the accuracy knob it governs -- no reflectivity or roughness,
11
+ because the material enters through the boundary condition itself rather than
12
+ through a Fresnel factor) and the state type behind ``build_options``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ import dataclasses
19
+ from pathlib import Path
20
+ from typing import Sequence
21
+
22
+ from PySide6.QtCore import Qt, Signal
23
+ from PySide6.QtWidgets import (
24
+ QAbstractItemView,
25
+ QComboBox,
26
+ QFileDialog,
27
+ QFormLayout,
28
+ QGroupBox,
29
+ QHBoxLayout,
30
+ QLabel,
31
+ QLineEdit,
32
+ QListWidget,
33
+ QListWidgetItem,
34
+ QMessageBox,
35
+ QProgressBar,
36
+ QPushButton,
37
+ QScrollArea,
38
+ QSplitter,
39
+ QTextEdit,
40
+ QVBoxLayout,
41
+ QWidget,
42
+ )
43
+
44
+ from .. import orders as orders_module
45
+ from .. import provenance
46
+ from ..integral_options import (
47
+ CONDUCTIVITY_MODES,
48
+ CONDUCTIVITY_NOTES,
49
+ IntegralOptionsState,
50
+ )
51
+ from ..integral_options import build_options as build_integral_options
52
+ from .sizing import fit_width_to_contents
53
+
54
+ __all__ = ["IntegralTab"]
55
+
56
+ _ORDER_ROLE = Qt.ItemDataRole.UserRole
57
+
58
+
59
+ class IntegralTab(QWidget):
60
+ """Integral's own controls, result, and provenance."""
61
+
62
+ name = "integral"
63
+
64
+ solve_requested = Signal()
65
+ cancel_requested = Signal()
66
+ convergence_requested = Signal()
67
+
68
+ def __init__(self, parent: QWidget | None = None) -> None:
69
+ super().__init__(parent)
70
+ self._fields: dict[str, QWidget] = {}
71
+ self._scan = None
72
+ self._energy = None
73
+ self._lambda_over_period: float | None = None
74
+ self._visible_orders: frozenset[int] = frozenset()
75
+ self._previous_orders: frozenset[int] = frozenset()
76
+ self._build()
77
+
78
+ # -- construction ------------------------------------------------
79
+
80
+ def _build(self) -> None:
81
+ controls = QWidget()
82
+ column = QVBoxLayout(controls)
83
+ column.setContentsMargins(0, 0, 0, 0)
84
+ column.addWidget(self._build_options_group())
85
+ column.addWidget(self._build_orders_group())
86
+ column.addLayout(self._build_action_buttons())
87
+ column.addWidget(self._export_button)
88
+ column.addWidget(self._progress)
89
+ column.addStretch(1)
90
+
91
+ scroller = QScrollArea()
92
+ scroller.setWidget(controls)
93
+ scroller.setWidgetResizable(True)
94
+ fit_width_to_contents(scroller)
95
+
96
+ right = QSplitter(Qt.Orientation.Vertical)
97
+ right.addWidget(self._build_efficiency_plot())
98
+ right.addWidget(self._build_provenance())
99
+ right.setStretchFactor(0, 1)
100
+ right.setStretchFactor(1, 0)
101
+
102
+ body = QSplitter(Qt.Orientation.Horizontal)
103
+ body.addWidget(scroller)
104
+ body.addWidget(right)
105
+ body.setStretchFactor(0, 0)
106
+ body.setStretchFactor(1, 1)
107
+ body.setSizes([260, 700])
108
+
109
+ outer = QVBoxLayout(self)
110
+ outer.setContentsMargins(0, 0, 0, 0)
111
+ outer.addWidget(body)
112
+
113
+ self._check_fields_match_integral_options()
114
+
115
+ def _build_options_group(self) -> QWidget:
116
+ defaults = IntegralOptionsState()
117
+ group = QGroupBox("Integral solver")
118
+ form = QFormLayout(group)
119
+ points = QLineEdit(defaults.boundary_points)
120
+ points.returnPressed.connect(self.solve_requested)
121
+ points.setToolTip(
122
+ "Equal-arc-length nodes on one groove period -- the accuracy\n"
123
+ "knob the convergence sweep varies. Cost grows roughly cubically."
124
+ )
125
+ self._fields["boundary_points"] = points
126
+ form.addRow("Boundary pts", points)
127
+
128
+ conductivity = QComboBox()
129
+ conductivity.addItems(CONDUCTIVITY_MODES)
130
+ conductivity.setToolTip(
131
+ "Which boundary condition the integral equation carries.\n"
132
+ "perfect: perfectly conducting, no material -- efficiencies are\n"
133
+ " relative to a perfect reflector and sum to 1. A coating is\n"
134
+ " not consulted.\n"
135
+ "tabulated: finite conductivity (Goray & Schmidt 2010), reading\n"
136
+ " the coating's complex index at every wavelength --\n"
137
+ " efficiencies are absolute and absorption is recorded.\n"
138
+ "Tabulated costs roughly twice as much per wavelength, and needs\n"
139
+ "a finer mesh: the field oscillates inside the material too."
140
+ )
141
+ conductivity.currentTextChanged.connect(self._show_conductivity_note)
142
+ self._fields["conductivity"] = conductivity
143
+ form.addRow("Conductivity", conductivity)
144
+
145
+ # Dynamic, because the label this replaced asserted a perfectly
146
+ # conducting boundary unconditionally and went stale the moment the
147
+ # solver grew a second one. A claim about the result is exactly what
148
+ # must not be hardcoded beside a control that can falsify it.
149
+ self._conductivity_note = QLabel()
150
+ self._conductivity_note.setWordWrap(True)
151
+ form.addRow(self._conductivity_note)
152
+ self._show_conductivity_note(conductivity.currentText())
153
+ return group
154
+
155
+ def _show_conductivity_note(self, mode: str) -> None:
156
+ """Say what the selected boundary condition means for the numbers."""
157
+ self._conductivity_note.setText(CONDUCTIVITY_NOTES.get(mode, ""))
158
+
159
+ def _build_orders_group(self) -> QWidget:
160
+ group = QGroupBox("Orders")
161
+ layout = QVBoxLayout(group)
162
+
163
+ self._order_list = QListWidget()
164
+ self._order_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
165
+ self._order_list.itemChanged.connect(self._on_order_item_changed)
166
+ layout.addWidget(self._order_list)
167
+
168
+ buttons = QHBoxLayout()
169
+ show_all = QPushButton("All")
170
+ show_all.clicked.connect(self._show_all_orders)
171
+ show_none = QPushButton("None")
172
+ show_none.clicked.connect(self._show_no_orders)
173
+ show_default = QPushButton("Default")
174
+ show_default.clicked.connect(self._show_default_orders)
175
+ for button in (show_all, show_none, show_default):
176
+ buttons.addWidget(button)
177
+ layout.addLayout(buttons)
178
+
179
+ self._order_count_label = QLabel("")
180
+ layout.addWidget(self._order_count_label)
181
+ return group
182
+
183
+ def _build_action_buttons(self) -> QVBoxLayout:
184
+ self._solve_button = QPushButton("Solve")
185
+ self._solve_button.clicked.connect(self.solve_requested)
186
+ self._cancel_button = QPushButton("Cancel")
187
+ self._cancel_button.clicked.connect(self.cancel_requested)
188
+ self._cancel_button.setEnabled(False)
189
+ self._export_button = QPushButton("Export CSV…")
190
+ self._export_button.clicked.connect(self.export_csv)
191
+
192
+ self._converge_button = QPushButton("Check convergence…")
193
+ self._converge_button.setToolTip(
194
+ "Sweep boundary_points until the answer stops moving, and report "
195
+ "the coarsest setting that can be defended."
196
+ )
197
+ self._converge_button.clicked.connect(self.convergence_requested)
198
+
199
+ self._progress = QProgressBar()
200
+ self._progress.setRange(0, 0)
201
+ self._progress.hide()
202
+
203
+ row = QHBoxLayout()
204
+ row.addWidget(self._solve_button)
205
+ row.addWidget(self._cancel_button)
206
+
207
+ column = QVBoxLayout()
208
+ column.addLayout(row)
209
+ column.addWidget(self._converge_button)
210
+ return column
211
+
212
+ def _build_efficiency_plot(self) -> QWidget:
213
+ from matplotlib.backends.backend_qtagg import (
214
+ FigureCanvasQTAgg,
215
+ NavigationToolbar2QT,
216
+ )
217
+ from matplotlib.figure import Figure
218
+
219
+ self._figure = Figure(figsize=(8, 3), layout="constrained")
220
+ self._axes = self._figure.add_subplot(1, 1, 1)
221
+ self._canvas = FigureCanvasQTAgg(self._figure)
222
+
223
+ panel = QWidget()
224
+ layout = QVBoxLayout(panel)
225
+ layout.setContentsMargins(0, 0, 0, 0)
226
+ layout.addWidget(NavigationToolbar2QT(self._canvas, panel))
227
+ layout.addWidget(self._canvas)
228
+ return panel
229
+
230
+ def _build_provenance(self) -> QWidget:
231
+ panel = QWidget()
232
+ layout = QVBoxLayout(panel)
233
+ layout.setContentsMargins(8, 4, 8, 8)
234
+ heading = QLabel("Provenance")
235
+ heading.setStyleSheet("font-weight: bold")
236
+ self._provenance = QTextEdit()
237
+ self._provenance.setReadOnly(True)
238
+ layout.addWidget(heading)
239
+ layout.addWidget(self._provenance)
240
+ return panel
241
+
242
+ def _check_fields_match_integral_options(self) -> None:
243
+ declared = {f.name for f in dataclasses.fields(IntegralOptionsState)}
244
+ if self._fields.keys() != declared:
245
+ raise AssertionError(
246
+ "integral-option fields do not match IntegralOptionsState: "
247
+ f"missing {sorted(declared - self._fields.keys())}, "
248
+ f"extra {sorted(self._fields.keys() - declared)}"
249
+ )
250
+
251
+ # -- the MainWindow contract ---------------------------------------
252
+
253
+ def build_options(self, problem, illumination, wavelengths) -> dict:
254
+ options = IntegralOptionsState(
255
+ boundary_points=_value(self._fields["boundary_points"]),
256
+ conductivity=_value(self._fields["conductivity"]),
257
+ )
258
+ return build_integral_options(problem, illumination, wavelengths, options)
259
+
260
+ def set_running(self, running: bool) -> None:
261
+ self._solve_button.setEnabled(not running)
262
+ self._converge_button.setEnabled(not running)
263
+ self._cancel_button.setEnabled(running)
264
+ # Only the knob: the sweep chooses it, so the field it would come from
265
+ # is dead for the duration. `conductivity` is passed through to the
266
+ # sweep untouched, so it stays live, like scalar's model selectors.
267
+ self._fields["boundary_points"].setEnabled(not running)
268
+ if not running:
269
+ self._progress.hide()
270
+
271
+ def show_progress(self) -> None:
272
+ self._progress.show()
273
+
274
+ def show_progress_value(self, done: int, total: int) -> None:
275
+ if self._progress.maximum() != total:
276
+ self._progress.setRange(0, total)
277
+ self._progress.setValue(done)
278
+
279
+ def show_solving(self, wavelength_count: int) -> None:
280
+ self._progress.setRange(0, 0)
281
+ self._paint(provenance.solving_lines(self.name, wavelength_count))
282
+
283
+ def show_result(self, scan, energy, lambda_over_period: float) -> None:
284
+ self._scan, self._energy = scan, energy
285
+ self._lambda_over_period = lambda_over_period
286
+
287
+ summaries = orders_module.summarize(scan)
288
+ self._visible_orders = orders_module.carry_over(
289
+ self._visible_orders, self._previous_orders, summaries
290
+ )
291
+ self._previous_orders = frozenset(s.order for s in summaries)
292
+ self._rebuild_order_list(summaries)
293
+
294
+ self._draw_efficiency(scan)
295
+ self._paint(provenance.provenance_lines(scan, energy, lambda_over_period))
296
+
297
+ def show_convergence(self, scan, energy, lambda_over_period: float, report) -> None:
298
+ self.show_result(scan, energy, lambda_over_period)
299
+ if report.converged_at is not None:
300
+ self._fields["boundary_points"].setText(str(report.converged_at))
301
+
302
+ def show_cancelled(self) -> None:
303
+ if self._scan is None:
304
+ return
305
+ self._paint(
306
+ provenance.provenance_lines(
307
+ self._scan, self._energy, self._lambda_over_period, cancelled=True
308
+ )
309
+ )
310
+
311
+ def show_error(self, message: str) -> None:
312
+ self._paint((provenance.Line(f"solve failed: {message}\n", "bad"),))
313
+
314
+ def show_field_errors(self, errors) -> None:
315
+ self._paint(provenance.error_lines(errors))
316
+
317
+ # -- orders ----------------------------------------------------------
318
+
319
+ def _rebuild_order_list(self, summaries: Sequence["orders_module.OrderSummary"]) -> None:
320
+ self._order_list.blockSignals(True)
321
+ self._order_list.clear()
322
+ for summary in summaries:
323
+ item = QListWidgetItem(orders_module.describe(summary))
324
+ item.setData(_ORDER_ROLE, summary.order)
325
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
326
+ item.setCheckState(
327
+ Qt.CheckState.Checked
328
+ if summary.order in self._visible_orders
329
+ else Qt.CheckState.Unchecked
330
+ )
331
+ if not summary.ever_propagating:
332
+ item.setForeground(self.palette().mid().color())
333
+ self._order_list.addItem(item)
334
+ self._order_list.blockSignals(False)
335
+ self._update_order_count_label(len(summaries))
336
+
337
+ def _update_order_count_label(self, total: int) -> None:
338
+ self._order_count_label.setText(
339
+ f"{len(self._visible_orders)} of {total} shown"
340
+ )
341
+
342
+ def _on_order_item_changed(self, item: QListWidgetItem) -> None:
343
+ order = item.data(_ORDER_ROLE)
344
+ if item.checkState() == Qt.CheckState.Checked:
345
+ self._visible_orders = self._visible_orders | {order}
346
+ else:
347
+ self._visible_orders = self._visible_orders - {order}
348
+ self._update_order_count_label(self._order_list.count())
349
+ if self._scan is not None:
350
+ self._draw_efficiency(self._scan)
351
+
352
+ def _show_all_orders(self) -> None:
353
+ self._visible_orders = frozenset(self._previous_orders)
354
+ self._resync_order_checkboxes()
355
+
356
+ def _show_no_orders(self) -> None:
357
+ self._visible_orders = frozenset()
358
+ self._resync_order_checkboxes()
359
+
360
+ def _show_default_orders(self) -> None:
361
+ if self._scan is None:
362
+ return
363
+ self._visible_orders = orders_module.default_visible(
364
+ orders_module.summarize(self._scan)
365
+ )
366
+ self._resync_order_checkboxes()
367
+
368
+ def _resync_order_checkboxes(self) -> None:
369
+ self._order_list.blockSignals(True)
370
+ for i in range(self._order_list.count()):
371
+ item = self._order_list.item(i)
372
+ item.setCheckState(
373
+ Qt.CheckState.Checked
374
+ if item.data(_ORDER_ROLE) in self._visible_orders
375
+ else Qt.CheckState.Unchecked
376
+ )
377
+ self._order_list.blockSignals(False)
378
+ self._update_order_count_label(self._order_list.count())
379
+ if self._scan is not None:
380
+ self._draw_efficiency(self._scan)
381
+
382
+ # -- drawing -----------------------------------------------------
383
+
384
+ def _paint(self, lines) -> None:
385
+ self._provenance.setHtml(provenance.to_html(lines))
386
+
387
+ def _draw_efficiency(self, scan) -> None:
388
+ axes = self._axes
389
+ axes.clear()
390
+ for index, order in enumerate(scan.orders):
391
+ if int(order) not in self._visible_orders:
392
+ continue
393
+ axes.plot(
394
+ scan.wavelengths, scan.efficiency[:, index],
395
+ lw=1.3, label=f"m={int(order):+d}",
396
+ )
397
+ axes.plot(scan.wavelengths, scan.total, "k--", lw=1.0, alpha=0.7, label="Σ")
398
+ if scan.absorption is not None:
399
+ # Under a finite-conductivity solve Σ alone is the reflected
400
+ # fraction and sits below the unity line by exactly the absorbed
401
+ # one. Drawing A and Σ + A puts the R + A = 1 theorem on the same
402
+ # axes as its own discretisation error, rather than leaving a
403
+ # correct result looking like a deficit.
404
+ axes.plot(
405
+ scan.wavelengths, scan.absorption,
406
+ color="#a5370d", lw=1.0, ls="-.", alpha=0.8, label="A",
407
+ )
408
+ axes.plot(
409
+ scan.wavelengths, scan.total + scan.absorption,
410
+ color="k", lw=1.0, ls="-", alpha=0.5, label="Σ + A",
411
+ )
412
+ axes.axhline(1.0, color="#b00020", lw=0.8, ls=":", alpha=0.7)
413
+ axes.set_xlabel("wavelength (nm)")
414
+ axes.set_ylabel("efficiency")
415
+ axes.set_ylim(bottom=0)
416
+ axes.grid(alpha=0.25)
417
+ handles, _ = axes.get_legend_handles_labels()
418
+ if handles:
419
+ axes.legend(fontsize=7, ncol=max(1, len(handles) // 8), loc="upper right")
420
+ self._canvas.draw_idle()
421
+
422
+ # -- export ------------------------------------------------------
423
+
424
+ def export_csv(self) -> None:
425
+ if self._scan is None:
426
+ QMessageBox.information(self, "Nothing to export", "Solve first.")
427
+ return
428
+ path, _ = QFileDialog.getSaveFileName(
429
+ self, "Export efficiencies", "efficiency.csv", "CSV (*.csv)"
430
+ )
431
+ if not path:
432
+ return
433
+ rows = self._scan.to_records()
434
+ with open(path, "w", newline="", encoding="utf-8") as handle:
435
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
436
+ writer.writeheader()
437
+ writer.writerows(rows)
438
+ QMessageBox.information(
439
+ self, "Exported", f"{len(rows)} rows to {Path(path).name}"
440
+ )
441
+
442
+
443
+ def _value(widget: QWidget) -> str:
444
+ """The text of an input, whichever kind it is."""
445
+ if isinstance(widget, QComboBox):
446
+ return widget.currentText()
447
+ return widget.text()