gratinglab 0.1.12__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 (186) hide show
  1. {gratinglab-0.1.12/src/gratinglab.egg-info → gratinglab-0.2.0}/PKG-INFO +6 -3
  2. {gratinglab-0.1.12 → gratinglab-0.2.0}/README.md +3 -1
  3. {gratinglab-0.1.12 → gratinglab-0.2.0}/pyproject.toml +8 -1
  4. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/__init__.py +1 -1
  5. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/checks.py +5 -0
  6. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/docs.py +13 -0
  7. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/efficiency_plot.py +11 -2
  8. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/launcher.py +4 -5
  9. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/provenance.py +13 -1
  10. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/launcher.py +91 -7
  11. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/main_window.py +9 -19
  12. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/theming.py +48 -2
  13. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/io/boundary.py +5 -2
  14. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/io/ggp.py +34 -14
  15. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/analyzer.py +143 -46
  16. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/pipeline.py +47 -13
  17. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/config.py +34 -0
  18. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/core/image_flatten.py +24 -3
  19. gratinglab-0.2.0/src/gratinglab/metrology/core/orientation.py +475 -0
  20. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/core/processing.py +192 -17
  21. gratinglab-0.2.0/src/gratinglab/metrology/core/region_mask.py +232 -0
  22. gratinglab-0.2.0/src/gratinglab/metrology/core/scars.py +345 -0
  23. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/boundary_view.py +63 -12
  24. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/canvas.py +46 -13
  25. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/efficiency_dialog.py +26 -3
  26. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/import_view.py +262 -17
  27. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/main_window.py +26 -3
  28. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/swath_dialog.py +18 -1
  29. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/state.py +55 -0
  30. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/io/file_io.py +13 -0
  31. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/io/ibw.py +34 -1
  32. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/io/metrics.py +24 -0
  33. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/io/spm.py +96 -1
  34. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/settings.py +81 -0
  35. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/__init__.py +2 -0
  36. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/file-formats.md +9 -0
  37. gratinglab-0.2.0/src/gratinglab/metrology/wiki/groove-orientation.md +114 -0
  38. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/overview.md +6 -1
  39. gratinglab-0.2.0/src/gratinglab/metrology/wiki/scars-and-masking.md +190 -0
  40. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/swath-statistics.md +6 -3
  41. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/workflows.py +35 -3
  42. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/problem.py +27 -1
  43. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/profiles.py +28 -0
  44. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/result.py +101 -35
  45. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/__init__.py +6 -0
  46. gratinglab-0.2.0/src/gratinglab/solvers/_conditioning.py +131 -0
  47. gratinglab-0.2.0/src/gratinglab/solvers/_execution.py +153 -0
  48. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/base.py +12 -0
  49. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/__init__.py +418 -0
  50. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/_core.py +405 -0
  51. gratinglab-0.2.0/src/gratinglab/solvers/cmethod/_series.py +196 -0
  52. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/__init__.py +155 -54
  53. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_boundary.py +33 -10
  54. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_core.py +6 -2
  55. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_finite.py +9 -3
  56. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_nystrom.py +18 -0
  57. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/__init__.py +415 -0
  58. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/_core.py +437 -0
  59. gratinglab-0.2.0/src/gratinglab/solvers/rcwa/_stack.py +364 -0
  60. {gratinglab-0.1.12 → gratinglab-0.2.0/src/gratinglab.egg-info}/PKG-INFO +6 -3
  61. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab.egg-info/SOURCES.txt +19 -0
  62. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab.egg-info/requires.txt +1 -0
  63. gratinglab-0.2.0/tests/test_changelog.py +88 -0
  64. gratinglab-0.2.0/tests/test_cmethod.py +523 -0
  65. gratinglab-0.2.0/tests/test_conditioning.py +201 -0
  66. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_cross_method.py +81 -3
  67. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_ggp.py +28 -2
  68. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_launcher.py +144 -1
  69. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_qt.py +6 -2
  70. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_theme.py +24 -0
  71. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_core.py +7 -2
  72. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_corpus.py +9 -2
  73. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_graded.py +6 -2
  74. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_solver.py +6 -1
  75. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_io_boundary.py +7 -2
  76. gratinglab-0.2.0/tests/test_rayleigh_anomaly.py +180 -0
  77. gratinglab-0.2.0/tests/test_rcwa.py +807 -0
  78. gratinglab-0.2.0/tests/test_rcwa_benchmarks.py +468 -0
  79. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_result.py +45 -1
  80. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_solver_conformance.py +43 -5
  81. gratinglab-0.2.0/tests/test_solver_execution.py +252 -0
  82. gratinglab-0.1.12/tests/test_changelog.py +0 -34
  83. {gratinglab-0.1.12 → gratinglab-0.2.0}/LICENSE +0 -0
  84. {gratinglab-0.1.12 → gratinglab-0.2.0}/setup.cfg +0 -0
  85. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/compare.py +0 -0
  86. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/convergence.py +0 -0
  87. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/geometry.py +0 -0
  88. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/__init__.py +0 -0
  89. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/app.py +0 -0
  90. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/diagram.py +0 -0
  91. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/diagram3d.py +0 -0
  92. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/icon.py +0 -0
  93. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/integral_options.py +0 -0
  94. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/mathtext.py +0 -0
  95. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/orders.py +0 -0
  96. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/__init__.py +0 -0
  97. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/gc_guard.py +0 -0
  98. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/geometry_panel.py +0 -0
  99. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/geometry_tab.py +0 -0
  100. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/integral_tab.py +0 -0
  101. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/profile_plot_panel.py +0 -0
  102. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/scalar_tab.py +0 -0
  103. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/scene3d_panel.py +0 -0
  104. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/setup_tab.py +0 -0
  105. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/sizing.py +0 -0
  106. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/theory_viewer.py +0 -0
  107. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/qt/worker.py +0 -0
  108. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/richtext.py +0 -0
  109. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/scalar_options.py +0 -0
  110. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/state.py +0 -0
  111. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/gui/theme.py +0 -0
  112. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/illumination.py +0 -0
  113. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/io/__init__.py +0 -0
  114. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/io/efficiency_table.py +0 -0
  115. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/materials/__init__.py +0 -0
  116. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/materials/data/SOURCES.md +0 -0
  117. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/materials/fresnel.py +0 -0
  118. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/materials/optical.py +0 -0
  119. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/materials/refractiveindex.py +0 -0
  120. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/measured.py +0 -0
  121. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/__init__.py +0 -0
  122. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/__init__.py +0 -0
  123. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/average.py +0 -0
  124. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/boundary/swaths.py +0 -0
  125. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/cli.py +0 -0
  126. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/core/__init__.py +0 -0
  127. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/core/analysis.py +0 -0
  128. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/core/tip.py +0 -0
  129. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/data/__init__.py +0 -0
  130. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/data/aggregation.py +0 -0
  131. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/__init__.py +0 -0
  132. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/icon.py +0 -0
  133. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/__init__.py +0 -0
  134. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/app.py +0 -0
  135. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/wiki_view.py +0 -0
  136. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/gui/qt/worker.py +0 -0
  137. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/io/__init__.py +0 -0
  138. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/__init__.py +0 -0
  139. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/analysis.py +0 -0
  140. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/stats/icc.py +0 -0
  141. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/__init__.py +0 -0
  142. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/diagnostics.py +0 -0
  143. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/profiles.py +0 -0
  144. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/visualization/statistics.py +0 -0
  145. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/facet-fitting.md +0 -0
  146. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/flattening.md +0 -0
  147. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/icc-correction.md +0 -0
  148. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/reading-outputs.md +0 -0
  149. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/row-groups.md +0 -0
  150. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/metrology/wiki/tip-correction.md +0 -0
  151. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/resolution.py +0 -0
  152. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_greens.py +0 -0
  153. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/integral/_kernels.py +0 -0
  154. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab/solvers/scalar.py +0 -0
  155. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab.egg-info/dependency_links.txt +0 -0
  156. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab.egg-info/entry_points.txt +0 -0
  157. {gratinglab-0.1.12 → gratinglab-0.2.0}/src/gratinglab.egg-info/top_level.txt +0 -0
  158. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_checks.py +0 -0
  159. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_compare.py +0 -0
  160. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_convergence.py +0 -0
  161. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_efficiency_table.py +0 -0
  162. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_fresnel.py +0 -0
  163. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_geometry.py +0 -0
  164. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_diagram.py +0 -0
  165. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_diagram3d.py +0 -0
  166. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_docs.py +0 -0
  167. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_integral_options.py +0 -0
  168. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_mathtext.py +0 -0
  169. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_orders.py +0 -0
  170. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_provenance.py +0 -0
  171. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_richtext.py +0 -0
  172. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_scalar_options.py +0 -0
  173. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_gui_state.py +0 -0
  174. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_illumination.py +0 -0
  175. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_finite.py +0 -0
  176. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_greens.py +0 -0
  177. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_kernels.py +0 -0
  178. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_integral_operators.py +0 -0
  179. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_materials.py +0 -0
  180. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_measured.py +0 -0
  181. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_perturbation.py +0 -0
  182. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_profiles.py +0 -0
  183. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_refractiveindex.py +0 -0
  184. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_resolution.py +0 -0
  185. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_scalar.py +0 -0
  186. {gratinglab-0.1.12 → gratinglab-0.2.0}/tests/test_solver_progress.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gratinglab
3
- Version: 0.1.12
3
+ Version: 0.2.0
4
4
  Summary: Open platform for diffraction grating groove metrology and efficiency analysis
5
- Author: Jake McCoy
5
+ Author: Jake McCoy, Aditi Gangadharan
6
6
  License: BSD-3-Clause
7
7
  Project-URL: Homepage, https://github.com/jamccoy/gratinglab
8
8
  Project-URL: Changelog, https://github.com/jamccoy/gratinglab/blob/master/CHANGELOG.md
@@ -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"
@@ -271,7 +272,9 @@ gratinglab-metrology-gui # straight to AFM import, blaze angles, boundary
271
272
  ```
272
273
 
273
274
  There are two windows, and `gratinglab-app` is the front door that asks which one
274
- you want. Both halves are this one package — the metrology measures a groove and the
275
+ you want. Neither is a dead end: each carries a **Go** menu that switches to the other
276
+ and returns to the chooser (`Ctrl+Shift+M`), and switching reuses a window that is
277
+ already open rather than starting a second empty copy of it. Both halves are this one package — the metrology measures a groove and the
275
278
  solvers compute an efficiency from the result — but the desktop layer used to ship
276
279
  them as two commands and two application bundles, the AFM one still named after the
277
280
  repository it was merged from, which made the half in regular use the half that was
@@ -227,7 +227,9 @@ gratinglab-metrology-gui # straight to AFM import, blaze angles, boundary
227
227
  ```
228
228
 
229
229
  There are two windows, and `gratinglab-app` is the front door that asks which one
230
- you want. Both halves are this one package — the metrology measures a groove and the
230
+ you want. Neither is a dead end: each carries a **Go** menu that switches to the other
231
+ and returns to the chooser (`Ctrl+Shift+M`), and switching reuses a window that is
232
+ already open rather than starting a second empty copy of it. Both halves are this one package — the metrology measures a groove and the
231
233
  solvers compute an efficiency from the result — but the desktop layer used to ship
232
234
  them as two commands and two application bundles, the AFM one still named after the
233
235
  repository it was merged from, which made the half in regular use the half that was
@@ -13,7 +13,7 @@ description = "Open platform for diffraction grating groove metrology and effici
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
15
15
  license = { text = "BSD-3-Clause" }
16
- authors = [{ name = "Jake McCoy" }]
16
+ authors = [{ name = "Jake McCoy" }, { name = "Aditi Gangadharan" }]
17
17
  # Wavelength-neutral on purpose. The off-plane work this package grew out of is
18
18
  # soft x-ray, and that band is still named, but nothing in the solvers or the
19
19
  # metrology assumes one: the grating equation carries no band, and the
@@ -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.12"
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)")
@@ -101,7 +101,7 @@ def _require_qt():
101
101
  def main(argv=None) -> int:
102
102
  """Console-script entry point for ``gratinglab-app``."""
103
103
  QApplication = _require_qt()
104
- from .qt.launcher import LauncherWindow
104
+ from .qt.launcher import show_chooser
105
105
 
106
106
  app = QApplication.instance() or QApplication(list(argv or sys.argv))
107
107
  # Fusion for the same reason the metrology window asks for it: the native
@@ -111,10 +111,9 @@ def main(argv=None) -> int:
111
111
 
112
112
  install(app)
113
113
 
114
- window = LauncherWindow()
115
- window.show()
116
- window.raise_()
117
- window.activateWindow()
114
+ # show_chooser, not LauncherWindow(), so that "Main Menu" in either
115
+ # window brings this same chooser back instead of a second copy.
116
+ show_chooser()
118
117
  return app.exec()
119
118
 
120
119
 
@@ -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(
@@ -11,6 +11,7 @@ from functools import partial
11
11
  from importlib import import_module
12
12
 
13
13
  from PySide6.QtCore import Qt
14
+ from PySide6.QtGui import QKeySequence
14
15
  from PySide6.QtWidgets import (
15
16
  QFrame,
16
17
  QHBoxLayout,
@@ -23,7 +24,19 @@ from PySide6.QtWidgets import (
23
24
 
24
25
  from ..launcher import MODES
25
26
 
26
- __all__ = ["LauncherWindow", "open_window"]
27
+ __all__ = [
28
+ "LauncherWindow",
29
+ "SWITCH_MENU_TITLE",
30
+ "install_switch_menu",
31
+ "open_window",
32
+ "show_chooser",
33
+ "window_for",
34
+ ]
35
+
36
+ #: Title of the menu each window gets, through which the other mode and the
37
+ #: chooser are reachable. Not "Window": macOS has conventions for a menu of
38
+ #: that name and these are modes, not windows of one document.
39
+ SWITCH_MENU_TITLE = "&Go"
27
40
 
28
41
  #: Windows opened from the chooser, kept alive deliberately.
29
42
  #:
@@ -34,17 +47,86 @@ __all__ = ["LauncherWindow", "open_window"]
34
47
  _OPEN_WINDOWS: list[QWidget] = []
35
48
 
36
49
 
50
+ #: The chooser, kept so that "Main Menu" shows the same one rather than a
51
+ #: second copy. Closing a window does not destroy it while a reference is
52
+ #: held, so this one can be shown again after it closed itself.
53
+ _CHOOSER: list = []
54
+
55
+
56
+ def window_for(chosen):
57
+ """The window already open for a mode, or None.
58
+
59
+ Opening a second window for a mode would be worse than useless: the
60
+ metrology window holds a loaded scan and its settings, so a "switch" that
61
+ quietly produced an empty second copy would look like losing the work.
62
+ """
63
+ for window in _OPEN_WINDOWS:
64
+ if getattr(window, "_gratinglab_mode", None) == chosen.key:
65
+ return window
66
+ return None
67
+
68
+
69
+ def _raise(window):
70
+ """Show a window that may be open behind something, or closed."""
71
+ window.show()
72
+ window.raise_()
73
+ window.activateWindow()
74
+ return window
75
+
76
+
77
+ def install_switch_menu(window, current):
78
+ """Give a window a menu leading to the other mode and to the chooser.
79
+
80
+ Added from here rather than by each window, so that neither window needs
81
+ to know the other exists and a third mode is still one entry in MODES.
82
+
83
+ The menu and its actions are held on the window. `gui/qt/main_window.py`
84
+ records why: under PySide6 a QMenu reached back through
85
+ ``menuBar().actions()[i].menu()`` is not reliably usable, so whatever
86
+ builds a menu must keep it.
87
+ """
88
+ menu = window.menuBar().addMenu(SWITCH_MENU_TITLE)
89
+ window._gratinglab_switch_menu = menu
90
+
91
+ actions = []
92
+ for other in MODES:
93
+ if other.key == current.key:
94
+ continue
95
+ action = menu.addAction(other.title)
96
+ action.triggered.connect(partial(open_window, other))
97
+ actions.append(action)
98
+
99
+ menu.addSeparator()
100
+ back = menu.addAction("Main Menu")
101
+ back.setShortcut(QKeySequence("Ctrl+Shift+M"))
102
+ back.triggered.connect(show_chooser)
103
+ actions.append(back)
104
+
105
+ window._gratinglab_switch_actions = actions
106
+ return menu
107
+
108
+
37
109
  def open_window(chosen):
38
- """Import the window a mode names, show it, and keep it alive."""
110
+ """Show the window a mode names, reusing it if it is already open."""
111
+ existing = window_for(chosen)
112
+ if existing is not None:
113
+ return _raise(existing)
114
+
39
115
  module_name, _, attribute = chosen.window.partition(":")
40
116
  window_class = getattr(import_module(module_name), attribute)
41
117
 
42
118
  window = window_class()
119
+ window._gratinglab_mode = chosen.key
120
+ install_switch_menu(window, chosen)
43
121
  _OPEN_WINDOWS.append(window)
44
- window.show()
45
- window.raise_()
46
- window.activateWindow()
47
- return window
122
+ return _raise(window)
123
+
124
+
125
+ def show_chooser():
126
+ """Bring the chooser back, the same one if it has been up before."""
127
+ if not _CHOOSER:
128
+ _CHOOSER.append(LauncherWindow())
129
+ return _raise(_CHOOSER[0])
48
130
 
49
131
 
50
132
  class LauncherWindow(QWidget):
@@ -80,7 +162,9 @@ class LauncherWindow(QWidget):
80
162
  layout.addStretch(1)
81
163
 
82
164
  hint = QLabel(
83
- "Each window is also a command of its own: "
165
+ "Either window's <b>Go</b> menu switches to the other and comes "
166
+ "back here, so this is not a one-way door. Each is also a command "
167
+ "of its own: "
84
168
  + ", ".join(f"<code>{m.script}</code>" for m in MODES)
85
169
  + "."
86
170
  )
@@ -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)
@@ -63,7 +63,8 @@ class BoundaryFile:
63
63
  """A groove profile read back from a gratinglab ``.json``."""
64
64
 
65
65
  period_nm: float
66
- #: Fractions of one period, as :class:`~gratinglab.profiles.FromProfileData` takes them.
66
+ #: Fractions of one period along ``+d-hat``, as ``x_norm`` and a ``.ggp``
67
+ #: hold them; :meth:`profile` reflects them into the profile parameter.
67
68
  t: tuple[float, ...]
68
69
  y: tuple[float, ...]
69
70
  #: Grooves averaged into this profile, carried onto the problem.
@@ -77,7 +78,9 @@ class BoundaryFile:
77
78
  """The shape, as the solvers take it."""
78
79
  from ..profiles import FromProfileData
79
80
 
80
- return FromProfileData(t=self.t, y=self.y, blaze_angle=self.blaze_angle)
81
+ return FromProfileData.along_dispersion(
82
+ self.t, self.y, blaze_angle=self.blaze_angle
83
+ )
81
84
 
82
85
  def to_problem(self, *, coating=None, substrate=None, roughness=0.0):
83
86
  """This groove as an efficiency problem, with the period it was measured at.
@@ -2,8 +2,13 @@
2
2
 
3
3
  A ``.ggp`` holds a groove profile normalised to the period: ``x`` runs 0 to 1
4
4
  across exactly one period and ``y`` is height as a fraction of that period.
5
- This is the same convention :mod:`gratinglab.profiles` uses, so no rescaling is
6
- needed.
5
+ The scale is the one :mod:`gratinglab.profiles` uses, so no rescaling is
6
+ needed. The direction is not: ``x`` advances along the dispersion direction
7
+ and a profile's ``t`` against it (``docs/conventions.md``, "Direction of the
8
+ profile parameter"), so :func:`to_profile` and :func:`write_ggp` reflect the
9
+ points through :meth:`~gratinglab.profiles.FromProfileData.along_dispersion`
10
+ and its inverse. The PCGrate corpus is what fixes the file's direction: the
11
+ TASTE run matches only with ``x`` along ``+d-hat``.
7
12
 
8
13
  Canonical form::
9
14
 
@@ -196,9 +201,10 @@ def to_profile(ggp: GgpFile) -> FromProfileData:
196
201
  """Convert to a :class:`~gratinglab.profiles.FromProfileData`.
197
202
 
198
203
  Heights are referenced to the profile minimum so the valley sits at zero,
199
- matching every analytic profile in :mod:`gratinglab.profiles`.
204
+ matching every analytic profile in :mod:`gratinglab.profiles`. The file's
205
+ ``x`` runs along ``+d-hat``, so the points are reflected into ``t``.
200
206
  """
201
- return FromProfileData(t=tuple(ggp.t), y=tuple(ggp.y - ggp.y.min()))
207
+ return FromProfileData.along_dispersion(ggp.t, ggp.y - ggp.y.min())
202
208
 
203
209
 
204
210
  def read_profile(path: str | Path) -> FromProfileData:
@@ -213,14 +219,26 @@ def write_ggp(
213
219
  t: ArrayLike | None = None,
214
220
  y: ArrayLike | None = None,
215
221
  fmt: str = "%f",
222
+ header: bool = True,
216
223
  ) -> Path:
217
224
  """Write a profile in the canonical, PCGrate-loadable form.
218
225
 
219
- Pass either a ``profile`` or explicit ``t``/``y`` arrays.
220
-
221
- Values are written to six decimals, single-space separated and newline
222
- terminated. The format is
223
- pinned by ``tests/test_ggp.py``; PCGrate rejects variations on it.
226
+ Pass either a ``profile`` or explicit ``t``/``y`` arrays. A profile is
227
+ reflected onto the file's ``+d-hat`` axis, the inverse of
228
+ :func:`to_profile`. Explicit arrays are written as given, so they are
229
+ file coordinates already, as the metrology pipeline's ``x_norm`` is.
230
+
231
+ Values are written to six decimals, single-space separated, one point per
232
+ line, with no line break after the last point. PCGrate rejects a file
233
+ that ends in one, as ``np.savetxt`` output does. The format is pinned by
234
+ ``tests/test_ggp.py``; PCGrate rejects variations on it.
235
+
236
+ ``header=False`` leaves out the two :data:`GGP_HEADER` lines and writes
237
+ the points alone, as ``np.savetxt`` with no ``header`` does. It exists to
238
+ test the claim in the module docstring that PCGrate needs the header:
239
+ that claim comes from the files found on disk, not from PCGrate's
240
+ documentation, and Aditi's script writes headerless files (2026-10-27).
241
+ Reading such a file back reports ``header_variant == "none"``.
224
242
  """
225
243
  if (profile is None) == (t is None):
226
244
  raise ValueError("pass either a profile or both t and y, not both or neither")
@@ -231,8 +249,7 @@ def write_ggp(
231
249
  "cannot write an undercut profile as .ggp: PCGrate's polygonal "
232
250
  "border expects a boundary that advances monotonically in x"
233
251
  )
234
- t_values = np.asarray(profile.t, dtype=np.float64)
235
- y_values = np.asarray(profile.y, dtype=np.float64)
252
+ t_values, y_values = profile.dispersion_axis()
236
253
  else:
237
254
  t_values = np.asarray(t, dtype=np.float64)
238
255
  y_values = np.asarray(y, dtype=np.float64)
@@ -243,8 +260,11 @@ def write_ggp(
243
260
 
244
261
  path = Path(path)
245
262
  path.parent.mkdir(parents=True, exist_ok=True)
263
+ points = (f"{fmt % x_value} {fmt % height}"
264
+ for x_value, height in zip(t_values, y_values))
246
265
  with path.open("w", encoding="utf-8") as handle:
247
- handle.write(GGP_HEADER + "\n")
248
- for x_value, height in zip(t_values, y_values):
249
- handle.write(f"{fmt % x_value} {fmt % height}\n")
266
+ # No line break after the last point: PCGrate reads a trailing one as
267
+ # an extra, empty point and rejects the file (found by Aditi,
268
+ # 2026-10-27).
269
+ handle.write("\n".join([GGP_HEADER, *points] if header else points))
250
270
  return path