microstructure-tpw 0.3.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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Comparison utilities for validating computed results against reference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from odas_tpw.scor160.io import L2Data, L3Data, L4Data
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _log_spectral_metrics(comp: np.ndarray, ref: np.ndarray) -> dict:
|
|
11
|
+
"""Compute log10 comparison metrics between two positive arrays."""
|
|
12
|
+
valid = np.isfinite(comp) & np.isfinite(ref) & (comp > 0) & (ref > 0)
|
|
13
|
+
if not valid.any():
|
|
14
|
+
return {"n_valid": 0}
|
|
15
|
+
|
|
16
|
+
log_diff = np.log10(comp[valid]) - np.log10(ref[valid])
|
|
17
|
+
abs_log_diff = np.abs(log_diff)
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
"n_valid": int(valid.sum()),
|
|
21
|
+
"mean_log_diff": float(np.mean(log_diff)),
|
|
22
|
+
"rms_log_diff": float(np.sqrt(np.mean(log_diff**2))),
|
|
23
|
+
"median_ratio": float(10 ** np.median(log_diff)),
|
|
24
|
+
"q0_abs_log": float(np.min(abs_log_diff)),
|
|
25
|
+
"q50_abs_log": float(np.median(abs_log_diff)),
|
|
26
|
+
"q100_abs_log": float(np.max(abs_log_diff)),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_report_header(level: str, filename: str) -> list[str]:
|
|
31
|
+
"""Build report header lines for comparison output."""
|
|
32
|
+
header = f"{level} comparison: {filename}" if filename else f"{level} comparison"
|
|
33
|
+
return [header, "=" * len(header)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compare_l2(computed: L2Data, reference: L2Data) -> dict:
|
|
37
|
+
"""Compare computed L2 against reference L2.
|
|
38
|
+
|
|
39
|
+
Returns a dict of comparison metrics.
|
|
40
|
+
"""
|
|
41
|
+
results: dict = {}
|
|
42
|
+
|
|
43
|
+
# Section agreement
|
|
44
|
+
ref_sec = reference.section_number
|
|
45
|
+
comp_sec = computed.section_number
|
|
46
|
+
ref_in = ref_sec > 0
|
|
47
|
+
comp_in = comp_sec > 0
|
|
48
|
+
both_in = ref_in & comp_in
|
|
49
|
+
|
|
50
|
+
# Guard the .max() reductions: np.max on a zero-size array raises
|
|
51
|
+
# ValueError; an empty/degenerate L2 should report 0 sections, not crash.
|
|
52
|
+
results["section"] = {
|
|
53
|
+
"ref_n_sections": int(ref_sec.max()) if ref_sec.size else 0,
|
|
54
|
+
"comp_n_sections": int(comp_sec.max()) if comp_sec.size else 0,
|
|
55
|
+
"ref_n_selected": int(ref_in.sum()),
|
|
56
|
+
"comp_n_selected": int(comp_in.sum()),
|
|
57
|
+
"overlap_frac": float(int(both_in.sum()) / max(int(ref_in.sum()), 1)),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Speed comparison (within reference sections)
|
|
61
|
+
if both_in.any():
|
|
62
|
+
w_ref = reference.pspd_rel[both_in]
|
|
63
|
+
w_comp = computed.pspd_rel[both_in]
|
|
64
|
+
finite = np.isfinite(w_ref) & np.isfinite(w_comp)
|
|
65
|
+
if finite.any():
|
|
66
|
+
diff = w_comp[finite] - w_ref[finite]
|
|
67
|
+
results["speed"] = {
|
|
68
|
+
"mean_diff": float(np.mean(diff)),
|
|
69
|
+
"rms_diff": float(np.sqrt(np.mean(diff**2))),
|
|
70
|
+
"max_abs_diff": float(np.max(np.abs(diff))),
|
|
71
|
+
"ref_mean": float(np.mean(w_ref[finite])),
|
|
72
|
+
"relative_rms": float(np.sqrt(np.mean(diff**2)) / np.mean(np.abs(w_ref[finite]))),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Shear comparison (within reference sections)
|
|
76
|
+
n_sh = min(computed.shear.shape[0], reference.shear.shape[0])
|
|
77
|
+
shear_stats = []
|
|
78
|
+
for i in range(n_sh):
|
|
79
|
+
ref_sh = reference.shear[i, both_in]
|
|
80
|
+
comp_sh = computed.shear[i, both_in]
|
|
81
|
+
finite = np.isfinite(ref_sh) & np.isfinite(comp_sh)
|
|
82
|
+
if finite.any():
|
|
83
|
+
diff = comp_sh[finite] - ref_sh[finite]
|
|
84
|
+
ref_rms = np.sqrt(np.mean(ref_sh[finite] ** 2))
|
|
85
|
+
shear_stats.append(
|
|
86
|
+
{
|
|
87
|
+
"probe": i + 1,
|
|
88
|
+
"rms_diff": float(np.sqrt(np.mean(diff**2))),
|
|
89
|
+
"ref_rms": float(ref_rms),
|
|
90
|
+
"relative_rms": float(
|
|
91
|
+
np.sqrt(np.mean(diff**2)) / ref_rms if ref_rms > 0 else np.inf
|
|
92
|
+
),
|
|
93
|
+
"corr": float(np.corrcoef(ref_sh[finite], comp_sh[finite])[0, 1]),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
results["shear"] = shear_stats
|
|
97
|
+
|
|
98
|
+
# Vibration comparison
|
|
99
|
+
n_vib = min(computed.vib.shape[0], reference.vib.shape[0])
|
|
100
|
+
vib_stats = []
|
|
101
|
+
for i in range(n_vib):
|
|
102
|
+
ref_v = reference.vib[i, both_in]
|
|
103
|
+
comp_v = computed.vib[i, both_in]
|
|
104
|
+
finite = np.isfinite(ref_v) & np.isfinite(comp_v)
|
|
105
|
+
if finite.any():
|
|
106
|
+
diff = comp_v[finite] - ref_v[finite]
|
|
107
|
+
ref_rms = np.sqrt(np.mean(ref_v[finite] ** 2))
|
|
108
|
+
vib_stats.append(
|
|
109
|
+
{
|
|
110
|
+
"sensor": i + 1,
|
|
111
|
+
"rms_diff": float(np.sqrt(np.mean(diff**2))),
|
|
112
|
+
"ref_rms": float(ref_rms),
|
|
113
|
+
"relative_rms": float(
|
|
114
|
+
np.sqrt(np.mean(diff**2)) / ref_rms if ref_rms > 0 else np.inf
|
|
115
|
+
),
|
|
116
|
+
"corr": float(np.corrcoef(ref_v[finite], comp_v[finite])[0, 1]),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
results["vibration"] = vib_stats
|
|
120
|
+
|
|
121
|
+
return results
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def format_l2_report(metrics: dict, filename: str = "") -> str:
|
|
125
|
+
"""Format L2 comparison metrics as a human-readable report."""
|
|
126
|
+
lines = _format_report_header("L2", filename)
|
|
127
|
+
|
|
128
|
+
# Section selection
|
|
129
|
+
sec = metrics.get("section", {})
|
|
130
|
+
lines.append("")
|
|
131
|
+
lines.append("Section selection:")
|
|
132
|
+
lines.append(f" Reference sections: {sec.get('ref_n_sections', '?')}")
|
|
133
|
+
lines.append(f" Computed sections: {sec.get('comp_n_sections', '?')}")
|
|
134
|
+
lines.append(f" Reference selected: {sec.get('ref_n_selected', '?')} samples")
|
|
135
|
+
lines.append(f" Computed selected: {sec.get('comp_n_selected', '?')} samples")
|
|
136
|
+
lines.append(f" Overlap fraction: {sec.get('overlap_frac', 0):.4f}")
|
|
137
|
+
|
|
138
|
+
# Speed
|
|
139
|
+
spd = metrics.get("speed")
|
|
140
|
+
if spd:
|
|
141
|
+
lines.append("")
|
|
142
|
+
lines.append("Profiling speed (within overlapping sections):")
|
|
143
|
+
lines.append(f" Reference mean: {spd['ref_mean']:.4f} m/s")
|
|
144
|
+
lines.append(f" Mean difference: {spd['mean_diff']:.6f} m/s")
|
|
145
|
+
lines.append(f" RMS difference: {spd['rms_diff']:.6f} m/s")
|
|
146
|
+
lines.append(f" Max |difference|: {spd['max_abs_diff']:.6f} m/s")
|
|
147
|
+
lines.append(f" Relative RMS: {spd['relative_rms']:.4f}")
|
|
148
|
+
|
|
149
|
+
# Shear
|
|
150
|
+
for sh in metrics.get("shear", []):
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append(f"Shear probe {sh['probe']}:")
|
|
153
|
+
lines.append(f" Reference RMS: {sh['ref_rms']:.6f} s⁻¹")
|
|
154
|
+
lines.append(f" RMS difference: {sh['rms_diff']:.6f} s⁻¹")
|
|
155
|
+
lines.append(f" Relative RMS diff: {sh['relative_rms']:.4f}")
|
|
156
|
+
lines.append(f" Correlation: {sh['corr']:.6f}")
|
|
157
|
+
|
|
158
|
+
# Vibration
|
|
159
|
+
for vb in metrics.get("vibration", []):
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append(f"Vibration sensor {vb['sensor']}:")
|
|
162
|
+
lines.append(f" Reference RMS: {vb['ref_rms']:.6f}")
|
|
163
|
+
lines.append(f" RMS difference: {vb['rms_diff']:.6f}")
|
|
164
|
+
lines.append(f" Relative RMS diff: {vb['relative_rms']:.4f}")
|
|
165
|
+
lines.append(f" Correlation: {vb['corr']:.6f}")
|
|
166
|
+
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# L3 comparison
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compare_l3(computed: L3Data, reference: L3Data) -> dict:
|
|
176
|
+
"""Compare computed L3 spectra against reference L3.
|
|
177
|
+
|
|
178
|
+
Compares wavenumber grids, spectral shapes, and per-window metadata.
|
|
179
|
+
"""
|
|
180
|
+
results: dict = {}
|
|
181
|
+
|
|
182
|
+
results["n_spectra"] = {
|
|
183
|
+
"ref": reference.n_spectra,
|
|
184
|
+
"comp": computed.n_spectra,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
n_common = min(computed.n_spectra, reference.n_spectra)
|
|
188
|
+
if n_common == 0:
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
# Speed comparison
|
|
192
|
+
spd_diff = computed.pspd_rel[:n_common] - reference.pspd_rel[:n_common]
|
|
193
|
+
finite = np.isfinite(spd_diff)
|
|
194
|
+
if finite.any():
|
|
195
|
+
results["speed"] = {
|
|
196
|
+
"rms_diff": float(np.sqrt(np.mean(spd_diff[finite] ** 2))),
|
|
197
|
+
"ref_mean": float(np.nanmean(reference.pspd_rel[:n_common])),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Pressure comparison
|
|
201
|
+
pres_diff = computed.pres[:n_common] - reference.pres[:n_common]
|
|
202
|
+
finite = np.isfinite(pres_diff)
|
|
203
|
+
if finite.any():
|
|
204
|
+
results["pressure"] = {
|
|
205
|
+
"rms_diff": float(np.sqrt(np.mean(pres_diff[finite] ** 2))),
|
|
206
|
+
"ref_mean": float(np.nanmean(reference.pres[:n_common])),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Spectral comparison — compare log10(spectrum) to handle dynamic range
|
|
210
|
+
n_sh = min(computed.n_shear, reference.n_shear)
|
|
211
|
+
n_wn = min(computed.n_wavenumber, reference.n_wavenumber)
|
|
212
|
+
|
|
213
|
+
spec_stats = []
|
|
214
|
+
for label, comp_spec, ref_spec in [
|
|
215
|
+
("raw", computed.sh_spec, reference.sh_spec),
|
|
216
|
+
("clean", computed.sh_spec_clean, reference.sh_spec_clean),
|
|
217
|
+
]:
|
|
218
|
+
for i in range(n_sh):
|
|
219
|
+
c = comp_spec[i, :n_wn, :n_common]
|
|
220
|
+
r = ref_spec[i, :n_wn, :n_common]
|
|
221
|
+
|
|
222
|
+
m = _log_spectral_metrics(c, r)
|
|
223
|
+
if m["n_valid"] == 0:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
spec_stats.append(
|
|
227
|
+
{
|
|
228
|
+
"type": label,
|
|
229
|
+
"probe": i + 1,
|
|
230
|
+
"mean_log_diff": m["mean_log_diff"],
|
|
231
|
+
"rms_log_diff": m["rms_log_diff"],
|
|
232
|
+
"median_ratio": m["median_ratio"],
|
|
233
|
+
"q0_abs_log": m["q0_abs_log"],
|
|
234
|
+
"q50_abs_log": m["q50_abs_log"],
|
|
235
|
+
"q100_abs_log": m["q100_abs_log"],
|
|
236
|
+
"n_values": m["n_valid"],
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
results["spectra"] = spec_stats
|
|
240
|
+
|
|
241
|
+
return results
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_l3_report(metrics: dict, filename: str = "") -> str:
|
|
245
|
+
"""Format L3 comparison metrics as a human-readable report."""
|
|
246
|
+
lines = _format_report_header("L3", filename)
|
|
247
|
+
|
|
248
|
+
ns = metrics.get("n_spectra", {})
|
|
249
|
+
lines.append(f"\n Reference spectra: {ns.get('ref', '?')}")
|
|
250
|
+
lines.append(f" Computed spectra: {ns.get('comp', '?')}")
|
|
251
|
+
|
|
252
|
+
spd = metrics.get("speed")
|
|
253
|
+
if spd:
|
|
254
|
+
lines.append(
|
|
255
|
+
f"\n Speed RMS diff: {spd['rms_diff']:.6f} m/s (ref mean {spd['ref_mean']:.4f})"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
prs = metrics.get("pressure")
|
|
259
|
+
if prs:
|
|
260
|
+
lines.append(
|
|
261
|
+
f" Pressure RMS diff: {prs['rms_diff']:.4f} dbar (ref mean {prs['ref_mean']:.1f})"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
for ss in metrics.get("spectra", []):
|
|
265
|
+
lines.append(f"\n Shear probe {ss['probe']} ({ss['type']}):")
|
|
266
|
+
lines.append(f" Mean log10 diff: {ss['mean_log_diff']:+.4f}")
|
|
267
|
+
lines.append(f" RMS log10 diff: {ss['rms_log_diff']:.4f}")
|
|
268
|
+
lines.append(f" Median ratio: {ss['median_ratio']:.4f}")
|
|
269
|
+
lines.append(
|
|
270
|
+
f" |log10 diff| Q0/Q50/Q100: "
|
|
271
|
+
f"{ss['q0_abs_log']:.4f} / {ss['q50_abs_log']:.4f} / "
|
|
272
|
+
f"{ss['q100_abs_log']:.4f}"
|
|
273
|
+
)
|
|
274
|
+
lines.append(f" N values compared: {ss['n_values']}")
|
|
275
|
+
|
|
276
|
+
return "\n".join(lines)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# L4 comparison
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def compare_l4(computed: L4Data, reference: L4Data) -> dict:
|
|
285
|
+
"""Compare computed L4 against reference L4.
|
|
286
|
+
|
|
287
|
+
Returns a dict of comparison metrics.
|
|
288
|
+
"""
|
|
289
|
+
results: dict = {}
|
|
290
|
+
|
|
291
|
+
results["n_spectra"] = {
|
|
292
|
+
"ref": reference.n_spectra,
|
|
293
|
+
"comp": computed.n_spectra,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
n_common = min(computed.n_spectra, reference.n_spectra)
|
|
297
|
+
if n_common == 0:
|
|
298
|
+
return results
|
|
299
|
+
|
|
300
|
+
n_sh = min(computed.n_shear, reference.n_shear)
|
|
301
|
+
|
|
302
|
+
# Per-probe epsilon comparison (log10 space — epsilon spans decades)
|
|
303
|
+
probe_stats = []
|
|
304
|
+
for i in range(n_sh):
|
|
305
|
+
ref_e = reference.epsi[i, :n_common]
|
|
306
|
+
comp_e = computed.epsi[i, :n_common]
|
|
307
|
+
|
|
308
|
+
m = _log_spectral_metrics(comp_e, ref_e)
|
|
309
|
+
if m["n_valid"] == 0:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Additional quantile metrics beyond the base helper
|
|
313
|
+
valid = np.isfinite(ref_e) & np.isfinite(comp_e) & (ref_e > 0) & (comp_e > 0)
|
|
314
|
+
abs_log_diff = np.abs(np.log10(comp_e[valid]) - np.log10(ref_e[valid]))
|
|
315
|
+
|
|
316
|
+
probe_stats.append(
|
|
317
|
+
{
|
|
318
|
+
"probe": i + 1,
|
|
319
|
+
"n_valid": m["n_valid"],
|
|
320
|
+
"mean_log_diff": m["mean_log_diff"],
|
|
321
|
+
"rms_log_diff": m["rms_log_diff"],
|
|
322
|
+
"median_ratio": m["median_ratio"],
|
|
323
|
+
"q0_abs_log": m["q0_abs_log"],
|
|
324
|
+
"q25_abs_log": float(np.percentile(abs_log_diff, 25)),
|
|
325
|
+
"q50_abs_log": m["q50_abs_log"],
|
|
326
|
+
"q75_abs_log": float(np.percentile(abs_log_diff, 75)),
|
|
327
|
+
"q100_abs_log": m["q100_abs_log"],
|
|
328
|
+
"within_factor2": float(np.mean(abs_log_diff < np.log10(2))),
|
|
329
|
+
"within_factor3": float(np.mean(abs_log_diff < np.log10(3))),
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
results["probes"] = probe_stats
|
|
333
|
+
|
|
334
|
+
# EPSI_FINAL comparison
|
|
335
|
+
ref_ef = reference.epsi_final[:n_common]
|
|
336
|
+
comp_ef = computed.epsi_final[:n_common]
|
|
337
|
+
m = _log_spectral_metrics(comp_ef, ref_ef)
|
|
338
|
+
if m["n_valid"] > 0:
|
|
339
|
+
valid = np.isfinite(ref_ef) & np.isfinite(comp_ef) & (ref_ef > 0) & (comp_ef > 0)
|
|
340
|
+
abs_log_diff = np.abs(np.log10(comp_ef[valid]) - np.log10(ref_ef[valid]))
|
|
341
|
+
results["epsi_final"] = {
|
|
342
|
+
"n_valid": m["n_valid"],
|
|
343
|
+
"mean_log_diff": m["mean_log_diff"],
|
|
344
|
+
"rms_log_diff": m["rms_log_diff"],
|
|
345
|
+
"median_ratio": m["median_ratio"],
|
|
346
|
+
"q0_abs_log": m["q0_abs_log"],
|
|
347
|
+
"q50_abs_log": m["q50_abs_log"],
|
|
348
|
+
"q100_abs_log": m["q100_abs_log"],
|
|
349
|
+
"within_factor2": float(np.mean(abs_log_diff < np.log10(2))),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# FOM comparison. The benchmark reference FOM is the MAD-based Lueck
|
|
353
|
+
# (2022) FM statistic, so compare the computed FM against it when
|
|
354
|
+
# available; the variance-ratio ``fom`` is a different quantity.
|
|
355
|
+
fom_stats = []
|
|
356
|
+
comp_fom_source = computed.FM if computed.FM is not None else computed.fom
|
|
357
|
+
for i in range(n_sh):
|
|
358
|
+
ref_f = reference.fom[i, :n_common]
|
|
359
|
+
comp_f = comp_fom_source[i, :n_common]
|
|
360
|
+
valid = np.isfinite(ref_f) & np.isfinite(comp_f)
|
|
361
|
+
if valid.any():
|
|
362
|
+
diff = comp_f[valid] - ref_f[valid]
|
|
363
|
+
fom_stats.append(
|
|
364
|
+
{
|
|
365
|
+
"probe": i + 1,
|
|
366
|
+
"ref_mean": float(np.mean(ref_f[valid])),
|
|
367
|
+
"comp_mean": float(np.mean(comp_f[valid])),
|
|
368
|
+
"rms_diff": float(np.sqrt(np.mean(diff**2))),
|
|
369
|
+
"corr": float(np.corrcoef(ref_f[valid], comp_f[valid])[0, 1])
|
|
370
|
+
if len(ref_f[valid]) > 1
|
|
371
|
+
else np.nan,
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
results["fom"] = fom_stats
|
|
375
|
+
|
|
376
|
+
# Method agreement
|
|
377
|
+
method_stats = []
|
|
378
|
+
for i in range(n_sh):
|
|
379
|
+
ref_m = reference.method[i, :n_common]
|
|
380
|
+
comp_m = computed.method[i, :n_common]
|
|
381
|
+
valid = np.isfinite(ref_m) & np.isfinite(comp_m)
|
|
382
|
+
if valid.any():
|
|
383
|
+
agree = (ref_m[valid] == comp_m[valid]).sum()
|
|
384
|
+
method_stats.append(
|
|
385
|
+
{
|
|
386
|
+
"probe": i + 1,
|
|
387
|
+
"agreement": float(agree / valid.sum()),
|
|
388
|
+
"ref_isr_frac": float((ref_m[valid] == 1).mean()),
|
|
389
|
+
"comp_isr_frac": float((comp_m[valid] == 1).mean()),
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
results["method"] = method_stats
|
|
393
|
+
|
|
394
|
+
# QC flag agreement (exact match of the full bitfield, plus per-bit
|
|
395
|
+
# agreement for the ATOMIX bits)
|
|
396
|
+
flag_stats = []
|
|
397
|
+
for i in range(n_sh):
|
|
398
|
+
ref_f = reference.epsi_flags[i, :n_common]
|
|
399
|
+
comp_f = computed.epsi_flags[i, :n_common]
|
|
400
|
+
valid = np.isfinite(ref_f) & np.isfinite(comp_f) & (ref_f != 255) & (comp_f != 255)
|
|
401
|
+
if valid.any():
|
|
402
|
+
rf = ref_f[valid].astype(np.int64)
|
|
403
|
+
cf = comp_f[valid].astype(np.int64)
|
|
404
|
+
entry = {
|
|
405
|
+
"probe": i + 1,
|
|
406
|
+
"exact": float((rf == cf).mean()),
|
|
407
|
+
"n_ref_flagged": int((rf > 0).sum()),
|
|
408
|
+
"n_comp_flagged": int((cf > 0).sum()),
|
|
409
|
+
}
|
|
410
|
+
for bit, name in ((1, "fom"), (2, "despike"), (4, "ratio"), (8, "passes"), (16, "var")):
|
|
411
|
+
entry[f"bit_{name}"] = float(((rf & bit) == (cf & bit)).mean())
|
|
412
|
+
flag_stats.append(entry)
|
|
413
|
+
results["flags"] = flag_stats
|
|
414
|
+
|
|
415
|
+
return results
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def format_l4_report(metrics: dict, filename: str = "") -> str:
|
|
419
|
+
"""Format L4 comparison metrics as a human-readable report."""
|
|
420
|
+
lines = _format_report_header("L4", filename)
|
|
421
|
+
|
|
422
|
+
ns = metrics.get("n_spectra", {})
|
|
423
|
+
lines.append(f"\n Reference spectra: {ns.get('ref', '?')}")
|
|
424
|
+
lines.append(f" Computed spectra: {ns.get('comp', '?')}")
|
|
425
|
+
|
|
426
|
+
for ps in metrics.get("probes", []):
|
|
427
|
+
lines.append(f"\n Shear probe {ps['probe']}:")
|
|
428
|
+
lines.append(f" N valid: {ps['n_valid']}")
|
|
429
|
+
lines.append(f" Mean log10 diff: {ps['mean_log_diff']:+.4f}")
|
|
430
|
+
lines.append(f" RMS log10 diff: {ps['rms_log_diff']:.4f}")
|
|
431
|
+
lines.append(f" Median ratio: {ps['median_ratio']:.4f}")
|
|
432
|
+
lines.append(
|
|
433
|
+
f" |log10 diff| Q0/Q25/Q50/Q75/Q100: "
|
|
434
|
+
f"{ps['q0_abs_log']:.4f} / {ps['q25_abs_log']:.4f} / "
|
|
435
|
+
f"{ps['q50_abs_log']:.4f} / {ps['q75_abs_log']:.4f} / "
|
|
436
|
+
f"{ps['q100_abs_log']:.4f}"
|
|
437
|
+
)
|
|
438
|
+
lines.append(f" Within factor 2: {ps['within_factor2']:.1%}")
|
|
439
|
+
lines.append(f" Within factor 3: {ps['within_factor3']:.1%}")
|
|
440
|
+
|
|
441
|
+
ef = metrics.get("epsi_final")
|
|
442
|
+
if ef:
|
|
443
|
+
lines.append("\n EPSI_FINAL (combined):")
|
|
444
|
+
lines.append(f" N valid: {ef['n_valid']}")
|
|
445
|
+
lines.append(f" Mean log10 diff: {ef['mean_log_diff']:+.4f}")
|
|
446
|
+
lines.append(f" RMS log10 diff: {ef['rms_log_diff']:.4f}")
|
|
447
|
+
lines.append(f" Median ratio: {ef['median_ratio']:.4f}")
|
|
448
|
+
lines.append(
|
|
449
|
+
f" |log10 diff| Q0/Q50/Q100: "
|
|
450
|
+
f"{ef['q0_abs_log']:.4f} / {ef['q50_abs_log']:.4f} / "
|
|
451
|
+
f"{ef['q100_abs_log']:.4f}"
|
|
452
|
+
)
|
|
453
|
+
lines.append(f" Within factor 2: {ef['within_factor2']:.1%}")
|
|
454
|
+
|
|
455
|
+
for fs in metrics.get("fom", []):
|
|
456
|
+
lines.append(f"\n FOM probe {fs['probe']}:")
|
|
457
|
+
lines.append(
|
|
458
|
+
f" Ref mean: {fs['ref_mean']:.4f} Comp mean: {fs['comp_mean']:.4f}"
|
|
459
|
+
f" RMS diff: {fs['rms_diff']:.4f} Corr: {fs['corr']:.4f}"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
for ms in metrics.get("method", []):
|
|
463
|
+
lines.append(f"\n Method probe {ms['probe']}:")
|
|
464
|
+
lines.append(
|
|
465
|
+
f" Agreement: {ms['agreement']:.1%}"
|
|
466
|
+
f" Ref ISR: {ms['ref_isr_frac']:.1%}"
|
|
467
|
+
f" Comp ISR: {ms['comp_isr_frac']:.1%}"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
for fl in metrics.get("flags", []):
|
|
471
|
+
lines.append(f"\n QC flags probe {fl['probe']}:")
|
|
472
|
+
lines.append(
|
|
473
|
+
f" Exact match: {fl['exact']:.1%}"
|
|
474
|
+
f" (ref flagged: {fl['n_ref_flagged']}, comp flagged: {fl['n_comp_flagged']})"
|
|
475
|
+
)
|
|
476
|
+
lines.append(
|
|
477
|
+
f" Per-bit agreement: fom {fl['bit_fom']:.1%}"
|
|
478
|
+
f" despike {fl['bit_despike']:.1%}"
|
|
479
|
+
f" ratio {fl['bit_ratio']:.1%}"
|
|
480
|
+
f" passes {fl['bit_passes']:.1%}"
|
|
481
|
+
f" var {fl['bit_var']:.1%}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return "\n".join(lines)
|