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,216 @@
1
+ """
2
+ Analysis settings as a value, rather than as module state.
3
+
4
+ `config.py` remains the file a user edits, and is still the default source of
5
+ every value here. What this module adds is the ability to *pass* a different
6
+ configuration without mutating anything.
7
+
8
+ Before this existed, the only way to run an analysis with different parameters
9
+ was to rebind names inside `analyzer` itself:
10
+
11
+ analyzer.FACET_TRIM = 0.2
12
+
13
+ which worked because `analyzer` binds its configuration at import time. The GUI
14
+ did exactly that. It is also why a CLI, a test, or two analyses with different
15
+ settings in one process were all impossible. Settings now travel as an argument.
16
+ """
17
+ from dataclasses import dataclass, replace
18
+
19
+ from .core.image_flatten import VALID_IMAGE_FLATTEN_METHODS
20
+ from .core.tip import VALID_TIP_CORRECTIONS
21
+
22
+ # The blaze facet is trimmed 2.5x harder on the trough side than the land side
23
+ # (see core/analysis.py trim_facet), so the total removed is FACET_TRIM * 3.5.
24
+ # At 1/3.5 = 0.2857 nothing is left, every fit fails, and the analysis returns no
25
+ # measurements rather than degrading. Kept here so the GUI, any validator and the
26
+ # documentation all cite one number.
27
+ MAX_FACET_TRIM = 0.28
28
+
29
+ VALID_BLAZE_SIDES = ('negative_slope', 'positive_slope', 'longer')
30
+
31
+ #: Scan directions a Nanoscope file records. Retrace is the default because it is
32
+ #: the plane this project's existing Gwyddion exports were taken from, so a .spm
33
+ #: and its .txt export agree by construction rather than by luck.
34
+ VALID_SPM_DIRECTIONS = ('Retrace', 'Trace')
35
+ VALID_FLATTEN_METHODS = ('linear', 'polynomial', 'groove_peaks', 'level_grooves')
36
+ VALID_FLATTEN_FEATURES = ('peaks', 'troughs', 'both')
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AnalysisSettings:
41
+ """Every knob the blaze-angle analysis reads. Immutable by design."""
42
+
43
+ # Scan
44
+ scan_x_size: float = 2.0
45
+ period_est: float = 315.0
46
+
47
+ # Raw Nanoscope (.spm) input. Ignored for text exports, which carry one
48
+ # channel and no direction.
49
+ spm_channel: str = 'Height Sensor'
50
+ spm_direction: str = 'Retrace'
51
+
52
+ # Groove detection
53
+ prominence_factor: float = 0.01
54
+ distance_factor: float = 0.3
55
+ edge_exclusion_periods: float = 0.6
56
+
57
+ # Facet fitting
58
+ facet_trim: float = 0.1
59
+ blaze_side: str = 'negative_slope'
60
+
61
+ # Image flattening: the 2-D stage, before rows are averaged. Affine methods
62
+ # provably cannot change a blaze angle here (see core/image_flatten.py), so
63
+ # the default costs nothing - measured 0.0000 deg on all eight samples.
64
+ image_flatten_method: str = 'align_rows'
65
+
66
+ # Tip correction: undoing what the tip shape did to the image, where that
67
+ # is possible, on the 2-D array right after image flattening. Off by
68
+ # default because it changes measured numbers (depth especially) and must
69
+ # be asked for. The tip is a cone with a spherical apex cap; the radius is
70
+ # the *apex radius* (a "2 nm wide" tip is radius 1), and the half angle is
71
+ # measured from the tip axis. See core/tip.py for what can and cannot be
72
+ # recovered.
73
+ tip_correction: str = 'none'
74
+ tip_radius_nm: float = 1.0
75
+ tip_half_angle_deg: float = 18.0
76
+
77
+ # Profile flattening: the 1-D stage, after each row group is averaged. This
78
+ # one does move the answer - about 0.49 deg across the four methods.
79
+ flatten_method: str = 'level_grooves'
80
+ flatten_poly_order: int = 2
81
+ flatten_exclude_edges: float = 0.05
82
+ flatten_feature: str = 'peaks'
83
+
84
+ # PCGrate boundary export. Only read by the boundary path; the blaze-angle
85
+ # analysis ignores them.
86
+ ggp_n_points: int = 2000
87
+ ggp_apply_smoothing: bool = True
88
+ ggp_smoothing_window: int = 5
89
+ ggp_min_half_width: int = 10
90
+
91
+ # Row groups
92
+ use_row_groups: bool = True
93
+ n_row_groups: int = 20
94
+
95
+ # Diagnostic plots
96
+ show_2d_image: bool = False
97
+ show_individual_grooves: bool = False
98
+ show_full_profile: bool = True
99
+ show_flattening_diagnostic: bool = False
100
+ show_local_angle_distribution: bool = True
101
+ show_analyzed_regions: bool = False
102
+
103
+ @classmethod
104
+ def from_config(cls):
105
+ """Build from the values currently in config.py.
106
+
107
+ Read at call time rather than import time, so editing config.py and
108
+ re-running in the same session behaves as a user would expect.
109
+ """
110
+ from . import config
111
+ return cls(
112
+ scan_x_size=config.SCAN_X_SIZE,
113
+ period_est=config.PERIOD_EST,
114
+ prominence_factor=config.PROMINENCE_FACTOR,
115
+ distance_factor=config.DISTANCE_FACTOR,
116
+ edge_exclusion_periods=config.EDGE_EXCLUSION_PERIODS,
117
+ facet_trim=config.FACET_TRIM,
118
+ blaze_side=config.BLAZE_SIDE,
119
+ image_flatten_method=getattr(config, 'IMAGE_FLATTEN_METHOD',
120
+ 'align_rows'),
121
+ tip_correction=getattr(config, 'TIP_CORRECTION', 'none'),
122
+ tip_radius_nm=getattr(config, 'TIP_RADIUS_NM', 1.0),
123
+ tip_half_angle_deg=getattr(config, 'TIP_HALF_ANGLE_DEG', 18.0),
124
+ flatten_method=config.FLATTEN_METHOD,
125
+ flatten_poly_order=config.FLATTEN_POLY_ORDER,
126
+ flatten_exclude_edges=config.FLATTEN_EXCLUDE_EDGES,
127
+ flatten_feature=config.FLATTEN_FEATURE,
128
+ spm_channel=getattr(config, 'SPM_CHANNEL', 'Height Sensor'),
129
+ spm_direction=getattr(config, 'SPM_DIRECTION', 'Retrace'),
130
+ ggp_n_points=getattr(config, 'GGP_N_POINTS', 2000),
131
+ ggp_apply_smoothing=getattr(config, 'GGP_APPLY_SMOOTHING', True),
132
+ ggp_smoothing_window=getattr(config, 'GGP_SMOOTHING_WINDOW', 5),
133
+ ggp_min_half_width=getattr(config, 'GGP_MIN_HALF_WIDTH', 10),
134
+ use_row_groups=config.USE_ROW_GROUPS,
135
+ n_row_groups=config.N_ROW_GROUPS,
136
+ show_2d_image=config.SHOW_2D_IMAGE,
137
+ show_individual_grooves=config.SHOW_INDIVIDUAL_GROOVES,
138
+ show_full_profile=config.SHOW_FULL_PROFILE,
139
+ show_flattening_diagnostic=config.SHOW_FLATTENING_DIAGNOSTIC,
140
+ show_local_angle_distribution=config.SHOW_LOCAL_ANGLE_DISTRIBUTION,
141
+ show_analyzed_regions=config.SHOW_ANALYZED_REGIONS,
142
+ )
143
+
144
+ def with_(self, **changes):
145
+ """A copy with some fields replaced - the frozen equivalent of setattr"""
146
+ return replace(self, **changes)
147
+
148
+ def validate(self):
149
+ """
150
+ Problems that would make an analysis fail or mislead.
151
+
152
+ Returns a tuple of (field_name, message). Empty means usable. Reported
153
+ rather than raised so a GUI can mark individual fields; callers wanting
154
+ an exception can raise on a non-empty result.
155
+ """
156
+ errors = []
157
+
158
+ if self.period_est <= 0:
159
+ errors.append(('period_est', "must be greater than 0 nm"))
160
+ if self.scan_x_size <= 0:
161
+ errors.append(('scan_x_size', "must be greater than 0 um"))
162
+
163
+ if not 0 <= self.facet_trim <= MAX_FACET_TRIM:
164
+ errors.append(('facet_trim',
165
+ f"must be between 0 and {MAX_FACET_TRIM}; above that "
166
+ f"the trim consumes the whole facet and no angle can "
167
+ f"be fitted"))
168
+
169
+ if self.blaze_side not in VALID_BLAZE_SIDES:
170
+ errors.append(('blaze_side',
171
+ f"must be one of {', '.join(VALID_BLAZE_SIDES)}"))
172
+ if self.image_flatten_method not in VALID_IMAGE_FLATTEN_METHODS:
173
+ errors.append(('image_flatten_method',
174
+ f"must be one of "
175
+ f"{', '.join(VALID_IMAGE_FLATTEN_METHODS)}"))
176
+
177
+ if self.tip_correction not in VALID_TIP_CORRECTIONS:
178
+ errors.append(('tip_correction',
179
+ f"must be one of {', '.join(VALID_TIP_CORRECTIONS)}"))
180
+ if self.tip_correction != 'none':
181
+ if self.tip_radius_nm <= 0:
182
+ errors.append(('tip_radius_nm', "must be greater than 0 nm"))
183
+ if not 0 < self.tip_half_angle_deg < 90:
184
+ errors.append(('tip_half_angle_deg',
185
+ "must lie strictly between 0 and 90 degrees; "
186
+ "90 would be a flat punch and 0 an infinitely "
187
+ "thin needle"))
188
+
189
+ if self.flatten_method not in VALID_FLATTEN_METHODS:
190
+ errors.append(('flatten_method',
191
+ f"must be one of {', '.join(VALID_FLATTEN_METHODS)}"))
192
+ if self.spm_direction not in VALID_SPM_DIRECTIONS:
193
+ errors.append(('spm_direction',
194
+ f"must be one of {', '.join(VALID_SPM_DIRECTIONS)}"))
195
+
196
+ if self.flatten_feature not in VALID_FLATTEN_FEATURES:
197
+ errors.append(('flatten_feature',
198
+ f"must be one of {', '.join(VALID_FLATTEN_FEATURES)}"))
199
+
200
+ if self.use_row_groups and self.n_row_groups < 2:
201
+ errors.append(('n_row_groups', "needs at least 2 groups"))
202
+ if self.ggp_n_points < 10:
203
+ errors.append(('ggp_n_points', "needs at least 10 points"))
204
+ if self.ggp_smoothing_window < 1:
205
+ errors.append(('ggp_smoothing_window', "must be at least 1"))
206
+ if self.ggp_min_half_width < 1:
207
+ errors.append(('ggp_min_half_width', "must be at least 1"))
208
+
209
+ if self.edge_exclusion_periods < 0:
210
+ errors.append(('edge_exclusion_periods', "cannot be negative"))
211
+ if not 0 <= self.flatten_exclude_edges < 0.5:
212
+ errors.append(('flatten_exclude_edges', "must be between 0 and 0.5"))
213
+ if self.flatten_poly_order < 1:
214
+ errors.append(('flatten_poly_order', "must be at least 1"))
215
+
216
+ return tuple(errors)
@@ -0,0 +1,16 @@
1
+ """
2
+ Statistical analysis functions
3
+ Comparisons, pairwise tests, and temperature analysis
4
+ """
5
+
6
+ from .analysis import (
7
+ print_comparison_summary,
8
+ print_pairwise_comparisons,
9
+ print_temperature_analysis
10
+ )
11
+
12
+ __all__ = [
13
+ 'print_comparison_summary',
14
+ 'print_pairwise_comparisons',
15
+ 'print_temperature_analysis'
16
+ ]
@@ -0,0 +1,329 @@
1
+ """
2
+ Statistical analysis functions
3
+ Handles comparisons between samples
4
+
5
+ UPDATED: Proper uncertainty propagation in all comparisons
6
+ """
7
+ import numpy as np
8
+ from scipy import stats as scipy_stats
9
+
10
+
11
+ def _effective_n(result):
12
+ """
13
+ Sample size to use for inference.
14
+
15
+ Row-group analysis re-measures the same physical grooves in every band, so
16
+ the raw count overstates how much independent information there is. Falls
17
+ back to the raw count for results produced before n_effective existed, and
18
+ for traditional (non row-group) analysis where the two are the same thing.
19
+ """
20
+ n_eff = result.get('n_effective')
21
+ if n_eff is None or not np.isfinite(n_eff) or n_eff <= 0:
22
+ return float(result['n_grooves'])
23
+ return float(n_eff)
24
+
25
+
26
+ def print_comparison_summary(results, labels):
27
+ """
28
+ Print a summary table comparing all samples
29
+
30
+ Parameters:
31
+ results: list of result dictionaries
32
+ labels: list of sample labels
33
+ """
34
+ print(f"\n{'='*80}")
35
+ print("COMPARISON SUMMARY")
36
+ print(f"{'='*80}")
37
+
38
+ # Determine if we have combined scans
39
+ has_multiple_scans = any('n_scans' in r and r['n_scans'] > 1 for r in results)
40
+
41
+ if has_multiple_scans:
42
+ print(f"{'Sample':<20} {'File(s)':<30} {'Scans':<6} {'N':<5} {'Mean ± SEM':<18} {'σ_total':<10} {'Spacing':<12}")
43
+ print(f"{'-'*100}")
44
+ for r in results:
45
+ n_scans = r.get('n_scans', 1)
46
+ if n_scans > 1:
47
+ file_str = f"{n_scans} files"
48
+ else:
49
+ file_str = r['filename'].split('/')[-1][:30]
50
+
51
+ # Get SEM
52
+ sem = r.get('sem_corrected', r.get('sem', r['std_angle'] / np.sqrt(r['n_grooves'])))
53
+ total_std = r.get('total_std', r['std_angle'])
54
+
55
+ print(f"{r.get('label', 'N/A'):<20} "
56
+ f"{file_str:<30} "
57
+ f"{n_scans:<6} "
58
+ f"{r['n_grooves']:<5} "
59
+ f"{r['mean_angle']:.2f} ± {sem:.3f}° "
60
+ f"{total_std:<10.2f} "
61
+ f"{r['period_nm']:<10.2f} nm")
62
+ else:
63
+ print(f"{'Sample':<20} {'File':<30} {'N':<5} {'Mean ± SEM':<18} {'σ_total':<10} {'Spacing':<12}")
64
+ print(f"{'-'*100}")
65
+ for r in results:
66
+ file_str = r['filename'].split('/')[-1][:30]
67
+
68
+ # Get SEM
69
+ sem = r.get('sem_corrected', r.get('sem', r['std_angle'] / np.sqrt(r['n_grooves'])))
70
+ total_std = r.get('total_std', r['std_angle'])
71
+
72
+ print(f"{r.get('label', 'N/A'):<20} "
73
+ f"{file_str:<30} "
74
+ f"{r['n_grooves']:<5} "
75
+ f"{r['mean_angle']:.2f} ± {sem:.3f}° "
76
+ f"{total_std:<10.2f} "
77
+ f"{r['period_nm']:<10.2f} nm")
78
+
79
+ print("\nNote: SEM = Standard Error of Mean (uncertainty in mean estimate)")
80
+ print(" σ_total = Total standard deviation (includes measurement + physical variation)")
81
+
82
+
83
+ def print_pairwise_comparisons(results):
84
+ """
85
+ Print pairwise statistical comparisons between samples
86
+ NOW WITH PROPER UNCERTAINTY PROPAGATION AND SIGNIFICANCE TESTS
87
+
88
+ Parameters:
89
+ results: list of result dictionaries
90
+ """
91
+ print(f"\n{'='*80}")
92
+ print("STATISTICAL COMPARISONS")
93
+ print(f"{'='*80}")
94
+ print("Using proper uncertainty propagation (measurement + physical variation)")
95
+ print("")
96
+
97
+ for i, r1 in enumerate(results):
98
+ for j, r2 in enumerate(results):
99
+ if i < j: # Only compare each pair once
100
+ label1 = r1.get('label', f'Sample {i+1}')
101
+ label2 = r2.get('label', f'Sample {j+1}')
102
+
103
+ diff = r2['mean_angle'] - r1['mean_angle']
104
+
105
+ # Use total_std if available (includes measurement uncertainty)
106
+ # Otherwise fall back to std_angle (physical variation only)
107
+ std1 = r1.get('total_std', r1['std_angle'])
108
+ std2 = r2.get('total_std', r2['std_angle'])
109
+
110
+ # Combined standard error of the difference, on the effective
111
+ # sample sizes rather than the raw measurement counts.
112
+ n1, n2 = _effective_n(r1), _effective_n(r2)
113
+ se_combined = np.sqrt(std1**2 / n1 + std2**2 / n2)
114
+
115
+ # Calculate t-statistic for significance test
116
+ t_stat = diff / se_combined if se_combined > 0 else 0
117
+
118
+ # Degrees of freedom (Welch-Satterthwaite approximation)
119
+ df = _calculate_welch_df(std1, n1, std2, n2)
120
+
121
+ # Two-tailed p-value
122
+ p_value = 2 * (1 - scipy_stats.t.cdf(abs(t_stat), df))
123
+
124
+ # 95% confidence interval on the difference
125
+ t_critical = scipy_stats.t.ppf(0.975, df)
126
+ ci_lower = diff - t_critical * se_combined
127
+ ci_upper = diff + t_critical * se_combined
128
+
129
+ print(f"{label1} vs {label2}:")
130
+ print(f" Difference: {diff:+.3f}° ± {se_combined:.3f}° (SE)")
131
+ print(f" 95% CI: [{ci_lower:+.3f}°, {ci_upper:+.3f}°]")
132
+ print(f" {label1}: {r1['mean_angle']:.2f}° ± {std1:.2f}° "
133
+ f"(σ_total, N={r1['n_grooves']}, N_eff={n1:.1f})")
134
+ print(f" {label2}: {r2['mean_angle']:.2f}° ± {std2:.2f}° "
135
+ f"(σ_total, N={r2['n_grooves']}, N_eff={n2:.1f})")
136
+ print(f" t-statistic: {t_stat:.2f} (df={df:.1f})")
137
+ print(f" p-value: {p_value:.4f}", end="")
138
+
139
+ # Interpret significance
140
+ if p_value < 0.001:
141
+ print(" *** (highly significant)")
142
+ elif p_value < 0.01:
143
+ print(" ** (very significant)")
144
+ elif p_value < 0.05:
145
+ print(" * (significant)")
146
+ else:
147
+ print(" (not significant)")
148
+
149
+ # Effect size (Cohen's d). Deliberately on the raw counts, not
150
+ # the effective ones: this is a standardised mean difference - a
151
+ # description of how far apart the samples are in units of their
152
+ # own spread - not an inference statistic. Correlation belongs in
153
+ # the standard error above, not in the pooled variance.
154
+ pooled_std = np.sqrt(((r1['n_grooves']-1)*std1**2 + (r2['n_grooves']-1)*std2**2) /
155
+ (r1['n_grooves'] + r2['n_grooves'] - 2))
156
+ cohens_d = diff / pooled_std if pooled_std > 0 else 0
157
+ print(f" Effect size (Cohen's d): {cohens_d:.2f}")
158
+ print()
159
+
160
+
161
+ def print_temperature_analysis(results, labels, temperatures):
162
+ """
163
+ Print temperature-dependent analysis with proper uncertainties
164
+
165
+ Parameters:
166
+ results: list of result dictionaries
167
+ labels: list of sample labels
168
+ temperatures: list of temperatures (None for master)
169
+ """
170
+ # Separate temperature and master samples
171
+ temp_samples = [(i, t, labels[i], results[i]) for i, t in enumerate(temperatures) if t is not None]
172
+ master_samples = [(i, labels[i], results[i]) for i, t in enumerate(temperatures) if t is None]
173
+
174
+ if len(temp_samples) >= 2:
175
+ print(f"\n{'='*80}")
176
+ print("TEMPERATURE-DEPENDENT CHANGES")
177
+ print(f"{'='*80}")
178
+
179
+ # Sort by temperature
180
+ temp_samples.sort(key=lambda x: x[1])
181
+
182
+ # Consecutive temperature steps
183
+ print("\nConsecutive temperature steps:")
184
+ print(f"{'-'*80}")
185
+
186
+ for idx in range(len(temp_samples) - 1):
187
+ i, temp_i, label_i, result_i = temp_samples[idx]
188
+ j, temp_j, label_j, result_j = temp_samples[idx + 1]
189
+
190
+ angle_diff = result_j['mean_angle'] - result_i['mean_angle']
191
+ temp_diff = temp_j - temp_i
192
+
193
+ if temp_diff != 0:
194
+ rate = angle_diff / temp_diff
195
+
196
+ # Use total_std for uncertainty
197
+ std_i = result_i.get('total_std', result_i['std_angle'])
198
+ std_j = result_j.get('total_std', result_j['std_angle'])
199
+
200
+ n_i, n_j = _effective_n(result_i), _effective_n(result_j)
201
+ se_combined = np.sqrt(std_i**2 / n_i + std_j**2 / n_j)
202
+ se_rate = se_combined / abs(temp_diff)
203
+
204
+ # Significance test
205
+ t_stat = angle_diff / se_combined if se_combined > 0 else 0
206
+ df = _calculate_welch_df(std_i, n_i, std_j, n_j)
207
+ p_value = 2 * (1 - scipy_stats.t.cdf(abs(t_stat), df))
208
+
209
+ print(f"\n{label_i} ({temp_i}°C) → {label_j} ({temp_j}°C):")
210
+ print(f" Temperature change: {temp_diff:+.0f}°C")
211
+ print(f" Angle change: {angle_diff:+.3f}° ± {se_combined:.3f}°")
212
+ print(f" Rate: {rate:+.6f} ± {se_rate:.6f} °/°C")
213
+ print(f" Significance: p = {p_value:.4f}", end="")
214
+ if p_value < 0.05:
215
+ print(" *")
216
+ else:
217
+ print()
218
+
219
+ # Master vs treated comparisons
220
+ if len(master_samples) > 0 and len(temp_samples) > 0:
221
+ print(f"\n{'='*80}")
222
+ print("MASTER vs TREATED COMPARISONS")
223
+ print(f"{'='*80}\n")
224
+
225
+ for i, label_i, result_i in master_samples:
226
+ for j, temp_j, label_j, result_j in temp_samples:
227
+ diff = result_j['mean_angle'] - result_i['mean_angle']
228
+
229
+ # Use total_std
230
+ std_i = result_i.get('total_std', result_i['std_angle'])
231
+ std_j = result_j.get('total_std', result_j['std_angle'])
232
+
233
+ n_i, n_j = _effective_n(result_i), _effective_n(result_j)
234
+ se_combined = np.sqrt(std_i**2 / n_i + std_j**2 / n_j)
235
+
236
+ # Significance test
237
+ t_stat = diff / se_combined if se_combined > 0 else 0
238
+ df = _calculate_welch_df(std_i, n_i, std_j, n_j)
239
+ p_value = 2 * (1 - scipy_stats.t.cdf(abs(t_stat), df))
240
+
241
+ print(f"{label_i} → {label_j} ({temp_j}°C):")
242
+ print(f" Angle change: {diff:+.3f}° ± {se_combined:.3f}°")
243
+ print(f" {label_i}: {result_i['mean_angle']:.2f}° ± {std_i:.2f}°")
244
+ print(f" {label_j}: {result_j['mean_angle']:.2f}° ± {std_j:.2f}°")
245
+ print(f" Significance: p = {p_value:.4f}", end="")
246
+ if p_value < 0.05:
247
+ print(" *")
248
+ else:
249
+ print()
250
+ print()
251
+
252
+
253
+ def _calculate_welch_df(std1, n1, std2, n2):
254
+ """
255
+ Calculate Welch-Satterthwaite degrees of freedom for unequal variances
256
+
257
+ This is the proper df for t-tests when variances are different
258
+ """
259
+ var1 = std1**2
260
+ var2 = std2**2
261
+
262
+ numerator = (var1/n1 + var2/n2)**2
263
+ denominator = (var1/n1)**2/(n1-1) + (var2/n2)**2/(n2-1)
264
+
265
+ if denominator > 0:
266
+ df = numerator / denominator
267
+ else:
268
+ df = min(n1, n2) - 1
269
+
270
+ return df
271
+
272
+
273
+ def print_uncertainty_breakdown(results, labels):
274
+ """
275
+ Print detailed breakdown of uncertainty components
276
+
277
+ This helps understand where your uncertainty comes from:
278
+ - Measurement uncertainty (from fits)
279
+ - Physical variation (groove-to-groove)
280
+ - Within-image variation (between row groups, if applicable)
281
+ """
282
+ print(f"\n{'='*80}")
283
+ print("UNCERTAINTY BREAKDOWN")
284
+ print(f"{'='*80}")
285
+ print("Understanding your error sources:\n")
286
+
287
+ for r, label in zip(results, labels):
288
+ print(f"{label}:")
289
+ print(f" Mean angle: {r['mean_angle']:.2f}°")
290
+
291
+ # Measurement uncertainty
292
+ if 'mean_measurement_uncertainty' in r:
293
+ print(f" Measurement uncertainty: {r['mean_measurement_uncertainty']:.3f}° (from fits)")
294
+
295
+ # Within-image variation (row-group analysis)
296
+ if 'within_image_std' in r and r['within_image_std'] > 0:
297
+ print(f" Within-image variation: {r['within_image_std']:.3f}° (between row groups)")
298
+
299
+ # Physical variation
300
+ print(f" Physical variation: {r['std_angle']:.3f}° (groove-to-groove)")
301
+
302
+ # Total uncertainty
303
+ total_std = r.get('total_std', r['std_angle'])
304
+ print(f" Total uncertainty: {total_std:.3f}° (combined)")
305
+
306
+ # SEM
307
+ sem = r.get('sem', total_std / np.sqrt(r['n_grooves']))
308
+ print(f" Standard error of mean: {sem:.3f}°")
309
+ print(f" 95% CI on mean: ±{1.96*sem:.3f}°")
310
+
311
+ # Sample size info
312
+ print(f" Sample size: N={r['n_grooves']} measurements", end="")
313
+ if 'n_groups' in r and r['n_groups'] > 1:
314
+ print(f" from {r['n_groups']} row groups")
315
+ else:
316
+ print()
317
+
318
+ # Variance decomposition
319
+ if 'measurement_variance' in r and 'physical_variance' in r:
320
+ meas_var = r['measurement_variance']
321
+ phys_var = r['physical_variance']
322
+ total_var = meas_var + phys_var
323
+
324
+ if total_var > 0:
325
+ meas_pct = 100 * meas_var / total_var
326
+ phys_pct = 100 * phys_var / total_var
327
+ print(f" Variance components: {meas_pct:.1f}% measurement, {phys_pct:.1f}% physical")
328
+
329
+ print()