gratinglab 0.2.5__tar.gz → 0.2.6__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 (193) hide show
  1. {gratinglab-0.2.5/src/gratinglab.egg-info → gratinglab-0.2.6}/PKG-INFO +1 -1
  2. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/__init__.py +1 -1
  3. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/io/boundary.py +4 -2
  4. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/boundary/__init__.py +4 -0
  5. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/boundary/average.py +116 -16
  6. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/boundary/pipeline.py +60 -12
  7. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/boundary_view.py +7 -1
  8. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/io/metrics.py +10 -0
  9. {gratinglab-0.2.5 → gratinglab-0.2.6/src/gratinglab.egg-info}/PKG-INFO +1 -1
  10. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab.egg-info/SOURCES.txt +1 -0
  11. gratinglab-0.2.6/tests/test_four_way.py +217 -0
  12. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_docs.py +7 -2
  13. {gratinglab-0.2.5 → gratinglab-0.2.6}/LICENSE +0 -0
  14. {gratinglab-0.2.5 → gratinglab-0.2.6}/README.md +0 -0
  15. {gratinglab-0.2.5 → gratinglab-0.2.6}/pyproject.toml +0 -0
  16. {gratinglab-0.2.5 → gratinglab-0.2.6}/setup.cfg +0 -0
  17. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/checks.py +0 -0
  18. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/compare.py +0 -0
  19. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/convergence.py +0 -0
  20. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/geometry.py +0 -0
  21. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/__init__.py +0 -0
  22. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/app.py +0 -0
  23. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/cmethod_options.py +0 -0
  24. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/diagram.py +0 -0
  25. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/diagram3d.py +0 -0
  26. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/docs.py +0 -0
  27. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/efficiency_plot.py +0 -0
  28. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/icon.py +0 -0
  29. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/integral_options.py +0 -0
  30. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/launcher.py +0 -0
  31. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/mathtext.py +0 -0
  32. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/orders.py +0 -0
  33. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/provenance.py +0 -0
  34. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/__init__.py +0 -0
  35. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/cmethod_tab.py +0 -0
  36. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/geometry_panel.py +0 -0
  37. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/geometry_tab.py +0 -0
  38. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/integral_tab.py +0 -0
  39. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/launcher.py +0 -0
  40. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/main_window.py +0 -0
  41. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/profile_plot_panel.py +0 -0
  42. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/rcwa_tab.py +0 -0
  43. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/scalar_tab.py +0 -0
  44. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/scene3d_panel.py +0 -0
  45. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/setup_tab.py +0 -0
  46. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/sizing.py +0 -0
  47. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/theming.py +0 -0
  48. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/theory_viewer.py +0 -0
  49. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/qt/worker.py +0 -0
  50. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/rcwa_options.py +0 -0
  51. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/richtext.py +0 -0
  52. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/scalar_options.py +0 -0
  53. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/state.py +0 -0
  54. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/gui/theme.py +0 -0
  55. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/illumination.py +0 -0
  56. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/io/__init__.py +0 -0
  57. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/io/efficiency_table.py +0 -0
  58. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/io/ggp.py +0 -0
  59. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/materials/__init__.py +0 -0
  60. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/materials/data/SOURCES.md +0 -0
  61. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/materials/fresnel.py +0 -0
  62. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/materials/optical.py +0 -0
  63. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/materials/refractiveindex.py +0 -0
  64. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/measured.py +0 -0
  65. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/__init__.py +0 -0
  66. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/analyzer.py +0 -0
  67. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/boundary/swaths.py +0 -0
  68. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/cli.py +0 -0
  69. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/config.py +0 -0
  70. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/__init__.py +0 -0
  71. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/analysis.py +0 -0
  72. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/image_flatten.py +0 -0
  73. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/orientation.py +0 -0
  74. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/processing.py +0 -0
  75. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/region_mask.py +0 -0
  76. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/scars.py +0 -0
  77. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/core/tip.py +0 -0
  78. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/data/__init__.py +0 -0
  79. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/data/aggregation.py +0 -0
  80. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/__init__.py +0 -0
  81. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/icon.py +0 -0
  82. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/__init__.py +0 -0
  83. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/app.py +0 -0
  84. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/canvas.py +0 -0
  85. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/efficiency_dialog.py +0 -0
  86. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/import_view.py +0 -0
  87. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/layout.py +0 -0
  88. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/main_window.py +0 -0
  89. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/swath_dialog.py +0 -0
  90. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/wiki_view.py +0 -0
  91. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/qt/worker.py +0 -0
  92. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/gui/state.py +0 -0
  93. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/io/__init__.py +0 -0
  94. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/io/file_io.py +0 -0
  95. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/io/ibw.py +0 -0
  96. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/io/spm.py +0 -0
  97. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/settings.py +0 -0
  98. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/stats/__init__.py +0 -0
  99. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/stats/analysis.py +0 -0
  100. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/stats/icc.py +0 -0
  101. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/visualization/__init__.py +0 -0
  102. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/visualization/diagnostics.py +0 -0
  103. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/visualization/profiles.py +0 -0
  104. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/visualization/statistics.py +0 -0
  105. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/__init__.py +0 -0
  106. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/facet-fitting.md +0 -0
  107. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/file-formats.md +0 -0
  108. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/flattening.md +0 -0
  109. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/groove-orientation.md +0 -0
  110. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/icc-correction.md +0 -0
  111. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/overview.md +0 -0
  112. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/reading-outputs.md +0 -0
  113. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/row-groups.md +0 -0
  114. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/scars-and-masking.md +0 -0
  115. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/swath-statistics.md +0 -0
  116. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/wiki/tip-correction.md +0 -0
  117. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/metrology/workflows.py +0 -0
  118. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/problem.py +0 -0
  119. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/profiles.py +0 -0
  120. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/resolution.py +0 -0
  121. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/result.py +0 -0
  122. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/__init__.py +0 -0
  123. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/_conditioning.py +0 -0
  124. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/_execution.py +0 -0
  125. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/base.py +0 -0
  126. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/cmethod/__init__.py +0 -0
  127. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/cmethod/_core.py +0 -0
  128. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/cmethod/_series.py +0 -0
  129. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/__init__.py +0 -0
  130. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_boundary.py +0 -0
  131. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_core.py +0 -0
  132. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_finite.py +0 -0
  133. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_greens.py +0 -0
  134. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_kernels.py +0 -0
  135. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/integral/_nystrom.py +0 -0
  136. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/rcwa/__init__.py +0 -0
  137. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/rcwa/_core.py +0 -0
  138. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/rcwa/_stack.py +0 -0
  139. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab/solvers/scalar.py +0 -0
  140. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab.egg-info/dependency_links.txt +0 -0
  141. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab.egg-info/entry_points.txt +0 -0
  142. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab.egg-info/requires.txt +0 -0
  143. {gratinglab-0.2.5 → gratinglab-0.2.6}/src/gratinglab.egg-info/top_level.txt +0 -0
  144. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_changelog.py +0 -0
  145. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_checks.py +0 -0
  146. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_cmethod.py +0 -0
  147. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_compare.py +0 -0
  148. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_conditioning.py +0 -0
  149. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_convergence.py +0 -0
  150. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_cross_method.py +0 -0
  151. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_efficiency_table.py +0 -0
  152. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_fresnel.py +0 -0
  153. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_geometry.py +0 -0
  154. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_ggp.py +0 -0
  155. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_cmethod_options.py +0 -0
  156. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_cmethod_tab.py +0 -0
  157. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_diagram.py +0 -0
  158. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_diagram3d.py +0 -0
  159. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_integral_options.py +0 -0
  160. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_launcher.py +0 -0
  161. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_mathtext.py +0 -0
  162. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_orders.py +0 -0
  163. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_provenance.py +0 -0
  164. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_qt.py +0 -0
  165. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_rcwa_options.py +0 -0
  166. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_richtext.py +0 -0
  167. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_scalar_options.py +0 -0
  168. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_state.py +0 -0
  169. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_gui_theme.py +0 -0
  170. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_illumination.py +0 -0
  171. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_core.py +0 -0
  172. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_corpus.py +0 -0
  173. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_finite.py +0 -0
  174. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_graded.py +0 -0
  175. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_greens.py +0 -0
  176. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_kernels.py +0 -0
  177. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_operators.py +0 -0
  178. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_integral_solver.py +0 -0
  179. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_io_boundary.py +0 -0
  180. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_materials.py +0 -0
  181. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_measured.py +0 -0
  182. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_perturbation.py +0 -0
  183. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_profiles.py +0 -0
  184. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_rayleigh_anomaly.py +0 -0
  185. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_rcwa.py +0 -0
  186. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_rcwa_benchmarks.py +0 -0
  187. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_refractiveindex.py +0 -0
  188. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_resolution.py +0 -0
  189. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_result.py +0 -0
  190. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_scalar.py +0 -0
  191. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_solver_conformance.py +0 -0
  192. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_solver_execution.py +0 -0
  193. {gratinglab-0.2.5 → gratinglab-0.2.6}/tests/test_solver_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gratinglab
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -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.2.5"
25
+ __version__ = "0.2.6"
26
26
 
27
27
  __all__ = [
28
28
  "Illumination",
@@ -88,8 +88,10 @@ class BoundaryFile:
88
88
  This is the same problem ``BoundaryProfile.to_problem`` builds in
89
89
  process, which is the purpose of the format; see
90
90
  ``tests/test_io_boundary.py``.
91
- Roughness is not in the file for the reason that method's docstring
92
- gives: nothing here measures surface microroughness.
91
+ Roughness is not applied, for the reason that method's docstring
92
+ gives. The measured groove residual travels in
93
+ ``metrics['groove_residual_rms_nm']`` and is passed only on request,
94
+ as ``roughness=``.
93
95
  """
94
96
  from ..problem import Problem
95
97
 
@@ -14,8 +14,10 @@ from .swaths import SwathStatistics, swath_statistics
14
14
  from .average import (
15
15
  flatten_endpoints,
16
16
  average_grooves,
17
+ groove_residuals,
17
18
  normalize_profile,
18
19
  profile_metrics,
20
+ select_grooves,
19
21
  )
20
22
 
21
23
  __all__ = [
@@ -25,6 +27,8 @@ __all__ = [
25
27
  'swath_statistics',
26
28
  'flatten_endpoints',
27
29
  'average_grooves',
30
+ 'groove_residuals',
28
31
  'normalize_profile',
29
32
  'profile_metrics',
33
+ 'select_grooves',
30
34
  ]
@@ -7,7 +7,7 @@ profile front-end now comes from gratinglab.metrology.core instead of a private
7
7
  copy.
8
8
  """
9
9
  import numpy as np
10
- from scipy.interpolate import interp1d
10
+ from scipy.interpolate import interp1d, make_interp_spline
11
11
  from scipy.ndimage import uniform_filter1d
12
12
 
13
13
  from ..core.processing import extract_single_groove
@@ -30,6 +30,36 @@ def flatten_endpoints(x, y):
30
30
  return y - line
31
31
 
32
32
 
33
+ def select_grooves(x, y, groove_centers, period_nm, margin=0.0,
34
+ min_half_width=10, require_full_period=True):
35
+ """
36
+ The grooves `average_grooves` averages, each centred on its own axis in µm.
37
+
38
+ Separate so that `groove_residuals` measures exactly the set that went
39
+ into the average, chosen by the same rules rather than a second copy of
40
+ them. The parameters mean what they mean in `average_grooves`.
41
+
42
+ Returns:
43
+ a list of (x_g, y_g), possibly empty
44
+ """
45
+ grooves = []
46
+ dx_nm = (x[1] - x[0]) * 1000
47
+ half_width = int(round((period_nm / 2) * (1 + margin) / dx_nm))
48
+
49
+ for center in groove_centers:
50
+ L = min(half_width, center, len(x) - 1 - center)
51
+ if require_full_period and L < half_width:
52
+ continue # the scan edge cuts into this groove's period
53
+ if L > min_half_width:
54
+ x_g, y_g = extract_single_groove(x, y, center, period_nm, margin,
55
+ allow_asymmetric=False)
56
+ # Re-create the exactly symmetric axis the original used, so that
57
+ # floating point matches to the last bit.
58
+ x_g = np.linspace(-L * dx_nm, L * dx_nm, len(y_g)) / 1000
59
+ grooves.append((x_g, y_g))
60
+ return grooves
61
+
62
+
33
63
  def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
34
64
  min_half_width=10, require_full_period=True):
35
65
  """
@@ -53,21 +83,9 @@ def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
53
83
  Returns:
54
84
  x_common, y_avg, y_std, n_used
55
85
  """
56
- grooves = []
57
- dx_nm = (x[1] - x[0]) * 1000
58
- half_width = int(round((period_nm / 2) * (1 + margin) / dx_nm))
59
-
60
- for center in groove_centers:
61
- L = min(half_width, center, len(x) - 1 - center)
62
- if require_full_period and L < half_width:
63
- continue # the scan edge cuts into this groove's period
64
- if L > min_half_width:
65
- x_g, y_g = extract_single_groove(x, y, center, period_nm, margin,
66
- allow_asymmetric=False)
67
- # Re-create the exactly symmetric axis the original used, so that
68
- # floating point matches to the last bit.
69
- x_g = np.linspace(-L * dx_nm, L * dx_nm, len(y_g)) / 1000
70
- grooves.append((x_g, y_g))
86
+ grooves = select_grooves(x, y, groove_centers, period_nm, margin=margin,
87
+ min_half_width=min_half_width,
88
+ require_full_period=require_full_period)
71
89
 
72
90
  if len(grooves) == 0:
73
91
  raise ValueError(
@@ -90,6 +108,88 @@ def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
90
108
  return x_common, np.mean(y_aligned, axis=0), np.std(y_aligned, axis=0), len(grooves)
91
109
 
92
110
 
111
+ def groove_residuals(grooves, x_common, y_avg, n_passes=3, n_steps=6):
112
+ """
113
+ How far each groove departs from the average, once registered to it.
114
+
115
+ This is the RMS residual of each averaged groove about the average: the
116
+ part of the measured profile that does not repeat from one groove to the
117
+ next. It is reported, not applied. `BoundaryProfile.to_problem` and
118
+ docs/theory/metrology.md section 4 say why it is not a Nevot-Croce sigma.
119
+
120
+ A plain difference against `y_avg` does not measure that. Each groove is
121
+ centred on the sample nearest its minimum, so the set is misregistered by
122
+ up to half a pixel, and on a facet steeper than 45 degrees half a pixel of
123
+ shift is several nanometres of height. On the synthetic sawtooth with no
124
+ noise at all, the plain difference reads 2.1 nm. So each groove is first
125
+ registered to the average by a sub-pixel shift and a height offset, fitted
126
+ by Gauss-Newton on a cubic spline of its own samples, and the average is
127
+ recomputed from the registered grooves, `n_passes` times over. The shift is
128
+ bounded to one pixel either way, and the window is the aligned window the
129
+ average uses less one pixel at each end, so a shifted groove is never
130
+ extrapolated.
131
+
132
+ The residual is then taken at each groove's own samples rather than on the
133
+ dense common axis, because interpolating onto that axis smooths pixel-scale
134
+ structure: it read white noise 10 to 15% low. It is scaled by
135
+ sqrt(N / (N - 1)), since the average contains the groove it is compared
136
+ with, and is undefined for a single groove.
137
+
138
+ `y_avg` itself is not changed. It is still the unregistered mean, and is
139
+ what is exported.
140
+
141
+ Returns:
142
+ dict with
143
+ rms_nm: over every groove, or None for fewer than two grooves
144
+ per_groove_nm: one RMS per groove, in the order of `grooves`
145
+ shift_px: each groove's registration shift, in pixels
146
+ """
147
+ n = len(grooves)
148
+ if n < 2:
149
+ return {'rms_nm': None, 'per_groove_nm': np.zeros(0),
150
+ 'shift_px': np.zeros(0)}
151
+
152
+ dx = grooves[0][0][1] - grooves[0][0][0]
153
+ inner = (x_common > x_common[0] + dx) & (x_common < x_common[-1] - dx)
154
+ xi = x_common[inner]
155
+ reference = np.asarray(y_avg, dtype=float)[inner]
156
+ splines = [make_interp_spline(g[0], g[1], k=3) for g in grooves]
157
+ slopes = [spline.derivative() for spline in splines]
158
+ shifts = np.zeros(n)
159
+
160
+ for _ in range(n_passes):
161
+ registered = np.empty((n, len(xi)))
162
+ for k, (spline, slope) in enumerate(zip(splines, slopes)):
163
+ d = shifts[k]
164
+ for _ in range(n_steps):
165
+ r = spline(xi + d) - reference
166
+ # Least squares in (shift, offset). The offset's normal
167
+ # equation removes the means, which leaves one unknown.
168
+ g = slope(xi + d)
169
+ g = g - g.mean()
170
+ denom = np.dot(g, g)
171
+ if denom == 0:
172
+ break
173
+ d = float(np.clip(d - np.dot(g, r - r.mean()) / denom, -dx, dx))
174
+ shifts[k] = d
175
+ curve = spline(xi + d)
176
+ registered[k] = curve - np.mean(curve - reference)
177
+ reference = registered.mean(axis=0)
178
+
179
+ at_reference = make_interp_spline(xi, reference, k=3)
180
+ mean_square = np.empty(n)
181
+ for k, (x_g, y_g) in enumerate(grooves):
182
+ xs = x_g - shifts[k]
183
+ keep = (xs >= xi[0]) & (xs <= xi[-1])
184
+ r = y_g[keep] - at_reference(xs[keep])
185
+ mean_square[k] = np.mean((r - r.mean()) ** 2)
186
+ mean_square *= n / (n - 1)
187
+
188
+ return {'rms_nm': float(np.sqrt(np.mean(mean_square))),
189
+ 'per_groove_nm': np.sqrt(mean_square),
190
+ 'shift_px': shifts / dx}
191
+
192
+
93
193
  def normalize_profile(x_avg, y_avg, period_nm, apply_smoothing=True,
94
194
  smoothing_window=5):
95
195
  """
@@ -25,8 +25,8 @@ from ..core.region_mask import (GROOVE_REACH_PERIODS, bridge_nan,
25
25
  describe_mask, groove_spacings_px, has_mask,
26
26
  reject_grooves_near_nan,
27
27
  tip_correct_around_nan)
28
- from .average import (average_grooves, flatten_endpoints, normalize_profile,
29
- profile_metrics)
28
+ from .average import (average_grooves, flatten_endpoints, groove_residuals,
29
+ normalize_profile, profile_metrics, select_grooves)
30
30
 
31
31
  __all__ = ["BoundaryProfile", "build_boundary_profile"]
32
32
 
@@ -66,6 +66,16 @@ class BoundaryProfile:
66
66
  f"period {self.period_nm:.2f} nm, "
67
67
  f"depth {self.metrics['groove_depth']:.4f} of period")
68
68
 
69
+ @property
70
+ def groove_residual_rms_nm(self):
71
+ """RMS residual of the grooves about their average, in nm.
72
+
73
+ None when fewer than two grooves were averaged. What it measures, and
74
+ why `to_problem` does not use it unless asked to, is in that method's
75
+ docstring and in docs/theory/metrology.md section 4.
76
+ """
77
+ return self.metrics.get('groove_residual_rms_nm')
78
+
69
79
  def to_problem(self, *, coating=None, substrate=None, roughness=0.0,
70
80
  blaze_angle=None):
71
81
  """This measured groove as an efficiency problem.
@@ -87,9 +97,11 @@ class BoundaryProfile:
87
97
  Parameters:
88
98
  coating, substrate: material names, resolved by
89
99
  `gratinglab.materials` at solve time.
90
- roughness: RMS surface roughness in nm. As the note below
91
- explains, this class does not measure it, and 0.0 means "not
92
- supplied", not "smooth".
100
+ roughness: RMS surface roughness in nm, the Nevot-Croce sigma.
101
+ 0.0 means "not supplied", not "smooth". It stays 0.0 unless
102
+ the caller passes a number, including the groove residual
103
+ this class measures; the note below says why that is not
104
+ the default.
93
105
  blaze_angle: fitted facet angle in degrees, if the same scan was
94
106
  also run through `core.analysis.extract_blaze_angle`. Without
95
107
  it the scalar solver evaluates reflection on the mean surface,
@@ -98,13 +110,39 @@ class BoundaryProfile:
98
110
 
99
111
  A note on roughness: `Problem.roughness` means high-spatial-frequency
100
112
  surface roughness, the quantity with which Nevot-Croce damps a Fresnel
101
- coefficient. This class computes no such number. `y_std_nm` is the
102
- groove-to-groove form spread and `metrics['rms_slope']` is a slope
103
- statistic; neither is surface microroughness, and passing either one
104
- in here would produce an efficiency that appears valid but is wrong,
105
- where an omitted value would at least be visibly missing. The
106
- appropriate quantity, the RMS residual of each groove about this
107
- average, is not computed yet.
113
+ coefficient. `y_std_nm` is the groove-to-groove form spread and
114
+ `metrics['rms_slope']` is a slope statistic, and neither is that.
115
+
116
+ `groove_residual_rms_nm` is the nearest thing this class measures: the
117
+ RMS of each groove about the average once registered to it, which is
118
+ the part of the profile that does not repeat with the period. It is
119
+ still not passed by default, for four reasons, each of which makes it
120
+ a different number from the sigma Nevot-Croce wants:
121
+
122
+ - It is measured on the row-averaged profile. Structure that varies
123
+ along a groove averages away over the scan lines, and for isotropic
124
+ roughness with a correlation length well under the scan height that
125
+ is nearly all of it. The number keeps only what is coherent along
126
+ the groove for the height of the scan, so it is a lower bound on the
127
+ surface's roughness in its band.
128
+ - Its band is not the sigma's band. It sees nothing finer than the
129
+ pixel pitch or the tip, a few nanometres, while the structure that
130
+ damps a soft X-ray reflection extends down to wavelengths near the
131
+ X-ray wavelength itself. At the other end it includes groove-scale
132
+ form error, whose correlation length is about a period, where
133
+ Nevot-Croce assumes a short one.
134
+ - Groove-to-groove form error removes light from the orders by
135
+ scattering it between them, a Debye-Waller factor on each order's
136
+ own momentum transfer, not a damping of the Fresnel coefficient at
137
+ the facet. Folding it into the facet reflectivity applies the wrong
138
+ factor to the right effect.
139
+ - It carries instrument terms: AFM noise divided by the square root of
140
+ the number of scan lines, and a floor where the surface has corners
141
+ sharper than a pixel (0.21 nm on the synthetic sawtooth).
142
+
143
+ A caller who accepts those can pass it, and the name then appears in
144
+ their own code, which is the point:
145
+ `profile.to_problem(roughness=profile.groove_residual_rms_nm)`.
108
146
  """
109
147
  # Imported here rather than at module scope: `boundary` is reachable
110
148
  # from the GUI preview, which has no reason to pull the solver stack in
@@ -217,6 +255,16 @@ def build_boundary_profile(data, scan_x_size, settings, orientation=None) -> Bou
217
255
  smoothing_window=settings.ggp_smoothing_window)
218
256
 
219
257
  metrics = profile_metrics(x_norm, y_norm, period_nm, n_used)
258
+ # The same grooves average_grooves used, chosen by the same rules.
259
+ residual = groove_residuals(
260
+ select_grooves(raw_x, flat_y, groove_centers, period_nm, margin=0.0,
261
+ min_half_width=settings.ggp_min_half_width),
262
+ x_avg, y_avg)
263
+ metrics['groove_residual_rms_nm'] = residual['rms_nm']
264
+ metrics['groove_residual_per_groove_nm'] = residual['per_groove_nm'].tolist()
265
+ metrics['groove_residual_max_shift_px'] = (
266
+ float(np.max(np.abs(residual['shift_px'])))
267
+ if residual['rms_nm'] is not None else None)
220
268
  if orientation is not None:
221
269
  metrics.update(orientation.as_metrics())
222
270
  metrics['tip_correction'] = settings.tip_correction
@@ -275,6 +275,11 @@ class BoundaryView(QWidget):
275
275
  f"+/- {m['groove_rotation_stderr_deg']:.3f} deg"
276
276
  + (", corrected" if m.get('groove_rotation_corrected')
277
277
  else ", not corrected"))
278
+ rms = m.get('groove_residual_rms_nm')
279
+ # Labelled as a residual, not a roughness: it is not what to_problem
280
+ # applies, and the panel should not suggest otherwise.
281
+ residual = ("not measured, one groove" if rms is None
282
+ else f"{rms:.3f} nm, not applied")
278
283
  self.metrics_label.setText(
279
284
  f"Period : {p.period_nm:.2f} nm\n"
280
285
  f"Grooves averaged: {p.n_used} of {p.n_grooves}{edge}\n"
@@ -282,7 +287,8 @@ class BoundaryView(QWidget):
282
287
  f"Peak-to-valley : {m['peak_to_valley']:.4f} of period\n"
283
288
  f"RMS slope : {m['rms_slope']:.4f}\n"
284
289
  f"Max sidewall : {m['max_angle_deg']:.2f} deg\n"
285
- f"Max curvature : {m['max_curvature']:.4f}{rotation}{tip}")
290
+ f"Max curvature : {m['max_curvature']:.4f}\n"
291
+ f"Groove residual : {residual}{rotation}{tip}")
286
292
 
287
293
  def _show_placeholder(self, message="Load an AFM file in the Import tab"):
288
294
  self._placeholder = message
@@ -25,6 +25,16 @@ def write_profile_metrics(path, metrics):
25
25
  f.write(f"RMS slope: {metrics['rms_slope']:.4f} (normalized units)\n")
26
26
  f.write(f"Max slope magnitude: {metrics['max_slope']:.4f}\n")
27
27
  f.write(f"Max curvature: {metrics['max_curvature']:.6f} (normalized units)\n")
28
+ # Written only by a pipeline that measures it, so a metrics dict from
29
+ # before it existed still writes. Not applied as roughness: see
30
+ # BoundaryProfile.to_problem.
31
+ if 'groove_residual_rms_nm' in metrics:
32
+ rms = metrics['groove_residual_rms_nm']
33
+ if rms is None:
34
+ f.write("Groove residual RMS: not measured (one groove)\n")
35
+ else:
36
+ f.write(f"Groove residual RMS: {rms:.3f} nm (about the average, "
37
+ f"not applied as roughness)\n")
28
38
  # Present only when the pipeline ran a tip correction. Stated even so:
29
39
  # a corrected depth and an uncorrected one are different measurements,
30
40
  # and this sidecar is where a reader learns which this file holds.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gratinglab
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -145,6 +145,7 @@ tests/test_conditioning.py
145
145
  tests/test_convergence.py
146
146
  tests/test_cross_method.py
147
147
  tests/test_efficiency_table.py
148
+ tests/test_four_way.py
148
149
  tests/test_fresnel.py
149
150
  tests/test_geometry.py
150
151
  tests/test_ggp.py
@@ -0,0 +1,217 @@
1
+ """All four solvers on one problem, through ``compare.sweep``.
2
+
3
+ The pairwise files (``test_rcwa.py``, ``test_cmethod.py``,
4
+ ``test_cross_method.py``) each meet the integral solver on a problem chosen to
5
+ suit the method under test. This file does the thing the project exists for:
6
+ one ``Problem``, one ``Illumination``, every registered method, one
7
+ ``Comparison``. The three rigorous methods are held to each other order by
8
+ order; scalar theory is held only to what it promises.
9
+
10
+ The case is a shallow gold sinusoid in grazing incidence, the founding
11
+ application's regime: period 40 nm, depth fraction 0.05, alpha = 80 deg, TE,
12
+ 2 to 6 nm, absolute efficiencies with absorption from the vendored CXRO table
13
+ (``materials/data/Au.txt``, in the repository and therefore in CI, never in
14
+ the wheel; see ``SOURCES.md``). Forty orders propagate at 2 nm and fourteen at
15
+ 6 nm.
16
+
17
+ How the tolerances were set
18
+ ---------------------------
19
+ From a convergence ladder, not from one run. Measured 2026-10-28 on MC1 as
20
+ the largest difference over the five wavelengths against the C-method at
21
+ truncation 80, which is converged: truncation 24, 40 and 80 agree to 2e-15,
22
+ and the integral solver closes on it as its mesh is refined.
23
+
24
+ ================== ========== ========== ========== ==========
25
+ knob order 0 order 1 orders 2+ absorbed
26
+ ================== ========== ========== ========== ==========
27
+ integral 600 1.05e-6 4.6e-7 1.6e-7 1.9e-6
28
+ integral 800 4.8e-7 2.2e-7 7.7e-8 9.4e-7
29
+ integral 1600 8.1e-8 4.2e-8 1.5e-8 1.7e-7
30
+ rcwa 45 4.1e-6 4.1e-7 2.2e-7 3.6e-6
31
+ rcwa 60 2.6e-6 2.7e-7 1.4e-7 2.3e-6
32
+ rcwa 120 9.1e-7 9.7e-8 5.1e-8 7.8e-7
33
+ ================== ========== ========== ========== ==========
34
+
35
+ Both fall as their own knob is refined, so the differences are discretisation
36
+ error, not a disagreement about the physics. The test runs integral 800,
37
+ RCWA 60 and C-method 40, which is the 2026-10-28 run, and its tolerances sit
38
+ about three times above those rows. RCWA's zero order is its weakest number
39
+ here because a sinusoid is a staircase to it.
40
+
41
+ The perfect-conductor variants need no table. RCWA cannot take part: it
42
+ expands a permittivity, and a perfect conductor has none, so it refuses, and
43
+ that refusal is asserted so that the day it learns the case, this file hears.
44
+ """
45
+
46
+ import numpy as np
47
+ import pytest
48
+
49
+ from gratinglab.checks import check_energy_balance
50
+ from gratinglab.compare import align, sweep
51
+ from gratinglab.illumination import Illumination
52
+ from gratinglab.problem import Problem
53
+ from gratinglab.profiles import Sinusoidal
54
+ from gratinglab.solvers import UnsupportedConfiguration, get_solver
55
+
56
+ WAVELENGTHS = np.linspace(2.0, 6.0, 5)
57
+ METHODS = ["scalar", "integral", "rcwa", "cmethod"]
58
+ RIGOROUS = ("integral", "rcwa", "cmethod")
59
+ SINE = Sinusoidal(depth_fraction=0.05)
60
+
61
+
62
+ def worst(comparison, a, b):
63
+ """Largest ``|a - b|`` in order 0, order 1 and the rest, over the grid."""
64
+ delta = np.abs(comparison.difference(a, b))
65
+ zero = int(np.flatnonzero(comparison.orders == 0)[0])
66
+ first = int(np.flatnonzero(comparison.orders == 1)[0])
67
+ rest = np.delete(delta, [zero, first], axis=1)
68
+ return delta[:, zero].max(), delta[:, first].max(), rest.max()
69
+
70
+
71
+ @pytest.fixture(scope="module")
72
+ def gold():
73
+ """The 2026-10-28 run: about 30 s, so it is solved once for the module."""
74
+ problem = Problem(period=40.0, profile=SINE, coating="Au")
75
+ illumination = Illumination.classical(alpha=80.0, polarization="TE")
76
+ scans = sweep(
77
+ problem,
78
+ illumination,
79
+ WAVELENGTHS,
80
+ METHODS,
81
+ {
82
+ "integral": {"conductivity": "tabulated", "boundary_points": 800},
83
+ "cmethod": {"conductivity": "tabulated", "truncation": 40},
84
+ "rcwa": {"truncation": 60},
85
+ },
86
+ )
87
+ return problem, illumination, dict(zip(METHODS, scans)), align(scans)
88
+
89
+
90
+ class TestGoldInGrazingIncidence:
91
+ def test_every_method_finds_the_same_propagating_orders(self, gold):
92
+ _, _, scans, comparison = gold
93
+ assert comparison.methods == tuple(METHODS)
94
+ reference = scans["integral"]
95
+ for name in METHODS:
96
+ assert np.array_equal(scans[name].orders, reference.orders), name
97
+ assert np.array_equal(scans[name].propagating, reference.propagating), name
98
+ assert reference.propagating.sum(axis=1).tolist() == [40, 27, 20, 16, 14]
99
+
100
+ def test_the_cmethod_is_converged_at_this_truncation(self, gold):
101
+ """What makes the C-method the anchor below: its own change from
102
+ truncation 24 to 40 is far under any tolerance in this file, so the
103
+ C-method-to-integral gap is the integral solver's error."""
104
+ problem, illumination, scans, _ = gold
105
+ (coarse,) = sweep(
106
+ problem, illumination, WAVELENGTHS, ["cmethod"],
107
+ {"cmethod": {"conductivity": "tabulated", "truncation": 24}},
108
+ )
109
+ assert np.abs(coarse.efficiency - scans["cmethod"].efficiency).max() < 1e-9
110
+
111
+ def test_the_cmethod_matches_the_integral_solver_order_by_order(self, gold):
112
+ _, _, scans, comparison = gold
113
+ zero, first, rest = worst(comparison, "cmethod", "integral")
114
+ assert zero < 1.5e-6 # measured 4.8e-7
115
+ assert first < 7e-7 # measured 2.2e-7
116
+ assert rest < 3e-7 # measured 7.7e-8
117
+ absorbed = np.abs(scans["cmethod"].absorption - scans["integral"].absorption)
118
+ assert absorbed.max() < 3e-6 # measured 9.4e-7
119
+
120
+ def test_rcwa_matches_the_integral_solver_order_by_order(self, gold):
121
+ _, _, scans, comparison = gold
122
+ zero, first, rest = worst(comparison, "rcwa", "integral")
123
+ assert zero < 1e-5 # measured 3.1e-6
124
+ assert first < 1e-6 # measured 2.6e-7
125
+ assert rest < 5e-7 # measured 1.1e-7
126
+ absorbed = np.abs(scans["rcwa"].absorption - scans["integral"].absorption)
127
+ assert absorbed.max() < 1e-5 # measured 2.4e-6
128
+
129
+ def test_each_rigorous_method_conserves_energy(self, gold):
130
+ """``R + A = 1`` for a one-interface absorbing grating, with ``A``
131
+ computed independently of ``R`` by all three. The integral solver's
132
+ deviation is its mesh error, measured 1.7e-6 at 800 points; the
133
+ modal methods balance to rounding."""
134
+ _, _, scans, _ = gold
135
+ assert check_energy_balance(scans["integral"], lossless=True, tolerance=5e-6).passed
136
+ assert check_energy_balance(scans["rcwa"], lossless=True, tolerance=1e-10).passed
137
+ assert check_energy_balance(scans["cmethod"], lossless=True, tolerance=1e-10).passed
138
+ for name in RIGOROUS:
139
+ assert scans[name].provenance.notes["normalization"] == "absolute", name
140
+
141
+ def test_scalar_theory_is_an_approximation_here_and_says_so(self, gold):
142
+ """Out of its regime: the reduced ratio reaches 0.15 and the facet
143
+ graze exceeds gold's critical angle. It still gets what the grating
144
+ equation decides, the orders, and lands within a factor of two of the
145
+ rigorous first order (measured 0.60 to 0.86 of it), and it warns that
146
+ per-order numbers want a rigorous cross-check. Nothing tighter is
147
+ asserted, because nothing tighter is promised."""
148
+ _, _, scans, comparison = gold
149
+ scalar = scans["scalar"]
150
+ assert check_energy_balance(scalar).passed # passive: sum <= 1
151
+ ratio = comparison.order(1)["scalar"] / comparison.order(1)["cmethod"]
152
+ assert np.all((ratio > 0.5) & (ratio < 2.0)), ratio
153
+ assert any("reduced ratio" in w for w in scalar.provenance.warnings)
154
+
155
+
156
+ class TestPerfectConductor:
157
+ """The same groove and mount with no material, so no table and no
158
+ absorption: efficiencies relative to a perfect reflector, summing to one.
159
+
160
+ Measured 2026-10-28 against the C-method at truncation 80: TE with 400
161
+ boundary points 2.0e-7, falling to 1.1e-8 at 1600; TM with 800 points
162
+ 7.1e-6, falling to 1.5e-6 at 1600. TM converges more slowly in the
163
+ integral solver, hence the extra points and the wider tolerance.
164
+ """
165
+
166
+ PROBLEM = Problem(period=40.0, profile=SINE)
167
+
168
+ @pytest.mark.parametrize(
169
+ "polarization, boundary_points, tolerance, balance",
170
+ [("TE", 400, 1e-6, 1e-9), ("TM", 800, 3e-5, 4e-5)],
171
+ )
172
+ def test_three_methods_agree(self, polarization, boundary_points, tolerance, balance):
173
+ illumination = Illumination.classical(alpha=80.0, polarization=polarization)
174
+ scans = sweep(
175
+ self.PROBLEM,
176
+ illumination,
177
+ WAVELENGTHS,
178
+ ["scalar", "integral", "cmethod"],
179
+ {"integral": {"boundary_points": boundary_points}, "cmethod": {"truncation": 40}},
180
+ )
181
+ _, integral, cmethod = scans
182
+ comparison = align(scans)
183
+
184
+ for scan in scans:
185
+ assert np.array_equal(scan.propagating, integral.propagating)
186
+ assert scan.provenance.notes["normalization"].startswith("relative")
187
+ assert comparison.max_abs_difference("cmethod", "integral") < tolerance
188
+ # Measured deviation 4.5e-11 (TE) and 1.2e-5 (TM) for the integral solver.
189
+ assert check_energy_balance(integral, lossless=True, tolerance=balance).passed
190
+ assert check_energy_balance(cmethod, lossless=True, tolerance=1e-10).passed
191
+
192
+ def test_scalar_cannot_see_the_polarization_the_rigorous_methods_do(self):
193
+ """Scalar theory is polarization-blind by construction, so its TE and
194
+ TM scans are identical, while the rigorous first order differs by 45%
195
+ to 216% between them here (0.281 against 0.407 at 2 nm, 0.086 against
196
+ 0.273 at 6 nm). Which is why no agreement is asserted for it beyond
197
+ the orders."""
198
+ te, tm = (
199
+ sweep(
200
+ self.PROBLEM,
201
+ Illumination.classical(alpha=80.0, polarization=p),
202
+ WAVELENGTHS,
203
+ ["scalar", "cmethod"],
204
+ {"cmethod": {"truncation": 40}},
205
+ )
206
+ for p in ("TE", "TM")
207
+ )
208
+ assert np.array_equal(te[0].efficiency, tm[0].efficiency)
209
+ first = int(np.flatnonzero(te[1].orders == 1)[0])
210
+ rigorous_te, rigorous_tm = te[1].efficiency[:, first], tm[1].efficiency[:, first]
211
+ assert np.all(np.abs(rigorous_tm - rigorous_te) / rigorous_te > 0.2)
212
+
213
+ def test_rcwa_refuses_a_perfect_conductor(self):
214
+ with pytest.raises(UnsupportedConfiguration, match="perfect conductor"):
215
+ get_solver("rcwa").solve(
216
+ self.PROBLEM, Illumination.classical(alpha=80.0), WAVELENGTHS
217
+ )
@@ -117,10 +117,15 @@ class TestGeneralPages:
117
117
  assert page.path is not None and page.path.name == "metrology.md"
118
118
 
119
119
  def test_metrology_page_states_that_roughness_is_not_measured(self):
120
- """The assumption most likely to be silently assumed away."""
120
+ """The assumption most likely to be silently assumed away.
121
+
122
+ The groove residual is measured now, so the page has to say that it is
123
+ not the roughness sigma and is not applied by default.
124
+ """
121
125
  page = self._page("metrology")
122
126
  assert "RMS surface roughness" in page.text
123
- assert "not yet computed" in page.text
127
+ assert "package measures that σ" in page.text
128
+ assert "not passed as the Névot–Croce σ by default" in page.text
124
129
 
125
130
  def test_general_pages_are_never_banner_flagged(self):
126
131
  """These are geometry references, not a solver's approximation --
File without changes
File without changes
File without changes
File without changes