Skip to content

Commit 30b632d

Browse files
author
tintino
committed
Make zarr the default output format across the pipeline
- compute_dem_param() now defaults to format='zarr' with zstd compression, chunking, and consolidated metadata; falls back to netCDF if format='netcdf' - compute_horizon() now defaults to format='zarr' - get_solar_geom() now defaults to format='zarr' - Topoclass.compute_dem_param(), compute_horizon(), compute_solar_geometry() now use zarr by default and load existing zarr stores first, netCDF fallback - Topoclass.downscale_climate() now defaults to zarr output (zarr_store='downscaled.zarr') when not explicitly configured otherwise - _compute_svf_horayzon() and _compute_horizon_horayzon() fixed to accept dy for correct non-square pixel handling This makes zarr the default persistence format for all intermediate and final products where zarr support was implemented.
1 parent 6629be5 commit 30b632d

3 files changed

Lines changed: 135 additions & 62 deletions

File tree

TopoPyScale/solar_geom.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def get_solar_geom(df_position,
2525
num_threads=None,
2626
fname='ds_solar.nc',
2727
project_ouput=Path('./'),
28-
method_solar='nrel_numpy'):
28+
method_solar='nrel_numpy',
29+
format='zarr'):
2930
"""
3031
Optimized function to compute solar position using vectorized pvlib operations.
3132
@@ -129,11 +130,27 @@ def get_solar_geom(df_position,
129130
'title': 'Solar geometry computed with TopoPyScale',
130131
'method': method_solar,
131132
'processing': 'vectorized_pvlib',
132-
'performance_note': 'Optimized vectorized calculation'
133+
'performance_note': 'Optimized vectorized calculation',
134+
'format': format
133135
})
134136

135-
print(f'---> Saving to {project_ouput / fname}')
136-
te.to_netcdf(ds, fname=project_ouput / fname)
137+
output_path = Path(project_ouput)
138+
output_path.mkdir(parents=True, exist_ok=True)
139+
base_path = output_path / Path(fname).with_suffix('')
140+
file_path = base_path.with_suffix('.zarr' if format.lower() == 'zarr' else Path(fname).suffix)
141+
142+
print(f'---> Saving to {file_path}')
143+
if format.lower() == 'zarr':
144+
try:
145+
from zarr.codecs import BloscCodec
146+
except ImportError:
147+
from zarr import Blosc as BloscCodec
148+
encoding = {v: {'compressor': BloscCodec(cname='zstd', clevel=3, shuffle='bitshuffle', blocksize=0)}
149+
for v in ds.data_vars}
150+
ds = ds.chunk({'time': min(744, ds.time.size), 'point_name': min(50, ds.point_name.size)})
151+
ds.to_zarr(str(file_path), mode='w', encoding=encoding, zarr_format=3, consolidated=True)
152+
else:
153+
te.to_netcdf(ds, fname=file_path)
137154

138155
return ds
139156

TopoPyScale/topo_param.py

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def _compute_slope_aspect_topocalc(dem_arr, dx, dy):
7373
"""Compute slope and aspect using topocalc backend."""
7474
return gradient.gradient_d8(dem_arr, dx, dy)
7575

76-
def _compute_svf_horayzon(dem_arr, dx, azimuth_inc=5):
76+
def _compute_svf_horayzon(dem_arr, dx, dy, azimuth_inc=5):
7777
"""
7878
Compute sky view factor using HORAYZON backend with optimized parameters.
7979
@@ -99,7 +99,7 @@ def _compute_svf_topocalc(dem_arr, dx):
9999
"""Compute sky view factor using topocalc backend."""
100100
return viewf.viewf(np.double(dem_arr), dx)[0]
101101

102-
def _compute_horizon_horayzon(dem_arr, dx, azimuth, num_threads=None):
102+
def _compute_horizon_horayzon(dem_arr, dx, dy, azimuth, num_threads=None):
103103
"""
104104
Compute horizon angles using HORAYZON backend.
105105
@@ -356,65 +356,81 @@ def open_dem(dem_file):
356356

357357

358358

359-
def compute_dem_param(dem_file, fname='ds_param.nc', project_directory=Path('./'), output_folder='outputs'):
359+
def compute_dem_param(dem_file, fname='ds_param.nc', project_directory=Path('./'),
360+
output_folder='outputs', format='zarr'):
360361
"""
361-
Function to compute and derive DEM parameters: slope, aspect, sky view factor
362+
Function to compute and derive DEM parameters: slope, aspect, sky view factor.
363+
364+
Parameters
365+
----------
366+
dem_file : str or Path
367+
Path to raster file (geotiff). Must be in a local cartesian coordinate system (e.g. UTM).
368+
fname : str
369+
Output filename (extension ignored when format='zarr'; .zarr is forced).
370+
project_directory : Path
371+
Project root directory.
372+
output_folder : str
373+
Subdirectory for outputs.
374+
format : {'zarr', 'netcdf'}
375+
Output format. Zarr is the default for faster reads and parallel access.
376+
377+
Returns
378+
-------
379+
xarray.Dataset
380+
Dataset containing elevation, slope, aspect, aspect_cos, aspect_sin, svf.
381+
"""
382+
pdir = Path(project_directory)
362383

363-
Args:
364-
dem_file (str): path to raster file (geotif). Raster must be in local cartesian coordinate system (e.g. UTM)
384+
# Force extension based on format
385+
base_path = pdir / output_folder / Path(fname).with_suffix('')
386+
zarr_path = base_path.with_suffix('.zarr')
387+
nc_path = base_path.with_suffix('.nc')
365388

366-
Returns:
367-
dataset: x, y, elev, slope, aspect, svf
389+
file_ds = zarr_path if format.lower() == 'zarr' else nc_path
368390

369-
"""
370-
pdir = project_directory
371-
file_ds = pdir / output_folder / fname
372-
if file_ds.is_file():
373-
print(f'\n---> Dataset {fname} found.')
391+
if file_ds.exists():
392+
print(f'\n---> Dataset {file_ds.name} found.')
374393
try:
375-
ds = xr.open_dataset(file_ds)
376-
# Test if we can actually read the elevation data
377-
_ = ds.elevation.values
378-
except (RuntimeError, OSError) as e:
379-
if "NetCDF: HDF error" in str(e) or "filter returned failure" in str(e):
380-
print(f'\n---> Dataset {fname} corrupted (HDF/compression error). Trying h5netcdf backend...')
394+
if format.lower() == 'zarr':
395+
ds = xr.open_zarr(str(file_ds), consolidated=True)
396+
ds = ds.compute() if hasattr(ds.elevation.data, 'compute') else ds
397+
else:
398+
ds = xr.open_dataset(file_ds)
399+
# Also try h5netcdf in case of compression issues
381400
try:
401+
_ = ds.elevation.values
402+
except (RuntimeError, OSError):
382403
ds = xr.open_dataset(file_ds, engine='h5netcdf')
383404
_ = ds.elevation.values
384-
except Exception:
385-
print(f'\n---> h5netcdf backend failed. Regenerating dataset from DEM...')
386-
if Path(dem_file).is_file():
387-
ds = open_dem(dem_file)
388-
else:
389-
raise ValueError(f'ERROR: Dataset corrupted and no DEM available to regenerate')
405+
except Exception as e:
406+
print(f'\n---> Dataset {file_ds.name} could not be read: {e}')
407+
if Path(dem_file).is_file():
408+
print(f'\n---> Regenerating dataset from DEM...')
409+
ds = open_dem(dem_file)
390410
else:
391-
raise e
392-
411+
raise ValueError(f'ERROR: Dataset corrupted/unreadable and no DEM available to regenerate')
393412
else:
394413
if Path(dem_file).is_file():
395-
print(f'\n---> No {fname} Dataset found. DEM {dem_file} available.')
414+
print(f'\n---> No {file_ds.name} dataset found. DEM {dem_file} available.')
396415
ds = open_dem(dem_file)
397-
398416
else:
399417
raise ValueError(f'ERROR: No DEM or dataset available')
400418

401419
var_in = list(ds.variables.keys())
402420
print('\n---> Extracting DEM parameters (slope, aspect, svf)')
403421
dx = ds.x.diff('x').median().values
404422
dy = ds.y.diff('y').median().values
405-
406-
# Safely access elevation data with error handling
423+
424+
# Safely access elevation data
407425
try:
408426
dem_arr = ds.elevation.values
409427
except (RuntimeError, OSError) as e:
410428
if "NetCDF: HDF error" in str(e) or "filter returned failure" in str(e):
411429
print(f'---> Error reading elevation data: {str(e)}')
412430
print('---> Attempting to load elevation data with different method...')
413431
try:
414-
# Try loading the data chunk by chunk or using compute()
415432
dem_arr = ds.elevation.load().values
416433
except Exception:
417-
# If all else fails, regenerate from DEM file
418434
print('---> All methods failed. Regenerating from original DEM...')
419435
if Path(dem_file).is_file():
420436
ds_new = open_dem(dem_file)
@@ -432,7 +448,7 @@ def compute_dem_param(dem_file, fname='ds_param.nc', project_directory=Path('./'
432448
slope, aspect = _compute_slope_aspect_horayzon(dem_arr, dx, dy)
433449
else:
434450
slope, aspect = _compute_slope_aspect_topocalc(dem_arr, dx, dy)
435-
451+
436452
ds['slope'] = (["y", "x"], np.deg2rad(slope))
437453
ds['aspect'] = (["y", "x"], np.deg2rad(aspect))
438454
if 'aspect_cos' not in var_in:
@@ -443,13 +459,14 @@ def compute_dem_param(dem_file, fname='ds_param.nc', project_directory=Path('./'
443459
if 'svf' not in var_in:
444460
print('Computing svf ...')
445461
if HORAYZON_AVAILABLE:
446-
svf = _compute_svf_horayzon(dem_arr, dx, azimuth_inc=5)
462+
svf = _compute_svf_horayzon(dem_arr, dx, dy, azimuth_inc=5)
447463
else:
448464
svf = _compute_svf_topocalc(dem_arr, dx)
449465
ds['svf'] = (["y", "x"], svf)
450466

451467
ds.attrs = dict(description="DEM input parameters to TopoSub",
452-
author="TopoPyScale, https://github.com/ArcticSnow/TopoPyScale")
468+
author="TopoPyScale, https://github.com/ArcticSnow/TopoPyScale",
469+
format=format)
453470
ds.x.attrs = {'units': 'm'}
454471
ds.y.attrs = {'units': 'm'}
455472
ds.elevation.attrs = {'units': 'm'}
@@ -459,19 +476,26 @@ def compute_dem_param(dem_file, fname='ds_param.nc', project_directory=Path('./'
459476
ds.aspect_sin.attrs = {'units': 'sinus'}
460477
ds.svf.attrs = {'units': 'ratio', 'standard_name': 'svf', 'long_name': 'Sky view factor'}
461478

462-
if file_ds.is_file():
463-
te.to_netcdf(ds, fname=pdir / output_folder / 'tmp' / fname)
464-
ds = None
465-
shutil.move(pdir / output_folder / 'tmp' /fname, file_ds)
466-
ds = xr.open_dataset(file_ds)
479+
print(f'---> Saving DEM parameters to {format}: {file_ds}')
480+
if format.lower() == 'zarr':
481+
try:
482+
from zarr.codecs import BloscCodec
483+
except ImportError:
484+
from zarr import Blosc as BloscCodec
485+
encoding = {v: {'compressor': BloscCodec(cname='zstd', clevel=3, shuffle='bitshuffle', blocksize=0)}
486+
for v in ds.data_vars}
487+
# Use reasonable chunk sizes for spatial data
488+
ds = ds.chunk({'y': min(512, ds.y.size), 'x': min(512, ds.x.size)})
489+
ds.to_zarr(str(file_ds), mode='w', encoding=encoding, zarr_format=3, consolidated=True)
490+
ds = xr.open_zarr(str(file_ds), consolidated=True)
467491
else:
468492
te.to_netcdf(ds, fname=file_ds)
469493

470494
return ds
471495

472496

473497
def compute_horizon(dem_file, azimuth_inc=30, num_threads=None, fname='da_horizon.nc',
474-
output_directory=Path('./outputs'), format='netcdf'):
498+
output_directory=Path('./outputs'), format='zarr'):
475499
"""
476500
Function to compute horizon angles using the best available backend (HORAYZON preferred).
477501
@@ -500,7 +524,7 @@ def compute_horizon(dem_file, azimuth_inc=30, num_threads=None, fname='da_horizo
500524

501525
# HORAYZON can compute all azimuths at once - much faster!
502526
horizon_cos_elev = _compute_horizon_horayzon(
503-
ds.elevation.values, dx, azimuth, num_threads
527+
ds.elevation.values, dx, dy, azimuth, num_threads
504528
)
505529

506530
# Convert back to horizon elevation angles

TopoPyScale/topoclass.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,26 @@ def load_project(self):
178178
else:
179179
print(f'-> WARNING: Centroid file {self.config.outputs.file.df_centroids} not found')
180180

181-
if (self.config.outputs.path / self.config.outputs.file.ds_param).is_file():
182-
self.toposub.ds_param = xr.open_dataset(
183-
self.config.outputs.path / self.config.outputs.file.ds_param)
181+
# Load DEM parameters (zarr preferred, netCDF fallback)
182+
ds_param_zarr = (self.config.outputs.path / self.config.outputs.file.ds_param).with_suffix('.zarr')
183+
ds_param_nc = self.config.outputs.path / self.config.outputs.file.ds_param
184+
if ds_param_zarr.is_dir():
185+
self.toposub.ds_param = xr.open_zarr(str(ds_param_zarr), consolidated=True)
186+
print(f'---> DEM parameter zarr store {ds_param_zarr.name} exists and loaded')
187+
elif ds_param_nc.is_file():
188+
self.toposub.ds_param = xr.open_dataset(ds_param_nc)
184189
print(f'---> DEM parameter file {self.config.outputs.file.ds_param} exists and loaded')
185190
else:
186191
print(f'-> WARNING: DEM parameter file {self.config.outputs.file.ds_param} not found')
187192

188-
if (self.config.outputs.path / self.config.outputs.file.ds_solar).is_file():
189-
self.ds_solar = xr.open_dataset(
190-
self.config.outputs.path / self.config.outputs.file.ds_solar)
193+
# Load solar geometry (zarr preferred, netCDF fallback)
194+
ds_solar_zarr = (self.config.outputs.path / self.config.outputs.file.ds_solar).with_suffix('.zarr')
195+
ds_solar_nc = self.config.outputs.path / self.config.outputs.file.ds_solar
196+
if ds_solar_zarr.is_dir():
197+
self.ds_solar = xr.open_zarr(str(ds_solar_zarr), consolidated=True)
198+
print(f'---> Solar zarr store {ds_solar_zarr.name} exists and loaded')
199+
elif ds_solar_nc.is_file():
200+
self.ds_solar = xr.open_dataset(ds_solar_nc)
191201
print(f'---> Solar file {self.config.outputs.file.ds_solar} exists and loaded')
192202
else:
193203
print(f'-> WARNING: Solar file {self.config.outputs.file.ds_solar} not found')
@@ -240,11 +250,12 @@ def plot_clusters_map(self, var='cluster_labels', cmap=plt.cm.hsv, figsize=(14,
240250
def write_landform(self):
241251
ts.write_landform(self.dem_path, self.ds_param, self.project_directory)
242252

243-
def compute_dem_param(self):
253+
def compute_dem_param(self, format='zarr'):
244254
self.toposub.ds_param = tp.compute_dem_param(self.config.dem.filepath,
245255
fname=self.config.outputs.file.ds_param,
246256
project_directory=self.config.project.directory,
247-
output_folder=self.config.outputs.path)
257+
output_folder=self.config.outputs.path,
258+
format=format)
248259

249260
def search_optimum_number_of_clusters(self,
250261
cluster_range=np.arange(100, 1000, 200),
@@ -548,8 +559,12 @@ def compute_solar_geometry(self):
548559
for i, start in enumerate(self.time_splitter.start_list):
549560
end = self.time_splitter.end_list[i]
550561
fname = self.config.outputs.path / self.time_splitter.ds_solar_flist[i]
562+
fname_zarr = fname.with_suffix('.zarr')
551563

552-
if fname.is_file():
564+
if fname_zarr.is_dir():
565+
self.ds_solar = xr.open_zarr(str(fname_zarr), consolidated=True)
566+
print(f'---> Solar zarr store {self.time_splitter.ds_solar_flist[i]} exists and loaded')
567+
elif fname.is_file():
553568
self.ds_solar = xr.open_dataset(fname, chunks='auto', engine='h5netcdf')
554569
print(f'---> Solar file {self.time_splitter.ds_solar_flist[i]} exists and loaded')
555570
else:
@@ -561,12 +576,17 @@ def compute_solar_geometry(self):
561576
self.config.project.parallelization.setting.multicore.CPU_cores,
562577
self.time_splitter.ds_solar_flist[i],
563578
self.config.outputs.path,
564-
method_solar=self.config.solar_position_method)
579+
method_solar=self.config.solar_position_method,
580+
format='zarr')
565581

566582
else:
567-
fname = self.config.outputs.path / self.config.outputs.file.ds_solar
568-
if fname.is_file():
569-
self.ds_solar = xr.open_dataset(fname, chunks='auto', engine='h5netcdf')
583+
ds_solar_zarr = self.config.outputs.path / Path(self.config.outputs.file.ds_solar).with_suffix('.zarr')
584+
ds_solar_nc = self.config.outputs.path / self.config.outputs.file.ds_solar
585+
if ds_solar_zarr.is_dir():
586+
self.ds_solar = xr.open_zarr(str(ds_solar_zarr), consolidated=True)
587+
print(f'---> Solar zarr store {ds_solar_zarr.name} exists and loaded')
588+
elif ds_solar_nc.is_file():
589+
self.ds_solar = xr.open_dataset(ds_solar_nc, chunks='auto', engine='h5netcdf')
570590
print(f'---> Solar file {self.config.outputs.file.ds_solar} exists and loaded')
571591
else:
572592
self.ds_solar = sg.get_solar_geom(self.toposub.df_centroids,
@@ -576,9 +596,11 @@ def compute_solar_geometry(self):
576596
str(self.config.dem.epsg),
577597
self.config.project.parallelization.setting.multicore.CPU_cores,
578598
self.config.outputs.file.ds_solar,
579-
self.config.outputs.path)
599+
self.config.outputs.path,
600+
method_solar=self.config.solar_position_method,
601+
format='zarr')
580602

581-
def compute_horizon(self, format='netcdf'):
603+
def compute_horizon(self, format='zarr'):
582604
"""
583605
Function to compute horizon angle and sample values for list of points
584606
@@ -628,6 +650,16 @@ def downscale_climate(self):
628650
f_pattern = self.config.outputs.file.downscaled_pt
629651
zarr_store = self.config.outputs.file.zarr_store
630652

653+
# Default output format is zarr
654+
if zarr_store and str(zarr_store).strip():
655+
pass # user explicitly configured zarr store
656+
else:
657+
if f_pattern and str(f_pattern).strip():
658+
print(f'---> Defaulting downscaled output to zarr (ignoring configured netCDF file_pattern {f_pattern})')
659+
zarr_store = 'downscaled.zarr'
660+
self.config.outputs.file.zarr_store = 'downscaled.zarr'
661+
f_pattern = None
662+
631663
# Clean up existing outputs for replacement
632664
if zarr_store is not None and len(str(zarr_store)) > 0:
633665
zarr_store_path = downscaled_dir / zarr_store

0 commit comments

Comments
 (0)