gratinglab 0.1.13__tar.gz → 0.2.0__tar.gz

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 (185) hide show
  1. {gratinglab-0.1.13/src/gratinglab.egg-info → gratinglab-0.2.0}/PKG-INFO +2 -1
  2. {gratinglab-0.1.13 → gratinglab-0.2.0}/pyproject.toml +7 -0
  3. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/__init__.py +1 -1
  4. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/checks.py +5 -0
  5. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/docs.py +13 -0
  6. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/efficiency_plot.py +11 -2
  7. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/provenance.py +13 -1
  8. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/main_window.py +9 -19
  9. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/theming.py +48 -2
  10. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/boundary_view.py +31 -7
  11. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/canvas.py +46 -13
  12. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/efficiency_dialog.py +26 -3
  13. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/import_view.py +16 -3
  14. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/main_window.py +16 -1
  15. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/swath_dialog.py +18 -1
  16. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/problem.py +27 -1
  17. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/result.py +101 -35
  18. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/__init__.py +6 -0
  19. gratinglab-0.2.0/src/gratinglab/solvers/_conditioning.py +131 -0
  20. gratinglab-0.2.0/src/gratinglab/solvers/_execution.py +153 -0
  21. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/base.py +12 -0
  22. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/__init__.py +418 -0
  23. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/_core.py +405 -0
  24. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/_series.py +196 -0
  25. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/__init__.py +155 -54
  26. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_core.py +6 -2
  27. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_finite.py +9 -3
  28. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_nystrom.py +18 -0
  29. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/__init__.py +415 -0
  30. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/_core.py +437 -0
  31. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/_stack.py +364 -0
  32. {gratinglab-0.1.13 → gratinglab-0.2.0/src/gratinglab.egg-info}/PKG-INFO +2 -1
  33. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab.egg-info/SOURCES.txt +14 -0
  34. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab.egg-info/requires.txt +1 -0
  35. gratinglab-0.2.0/tests/test_cmethod.py +523 -0
  36. gratinglab-0.2.0/tests/test_conditioning.py +201 -0
  37. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_qt.py +6 -2
  38. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_theme.py +24 -0
  39. gratinglab-0.2.0/tests/test_rayleigh_anomaly.py +180 -0
  40. gratinglab-0.2.0/tests/test_rcwa.py +807 -0
  41. gratinglab-0.2.0/tests/test_rcwa_benchmarks.py +468 -0
  42. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_result.py +45 -1
  43. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_solver_conformance.py +43 -5
  44. gratinglab-0.2.0/tests/test_solver_execution.py +252 -0
  45. {gratinglab-0.1.13 → gratinglab-0.2.0}/LICENSE +0 -0
  46. {gratinglab-0.1.13 → gratinglab-0.2.0}/README.md +0 -0
  47. {gratinglab-0.1.13 → gratinglab-0.2.0}/setup.cfg +0 -0
  48. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/compare.py +0 -0
  49. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/convergence.py +0 -0
  50. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/geometry.py +0 -0
  51. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/__init__.py +0 -0
  52. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/app.py +0 -0
  53. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/diagram.py +0 -0
  54. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/diagram3d.py +0 -0
  55. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/icon.py +0 -0
  56. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/integral_options.py +0 -0
  57. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/launcher.py +0 -0
  58. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/mathtext.py +0 -0
  59. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/orders.py +0 -0
  60. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/__init__.py +0 -0
  61. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/gc_guard.py +0 -0
  62. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/geometry_panel.py +0 -0
  63. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/geometry_tab.py +0 -0
  64. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/integral_tab.py +0 -0
  65. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/launcher.py +0 -0
  66. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/profile_plot_panel.py +0 -0
  67. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/scalar_tab.py +0 -0
  68. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/scene3d_panel.py +0 -0
  69. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/setup_tab.py +0 -0
  70. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/sizing.py +0 -0
  71. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/theory_viewer.py +0 -0
  72. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/qt/worker.py +0 -0
  73. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/richtext.py +0 -0
  74. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/scalar_options.py +0 -0
  75. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/state.py +0 -0
  76. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/gui/theme.py +0 -0
  77. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/illumination.py +0 -0
  78. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/io/__init__.py +0 -0
  79. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/io/boundary.py +0 -0
  80. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/io/efficiency_table.py +0 -0
  81. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/io/ggp.py +0 -0
  82. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/materials/__init__.py +0 -0
  83. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/materials/data/SOURCES.md +0 -0
  84. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/materials/fresnel.py +0 -0
  85. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/materials/optical.py +0 -0
  86. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/materials/refractiveindex.py +0 -0
  87. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/measured.py +0 -0
  88. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/__init__.py +0 -0
  89. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/analyzer.py +0 -0
  90. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/__init__.py +0 -0
  91. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/average.py +0 -0
  92. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/pipeline.py +0 -0
  93. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/swaths.py +0 -0
  94. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/cli.py +0 -0
  95. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/config.py +0 -0
  96. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/__init__.py +0 -0
  97. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/analysis.py +0 -0
  98. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/image_flatten.py +0 -0
  99. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/orientation.py +0 -0
  100. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/processing.py +0 -0
  101. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/region_mask.py +0 -0
  102. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/scars.py +0 -0
  103. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/core/tip.py +0 -0
  104. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/data/__init__.py +0 -0
  105. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/data/aggregation.py +0 -0
  106. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/__init__.py +0 -0
  107. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/icon.py +0 -0
  108. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/__init__.py +0 -0
  109. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/app.py +0 -0
  110. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/wiki_view.py +0 -0
  111. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/worker.py +0 -0
  112. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/state.py +0 -0
  113. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/io/__init__.py +0 -0
  114. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/io/file_io.py +0 -0
  115. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/io/ibw.py +0 -0
  116. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/io/metrics.py +0 -0
  117. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/io/spm.py +0 -0
  118. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/settings.py +0 -0
  119. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/__init__.py +0 -0
  120. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/analysis.py +0 -0
  121. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/icc.py +0 -0
  122. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/__init__.py +0 -0
  123. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/diagnostics.py +0 -0
  124. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/profiles.py +0 -0
  125. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/statistics.py +0 -0
  126. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/__init__.py +0 -0
  127. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/facet-fitting.md +0 -0
  128. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/file-formats.md +0 -0
  129. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/flattening.md +0 -0
  130. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/groove-orientation.md +0 -0
  131. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/icc-correction.md +0 -0
  132. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/overview.md +0 -0
  133. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/reading-outputs.md +0 -0
  134. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/row-groups.md +0 -0
  135. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/scars-and-masking.md +0 -0
  136. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/swath-statistics.md +0 -0
  137. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/tip-correction.md +0 -0
  138. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/metrology/workflows.py +0 -0
  139. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/profiles.py +0 -0
  140. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/resolution.py +0 -0
  141. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_boundary.py +0 -0
  142. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_greens.py +0 -0
  143. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_kernels.py +0 -0
  144. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab/solvers/scalar.py +0 -0
  145. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab.egg-info/dependency_links.txt +0 -0
  146. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab.egg-info/entry_points.txt +0 -0
  147. {gratinglab-0.1.13 → gratinglab-0.2.0}/src/gratinglab.egg-info/top_level.txt +0 -0
  148. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_changelog.py +0 -0
  149. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_checks.py +0 -0
  150. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_compare.py +0 -0
  151. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_convergence.py +0 -0
  152. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_cross_method.py +0 -0
  153. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_efficiency_table.py +0 -0
  154. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_fresnel.py +0 -0
  155. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_geometry.py +0 -0
  156. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_ggp.py +0 -0
  157. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_diagram.py +0 -0
  158. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_diagram3d.py +0 -0
  159. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_docs.py +0 -0
  160. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_integral_options.py +0 -0
  161. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_launcher.py +0 -0
  162. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_mathtext.py +0 -0
  163. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_orders.py +0 -0
  164. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_provenance.py +0 -0
  165. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_richtext.py +0 -0
  166. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_scalar_options.py +0 -0
  167. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_gui_state.py +0 -0
  168. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_illumination.py +0 -0
  169. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_core.py +0 -0
  170. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_corpus.py +0 -0
  171. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_finite.py +0 -0
  172. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_graded.py +0 -0
  173. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_greens.py +0 -0
  174. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_kernels.py +0 -0
  175. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_operators.py +0 -0
  176. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_integral_solver.py +0 -0
  177. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_io_boundary.py +0 -0
  178. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_materials.py +0 -0
  179. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_measured.py +0 -0
  180. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_perturbation.py +0 -0
  181. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_profiles.py +0 -0
  182. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_refractiveindex.py +0 -0
  183. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_resolution.py +0 -0
  184. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_scalar.py +0 -0
  185. {gratinglab-0.1.13 → gratinglab-0.2.0}/tests/test_solver_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gratinglab
3
- Version: 0.1.13
3
+ Version: 0.2.0
4
4
  Summary: Open platform for diffraction grating groove metrology and efficiency analysis
5
5
  Author: Jake McCoy, Aditi Gangadharan
6
6
  License: BSD-3-Clause
@@ -23,6 +23,7 @@ Requires-Dist: numpy>=1.26
23
23
  Requires-Dist: scipy>=1.11
24
24
  Requires-Dist: pydantic>=2.6
25
25
  Requires-Dist: endstation>=0.2.2
26
+ Requires-Dist: threadpoolctl>=3.1
26
27
  Provides-Extra: dev
27
28
  Requires-Dist: pytest>=7; extra == "dev"
28
29
  Requires-Dist: pytest-qt>=4.5; extra == "dev"
@@ -52,6 +52,13 @@ dependencies = [
52
52
  # so this line adds no transitive weight -- the Qt and matplotlib parts sit
53
53
  # behind its own extras, which our `gui` extra asks for by name.
54
54
  "endstation>=0.2.2",
55
+ # The rigorous solvers cap BLAS threads for the length of a solve
56
+ # (solvers/_execution.py): OpenBLAS's thread-per-core default made RCWA
57
+ # up to 135x slower on a busy machine, measured in benchmarks/
58
+ # solver_speed.py. NumPy and SciPy each load their own OpenBLAS, and
59
+ # this is the one maintained way to reach both. Pure Python, BSD-3, no
60
+ # dependencies of its own.
61
+ "threadpoolctl>=3.1",
55
62
  ]
56
63
 
57
64
  [project.optional-dependencies]
@@ -22,7 +22,7 @@ from .measured import MeasuredEfficiency, MeasurementProvenance
22
22
  from .resolution import ResolvingPower, resolving_power
23
23
  from .result import EfficiencyScan, OrderEfficiency, Provenance
24
24
 
25
- __version__ = "0.1.13"
25
+ __version__ = "0.2.0"
26
26
 
27
27
  __all__ = [
28
28
  "Illumination",
@@ -238,6 +238,9 @@ def check_energy_balance(
238
238
 
239
239
  Notes
240
240
  -----
241
+ Transmitted orders, when the scan records them, are added to the sum: the
242
+ power a lossless substrate carries away is accounted for, not lost.
243
+
241
244
  When the scan records an absorbed fraction (``scan.absorption``, from a
242
245
  solver that computes one independently), the conserved quantity is the
243
246
  sum plus the absorption, and that is what is checked. ``R + A = 1`` is a
@@ -253,6 +256,8 @@ def check_energy_balance(
253
256
  reached 3.6.
254
257
  """
255
258
  total = np.asarray(scan.total, dtype=np.float64)
259
+ if scan.transmission is not None:
260
+ total = total + scan.transmission.total
256
261
  if scan.absorption is not None:
257
262
  total = total + scan.absorption
258
263
  return EnergyReport(
@@ -44,8 +44,10 @@ __all__ = [
44
44
  #: name for anything not listed here, so a new solver still gets a menu entry
45
45
  #: before anyone remembers to add a nicer title.
46
46
  _TITLES = {
47
+ "cmethod": "C-method (Chandezon)",
47
48
  "scalar": "Scalar (Kirchhoff)",
48
49
  "integral": "Integral (boundary integral equation)",
50
+ "rcwa": "RCWA (Fourier modal method)",
49
51
  }
50
52
 
51
53
  #: Non-solver reference pages: (key, title, filename relative to docs/).
@@ -105,6 +107,17 @@ def display_title(name: str) -> str:
105
107
  return _TITLES.get(name, name)
106
108
 
107
109
 
110
+ def short_title(name: str) -> str:
111
+ """A solver's tab label: its display title without the bracketed gloss.
112
+
113
+ ``"Integral (boundary integral equation)"`` becomes ``"Integral"``. The
114
+ full title was the tab label until 2026-10-27, and with four tabs plus the
115
+ geometry dock it already overflowed the bar, so every new solver made it
116
+ worse. The full title stays in the Help menu and the tab's tooltip.
117
+ """
118
+ return display_title(name).split(" (", 1)[0]
119
+
120
+
108
121
  def find_theory_root(start: Path | None = None) -> Path | None:
109
122
  """Search upward from ``start`` for a ``docs/theory`` directory.
110
123
 
@@ -50,6 +50,14 @@ def draw_efficiency(
50
50
  scan.wavelengths, scan.total,
51
51
  color=theme.ink, ls="--", lw=1.0, alpha=0.7, label="Σ",
52
52
  )
53
+ transmitted = (
54
+ scan.transmission.total if scan.transmission is not None else 0.0
55
+ )
56
+ if scan.transmission is not None:
57
+ axes.plot(
58
+ scan.wavelengths, scan.transmission.total,
59
+ color=theme.ink, lw=1.0, ls=":", alpha=0.9, label="T",
60
+ )
53
61
  if scan.absorption is not None:
54
62
  # Under a finite-conductivity solve Σ alone is the reflected fraction
55
63
  # and sits below the unity line by exactly the absorbed one. Drawing
@@ -61,8 +69,9 @@ def draw_efficiency(
61
69
  color=theme.warn, lw=1.0, ls="-.", alpha=0.9, label="A",
62
70
  )
63
71
  axes.plot(
64
- scan.wavelengths, scan.total + scan.absorption,
65
- color=theme.ink, lw=1.0, ls="-", alpha=0.5, label="Σ + A",
72
+ scan.wavelengths, scan.total + transmitted + scan.absorption,
73
+ color=theme.ink, lw=1.0, ls="-", alpha=0.5,
74
+ label="Σ + T + A" if scan.transmission is not None else "Σ + A",
66
75
  )
67
76
  axes.axhline(1.0, color=theme.bad, lw=0.8, ls=":", alpha=0.8)
68
77
  axes.set_xlabel("wavelength (nm)")
@@ -147,6 +147,16 @@ def provenance_lines(
147
147
  lines.append(Line("normalization: ", "dim"))
148
148
  lines.append(Line(f"{normalization} ({detail})\n", "dim"))
149
149
 
150
+ if scan.transmission is not None:
151
+ lines.append(Line("transmission: ", "dim"))
152
+ lines.append(
153
+ Line(
154
+ f"T ∈ [{scan.transmission.total.min():.4f}, "
155
+ f"{scan.transmission.total.max():.4f}]\n",
156
+ "dim",
157
+ )
158
+ )
159
+
150
160
  if scan.absorption is not None:
151
161
  # A physical result, not a problem: `dim`, never an alarm tag. It also
152
162
  # explains the deficit in Σ below, which would otherwise read as a
@@ -163,7 +173,9 @@ def provenance_lines(
163
173
  # `check_energy_balance` folds absorption into the total when a scan
164
174
  # carries one, so the conserved quantity is R + A there. Naming it Σ
165
175
  # anyway would collide with the Σ curve on the plot, which is R alone.
166
- conserved = "Σ + A" if scan.absorption is not None else "Σ"
176
+ conserved = "Σ" + (" + T" if scan.transmission is not None else "") + (
177
+ " + A" if scan.absorption is not None else ""
178
+ )
167
179
  lines.append(Line("energy balance: ", "dim"))
168
180
  lines.append(
169
181
  Line(
@@ -26,7 +26,7 @@ from __future__ import annotations
26
26
  import threading
27
27
 
28
28
  from PySide6.QtCore import Qt, QThread, QTimer, Signal
29
- from PySide6.QtGui import QActionGroup, QKeySequence
29
+ from PySide6.QtGui import QKeySequence
30
30
  from PySide6.QtWidgets import (
31
31
  QDockWidget,
32
32
  QMainWindow,
@@ -38,7 +38,6 @@ from PySide6.QtWidgets import (
38
38
 
39
39
  from .. import provenance
40
40
  from ..state import FormErrors, build, validate
41
- from ..theme import SYSTEM, THEMES
42
41
  from . import theming
43
42
  from .geometry_panel import GeometryPanel
44
43
  from .geometry_tab import GeometryTab
@@ -189,7 +188,7 @@ class MainWindow(QMainWindow):
189
188
  self.resizeDocks([dock], [300], Qt.Orientation.Horizontal)
190
189
 
191
190
  def _build_tabs(self) -> QWidget:
192
- from ..docs import display_title
191
+ from ..docs import display_title, short_title
193
192
  from ...solvers import available_solvers
194
193
 
195
194
  self.tabs: dict[str, QWidget] = {}
@@ -220,7 +219,8 @@ class MainWindow(QMainWindow):
220
219
  # has nothing to sweep and should not carry the button.
221
220
  converge.connect(lambda _checked=False, n=name: self.solve(n, sweep=True))
222
221
  self.tabs[name] = tab
223
- self._tab_widget.addTab(tab, display_title(name))
222
+ index = self._tab_widget.addTab(tab, short_title(name))
223
+ self._tab_widget.setTabToolTip(index, display_title(name))
224
224
 
225
225
  # Setup is leftmost so it is easy to find, but a solver tab -- not
226
226
  # the explanatory stub -- is what should be showing, and solving,
@@ -284,19 +284,11 @@ class MainWindow(QMainWindow):
284
284
  self._view_menu.addSeparator()
285
285
 
286
286
  # One exclusive group: System follows the OS, the rest are fixed.
287
- self._theme_menu = self._view_menu.addMenu("&Theme")
288
- self._theme_actions = QActionGroup(self)
289
- self._theme_actions.setExclusive(True)
290
- choices = [(SYSTEM, "System")] + [(k, t.label) for k, t in THEMES.items()]
291
- for key, label in choices:
292
- action = self._theme_menu.addAction(label)
293
- action.setCheckable(True)
294
- action.setData(key)
295
- action.setChecked(key == self._themes.choice)
296
- action.triggered.connect(lambda _checked=False, k=key: self.set_theme(k))
297
- self._theme_actions.addAction(action)
298
- if key == SYSTEM:
299
- self._theme_menu.addSeparator()
287
+ # Shared with the AFM window, so both offer it and both stay checked
288
+ # in step whichever one the choice was made in.
289
+ self._theme_menu = theming.ThemeMenu(self)
290
+ self._view_menu.addMenu(self._theme_menu)
291
+ self._theme_actions = self._theme_menu._group
300
292
 
301
293
  menu = self._help_menu = self._menu_bar.addMenu("&Help")
302
294
  menu.addAction("About GratingLab", self.show_about)
@@ -320,8 +312,6 @@ class MainWindow(QMainWindow):
320
312
  def set_theme(self, choice: str) -> None:
321
313
  """Switch theme by key, or ``"system"``; the menu calls this."""
322
314
  self._themes.set_choice(choice)
323
- for action in self._theme_actions.actions():
324
- action.setChecked(action.data() == choice)
325
315
 
326
316
  def _start_worker(self) -> None:
327
317
  self._thread = QThread(self)
@@ -22,14 +22,16 @@ import os
22
22
  from collections.abc import Callable
23
23
 
24
24
  from PySide6.QtCore import QObject, QSettings, Qt, Signal
25
- from PySide6.QtGui import QColor, QGuiApplication, QIcon, QPalette, QPixmap
26
- from PySide6.QtWidgets import QApplication
25
+ from PySide6.QtGui import (QAction, QActionGroup, QColor, QGuiApplication,
26
+ QIcon, QPalette, QPixmap)
27
+ from PySide6.QtWidgets import QApplication, QMenu
27
28
 
28
29
  from ..theme import DARK, LIGHT, SYSTEM, THEMES, Theme, rc_params
29
30
 
30
31
  __all__ = [
31
32
  "QIcon",
32
33
  "SETTINGS_ENV",
34
+ "ThemeMenu",
33
35
  "ThemeManager",
34
36
  "current",
35
37
  "install",
@@ -187,6 +189,50 @@ def subscribe(callback: Callable[[Theme], None]) -> None:
187
189
  found.changed.connect(callback)
188
190
 
189
191
 
192
+ class ThemeMenu(QMenu):
193
+ """The View > Theme submenu, one per window, all kept in step.
194
+
195
+ Every window that has a menu bar carries one, built here so that none can
196
+ be left without it: the AFM window had no theme choice at all while the
197
+ efficiency window did. Choosing in one window re-checks the others,
198
+ because each menu follows the manager's ``changed`` signal rather than
199
+ its own clicks.
200
+ """
201
+
202
+ def __init__(self, parent=None) -> None:
203
+ super().__init__("&Theme", parent)
204
+ self._group = QActionGroup(self)
205
+ self._group.setExclusive(True)
206
+ choices = [(SYSTEM, "System")] + [(k, t.label) for k, t in THEMES.items()]
207
+ for key, label in choices:
208
+ action = self.addAction(label)
209
+ action.setCheckable(True)
210
+ action.setData(key)
211
+ action.triggered.connect(self._chosen)
212
+ self._group.addAction(action)
213
+ if key == SYSTEM:
214
+ self.addSeparator()
215
+ found = manager()
216
+ if found is not None:
217
+ found.changed.connect(self._sync)
218
+ self._sync()
219
+
220
+ def actions_by_key(self) -> dict[str, QAction]:
221
+ return {a.data(): a for a in self._group.actions()}
222
+
223
+ def _chosen(self) -> None:
224
+ action = self.sender()
225
+ found = manager()
226
+ if found is not None and isinstance(action, QAction):
227
+ found.set_choice(action.data())
228
+
229
+ def _sync(self, _theme=None) -> None:
230
+ found = manager()
231
+ choice = found.choice if found is not None else SYSTEM
232
+ for key, action in self.actions_by_key().items():
233
+ action.setChecked(key == choice)
234
+
235
+
190
236
  def swatch(color: str, size: int = 12) -> QIcon:
191
237
  """A square of ``color``, for a list entry that names a plotted line."""
192
238
  pixmap = QPixmap(size, size)
@@ -27,6 +27,9 @@ from PySide6.QtWidgets import (
27
27
  from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
28
28
  from matplotlib.figure import Figure
29
29
 
30
+ from gratinglab.gui.qt import theming
31
+ from gratinglab.gui.theme import style_figure
32
+
30
33
  from ...boundary import build_boundary_profile
31
34
  from ...core.tip import format_angles
32
35
  from ...config import RESULTS_DIR
@@ -79,6 +82,7 @@ class BoundaryView(QWidget):
79
82
  self.figure = Figure(figsize=(8, 6), tight_layout=True)
80
83
  self.canvas = FigureCanvas(self.figure)
81
84
  self._show_placeholder()
85
+ theming.subscribe(self._on_theme)
82
86
 
83
87
  root.addWidget(scroll)
84
88
  divider = QFrame()
@@ -291,10 +295,11 @@ class BoundaryView(QWidget):
291
295
  f"Max curvature : {m['max_curvature']:.4f}{rotation}{tip}")
292
296
 
293
297
  def _show_placeholder(self, message="Load an AFM file in the Import tab"):
298
+ self._placeholder = message
294
299
  self.figure.clear()
295
300
  axes = self.figure.add_subplot(111)
296
301
  axes.text(0.5, 0.5, message, ha='center', va='center', fontsize=12,
297
- color='gray', transform=axes.transAxes)
302
+ color=theming.current().muted, transform=axes.transAxes)
298
303
  axes.set_axis_off()
299
304
  self.canvas.draw()
300
305
 
@@ -310,19 +315,25 @@ class BoundaryView(QWidget):
310
315
  x_nm = p.x_avg_um * 1000
311
316
  avg_ax.plot(x_nm, p.y_avg_nm, color='crimson', linewidth=1.8,
312
317
  label=f'Average of {p.n_used} grooves')
313
- avg_ax.fill_between(x_nm, p.y_avg_nm - p.y_std_nm,
314
- p.y_avg_nm + p.y_std_nm,
315
- color='crimson', alpha=0.2, label='±1σ')
318
+ # The band was crimson at alpha 0.2 alone, which vanishes against a
319
+ # dark axes, and a narrow band hides behind the line. Edges show its
320
+ # width even then.
321
+ low, high = p.y_avg_nm - p.y_std_nm, p.y_avg_nm + p.y_std_nm
322
+ dark = theming.current().dark
323
+ avg_ax.fill_between(x_nm, low, high, color='crimson',
324
+ alpha=0.35 if dark else 0.2, linewidth=0, label='±1σ')
325
+ for edge in (low, high):
326
+ avg_ax.plot(x_nm, edge, color='crimson', linewidth=0.6,
327
+ alpha=0.8 if dark else 0.6)
316
328
  avg_ax.set_xlabel("Position (nm)", fontsize=10)
317
329
  avg_ax.set_ylabel("Height (nm)", fontsize=10)
318
- avg_ax.set_title(
319
- f"Averaged groove: {os.path.basename(self._filename)}", fontsize=10)
330
+ avg_ax.set_title("Averaged groove", fontsize=10)
320
331
  avg_ax.legend(fontsize=8)
321
332
  avg_ax.grid(True, alpha=0.3)
322
333
 
323
334
  # What lands in the file.
324
335
  norm_ax.plot(p.x_norm, p.y_norm, color='steelblue', linewidth=1.6)
325
- norm_ax.axhline(0, color='k', linestyle='--', linewidth=0.5)
336
+ norm_ax.axhline(0, color=theming.current().ink, linestyle='--', linewidth=0.5)
326
337
  norm_ax.set_xlabel("Normalised position (0 to 1 = one period)", fontsize=10)
327
338
  norm_ax.set_ylabel("Height (fraction of period)", fontsize=10)
328
339
  norm_ax.set_title(
@@ -332,6 +343,19 @@ class BoundaryView(QWidget):
332
343
 
333
344
  self.canvas.draw()
334
345
 
346
+ def _on_theme(self, theme):
347
+ """Draw the figure again in the new theme.
348
+
349
+ Each draw clears the figure and builds fresh axes, which take their
350
+ colours from matplotlib's settings, so redrawing is the restyle. The
351
+ figure predates the change and needs its own background set first.
352
+ """
353
+ style_figure(self.figure, theme)
354
+ if self._profile is not None:
355
+ self._draw()
356
+ else:
357
+ self._show_placeholder(self._placeholder)
358
+
335
359
  def _set_actions_enabled(self, enabled: bool) -> None:
336
360
  """All four actions need only a profile, so they are enabled together."""
337
361
  self.export_btn.setEnabled(enabled)
@@ -6,7 +6,7 @@ nothing here is ever called from another thread.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- import os
9
+ import functools
10
10
 
11
11
  import numpy as np
12
12
 
@@ -16,35 +16,66 @@ import matplotlib
16
16
  from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
17
17
  from matplotlib.figure import Figure
18
18
 
19
+ from gratinglab.gui.qt import theming
20
+ from gratinglab.gui.theme import style_figure
21
+
19
22
  __all__ = ["PlotCanvas"]
20
23
 
21
24
 
25
+ def _remembered(method):
26
+ """Record the call, so a theme change can draw the same plot again."""
27
+ @functools.wraps(method)
28
+ def wrapper(self, *args, **kwargs):
29
+ self._last = (method, args, kwargs)
30
+ return method(self, *args, **kwargs)
31
+ return wrapper
32
+
33
+
22
34
  class PlotCanvas(FigureCanvas):
23
35
  def __init__(self, parent=None):
24
36
  self.fig = Figure(figsize=(8, 5), tight_layout=True)
25
37
  super().__init__(self.fig)
26
38
  self.setParent(parent)
39
+ self._last = None
40
+ theming.subscribe(self._on_theme)
27
41
  self.show_placeholder("Load an AFM file to see the raw profile")
28
42
 
43
+ def _on_theme(self, theme):
44
+ """Redraw the last plot in the new theme.
45
+
46
+ Every plot clears the figure and builds fresh axes, which take their
47
+ colours from matplotlib's settings at that moment, so drawing it again
48
+ is the whole restyle. The figure itself predates the change and needs
49
+ its own background set first.
50
+ """
51
+ style_figure(self.fig, theme)
52
+ if self._last is not None:
53
+ method, args, kwargs = self._last
54
+ method(self, *args, **kwargs)
55
+ else:
56
+ self.draw_idle()
57
+
58
+ @_remembered
29
59
  def show_placeholder(self, message):
30
60
  self.fig.clear()
31
61
  ax = self.fig.add_subplot(111)
32
62
  ax.text(0.5, 0.5, message, ha='center', va='center', fontsize=12,
33
- color='gray', transform=ax.transAxes)
63
+ color=theming.current().muted, transform=ax.transAxes)
34
64
  ax.set_axis_off()
35
65
  self.draw()
36
66
 
67
+ @_remembered
37
68
  def plot_raw(self, disp_um, profile_nm, filename, scan_size_um):
38
69
  self.fig.clear()
39
70
  ax = self.fig.add_subplot(111)
40
71
  ax.plot(disp_um, profile_nm, color='steelblue', linewidth=1.0)
41
72
  ax.set_xlabel("Position (µm)", fontsize=11)
42
73
  ax.set_ylabel("Height (nm)", fontsize=11)
43
- ax.set_title(f"Raw AFM Profile: {os.path.basename(filename)}\n"
44
- f"Scan width: {scan_size_um:.3f} µm", fontsize=11)
74
+ ax.set_title(f"Raw profile, scan width {scan_size_um:.3f} µm", fontsize=11)
45
75
  ax.grid(True, alpha=0.3)
46
76
  self.draw()
47
77
 
78
+ @_remembered
48
79
  def plot_2d(self, data, scan_size_um, filename):
49
80
  self.fig.clear()
50
81
  ax = self.fig.add_subplot(111)
@@ -53,9 +84,10 @@ class PlotCanvas(FigureCanvas):
53
84
  self.fig.colorbar(im, ax=ax, label='Height (nm)')
54
85
  ax.set_xlabel("X (µm)", fontsize=11)
55
86
  ax.set_ylabel("Scan line", fontsize=11)
56
- ax.set_title(f"2D AFM Topography: {os.path.basename(filename)}", fontsize=11)
87
+ ax.set_title("Topography", fontsize=11)
57
88
  self.draw()
58
89
 
90
+ @_remembered
59
91
  def plot_row_groups(self, disp_um, profiles_nm, group_info, filename, scan_size_um):
60
92
  self.fig.clear()
61
93
  ax = self.fig.add_subplot(111)
@@ -66,11 +98,11 @@ class PlotCanvas(FigureCanvas):
66
98
  ax.plot(disp_um, profile, color=cmap(i / max(n_groups - 1, 1)),
67
99
  linewidth=0.8, alpha=0.6)
68
100
 
69
- ax.plot(disp_um, np.mean(np.array(profiles_nm), axis=0), color='black',
101
+ ax.plot(disp_um, np.mean(np.array(profiles_nm), axis=0), color=theming.current().ink,
70
102
  linewidth=1.8, label=f'Mean (n={n_groups})', zorder=5)
71
103
  ax.set_xlabel("Position (µm)", fontsize=11)
72
104
  ax.set_ylabel("Height (nm)", fontsize=11)
73
- ax.set_title(f"Row-Group Profiles: {os.path.basename(filename)}\n"
105
+ ax.set_title("Row-group profiles\n"
74
106
  f"{n_groups} groups × {group_info['rows_per_group']} rows each | "
75
107
  f"Scan width: {scan_size_um:.3f} µm", fontsize=10)
76
108
  ax.legend(fontsize=10)
@@ -84,6 +116,7 @@ class PlotCanvas(FigureCanvas):
84
116
  "Image row (top → bottom)", fontsize=9)
85
117
  self.draw()
86
118
 
119
+ @_remembered
87
120
  def plot_groove_detection(self, result, filename, settings):
88
121
  """Flattened profile with the grooves the analysis used"""
89
122
  self.fig.clear()
@@ -91,7 +124,7 @@ class PlotCanvas(FigureCanvas):
91
124
  x, y = result['raw_x'], result['flat_y']
92
125
  centers = result['groove_centers']
93
126
 
94
- ax.plot(x, y, 'k-', linewidth=0.7, label='Flattened profile')
127
+ ax.plot(x, y, '-', color=theming.current().ink, linewidth=0.7, label='Flattened profile')
95
128
  ax.plot(x[centers], y[centers], 'ro', markersize=9,
96
129
  label=f'Detected grooves (N={len(centers)})')
97
130
 
@@ -107,11 +140,12 @@ class PlotCanvas(FigureCanvas):
107
140
 
108
141
  ax.set_xlabel("Position (µm)", fontsize=11)
109
142
  ax.set_ylabel("Height (nm)", fontsize=11)
110
- ax.set_title(f"Groove Detection: {os.path.basename(filename)}", fontsize=11)
143
+ ax.set_title("Groove detection", fontsize=11)
111
144
  ax.legend(fontsize=9)
112
145
  ax.grid(True, alpha=0.3)
113
146
  self.draw()
114
147
 
148
+ @_remembered
115
149
  def plot_blaze_angles(self, result, filename):
116
150
  """Angle distribution, fit quality, and angle against facet width"""
117
151
  self.fig.clear()
@@ -121,7 +155,7 @@ class PlotCanvas(FigureCanvas):
121
155
  quality = result['quality']
122
156
  mean, sem = result['mean_angle'], result.get('sem', 0.0)
123
157
 
124
- ax1.hist(angles, bins=20, edgecolor='black', alpha=0.75, color='steelblue')
158
+ ax1.hist(angles, bins=20, edgecolor=theming.current().ink, alpha=0.75, color='steelblue')
125
159
  ax1.axvline(mean, color='r', linestyle='--', linewidth=2,
126
160
  label=f'Mean {mean:.2f}°')
127
161
  ax1.set_xlabel("Blaze angle (°)", fontsize=10)
@@ -132,7 +166,7 @@ class PlotCanvas(FigureCanvas):
132
166
  ax1.grid(True, alpha=0.3)
133
167
 
134
168
  r2 = np.array([q.get('blaze_r2', np.nan) for q in quality])
135
- ax2.hist(r2, bins=20, edgecolor='black', alpha=0.75, color='seagreen')
169
+ ax2.hist(r2, bins=20, edgecolor=theming.current().ink, alpha=0.75, color='seagreen')
136
170
  ax2.set_xlabel("Fit R²", fontsize=10)
137
171
  ax2.set_ylabel("Count", fontsize=10)
138
172
  ax2.set_title(f"Fit quality\nworst R² = {np.nanmin(r2):.4f}", fontsize=10)
@@ -146,6 +180,5 @@ class PlotCanvas(FigureCanvas):
146
180
  fontsize=10)
147
181
  ax3.grid(True, alpha=0.3)
148
182
 
149
- self.fig.suptitle(f"{os.path.basename(filename)}: "
150
- f"{mean:.2f}° ± {sem:.3f}° (SEM)", fontsize=11)
183
+ self.fig.suptitle(f"Blaze angle {mean:.2f}° ± {sem:.3f}° (SEM)", fontsize=11)
151
184
  self.draw()
@@ -35,6 +35,9 @@ from PySide6.QtWidgets import (
35
35
  from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
36
36
  from matplotlib.figure import Figure
37
37
 
38
+ from gratinglab.gui.qt import theming
39
+ from gratinglab.gui.theme import style_figure
40
+
38
41
  from gratinglab import materials
39
42
  from gratinglab.illumination import Illumination
40
43
  from gratinglab.resolution import describe
@@ -89,6 +92,8 @@ class EfficiencyDialog(QDialog):
89
92
  self.figure = Figure(figsize=(6, 4.6))
90
93
  self.canvas = FigureCanvas(self.figure)
91
94
  root.addWidget(self.canvas, stretch=1)
95
+ self._placeholder = None
96
+ theming.subscribe(self._on_theme)
92
97
 
93
98
  def _build_geometry_group(self):
94
99
  group = QGroupBox("Grating")
@@ -237,14 +242,31 @@ class EfficiencyDialog(QDialog):
237
242
  # ── Draw ─────────────────────────────────────────────────────────────────
238
243
 
239
244
  def _show_placeholder(self, message):
245
+ self._placeholder = message
240
246
  self.figure.clear()
241
247
  axes = self.figure.add_subplot(111)
242
248
  axes.text(0.5, 0.5, message, ha='center', va='center', fontsize=11,
243
- color='gray', transform=axes.transAxes, wrap=True)
249
+ color=theming.current().muted, transform=axes.transAxes, wrap=True)
244
250
  axes.set_axis_off()
245
251
  self.canvas.draw()
246
252
 
253
+ def _on_theme(self, theme):
254
+ """Draw the figure again in the new theme.
255
+
256
+ Each draw clears the figure and builds fresh axes, which take their
257
+ colours from matplotlib's settings, so redrawing is the restyle. The
258
+ figure predates the change and needs its own background set first.
259
+ """
260
+ style_figure(self.figure, theme)
261
+ if self._placeholder is None and getattr(self, '_scan', None) is not None:
262
+ self._draw()
263
+ elif self._placeholder is not None:
264
+ self._show_placeholder(self._placeholder)
265
+ else:
266
+ self.canvas.draw_idle()
267
+
247
268
  def _draw(self):
269
+ self._placeholder = None
248
270
  scan = self._scan
249
271
  self.figure.clear()
250
272
  axes = self.figure.add_subplot(111)
@@ -258,7 +280,8 @@ class EfficiencyDialog(QDialog):
258
280
 
259
281
  for m, efficiency in carried[:8]:
260
282
  axes.plot(scan.wavelengths, efficiency, lw=1.3, label=f"m = {m}")
261
- axes.plot(scan.wavelengths, scan.total, 'k--', lw=1.0, alpha=0.6,
283
+ axes.plot(scan.wavelengths, scan.total, '--', color=theming.current().ink,
284
+ lw=1.0, alpha=0.6,
262
285
  label="total")
263
286
 
264
287
  axes.set_xlabel("Wavelength (nm)")
@@ -270,7 +293,7 @@ class EfficiencyDialog(QDialog):
270
293
  axes.legend(fontsize=8, ncol=2)
271
294
  else:
272
295
  axes.text(0.5, 0.5, "No order carries more than 0.5%",
273
- ha='center', va='center', color='gray',
296
+ ha='center', va='center', color=theming.current().muted,
274
297
  transform=axes.transAxes)
275
298
  self.figure.tight_layout()
276
299
  self.canvas.draw()
@@ -27,6 +27,9 @@ from PySide6.QtWidgets import (
27
27
  )
28
28
  from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
29
29
  from matplotlib.figure import Figure
30
+
31
+ from gratinglab.gui.qt import theming
32
+ from gratinglab.gui.theme import style_figure
30
33
  from matplotlib.patches import Rectangle
31
34
  from matplotlib.widgets import RectangleSelector
32
35
 
@@ -112,6 +115,7 @@ class ImportView(QWidget):
112
115
  self.figure = Figure(figsize=(8, 6), tight_layout=True)
113
116
  self.canvas = FigureCanvas(self.figure)
114
117
  self._show_placeholder()
118
+ theming.subscribe(self._on_theme)
115
119
 
116
120
  root.addWidget(scroll)
117
121
  divider = QFrame()
@@ -528,10 +532,20 @@ class ImportView(QWidget):
528
532
  self.figure.clear()
529
533
  axes = self.figure.add_subplot(111)
530
534
  axes.text(0.5, 0.5, message, ha='center', va='center', fontsize=12,
531
- color='gray', transform=axes.transAxes)
535
+ color=theming.current().muted, transform=axes.transAxes)
532
536
  axes.set_axis_off()
533
537
  self.canvas.draw()
534
538
 
539
+ def _on_theme(self, theme):
540
+ """Draw the figure again in the new theme.
541
+
542
+ Each draw clears the figure and builds fresh axes, which take their
543
+ colours from matplotlib's settings, so redrawing is the restyle. The
544
+ figure predates the change and needs its own background set first.
545
+ """
546
+ style_figure(self.figure, theme)
547
+ self._redraw()
548
+
535
549
  def _redraw(self):
536
550
  if self._flat is None:
537
551
  self._show_placeholder()
@@ -561,8 +575,7 @@ class ImportView(QWidget):
561
575
  props=dict(facecolor='crimson', edgecolor='crimson', alpha=0.25,
562
576
  fill=True))
563
577
  image_ax.set_title(
564
- f"{os.path.basename(self._filename)}: image flattening: "
565
- f"{self.image_method_combo.currentText()}", fontsize=10)
578
+ f"Image flattening: {self.image_method_combo.currentText()}", fontsize=10)
566
579
 
567
580
  try:
568
581
  x_um, profile_nm = raw_data(self._flat, self._scan_size)