pyadps 0.1.3__py3-none-any.whl → 0.1.4__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.
- pyadps/pages/09_Add-Ons.py +168 -0
- pyadps/utils/__init__.py +2 -0
- pyadps/utils/logging_utils.py +269 -0
- pyadps/utils/multifile.py +244 -0
- {pyadps-0.1.3.dist-info → pyadps-0.1.4.dist-info}/METADATA +1 -1
- {pyadps-0.1.3.dist-info → pyadps-0.1.4.dist-info}/RECORD +9 -7
- pyadps/pages/09_Auto_process.py +0 -64
- {pyadps-0.1.3.dist-info → pyadps-0.1.4.dist-info}/LICENSE +0 -0
- {pyadps-0.1.3.dist-info → pyadps-0.1.4.dist-info}/WHEEL +0 -0
- {pyadps-0.1.3.dist-info → pyadps-0.1.4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,168 @@
|
|
1
|
+
import os
|
2
|
+
import tempfile
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import re
|
6
|
+
import io
|
7
|
+
import contextlib
|
8
|
+
|
9
|
+
import configparser
|
10
|
+
import streamlit as st
|
11
|
+
from utils.autoprocess import autoprocess
|
12
|
+
from utils.multifile import ADCPBinFileCombiner
|
13
|
+
|
14
|
+
# To make the page wider if the user presses the reload button.
|
15
|
+
st.set_page_config(layout="wide")
|
16
|
+
|
17
|
+
|
18
|
+
def ansi_to_html(text):
|
19
|
+
"""
|
20
|
+
Function to convert ANSI (console color) to HTML.
|
21
|
+
To display the text, map the output to st.markdown
|
22
|
+
"""
|
23
|
+
text = re.sub(r"\x1b\[31m", "<span style='color:red'><br>", text) # red
|
24
|
+
text = re.sub(r"\x1b\[32m", "<span style='color:green'><br>", text) # red
|
25
|
+
text = re.sub(r"\x1b\[33m", "<span style='color:orange'><br>", text) # green
|
26
|
+
text = re.sub(r"\x1b\[0m", "</span>", text) # reset
|
27
|
+
return text
|
28
|
+
|
29
|
+
|
30
|
+
@st.cache_data
|
31
|
+
def file_access(uploaded_file):
|
32
|
+
"""
|
33
|
+
Function creates temporary directory to store the uploaded file.
|
34
|
+
The path of the file is returned
|
35
|
+
|
36
|
+
Args:
|
37
|
+
uploaded_file (string): Name of the uploaded file
|
38
|
+
|
39
|
+
Returns:
|
40
|
+
path (string): Path of the uploaded file
|
41
|
+
"""
|
42
|
+
temp_dir = tempfile.mkdtemp()
|
43
|
+
path = os.path.join(temp_dir, uploaded_file.name)
|
44
|
+
with open(path, "wb") as f:
|
45
|
+
f.write(uploaded_file.getvalue())
|
46
|
+
return path
|
47
|
+
|
48
|
+
|
49
|
+
def display_config_as_json(config_file):
|
50
|
+
config = configparser.ConfigParser()
|
51
|
+
config.read_string(config_file.getvalue().decode("utf-8"))
|
52
|
+
st.json({section: dict(config[section]) for section in config.sections()})
|
53
|
+
|
54
|
+
|
55
|
+
def main():
|
56
|
+
st.title("🧰 Add-Ons")
|
57
|
+
st.header("🔧 Auto Processing Tool", divider=True)
|
58
|
+
st.write(
|
59
|
+
"You can use a configuration file from `pyadps` to re-process ADCP data by simply adjusting threshold values within the file. "
|
60
|
+
"This allows you to fine-tune the output without repeating the full processing workflow in the software."
|
61
|
+
)
|
62
|
+
st.write(
|
63
|
+
"To begin, upload both a binary ADCP file and a `config.ini` file for processing."
|
64
|
+
)
|
65
|
+
|
66
|
+
# File Upload Section
|
67
|
+
uploaded_binary_file = st.file_uploader(
|
68
|
+
"Upload ADCP Binary File", type=["000", "bin"]
|
69
|
+
)
|
70
|
+
uploaded_config_file = st.file_uploader(
|
71
|
+
"Upload Config File (config.ini)", type=["ini"]
|
72
|
+
)
|
73
|
+
|
74
|
+
if uploaded_binary_file and uploaded_config_file:
|
75
|
+
st.success("Files uploaded successfully!")
|
76
|
+
|
77
|
+
# Display config.ini file content as JSON
|
78
|
+
display_config_as_json(uploaded_config_file)
|
79
|
+
|
80
|
+
fpath = file_access(uploaded_binary_file)
|
81
|
+
# Process files
|
82
|
+
with st.spinner("Processing files. Please wait..."):
|
83
|
+
autoprocess(uploaded_config_file, binary_file_path=fpath)
|
84
|
+
st.success("Processing completed successfully!")
|
85
|
+
st.write("Processed file written.")
|
86
|
+
|
87
|
+
st.header("🔗 Binary File Combiner", divider=True)
|
88
|
+
st.write(
|
89
|
+
"ADCPs may produce multiple binary segments instead of a single continuous file. "
|
90
|
+
"This tool scans each uploaded binary file for the `7f7f` header, removes any broken ensembles at the beginning or the end, and combines all valid segments into a single file. "
|
91
|
+
"To ensure correct order during concatenation, please rename the files using sequential numbering. "
|
92
|
+
"For example: `KKS_000.000`, `KKS_001.000`, `KKS_002.000`."
|
93
|
+
)
|
94
|
+
output_cat_filename = "merged_000.000"
|
95
|
+
st.info(f"Current file name: **{output_cat_filename}**")
|
96
|
+
output_cat_filename_radio = st.radio(
|
97
|
+
"Would you like to edit the output filename?",
|
98
|
+
["No", "Yes"],
|
99
|
+
horizontal=True,
|
100
|
+
)
|
101
|
+
if output_cat_filename_radio == "Yes":
|
102
|
+
output_cat_filename = st.text_input(
|
103
|
+
"Enter file name (e.g., GD10A000)",
|
104
|
+
value=output_cat_filename,
|
105
|
+
)
|
106
|
+
|
107
|
+
display_log = st.radio(
|
108
|
+
"Display log from console:",
|
109
|
+
["No", "Yes"],
|
110
|
+
horizontal=True,
|
111
|
+
)
|
112
|
+
|
113
|
+
uploaded_files = st.file_uploader(
|
114
|
+
"Upload multiple binary files", type=["bin", "000"], accept_multiple_files=True
|
115
|
+
)
|
116
|
+
|
117
|
+
if uploaded_files:
|
118
|
+
st.info("Saving uploaded files to temporary disk files...")
|
119
|
+
|
120
|
+
# Save files to temporary path
|
121
|
+
temp_file_paths = []
|
122
|
+
for uploaded_file in uploaded_files:
|
123
|
+
suffix = Path(uploaded_file.name).suffix
|
124
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
125
|
+
tmp.write(uploaded_file.read())
|
126
|
+
temp_path = Path(tmp.name)
|
127
|
+
temp_file_paths.append(temp_path)
|
128
|
+
|
129
|
+
st.divider()
|
130
|
+
st.subheader("🛠 Processing and Combining...")
|
131
|
+
|
132
|
+
if display_log == "Yes":
|
133
|
+
# The `buffer` is used to display console output to streamlit
|
134
|
+
buffer = io.StringIO()
|
135
|
+
with contextlib.redirect_stdout(buffer):
|
136
|
+
adcpcat = ADCPBinFileCombiner()
|
137
|
+
combined_data = adcpcat.combine_files(temp_file_paths)
|
138
|
+
st.markdown(ansi_to_html(buffer.getvalue()), unsafe_allow_html=True)
|
139
|
+
else:
|
140
|
+
adcpcat = ADCPBinFileCombiner()
|
141
|
+
combined_data = adcpcat.combine_files(temp_file_paths)
|
142
|
+
|
143
|
+
if combined_data:
|
144
|
+
st.success("✅ Valid binary data has been combined successfully.")
|
145
|
+
st.warning(
|
146
|
+
"⚠️ Note: The time axis in the final file may be irregular due to missing ensembles during concatenation."
|
147
|
+
)
|
148
|
+
st.download_button(
|
149
|
+
label="📥 Download Combined Binary File",
|
150
|
+
data=bytes(combined_data),
|
151
|
+
file_name=output_cat_filename,
|
152
|
+
mime="application/octet-stream",
|
153
|
+
)
|
154
|
+
else:
|
155
|
+
st.warning("⚠️ No valid data found to combine.")
|
156
|
+
|
157
|
+
# Optional: Clean up temporary files
|
158
|
+
for path in temp_file_paths:
|
159
|
+
try:
|
160
|
+
os.remove(path)
|
161
|
+
except Exception as e:
|
162
|
+
st.warning(f"Failed to delete temp file {path}: {e}")
|
163
|
+
else:
|
164
|
+
st.info("Please upload binary files to begin.")
|
165
|
+
|
166
|
+
|
167
|
+
if __name__ == "__main__":
|
168
|
+
main()
|
pyadps/utils/__init__.py
CHANGED
@@ -9,4 +9,6 @@ from pyadps.utils.signal_quality import *
|
|
9
9
|
from pyadps.utils.velocity_test import *
|
10
10
|
from pyadps.utils.writenc import *
|
11
11
|
from pyadps.utils.autoprocess import *
|
12
|
+
from pyadps.utils.logging_utils import *
|
13
|
+
from pyadps.utils.multifile import *
|
12
14
|
from pyadps.utils.script import *
|
@@ -0,0 +1,269 @@
|
|
1
|
+
"""
|
2
|
+
Reusable Logging Utilities
|
3
|
+
A clean, configurable logging module that can be used across multiple projects.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import logging
|
7
|
+
import sys
|
8
|
+
from enum import Enum
|
9
|
+
from typing import Optional, Union
|
10
|
+
from pathlib import Path
|
11
|
+
|
12
|
+
|
13
|
+
class LogLevel(Enum):
|
14
|
+
"""Log level enumeration"""
|
15
|
+
|
16
|
+
DEBUG = logging.DEBUG
|
17
|
+
INFO = logging.INFO
|
18
|
+
WARNING = logging.WARNING
|
19
|
+
ERROR = logging.ERROR
|
20
|
+
CRITICAL = logging.CRITICAL
|
21
|
+
|
22
|
+
|
23
|
+
class CustomFormatter(logging.Formatter):
|
24
|
+
"""Custom colored formatter for console logging"""
|
25
|
+
|
26
|
+
COLORS = {
|
27
|
+
logging.DEBUG: "\x1b[36m", # Cyan
|
28
|
+
logging.INFO: "\x1b[32m", # Green
|
29
|
+
logging.WARNING: "\x1b[33m", # Yellow
|
30
|
+
logging.ERROR: "\x1b[31m", # Red
|
31
|
+
logging.CRITICAL: "\x1b[31;1m", # Bold Red
|
32
|
+
}
|
33
|
+
RESET = "\x1b[0m"
|
34
|
+
|
35
|
+
def __init__(self, include_timestamp: bool = True, include_module: bool = False):
|
36
|
+
"""
|
37
|
+
Initialize formatter with optional components
|
38
|
+
|
39
|
+
Args:
|
40
|
+
include_timestamp: Whether to include timestamp in log format
|
41
|
+
include_module: Whether to include module name in log format
|
42
|
+
"""
|
43
|
+
self.include_timestamp = include_timestamp
|
44
|
+
self.include_module = include_module
|
45
|
+
super().__init__()
|
46
|
+
|
47
|
+
def format(self, record):
|
48
|
+
"""Format log record with colors and optional components"""
|
49
|
+
# Build format string based on options
|
50
|
+
format_parts = []
|
51
|
+
|
52
|
+
if self.include_timestamp:
|
53
|
+
format_parts.append("%(asctime)s")
|
54
|
+
|
55
|
+
format_parts.append("%(levelname)s")
|
56
|
+
|
57
|
+
if self.include_module:
|
58
|
+
format_parts.append("%(name)s")
|
59
|
+
|
60
|
+
format_parts.append("%(message)s")
|
61
|
+
|
62
|
+
log_format = " - ".join(format_parts)
|
63
|
+
|
64
|
+
# Apply color
|
65
|
+
color = self.COLORS.get(record.levelno, "")
|
66
|
+
colored_format = color + log_format + self.RESET
|
67
|
+
|
68
|
+
formatter = logging.Formatter(
|
69
|
+
colored_format,
|
70
|
+
datefmt="%Y-%m-%d %H:%M:%S" if self.include_timestamp else None,
|
71
|
+
)
|
72
|
+
return formatter.format(record)
|
73
|
+
|
74
|
+
|
75
|
+
class LoggerConfig:
|
76
|
+
"""Configuration class for logger setup"""
|
77
|
+
|
78
|
+
def __init__(
|
79
|
+
self,
|
80
|
+
level: LogLevel = LogLevel.INFO,
|
81
|
+
include_timestamp: bool = True,
|
82
|
+
include_module: bool = False,
|
83
|
+
log_to_file: bool = False,
|
84
|
+
log_file_path: Optional[Union[str, Path]] = None,
|
85
|
+
file_log_level: Optional[LogLevel] = None,
|
86
|
+
max_file_size: int = 10 * 1024 * 1024, # 10MB
|
87
|
+
backup_count: int = 5,
|
88
|
+
):
|
89
|
+
"""
|
90
|
+
Initialize logger configuration
|
91
|
+
|
92
|
+
Args:
|
93
|
+
level: Console logging level
|
94
|
+
include_timestamp: Include timestamp in console output
|
95
|
+
include_module: Include module name in output
|
96
|
+
log_to_file: Whether to also log to file
|
97
|
+
log_file_path: Path for log file (if log_to_file is True)
|
98
|
+
file_log_level: File logging level (defaults to console level)
|
99
|
+
max_file_size: Maximum size of log file before rotation
|
100
|
+
backup_count: Number of backup files to keep
|
101
|
+
"""
|
102
|
+
self.level = level
|
103
|
+
self.include_timestamp = include_timestamp
|
104
|
+
self.include_module = include_module
|
105
|
+
self.log_to_file = log_to_file
|
106
|
+
self.log_file_path = Path(log_file_path) if log_file_path else None
|
107
|
+
self.file_log_level = file_log_level or level
|
108
|
+
self.max_file_size = max_file_size
|
109
|
+
self.backup_count = backup_count
|
110
|
+
|
111
|
+
|
112
|
+
class LoggerManager:
|
113
|
+
"""Manages logger configuration and setup"""
|
114
|
+
|
115
|
+
_loggers = {} # Cache for created loggers
|
116
|
+
|
117
|
+
@classmethod
|
118
|
+
def setup_logger(
|
119
|
+
self, name: str = "app", config: Optional[LoggerConfig] = None
|
120
|
+
) -> logging.Logger:
|
121
|
+
"""
|
122
|
+
Set up and configure logger with given configuration
|
123
|
+
|
124
|
+
Args:
|
125
|
+
name: Logger name
|
126
|
+
config: Logger configuration (uses defaults if None)
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
Configured logger instance
|
130
|
+
"""
|
131
|
+
# Use default config if none provided
|
132
|
+
if config is None:
|
133
|
+
config = LoggerConfig()
|
134
|
+
|
135
|
+
# Return cached logger if it exists
|
136
|
+
cache_key = f"{name}_{id(config)}"
|
137
|
+
if cache_key in self._loggers:
|
138
|
+
return self._loggers[cache_key]
|
139
|
+
|
140
|
+
logger = logging.getLogger(name)
|
141
|
+
logger.setLevel(config.level.value)
|
142
|
+
|
143
|
+
# Remove existing handlers to avoid duplicates
|
144
|
+
logger.handlers.clear()
|
145
|
+
|
146
|
+
# Create console handler
|
147
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
148
|
+
console_handler.setLevel(config.level.value)
|
149
|
+
console_formatter = CustomFormatter(
|
150
|
+
include_timestamp=config.include_timestamp,
|
151
|
+
include_module=config.include_module,
|
152
|
+
)
|
153
|
+
console_handler.setFormatter(console_formatter)
|
154
|
+
logger.addHandler(console_handler)
|
155
|
+
|
156
|
+
# Add file handler if requested
|
157
|
+
if config.log_to_file and config.log_file_path:
|
158
|
+
self._add_file_handler(logger, config)
|
159
|
+
|
160
|
+
# Prevent propagation to root logger
|
161
|
+
logger.propagate = False
|
162
|
+
|
163
|
+
# Cache the logger
|
164
|
+
self._loggers[cache_key] = logger
|
165
|
+
|
166
|
+
return logger
|
167
|
+
|
168
|
+
@classmethod
|
169
|
+
def _add_file_handler(self, logger: logging.Logger, config: LoggerConfig):
|
170
|
+
"""Add rotating file handler to logger"""
|
171
|
+
from logging.handlers import RotatingFileHandler
|
172
|
+
|
173
|
+
# Ensure log directory exists
|
174
|
+
config.log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
175
|
+
|
176
|
+
file_handler = RotatingFileHandler(
|
177
|
+
config.log_file_path,
|
178
|
+
maxBytes=config.max_file_size,
|
179
|
+
backupCount=config.backup_count,
|
180
|
+
)
|
181
|
+
file_handler.setLevel(config.file_log_level.value)
|
182
|
+
|
183
|
+
# File logs typically include more detail
|
184
|
+
file_formatter = logging.Formatter(
|
185
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s",
|
186
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
187
|
+
)
|
188
|
+
file_handler.setFormatter(file_formatter)
|
189
|
+
logger.addHandler(file_handler)
|
190
|
+
|
191
|
+
@classmethod
|
192
|
+
def get_logger(self, name: str = "app") -> logging.Logger:
|
193
|
+
"""Get existing logger or create with default config"""
|
194
|
+
return logging.getLogger(name) or self.setup_logger(name)
|
195
|
+
|
196
|
+
@classmethod
|
197
|
+
def clear_cache(self):
|
198
|
+
"""Clear logger cache (useful for testing)"""
|
199
|
+
self._loggers.clear()
|
200
|
+
|
201
|
+
|
202
|
+
# Convenience functions for quick setup
|
203
|
+
def get_console_logger(
|
204
|
+
name: str = "app", level: LogLevel = LogLevel.INFO, include_timestamp: bool = True
|
205
|
+
) -> logging.Logger:
|
206
|
+
"""Quick setup for console-only logger"""
|
207
|
+
config = LoggerConfig(
|
208
|
+
level=level, include_timestamp=include_timestamp, include_module=False
|
209
|
+
)
|
210
|
+
return LoggerManager.setup_logger(name, config)
|
211
|
+
|
212
|
+
|
213
|
+
def get_file_logger(
|
214
|
+
name: str = "app",
|
215
|
+
log_file: Union[str, Path] = "app.log",
|
216
|
+
level: LogLevel = LogLevel.INFO,
|
217
|
+
file_level: Optional[LogLevel] = None,
|
218
|
+
) -> logging.Logger:
|
219
|
+
"""Quick setup for file + console logger"""
|
220
|
+
config = LoggerConfig(
|
221
|
+
level=level,
|
222
|
+
log_to_file=True,
|
223
|
+
log_file_path=log_file,
|
224
|
+
file_log_level=file_level or LogLevel.DEBUG,
|
225
|
+
)
|
226
|
+
return LoggerManager.setup_logger(name, config)
|
227
|
+
|
228
|
+
|
229
|
+
def get_detailed_logger(
|
230
|
+
name: str = "app",
|
231
|
+
log_file: Union[str, Path] = "app.log",
|
232
|
+
console_level: LogLevel = LogLevel.INFO,
|
233
|
+
file_level: LogLevel = LogLevel.DEBUG,
|
234
|
+
) -> logging.Logger:
|
235
|
+
"""Setup logger with detailed configuration"""
|
236
|
+
config = LoggerConfig(
|
237
|
+
level=console_level,
|
238
|
+
include_timestamp=True,
|
239
|
+
include_module=True,
|
240
|
+
log_to_file=True,
|
241
|
+
log_file_path=log_file,
|
242
|
+
file_log_level=file_level,
|
243
|
+
)
|
244
|
+
return LoggerManager.setup_logger(name, config)
|
245
|
+
|
246
|
+
|
247
|
+
# Example usage
|
248
|
+
if __name__ == "__main__":
|
249
|
+
# Test different logger configurations
|
250
|
+
|
251
|
+
# Simple console logger
|
252
|
+
simple_logger = get_console_logger("simple", LogLevel.DEBUG)
|
253
|
+
simple_logger.debug("Debug message")
|
254
|
+
simple_logger.info("Info message")
|
255
|
+
simple_logger.warning("Warning message")
|
256
|
+
simple_logger.error("Error message")
|
257
|
+
|
258
|
+
print("\n" + "=" * 50 + "\n")
|
259
|
+
|
260
|
+
# File + console logger
|
261
|
+
file_logger = get_file_logger("file_test", "test.log", LogLevel.INFO)
|
262
|
+
file_logger.info("This goes to both console and file")
|
263
|
+
file_logger.debug("This only goes to file")
|
264
|
+
|
265
|
+
print("\n" + "=" * 50 + "\n")
|
266
|
+
|
267
|
+
# Detailed logger
|
268
|
+
detailed_logger = get_detailed_logger("detailed", "detailed.log")
|
269
|
+
detailed_logger.info("Detailed logging with module names")
|
@@ -0,0 +1,244 @@
|
|
1
|
+
"""
|
2
|
+
ADCP (Acoustic Doppler Current Profiler) File Processor
|
3
|
+
A clean, maintainable implementation for processing and combining ADCP binary files.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from pathlib import Path
|
7
|
+
from dataclasses import dataclass
|
8
|
+
from typing import List, Union
|
9
|
+
|
10
|
+
# Import from our separate logging module
|
11
|
+
from .logging_utils import LogLevel, get_console_logger
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class ADCPConfig:
|
16
|
+
"""Configuration for ADCP file processing"""
|
17
|
+
|
18
|
+
file_extension: str = "*.000"
|
19
|
+
header_signature: bytes = b"\x7f\x7f"
|
20
|
+
header_signature_ext: bytes = b"\x7f\x7f\xf0\x02"
|
21
|
+
ensemble_size_offset: int = 2
|
22
|
+
ensemble_size_length: int = 2
|
23
|
+
header_size_adjustment: int = 2
|
24
|
+
chunk_size: int = 8192 # For large file processing
|
25
|
+
|
26
|
+
|
27
|
+
class ADCPError(Exception):
|
28
|
+
"""Base exception for ADCP processing errors"""
|
29
|
+
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
class InvalidHeaderError(ADCPError):
|
34
|
+
"""Raised when ADCP file has invalid header"""
|
35
|
+
|
36
|
+
pass
|
37
|
+
|
38
|
+
|
39
|
+
class CorruptedFileError(ADCPError):
|
40
|
+
"""Raised when ADCP file is corrupted"""
|
41
|
+
|
42
|
+
pass
|
43
|
+
|
44
|
+
|
45
|
+
class ADCPFileValidator:
|
46
|
+
"""Validates ADCP files and headers"""
|
47
|
+
|
48
|
+
def __init__(self, config: ADCPConfig, logger_name: str = "adcp_validator"):
|
49
|
+
self.config = config
|
50
|
+
self.logger = get_console_logger(logger_name, LogLevel.INFO)
|
51
|
+
|
52
|
+
def find_header_start(self, data: bytes) -> int:
|
53
|
+
"""Find the first occurrence of the extended header signature"""
|
54
|
+
return data.find(self.config.header_signature_ext)
|
55
|
+
|
56
|
+
def validate_file_path(self, filepath: Path) -> None:
|
57
|
+
"""Validate file path exists and is accessible"""
|
58
|
+
if not filepath.exists():
|
59
|
+
raise FileNotFoundError(f"File {filepath} does not exist")
|
60
|
+
if not filepath.is_file():
|
61
|
+
raise ValueError(f"Path {filepath} is not a file")
|
62
|
+
if filepath.stat().st_size == 0:
|
63
|
+
raise ValueError(f"File {filepath} is empty")
|
64
|
+
|
65
|
+
def has_valid_header(self, data: bytes) -> bool:
|
66
|
+
"""Check if data starts with valid ADCP header"""
|
67
|
+
return data.startswith(self.config.header_signature)
|
68
|
+
|
69
|
+
|
70
|
+
class ADCPFileProcessor:
|
71
|
+
"""Processes individual ADCP files"""
|
72
|
+
|
73
|
+
def __init__(self, config: ADCPConfig = None, logger_name: str = "adcp_processor"):
|
74
|
+
self.config = config or ADCPConfig()
|
75
|
+
self.validator = ADCPFileValidator(self.config, f"{logger_name}_validator")
|
76
|
+
self.logger = get_console_logger(logger_name, LogLevel.INFO)
|
77
|
+
|
78
|
+
def _calculate_ensemble_size(self, data: bytes) -> int:
|
79
|
+
"""Calculate size of single ensemble from header"""
|
80
|
+
offset = self.config.ensemble_size_offset
|
81
|
+
length = self.config.ensemble_size_length
|
82
|
+
return (
|
83
|
+
int.from_bytes(data[offset : offset + length], byteorder="little")
|
84
|
+
+ self.config.header_size_adjustment
|
85
|
+
)
|
86
|
+
|
87
|
+
def _validate_file_integrity(
|
88
|
+
self, filepath: Path, data: bytes, ensemble_size: int
|
89
|
+
) -> int:
|
90
|
+
"""Validate file integrity and return number of valid ensembles"""
|
91
|
+
file_size = filepath.stat().st_size
|
92
|
+
if file_size % ensemble_size != 0:
|
93
|
+
valid_ensembles = file_size // ensemble_size
|
94
|
+
self.logger.warning(
|
95
|
+
f"File {filepath.name} is corrupted. "
|
96
|
+
f"Valid ensembles: {valid_ensembles}/{valid_ensembles + 1}"
|
97
|
+
)
|
98
|
+
return valid_ensembles
|
99
|
+
return file_size // ensemble_size
|
100
|
+
|
101
|
+
def process_file(self, filepath: Union[str, Path]) -> bytes:
|
102
|
+
"""Process a single ADCP file and return valid data"""
|
103
|
+
filepath = Path(filepath)
|
104
|
+
try:
|
105
|
+
self.validator.validate_file_path(filepath)
|
106
|
+
|
107
|
+
with open(filepath, "rb") as f:
|
108
|
+
data = f.read()
|
109
|
+
|
110
|
+
header_index = 0
|
111
|
+
# Check if file starts with valid header
|
112
|
+
if not self.validator.has_valid_header(data):
|
113
|
+
header_index = self.validator.find_header_start(data)
|
114
|
+
if header_index == -1:
|
115
|
+
raise InvalidHeaderError(
|
116
|
+
f"File {filepath.name} contains no valid ADCP header"
|
117
|
+
)
|
118
|
+
self.logger.warning(
|
119
|
+
f"File {filepath.name} header found at byte {header_index}. "
|
120
|
+
"Truncating invalid data before header."
|
121
|
+
)
|
122
|
+
else:
|
123
|
+
self.logger.info(f"Valid ADCP file: {filepath.name}")
|
124
|
+
|
125
|
+
# Calculate ensemble size and validate file integrity
|
126
|
+
ensemble_size = self._calculate_ensemble_size(data[header_index:])
|
127
|
+
valid_ensembles = self._validate_file_integrity(
|
128
|
+
filepath, data, ensemble_size
|
129
|
+
)
|
130
|
+
|
131
|
+
# Return only valid data
|
132
|
+
end_index = header_index + (valid_ensembles * ensemble_size)
|
133
|
+
return data[header_index:end_index]
|
134
|
+
|
135
|
+
except (InvalidHeaderError, FileNotFoundError, ValueError) as e:
|
136
|
+
self.logger.error(f"Error processing {filepath.name}: {e}")
|
137
|
+
return b""
|
138
|
+
except Exception as e:
|
139
|
+
self.logger.error(f"Unexpected error processing {filepath.name}: {e}")
|
140
|
+
return b""
|
141
|
+
|
142
|
+
|
143
|
+
class ADCPBinFileCombiner:
|
144
|
+
"""Combines or joins multiple ADCP files"""
|
145
|
+
|
146
|
+
def __init__(self, config: ADCPConfig = None, logger_name: str = "adcp_combiner"):
|
147
|
+
self.config = config or ADCPConfig()
|
148
|
+
self.processor = ADCPFileProcessor(self.config, f"{logger_name}_processor")
|
149
|
+
self.logger = get_console_logger(logger_name, LogLevel.INFO)
|
150
|
+
|
151
|
+
def get_adcp_files(self, folder_path: Union[str, Path]) -> List[Path]:
|
152
|
+
"""Get all ADCP files from folder"""
|
153
|
+
folder_path = Path(folder_path)
|
154
|
+
if not folder_path.exists():
|
155
|
+
raise FileNotFoundError(f"Folder {folder_path} does not exist")
|
156
|
+
if not folder_path.is_dir():
|
157
|
+
raise NotADirectoryError(f"Path {folder_path} is not a directory")
|
158
|
+
|
159
|
+
files = sorted(folder_path.glob(self.config.file_extension))
|
160
|
+
if not files:
|
161
|
+
self.logger.error(
|
162
|
+
f"No {self.config.file_extension} files found in {folder_path}"
|
163
|
+
)
|
164
|
+
return files
|
165
|
+
|
166
|
+
def combine_files(self, files: List[Union[str, Path]]) -> bytearray:
|
167
|
+
"""Combine multiple ADCP files into single bytearray"""
|
168
|
+
if not files:
|
169
|
+
self.logger.warning("No files provided for combination")
|
170
|
+
return bytearray()
|
171
|
+
|
172
|
+
combined_data = bytearray()
|
173
|
+
processed_count = 0
|
174
|
+
|
175
|
+
for file_path in files:
|
176
|
+
valid_data = self.processor.process_file(file_path)
|
177
|
+
if valid_data:
|
178
|
+
combined_data.extend(valid_data)
|
179
|
+
processed_count += 1
|
180
|
+
|
181
|
+
self.logger.info(f"Successfully combined {processed_count}/{len(files)} files")
|
182
|
+
return combined_data
|
183
|
+
|
184
|
+
def combine_folder(
|
185
|
+
self, folder_path: Union[str, Path], output_file: Union[str, Path]
|
186
|
+
) -> bool:
|
187
|
+
"""Combine all ADCP files from folder and write to output file"""
|
188
|
+
try:
|
189
|
+
files = self.get_adcp_files(folder_path)
|
190
|
+
if not files:
|
191
|
+
self.logger.error("No valid files found to combine")
|
192
|
+
return False
|
193
|
+
|
194
|
+
combined_data = self.combine_files(files)
|
195
|
+
if not combined_data:
|
196
|
+
self.logger.error("No valid data to write")
|
197
|
+
return False
|
198
|
+
|
199
|
+
output_path = Path(output_file)
|
200
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
201
|
+
|
202
|
+
with open(output_path, "wb") as f:
|
203
|
+
f.write(combined_data)
|
204
|
+
|
205
|
+
self.logger.info(
|
206
|
+
# f"Successfully combined {len(files)} files. "
|
207
|
+
f"Output written to: {output_path} ({len(combined_data)} bytes)"
|
208
|
+
)
|
209
|
+
return True
|
210
|
+
|
211
|
+
except Exception as e:
|
212
|
+
self.logger.error(f"Error combining folder {folder_path}: {e}")
|
213
|
+
return False
|
214
|
+
|
215
|
+
|
216
|
+
def main():
|
217
|
+
"""Main entry point for CLI usage"""
|
218
|
+
try:
|
219
|
+
folder = input("Enter folder containing ADCP files (*.000): ").strip()
|
220
|
+
if not folder:
|
221
|
+
print("No folder specified. Exiting.")
|
222
|
+
return
|
223
|
+
|
224
|
+
output = input("Enter output filename (default: merged_000.000): ").strip()
|
225
|
+
if not output:
|
226
|
+
output = "merged_000.000"
|
227
|
+
|
228
|
+
# Create combiner with custom logger configuration
|
229
|
+
combiner = ADCPBinFileCombiner(logger_name="adcp_main")
|
230
|
+
success = combiner.combine_folder(folder, output)
|
231
|
+
|
232
|
+
if success:
|
233
|
+
print(f"✅ Files successfully combined to {output}")
|
234
|
+
else:
|
235
|
+
print("❌ Failed to combine files. Check logs for details.")
|
236
|
+
|
237
|
+
except KeyboardInterrupt:
|
238
|
+
print("\n⚠️ Operation cancelled by user.")
|
239
|
+
except Exception as e:
|
240
|
+
print(f"❌ Unexpected error: {e}")
|
241
|
+
|
242
|
+
|
243
|
+
if __name__ == "__main__":
|
244
|
+
main()
|
@@ -9,14 +9,16 @@ pyadps/pages/05_QC_Test.py,sha256=6YjQTg_t0_Qll9xQ3SYieCBS2cDEMz9znovW3olPFwc,16
|
|
9
9
|
pyadps/pages/06_Profile_Test.py,sha256=Vir91oRIWApbO2elBm4I59rdf83NtspUmtzAyWdsIiY,34891
|
10
10
|
pyadps/pages/07_Velocity_Test.py,sha256=K4vEiLPMXrU4JMLj-mIA1G4H5ORozMbHMiMov3ZZXP0,23008
|
11
11
|
pyadps/pages/08_Write_File.py,sha256=dehT7x68b0bzDDiTSE7W0cav4JdvoB1Awicip711hGY,23079
|
12
|
-
pyadps/pages/
|
12
|
+
pyadps/pages/09_Add-Ons.py,sha256=lweofAxcvKLxzfCJl6vybmUmxWgcm4jEn5DjFaTHbpI,5986
|
13
13
|
pyadps/pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
14
|
-
pyadps/utils/__init__.py,sha256=
|
14
|
+
pyadps/utils/__init__.py,sha256=JDIy5Yx6meDGxfLGyB5135TFWQc0lyNxu_cWrfSEDhU,485
|
15
15
|
pyadps/utils/autoprocess.py,sha256=r6SZwx-FPV9Fp1DzuLfH06X5zStWVIi94182juebxfA,20374
|
16
|
+
pyadps/utils/logging_utils.py,sha256=-b6rwnpF-g6ZSIj3PVyxiz4-blBKvKqh1HW1KRKpmyY,8492
|
16
17
|
pyadps/utils/metadata/config.ini,sha256=TC7htzGwUukIXt_u3JR5ycyvOoDj_JxWgGY6khjNeck,2154
|
17
18
|
pyadps/utils/metadata/demo.000,sha256=qxB3sgjABrpv4DNXkwjpbSxk5sc4UwAI8kgQX0--PM8,234468
|
18
19
|
pyadps/utils/metadata/flmeta.json,sha256=eGw1lM5IKhZ6IuSkj7PiKQ-MpNtH446HaRmcby8rBIY,12673
|
19
20
|
pyadps/utils/metadata/vlmeta.json,sha256=_dkQlGkkUvpAIM7S6kEUenSaiCpOrwXg8n1aU3dDF3s,22535
|
21
|
+
pyadps/utils/multifile.py,sha256=NKSbikp6k98pHgT-5tciAyykYCTyWfPH1JFA9F8eiUs,8747
|
20
22
|
pyadps/utils/plotgen.py,sha256=c7TUA9unErUxppYc14LZ2o_stzNMmmZ5X542iI5v1tA,26346
|
21
23
|
pyadps/utils/profile_test.py,sha256=gnbS6ZsqKvv2tcHTj-Fi_VNOszbxDcPxl77_n4dLzSo,29237
|
22
24
|
pyadps/utils/pyreadrdi.py,sha256=VwypCpKGfjTO0h0j_Vl1-JMhXooiaXnmQHmEFVPifGo,36886
|
@@ -26,8 +28,8 @@ pyadps/utils/sensor_health.py,sha256=aHRaU4kMJZ9dGmYypKpCCgq-owWoNjvcl1I_9I7dG68
|
|
26
28
|
pyadps/utils/signal_quality.py,sha256=BYPAbXrAPGQBEfEyG4PFktYpqxuvhoafsWM172TVn08,16737
|
27
29
|
pyadps/utils/velocity_test.py,sha256=O8dgjv_5pxhJq6QuWHxysMjNzxSnob_2KPLInmO1kHI,6112
|
28
30
|
pyadps/utils/writenc.py,sha256=KDolQ11Whh2bi4L2wfrgjXym5BWL8ikp0xvo4Pa0r4E,14455
|
29
|
-
pyadps-0.1.
|
30
|
-
pyadps-0.1.
|
31
|
-
pyadps-0.1.
|
32
|
-
pyadps-0.1.
|
33
|
-
pyadps-0.1.
|
31
|
+
pyadps-0.1.4.dist-info/LICENSE,sha256=sfY_7DzQF5FxnO2T6ek74dfm5uBmwEp1oEg_WlzNsb8,1092
|
32
|
+
pyadps-0.1.4.dist-info/METADATA,sha256=M9Qsr-K3w1VHOMajGLAw8CVlhJzD2gz7oqW_er-Jwak,4518
|
33
|
+
pyadps-0.1.4.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
|
34
|
+
pyadps-0.1.4.dist-info/entry_points.txt,sha256=-oZhbbJq8Q29uNVh5SmzOLp9OeFM9VUzHVxovfI4LXA,126
|
35
|
+
pyadps-0.1.4.dist-info/RECORD,,
|
pyadps/pages/09_Auto_process.py
DELETED
@@ -1,64 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
import tempfile
|
3
|
-
|
4
|
-
import configparser
|
5
|
-
import json
|
6
|
-
import streamlit as st
|
7
|
-
from utils.autoprocess import autoprocess
|
8
|
-
|
9
|
-
# To make the page wider if the user presses the reload button.
|
10
|
-
st.set_page_config(layout="wide")
|
11
|
-
|
12
|
-
@st.cache_data
|
13
|
-
def file_access(uploaded_file):
|
14
|
-
"""
|
15
|
-
Function creates temporary directory to store the uploaded file.
|
16
|
-
The path of the file is returned
|
17
|
-
|
18
|
-
Args:
|
19
|
-
uploaded_file (string): Name of the uploaded file
|
20
|
-
|
21
|
-
Returns:
|
22
|
-
path (string): Path of the uploaded file
|
23
|
-
"""
|
24
|
-
temp_dir = tempfile.mkdtemp()
|
25
|
-
path = os.path.join(temp_dir, uploaded_file.name)
|
26
|
-
with open(path, "wb") as f:
|
27
|
-
f.write(uploaded_file.getvalue())
|
28
|
-
return path
|
29
|
-
|
30
|
-
|
31
|
-
def display_config_as_json(config_file):
|
32
|
-
config = configparser.ConfigParser()
|
33
|
-
config.read_string(config_file.getvalue().decode("utf-8"))
|
34
|
-
st.json({section: dict(config[section]) for section in config.sections()})
|
35
|
-
|
36
|
-
|
37
|
-
def main():
|
38
|
-
st.title("ADCP Data Auto Processing Tool")
|
39
|
-
st.write("Upload a binary input file and config.ini file for processing.")
|
40
|
-
|
41
|
-
# File Upload Section
|
42
|
-
uploaded_binary_file = st.file_uploader(
|
43
|
-
"Upload ADCP Binary File", type=["000", "bin"]
|
44
|
-
)
|
45
|
-
uploaded_config_file = st.file_uploader(
|
46
|
-
"Upload Config File (config.ini)", type=["ini"]
|
47
|
-
)
|
48
|
-
|
49
|
-
if uploaded_binary_file and uploaded_config_file:
|
50
|
-
st.success("Files uploaded successfully!")
|
51
|
-
|
52
|
-
# Display config.ini file content as JSON
|
53
|
-
display_config_as_json(uploaded_config_file)
|
54
|
-
|
55
|
-
fpath = file_access(uploaded_binary_file)
|
56
|
-
# Process files
|
57
|
-
with st.spinner("Processing files. Please wait..."):
|
58
|
-
autoprocess(uploaded_config_file, binary_file_path=fpath)
|
59
|
-
st.success("Processing completed successfully!")
|
60
|
-
st.write("Processed file written.")
|
61
|
-
|
62
|
-
|
63
|
-
if __name__ == "__main__":
|
64
|
-
main()
|
File without changes
|
File without changes
|
File without changes
|