From e33ddb85dba8e14ea089f41f48e51a3f8a79e41c Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 22 Oct 2025 13:51:07 +0200 Subject: [PATCH 01/93] download_ERA5_input: add variables - temperature - wind components - humidity (to be checked) --- mkforcing/download_ERA5_input.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 013db7a..933afdd 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -48,6 +48,9 @@ def generate_datarequest(year, monthstr, days, target=None): """Generate and execute ERA5 data download request. + "ERA5 hourly data on single levels from 1940 to present": + https://cds.climate.copernicus.eu/datasets/reanalysis-era5-single-levels?tab=overview + Args: year (int): Year to download monthstr (str): Month as zero-padded string (e.g., '07') @@ -71,7 +74,11 @@ def generate_datarequest(year, monthstr, days, "surface_pressure", "mean_surface_downward_long_wave_radiation_flux", "mean_surface_downward_short_wave_radiation_flux", - "mean_total_precipitation_rate" + "mean_total_precipitation_rate", + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_temperature", + "total_column_water_vapour", ], "year": [str(year)], "month": [monthstr], From a33011555eead643b99ed7cf6a41adae95e53906 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 23 Oct 2025 13:35:47 +0200 Subject: [PATCH 02/93] Adapt `prepare_ERA5_input` for only using downloaded ERA5 - no remapping of meteocloud file - WIND computation from ERA5_instant file - temperature, humidity from ERA5 instant file - variable renaming adapted to ERA5 variable names --- mkforcing/prepare_ERA5_input.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 6427065..a5f8fd7 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -77,24 +77,28 @@ do unzip ${pathdata}/download_era5_${year}_${month}.zip -d ${tmpdir} cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/rmp_era5_${year}_${month}_ins.nc cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-avg.nc ${tmpdir}/rmp_era5_${year}_${month}_avg.nc - cdo -P ${ompthd} remap,${griddesfile},${wgtmeteo} ${pathdata}/meteocloud_${year}_${month}.nc ${tmpdir}/rmp_meteocloud_${year}_${month}.nc + # cdo -P ${ompthd} remap,${griddesfile},${wgtmeteo} ${pathdata}/meteocloud_${year}_${month}.nc ${tmpdir}/rmp_meteocloud_${year}_${month}.nc fi if $lmerge; then - cdo -P ${ompthd} expr,'WIND=sqrt(u^2+v^2)' ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc + # cdo -P ${ompthd} expr,'WIND=sqrt(u^2+v^2)' ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc + cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10, and rename t2m to TBOT cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_const.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_temp2.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp7.nc - cdo selvar,t,q ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp3.nc + # cdo selvar,t,q ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp3.nc + # cdo selvar,t2m,tcwv ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp3.nc # Select t2m and tcwv from instant file - cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp3.nc ${tmpdir}/${year}_${month}_temp2.nc \ + cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc + # ${tmpdir}/${year}_${month}_temp3.nc ncks -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc ncwa -O -a lev ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc - ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc + # ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc + ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v tcwv,QBOT ${year}-${month}.nc # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc From 453b96f54feb22775946dad8888e51fa21d97841 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 19 Nov 2025 10:49:54 +0100 Subject: [PATCH 03/93] add README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4b37491..c83e469 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ cdo gendis, > ``` +- ``: `caf` stands for "CdsApi Format" and `` can be on of the netCDF-files downloaded from the cdsapi. +- `` can be chosen, illustrative example: + `wgtdis_era5caf_to_eur11u-189976.nc` + Usage: `sh prepare_ERA5_input.sh iyear= imonth= wgtcaf= wgtmeteo= griddesfile=` More options are available, see script for details. From c5150a4005cd3a4dd5c6f58666e3989ffe2b1b64 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 10:59:19 +0100 Subject: [PATCH 04/93] docs update --- README.md | 83 +------------------------------- docs/users_guide/era5-forcing.md | 4 ++ 2 files changed, 5 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 0745ab3..2fb936d 100644 --- a/README.md +++ b/README.md @@ -7,88 +7,7 @@ This repository shows how to generate atmospheric forcings for eCLM simulations. -Basis: Mainly CDO commands. - -By sourcing the provided environment file - -``` -source jsc.2024_Intel.sh -``` - -## Creation of forcing data from ERA5 - -A possible source of atmospheric forcing for CLM (eCLM, CLM5, CLM3.5) is ERA5. It is safer to extract the lowermost level of temperature, humidity and wind of ERA5 instead of taking mixed 2m-values and 10m values. [This internal issue](https://gitlab.jsc.fz-juelich.de/HPSCTerrSys/tsmp-internal-development-tracking/-/issues/36) provides some details. The `download_ERA5_input.py` can be adapted to download another set of quantities. - -The folder `mkforcing/` contains three scripts that assist the ERA5 retrieval. - -Note: This worfklow is not fully tested. - -### Download of ERA5 data - -`download_ERA5_input.py` contains a prepared retrieval for the cdsapi python module. -The script requires that cdsapi is installed with a user specific key (API access token). - -More information about the installation and access can be found [here](https://cds.climate.copernicus.eu/how-to-api) or alternatively [here](https://github.com/ecmwf/cdsapi?tab=readme-ov-file#install). - -Usage: -Either directly: -`python download_ERA5_input.py ` -Or using the wrapper script: -`./download_ERA5_input_wrapper.sh` -after changing dates and output directory in the `Settings` section inside this wrapper script. - -Non-JSC users should adapt the download script to include temperature, specific humidity and horizontal wind speed. - -### Preparation of ERA5 data I: Lowermost model level variables (10m altitude) -`extract_ERA5_meteocloud.sh` prepares ERA5 variables form the -lowermost model level (relies on JSC-local files). - -Uses level 137, for more information see -https://confluence.ecmwf.int/display/UDOC/L137+model+level+definitions - -`extract_ERA5_meteocloud.sh` uses JSC-local grib-input files in -`/p/data1/slmet/met_data/ecmwf/era5/grib/`. Further information: -`/p/data1/slmet/met_data/ecmwf/README.md`. - -`extract_ERA5_meteocloud.sh` provides NetCDF files -`meteocloud_YYYY_MM.nc` with lowermost model level atmospheric -variables. The variables from these NetCDF files are used by -`prepare_ERA5_input.sh` in the following ERA5 preparation step. - -Usage: -Running the wrapper job -`sbatch extract_ERA5_meteocloud_wrapper.job` -after adapting `year` and `month` loops according to needed dates. - -### Preparation of ERA5 data II: Remapping, Data merging, CLM3.5 -`prepare_ERA5_input.sh` prepares ERA5 as an input by remapping the -ERA5 data, changing names and modifying units. - -The script is divided into three parts, which could be handled -separately. - -1. Remapping -2. Merging the data -3. CLM3.5 specific data preparation. - -If remapping is to be used, the remapping weights for the ERA data as -well as the grid definition file of the target domain should be -created beforehand. The following commands can be used to create the -necessary files: - -``` -cdo gendis, -cdo gendis, -cdo griddes > -``` - -- ``: `caf` stands for "CdsApi Format" and `` can be on of the netCDF-files downloaded from the cdsapi. -- `` can be chosen, illustrative example: - `wgtdis_era5caf_to_eur11u-189976.nc` - -Usage: `sh prepare_ERA5_input.sh iyear= imonth= -wgtcaf= wgtmeteo= griddesfile=` More -options are available, see script for details. +## Usage / Documentation Please check the documentation at https://hpscterrsys.github.io/eCLM_atm-forcing-generator/INDEX.html diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 4cb9797..7f72993 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -76,6 +76,10 @@ cdo gendis, > ``` +- ``: `caf` stands for "CdsApi Format" and `` can be on of the netCDF-files downloaded from the cdsapi. +- `` can be chosen, illustrative example: + `wgtdis_era5caf_to_eur11u-189976.nc` + Usage: `sh prepare_ERA5_input.sh iyear= imonth= wgtcaf= wgtmeteo= griddesfile=` More options are available, see script for details. From 73fbd9172e12b768f69f7e81d9ac27060a9cb69a Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 11:24:58 +0100 Subject: [PATCH 05/93] download_ERA5_input script with descriptive options --- docs/users_guide/era5-forcing.md | 2 +- mkforcing/download_ERA5_input.py | 37 ++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 7f72993..401b58a 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -26,7 +26,7 @@ More information about the installation and access can be found [here](https://c Usage: Either directly: -`python download_ERA5_input.py ` +`python download_ERA5_input.py --year --month --dirout ` Or using the wrapper script: `./download_ERA5_input_wrapper.sh` after changing dates and output directory in the `Settings` section inside this wrapper script. diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 9c127a4..d59d45f 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -11,14 +11,15 @@ - CDS API credentials configured in ~/.cdsapirc Usage: - python download_ERA5_input.py - python download_ERA5_input.py 2017 7 ./output + python download_ERA5_input.py --year --month --dirout + python download_ERA5_input.py --year 2017 --month 7 --dirout ./output python download_ERA5_input.py --help Note: CDS API credentials must be configured before use. See: https://cds.climate.copernicus.eu/api-how-to """ +import argparse import calendar import cdsapi import sys @@ -110,10 +111,34 @@ def generate_datarequest(year, monthstr, days, if __name__ == "__main__": - # Get the year and month from command-line arguments - year = int(sys.argv[1]) - month = int(sys.argv[2]) - dirout = sys.argv[3] + # Set up argument parser + parser = argparse.ArgumentParser( + description="Download ERA5 reanalysis data from Copernicus Climate Data Store (CDS)." + ) + parser.add_argument( + "--year", + type=int, + required=True, + help="Year to download (e.g., 2017)" + ) + parser.add_argument( + "--month", + type=int, + required=True, + help="Month to download (1-12)" + ) + parser.add_argument( + "--dirout", + type=str, + required=True, + help="Output directory path" + ) + + # Parse command-line arguments + args = parser.parse_args() + year = args.year + month = args.month + dirout = args.dirout # Ensure the output directory exists, if not, create it if not os.path.exists(dirout): From 7c84ec56ea86500b39039ff510907b2baab28bda Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 11:31:43 +0100 Subject: [PATCH 06/93] add request and dataset as options to `download_ERA5_input` --- mkforcing/download_ERA5_input.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index d59d45f..2726555 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -13,6 +13,7 @@ Usage: python download_ERA5_input.py --year --month --dirout python download_ERA5_input.py --year 2017 --month 7 --dirout ./output + python download_ERA5_input.py --year 2017 --month 7 --dirout ./output --request custom_request.py python download_ERA5_input.py --help Note: @@ -133,6 +134,19 @@ def generate_datarequest(year, monthstr, days, required=True, help="Output directory path" ) + parser.add_argument( + "--request", + type=str, + required=False, + help="Path to Python file defining custom 'request' variable" + ) + parser.add_argument( + "--dataset", + type=str, + required=False, + default="reanalysis-era5-single-levels", + help="CDS dataset name (default: reanalysis-era5-single-levels)" + ) # Parse command-line arguments args = parser.parse_args() @@ -140,6 +154,19 @@ def generate_datarequest(year, monthstr, days, month = args.month dirout = args.dirout + # Load custom request if provided + custom_request = None + if args.request: + import importlib.util + spec = importlib.util.spec_from_file_location("custom_request_module", args.request) + custom_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(custom_module) + if hasattr(custom_module, 'request'): + custom_request = custom_module.request + print(f"Loaded custom request from: {args.request}") + else: + print(f"Warning: No 'request' variable found in {args.request}, using default") + # Ensure the output directory exists, if not, create it if not os.path.exists(dirout): os.makedirs(dirout) @@ -154,8 +181,9 @@ def generate_datarequest(year, monthstr, days, days = generate_days(year, month) print(f"Downloading ERA5 data for {year}-{monthstr}") + print(f"Dataset: {args.dataset}") print(f"Output directory: {os.getcwd()}") # Execute download request - target = generate_datarequest(year, monthstr, days) + target = generate_datarequest(year, monthstr, days, dataset=args.dataset, request=custom_request) print(f"Download complete: {os.path.abspath(target)}") From 350cdcc41a01be22623c3f0507f4509ff6ef82ed Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:07:39 +0100 Subject: [PATCH 07/93] Adapt year, month and day in custom request to input values --- mkforcing/download_ERA5_input.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 2726555..11d0ac4 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -99,6 +99,11 @@ def generate_datarequest(year, monthstr, days, "download_format": "unarchived", "area": [74, -42, 20, 69] } + else: + # Adapt year, month and day to input values + request["year"] = [str(year)] + request["month"] = [monthstr] + request["day"] = days # Default filename if not provided if target is None: From d0ad2036226f10c539072ac5d2f051e1bba12af7 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:08:01 +0100 Subject: [PATCH 08/93] revert to default request --- mkforcing/download_ERA5_input.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 11d0ac4..970b061 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -76,11 +76,7 @@ def generate_datarequest(year, monthstr, days, "surface_pressure", "mean_surface_downward_long_wave_radiation_flux", "mean_surface_downward_short_wave_radiation_flux", - "mean_total_precipitation_rate", - "10m_u_component_of_wind", - "10m_v_component_of_wind", - "2m_temperature", - "total_column_water_vapour", + "mean_total_precipitation_rate" ], "year": [str(year)], "month": [monthstr], From 143bebdceb6a9cb541801dedc92d1ebc2bddab42 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:11:08 +0100 Subject: [PATCH 09/93] custom dataset input can also be read from request file --- mkforcing/download_ERA5_input.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 970b061..4be397b 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -157,6 +157,7 @@ def generate_datarequest(year, monthstr, days, # Load custom request if provided custom_request = None + custom_dataset = args.dataset if args.request: import importlib.util spec = importlib.util.spec_from_file_location("custom_request_module", args.request) @@ -167,6 +168,9 @@ def generate_datarequest(year, monthstr, days, print(f"Loaded custom request from: {args.request}") else: print(f"Warning: No 'request' variable found in {args.request}, using default") + if hasattr(custom_module, 'dataset'): + custom_dataset = custom_module.dataset + print(f"Loaded custom dataset from: {args.request}") # Ensure the output directory exists, if not, create it if not os.path.exists(dirout): @@ -182,9 +186,9 @@ def generate_datarequest(year, monthstr, days, days = generate_days(year, month) print(f"Downloading ERA5 data for {year}-{monthstr}") - print(f"Dataset: {args.dataset}") + print(f"Dataset: {custom_dataset}") print(f"Output directory: {os.getcwd()}") # Execute download request - target = generate_datarequest(year, monthstr, days, dataset=args.dataset, request=custom_request) + target = generate_datarequest(year, monthstr, days, dataset=custom_dataset, request=custom_request) print(f"Download complete: {os.path.abspath(target)}") From 796d6dd97c37c10d1ba5b25d5d9c04190cfcb93c Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:11:46 +0100 Subject: [PATCH 10/93] style --- mkforcing/download_ERA5_input.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 4be397b..9ce5460 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -23,9 +23,10 @@ import argparse import calendar import cdsapi -import sys +# import sys import os + def generate_days(year, month): """Get the number of days in a given month and year. @@ -44,10 +45,11 @@ def generate_days(year, month): return days + def generate_datarequest(year, monthstr, days, - dataset="reanalysis-era5-single-levels", - request=None, - target=None): + dataset="reanalysis-era5-single-levels", + request=None, + target=None): """Generate and execute ERA5 data download request. "ERA5 hourly data on single levels from 1940 to present": From 446726461c6fdf14812e2b2a90e1fddf83fec0ff Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:29:36 +0100 Subject: [PATCH 11/93] doc adaption --- mkforcing/prepare_ERA5_input.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index a5f8fd7..0a14765 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -83,7 +83,7 @@ do if $lmerge; then # cdo -P ${ompthd} expr,'WIND=sqrt(u^2+v^2)' ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc - cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10, and rename t2m to TBOT + cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_const.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_temp2.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp7.nc From a7badf9c02fbdce511b4da5260a8d655975c9c60 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 26 Nov 2025 12:43:57 +0100 Subject: [PATCH 12/93] add custom request for ERA5 --- mkforcing/custom_request_ERA5.py | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 mkforcing/custom_request_ERA5.py diff --git a/mkforcing/custom_request_ERA5.py b/mkforcing/custom_request_ERA5.py new file mode 100644 index 0000000..cbf85b5 --- /dev/null +++ b/mkforcing/custom_request_ERA5.py @@ -0,0 +1,35 @@ +# Custom ERA5 request, when all information should be downloaded from ERA5 + + +# import cdsapi + +dataset = "reanalysis-era5-single-levels" +request = { + "product_type": ["reanalysis"], + "variable": [ + "surface_pressure", + "mean_surface_downward_long_wave_radiation_flux", + "mean_surface_downward_short_wave_radiation_flux", + "mean_total_precipitation_rate", + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_temperature", + "2m_dewpoint_temperature", + ], + "time": [ + "00:00", "01:00", "02:00", + "03:00", "04:00", "05:00", + "06:00", "07:00", "08:00", + "09:00", "10:00", "11:00", + "12:00", "13:00", "14:00", + "15:00", "16:00", "17:00", + "18:00", "19:00", "20:00", + "21:00", "22:00", "23:00" + ], + "data_format": "netcdf", + "download_format": "unarchived", + "area": [74, -42, 20, 69] +} + +# client = cdsapi.Client() +# client.retrieve(dataset, request).download() From 7850112e68a46506e9bbd3fa36e558861fbaeb3c Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:10:14 +0100 Subject: [PATCH 13/93] dewpoint to specific humidity --- docs/users_guide/era5-forcing.md | 12 +- mkforcing/dewpoint_to_specific_humidity.py | 150 +++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 mkforcing/dewpoint_to_specific_humidity.py diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 401b58a..495a45c 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -54,7 +54,17 @@ Running the wrapper job `sbatch extract_ERA5_meteocloud_wrapper.job` after adapting `year` and `month` loops according to needed dates. -### Preparation of ERA5 data II: Remapping, Data merging, CLM3.5 +### Preparation of ERA5 data II: Specific humidity computation + +For users, who do not have access to the Meteocloud from the previous +section. + +For ERA5, specific humidity can be computed from dewpoint temperature +and surface pressure using `python dewpoint_to_specific_humidity.py` +after adapting `filename`. + + +### Preparation of ERA5 data III: Remapping, Data merging, CLM3.5 `prepare_ERA5_input.sh` prepares ERA5 as an input by remapping the ERA5 data, changing names and modifying units. diff --git a/mkforcing/dewpoint_to_specific_humidity.py b/mkforcing/dewpoint_to_specific_humidity.py new file mode 100644 index 0000000..64b8832 --- /dev/null +++ b/mkforcing/dewpoint_to_specific_humidity.py @@ -0,0 +1,150 @@ +import numpy as np +import netCDF4 + + +def dewpoint_to_specific_humidity(T_d, P): + """ + Convert dewpoint temperature to specific humidity + + Source: + ------- + - Stull, R., 2017: "Practical Meteorology: An Algebra-based Survey of Atmospheric + Science" -version 1.02b. Univ. of British Columbia. 940 pages. + isbn 978-0-88865-283-6 . + + Parameters: + ----------- + T_d : float or array + Dewpoint temperature [K] + P : float or array + Surface pressure [Pa] + + Returns: + -------- + q : float or array + Specific humidity [kg/kg] + """ + # Constants + epsilon = 0.622 # Ratio of molecular weights + + # Convert dewpoint to vapor pressure using August-Roche-Magnus formula + # e_s in Pa + # https://en.wikipedia.org/wiki/Clausius%E2%80%93Clapeyron_relation#Meteorology_and_climatology + e_s = 610.2 * np.exp(17.625 * (T_d - 273.15) / ((T_d - 273.15) + 243.04)) + + # # Tetens formula + # # Stull2017, Equation (4.2) + # e_s = 611.3 * np.exp(17.2694 * (T_d - 273.15) / ((T_d - 273.15) + 237.29)) + + # Convert vapor pressure to specific humidity + # Stull2017, Table 4a + q = epsilon * e_s / (P - (1.0 - epsilon) * e_s) + + return q + + +def add_specific_humidity_to_netcdf(filename): + """ + Read surface pressure and dewpoint temperature from a netCDF file, + calculate specific humidity at 2m, and write it back to the file. + + Parameters: + ----------- + filename : str + Path to the netCDF file containing 'sp' and 'd2m' variables + + Returns: + -------- + None + Modifies the netCDF file in place by adding 'q2m' variable + + Raises: + ------- + ValueError + If 'q2m' variable already exists in the file + KeyError + If required variables 'sp' or 'd2m' are not found + """ + + # Open netCDF file in append mode + print(f"Opening {filename}...") + nc = netCDF4.Dataset(filename, "a") + + try: + # Check if q2m already exists - if so, raise error and exit + if "q2m" in nc.variables: + nc.close() + raise ValueError( + f"Variable 'q2m' already exists in {filename}. " + "No changes made. Delete the variable first if you want to recalculate." + ) + + # Read the required variables + print("Reading surface pressure (sp) and dewpoint temperature (d2m)...") + sp = nc.variables["sp"][:] # Surface pressure [Pa] + d2m = nc.variables["d2m"][:] # Dewpoint temperature at 2m [K] + + print(f"Data shapes - sp: {sp.shape}, d2m: {d2m.shape}") + + # Calculate specific humidity + print("Calculating specific humidity...") + q2m = dewpoint_to_specific_humidity(d2m, sp) + + # Create the new variable + print("Creating new variable 'q2m'...") + + # Get dimension names from d2m (they should be the same) + dim_names = nc.variables["d2m"].dimensions + + # Create the q2m variable with same dimensions as d2m + q2m_var = nc.createVariable("q2m", "f4", dim_names, zlib=True, complevel=4) + + # Add attributes + q2m_var.units = "kg kg-1" + q2m_var.long_name = "Specific humidity at 2m" + q2m_var.standard_name = "specific_humidity" + q2m_var.description = ( + "Calculated from dewpoint temperature (d2m) and surface pressure (sp)" + ) + + # Write the data + q2m_var[:] = q2m + + print("Variable 'q2m' created and written successfully!") + + # Print some statistics + print("\nStatistics:") + print( + f" Specific humidity range: {np.min(q2m):.6f} to {np.max(q2m):.6f} kg/kg" + ) + print(f" Specific humidity mean: {np.mean(q2m):.6f} kg/kg") + print(f" In g/kg: {np.mean(q2m)*1000:.3f} g/kg (mean)") + + except KeyError as e: + print(f"Error: Required variable not found in netCDF file: {e}") + print(f"Available variables: {list(nc.variables.keys())}") + nc.close() + raise + + except ValueError as e: + # This catches the "q2m already exists" error + print(f"Error: {e}") + raise + + except Exception as e: + print(f"Unexpected error occurred: {e}") + nc.close() + raise + + else: + # Only executes if no exception was raised + nc.close() + print(f"\nFile {filename} closed successfully.") + + +if __name__ == "__main__": + # Specify your netCDF file + filename = "cdsapidwn2/data_stream-oper_stepType-instant.nc" + + # Process the file + add_specific_humidity_to_netcdf(filename) From 566f3cb1836f719e341c32b8ecd7b700a4028fbe Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:12:52 +0100 Subject: [PATCH 14/93] dewpoint_to_specific_humidity: make filename command line input --- docs/users_guide/era5-forcing.md | 6 ++++-- mkforcing/dewpoint_to_specific_humidity.py | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 495a45c..aa836b5 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -60,9 +60,11 @@ For users, who do not have access to the Meteocloud from the previous section. For ERA5, specific humidity can be computed from dewpoint temperature -and surface pressure using `python dewpoint_to_specific_humidity.py` -after adapting `filename`. +and surface pressure using +``` +python dewpoint_to_specific_humidity.py --filename +``` ### Preparation of ERA5 data III: Remapping, Data merging, CLM3.5 `prepare_ERA5_input.sh` prepares ERA5 as an input by remapping the diff --git a/mkforcing/dewpoint_to_specific_humidity.py b/mkforcing/dewpoint_to_specific_humidity.py index 64b8832..30f3a0c 100644 --- a/mkforcing/dewpoint_to_specific_humidity.py +++ b/mkforcing/dewpoint_to_specific_humidity.py @@ -1,3 +1,4 @@ +import argparse import numpy as np import netCDF4 @@ -143,8 +144,18 @@ def add_specific_humidity_to_netcdf(filename): if __name__ == "__main__": - # Specify your netCDF file - filename = "cdsapidwn2/data_stream-oper_stepType-instant.nc" + # Set up argument parser + parser = argparse.ArgumentParser( + description="Add specific humidity (q2m) to a netCDF file based on dewpoint temperature and surface pressure." + ) + parser.add_argument( + "filename", + type=str, + help="Path to the ERA5-downloaded netCDF file containing 'sp' and 'd2m' variables" + ) + + # Parse command-line arguments + args = parser.parse_args() # Process the file - add_specific_humidity_to_netcdf(filename) + add_specific_humidity_to_netcdf(args.filename) From dd856e91f7e0497125d262793d2af401b01ae52b Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:15:08 +0100 Subject: [PATCH 15/93] adapt prepare script for all-downloaded ERA5 --- mkforcing/prepare_ERA5_input.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 0a14765..cd24f5f 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -88,17 +88,18 @@ do ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_temp2.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp7.nc # cdo selvar,t,q ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp3.nc - # cdo selvar,t2m,tcwv ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp3.nc # Select t2m and tcwv from instant file + # cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp3.nc ${tmpdir}/${year}_${month}_temp2.nc \ + # ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc - # ${tmpdir}/${year}_${month}_temp3.nc ncks -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc - ncwa -O -a lev ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + # ncwa -O -a lev ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc # Simply copy the file # ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc - ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v tcwv,QBOT ${year}-${month}.nc + ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v q2m,QBOT ${year}-${month}.nc # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc From b868b74a9afbab9e1b0d639e190654202082d788 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:21:10 +0100 Subject: [PATCH 16/93] lmeteo: switch for using meteocloud data --- mkforcing/prepare_ERA5_input.sh | 47 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index cd24f5f..9003352 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -5,6 +5,7 @@ set -eo pipefail lrmp=true lmerge=true lclm3=false +lmeteo=true # Switch for using JSC-internal meteocloud data ompthd=1 # TSMP2/eclm pathdata=./ @@ -35,6 +36,7 @@ parse_arguments() { lrmp) lrmp="$value" ;; lmerge) lmerge="$value" ;; lclm3) lclm3="$value" ;; + lmeteo) lmeteo="$value" ;; ompthd) ompthd="$value" ;; pathdata) pathdata="$value" ;; wgtcaf) wgtcaf="$value" ;; @@ -77,29 +79,46 @@ do unzip ${pathdata}/download_era5_${year}_${month}.zip -d ${tmpdir} cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/rmp_era5_${year}_${month}_ins.nc cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-avg.nc ${tmpdir}/rmp_era5_${year}_${month}_avg.nc - # cdo -P ${ompthd} remap,${griddesfile},${wgtmeteo} ${pathdata}/meteocloud_${year}_${month}.nc ${tmpdir}/rmp_meteocloud_${year}_${month}.nc + if $lmeteo; then + cdo -P ${ompthd} remap,${griddesfile},${wgtmeteo} ${pathdata}/meteocloud_${year}_${month}.nc ${tmpdir}/rmp_meteocloud_${year}_${month}.nc + fi fi if $lmerge; then - # cdo -P ${ompthd} expr,'WIND=sqrt(u^2+v^2)' ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc - cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 + if $lmeteo; then + cdo -P ${ompthd} expr,'WIND=sqrt(u^2+v^2)' ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc + else + cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 + fi cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_const.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_avg.nc ${tmpdir}/${year}_${month}_temp2.nc ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_ins.nc ${tmpdir}/${year}_${month}_temp7.nc - # cdo selvar,t,q ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp3.nc - - # cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp3.nc ${tmpdir}/${year}_${month}_temp2.nc \ - # ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc - cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ - ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc + if $lmeteo; then + cdo selvar,t,q ${tmpdir}/rmp_meteocloud_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp3.nc + fi + + if $lmeteo; then + cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp3.nc ${tmpdir}/${year}_${month}_temp2.nc \ + ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc + else + cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ + ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp7.nc ${tmpdir}/${year}_${month}_temp4.nc + fi ncks -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc - # ncwa -O -a lev ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc - cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc # Simply copy the file - - # ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc - ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v q2m,QBOT ${year}-${month}.nc + if $lmeteo; then + ncwa -O -a lev ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + else + # Simply copy the file + cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + fi + + if $lmeteo; then + ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc + else + ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v q2m,QBOT ${year}-${month}.nc + fi # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc From a1230f318255904c6de2394587908b383388e4a8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:24:59 +0100 Subject: [PATCH 17/93] small adpations --- mkforcing/custom_request_ERA5.py | 3 ++- mkforcing/download_ERA5_input.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mkforcing/custom_request_ERA5.py b/mkforcing/custom_request_ERA5.py index cbf85b5..988599f 100644 --- a/mkforcing/custom_request_ERA5.py +++ b/mkforcing/custom_request_ERA5.py @@ -28,7 +28,8 @@ ], "data_format": "netcdf", "download_format": "unarchived", - "area": [74, -42, 20, 69] + "area": [51, 6, 50, 7] # Selhausen + # "area": [74, -42, 20, 69] # Europe } # client = cdsapi.Client() diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 9ce5460..99fcf32 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -95,7 +95,7 @@ def generate_datarequest(year, monthstr, days, ], "data_format": "netcdf", "download_format": "unarchived", - "area": [74, -42, 20, 69] + "area": [74, -42, 20, 69] # N, W, S, E } else: # Adapt year, month and day to input values From c4bdd4d2f8520d6880e0d45b73b216ae56683b3d Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:28:07 +0100 Subject: [PATCH 18/93] doc fix --- docs/users_guide/era5-forcing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index aa836b5..877e374 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -63,7 +63,7 @@ For ERA5, specific humidity can be computed from dewpoint temperature and surface pressure using ``` -python dewpoint_to_specific_humidity.py --filename +python dewpoint_to_specific_humidity.py ``` ### Preparation of ERA5 data III: Remapping, Data merging, CLM3.5 From 12bf81f72b5c90aa7c946fdece269b62e625baf7 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:39:59 +0100 Subject: [PATCH 19/93] unzip optional --- mkforcing/prepare_ERA5_input.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 9003352..267e9ce 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -6,6 +6,7 @@ lrmp=true lmerge=true lclm3=false lmeteo=true # Switch for using JSC-internal meteocloud data +lunzip=true # Switch for unzipping or (if false) copying the ERA5 data ompthd=1 # TSMP2/eclm pathdata=./ @@ -37,6 +38,7 @@ parse_arguments() { lmerge) lmerge="$value" ;; lclm3) lclm3="$value" ;; lmeteo) lmeteo="$value" ;; + lunzip) lunzip="$value" ;; ompthd) ompthd="$value" ;; pathdata) pathdata="$value" ;; wgtcaf) wgtcaf="$value" ;; @@ -76,7 +78,13 @@ do mkdir -pv $tmpdir if $lrmp; then - unzip ${pathdata}/download_era5_${year}_${month}.zip -d ${tmpdir} + if $lunzip; then + # Unzip ERA5-downloaded data from zip file + unzip ${pathdata}/download_era5_${year}_${month}.zip -d ${tmpdir} + else + # Copy already unzipped data + cp ${pathdata}/data_stream-oper_stepType-instant.nc ${pathdata}/data_stream-oper_stepType-avg.nc ${tmpdir} + fi cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/rmp_era5_${year}_${month}_ins.nc cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/data_stream-oper_stepType-avg.nc ${tmpdir}/rmp_era5_${year}_${month}_avg.nc if $lmeteo; then From f7da39d6c95446793371ed045e70ed6089d26d11 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 27 Nov 2025 00:53:49 +0100 Subject: [PATCH 20/93] custom request for seasonal forecast --- mkforcing/custom_request_SEAS5.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 mkforcing/custom_request_SEAS5.py diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py new file mode 100644 index 0000000..0985bb2 --- /dev/null +++ b/mkforcing/custom_request_SEAS5.py @@ -0,0 +1,39 @@ +## Download for SEAS5 forecast variables +# https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download + +# Forecast for 7 months + +# import cdsapi + +dataset = "seasonal-original-single-levels" +request = { + "originating_centre": "ecmwf", + "system": "51", + "variable": [ + "mean_sea_level_pressure", + "surface_thermal_radiation_downwards", + "surface_solar_radiation_downwards", + "total_precipitation" + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_temperature", + "2m_dewpoint_temperature", + ], + "year": ["2025"], + "month": ["09"], + "day": ["01"], + "leadtime_hour": [ + "6", "12", "18", "24", "30", "36", "42", "48", "54", "60", "66", "72", "78", "84", "90", "96", "102", "108", "114", "120", "126", "132", "138", "144", "150", "156", "162", "168", "174", "180", "186", "192", "198", "204", "210", "216", "222", "228", "234", "240", "246", "252", "258", "264", "270", "276", "282", "288", "294", "300", "306", "312", "318", "324", "330", "336", "342", "348", "354", "360", "366", "372", "378", "384", "390", "396", "402", "408", "414", "420", "426", "432", "438", "444", "450", "456", "462", "468", "474", "480", "486", "492", "498", "504", "510", "516", "522", "528", "534", "540", "546", "552", "558", "564", "570", "576", "582", "588", "594", "600", "606", "612", "618", "624", "630", "636", "642", "648", "654", "660", "666", "672", "678", "684", "690", "696", "702", "708", "714", "720", "726", "732", "738", "744", "750", "756", "762", "768", "774", "780", "786", "792", "798", "804", "810", "816", "822", "828", "834", "840", "846", "852", "858", "864", "870", "876", "882", "888", "894", "900", "906", "912", "918", "924", "930", "936", "942", "948", "954", "960", "966", "972", "978", "984", "990", "996", + "1002", "1008", "1014", "1020", "1026", "1032", "1038", "1044", "1050", "1056", "1062", "1068", "1074", "1080", "1086", "1092", "1098", "1104", "1110", "1116", "1122", "1128", "1134", "1140", "1146", "1152", "1158", "1164", "1170", "1176", "1182", "1188", "1194", "1200", "1206", "1212", "1218", "1224", "1230", "1236", "1242", "1248", "1254", "1260", "1266", "1272", "1278", "1284", "1290", "1296", "1302", "1308", "1314", "1320", "1326", "1332", "1338", "1344", "1350", "1356", "1362", "1368", "1374", "1380", "1386", "1392", "1398", "1404", "1410", "1416", "1422", "1428", "1434", "1440", "1446", "1452", "1458", "1464", "1470", "1476", "1482", "1488", "1494", "1500", "1506", "1512", "1518", "1524", "1530", "1536", "1542", "1548", "1554", "1560", "1566", "1572", "1578", "1584", "1590", "1596", "1602", "1608", "1614", "1620", "1626", "1632", "1638", "1644", "1650", "1656", "1662", "1668", "1674", "1680", "1686", "1692", "1698", "1704", "1710", "1716", "1722", "1728", "1734", "1740", "1746", "1752", "1758", "1764", "1770", "1776", "1782", "1788", "1794", "1800", "1806", "1812", "1818", "1824", "1830", "1836", "1842", "1848", "1854", "1860", "1866", "1872", "1878", "1884", "1890", "1896", "1902", "1908", "1914", "1920", "1926", "1932", "1938", "1944", "1950", "1956", "1962", "1968", "1974", "1980", "1986", "1992", "1998", + "2004", "2010", "2016", "2022", "2028", "2034", "2040", "2046", "2052", "2058", "2064", "2070", "2076", "2082", "2088", "2094", "2100", "2106", "2112", "2118", "2124", "2130", "2136", "2142", "2148", "2154", "2160", "2166", "2172", "2178", "2184", "2190", "2196", "2202", "2208", "2214", "2220", "2226", "2232", "2238", "2244", "2250", "2256", "2262", "2268", "2274", "2280", "2286", "2292", "2298", "2304", "2310", "2316", "2322", "2328", "2334", "2340", "2346", "2352", "2358", "2364", "2370", "2376", "2382", "2388", "2394", "2400", "2406", "2412", "2418", "2424", "2430", "2436", "2442", "2448", "2454", "2460", "2466", "2472", "2478", "2484", "2490", "2496", "2502", "2508", "2514", "2520", "2526", "2532", "2538", "2544", "2550", "2556", "2562", "2568", "2574", "2580", "2586", "2592", "2598", "2604", "2610", "2616", "2622", "2628", "2634", "2640", "2646", "2652", "2658", "2664", "2670", "2676", "2682", "2688", "2694", "2700", "2706", "2712", "2718", "2724", "2730", "2736", "2742", "2748", "2754", "2760", "2766", "2772", "2778", "2784", "2790", "2796", "2802", "2808", "2814", "2820", "2826", "2832", "2838", "2844", "2850", "2856", "2862", "2868", "2874", "2880", "2886", "2892", "2898", "2904", "2910", "2916", "2922", "2928", "2934", "2940", "2946", "2952", "2958", "2964", "2970", "2976", "2982", "2988", "2994", + "3000", "3006", "3012", "3018", "3024", "3030", "3036", "3042", "3048", "3054", "3060", "3066", "3072", "3078", "3084", "3090", "3096", "3102", "3108", "3114", "3120", "3126", "3132", "3138", "3144", "3150", "3156", "3162", "3168", "3174", "3180", "3186", "3192", "3198", "3204", "3210", "3216", "3222", "3228", "3234", "3240", "3246", "3252", "3258", "3264", "3270", "3276", "3282", "3288", "3294", "3300", "3306", "3312", "3318", "3324", "3330", "3336", "3342", "3348", "3354", "3360", "3366", "3372", "3378", "3384", "3390", "3396", "3402", "3408", "3414", "3420", "3426", "3432", "3438", "3444", "3450", "3456", "3462", "3468", "3474", "3480", "3486", "3492", "3498", "3504", "3510", "3516", "3522", "3528", "3534", "3540", "3546", "3552", "3558", "3564", "3570", "3576", "3582", "3588", "3594", "3600", "3606", "3612", "3618", "3624", "3630", "3636", "3642", "3648", "3654", "3660", "3666", "3672", "3678", "3684", "3690", "3696", "3702", "3708", "3714", "3720", "3726", "3732", "3738", "3744", "3750", "3756", "3762", "3768", "3774", "3780", "3786", "3792", "3798", "3804", "3810", "3816", "3822", "3828", "3834", "3840", "3846", "3852", "3858", "3864", "3870", "3876", "3882", "3888", "3894", "3900", "3906", "3912", "3918", "3924", "3930", "3936", "3942", "3948", "3954", "3960", "3966", "3972", "3978", "3984", "3990", "3996", + "4002", "4008", "4014", "4020", "4026", "4032", "4038", "4044", "4050", "4056", "4062", "4068", "4074", "4080", "4086", "4092", "4098", "4104", "4110", "4116", "4122", "4128", "4134", "4140", "4146", "4152", "4158", "4164", "4170", "4176", "4182", "4188", "4194", "4200", "4206", "4212", "4218", "4224", "4230", "4236", "4242", "4248", "4254", "4260", "4266", "4272", "4278", "4284", "4290", "4296", "4302", "4308", "4314", "4320", "4326", "4332", "4338", "4344", "4350", "4356", "4362", "4368", "4374", "4380", "4386", "4392", "4398", "4404", "4410", "4416", "4422", "4428", "4434", "4440", "4446", "4452", "4458", "4464", "4470", "4476", "4482", "4488", "4494", "4500", "4506", "4512", "4518", "4524", "4530", "4536", "4542", "4548", "4554", "4560", "4566", "4572", "4578", "4584", "4590", "4596", "4602", "4608", "4614", "4620", "4626", "4632", "4638", "4644", "4650", "4656", "4662", "4668", "4674", "4680", "4686", "4692", "4698", "4704", "4710", "4716", "4722", "4728", "4734", "4740", "4746", "4752", "4758", "4764", "4770", "4776", "4782", "4788", "4794", "4800", "4806", "4812", "4818", "4824", "4830", "4836", "4842", "4848", "4854", "4860", "4866", "4872", "4878", "4884", "4890", "4896", "4902", "4908", "4914", "4920", "4926", "4932", "4938", "4944", "4950", "4956", "4962", "4968", "4974", "4980", "4986", "4992", "4998", + "5004", "5010", "5016", "5022", "5028", "5034", "5040", "5046", "5052", "5058", "5064", "5070", "5076", "5082", "5088", "5094", "5100", "5106", "5112", "5118", "5124", "5130", "5136", "5142", "5148", "5154", "5160" + ], + "data_format": "netcdf", + "area": [51, 6, 50, 7] # Selhausen + # "area": [74, -42, 20, 69] # Europe +} + +# client = cdsapi.Client() +# client.retrieve(dataset, request).download() From a938a821ddf4aa110589bac3a57e035e677191d5 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 28 Nov 2025 14:27:38 +0100 Subject: [PATCH 21/93] add link in custom ERA5 request --- mkforcing/custom_request_ERA5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/custom_request_ERA5.py b/mkforcing/custom_request_ERA5.py index 988599f..662ebe4 100644 --- a/mkforcing/custom_request_ERA5.py +++ b/mkforcing/custom_request_ERA5.py @@ -1,5 +1,5 @@ # Custom ERA5 request, when all information should be downloaded from ERA5 - +# https://cds.climate.copernicus.eu/datasets/reanalysis-era5-single-levels?tab=download # import cdsapi From f65cf6cf0486d08425a85d1b8662043609238ae6 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 28 Nov 2025 14:28:43 +0100 Subject: [PATCH 22/93] custom request for SEAS5 --- mkforcing/custom_request_SEAS5.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 mkforcing/custom_request_SEAS5.py diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py new file mode 100644 index 0000000..0985bb2 --- /dev/null +++ b/mkforcing/custom_request_SEAS5.py @@ -0,0 +1,39 @@ +## Download for SEAS5 forecast variables +# https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download + +# Forecast for 7 months + +# import cdsapi + +dataset = "seasonal-original-single-levels" +request = { + "originating_centre": "ecmwf", + "system": "51", + "variable": [ + "mean_sea_level_pressure", + "surface_thermal_radiation_downwards", + "surface_solar_radiation_downwards", + "total_precipitation" + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_temperature", + "2m_dewpoint_temperature", + ], + "year": ["2025"], + "month": ["09"], + "day": ["01"], + "leadtime_hour": [ + "6", "12", "18", "24", "30", "36", "42", "48", "54", "60", "66", "72", "78", "84", "90", "96", "102", "108", "114", "120", "126", "132", "138", "144", "150", "156", "162", "168", "174", "180", "186", "192", "198", "204", "210", "216", "222", "228", "234", "240", "246", "252", "258", "264", "270", "276", "282", "288", "294", "300", "306", "312", "318", "324", "330", "336", "342", "348", "354", "360", "366", "372", "378", "384", "390", "396", "402", "408", "414", "420", "426", "432", "438", "444", "450", "456", "462", "468", "474", "480", "486", "492", "498", "504", "510", "516", "522", "528", "534", "540", "546", "552", "558", "564", "570", "576", "582", "588", "594", "600", "606", "612", "618", "624", "630", "636", "642", "648", "654", "660", "666", "672", "678", "684", "690", "696", "702", "708", "714", "720", "726", "732", "738", "744", "750", "756", "762", "768", "774", "780", "786", "792", "798", "804", "810", "816", "822", "828", "834", "840", "846", "852", "858", "864", "870", "876", "882", "888", "894", "900", "906", "912", "918", "924", "930", "936", "942", "948", "954", "960", "966", "972", "978", "984", "990", "996", + "1002", "1008", "1014", "1020", "1026", "1032", "1038", "1044", "1050", "1056", "1062", "1068", "1074", "1080", "1086", "1092", "1098", "1104", "1110", "1116", "1122", "1128", "1134", "1140", "1146", "1152", "1158", "1164", "1170", "1176", "1182", "1188", "1194", "1200", "1206", "1212", "1218", "1224", "1230", "1236", "1242", "1248", "1254", "1260", "1266", "1272", "1278", "1284", "1290", "1296", "1302", "1308", "1314", "1320", "1326", "1332", "1338", "1344", "1350", "1356", "1362", "1368", "1374", "1380", "1386", "1392", "1398", "1404", "1410", "1416", "1422", "1428", "1434", "1440", "1446", "1452", "1458", "1464", "1470", "1476", "1482", "1488", "1494", "1500", "1506", "1512", "1518", "1524", "1530", "1536", "1542", "1548", "1554", "1560", "1566", "1572", "1578", "1584", "1590", "1596", "1602", "1608", "1614", "1620", "1626", "1632", "1638", "1644", "1650", "1656", "1662", "1668", "1674", "1680", "1686", "1692", "1698", "1704", "1710", "1716", "1722", "1728", "1734", "1740", "1746", "1752", "1758", "1764", "1770", "1776", "1782", "1788", "1794", "1800", "1806", "1812", "1818", "1824", "1830", "1836", "1842", "1848", "1854", "1860", "1866", "1872", "1878", "1884", "1890", "1896", "1902", "1908", "1914", "1920", "1926", "1932", "1938", "1944", "1950", "1956", "1962", "1968", "1974", "1980", "1986", "1992", "1998", + "2004", "2010", "2016", "2022", "2028", "2034", "2040", "2046", "2052", "2058", "2064", "2070", "2076", "2082", "2088", "2094", "2100", "2106", "2112", "2118", "2124", "2130", "2136", "2142", "2148", "2154", "2160", "2166", "2172", "2178", "2184", "2190", "2196", "2202", "2208", "2214", "2220", "2226", "2232", "2238", "2244", "2250", "2256", "2262", "2268", "2274", "2280", "2286", "2292", "2298", "2304", "2310", "2316", "2322", "2328", "2334", "2340", "2346", "2352", "2358", "2364", "2370", "2376", "2382", "2388", "2394", "2400", "2406", "2412", "2418", "2424", "2430", "2436", "2442", "2448", "2454", "2460", "2466", "2472", "2478", "2484", "2490", "2496", "2502", "2508", "2514", "2520", "2526", "2532", "2538", "2544", "2550", "2556", "2562", "2568", "2574", "2580", "2586", "2592", "2598", "2604", "2610", "2616", "2622", "2628", "2634", "2640", "2646", "2652", "2658", "2664", "2670", "2676", "2682", "2688", "2694", "2700", "2706", "2712", "2718", "2724", "2730", "2736", "2742", "2748", "2754", "2760", "2766", "2772", "2778", "2784", "2790", "2796", "2802", "2808", "2814", "2820", "2826", "2832", "2838", "2844", "2850", "2856", "2862", "2868", "2874", "2880", "2886", "2892", "2898", "2904", "2910", "2916", "2922", "2928", "2934", "2940", "2946", "2952", "2958", "2964", "2970", "2976", "2982", "2988", "2994", + "3000", "3006", "3012", "3018", "3024", "3030", "3036", "3042", "3048", "3054", "3060", "3066", "3072", "3078", "3084", "3090", "3096", "3102", "3108", "3114", "3120", "3126", "3132", "3138", "3144", "3150", "3156", "3162", "3168", "3174", "3180", "3186", "3192", "3198", "3204", "3210", "3216", "3222", "3228", "3234", "3240", "3246", "3252", "3258", "3264", "3270", "3276", "3282", "3288", "3294", "3300", "3306", "3312", "3318", "3324", "3330", "3336", "3342", "3348", "3354", "3360", "3366", "3372", "3378", "3384", "3390", "3396", "3402", "3408", "3414", "3420", "3426", "3432", "3438", "3444", "3450", "3456", "3462", "3468", "3474", "3480", "3486", "3492", "3498", "3504", "3510", "3516", "3522", "3528", "3534", "3540", "3546", "3552", "3558", "3564", "3570", "3576", "3582", "3588", "3594", "3600", "3606", "3612", "3618", "3624", "3630", "3636", "3642", "3648", "3654", "3660", "3666", "3672", "3678", "3684", "3690", "3696", "3702", "3708", "3714", "3720", "3726", "3732", "3738", "3744", "3750", "3756", "3762", "3768", "3774", "3780", "3786", "3792", "3798", "3804", "3810", "3816", "3822", "3828", "3834", "3840", "3846", "3852", "3858", "3864", "3870", "3876", "3882", "3888", "3894", "3900", "3906", "3912", "3918", "3924", "3930", "3936", "3942", "3948", "3954", "3960", "3966", "3972", "3978", "3984", "3990", "3996", + "4002", "4008", "4014", "4020", "4026", "4032", "4038", "4044", "4050", "4056", "4062", "4068", "4074", "4080", "4086", "4092", "4098", "4104", "4110", "4116", "4122", "4128", "4134", "4140", "4146", "4152", "4158", "4164", "4170", "4176", "4182", "4188", "4194", "4200", "4206", "4212", "4218", "4224", "4230", "4236", "4242", "4248", "4254", "4260", "4266", "4272", "4278", "4284", "4290", "4296", "4302", "4308", "4314", "4320", "4326", "4332", "4338", "4344", "4350", "4356", "4362", "4368", "4374", "4380", "4386", "4392", "4398", "4404", "4410", "4416", "4422", "4428", "4434", "4440", "4446", "4452", "4458", "4464", "4470", "4476", "4482", "4488", "4494", "4500", "4506", "4512", "4518", "4524", "4530", "4536", "4542", "4548", "4554", "4560", "4566", "4572", "4578", "4584", "4590", "4596", "4602", "4608", "4614", "4620", "4626", "4632", "4638", "4644", "4650", "4656", "4662", "4668", "4674", "4680", "4686", "4692", "4698", "4704", "4710", "4716", "4722", "4728", "4734", "4740", "4746", "4752", "4758", "4764", "4770", "4776", "4782", "4788", "4794", "4800", "4806", "4812", "4818", "4824", "4830", "4836", "4842", "4848", "4854", "4860", "4866", "4872", "4878", "4884", "4890", "4896", "4902", "4908", "4914", "4920", "4926", "4932", "4938", "4944", "4950", "4956", "4962", "4968", "4974", "4980", "4986", "4992", "4998", + "5004", "5010", "5016", "5022", "5028", "5034", "5040", "5046", "5052", "5058", "5064", "5070", "5076", "5082", "5088", "5094", "5100", "5106", "5112", "5118", "5124", "5130", "5136", "5142", "5148", "5154", "5160" + ], + "data_format": "netcdf", + "area": [51, 6, 50, 7] # Selhausen + # "area": [74, -42, 20, 69] # Europe +} + +# client = cdsapi.Client() +# client.retrieve(dataset, request).download() From 11b0546c564a3a652b4b918aa04859fd7563e5d3 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 28 Nov 2025 14:28:56 +0100 Subject: [PATCH 23/93] todos in custom request --- mkforcing/custom_request_SEAS5.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index 0985bb2..880422f 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -10,10 +10,10 @@ "originating_centre": "ecmwf", "system": "51", "variable": [ - "mean_sea_level_pressure", - "surface_thermal_radiation_downwards", + "mean_sea_level_pressure", # convert to surface pressure, use elevation (barometric formula, ICAO standard), sufrace geopotential height, (`geopotential` from pressure level data) + "surface_thermal_radiation_downwards", # maybe needs unit conversion J/m2 to W/m2 "surface_solar_radiation_downwards", - "total_precipitation" + "total_precipitation", "10m_u_component_of_wind", "10m_v_component_of_wind", "2m_temperature", From 7eba141ead0f9ca6dc62ca1f28686e807f90e9e3 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 28 Nov 2025 14:29:19 +0100 Subject: [PATCH 24/93] remove seas5 request (moved to own PR) --- mkforcing/custom_request_SEAS5.py | 39 ------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 mkforcing/custom_request_SEAS5.py diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py deleted file mode 100644 index 0985bb2..0000000 --- a/mkforcing/custom_request_SEAS5.py +++ /dev/null @@ -1,39 +0,0 @@ -## Download for SEAS5 forecast variables -# https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download - -# Forecast for 7 months - -# import cdsapi - -dataset = "seasonal-original-single-levels" -request = { - "originating_centre": "ecmwf", - "system": "51", - "variable": [ - "mean_sea_level_pressure", - "surface_thermal_radiation_downwards", - "surface_solar_radiation_downwards", - "total_precipitation" - "10m_u_component_of_wind", - "10m_v_component_of_wind", - "2m_temperature", - "2m_dewpoint_temperature", - ], - "year": ["2025"], - "month": ["09"], - "day": ["01"], - "leadtime_hour": [ - "6", "12", "18", "24", "30", "36", "42", "48", "54", "60", "66", "72", "78", "84", "90", "96", "102", "108", "114", "120", "126", "132", "138", "144", "150", "156", "162", "168", "174", "180", "186", "192", "198", "204", "210", "216", "222", "228", "234", "240", "246", "252", "258", "264", "270", "276", "282", "288", "294", "300", "306", "312", "318", "324", "330", "336", "342", "348", "354", "360", "366", "372", "378", "384", "390", "396", "402", "408", "414", "420", "426", "432", "438", "444", "450", "456", "462", "468", "474", "480", "486", "492", "498", "504", "510", "516", "522", "528", "534", "540", "546", "552", "558", "564", "570", "576", "582", "588", "594", "600", "606", "612", "618", "624", "630", "636", "642", "648", "654", "660", "666", "672", "678", "684", "690", "696", "702", "708", "714", "720", "726", "732", "738", "744", "750", "756", "762", "768", "774", "780", "786", "792", "798", "804", "810", "816", "822", "828", "834", "840", "846", "852", "858", "864", "870", "876", "882", "888", "894", "900", "906", "912", "918", "924", "930", "936", "942", "948", "954", "960", "966", "972", "978", "984", "990", "996", - "1002", "1008", "1014", "1020", "1026", "1032", "1038", "1044", "1050", "1056", "1062", "1068", "1074", "1080", "1086", "1092", "1098", "1104", "1110", "1116", "1122", "1128", "1134", "1140", "1146", "1152", "1158", "1164", "1170", "1176", "1182", "1188", "1194", "1200", "1206", "1212", "1218", "1224", "1230", "1236", "1242", "1248", "1254", "1260", "1266", "1272", "1278", "1284", "1290", "1296", "1302", "1308", "1314", "1320", "1326", "1332", "1338", "1344", "1350", "1356", "1362", "1368", "1374", "1380", "1386", "1392", "1398", "1404", "1410", "1416", "1422", "1428", "1434", "1440", "1446", "1452", "1458", "1464", "1470", "1476", "1482", "1488", "1494", "1500", "1506", "1512", "1518", "1524", "1530", "1536", "1542", "1548", "1554", "1560", "1566", "1572", "1578", "1584", "1590", "1596", "1602", "1608", "1614", "1620", "1626", "1632", "1638", "1644", "1650", "1656", "1662", "1668", "1674", "1680", "1686", "1692", "1698", "1704", "1710", "1716", "1722", "1728", "1734", "1740", "1746", "1752", "1758", "1764", "1770", "1776", "1782", "1788", "1794", "1800", "1806", "1812", "1818", "1824", "1830", "1836", "1842", "1848", "1854", "1860", "1866", "1872", "1878", "1884", "1890", "1896", "1902", "1908", "1914", "1920", "1926", "1932", "1938", "1944", "1950", "1956", "1962", "1968", "1974", "1980", "1986", "1992", "1998", - "2004", "2010", "2016", "2022", "2028", "2034", "2040", "2046", "2052", "2058", "2064", "2070", "2076", "2082", "2088", "2094", "2100", "2106", "2112", "2118", "2124", "2130", "2136", "2142", "2148", "2154", "2160", "2166", "2172", "2178", "2184", "2190", "2196", "2202", "2208", "2214", "2220", "2226", "2232", "2238", "2244", "2250", "2256", "2262", "2268", "2274", "2280", "2286", "2292", "2298", "2304", "2310", "2316", "2322", "2328", "2334", "2340", "2346", "2352", "2358", "2364", "2370", "2376", "2382", "2388", "2394", "2400", "2406", "2412", "2418", "2424", "2430", "2436", "2442", "2448", "2454", "2460", "2466", "2472", "2478", "2484", "2490", "2496", "2502", "2508", "2514", "2520", "2526", "2532", "2538", "2544", "2550", "2556", "2562", "2568", "2574", "2580", "2586", "2592", "2598", "2604", "2610", "2616", "2622", "2628", "2634", "2640", "2646", "2652", "2658", "2664", "2670", "2676", "2682", "2688", "2694", "2700", "2706", "2712", "2718", "2724", "2730", "2736", "2742", "2748", "2754", "2760", "2766", "2772", "2778", "2784", "2790", "2796", "2802", "2808", "2814", "2820", "2826", "2832", "2838", "2844", "2850", "2856", "2862", "2868", "2874", "2880", "2886", "2892", "2898", "2904", "2910", "2916", "2922", "2928", "2934", "2940", "2946", "2952", "2958", "2964", "2970", "2976", "2982", "2988", "2994", - "3000", "3006", "3012", "3018", "3024", "3030", "3036", "3042", "3048", "3054", "3060", "3066", "3072", "3078", "3084", "3090", "3096", "3102", "3108", "3114", "3120", "3126", "3132", "3138", "3144", "3150", "3156", "3162", "3168", "3174", "3180", "3186", "3192", "3198", "3204", "3210", "3216", "3222", "3228", "3234", "3240", "3246", "3252", "3258", "3264", "3270", "3276", "3282", "3288", "3294", "3300", "3306", "3312", "3318", "3324", "3330", "3336", "3342", "3348", "3354", "3360", "3366", "3372", "3378", "3384", "3390", "3396", "3402", "3408", "3414", "3420", "3426", "3432", "3438", "3444", "3450", "3456", "3462", "3468", "3474", "3480", "3486", "3492", "3498", "3504", "3510", "3516", "3522", "3528", "3534", "3540", "3546", "3552", "3558", "3564", "3570", "3576", "3582", "3588", "3594", "3600", "3606", "3612", "3618", "3624", "3630", "3636", "3642", "3648", "3654", "3660", "3666", "3672", "3678", "3684", "3690", "3696", "3702", "3708", "3714", "3720", "3726", "3732", "3738", "3744", "3750", "3756", "3762", "3768", "3774", "3780", "3786", "3792", "3798", "3804", "3810", "3816", "3822", "3828", "3834", "3840", "3846", "3852", "3858", "3864", "3870", "3876", "3882", "3888", "3894", "3900", "3906", "3912", "3918", "3924", "3930", "3936", "3942", "3948", "3954", "3960", "3966", "3972", "3978", "3984", "3990", "3996", - "4002", "4008", "4014", "4020", "4026", "4032", "4038", "4044", "4050", "4056", "4062", "4068", "4074", "4080", "4086", "4092", "4098", "4104", "4110", "4116", "4122", "4128", "4134", "4140", "4146", "4152", "4158", "4164", "4170", "4176", "4182", "4188", "4194", "4200", "4206", "4212", "4218", "4224", "4230", "4236", "4242", "4248", "4254", "4260", "4266", "4272", "4278", "4284", "4290", "4296", "4302", "4308", "4314", "4320", "4326", "4332", "4338", "4344", "4350", "4356", "4362", "4368", "4374", "4380", "4386", "4392", "4398", "4404", "4410", "4416", "4422", "4428", "4434", "4440", "4446", "4452", "4458", "4464", "4470", "4476", "4482", "4488", "4494", "4500", "4506", "4512", "4518", "4524", "4530", "4536", "4542", "4548", "4554", "4560", "4566", "4572", "4578", "4584", "4590", "4596", "4602", "4608", "4614", "4620", "4626", "4632", "4638", "4644", "4650", "4656", "4662", "4668", "4674", "4680", "4686", "4692", "4698", "4704", "4710", "4716", "4722", "4728", "4734", "4740", "4746", "4752", "4758", "4764", "4770", "4776", "4782", "4788", "4794", "4800", "4806", "4812", "4818", "4824", "4830", "4836", "4842", "4848", "4854", "4860", "4866", "4872", "4878", "4884", "4890", "4896", "4902", "4908", "4914", "4920", "4926", "4932", "4938", "4944", "4950", "4956", "4962", "4968", "4974", "4980", "4986", "4992", "4998", - "5004", "5010", "5016", "5022", "5028", "5034", "5040", "5046", "5052", "5058", "5064", "5070", "5076", "5082", "5088", "5094", "5100", "5106", "5112", "5118", "5124", "5130", "5136", "5142", "5148", "5154", "5160" - ], - "data_format": "netcdf", - "area": [51, 6, 50, 7] # Selhausen - # "area": [74, -42, 20, 69] # Europe -} - -# client = cdsapi.Client() -# client.retrieve(dataset, request).download() From c466e2702b4a0bbbde2f6637b6ed55c9a2fe3ea1 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 1 Dec 2025 13:18:56 +0100 Subject: [PATCH 25/93] whitespace removed --- docs/users_guide/era5-forcing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 877e374..172f27f 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -60,7 +60,7 @@ For users, who do not have access to the Meteocloud from the previous section. For ERA5, specific humidity can be computed from dewpoint temperature -and surface pressure using +and surface pressure using ``` python dewpoint_to_specific_humidity.py From fe8b6a0f950d2ca948ff9c74f9d4f9777ac13b47 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 1 Dec 2025 13:58:59 +0100 Subject: [PATCH 26/93] link in doc of python script --- mkforcing/dewpoint_to_specific_humidity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mkforcing/dewpoint_to_specific_humidity.py b/mkforcing/dewpoint_to_specific_humidity.py index 30f3a0c..047ff50 100644 --- a/mkforcing/dewpoint_to_specific_humidity.py +++ b/mkforcing/dewpoint_to_specific_humidity.py @@ -12,6 +12,7 @@ def dewpoint_to_specific_humidity(T_d, P): - Stull, R., 2017: "Practical Meteorology: An Algebra-based Survey of Atmospheric Science" -version 1.02b. Univ. of British Columbia. 940 pages. isbn 978-0-88865-283-6 . + - https://www.eoas.ubc.ca/books/Practical_Meteorology/ Parameters: ----------- From 0b2d330d2de5df682d8bdc41db91f342dccad0f5 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 1 Dec 2025 14:01:14 +0100 Subject: [PATCH 27/93] python script for 2m->10m conversion of t and q --- mkforcing/2m_to_10m_conversion.py | 223 ++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 mkforcing/2m_to_10m_conversion.py diff --git a/mkforcing/2m_to_10m_conversion.py b/mkforcing/2m_to_10m_conversion.py new file mode 100644 index 0000000..52316e0 --- /dev/null +++ b/mkforcing/2m_to_10m_conversion.py @@ -0,0 +1,223 @@ +import argparse +import numpy as np +import netCDF4 +# import xarray as xr +from typing import Union + + +def temperature_extrapolation_adiabatic( + T_2m: Union[float, np.ndarray], + z1: float = 2.0, + z2: float = 10.0, + lapse_rate: float = -0.0065, +) -> Union[float, np.ndarray]: + """ + Temperature extrapolation using atmospheric lapse rate. + + Formula: + -------- + T(z2) = T(z1) + Γ * (z2 - z1) + + where Γ is the atmospheric lapse rate (K/m) + + Parameters: + ----------- + T_2m : float or array + Temperature at 2m height (K or °C) + z1 : float + Initial height (m), default 2.0 + z2 : float + Target height (m), default 10.0 + lapse_rate : float + Temperature lapse rate (K/m), default + -0.0065 K/m (free atmosphere) + + Returns: + -------- + T_10m : float or array + Temperature at 10m height (same units as input) + + References: + ----------- + Lapse rate from standard atmosphere (ISO 2533:1975). + """ + dz = z2 - z1 + T_10m = T_2m + lapse_rate * dz + return T_10m + + +def humidity_extrapolation_constant_mixing_ratio( + q_2m: Union[float, np.ndarray], +) -> Union[float, np.ndarray]: + """ + Extrapolate specific humidity assuming constant mixing ratio. + + The mixing ratio is assumed constant with height. + In this simple case: q(2m) = q(10m). + + Formula: + -------- + 1. Calculate mixing ratio at 2m: + r = q / (1 - q) [kg/kg] + + 2. Assume mixing ration at 10m: + r(10m) = r(2m) + + 3. Convert back to specific humidity: + q(10m) = r / (1 + r) + + Parameters: + ----------- + q_2m : float or array + Specific humidity at 2m (kg/kg) + + Returns: + -------- + q_10m : float or array + Specific humidity at 10m (kg/kg) + + """ + # Convert to mixing ratio + mixing_ratio_2m = q_2m / (1.0 - q_2m) + + # Assume constant mixing ratio (well-mixed assumption) + mixing_ratio_10m = mixing_ratio_2m + + # Convert back to specific humidity + q_10m = mixing_ratio_10m / (1.0 + mixing_ratio_10m) + + return q_10m + + +def convert_2m_to_10m_in_netcdf(filename): + """ + Read 2m temperature and specific humidity from a netCDF file, + extrapolate to 10m height, and write back to the file. + + Parameters: + ----------- + filename : str + Path to the netCDF file containing 't2m' and 'q2m' variables + + Returns: + -------- + None + Modifies the netCDF file in place by adding 't10m' and 'q10m' variables + + Raises: + ------- + ValueError + If 't10m' or 'q10m' variables already exist in the file + KeyError + If required variables 't2m' or 'q2m' are not found + """ + + # Open netCDF file in append mode + print(f"Opening {filename}...") + nc = netCDF4.Dataset(filename, "a") + + try: + # Check if t10m or q10m already exist - if so, raise error and exit + if "t10m" in nc.variables or "q10m" in nc.variables: + nc.close() + raise ValueError( + f"Variable 't10m' and/or 'q10m' already exist in {filename}. " + "No changes made. Delete the variable(s) first if you want to recalculate." + ) + + # Read the required variables + print("Reading temperature (t2m) and specific humidity (q2m)...") + t2m = nc.variables["t2m"][:] # Temperature at 2m [K] + q2m = nc.variables["q2m"][:] # Specific humidity at 2m [kg/kg] + + print(f"Data shapes - t2m: {t2m.shape}, q2m: {q2m.shape}") + + # Extrapolate to 10m + print("Extrapolating temperature to 10m using adiabatic lapse rate...") + t10m = temperature_extrapolation_adiabatic(t2m) + + print( + "Extrapolating specific humidity to 10m assuming constant mixing ratio..." + ) + q10m = humidity_extrapolation_constant_mixing_ratio(q2m) + + # Create the new variables + print("Creating new variables 't10m' and 'q10m'...") + + # Get dimension names from t2m + dim_names = nc.variables["t2m"].dimensions + + # Create the t10m variable + t10m_var = nc.createVariable("t10m", "f4", dim_names, zlib=True, complevel=4) + + # Add attributes for t10m + t10m_var.units = "K" + t10m_var.long_name = "Temperature at 10m" + t10m_var.standard_name = "air_temperature" + t10m_var.description = ( + "Extrapolated from 2m temperature (t2m) using adiabatic lapse rate" + ) + + # Write the temperature data + t10m_var[:] = t10m + + # Create the q10m variable + q10m_var = nc.createVariable("q10m", "f4", dim_names, zlib=True, complevel=4) + + # Add attributes for q10m + q10m_var.units = "kg kg-1" + q10m_var.long_name = "Specific humidity at 10m" + q10m_var.standard_name = "specific_humidity" + q10m_var.description = "Extrapolated from 2m specific humidity (q2m) assuming constant mixing ratio" + + # Write the humidity data + q10m_var[:] = q10m + + print("Variables 't10m' and 'q10m' created and written successfully!") + + # Print some statistics + print("\nStatistics:") + print(f" Temperature 2m range: {np.min(t2m):.2f} to {np.max(t2m):.2f} K") + print(f" Temperature 10m range: {np.min(t10m):.2f} to {np.max(t10m):.2f} K") + print(f" Temperature difference (10m-2m) mean: {np.mean(t10m - t2m):.4f} K") + print(f" Specific humidity 2m mean: {np.mean(q2m):.6f} kg/kg") + print(f" Specific humidity 10m mean: {np.mean(q10m):.6f} kg/kg") + + except KeyError as e: + print(f"Error: Required variable not found in netCDF file: {e}") + print(f"Available variables: {list(nc.variables.keys())}") + nc.close() + raise + + except ValueError as e: + # This catches the "t10m/q10m already exists" error + print(f"Error: {e}") + raise + + except Exception as e: + print(f"Unexpected error occurred: {e}") + nc.close() + raise + + else: + # Only executes if no exception was raised + nc.close() + print(f"\nFile {filename} closed successfully.") + + +if __name__ == "__main__": + # Set up argument parser + parser = argparse.ArgumentParser( + description="Convert 2m temperature and humidity to 10m height in a netCDF file." + ) + parser.add_argument( + "filename", + type=str, + help="Path to the ERA5-downloaded netCDF file containing 't2m' and 'q2m' variables", + ) + + # Parse command-line arguments + args = parser.parse_args() + + # Process the file + convert_2m_to_10m_in_netcdf(args.filename) From eb7b60590e23b7f062f1febdc3f21c6674e72911 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 1 Dec 2025 14:11:09 +0100 Subject: [PATCH 28/93] use q10m, t10m in prepare-script --- mkforcing/prepare_ERA5_input.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 267e9ce..2d24fd7 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -125,7 +125,7 @@ do if $lmeteo; then ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t,TBOT -v q,QBOT ${year}-${month}.nc else - ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t2m,TBOT -v q2m,QBOT ${year}-${month}.nc + ncrename -v sp,PSRF -v avg_sdswrf,FSDS -v avg_sdlwrf,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${year}-${month}.nc fi # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc From 609eba0ccf3f7eee7500076ba44ae265a09f1443 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 1 Dec 2025 14:19:48 +0100 Subject: [PATCH 29/93] document usage of 2m->10m script --- docs/users_guide/era5-forcing.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 172f27f..1286375 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -54,7 +54,7 @@ Running the wrapper job `sbatch extract_ERA5_meteocloud_wrapper.job` after adapting `year` and `month` loops according to needed dates. -### Preparation of ERA5 data II: Specific humidity computation +### Preparation of ERA5 data II: Specific humidity computation and 2m->10m conversion For users, who do not have access to the Meteocloud from the previous section. @@ -66,6 +66,13 @@ and surface pressure using python dewpoint_to_specific_humidity.py ``` +Also temperature and specific humidity can be converted from 2m to 10m +using. + +``` +python 2m_to_10m_conversion.py +``` + ### Preparation of ERA5 data III: Remapping, Data merging, CLM3.5 `prepare_ERA5_input.sh` prepares ERA5 as an input by remapping the ERA5 data, changing names and modifying units. From d8b3f3f2290929459aa58ad69a26f420f8e086b9 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 4 Dec 2025 15:17:21 +0100 Subject: [PATCH 30/93] typo --- mkforcing/custom_request_SEAS5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index 880422f..ae98e2d 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -10,7 +10,7 @@ "originating_centre": "ecmwf", "system": "51", "variable": [ - "mean_sea_level_pressure", # convert to surface pressure, use elevation (barometric formula, ICAO standard), sufrace geopotential height, (`geopotential` from pressure level data) + "mean_sea_level_pressure", # convert to surface pressure, use elevation (barometric formula, ICAO standard), surface geopotential height, (`geopotential` from pressure level data) "surface_thermal_radiation_downwards", # maybe needs unit conversion J/m2 to W/m2 "surface_solar_radiation_downwards", "total_precipitation", From 123d845a5dcc462acb013017f9b8670a329469b6 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 4 Dec 2025 15:22:47 +0100 Subject: [PATCH 31/93] more precise task in SEAS5 request --- mkforcing/custom_request_SEAS5.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index ae98e2d..d461487 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -11,8 +11,8 @@ "system": "51", "variable": [ "mean_sea_level_pressure", # convert to surface pressure, use elevation (barometric formula, ICAO standard), surface geopotential height, (`geopotential` from pressure level data) - "surface_thermal_radiation_downwards", # maybe needs unit conversion J/m2 to W/m2 - "surface_solar_radiation_downwards", + "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] "total_precipitation", "10m_u_component_of_wind", "10m_v_component_of_wind", From 15c5ebe9c370150ead93ea53a58fc493aa68e72d Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 4 Dec 2025 16:40:10 +0100 Subject: [PATCH 32/93] script moving mslp to sp --- mkforcing/mslp_to_sp.py | 458 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 mkforcing/mslp_to_sp.py diff --git a/mkforcing/mslp_to_sp.py b/mkforcing/mslp_to_sp.py new file mode 100644 index 0000000..5ca3437 --- /dev/null +++ b/mkforcing/mslp_to_sp.py @@ -0,0 +1,458 @@ +""" +Convert between Mean Sea Level Pressure (MSLP) and Surface Pressure +Using the Method from Stull's Practical Meteorology Chapter 9 + +This implementation follows the standard meteorological sea-level pressure +reduction method that accounts for the elevation and uses a fictitious +temperature for the imaginary air column between the surface and sea level. + +References (all open-access): +1. Stull, R., 2017: Practical Meteorology: An Algebra-based Survey of + Atmospheric Science. University of British Columbia, 940 pp. + ISBN 978-0-88865-283-6 + Available under Creative Commons License (CC BY-NC-SA 4.0) + URL: https://www.eoas.ubc.ca/books/Practical_Meteorology/ + See Chapter 9, Section "Sea-level Pressure Reduction" + +Mathematical Background: +The sea-level pressure reduction uses the hypsometric equation: + + P_MSL = P_surface * exp(z_stn / (a * T_v*)) + +where: +- P_MSL: Mean sea level pressure (Pa or hPa) +- P_surface: Surface pressure at the station (Pa or hPa) +- z_stn: Station elevation above sea level (m) +- a: Scale height parameter = R_d / g ≈ 29.3 m/K +- T_v*: Fictitious average virtual temperature (K) + +The fictitious temperature T_v* represents the temperature of an imaginary +air column between the surface and sea level. It is calculated as: + + T_v* = 0.5 * [T_v(t_0) + T_v(t_0 - 12h) + γ_sa * z_stn] + +where: +- T_v(t_0): Current virtual temperature at the surface +- T_v(t_0 - 12h): Virtual temperature 12 hours ago +- γ_sa: Standard atmosphere lapse rate = 0.0065 K/m +""" + +import argparse +import numpy as np +import netCDF4 + + +def mslp_to_surface_pressure( + mslp: float, + elevation: float, + temperature_current: float, + temperature_12h_ago: float = None, + pressure_units: str = "Pa", +) -> float: + """ + Convert mean sea level pressure to surface pressure at a given elevation. + + Uses the standard meteorological method with a fictitious temperature + for the imaginary air column between the surface and sea level. + + Parameters + ---------- + mslp : float + Mean sea level pressure (in units specified by pressure_units) + elevation : float + Station elevation above mean sea level (meters) + - Positive for locations above sea level + - Negative for locations below sea level + temperature_current : float + Current surface air temperature (Kelvin) + temperature_12h_ago : float, optional + Surface air temperature 12 hours ago (Kelvin) + If None, uses temperature_current (i.e., assumes steady conditions) + pressure_units : str, optional + Units for pressure ('Pa' or 'hPa'), default='Pa' + + Returns + ------- + float + Surface pressure at the given elevation (same units as input) + + Notes + ----- + 1. This follows the method described in Stull (2017), Chapter 9. + 2. The fictitious temperature accounts for: + - Current surface temperature + - Temperature 12 hours ago (for diurnal averaging) + - Lapse rate correction for the imaginary column below the surface + 3. For locations at sea level (elevation=0), returns mslp unchanged. + 4. The 12-hour averaging helps smooth out diurnal temperature variations. + + Examples + -------- + >>> # Sea level location + >>> surface_p = mslp_to_surface_pressure(101325, 0, 288.15, None, 'Pa') + >>> print(f"Surface pressure: {surface_p:.1f} Pa") + Surface pressure: 101325.0 Pa + + >>> # Mountain station (2000m) with temperature data + >>> surface_p = mslp_to_surface_pressure(1013.25, 2000, 278, 276, 'hPa') + >>> print(f"Surface pressure: {surface_p:.1f} hPa") + Surface pressure: 795.3 hPa + """ + + # Physical constants + R_d = 287.05 # Gas constant for dry air (J/(kg·K)) + g = 9.80665 # Gravitational acceleration (m/s²) + gamma_sa = 0.0065 # Standard atmosphere lapse rate (K/m) + + # Calculate scale height parameter + a = R_d / g # ≈ 29.3 m/K + + # If no 12-hour-ago temperature provided, use current temperature + if temperature_12h_ago is None: + temperature_12h_ago = temperature_current + + # Calculate fictitious average virtual temperature for the column + # This is the temperature of the imaginary air column between + # the surface and sea level + # T_v* = 0.5 * [T_v(now) + T_v(12h ago) + γ_sa * z_stn] + T_v_star = 0.5 * (temperature_current + temperature_12h_ago + gamma_sa * elevation) + + # Apply hypsometric equation (inverse direction) + # P_surface = P_MSL * exp(-z / (a * T_v*)) + exponent = -elevation / (a * T_v_star) + surface_pressure = mslp * np.exp(exponent) + + return surface_pressure + + +def surface_to_mslp( + surface_pressure: float, + elevation: float, + temperature_current: float, + temperature_12h_ago: float = None, + pressure_units: str = "Pa", +) -> float: + """ + Convert surface pressure to mean sea level pressure. + + This is the standard meteorological "sea-level pressure reduction" + operation used to create weather maps. + + Parameters + ---------- + surface_pressure : float + Pressure at the surface (in units specified by pressure_units) + elevation : float + Station elevation above mean sea level (meters) + temperature_current : float + Current surface air temperature (Kelvin) + temperature_12h_ago : float, optional + Surface air temperature 12 hours ago (Kelvin) + If None, uses temperature_current + pressure_units : str, optional + Units for pressure ('Pa' or 'hPa'), default='Pa' + + Returns + ------- + float + Mean sea level pressure (same units as input) + + Notes + ----- + This function uses the fictitious temperature method from Stull (2017): + + T_v* = 0.5 * [T_v(now) + T_v(12h ago) + γ_sa * z_stn] + + Then applies: + P_MSL = P_surface * exp(z / (a * T_v*)) + + Examples + -------- + >>> # Convert Denver surface pressure to MSLP + >>> mslp = surface_to_mslp(83500, 1600, 288.15, 285.15, 'Pa') + >>> print(f"MSLP: {mslp:.1f} Pa ({mslp/100:.1f} hPa)") + MSLP: 101362.4 Pa (1013.6 hPa) + + >>> # Mountain weather station + >>> mslp = surface_to_mslp(795.0, 2000, 278, 276, 'hPa') + >>> print(f"MSLP: {mslp:.2f} hPa") + MSLP: 1012.85 hPa + """ + + # Physical constants + R_d = 287.05 # Gas constant for dry air (J/(kg·K)) + g = 9.80665 # Gravitational acceleration (m/s²) + gamma_sa = 0.0065 # Standard atmosphere lapse rate (K/m) + + # Calculate scale height parameter + a = R_d / g # ≈ 29.3 m/K + + # If no 12-hour-ago temperature provided, use current temperature + if temperature_12h_ago is None: + temperature_12h_ago = temperature_current + + # Calculate fictitious average temperature for the imaginary column + T_v_star = 0.5 * (temperature_current + temperature_12h_ago + gamma_sa * elevation) + + # Apply hypsometric equation + # P_MSL = P_surface * exp(z / (a * T_v*)) + exponent = elevation / (a * T_v_star) + mslp = surface_pressure * np.exp(exponent) + + return mslp + + +def calculate_fictitious_temperature( + temperature_current: float, temperature_12h_ago: float, elevation: float +) -> float: + """ + Calculate the fictitious temperature for the imaginary air column. + + This is a helper function that computes T_v* according to the method + in Stull (2017), Chapter 9. + + Parameters + ---------- + temperature_current : float + Current surface temperature (Kelvin) + temperature_12h_ago : float + Temperature 12 hours ago (Kelvin) + elevation : float + Station elevation (meters) + + Returns + ------- + float + Fictitious temperature T_v* (Kelvin) + + Notes + ----- + The formula is: + T_v* = 0.5 * [T(now) + T(12h ago) + γ_sa * z] + + This represents: + 1. Average of current and 12-hour-ago temperatures (diurnal smoothing) + 2. Plus a lapse-rate correction for the imaginary column height + + Examples + -------- + >>> T_star = calculate_fictitious_temperature(288.15, 285.15, 1600) + >>> print(f"Fictitious temperature: {T_star:.2f} K ({T_star-273.15:.2f}°C)") + Fictitious temperature: 291.55 K (18.40°C) + """ + gamma_sa = 0.0065 # Standard atmosphere lapse rate (K/m) + + T_v_star = 0.5 * (temperature_current + temperature_12h_ago + gamma_sa * elevation) + + return T_v_star + + +def add_surface_pressure_to_netcdf(filename, elevation_var="z", temp_var="t2m", mslp_var="msl"): + """ + Read MSLP, temperature, and elevation from a netCDF file, + calculate surface pressure, and write it back to the file. + + Parameters: + ----------- + filename : str + Path to the netCDF file + elevation_var : str, optional + Name of the elevation variable in the file (default: 'z') + The geopotential variable will be converted to elevation (m) by dividing by g + temp_var : str, optional + Name of the temperature variable (default: 't2m') + mslp_var : str, optional + Name of the MSLP variable (default: 'msl') + + Returns: + -------- + None + Modifies the netCDF file in place by adding 'sp' variable + + Raises: + ------- + ValueError + If 'sp' variable already exists in the file + KeyError + If required variables are not found + """ + + # Open netCDF file in append mode + print(f"Opening {filename}...") + nc = netCDF4.Dataset(filename, "a") + + try: + # Check if sp already exists - if so, raise error and exit + if "sp" in nc.variables: + nc.close() + raise ValueError( + f"Variable 'sp' already exists in {filename}. " + "No changes made. Delete the variable first if you want to recalculate." + ) + + # Read the required variables + print(f"Reading {mslp_var}, {temp_var}, and {elevation_var}...") + msl = nc.variables[mslp_var][:] # Mean sea level pressure [Pa] + t2m = nc.variables[temp_var][:] # Temperature at 2m [K] + z_geopotential = nc.variables[elevation_var][:] # Geopotential [m^2/s^2] + + print(f"Data shapes - {mslp_var}: {msl.shape}, {temp_var}: {t2m.shape}, {elevation_var}: {z_geopotential.shape}") + + # Convert geopotential to elevation (meters) + g = 9.80665 # Standard gravity [m/s^2] + elevation = z_geopotential / g + + print(f"Elevation range: {np.min(elevation):.1f} to {np.max(elevation):.1f} m") + + # Determine time dimension for 12h offset + # Assume first dimension is time + time_dim = nc.variables[temp_var].dimensions[0] + time_size = nc.dimensions[time_dim].size + print(f"Time dimension: {time_dim} with size {time_size}") + + # Calculate surface pressure + print("Calculating surface pressure...") + + # Create array for surface pressure + sp = np.zeros_like(msl) + + # For the first timestep (index 0), we don't have 12h ago data + # Use current temperature for both + if time_size > 0: + print("Processing first timestep (no 12h-ago data available)...") + sp[0, ...] = mslp_to_surface_pressure( + msl[0, ...], + elevation, + t2m[0, ...], + t2m[0, ...], # Use current temp as 12h-ago temp + "Pa" + ) + + # For remaining timesteps, check if we can use 12h offset + # Assuming hourly data, 12h offset = 12 timesteps back + # For other time resolutions, this logic may need adjustment + if time_size > 12: + print("Processing remaining timesteps with 12h-ago data...") + for t in range(12, time_size): + sp[t, ...] = mslp_to_surface_pressure( + msl[t, ...], + elevation, + t2m[t, ...], + t2m[t - 12, ...], # Temperature 12 timesteps ago + "Pa" + ) + + # For timesteps 1-11, use current temp (no 12h data yet) + if time_size > 1: + print("Processing timesteps 1-11 without 12h-ago data...") + for t in range(1, min(12, time_size)): + sp[t, ...] = mslp_to_surface_pressure( + msl[t, ...], + elevation, + t2m[t, ...], + t2m[t, ...], + "Pa" + ) + elif time_size > 1: + # If we have fewer than 12 timesteps, process without 12h offset + print("Processing all timesteps without 12h-ago data (less than 12 timesteps)...") + for t in range(1, time_size): + sp[t, ...] = mslp_to_surface_pressure( + msl[t, ...], + elevation, + t2m[t, ...], + t2m[t, ...], + "Pa" + ) + + # Create the new variable + print("Creating new variable 'sp'...") + + # Get dimension names from msl + dim_names = nc.variables[mslp_var].dimensions + + # Create the sp variable with same dimensions as msl + sp_var = nc.createVariable("sp", "f4", dim_names, zlib=True, complevel=4) + + # Add attributes + sp_var.units = "Pa" + sp_var.long_name = "Surface pressure" + sp_var.standard_name = "surface_air_pressure" + sp_var.description = ( + f"Calculated from {mslp_var}, {temp_var}, and {elevation_var} " + "using Stull (2017) Chapter 9 method" + ) + + # Write the data + sp_var[:] = sp + + print("Variable 'sp' created and written successfully!") + + # Print some statistics + print("\nStatistics:") + print(f" Surface pressure range: {np.min(sp):.1f} to {np.max(sp):.1f} Pa") + print(f" Surface pressure mean: {np.mean(sp):.1f} Pa ({np.mean(sp)/100:.2f} hPa)") + print(f" MSLP mean: {np.mean(msl):.1f} Pa ({np.mean(msl)/100:.2f} hPa)") + print(f" Mean difference: {np.mean(msl - sp):.1f} Pa ({np.mean(msl - sp)/100:.2f} hPa)") + + except KeyError as e: + print(f"Error: Required variable not found in netCDF file: {e}") + print(f"Available variables: {list(nc.variables.keys())}") + nc.close() + raise + + except ValueError as e: + # This catches the "sp already exists" error + print(f"Error: {e}") + raise + + except Exception as e: + print(f"Unexpected error occurred: {e}") + nc.close() + raise + + else: + # Only executes if no exception was raised + nc.close() + print(f"\nFile {filename} closed successfully.") + + +if __name__ == "__main__": + # Set up argument parser + parser = argparse.ArgumentParser( + description="Add surface pressure (sp) to a netCDF file based on MSLP, temperature, and elevation." + ) + parser.add_argument( + "filename", + type=str, + help="Path to the netCDF file containing MSLP, temperature, and elevation variables" + ) + parser.add_argument( + "--elevation-var", + type=str, + default="z", + help="Name of the elevation/geopotential variable (default: z)" + ) + parser.add_argument( + "--temp-var", + type=str, + default="t2m", + help="Name of the temperature variable (default: t2m)" + ) + parser.add_argument( + "--mslp-var", + type=str, + default="msl", + help="Name of the MSLP variable (default: msl)" + ) + + # Parse command-line arguments + args = parser.parse_args() + + # Process the file + add_surface_pressure_to_netcdf( + args.filename, + elevation_var=args.elevation_var, + temp_var=args.temp_var, + mslp_var=args.mslp_var + ) From 58d26c8b214d4fe6456989e93802d6040b6a2925 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 4 Dec 2025 16:40:27 +0100 Subject: [PATCH 33/93] update docstrings --- mkforcing/custom_request_SEAS5.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index d461487..f1246fc 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -10,7 +10,8 @@ "originating_centre": "ecmwf", "system": "51", "variable": [ - "mean_sea_level_pressure", # convert to surface pressure, use elevation (barometric formula, ICAO standard), surface geopotential height, (`geopotential` from pressure level data) + "mean_sea_level_pressure", # convert to surface pressure, use elevation (hypsometric formula), surface geopotential height (orography) + "orography", # used to convert mslp to sp "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] "total_precipitation", From 717adf97e93cefad99218fa8aae2ed7fed8cb267 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 4 Dec 2025 16:46:01 +0100 Subject: [PATCH 34/93] first drafts for conversion Python scripts --- mkforcing/accumulated_radiation_to_flux.py | 339 +++++++++++++++++++++ mkforcing/orography_to_elevation.py | 249 +++++++++++++++ 2 files changed, 588 insertions(+) create mode 100644 mkforcing/accumulated_radiation_to_flux.py create mode 100644 mkforcing/orography_to_elevation.py diff --git a/mkforcing/accumulated_radiation_to_flux.py b/mkforcing/accumulated_radiation_to_flux.py new file mode 100644 index 0000000..97e9f9e --- /dev/null +++ b/mkforcing/accumulated_radiation_to_flux.py @@ -0,0 +1,339 @@ +""" +Convert accumulated radiation to radiative flux. + +This script converts accumulated radiation values (J/m²) to instantaneous +radiative flux values (W/m²) by calculating the rate of change over the +time intervals between measurements. + +Mathematical Background: +Radiative flux is the rate of energy transfer per unit area: + + F = ΔE / (A × Δt) = ΔE / Δt [W/m²] + +where: +- F: Radiative flux (W/m²) or (J/(s·m²)) +- ΔE: Change in accumulated energy (J/m²) +- Δt: Time interval (s) +- A: Area (m²), which cancels out since we work with per-unit-area values + +For accumulated radiation data, the flux is computed as: + + F(t) = (E_acc(t) - E_acc(t-1)) / Δt + +where E_acc is the accumulated radiation at each timestep. + +Notes on accumulated radiation in reanalysis products: +- ERA5 and similar reanalysis datasets provide radiation as accumulated values +- Accumulations are typically reset at the start of each forecast +- The time interval Δt should be computed from the actual time coordinates +- First timestep flux cannot be computed (requires previous accumulation) + +References: +1. ECMWF IFS Documentation - Part IV: Physical Processes +2. ERA5 documentation: https://confluence.ecmwf.int/display/CKB/ERA5+documentation +""" + +import argparse +import numpy as np +import netCDF4 +from datetime import datetime, timedelta + + +def accumulated_to_flux(accumulated, time_seconds): + """ + Convert accumulated radiation to radiative flux. + + Parameters + ---------- + accumulated : array + Accumulated radiation values [J/m²] + Shape: (time, ...) + time_seconds : array + Time coordinates in seconds since reference + Shape: (time,) + + Returns + ------- + flux : array + Radiative flux [W/m²] + Shape: (time, ...) + First timestep will be NaN (cannot compute without previous value) + + Notes + ----- + The flux at timestep t is computed as: + flux[t] = (accumulated[t] - accumulated[t-1]) / (time[t] - time[t-1]) + + The first timestep will contain NaN values since there is no previous + accumulation to compute the difference from. + + Examples + -------- + >>> # Example with hourly data (3600 seconds interval) + >>> accumulated = np.array([3600000, 7200000, 10800000]) # J/m² + >>> time = np.array([0, 3600, 7200]) # seconds + >>> flux = accumulated_to_flux(accumulated, time) + >>> print(flux) + [nan 1000. 1000.] # W/m² + """ + # Initialize flux array with same shape as accumulated + flux = np.zeros_like(accumulated, dtype=np.float32) + flux[0, ...] = np.nan # First timestep cannot be computed + + # Get the number of timesteps + n_time = accumulated.shape[0] + + # Compute flux for each timestep + for t in range(1, n_time): + # Calculate time difference in seconds + dt = time_seconds[t] - time_seconds[t - 1] + + if dt <= 0: + raise ValueError(f"Non-positive time difference at timestep {t}: dt={dt}") + + # Calculate energy difference + de = accumulated[t, ...] - accumulated[t - 1, ...] + + # Handle potential negative values (can occur at accumulation resets) + # If accumulated value decreases, it indicates a reset in accumulation + # In this case, use the current accumulated value as the energy difference + de = np.where(de < 0, accumulated[t, ...], de) + + # Compute flux: W/m² = J/m² / s + flux[t, ...] = de / dt + + return flux + + +def add_radiation_flux_to_netcdf( + filename, + thermal_var="strd", + solar_var="ssrd", + time_var="time", + thermal_flux_name="flds", + solar_flux_name="fsds" +): + """ + Read accumulated radiation from a netCDF file, + calculate radiative fluxes, and write them back to the file. + + Parameters: + ----------- + filename : str + Path to the netCDF file + thermal_var : str, optional + Name of the thermal radiation variable (default: 'strd') + Surface thermal radiation downwards (accumulated) + solar_var : str, optional + Name of the solar radiation variable (default: 'ssrd') + Surface solar radiation downwards (accumulated) + time_var : str, optional + Name of the time variable (default: 'time') + thermal_flux_name : str, optional + Name for the output thermal flux variable (default: 'flds') + Downward longwave radiation at surface + solar_flux_name : str, optional + Name for the output solar flux variable (default: 'fsds') + Downward shortwave radiation at surface + + Returns: + -------- + None + Modifies the netCDF file in place by adding flux variables + + Raises: + ------- + ValueError + If flux variables already exist in the file + KeyError + If required variables are not found + """ + + # Open netCDF file in append mode + print(f"Opening {filename}...") + nc = netCDF4.Dataset(filename, "a") + + try: + # Check if flux variables already exist + if thermal_flux_name in nc.variables: + nc.close() + raise ValueError( + f"Variable '{thermal_flux_name}' already exists in {filename}. " + "No changes made. Delete the variable first if you want to recalculate." + ) + if solar_flux_name in nc.variables: + nc.close() + raise ValueError( + f"Variable '{solar_flux_name}' already exists in {filename}. " + "No changes made. Delete the variable first if you want to recalculate." + ) + + # Read the required variables + print(f"Reading {thermal_var}, {solar_var}, and {time_var}...") + strd = nc.variables[thermal_var][:] # Accumulated thermal radiation [J/m²] + ssrd = nc.variables[solar_var][:] # Accumulated solar radiation [J/m²] + time_var_obj = nc.variables[time_var] + time_values = time_var_obj[:] + + print(f"Data shapes - {thermal_var}: {strd.shape}, {solar_var}: {ssrd.shape}") + print(f"Time dimension size: {len(time_values)}") + + # Convert time to seconds since first timestep + # Handle different time units + time_units = time_var_obj.units + time_calendar = getattr(time_var_obj, 'calendar', 'standard') + + print(f"Time units: {time_units}") + print(f"Time calendar: {time_calendar}") + + # Use netCDF4's num2date to convert time values to datetime objects + time_dates = netCDF4.num2date(time_values, units=time_units, calendar=time_calendar) + + # Convert to seconds since first timestep + time_seconds = np.array([ + (date - time_dates[0]).total_seconds() for date in time_dates + ]) + + print(f"Time range: {time_dates[0]} to {time_dates[-1]}") + print(f"Time step (first interval): {time_seconds[1] - time_seconds[0]:.0f} seconds") + + # Calculate fluxes + print("Calculating thermal radiation flux (longwave downward)...") + flds = accumulated_to_flux(strd, time_seconds) + + print("Calculating solar radiation flux (shortwave downward)...") + fsds = accumulated_to_flux(ssrd, time_seconds) + + # Create the thermal flux variable + print(f"Creating new variable '{thermal_flux_name}'...") + dim_names = nc.variables[thermal_var].dimensions + flds_var = nc.createVariable(thermal_flux_name, "f4", dim_names, zlib=True, complevel=4) + + # Add attributes for thermal flux + flds_var.units = "W m-2" + flds_var.long_name = "Downward longwave radiation at surface" + flds_var.standard_name = "surface_downwelling_longwave_flux_in_air" + flds_var.description = ( + f"Calculated from accumulated {thermal_var} by differencing consecutive " + "timesteps and dividing by the time interval" + ) + flds_var.missing_value = np.nan + flds_var[:] = flds + + print(f"Variable '{thermal_flux_name}' created and written successfully!") + + # Create the solar flux variable + print(f"Creating new variable '{solar_flux_name}'...") + dim_names = nc.variables[solar_var].dimensions + fsds_var = nc.createVariable(solar_flux_name, "f4", dim_names, zlib=True, complevel=4) + + # Add attributes for solar flux + fsds_var.units = "W m-2" + fsds_var.long_name = "Downward shortwave radiation at surface" + fsds_var.standard_name = "surface_downwelling_shortwave_flux_in_air" + fsds_var.description = ( + f"Calculated from accumulated {solar_var} by differencing consecutive " + "timesteps and dividing by the time interval" + ) + fsds_var.missing_value = np.nan + fsds_var[:] = fsds + + print(f"Variable '{solar_flux_name}' created and written successfully!") + + # Print some statistics (excluding first timestep with NaN) + print("\nStatistics:") + print(f"\nThermal radiation flux ({thermal_flux_name}):") + print(f" Range: {np.nanmin(flds):.2f} to {np.nanmax(flds):.2f} W/m²") + print(f" Mean: {np.nanmean(flds):.2f} W/m²") + print(f" Std: {np.nanstd(flds):.2f} W/m²") + + print(f"\nSolar radiation flux ({solar_flux_name}):") + print(f" Range: {np.nanmin(fsds):.2f} to {np.nanmax(fsds):.2f} W/m²") + print(f" Mean: {np.nanmean(fsds):.2f} W/m²") + print(f" Std: {np.nanstd(fsds):.2f} W/m²") + + # Check for negative values (excluding NaN) + n_negative_thermal = np.sum(flds[~np.isnan(flds)] < 0) + n_negative_solar = np.sum(fsds[~np.isnan(fsds)] < 0) + + if n_negative_thermal > 0: + print(f"\nWarning: {n_negative_thermal} negative values in thermal flux") + if n_negative_solar > 0: + print(f"\nWarning: {n_negative_solar} negative values in solar flux") + + print(f"\nNote: First timestep contains NaN values (no previous accumulation)") + + except KeyError as e: + print(f"Error: Required variable not found in netCDF file: {e}") + print(f"Available variables: {list(nc.variables.keys())}") + nc.close() + raise + + except ValueError as e: + print(f"Error: {e}") + raise + + except Exception as e: + print(f"Unexpected error occurred: {e}") + nc.close() + raise + + else: + # Only executes if no exception was raised + nc.close() + print(f"\nFile {filename} closed successfully.") + + +if __name__ == "__main__": + # Set up argument parser + parser = argparse.ArgumentParser( + description="Add radiative flux variables to a netCDF file based on accumulated radiation." + ) + parser.add_argument( + "filename", + type=str, + help="Path to the netCDF file containing accumulated radiation variables" + ) + parser.add_argument( + "--thermal-var", + type=str, + default="strd", + help="Name of the accumulated thermal radiation variable (default: strd)" + ) + parser.add_argument( + "--solar-var", + type=str, + default="ssrd", + help="Name of the accumulated solar radiation variable (default: ssrd)" + ) + parser.add_argument( + "--time-var", + type=str, + default="time", + help="Name of the time variable (default: time)" + ) + parser.add_argument( + "--thermal-flux-name", + type=str, + default="flds", + help="Name for the output thermal flux variable (default: flds)" + ) + parser.add_argument( + "--solar-flux-name", + type=str, + default="fsds", + help="Name for the output solar flux variable (default: fsds)" + ) + + # Parse command-line arguments + args = parser.parse_args() + + # Process the file + add_radiation_flux_to_netcdf( + args.filename, + thermal_var=args.thermal_var, + solar_var=args.solar_var, + time_var=args.time_var, + thermal_flux_name=args.thermal_flux_name, + solar_flux_name=args.solar_flux_name + ) diff --git a/mkforcing/orography_to_elevation.py b/mkforcing/orography_to_elevation.py new file mode 100644 index 0000000..210435c --- /dev/null +++ b/mkforcing/orography_to_elevation.py @@ -0,0 +1,249 @@ +""" +Convert geopotential (orography) to elevation above mean sea level. + +Geopotential represents the gravitational potential energy per unit mass +at a given height. It is related to elevation through the gravitational +acceleration constant. + +Mathematical Background: +The relationship between geopotential and geometric height is: + + z = Φ / g + +where: +- z: Geometric height (elevation) above mean sea level (m) +- Φ: Geopotential (m²/s²) +- g: Standard gravitational acceleration = 9.80665 m/s² + +This conversion assumes a constant gravitational acceleration, which is +appropriate for typical atmospheric applications at Earth's surface. + +References: +1. WMO Guide to Meteorological Instruments and Methods of Observation + (WMO-No. 8), 2018 edition +2. ECMWF IFS Documentation - Part III: Dynamics and Numerical Procedures +""" + +import argparse +import numpy as np +import netCDF4 + + +def geopotential_to_elevation(geopotential, g=9.80665): + """ + Convert geopotential to elevation above mean sea level. + + Parameters + ---------- + geopotential : float or array + Geopotential [m²/s²] + g : float, optional + Standard gravitational acceleration [m/s²] (default: 9.80665) + + Returns + ------- + elevation : float or array + Elevation above mean sea level [m] + + Notes + ----- + The standard gravitational acceleration g = 9.80665 m/s² is defined + by ISO 80000-3:2006 and is used in meteorological applications. + + Examples + -------- + >>> # Sea level + >>> elevation = geopotential_to_elevation(0.0) + >>> print(f"Elevation: {elevation:.1f} m") + Elevation: 0.0 m + + >>> # Typical mountain height + >>> geopotential = 19613.3 # m²/s² + >>> elevation = geopotential_to_elevation(geopotential) + >>> print(f"Elevation: {elevation:.1f} m") + Elevation: 2000.0 m + """ + elevation = geopotential / g + return elevation + + +def elevation_to_geopotential(elevation, g=9.80665): + """ + Convert elevation to geopotential. + + Parameters + ---------- + elevation : float or array + Elevation above mean sea level [m] + g : float, optional + Standard gravitational acceleration [m/s²] (default: 9.80665) + + Returns + ------- + geopotential : float or array + Geopotential [m²/s²] + + Notes + ----- + This is the inverse operation of geopotential_to_elevation. + + Examples + -------- + >>> # Mount Everest + >>> geopotential = elevation_to_geopotential(8849) + >>> print(f"Geopotential: {geopotential:.1f} m²/s²") + Geopotential: 86763.3 m²/s² + + >>> # Below sea level (Dead Sea) + >>> geopotential = elevation_to_geopotential(-430) + >>> print(f"Geopotential: {geopotential:.1f} m²/s²") + Geopotential: -4216.6 m²/s² + """ + geopotential = elevation * g + return geopotential + + +def add_elevation_to_netcdf(filename, geopotential_var="z", elevation_var_name="elevation"): + """ + Read geopotential (orography) from a netCDF file, + calculate elevation, and write it back to the file. + + Parameters: + ----------- + filename : str + Path to the netCDF file + geopotential_var : str, optional + Name of the geopotential variable in the file (default: 'z') + elevation_var_name : str, optional + Name for the output elevation variable (default: 'elevation') + + Returns: + -------- + None + Modifies the netCDF file in place by adding elevation variable + + Raises: + ------- + ValueError + If elevation variable already exists in the file + KeyError + If required geopotential variable is not found + """ + + # Open netCDF file in append mode + print(f"Opening {filename}...") + nc = netCDF4.Dataset(filename, "a") + + try: + # Check if elevation variable already exists - if so, raise error and exit + if elevation_var_name in nc.variables: + nc.close() + raise ValueError( + f"Variable '{elevation_var_name}' already exists in {filename}. " + "No changes made. Delete the variable first if you want to recalculate." + ) + + # Read the geopotential variable + print(f"Reading geopotential variable '{geopotential_var}'...") + geopotential = nc.variables[geopotential_var][:] # Geopotential [m²/s²] + + print(f"Data shape - {geopotential_var}: {geopotential.shape}") + print(f"Geopotential range: {np.min(geopotential):.1f} to {np.max(geopotential):.1f} m²/s²") + + # Calculate elevation + print("Calculating elevation...") + elevation = geopotential_to_elevation(geopotential) + + print(f"Elevation range: {np.min(elevation):.1f} to {np.max(elevation):.1f} m") + + # Create the new variable + print(f"Creating new variable '{elevation_var_name}'...") + + # Get dimension names from geopotential variable + dim_names = nc.variables[geopotential_var].dimensions + + # Create the elevation variable with same dimensions as geopotential + elevation_var = nc.createVariable( + elevation_var_name, "f4", dim_names, zlib=True, complevel=4 + ) + + # Add attributes + elevation_var.units = "m" + elevation_var.long_name = "Elevation above mean sea level" + elevation_var.standard_name = "surface_altitude" + elevation_var.description = ( + f"Calculated from geopotential variable '{geopotential_var}' " + "using z = Φ / g with g = 9.80665 m/s²" + ) + + # Write the data + elevation_var[:] = elevation + + print(f"Variable '{elevation_var_name}' created and written successfully!") + + # Print some statistics + print("\nStatistics:") + print(f" Elevation range: {np.min(elevation):.1f} to {np.max(elevation):.1f} m") + print(f" Elevation mean: {np.mean(elevation):.1f} m") + print(f" Elevation std: {np.std(elevation):.1f} m") + + # Identify any interesting features + if np.min(elevation) < 0: + print(f" Below sea level: Yes (min: {np.min(elevation):.1f} m)") + if np.max(elevation) > 1000: + print(f" High elevation areas: Yes (max: {np.max(elevation):.1f} m)") + + except KeyError as e: + print(f"Error: Required variable not found in netCDF file: {e}") + print(f"Available variables: {list(nc.variables.keys())}") + nc.close() + raise + + except ValueError as e: + # This catches the "elevation already exists" error + print(f"Error: {e}") + raise + + except Exception as e: + print(f"Unexpected error occurred: {e}") + nc.close() + raise + + else: + # Only executes if no exception was raised + nc.close() + print(f"\nFile {filename} closed successfully.") + + +if __name__ == "__main__": + # Set up argument parser + parser = argparse.ArgumentParser( + description="Add elevation variable to a netCDF file based on geopotential (orography)." + ) + parser.add_argument( + "filename", + type=str, + help="Path to the netCDF file containing geopotential/orography variable" + ) + parser.add_argument( + "--geopotential-var", + type=str, + default="z", + help="Name of the geopotential variable (default: z)" + ) + parser.add_argument( + "--elevation-var", + type=str, + default="elevation", + help="Name for the output elevation variable (default: elevation)" + ) + + # Parse command-line arguments + args = parser.parse_args() + + # Process the file + add_elevation_to_netcdf( + args.filename, + geopotential_var=args.geopotential_var, + elevation_var_name=args.elevation_var + ) From 7c30fa7eac1c000a9503b18056a871c5063aa5fd Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 11:35:02 +0100 Subject: [PATCH 35/93] Copernicus-Download-Script: More robust default file-target `download_ERA5_input.py`: When the file target is not supplied as input variable `target`, the downloaded file has up to now always been called `*.zip`. This default behavior is replaced with the following, more robust alternative: 1. Download the data to a temporary target without extension/suffix. 2. Detect the file type (supports zip, netCDF and GRIB). 3. Add the extension of the detected file type. --- mkforcing/download_ERA5_input.py | 69 ++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 5961d99..92809e2 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -23,6 +23,8 @@ import cdsapi import sys import os +import tempfile + def generate_days(year, month): """Get the number of days in a given month and year. @@ -42,10 +44,48 @@ def generate_days(year, month): return days + +def detect_file_type(filepath): + """Detect if downloaded file is NetCDF, GRIB or ZIP format. + + Args: + filepath (str): Path to the downloaded file + + Returns: + str: File extension ('.nc' for NetCDF, '.zip' for ZIP, '.grib' for GRIB) + + Raises: + ValueError: If file format is not recognized as NetCDF, GRIB, or ZIP + """ + # Read file magic bytes + with open(filepath, 'rb') as f: + magic = f.read(8) + + # ZIP files start with 'PK' (0x504B) + if magic[:2] == b'PK': + return '.zip' + + # NetCDF files start with 'CDF' (0x43444601 or 0x43444602) or HDF5 signature + if magic[:3] == b'CDF' or magic[:4] == b'\x89HDF': + return '.nc' + + # GRIB files start with 'GRIB' + if magic[:4] == b'GRIB': + return '.grib' + + # If we reach here, the file format is not recognized + magic_hex = magic.hex() + raise ValueError( + f"Unrecognized file format for '{filepath}'. " + f"Magic bytes: {magic_hex}. " + f"Expected NetCDF (CDF/HDF5), GRIB, or ZIP (PK) format." + ) + + def generate_datarequest(year, monthstr, days, - dataset="reanalysis-era5-single-levels", - request=None, - target=None): + dataset="reanalysis-era5-single-levels", + request=None, + target=None): """Generate and execute ERA5 data download request. Args: @@ -54,7 +94,7 @@ def generate_datarequest(year, monthstr, days, days (list): List of days in the month dataset (str, optional): CDS dataset name. Defaults to 'reanalysis-era5-single-levels'. request (dict, optional): Custom CDS request dictionary. If None, uses default request. - target (str, optional): Output filename. If None, uses 'download_era5_YYYY_MM.zip'. + target (str, optional): Output filename. If None, auto-detects extension based on downloaded file type. Returns: str: Path to downloaded file @@ -91,13 +131,28 @@ def generate_datarequest(year, monthstr, days, "area": [74, -42, 20, 69] } - # Default filename if not provided - if target is None: - target = f'download_era5_{year}_{monthstr}.zip' + # Temporary filename w/o extension if not provided + auto_detect_extension = target is None + if auto_detect_extension: + # Create a temporary file for download + temp_fd, target = tempfile.mkstemp( + prefix=f'download_era5_{year}_{monthstr}', + dir='.') + os.close(temp_fd) # Close the file descriptor # Get the data from cds client.retrieve(dataset, request, target) + # If target was not provided, detect the file type after download + if auto_detect_extension: + # Detect the actual file type + extension = detect_file_type(target) + + # Rename to final target with correct extension + final_target = f'{target}{extension}' + os.rename(target, final_target) + target = final_target + return target From a0973cec4e1ef6b3a33a83fc1c26d99478bf3b87 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 12:57:28 +0100 Subject: [PATCH 36/93] SEAS5-specialty for "day"-field in request --- mkforcing/download_ERA5_input.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 459ee9f..105f7aa 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -140,7 +140,11 @@ def generate_datarequest(year, monthstr, days, # Adapt year, month and day to input values request["year"] = [str(year)] request["month"] = [monthstr] - request["day"] = days + if dataset == "seasonal-original-single-levels": + # First day of month specified for SEAS5 + request["day"] = ["01"] + else: + request["day"] = days # Temporary filename w/o extension if not provided auto_detect_extension = target is None From c8a63f6515602e1cfc0dcab966840699b5d84195 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 13:04:52 +0100 Subject: [PATCH 37/93] concise leadtime_hour array --- mkforcing/custom_request_SEAS5.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index f1246fc..42fd11e 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -23,14 +23,7 @@ "year": ["2025"], "month": ["09"], "day": ["01"], - "leadtime_hour": [ - "6", "12", "18", "24", "30", "36", "42", "48", "54", "60", "66", "72", "78", "84", "90", "96", "102", "108", "114", "120", "126", "132", "138", "144", "150", "156", "162", "168", "174", "180", "186", "192", "198", "204", "210", "216", "222", "228", "234", "240", "246", "252", "258", "264", "270", "276", "282", "288", "294", "300", "306", "312", "318", "324", "330", "336", "342", "348", "354", "360", "366", "372", "378", "384", "390", "396", "402", "408", "414", "420", "426", "432", "438", "444", "450", "456", "462", "468", "474", "480", "486", "492", "498", "504", "510", "516", "522", "528", "534", "540", "546", "552", "558", "564", "570", "576", "582", "588", "594", "600", "606", "612", "618", "624", "630", "636", "642", "648", "654", "660", "666", "672", "678", "684", "690", "696", "702", "708", "714", "720", "726", "732", "738", "744", "750", "756", "762", "768", "774", "780", "786", "792", "798", "804", "810", "816", "822", "828", "834", "840", "846", "852", "858", "864", "870", "876", "882", "888", "894", "900", "906", "912", "918", "924", "930", "936", "942", "948", "954", "960", "966", "972", "978", "984", "990", "996", - "1002", "1008", "1014", "1020", "1026", "1032", "1038", "1044", "1050", "1056", "1062", "1068", "1074", "1080", "1086", "1092", "1098", "1104", "1110", "1116", "1122", "1128", "1134", "1140", "1146", "1152", "1158", "1164", "1170", "1176", "1182", "1188", "1194", "1200", "1206", "1212", "1218", "1224", "1230", "1236", "1242", "1248", "1254", "1260", "1266", "1272", "1278", "1284", "1290", "1296", "1302", "1308", "1314", "1320", "1326", "1332", "1338", "1344", "1350", "1356", "1362", "1368", "1374", "1380", "1386", "1392", "1398", "1404", "1410", "1416", "1422", "1428", "1434", "1440", "1446", "1452", "1458", "1464", "1470", "1476", "1482", "1488", "1494", "1500", "1506", "1512", "1518", "1524", "1530", "1536", "1542", "1548", "1554", "1560", "1566", "1572", "1578", "1584", "1590", "1596", "1602", "1608", "1614", "1620", "1626", "1632", "1638", "1644", "1650", "1656", "1662", "1668", "1674", "1680", "1686", "1692", "1698", "1704", "1710", "1716", "1722", "1728", "1734", "1740", "1746", "1752", "1758", "1764", "1770", "1776", "1782", "1788", "1794", "1800", "1806", "1812", "1818", "1824", "1830", "1836", "1842", "1848", "1854", "1860", "1866", "1872", "1878", "1884", "1890", "1896", "1902", "1908", "1914", "1920", "1926", "1932", "1938", "1944", "1950", "1956", "1962", "1968", "1974", "1980", "1986", "1992", "1998", - "2004", "2010", "2016", "2022", "2028", "2034", "2040", "2046", "2052", "2058", "2064", "2070", "2076", "2082", "2088", "2094", "2100", "2106", "2112", "2118", "2124", "2130", "2136", "2142", "2148", "2154", "2160", "2166", "2172", "2178", "2184", "2190", "2196", "2202", "2208", "2214", "2220", "2226", "2232", "2238", "2244", "2250", "2256", "2262", "2268", "2274", "2280", "2286", "2292", "2298", "2304", "2310", "2316", "2322", "2328", "2334", "2340", "2346", "2352", "2358", "2364", "2370", "2376", "2382", "2388", "2394", "2400", "2406", "2412", "2418", "2424", "2430", "2436", "2442", "2448", "2454", "2460", "2466", "2472", "2478", "2484", "2490", "2496", "2502", "2508", "2514", "2520", "2526", "2532", "2538", "2544", "2550", "2556", "2562", "2568", "2574", "2580", "2586", "2592", "2598", "2604", "2610", "2616", "2622", "2628", "2634", "2640", "2646", "2652", "2658", "2664", "2670", "2676", "2682", "2688", "2694", "2700", "2706", "2712", "2718", "2724", "2730", "2736", "2742", "2748", "2754", "2760", "2766", "2772", "2778", "2784", "2790", "2796", "2802", "2808", "2814", "2820", "2826", "2832", "2838", "2844", "2850", "2856", "2862", "2868", "2874", "2880", "2886", "2892", "2898", "2904", "2910", "2916", "2922", "2928", "2934", "2940", "2946", "2952", "2958", "2964", "2970", "2976", "2982", "2988", "2994", - "3000", "3006", "3012", "3018", "3024", "3030", "3036", "3042", "3048", "3054", "3060", "3066", "3072", "3078", "3084", "3090", "3096", "3102", "3108", "3114", "3120", "3126", "3132", "3138", "3144", "3150", "3156", "3162", "3168", "3174", "3180", "3186", "3192", "3198", "3204", "3210", "3216", "3222", "3228", "3234", "3240", "3246", "3252", "3258", "3264", "3270", "3276", "3282", "3288", "3294", "3300", "3306", "3312", "3318", "3324", "3330", "3336", "3342", "3348", "3354", "3360", "3366", "3372", "3378", "3384", "3390", "3396", "3402", "3408", "3414", "3420", "3426", "3432", "3438", "3444", "3450", "3456", "3462", "3468", "3474", "3480", "3486", "3492", "3498", "3504", "3510", "3516", "3522", "3528", "3534", "3540", "3546", "3552", "3558", "3564", "3570", "3576", "3582", "3588", "3594", "3600", "3606", "3612", "3618", "3624", "3630", "3636", "3642", "3648", "3654", "3660", "3666", "3672", "3678", "3684", "3690", "3696", "3702", "3708", "3714", "3720", "3726", "3732", "3738", "3744", "3750", "3756", "3762", "3768", "3774", "3780", "3786", "3792", "3798", "3804", "3810", "3816", "3822", "3828", "3834", "3840", "3846", "3852", "3858", "3864", "3870", "3876", "3882", "3888", "3894", "3900", "3906", "3912", "3918", "3924", "3930", "3936", "3942", "3948", "3954", "3960", "3966", "3972", "3978", "3984", "3990", "3996", - "4002", "4008", "4014", "4020", "4026", "4032", "4038", "4044", "4050", "4056", "4062", "4068", "4074", "4080", "4086", "4092", "4098", "4104", "4110", "4116", "4122", "4128", "4134", "4140", "4146", "4152", "4158", "4164", "4170", "4176", "4182", "4188", "4194", "4200", "4206", "4212", "4218", "4224", "4230", "4236", "4242", "4248", "4254", "4260", "4266", "4272", "4278", "4284", "4290", "4296", "4302", "4308", "4314", "4320", "4326", "4332", "4338", "4344", "4350", "4356", "4362", "4368", "4374", "4380", "4386", "4392", "4398", "4404", "4410", "4416", "4422", "4428", "4434", "4440", "4446", "4452", "4458", "4464", "4470", "4476", "4482", "4488", "4494", "4500", "4506", "4512", "4518", "4524", "4530", "4536", "4542", "4548", "4554", "4560", "4566", "4572", "4578", "4584", "4590", "4596", "4602", "4608", "4614", "4620", "4626", "4632", "4638", "4644", "4650", "4656", "4662", "4668", "4674", "4680", "4686", "4692", "4698", "4704", "4710", "4716", "4722", "4728", "4734", "4740", "4746", "4752", "4758", "4764", "4770", "4776", "4782", "4788", "4794", "4800", "4806", "4812", "4818", "4824", "4830", "4836", "4842", "4848", "4854", "4860", "4866", "4872", "4878", "4884", "4890", "4896", "4902", "4908", "4914", "4920", "4926", "4932", "4938", "4944", "4950", "4956", "4962", "4968", "4974", "4980", "4986", "4992", "4998", - "5004", "5010", "5016", "5022", "5028", "5034", "5040", "5046", "5052", "5058", "5064", "5070", "5076", "5082", "5088", "5094", "5100", "5106", "5112", "5118", "5124", "5130", "5136", "5142", "5148", "5154", "5160" - ], + "leadtime_hour": [str(h) for h in range(6, 5161, 6)], "data_format": "netcdf", "area": [51, 6, 50, 7] # Selhausen # "area": [74, -42, 20, 69] # Europe From d0e100c52fd446a7b700a903a493ebeb3a33330a Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 13:18:21 +0100 Subject: [PATCH 38/93] change naming scheme --- mkforcing/download_ERA5_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 105f7aa..cee273a 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -164,7 +164,7 @@ def generate_datarequest(year, monthstr, days, extension = detect_file_type(target) # Rename to final target with correct extension - final_target = f'{target}{extension}' + final_target = f'download_era5_{year}_{monthstr}{extension}' os.rename(target, final_target) target = final_target From 8fabe1b7137f5b99b06a462111a0d6238abda646 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 13:37:25 +0100 Subject: [PATCH 39/93] start leadtime_hour from 0 --- mkforcing/custom_request_SEAS5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index 42fd11e..0e34495 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -23,7 +23,7 @@ "year": ["2025"], "month": ["09"], "day": ["01"], - "leadtime_hour": [str(h) for h in range(6, 5161, 6)], + "leadtime_hour": [str(h) for h in range(0, 5161, 6)], "data_format": "netcdf", "area": [51, 6, 50, 7] # Selhausen # "area": [74, -42, 20, 69] # Europe From 3010e3bbd07e7b2fe5f30213d323e204529fe469 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 13:51:13 +0100 Subject: [PATCH 40/93] SEAS5 request for single-site (DE-RuS) --- mkforcing/custom_request_SEAS5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5.py index 0e34495..d9f849e 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5.py @@ -25,7 +25,7 @@ "day": ["01"], "leadtime_hour": [str(h) for h in range(0, 5161, 6)], "data_format": "netcdf", - "area": [51, 6, 50, 7] # Selhausen + "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen # "area": [74, -42, 20, 69] # Europe } From c8f1d7cc0029d94c3108c6ace025dc97827579c4 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 5 Dec 2025 13:51:55 +0100 Subject: [PATCH 41/93] ERA5 request for single-site (DE-RuS) --- mkforcing/custom_request_ERA5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/custom_request_ERA5.py b/mkforcing/custom_request_ERA5.py index 662ebe4..9e9eb19 100644 --- a/mkforcing/custom_request_ERA5.py +++ b/mkforcing/custom_request_ERA5.py @@ -28,7 +28,7 @@ ], "data_format": "netcdf", "download_format": "unarchived", - "area": [51, 6, 50, 7] # Selhausen + "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen # "area": [74, -42, 20, 69] # Europe } From 713a0dc6e100ae83e8a5ac31e66b8ff7078b2f72 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 10:37:40 +0100 Subject: [PATCH 42/93] prepare_ERA5_input: step for renaming "valid_time" to "time" both dimension and variable --- mkforcing/prepare_ERA5_input.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 6d86301..50b5b81 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -89,6 +89,32 @@ do cp ${pathdata}/data_stream-oper_stepType-instant.nc ${pathdata}/data_stream-oper_stepType-avg.nc ${tmpdir} fi + # Rename valid_time to time if it exists (in particular needed if + # Meteocloud is not used) + for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do + # Check and rename dimension + if ncdump -h "$file" | grep -q "^\s*time\s*="; then + echo "Dimension 'time' already exists in $file" + elif ncdump -h "$file" | grep -q "^\s*valid_time\s*="; then + echo "Renaming dimension 'valid_time' to 'time' in $file" + ncrename -d valid_time,time "$file" + else + echo "ERROR: Neither 'time' nor 'valid_time' dimension found in $file" >&2 + exit 1 + fi + + # Check and rename variable + if ncdump -h "$file" | grep -q "\s\+time("; then + echo "Variable 'time' already exists in $file" + elif ncdump -h "$file" | grep -q "\s\+valid_time("; then + echo "Renaming variable 'valid_time' to 'time' in $file" + ncrename -v valid_time,time "$file" + else + echo "ERROR: Neither 'time' nor 'valid_time' variable found in $file" >&2 + exit 1 + fi + done + if $lwgtdis; then cdo gendis,${domainfile} ${tmpdir}/data_stream-oper_stepType-instant.nc ${wgtcaf} if $lmeteo; then From ba0ec5a2db8da0e39f4687cc30b8a8c31be475db Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:00:56 +0100 Subject: [PATCH 43/93] prepare_ERA5_input: simplify grep check for time / valid_time --- mkforcing/prepare_ERA5_input.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 50b5b81..bdf00fe 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -93,9 +93,9 @@ do # Meteocloud is not used) for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do # Check and rename dimension - if ncdump -h "$file" | grep -q "^\s*time\s*="; then + if ncdump -h "$file" | grep -q " time = "; then echo "Dimension 'time' already exists in $file" - elif ncdump -h "$file" | grep -q "^\s*valid_time\s*="; then + elif ncdump -h "$file" | grep -q " valid_time = "; then echo "Renaming dimension 'valid_time' to 'time' in $file" ncrename -d valid_time,time "$file" else @@ -104,9 +104,9 @@ do fi # Check and rename variable - if ncdump -h "$file" | grep -q "\s\+time("; then + if ncdump -h "$file" | grep -q " time("; then echo "Variable 'time' already exists in $file" - elif ncdump -h "$file" | grep -q "\s\+valid_time("; then + elif ncdump -h "$file" | grep -q " valid_time("; then echo "Renaming variable 'valid_time' to 'time' in $file" ncrename -v valid_time,time "$file" else From 121f9ef963c9fb7ced39afb17461e85b40ec8117 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:12:15 +0100 Subject: [PATCH 44/93] much simplified renaming --- mkforcing/prepare_ERA5_input.sh | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index bdf00fe..7fc7131 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -92,27 +92,11 @@ do # Rename valid_time to time if it exists (in particular needed if # Meteocloud is not used) for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do - # Check and rename dimension - if ncdump -h "$file" | grep -q " time = "; then - echo "Dimension 'time' already exists in $file" - elif ncdump -h "$file" | grep -q " valid_time = "; then - echo "Renaming dimension 'valid_time' to 'time' in $file" - ncrename -d valid_time,time "$file" - else - echo "ERROR: Neither 'time' nor 'valid_time' dimension found in $file" >&2 - exit 1 - fi + # Renaming dimension 'valid_time' to 'time' in $file + ncrename -d valid_time,time "$file" - # Check and rename variable - if ncdump -h "$file" | grep -q " time("; then - echo "Variable 'time' already exists in $file" - elif ncdump -h "$file" | grep -q " valid_time("; then - echo "Renaming variable 'valid_time' to 'time' in $file" - ncrename -v valid_time,time "$file" - else - echo "ERROR: Neither 'time' nor 'valid_time' variable found in $file" >&2 - exit 1 - fi + # Renaming variable 'valid_time' to 'time' in $file + ncrename -v valid_time,time "$file" done if $lwgtdis; then From 2c714e9ac62d8778e0489f0981c69b979d9cf668 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:17:56 +0100 Subject: [PATCH 45/93] renaming valid_time to time in a switch --- mkforcing/prepare_ERA5_input.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 7fc7131..b5fea3a 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -7,6 +7,7 @@ lmerge=true lclm3=false lmeteo=true # Switch for using JSC-internal meteocloud data lunzip=true # Switch for unzipping or (if false) copying the ERA5 data +lrenametime=true # Switch for renaming valid_time to time lwgtdis=false # Switch for creating wgtdis file in script lgriddes=false # Switch for creating griddes file in script ompthd=1 @@ -42,6 +43,7 @@ parse_arguments() { lclm3) lclm3="$value" ;; lmeteo) lmeteo="$value" ;; lunzip) lunzip="$value" ;; + lrenametime) lrenametime="$value" ;; ompthd) ompthd="$value" ;; pathdata) pathdata="$value" ;; wgtcaf) wgtcaf="$value" ;; @@ -89,15 +91,17 @@ do cp ${pathdata}/data_stream-oper_stepType-instant.nc ${pathdata}/data_stream-oper_stepType-avg.nc ${tmpdir} fi - # Rename valid_time to time if it exists (in particular needed if - # Meteocloud is not used) - for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do - # Renaming dimension 'valid_time' to 'time' in $file - ncrename -d valid_time,time "$file" + if $lrenametime; then + # Rename valid_time to time if it exists (in particular needed if + # Meteocloud is not used) + for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do + # Renaming dimension 'valid_time' to 'time' in $file + ncrename -d valid_time,time "$file" - # Renaming variable 'valid_time' to 'time' in $file - ncrename -v valid_time,time "$file" - done + # Renaming variable 'valid_time' to 'time' in $file + ncrename -v valid_time,time "$file" + done + fi if $lwgtdis; then cdo gendis,${domainfile} ${tmpdir}/data_stream-oper_stepType-instant.nc ${wgtcaf} From 82eedf6c2ab9c4b3e42e7c8ca65a14981ad9cc2b Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:18:37 +0100 Subject: [PATCH 46/93] default: no renaming of time dim/var --- mkforcing/prepare_ERA5_input.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index b5fea3a..8b38966 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -7,7 +7,7 @@ lmerge=true lclm3=false lmeteo=true # Switch for using JSC-internal meteocloud data lunzip=true # Switch for unzipping or (if false) copying the ERA5 data -lrenametime=true # Switch for renaming valid_time to time +lrenametime=false # Switch for renaming valid_time to time lwgtdis=false # Switch for creating wgtdis file in script lgriddes=false # Switch for creating griddes file in script ompthd=1 From 4a9a7822815c5346cb4a8fbfa3c35e617e5553a8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:51:16 +0100 Subject: [PATCH 47/93] adapt wrapper to options --- mkforcing/download_ERA5_input_wrapper.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkforcing/download_ERA5_input_wrapper.sh b/mkforcing/download_ERA5_input_wrapper.sh index a237d8e..8b174e8 100755 --- a/mkforcing/download_ERA5_input_wrapper.sh +++ b/mkforcing/download_ERA5_input_wrapper.sh @@ -40,7 +40,7 @@ while [ "$current_date" \< "$end_date" ]; do month="${current_date#*-}" # start download script with data request - ./download_ERA5_input.py $year $month $out_dir + ./download_ERA5_input.py --year $year --month $month --dirout $out_dir # Increment the month, arbitrarily setting unimportant day of month to 1 # POSIX.1-2024 prescribes that months start at zero and years are since 1900 From 37bab6c8edec9ad226dae81d2c430e05970154d2 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:51:49 +0100 Subject: [PATCH 48/93] download_ERA5_input: if needed, parse year input from custom_request --- mkforcing/download_ERA5_input.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 4e3c631..27fbafc 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -175,8 +175,9 @@ def generate_datarequest(year, monthstr, days, parser.add_argument( "--year", type=int, - required=True, - help="Year to download (e.g., 2017)" + required=False, + default=None, + help="Year to download (e.g., 2017). Required for default request, optional for custom request (uses year from custom request if not provided).", ) parser.add_argument( "--month", @@ -227,6 +228,21 @@ def generate_datarequest(year, monthstr, days, custom_dataset = custom_module.dataset print(f"Loaded custom dataset from: {args.request}") + # Handle year: extract from custom request if not provided + if year is None: + if custom_request and "year" in custom_request: + year_from_request = custom_request["year"] + if isinstance(year_from_request, list): + year = int(year_from_request[0]) + else: + year = int(year_from_request) + print(f"Using year from custom request: {year}") + else: + raise ValueError( + "Year is required. Provide it either as --year argument " + "or in the custom request file." + ) + # Ensure the output directory exists, if not, create it if not os.path.exists(dirout): os.makedirs(dirout) From e4062f248e466801e597e0eee4b9b31225e2600a Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:54:14 +0100 Subject: [PATCH 49/93] download_ERA5_input: if needed, parse month input from custom_request --- mkforcing/download_ERA5_input.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 27fbafc..b2a1162 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -182,8 +182,9 @@ def generate_datarequest(year, monthstr, days, parser.add_argument( "--month", type=int, - required=True, - help="Month to download (1-12)" + required=False, + default=None, + help="Month to download (1-12). Required for default request, optional for custom request (uses month from custom request if not provided)." ) parser.add_argument( "--dirout", @@ -243,6 +244,21 @@ def generate_datarequest(year, monthstr, days, "or in the custom request file." ) + # Handle month: extract from custom request if not provided + if month is None: + if custom_request and "month" in custom_request: + month_from_request = custom_request["month"] + if isinstance(month_from_request, list): + month = int(month_from_request[0]) + else: + month = int(month_from_request) + print(f"Using month from custom request: {month}") + else: + raise ValueError( + "Month is required. Provide it either as --month argument " + "or in the custom request file." + ) + # Ensure the output directory exists, if not, create it if not os.path.exists(dirout): os.makedirs(dirout) From 6bbf3fc679302e7b28c0295cf3f4a481e7a4b6fc Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 11:57:37 +0100 Subject: [PATCH 50/93] download_ERA5_input: optional input --day --- mkforcing/download_ERA5_input.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index b2a1162..342fb3f 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -186,6 +186,13 @@ def generate_datarequest(year, monthstr, days, default=None, help="Month to download (1-12). Required for default request, optional for custom request (uses month from custom request if not provided)." ) + parser.add_argument( + "--day", + type=str, + required=False, + default=None, + help="Day(s) to download as comma-separated values (e.g., '15' or '1,15,30'). If not provided, all days in the month are downloaded." + ) parser.add_argument( "--dirout", type=str, @@ -269,8 +276,23 @@ def generate_datarequest(year, monthstr, days, # Format the month with a leading zero if needed monthstr = f"{month:02d}" - # Get the list of days for the request - days = generate_days(year, month) + # Handle day: parse from argument, extract from custom request, or compute all days + if args.day is not None: + # Parse comma-separated day values + days = [int(d.strip()) for d in args.day.split(',')] + print(f"Using days from argument: {days}") + elif custom_request and "day" in custom_request: + # Extract from custom request + day_from_request = custom_request["day"] + if isinstance(day_from_request, list): + days = [int(d) for d in day_from_request] + else: + days = [int(day_from_request)] + print(f"Using days from custom request: {days}") + else: + # Compute all days in the month + days = generate_days(year, month) + print(f"Using all days in month: {len(days)} days") print(f"Downloading ERA5 data for {year}-{monthstr}") print(f"Dataset: {custom_dataset}") From ea0037ab184cde293c7a5e950441d4d0ac4de6b3 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 12:02:13 +0100 Subject: [PATCH 51/93] change order --- mkforcing/download_ERA5_input.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index 342fb3f..6bf1467 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -219,6 +219,13 @@ def generate_datarequest(year, monthstr, days, month = args.month dirout = args.dirout + # Ensure the output directory exists, if not, create it + if not os.path.exists(dirout): + os.makedirs(dirout) + + # change to output directory + os.chdir(dirout) + # Load custom request if provided custom_request = None custom_dataset = args.dataset @@ -266,13 +273,6 @@ def generate_datarequest(year, monthstr, days, "or in the custom request file." ) - # Ensure the output directory exists, if not, create it - if not os.path.exists(dirout): - os.makedirs(dirout) - - # change to output directory - os.chdir(dirout) - # Format the month with a leading zero if needed monthstr = f"{month:02d}" From b1009f30f9cbc251af22a9dbed3d4c289fabd137 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 21:54:06 +0100 Subject: [PATCH 52/93] bugfix: do not rename dimension Renaming both dimension and variable lead to erroneous values for unclear reasons. --- mkforcing/prepare_ERA5_input.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index 8b38966..d0401f7 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -95,8 +95,8 @@ do # Rename valid_time to time if it exists (in particular needed if # Meteocloud is not used) for file in ${tmpdir}/data_stream-oper_stepType-instant.nc ${tmpdir}/data_stream-oper_stepType-avg.nc; do - # Renaming dimension 'valid_time' to 'time' in $file - ncrename -d valid_time,time "$file" + # # Renaming dimension 'valid_time' to 'time' in $file + # ncrename -d valid_time,time "$file" # Renaming variable 'valid_time' to 'time' in $file ncrename -v valid_time,time "$file" From 91fab775c1cc72b32e65d4a5d6950e6f6178fcef Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 10 Dec 2025 22:21:30 +0100 Subject: [PATCH 53/93] bugfix: add lwgtdsi and lgriddes to inputs --- mkforcing/prepare_ERA5_input.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkforcing/prepare_ERA5_input.sh b/mkforcing/prepare_ERA5_input.sh index d0401f7..b13dd76 100755 --- a/mkforcing/prepare_ERA5_input.sh +++ b/mkforcing/prepare_ERA5_input.sh @@ -44,6 +44,8 @@ parse_arguments() { lmeteo) lmeteo="$value" ;; lunzip) lunzip="$value" ;; lrenametime) lrenametime="$value" ;; + lwgtdis) lwgtdis="$value" ;; + lgriddes) lgriddes="$value" ;; ompthd) ompthd="$value" ;; pathdata) pathdata="$value" ;; wgtcaf) wgtcaf="$value" ;; From d6dc17105b8147beffefea5f5e0b3a42532a64a2 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 15 Jan 2026 23:28:18 +0100 Subject: [PATCH 54/93] SEAS5: request has to be distributed along 3 files according to the frequency of values for each variable. could be a problem of the netCDF download. --- ...t_SEAS5.py => custom_request_SEAS5_06h.py} | 13 +++++-- mkforcing/custom_request_SEAS5_24h.py | 38 +++++++++++++++++++ mkforcing/custom_request_SEAS5_const.py | 38 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) rename mkforcing/{custom_request_SEAS5.py => custom_request_SEAS5_06h.py} (73%) create mode 100644 mkforcing/custom_request_SEAS5_24h.py create mode 100644 mkforcing/custom_request_SEAS5_const.py diff --git a/mkforcing/custom_request_SEAS5.py b/mkforcing/custom_request_SEAS5_06h.py similarity index 73% rename from mkforcing/custom_request_SEAS5.py rename to mkforcing/custom_request_SEAS5_06h.py index d9f849e..fe29820 100644 --- a/mkforcing/custom_request_SEAS5.py +++ b/mkforcing/custom_request_SEAS5_06h.py @@ -1,6 +1,8 @@ ## Download for SEAS5 forecast variables # https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download +# 6-HOURLY VARIABLES + # Forecast for 7 months # import cdsapi @@ -10,11 +12,14 @@ "originating_centre": "ecmwf", "system": "51", "variable": [ + # # constant + # "orography", # used to convert mslp to sp + # # 24-hourly + # "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + # "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + # "total_precipitation", + # 6-hourly "mean_sea_level_pressure", # convert to surface pressure, use elevation (hypsometric formula), surface geopotential height (orography) - "orography", # used to convert mslp to sp - "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] - "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] - "total_precipitation", "10m_u_component_of_wind", "10m_v_component_of_wind", "2m_temperature", diff --git a/mkforcing/custom_request_SEAS5_24h.py b/mkforcing/custom_request_SEAS5_24h.py new file mode 100644 index 0000000..b2bf3b1 --- /dev/null +++ b/mkforcing/custom_request_SEAS5_24h.py @@ -0,0 +1,38 @@ +## Download for SEAS5 forecast variables +# https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download + +# 24-HOURLY / DAILY VARIABLES + +# Forecast for 7 months + +# import cdsapi + +dataset = "seasonal-original-single-levels" +request = { + "originating_centre": "ecmwf", + "system": "51", + "variable": [ + # # constant + # "orography", # used to convert mslp to sp + # 24-hourly + "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + "total_precipitation", + # # 6-hourly + # "mean_sea_level_pressure", # convert to surface pressure, use elevation (hypsometric formula), surface geopotential height (orography) + # "10m_u_component_of_wind", + # "10m_v_component_of_wind", + # "2m_temperature", + # "2m_dewpoint_temperature", + ], + "year": ["2025"], + "month": ["09"], + "day": ["01"], + "leadtime_hour": [str(h) for h in range(0, 5161, 6)], + "data_format": "netcdf", + "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen + # "area": [74, -42, 20, 69] # Europe +} + +# client = cdsapi.Client() +# client.retrieve(dataset, request).download() diff --git a/mkforcing/custom_request_SEAS5_const.py b/mkforcing/custom_request_SEAS5_const.py new file mode 100644 index 0000000..e814b8d --- /dev/null +++ b/mkforcing/custom_request_SEAS5_const.py @@ -0,0 +1,38 @@ +## Download for SEAS5 forecast variables +# https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels?tab=download + +# CONSTANT VARIABLES + +# Forecast for 7 months + +# import cdsapi + +dataset = "seasonal-original-single-levels" +request = { + "originating_centre": "ecmwf", + "system": "51", + "variable": [ + # constant + "orography", # used to convert mslp to sp + # # 24-hourly + # "surface_thermal_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + # "surface_solar_radiation_downwards", # Unit conversion from accumulated value [J/m2] to mean rate [W/m2] + # "total_precipitation", + # # 6-hourly + # "mean_sea_level_pressure", # convert to surface pressure, use elevation (hypsometric formula), surface geopotential height (orography) + # "10m_u_component_of_wind", + # "10m_v_component_of_wind", + # "2m_temperature", + # "2m_dewpoint_temperature", + ], + "year": ["2025"], + "month": ["09"], + "day": ["01"], + "leadtime_hour": [str(h) for h in range(0, 5161, 6)], + "data_format": "netcdf", + "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen + # "area": [74, -42, 20, 69] # Europe +} + +# client = cdsapi.Client() +# client.retrieve(dataset, request).download() From 2d32f43fe518ed9c2150f891cdeef8b7e79bc0d4 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 16 Jan 2026 11:50:16 +0100 Subject: [PATCH 55/93] mkforcing/seas5_daily_to_6hourly: first draft Constant and daily variables to 6-hourly. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 407 ++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 mkforcing/seas5_daily_to_6hourly.py diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py new file mode 100644 index 0000000..e140c4a --- /dev/null +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Convert SEAS5 constant and daily variables to 6-hourly resolution and merge +with existing 6-hourly file. + +This script: +1. Adds constant `z` (orography) to the 6-hourly file, broadcast to all time steps +2. Converts daily `tp` (total precipitation) to 6-hourly by dividing by 4 +3. Converts daily `strd` and `ssrd` (radiation) to 6-hourly: + - Zero in first/last 6-hour intervals ("night") + - Half the daily value in the middle two intervals ("day") + +Usage: + python seas5_daily_to_6hourly.py --const --daily --hourly <6h_file> [--output ] + +If --output is not specified, the 6-hourly file will be modified in place. +""" + +import argparse +import numpy as np +import xarray as xr + + +def forecast_period_to_hours(forecast_period): + """ + Convert forecast_period values to hours. + + xarray may store forecast_period as timedelta64[ns] (nanoseconds), + but we need hours for our calculations. + + Parameters + ---------- + forecast_period : array-like + Forecast period values (may be in nanoseconds or hours) + + Returns + ------- + np.ndarray + Forecast period values in hours + """ + values = np.asarray(forecast_period) + + # Check if values are in nanoseconds (very large numbers) + # 1 hour = 3600 seconds = 3.6e12 nanoseconds + if values.dtype.kind == "m": # timedelta type + # Convert timedelta64 to hours + return values.astype("timedelta64[h]").astype(float) + elif np.max(values) > 1e9: + # Likely nanoseconds, convert to hours + return values / (3600 * 1e9) + else: + # Assume already in hours + return values.astype(float) + + +def expand_constant_to_6hourly(ds_const, n_timesteps): + """ + Expand constant variable z to 6-hourly resolution. + + The constant z has forecast_period=0 (single time), we need to broadcast + it to all 6-hourly time steps. + + Parameters + ---------- + ds_const : xarray.Dataset + Dataset containing constant variables (z) + n_timesteps : int + Number of 6-hourly time steps to expand to + + Returns + ------- + xarray.DataArray + z variable expanded to n_timesteps + """ + z = ds_const["z"] + + # Remove the forecast_period dimension (size 1) and we'll broadcast later + z_squeezed = z.squeeze("forecast_period", drop=True) + + # Expand along a new forecast_period dimension + z_expanded = z_squeezed.expand_dims("forecast_period", axis=2) + z_expanded = z_expanded.broadcast_to( + number=z_squeezed.sizes.get("number", z_squeezed.shape[0]), + forecast_reference_time=z_squeezed.sizes.get("forecast_reference_time", 1), + forecast_period=n_timesteps, + latitude=z_squeezed.sizes.get("latitude", 1), + longitude=z_squeezed.sizes.get("longitude", 1), + ) + + return z_expanded + + +def distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h): + """ + Distribute daily total precipitation to 6-hourly intervals. + + Each daily value is divided by 4 and assigned to the four corresponding + 6-hour intervals. + + Parameters + ---------- + ds_daily : xarray.Dataset + Dataset containing daily variables + forecast_periods_6h : array-like + The 6-hourly forecast periods (e.g., [6, 12, 18, 24, 30, 36, 42, 48] in hours) + + Returns + ------- + xarray.DataArray + tp variable at 6-hourly resolution + """ + tp_daily = ds_daily["tp"] + daily_periods_raw = ds_daily["forecast_period"].values + + # Convert to hours + forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) + daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + + # Get dimensions + n_number = tp_daily.sizes.get("number", tp_daily.shape[0]) + n_ref_time = tp_daily.sizes.get("forecast_reference_time", 1) + n_lat = tp_daily.sizes.get("latitude", 1) + n_lon = tp_daily.sizes.get("longitude", 1) + n_6h = len(forecast_periods_6h) + + # Create output array + tp_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + + # For each daily period, distribute to 4 6-hourly periods + for i, daily_period_hours in enumerate(daily_periods_hours): + # Find the 4 6-hourly periods that belong to this day + # Day ending at hour 24 -> 6h intervals at 6, 12, 18, 24 + # Day ending at hour 48 -> 6h intervals at 30, 36, 42, 48 + start_hour = daily_period_hours - 24 + 6 # first 6h period of this day + + # Get indices of the 4 6-hourly periods for this day + indices_6h = [] + for h in range(4): + hour = start_hour + h * 6 + idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] + if len(idx) > 0: + indices_6h.append(idx[0]) + + # Divide daily value by 4 and assign to each 6-hourly period + daily_value = tp_daily.isel(forecast_period=i).values + for idx in indices_6h: + tp_6h[:, :, idx, :, :] = daily_value / 4.0 + + return tp_6h + + +def distribute_daily_radiation_to_6hourly(ds_daily, var_name, forecast_periods_6h): + """ + Distribute daily radiation to 6-hourly intervals. + + For radiation (strd, ssrd): + - Zero in first 6-hour interval (night: 00-06) + - Half the daily value in middle two intervals (day: 06-12, 12-18) + - Zero in last 6-hour interval (night: 18-24) + + Parameters + ---------- + ds_daily : xarray.Dataset + Dataset containing daily variables + var_name : str + Variable name ('strd' or 'ssrd') + forecast_periods_6h : array-like + The 6-hourly forecast periods + + Returns + ------- + np.ndarray + Radiation variable at 6-hourly resolution + """ + rad_daily = ds_daily[var_name] + daily_periods_raw = ds_daily["forecast_period"].values + + # Convert to hours + forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) + daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + + # Get dimensions + n_number = rad_daily.sizes.get("number", rad_daily.shape[0]) + n_ref_time = rad_daily.sizes.get("forecast_reference_time", 1) + n_lat = rad_daily.sizes.get("latitude", 1) + n_lon = rad_daily.sizes.get("longitude", 1) + n_6h = len(forecast_periods_6h) + + # Create output array + rad_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + + # For each daily period, distribute to 4 6-hourly periods + for i, daily_period_hours in enumerate(daily_periods_hours): + # Find the 4 6-hourly periods that belong to this day + start_hour = daily_period_hours - 24 + 6 + + # Get indices of the 4 6-hourly periods for this day + hours_6h = [start_hour + h * 6 for h in range(4)] + indices_6h = [] + for hour in hours_6h: + idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] + if len(idx) > 0: + indices_6h.append(idx[0]) + + if len(indices_6h) != 4: + print( + f"Warning: Expected 4 6-hourly indices for day {daily_period_hours}h, " + + f"got {len(indices_6h)}" + ) + continue + + # Daily value + daily_value = rad_daily.isel(forecast_period=i).values + + # First interval (night): 0 + rad_6h[:, :, indices_6h[0], :, :] = 0.0 + # Second interval (day): half + rad_6h[:, :, indices_6h[1], :, :] = daily_value / 2.0 + # Third interval (day): half + rad_6h[:, :, indices_6h[2], :, :] = daily_value / 2.0 + # Fourth interval (night): 0 + rad_6h[:, :, indices_6h[3], :, :] = 0.0 + + return rad_6h + + +def main(): + parser = argparse.ArgumentParser( + description="Convert SEAS5 constant and daily variables to 6-hourly resolution" + ) + parser.add_argument( + "--const", required=True, help="Path to netCDF file with constant variables (z)" + ) + parser.add_argument( + "--daily", + required=True, + help="Path to netCDF file with daily variables (strd, ssrd, tp)", + ) + parser.add_argument( + "--hourly", required=True, help="Path to netCDF file with 6-hourly variables" + ) + parser.add_argument( + "--output", + default=None, + help="Output file path (default: modify hourly file in place)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without writing output", + ) + + args = parser.parse_args() + + # Determine output file early + output_file = args.output if args.output else args.hourly + in_place = output_file == args.hourly + + if in_place: + print(f"Note: Modifying {args.hourly} in place") + + # Load datasets - use load() to ensure data is in memory + # This is important for in-place modification + print(f"Loading constant file: {args.const}") + ds_const = xr.open_dataset(args.const).load() + + print(f"Loading daily file: {args.daily}") + ds_daily = xr.open_dataset(args.daily).load() + + print(f"Loading 6-hourly file: {args.hourly}") + ds_6h = xr.open_dataset(args.hourly).load() + + # Get 6-hourly forecast periods + forecast_periods_6h = ds_6h["forecast_period"].values + n_timesteps = len(forecast_periods_6h) + + # Convert to hours for display + fp_6h_hours = forecast_period_to_hours(forecast_periods_6h) + fp_daily_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) + + print(f"6-hourly forecast periods (hours): {fp_6h_hours}") + print(f"Daily forecast periods (hours): {fp_daily_hours}") + + # 1. Expand constant z to 6-hourly + print("Expanding constant z to 6-hourly resolution...") + z_const = ds_const["z"] + + # Squeeze out the singleton forecast_period dimension from constant + z_squeezed = z_const.squeeze("forecast_period", drop=True) + + # Create z_6h by tiling the constant value + # Shape: (number, forecast_reference_time, forecast_period, latitude, longitude) + z_data = np.tile( + z_squeezed.values[:, :, np.newaxis, :, :], (1, 1, n_timesteps, 1, 1) + ) + + # 2. Distribute daily precipitation to 6-hourly + print("Distributing daily precipitation to 6-hourly...") + tp_6h = distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h) + + # 3. Distribute daily radiation to 6-hourly + print("Distributing daily strd (thermal radiation) to 6-hourly...") + strd_6h = distribute_daily_radiation_to_6hourly( + ds_daily, "strd", forecast_periods_6h + ) + + print("Distributing daily ssrd (solar radiation) to 6-hourly...") + ssrd_6h = distribute_daily_radiation_to_6hourly( + ds_daily, "ssrd", forecast_periods_6h + ) + + # Create new dataset with all variables + print("Creating merged dataset...") + + # Copy the 6-hourly dataset structure + ds_out = ds_6h.copy(deep=True) + + # Add z variable + ds_out["z"] = xr.DataArray( + data=z_data, + dims=[ + "number", + "forecast_reference_time", + "forecast_period", + "latitude", + "longitude", + ], + attrs=z_const.attrs, + ) + + # Add tp variable + ds_out["tp"] = xr.DataArray( + data=tp_6h, + dims=[ + "number", + "forecast_reference_time", + "forecast_period", + "latitude", + "longitude", + ], + attrs=ds_daily["tp"].attrs, + ) + + # Add strd variable + ds_out["strd"] = xr.DataArray( + data=strd_6h, + dims=[ + "number", + "forecast_reference_time", + "forecast_period", + "latitude", + "longitude", + ], + attrs=ds_daily["strd"].attrs, + ) + + # Add ssrd variable + ds_out["ssrd"] = xr.DataArray( + data=ssrd_6h, + dims=[ + "number", + "forecast_reference_time", + "forecast_period", + "latitude", + "longitude", + ], + attrs=ds_daily["ssrd"].attrs, + ) + + # Write output (unless dry-run) + if args.dry_run: + print(f"\n[DRY RUN] Would write output to: {output_file}") + print("[DRY RUN] Output dataset structure:") + print(ds_out) + else: + print(f"Writing output to: {output_file}") + + # Use encoding to ensure proper compression and data types + encoding = {} + for var in ds_out.data_vars: + encoding[var] = {"zlib": True, "complevel": 4} + + ds_out.to_netcdf(output_file, encoding=encoding) + + # Close datasets + ds_const.close() + ds_daily.close() + ds_6h.close() + + print("Done!") + + # Print summary + print("\nSummary of added variables:") + print(f" z: constant orography, broadcast to {n_timesteps} time steps") + print(" tp: daily precipitation divided by 4 for each 6-hourly interval") + print( + " strd: daily thermal radiation - " + + "0 at night (first/last), half at day (middle)" + ) + print( + " ssrd: daily solar radiation - " + + "0 at night (first/last), half at day (middle)" + ) + + +if __name__ == "__main__": + main() From 7947f807cb893b887791fcd5ccaf24191ff17c74 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 16 Jan 2026 13:13:59 +0100 Subject: [PATCH 56/93] mslp_to_sp.py: Handling of time axis of SEAS5 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/mslp_to_sp.py | 159 +++++++++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 41 deletions(-) diff --git a/mkforcing/mslp_to_sp.py b/mkforcing/mslp_to_sp.py index 5ca3437..fcdfa89 100644 --- a/mkforcing/mslp_to_sp.py +++ b/mkforcing/mslp_to_sp.py @@ -305,65 +305,142 @@ def add_surface_pressure_to_netcdf(filename, elevation_var="z", temp_var="t2m", print(f"Elevation range: {np.min(elevation):.1f} to {np.max(elevation):.1f} m") # Determine time dimension for 12h offset - # Assume first dimension is time - time_dim = nc.variables[temp_var].dimensions[0] + # Check for SEAS5 structure (number, forecast_reference_time, forecast_period, lat, lon) + # vs ERA5 structure (time, lat, lon) + dim_names = nc.variables[temp_var].dimensions + print(f"Dimension names: {dim_names}") + + # Find the time dimension - prefer 'forecast_period' for SEAS5, otherwise first dim + if "forecast_period" in dim_names: + time_dim = "forecast_period" + time_axis = dim_names.index("forecast_period") + elif "time" in dim_names: + time_dim = "time" + time_axis = dim_names.index("time") + else: + # Fall back to first dimension + time_dim = dim_names[0] + time_axis = 0 + time_size = nc.dimensions[time_dim].size - print(f"Time dimension: {time_dim} with size {time_size}") + print(f"Time dimension: {time_dim} (axis {time_axis}) with size {time_size}") # Calculate surface pressure print("Calculating surface pressure...") + # For SEAS5 data with forecast_period as time dimension, we need to + # process along the forecast_period axis while keeping other dimensions + # The mslp_to_surface_pressure function works element-wise via numpy + # Create array for surface pressure sp = np.zeros_like(msl) - # For the first timestep (index 0), we don't have 12h ago data - # Use current temperature for both - if time_size > 0: - print("Processing first timestep (no 12h-ago data available)...") - sp[0, ...] = mslp_to_surface_pressure( - msl[0, ...], - elevation, - t2m[0, ...], - t2m[0, ...], # Use current temp as 12h-ago temp - "Pa" - ) + # For SEAS5 structure: (number, forecast_reference_time, forecast_period, lat, lon) + # time_axis = 2, so we need to slice along axis 2 + if time_axis == 2: + # SEAS5 structure + print("Detected SEAS5 data structure (ensemble data)...") + + # For the first timestep (index 0), we don't have 12h ago data + if time_size > 0: + print("Processing first timestep (no 12h-ago data available)...") + sp[:, :, 0, :, :] = mslp_to_surface_pressure( + msl[:, :, 0, :, :], + elevation[:, :, 0, :, :], + t2m[:, :, 0, :, :], + t2m[:, :, 0, :, :], # Use current temp as 12h-ago temp + "Pa" + ) - # For remaining timesteps, check if we can use 12h offset - # Assuming hourly data, 12h offset = 12 timesteps back - # For other time resolutions, this logic may need adjustment - if time_size > 12: - print("Processing remaining timesteps with 12h-ago data...") - for t in range(12, time_size): - sp[t, ...] = mslp_to_surface_pressure( - msl[t, ...], - elevation, - t2m[t, ...], - t2m[t - 12, ...], # Temperature 12 timesteps ago + # For 6-hourly data, 12h = 2 timesteps back + # For hourly data, 12h = 12 timesteps back + # Assume 6-hourly if time_size <= 8 per day, otherwise hourly + timesteps_12h = 2 if time_size <= 8 else 12 + + if time_size > timesteps_12h: + print(f"Processing remaining timesteps with 12h-ago data (offset={timesteps_12h})...") + for t in range(timesteps_12h, time_size): + sp[:, :, t, :, :] = mslp_to_surface_pressure( + msl[:, :, t, :, :], + elevation[:, :, t, :, :], + t2m[:, :, t, :, :], + t2m[:, :, t - timesteps_12h, :, :], + "Pa" + ) + + # For timesteps 1 to timesteps_12h-1, use current temp + if time_size > 1: + print(f"Processing timesteps 1-{min(timesteps_12h, time_size)-1} without 12h-ago data...") + for t in range(1, min(timesteps_12h, time_size)): + sp[:, :, t, :, :] = mslp_to_surface_pressure( + msl[:, :, t, :, :], + elevation[:, :, t, :, :], + t2m[:, :, t, :, :], + t2m[:, :, t, :, :], + "Pa" + ) + elif time_size > 1: + print("Processing all timesteps without 12h-ago data...") + for t in range(1, time_size): + sp[:, :, t, :, :] = mslp_to_surface_pressure( + msl[:, :, t, :, :], + elevation[:, :, t, :, :], + t2m[:, :, t, :, :], + t2m[:, :, t, :, :], + "Pa" + ) + else: + # ERA5 structure - time is first dimension + print("Detected ERA5 data structure...") + + # For the first timestep (index 0), we don't have 12h ago data + if time_size > 0: + print("Processing first timestep (no 12h-ago data available)...") + sp[0, ...] = mslp_to_surface_pressure( + msl[0, ...], + elevation[0, ...] if elevation.shape[0] == time_size else elevation, + t2m[0, ...], + t2m[0, ...], # Use current temp as 12h-ago temp "Pa" ) - # For timesteps 1-11, use current temp (no 12h data yet) - if time_size > 1: - print("Processing timesteps 1-11 without 12h-ago data...") - for t in range(1, min(12, time_size)): + # For remaining timesteps, check if we can use 12h offset + # Assuming hourly data, 12h offset = 12 timesteps back + if time_size > 12: + print("Processing remaining timesteps with 12h-ago data...") + for t in range(12, time_size): + elev_t = elevation[t, ...] if elevation.shape[0] == time_size else elevation sp[t, ...] = mslp_to_surface_pressure( msl[t, ...], - elevation, + elev_t, + t2m[t, ...], + t2m[t - 12, ...], + "Pa" + ) + + # For timesteps 1-11, use current temp + if time_size > 1: + print("Processing timesteps 1-11 without 12h-ago data...") + for t in range(1, min(12, time_size)): + elev_t = elevation[t, ...] if elevation.shape[0] == time_size else elevation + sp[t, ...] = mslp_to_surface_pressure( + msl[t, ...], + elev_t, + t2m[t, ...], + t2m[t, ...], + "Pa" + ) + elif time_size > 1: + print("Processing all timesteps without 12h-ago data...") + for t in range(1, time_size): + elev_t = elevation[t, ...] if elevation.shape[0] == time_size else elevation + sp[t, ...] = mslp_to_surface_pressure( + msl[t, ...], + elev_t, t2m[t, ...], t2m[t, ...], "Pa" ) - elif time_size > 1: - # If we have fewer than 12 timesteps, process without 12h offset - print("Processing all timesteps without 12h-ago data (less than 12 timesteps)...") - for t in range(1, time_size): - sp[t, ...] = mslp_to_surface_pressure( - msl[t, ...], - elevation, - t2m[t, ...], - t2m[t, ...], - "Pa" - ) # Create the new variable print("Creating new variable 'sp'...") From 7d03fac7f502e9cb46a5cc4ae7becd1971c0350f Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:02:42 +0100 Subject: [PATCH 57/93] docs: SEAS5 related docs page --- docs/users_guide/README.md | 2 + docs/users_guide/era5-forcing.md | 1 + docs/users_guide/seas5-forcing.md | 101 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 docs/users_guide/seas5-forcing.md diff --git a/docs/users_guide/README.md b/docs/users_guide/README.md index d8855a7..dc6a6e2 100644 --- a/docs/users_guide/README.md +++ b/docs/users_guide/README.md @@ -5,3 +5,5 @@ This repository shows how to generate atmospheric forcings for eCLM simulations. For an overview of the needed atmospheric forcing variables for eCLM see [Overview atmospheric forcing files](overview-section). For the default creation of atmospheric forcing based on ERA5 data see [eCLM atmospheric forcing based on ERA5](era5forcing). + +For the default creation of atmospheric forcing based on SEAS5 data see [eCLM atmospheric forcing based on SEAS5](seas5forcing). diff --git a/docs/users_guide/era5-forcing.md b/docs/users_guide/era5-forcing.md index 1bed918..4529e24 100644 --- a/docs/users_guide/era5-forcing.md +++ b/docs/users_guide/era5-forcing.md @@ -17,6 +17,7 @@ The folder `mkforcing/` contains three scripts that assist the ERA5 retrieval. Note: This worfklow is not fully tested. +(era5forcing-download)= ### Download of ERA5 data `download_ERA5_input.py` contains a prepared retrieval for the cdsapi python module. diff --git a/docs/users_guide/seas5-forcing.md b/docs/users_guide/seas5-forcing.md new file mode 100644 index 0000000..23fef9a --- /dev/null +++ b/docs/users_guide/seas5-forcing.md @@ -0,0 +1,101 @@ +(seas5forcing)= +# eCLM atmospheric forcing based on SEAS5 + +Start by sourcing the provided environment file + +``` +source jsc.2024_Intel.sh +``` + +## Creation of forcing data from SEAS5 + +Creation of SEAS5 forcing files is adapted from the creation of ERA5 +forcing files. + +The folder `mkforcing/` contains the scripts that assist the SEAS5 +retrieval. + +### Download of SEAS5 data + +`download_ERA5_input.py` contains a prepared retrieval for the cdsapi python module. + +More about cdsapi can be found in [Download of ERA5 +data](era5forcing-download). + +Usage: Three separate commands have to be executed, one for constant +variables (orography), one for daily variables (e.g. total +precipitation) and one for 6-hourly variables (e.g. temperature). + +For each type of output, a dedicated download directory is created. + +A main adaption from ERA5 download is the specification of a +customized CDSAPI request using `--request`. + +```bash + python download_ERA5_input.py --year --month --dirout cdsapidwn_SEAS5_const --request ../custom_request_SEAS5_const.py + python download_ERA5_input.py --year --month --dirout cdsapidwn_SEAS5_24h --request ../custom_request_SEAS5_24h.py + python download_ERA5_input.py --year --month --dirout cdsapidwn_SEAS5_06h --request ../custom_request_SEAS5_06h.py +``` + +**Note:** The wrapper script: `./download_ERA5_input_wrapper.sh` is +currently NOT SUPPORTED for SEAS5 download. + + +### Preparation of SEAS5 data: all variable to 06h + +First, we want to have all variables in 6-hourly interval + +- from constant: `z` has to be ported (same values as before) +- from daily: `strd`, `ssrd` (thermal, solar, each accumulated) and + `tp` (total precipitation), these values would have to be + distributed. For `tp` the value would be divided by four and + assigned to the four corresponding 6-hour intervals. For the + radiations, it would be zero in the first/last interval ("night") + and half the value in the middle two intervals, then converted + to flux (W/m²) by dividing by the time interval. + +The script outputs radiation directly as flux variables (`flds`, `fsds`) +in W/m², so the separate `accumulated_radiation_to_flux.py` step is +not needed for SEAS5 data. + +```bash +python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc +``` + +### Preparation of SEAS5 data: correct input variables + +Steps for preparing SEAS5 data as eCLM input data + +1. Orography to elevation (adds a variable `elevation` to the netCDF + file) + +```bash +python orography_to_elevation.py cdsapidwn_SEAS5/download_era5_2026_01.nc +``` + +2. Mean sea level pressure to surface pressure + +```bash +python mslp_to_sp.py cdsapidwn_SEAS5/download_era5_2026_01.nc --elevation-var elevation +``` + +3. Humidity computed from dewpoint temperature and surface pressure + +``` +python dewpoint_to_specific_humidity.py cdsapidwn_SEAS5/download_era5_2026_01.nc +``` + +4. Temperature and Specific Humidity converted from 2m to 10m + +``` +python 2m_to_10m_conversion.py cdsapidwn_SEAS5/download_era5_2026_01.nc +``` + +### Preparation of SEAS5 data: Remapping, Data merging, CLM3.5 + + +Check inputs and replace according to your case. + +``` +sh prepare_SEAS5_input.sh lwgtdis=true lgriddes=true wgtcaf=../wgtdis_era5caf_to_DE-RuS-$(date +%y%m%d).nc griddesfile=../griddes_DE-RuS_$(date +%y%m%d).txt iyear=2026 imonth=01 author="Johannes KELLER" email="jo.keller@fz-juelich.de" tmpdir=tmpdir pathdata=../cdsapidwn_SEAS5 +``` From 8aa4da9c2491ae0e2d6263bc7e87b6a5b059ec0a Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:05:51 +0100 Subject: [PATCH 58/93] SEAS5: accumulated radiation -> flux conversion in 6-hourly step Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- docs/users_guide/seas5-forcing.md | 5 +- mkforcing/accumulated_radiation_to_flux.py | 339 --------------------- mkforcing/seas5_daily_to_6hourly.py | 79 +++-- 3 files changed, 52 insertions(+), 371 deletions(-) delete mode 100644 mkforcing/accumulated_radiation_to_flux.py diff --git a/docs/users_guide/seas5-forcing.md b/docs/users_guide/seas5-forcing.md index 23fef9a..e093bcb 100644 --- a/docs/users_guide/seas5-forcing.md +++ b/docs/users_guide/seas5-forcing.md @@ -54,9 +54,8 @@ First, we want to have all variables in 6-hourly interval and half the value in the middle two intervals, then converted to flux (W/m²) by dividing by the time interval. -The script outputs radiation directly as flux variables (`flds`, `fsds`) -in W/m², so the separate `accumulated_radiation_to_flux.py` step is -not needed for SEAS5 data. +The script outputs radiation directly as flux variables (`flds`, +`fsds`) in W/m². ```bash python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc diff --git a/mkforcing/accumulated_radiation_to_flux.py b/mkforcing/accumulated_radiation_to_flux.py deleted file mode 100644 index 97e9f9e..0000000 --- a/mkforcing/accumulated_radiation_to_flux.py +++ /dev/null @@ -1,339 +0,0 @@ -""" -Convert accumulated radiation to radiative flux. - -This script converts accumulated radiation values (J/m²) to instantaneous -radiative flux values (W/m²) by calculating the rate of change over the -time intervals between measurements. - -Mathematical Background: -Radiative flux is the rate of energy transfer per unit area: - - F = ΔE / (A × Δt) = ΔE / Δt [W/m²] - -where: -- F: Radiative flux (W/m²) or (J/(s·m²)) -- ΔE: Change in accumulated energy (J/m²) -- Δt: Time interval (s) -- A: Area (m²), which cancels out since we work with per-unit-area values - -For accumulated radiation data, the flux is computed as: - - F(t) = (E_acc(t) - E_acc(t-1)) / Δt - -where E_acc is the accumulated radiation at each timestep. - -Notes on accumulated radiation in reanalysis products: -- ERA5 and similar reanalysis datasets provide radiation as accumulated values -- Accumulations are typically reset at the start of each forecast -- The time interval Δt should be computed from the actual time coordinates -- First timestep flux cannot be computed (requires previous accumulation) - -References: -1. ECMWF IFS Documentation - Part IV: Physical Processes -2. ERA5 documentation: https://confluence.ecmwf.int/display/CKB/ERA5+documentation -""" - -import argparse -import numpy as np -import netCDF4 -from datetime import datetime, timedelta - - -def accumulated_to_flux(accumulated, time_seconds): - """ - Convert accumulated radiation to radiative flux. - - Parameters - ---------- - accumulated : array - Accumulated radiation values [J/m²] - Shape: (time, ...) - time_seconds : array - Time coordinates in seconds since reference - Shape: (time,) - - Returns - ------- - flux : array - Radiative flux [W/m²] - Shape: (time, ...) - First timestep will be NaN (cannot compute without previous value) - - Notes - ----- - The flux at timestep t is computed as: - flux[t] = (accumulated[t] - accumulated[t-1]) / (time[t] - time[t-1]) - - The first timestep will contain NaN values since there is no previous - accumulation to compute the difference from. - - Examples - -------- - >>> # Example with hourly data (3600 seconds interval) - >>> accumulated = np.array([3600000, 7200000, 10800000]) # J/m² - >>> time = np.array([0, 3600, 7200]) # seconds - >>> flux = accumulated_to_flux(accumulated, time) - >>> print(flux) - [nan 1000. 1000.] # W/m² - """ - # Initialize flux array with same shape as accumulated - flux = np.zeros_like(accumulated, dtype=np.float32) - flux[0, ...] = np.nan # First timestep cannot be computed - - # Get the number of timesteps - n_time = accumulated.shape[0] - - # Compute flux for each timestep - for t in range(1, n_time): - # Calculate time difference in seconds - dt = time_seconds[t] - time_seconds[t - 1] - - if dt <= 0: - raise ValueError(f"Non-positive time difference at timestep {t}: dt={dt}") - - # Calculate energy difference - de = accumulated[t, ...] - accumulated[t - 1, ...] - - # Handle potential negative values (can occur at accumulation resets) - # If accumulated value decreases, it indicates a reset in accumulation - # In this case, use the current accumulated value as the energy difference - de = np.where(de < 0, accumulated[t, ...], de) - - # Compute flux: W/m² = J/m² / s - flux[t, ...] = de / dt - - return flux - - -def add_radiation_flux_to_netcdf( - filename, - thermal_var="strd", - solar_var="ssrd", - time_var="time", - thermal_flux_name="flds", - solar_flux_name="fsds" -): - """ - Read accumulated radiation from a netCDF file, - calculate radiative fluxes, and write them back to the file. - - Parameters: - ----------- - filename : str - Path to the netCDF file - thermal_var : str, optional - Name of the thermal radiation variable (default: 'strd') - Surface thermal radiation downwards (accumulated) - solar_var : str, optional - Name of the solar radiation variable (default: 'ssrd') - Surface solar radiation downwards (accumulated) - time_var : str, optional - Name of the time variable (default: 'time') - thermal_flux_name : str, optional - Name for the output thermal flux variable (default: 'flds') - Downward longwave radiation at surface - solar_flux_name : str, optional - Name for the output solar flux variable (default: 'fsds') - Downward shortwave radiation at surface - - Returns: - -------- - None - Modifies the netCDF file in place by adding flux variables - - Raises: - ------- - ValueError - If flux variables already exist in the file - KeyError - If required variables are not found - """ - - # Open netCDF file in append mode - print(f"Opening {filename}...") - nc = netCDF4.Dataset(filename, "a") - - try: - # Check if flux variables already exist - if thermal_flux_name in nc.variables: - nc.close() - raise ValueError( - f"Variable '{thermal_flux_name}' already exists in {filename}. " - "No changes made. Delete the variable first if you want to recalculate." - ) - if solar_flux_name in nc.variables: - nc.close() - raise ValueError( - f"Variable '{solar_flux_name}' already exists in {filename}. " - "No changes made. Delete the variable first if you want to recalculate." - ) - - # Read the required variables - print(f"Reading {thermal_var}, {solar_var}, and {time_var}...") - strd = nc.variables[thermal_var][:] # Accumulated thermal radiation [J/m²] - ssrd = nc.variables[solar_var][:] # Accumulated solar radiation [J/m²] - time_var_obj = nc.variables[time_var] - time_values = time_var_obj[:] - - print(f"Data shapes - {thermal_var}: {strd.shape}, {solar_var}: {ssrd.shape}") - print(f"Time dimension size: {len(time_values)}") - - # Convert time to seconds since first timestep - # Handle different time units - time_units = time_var_obj.units - time_calendar = getattr(time_var_obj, 'calendar', 'standard') - - print(f"Time units: {time_units}") - print(f"Time calendar: {time_calendar}") - - # Use netCDF4's num2date to convert time values to datetime objects - time_dates = netCDF4.num2date(time_values, units=time_units, calendar=time_calendar) - - # Convert to seconds since first timestep - time_seconds = np.array([ - (date - time_dates[0]).total_seconds() for date in time_dates - ]) - - print(f"Time range: {time_dates[0]} to {time_dates[-1]}") - print(f"Time step (first interval): {time_seconds[1] - time_seconds[0]:.0f} seconds") - - # Calculate fluxes - print("Calculating thermal radiation flux (longwave downward)...") - flds = accumulated_to_flux(strd, time_seconds) - - print("Calculating solar radiation flux (shortwave downward)...") - fsds = accumulated_to_flux(ssrd, time_seconds) - - # Create the thermal flux variable - print(f"Creating new variable '{thermal_flux_name}'...") - dim_names = nc.variables[thermal_var].dimensions - flds_var = nc.createVariable(thermal_flux_name, "f4", dim_names, zlib=True, complevel=4) - - # Add attributes for thermal flux - flds_var.units = "W m-2" - flds_var.long_name = "Downward longwave radiation at surface" - flds_var.standard_name = "surface_downwelling_longwave_flux_in_air" - flds_var.description = ( - f"Calculated from accumulated {thermal_var} by differencing consecutive " - "timesteps and dividing by the time interval" - ) - flds_var.missing_value = np.nan - flds_var[:] = flds - - print(f"Variable '{thermal_flux_name}' created and written successfully!") - - # Create the solar flux variable - print(f"Creating new variable '{solar_flux_name}'...") - dim_names = nc.variables[solar_var].dimensions - fsds_var = nc.createVariable(solar_flux_name, "f4", dim_names, zlib=True, complevel=4) - - # Add attributes for solar flux - fsds_var.units = "W m-2" - fsds_var.long_name = "Downward shortwave radiation at surface" - fsds_var.standard_name = "surface_downwelling_shortwave_flux_in_air" - fsds_var.description = ( - f"Calculated from accumulated {solar_var} by differencing consecutive " - "timesteps and dividing by the time interval" - ) - fsds_var.missing_value = np.nan - fsds_var[:] = fsds - - print(f"Variable '{solar_flux_name}' created and written successfully!") - - # Print some statistics (excluding first timestep with NaN) - print("\nStatistics:") - print(f"\nThermal radiation flux ({thermal_flux_name}):") - print(f" Range: {np.nanmin(flds):.2f} to {np.nanmax(flds):.2f} W/m²") - print(f" Mean: {np.nanmean(flds):.2f} W/m²") - print(f" Std: {np.nanstd(flds):.2f} W/m²") - - print(f"\nSolar radiation flux ({solar_flux_name}):") - print(f" Range: {np.nanmin(fsds):.2f} to {np.nanmax(fsds):.2f} W/m²") - print(f" Mean: {np.nanmean(fsds):.2f} W/m²") - print(f" Std: {np.nanstd(fsds):.2f} W/m²") - - # Check for negative values (excluding NaN) - n_negative_thermal = np.sum(flds[~np.isnan(flds)] < 0) - n_negative_solar = np.sum(fsds[~np.isnan(fsds)] < 0) - - if n_negative_thermal > 0: - print(f"\nWarning: {n_negative_thermal} negative values in thermal flux") - if n_negative_solar > 0: - print(f"\nWarning: {n_negative_solar} negative values in solar flux") - - print(f"\nNote: First timestep contains NaN values (no previous accumulation)") - - except KeyError as e: - print(f"Error: Required variable not found in netCDF file: {e}") - print(f"Available variables: {list(nc.variables.keys())}") - nc.close() - raise - - except ValueError as e: - print(f"Error: {e}") - raise - - except Exception as e: - print(f"Unexpected error occurred: {e}") - nc.close() - raise - - else: - # Only executes if no exception was raised - nc.close() - print(f"\nFile {filename} closed successfully.") - - -if __name__ == "__main__": - # Set up argument parser - parser = argparse.ArgumentParser( - description="Add radiative flux variables to a netCDF file based on accumulated radiation." - ) - parser.add_argument( - "filename", - type=str, - help="Path to the netCDF file containing accumulated radiation variables" - ) - parser.add_argument( - "--thermal-var", - type=str, - default="strd", - help="Name of the accumulated thermal radiation variable (default: strd)" - ) - parser.add_argument( - "--solar-var", - type=str, - default="ssrd", - help="Name of the accumulated solar radiation variable (default: ssrd)" - ) - parser.add_argument( - "--time-var", - type=str, - default="time", - help="Name of the time variable (default: time)" - ) - parser.add_argument( - "--thermal-flux-name", - type=str, - default="flds", - help="Name for the output thermal flux variable (default: flds)" - ) - parser.add_argument( - "--solar-flux-name", - type=str, - default="fsds", - help="Name for the output solar flux variable (default: fsds)" - ) - - # Parse command-line arguments - args = parser.parse_args() - - # Process the file - add_radiation_flux_to_netcdf( - args.filename, - thermal_var=args.thermal_var, - solar_var=args.solar_var, - time_var=args.time_var, - thermal_flux_name=args.thermal_flux_name, - solar_flux_name=args.solar_flux_name - ) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index e140c4a..b7f8df4 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -149,15 +149,18 @@ def distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h): return tp_6h -def distribute_daily_radiation_to_6hourly(ds_daily, var_name, forecast_periods_6h): +def distribute_daily_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): """ - Distribute daily radiation to 6-hourly intervals. + Distribute daily radiation to 6-hourly intervals and convert to flux. For radiation (strd, ssrd): - Zero in first 6-hour interval (night: 00-06) - Half the daily value in middle two intervals (day: 06-12, 12-18) - Zero in last 6-hour interval (night: 18-24) + The daily accumulated radiation (J/m²) is converted to flux (W/m²) by + dividing by the time interval (6 hours = 21600 seconds). + Parameters ---------- ds_daily : xarray.Dataset @@ -170,7 +173,7 @@ def distribute_daily_radiation_to_6hourly(ds_daily, var_name, forecast_periods_6 Returns ------- np.ndarray - Radiation variable at 6-hourly resolution + Radiation flux (W/m²) at 6-hourly resolution """ rad_daily = ds_daily[var_name] daily_periods_raw = ds_daily["forecast_period"].values @@ -186,8 +189,11 @@ def distribute_daily_radiation_to_6hourly(ds_daily, var_name, forecast_periods_6 n_lon = rad_daily.sizes.get("longitude", 1) n_6h = len(forecast_periods_6h) + # Time interval in seconds (6 hours) + dt_seconds = 6 * 3600 # 21600 seconds + # Create output array - rad_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + flux_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) # For each daily period, distribute to 4 6-hourly periods for i, daily_period_hours in enumerate(daily_periods_hours): @@ -209,19 +215,24 @@ def distribute_daily_radiation_to_6hourly(ds_daily, var_name, forecast_periods_6 ) continue - # Daily value + # Daily accumulated value (J/m²) daily_value = rad_daily.isel(forecast_period=i).values + # Convert to flux (W/m²): energy per 6-hour interval / time in seconds + # Half the daily energy goes to each of the two "day" intervals + # Flux = (daily_value / 2) / dt_seconds + flux_value = (daily_value / 2.0) / dt_seconds + # First interval (night): 0 - rad_6h[:, :, indices_6h[0], :, :] = 0.0 - # Second interval (day): half - rad_6h[:, :, indices_6h[1], :, :] = daily_value / 2.0 - # Third interval (day): half - rad_6h[:, :, indices_6h[2], :, :] = daily_value / 2.0 + flux_6h[:, :, indices_6h[0], :, :] = 0.0 + # Second interval (day): flux + flux_6h[:, :, indices_6h[1], :, :] = flux_value + # Third interval (day): flux + flux_6h[:, :, indices_6h[2], :, :] = flux_value # Fourth interval (night): 0 - rad_6h[:, :, indices_6h[3], :, :] = 0.0 + flux_6h[:, :, indices_6h[3], :, :] = 0.0 - return rad_6h + return flux_6h def main(): @@ -298,14 +309,14 @@ def main(): print("Distributing daily precipitation to 6-hourly...") tp_6h = distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h) - # 3. Distribute daily radiation to 6-hourly - print("Distributing daily strd (thermal radiation) to 6-hourly...") - strd_6h = distribute_daily_radiation_to_6hourly( + # 3. Distribute daily radiation to 6-hourly and convert to flux (W/m²) + print("Distributing daily strd (thermal radiation) to 6-hourly flux (flds)...") + flds_6h = distribute_daily_radiation_to_6hourly_flux( ds_daily, "strd", forecast_periods_6h ) - print("Distributing daily ssrd (solar radiation) to 6-hourly...") - ssrd_6h = distribute_daily_radiation_to_6hourly( + print("Distributing daily ssrd (solar radiation) to 6-hourly flux (fsds)...") + fsds_6h = distribute_daily_radiation_to_6hourly_flux( ds_daily, "ssrd", forecast_periods_6h ) @@ -341,9 +352,9 @@ def main(): attrs=ds_daily["tp"].attrs, ) - # Add strd variable - ds_out["strd"] = xr.DataArray( - data=strd_6h, + # Add flds variable (thermal radiation flux, converted from strd) + ds_out["flds"] = xr.DataArray( + data=flds_6h, dims=[ "number", "forecast_reference_time", @@ -351,12 +362,17 @@ def main(): "latitude", "longitude", ], - attrs=ds_daily["strd"].attrs, + attrs={ + "units": "W m-2", + "long_name": "Downward longwave radiation at surface", + "standard_name": "surface_downwelling_longwave_flux_in_air", + "description": "Converted from daily accumulated strd by distributing to 6-hourly intervals and dividing by time", + }, ) - # Add ssrd variable - ds_out["ssrd"] = xr.DataArray( - data=ssrd_6h, + # Add fsds variable (solar radiation flux, converted from ssrd) + ds_out["fsds"] = xr.DataArray( + data=fsds_6h, dims=[ "number", "forecast_reference_time", @@ -364,7 +380,12 @@ def main(): "latitude", "longitude", ], - attrs=ds_daily["ssrd"].attrs, + attrs={ + "units": "W m-2", + "long_name": "Downward shortwave radiation at surface", + "standard_name": "surface_downwelling_shortwave_flux_in_air", + "description": "Converted from daily accumulated ssrd by distributing to 6-hourly intervals and dividing by time", + }, ) # Write output (unless dry-run) @@ -394,12 +415,12 @@ def main(): print(f" z: constant orography, broadcast to {n_timesteps} time steps") print(" tp: daily precipitation divided by 4 for each 6-hourly interval") print( - " strd: daily thermal radiation - " - + "0 at night (first/last), half at day (middle)" + " flds: thermal radiation flux (W/m²) - " + + "0 at night, flux at day (converted from strd)" ) print( - " ssrd: daily solar radiation - " - + "0 at night (first/last), half at day (middle)" + " fsds: solar radiation flux (W/m²) - " + + "0 at night, flux at day (converted from ssrd)" ) From d57d5c2218a21aaea85952470e0a1f9925863ac7 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:08:44 +0100 Subject: [PATCH 59/93] SEAS5: remapping script prepare_SEAS5_input.sh --- mkforcing/prepare_SEAS5_input.sh | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100755 mkforcing/prepare_SEAS5_input.sh diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh new file mode 100755 index 0000000..9003555 --- /dev/null +++ b/mkforcing/prepare_SEAS5_input.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -eo pipefail + +# default values of parameters +lrmp=true +lmerge=true +lwgtdis=false # Switch for creating wgtdis file in script +lgriddes=false # Switch for creating griddes file in script +ompthd=1 + +# TSMP2/eclm +pathdata=./ +domainfile=../domain.lnd.DE-RuS_DE-RuS.250926.nc +wgtcaf=/p/scratch/cslts/poll1/sim/euro-cordex/tsmp2_wfe_eur-11u/dta/rmp_gridwgts/wgtdis_era5caf_to_eur11u-189976.nc +# wgtmeteo=/p/scratch/cslts/poll1/sim/euro-cordex/tsmp2_wfe_eur-11u/dta/rmp_gridwgts/wgtdis_era5meteo_to_eur11u-189976.nc +griddesfile=/p/scratch/cslts/poll1/sim/euro-cordex/tsmp2_wfe_eur-11u/dta/rmp_gridwgts/griddes_eur-11u_189976.txt + +iyear=2017 +imonth=07 +tmpdir=tmpdir +wrkdir="" +author="Default AUTHOR" +email="d.fault@fz-juelich.de" + +# Function to parse input +parse_arguments() { + for arg in "$@"; do + key="${arg%%=*}" + value="${arg#*=}" + + case "$key" in + lrmp) lrmp="$value" ;; + lmerge) lmerge="$value" ;; + lwgtdis) lwgtdis="$value" ;; + lgriddes) lgriddes="$value" ;; + ompthd) ompthd="$value" ;; + pathdata) pathdata="$value" ;; + wgtcaf) wgtcaf="$value" ;; + # wgtmeteo) wgtmeteo="$value" ;; + griddesfile) griddesfile="$value" ;; + tmpdir) tmpdir="$value" ;; + wrkdir) wrkdir="$value" ;; + imonth) imonth="$value" ;; + iyear) iyear="$value" ;; + author) author="$value" ;; + email) email="$value" ;; + *) echo "Warning: Unknown parameter: $key" ;; + esac + done +} + +# Call the function to parse the input arguments +# Users needs to make sure for consistent input +parse_arguments "$@" + +# +#cd $wrkdir +#mkdir -pv $tmpdir + +# +for year in ${iyear} +do +for month in ${imonth} +do + + # Go into working directory and create temporary directory + if [ -z ${wrkdir} ];then + wrkdir=${iyear}-${imonth} + fi + cd $wrkdir + mkdir -pv $tmpdir + + if $lrmp; then + + # Copy netCDF file + cp ${pathdata}/download_era5_${year}_${month}.nc ${tmpdir} + + # Extract the first ensemble member + ncks --overwrite -d number,0 -O ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc + # Remove number and forecast_reference_time dimensions + ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc + + # Renaming variable 'valid_time' to 'time' in $file + ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}.nc + + if $lwgtdis; then + cdo gendis,${domainfile} ${tmpdir}/download_era5_${year}_${month}.nc ${wgtcaf} + fi + + if $lgriddes; then + cdo griddes ${domainfile} > ${griddesfile} + fi + + cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/rmp_era5_${year}_${month}.nc + fi + + if $lmerge; then + + cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 + cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_const.nc + ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp2.nc + + cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ + ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp4.nc + + ncks -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc + + # Simply copy the file + cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + + # Rename variables + ncrename -v sp,PSRF -v fsds,FSDS -v flds,FLDS -v tp,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${year}-${month}.nc + +# ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc + ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc + + ncks -O -h --glb author="${author}" ${year}-${month}.nc + ncks -O -h --glb contact="${email}" ${year}-${month}.nc + + rm ${tmpdir}/${year}_${month}_temp*nc ${tmpdir}/${year}_${month}_const.nc + fi + +done +done + From ab1f2948e42ac45ef24e93354fa7bff3d8d99950 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:34:00 +0100 Subject: [PATCH 60/93] docs: SEAS5 updates --- docs/users_guide/seas5-forcing.md | 35 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/users_guide/seas5-forcing.md b/docs/users_guide/seas5-forcing.md index e093bcb..0f9449e 100644 --- a/docs/users_guide/seas5-forcing.md +++ b/docs/users_guide/seas5-forcing.md @@ -7,6 +7,23 @@ Start by sourcing the provided environment file source jsc.2024_Intel.sh ``` +Provide a Python virtual environment including all necessary modules, +in particular `cdsapi` for downloading. It is important to load or +activate the pyvenv after sourcing the environment file, since the +environment file loads Python modules itself. + +First usage - creation of pyvenv: +``` + python -m venv python_cdsapi + source python_cdsapi/bin/activate + pip install cdsapi +``` + +Existing pyvenv - activating the pyvenv: +``` + source python_cdsapi/bin/activate +``` + ## Creation of forcing data from SEAS5 Creation of SEAS5 forcing files is adapted from the creation of ERA5 @@ -15,6 +32,10 @@ forcing files. The folder `mkforcing/` contains the scripts that assist the SEAS5 retrieval. +``` + cd mkforcing +``` + ### Download of SEAS5 data `download_ERA5_input.py` contains a prepared retrieval for the cdsapi python module. @@ -43,7 +64,7 @@ currently NOT SUPPORTED for SEAS5 download. ### Preparation of SEAS5 data: all variable to 06h -First, we want to have all variables in 6-hourly interval +Next, we want to have all variables available in 6-hourly interval - from constant: `z` has to be ported (same values as before) - from daily: `strd`, `ssrd` (thermal, solar, each accumulated) and @@ -58,7 +79,7 @@ The script outputs radiation directly as flux variables (`flds`, `fsds`) in W/m². ```bash -python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc + python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc ``` ### Preparation of SEAS5 data: correct input variables @@ -69,25 +90,25 @@ Steps for preparing SEAS5 data as eCLM input data file) ```bash -python orography_to_elevation.py cdsapidwn_SEAS5/download_era5_2026_01.nc + python orography_to_elevation.py cdsapidwn_SEAS5/download_era5_2026_01.nc ``` 2. Mean sea level pressure to surface pressure ```bash -python mslp_to_sp.py cdsapidwn_SEAS5/download_era5_2026_01.nc --elevation-var elevation + python mslp_to_sp.py cdsapidwn_SEAS5/download_era5_2026_01.nc --elevation-var elevation ``` 3. Humidity computed from dewpoint temperature and surface pressure ``` -python dewpoint_to_specific_humidity.py cdsapidwn_SEAS5/download_era5_2026_01.nc + python dewpoint_to_specific_humidity.py cdsapidwn_SEAS5/download_era5_2026_01.nc ``` 4. Temperature and Specific Humidity converted from 2m to 10m ``` -python 2m_to_10m_conversion.py cdsapidwn_SEAS5/download_era5_2026_01.nc + python 2m_to_10m_conversion.py cdsapidwn_SEAS5/download_era5_2026_01.nc ``` ### Preparation of SEAS5 data: Remapping, Data merging, CLM3.5 @@ -96,5 +117,5 @@ python 2m_to_10m_conversion.py cdsapidwn_SEAS5/download_era5_2026_01.nc Check inputs and replace according to your case. ``` -sh prepare_SEAS5_input.sh lwgtdis=true lgriddes=true wgtcaf=../wgtdis_era5caf_to_DE-RuS-$(date +%y%m%d).nc griddesfile=../griddes_DE-RuS_$(date +%y%m%d).txt iyear=2026 imonth=01 author="Johannes KELLER" email="jo.keller@fz-juelich.de" tmpdir=tmpdir pathdata=../cdsapidwn_SEAS5 + sh prepare_SEAS5_input.sh lwgtdis=true lgriddes=true wgtcaf=../wgtdis_era5caf_to_DE-RuS-$(date +%y%m%d).nc griddesfile=../griddes_DE-RuS_$(date +%y%m%d).txt iyear=2026 imonth=01 author="Johannes KELLER" email="jo.keller@fz-juelich.de" tmpdir=tmpdir pathdata=../cdsapidwn_SEAS5 ``` From b13b27c77645377c39fba16bf263e092d16546d8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:34:12 +0100 Subject: [PATCH 61/93] mkforcing/seas5_daily_to_6hourly: output directory check Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index b7f8df4..c38453f 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -17,6 +17,9 @@ """ import argparse +import os +import sys + import numpy as np import xarray as xr @@ -267,6 +270,13 @@ def main(): output_file = args.output if args.output else args.hourly in_place = output_file == args.hourly + # Check if output directory exists + output_dir = os.path.dirname(output_file) + if output_dir and not os.path.exists(output_dir): + print(f"Error: Output directory does not exist: {output_dir}") + print(f"Please create it with: mkdir -p {output_dir}") + sys.exit(1) + if in_place: print(f"Note: Modifying {args.hourly} in place") From 3c17be9b0f8f9e6512b325ae6e82915ca7cbaaef Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:51:34 +0100 Subject: [PATCH 62/93] mkforcing/seas5_daily_to_6hourly: preserve original encodings Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 50 +++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index c38453f..8cad3f3 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -406,10 +406,54 @@ def main(): else: print(f"Writing output to: {output_file}") - # Use encoding to ensure proper compression and data types + # Build encoding dict preserving original encodings where possible encoding = {} - for var in ds_out.data_vars: - encoding[var] = {"zlib": True, "complevel": 4} + + # Preserve encoding for all coordinates from the 6-hourly file + for coord in ds_6h.coords: + if ds_6h[coord].encoding: + encoding[coord] = { + k: v + for k, v in ds_6h[coord].encoding.items() + if k not in ("source", "original_shape") + } + + # Preserve encoding for existing data variables from the 6-hourly file + for var in ds_6h.data_vars: + if var in ds_out.data_vars and ds_6h[var].encoding: + encoding[var] = { + k: v + for k, v in ds_6h[var].encoding.items() + if k not in ("source", "original_shape") + } + # Ensure compression is enabled + encoding[var].setdefault("zlib", True) + encoding[var].setdefault("complevel", 4) + + # Preserve encoding for z from constant file + if "z" in ds_out.data_vars and ds_const["z"].encoding: + encoding["z"] = { + k: v + for k, v in ds_const["z"].encoding.items() + if k not in ("source", "original_shape") + } + encoding["z"].setdefault("zlib", True) + encoding["z"].setdefault("complevel", 4) + + # Preserve encoding for tp from daily file + if "tp" in ds_out.data_vars and ds_daily["tp"].encoding: + encoding["tp"] = { + k: v + for k, v in ds_daily["tp"].encoding.items() + if k not in ("source", "original_shape") + } + encoding["tp"].setdefault("zlib", True) + encoding["tp"].setdefault("complevel", 4) + + # For flds/fsds (derived from strd/ssrd), use compression with float32 + for var in ("flds", "fsds"): + if var in ds_out.data_vars and var not in encoding: + encoding[var] = {"zlib": True, "complevel": 4, "dtype": "float32"} ds_out.to_netcdf(output_file, encoding=encoding) From e754ff464423bfa1501be8afa1de0ce2dc0aff5d Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 18 Jan 2026 13:54:28 +0100 Subject: [PATCH 63/93] mkforcing/seas5_daily_to_6hourly: filter valid netCDF4 encodings Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 8cad3f3..037a5da 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -409,44 +409,48 @@ def main(): # Build encoding dict preserving original encodings where possible encoding = {} + # Valid encoding parameters for netCDF4 backend + valid_encodings = { + "compression", + "dtype", + "least_significant_digit", + "zlib", + "_FillValue", + "fletcher32", + "complevel", + "chunksizes", + "shuffle", + "contiguous", + "calendar", + "units", + } + + def filter_encoding(enc): + """Filter encoding dict to only valid netCDF4 parameters.""" + return {k: v for k, v in enc.items() if k in valid_encodings} + # Preserve encoding for all coordinates from the 6-hourly file for coord in ds_6h.coords: if ds_6h[coord].encoding: - encoding[coord] = { - k: v - for k, v in ds_6h[coord].encoding.items() - if k not in ("source", "original_shape") - } + encoding[coord] = filter_encoding(ds_6h[coord].encoding) # Preserve encoding for existing data variables from the 6-hourly file for var in ds_6h.data_vars: if var in ds_out.data_vars and ds_6h[var].encoding: - encoding[var] = { - k: v - for k, v in ds_6h[var].encoding.items() - if k not in ("source", "original_shape") - } + encoding[var] = filter_encoding(ds_6h[var].encoding) # Ensure compression is enabled encoding[var].setdefault("zlib", True) encoding[var].setdefault("complevel", 4) # Preserve encoding for z from constant file if "z" in ds_out.data_vars and ds_const["z"].encoding: - encoding["z"] = { - k: v - for k, v in ds_const["z"].encoding.items() - if k not in ("source", "original_shape") - } + encoding["z"] = filter_encoding(ds_const["z"].encoding) encoding["z"].setdefault("zlib", True) encoding["z"].setdefault("complevel", 4) # Preserve encoding for tp from daily file if "tp" in ds_out.data_vars and ds_daily["tp"].encoding: - encoding["tp"] = { - k: v - for k, v in ds_daily["tp"].encoding.items() - if k not in ("source", "original_shape") - } + encoding["tp"] = filter_encoding(ds_daily["tp"].encoding) encoding["tp"].setdefault("zlib", True) encoding["tp"].setdefault("complevel", 4) From b20361773ae2b19c17b918581036f3aef7e249b1 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 10:24:15 +0100 Subject: [PATCH 64/93] mkforcing/prepare_SEAS5_input: remove dimension number only single ensemble member prepared --- mkforcing/prepare_SEAS5_input.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index 9003555..e40abbd 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -76,9 +76,11 @@ do cp ${pathdata}/download_era5_${year}_${month}.nc ${tmpdir} # Extract the first ensemble member + # TODO: Preparation of all 51 ensemble members ncks --overwrite -d number,0 -O ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc # Remove number and forecast_reference_time dimensions ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc + ncwa --overwrite -a number ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc # Renaming variable 'valid_time' to 'time' in $file ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}.nc From 3a6cebac6eae496dad9cd302d26f59cd38f4b4f4 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 11:04:35 +0100 Subject: [PATCH 65/93] mkforcing/prepare_SEAS5_input: time var/dim renaming Background: With this naming scheme the CDO remap command below will generate a time variable called "time" in "seconds since 1970-01-01" as for ERA5. With other names, either the variable-name or the unit were different (e.g. changing the units to "hours" and long_name to "time since forecast_reference_time"). --- mkforcing/prepare_SEAS5_input.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index e40abbd..08f1424 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -82,8 +82,12 @@ do ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc ncwa --overwrite -a number ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc - # Renaming variable 'valid_time' to 'time' in $file + # 1) Renaming variable 'valid_time' to 'time' in $file + # 2) Renaming dimension 'forecast_period' to 'valid_time' in $file + # Background: With this naming scheme the CDO remap command below will generate a time variable + # called "time" in "seconds since 1970-01-01" as for ERA5. ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}.nc + ncrename -d forecast_period,valid_time ${tmpdir}/download_era5_${year}_${month}.nc if $lwgtdis; then cdo gendis,${domainfile} ${tmpdir}/download_era5_${year}_${month}.nc ${wgtcaf} From 3861d3105380175d5b0738fc5a7beb3843a1b39f Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 14:10:25 +0100 Subject: [PATCH 66/93] mkforcing/seas5_daily_to_6hourly: thermal radiation conversion Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 101 +++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 037a5da..027233e 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -6,7 +6,10 @@ This script: 1. Adds constant `z` (orography) to the 6-hourly file, broadcast to all time steps 2. Converts daily `tp` (total precipitation) to 6-hourly by dividing by 4 -3. Converts daily `strd` and `ssrd` (radiation) to 6-hourly: +3. Converts daily `strd` (thermal radiation) to 6-hourly: + - Divided by 4 and distributed equally to all four 6-hour intervals + - Thermal radiation occurs continuously, day and night +4. Converts daily `ssrd` (solar radiation) to 6-hourly: - Zero in first/last 6-hour intervals ("night") - Half the daily value in the middle two intervals ("day") @@ -152,11 +155,91 @@ def distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h): return tp_6h -def distribute_daily_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): +def distribute_daily_thermal_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): """ - Distribute daily radiation to 6-hourly intervals and convert to flux. + Distribute daily thermal radiation to 6-hourly intervals and convert to flux. - For radiation (strd, ssrd): + For thermal/longwave radiation (strd): + - Divide daily value by 4 and distribute evenly to all 4 intervals + - Thermal radiation occurs continuously, day and night + + The daily accumulated radiation (J/m²) is converted to flux (W/m²) by + dividing by the time interval (6 hours = 21600 seconds). + + Parameters + ---------- + ds_daily : xarray.Dataset + Dataset containing daily variables + var_name : str + Variable name (typically 'strd') + forecast_periods_6h : array-like + The 6-hourly forecast periods + + Returns + ------- + np.ndarray + Radiation flux (W/m²) at 6-hourly resolution + """ + rad_daily = ds_daily[var_name] + daily_periods_raw = ds_daily["forecast_period"].values + + # Convert to hours + forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) + daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + + # Get dimensions + n_number = rad_daily.sizes.get("number", rad_daily.shape[0]) + n_ref_time = rad_daily.sizes.get("forecast_reference_time", 1) + n_lat = rad_daily.sizes.get("latitude", 1) + n_lon = rad_daily.sizes.get("longitude", 1) + n_6h = len(forecast_periods_6h) + + # Time interval in seconds (6 hours) + dt_seconds = 6 * 3600 # 21600 seconds + + # Create output array + flux_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + + # For each daily period, distribute to 4 6-hourly periods + for i, daily_period_hours in enumerate(daily_periods_hours): + # Find the 4 6-hourly periods that belong to this day + start_hour = daily_period_hours - 24 + 6 + + # Get indices of the 4 6-hourly periods for this day + hours_6h = [start_hour + h * 6 for h in range(4)] + indices_6h = [] + for hour in hours_6h: + idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] + if len(idx) > 0: + indices_6h.append(idx[0]) + + if len(indices_6h) != 4: + print( + f"Warning: Expected 4 6-hourly indices for day {daily_period_hours}h, " + + f"got {len(indices_6h)}" + ) + continue + + # Daily accumulated value (J/m²) + daily_value = rad_daily.isel(forecast_period=i).values + + # Convert to flux (W/m²): energy per 6-hour interval / time in seconds + # Divide daily energy by 4 (one quarter per interval) + # Flux = (daily_value / 4) / dt_seconds + flux_value = (daily_value / 4.0) / dt_seconds + + # Distribute equally to all 4 intervals + for idx in indices_6h: + flux_6h[:, :, idx, :, :] = flux_value + + return flux_6h + + +def distribute_daily_solar_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): + """ + Distribute daily solar radiation to 6-hourly intervals and convert to flux. + + For solar/shortwave radiation (ssrd): - Zero in first 6-hour interval (night: 00-06) - Half the daily value in middle two intervals (day: 06-12, 12-18) - Zero in last 6-hour interval (night: 18-24) @@ -169,7 +252,7 @@ def distribute_daily_radiation_to_6hourly_flux(ds_daily, var_name, forecast_peri ds_daily : xarray.Dataset Dataset containing daily variables var_name : str - Variable name ('strd' or 'ssrd') + Variable name (typically 'ssrd') forecast_periods_6h : array-like The 6-hourly forecast periods @@ -321,12 +404,12 @@ def main(): # 3. Distribute daily radiation to 6-hourly and convert to flux (W/m²) print("Distributing daily strd (thermal radiation) to 6-hourly flux (flds)...") - flds_6h = distribute_daily_radiation_to_6hourly_flux( + flds_6h = distribute_daily_thermal_radiation_to_6hourly_flux( ds_daily, "strd", forecast_periods_6h ) print("Distributing daily ssrd (solar radiation) to 6-hourly flux (fsds)...") - fsds_6h = distribute_daily_radiation_to_6hourly_flux( + fsds_6h = distribute_daily_solar_radiation_to_6hourly_flux( ds_daily, "ssrd", forecast_periods_6h ) @@ -376,7 +459,7 @@ def main(): "units": "W m-2", "long_name": "Downward longwave radiation at surface", "standard_name": "surface_downwelling_longwave_flux_in_air", - "description": "Converted from daily accumulated strd by distributing to 6-hourly intervals and dividing by time", + "description": "Converted from daily accumulated strd by dividing by 4 and distributing equally to all 6-hourly intervals", }, ) @@ -474,7 +557,7 @@ def filter_encoding(enc): print(" tp: daily precipitation divided by 4 for each 6-hourly interval") print( " flds: thermal radiation flux (W/m²) - " - + "0 at night, flux at day (converted from strd)" + + "divided by 4, distributed equally to all intervals (converted from strd)" ) print( " fsds: solar radiation flux (W/m²) - " From 8f727be1e32a38b647e52d38401538183385f644 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 20:30:57 +0100 Subject: [PATCH 67/93] mkforcing/seas5_daily_to_6hourly: different frequencies Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 627 ++++++++++++++-------------- 1 file changed, 305 insertions(+), 322 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 027233e..f0a3bfd 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -1,22 +1,20 @@ #!/usr/bin/env python3 """ -Convert SEAS5 constant and daily variables to 6-hourly resolution and merge -with existing 6-hourly file. +Convert SEAS5 constant, daily, and 6-hourly variables to a target time resolution. This script: -1. Adds constant `z` (orography) to the 6-hourly file, broadcast to all time steps -2. Converts daily `tp` (total precipitation) to 6-hourly by dividing by 4 -3. Converts daily `strd` (thermal radiation) to 6-hourly: - - Divided by 4 and distributed equally to all four 6-hour intervals - - Thermal radiation occurs continuously, day and night -4. Converts daily `ssrd` (solar radiation) to 6-hourly: - - Zero in first/last 6-hour intervals ("night") - - Half the daily value in the middle two intervals ("day") +1. Adds constant `z` (orography) broadcast to all time steps +2. Expands 6-hourly variables (msl, u10, v10, t2m, d2m) to target frequency +3. Converts daily `tp` (total precipitation) by dividing equally across intervals +4. Converts daily `strd` (thermal radiation) to flux, distributed equally +5. Converts daily `ssrd` (solar radiation) to flux with bell-shaped diurnal cycle + (cosine distribution: zero at 6:00 and 18:00, peak at noon) Usage: - python seas5_daily_to_6hourly.py --const --daily --hourly <6h_file> [--output ] + python seas5_daily_to_6hourly.py --const --daily \\ + --hourly <6h_file> [--output ] [--frequency ] -If --output is not specified, the 6-hourly file will be modified in place. +If --output is not specified, the output file will be named based on frequency. """ import argparse @@ -59,112 +57,138 @@ def forecast_period_to_hours(forecast_period): return values.astype(float) -def expand_constant_to_6hourly(ds_const, n_timesteps): +def generate_target_forecast_periods(input_periods_hours, frequency_hours): """ - Expand constant variable z to 6-hourly resolution. + Generate target forecast periods at the desired frequency. + + Parameters + ---------- + input_periods_hours : np.ndarray + Input forecast periods in hours (e.g., [6, 12, 18, 24, 30, 36, 42, 48]) + frequency_hours : int + Target frequency in hours (e.g., 1 for hourly) + + Returns + ------- + np.ndarray + Target forecast periods in hours + """ + start_hour = frequency_hours # First period (e.g., 1 for hourly, 6 for 6-hourly) + end_hour = int(np.max(input_periods_hours)) + return np.arange(start_hour, end_hour + frequency_hours, frequency_hours, dtype=float) - The constant z has forecast_period=0 (single time), we need to broadcast - it to all 6-hourly time steps. + +def expand_6hourly_to_target(ds_6h, var_name, target_periods_hours, frequency_hours): + """ + Expand a 6-hourly variable to target frequency by repeating values. + + Each 6-hourly value is assigned to all sub-intervals within that 6-hour window. + For example, with hourly output, the value at hour 6 is assigned to hours 1-6. Parameters ---------- - ds_const : xarray.Dataset - Dataset containing constant variables (z) - n_timesteps : int - Number of 6-hourly time steps to expand to + ds_6h : xarray.Dataset + Dataset containing 6-hourly variables + var_name : str + Variable name to expand + target_periods_hours : np.ndarray + Target forecast periods in hours + frequency_hours : int + Target frequency in hours Returns ------- - xarray.DataArray - z variable expanded to n_timesteps + np.ndarray + Variable data at target frequency """ - z = ds_const["z"] - - # Remove the forecast_period dimension (size 1) and we'll broadcast later - z_squeezed = z.squeeze("forecast_period", drop=True) - - # Expand along a new forecast_period dimension - z_expanded = z_squeezed.expand_dims("forecast_period", axis=2) - z_expanded = z_expanded.broadcast_to( - number=z_squeezed.sizes.get("number", z_squeezed.shape[0]), - forecast_reference_time=z_squeezed.sizes.get("forecast_reference_time", 1), - forecast_period=n_timesteps, - latitude=z_squeezed.sizes.get("latitude", 1), - longitude=z_squeezed.sizes.get("longitude", 1), - ) + var_data = ds_6h[var_name] + input_periods_hours = forecast_period_to_hours(ds_6h["forecast_period"].values) + + # Get dimensions + n_number = var_data.sizes.get("number", var_data.shape[0]) + n_ref_time = var_data.sizes.get("forecast_reference_time", 1) + n_lat = var_data.sizes.get("latitude", 1) + n_lon = var_data.sizes.get("longitude", 1) + n_target = len(target_periods_hours) + + # Create output array + output = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) + + # For each target period, find the corresponding 6-hourly period + for i, target_hour in enumerate(target_periods_hours): + # Find the 6-hourly period that contains this target hour + # Hour 1-6 -> 6h period at hour 6, Hour 7-12 -> 6h period at hour 12, etc. + containing_6h = int(np.ceil(target_hour / 6.0) * 6) + idx_6h = np.where(np.isclose(input_periods_hours, containing_6h))[0] + if len(idx_6h) > 0: + output[:, :, i, :, :] = var_data.isel(forecast_period=idx_6h[0]).values - return z_expanded + return output -def distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h): +def distribute_daily_to_target(ds_daily, var_name, target_periods_hours, frequency_hours): """ - Distribute daily total precipitation to 6-hourly intervals. + Distribute daily accumulated variable to target frequency intervals. - Each daily value is divided by 4 and assigned to the four corresponding - 6-hour intervals. + Each daily value is divided equally among all intervals within that day. Parameters ---------- ds_daily : xarray.Dataset Dataset containing daily variables - forecast_periods_6h : array-like - The 6-hourly forecast periods (e.g., [6, 12, 18, 24, 30, 36, 42, 48] in hours) + var_name : str + Variable name (e.g., 'tp') + target_periods_hours : np.ndarray + Target forecast periods in hours + frequency_hours : int + Target frequency in hours Returns ------- - xarray.DataArray - tp variable at 6-hourly resolution + np.ndarray + Variable data at target frequency """ - tp_daily = ds_daily["tp"] - daily_periods_raw = ds_daily["forecast_period"].values - - # Convert to hours - forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) - daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + var_daily = ds_daily[var_name] + daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) # Get dimensions - n_number = tp_daily.sizes.get("number", tp_daily.shape[0]) - n_ref_time = tp_daily.sizes.get("forecast_reference_time", 1) - n_lat = tp_daily.sizes.get("latitude", 1) - n_lon = tp_daily.sizes.get("longitude", 1) - n_6h = len(forecast_periods_6h) + n_number = var_daily.sizes.get("number", var_daily.shape[0]) + n_ref_time = var_daily.sizes.get("forecast_reference_time", 1) + n_lat = var_daily.sizes.get("latitude", 1) + n_lon = var_daily.sizes.get("longitude", 1) + n_target = len(target_periods_hours) + + # Number of intervals per day + intervals_per_day = int(24 / frequency_hours) # Create output array - tp_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + output = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) - # For each daily period, distribute to 4 6-hourly periods + # For each daily period, distribute to target intervals for i, daily_period_hours in enumerate(daily_periods_hours): - # Find the 4 6-hourly periods that belong to this day - # Day ending at hour 24 -> 6h intervals at 6, 12, 18, 24 - # Day ending at hour 48 -> 6h intervals at 30, 36, 42, 48 - start_hour = daily_period_hours - 24 + 6 # first 6h period of this day + # Find target intervals belonging to this day + day_start = daily_period_hours - 24 + frequency_hours + day_end = daily_period_hours - # Get indices of the 4 6-hourly periods for this day - indices_6h = [] - for h in range(4): - hour = start_hour + h * 6 - idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] - if len(idx) > 0: - indices_6h.append(idx[0]) + indices = [] + for j, target_hour in enumerate(target_periods_hours): + if day_start <= target_hour <= day_end: + indices.append(j) - # Divide daily value by 4 and assign to each 6-hourly period - daily_value = tp_daily.isel(forecast_period=i).values - for idx in indices_6h: - tp_6h[:, :, idx, :, :] = daily_value / 4.0 + if len(indices) > 0: + # Divide daily value by number of intervals + daily_value = var_daily.isel(forecast_period=i).values + for idx in indices: + output[:, :, idx, :, :] = daily_value / len(indices) - return tp_6h + return output -def distribute_daily_thermal_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): +def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hours, frequency_hours): """ - Distribute daily thermal radiation to 6-hourly intervals and convert to flux. + Distribute daily thermal radiation to target frequency and convert to flux. - For thermal/longwave radiation (strd): - - Divide daily value by 4 and distribute evenly to all 4 intervals - - Thermal radiation occurs continuously, day and night - - The daily accumulated radiation (J/m²) is converted to flux (W/m²) by - dividing by the time interval (6 hours = 21600 seconds). + Thermal radiation is distributed evenly across all intervals (day and night). Parameters ---------- @@ -172,80 +196,64 @@ def distribute_daily_thermal_radiation_to_6hourly_flux(ds_daily, var_name, forec Dataset containing daily variables var_name : str Variable name (typically 'strd') - forecast_periods_6h : array-like - The 6-hourly forecast periods + target_periods_hours : np.ndarray + Target forecast periods in hours + frequency_hours : int + Target frequency in hours Returns ------- np.ndarray - Radiation flux (W/m²) at 6-hourly resolution + Radiation flux (W/m²) at target frequency """ rad_daily = ds_daily[var_name] - daily_periods_raw = ds_daily["forecast_period"].values - - # Convert to hours - forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) - daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) # Get dimensions n_number = rad_daily.sizes.get("number", rad_daily.shape[0]) n_ref_time = rad_daily.sizes.get("forecast_reference_time", 1) n_lat = rad_daily.sizes.get("latitude", 1) n_lon = rad_daily.sizes.get("longitude", 1) - n_6h = len(forecast_periods_6h) + n_target = len(target_periods_hours) - # Time interval in seconds (6 hours) - dt_seconds = 6 * 3600 # 21600 seconds + # Time interval in seconds + dt_seconds = frequency_hours * 3600 + + # Number of intervals per day + intervals_per_day = int(24 / frequency_hours) # Create output array - flux_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + flux = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) - # For each daily period, distribute to 4 6-hourly periods + # For each daily period, distribute to target intervals for i, daily_period_hours in enumerate(daily_periods_hours): - # Find the 4 6-hourly periods that belong to this day - start_hour = daily_period_hours - 24 + 6 - - # Get indices of the 4 6-hourly periods for this day - hours_6h = [start_hour + h * 6 for h in range(4)] - indices_6h = [] - for hour in hours_6h: - idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] - if len(idx) > 0: - indices_6h.append(idx[0]) - - if len(indices_6h) != 4: - print( - f"Warning: Expected 4 6-hourly indices for day {daily_period_hours}h, " - + f"got {len(indices_6h)}" - ) - continue + day_start = daily_period_hours - 24 + frequency_hours + day_end = daily_period_hours - # Daily accumulated value (J/m²) - daily_value = rad_daily.isel(forecast_period=i).values + indices = [] + for j, target_hour in enumerate(target_periods_hours): + if day_start <= target_hour <= day_end: + indices.append(j) - # Convert to flux (W/m²): energy per 6-hour interval / time in seconds - # Divide daily energy by 4 (one quarter per interval) - # Flux = (daily_value / 4) / dt_seconds - flux_value = (daily_value / 4.0) / dt_seconds + if len(indices) > 0: + # Daily accumulated value (J/m²) + daily_value = rad_daily.isel(forecast_period=i).values + # Flux = (daily_value / n_intervals) / dt_seconds + flux_value = (daily_value / len(indices)) / dt_seconds + for idx in indices: + flux[:, :, idx, :, :] = flux_value - # Distribute equally to all 4 intervals - for idx in indices_6h: - flux_6h[:, :, idx, :, :] = flux_value + return flux - return flux_6h - -def distribute_daily_solar_radiation_to_6hourly_flux(ds_daily, var_name, forecast_periods_6h): +def distribute_solar_radiation_to_flux(ds_daily, var_name, target_periods_hours, frequency_hours): """ - Distribute daily solar radiation to 6-hourly intervals and convert to flux. - - For solar/shortwave radiation (ssrd): - - Zero in first 6-hour interval (night: 00-06) - - Half the daily value in middle two intervals (day: 06-12, 12-18) - - Zero in last 6-hour interval (night: 18-24) + Distribute daily solar radiation with bell-shaped diurnal cycle. - The daily accumulated radiation (J/m²) is converted to flux (W/m²) by - dividing by the time interval (6 hours = 21600 seconds). + Uses a cosine distribution: + - Zero radiation between 18:00 and 06:00 (night) + - Bell-shaped curve between 06:00 and 18:00, peak at noon + - weight(h) = max(0, cos((h - 12) * π / 12)) Parameters ---------- @@ -253,77 +261,70 @@ def distribute_daily_solar_radiation_to_6hourly_flux(ds_daily, var_name, forecas Dataset containing daily variables var_name : str Variable name (typically 'ssrd') - forecast_periods_6h : array-like - The 6-hourly forecast periods + target_periods_hours : np.ndarray + Target forecast periods in hours + frequency_hours : int + Target frequency in hours Returns ------- np.ndarray - Radiation flux (W/m²) at 6-hourly resolution + Radiation flux (W/m²) at target frequency """ rad_daily = ds_daily[var_name] - daily_periods_raw = ds_daily["forecast_period"].values - - # Convert to hours - forecast_periods_6h_hours = forecast_period_to_hours(forecast_periods_6h) - daily_periods_hours = forecast_period_to_hours(daily_periods_raw) + daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) # Get dimensions n_number = rad_daily.sizes.get("number", rad_daily.shape[0]) n_ref_time = rad_daily.sizes.get("forecast_reference_time", 1) n_lat = rad_daily.sizes.get("latitude", 1) n_lon = rad_daily.sizes.get("longitude", 1) - n_6h = len(forecast_periods_6h) + n_target = len(target_periods_hours) - # Time interval in seconds (6 hours) - dt_seconds = 6 * 3600 # 21600 seconds + # Time interval in seconds + dt_seconds = frequency_hours * 3600 # Create output array - flux_6h = np.zeros((n_number, n_ref_time, n_6h, n_lat, n_lon), dtype=np.float32) + flux = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) - # For each daily period, distribute to 4 6-hourly periods + # For each daily period, distribute with bell-shaped weights for i, daily_period_hours in enumerate(daily_periods_hours): - # Find the 4 6-hourly periods that belong to this day - start_hour = daily_period_hours - 24 + 6 - - # Get indices of the 4 6-hourly periods for this day - hours_6h = [start_hour + h * 6 for h in range(4)] - indices_6h = [] - for hour in hours_6h: - idx = np.where(np.isclose(forecast_periods_6h_hours, hour))[0] - if len(idx) > 0: - indices_6h.append(idx[0]) - - if len(indices_6h) != 4: - print( - f"Warning: Expected 4 6-hourly indices for day {daily_period_hours}h, " - + f"got {len(indices_6h)}" - ) - continue - - # Daily accumulated value (J/m²) - daily_value = rad_daily.isel(forecast_period=i).values - - # Convert to flux (W/m²): energy per 6-hour interval / time in seconds - # Half the daily energy goes to each of the two "day" intervals - # Flux = (daily_value / 2) / dt_seconds - flux_value = (daily_value / 2.0) / dt_seconds - - # First interval (night): 0 - flux_6h[:, :, indices_6h[0], :, :] = 0.0 - # Second interval (day): flux - flux_6h[:, :, indices_6h[1], :, :] = flux_value - # Third interval (day): flux - flux_6h[:, :, indices_6h[2], :, :] = flux_value - # Fourth interval (night): 0 - flux_6h[:, :, indices_6h[3], :, :] = 0.0 - - return flux_6h + day_start = daily_period_hours - 24 + frequency_hours + day_end = daily_period_hours + + # Find indices and calculate weights for this day + indices = [] + weights = [] + for j, target_hour in enumerate(target_periods_hours): + if day_start <= target_hour <= day_end: + indices.append(j) + # Hour of day (0-24) - use center of interval + hour_of_day = ((target_hour - frequency_hours / 2) % 24) + # Bell-shaped weight: cosine centered at noon + # cos((h - 12) * π / 12) gives 0 at h=6 and h=18, 1 at h=12 + weight = max(0.0, np.cos((hour_of_day - 12) * np.pi / 12)) + weights.append(weight) + + if len(indices) > 0 and sum(weights) > 0: + # Normalize weights to sum to 1 + weights = np.array(weights) + weights = weights / weights.sum() + + # Daily accumulated value (J/m²) + daily_value = rad_daily.isel(forecast_period=i).values + + # Distribute according to weights and convert to flux + for idx, weight in zip(indices, weights): + # Energy for this interval = daily_value * weight + # Flux = energy / dt_seconds + flux[:, :, idx, :, :] = (daily_value * weight) / dt_seconds + + return flux def main(): parser = argparse.ArgumentParser( - description="Convert SEAS5 constant and daily variables to 6-hourly resolution" + description="Convert SEAS5 variables to target time resolution" ) parser.add_argument( "--const", required=True, help="Path to netCDF file with constant variables (z)" @@ -339,7 +340,14 @@ def main(): parser.add_argument( "--output", default=None, - help="Output file path (default: modify hourly file in place)", + help="Output file path (default: auto-generated based on frequency)", + ) + parser.add_argument( + "--frequency", + type=int, + default=6, + choices=[1, 2, 3, 6], + help="Target frequency in hours (default: 6). Must divide 24 evenly.", ) parser.add_argument( "--dry-run", @@ -349,9 +357,14 @@ def main(): args = parser.parse_args() - # Determine output file early - output_file = args.output if args.output else args.hourly - in_place = output_file == args.hourly + frequency_hours = args.frequency + + # Determine output file + if args.output: + output_file = args.output + else: + base = os.path.splitext(args.hourly)[0] + output_file = f"{base}_{frequency_hours}h.nc" # Check if output directory exists output_dir = os.path.dirname(output_file) @@ -360,11 +373,10 @@ def main(): print(f"Please create it with: mkdir -p {output_dir}") sys.exit(1) - if in_place: - print(f"Note: Modifying {args.hourly} in place") + print(f"Target frequency: {frequency_hours} hours") + print(f"Output file: {output_file}") - # Load datasets - use load() to ensure data is in memory - # This is important for in-place modification + # Load datasets print(f"Loading constant file: {args.const}") ds_const = xr.open_dataset(args.const).load() @@ -374,114 +386,114 @@ def main(): print(f"Loading 6-hourly file: {args.hourly}") ds_6h = xr.open_dataset(args.hourly).load() - # Get 6-hourly forecast periods - forecast_periods_6h = ds_6h["forecast_period"].values - n_timesteps = len(forecast_periods_6h) + # Get input periods and generate target periods + input_periods_hours = forecast_period_to_hours(ds_6h["forecast_period"].values) + target_periods_hours = generate_target_forecast_periods(input_periods_hours, frequency_hours) + n_target = len(target_periods_hours) - # Convert to hours for display - fp_6h_hours = forecast_period_to_hours(forecast_periods_6h) - fp_daily_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) + daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) - print(f"6-hourly forecast periods (hours): {fp_6h_hours}") - print(f"Daily forecast periods (hours): {fp_daily_hours}") + print(f"Input 6-hourly periods (hours): {input_periods_hours}") + print(f"Daily periods (hours): {daily_periods_hours}") + print(f"Target periods (hours): {target_periods_hours}") - # 1. Expand constant z to 6-hourly - print("Expanding constant z to 6-hourly resolution...") + # 1. Expand constant z + print("Expanding constant z...") z_const = ds_const["z"] - - # Squeeze out the singleton forecast_period dimension from constant z_squeezed = z_const.squeeze("forecast_period", drop=True) - - # Create z_6h by tiling the constant value - # Shape: (number, forecast_reference_time, forecast_period, latitude, longitude) z_data = np.tile( - z_squeezed.values[:, :, np.newaxis, :, :], (1, 1, n_timesteps, 1, 1) + z_squeezed.values[:, :, np.newaxis, :, :], (1, 1, n_target, 1, 1) ) - # 2. Distribute daily precipitation to 6-hourly - print("Distributing daily precipitation to 6-hourly...") - tp_6h = distribute_daily_precip_to_6hourly(ds_daily, forecast_periods_6h) + # 2. Expand 6-hourly variables + vars_6h = ["msl", "u10", "v10", "t2m", "d2m"] + expanded_vars = {} + for var in vars_6h: + if var in ds_6h.data_vars: + print(f"Expanding {var} to {frequency_hours}-hourly...") + expanded_vars[var] = expand_6hourly_to_target( + ds_6h, var, target_periods_hours, frequency_hours + ) - # 3. Distribute daily radiation to 6-hourly and convert to flux (W/m²) - print("Distributing daily strd (thermal radiation) to 6-hourly flux (flds)...") - flds_6h = distribute_daily_thermal_radiation_to_6hourly_flux( - ds_daily, "strd", forecast_periods_6h + # 3. Distribute daily precipitation + print("Distributing daily precipitation...") + tp_target = distribute_daily_to_target( + ds_daily, "tp", target_periods_hours, frequency_hours ) - print("Distributing daily ssrd (solar radiation) to 6-hourly flux (fsds)...") - fsds_6h = distribute_daily_solar_radiation_to_6hourly_flux( - ds_daily, "ssrd", forecast_periods_6h + # 4. Distribute thermal radiation (evenly) + print("Distributing thermal radiation (flds)...") + flds_target = distribute_thermal_radiation_to_flux( + ds_daily, "strd", target_periods_hours, frequency_hours ) - # Create new dataset with all variables - print("Creating merged dataset...") - - # Copy the 6-hourly dataset structure - ds_out = ds_6h.copy(deep=True) - - # Add z variable - ds_out["z"] = xr.DataArray( - data=z_data, - dims=[ - "number", - "forecast_reference_time", - "forecast_period", - "latitude", - "longitude", - ], - attrs=z_const.attrs, + # 5. Distribute solar radiation (bell-shaped) + print("Distributing solar radiation with bell-shaped diurnal cycle (fsds)...") + fsds_target = distribute_solar_radiation_to_flux( + ds_daily, "ssrd", target_periods_hours, frequency_hours ) - # Add tp variable - ds_out["tp"] = xr.DataArray( - data=tp_6h, - dims=[ - "number", - "forecast_reference_time", - "forecast_period", - "latitude", - "longitude", - ], - attrs=ds_daily["tp"].attrs, + # Create output dataset + print("Creating output dataset...") + + dims = ["number", "forecast_reference_time", "forecast_period", "latitude", "longitude"] + + # Start with coordinates + ds_out = xr.Dataset( + coords={ + "number": ds_6h["number"], + "forecast_reference_time": ds_6h["forecast_reference_time"], + "forecast_period": target_periods_hours, + "latitude": ds_6h["latitude"], + "longitude": ds_6h["longitude"], + } ) - # Add flds variable (thermal radiation flux, converted from strd) + # Copy coordinate attributes + ds_out["forecast_period"].attrs = { + "long_name": "time since forecast_reference_time", + "standard_name": "forecast_period", + "units": "hours", + } + + # Add z + ds_out["z"] = xr.DataArray(data=z_data, dims=dims, attrs=z_const.attrs) + + # Add expanded 6-hourly variables + for var, data in expanded_vars.items(): + ds_out[var] = xr.DataArray(data=data, dims=dims, attrs=ds_6h[var].attrs) + + # Add precipitation + ds_out["tp"] = xr.DataArray(data=tp_target, dims=dims, attrs=ds_daily["tp"].attrs) + + # Add radiation fluxes ds_out["flds"] = xr.DataArray( - data=flds_6h, - dims=[ - "number", - "forecast_reference_time", - "forecast_period", - "latitude", - "longitude", - ], + data=flds_target, + dims=dims, attrs={ "units": "W m-2", "long_name": "Downward longwave radiation at surface", "standard_name": "surface_downwelling_longwave_flux_in_air", - "description": "Converted from daily accumulated strd by dividing by 4 and distributing equally to all 6-hourly intervals", + "description": f"Converted from daily strd, distributed equally to {frequency_hours}-hourly intervals", }, ) - # Add fsds variable (solar radiation flux, converted from ssrd) ds_out["fsds"] = xr.DataArray( - data=fsds_6h, - dims=[ - "number", - "forecast_reference_time", - "forecast_period", - "latitude", - "longitude", - ], + data=fsds_target, + dims=dims, attrs={ "units": "W m-2", "long_name": "Downward shortwave radiation at surface", "standard_name": "surface_downwelling_shortwave_flux_in_air", - "description": "Converted from daily accumulated ssrd by distributing to 6-hourly intervals and dividing by time", + "description": f"Converted from daily ssrd with bell-shaped diurnal cycle (cosine, peak at noon)", }, ) - # Write output (unless dry-run) + # Copy global attributes + ds_out.attrs = ds_6h.attrs.copy() + ds_out.attrs["frequency"] = f"{frequency_hours} hours" + + # Write output if args.dry_run: print(f"\n[DRY RUN] Would write output to: {output_file}") print("[DRY RUN] Output dataset structure:") @@ -489,58 +501,32 @@ def main(): else: print(f"Writing output to: {output_file}") - # Build encoding dict preserving original encodings where possible - encoding = {} - - # Valid encoding parameters for netCDF4 backend + # Build encoding valid_encodings = { - "compression", - "dtype", - "least_significant_digit", - "zlib", - "_FillValue", - "fletcher32", - "complevel", - "chunksizes", - "shuffle", - "contiguous", - "calendar", - "units", + "compression", "dtype", "least_significant_digit", "zlib", + "_FillValue", "fletcher32", "complevel", "chunksizes", + "shuffle", "contiguous", "calendar", "units", } def filter_encoding(enc): - """Filter encoding dict to only valid netCDF4 parameters.""" return {k: v for k, v in enc.items() if k in valid_encodings} - # Preserve encoding for all coordinates from the 6-hourly file - for coord in ds_6h.coords: - if ds_6h[coord].encoding: - encoding[coord] = filter_encoding(ds_6h[coord].encoding) - - # Preserve encoding for existing data variables from the 6-hourly file - for var in ds_6h.data_vars: - if var in ds_out.data_vars and ds_6h[var].encoding: - encoding[var] = filter_encoding(ds_6h[var].encoding) - # Ensure compression is enabled - encoding[var].setdefault("zlib", True) - encoding[var].setdefault("complevel", 4) - - # Preserve encoding for z from constant file - if "z" in ds_out.data_vars and ds_const["z"].encoding: - encoding["z"] = filter_encoding(ds_const["z"].encoding) - encoding["z"].setdefault("zlib", True) - encoding["z"].setdefault("complevel", 4) - - # Preserve encoding for tp from daily file - if "tp" in ds_out.data_vars and ds_daily["tp"].encoding: - encoding["tp"] = filter_encoding(ds_daily["tp"].encoding) - encoding["tp"].setdefault("zlib", True) - encoding["tp"].setdefault("complevel", 4) - - # For flds/fsds (derived from strd/ssrd), use compression with float32 - for var in ("flds", "fsds"): - if var in ds_out.data_vars and var not in encoding: - encoding[var] = {"zlib": True, "complevel": 4, "dtype": "float32"} + encoding = {} + + # Default encoding for all data variables + for var in ds_out.data_vars: + encoding[var] = {"zlib": True, "complevel": 4, "dtype": "float32"} + + # Preserve encoding from source files where applicable + for var in vars_6h: + if var in ds_6h.data_vars and ds_6h[var].encoding: + encoding[var].update(filter_encoding(ds_6h[var].encoding)) + + if ds_const["z"].encoding: + encoding["z"].update(filter_encoding(ds_const["z"].encoding)) + + if ds_daily["tp"].encoding: + encoding["tp"].update(filter_encoding(ds_daily["tp"].encoding)) ds_out.to_netcdf(output_file, encoding=encoding) @@ -552,17 +538,14 @@ def filter_encoding(enc): print("Done!") # Print summary - print("\nSummary of added variables:") - print(f" z: constant orography, broadcast to {n_timesteps} time steps") - print(" tp: daily precipitation divided by 4 for each 6-hourly interval") - print( - " flds: thermal radiation flux (W/m²) - " - + "divided by 4, distributed equally to all intervals (converted from strd)" - ) - print( - " fsds: solar radiation flux (W/m²) - " - + "0 at night, flux at day (converted from ssrd)" - ) + print(f"\nSummary (target frequency: {frequency_hours} hours):") + print(f" z: constant orography, broadcast to {n_target} time steps") + for var in vars_6h: + if var in expanded_vars: + print(f" {var}: expanded from 6-hourly by value repetition") + print(f" tp: daily precipitation distributed equally") + print(f" flds: thermal radiation flux - distributed equally (day and night)") + print(f" fsds: solar radiation flux - bell-shaped (cosine, 6-18h, peak at noon)") if __name__ == "__main__": From 22b0722912d1bef8c59c7584e6c6a45e1899d3f0 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 20:43:01 +0100 Subject: [PATCH 68/93] mkforcing/seas5_daily_to_6hourly: add `valid_time` Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- docs/users_guide/seas5-forcing.md | 2 +- mkforcing/seas5_daily_to_6hourly.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/users_guide/seas5-forcing.md b/docs/users_guide/seas5-forcing.md index 0f9449e..3947cdc 100644 --- a/docs/users_guide/seas5-forcing.md +++ b/docs/users_guide/seas5-forcing.md @@ -79,7 +79,7 @@ The script outputs radiation directly as flux variables (`flds`, `fsds`) in W/m². ```bash - python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc + python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc --frequency 1 ``` ### Preparation of SEAS5 data: correct input variables diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index f0a3bfd..ee2e42d 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -456,6 +456,21 @@ def main(): "units": "hours", } + # Compute valid_time = forecast_reference_time + forecast_period + # forecast_reference_time is in seconds since epoch, forecast_period in hours + ref_time_seconds = ds_6h["forecast_reference_time"].values[0] + valid_time_seconds = ref_time_seconds + target_periods_hours * 3600 + ds_out["valid_time"] = xr.DataArray( + data=valid_time_seconds, + dims=["forecast_period"], + attrs={ + "standard_name": "time", + "long_name": "time", + "units": "seconds since 1970-01-01T00:00:00", + "calendar": "proleptic_gregorian", + }, + ) + # Add z ds_out["z"] = xr.DataArray(data=z_data, dims=dims, attrs=z_const.attrs) From b7a75b2504392d9a8532ac80f3740e21c8c50fa3 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 20:45:20 +0100 Subject: [PATCH 69/93] mkforcing/seas5_daily_to_6hourly: fix error Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index ee2e42d..60b430b 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -457,8 +457,13 @@ def main(): } # Compute valid_time = forecast_reference_time + forecast_period - # forecast_reference_time is in seconds since epoch, forecast_period in hours - ref_time_seconds = ds_6h["forecast_reference_time"].values[0] + # forecast_reference_time may be datetime64 or seconds since epoch + ref_time = ds_6h["forecast_reference_time"].values[0] + if np.issubdtype(type(ref_time), np.datetime64): + # Convert datetime64 to seconds since epoch + ref_time_seconds = (ref_time - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's') + else: + ref_time_seconds = float(ref_time) valid_time_seconds = ref_time_seconds + target_periods_hours * 3600 ds_out["valid_time"] = xr.DataArray( data=valid_time_seconds, From 2b1c181d13485a1014d350391f4a399015282290 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 20:50:56 +0100 Subject: [PATCH 70/93] mkforcing/seas5_daily_to_6hourly: valid_time as integer Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 60b430b..2b82ff7 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -464,14 +464,14 @@ def main(): ref_time_seconds = (ref_time - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 's') else: ref_time_seconds = float(ref_time) - valid_time_seconds = ref_time_seconds + target_periods_hours * 3600 + valid_time_seconds = (ref_time_seconds + target_periods_hours * 3600).astype(np.int64) ds_out["valid_time"] = xr.DataArray( data=valid_time_seconds, dims=["forecast_period"], attrs={ "standard_name": "time", "long_name": "time", - "units": "seconds since 1970-01-01T00:00:00", + "units": "seconds since 1970-01-01", "calendar": "proleptic_gregorian", }, ) @@ -548,6 +548,9 @@ def filter_encoding(enc): if ds_daily["tp"].encoding: encoding["tp"].update(filter_encoding(ds_daily["tp"].encoding)) + # valid_time should be int64, not float + encoding["valid_time"] = {"dtype": "int64"} + ds_out.to_netcdf(output_file, encoding=encoding) # Close datasets From 76d5ce6c1560566bdfe8bae6525c502557f5183e Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 21:17:11 +0100 Subject: [PATCH 71/93] mkforcing/seas5_daily_to_6hourly: valid_time in hours Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 2b82ff7..c88093d 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -465,14 +465,30 @@ def main(): else: ref_time_seconds = float(ref_time) valid_time_seconds = (ref_time_seconds + target_periods_hours * 3600).astype(np.int64) + # ds_out["valid_time"] = xr.DataArray( + # data=valid_time_seconds, + # dims=["forecast_period"], + # attrs={ + # "standard_name": "time", + # "long_name": "time", + # "units": "seconds since 1970-01-01", + # "calendar": "proleptic_gregorian", + # }, + # ) + + # Also add time in hours since 1900-01-01 (common format for climate data) + # Hours from 1900-01-01 to 1970-01-01: 613608 hours + hours_1900_to_1970 = 613608 + time_hours = (valid_time_seconds / 3600 + hours_1900_to_1970).astype(np.int32) ds_out["valid_time"] = xr.DataArray( - data=valid_time_seconds, + data=time_hours, dims=["forecast_period"], attrs={ "standard_name": "time", "long_name": "time", - "units": "seconds since 1970-01-01", - "calendar": "proleptic_gregorian", + "units": "hours since 1900-01-01 00:00:00.0", + "calendar": "gregorian", + "axis": "T", }, ) @@ -548,8 +564,9 @@ def filter_encoding(enc): if ds_daily["tp"].encoding: encoding["tp"].update(filter_encoding(ds_daily["tp"].encoding)) - # valid_time should be int64, not float + # valid_time should be int64, time should be int32 encoding["valid_time"] = {"dtype": "int64"} + encoding["time"] = {"dtype": "int32"} ds_out.to_netcdf(output_file, encoding=encoding) From 4080bec517d0633392e62c5044b7f98bbc88f430 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 21:19:40 +0100 Subject: [PATCH 72/93] mkforcing/seas5_daily_to_6hourly: bugfix --- mkforcing/seas5_daily_to_6hourly.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index c88093d..4057846 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -565,8 +565,8 @@ def filter_encoding(enc): encoding["tp"].update(filter_encoding(ds_daily["tp"].encoding)) # valid_time should be int64, time should be int32 - encoding["valid_time"] = {"dtype": "int64"} - encoding["time"] = {"dtype": "int32"} + # encoding["valid_time"] = {"dtype": "int64"} + encoding["valid_time"] = {"dtype": "int32"} ds_out.to_netcdf(output_file, encoding=encoding) From 963dd6d8f61dd48546b22b7997485b651164b757 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 19 Jan 2026 23:55:33 +0100 Subject: [PATCH 73/93] mkforcing/seas5_daily_to_6hourly: include zero hour Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 34 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 4057846..66266b3 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -57,7 +57,7 @@ def forecast_period_to_hours(forecast_period): return values.astype(float) -def generate_target_forecast_periods(input_periods_hours, frequency_hours): +def generate_target_forecast_periods(input_periods_hours, frequency_hours, include_hour_zero=False): """ Generate target forecast periods at the desired frequency. @@ -67,13 +67,15 @@ def generate_target_forecast_periods(input_periods_hours, frequency_hours): Input forecast periods in hours (e.g., [6, 12, 18, 24, 30, 36, 42, 48]) frequency_hours : int Target frequency in hours (e.g., 1 for hourly) + include_hour_zero : bool + If True, include hour 0 as the first time step (for initial conditions) Returns ------- np.ndarray Target forecast periods in hours """ - start_hour = frequency_hours # First period (e.g., 1 for hourly, 6 for 6-hourly) + start_hour = 0 if include_hour_zero else frequency_hours end_hour = int(np.max(input_periods_hours)) return np.arange(start_hour, end_hour + frequency_hours, frequency_hours, dtype=float) @@ -117,8 +119,10 @@ def expand_6hourly_to_target(ds_6h, var_name, target_periods_hours, frequency_ho # For each target period, find the corresponding 6-hourly period for i, target_hour in enumerate(target_periods_hours): # Find the 6-hourly period that contains this target hour - # Hour 1-6 -> 6h period at hour 6, Hour 7-12 -> 6h period at hour 12, etc. + # Hour 0-6 -> 6h period at hour 6, Hour 7-12 -> 6h period at hour 12, etc. containing_6h = int(np.ceil(target_hour / 6.0) * 6) + if containing_6h == 0: + containing_6h = 6 # Hour 0 uses the first available value (hour 6) idx_6h = np.where(np.isclose(input_periods_hours, containing_6h))[0] if len(idx_6h) > 0: output[:, :, i, :, :] = var_data.isel(forecast_period=idx_6h[0]).values @@ -167,7 +171,10 @@ def distribute_daily_to_target(ds_daily, var_name, target_periods_hours, frequen # For each daily period, distribute to target intervals for i, daily_period_hours in enumerate(daily_periods_hours): # Find target intervals belonging to this day - day_start = daily_period_hours - 24 + frequency_hours + # For first day, include hour 0 if present + day_start = daily_period_hours - 24 + if i > 0: + day_start += frequency_hours # Avoid overlap with previous day day_end = daily_period_hours indices = [] @@ -227,7 +234,10 @@ def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hour # For each daily period, distribute to target intervals for i, daily_period_hours in enumerate(daily_periods_hours): - day_start = daily_period_hours - 24 + frequency_hours + # For first day, include hour 0 if present + day_start = daily_period_hours - 24 + if i > 0: + day_start += frequency_hours # Avoid overlap with previous day day_end = daily_period_hours indices = [] @@ -289,7 +299,10 @@ def distribute_solar_radiation_to_flux(ds_daily, var_name, target_periods_hours, # For each daily period, distribute with bell-shaped weights for i, daily_period_hours in enumerate(daily_periods_hours): - day_start = daily_period_hours - 24 + frequency_hours + # For first day, include hour 0 if present + day_start = daily_period_hours - 24 + if i > 0: + day_start += frequency_hours # Avoid overlap with previous day day_end = daily_period_hours # Find indices and calculate weights for this day @@ -354,6 +367,11 @@ def main(): action="store_true", help="Print what would be done without writing output", ) + parser.add_argument( + "--include-hour-zero", + action="store_true", + help="Include hour 0 as initial time step (duplicates first available value)", + ) args = parser.parse_args() @@ -388,7 +406,9 @@ def main(): # Get input periods and generate target periods input_periods_hours = forecast_period_to_hours(ds_6h["forecast_period"].values) - target_periods_hours = generate_target_forecast_periods(input_periods_hours, frequency_hours) + target_periods_hours = generate_target_forecast_periods( + input_periods_hours, frequency_hours, args.include_hour_zero + ) n_target = len(target_periods_hours) daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) From 77905c8712d0ce4babf867c968620f6083e14961 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Tue, 20 Jan 2026 11:34:13 +0100 Subject: [PATCH 74/93] docs: add `--include-hour-zero` to default command --- docs/users_guide/seas5-forcing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/seas5-forcing.md b/docs/users_guide/seas5-forcing.md index 3947cdc..43af23d 100644 --- a/docs/users_guide/seas5-forcing.md +++ b/docs/users_guide/seas5-forcing.md @@ -79,7 +79,7 @@ The script outputs radiation directly as flux variables (`flds`, `fsds`) in W/m². ```bash - python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc --frequency 1 + python seas5_daily_to_6hourly.py --const cdsapidwn_SEAS5_const/download_era5_2026_01.nc --daily cdsapidwn_SEAS5_24h/download_era5_2026_01.nc --hourly cdsapidwn_SEAS5_06h/download_era5_2026_01.nc --output cdsapidwn_SEAS5/download_era5_2026_01.nc --frequency 3 --include-hour-zero ``` ### Preparation of SEAS5 data: correct input variables From a9b80409e137637f3284988cc6ba1f5c95250143 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Tue, 20 Jan 2026 12:58:38 +0100 Subject: [PATCH 75/93] mkforcing/seas5_daily_to_6hourly: tp to precipitation flux Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/prepare_SEAS5_input.sh | 2 +- mkforcing/seas5_daily_to_6hourly.py | 89 ++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index 08f1424..8074ccd 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -115,7 +115,7 @@ do cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc # Rename variables - ncrename -v sp,PSRF -v fsds,FSDS -v flds,FLDS -v tp,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${year}-${month}.nc + ncrename -v sp,PSRF -v fsds,FSDS -v flds,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${year}-${month}.nc # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 66266b3..16a46eb 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -5,7 +5,7 @@ This script: 1. Adds constant `z` (orography) broadcast to all time steps 2. Expands 6-hourly variables (msl, u10, v10, t2m, d2m) to target frequency -3. Converts daily `tp` (total precipitation) by dividing equally across intervals +3. Converts daily `tp` (total precipitation, m) to precipitation flux `avg_tprate` (kg m**-2 s**-1) 4. Converts daily `strd` (thermal radiation) to flux, distributed equally 5. Converts daily `ssrd` (solar radiation) to flux with bell-shaped diurnal cycle (cosine distribution: zero at 6:00 and 18:00, peak at noon) @@ -191,6 +191,70 @@ def distribute_daily_to_target(ds_daily, var_name, target_periods_hours, frequen return output +def distribute_precipitation_to_flux(ds_daily, var_name, target_periods_hours, frequency_hours): + """ + Distribute daily accumulated precipitation to target frequency and convert to flux. + + Precipitation is distributed evenly across all intervals. + Converts from meters [m] to precipitation rate [kg m**-2 s**-1] (= mm/s). + + Parameters + ---------- + ds_daily : xarray.Dataset + Dataset containing daily variables + var_name : str + Variable name (typically 'tp') + target_periods_hours : np.ndarray + Target forecast periods in hours + frequency_hours : int + Target frequency in hours + + Returns + ------- + np.ndarray + Precipitation flux (kg m**-2 s**-1) at target frequency + """ + precip_daily = ds_daily[var_name] + daily_periods_hours = forecast_period_to_hours(ds_daily["forecast_period"].values) + + # Get dimensions + n_number = precip_daily.sizes.get("number", precip_daily.shape[0]) + n_ref_time = precip_daily.sizes.get("forecast_reference_time", 1) + n_lat = precip_daily.sizes.get("latitude", 1) + n_lon = precip_daily.sizes.get("longitude", 1) + n_target = len(target_periods_hours) + + # Time interval in seconds + dt_seconds = frequency_hours * 3600 + + # Create output array + flux = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) + + # For each daily period, distribute to target intervals + for i, daily_period_hours in enumerate(daily_periods_hours): + # For first day, include hour 0 if present + day_start = daily_period_hours - 24 + if i > 0: + day_start += frequency_hours # Avoid overlap with previous day + day_end = daily_period_hours + + indices = [] + for j, target_hour in enumerate(target_periods_hours): + if day_start <= target_hour <= day_end: + indices.append(j) + + if len(indices) > 0: + # Daily accumulated value in meters [m] + daily_value = precip_daily.isel(forecast_period=i).values + # Convert: m -> mm (×1000), then divide by interval seconds to get rate + # flux [kg m**-2 s**-1] = (daily_value [m] × 1000) / (n_intervals × dt_seconds) + flux_value = (daily_value * 1000) / (len(indices) * dt_seconds) + for idx in indices: + flux[:, :, idx, :, :] = flux_value + + return flux + + def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hours, frequency_hours): """ Distribute daily thermal radiation to target frequency and convert to flux. @@ -435,9 +499,9 @@ def main(): ds_6h, var, target_periods_hours, frequency_hours ) - # 3. Distribute daily precipitation - print("Distributing daily precipitation...") - tp_target = distribute_daily_to_target( + # 3. Distribute daily precipitation and convert to flux + print("Distributing daily precipitation and converting to flux...") + tprate_target = distribute_precipitation_to_flux( ds_daily, "tp", target_periods_hours, frequency_hours ) @@ -519,8 +583,17 @@ def main(): for var, data in expanded_vars.items(): ds_out[var] = xr.DataArray(data=data, dims=dims, attrs=ds_6h[var].attrs) - # Add precipitation - ds_out["tp"] = xr.DataArray(data=tp_target, dims=dims, attrs=ds_daily["tp"].attrs) + # Add precipitation flux + ds_out["avg_tprate"] = xr.DataArray( + data=tprate_target, + dims=dims, + attrs={ + "units": "kg m**-2 s**-1", + "long_name": "Time-mean total precipitation rate", + "standard_name": "precipitation_flux", + "description": f"Converted from daily tp [m], distributed equally to {frequency_hours}-hourly intervals", + }, + ) # Add radiation fluxes ds_out["flds"] = xr.DataArray( @@ -582,7 +655,7 @@ def filter_encoding(enc): encoding["z"].update(filter_encoding(ds_const["z"].encoding)) if ds_daily["tp"].encoding: - encoding["tp"].update(filter_encoding(ds_daily["tp"].encoding)) + encoding["avg_tprate"].update(filter_encoding(ds_daily["tp"].encoding)) # valid_time should be int64, time should be int32 # encoding["valid_time"] = {"dtype": "int64"} @@ -603,7 +676,7 @@ def filter_encoding(enc): for var in vars_6h: if var in expanded_vars: print(f" {var}: expanded from 6-hourly by value repetition") - print(f" tp: daily precipitation distributed equally") + print(f" avg_tprate: precipitation rate [kg m**-2 s**-1] - converted from daily tp [m]") print(f" flds: thermal radiation flux - distributed equally (day and night)") print(f" fsds: solar radiation flux - bell-shaped (cosine, 6-18h, peak at noon)") From 5025b86e697fc35a959bed12a43a015e65cf44d8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Tue, 20 Jan 2026 13:17:49 +0100 Subject: [PATCH 76/93] mkforcing/seas5_daily_to_6hourly: de-accumulated tp Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index 16a46eb..a5f2c60 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -193,15 +193,17 @@ def distribute_daily_to_target(ds_daily, var_name, target_periods_hours, frequen def distribute_precipitation_to_flux(ds_daily, var_name, target_periods_hours, frequency_hours): """ - Distribute daily accumulated precipitation to target frequency and convert to flux. + Distribute daily precipitation to target frequency and convert to flux. - Precipitation is distributed evenly across all intervals. + The input data is accumulated since forecast start, so we first de-accumulate + by taking differences (day[i] - day[i-1]). The first day uses its value directly. + The de-accumulated daily values are then distributed evenly across all intervals. Converts from meters [m] to precipitation rate [kg m**-2 s**-1] (= mm/s). Parameters ---------- ds_daily : xarray.Dataset - Dataset containing daily variables + Dataset containing daily variables (accumulated since forecast start) var_name : str Variable name (typically 'tp') target_periods_hours : np.ndarray @@ -244,8 +246,18 @@ def distribute_precipitation_to_flux(ds_daily, var_name, target_periods_hours, f indices.append(j) if len(indices) > 0: - # Daily accumulated value in meters [m] - daily_value = precip_daily.isel(forecast_period=i).values + # Daily value in meters [m] + # De-accumulate: get actual daily value from accumulated values + # Input is accumulated since forecast start + accumulated_value = precip_daily.isel(forecast_period=i).values + if i == 0: + # First day: use accumulated value directly + daily_value = accumulated_value + else: + # Subsequent days: take difference with previous day + previous_accumulated = precip_daily.isel(forecast_period=i - 1).values + daily_value = accumulated_value - previous_accumulated + # Convert: m -> mm (×1000), then divide by interval seconds to get rate # flux [kg m**-2 s**-1] = (daily_value [m] × 1000) / (n_intervals × dt_seconds) flux_value = (daily_value * 1000) / (len(indices) * dt_seconds) From 63b679bc168568623f50f3b80f79fd7956979046 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Tue, 20 Jan 2026 13:23:39 +0100 Subject: [PATCH 77/93] mkforcing/seas5_daily_to_6hourly: de-accumulated radiation vars Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/seas5_daily_to_6hourly.py | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/mkforcing/seas5_daily_to_6hourly.py b/mkforcing/seas5_daily_to_6hourly.py index a5f2c60..26ba778 100644 --- a/mkforcing/seas5_daily_to_6hourly.py +++ b/mkforcing/seas5_daily_to_6hourly.py @@ -271,12 +271,14 @@ def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hour """ Distribute daily thermal radiation to target frequency and convert to flux. + The input data is accumulated since forecast start, so we first de-accumulate + by taking differences (day[i] - day[i-1]). The first day uses its value directly. Thermal radiation is distributed evenly across all intervals (day and night). Parameters ---------- ds_daily : xarray.Dataset - Dataset containing daily variables + Dataset containing daily variables (accumulated since forecast start) var_name : str Variable name (typically 'strd') target_periods_hours : np.ndarray @@ -302,9 +304,6 @@ def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hour # Time interval in seconds dt_seconds = frequency_hours * 3600 - # Number of intervals per day - intervals_per_day = int(24 / frequency_hours) - # Create output array flux = np.zeros((n_number, n_ref_time, n_target, n_lat, n_lon), dtype=np.float32) @@ -322,8 +321,17 @@ def distribute_thermal_radiation_to_flux(ds_daily, var_name, target_periods_hour indices.append(j) if len(indices) > 0: - # Daily accumulated value (J/m²) - daily_value = rad_daily.isel(forecast_period=i).values + # De-accumulate: get actual daily value from accumulated values + # Input is accumulated since forecast start (J/m²) + accumulated_value = rad_daily.isel(forecast_period=i).values + if i == 0: + # First day: use accumulated value directly + daily_value = accumulated_value + else: + # Subsequent days: take difference with previous day + previous_accumulated = rad_daily.isel(forecast_period=i - 1).values + daily_value = accumulated_value - previous_accumulated + # Flux = (daily_value / n_intervals) / dt_seconds flux_value = (daily_value / len(indices)) / dt_seconds for idx in indices: @@ -336,6 +344,9 @@ def distribute_solar_radiation_to_flux(ds_daily, var_name, target_periods_hours, """ Distribute daily solar radiation with bell-shaped diurnal cycle. + The input data is accumulated since forecast start, so we first de-accumulate + by taking differences (day[i] - day[i-1]). The first day uses its value directly. + Uses a cosine distribution: - Zero radiation between 18:00 and 06:00 (night) - Bell-shaped curve between 06:00 and 18:00, peak at noon @@ -344,7 +355,7 @@ def distribute_solar_radiation_to_flux(ds_daily, var_name, target_periods_hours, Parameters ---------- ds_daily : xarray.Dataset - Dataset containing daily variables + Dataset containing daily variables (accumulated since forecast start) var_name : str Variable name (typically 'ssrd') target_periods_hours : np.ndarray @@ -399,8 +410,16 @@ def distribute_solar_radiation_to_flux(ds_daily, var_name, target_periods_hours, weights = np.array(weights) weights = weights / weights.sum() - # Daily accumulated value (J/m²) - daily_value = rad_daily.isel(forecast_period=i).values + # De-accumulate: get actual daily value from accumulated values + # Input is accumulated since forecast start (J/m²) + accumulated_value = rad_daily.isel(forecast_period=i).values + if i == 0: + # First day: use accumulated value directly + daily_value = accumulated_value + else: + # Subsequent days: take difference with previous day + previous_accumulated = rad_daily.isel(forecast_period=i - 1).values + daily_value = accumulated_value - previous_accumulated # Distribute according to weights and convert to flux for idx, weight in zip(indices, weights): From 397c3857e4a781d3c1c52f14898e48f9d32c8213 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 21 Jan 2026 11:21:07 +0100 Subject: [PATCH 78/93] mkforcing/prepare_SEAS5_input: loop over all ens members Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/prepare_SEAS5_input.sh | 80 +++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index 8074ccd..501ee0b 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -72,58 +72,86 @@ do if $lrmp; then - # Copy netCDF file - cp ${pathdata}/download_era5_${year}_${month}.nc ${tmpdir} + # Copy netCDF file to a template for remapping (preserves original with all ensemble members) + cp ${pathdata}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}_4rmp.nc - # Extract the first ensemble member - # TODO: Preparation of all 51 ensemble members - ncks --overwrite -d number,0 -O ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc + # Extract the first ensemble member for the template + # The original file with all 51 ensemble members remains in pathdata for later processing + ncks --overwrite -d number,0 -O ${tmpdir}/download_era5_${year}_${month}_4rmp.nc ${tmpdir}/download_era5_${year}_${month}_4rmp.nc # Remove number and forecast_reference_time dimensions - ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc - ncwa --overwrite -a number ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}.nc + ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}_4rmp.nc ${tmpdir}/download_era5_${year}_${month}_4rmp.nc + ncwa --overwrite -a number ${tmpdir}/download_era5_${year}_${month}_4rmp.nc ${tmpdir}/download_era5_${year}_${month}_4rmp.nc # 1) Renaming variable 'valid_time' to 'time' in $file # 2) Renaming dimension 'forecast_period' to 'valid_time' in $file # Background: With this naming scheme the CDO remap command below will generate a time variable # called "time" in "seconds since 1970-01-01" as for ERA5. - ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}.nc - ncrename -d forecast_period,valid_time ${tmpdir}/download_era5_${year}_${month}.nc + ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}_4rmp.nc + ncrename -d forecast_period,valid_time ${tmpdir}/download_era5_${year}_${month}_4rmp.nc if $lwgtdis; then - cdo gendis,${domainfile} ${tmpdir}/download_era5_${year}_${month}.nc ${wgtcaf} + cdo gendis,${domainfile} ${tmpdir}/download_era5_${year}_${month}_4rmp.nc ${wgtcaf} fi if $lgriddes; then cdo griddes ${domainfile} > ${griddesfile} fi - cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/download_era5_${year}_${month}.nc ${tmpdir}/rmp_era5_${year}_${month}.nc + # TODO: Look into remapping! Now skipped here, but done in the loop below + # cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/download_era5_${year}_${month}_4rmp.nc ${tmpdir}/rmp_era5_${year}_${month}.nc fi if $lmerge; then - cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 - cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_const.nc - ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}.nc ${tmpdir}/${year}_${month}_temp2.nc + # Loop over all 51 ensemble members (indices 0-50) + for ens in $(seq 0 50); do + # Format ensemble number as 5-digit with leading zeros (1-based: ens+1) + ens_num=$(printf "%05d" $((ens + 1))) + ens_dir=${tmpdir}/real_${ens_num} + mkdir -pv ${ens_dir} - cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ - ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp4.nc + # Copy netCDF file for this ensemble member + cp ${pathdata}/download_era5_${year}_${month}.nc ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc - ncks -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc + # Extract the specific ensemble member + ncks --overwrite -d number,${ens} -O ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc + # Remove number and forecast_reference_time dimensions + ncwa --overwrite -a forecast_reference_time ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc + ncwa --overwrite -a number ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc - # Simply copy the file - cp ${tmpdir}/${year}_${month}_temp5.nc ${year}-${month}.nc + # Rename dimensions/variables for CDO compatibility + ncrename -v valid_time,time ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc + ncrename -d forecast_period,valid_time ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc - # Rename variables - ncrename -v sp,PSRF -v fsds,FSDS -v flds,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${year}-${month}.nc + # Remap this ensemble member + cdo -P ${ompthd} remap,${griddesfile},${wgtcaf} ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/rmp_era5_${year}_${month}_ens${ens}.nc -# ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${year}_${month}.nc - ncatted -O -a units,ZBOT,m,c,"m" ${year}-${month}.nc + cdo -P ${ompthd} expr,'WIND=sqrt(u10^2+v10^2)' ${tmpdir}/rmp_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/${year}_${month}_temp.nc # Calculate WIND from u10 and v10 + cdo -f nc4c const,10,${tmpdir}/rmp_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/${year}_${month}_const.nc + ncpdq -U ${tmpdir}/rmp_era5_${year}_${month}_ens${ens}.nc ${tmpdir}/${year}_${month}_temp2.nc - ncks -O -h --glb author="${author}" ${year}-${month}.nc - ncks -O -h --glb contact="${email}" ${year}-${month}.nc + cdo merge ${tmpdir}/${year}_${month}_const.nc ${tmpdir}/${year}_${month}_temp2.nc \ + ${tmpdir}/${year}_${month}_temp.nc ${tmpdir}/${year}_${month}_temp4.nc - rm ${tmpdir}/${year}_${month}_temp*nc ${tmpdir}/${year}_${month}_const.nc + ncks -Q -C -x -v hyai,hyam,hybi,hybm ${tmpdir}/${year}_${month}_temp4.nc ${tmpdir}/${year}_${month}_temp5.nc + + # Copy to ensemble-specific directory + cp ${tmpdir}/${year}_${month}_temp5.nc ${ens_dir}/${year}-${month}.nc + + # Rename variables + ncrename -v sp,PSRF -v fsds,FSDS -v flds,FLDS -v avg_tprate,PRECTmms -v const,ZBOT -v t10m,TBOT -v q10m,QBOT ${ens_dir}/${year}-${month}.nc + +# ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${ens_dir}/${year}-${month}.nc + ncatted -O -a units,ZBOT,m,c,"m" ${ens_dir}/${year}-${month}.nc + + ncks -Q -O -h --glb author="${author}" ${ens_dir}/${year}-${month}.nc + ncks -Q -O -h --glb contact="${email}" ${ens_dir}/${year}-${month}.nc + + # Cleanup temporary files for this ensemble member + rm ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc + rm ${tmpdir}/rmp_era5_${year}_${month}_ens${ens}.nc + rm ${tmpdir}/${year}_${month}_temp*nc ${tmpdir}/${year}_${month}_const.nc + done fi done From 764e248723b395b8acea2f6ccfdd33b9abbd4fbe Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 21 Jan 2026 11:27:25 +0100 Subject: [PATCH 79/93] mkforcing/prepare_SEAS5_input: fixes --- mkforcing/prepare_SEAS5_input.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index 501ee0b..a6ad6bc 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -107,7 +107,7 @@ do for ens in $(seq 0 50); do # Format ensemble number as 5-digit with leading zeros (1-based: ens+1) ens_num=$(printf "%05d" $((ens + 1))) - ens_dir=${tmpdir}/real_${ens_num} + ens_dir=real_${ens_num} mkdir -pv ${ens_dir} # Copy netCDF file for this ensemble member @@ -144,8 +144,8 @@ do # ncap2 -O -s 'where(FSDS<0.) FSDS=0' ${ens_dir}/${year}-${month}.nc ncatted -O -a units,ZBOT,m,c,"m" ${ens_dir}/${year}-${month}.nc - ncks -Q -O -h --glb author="${author}" ${ens_dir}/${year}-${month}.nc - ncks -Q -O -h --glb contact="${email}" ${ens_dir}/${year}-${month}.nc + ncks -Q -O -h --glb author="${author}" ${ens_dir}/${year}-${month}.nc ${ens_dir}/${year}-${month}.nc + ncks -Q -O -h --glb contact="${email}" ${ens_dir}/${year}-${month}.nc ${ens_dir}/${year}-${month}.nc # Cleanup temporary files for this ensemble member rm ${tmpdir}/download_era5_${year}_${month}_ens${ens}.nc From 09607af4fa73788ea0755175de3afee8cfefaeae Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Wed, 21 Jan 2026 11:33:49 +0100 Subject: [PATCH 80/93] mkforcing/prepare_SEAS5_input: nens as input to script Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mkforcing/prepare_SEAS5_input.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index a6ad6bc..d76b9f2 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -21,6 +21,7 @@ tmpdir=tmpdir wrkdir="" author="Default AUTHOR" email="d.fault@fz-juelich.de" +nens=51 # Number of ensemble members to process # Function to parse input parse_arguments() { @@ -44,6 +45,7 @@ parse_arguments() { iyear) iyear="$value" ;; author) author="$value" ;; email) email="$value" ;; + nens) nens="$value" ;; *) echo "Warning: Unknown parameter: $key" ;; esac done @@ -104,7 +106,7 @@ do if $lmerge; then # Loop over all 51 ensemble members (indices 0-50) - for ens in $(seq 0 50); do + for ens in $(seq 0 $((nens - 1))); do # Format ensemble number as 5-digit with leading zeros (1-based: ens+1) ens_num=$(printf "%05d" $((ens + 1))) ens_dir=real_${ens_num} From 27fba50b5e6baede2b7537d20360ef6f2cf73b46 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Thu, 19 Feb 2026 15:00:25 +0100 Subject: [PATCH 81/93] docs: add "SEAS5 atmospheric forcing" to toc --- docs/_toc.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_toc.yml b/docs/_toc.yml index 1d62e4a..7da213c 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -12,6 +12,8 @@ parts: title: Overview atmospheric forcing - file: users_guide/era5-forcing title: ERA5 atmospheric forcing + - file: users_guide/seas5-forcing + title: SEAS5 atmospheric forcing - file: users_guide/other-forcing title: Other atmospheric forcing From d8e96e9eca82a5390616f8d65a9a26004c6a3007 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 16 Mar 2026 12:53:01 +0100 Subject: [PATCH 82/93] dewpoint_to_specific_humidity: source Alduchov1996, parameters --- mkforcing/dewpoint_to_specific_humidity.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/mkforcing/dewpoint_to_specific_humidity.py b/mkforcing/dewpoint_to_specific_humidity.py index 047ff50..cdeabb7 100644 --- a/mkforcing/dewpoint_to_specific_humidity.py +++ b/mkforcing/dewpoint_to_specific_humidity.py @@ -5,14 +5,20 @@ def dewpoint_to_specific_humidity(T_d, P): """ - Convert dewpoint temperature to specific humidity + Convert dewpoint temperature (K) to specific humidity (Pa). - Source: + Sources: ------- - Stull, R., 2017: "Practical Meteorology: An Algebra-based Survey of Atmospheric Science" -version 1.02b. Univ. of British Columbia. 940 pages. isbn 978-0-88865-283-6 . - https://www.eoas.ubc.ca/books/Practical_Meteorology/ + - Alduchov, O. A., & Eskridge, R. E. (1996). Improved magnus form + approximation of saturation vapor pressure. Journal of Applied + Meteorology, 35(4), 601–609. + http://dx.doi.org/10.1175/1520-0450(1996)035<0601:imfaos>2.0.co;2 + - Wikipedia: + https://en.wikipedia.org/wiki/Clausius%E2%80%93Clapeyron_relation#Meteorology_and_climatology Parameters: ----------- @@ -28,18 +34,21 @@ def dewpoint_to_specific_humidity(T_d, P): """ # Constants epsilon = 0.622 # Ratio of molecular weights + a = 610.94 # Magnus form constant a (Alduchov1996) + b = 17.625 # Magnus form constant b (Alduchov1996) + c = 243.04 # Magnus form constant c (Alduchov1996) # Convert dewpoint to vapor pressure using August-Roche-Magnus formula # e_s in Pa - # https://en.wikipedia.org/wiki/Clausius%E2%80%93Clapeyron_relation#Meteorology_and_climatology - e_s = 610.2 * np.exp(17.625 * (T_d - 273.15) / ((T_d - 273.15) + 243.04)) + # # Alduchov1996, Table 1, Formula AERK + e_s = a * np.exp(b * (T_d - 273.15) / ((T_d - 273.15) + c)) # # Tetens formula # # Stull2017, Equation (4.2) # e_s = 611.3 * np.exp(17.2694 * (T_d - 273.15) / ((T_d - 273.15) + 237.29)) # Convert vapor pressure to specific humidity - # Stull2017, Table 4a + # Stull2017, Table 4-2a q = epsilon * e_s / (P - (1.0 - epsilon) * e_s) return q From a4d27d6fac5f8855ce31ee71fa35cdf0efda3549 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Mon, 16 Mar 2026 13:10:23 +0100 Subject: [PATCH 83/93] dewpoint_to_specific_humidity: source Alduchov1996, parameters --- mkforcing/dewpoint_to_specific_humidity.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mkforcing/dewpoint_to_specific_humidity.py b/mkforcing/dewpoint_to_specific_humidity.py index cdeabb7..6306b12 100644 --- a/mkforcing/dewpoint_to_specific_humidity.py +++ b/mkforcing/dewpoint_to_specific_humidity.py @@ -34,14 +34,15 @@ def dewpoint_to_specific_humidity(T_d, P): """ # Constants epsilon = 0.622 # Ratio of molecular weights - a = 610.94 # Magnus form constant a (Alduchov1996) - b = 17.625 # Magnus form constant b (Alduchov1996) - c = 243.04 # Magnus form constant c (Alduchov1996) + # Alduchov1996, Table 1, Approximation AERK + a = 17.625 # Magnus form constant a (Alduchov1996) + b = 243.04 # Magnus form constant b (Alduchov1996) + c = 610.94 # Magnus form constant c (Alduchov1996) # Convert dewpoint to vapor pressure using August-Roche-Magnus formula # e_s in Pa - # # Alduchov1996, Table 1, Formula AERK - e_s = a * np.exp(b * (T_d - 273.15) / ((T_d - 273.15) + c)) + # Alduchov1996, Equation 6 + e_s = c * np.exp(a * (T_d - 273.15) / ((T_d - 273.15) + b)) # # Tetens formula # # Stull2017, Equation (4.2) From 41414e20c6c648ad9b08528322eb6ea1e9a9cf00 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 12:37:03 +0200 Subject: [PATCH 84/93] run_atm_forcing_generator.sh: first draft for runscript --- run_atm_forcing_generator.sh | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 run_atm_forcing_generator.sh diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh new file mode 100644 index 0000000..56c5c52 --- /dev/null +++ b/run_atm_forcing_generator.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e +if [[ -z "$1" || -z "$2" || -z "$3" ]]; then + echo "Usage: $0 MODE YEAR MONTH" + exit 1 +fi + +MODE=$1 +YEAR=$2 +MONTH=$3 +DOMAINFILE="domain.lnd.DE-RuS_DE-RuS.250926.nc" + +source jsc.2024_Intel.sh + +mkdir -p ${YEAR}-${MONTH} +if [[ "$MODE" == "ERA5" ]]; then + echo "$MODE not available" + # mkdir -p data + # uv run mkforcing/download_ERA5_input.py \ + # --year $YEAR \ + # --month $MONTH \ + # --dirout data \ + # --request "${HOME}/mkforcing/custom_request_ERA5.py" \ + # --domainfile "${HOME}/domain.nc" + # unzip "data/download_era5_${YEAR}_${MONTH}.zip" -d data/ + # uv run mkforcing/dewpoint_to_specific_humidity.py data/data_stream-oper_stepType-instant.nc + # uv run mkforcing/2m_to_10m_conversion.py data/data_stream-oper_stepType-instant.nc + # mkforcing/prepare_ERA5_input.sh \ + # lrenametime=true lmeteo=false \ + # lunzip=false wgtcaf=../wgtdis_era5caf_to_domain.nc \ + # griddesfile=../domain_griddef.txt iyear=$YEAR \ + # imonth=$MONTH \ + # pathdata=../data \ + # lwgtdis=true lgriddes=true domainfile="${HOME}/domain.nc" + +else + mkdir cdsapidwn_SEAS5_const + python mkforcing/download_ERA5_input.py \ + --year $YEAR --month ${MONTH} \ + --dirout cdsapidwn_SEAS5_const \ + --request "mkforcing/custom_request_SEAS5_const.py" \ + --domainfile $DOMAINFILE + python mkforcing/download_ERA5_input.py \ + --year ${YEAR} --month ${MONTH} \ + --dirout cdsapidwn_SEAS5_24h \ + --request "mkforcing/custom_request_SEAS5_24h.py" \ + --domainfile $DOMAINFILE + python mkforcing/download_ERA5_input.py \ + --year ${YEAR} --month ${MONTH} \ + --dirout cdsapidwn_SEAS5_06h \ + --request "mkforcing/custom_request_SEAS5_06h.py" \ + --domainfile $DOMAINFILE + mkdir -p cdsapidwn_SEAS5 + python mkforcing/seas5_daily_to_6hourly.py \ + --const cdsapidwn_SEAS5_const/download_era5_${YEAR}_${MONTH}.nc \ + --daily cdsapidwn_SEAS5_24h/download_era5_${YEAR}_${MONTH}.nc \ + --hourly cdsapidwn_SEAS5_06h/download_era5_${YEAR}_${MONTH}.nc \ + --output cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc \ + --frequency 3 --include-hour-zero + python mkforcing/orography_to_elevation.py \ + cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc + python mkforcing/mslp_to_sp.py \ + cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc \ + --elevation-var elevation + python mkforcing/dewpoint_to_specific_humidity.py \ + cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc + python mkforcing/2m_to_10m_conversion.py \ + cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc + + mkforcing/prepare_SEAS5_input.sh \ + lwgtdis=true lgriddes=true \ + domainfile=$DOMAINFILE \ + griddesfile=../domain_griddef.txt \ + wgtcaf=../wgtdis_era5caf_to_domain.nc \ + iyear=${YEAR} imonth=${MONTH} \ + pathdata=../cdsapidwn_SEAS5 +fi From 9c19e9e844f7c4ab8f3b980a7e807a790a3fe105 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 12:41:35 +0200 Subject: [PATCH 85/93] run_atm_forcing_generator.sh: small changes --- run_atm_forcing_generator.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh index 56c5c52..c5ba1e5 100644 --- a/run_atm_forcing_generator.sh +++ b/run_atm_forcing_generator.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail if [[ -z "$1" || -z "$2" || -z "$3" ]]; then echo "Usage: $0 MODE YEAR MONTH" exit 1 From d2a14ed0a21bed241e0d7e19d4b8b6914c46b4a0 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 12:44:18 +0200 Subject: [PATCH 86/93] run_atm_forcing_generator: make executable --- run_atm_forcing_generator.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 run_atm_forcing_generator.sh diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh old mode 100644 new mode 100755 From 50e4d2601aeddc12ff6b0056de7e0cc884dd7f91 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 12:59:35 +0200 Subject: [PATCH 87/93] pyproject.toml: setting the dependencies --- pyproject.toml | 15 +++++++++++++++ run_atm_forcing_generator.sh | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7cfbfe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "eclm-atm-forcing-generator" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "cdsapi>=0.7.7", + "netcdf4>=1.7.4", + "numpy>=1.25.1", + "xarray>=2023.8.0", +] + +[tool.setuptools] +packages = [] diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh index c5ba1e5..4744b2f 100755 --- a/run_atm_forcing_generator.sh +++ b/run_atm_forcing_generator.sh @@ -10,7 +10,8 @@ YEAR=$2 MONTH=$3 DOMAINFILE="domain.lnd.DE-RuS_DE-RuS.250926.nc" -source jsc.2024_Intel.sh +source jsc.2024_Intel.sh +pip install . mkdir -p ${YEAR}-${MONTH} if [[ "$MODE" == "ERA5" ]]; then From 7bdbed6a24cb7fdeac837e8e3ff9181de6006cf8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 13:03:32 +0200 Subject: [PATCH 88/93] run_atm_forcing_generator: create pyvenv Co-Authored-By: Claude Sonnet 4.6 --- run_atm_forcing_generator.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh index 4744b2f..3488325 100755 --- a/run_atm_forcing_generator.sh +++ b/run_atm_forcing_generator.sh @@ -11,6 +11,12 @@ MONTH=$3 DOMAINFILE="domain.lnd.DE-RuS_DE-RuS.250926.nc" source jsc.2024_Intel.sh + +VENV_DIR="pyvenv_eclm_atm_forcing_generator" +if [[ ! -d "$VENV_DIR" ]]; then + python -m venv "$VENV_DIR" +fi +source "${VENV_DIR}/bin/activate" pip install . mkdir -p ${YEAR}-${MONTH} From 1e2e9f0e38314f1e4f1ac5ebd6450f54fedca5e8 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 13:42:16 +0200 Subject: [PATCH 89/93] run_atm_forcing_generator: anchored to SCRIPT_DIRect Co-Authored-By: Claude Sonnet 4.6 --- run_atm_forcing_generator.sh | 74 ++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh index 3488325..f80484b 100755 --- a/run_atm_forcing_generator.sh +++ b/run_atm_forcing_generator.sh @@ -9,17 +9,18 @@ MODE=$1 YEAR=$2 MONTH=$3 DOMAINFILE="domain.lnd.DE-RuS_DE-RuS.250926.nc" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source jsc.2024_Intel.sh +source "${SCRIPT_DIR}/jsc.2024_Intel.sh" -VENV_DIR="pyvenv_eclm_atm_forcing_generator" +VENV_DIR="${SCRIPT_DIR}/pyvenv_eclm_atm_forcing_generator" if [[ ! -d "$VENV_DIR" ]]; then python -m venv "$VENV_DIR" fi source "${VENV_DIR}/bin/activate" -pip install . +pip install "${SCRIPT_DIR}" -mkdir -p ${YEAR}-${MONTH} +mkdir -p "${SCRIPT_DIR}/${YEAR}-${MONTH}" if [[ "$MODE" == "ERA5" ]]; then echo "$MODE not available" # mkdir -p data @@ -41,44 +42,43 @@ if [[ "$MODE" == "ERA5" ]]; then # lwgtdis=true lgriddes=true domainfile="${HOME}/domain.nc" else - mkdir cdsapidwn_SEAS5_const - python mkforcing/download_ERA5_input.py \ + python "${SCRIPT_DIR}/mkforcing/download_ERA5_input.py" \ --year $YEAR --month ${MONTH} \ - --dirout cdsapidwn_SEAS5_const \ - --request "mkforcing/custom_request_SEAS5_const.py" \ - --domainfile $DOMAINFILE - python mkforcing/download_ERA5_input.py \ + --dirout "${SCRIPT_DIR}/cdsapidwn_SEAS5_const" \ + --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_const.py" # \ + # --domainfile $DOMAINFILE + python "${SCRIPT_DIR}/mkforcing/download_ERA5_input.py" \ --year ${YEAR} --month ${MONTH} \ - --dirout cdsapidwn_SEAS5_24h \ - --request "mkforcing/custom_request_SEAS5_24h.py" \ - --domainfile $DOMAINFILE - python mkforcing/download_ERA5_input.py \ + --dirout "${SCRIPT_DIR}/cdsapidwn_SEAS5_24h" \ + --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_24h.py"# \ + # --domainfile $DOMAINFILE + python "${SCRIPT_DIR}/mkforcing/download_ERA5_input.py" \ --year ${YEAR} --month ${MONTH} \ - --dirout cdsapidwn_SEAS5_06h \ - --request "mkforcing/custom_request_SEAS5_06h.py" \ - --domainfile $DOMAINFILE - mkdir -p cdsapidwn_SEAS5 - python mkforcing/seas5_daily_to_6hourly.py \ - --const cdsapidwn_SEAS5_const/download_era5_${YEAR}_${MONTH}.nc \ - --daily cdsapidwn_SEAS5_24h/download_era5_${YEAR}_${MONTH}.nc \ - --hourly cdsapidwn_SEAS5_06h/download_era5_${YEAR}_${MONTH}.nc \ - --output cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc \ + --dirout "${SCRIPT_DIR}/cdsapidwn_SEAS5_06h" \ + --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_06h.py"# \ + # --domainfile $DOMAINFILE + mkdir -p "${SCRIPT_DIR}/cdsapidwn_SEAS5" + python "${SCRIPT_DIR}/mkforcing/seas5_daily_to_6hourly.py" \ + --const "${SCRIPT_DIR}/cdsapidwn_SEAS5_const/download_era5_${YEAR}_${MONTH}.nc" \ + --daily "${SCRIPT_DIR}/cdsapidwn_SEAS5_24h/download_era5_${YEAR}_${MONTH}.nc" \ + --hourly "${SCRIPT_DIR}/cdsapidwn_SEAS5_06h/download_era5_${YEAR}_${MONTH}.nc" \ + --output "${SCRIPT_DIR}/cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc" \ --frequency 3 --include-hour-zero - python mkforcing/orography_to_elevation.py \ - cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc - python mkforcing/mslp_to_sp.py \ - cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc \ + python "${SCRIPT_DIR}/mkforcing/orography_to_elevation.py" \ + "${SCRIPT_DIR}/cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc" + python "${SCRIPT_DIR}/mkforcing/mslp_to_sp.py" \ + "${SCRIPT_DIR}/cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc" \ --elevation-var elevation - python mkforcing/dewpoint_to_specific_humidity.py \ - cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc - python mkforcing/2m_to_10m_conversion.py \ - cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc - - mkforcing/prepare_SEAS5_input.sh \ + python "${SCRIPT_DIR}/mkforcing/dewpoint_to_specific_humidity.py" \ + "${SCRIPT_DIR}/cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc" + python "${SCRIPT_DIR}/mkforcing/2m_to_10m_conversion.py" \ + "${SCRIPT_DIR}/cdsapidwn_SEAS5/download_era5_${YEAR}_${MONTH}.nc" + + "${SCRIPT_DIR}/mkforcing/prepare_SEAS5_input.sh" \ lwgtdis=true lgriddes=true \ - domainfile=$DOMAINFILE \ - griddesfile=../domain_griddef.txt \ - wgtcaf=../wgtdis_era5caf_to_domain.nc \ + domainfile="${SCRIPT_DIR}/${DOMAINFILE}" \ + griddesfile="${SCRIPT_DIR}/domain_griddef.txt" \ + wgtcaf="${SCRIPT_DIR}/wgtdis_era5caf_to_domain.nc" \ iyear=${YEAR} imonth=${MONTH} \ - pathdata=../cdsapidwn_SEAS5 + pathdata="${SCRIPT_DIR}/cdsapidwn_SEAS5" fi From 53608134a98fee83812747d5c3909e7f01a1586b Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 13:49:01 +0200 Subject: [PATCH 90/93] mkforcing/download_ERA5_input.py: check custom_request file --- mkforcing/download_ERA5_input.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkforcing/download_ERA5_input.py b/mkforcing/download_ERA5_input.py index e4d1e4e..cdfbfbc 100755 --- a/mkforcing/download_ERA5_input.py +++ b/mkforcing/download_ERA5_input.py @@ -234,6 +234,8 @@ def generate_datarequest(year, monthstr, days, custom_request = None custom_dataset = args.dataset if args.request: + if not os.path.isfile(args.request): + raise FileNotFoundError(f"Custom request file not found: {args.request}") import importlib.util spec = importlib.util.spec_from_file_location("custom_request_module", args.request) custom_module = importlib.util.module_from_spec(spec) From 9acf3d2efddf0028d6f2d2749fcbecc8dac0fdb3 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 13:49:14 +0200 Subject: [PATCH 91/93] run_atm_forcing_generator.sh: fix custom_request paths --- run_atm_forcing_generator.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_atm_forcing_generator.sh b/run_atm_forcing_generator.sh index f80484b..2b7dfc5 100755 --- a/run_atm_forcing_generator.sh +++ b/run_atm_forcing_generator.sh @@ -50,12 +50,12 @@ else python "${SCRIPT_DIR}/mkforcing/download_ERA5_input.py" \ --year ${YEAR} --month ${MONTH} \ --dirout "${SCRIPT_DIR}/cdsapidwn_SEAS5_24h" \ - --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_24h.py"# \ + --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_24h.py" # \ # --domainfile $DOMAINFILE python "${SCRIPT_DIR}/mkforcing/download_ERA5_input.py" \ --year ${YEAR} --month ${MONTH} \ --dirout "${SCRIPT_DIR}/cdsapidwn_SEAS5_06h" \ - --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_06h.py"# \ + --request "${SCRIPT_DIR}/mkforcing/custom_request_SEAS5_06h.py" # \ # --domainfile $DOMAINFILE mkdir -p "${SCRIPT_DIR}/cdsapidwn_SEAS5" python "${SCRIPT_DIR}/mkforcing/seas5_daily_to_6hourly.py" \ From 66edc709a907affd3dfff2c820ffbc61ceeaf0e5 Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Fri, 24 Apr 2026 15:11:54 +0200 Subject: [PATCH 92/93] SEAS5: custom-requests broader due to an API change, a broader area needs to be specified --- mkforcing/custom_request_SEAS5_06h.py | 2 +- mkforcing/custom_request_SEAS5_24h.py | 2 +- mkforcing/custom_request_SEAS5_const.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mkforcing/custom_request_SEAS5_06h.py b/mkforcing/custom_request_SEAS5_06h.py index fe29820..8aa6d4b 100644 --- a/mkforcing/custom_request_SEAS5_06h.py +++ b/mkforcing/custom_request_SEAS5_06h.py @@ -30,7 +30,7 @@ "day": ["01"], "leadtime_hour": [str(h) for h in range(0, 5161, 6)], "data_format": "netcdf", - "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen + "area": [50.870906+0.5, 6.4421445-0.5, 50.870906-0.5, 6.4421445+0.5] # Selhausen # "area": [74, -42, 20, 69] # Europe } diff --git a/mkforcing/custom_request_SEAS5_24h.py b/mkforcing/custom_request_SEAS5_24h.py index b2bf3b1..5a4b74e 100644 --- a/mkforcing/custom_request_SEAS5_24h.py +++ b/mkforcing/custom_request_SEAS5_24h.py @@ -30,7 +30,7 @@ "day": ["01"], "leadtime_hour": [str(h) for h in range(0, 5161, 6)], "data_format": "netcdf", - "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen + "area": [50.870906+0.5, 6.4421445-0.5, 50.870906-0.5, 6.4421445+0.5] # Selhausen # "area": [74, -42, 20, 69] # Europe } diff --git a/mkforcing/custom_request_SEAS5_const.py b/mkforcing/custom_request_SEAS5_const.py index e814b8d..c6203bf 100644 --- a/mkforcing/custom_request_SEAS5_const.py +++ b/mkforcing/custom_request_SEAS5_const.py @@ -30,7 +30,7 @@ "day": ["01"], "leadtime_hour": [str(h) for h in range(0, 5161, 6)], "data_format": "netcdf", - "area": [50.870906, 6.4421445, 50.870906, 6.4421445] # Selhausen + "area": [50.870906+0.5, 6.4421445-0.5, 50.870906-0.5, 6.4421445+0.5] # Selhausen # "area": [74, -42, 20, 69] # Europe } From 8f3be5767ba79d0258a618ed13965c765478993d Mon Sep 17 00:00:00 2001 From: Johannes Keller Date: Sun, 17 May 2026 22:43:16 +0200 Subject: [PATCH 93/93] SEAS5: add domainfile argument Co-Authored-By: Claude Sonnet 4.6 --- mkforcing/prepare_SEAS5_input.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/mkforcing/prepare_SEAS5_input.sh b/mkforcing/prepare_SEAS5_input.sh index d76b9f2..81a5a18 100755 --- a/mkforcing/prepare_SEAS5_input.sh +++ b/mkforcing/prepare_SEAS5_input.sh @@ -37,6 +37,7 @@ parse_arguments() { ompthd) ompthd="$value" ;; pathdata) pathdata="$value" ;; wgtcaf) wgtcaf="$value" ;; + domainfile) domainfile="$value" ;; # wgtmeteo) wgtmeteo="$value" ;; griddesfile) griddesfile="$value" ;; tmpdir) tmpdir="$value" ;;