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
odas_tpw/pyturb/bin.py ADDED
@@ -0,0 +1,113 @@
1
+ """pyturb-cli bin: depth-bin profile results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import xarray as xr
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def run_bin(args: argparse.Namespace) -> None:
16
+ """Execute the bin subcommand."""
17
+ output_path = Path(args.output)
18
+
19
+ # Collect input files
20
+ paths: list[Path] = []
21
+ for pattern in args.files:
22
+ p = Path(pattern)
23
+ if p.is_file():
24
+ paths.append(p)
25
+ else:
26
+ paths.extend(sorted(Path(".").glob(pattern)))
27
+
28
+ if not paths:
29
+ logger.error("No input files found")
30
+ return
31
+
32
+ var_names = [v.strip() for v in args.vars.split(",") if v.strip()]
33
+
34
+ from odas_tpw.rsi.binning import bin_by_depth
35
+ from odas_tpw.rsi.combine import combine_profiles
36
+
37
+ binned_list: list[xr.Dataset] = []
38
+ metadata_list: list[dict] = []
39
+
40
+ # pyturb-cli bins dissipation with the GEOMETRIC (log-space) mean for
41
+ # Jesse-parity. NOTE (audit 2026-07-01): this is the per-bin MEDIAN of a
42
+ # lognormal, systematically LOWER than the ARITHMETIC-mean binned products
43
+ # from the perturb/rsi pipelines on the same data (by exp(-sigma_ln^2/2):
44
+ # ~0.61x at sigma_ln=1.0, ~0.32x at 1.5). Do not mix these two products.
45
+ log_mean_vars = {"eps_1", "eps_2", "eps_final", "epsilon", "chi", "chi_final", "epsi_final"}
46
+ logger.info(
47
+ "pyturb bin: dissipation (%s) uses the GEOMETRIC mean (median of a "
48
+ "lognormal), NOT the arithmetic mean used by the perturb/rsi products",
49
+ ", ".join(sorted(log_mean_vars)),
50
+ )
51
+
52
+ for filepath in sorted(paths):
53
+ try:
54
+ ds = xr.open_dataset(filepath)
55
+ except Exception as e:
56
+ logger.error(f"{filepath.name}: {e}")
57
+ continue
58
+
59
+ # Get pressure
60
+ if "pressure" in ds:
61
+ pres = ds["pressure"].values
62
+ elif "P_mean" in ds:
63
+ pres = ds["P_mean"].values
64
+ else:
65
+ logger.warning(f"{filepath.name}: no pressure variable, skipping")
66
+ ds.close()
67
+ continue
68
+
69
+ # Convert pressure to depth if not using --pressure mode
70
+ if not args.pressure:
71
+ try:
72
+ import gsw
73
+
74
+ depth = -gsw.z_from_p(pres, args.lat)
75
+ except ImportError:
76
+ logger.warning("gsw not available, binning by pressure instead")
77
+ depth = pres
78
+ else:
79
+ depth = pres
80
+
81
+ # Collect variables
82
+ values: dict[str, np.ndarray] = {}
83
+ for vname in var_names:
84
+ if vname in ds:
85
+ values[vname] = ds[vname].values
86
+
87
+ if not values:
88
+ logger.warning(f"{filepath.name}: no matching variables, skipping")
89
+ ds.close()
90
+ continue
91
+
92
+ binned = bin_by_depth(
93
+ depth,
94
+ values,
95
+ bin_size=args.bin_width,
96
+ pres_range=(args.dmin, args.dmax),
97
+ log_mean_vars=log_mean_vars & set(var_names),
98
+ )
99
+ binned_list.append(binned)
100
+ metadata_list.append({"source": filepath.name})
101
+ ds.close()
102
+
103
+ if not binned_list:
104
+ logger.error("No profiles binned")
105
+ return
106
+
107
+ combined = combine_profiles(binned_list, profile_metadata=metadata_list)
108
+ output_path.parent.mkdir(parents=True, exist_ok=True)
109
+ combined.to_netcdf(output_path)
110
+
111
+ n_prof = combined.sizes.get("profile", 0)
112
+ n_bins = combined.sizes.get("depth_bin", 0)
113
+ print(f"Binned {n_prof} profiles x {n_bins} depth bins -> {output_path.name}")
odas_tpw/pyturb/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ """pyturb-cli: Jesse-compatible CLI for VMP turbulence processing.
2
+
3
+ Provides four subcommands matching Jesse's pyturb interface:
4
+ p2nc — convert .p files to NetCDF
5
+ merge — merge multiple NetCDF files
6
+ eps — compute epsilon (and gradT spectra)
7
+ bin — depth-bin profile results
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import logging
14
+ import sys
15
+
16
+ logger = logging.getLogger("pyturb")
17
+
18
+
19
+ def _add_p2nc(sub: argparse._SubParsersAction) -> None:
20
+ p = sub.add_parser("p2nc", help="Convert .p files to NetCDF")
21
+ p.add_argument("files", nargs="+", help=".p files or glob patterns")
22
+ p.add_argument("-o", "--output", default=".", help="Output directory (default: .)")
23
+ p.add_argument("--compress", action="store_true", default=True, dest="compress")
24
+ p.add_argument("--no-compress", action="store_false", dest="compress")
25
+ p.add_argument("--compression-level", type=int, default=4, help="zlib level 1-9 (default: 4)")
26
+ p.add_argument("-n", "--n-workers", type=int, default=1, help="Parallel workers (default: 1)")
27
+ p.add_argument("--min-file-size", type=int, default=100000, help="Skip files < N bytes")
28
+ p.add_argument("-w", "--overwrite", action="store_true", default=False)
29
+ p.add_argument("-W", "--no-overwrite", action="store_false", dest="overwrite")
30
+ p.set_defaults(func=_run_p2nc)
31
+
32
+
33
+ def _add_merge(sub: argparse._SubParsersAction) -> None:
34
+ p = sub.add_parser("merge", help="Merge NetCDF files along time")
35
+ p.add_argument("files", nargs="+", help=".nc files")
36
+ p.add_argument("-o", "--output", required=True, help="Output file (required)")
37
+ p.add_argument("--dry-run", action="store_true", help="Print summary without writing")
38
+ p.add_argument("-w", "--overwrite", action="store_true", default=False)
39
+ p.add_argument("-W", "--no-overwrite", action="store_false", dest="overwrite")
40
+ p.set_defaults(func=_run_merge)
41
+
42
+
43
+ def _add_eps(sub: argparse._SubParsersAction) -> None:
44
+ p = sub.add_parser("eps", help="Compute epsilon and gradT spectra")
45
+ p.add_argument("files", nargs="+", help=".p or .nc files")
46
+ p.add_argument("-o", "--output", required=True, help="Output directory (required)")
47
+ p.add_argument("-d", "--diss-len", type=float, default=4.0, help="Dissipation window [s]")
48
+ p.add_argument("-f", "--fft-len", type=float, default=1.0, help="FFT segment [s]")
49
+ p.add_argument("-s", "--min-speed", type=float, default=0.2, help="Min profiling speed [m/s]")
50
+ p.add_argument(
51
+ "--pressure-smoothing", type=float, default=0.25, help="Pressure smoothing [s]"
52
+ )
53
+ p.add_argument("-t", "--temperature", default="JAC_T", help="Temperature variable name")
54
+ p.add_argument("--speed", default="W", help="Speed variable name")
55
+ p.add_argument(
56
+ "--direction", choices=["down", "up", "both"], default="down", help="Profile direction"
57
+ )
58
+ p.add_argument("--min-profile-pressure", type=float, default=0.0, help="Min pressure [dbar]")
59
+ p.add_argument("--peaks-height", type=float, default=25.0, help="Peak height [dbar]")
60
+ p.add_argument("--peaks-distance", type=int, default=200, help="Peak distance [samples]")
61
+ p.add_argument("--peaks-prominence", type=float, default=25.0, help="Peak prominence [dbar]")
62
+ p.add_argument("--despike-passes", type=int, default=6, help="Despike passes")
63
+ p.add_argument("-a", "--aux", help="Auxiliary CTD NetCDF file")
64
+ p.add_argument("--aux-lat", default="lat", help="Auxiliary latitude variable")
65
+ p.add_argument("--aux-lon", default="lon", help="Auxiliary longitude variable")
66
+ p.add_argument("--aux-temp", default="temperature", help="Auxiliary temperature variable")
67
+ p.add_argument("--aux-sal", default="salinity", help="Auxiliary salinity variable")
68
+ p.add_argument("--aux-dens", default="density", help="Auxiliary density variable")
69
+ p.add_argument("--salinity", type=float, default=35.0, help="Salinity [PSU] (default: 35)")
70
+ p.add_argument("-n", "--n-workers", type=int, default=1, help="Parallel workers")
71
+ p.add_argument("-w", "--overwrite", action="store_true", default=False)
72
+ p.add_argument("-W", "--no-overwrite", action="store_false", dest="overwrite")
73
+ p.add_argument("--goodman", action="store_true", default=False, dest="goodman",
74
+ help="Enable Goodman coherent noise removal (off by default to match pyturb)")
75
+ p.add_argument("--no-goodman", action="store_false", dest="goodman",
76
+ help="Disable Goodman cleaning (default)")
77
+ p.add_argument("--aoa", type=float, default=None, help="Angle of attack (not implemented)")
78
+ p.add_argument(
79
+ "--pitch-correction", action="store_true", default=False, help="(not implemented)"
80
+ )
81
+ p.set_defaults(func=_run_eps)
82
+
83
+
84
+ def _add_bin(sub: argparse._SubParsersAction) -> None:
85
+ p = sub.add_parser("bin", help="Depth-bin profile results")
86
+ p.add_argument("files", nargs="+", help="Profile .nc files (from eps)")
87
+ p.add_argument("-o", "--output", default="binned_profiles.nc", help="Output file")
88
+ p.add_argument("-b", "--bin-width", type=float, default=2.0, help="Bin width [m]")
89
+ p.add_argument("--dmin", type=float, default=0.0, help="Min depth [m]")
90
+ p.add_argument("--dmax", type=float, default=1000.0, help="Max depth [m]")
91
+ p.add_argument("--lat", type=float, default=45.0, help="Latitude for P-to-depth")
92
+ p.add_argument("-p", "--pressure", action="store_true", help="Bin by pressure instead of depth")
93
+ p.add_argument(
94
+ "-v",
95
+ "--vars",
96
+ default="eps_1,eps_2,W,temperature,salinity,density,nu,lat,lon",
97
+ help="Variables to bin (comma-separated)",
98
+ )
99
+ p.add_argument("-n", "--n-workers", type=int, default=1, help="Parallel workers")
100
+ p.set_defaults(func=_run_bin)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Dispatch wrappers
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _run_p2nc(args: argparse.Namespace) -> None:
109
+ from odas_tpw.pyturb.p2nc import run_p2nc
110
+
111
+ run_p2nc(args)
112
+
113
+
114
+ def _run_merge(args: argparse.Namespace) -> None:
115
+ from odas_tpw.pyturb.merge import run_merge
116
+
117
+ run_merge(args)
118
+
119
+
120
+ def _run_eps(args: argparse.Namespace) -> None:
121
+ if args.aoa is not None:
122
+ logger.warning("--aoa is accepted but not implemented")
123
+ if args.pitch_correction:
124
+ logger.warning("--pitch-correction is accepted but not implemented")
125
+
126
+ from odas_tpw.pyturb.eps import run_eps
127
+
128
+ run_eps(args)
129
+
130
+
131
+ def _run_bin(args: argparse.Namespace) -> None:
132
+ from odas_tpw.pyturb.bin import run_bin
133
+
134
+ run_bin(args)
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Entry point
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> None:
143
+ parser = argparse.ArgumentParser(
144
+ prog="pyturb-cli",
145
+ description="Jesse-compatible VMP processing CLI backed by odas_tpw",
146
+ )
147
+ parser.add_argument(
148
+ "-v", "--verbose", action="count", default=0, help="Increase verbosity (-v, -vv)"
149
+ )
150
+
151
+ sub = parser.add_subparsers(dest="command")
152
+ _add_p2nc(sub)
153
+ _add_merge(sub)
154
+ _add_eps(sub)
155
+ _add_bin(sub)
156
+
157
+ args = parser.parse_args(argv)
158
+
159
+ # Configure logging
160
+ level = logging.WARNING
161
+ if args.verbose >= 2:
162
+ level = logging.DEBUG
163
+ elif args.verbose >= 1:
164
+ level = logging.INFO
165
+ logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
166
+
167
+ if args.command is None:
168
+ parser.print_help()
169
+ sys.exit(1)
170
+
171
+ args.func(args)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
odas_tpw/pyturb/eps.py ADDED
@@ -0,0 +1,353 @@
1
+ """pyturb-cli eps: compute epsilon and temperature gradient spectra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ from concurrent.futures import ProcessPoolExecutor, as_completed
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from odas_tpw.pyturb._compat import (
13
+ check_overwrite,
14
+ load_auxiliary,
15
+ rename_eps_dataset,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _process_one_file(
22
+ filepath: Path,
23
+ output_dir: Path,
24
+ *,
25
+ fft_len_sec: float,
26
+ diss_len_sec: float,
27
+ direction: str,
28
+ min_speed: float,
29
+ min_profile_pressure: float,
30
+ peaks_height: float,
31
+ peaks_distance: int,
32
+ peaks_prominence: float,
33
+ despike_passes: int,
34
+ salinity: float,
35
+ overwrite: bool,
36
+ goodman: bool = False,
37
+ aux_path: str | None = None,
38
+ aux_kwargs: dict | None = None,
39
+ pressure_smoothing: float = 0.25,
40
+ ) -> list[Path]:
41
+ """Process a single file: detect profiles, compute epsilon + gradT spectra."""
42
+ from odas_tpw.pyturb._compat import compute_window_parameters
43
+ from odas_tpw.pyturb._profind import find_profiles_peaks
44
+ from odas_tpw.rsi.adapter import nc_to_l1data, pfile_to_l1data
45
+ from odas_tpw.rsi.p_file import PFile
46
+ from odas_tpw.scor160.io import L2Params, L3Params
47
+
48
+ def _window_for_fs(fs: float):
49
+ """Convert the requested FFT/diss DURATIONS to sample counts at this
50
+ file's actual fs (NOT a fixed 512 Hz), so window durations are correct
51
+ for non-512-Hz instruments."""
52
+ n_fft, n_diss, _fft_ovlp, diss_ovlp = compute_window_parameters(
53
+ fft_len_sec, diss_len_sec, fs
54
+ )
55
+ return n_fft, n_diss, diss_ovlp
56
+
57
+ suffix = filepath.suffix.lower()
58
+ stem = filepath.stem
59
+ output_dir.mkdir(parents=True, exist_ok=True)
60
+
61
+ # Load auxiliary data if provided
62
+ aux_data = None
63
+ if aux_path:
64
+ aux_data = load_auxiliary(aux_path, **(aux_kwargs or {}))
65
+
66
+ l2_params = L2Params(
67
+ HP_cut=0.25,
68
+ despike_sh=np.array([8.0, 0.5, 0.04]),
69
+ despike_A=np.array([np.inf, 0.5, 0.04]),
70
+ profile_min_W=min_speed,
71
+ profile_min_P=min_profile_pressure,
72
+ profile_min_duration=10.0,
73
+ speed_tau=1.5,
74
+ )
75
+
76
+ # Window sample counts are (re)computed per file from its own fs_fast in
77
+ # each branch below; seed with the 512 Hz values so L3Params is valid.
78
+ _n_fft0, _n_diss0, _ovlp0 = _window_for_fs(512.0)
79
+ l3_params = L3Params(
80
+ fft_length=_n_fft0,
81
+ diss_length=_n_diss0,
82
+ overlap=_ovlp0,
83
+ HP_cut=0.25,
84
+ fs_fast=512.0, # overwritten per-file below
85
+ goodman=goodman,
86
+ )
87
+
88
+ output_paths: list[Path] = []
89
+
90
+ if suffix == ".p":
91
+ pf = PFile(filepath)
92
+ fs_fast = pf.fs_fast
93
+ fs_slow = pf.fs_slow
94
+ l3_params.fs_fast = fs_fast
95
+ l3_params.fft_length, l3_params.diss_length, l3_params.overlap = (
96
+ _window_for_fs(fs_fast)
97
+ )
98
+
99
+ # Detect profiles using peak-finding
100
+ P_slow = pf.channels.get("P_dP", pf.channels.get("P"))
101
+ if P_slow is None:
102
+ logger.error(f"{filepath.name}: no pressure channel")
103
+ return []
104
+
105
+ profiles = find_profiles_peaks(
106
+ P_slow,
107
+ fs_slow,
108
+ direction=direction,
109
+ min_pressure=min_profile_pressure,
110
+ peaks_height=peaks_height,
111
+ peaks_distance=peaks_distance,
112
+ peaks_prominence=peaks_prominence,
113
+ min_speed=min_speed,
114
+ smoothing_tau=pressure_smoothing,
115
+ )
116
+
117
+ if not profiles:
118
+ logger.warning(f"{filepath.name}: no profiles detected")
119
+ return []
120
+
121
+ for pi, (s_slow, e_slow) in enumerate(profiles):
122
+ out_path = output_dir / f"{stem}_p{pi:04d}.nc"
123
+ if not check_overwrite(out_path, overwrite):
124
+ logger.info(f"Skipping {out_path.name}: exists")
125
+ continue
126
+
127
+ # Build L1Data for this profile
128
+ l1 = pfile_to_l1data(pf, profile_slice=(s_slow, e_slow), direction=direction)
129
+ ds = _process_profile(l1, l2_params, l3_params, salinity, aux_data)
130
+ if ds is not None:
131
+ ds.attrs["source_file"] = filepath.name
132
+ ds.attrs["profile_index"] = pi
133
+ ds.to_netcdf(out_path)
134
+ output_paths.append(out_path)
135
+ logger.info(f"{out_path.name}: {ds.sizes.get('time', 0)} estimates")
136
+
137
+ elif suffix == ".nc":
138
+ # Load from NetCDF (may be full-record or single-profile)
139
+ l1 = nc_to_l1data(filepath)
140
+ fs_fast = l1.fs_fast
141
+ fs_slow = l1.fs_slow
142
+ l3_params.fs_fast = fs_fast
143
+ l3_params.fft_length, l3_params.diss_length, l3_params.overlap = (
144
+ _window_for_fs(fs_fast)
145
+ )
146
+
147
+ if l1.pres.size == 0:
148
+ logger.error(f"{filepath.name}: no pressure data")
149
+ return []
150
+
151
+ # If pressure range suggests a single profile, process directly
152
+ pres_range = np.nanmax(l1.pres) - np.nanmin(l1.pres)
153
+ if pres_range < peaks_height * 2:
154
+ # Single profile — process entire file
155
+ out_path = output_dir / f"{stem}_p0000.nc"
156
+ if not check_overwrite(out_path, overwrite):
157
+ return []
158
+ ds = _process_profile(l1, l2_params, l3_params, salinity, aux_data)
159
+ if ds is not None:
160
+ ds.attrs["source_file"] = filepath.name
161
+ ds.attrs["profile_index"] = 0
162
+ ds.to_netcdf(out_path)
163
+ output_paths.append(out_path)
164
+ else:
165
+ # Multiple profiles — use peak-finding on slow-rate pressure
166
+ if l1.pres_slow.size > 0:
167
+ P_for_peaks = l1.pres_slow
168
+ fs_peaks = fs_slow if fs_slow > 0 else fs_fast / 8
169
+ else:
170
+ P_for_peaks = l1.pres
171
+ fs_peaks = fs_fast
172
+
173
+ profiles = find_profiles_peaks(
174
+ P_for_peaks,
175
+ fs_peaks,
176
+ direction=direction,
177
+ min_pressure=min_profile_pressure,
178
+ peaks_height=peaks_height,
179
+ peaks_distance=peaks_distance,
180
+ peaks_prominence=peaks_prominence,
181
+ min_speed=min_speed,
182
+ smoothing_tau=pressure_smoothing,
183
+ )
184
+ if not profiles:
185
+ logger.warning(f"{filepath.name}: no profiles detected")
186
+ return []
187
+
188
+ ratio = round(fs_fast / fs_peaks) if fs_peaks < fs_fast else 1
189
+ for pi, (s_slow, e_slow) in enumerate(profiles):
190
+ out_path = output_dir / f"{stem}_p{pi:04d}.nc"
191
+ if not check_overwrite(out_path, overwrite):
192
+ continue
193
+
194
+ # Slice L1Data
195
+ s_fast = s_slow * ratio
196
+ e_fast = min((e_slow + 1) * ratio, l1.n_time)
197
+
198
+ from odas_tpw.scor160.io import L1Data
199
+
200
+ l1_prof = L1Data(
201
+ time=l1.time[s_fast:e_fast],
202
+ pres=l1.pres[s_fast:e_fast],
203
+ shear=l1.shear[:, s_fast:e_fast] if l1.shear.size > 0 else l1.shear,
204
+ vib=l1.vib[:, s_fast:e_fast] if l1.vib.size > 0 else l1.vib,
205
+ vib_type=l1.vib_type,
206
+ fs_fast=l1.fs_fast,
207
+ f_AA=l1.f_AA,
208
+ vehicle=l1.vehicle,
209
+ profile_dir=direction,
210
+ time_reference_year=l1.time_reference_year,
211
+ pspd_rel=l1.pspd_rel[s_fast:e_fast] if l1.pspd_rel.size > 0 else l1.pspd_rel,
212
+ temp=l1.temp[s_fast:e_fast] if l1.temp.size >= e_fast else l1.temp,
213
+ temp_fast=(
214
+ l1.temp_fast[:, s_fast:e_fast]
215
+ if l1.temp_fast.ndim == 2 and l1.temp_fast.shape[1] >= e_fast
216
+ else l1.temp_fast
217
+ ),
218
+ diff_gains=l1.diff_gains,
219
+ )
220
+
221
+ ds = _process_profile(l1_prof, l2_params, l3_params, salinity, aux_data)
222
+ if ds is not None:
223
+ ds.attrs["source_file"] = filepath.name
224
+ ds.attrs["profile_index"] = pi
225
+ ds.to_netcdf(out_path)
226
+ output_paths.append(out_path)
227
+ logger.info(f"{out_path.name}: {ds.sizes.get('time', 0)} estimates")
228
+ else:
229
+ logger.error(f"{filepath.name}: unsupported format {suffix}")
230
+
231
+ return output_paths
232
+
233
+
234
+ def _process_profile(l1, l2_params, l3_params, salinity, aux_data):
235
+ """Run the shear + chi processing chain on a single profile L1Data."""
236
+ from odas_tpw.chi.l2_chi import process_l2_chi
237
+ from odas_tpw.chi.l3_chi import process_l3_chi
238
+ from odas_tpw.scor160.l2 import process_l2
239
+ from odas_tpw.scor160.l3 import process_l3
240
+ from odas_tpw.scor160.l4 import process_l4
241
+
242
+ try:
243
+ l2 = process_l2(l1, l2_params)
244
+ l3 = process_l3(l2, l1, l3_params)
245
+ l4 = process_l4(l3, temp=l3.temp, f_AA=l1.f_AA)
246
+ except Exception as e:
247
+ logger.error(f"Shear processing failed: {e}")
248
+ return None
249
+
250
+ if l4.n_spectra == 0:
251
+ return None
252
+
253
+ # Chi chain (temperature gradient spectra)
254
+ l3_chi = None
255
+ if l1.has_temp_fast:
256
+ try:
257
+ l2_chi = process_l2_chi(l1, l2)
258
+ l3_chi = process_l3_chi(l2_chi, l3_params, salinity=salinity)
259
+ except Exception as e:
260
+ logger.warning(f"Chi processing failed (continuing without gradT): {e}")
261
+
262
+ return rename_eps_dataset(
263
+ l4,
264
+ l3,
265
+ l3_chi,
266
+ aux_data=aux_data,
267
+ fs_fast=l1.fs_fast,
268
+ salinity=salinity,
269
+ )
270
+
271
+
272
+ def run_eps(args: argparse.Namespace) -> None:
273
+ """Execute the eps subcommand."""
274
+ output_dir = Path(args.output)
275
+ output_dir.mkdir(parents=True, exist_ok=True)
276
+
277
+ # Collect input files
278
+ paths: list[Path] = []
279
+ for pattern in args.files:
280
+ p = Path(pattern)
281
+ if p.is_file():
282
+ paths.append(p)
283
+ else:
284
+ paths.extend(sorted(Path(".").glob(pattern)))
285
+
286
+ if not paths:
287
+ logger.error("No input files found")
288
+ return
289
+
290
+ # Warn about accepted-but-unimplemented flags rather than silently ignoring
291
+ # them (audit 2026-07-01): --despike-passes never reaches despike() (which
292
+ # runs the scor160 default of 10 passes), and --temperature/--speed are read
293
+ # nowhere.
294
+ if getattr(args, "despike_passes", None) not in (None, 10):
295
+ logger.warning(
296
+ "--despike-passes=%s is not implemented; despike runs the scor160 "
297
+ "default (10 passes)", args.despike_passes,
298
+ )
299
+ if getattr(args, "temperature", None) is not None:
300
+ logger.warning("-t/--temperature is not implemented and is ignored")
301
+ if getattr(args, "speed", None) is not None:
302
+ logger.warning("--speed is not implemented and is ignored")
303
+
304
+ # FFT/diss durations (seconds) are converted to sample counts PER FILE using
305
+ # that file's own fs_fast inside _process_one_file, so window durations are
306
+ # correct for non-512-Hz instruments.
307
+ common_kwargs = dict(
308
+ fft_len_sec=args.fft_len,
309
+ diss_len_sec=args.diss_len,
310
+ goodman=args.goodman,
311
+ direction=args.direction,
312
+ min_speed=args.min_speed,
313
+ min_profile_pressure=args.min_profile_pressure,
314
+ peaks_height=args.peaks_height,
315
+ peaks_distance=args.peaks_distance,
316
+ peaks_prominence=args.peaks_prominence,
317
+ despike_passes=args.despike_passes,
318
+ salinity=args.salinity,
319
+ overwrite=args.overwrite,
320
+ pressure_smoothing=args.pressure_smoothing,
321
+ )
322
+
323
+ if args.aux:
324
+ common_kwargs["aux_path"] = args.aux
325
+ common_kwargs["aux_kwargs"] = {
326
+ "lat_var": args.aux_lat,
327
+ "lon_var": args.aux_lon,
328
+ "temp_var": args.aux_temp,
329
+ "sal_var": args.aux_sal,
330
+ "dens_var": args.aux_dens,
331
+ }
332
+
333
+ if args.n_workers <= 1:
334
+ for filepath in paths:
335
+ try:
336
+ out_paths = _process_one_file(filepath, output_dir, **common_kwargs)
337
+ for op in out_paths:
338
+ print(f" {op.name}")
339
+ except Exception as e:
340
+ logger.error(f"{filepath.name}: {e}")
341
+ else:
342
+ with ProcessPoolExecutor(max_workers=args.n_workers) as pool:
343
+ futures = {
344
+ pool.submit(_process_one_file, fp, output_dir, **common_kwargs): fp for fp in paths
345
+ }
346
+ for future in as_completed(futures):
347
+ fp = futures[future]
348
+ try:
349
+ out_paths = future.result()
350
+ for op in out_paths:
351
+ print(f" {op.name}")
352
+ except Exception as e:
353
+ logger.error(f"{fp.name}: {e}")