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
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}")
|