gratinglab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. gratinglab/__init__.py +35 -0
  2. gratinglab/checks.py +263 -0
  3. gratinglab/compare.py +233 -0
  4. gratinglab/convergence.py +455 -0
  5. gratinglab/geometry.py +483 -0
  6. gratinglab/gui/__init__.py +0 -0
  7. gratinglab/gui/app.py +80 -0
  8. gratinglab/gui/diagram.py +699 -0
  9. gratinglab/gui/diagram3d.py +609 -0
  10. gratinglab/gui/docs.py +189 -0
  11. gratinglab/gui/icon.py +101 -0
  12. gratinglab/gui/integral_options.py +219 -0
  13. gratinglab/gui/mathtext.py +209 -0
  14. gratinglab/gui/orders.py +152 -0
  15. gratinglab/gui/provenance.py +273 -0
  16. gratinglab/gui/qt/__init__.py +20 -0
  17. gratinglab/gui/qt/geometry_panel.py +239 -0
  18. gratinglab/gui/qt/geometry_tab.py +387 -0
  19. gratinglab/gui/qt/integral_tab.py +447 -0
  20. gratinglab/gui/qt/main_window.py +514 -0
  21. gratinglab/gui/qt/profile_plot_panel.py +67 -0
  22. gratinglab/gui/qt/scalar_tab.py +498 -0
  23. gratinglab/gui/qt/scene3d_panel.py +347 -0
  24. gratinglab/gui/qt/setup_tab.py +215 -0
  25. gratinglab/gui/qt/sizing.py +25 -0
  26. gratinglab/gui/qt/theory_viewer.py +89 -0
  27. gratinglab/gui/qt/worker.py +203 -0
  28. gratinglab/gui/richtext.py +463 -0
  29. gratinglab/gui/scalar_options.py +139 -0
  30. gratinglab/gui/state.py +379 -0
  31. gratinglab/illumination.py +212 -0
  32. gratinglab/io/__init__.py +0 -0
  33. gratinglab/io/efficiency_table.py +382 -0
  34. gratinglab/io/ggp.py +248 -0
  35. gratinglab/materials/__init__.py +218 -0
  36. gratinglab/materials/data/SOURCES.md +53 -0
  37. gratinglab/materials/fresnel.py +282 -0
  38. gratinglab/materials/optical.py +319 -0
  39. gratinglab/materials/refractiveindex.py +306 -0
  40. gratinglab/metrology/__init__.py +121 -0
  41. gratinglab/metrology/analyzer.py +875 -0
  42. gratinglab/metrology/boundary/__init__.py +27 -0
  43. gratinglab/metrology/boundary/average.py +143 -0
  44. gratinglab/metrology/boundary/pipeline.py +211 -0
  45. gratinglab/metrology/cli.py +52 -0
  46. gratinglab/metrology/config.py +170 -0
  47. gratinglab/metrology/core/__init__.py +23 -0
  48. gratinglab/metrology/core/analysis.py +315 -0
  49. gratinglab/metrology/core/image_flatten.py +131 -0
  50. gratinglab/metrology/core/processing.py +344 -0
  51. gratinglab/metrology/core/tip.py +233 -0
  52. gratinglab/metrology/data/__init__.py +16 -0
  53. gratinglab/metrology/data/aggregation.py +193 -0
  54. gratinglab/metrology/gui/__init__.py +56 -0
  55. gratinglab/metrology/gui/icon.py +136 -0
  56. gratinglab/metrology/gui/qt/__init__.py +19 -0
  57. gratinglab/metrology/gui/qt/app.py +28 -0
  58. gratinglab/metrology/gui/qt/boundary_view.py +324 -0
  59. gratinglab/metrology/gui/qt/canvas.py +151 -0
  60. gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
  61. gratinglab/metrology/gui/qt/import_view.py +380 -0
  62. gratinglab/metrology/gui/qt/main_window.py +398 -0
  63. gratinglab/metrology/gui/qt/wiki_view.py +99 -0
  64. gratinglab/metrology/gui/qt/worker.py +59 -0
  65. gratinglab/metrology/gui/state.py +168 -0
  66. gratinglab/metrology/io/__init__.py +8 -0
  67. gratinglab/metrology/io/file_io.py +253 -0
  68. gratinglab/metrology/io/metrics.py +38 -0
  69. gratinglab/metrology/io/spm.py +210 -0
  70. gratinglab/metrology/settings.py +216 -0
  71. gratinglab/metrology/stats/__init__.py +16 -0
  72. gratinglab/metrology/stats/analysis.py +329 -0
  73. gratinglab/metrology/stats/icc.py +128 -0
  74. gratinglab/metrology/visualization/__init__.py +16 -0
  75. gratinglab/metrology/visualization/diagnostics.py +111 -0
  76. gratinglab/metrology/visualization/profiles.py +234 -0
  77. gratinglab/metrology/visualization/statistics.py +412 -0
  78. gratinglab/metrology/wiki/__init__.py +91 -0
  79. gratinglab/metrology/wiki/facet-fitting.md +95 -0
  80. gratinglab/metrology/wiki/file-formats.md +116 -0
  81. gratinglab/metrology/wiki/flattening.md +105 -0
  82. gratinglab/metrology/wiki/icc-correction.md +111 -0
  83. gratinglab/metrology/wiki/overview.md +51 -0
  84. gratinglab/metrology/wiki/reading-outputs.md +154 -0
  85. gratinglab/metrology/wiki/row-groups.md +75 -0
  86. gratinglab/metrology/wiki/tip-correction.md +101 -0
  87. gratinglab/metrology/workflows.py +383 -0
  88. gratinglab/problem.py +79 -0
  89. gratinglab/profiles.py +531 -0
  90. gratinglab/resolution.py +198 -0
  91. gratinglab/result.py +268 -0
  92. gratinglab/solvers/__init__.py +34 -0
  93. gratinglab/solvers/base.py +207 -0
  94. gratinglab/solvers/integral/__init__.py +406 -0
  95. gratinglab/solvers/integral/_boundary.py +50 -0
  96. gratinglab/solvers/integral/_core.py +126 -0
  97. gratinglab/solvers/integral/_finite.py +396 -0
  98. gratinglab/solvers/integral/_greens.py +234 -0
  99. gratinglab/solvers/integral/_kernels.py +308 -0
  100. gratinglab/solvers/integral/_nystrom.py +474 -0
  101. gratinglab/solvers/scalar.py +1144 -0
  102. gratinglab-0.1.0.dist-info/METADATA +319 -0
  103. gratinglab-0.1.0.dist-info/RECORD +107 -0
  104. gratinglab-0.1.0.dist-info/WHEEL +5 -0
  105. gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
  106. gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
  107. gratinglab-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,101 @@
1
+ # Tip correction
2
+
3
+ An AFM image is not the surface. It is the surface **dilated** by the tip: at
4
+ every pixel the tip descends until something touches, and the height recorded is
5
+ where the *apex* stopped — which is above the true surface everywhere the contact
6
+ happened on the tip's flank instead of its point.
7
+
8
+ The correction (Villarrubia 1997) inverts that where the inversion exists, and
9
+ says so where it does not. It is **off by default**, because it changes measured
10
+ numbers — groove depth especially — and a corrected depth and an uncorrected
11
+ depth are different measurements that must not be filed under one name.
12
+
13
+ ## The tip model
14
+
15
+ A cone with a spherical cap:
16
+
17
+ | Setting | Means |
18
+ |---|---|
19
+ | `TIP_RADIUS_NM` | The **apex radius**. A probe sold as "2 nm wide" is radius 1. |
20
+ | `TIP_HALF_ANGLE_DEG` | The cone half-angle, measured **from the tip axis**. 18° is typical. |
21
+ | `TIP_CORRECTION` | `'none'` (default) or `'erosion'`. |
22
+
23
+ The half-angle is the one people misread. Measured from the axis, an 18° tip has
24
+ a flank rising at **72°** from the surface — which clears a 70.5° anti-blaze
25
+ facet by 1.5°, and would not clear a 75° one at all. That near-parallelism, not
26
+ the nanometre apex, is what limits blazed-grating metrology.
27
+
28
+ Set them in `config.py`, or pass an `AnalysisSettings` with the fields set.
29
+ There is no control in the window yet.
30
+
31
+ ## What you get back, and what you do not
32
+
33
+ Erosion returns the **least upper bound** on the true surface consistent with the
34
+ image and the tip. That is two statements:
35
+
36
+ - Wherever the **apex** made contact, the bound *is* the surface. Recovery there
37
+ is exact — machine precision on clean data.
38
+ - Wherever it did not, the tip physically could not reach, and **no algorithm
39
+ recovers what is there**. The trough wedge between two facets is the standard
40
+ case: a sphere of radius *R* stands off a corner by a distance that grows with
41
+ *R*, and that depth is simply not in the data.
42
+
43
+ So the number to read is not the corrected profile alone but the **certain
44
+ fraction** that comes with it — reported in the metrics, in the `.txt` sidecar,
45
+ and in the log line. The uncertain remainder means "at most this high", never
46
+ "this high". A corrected profile without that fraction would manufacture exactly
47
+ the false confidence the correction exists to remove.
48
+
49
+ On the test fixture — a known sawtooth imaged through a known 20 nm tip — the
50
+ uncorrected surface is wrong by 9.06 nm on average; after erosion, over the
51
+ certain pixels, by 0.18 nm. The residual is the noise floor, not the tip.
52
+
53
+ ## What it found on a real scan
54
+
55
+ The reason this exists. A TASTE grating scan measures a blaze angle of 27.9°
56
+ from its facet fit and 20.3° from its depth — a 7.6° disagreement that on a
57
+ sharp groove is impossible, since depth and facet angle are locked together.
58
+ Tip rounding was the obvious suspect.
59
+
60
+ Running the correction **acquits the nominal tip**, in both directions:
61
+
62
+ | Apex radius | Depth/period after erosion | Implied blaze | Pixels certain |
63
+ |---|---|---|---|
64
+ | uncorrected | 0.3275 | 20.33° | — |
65
+ | 1 nm (nominal) | 0.3275 | 20.33° | 97.7% |
66
+ | 2 nm | 0.3275 | 20.33° | 95.9% |
67
+ | 5 nm | 0.3275 | 20.33° | 81.8% |
68
+ | 10 nm | 0.3274 | 20.32° | 74.8% |
69
+
70
+ Nothing moves. The measured surface is already reachable by a sharp tip, so
71
+ there is no tip-hidden depth to give back.
72
+
73
+ Forward, dilating an ideal 27.91°/70.5° groove and asking what a tip would
74
+ report:
75
+
76
+ | Apex radius | Image depth/period | Implied blaze |
77
+ |---|---|---|
78
+ | sharp truth | 0.4460 | 27.91° |
79
+ | 1 nm (nominal) | 0.4439 | 27.77° |
80
+ | 10 nm | 0.4316 | 27.00° |
81
+ | 40 nm | 0.3909 | 24.40° |
82
+ | **80 nm** | **0.3362** | **20.89°** |
83
+
84
+ A nominal tip costs 0.14° of the 7.6° gap. Reproducing the measurement takes an
85
+ apex worn to ~80 nm, forty to eighty times its specification.
86
+
87
+ **So the groove is probably rounded, or the tip was badly worn.** Which one is
88
+ not answerable from the scan: it needs the probe's condition at scan time, a
89
+ re-scan with a fresh tip, or a cross-section.
90
+
91
+ ## When to turn it on
92
+
93
+ - **A tip you have reason to distrust** — late in its life, or after imaging
94
+ something hard. Compare the corrected and uncorrected depth; if they differ,
95
+ the tip was hiding surface.
96
+ - **Sharp features near the tip's own scale.** Trenches, undercuts, anything
97
+ where the flank angle is close to the sidewall angle.
98
+
99
+ And when not to: as a matter of routine on well-behaved scans. If the erosion
100
+ returns the image unchanged, it has told you the tip was adequate — which is
101
+ worth knowing once, not on every run.
@@ -0,0 +1,383 @@
1
+ """
2
+ Analysis workflow orchestration
3
+ Handles the three main analysis modes: single, multiple, compare
4
+ """
5
+ import glob
6
+ import os
7
+ from datetime import datetime
8
+
9
+ from .config import (
10
+ SINGLE_FILE, FILE_PATTERN, SAMPLES_TO_COMPARE, DATA_DIR, RESULTS_DIR,
11
+ resolve_path, GGP_SOURCE_FILE,
12
+ )
13
+ from .analyzer import analyze_single_file
14
+ from .io.file_io import save_results_to_file
15
+ from .data.aggregation import group_by_temperature, extract_temperatures_for_output
16
+ from .stats.analysis import (
17
+ print_comparison_summary, print_pairwise_comparisons,
18
+ print_temperature_analysis
19
+ )
20
+ from .visualization.statistics import plot_multi_file_comparison
21
+ from .visualization.profiles import plot_sample_profiles_by_temperature
22
+
23
+
24
+ def run_single_file_analysis():
25
+ """
26
+ Analyze a single AFM file with full diagnostic plots
27
+ """
28
+ print("Mode: Single file analysis")
29
+ result = analyze_single_file(resolve_path(SINGLE_FILE), show_plots=True)
30
+
31
+ if result is not None:
32
+ save_results_to_file([result])
33
+
34
+ return result
35
+
36
+
37
+ def run_multiple_file_analysis():
38
+ """
39
+ Analyze multiple files matching a pattern
40
+ """
41
+ print("Mode: Multiple file analysis (pattern matching)")
42
+ # Patterns are matched inside the data directory, not the working directory
43
+ files = sorted(glob.glob(os.path.join(DATA_DIR, FILE_PATTERN)))
44
+
45
+ if len(files) == 0:
46
+ print(f"No files found in {DATA_DIR} matching pattern: {FILE_PATTERN}")
47
+ return []
48
+
49
+ print(f"Found {len(files)} files to analyze")
50
+
51
+ # Analyze all files
52
+ results = []
53
+ for f in files:
54
+ result = analyze_single_file(f, show_plots=False)
55
+ if result is not None:
56
+ results.append(result)
57
+
58
+ if len(results) == 0:
59
+ return []
60
+
61
+ # Generate comparison plots
62
+ plot_multi_file_comparison(results)
63
+
64
+ # Print summary table
65
+ _print_multiple_file_summary(results)
66
+
67
+ # Save results
68
+ save_results_to_file(results)
69
+
70
+ return results
71
+
72
+
73
+ def run_comparison_analysis():
74
+ """
75
+ Compare specific samples with optional temperature grouping
76
+ """
77
+ print("Mode: Compare specific samples")
78
+ print(f"Number of sample entries: {len(SAMPLES_TO_COMPARE)}")
79
+
80
+ # Parse sample information and analyze files
81
+ results, labels, temperatures = _analyze_comparison_samples()
82
+
83
+ if len(results) == 0:
84
+ print("No samples were successfully analyzed!")
85
+ return []
86
+
87
+ # Group by temperature and combine multiple scans
88
+ grouped_results, grouped_labels, temp_order = group_by_temperature(
89
+ results, labels, temperatures
90
+ )
91
+
92
+ # Convert to lists for compatibility
93
+ combined_results = [grouped_results[key] for key in temp_order]
94
+ combined_labels = [grouped_labels[key] for key in temp_order]
95
+ combined_temps = extract_temperatures_for_output(grouped_results, temp_order)
96
+
97
+ # Update labels in results
98
+ for result, label in zip(combined_results, combined_labels):
99
+ result['label'] = label
100
+
101
+ # Generate all plots and statistics
102
+ _generate_comparison_outputs(combined_results, combined_labels, combined_temps,
103
+ grouped_results, grouped_labels, temp_order)
104
+
105
+ return combined_results
106
+
107
+
108
+ def run_boundary_profile_export(settings=None, filename=None):
109
+ """
110
+ Average the grooves of one scan into a PCGrate .ggp boundary profile.
111
+
112
+ Shares the front-end with the blaze-angle workflows - loading, image
113
+ flattening, groove detection - then diverges: instead of fitting facets, the
114
+ grooves are averaged, normalised to one period and exported for
115
+ grating-efficiency modelling.
116
+
117
+ Parameters:
118
+ settings: AnalysisSettings; defaults to config.py, so the CLI behaves as
119
+ it always has. The GUI passes its own.
120
+ filename: overrides GGP_SOURCE_FILE, for the GUI which already has a file
121
+ open.
122
+
123
+ The computation lives in boundary.build_boundary_profile so the GUI can
124
+ preview exactly what this writes.
125
+ """
126
+ from .core.processing import load_afm_data
127
+ from .core.image_flatten import flatten_image
128
+ from .boundary import build_boundary_profile
129
+ from gratinglab.io.ggp import write_ggp
130
+
131
+ from .io.metrics import write_profile_metrics
132
+ from .settings import AnalysisSettings
133
+
134
+ if settings is None:
135
+ settings = AnalysisSettings.from_config()
136
+ filename = resolve_path(filename or GGP_SOURCE_FILE)
137
+
138
+ print("Mode: PCGrate boundary profile export")
139
+ print(f"Source: {filename}")
140
+
141
+ data, scan_x_size = load_afm_data(filename,
142
+ default_scan_size=settings.scan_x_size,
143
+ settings=settings)
144
+
145
+ # Applied here too, so both outputs treat a scan the same way. Provably a
146
+ # no-op for the affine methods - the endpoint flattening downstream removes
147
+ # constant and linear terms again - and verified identical at the written
148
+ # precision, so the stored .ggp fixture still matches.
149
+ if settings.image_flatten_method != 'none':
150
+ data = flatten_image(data, settings.image_flatten_method)
151
+
152
+ try:
153
+ profile = build_boundary_profile(data, scan_x_size, settings)
154
+ except ValueError as exc:
155
+ print(f"{exc}")
156
+ return None
157
+
158
+ if profile.metrics.get('tip_correction', 'none') != 'none':
159
+ print(f"Tip correction: erosion "
160
+ f"(R = {profile.metrics['tip_radius_nm']:g} nm, "
161
+ f"half angle = {profile.metrics['tip_half_angle_deg']:g} deg), "
162
+ f"{100.0 * profile.metrics['tip_certain_fraction']:.1f}% of "
163
+ f"pixels certain; the rest are upper bounds")
164
+
165
+ edge_note = (f" ({profile.n_edge_rejected} rejected: clipped by scan edge)"
166
+ if profile.n_edge_rejected else "")
167
+ print(f"Found {profile.n_grooves} grooves{edge_note}")
168
+ print(f"Measured period: {profile.period_nm:.2f} nm")
169
+ print(f"Averaged {profile.n_used} of {profile.n_grooves} grooves")
170
+
171
+ stem = os.path.splitext(os.path.basename(filename))[0]
172
+ ggp_path = os.path.join(RESULTS_DIR, f'averaged_groove_profile_{stem}.ggp')
173
+ met_path = os.path.join(RESULTS_DIR, f'groove_analysis_metrics_{stem}.txt')
174
+ write_ggp(ggp_path, t=profile.x_norm, y=profile.y_norm)
175
+ write_profile_metrics(met_path, profile.metrics)
176
+
177
+ print(f"\n Groove depth: {profile.metrics['groove_depth']:.4f} of period")
178
+ print(f" Max sidewall angle: {profile.metrics['max_angle_deg']:.2f}deg")
179
+ print(f"\n✓ Boundary profile saved to: {ggp_path}")
180
+ print(f"✓ Profile metrics saved to: {met_path}")
181
+
182
+ return {'x': profile.x_norm, 'y': profile.y_norm,
183
+ 'metrics': profile.metrics, 'ggp_path': ggp_path,
184
+ 'profile': profile}
185
+
186
+
187
+ def run_icc_report():
188
+ """
189
+ Measure how correlated the row-group measurements actually are.
190
+
191
+ Diagnostic only - reports numbers, changes nothing. Row-group analysis
192
+ re-measures the same grooves in each band of a scan, so the measurements are
193
+ not independent, but the reported SEMs and p-values assume they are. The ICC
194
+ quantifies that, per scan, and the design effect turns it into the factor by
195
+ which the current SEMs are understated.
196
+ """
197
+ from .stats.icc import (compute_icc, effective_sample_size,
198
+ sem_inflation_factor)
199
+
200
+ print("Mode: ICC diagnostic (row-group correlation)")
201
+ print(f"Scans to check: {len(SAMPLES_TO_COMPARE)}\n")
202
+
203
+ rows = []
204
+ for sample_info in SAMPLES_TO_COMPARE:
205
+ filename, label = sample_info[0], sample_info[1]
206
+ result = analyze_single_file(resolve_path(filename), show_plots=False)
207
+ if result is None:
208
+ print(f" {label}: analysis produced no measurements, skipping")
209
+ continue
210
+
211
+ groups = result.get('groove_row_groups')
212
+ if not groups:
213
+ print(f" {label}: no row-group labels "
214
+ f"(USE_ROW_GROUPS is off?), skipping")
215
+ continue
216
+
217
+ stats = compute_icc(result['all_angles'], groups)
218
+ stats['n_eff'] = effective_sample_size(
219
+ stats['n_measurements'], stats['mean_group_size'], stats['icc'])
220
+ stats['sem_factor'] = sem_inflation_factor(
221
+ stats['mean_group_size'], stats['icc'])
222
+ stats['label'] = label
223
+ stats['file'] = os.path.basename(filename)
224
+ stats['sem_reported'] = result.get('sem', float('nan'))
225
+ rows.append(stats)
226
+
227
+ if not rows:
228
+ print("No scans could be assessed.")
229
+ return []
230
+
231
+ report = _format_icc_report(rows)
232
+ print(report)
233
+
234
+ os.makedirs(RESULTS_DIR, exist_ok=True)
235
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
236
+ path = os.path.join(RESULTS_DIR, f'icc_report_{timestamp}.txt')
237
+ with open(path, 'w', encoding="utf-8") as f:
238
+ f.write(report)
239
+ print(f"\n✓ ICC report saved to: {path}")
240
+
241
+ return rows
242
+
243
+
244
+ def _format_icc_report(rows):
245
+ """Render the ICC table and its interpretation"""
246
+ from .stats.icc import interpret_icc
247
+
248
+ lines = []
249
+ lines.append("=" * 100)
250
+ lines.append("ROW-GROUP CORRELATION (ICC) DIAGNOSTIC")
251
+ lines.append("=" * 100)
252
+ lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
253
+ lines.append("")
254
+ lines.append("Row-group analysis measures the same physical grooves once per")
255
+ lines.append("horizontal band, so those measurements are not independent. The")
256
+ lines.append("reported SEM divides by sqrt(N) over all of them, which is only")
257
+ lines.append("valid at ICC = 0.")
258
+ lines.append("")
259
+ lines.append(f"{'Sample':<9}{'Scan file':<34}{'N':>5}{'grp':>5}{'ICC':>8}"
260
+ f"{'N_eff':>8}{'SEM x':>7} interpretation")
261
+ lines.append("-" * 100)
262
+
263
+ for r in rows:
264
+ lines.append(
265
+ f"{r['label']:<9}{r['file']:<34}{r['n_measurements']:>5}"
266
+ f"{r['n_groups']:>5}{r['icc']:>8.3f}{r['n_eff']:>8.1f}"
267
+ f"{r['sem_factor']:>7.2f} {r['interpretation']}")
268
+
269
+ lines.append("-" * 100)
270
+
271
+ iccs = [r['icc'] for r in rows]
272
+ factors = [r['sem_factor'] for r in rows]
273
+ lines.append("")
274
+ lines.append(f"ICC range : {min(iccs):.3f} to {max(iccs):.3f} "
275
+ f"(median {sorted(iccs)[len(iccs)//2]:.3f})")
276
+ lines.append(f"SEM inflation : {min(factors):.2f}x to {max(factors):.2f}x")
277
+ lines.append("")
278
+ lines.append("Reading:")
279
+ lines.append(" ICC fraction of variance sitting between row groups rather")
280
+ lines.append(" than within them. 0 = independent, 1 = each group adds")
281
+ lines.append(" only one measurement's worth of information.")
282
+ lines.append(" N_eff measurements the data is actually worth, n / design effect.")
283
+ lines.append(" SEM x factor the reported standard error should be multiplied")
284
+ lines.append(" by. Confidence intervals scale the same way.")
285
+ lines.append("")
286
+
287
+ worst = max(rows, key=lambda r: r['icc'])
288
+ lines.append(f"Overall: {interpret_icc(max(iccs))} "
289
+ f"(driven by {worst['label']} / {worst['file']}).")
290
+ lines.append("")
291
+ lines.append("This report changes nothing. It sizes the correction that")
292
+ lines.append("row-group analysis applies via stats/icc.py.")
293
+ lines.append("=" * 100)
294
+ return "\n".join(lines)
295
+
296
+
297
+ def _analyze_comparison_samples():
298
+ """
299
+ Parse SAMPLES_TO_COMPARE and analyze each file
300
+
301
+ Returns:
302
+ results: list of analysis results
303
+ labels: list of sample labels
304
+ temperatures: list of temperatures (None for master)
305
+ """
306
+ results = []
307
+ labels = []
308
+ temperatures = []
309
+
310
+ for sample_info in SAMPLES_TO_COMPARE:
311
+ # Handle both (filename, label) and (filename, label, temperature) formats
312
+ if len(sample_info) == 2:
313
+ filename, label = sample_info
314
+ temperature = None
315
+ elif len(sample_info) == 3:
316
+ filename, label, temperature = sample_info
317
+ else:
318
+ print(f"Error: Invalid sample format: {sample_info}")
319
+ continue
320
+
321
+ print(f"\nProcessing: {label} ({filename})")
322
+ result = analyze_single_file(resolve_path(filename), show_plots=False)
323
+
324
+ if result is not None:
325
+ results.append(result)
326
+ labels.append(label)
327
+ temperatures.append(temperature)
328
+ # Store metadata in result
329
+ result['label'] = label
330
+ result['temperature'] = temperature
331
+
332
+ return results, labels, temperatures
333
+
334
+
335
+ def _print_multiple_file_summary(results):
336
+ """Print summary table for multiple file analysis"""
337
+ print(f"\n{'='*80}")
338
+ print("SUMMARY TABLE")
339
+ print(f"{'='*80}")
340
+ print(f"{'File':<40} {'N':<5} {'Mean':<10} {'Std':<10} {'Period(nm)':<12}")
341
+ print(f"{'-'*80}")
342
+
343
+ for r in results:
344
+ print(f"{os.path.basename(r['filename']):<40} "
345
+ f"{r['n_grooves']:<5} "
346
+ f"{r['mean_angle']:<10.2f} "
347
+ f"{r['std_angle']:<10.2f} "
348
+ f"{r['period_nm']:<12.2f}")
349
+
350
+
351
+ def _generate_comparison_outputs(combined_results, combined_labels, combined_temps,
352
+ grouped_results, grouped_labels, temp_order):
353
+ """
354
+ Generate all comparison plots and statistics
355
+
356
+ Parameters:
357
+ combined_results: list of combined results
358
+ combined_labels: list of labels
359
+ combined_temps: list of temperatures
360
+ grouped_results: dict of results by temperature key
361
+ grouped_labels: dict of labels by temperature key
362
+ temp_order: ordered list of temperature keys
363
+ """
364
+ # Comparison plots with custom labels
365
+ plot_multi_file_comparison(combined_results, labels=combined_labels,
366
+ temperatures=combined_temps)
367
+
368
+ # AFM profiles organized by temperature
369
+ plot_sample_profiles_by_temperature(grouped_results, grouped_labels, temp_order)
370
+
371
+ # Print comparison summary table
372
+ print_comparison_summary(combined_results, combined_labels)
373
+
374
+ # Statistical comparison
375
+ print_pairwise_comparisons(combined_results)
376
+
377
+ # Temperature analysis if applicable
378
+ if any(t is not None for t in combined_temps):
379
+ print_temperature_analysis(combined_results, combined_labels, combined_temps)
380
+
381
+ # Save all results to files
382
+ save_results_to_file(combined_results, labels=combined_labels,
383
+ temperatures=combined_temps)
gratinglab/problem.py ADDED
@@ -0,0 +1,79 @@
1
+ """The physical problem: what the grating *is*, independent of how it is solved.
2
+
3
+ ``Problem`` carries **no solver-specific fields, ever**. That single constraint
4
+ is what makes the comparison harness possible -- every method receives the same
5
+ object, so a disagreement between methods is a disagreement about physics rather
6
+ than about how two codes were configured.
7
+
8
+ It is also serializable, so a benchmark case is a file rather than a script.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Union
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from .profiles import Blazed, FromProfileData, Lamellar, Sinusoidal
18
+
19
+ __all__ = ["Problem", "AnyProfile"]
20
+
21
+ #: Profiles that can round-trip through JSON. A ``Profile`` structurally typed
22
+ #: duck can be passed to a solver directly, but only these serialise.
23
+ AnyProfile = Union[Blazed, Lamellar, Sinusoidal, FromProfileData]
24
+
25
+
26
+ class Problem(BaseModel):
27
+ """A grating, in physical terms.
28
+
29
+ >>> from gratinglab.profiles import Blazed
30
+ >>> p = Problem(period=160.0, profile=Blazed(blaze_angle=30.0))
31
+ >>> round(p.depth, 2)
32
+ 92.38
33
+
34
+ Attributes
35
+ ----------
36
+ period
37
+ Groove spacing in nm (``docs/conventions.md`` §1).
38
+ profile
39
+ Groove shape, normalised to the period. Multiply by ``period`` for
40
+ physical height, or use :attr:`depth`.
41
+ coating, substrate
42
+ Material identifiers. Optional for now -- scalar theory needs no optical
43
+ constants, and the perfect-conductivity reference data it is first
44
+ compared against needs none either. The materials layer lands next.
45
+ roughness
46
+ RMS surface roughness in nm, for the Névot–Croce / Debye–Waller factors.
47
+ n_grooves
48
+ Illuminated groove count, for the finite-N interference factor of
49
+ ISSI eq. (8). ``None`` means the infinite-grating limit, where orders
50
+ become delta functions.
51
+ """
52
+
53
+ model_config = ConfigDict(frozen=True, extra="forbid")
54
+
55
+ period: float = Field(gt=0.0, description="Groove spacing, nm")
56
+ profile: AnyProfile = Field(discriminator=None)
57
+ coating: str | None = None
58
+ substrate: str | None = None
59
+ roughness: float = Field(default=0.0, ge=0.0, description="RMS roughness, nm")
60
+ n_grooves: int | None = Field(default=None, gt=0)
61
+
62
+ @property
63
+ def depth(self) -> float:
64
+ """Peak-to-valley groove depth in nm."""
65
+ return self.profile.depth * self.period
66
+
67
+ def height_nm(self, t) -> "object":
68
+ """Profile height in nm at normalised position ``t``.
69
+
70
+ The one place the normalised profile is converted to physical units.
71
+ Solvers should call this rather than multiplying by hand.
72
+ """
73
+ return self.profile.height(t) * self.period
74
+
75
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
76
+ return (
77
+ f"Problem(period={self.period:g}nm, "
78
+ f"{type(self.profile).__name__}, depth={self.depth:.3g}nm)"
79
+ )