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 ADDED
@@ -0,0 +1,3 @@
1
+ """netCDF to COG TIFF Converter Package."""
2
+
3
+ from .__version__ import __version__
nc2cog/__version__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Version information for netCDF to COG TIFF converter."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("nc2cog")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
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}")