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,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()
|