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,181 @@
|
|
|
1
|
+
# May-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Logging fan-out for perturb runs.
|
|
3
|
+
|
|
4
|
+
Three layers, each writing to a separate ``FileHandler`` on the root
|
|
5
|
+
logger so a single ``logger.info(...)`` call lands in every relevant log:
|
|
6
|
+
|
|
7
|
+
* **Per-CLI-invocation** — ``<output_root>/logs/run_<timestamp>.log``,
|
|
8
|
+
always written. ``--stdout`` adds a stderr ``StreamHandler`` so the
|
|
9
|
+
user can also watch progress on the terminal.
|
|
10
|
+
* **Per-worker** —
|
|
11
|
+
``<output_root>/logs/worker_<runtimestamp>_<pid>.log``, installed by
|
|
12
|
+
``init_worker_logging`` from a ``ProcessPoolExecutor`` initializer so
|
|
13
|
+
each worker writes its own file (the spawn-context default on macOS
|
|
14
|
+
doesn't inherit handlers).
|
|
15
|
+
* **Per-stage scope** — ``stage_log(stage_dir, basename)`` is a context
|
|
16
|
+
manager that adds and removes a ``FileHandler`` for the duration of a
|
|
17
|
+
block, so e.g. all log records emitted while processing ``a.p``'s
|
|
18
|
+
diss stage land in ``diss_NN/a.log`` *as well as* the worker and run
|
|
19
|
+
logs.
|
|
20
|
+
|
|
21
|
+
The handlers stack: a single record fans out to whichever handlers are
|
|
22
|
+
attached at emission time.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from collections.abc import Iterator
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
_FILE_FORMAT = "%(asctime)s %(levelname)-7s %(name)s [%(processName)s]: %(message)s"
|
|
37
|
+
_STREAM_FORMAT = "%(message)s"
|
|
38
|
+
_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
39
|
+
|
|
40
|
+
_FILE_FORMATTER = logging.Formatter(_FILE_FORMAT, datefmt=_DATE_FORMAT)
|
|
41
|
+
_STREAM_FORMATTER = logging.Formatter(_STREAM_FORMAT)
|
|
42
|
+
|
|
43
|
+
# Marker attribute: handlers we install carry this so repeat calls to
|
|
44
|
+
# setup_root_logging can replace their own handlers without touching
|
|
45
|
+
# anything the user (e.g. a pytest plugin) attached.
|
|
46
|
+
_OWNED_ATTR = "_perturb_logging_owned"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_timestamp() -> str:
|
|
50
|
+
"""Return a UTC timestamp suitable for log file names (no separators that
|
|
51
|
+
confuse shells)."""
|
|
52
|
+
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_CURRENT_RUN_STAMP: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current_run_stamp() -> str:
|
|
59
|
+
"""Return the run stamp for this process, generating one on first call.
|
|
60
|
+
|
|
61
|
+
Used by the CLI for the per-invocation log file name and by
|
|
62
|
+
:mod:`pipeline` to seed worker log file names so all logs from a
|
|
63
|
+
single ``perturb`` invocation share the same prefix.
|
|
64
|
+
"""
|
|
65
|
+
global _CURRENT_RUN_STAMP
|
|
66
|
+
if _CURRENT_RUN_STAMP is None:
|
|
67
|
+
_CURRENT_RUN_STAMP = run_timestamp()
|
|
68
|
+
return _CURRENT_RUN_STAMP
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reset_run_stamp() -> None:
|
|
72
|
+
"""Test helper: clear the cached run stamp."""
|
|
73
|
+
global _CURRENT_RUN_STAMP
|
|
74
|
+
_CURRENT_RUN_STAMP = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _mark_owned(handler: logging.Handler) -> logging.Handler:
|
|
78
|
+
setattr(handler, _OWNED_ATTR, True)
|
|
79
|
+
return handler
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _drop_owned_handlers(logger: logging.Logger) -> None:
|
|
83
|
+
for h in list(logger.handlers):
|
|
84
|
+
if getattr(h, _OWNED_ATTR, False):
|
|
85
|
+
logger.removeHandler(h)
|
|
86
|
+
with contextlib.suppress(Exception):
|
|
87
|
+
h.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def setup_root_logging(
|
|
91
|
+
log_file: Path,
|
|
92
|
+
*,
|
|
93
|
+
level: int = logging.INFO,
|
|
94
|
+
stdout: bool = False,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Configure the root logger for a CLI invocation.
|
|
97
|
+
|
|
98
|
+
Always installs a ``FileHandler`` at *log_file* (parent dir is
|
|
99
|
+
created). When *stdout* is True, also installs a stderr
|
|
100
|
+
``StreamHandler``.
|
|
101
|
+
|
|
102
|
+
Calling this twice in the same process is safe — handlers installed
|
|
103
|
+
by previous calls are removed before new ones are added. Handlers
|
|
104
|
+
attached by other code (e.g. a test fixture's ``caplog``) are left
|
|
105
|
+
alone.
|
|
106
|
+
"""
|
|
107
|
+
log_file = Path(log_file)
|
|
108
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
root = logging.getLogger()
|
|
111
|
+
root.setLevel(level)
|
|
112
|
+
|
|
113
|
+
_drop_owned_handlers(root)
|
|
114
|
+
|
|
115
|
+
fh = logging.FileHandler(log_file)
|
|
116
|
+
fh.setLevel(level)
|
|
117
|
+
fh.setFormatter(_FILE_FORMATTER)
|
|
118
|
+
root.addHandler(_mark_owned(fh))
|
|
119
|
+
|
|
120
|
+
if stdout:
|
|
121
|
+
sh = logging.StreamHandler(sys.stderr)
|
|
122
|
+
sh.setLevel(level)
|
|
123
|
+
sh.setFormatter(_STREAM_FORMATTER)
|
|
124
|
+
root.addHandler(_mark_owned(sh))
|
|
125
|
+
|
|
126
|
+
return log_file
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def init_worker_logging(log_dir: Path, run_stamp: str) -> Path:
|
|
130
|
+
"""Install a per-worker ``FileHandler`` on the root logger.
|
|
131
|
+
|
|
132
|
+
Designed for ``ProcessPoolExecutor(initializer=init_worker_logging,
|
|
133
|
+
initargs=(log_dir, run_stamp))``. Each spawn-context worker runs
|
|
134
|
+
this exactly once; ``run_stamp`` is the parent run's timestamp so
|
|
135
|
+
all workers from one invocation share a prefix and old runs aren't
|
|
136
|
+
overwritten.
|
|
137
|
+
"""
|
|
138
|
+
log_dir = Path(log_dir)
|
|
139
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
log_file = log_dir / f"worker_{run_stamp}_{os.getpid()}.log"
|
|
141
|
+
|
|
142
|
+
root = logging.getLogger()
|
|
143
|
+
root.setLevel(logging.INFO)
|
|
144
|
+
_drop_owned_handlers(root)
|
|
145
|
+
|
|
146
|
+
fh = logging.FileHandler(log_file)
|
|
147
|
+
fh.setLevel(logging.INFO)
|
|
148
|
+
fh.setFormatter(_FILE_FORMATTER)
|
|
149
|
+
root.addHandler(_mark_owned(fh))
|
|
150
|
+
|
|
151
|
+
return log_file
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def stage_log(stage_dir: Path | None, basename: str) -> Iterator[Path | None]:
|
|
156
|
+
"""Add a ``FileHandler`` at ``<stage_dir>/<basename>.log`` for the
|
|
157
|
+
duration of the block; remove and close on exit.
|
|
158
|
+
|
|
159
|
+
A no-op when *stage_dir* is None — lets call sites use the same
|
|
160
|
+
``with`` construct whether or not a stage is enabled.
|
|
161
|
+
"""
|
|
162
|
+
if stage_dir is None:
|
|
163
|
+
yield None
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
stage_dir = Path(stage_dir)
|
|
167
|
+
stage_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
log_file = stage_dir / f"{basename}.log"
|
|
169
|
+
|
|
170
|
+
handler = logging.FileHandler(log_file)
|
|
171
|
+
handler.setLevel(logging.INFO)
|
|
172
|
+
handler.setFormatter(_FILE_FORMATTER)
|
|
173
|
+
|
|
174
|
+
root = logging.getLogger()
|
|
175
|
+
root.addHandler(handler)
|
|
176
|
+
try:
|
|
177
|
+
yield log_file
|
|
178
|
+
finally:
|
|
179
|
+
root.removeHandler(handler)
|
|
180
|
+
with contextlib.suppress(Exception):
|
|
181
|
+
handler.close()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Merge split .p files that were rolled over due to file size limits.
|
|
3
|
+
|
|
4
|
+
Files are mergeable when they share the same configuration (config string
|
|
5
|
+
hash), endianness, and record size, and have sequential file numbers.
|
|
6
|
+
|
|
7
|
+
Reference: Code/merge_p_files.m
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import struct
|
|
15
|
+
import tempfile
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from odas_tpw.rsi.p_file import _H, HEADER_BYTES, HEADER_WORDS, _detect_endian
|
|
20
|
+
|
|
21
|
+
# Max gap between a chained file's start and the previous file's computed end
|
|
22
|
+
# for them to count as a genuine size-limit rollover (vs two independent casts).
|
|
23
|
+
_MERGE_GAP_TOL_S = 5.0
|
|
24
|
+
|
|
25
|
+
# I/O chunk size for the bounded record-aligned copy in merge_p_files.
|
|
26
|
+
_COPY_CHUNK = 1 << 20 # 1 MiB
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _copy_n(in_f, out_f, length: int) -> None:
|
|
30
|
+
"""Copy exactly *length* bytes from *in_f* to *out_f* (record-aligned copy).
|
|
31
|
+
|
|
32
|
+
Unlike shutil.copyfileobj, this stops after *length* bytes so a fractional
|
|
33
|
+
trailing record in the source is never appended (audit #96).
|
|
34
|
+
"""
|
|
35
|
+
remaining = length
|
|
36
|
+
while remaining > 0:
|
|
37
|
+
chunk = in_f.read(min(_COPY_CHUNK, remaining))
|
|
38
|
+
if not chunk:
|
|
39
|
+
break # source shorter than expected; copy what exists
|
|
40
|
+
out_f.write(chunk)
|
|
41
|
+
remaining -= len(chunk)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_merge_info(path: Path) -> dict:
|
|
45
|
+
"""Read header fields needed for merge decisions."""
|
|
46
|
+
with open(path, "rb") as f:
|
|
47
|
+
raw_hdr = f.read(HEADER_BYTES)
|
|
48
|
+
if len(raw_hdr) < HEADER_BYTES:
|
|
49
|
+
raise ValueError(f"{path.name}: file too small for header")
|
|
50
|
+
|
|
51
|
+
endian = _detect_endian(raw_hdr, path)
|
|
52
|
+
fmt = f"{endian}{HEADER_WORDS}H"
|
|
53
|
+
words = struct.unpack(fmt, raw_hdr)
|
|
54
|
+
header = {name: words[idx] for name, idx in _H.items()}
|
|
55
|
+
|
|
56
|
+
header_size = header["header_size"]
|
|
57
|
+
config_size = header["config_size"]
|
|
58
|
+
record_size = header["record_size"]
|
|
59
|
+
file_number = header["file_number"]
|
|
60
|
+
|
|
61
|
+
# Read config string to compute hash for matching
|
|
62
|
+
f.seek(header_size)
|
|
63
|
+
config_bytes = f.read(config_size)
|
|
64
|
+
config_hash = hashlib.md5(config_bytes).hexdigest()
|
|
65
|
+
|
|
66
|
+
f.seek(0, 2)
|
|
67
|
+
file_size = f.tell()
|
|
68
|
+
|
|
69
|
+
# Recording start time and duration, for the rollover-continuity check.
|
|
70
|
+
# Both are None when the header lacks a valid date / clock geometry (e.g.
|
|
71
|
+
# a synthetic or corrupt header) — in that case continuity is treated as
|
|
72
|
+
# unverifiable and does not block a merge.
|
|
73
|
+
try:
|
|
74
|
+
start_dt: datetime | None = datetime(
|
|
75
|
+
header["year"], header["month"], header["day"],
|
|
76
|
+
header["hour"], header["minute"], header["second"],
|
|
77
|
+
header["millisecond"] * 1000,
|
|
78
|
+
)
|
|
79
|
+
except (ValueError, OverflowError):
|
|
80
|
+
start_dt = None
|
|
81
|
+
|
|
82
|
+
duration: float | None = None
|
|
83
|
+
n_cols = header["fast_cols"] + header["slow_cols"]
|
|
84
|
+
clock = header["clock_hz"] + header["clock_frac"] / 1000.0
|
|
85
|
+
if start_dt is not None and n_cols > 0 and clock > 0 and record_size > header_size:
|
|
86
|
+
fs_fast = clock / n_cols
|
|
87
|
+
n_records = (file_size - (header_size + config_size)) // record_size
|
|
88
|
+
scans_per_record = ((record_size - header_size) // 2) // n_cols
|
|
89
|
+
if fs_fast > 0 and n_records > 0 and scans_per_record > 0:
|
|
90
|
+
duration = (n_records * scans_per_record) / fs_fast
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"path": path,
|
|
94
|
+
"file_number": file_number,
|
|
95
|
+
"endian": endian,
|
|
96
|
+
"header_size": header_size,
|
|
97
|
+
"config_size": config_size,
|
|
98
|
+
"record_size": record_size,
|
|
99
|
+
"config_hash": config_hash,
|
|
100
|
+
"file_size": file_size,
|
|
101
|
+
"start_dt": start_dt,
|
|
102
|
+
"duration": duration,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_continuous(prev: dict, nxt: dict, tol: float = _MERGE_GAP_TOL_S) -> bool:
|
|
107
|
+
"""True if *nxt* starts within *tol* seconds of where *prev* ended.
|
|
108
|
+
|
|
109
|
+
A size-limit rollover continuation begins essentially where the previous
|
|
110
|
+
file ended; two independent casts are minutes/hours apart. When either
|
|
111
|
+
file's start time or the previous file's duration cannot be computed,
|
|
112
|
+
continuity is unverifiable and we return True (do not block the merge on
|
|
113
|
+
missing metadata — the config/geometry/sequence checks still apply).
|
|
114
|
+
"""
|
|
115
|
+
if prev["start_dt"] is None or nxt["start_dt"] is None or prev["duration"] is None:
|
|
116
|
+
return True
|
|
117
|
+
prev_end = prev["start_dt"] + timedelta(seconds=prev["duration"])
|
|
118
|
+
return bool(abs((nxt["start_dt"] - prev_end).total_seconds()) <= tol)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _file_group_key(info: dict) -> tuple:
|
|
122
|
+
"""Key for grouping files that could potentially be merged.
|
|
123
|
+
|
|
124
|
+
header_size and config_size are part of the key, not just record_size:
|
|
125
|
+
the merged file is reparsed with the FIRST file's geometry (PFile._read
|
|
126
|
+
slices every record as header_size//2 header words + data words), but the
|
|
127
|
+
splice skips each continuation file's own header_size + config_size. If a
|
|
128
|
+
chained file's header_size differed, its records would be silently
|
|
129
|
+
mis-sliced. Keying on the full geometry keeps geometry-mismatched files in
|
|
130
|
+
separate groups so they never chain.
|
|
131
|
+
"""
|
|
132
|
+
return (
|
|
133
|
+
info["config_hash"],
|
|
134
|
+
info["endian"],
|
|
135
|
+
info["record_size"],
|
|
136
|
+
info["header_size"],
|
|
137
|
+
info["config_size"],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def find_mergeable_files(p_files: list[Path]) -> list[list[Path]]:
|
|
142
|
+
"""Identify chains of split files that should be merged.
|
|
143
|
+
|
|
144
|
+
Files are chained when they share the same config hash, endianness,
|
|
145
|
+
and record size, and have sequential file numbers.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
p_files : list of Path
|
|
150
|
+
.p file paths (typically from :func:`perturb.discover.find_p_files`).
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
list of list of Path
|
|
155
|
+
Each inner list is a chain of files to merge (length >= 2).
|
|
156
|
+
Single files (no merge partner) are not included.
|
|
157
|
+
"""
|
|
158
|
+
if len(p_files) < 2:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
# Read headers
|
|
162
|
+
infos = []
|
|
163
|
+
for p in p_files:
|
|
164
|
+
try:
|
|
165
|
+
infos.append(_read_merge_info(p))
|
|
166
|
+
except (ValueError, OSError):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Group by matching properties
|
|
170
|
+
groups: dict[tuple, list[dict]] = {}
|
|
171
|
+
for info in infos:
|
|
172
|
+
key = _file_group_key(info)
|
|
173
|
+
groups.setdefault(key, []).append(info)
|
|
174
|
+
|
|
175
|
+
chains = []
|
|
176
|
+
for group in groups.values():
|
|
177
|
+
if len(group) < 2:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Sort by file number
|
|
181
|
+
group.sort(key=lambda x: x["file_number"])
|
|
182
|
+
|
|
183
|
+
# Build chains of sequential file numbers
|
|
184
|
+
chain = [group[0]]
|
|
185
|
+
for i in range(1, len(group)):
|
|
186
|
+
if group[i]["file_number"] == chain[-1]["file_number"] + 1 and _is_continuous(
|
|
187
|
+
chain[-1], group[i]
|
|
188
|
+
):
|
|
189
|
+
chain.append(group[i])
|
|
190
|
+
else:
|
|
191
|
+
if len(chain) >= 2:
|
|
192
|
+
chains.append([info["path"] for info in chain])
|
|
193
|
+
chain = [group[i]]
|
|
194
|
+
if len(chain) >= 2:
|
|
195
|
+
chains.append([info["path"] for info in chain])
|
|
196
|
+
|
|
197
|
+
return chains
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def merge_destination(
|
|
201
|
+
chain: list[Path],
|
|
202
|
+
output_dir: Path,
|
|
203
|
+
root: Path | str | None = None,
|
|
204
|
+
) -> Path:
|
|
205
|
+
"""Return the merged output path for *chain*.
|
|
206
|
+
|
|
207
|
+
The first file in the chain names the merged file. When *root* is
|
|
208
|
+
supplied and that first file lives underneath it, the relative directory
|
|
209
|
+
structure is preserved under *output_dir*.
|
|
210
|
+
"""
|
|
211
|
+
if not chain:
|
|
212
|
+
raise ValueError("Empty chain")
|
|
213
|
+
first = Path(chain[0])
|
|
214
|
+
output_dir = Path(output_dir)
|
|
215
|
+
if root is not None:
|
|
216
|
+
try:
|
|
217
|
+
rel = first.resolve().relative_to(Path(root).resolve())
|
|
218
|
+
except ValueError:
|
|
219
|
+
rel = Path(first.name)
|
|
220
|
+
return output_dir / rel
|
|
221
|
+
return output_dir / first.name
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def plan_merge_outputs(
|
|
225
|
+
p_files: list[Path],
|
|
226
|
+
output_dir: Path,
|
|
227
|
+
root: Path | str | None = None,
|
|
228
|
+
) -> list[tuple[Path, list[Path]]]:
|
|
229
|
+
"""Plan merge outputs for all inputs.
|
|
230
|
+
|
|
231
|
+
Returns ``(output_path, source_chain)`` pairs. Mergeable chains map to a
|
|
232
|
+
new output path; non-mergeable singleton files map to themselves so the
|
|
233
|
+
caller can keep processing them unchanged.
|
|
234
|
+
"""
|
|
235
|
+
chains = find_mergeable_files(p_files)
|
|
236
|
+
chain_members = {p.resolve() for chain in chains for p in chain}
|
|
237
|
+
|
|
238
|
+
plan: list[tuple[Path, list[Path]]] = []
|
|
239
|
+
for chain in chains:
|
|
240
|
+
plan.append((merge_destination(chain, output_dir, root=root), chain))
|
|
241
|
+
for p in p_files:
|
|
242
|
+
if p.resolve() not in chain_members:
|
|
243
|
+
plan.append((p, [p]))
|
|
244
|
+
return plan
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def merge_p_files(
|
|
248
|
+
chain: list[Path],
|
|
249
|
+
output_dir: Path,
|
|
250
|
+
root: Path | str | None = None,
|
|
251
|
+
) -> Path:
|
|
252
|
+
"""Merge a chain of split .p files into a single file.
|
|
253
|
+
|
|
254
|
+
The first file is copied in its entirety. Subsequent files contribute
|
|
255
|
+
only their data records (header + config are skipped).
|
|
256
|
+
|
|
257
|
+
Parameters
|
|
258
|
+
----------
|
|
259
|
+
chain : list of Path
|
|
260
|
+
Ordered list of .p files to merge (first file is the base).
|
|
261
|
+
output_dir : Path
|
|
262
|
+
Directory for the merged output file.
|
|
263
|
+
root : Path, optional
|
|
264
|
+
Source root used to preserve relative paths under *output_dir*.
|
|
265
|
+
|
|
266
|
+
Returns
|
|
267
|
+
-------
|
|
268
|
+
Path
|
|
269
|
+
Path to the merged output file (named after the first file in the chain).
|
|
270
|
+
"""
|
|
271
|
+
if not chain:
|
|
272
|
+
raise ValueError("Empty chain")
|
|
273
|
+
|
|
274
|
+
dest = merge_destination(chain, output_dir, root=root)
|
|
275
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
|
|
277
|
+
if len(chain) == 1:
|
|
278
|
+
if dest.resolve() != chain[0].resolve():
|
|
279
|
+
shutil.copy2(chain[0], dest)
|
|
280
|
+
return dest
|
|
281
|
+
|
|
282
|
+
# Build the merged file in a temp file then atomically replace. If dest
|
|
283
|
+
# resolves to chain[0] (output_dir == the chain's own directory), opening
|
|
284
|
+
# dest "wb" directly would truncate the base file to 0 bytes BEFORE it is
|
|
285
|
+
# read — yielding a headerless, unparseable file and destroying the source.
|
|
286
|
+
# Writing via a separate temp fd keeps every source intact until os.replace.
|
|
287
|
+
fd, tmp = tempfile.mkstemp(dir=str(dest.parent), prefix=".merge-", suffix=".tmp")
|
|
288
|
+
try:
|
|
289
|
+
with os.fdopen(fd, "wb") as out_f:
|
|
290
|
+
# Defect (audit #96): the splice previously copied each member to
|
|
291
|
+
# EOF, so a fractional trailing record (e.g. when files.trim is off
|
|
292
|
+
# or a non-final file rolled over mid-record) was appended verbatim
|
|
293
|
+
# and shifted every subsequent file's records by a sub-record
|
|
294
|
+
# offset, silently mis-slicing the merged file's interior records.
|
|
295
|
+
# Copy only the integer-record prefix of every member so the
|
|
296
|
+
# merged geometry stays record-aligned regardless of trim setting.
|
|
297
|
+
for i, p in enumerate(chain):
|
|
298
|
+
info = _read_merge_info(p)
|
|
299
|
+
data_start = info["header_size"] + info["config_size"]
|
|
300
|
+
record_size = info["record_size"]
|
|
301
|
+
if record_size <= 0:
|
|
302
|
+
raise ValueError(f"{p.name}: invalid record_size {record_size}")
|
|
303
|
+
data_bytes = info["file_size"] - data_start
|
|
304
|
+
if data_bytes < 0:
|
|
305
|
+
raise ValueError(f"{p.name}: file shorter than header+config")
|
|
306
|
+
n_complete = data_bytes // record_size
|
|
307
|
+
# Base file (i == 0) keeps its header+config + complete records;
|
|
308
|
+
# continuations contribute only their complete data records.
|
|
309
|
+
copy_start = 0 if i == 0 else data_start
|
|
310
|
+
copy_len = (data_start - copy_start) + n_complete * record_size
|
|
311
|
+
with open(p, "rb") as in_f:
|
|
312
|
+
in_f.seek(copy_start)
|
|
313
|
+
_copy_n(in_f, out_f, copy_len)
|
|
314
|
+
os.replace(tmp, dest)
|
|
315
|
+
except BaseException:
|
|
316
|
+
with contextlib.suppress(OSError):
|
|
317
|
+
os.unlink(tmp)
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
return dest
|