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.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. 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)