gratinglab 0.1.2__tar.gz → 0.1.4__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 (151) hide show
  1. {gratinglab-0.1.2/src/gratinglab.egg-info → gratinglab-0.1.4}/PKG-INFO +6 -2
  2. {gratinglab-0.1.2 → gratinglab-0.1.4}/README.md +5 -1
  3. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/__init__.py +1 -1
  4. gratinglab-0.1.4/src/gratinglab/gui/qt/gc_guard.py +102 -0
  5. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/geometry_panel.py +32 -5
  6. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/main_window.py +14 -0
  7. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/state.py +5 -2
  8. gratinglab-0.1.4/src/gratinglab/io/boundary.py +270 -0
  9. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/boundary/average.py +18 -6
  10. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/boundary_view.py +121 -16
  11. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/main_window.py +82 -1
  12. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/state.py +13 -0
  13. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/reading-outputs.md +27 -9
  14. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/tip-correction.md +13 -1
  15. {gratinglab-0.1.2 → gratinglab-0.1.4/src/gratinglab.egg-info}/PKG-INFO +6 -2
  16. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab.egg-info/SOURCES.txt +3 -0
  17. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_qt.py +127 -0
  18. gratinglab-0.1.4/tests/test_io_boundary.py +156 -0
  19. {gratinglab-0.1.2 → gratinglab-0.1.4}/LICENSE +0 -0
  20. {gratinglab-0.1.2 → gratinglab-0.1.4}/pyproject.toml +0 -0
  21. {gratinglab-0.1.2 → gratinglab-0.1.4}/setup.cfg +0 -0
  22. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/checks.py +0 -0
  23. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/compare.py +0 -0
  24. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/convergence.py +0 -0
  25. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/geometry.py +0 -0
  26. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/__init__.py +0 -0
  27. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/app.py +0 -0
  28. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/diagram.py +0 -0
  29. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/diagram3d.py +0 -0
  30. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/docs.py +0 -0
  31. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/icon.py +0 -0
  32. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/integral_options.py +0 -0
  33. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/mathtext.py +0 -0
  34. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/orders.py +0 -0
  35. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/provenance.py +0 -0
  36. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/__init__.py +0 -0
  37. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/geometry_tab.py +0 -0
  38. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/integral_tab.py +0 -0
  39. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/profile_plot_panel.py +0 -0
  40. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/scalar_tab.py +0 -0
  41. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/scene3d_panel.py +0 -0
  42. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/setup_tab.py +0 -0
  43. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/sizing.py +0 -0
  44. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/theory_viewer.py +0 -0
  45. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/qt/worker.py +0 -0
  46. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/richtext.py +0 -0
  47. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/gui/scalar_options.py +0 -0
  48. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/illumination.py +0 -0
  49. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/io/__init__.py +0 -0
  50. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/io/efficiency_table.py +0 -0
  51. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/io/ggp.py +0 -0
  52. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/materials/__init__.py +0 -0
  53. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/materials/data/SOURCES.md +0 -0
  54. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/materials/fresnel.py +0 -0
  55. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/materials/optical.py +0 -0
  56. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/materials/refractiveindex.py +0 -0
  57. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/__init__.py +0 -0
  58. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/analyzer.py +0 -0
  59. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/boundary/__init__.py +0 -0
  60. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/boundary/pipeline.py +0 -0
  61. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/cli.py +0 -0
  62. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/config.py +0 -0
  63. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/core/__init__.py +0 -0
  64. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/core/analysis.py +0 -0
  65. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/core/image_flatten.py +0 -0
  66. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/core/processing.py +0 -0
  67. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/core/tip.py +0 -0
  68. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/data/__init__.py +0 -0
  69. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/data/aggregation.py +0 -0
  70. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/__init__.py +0 -0
  71. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/icon.py +0 -0
  72. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/__init__.py +0 -0
  73. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/app.py +0 -0
  74. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/canvas.py +0 -0
  75. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/efficiency_dialog.py +0 -0
  76. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/import_view.py +0 -0
  77. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/wiki_view.py +0 -0
  78. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/gui/qt/worker.py +0 -0
  79. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/io/__init__.py +0 -0
  80. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/io/file_io.py +0 -0
  81. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/io/metrics.py +0 -0
  82. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/io/spm.py +0 -0
  83. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/settings.py +0 -0
  84. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/stats/__init__.py +0 -0
  85. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/stats/analysis.py +0 -0
  86. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/stats/icc.py +0 -0
  87. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/visualization/__init__.py +0 -0
  88. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/visualization/diagnostics.py +0 -0
  89. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/visualization/profiles.py +0 -0
  90. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/visualization/statistics.py +0 -0
  91. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/__init__.py +0 -0
  92. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/facet-fitting.md +0 -0
  93. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/file-formats.md +0 -0
  94. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/flattening.md +0 -0
  95. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/icc-correction.md +0 -0
  96. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/overview.md +0 -0
  97. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/wiki/row-groups.md +0 -0
  98. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/metrology/workflows.py +0 -0
  99. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/problem.py +0 -0
  100. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/profiles.py +0 -0
  101. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/resolution.py +0 -0
  102. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/result.py +0 -0
  103. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/__init__.py +0 -0
  104. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/base.py +0 -0
  105. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/__init__.py +0 -0
  106. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_boundary.py +0 -0
  107. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_core.py +0 -0
  108. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_finite.py +0 -0
  109. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_greens.py +0 -0
  110. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_kernels.py +0 -0
  111. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/integral/_nystrom.py +0 -0
  112. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab/solvers/scalar.py +0 -0
  113. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab.egg-info/dependency_links.txt +0 -0
  114. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab.egg-info/entry_points.txt +0 -0
  115. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab.egg-info/requires.txt +0 -0
  116. {gratinglab-0.1.2 → gratinglab-0.1.4}/src/gratinglab.egg-info/top_level.txt +0 -0
  117. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_checks.py +0 -0
  118. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_compare.py +0 -0
  119. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_convergence.py +0 -0
  120. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_cross_method.py +0 -0
  121. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_efficiency_table.py +0 -0
  122. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_fresnel.py +0 -0
  123. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_geometry.py +0 -0
  124. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_ggp.py +0 -0
  125. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_diagram.py +0 -0
  126. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_diagram3d.py +0 -0
  127. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_docs.py +0 -0
  128. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_integral_options.py +0 -0
  129. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_mathtext.py +0 -0
  130. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_orders.py +0 -0
  131. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_provenance.py +0 -0
  132. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_richtext.py +0 -0
  133. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_scalar_options.py +0 -0
  134. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_gui_state.py +0 -0
  135. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_illumination.py +0 -0
  136. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_core.py +0 -0
  137. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_corpus.py +0 -0
  138. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_finite.py +0 -0
  139. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_greens.py +0 -0
  140. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_kernels.py +0 -0
  141. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_operators.py +0 -0
  142. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_integral_solver.py +0 -0
  143. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_materials.py +0 -0
  144. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_perturbation.py +0 -0
  145. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_profiles.py +0 -0
  146. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_refractiveindex.py +0 -0
  147. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_resolution.py +0 -0
  148. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_result.py +0 -0
  149. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_scalar.py +0 -0
  150. {gratinglab-0.1.2 → gratinglab-0.1.4}/tests/test_solver_conformance.py +0 -0
  151. {gratinglab-0.1.2 → gratinglab-0.1.4}/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.2
3
+ Version: 0.1.4
4
4
  Summary: Open platform for diffraction grating groove metrology and efficiency analysis
5
5
  Author: Jake McCoy
6
6
  License: BSD-3-Clause
@@ -74,7 +74,11 @@ The two halves are one package because a file between them loses information. A
74
74
  PCGrate `.ggp` boundary carries a *shape* and not a grating: it cannot hold the period,
75
75
  so the period gets retyped, guessed, or recovered from a sidecar later.
76
76
  `BoundaryProfile.to_problem()` carries the period the scan measured, and the fitted
77
- facet angle with it.
77
+ facet angle with it. To save a profile without losing that, write it as gratinglab's
78
+ own `.json` (`gratinglab.io.boundary.write_boundary_json`): reading it back gives the
79
+ same problem, and the efficiency window's "Load boundary profile" fills in the
80
+ period from it. The metrology window's Boundary tab exports `.ggp`, `.json` or
81
+ `.csv`, and its "Send to efficiency window" button opens the groove there directly.
78
82
 
79
83
  ```python
80
84
  from gratinglab.metrology.boundary import build_boundary_profile
@@ -35,7 +35,11 @@ The two halves are one package because a file between them loses information. A
35
35
  PCGrate `.ggp` boundary carries a *shape* and not a grating: it cannot hold the period,
36
36
  so the period gets retyped, guessed, or recovered from a sidecar later.
37
37
  `BoundaryProfile.to_problem()` carries the period the scan measured, and the fitted
38
- facet angle with it.
38
+ facet angle with it. To save a profile without losing that, write it as gratinglab's
39
+ own `.json` (`gratinglab.io.boundary.write_boundary_json`): reading it back gives the
40
+ same problem, and the efficiency window's "Load boundary profile" fills in the
41
+ period from it. The metrology window's Boundary tab exports `.ggp`, `.json` or
42
+ `.csv`, and its "Send to efficiency window" button opens the groove there directly.
39
43
 
40
44
  ```python
41
45
  from gratinglab.metrology.boundary import build_boundary_profile
@@ -21,7 +21,7 @@ from .illumination import Illumination, Polarization
21
21
  from .resolution import ResolvingPower, resolving_power
22
22
  from .result import EfficiencyScan, OrderEfficiency, Provenance
23
23
 
24
- __version__ = "0.1.2"
24
+ __version__ = "0.1.4"
25
25
 
26
26
  __all__ = [
27
27
  "Illumination",
@@ -0,0 +1,102 @@
1
+ """Run Python's cycle collector on the GUI thread, never on a worker's.
2
+
3
+ Both windows do their heavy work on a ``QThread``: the metrology window's
4
+ analysis and the main window's solves. Python's automatic collector runs on
5
+ whichever thread happens to allocate past its threshold, so it regularly ran
6
+ *inside* those workers, and when the garbage it found held Qt objects (a
7
+ closed window, a figure from an earlier preview) it destroyed them off the GUI
8
+ thread. Qt does not allow that, and on macOS it segfaults: CI run 39835186938
9
+ died with ``Fatal Python error: Segmentation fault`` and "Garbage-collecting"
10
+ at the top of the worker thread's stack, in the middle of an ordinary
11
+ ``genfromtxt``. The same thing can happen to a user: close some windows, press
12
+ Run, and the application crashes.
13
+
14
+ The remedy is the usual one for PySide and PyQt applications. While any window
15
+ holds the guard, automatic collection is off, and a timer on the GUI thread
16
+ runs the collector whenever the generation counts say it is due, so garbage is
17
+ still collected promptly and always on the one thread allowed to free a Qt
18
+ object. When the last window releases it, automatic collection is restored,
19
+ so code that runs with no window open behaves exactly as before.
20
+
21
+ A window that is closed but never deleted never releases it, so the guard
22
+ also restores collection at interpreter exit, through ``atexit``, and collects
23
+ once while Qt still exists. Without that, Python's own final collection freed
24
+ the accumulated Qt garbage after Qt was gone: the ubuntu / py3.11 CI leg passed
25
+ every test and then aborted with ``Fatal Python error: bool_dealloc``, twice,
26
+ with one window still holding the guard at the end of the session.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import atexit
32
+ import gc
33
+
34
+ from PySide6.QtCore import QObject, QTimer
35
+ from PySide6.QtWidgets import QApplication
36
+
37
+ __all__ = ["guard", "is_active"]
38
+
39
+ #: How often the GUI thread checks whether a collection is due. Checking is
40
+ #: three integer comparisons; collecting happens only past the thresholds.
41
+ INTERVAL_MS = 500
42
+
43
+ _collector: "_Collector | None" = None
44
+ _holders = 0
45
+
46
+
47
+ class _Collector(QObject):
48
+ def __init__(self, parent: QObject) -> None:
49
+ super().__init__(parent)
50
+ self._timer = QTimer(self)
51
+ self._timer.timeout.connect(self.collect_if_due)
52
+ self._timer.start(INTERVAL_MS)
53
+
54
+ def collect_if_due(self) -> None:
55
+ """What the automatic collector would have done, on this thread."""
56
+ count0, count1, count2 = gc.get_count()
57
+ limit0, limit1, limit2 = gc.get_threshold()
58
+ if count0 > limit0:
59
+ generation = 2 if count2 > limit2 else 1 if count1 > limit1 else 0
60
+ gc.collect(generation)
61
+
62
+
63
+ def guard(window: QObject) -> None:
64
+ """Keep the collector on the GUI thread for as long as ``window`` exists.
65
+
66
+ Call from the window's constructor, on the GUI thread. Each call is
67
+ released by that window's ``destroyed`` signal; the last release restores
68
+ automatic collection.
69
+ """
70
+ global _collector, _holders
71
+ if _holders == 0:
72
+ app = QApplication.instance()
73
+ # Whatever garbage already exists is collected here and now, on this
74
+ # thread, before any worker can start allocating.
75
+ gc.collect()
76
+ gc.disable()
77
+ _collector = _Collector(app if app is not None else window)
78
+ _holders += 1
79
+ window.destroyed.connect(_release)
80
+
81
+
82
+ def _release(*_args) -> None:
83
+ global _collector, _holders
84
+ _holders = max(0, _holders - 1)
85
+ if _holders == 0 and _collector is not None:
86
+ _collector._timer.stop()
87
+ _collector.deleteLater()
88
+ _collector = None
89
+ gc.enable()
90
+
91
+
92
+ @atexit.register
93
+ def _restore_at_exit() -> None:
94
+ """Collect while Qt is still alive, and hand collection back to Python."""
95
+ if _holders > 0:
96
+ gc.enable()
97
+ gc.collect()
98
+
99
+
100
+ def is_active() -> bool:
101
+ """True while some window holds the guard, so automatic collection is off."""
102
+ return _holders > 0
@@ -216,13 +216,40 @@ class GeometryPanel(QWidget):
216
216
 
217
217
  def load_profile(self) -> None:
218
218
  path, _ = QFileDialog.getOpenFileName(
219
- self, "Load boundary profile", "", "PCGrate boundary (*.ggp);;All files (*)"
219
+ self, "Load boundary profile", "",
220
+ "Boundary profiles (*.json *.ggp);;"
221
+ "gratinglab boundary, with period (*.json);;"
222
+ "PCGrate boundary (*.ggp);;All files (*)",
220
223
  )
221
224
  if path:
222
- self._fields["profile_path"].setText(path)
223
- self._path_label.setText(Path(path).name)
224
- self.changed.emit()
225
- self.solve_requested.emit()
225
+ self.load_boundary_file(path)
226
+
227
+ def load_boundary_file(self, path: "str | Path") -> None:
228
+ """Use a boundary file as the profile, and its period if it has one.
229
+
230
+ A gratinglab ``.json`` carries the period it was measured at, so the
231
+ period field is set from it; a ``.ggp`` cannot, so the field is left as
232
+ it is, which is the one case where the user still has to supply it.
233
+ Also what the metrology window's "Send to efficiency window" calls.
234
+ """
235
+ path = Path(path)
236
+ note = ""
237
+ if path.suffix.lower() == ".json":
238
+ from ...io.boundary import read_boundary_json
239
+
240
+ try:
241
+ saved = read_boundary_json(path)
242
+ except (OSError, ValueError):
243
+ pass # the form's own validation reports the reason
244
+ else:
245
+ self._fields["period"].setText(f"{saved.period_nm:.6g}")
246
+ note = f" (period {saved.period_nm:.6g} nm from the file)"
247
+ kind = self._fields["profile_kind"]
248
+ kind.setCurrentIndex(kind.findText("From file"))
249
+ self._fields["profile_path"].setText(str(path))
250
+ self._path_label.setText(path.name + note)
251
+ self.changed.emit()
252
+ self.solve_requested.emit()
226
253
 
227
254
 
228
255
  def _value(widget: QWidget) -> str:
@@ -103,6 +103,10 @@ class MainWindow(QMainWindow):
103
103
  super().__init__(parent)
104
104
  self.setWindowTitle("GratingLab")
105
105
  self.resize(1180, 820)
106
+ # Solves run on a worker thread; keep the cycle collector off it.
107
+ from .gc_guard import guard
108
+
109
+ guard(self)
106
110
 
107
111
  self._parsed = None
108
112
  self._token = 0
@@ -313,6 +317,16 @@ class MainWindow(QMainWindow):
313
317
 
314
318
  # -- actions ---------------------------------------------------------
315
319
 
320
+ def open_boundary(self, path) -> None:
321
+ """Load a boundary profile file as the grating, as if chosen by hand.
322
+
323
+ The metrology window's "Send to efficiency window" calls this with a
324
+ gratinglab ``.json``, so the measured period arrives with the shape.
325
+ """
326
+ self.geometry.load_boundary_file(path)
327
+ if not self.geometry_dock.isVisible():
328
+ self.geometry_dock.show()
329
+
316
330
  def _geometry_edited(self) -> None:
317
331
  """A field was edited. Schedule a redraw; solve nothing.
318
332
 
@@ -246,9 +246,12 @@ def _build_profile(form: FormState, errors: list[FieldError]):
246
246
  errors.append(FieldError("profile_path", f"no such file: {path}"))
247
247
  return None
248
248
  try:
249
- from ..io.ggp import read_profile
249
+ # A gratinglab .json or a PCGrate .ggp, by suffix. The JSON's period is
250
+ # applied to the form when the file is loaded (GeometryPanel), not here:
251
+ # the period field stays the one place the period is read from.
252
+ from ..io.boundary import read_profile_file
250
253
 
251
- return read_profile(path)
254
+ return read_profile_file(path)
252
255
  except Exception as exc: # the reader raises ValueError with a useful message
253
256
  errors.append(FieldError("profile_path", str(exc).split("\n")[0]))
254
257
  return None
@@ -0,0 +1,270 @@
1
+ """Saving a measured groove profile with its scale and where it came from.
2
+
3
+ A ``.ggp`` holds a groove's *shape* and nothing else: it is normalised to one
4
+ period and has no room for the period itself, so a profile written to one and
5
+ read back has lost the number the solver's phase term scales by. That is the
6
+ gap ``docs/roadmap.md`` item 5 names. Two formats close it here:
7
+
8
+ ``.json``, gratinglab's own
9
+ The normalised profile at full precision, the measured period in nm, the
10
+ groove count, the profile metrics, and provenance: the gratinglab version,
11
+ the scan it came from, how many grooves were detected and averaged, and
12
+ the analysis settings. :func:`read_boundary_json` reads it back, and its
13
+ :meth:`BoundaryFile.to_problem` gives the same :class:`~gratinglab.problem.Problem`
14
+ the in-process ``BoundaryProfile.to_problem`` does, so saving loses nothing.
15
+ The main window's "Load boundary profile" reads it and fills in the period.
16
+
17
+ ``.csv``, for everything else
18
+ One row per point, ``t`` and ``y`` as fractions of the period plus the same
19
+ points in nm, under ``#`` comment lines carrying the period, groove counts
20
+ and source. For a spreadsheet, MATLAB, or another code. Write-only: the
21
+ JSON is the one to read back.
22
+
23
+ Both writers take any object with the fields of
24
+ ``gratinglab.metrology.boundary.BoundaryProfile`` (``x_norm``, ``y_norm``,
25
+ ``period_nm``, ``n_grooves``, ``n_used``, ``n_edge_rejected``, ``metrics``)
26
+ rather than importing it, because the metrology package needs the plotting
27
+ extra and this module must not.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import json
34
+ import math
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ import numpy as np
41
+
42
+ __all__ = [
43
+ "FORMAT",
44
+ "VERSION",
45
+ "BoundaryFile",
46
+ "read_boundary_json",
47
+ "read_profile_file",
48
+ "write_boundary_csv",
49
+ "write_boundary_json",
50
+ ]
51
+
52
+ #: Identifies the file, so a reader can refuse an unrelated JSON by name
53
+ #: rather than failing on a missing key three lines later.
54
+ FORMAT = "gratinglab-boundary"
55
+
56
+ #: Bumped when a reader of the previous version could misread a new file.
57
+ #: Adding an optional key is not such a change.
58
+ VERSION = 1
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class BoundaryFile:
63
+ """A groove profile read back from a gratinglab ``.json``."""
64
+
65
+ period_nm: float
66
+ #: Fractions of one period, as :class:`~gratinglab.profiles.FromProfileData` takes them.
67
+ t: tuple[float, ...]
68
+ y: tuple[float, ...]
69
+ #: Grooves averaged into this profile, carried onto the problem.
70
+ n_grooves: int | None = None
71
+ #: A fitted facet angle in degrees, if the writer had one.
72
+ blaze_angle: float | None = None
73
+ metrics: dict = field(default_factory=dict)
74
+ provenance: dict = field(default_factory=dict)
75
+
76
+ def profile(self):
77
+ """The shape, as the solvers take it."""
78
+ from ..profiles import FromProfileData
79
+
80
+ return FromProfileData(t=self.t, y=self.y, blaze_angle=self.blaze_angle)
81
+
82
+ def to_problem(self, *, coating=None, substrate=None, roughness=0.0):
83
+ """This groove as an efficiency problem, with the period it was measured at.
84
+
85
+ The same problem ``BoundaryProfile.to_problem`` builds in process,
86
+ which is the point of the format: see ``tests/test_io_boundary.py``.
87
+ Roughness is not in the file for the reason that method's docstring
88
+ gives: nothing here measures surface microroughness.
89
+ """
90
+ from ..problem import Problem
91
+
92
+ return Problem(
93
+ period=self.period_nm,
94
+ profile=self.profile(),
95
+ coating=coating,
96
+ substrate=substrate,
97
+ roughness=roughness,
98
+ n_grooves=self.n_grooves,
99
+ )
100
+
101
+
102
+ def write_boundary_json(
103
+ path: "str | Path",
104
+ profile: Any,
105
+ *,
106
+ source: "str | Path | None" = None,
107
+ settings: Any = None,
108
+ blaze_angle: float | None = None,
109
+ ) -> Path:
110
+ """Write ``profile`` (a ``BoundaryProfile``) as a gratinglab ``.json``.
111
+
112
+ ``source`` names the scan it came from and ``settings`` is the
113
+ ``AnalysisSettings`` it was built with; both land in the provenance and
114
+ neither is required. Floats are written at full precision, so reading the
115
+ file back reproduces the profile exactly rather than to six decimals as a
116
+ ``.ggp`` does.
117
+ """
118
+ path = Path(path)
119
+ t, y = _points(profile)
120
+ document = {
121
+ "format": FORMAT,
122
+ "version": VERSION,
123
+ "period_nm": float(profile.period_nm),
124
+ "profile": {
125
+ "units": "t and y are fractions of one period",
126
+ "t": t,
127
+ "y": y,
128
+ },
129
+ "n_grooves": int(profile.n_used),
130
+ "blaze_angle_deg": None if blaze_angle is None else float(blaze_angle),
131
+ "metrics": _plain(profile.metrics),
132
+ "provenance": _provenance(profile, source, settings),
133
+ }
134
+ text = json.dumps(document, indent=1, allow_nan=False) + "\n"
135
+ path.write_bytes(text.encode("utf-8"))
136
+ return path
137
+
138
+
139
+ def read_boundary_json(path: "str | Path") -> BoundaryFile:
140
+ """Read a gratinglab ``.json`` boundary profile.
141
+
142
+ Refuses, by name, a JSON that is not one, a newer version than this reader
143
+ knows, and a profile whose two columns differ in length or whose period is
144
+ not a positive number. The profile's own checks (at least two points, and
145
+ so on) run when :meth:`BoundaryFile.profile` builds it.
146
+ """
147
+ path = Path(path)
148
+ try:
149
+ document = json.loads(path.read_bytes().decode("utf-8"))
150
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
151
+ raise ValueError(f"{path}: not a JSON file ({exc})") from exc
152
+ if not isinstance(document, dict) or document.get("format") != FORMAT:
153
+ raise ValueError(f"{path}: not a gratinglab boundary profile (no format: {FORMAT!r})")
154
+ version = document.get("version")
155
+ if not isinstance(version, int) or version > VERSION:
156
+ raise ValueError(
157
+ f"{path}: boundary format version {version!r}; this gratinglab reads "
158
+ f"up to {VERSION}, so upgrade it to read this file")
159
+ period = document.get("period_nm")
160
+ if not isinstance(period, (int, float)) or not math.isfinite(period) or period <= 0:
161
+ raise ValueError(f"{path}: period_nm must be a positive number, got {period!r}")
162
+ points = document.get("profile") or {}
163
+ t, y = points.get("t"), points.get("y")
164
+ if not isinstance(t, list) or not isinstance(y, list) or len(t) != len(y):
165
+ raise ValueError(f"{path}: profile t and y must be two lists of equal length")
166
+ blaze = document.get("blaze_angle_deg")
167
+ n_grooves = document.get("n_grooves")
168
+ return BoundaryFile(
169
+ period_nm=float(period),
170
+ t=tuple(float(v) for v in t),
171
+ y=tuple(float(v) for v in y),
172
+ n_grooves=None if n_grooves is None else int(n_grooves),
173
+ blaze_angle=None if blaze is None else float(blaze),
174
+ metrics=document.get("metrics") or {},
175
+ provenance=document.get("provenance") or {},
176
+ )
177
+
178
+
179
+ def write_boundary_csv(
180
+ path: "str | Path",
181
+ profile: Any,
182
+ *,
183
+ source: "str | Path | None" = None,
184
+ settings: Any = None,
185
+ ) -> Path:
186
+ """Write ``profile`` as a CSV table under ``#`` comment lines.
187
+
188
+ Columns ``t, y`` are fractions of one period and ``x_nm, y_nm`` the same
189
+ points scaled by the measured period, so a reader needs no other number to
190
+ plot it at true size. ``settings`` is accepted for symmetry with
191
+ :func:`write_boundary_json`; only the JSON records them in full.
192
+ """
193
+ path = Path(path)
194
+ t, y = _points(profile)
195
+ period = float(profile.period_nm)
196
+ prov = _provenance(profile, source, None)
197
+ # The column header comes FIRST and the comments after it: numpy's
198
+ # genfromtxt(names=True) takes names from line one even when it is a
199
+ # comment, so a comment there containing a comma became two junk column
200
+ # names. Header-first reads cleanly in numpy, pandas (comment="#") and a
201
+ # spreadsheet, whose first row is then the column names.
202
+ lines = [
203
+ "t,y,x_nm,y_nm",
204
+ f"# gratinglab boundary profile written by gratinglab {prov['gratinglab_version']}",
205
+ f"# period_nm: {period!r}",
206
+ f"# grooves: {prov['grooves_averaged']} averaged of {prov['grooves_detected']} detected",
207
+ ]
208
+ if prov.get("source"):
209
+ lines.append(f"# source: {prov['source']}")
210
+ lines.append(
211
+ "# t and y are fractions of one period; x_nm and y_nm are the same points times the period")
212
+ lines += [f"{ti!r},{yi!r},{ti * period!r},{yi * period!r}" for ti, yi in zip(t, y)]
213
+ path.write_bytes(("\n".join(lines) + "\n").encode("utf-8"))
214
+ return path
215
+
216
+
217
+ def read_profile_file(path: "str | Path"):
218
+ """A profile from either a gratinglab ``.json`` or a PCGrate ``.ggp``.
219
+
220
+ By suffix, because the two are unrelated formats and sniffing would only
221
+ turn a clear refusal into a confusing one. Only the JSON carries a period;
222
+ read it with :func:`read_boundary_json` to get that too.
223
+ """
224
+ path = Path(path)
225
+ if path.suffix.lower() == ".json":
226
+ return read_boundary_json(path).profile()
227
+ from .ggp import read_profile
228
+
229
+ return read_profile(path)
230
+
231
+
232
+ def _points(profile: Any) -> tuple[list[float], list[float]]:
233
+ t = [float(v) for v in np.asarray(profile.x_norm, dtype=float)]
234
+ y = [float(v) for v in np.asarray(profile.y_norm, dtype=float)]
235
+ if len(t) != len(y):
236
+ raise ValueError("profile x_norm and y_norm differ in length")
237
+ return t, y
238
+
239
+
240
+ def _provenance(profile: Any, source: "str | Path | None", settings: Any) -> dict:
241
+ from .. import __version__
242
+
243
+ record = {
244
+ "gratinglab_version": __version__,
245
+ "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
246
+ "source": None if source is None else str(source),
247
+ "grooves_detected": int(profile.n_grooves),
248
+ "grooves_averaged": int(profile.n_used),
249
+ "grooves_edge_rejected": int(profile.n_edge_rejected),
250
+ }
251
+ if settings is not None:
252
+ record["settings"] = _plain(
253
+ dataclasses.asdict(settings) if dataclasses.is_dataclass(settings) else dict(settings)
254
+ )
255
+ return record
256
+
257
+
258
+ def _plain(value: Any) -> Any:
259
+ """JSON-safe: numpy scalars and arrays to Python, tuples to lists, NaN refused."""
260
+ if isinstance(value, dict):
261
+ return {str(k): _plain(v) for k, v in value.items()}
262
+ if isinstance(value, (list, tuple)):
263
+ return [_plain(v) for v in value]
264
+ if isinstance(value, np.ndarray):
265
+ return [_plain(v) for v in value.tolist()]
266
+ if isinstance(value, np.generic):
267
+ value = value.item()
268
+ if isinstance(value, float) and not math.isfinite(value):
269
+ return None
270
+ return value
@@ -30,16 +30,24 @@ def flatten_endpoints(x, y):
30
30
 
31
31
 
32
32
  def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
33
- min_half_width=10):
33
+ min_half_width=10, require_full_period=True):
34
34
  """
35
35
  Extract each groove symmetrically and average them onto a common axis.
36
36
 
37
37
  Parameters:
38
38
  min_half_width: skip grooves whose symmetric half-extent is at or below
39
- this many samples. A groove near a scan edge cannot be extracted
40
- symmetrically - the scan stops part-way through it - so including it
41
- would distort the average. This is the boundary-profile equivalent of
42
- EDGE_EXCLUSION_PERIODS in the blaze-angle path.
39
+ this many samples, a floor on how coarsely a groove may be sampled.
40
+ require_full_period: skip any groove whose full one-period window does
41
+ not fit inside the scan. The common axis below is the narrowest
42
+ window in the set, and normalize_profile stretches that window to a
43
+ full period, so a single groove the scan edge cuts into would
44
+ truncate every other groove to its window and then stretch the
45
+ result. On the synthetic sawtooth one partial groove narrowed the
46
+ window to 0.66 of a period, turned a 70 degree anti-blaze facet
47
+ into 58 and took 13% off the depth. EDGE_EXCLUSION_PERIODS upstream
48
+ usually keeps such grooves out, but it is a user setting, so this
49
+ check does not rely on it. False reproduces the retired rev3
50
+ script, which tests/metrology/test_ggp_equivalence.py pins.
43
51
 
44
52
  Returns:
45
53
  x_common, y_avg, y_std, n_used
@@ -50,6 +58,8 @@ def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
50
58
 
51
59
  for center in groove_centers:
52
60
  L = min(half_width, center, len(x) - 1 - center)
61
+ if require_full_period and L < half_width:
62
+ continue # the scan edge cuts into this groove's period
53
63
  if L > min_half_width:
54
64
  x_g, y_g = extract_single_groove(x, y, center, period_nm, margin,
55
65
  allow_asymmetric=False)
@@ -59,7 +69,9 @@ def average_grooves(x, y, groove_centers, period_nm, margin=0.0, n_points=2000,
59
69
  grooves.append((x_g, y_g))
60
70
 
61
71
  if len(grooves) == 0:
62
- raise ValueError("No grooves could be extracted.")
72
+ raise ValueError(
73
+ "No grooves could be extracted: none has a full period inside the "
74
+ "scan, or the sampling is coarser than min_half_width allows.")
63
75
 
64
76
  # Common symmetric extent = most restrictive of the set
65
77
  max_left = min(-g[0][0] for g in grooves)