nc2cog 0.1.3__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.
- nc2cog/__init__.py +3 -0
- nc2cog/__version__.py +8 -0
- nc2cog/analyzer.py +185 -0
- nc2cog/cli.py +266 -0
- nc2cog/config.py +158 -0
- nc2cog/discovery.py +109 -0
- nc2cog/errors.py +25 -0
- nc2cog/logger.py +48 -0
- nc2cog/metadata.py +254 -0
- nc2cog/processor.py +534 -0
- nc2cog-0.1.3.dist-info/METADATA +360 -0
- nc2cog-0.1.3.dist-info/RECORD +16 -0
- nc2cog-0.1.3.dist-info/WHEEL +5 -0
- nc2cog-0.1.3.dist-info/entry_points.txt +2 -0
- nc2cog-0.1.3.dist-info/licenses/LICENSE +21 -0
- nc2cog-0.1.3.dist-info/top_level.txt +1 -0
nc2cog/__init__.py
ADDED
nc2cog/__version__.py
ADDED
nc2cog/analyzer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Analyzer for netCDF file structure and subdatasets."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
|
|
6
|
+
# Coordinate variables to exclude from data variables
|
|
7
|
+
_COORD_NAMES = frozenset({
|
|
8
|
+
'lat', 'lon', 'latitude', 'longitude', 'time', 'crs',
|
|
9
|
+
'x', 'y', 'spatial_ref', 'nav_lat', 'nav_lon',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
# Try to import netCDF4
|
|
13
|
+
try:
|
|
14
|
+
import netCDF4
|
|
15
|
+
NETCDF4_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
NETCDF4_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NCAnalyzer:
|
|
21
|
+
"""Analyzes netCDF files for variable and dimension structure."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, input_file: Path):
|
|
24
|
+
"""
|
|
25
|
+
Initialize analyzer.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
input_file: Path to netCDF file
|
|
29
|
+
"""
|
|
30
|
+
self.input_file = Path(input_file)
|
|
31
|
+
|
|
32
|
+
def get_subdatasets(self) -> List[str]:
|
|
33
|
+
"""
|
|
34
|
+
Get GDAL subdataset paths for this netCDF file.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of subdataset paths. Empty list means the file is
|
|
38
|
+
directly readable as a 2D raster (no subdatasets).
|
|
39
|
+
"""
|
|
40
|
+
from osgeo import gdal
|
|
41
|
+
ds = gdal.Open(str(self.input_file))
|
|
42
|
+
if ds is None:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
subdatasets = ds.GetMetadata('SUBDATASETS')
|
|
46
|
+
result = []
|
|
47
|
+
for key, value in subdatasets.items():
|
|
48
|
+
if key.endswith('_NAME'):
|
|
49
|
+
result.append(value)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
def get_data_variables(self) -> List[str]:
|
|
53
|
+
"""
|
|
54
|
+
Get list of data variable names, excluding coordinate variables.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of data variable names
|
|
58
|
+
"""
|
|
59
|
+
if not NETCDF4_AVAILABLE:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"netCDF4 is required for multi-dimensional NC files. "
|
|
62
|
+
"Install it with: pip install netCDF4"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
nc = netCDF4.Dataset(str(self.input_file), 'r')
|
|
66
|
+
try:
|
|
67
|
+
# Get all variable names that have >1 dimension (data variables)
|
|
68
|
+
# and exclude known coordinate variables
|
|
69
|
+
data_vars = []
|
|
70
|
+
for name in nc.variables:
|
|
71
|
+
if name.lower() in _COORD_NAMES:
|
|
72
|
+
continue
|
|
73
|
+
var = nc.variables[name]
|
|
74
|
+
# Must be at least 1D and not a scalar metadata variable
|
|
75
|
+
if var.ndim >= 1 and len(var.dimensions) >= 1:
|
|
76
|
+
data_vars.append(name)
|
|
77
|
+
return sorted(data_vars)
|
|
78
|
+
finally:
|
|
79
|
+
nc.close()
|
|
80
|
+
|
|
81
|
+
def analyze_subdataset(self, subdataset_path: str) -> Dict:
|
|
82
|
+
"""
|
|
83
|
+
Analyze a GDAL subdataset to extract dimension info.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
subdataset_path: GDAL subdataset path
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Dict with keys: name, dims, shape, dtype, time_count, time_units
|
|
90
|
+
"""
|
|
91
|
+
from osgeo import gdal
|
|
92
|
+
ds = gdal.Open(subdataset_path)
|
|
93
|
+
if ds is None:
|
|
94
|
+
raise ValueError(f"Cannot open subdataset: {subdataset_path}")
|
|
95
|
+
|
|
96
|
+
# Extract variable name from subdataset path
|
|
97
|
+
# Format: NETCDF:"file.nc":VARNAME
|
|
98
|
+
name = subdataset_path.rsplit(':', 1)[-1]
|
|
99
|
+
|
|
100
|
+
info = {
|
|
101
|
+
'name': name,
|
|
102
|
+
'width': ds.RasterXSize,
|
|
103
|
+
'height': ds.RasterYSize,
|
|
104
|
+
'bands': ds.RasterCount,
|
|
105
|
+
'dtype': gdal.GetDataTypeName(ds.GetRasterBand(1).DataType),
|
|
106
|
+
'gdal_dtype': ds.GetRasterBand(1).DataType,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Try to get time info from netCDF4
|
|
110
|
+
if NETCDF4_AVAILABLE:
|
|
111
|
+
try:
|
|
112
|
+
nc = netCDF4.Dataset(str(self.input_file), 'r')
|
|
113
|
+
if name in nc.variables:
|
|
114
|
+
var = nc.variables[name]
|
|
115
|
+
info['dims'] = list(var.dimensions)
|
|
116
|
+
info['shape'] = list(var.shape)
|
|
117
|
+
|
|
118
|
+
# Find time dimension
|
|
119
|
+
for dim_name in var.dimensions:
|
|
120
|
+
if dim_name.lower() in ('time', 't'):
|
|
121
|
+
if dim_name in nc.variables:
|
|
122
|
+
time_var = nc.variables[dim_name]
|
|
123
|
+
info['time_count'] = len(time_var)
|
|
124
|
+
info['time_units'] = getattr(time_var, 'units', None)
|
|
125
|
+
if hasattr(time_var, '__getitem__'):
|
|
126
|
+
info['time_values'] = time_var[:]
|
|
127
|
+
nc.close()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return info
|
|
132
|
+
|
|
133
|
+
def get_time_descriptions(self, variable_name: str) -> List[str]:
|
|
134
|
+
"""
|
|
135
|
+
Get human-readable time descriptions for a variable.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
variable_name: Name of the variable
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of time step descriptions
|
|
142
|
+
"""
|
|
143
|
+
if not NETCDF4_AVAILABLE:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
from datetime import datetime, timedelta
|
|
148
|
+
nc = netCDF4.Dataset(str(self.input_file), 'r')
|
|
149
|
+
try:
|
|
150
|
+
if variable_name not in nc.variables:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
var = nc.variables[variable_name]
|
|
154
|
+
time_dim_name = None
|
|
155
|
+
for dim_name in var.dimensions:
|
|
156
|
+
if dim_name.lower() in ('time', 't'):
|
|
157
|
+
time_dim_name = dim_name
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
if time_dim_name is None or time_dim_name not in nc.variables:
|
|
161
|
+
return [f"step_{i}" for i in range(var.shape[0])]
|
|
162
|
+
|
|
163
|
+
time_var = nc.variables[time_dim_name]
|
|
164
|
+
time_units = getattr(time_var, 'units', None)
|
|
165
|
+
times = time_var[:]
|
|
166
|
+
|
|
167
|
+
descriptions = []
|
|
168
|
+
if time_units:
|
|
169
|
+
# Parse CF time units like "minutes since 2025-11-13T06:30:00"
|
|
170
|
+
try:
|
|
171
|
+
base_str = time_units.split(' since ')[1].strip()
|
|
172
|
+
for i, t in enumerate(times):
|
|
173
|
+
# netCDF4.num2date handles the conversion
|
|
174
|
+
dt = netCDF4.num2date(t, time_units)
|
|
175
|
+
descriptions.append(f"time={i}, {dt.isoformat()}")
|
|
176
|
+
except Exception:
|
|
177
|
+
descriptions = [f"time={i}" for i in range(len(times))]
|
|
178
|
+
else:
|
|
179
|
+
descriptions = [f"time={i}" for i in range(len(times))]
|
|
180
|
+
|
|
181
|
+
return descriptions
|
|
182
|
+
finally:
|
|
183
|
+
nc.close()
|
|
184
|
+
except Exception:
|
|
185
|
+
return []
|
nc2cog/cli.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Command-line interface for netCDF to COG TIFF converter."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from .config import ConfigManager
|
|
9
|
+
from .discovery import FileDiscovery
|
|
10
|
+
from .processor import ProcessingEngine
|
|
11
|
+
from .logger import setup_logger
|
|
12
|
+
from .errors import NC2COGError
|
|
13
|
+
from .analyzer import NCAnalyzer
|
|
14
|
+
from .__version__ import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.version_option(version=__version__, prog_name='nc2cog')
|
|
19
|
+
@click.option('-V', is_flag=True, callback=lambda ctx, param, value: click.echo(f"nc2cog {__version__}") or ctx.exit(0) if value else None, expose_value=False, is_eager=True, help='Show version and exit')
|
|
20
|
+
@click.argument('input_path', type=click.Path(exists=True, dir_okay=True, file_okay=True))
|
|
21
|
+
@click.argument('output_path', type=click.Path(dir_okay=True, file_okay=True))
|
|
22
|
+
@click.option('--config', '-c', type=click.Path(exists=True), help='Path to configuration file')
|
|
23
|
+
@click.option('--compression', type=click.Choice(['deflate', 'lzw', 'jpeg']), default='deflate', help='Compression type')
|
|
24
|
+
@click.option('--zlevel', type=click.IntRange(1, 9), default=6, help='Compression level for deflate (1-9, default: 6)')
|
|
25
|
+
@click.option('--block-size', type=int, default=256, help='Block size for compression (default: 256)')
|
|
26
|
+
@click.option('--resampling', type=click.Choice(['nearest', 'bilinear', 'cubic', 'average', 'mode', 'gauss', 'rms']), default='nearest', help='Resampling method for overviews (default: nearest)')
|
|
27
|
+
@click.option('--tile-size', type=int, default=512, help='Tile size for COG (default: 512)')
|
|
28
|
+
@click.option('--overview-levels', default='2,4,8,16', help='Overview levels for pyramid structure, comma-separated (default: 2,4,8,16)')
|
|
29
|
+
@click.option('--overwrite', is_flag=True, help='Overwrite existing output files')
|
|
30
|
+
@click.option('--dry-run', is_flag=True, help='Show what would be processed without doing it')
|
|
31
|
+
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose logging')
|
|
32
|
+
@click.option('--resume', is_flag=True, help='Resume from last processed file')
|
|
33
|
+
@click.option('--threads', type=int, default=1, help='Number of parallel processing threads')
|
|
34
|
+
@click.option('--src-proj', type=str, help='Source projection in EPSG format (e.g., EPSG:4326)')
|
|
35
|
+
@click.option('--dst-proj', type=str, help='Target projection in EPSG format (e.g., EPSG:3857)')
|
|
36
|
+
@click.option('--variables', type=str, default=None, help='Variables to convert (comma-separated, e.g., PRE,REF)')
|
|
37
|
+
@click.option('--metadata-source', type=str, default=None,
|
|
38
|
+
help='Data source description for metadata (e.g., satellite, sensor)')
|
|
39
|
+
def main(
|
|
40
|
+
input_path: str,
|
|
41
|
+
output_path: str,
|
|
42
|
+
config: Optional[str],
|
|
43
|
+
compression: str,
|
|
44
|
+
zlevel: int,
|
|
45
|
+
block_size: int,
|
|
46
|
+
resampling: str,
|
|
47
|
+
tile_size: int,
|
|
48
|
+
overview_levels: str,
|
|
49
|
+
overwrite: bool,
|
|
50
|
+
dry_run: bool,
|
|
51
|
+
verbose: bool,
|
|
52
|
+
resume: bool,
|
|
53
|
+
threads: int,
|
|
54
|
+
src_proj: Optional[str],
|
|
55
|
+
dst_proj: Optional[str],
|
|
56
|
+
variables: Optional[str],
|
|
57
|
+
metadata_source: Optional[str],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Convert netCDF files to Cloud-Optimized GeoTIFF format.
|
|
61
|
+
|
|
62
|
+
INPUT_PATH: Source directory or file path
|
|
63
|
+
OUTPUT_PATH: Destination directory
|
|
64
|
+
"""
|
|
65
|
+
# Setup logging
|
|
66
|
+
logger = setup_logger(verbose=verbose)
|
|
67
|
+
|
|
68
|
+
# Initialize components
|
|
69
|
+
try:
|
|
70
|
+
# Load configuration
|
|
71
|
+
config_path = Path(config) if config else None
|
|
72
|
+
config_manager = ConfigManager(config_path)
|
|
73
|
+
|
|
74
|
+
# Override config with CLI options if provided
|
|
75
|
+
if compression != 'deflate':
|
|
76
|
+
config_manager.config['compression'] = compression
|
|
77
|
+
if zlevel != 6:
|
|
78
|
+
config_manager.config['zlevel'] = zlevel
|
|
79
|
+
if block_size != 256:
|
|
80
|
+
config_manager.config['block_size'] = [block_size, block_size]
|
|
81
|
+
if resampling != 'nearest':
|
|
82
|
+
config_manager.config['overviews']['resampling'] = resampling
|
|
83
|
+
if tile_size != 512:
|
|
84
|
+
config_manager.config['tile_size'] = [tile_size, tile_size]
|
|
85
|
+
# Parse overview levels from comma-separated string and convert to list of ints
|
|
86
|
+
if overview_levels != '2,4,8,16':
|
|
87
|
+
levels_list = [int(x.strip()) for x in overview_levels.split(',')]
|
|
88
|
+
config_manager.config['overviews']['levels'] = levels_list
|
|
89
|
+
if overwrite:
|
|
90
|
+
config_manager.config['overwrite'] = True
|
|
91
|
+
|
|
92
|
+
# Handle projection parameters
|
|
93
|
+
if dst_proj:
|
|
94
|
+
config_manager.config['projection'] = config_manager.config.get('projection', {})
|
|
95
|
+
config_manager.config['projection']['target'] = dst_proj
|
|
96
|
+
if src_proj:
|
|
97
|
+
config_manager.config['projection'] = config_manager.config.get('projection', {})
|
|
98
|
+
config_manager.config['projection']['source'] = src_proj
|
|
99
|
+
|
|
100
|
+
# Handle metadata source parameter
|
|
101
|
+
if metadata_source:
|
|
102
|
+
config_manager.config['metadata'] = config_manager.config.get('metadata', {})
|
|
103
|
+
config_manager.config['metadata']['source'] = metadata_source
|
|
104
|
+
|
|
105
|
+
# Validate configuration
|
|
106
|
+
config_manager.validate()
|
|
107
|
+
|
|
108
|
+
# Setup processing engine
|
|
109
|
+
engine = ProcessingEngine(config_manager)
|
|
110
|
+
|
|
111
|
+
# Setup file discovery
|
|
112
|
+
input_path_obj = Path(input_path)
|
|
113
|
+
output_path_obj = Path(output_path)
|
|
114
|
+
|
|
115
|
+
discovery = FileDiscovery(input_path_obj)
|
|
116
|
+
|
|
117
|
+
# Detect single-file mode: input is a file AND output path ends with .tif
|
|
118
|
+
single_file_mode = input_path_obj.is_file() and str(output_path).endswith('.tif')
|
|
119
|
+
|
|
120
|
+
# Detect multi-dimensional NC: input is a file with GDAL subdatasets
|
|
121
|
+
multi_dim_mode = False
|
|
122
|
+
if input_path_obj.is_file():
|
|
123
|
+
analyzer = NCAnalyzer(input_path_obj)
|
|
124
|
+
subdatasets = analyzer.get_subdatasets()
|
|
125
|
+
if len(subdatasets) > 0:
|
|
126
|
+
multi_dim_mode = True
|
|
127
|
+
variables_list = [v.strip() for v in variables.split(',')] if variables else analyzer.get_data_variables()
|
|
128
|
+
if not variables_list:
|
|
129
|
+
logger.info("No data variables found in the netCDF file")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Find all netCDF files
|
|
133
|
+
all_files = discovery.find_files()
|
|
134
|
+
logger.info(f"Found {len(all_files)} netCDF files to process")
|
|
135
|
+
|
|
136
|
+
if not single_file_mode and resume:
|
|
137
|
+
files_to_process = discovery.get_resume_state(output_path_obj, all_files)
|
|
138
|
+
logger.info(f"After resume check, {len(files_to_process)} files still need processing")
|
|
139
|
+
else:
|
|
140
|
+
files_to_process = all_files
|
|
141
|
+
|
|
142
|
+
if dry_run:
|
|
143
|
+
if multi_dim_mode:
|
|
144
|
+
logger.info("Dry run mode - multi-dimensional file detected:")
|
|
145
|
+
logger.info(f" Input: {input_path_obj}")
|
|
146
|
+
if single_file_mode:
|
|
147
|
+
logger.info(f" Output: {output_path_obj} (variable: {variables_list[0]})")
|
|
148
|
+
else:
|
|
149
|
+
logger.info(f" Variables: {', '.join(variables_list)}")
|
|
150
|
+
for var_name in variables_list:
|
|
151
|
+
logger.info(f" {var_name} -> {output_path_obj / f'{var_name}.tif'}")
|
|
152
|
+
else:
|
|
153
|
+
logger.info("Dry run mode - would process:")
|
|
154
|
+
for f in files_to_process:
|
|
155
|
+
if single_file_mode:
|
|
156
|
+
out_file = Path(output_path)
|
|
157
|
+
elif input_path_obj.is_file():
|
|
158
|
+
out_file = output_path_obj / input_path_obj.with_suffix('.tif').name
|
|
159
|
+
else:
|
|
160
|
+
relative_path = f.relative_to(input_path_obj)
|
|
161
|
+
out_file = output_path_obj / relative_path.with_suffix('.tif')
|
|
162
|
+
logger.info(f" {f} -> {out_file}")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if not files_to_process:
|
|
166
|
+
logger.info("No files to process")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Multi-dimensional NC processing
|
|
170
|
+
if multi_dim_mode:
|
|
171
|
+
logger.info(f"Multi-dimensional mode: converting variables: {', '.join(variables_list)}")
|
|
172
|
+
start_time = time.time()
|
|
173
|
+
|
|
174
|
+
if single_file_mode:
|
|
175
|
+
# Direct file output (single variable to specified .tif)
|
|
176
|
+
engine.convert_multiband_file(input_path_obj, output_path_obj, variables_list[0])
|
|
177
|
+
successful = 1
|
|
178
|
+
failed = 0
|
|
179
|
+
else:
|
|
180
|
+
# Directory output (all variables)
|
|
181
|
+
results = engine.convert_multiband(input_path_obj, output_path_obj, variables_list)
|
|
182
|
+
successful = sum(1 for v in results.values() if v)
|
|
183
|
+
failed = sum(1 for v in results.values() if not v)
|
|
184
|
+
|
|
185
|
+
elapsed_time = time.time() - start_time
|
|
186
|
+
|
|
187
|
+
logger.info(f"\nProcessing complete!")
|
|
188
|
+
logger.info(f"Successful: {successful}")
|
|
189
|
+
logger.info(f"Failed: {failed}")
|
|
190
|
+
logger.info(f"Total: {successful + failed}")
|
|
191
|
+
logger.info(f"Elapsed time: {elapsed_time:.2f} seconds")
|
|
192
|
+
|
|
193
|
+
if failed > 0:
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Process files
|
|
198
|
+
successful = 0
|
|
199
|
+
failed = 0
|
|
200
|
+
start_time = time.time()
|
|
201
|
+
|
|
202
|
+
logger.info("Starting conversion process...")
|
|
203
|
+
|
|
204
|
+
for i, input_file in enumerate(files_to_process):
|
|
205
|
+
try:
|
|
206
|
+
# Generate output file path
|
|
207
|
+
if single_file_mode:
|
|
208
|
+
output_file = Path(output_path)
|
|
209
|
+
elif input_path_obj.is_file():
|
|
210
|
+
output_file = output_path_obj / input_path_obj.with_suffix('.tif').name
|
|
211
|
+
else:
|
|
212
|
+
relative_path = input_file.relative_to(input_path_obj)
|
|
213
|
+
output_file = output_path_obj / relative_path.with_suffix('.tif')
|
|
214
|
+
|
|
215
|
+
# Skip if file exists and overwrite is not enabled
|
|
216
|
+
if output_file.exists() and not overwrite:
|
|
217
|
+
logger.warning(f"Output file exists, skipping: {output_file}")
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
logger.info(f"[{i+1}/{len(files_to_process)}] Processing: {input_file.name}")
|
|
221
|
+
|
|
222
|
+
# Validate input file
|
|
223
|
+
engine.validate_input(input_file)
|
|
224
|
+
|
|
225
|
+
# Convert the file
|
|
226
|
+
result = engine.convert_file(input_file, output_file)
|
|
227
|
+
|
|
228
|
+
if result:
|
|
229
|
+
successful += 1
|
|
230
|
+
logger.info(f" ✓ Completed: {output_file.name}")
|
|
231
|
+
else:
|
|
232
|
+
failed += 1
|
|
233
|
+
logger.error(f" ✗ Failed: {input_file.name}")
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
failed += 1
|
|
237
|
+
logger.error(f" ✗ Failed to process {input_file.name}: {str(e)}")
|
|
238
|
+
|
|
239
|
+
# Continue with other files if skip_errors is enabled
|
|
240
|
+
if not config_manager.get('skip_errors', True):
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
# Print summary
|
|
244
|
+
elapsed_time = time.time() - start_time
|
|
245
|
+
logger.info(f"\nProcessing complete!")
|
|
246
|
+
logger.info(f"Successful: {successful}")
|
|
247
|
+
logger.info(f"Failed: {failed}")
|
|
248
|
+
logger.info(f"Total: {successful + failed}")
|
|
249
|
+
logger.info(f"Elapsed time: {elapsed_time:.2f} seconds")
|
|
250
|
+
|
|
251
|
+
if failed > 0:
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
except NC2COGError as e:
|
|
255
|
+
logger.error(f"NC2COG Error: {str(e)}")
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
logger.info("\nProcessing interrupted by user")
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Unexpected error: {str(e)}")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == '__main__':
|
|
266
|
+
main()
|
nc2cog/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Configuration management for netCDF to COG TIFF converter."""
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
from .errors import ConfigError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigManager:
|
|
10
|
+
"""Manages configuration for the netCDF to COG TIFF converter."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
13
|
+
"""
|
|
14
|
+
Initialize configuration manager.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config_path: Optional path to config file
|
|
18
|
+
"""
|
|
19
|
+
self.default_config_path = Path(__file__).parent.parent.parent / "config" / "default_config.yaml"
|
|
20
|
+
self.user_config_path = config_path
|
|
21
|
+
|
|
22
|
+
# Load and merge configurations
|
|
23
|
+
self._config = self._load_default_config()
|
|
24
|
+
if config_path:
|
|
25
|
+
user_config = self._load_user_config(config_path)
|
|
26
|
+
self._config = self._merge_configs(self._config, user_config)
|
|
27
|
+
|
|
28
|
+
def _load_default_config(self) -> Dict[str, Any]:
|
|
29
|
+
"""Load default configuration from file."""
|
|
30
|
+
if not self.default_config_path.exists():
|
|
31
|
+
raise ConfigError(f"Default configuration file not found: {self.default_config_path}")
|
|
32
|
+
|
|
33
|
+
with open(self.default_config_path, 'r') as f:
|
|
34
|
+
try:
|
|
35
|
+
return yaml.safe_load(f) or {}
|
|
36
|
+
except yaml.YAMLError as e:
|
|
37
|
+
raise ConfigError(f"Invalid YAML in default config: {e}")
|
|
38
|
+
|
|
39
|
+
def _load_user_config(self, config_path: Path) -> Dict[str, Any]:
|
|
40
|
+
"""Load user configuration from file."""
|
|
41
|
+
if not config_path.exists():
|
|
42
|
+
raise ConfigError(f"Configuration file not found: {config_path}")
|
|
43
|
+
|
|
44
|
+
with open(config_path, 'r') as f:
|
|
45
|
+
try:
|
|
46
|
+
return yaml.safe_load(f) or {}
|
|
47
|
+
except yaml.YAMLError as e:
|
|
48
|
+
raise ConfigError(f"Invalid YAML in config file: {config_path}: {e}")
|
|
49
|
+
|
|
50
|
+
def _merge_configs(self, default: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
|
51
|
+
"""Recursively merge user config into default config."""
|
|
52
|
+
result = default.copy()
|
|
53
|
+
|
|
54
|
+
for key, value in user.items():
|
|
55
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
56
|
+
result[key] = self._merge_configs(result[key], value)
|
|
57
|
+
else:
|
|
58
|
+
result[key] = value
|
|
59
|
+
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def config(self) -> Dict[str, Any]:
|
|
64
|
+
"""Get the merged configuration."""
|
|
65
|
+
return self._config
|
|
66
|
+
|
|
67
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
68
|
+
"""
|
|
69
|
+
Get a configuration value using dot notation.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
key: Configuration key using dot notation (e.g., 'processing.compression')
|
|
73
|
+
default: Default value if key is not found
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Configuration value or default
|
|
77
|
+
"""
|
|
78
|
+
keys = key.split('.')
|
|
79
|
+
value = self._config
|
|
80
|
+
|
|
81
|
+
for k in keys:
|
|
82
|
+
if isinstance(value, dict) and k in value:
|
|
83
|
+
value = value[k]
|
|
84
|
+
else:
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
def validate(self) -> None:
|
|
90
|
+
"""Validate configuration values."""
|
|
91
|
+
# Compression type validation
|
|
92
|
+
compression = self.get('compression', 'deflate')
|
|
93
|
+
valid_compressions = ['deflate', 'lzw', 'jpeg']
|
|
94
|
+
if compression not in valid_compressions:
|
|
95
|
+
raise ConfigError(f"Invalid compression type: {compression}. Valid options: {valid_compressions}")
|
|
96
|
+
|
|
97
|
+
# Tile size validation
|
|
98
|
+
tile_size = self.get('tile_size', [512, 512])
|
|
99
|
+
if not isinstance(tile_size, list) or len(tile_size) != 2:
|
|
100
|
+
raise ConfigError(f"Invalid tile_size: {tile_size}. Must be a list of two integers.")
|
|
101
|
+
|
|
102
|
+
for size in tile_size:
|
|
103
|
+
if not isinstance(size, int) or size <= 0:
|
|
104
|
+
raise ConfigError(f"Invalid tile size: {size}. Must be a positive integer.")
|
|
105
|
+
|
|
106
|
+
# Block size validation
|
|
107
|
+
block_size = self.get('block_size', [256, 256])
|
|
108
|
+
if not isinstance(block_size, list) or len(block_size) != 2:
|
|
109
|
+
raise ConfigError(f"Invalid block_size: {block_size}. Must be a list of two integers.")
|
|
110
|
+
|
|
111
|
+
for size in block_size:
|
|
112
|
+
if not isinstance(size, int) or size <= 0:
|
|
113
|
+
raise ConfigError(f"Invalid block size: {size}. Must be a positive integer.")
|
|
114
|
+
|
|
115
|
+
# Z-level validation
|
|
116
|
+
zlevel = self.get('zlevel', 6)
|
|
117
|
+
if not isinstance(zlevel, int) or zlevel < 1 or zlevel > 9:
|
|
118
|
+
raise ConfigError(f"Invalid zlevel: {zlevel}. Must be an integer between 1 and 9.")
|
|
119
|
+
|
|
120
|
+
# Overviews resampling validation
|
|
121
|
+
resampling = self.get('overviews.resampling', 'nearest')
|
|
122
|
+
valid_resampling_methods = ['nearest', 'bilinear', 'cubic', 'cubicspline', 'lanczos', 'average', 'mode']
|
|
123
|
+
if resampling not in valid_resampling_methods:
|
|
124
|
+
raise ConfigError(f"Invalid resampling method: {resampling}. Valid options: {valid_resampling_methods}")
|
|
125
|
+
|
|
126
|
+
# Projection parameters validation
|
|
127
|
+
source_projection = self.get('projection.source', None)
|
|
128
|
+
target_projection = self.get('projection.target', None)
|
|
129
|
+
resampling_method = self.get('projection.resampling_method', 'nearest')
|
|
130
|
+
|
|
131
|
+
if source_projection is not None:
|
|
132
|
+
# Validate source projection format (expecting EPSG:XXXX format)
|
|
133
|
+
if not isinstance(source_projection, str) or not source_projection.upper().startswith('EPSG:'):
|
|
134
|
+
raise ConfigError(f"Invalid source projection format: {source_projection}. Expected format: 'EPSG:XXXX'")
|
|
135
|
+
|
|
136
|
+
# Validate EPSG code structure (should be EPSG:number)
|
|
137
|
+
try:
|
|
138
|
+
epsg_code = source_projection.split(':')[1]
|
|
139
|
+
int(epsg_code) # Verify it's a valid integer
|
|
140
|
+
except (IndexError, ValueError):
|
|
141
|
+
raise ConfigError(f"Invalid source EPSG code: {source_projection}. Expected format: 'EPSG:XXXX' where XXXX is a number")
|
|
142
|
+
|
|
143
|
+
if target_projection is not None:
|
|
144
|
+
# Validate target projection format (expecting EPSG:XXXX format)
|
|
145
|
+
if not isinstance(target_projection, str) or not target_projection.upper().startswith('EPSG:'):
|
|
146
|
+
raise ConfigError(f"Invalid target projection format: {target_projection}. Expected format: 'EPSG:XXXX'")
|
|
147
|
+
|
|
148
|
+
# Validate EPSG code structure (should be EPSG:number)
|
|
149
|
+
try:
|
|
150
|
+
epsg_code = target_projection.split(':')[1]
|
|
151
|
+
int(epsg_code) # Verify it's a valid integer
|
|
152
|
+
except (IndexError, ValueError):
|
|
153
|
+
raise ConfigError(f"Invalid target EPSG code: {target_projection}. Expected format: 'EPSG:XXXX' where XXXX is a number")
|
|
154
|
+
|
|
155
|
+
# Validate reprojection resampling method
|
|
156
|
+
valid_reprojection_methods = ['nearest', 'bilinear', 'cubic', 'cubicspline', 'lanczos', 'average', 'mode', 'max', 'min', 'med', 'q1', 'q3']
|
|
157
|
+
if resampling_method not in valid_reprojection_methods:
|
|
158
|
+
raise ConfigError(f"Invalid reprojection resampling method: {resampling_method}. Valid options: {valid_reprojection_methods}")
|