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,128 @@
1
+ """
2
+ Intraclass correlation for row-group measurements
3
+
4
+ Row-group analysis produces many blaze-angle measurements per image, but they
5
+ re-measure the same physical grooves from different horizontal bands of one scan.
6
+ The rest of the statistics in this package divide by sqrt(N) over that full count,
7
+ which is only valid if the measurements are independent.
8
+
9
+ The intraclass correlation coefficient (ICC) is how that assumption gets tested:
10
+ the fraction of total variance that sits *between* row groups rather than within
11
+ them.
12
+
13
+ ICC = 0 groups are indistinguishable; measurements behave independently
14
+ and dividing by sqrt(N) is roughly right
15
+ ICC = 1 all variance is between groups; the measurements within a group
16
+ carry no new information, and the effective sample size is closer
17
+ to the number of groups than the number of measurements
18
+
19
+ This module only measures. The correction that this measurement sizes is applied
20
+ in analyzer.py, which uses effective_sample_size() below to compute a
21
+ row-group-corrected SEM.
22
+ """
23
+ import numpy as np
24
+
25
+ # Interpretation thresholds, carried over from an earlier prototype
26
+ # (correlation_source_analysis.py, see git history prior to 2026-08-09).
27
+ ICC_NEGLIGIBLE = 0.1
28
+ ICC_MODERATE = 0.2
29
+
30
+
31
+ def interpret_icc(icc):
32
+ """One-line reading of an ICC value"""
33
+ if icc < ICC_NEGLIGIBLE:
34
+ return "negligible - simple statistics defensible"
35
+ if icc < ICC_MODERATE:
36
+ return "moderate - hierarchical statistics recommended"
37
+ return "substantial - hierarchical statistics needed"
38
+
39
+
40
+ def compute_icc(values, group_labels):
41
+ """
42
+ One-way ICC of `values` grouped by `group_labels`.
43
+
44
+ Uses the same variance-ratio form as the original quick_icc_check:
45
+ between-group variance over total variance, where within-group variance is
46
+ the mean of the per-group variances.
47
+
48
+ Parameters:
49
+ values: measurements (e.g. blaze angles)
50
+ group_labels: which group each measurement belongs to, same length
51
+
52
+ Returns:
53
+ dict with icc, between_var, within_var, n_groups, n_measurements,
54
+ mean_group_size and interpretation. Returns icc = nan when there are
55
+ fewer than two groups, since between-group variance is undefined - a
56
+ caller must not read that as "no correlation".
57
+ """
58
+ values = np.asarray(values, dtype=float)
59
+ labels = np.asarray(group_labels)
60
+
61
+ if len(values) != len(labels):
62
+ raise ValueError(f"values and group_labels must be the same length, "
63
+ f"got {len(values)} and {len(labels)}")
64
+
65
+ unique = np.unique(labels)
66
+ n_groups = len(unique)
67
+
68
+ result = {
69
+ 'n_measurements': len(values),
70
+ 'n_groups': n_groups,
71
+ 'mean_group_size': len(values) / n_groups if n_groups else np.nan,
72
+ }
73
+
74
+ if n_groups < 2 or len(values) < 2:
75
+ result.update({'icc': np.nan, 'between_var': np.nan,
76
+ 'within_var': np.nan,
77
+ 'interpretation': 'undefined - need at least 2 groups'})
78
+ return result
79
+
80
+ group_means = np.array([values[labels == g].mean() for g in unique])
81
+ # Groups of size 1 contribute no within-group variance information
82
+ group_vars = np.array([values[labels == g].var(ddof=1)
83
+ if (labels == g).sum() > 1 else np.nan
84
+ for g in unique])
85
+
86
+ between_var = float(group_means.var(ddof=1))
87
+ within_var = float(np.nanmean(group_vars)) if not np.all(np.isnan(group_vars)) else 0.0
88
+
89
+ total = between_var + within_var
90
+ icc = float(between_var / total) if total > 0 else 0.0
91
+
92
+ result.update({
93
+ 'icc': icc,
94
+ 'between_var': between_var,
95
+ 'within_var': within_var,
96
+ 'interpretation': interpret_icc(icc),
97
+ })
98
+ return result
99
+
100
+
101
+ def effective_sample_size(n_measurements, mean_group_size, icc):
102
+ """
103
+ Sample size the measurements are actually worth, given their correlation.
104
+
105
+ n_eff = n / (1 + (m - 1) * ICC), the standard design-effect correction, where
106
+ m is the mean measurements per group. At ICC = 0 this returns n; as ICC rises
107
+ it falls toward the number of groups.
108
+
109
+ This is the number that shows how far the current SEMs are off: they are
110
+ computed with n, and dividing by sqrt(n) instead of sqrt(n_eff) understates
111
+ the standard error by sqrt(n / n_eff).
112
+ """
113
+ if not np.isfinite(icc):
114
+ return np.nan
115
+ design_effect = 1.0 + (mean_group_size - 1.0) * icc
116
+ return n_measurements / design_effect if design_effect > 0 else np.nan
117
+
118
+
119
+ def sem_inflation_factor(mean_group_size, icc):
120
+ """
121
+ How much the reported SEM should grow: sqrt(design effect).
122
+
123
+ A factor of 2 means the true standard error is twice what is currently
124
+ reported, and every confidence interval is half the width it should be.
125
+ """
126
+ if not np.isfinite(icc):
127
+ return np.nan
128
+ return float(np.sqrt(1.0 + (mean_group_size - 1.0) * icc))
@@ -0,0 +1,16 @@
1
+ """
2
+ Visualization and plotting functions
3
+ Diagnostic plots, statistics, and AFM profiles
4
+ """
5
+
6
+ from .diagnostics import plot_analyzed_regions_overlay, plot_flattening_diagnostic
7
+ from .statistics import plot_summary_statistics, plot_multi_file_comparison
8
+ from .profiles import plot_sample_profiles_by_temperature
9
+
10
+ __all__ = [
11
+ 'plot_analyzed_regions_overlay',
12
+ 'plot_flattening_diagnostic',
13
+ 'plot_summary_statistics',
14
+ 'plot_multi_file_comparison',
15
+ 'plot_sample_profiles_by_temperature'
16
+ ]
@@ -0,0 +1,111 @@
1
+ """
2
+ Diagnostic plotting functions
3
+ Shows analysis details like flattening and analyzed regions
4
+ """
5
+ import matplotlib.pyplot as plt
6
+ from scipy.signal import find_peaks
7
+ from matplotlib.lines import Line2D
8
+
9
+
10
+ def plot_analyzed_regions_overlay(raw_x, flat_y, groove_centers, period_nm, quality_list, facet_trim):
11
+ """
12
+ Create an overlay plot showing which portions of each groove are actually analyzed.
13
+
14
+ Parameters:
15
+ raw_x: x-axis positions (µm)
16
+ flat_y: flattened height profile (nm)
17
+ groove_centers: indices of detected groove centers
18
+ period_nm: groove period (nm)
19
+ quality_list: list of quality dictionaries with region info
20
+ facet_trim: fraction of facet trimmed from edges
21
+ """
22
+ fig, ax = plt.subplots(figsize=(14, 6))
23
+
24
+ ax.plot(raw_x, flat_y, 'k-', linewidth=1, alpha=0.4, label='Flattened profile', zorder=1)
25
+ ax.plot(raw_x[groove_centers], flat_y[groove_centers], 'ro',
26
+ markersize=6, label='Groove centers', zorder=3, alpha=0.7)
27
+
28
+ for i, qual in enumerate(quality_list):
29
+ if 'regions' in qual:
30
+ reg = qual['regions']
31
+ blaze_x_um = reg['blaze_x_trim'] / 1000
32
+ blaze_y = reg['blaze_y_trim']
33
+ ax.plot(blaze_x_um, blaze_y, 'b-', linewidth=3, alpha=0.8, zorder=2)
34
+
35
+ blaze_x_full_um = reg['blaze_x_full'] / 1000
36
+ blaze_y_full = reg['blaze_y_full']
37
+ ax.plot(blaze_x_full_um, blaze_y_full, 'b-', linewidth=1.5, alpha=0.3, zorder=1)
38
+
39
+ if len(blaze_x_um) > 0:
40
+ fit_line = reg['blaze_fit']
41
+ ax.plot(blaze_x_um, fit_line, 'cyan', linewidth=2,
42
+ linestyle='--', alpha=0.7, zorder=2)
43
+
44
+ custom_lines = [
45
+ Line2D([0], [0], color='k', linewidth=1, alpha=0.4),
46
+ Line2D([0], [0], color='b', linewidth=1.5, alpha=0.3),
47
+ Line2D([0], [0], color='b', linewidth=3, alpha=0.8),
48
+ Line2D([0], [0], color='cyan', linewidth=2, linestyle='--', alpha=0.7),
49
+ Line2D([0], [0], marker='o', color='w', markerfacecolor='r', markersize=6)
50
+ ]
51
+ ax.legend(custom_lines,
52
+ ['Full profile (flattened)', 'Full blaze facets',
53
+ 'Analyzed regions (trimmed)', 'Linear fits', 'Groove centers'],
54
+ loc='best', fontsize=10)
55
+
56
+ ax.set_xlabel('Position (µm)', fontsize=12)
57
+ ax.set_ylabel('Height (nm)', fontsize=12)
58
+ ax.set_title(f'Analyzed Regions Overlay - Trim fraction: {facet_trim:.0%} (2.5x extra trough trimming)', fontsize=13)
59
+ ax.grid(True, alpha=0.3)
60
+ plt.tight_layout()
61
+
62
+
63
+ def plot_flattening_diagnostic(raw_x, raw_y, flat_y, background, flatten_method,
64
+ flatten_feature, period_nm_est):
65
+ """
66
+ Show before/after flattening comparison
67
+
68
+ Parameters:
69
+ raw_x: x-axis positions (µm)
70
+ raw_y: raw height profile (nm)
71
+ flat_y: flattened height profile (nm)
72
+ background: fitted background curve (nm)
73
+ flatten_method: method used for flattening
74
+ flatten_feature: feature used (for level_grooves method)
75
+ period_nm_est: estimated period (nm)
76
+ """
77
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6))
78
+
79
+ ax1.plot(raw_x, raw_y, 'b-', linewidth=1, label='Raw profile', alpha=0.7)
80
+ ax1.plot(raw_x, background, 'r--', linewidth=2, label=f'Background ({flatten_method})')
81
+
82
+ if flatten_method == 'level_grooves':
83
+ if flatten_feature in ['peaks', 'both']:
84
+ dx_nm = (raw_x[1] - raw_x[0]) * 1000
85
+ min_distance = int(0.3 * period_nm_est / dx_nm)
86
+ peaks, _ = find_peaks(raw_y, distance=min_distance)
87
+ ax1.plot(raw_x[peaks], raw_y[peaks], 'go', markersize=8,
88
+ label=f'Peaks used ({len(peaks)})', zorder=5)
89
+ if flatten_feature in ['troughs', 'both']:
90
+ y_inv = -raw_y
91
+ dx_nm = (raw_x[1] - raw_x[0]) * 1000
92
+ min_distance = int(0.3 * period_nm_est / dx_nm)
93
+ troughs, _ = find_peaks(y_inv, distance=min_distance)
94
+ ax1.plot(raw_x[troughs], raw_y[troughs], 'mo', markersize=8,
95
+ label=f'Troughs used ({len(troughs)})', zorder=5)
96
+
97
+ ax1.set_xlabel('Displacement (µm)', fontsize=11)
98
+ ax1.set_ylabel('Height (nm)', fontsize=11)
99
+ ax1.set_title('Before Flattening: Raw Profile + Background Fit', fontsize=12)
100
+ ax1.legend(fontsize=10)
101
+ ax1.grid(True, alpha=0.3)
102
+
103
+ ax2.plot(raw_x, flat_y, 'g-', linewidth=1, label='Flattened profile')
104
+ ax2.axhline(y=0, color='k', linestyle='--', linewidth=0.5, alpha=0.5)
105
+ ax2.set_xlabel('Displacement (µm)', fontsize=11)
106
+ ax2.set_ylabel('Height (nm)', fontsize=11)
107
+ ax2.set_title('After Flattening: Background Removed', fontsize=12)
108
+ ax2.legend(fontsize=10)
109
+ ax2.grid(True, alpha=0.3)
110
+
111
+ plt.tight_layout()
@@ -0,0 +1,234 @@
1
+ """
2
+ AFM profile plotting functions
3
+ Complex multi-panel layouts organized by temperature
4
+ """
5
+ import matplotlib.pyplot as plt
6
+ import os
7
+
8
+
9
+ def plot_sample_profiles_by_temperature(grouped_results, grouped_labels, temp_order):
10
+ """
11
+ Plot AFM profiles organized by temperature (one figure per temperature)
12
+
13
+ Parameters:
14
+ grouped_results: dict mapping temperature key to result
15
+ grouped_labels: dict mapping temperature key to label
16
+ temp_order: list of temperature keys in order
17
+ """
18
+
19
+ for temp_key in temp_order:
20
+ result = grouped_results[temp_key]
21
+ label = grouped_labels[temp_key]
22
+
23
+ # Check if this is a combined result with multiple scans
24
+ if 'individual_scans' in result:
25
+ # Multiple scans at this temperature
26
+ _plot_multiple_scans(result, label)
27
+ else:
28
+ # Single scan at this temperature
29
+ _plot_single_scan(result, label)
30
+
31
+
32
+ def _plot_multiple_scans(result, label):
33
+ """
34
+ Plot multiple AFM scans on separate subplots (for same temperature)
35
+
36
+ Parameters:
37
+ result: combined result dictionary with 'individual_scans' key
38
+ label: sample label
39
+ """
40
+ scans = result['individual_scans']
41
+ n_scans = len(scans)
42
+
43
+ # Create figure with one subplot per scan
44
+ subplot_height = 3
45
+ total_height = min(subplot_height * n_scans, 11)
46
+ fig, axes = plt.subplots(n_scans, 1, figsize=(14, total_height))
47
+
48
+ if n_scans == 1:
49
+ axes = [axes]
50
+
51
+ # Overall figure title
52
+ fig.suptitle(f'{label} - Combined: {result["mean_angle"]:.2f}° ± {result["std_angle"]:.2f}° '
53
+ f'(N={result["n_grooves"]} grooves from {n_scans} scans)',
54
+ fontsize=12, fontweight='bold', y=0.995)
55
+
56
+ for i, scan in enumerate(scans):
57
+ ax = axes[i]
58
+
59
+ if 'raw_x' in scan and 'flat_y' in scan:
60
+ # Plot profile
61
+ ax.plot(scan['raw_x'], scan['flat_y'], 'k-', linewidth=0.8, alpha=0.5,
62
+ label='Flattened profile')
63
+
64
+ # Plot groove centers
65
+ if 'groove_centers' in scan:
66
+ ax.plot(scan['raw_x'][scan['groove_centers']],
67
+ scan['flat_y'][scan['groove_centers']],
68
+ 'ro', markersize=4, alpha=0.7, label='Groove centers')
69
+
70
+ # Plot analyzed regions and fits
71
+ if 'quality' in scan:
72
+ for j, qual in enumerate(scan['quality']):
73
+ if 'regions' in qual:
74
+ reg = qual['regions']
75
+ blaze_x_um = reg['blaze_x_trim'] / 1000
76
+ blaze_y = reg['blaze_y_trim']
77
+ fit_line = reg['blaze_fit']
78
+
79
+ if j == 0:
80
+ ax.plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8,
81
+ label='Analyzed regions', zorder=3)
82
+ ax.plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
83
+ linestyle='--', alpha=0.7, label='Linear fits', zorder=4)
84
+ else:
85
+ ax.plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8, zorder=3)
86
+ ax.plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
87
+ linestyle='--', alpha=0.7, zorder=4)
88
+
89
+ ax.set_ylabel('Height (nm)', fontsize=9)
90
+
91
+ # Scan-specific title
92
+ scan_title = f'Scan {i+1}: {os.path.basename(scan["filename"])}\n'
93
+ scan_title += f'Mean = {scan["mean_angle"]:.2f}° ± {scan["std_angle"]:.2f}° '
94
+ scan_title += f'(N={scan["n_grooves"]} grooves) | Spacing: {scan["period_nm"]:.2f} nm'
95
+ ax.set_title(scan_title, fontsize=9)
96
+ ax.legend(loc='best', fontsize=7)
97
+ ax.grid(True, alpha=0.3)
98
+
99
+ if i == n_scans - 1:
100
+ ax.set_xlabel('Position (µm)', fontsize=9)
101
+ else:
102
+ ax.text(0.5, 0.5, f'Scan {i+1}\nProfile data not available',
103
+ ha='center', va='center', transform=ax.transAxes)
104
+
105
+ plt.tight_layout(rect=[0, 0, 1, 0.99])
106
+
107
+
108
+ def _plot_single_scan(result, label):
109
+ """
110
+ Plot a single AFM scan
111
+
112
+ Parameters:
113
+ result: result dictionary
114
+ label: sample label
115
+ """
116
+ fig, ax = plt.subplots(1, 1, figsize=(14, 4))
117
+
118
+ if 'raw_x' in result and 'flat_y' in result:
119
+ # Plot profile
120
+ ax.plot(result['raw_x'], result['flat_y'], 'k-', linewidth=0.8, alpha=0.5,
121
+ label='Flattened profile')
122
+
123
+ # Plot groove centers
124
+ if 'groove_centers' in result:
125
+ ax.plot(result['raw_x'][result['groove_centers']],
126
+ result['flat_y'][result['groove_centers']],
127
+ 'ro', markersize=4, alpha=0.7, label='Groove centers')
128
+
129
+ # Plot analyzed regions and fits
130
+ if 'quality' in result:
131
+ for j, qual in enumerate(result['quality']):
132
+ if 'regions' in qual:
133
+ reg = qual['regions']
134
+ blaze_x_um = reg['blaze_x_trim'] / 1000
135
+ blaze_y = reg['blaze_y_trim']
136
+ fit_line = reg['blaze_fit']
137
+
138
+ if j == 0:
139
+ ax.plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8,
140
+ label='Analyzed regions', zorder=3)
141
+ ax.plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
142
+ linestyle='--', alpha=0.7, label='Linear fits', zorder=4)
143
+ else:
144
+ ax.plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8, zorder=3)
145
+ ax.plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
146
+ linestyle='--', alpha=0.7, zorder=4)
147
+
148
+ ax.set_ylabel('Height (nm)', fontsize=10)
149
+ ax.set_xlabel('Position (µm)', fontsize=10)
150
+
151
+ # Title
152
+ title = f'{label}: Mean = {result["mean_angle"]:.2f}° ± {result["std_angle"]:.2f}° '
153
+ title += f'(N={result["n_grooves"]} grooves) | Spacing: {result["period_nm"]:.2f} nm'
154
+ ax.set_title(title, fontsize=11, fontweight='bold')
155
+ ax.legend(loc='best', fontsize=9)
156
+ ax.grid(True, alpha=0.3)
157
+ else:
158
+ ax.text(0.5, 0.5, f'{label}\nProfile data not available',
159
+ ha='center', va='center', transform=ax.transAxes)
160
+
161
+ plt.tight_layout()
162
+
163
+
164
+ # Legacy function for backwards compatibility
165
+ def plot_sample_profiles_comparison(results, labels):
166
+ """
167
+ DEPRECATED: Use plot_sample_profiles_by_temperature instead
168
+
169
+ Plot AFM profiles for all samples in a single multi-panel figure
170
+ This is kept for backwards compatibility but not recommended for many samples
171
+ """
172
+ import warnings
173
+ warnings.warn("plot_sample_profiles_comparison is deprecated. "
174
+ "Use plot_sample_profiles_by_temperature instead.",
175
+ DeprecationWarning)
176
+
177
+ n_samples = len(results)
178
+
179
+ if n_samples <= 3:
180
+ subplot_height = 3
181
+ elif n_samples == 4:
182
+ subplot_height = 2.5
183
+ else:
184
+ subplot_height = 2.0
185
+
186
+ total_height = min(subplot_height * n_samples, 11)
187
+ fig, axes = plt.subplots(n_samples, 1, figsize=(14, total_height))
188
+
189
+ if n_samples == 1:
190
+ axes = [axes]
191
+
192
+ for i, (r, label) in enumerate(zip(results, labels)):
193
+ if 'raw_x' in r and 'flat_y' in r:
194
+ axes[i].plot(r['raw_x'], r['flat_y'], 'k-', linewidth=0.8, alpha=0.5,
195
+ label='Flattened profile')
196
+
197
+ if 'groove_centers' in r:
198
+ axes[i].plot(r['raw_x'][r['groove_centers']], r['flat_y'][r['groove_centers']],
199
+ 'ro', markersize=4, alpha=0.7, label='Groove centers')
200
+
201
+ if 'quality' in r:
202
+ for j, qual in enumerate(r['quality']):
203
+ if 'regions' in qual:
204
+ reg = qual['regions']
205
+ blaze_x_um = reg['blaze_x_trim'] / 1000
206
+ blaze_y = reg['blaze_y_trim']
207
+ fit_line = reg['blaze_fit']
208
+
209
+ if j == 0:
210
+ axes[i].plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8,
211
+ label='Analyzed regions', zorder=3)
212
+ axes[i].plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
213
+ linestyle='--', alpha=0.7, label='Linear fits', zorder=4)
214
+ else:
215
+ axes[i].plot(blaze_x_um, blaze_y, 'b-', linewidth=2.5, alpha=0.8, zorder=3)
216
+ axes[i].plot(blaze_x_um, fit_line, 'cyan', linewidth=1.5,
217
+ linestyle='--', alpha=0.7, zorder=4)
218
+
219
+ axes[i].set_ylabel('Height (nm)', fontsize=9)
220
+
221
+ # Include spacing in the title
222
+ spacing_str = f"Spacing: {r['period_nm']:.2f} nm"
223
+ axes[i].set_title(f'{label}: Mean = {r["mean_angle"]:.2f}° ± {r["std_angle"]:.2f}° (N={r["n_grooves"]}) | {spacing_str}',
224
+ fontsize=10)
225
+ axes[i].legend(loc='best', fontsize=7)
226
+ axes[i].grid(True, alpha=0.3)
227
+
228
+ if i == n_samples - 1:
229
+ axes[i].set_xlabel('Position (µm)', fontsize=9)
230
+ else:
231
+ axes[i].text(0.5, 0.5, f'{label}\nProfile data not available',
232
+ ha='center', va='center', transform=axes[i].transAxes)
233
+
234
+ plt.tight_layout()