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.
- gratinglab/__init__.py +35 -0
- gratinglab/checks.py +263 -0
- gratinglab/compare.py +233 -0
- gratinglab/convergence.py +455 -0
- gratinglab/geometry.py +483 -0
- gratinglab/gui/__init__.py +0 -0
- gratinglab/gui/app.py +80 -0
- gratinglab/gui/diagram.py +699 -0
- gratinglab/gui/diagram3d.py +609 -0
- gratinglab/gui/docs.py +189 -0
- gratinglab/gui/icon.py +101 -0
- gratinglab/gui/integral_options.py +219 -0
- gratinglab/gui/mathtext.py +209 -0
- gratinglab/gui/orders.py +152 -0
- gratinglab/gui/provenance.py +273 -0
- gratinglab/gui/qt/__init__.py +20 -0
- gratinglab/gui/qt/geometry_panel.py +239 -0
- gratinglab/gui/qt/geometry_tab.py +387 -0
- gratinglab/gui/qt/integral_tab.py +447 -0
- gratinglab/gui/qt/main_window.py +514 -0
- gratinglab/gui/qt/profile_plot_panel.py +67 -0
- gratinglab/gui/qt/scalar_tab.py +498 -0
- gratinglab/gui/qt/scene3d_panel.py +347 -0
- gratinglab/gui/qt/setup_tab.py +215 -0
- gratinglab/gui/qt/sizing.py +25 -0
- gratinglab/gui/qt/theory_viewer.py +89 -0
- gratinglab/gui/qt/worker.py +203 -0
- gratinglab/gui/richtext.py +463 -0
- gratinglab/gui/scalar_options.py +139 -0
- gratinglab/gui/state.py +379 -0
- gratinglab/illumination.py +212 -0
- gratinglab/io/__init__.py +0 -0
- gratinglab/io/efficiency_table.py +382 -0
- gratinglab/io/ggp.py +248 -0
- gratinglab/materials/__init__.py +218 -0
- gratinglab/materials/data/SOURCES.md +53 -0
- gratinglab/materials/fresnel.py +282 -0
- gratinglab/materials/optical.py +319 -0
- gratinglab/materials/refractiveindex.py +306 -0
- gratinglab/metrology/__init__.py +121 -0
- gratinglab/metrology/analyzer.py +875 -0
- gratinglab/metrology/boundary/__init__.py +27 -0
- gratinglab/metrology/boundary/average.py +143 -0
- gratinglab/metrology/boundary/pipeline.py +211 -0
- gratinglab/metrology/cli.py +52 -0
- gratinglab/metrology/config.py +170 -0
- gratinglab/metrology/core/__init__.py +23 -0
- gratinglab/metrology/core/analysis.py +315 -0
- gratinglab/metrology/core/image_flatten.py +131 -0
- gratinglab/metrology/core/processing.py +344 -0
- gratinglab/metrology/core/tip.py +233 -0
- gratinglab/metrology/data/__init__.py +16 -0
- gratinglab/metrology/data/aggregation.py +193 -0
- gratinglab/metrology/gui/__init__.py +56 -0
- gratinglab/metrology/gui/icon.py +136 -0
- gratinglab/metrology/gui/qt/__init__.py +19 -0
- gratinglab/metrology/gui/qt/app.py +28 -0
- gratinglab/metrology/gui/qt/boundary_view.py +324 -0
- gratinglab/metrology/gui/qt/canvas.py +151 -0
- gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
- gratinglab/metrology/gui/qt/import_view.py +380 -0
- gratinglab/metrology/gui/qt/main_window.py +398 -0
- gratinglab/metrology/gui/qt/wiki_view.py +99 -0
- gratinglab/metrology/gui/qt/worker.py +59 -0
- gratinglab/metrology/gui/state.py +168 -0
- gratinglab/metrology/io/__init__.py +8 -0
- gratinglab/metrology/io/file_io.py +253 -0
- gratinglab/metrology/io/metrics.py +38 -0
- gratinglab/metrology/io/spm.py +210 -0
- gratinglab/metrology/settings.py +216 -0
- gratinglab/metrology/stats/__init__.py +16 -0
- gratinglab/metrology/stats/analysis.py +329 -0
- gratinglab/metrology/stats/icc.py +128 -0
- gratinglab/metrology/visualization/__init__.py +16 -0
- gratinglab/metrology/visualization/diagnostics.py +111 -0
- gratinglab/metrology/visualization/profiles.py +234 -0
- gratinglab/metrology/visualization/statistics.py +412 -0
- gratinglab/metrology/wiki/__init__.py +91 -0
- gratinglab/metrology/wiki/facet-fitting.md +95 -0
- gratinglab/metrology/wiki/file-formats.md +116 -0
- gratinglab/metrology/wiki/flattening.md +105 -0
- gratinglab/metrology/wiki/icc-correction.md +111 -0
- gratinglab/metrology/wiki/overview.md +51 -0
- gratinglab/metrology/wiki/reading-outputs.md +154 -0
- gratinglab/metrology/wiki/row-groups.md +75 -0
- gratinglab/metrology/wiki/tip-correction.md +101 -0
- gratinglab/metrology/workflows.py +383 -0
- gratinglab/problem.py +79 -0
- gratinglab/profiles.py +531 -0
- gratinglab/resolution.py +198 -0
- gratinglab/result.py +268 -0
- gratinglab/solvers/__init__.py +34 -0
- gratinglab/solvers/base.py +207 -0
- gratinglab/solvers/integral/__init__.py +406 -0
- gratinglab/solvers/integral/_boundary.py +50 -0
- gratinglab/solvers/integral/_core.py +126 -0
- gratinglab/solvers/integral/_finite.py +396 -0
- gratinglab/solvers/integral/_greens.py +234 -0
- gratinglab/solvers/integral/_kernels.py +308 -0
- gratinglab/solvers/integral/_nystrom.py +474 -0
- gratinglab/solvers/scalar.py +1144 -0
- gratinglab-0.1.0.dist-info/METADATA +319 -0
- gratinglab-0.1.0.dist-info/RECORD +107 -0
- gratinglab-0.1.0.dist-info/WHEEL +5 -0
- gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
- gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
- 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
|
+
)
|