├── img ├── icon.png ├── info.png ├── new.png ├── save.png ├── close.png ├── folder.png ├── radar.png ├── regnie.png ├── search.png ├── stack.png ├── stats.png ├── execute.png ├── qgis_logo.png ├── sw_info.png ├── plugin_logo.png ├── sw_download.png └── weihnachten.gif ├── example ├── visualization.jpg ├── shapes │ ├── DEU_adm0 │ │ ├── DEU_adm0.dbf │ │ ├── DEU_adm0.sbn │ │ ├── DEU_adm0.sbx │ │ ├── DEU_adm0.shp │ │ ├── DEU_adm0.shx │ │ ├── DEU_adm0.prj │ │ └── DEU_readme.txt │ ├── DEU_adm1 │ │ ├── DEU_adm1.dbf │ │ ├── DEU_adm1.sbn │ │ ├── DEU_adm1.sbx │ │ ├── DEU_adm1.shp │ │ ├── DEU_adm1.shx │ │ ├── DEU_adm1.prj │ │ └── DEU_readme.txt │ ├── RadarNetwork │ │ ├── radarloc.gpkg │ │ └── radarbuffer_150km.gpkg │ ├── state_capitals_germany.gpkg │ └── Hessen │ │ ├── DE_bundeslaender_GEN_Hessen.dbf │ │ ├── DE_bundeslaender_GEN_Hessen.shp │ │ ├── DE_bundeslaender_GEN_Hessen.shx │ │ ├── DE_bundeslaender_GEN_Hessen.prj │ │ └── DE_bundeslaender_GEN_Hessen.qpj └── sample_file │ └── raa01-rw_10000-1708020250-dwd---bin.gz ├── resources.qrc ├── .gitignore ├── tests └── test_env.py ├── config.ini ├── classes ├── def_projections.py ├── regnie2raster.py ├── ASCIIGridWriter.py ├── Regnie.py ├── SettingsTab.py ├── ActionTabRegnie.py ├── def_products.py ├── ActionTabBase.py ├── gui.py ├── NumpyRadolanAdder.py ├── ActionTabRADOLANAdder.py ├── LayerLoader.py ├── Model.py └── GDALProcessing.py ├── .flake8 ├── symbology ├── BJ.qml ├── rvp6units.qml ├── YU.qml ├── YR.qml ├── YA.qml ├── YB.qml ├── YW.qml ├── daily+.qml ├── daily.qml ├── hourly.qml ├── 5min.qml ├── yearly.qml ├── monthly.qml ├── HG.qml ├── regnie_raster_daily.qml ├── regnie_raster_yearly.qml ├── regnie_raster_monthly.qml └── WN.qml ├── .github └── workflows │ └── python-app.yml ├── __init__.py ├── pb_tool.cfg ├── README.md └── metadata.txt /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/icon.png -------------------------------------------------------------------------------- /img/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/info.png -------------------------------------------------------------------------------- /img/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/new.png -------------------------------------------------------------------------------- /img/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/save.png -------------------------------------------------------------------------------- /img/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/close.png -------------------------------------------------------------------------------- /img/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/folder.png -------------------------------------------------------------------------------- /img/radar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/radar.png -------------------------------------------------------------------------------- /img/regnie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/regnie.png -------------------------------------------------------------------------------- /img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/search.png -------------------------------------------------------------------------------- /img/stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/stack.png -------------------------------------------------------------------------------- /img/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/stats.png -------------------------------------------------------------------------------- /img/execute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/execute.png -------------------------------------------------------------------------------- /img/qgis_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/qgis_logo.png -------------------------------------------------------------------------------- /img/sw_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/sw_info.png -------------------------------------------------------------------------------- /img/plugin_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/plugin_logo.png -------------------------------------------------------------------------------- /img/sw_download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/sw_download.png -------------------------------------------------------------------------------- /img/weihnachten.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/img/weihnachten.gif -------------------------------------------------------------------------------- /example/visualization.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/visualization.jpg -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm0/DEU_adm0.dbf -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.sbn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm0/DEU_adm0.sbn -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.sbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm0/DEU_adm0.sbx -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm0/DEU_adm0.shp -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm0/DEU_adm0.shx -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm1/DEU_adm1.dbf -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.sbn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm1/DEU_adm1.sbn -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.sbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm1/DEU_adm1.sbx -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm1/DEU_adm1.shp -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/DEU_adm1/DEU_adm1.shx -------------------------------------------------------------------------------- /example/shapes/RadarNetwork/radarloc.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/RadarNetwork/radarloc.gpkg -------------------------------------------------------------------------------- /example/shapes/state_capitals_germany.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/state_capitals_germany.gpkg -------------------------------------------------------------------------------- /resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | img/icon.png 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/shapes/RadarNetwork/radarbuffer_150km.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/RadarNetwork/radarbuffer_150km.gpkg -------------------------------------------------------------------------------- /example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.dbf -------------------------------------------------------------------------------- /example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.shp -------------------------------------------------------------------------------- /example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.shx -------------------------------------------------------------------------------- /example/sample_file/raa01-rw_10000-1708020250-dwd---bin.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weathermann/radolan2map/HEAD/example/sample_file/raa01-rw_10000-1708020250-dwd---bin.gz -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_adm0.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]] -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_adm1.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python compiled 2 | __pycache__ 3 | classes/__pycache__ 4 | .idea 5 | settings.json 6 | resources.py 7 | # pb_tools zip build 8 | zip_build/ 9 | # VS Code 10 | .vscode 11 | news.txt 12 | -------------------------------------------------------------------------------- /example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.prj: -------------------------------------------------------------------------------- 1 | PROJCS["Stereographic_North_Pole",GEOGCS["GCS_unnamed ellipse",DATUM["D_unknown",SPHEROID["Unknown",6370040,0]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]],PROJECTION["Stereographic_North_Pole"],PARAMETER["standard_parallel_1",60],PARAMETER["central_meridian",10],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["Meter",1]] -------------------------------------------------------------------------------- /example/shapes/Hessen/DE_bundeslaender_GEN_Hessen.qpj: -------------------------------------------------------------------------------- 1 | PROJCS["unnamed",GEOGCS["unnamed ellipse",DATUM["unknown",SPHEROID["unnamed",6370040,0]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Polar_Stereographic"],PARAMETER["latitude_of_origin",60],PARAMETER["central_meridian",10],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["Meter",1]] 2 | -------------------------------------------------------------------------------- /example/shapes/DEU_adm0/DEU_readme.txt: -------------------------------------------------------------------------------- 1 | These files were extracted from GADM version 1.0, in March 2009 2 | 3 | GADM is a geographic database of global administrative areas (boundaries). 4 | 5 | This work is licensed under a Creative Commons Attribution-Noncommercial-Share Alike 3.0 United States License. 6 | This only covers our contribution, not that of others (data provided to us). 7 | 8 | Visit http://www.gadm.org for details 9 | 10 | -------------------------------------------------------------------------------- /example/shapes/DEU_adm1/DEU_readme.txt: -------------------------------------------------------------------------------- 1 | These files were extracted from GADM version 1.0, in March 2009 2 | 3 | GADM is a geographic database of global administrative areas (boundaries). 4 | 5 | This work is licensed under a Creative Commons Attribution-Noncommercial-Share Alike 3.0 United States License. 6 | This only covers our contribution, not that of others (data provided to us). 7 | 8 | Visit http://www.gadm.org for details 9 | 10 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | # test_env.py 2 | 3 | import os 4 | import subprocess 5 | import platform 6 | 7 | 8 | def test_print_platform(): 9 | print(platform.platform()) 10 | print("Cores:", os.cpu_count()) 11 | mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") # total physical memory in Bytes 12 | print("Mem:", mem) 13 | 14 | def test_system(): 15 | cmd = "cat /etc/os-release" 16 | ret = subprocess.call(cmd, shell=True) 17 | assert ret == 0 18 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | # Paths configuration for 'radolan2map' 2 | 3 | [Paths] 4 | 5 | ; Unterverzeichnisse / Subdirs in "plugin dir": 6 | STYLE_PATH = symbology 7 | BASE_DATA_PATH = base_data 8 | DEFAULT_PROJECT = example/project_template.qgz.qgs 9 | print_layout = example/print_layout.qpt 10 | ; default cut to this shape file: 11 | CUT_TO = example/shapes/DEU_adm0/DEU_adm0.shp 12 | 13 | # you can define your own logo here for usage in print layout: 14 | #logo_image = .../radolan2map/img/plugin_logo.png 15 | 16 | datadir_deffile_basename = data_path.conf 17 | last_products_basename = last.conf 18 | 19 | -------------------------------------------------------------------------------- /classes/def_projections.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on 21.11.2019 3 | """ 4 | 5 | from collections import OrderedDict 6 | 7 | 8 | # int: String 9 | # EPSG code, projection description 10 | projs = OrderedDict() 11 | 12 | # The projections are inserted in the projection combo box in the specified order. 13 | # Key: number, Value: description, optional: projection parameters - value must be list type then. 14 | projs[3035] = "EPSG 3035: ETRS89 / LAEA Europe" 15 | projs[3857] = "EPSG 3857: Web Mercator / Pseudo Mercator" 16 | projs[4326] = "EPSG 4326: WGS84 / World Geodetic System 1984" 17 | 18 | # classical RADOLAN products, earth as sphere 19 | """ With QGIS 3.10.1-A Coruña on openSUSE Linux 15.1 this projection 20 | wasn't processed by gdal.Warp() with GDAL version 3.0.2 any more; maybe 'k' is the problem: 21 | '+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10 +k=0.93301270189 +x_0=0 +y_0=0 +a=6370040 +b=6370040 +units=m +no_defs' """ 22 | projs[0] = ["RADOLAN polarstereographic projection", 23 | '+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10 +a=6370040 +b=6370040 +units=m +no_defs'] 24 | # 2022: POLARA generated products(?), earth as WGS ellipsoid 25 | projs[1] = ["polarstereographic WGS projection", 26 | '+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10 +a=6378137 +b=6356752.3142451802 +units=m +no_defs'] 27 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | .gitignore, 5 | .vscode, 6 | __pycache__, 7 | zip_build, 8 | example, 9 | img, 10 | symbology, 11 | resources.py, 12 | ignore = 13 | # C901 # function is too complex (mccabe) 14 | # E114, # indentation is not a multiple of four (comment) 15 | # E117, # over-indented (comment) 16 | # E122, # continuation line missing indentation or outdented 17 | # E203, # whitespace before ',' 18 | # E226, # missing whitespace around arithmetic operator 19 | # E241, # multiple spaces after ',' 20 | # E242, # tab after ',' 21 | # E251, # unexpected spaces around keyword / parameter equals 22 | # E302, # expected 2 blank lines, found 1 23 | # E303, # too many blank lines (2) 24 | # E501, # line too long (> 175 characters) 25 | # E701, # multiple statements on one line (colon) 26 | # E722, # do not use bare 'except' 27 | # E999, # SyntaxError: invalid syntax 28 | # F401, # imported but unused 29 | # F541, # f-string is missing placeholders 30 | # F841, # local variable is assigned to but never used 31 | # W291, # trailing whitespace 32 | # W293, # blank line contains whitespace 33 | statistics = True 34 | count = True 35 | max-line-length = 175 36 | max-complexity=5 37 | -------------------------------------------------------------------------------- /symbology/BJ.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 0 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | # verschiedene nicht-existente Variablen/Methoden im NumpyRadolanReader, 32 | # daher flake erstmal deaktivieren: 33 | #- name: Lint with flake8 34 | # run: | 35 | # # stop the build if there are Python syntax errors or undefined names 36 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | run: | 41 | #pytest 42 | pytest -v 43 | -------------------------------------------------------------------------------- /symbology/rvp6units.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Estimated 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 0 27 | 249 28 | StretchToMinimumMaximum 29 | 30 | 31 | 32 | 33 | 34 | 35 | 0 36 | 37 | -------------------------------------------------------------------------------- /symbology/YU.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 0 27 | 28 | -------------------------------------------------------------------------------- /symbology/YR.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 0 27 | 28 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | This script initializes the plugin, making it known to QGIS. 5 | 6 | Radolan2Map 7 | A QGIS plugin 8 | A QGIS plugin to bring a RADOLAN binary file onto a map 9 | Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ 10 | ------------------- 11 | begin : 2019-08-05 12 | copyright : (C) 2019 by Weatherman 13 | email : radolan2map@e.mail.de 14 | git sha : $Format:%H$ 15 | ***************************************************************************/ 16 | 17 | /*************************************************************************** 18 | * * 19 | * This program is free software; you can redistribute it and/or modify * 20 | * it under the terms of the GNU General Public License as published by * 21 | * the Free Software Foundation; either version 2 of the License, or * 22 | * (at your option) any later version. * 23 | * * 24 | ***************************************************************************/ 25 | """ 26 | 27 | 28 | # noinspection PyPep8Naming 29 | def classFactory(iface): # pylint: disable=invalid-name 30 | """Load Radolan2Map class from file Radolan2Map. 31 | 32 | :param iface: A QGIS interface instance. 33 | :type iface: QgsInterface 34 | """ 35 | # 36 | from .radolan2map import Radolan2Map 37 | return Radolan2Map(iface) 38 | -------------------------------------------------------------------------------- /symbology/YA.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 0 27 | 28 | -------------------------------------------------------------------------------- /symbology/YB.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 0 27 | 28 | -------------------------------------------------------------------------------- /symbology/YW.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 0 28 | 29 | -------------------------------------------------------------------------------- /symbology/daily+.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 0 29 | 30 | -------------------------------------------------------------------------------- /pb_tool.cfg: -------------------------------------------------------------------------------- 1 | # Configuration file for plugin builder tool 2 | # Sane defaults for your plugin generated by the Plugin Builder are 3 | # already set below. 4 | # 5 | # As you add Python source files and UI files to your plugin, add 6 | # them to the appropriate [files] section below. 7 | 8 | [plugin] 9 | # Name of the plugin. This is the name of the directory that will 10 | # be created when deployed 11 | name: radolan2map 12 | 13 | # Full path to where you want your plugin directory copied. If empty, 14 | # the QGIS default path will be used. Don't include the plugin name in 15 | # the path. 16 | # plugin_path: /home/USER/.local/share/QGIS/QGIS3/profiles/default/python/plugins 17 | # plugin_path: c:\Users\USER\AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins 18 | # plugin_path: /Users/USER/Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins 19 | 20 | [files] 21 | # Python files that should be deployed with the plugin 22 | python_files: radolan2map.py __init__.py 23 | 24 | # The main dialog file that is loaded (not compiled) 25 | main_dialog: dock_widget.ui 26 | 27 | # Other ui files for your dialogs (these will be compiled) 28 | compiled_ui_files: 29 | 30 | # Resource file(s) that will be compiled 31 | resource_files: resources.qrc 32 | 33 | # Other files required for the plugin 34 | extras: metadata.txt config.ini 35 | 36 | # Other directories to be deployed with the plugin. 37 | # These must be subdirectories under the plugin directory 38 | extra_dirs: classes example img symbology 39 | 40 | # ISO code(s) for any locales (translations), separated by spaces. 41 | # Corresponding .ts files must exist in the i18n directory 42 | locales: 43 | 44 | # Uncomment the following to include help in the deployment 45 | # [help] 46 | # # the built help directory that should be deployed with the plugin 47 | # dir: help/build/html 48 | # # the name of the directory to target in the deployed plugin 49 | # target: help 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # radolan2map 2 | 3 | **QGIS plugin for DWD rasterized precipitation datasets like RADOLAN/RADKLIM and REGNIE** 4 | This project is a QGIS plugin that brings precipitation data from German Meteorological Service (DWD, https://www.dwd.de) - radar data in RADOLAN/RADKLIM binary format in any grid dimension as well as REGNIE - on a map. You also can **add** up RADOLAN data! 5 | Applications: hydrometeorological and agricultural analyses. 6 | 7 | ### Features 8 | load the following data types: 9 | - load/display any binary file in RADOLAN / RADKLIM format (incl. gz-compression) 10 | - radolan summation: you can add up RADOLAN data! 11 | - daily, monthly and multiannual REGNIE files (incl. gz-compression) 12 | 13 | additional functions for RADOLAN: 14 | - bring it to standard projection [ETRS89 / LAEA Europe](https://epsg.io/3035) 15 | - optional cut to german border (or any other shape file) 16 | - status of DWD Radar Network included (see [Wiki](https://gitlab.com/Weatherman_/radolan2map/wikis/home)) 17 | 18 | 19 | ### Installation, Usage 20 | => [Wiki](https://gitlab.com/Weatherman_/radolan2map/wikis/home) 21 | 22 | 23 | ### Data info 24 | #### RADOLAN 25 | * Overview: [https://dwd.de/RADOLAN](https://dwd.de/RADOLAN). There are hourly (RH, RW) or daily (SF) datasets. 26 | A RADOLAN-RW-testfile (gzipped binary) is included in directory `example/sample_file/`. 27 | * Data download: RADOLAN (recent) and RADKLIM radar climatology dataset: 28 | [DWD open climate data](https://opendata.dwd.de/climate_environment/CDC/grids_germany/hourly/radolan/) 29 | #### REGNIE 30 | * REGNIE overview: https://www.dwd.de/DE/leistungen/regnie/regnie.html 31 | * Data download: 32 | * [REGNIE daily data](https://opendata.dwd.de/climate_environment/CDC/grids_germany/daily/regnie/) 33 | * [REGNIE monthly data](https://opendata.dwd.de/climate_environment/CDC/grids_germany/monthly/regnie/) 34 | * [REGNIE multiannual data](https://opendata.dwd.de/climate_environment/CDC/grids_germany/multi_annual/regnie/) 35 | 36 | ### Platform info 37 | Plugin was successfully tested with QGIS 38 | * 3.16 *Hannover* on Linux openSUSE 15.3 and Windows 10 39 | * 3.10 *A Coruña* on Linux openSUSE 15.1 and Windows 10 40 | 41 | If you experience any problems when starting the plugin, please make sure that 42 | you have a **current version of QGIS and Python installed** on your system. 43 | 44 | 45 | ### QGIS 46 | * [https://qgis.org](https://qgis.org) 47 | * [`radolan2map`](http://plugins.qgis.org/plugins/radolan2map/) at qgis.org 48 | 49 | ### Feel invited... 50 | to give me feedback about usability and 51 | help improving this plugin to promote open source (GIS)software and data! 52 | 53 | -------------------------------------------------------------------------------- /symbology/daily.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Exact 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | -------------------------------------------------------------------------------- /symbology/hourly.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Exact 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | -------------------------------------------------------------------------------- /symbology/5min.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Exact 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | -------------------------------------------------------------------------------- /symbology/yearly.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Exact 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 0 56 | 57 | -------------------------------------------------------------------------------- /symbology/monthly.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | None 19 | WholeRaster 20 | Exact 21 | 0.02 22 | 0.98 23 | 2 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 0 57 | 58 | -------------------------------------------------------------------------------- /symbology/HG.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 0 8 | 9 | 10 | 11 | 2022-12-10T18:56:00Z 12 | 2022-12-10T19:00:00Z 13 | 14 | 15 | 16 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | None 38 | WholeRaster 39 | Estimated 40 | 0.02 41 | 0.98 42 | 2 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | resamplingFilter 63 | 64 | 0 65 | 66 | -------------------------------------------------------------------------------- /symbology/regnie_raster_daily.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | None 28 | WholeRaster 29 | Exact 30 | 0.02 31 | 0.98 32 | 2 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | resamplingFilter 63 | 64 | 0 65 | 66 | -------------------------------------------------------------------------------- /symbology/regnie_raster_yearly.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | None 28 | WholeRaster 29 | Exact 30 | 0.02 31 | 0.98 32 | 2 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | resamplingFilter 63 | 64 | 0 65 | 66 | -------------------------------------------------------------------------------- /symbology/regnie_raster_monthly.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | None 28 | WholeRaster 29 | Exact 30 | 0.02 31 | 0.98 32 | 2 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | resamplingFilter 63 | 64 | 0 65 | 66 | -------------------------------------------------------------------------------- /classes/regnie2raster.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Converts a REGNIE file to a raster file (geotiff) 4 | 5 | This script can handle daily, monthly and multiannual REGNIE files 6 | 7 | Monthly and multiannual REGNIE files contain values in the unit mm and are converted to Int32 rasters with a NoData value of -999 8 | Daily REGNIE files contain values in the unit 1/10 mm, which are first converted to mm and then converted to Float64 rasters with a default NoData value 9 | 10 | REGNIE description: https://www.dwd.de/DE/leistungen/regnie/regnie.html 11 | 12 | REGNIE files are freely available from the DWD open data platform: 13 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/daily/regnie/ 14 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/monthly/regnie/ 15 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/multi_annual/regnie/ 16 | 17 | @author: Felix Froehlich 18 | """ 19 | import argparse 20 | from pathlib import Path 21 | 22 | import numpy as np 23 | from osgeo import gdal 24 | from osgeo import osr 25 | 26 | from .Regnie import Regnie 27 | 28 | def regnie2raster(file_regnie: Path, file_raster: Path) -> None: 29 | """ 30 | Converts a REGNIE file to a raster file (geotiff) 31 | 32 | :param file_regnie: input REGNIE file 33 | :param file_raster: output raster file 34 | """ 35 | # get array values from regnie file 36 | rg = Regnie(file_regnie) 37 | 38 | # define pixel size and origin 39 | # from https://opendata.dwd.de/climate_environment/CDC/grids_germany/daily/regnie/REGNIE_Beschreibung_20201109.pdf 40 | # (also applies to monthly regnie files) 41 | x_delta = 1.0 / 60.0 42 | y_delta = 1.0 / 120.0 43 | x_min = 6.0 - (10.0 * x_delta) 44 | y_max = (55.0 + 10.0 * y_delta) 45 | origin = (x_min - x_delta / 2, y_max + y_delta / 2) # include half cell offset 46 | 47 | # save as raster 48 | array2raster(file_raster, origin, x_delta, y_delta, rg.data) 49 | 50 | def array2raster(outfile: Path, rasterOrigin: tuple, pixelWidth: int, pixelHeight: int, array: np.array) -> None: 51 | """ 52 | writes a numpy array to a raster file (geotiff) 53 | adapted from https://pcjericks.github.io/py-gdalogr-cookbook/raster_layers.html#create-raster-from-array 54 | 55 | :param outfile: path to output raster file 56 | :param rasterOrigin: tuple (x, y) of the top left coordinates of the raster 57 | :param pixelWidth: pixel width 58 | :param pixelHeight: pixel height 59 | :param array: two-dimensional numpy array with cell values 60 | """ 61 | 62 | cols = array.shape[1] 63 | rows = array.shape[0] 64 | originX = rasterOrigin[0] 65 | originY = rasterOrigin[1] 66 | 67 | # set pixel data type depending on array dtype 68 | # available pixel data types: https://naturalatlas.github.io/node-gdal/classes/Constants%20(GDT).html 69 | # TODO: add more types 70 | if array.dtype == np.dtype(np.int32): 71 | typ = gdal.GDT_Int32 72 | else: 73 | typ = gdal.GDT_Float64 74 | 75 | driver = gdal.GetDriverByName('GTiff') 76 | 77 | outRaster = driver.Create(str(outfile), cols, rows, 1, typ) 78 | outRaster.SetGeoTransform((originX, pixelWidth, 0, originY, 0, -pixelHeight)) 79 | 80 | outband = outRaster.GetRasterBand(1) 81 | if array.dtype == np.dtype(np.int32): 82 | # set noData value for integer rasters, for float rasters the input array is expected to have NaN values for NoData 83 | outband.SetNoDataValue(-999) 84 | outband.WriteArray(array) 85 | 86 | outRasterSRS = osr.SpatialReference() 87 | outRasterSRS.ImportFromEPSG(4326) 88 | outRaster.SetProjection(outRasterSRS.ExportToWkt()) 89 | 90 | outband.FlushCache() 91 | 92 | if __name__ == "__main__": 93 | 94 | # commandline interface 95 | 96 | try: 97 | parser = argparse.ArgumentParser( 98 | description=""" 99 | Converts a REGNIE file to a raster file (geotiff) 100 | 101 | This script can handle daily, monthly and multiannual REGNIE files 102 | 103 | Monthly and multiannual REGNIE files contain values in the unit mm and are converted to Int32 rasters with a NoData value of -999 104 | Daily REGNIE files contain values in the unit 1/10 mm, which are first converted to mm and then converted to Float64 rasters with a default NoData value 105 | 106 | REGNIE description: https://www.dwd.de/DE/leistungen/regnie/regnie.html 107 | 108 | REGNIE files are freely available from the DWD open data platform: 109 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/daily/regnie/ 110 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/monthly/regnie/ 111 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/multi_annual/regnie/ 112 | """ 113 | ) 114 | 115 | parser.add_argument("regnie_file", type=str, help="input REGNIE file") 116 | parser.add_argument("output_file", type=str, help="output raster file (tif)") 117 | 118 | args = parser.parse_args() 119 | 120 | regnie2raster(Path(args.regnie_file), Path(args.output_file)) 121 | 122 | print("Conversion successful!") 123 | 124 | except Exception as e: 125 | 126 | print(f"ERROR: {e}") 127 | 128 | -------------------------------------------------------------------------------- /classes/ASCIIGridWriter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | import numpy as np 4 | 5 | from .NumpyRadolanReader import NumpyRadolanReader 6 | 7 | default_nodata_value = -1.0 8 | 9 | 10 | class ASCIIGridWriter: 11 | """ASCIIGridWriter 12 | 13 | Creates a ESRI ASCII GRID (to convert this to GeoTIFF afterwards) 14 | from original binary RADOLAN file 15 | 16 | Created on 06.12.2020 17 | @author: Weatherman""" 18 | 19 | def __init__(self, np_2Ddata, precision, asc_filename_path, nodata_value=default_nodata_value): 20 | """ 21 | :param np_2Ddata: 22 | :param precision: 23 | :param asc_filename_path: 24 | :param nodata_value: possibility to specify another missing value, 25 | e.g. for dBZ products (e.g. -50.0; -32.5 = neg. maximum) 26 | """ 27 | print(self) 28 | 29 | self._np_2Ddata = np_2Ddata 30 | self._asc_filename_path = asc_filename_path 31 | self._prec = precision # precision: 1.0, 0.1, 0.01 32 | 33 | # catch 'None': 34 | if nodata_value is None: 35 | nodata_value = default_nodata_value 36 | 37 | self._nodata_value = nodata_value 38 | 39 | if nodata_value != default_nodata_value: 40 | self.out(f"nodata_value: {nodata_value}") 41 | 42 | def __str__(self): 43 | return self.__class__.__name__ 44 | 45 | def out(self, s, ok=True): 46 | if ok: 47 | print(f"{self}: {s}") 48 | else: 49 | print(f"{self}: {s}", file=sys.stderr) 50 | 51 | def write(self): 52 | """ 53 | Kapselt die verschiedenen internen Methoden (ganze RADOLAN- 54 | Datenverarbeitung als Schnittstelle nach außen. 55 | """ 56 | 57 | nrows, ncols = self._np_2Ddata.shape 58 | 59 | d_projected_meters = { 60 | # key: row, value: tuple of projected meters: x0, y0 61 | 1500: (-673462, -5008645), # central europe composite, 1500x1400 62 | 1200: (-543197, -4822589), # WN, HG, 1200x1100 *) 63 | 1100: (-443462, -4758645), # extended national composite / RADKLIM: 1100x900 64 | 900: (-523462, -4658645) # national composite: 900x900 65 | } 66 | # *) taken y0 from HG format description and minus 1200 kilometers. 67 | # They specify the _upper_ left corner there. 68 | 69 | try: 70 | llcorner = d_projected_meters[nrows] 71 | except KeyError: 72 | raise NotImplementedError 73 | 74 | l_gis_header_template = [] 75 | l_gis_header_template.append(f"ncols {ncols}") 76 | l_gis_header_template.append(f"nrows {nrows}") 77 | l_gis_header_template.append(f"xllcorner {llcorner[0]}") 78 | l_gis_header_template.append(f"yllcorner {llcorner[1]}") 79 | l_gis_header_template.append("cellsize 1000") 80 | l_gis_header_template.append(f"nodata_value {self._nodata_value}") 81 | 82 | gis_header = "\n".join(l_gis_header_template) 83 | 84 | # precision: 1.0, 0.1, 0.01 85 | if self._prec == 0.1: 86 | fmt = '%.1f' 87 | elif self._prec == 0.01: 88 | fmt = '%.2f' 89 | else: 90 | fmt = '%d' # as integer 91 | 92 | np_data = np.copy(self._np_2Ddata) # otherwise values will be changed 93 | 94 | #mask = np.isnan(np_data) 95 | #np_data[mask] = NODATA 96 | # -> ok 97 | np_data[np.isnan(np_data)] = self._nodata_value # set np.nan to ASCII value 98 | 99 | # np.flipud(): Flip array in the up/down direction. 100 | np.savetxt(self._asc_filename_path, np.flipud(np_data), fmt=fmt, 101 | delimiter=' ', newline='\n', header=gis_header, comments='') # footer='' 102 | 103 | self.out(f"write(): -> {self._asc_filename_path}") 104 | 105 | """ 106 | if np.amax(data_clutter) > 0.0: 107 | save_file = path.join(self._temp_dir, out_file_bn + "_clutter.asc") 108 | self._write_ascii_grid(data_clutter, gis_header, save_file) 109 | 110 | if np.amax(data_ground) > 0.0: 111 | save_file = path.join(self._temp_dir, out_file_bn + "_ground.asc") 112 | self._write_ascii_grid(data_ground, gis_header, save_file) 113 | 114 | if np.amax(data_nodata) > 0.0: 115 | save_file = path.join(self._temp_dir, out_file_bn + "_nodata.asc") 116 | self._write_ascii_grid(data_nodata, gis_header, save_file) 117 | """ 118 | 119 | 120 | def test_hg(): 121 | print("*** Test HG ***\n") 122 | 123 | hg_path = Path("/run/media/loki/ungesichert/Testdaten/HG") 124 | 125 | for hg in hg_path.glob("HG*_???"): 126 | _to_asc(hg) 127 | 128 | def test_wn(): 129 | print("*** Test WN ***\n") 130 | wn_path = Path("/run/media/loki/ungesichert/Testdaten/WN") 131 | wn1 = wn_path / "20221224-2200/WN2212242200_000" 132 | wn2 = wn_path / "20221226-1300/WN2212261300_000" 133 | 134 | for wn in (wn1, wn2): 135 | _to_asc(wn, -50.0) 136 | 137 | def _to_asc(bin, nodata_value=None): 138 | nrr = NumpyRadolanReader(bin) # FileNotFoundError 139 | nrr.read() 140 | 141 | asc_file = Path(bin.parent, bin.name + ".asc") 142 | 143 | ascii_writer = ASCIIGridWriter(nrr.data, nrr.precision, asc_file, nodata_value) 144 | ascii_writer.write() 145 | 146 | if __name__ == "__main__": 147 | """ Test ASCII convert """ 148 | 149 | #test_hg() 150 | test_wn() 151 | -------------------------------------------------------------------------------- /classes/Regnie.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Class for reading REGNIE files 4 | 5 | Can handle daily, monthly and multiannual REGNIE files 6 | 7 | REGNIE description: https://www.dwd.de/DE/leistungen/regnie/regnie.html 8 | 9 | REGNIE files are freely available from the DWD open data platform: 10 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/daily/regnie/ 11 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/monthly/regnie/ 12 | https://opendata.dwd.de/climate_environment/CDC/grids_germany/multi_annual/regnie/ 13 | 14 | @author: Felix Froehlich 15 | 16 | partially based on Regnie2CSV.py by Mario Hafer, Deutscher Wetterdienst (DWD) 17 | """ 18 | import re 19 | import gzip 20 | from pathlib import Path 21 | 22 | import numpy as np 23 | 24 | # REGNIE extent: 25 | Y_MAX = 971 26 | X_MAX = 611 27 | 28 | class Regnie: 29 | 30 | def __init__(self, file_regnie: Path): 31 | """ 32 | Instantiates a new Regnie class instance representing a REGNIE file. 33 | The file is read immediately. 34 | 35 | :param file_regnie: path to the REGNIE file (can be gz compressed) 36 | """ 37 | if not file_regnie.exists(): 38 | raise FileNotFoundError(f"File not found: {file_regnie}") 39 | 40 | self._file = file_regnie 41 | self._datatype = self._get_datatype() 42 | self._data = None 43 | 44 | self._read_file() 45 | 46 | def _read_file(self): 47 | """ 48 | Reads the REGNIE file and stores the values in the `data` property 49 | """ 50 | if self._file.name.endswith(".gz"): 51 | file = gzip.open(self._file, "rt") 52 | else: 53 | file = open(self._file, "r") 54 | 55 | rows = [] 56 | for i, line in enumerate(file, start=1): 57 | if i > Y_MAX: 58 | # in daily datasets, the last line contains other stuff we don't need 59 | break 60 | line = line.strip() 61 | # split line into strings of 4 characters each 62 | strings = [] 63 | for j in range(len(line) // 4): 64 | strings.append(line[j*4:(j*4)+4]) 65 | 66 | # convert strings to values depending on datatype 67 | if self.datatype in ["monthly", "multiannual"]: 68 | values = [int(s) for s in strings] 69 | elif self.datatype == "daily": 70 | values = [float(s) if s != "-999" else np.nan for s in strings] 71 | values = [v / 10.0 for v in values] 72 | 73 | rows.append(values) 74 | 75 | file.close() 76 | 77 | self._data = np.array(rows) 78 | 79 | def _get_datatype(self) -> str: 80 | """ 81 | Determines the data type of a REGNIE file from its filename 82 | 83 | Examples: 84 | ra210627 - daily values 85 | RASA2110 - monthly values 86 | RAS9120.JAH - multiannual values 87 | 88 | :param file_regnie: REGNIE file 89 | :return: "daily", "monthly" or "multiannual" 90 | """ 91 | if re.fullmatch(r"ra\d{6}(\.gz)?", self._file.name, re.I): 92 | return "daily" 93 | elif re.fullmatch(r"RASA\d{4}(\.gz)?", self._file.name, re.I): 94 | return "monthly" 95 | elif re.fullmatch(r"RAS\d{4}\.[a-z]+(\.gz)?", self._file.name, re.I): 96 | return "multiannual" 97 | else: 98 | raise Exception(f"Unable to determine REGNIE data type from filename {self._file.name}!") 99 | 100 | def _pixel_to_latlon(self, x: int, y: int) -> tuple: 101 | """ 102 | Converts a REGNIE x,y pixel coordinate to a lat,lon coordinate 103 | 104 | :param x: x coordinate 105 | :param y: y coordinate 106 | :return: (lat, lon) 107 | """ 108 | x_delta = 1.0 / 60.0 109 | y_delta = 1.0 / 120.0 110 | 111 | x_min = 6.0 - 10.0 * x_delta 112 | y_max = 55.0 + 10.0 * y_delta 113 | 114 | lon = x_min + (x - 1) * x_delta 115 | lat = y_max - (y - 1) * y_delta 116 | 117 | return lat, lon 118 | 119 | @property 120 | def data(self) -> np.array: 121 | """ 122 | Returns the values contained in the REGNIE file as a numpy array 123 | 124 | The data type of the array is integer for monthly and multiannual values and float for daily values 125 | REGNIE error values ("-999") are preserved as -999 for monthly and multiannual values and replaced with np.nan for daily values 126 | Daily REGNIE values are converted from 1/10 mm to mm 127 | 128 | :return: a two-dimensional numpy array 129 | """ 130 | return self._data 131 | 132 | @property 133 | def datatype(self) -> str: 134 | """ 135 | Returns the data type of the REGNIE file 136 | 137 | :return: either "daily", "monthly" or "multiannual" 138 | """ 139 | return self._datatype 140 | 141 | @property 142 | def statistics(self) -> dict: 143 | """ 144 | Returns statistics from the REGNIE data 145 | 146 | :return: dictionary of statistical values 147 | """ 148 | # extract only valid cells 149 | if self.datatype == "daily": 150 | valid_data = np.extract(~np.isnan(self.data), self.data) 151 | elif self.datatype in ["monthly", "multiannual"]: 152 | valid_data = np.extract(self.data != -999, self.data) 153 | 154 | stats = { 155 | "total_pixels": self.data.size, 156 | "valid_pixels": valid_data.size, 157 | "non_valid_pixels": self.data.size - valid_data.size, 158 | "max": np.max(valid_data), 159 | "min": np.min(valid_data), 160 | "mean": np.mean(valid_data) 161 | } 162 | 163 | return stats 164 | -------------------------------------------------------------------------------- /symbology/WN.qml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | 1 6 | 1 7 | 0 8 | 9 | 10 | 11 | 2022-12-24T21:56:00Z 12 | 2022-12-24T22:00:00Z 13 | 14 | 15 | 16 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | None 38 | WholeRaster 39 | Estimated 40 | 0.02 41 | 0.98 42 | 2 43 | 44 | 45 | 46 | 47 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | resamplingFilter 97 | 98 | 0 99 | 100 | -------------------------------------------------------------------------------- /classes/SettingsTab.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from qgis.core import QgsProject 5 | from qgis.PyQt.QtWidgets import QFileDialog 6 | 7 | # own classes: 8 | from .ActionTabBase import ActionTabBase # base class 9 | 10 | 11 | 12 | class SettingsTab(ActionTabBase): 13 | """ 14 | SettingsTab 15 | 16 | Separate __actions__ of setting operations from the other components 17 | 18 | Created on 20.12.2020 19 | @author: Weatherman 20 | """ 21 | 22 | 23 | def __init__(self, iface, model, dock): 24 | super().__init__(iface, model, dock) 25 | 26 | self.dock.btn_select_storage_dir.clicked.connect(self._select_storage_dir) 27 | self.dock.btn_save.clicked.connect(self._write_new_storage_location) 28 | 29 | """ 30 | fill projections 31 | Qt-element is connected with the projection list, via same index 32 | """ 33 | 34 | for number, proj_desc in model.dict_projections.items(): 35 | if number > 999: # 4 digits expected 36 | entry = proj_desc 37 | else: # !: list type expected: desc, proj4-string 38 | assert type(proj_desc) is list 39 | entry, _ = proj_desc 40 | 41 | self.dock.cbbox_projections.addItem(entry) 42 | # for 43 | 44 | 45 | 46 | """ 47 | Actions 48 | """ 49 | 50 | def _select_storage_dir(self): 51 | dock = self.dock # shorten 52 | 53 | start_dir = dock.textfield_path.text() 54 | # We assume a set up file here: 55 | if not start_dir: 56 | start_dir = str(Path.home()) 57 | 58 | # parent, caption, directory, file_filter 59 | selected_dir = QFileDialog.getExistingDirectory(dock, 'Select RADOLAN/RADKLIM directory', start_dir, 60 | # these additional parameters are used, because QFileDialog otherwise doesn't start with the given path: 61 | QFileDialog.DontUseNativeDialog) 62 | 63 | if not selected_dir: 64 | return # preserve evtl. filled line 65 | 66 | # NOT 'radolan2map/radolan2map'! 67 | const_last_part = 'radolan2map' 68 | if not selected_dir.endswith(const_last_part): 69 | complete_path = Path(selected_dir) / const_last_part 70 | else: 71 | complete_path = selected_dir 72 | 73 | # Set: 74 | self.storage_dir = complete_path 75 | 76 | # after folder selection enable save button: 77 | dock.btn_save.setEnabled(True) 78 | 79 | 80 | 81 | def update_projection_based_on_current_project(self): 82 | """ 83 | - Determines projection (EPSG code) of current project 84 | - insert it in the list of projections 85 | - and choose it. """ 86 | 87 | self.out("update_projection_based_on_current_project()") 88 | 89 | if not QgsProject.instance().fileName(): # if project loaded: 90 | return 91 | 92 | epsg_code = self._iface.mapCanvas().mapSettings().destinationCrs().authid() 93 | # Problem occured on Windows 10 with QGIS 3.10: empty 'epsg_code'. 94 | # Maybe with the application of "setDestinationCrs()" in 'create_default_project()' the problem isn't existent anymore. 95 | if not epsg_code: 96 | self.out("project CRS couldn't be determined; this is a unknown problem on Windows - not critical", 97 | False) 98 | return 99 | 100 | def add_projection(projection_description, epsg_code): 101 | """ add projection to ComboBox and a list with epsg codes, 102 | which corresponds each other. """ 103 | self.dock.cbbox_projections.addItem(projection_description) 104 | self._model.projections.append(epsg_code) 105 | 106 | # add only, if this projection doesn't exist: 107 | if not epsg_code in self._model.projections: 108 | projection_description = f"Project: {epsg_code}" 109 | add_projection(projection_description, epsg_code) 110 | # projection appended last, select last entry: 111 | index = len(self._model.projections) - 1 # -1 as last index doesn't work! 112 | self.dock.cbbox_projections.setCurrentIndex(index) 113 | 114 | 115 | def _write_new_storage_location(self): 116 | """ triggered by save button """ 117 | 118 | path_to_save = self.storage_dir # this dir probably doesn't exist, so we can't check it, we need the parent 119 | parent_dir = path_to_save.resolve().parent 120 | 121 | # protection: check if writetable: 122 | if not os.access(parent_dir, os.W_OK): 123 | l_msg = [] 124 | l_msg.append(f'Directory "{parent_dir}" is not writable!') 125 | # User hint for Windows: 126 | if str(parent_dir).startswith('C:'): 127 | l_msg.append("(as a user, you probably do not have write permissions directly on C:)") 128 | l_msg.append('\nPlease choose another one.') 129 | msg = '\n'.join(l_msg) 130 | super()._show_critical_message_box(msg, 'Write permission error') 131 | return 132 | 133 | data_root_def_file = self._model.data_root_def_file 134 | 135 | with data_root_def_file.open('w') as f: 136 | f.write(str(path_to_save)) 137 | 138 | self.out("_write_new_storage_location()") 139 | print(f" saved path '{path_to_save}' to file '{data_root_def_file}'") 140 | 141 | dock = self.dock # shorten 142 | dock.btn_save.setEnabled(False) # show user, that action was performed 143 | # update new data path: 144 | self._model.data_root = Path(path_to_save) 145 | 146 | dock.tabWidget.setTabEnabled(dock.TAB_RADOLAN_LOADER, True) 147 | dock.tabWidget.setTabEnabled(dock.TAB_RADOLAN_ADDER, True) 148 | dock.tabWidget.setTabEnabled(dock.TAB_REGNIE, True) 149 | 150 | # .......................................................... 151 | 152 | @property 153 | def storage_dir(self): 154 | return Path(self.dock.textfield_path.text()) 155 | @storage_dir.setter 156 | def storage_dir(self, d): 157 | self.dock.textfield_path.setText(str(d)) 158 | 159 | -------------------------------------------------------------------------------- /classes/ActionTabRegnie.py: -------------------------------------------------------------------------------- 1 | """ 2 | ActionTabRegnie 3 | 4 | Separate __actions__ of REGNIE operations from the other components 5 | 6 | Created on 01.12.2020 7 | @author: Weatherman 8 | """ 9 | from pathlib import Path 10 | 11 | from qgis.PyQt.QtWidgets import QFileDialog 12 | 13 | # own classes: 14 | from .ActionTabBase import ActionTabBase # base class 15 | from .LayerLoader import LayerLoader 16 | from .Regnie import Regnie 17 | from . import regnie2raster as r2r 18 | 19 | class ActionTabRegnie(ActionTabBase): 20 | ''' 21 | classdocs 22 | ''' 23 | 24 | 25 | def __init__(self, iface, model, dock): 26 | ''' 27 | Constructor 28 | ''' 29 | 30 | super().__init__(iface, model, dock) 31 | 32 | 33 | self.dock.btn_select_regnie.clicked.connect(self._select_regnie_file) 34 | self.dock.btn_load_regnie.clicked.connect(self._run) 35 | 36 | 37 | 38 | 39 | """ 40 | Actions 41 | """ 42 | 43 | def _select_regnie_file(self): 44 | text = self.regnie_file 45 | start_dir = Path(text).parent if text else Path.home() 46 | 47 | # after title string: start path, file_filter 48 | regnie_file, _ = QFileDialog.getOpenFileName(self.dock, "Please select a REGNIE file", 49 | str(start_dir), None, 50 | # these additional parameters are used, because QFileDialog otherwise doesn't start with the given path: 51 | None, QFileDialog.DontUseNativeDialog) 52 | if not regnie_file: 53 | return # keep path anyway 54 | 55 | self.regnie_file = regnie_file # -> enable button 56 | 57 | 58 | 59 | 60 | def _run(self): 61 | self.out("_run()") 62 | 63 | # shorten: 64 | regnie_file = self.regnie_file 65 | dock = self.dock 66 | 67 | # field was leaved empty 68 | if not regnie_file: 69 | super()._show_critical_message_box("REGNIE input file wasn't specified!") 70 | dock.btn_load_regnie.setEnabled(False) 71 | return 72 | 73 | 74 | regnie_file = Path(regnie_file) 75 | 76 | if not regnie_file.exists(): 77 | super()._show_critical_message_box(f"File\n'{regnie_file}'\nnot found!") 78 | dock.btn_load_regnie.setEnabled(False) 79 | return 80 | 81 | 82 | self._model.set_data_dir('regnie') 83 | data_dir = self._model.data_dir 84 | 85 | try: # include everything that can raise exceptions 86 | try: 87 | self._model.create_storage_folder_structure() 88 | self.out(f"create data dir for converted REGNIE: '{data_dir}'") 89 | except FileExistsError: 90 | pass 91 | 92 | # instantiate a Regnie class instance 93 | rg = Regnie(regnie_file) 94 | self.out(f"Detected REGNIE datatype: {rg.datatype}") 95 | 96 | # convert to raster 97 | self.out("Starting REGNIE to raster conversion...") 98 | regnie_raster_file = data_dir / f"{regnie_file.name.replace('.gz', '')}.tif" 99 | try: 100 | #TODO: this currently reads the REGNIE file a second time 101 | r2r.regnie2raster(regnie_file, regnie_raster_file) 102 | except ModuleNotFoundError as e: 103 | """ Under Linux a "ModuleNotFoundError: No module named '_gdal'" 104 | error can occur. """ 105 | msg = f"Exception: {e}:\nPossibly a GDAL installation error on Linux(?)" 106 | self.out(f"{msg}") 107 | super()._show_critical_message_box(msg, 'REGNIE raster conversion error') 108 | return 109 | except Exception as e: 110 | msg = f"Exception: {e}" 111 | self.out(f"{msg}") 112 | super()._show_critical_message_box(msg, 'REGNIE raster conversion error') 113 | return 114 | 115 | self.out(f"Successfully created REGNIE raster file {regnie_raster_file}!") 116 | 117 | except Exception as e: 118 | msg = f"{e}, wrong format!" 119 | super()._show_critical_message_box(msg, 'Layer loading error') # disable here, because it was only set a folder 120 | dock.btn_load_regnie.setEnabled(False) 121 | # Reset - don't save the wrong data for the next run!: 122 | #dock.text_regnie.clear() 123 | #self.regnie_file = str(regnie_file.parent) # nevertheless save last path for next suggestion 124 | return 125 | 126 | 127 | """ 128 | Add the layers ... 129 | """ 130 | 131 | # Determine prepared QML-File delivered with the plugin: 132 | d_regnie_qml = { 133 | 'daily': "regnie_raster_daily.qml", 134 | 'monthly': "regnie_raster_monthly.qml", 135 | 'multiannual': "regnie_raster_yearly.qml", 136 | } 137 | 138 | ll = LayerLoader(self._iface) # 'iface' is from 'radolan2map' 139 | 140 | # load the regnie raster file 141 | qml_file = self._model.symbology_path / d_regnie_qml[rg.datatype] 142 | ll.load_raster(regnie_raster_file, qml_file) 143 | 144 | # fill statistics output 145 | stats = rg.statistics 146 | dock.text_filename.setText(regnie_file.name.replace(".gz", "")) 147 | dock.text_shape.setText(f"{rg.data.shape}") 148 | dock.text_max.setText(f"{stats['max']:.1f}") 149 | dock.text_min.setText(f"{stats['min']:.1f}") 150 | dock.text_mean.setText(f"{stats['mean']:.1f}") 151 | dock.text_total_pixels.setText(f"{stats['total_pixels']}") 152 | dock.text_valid_pixels.setText(f"{stats['valid_pixels']}") 153 | dock.text_nonvalid_pixels.setText(f"{stats['non_valid_pixels']}") 154 | 155 | super()._enable_and_show_statistics_tab() 156 | super()._finish() 157 | 158 | # ...................................................................... 159 | 160 | 161 | # for saving: 162 | 163 | @property 164 | def regnie_file(self): 165 | return self.dock.text_regnie.text() 166 | @regnie_file.setter 167 | def regnie_file(self, f): 168 | self.dock.text_regnie.setText(f) 169 | # after folder selection enable load button: 170 | self.dock.btn_load_regnie.setEnabled(True) 171 | 172 | 173 | -------------------------------------------------------------------------------- /metadata.txt: -------------------------------------------------------------------------------- 1 | # This file contains metadata for your plugin. 2 | # This file should be included when you package your plugin. 3 | # Mandatory items: 4 | 5 | [general] 6 | name=radolan2map 7 | qgisMinimumVersion=3.0 8 | # short text which describes the plugin, no HTML allowed: 9 | description=Brings DWD precipitation products like RADOLAN, RADKLIM and REGNIE onto a map 10 | version=1.8 11 | author=Weatherman 12 | email=radolan2map@e.mail.de 13 | 14 | # more detailed description: 15 | about=This QGIS plugin brings precipitation radar data from German Meteorological Service (DWD, https://www.dwd.de) in RADOLAN/RADKLIM binary format in any grid dimension as well as REGNIE on a map 16 | You also can **add** up RADOLAN data! 17 | Applications: hydrometeorological and agricultural analyses. 18 | 19 | RADOLAN overview: https://dwd.de/RADOLAN 20 | RADOLAN data (recent): https://opendata.dwd.de/climate_environment/CDC/grids_germany/hourly/radolan/recent/bin 21 | RADKLIM data (radar climatology): https://opendata.dwd.de/climate_environment/CDC/grids_germany/hourly/radolan/reproc/2017_002/bin 22 | Radar products on DWD Open Data: https://opendata.dwd.de/weather/radar/composit/ 23 | 24 | A RADOLAN-RW-testfile (gzipped binary) is included in directory 'example/sample_file/'. 25 | 26 | The plugin was successfully tested with 27 | - QGIS 3.18 on Windows 10 28 | - QGIS 3.36 Linux openSUSE Tumbleweed 29 | 30 | Please restart QGIS after updating the plugin! 31 | 32 | Code Repository: https://github.com/Weathermann/radolan2map 33 | 34 | More info about installation/usage: https://gitlab.com/Weatherman_/radolan2map/wikis/home 35 | 36 | 37 | tracker=https://github.com/Weathermann/radolan2map/issues 38 | repository=https://github.com/Weathermann/radolan2map 39 | # End of mandatory metadata 40 | 41 | # Recommended items: 42 | 43 | hasProcessingProvider=no 44 | # Uncomment the following line and add your changelog: 45 | # string, can be multiline, no HTML allowed 46 | changelog= 47 | 1.8, 2024-04-21: 48 | * Functionality 49 | - allow multiselect in RADOLAN load tab 50 | - automatically layer grouping (day), collapsed 51 | - reduced log output for performance reason 52 | * fix 53 | - temporal settings (after project reload; UTC error?) 54 | 1.7, 2023-01-23: 55 | * Functionality - new products: display of the 56 | - HG product (precipitation type): https://opendata.dwd.de/weather/radar/composit/hg/ 57 | - WN product (reflectivity product): https://opendata.dwd.de/weather/radar/composit/wn/ 58 | - bzip2 format is now readable directly ('HG'), not 'tar.bz2' ('WN') 59 | * Projection 60 | - ASCIIGridWriter: added projection for WN (lower left corner) 61 | - added projection for stereographic projection based on WGS ellipsoid (POLARA products) 62 | * correction: german radar network: radius 150 km 63 | 1.6, 2021-12-04: 64 | - REGNIE files are now converted to GeoTIFF rasters instead of CSV 65 | 1.5, 2021-02-26: Bugfix version 66 | - ### bug fixed that prevents defining the data dir ### 67 | - fixed a possible bug at RADOLAN adding (handling of NaN values) 68 | - optimization: RADOLAN adder file access: no glob for every file any more 69 | - display of new features, which is presented to the user once 70 | 1.4, 2020-12-26: Christmas gift: "radolan summation version" 71 | - in a separate tab you can now generate a sum product based on individual RADOLAN files! 72 | - revised handling of 'interval unit' to assign the proper QML color scale 73 | - redesign of internal processes (tasks per tab) 74 | - the last used processing tab is saved (will be shown on the next run) 75 | - you can define your own logo in 'config.ini' for usage in print layout 76 | 1.3, 2020-05-21: "print layout version" 77 | - print layout implemented 78 | - catch global exception, show it to the user as a window 79 | - catch exception at reading corrupted RADOLAN bin file and show this in GUI window 80 | - links to REGNIE download site included 81 | - layer storage folder reorganized - root and data dir 82 | - separate subdirectories for the different data types: 'regnie', 'radolan', 'radklim' 83 | 1.2, 2020-01-24: "REGNIE version" 84 | - from now 'radolan2map' can load REGNIE raster files (daily, monthly, yearly) 85 | but the support is rudimentary (data is displayed as point layer) 86 | - new button which loads DWDs radar network from layer definition file (qlr) 87 | 1.1, 2019-12-10: 88 | - current DWD radar network in template project included 89 | - combo box for selection of destination projection 90 | - settings are saved 91 | - Checks: writable check of user defined storage folder and mask file 92 | - data download link included, icons for links 93 | 1.0, 2019-11-17: "Tab-Version" 94 | - with this extension it is time for a final version now 95 | - tab: statistics about the processed product 96 | - product history: stores history of selected products 97 | 0.7, 2019-10-30: "toolbar version" 98 | - Toolbar icons 99 | - switch to alternative Qt-dialog, because of non-working start directory 100 | - test product on the first 2 characters to determine if it is a "X-product" 101 | so it is independent from filename 102 | 0.6, 2019-10-09: 103 | - Bugfix: select mask button 104 | - 'data dir def file' is stored in QGIS profile folder now, so that it is not lost by plugin update 105 | 0.5, 2019-10-07: layer storage version 106 | - request to the user where the data directory should be set up 107 | - cut GeoTIFF directly using shapefile 108 | - GUI extension: conversion of RVP6 units -> mm / 5min 109 | - Optimization of the GUI elements 110 | 0.4, 2019-09-27: RX version 111 | - RX processing, also WX, EX 112 | - hopefully improved floating QDockWidget 113 | - template project with state capitals of Germany (as GeoPackage now) 114 | - revised symbology QMLs 115 | 0.3, 2019-09-19: new function: 'exclude zeroes', fixed clipping problem (Windows), using "DEU_adm0" as border dataset now 116 | 0.2, 2019-09-15: changed from QDialog to QDockWidget 117 | 0.1, 2019-09-02: first release, plugin uses QDialog, experimental state 118 | 119 | # Tags are comma separated with spaces allowed 120 | tags=DWD,radar,RADKLIM,RADOLAN,REGNIE,raster,precipitation,QPE 121 | 122 | homepage=https://gitlab.com/Weatherman_/radolan2map/wikis/home 123 | # Category of the plugin: Raster, Vector, Database or Web 124 | category=Raster 125 | icon=img/icon.png 126 | # experimental flag 127 | experimental=False 128 | 129 | # deprecated flag (applies to the whole plugin, not just a single version) 130 | deprecated=False 131 | 132 | # Since QGIS 3.8, a comma separated list of plugins to be installed 133 | # (or upgraded) can be specified. 134 | # Check the documentation for more information. 135 | # plugin_dependencies= 136 | 137 | # If the plugin can run on QGIS Server. 138 | server=False 139 | 140 | -------------------------------------------------------------------------------- /classes/def_products.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # String: String 4 | # product id, product description 5 | dict_titles = { 6 | 7 | # RADOLAN (online) base products: 8 | 'EY': "Europäische quantitative Radardaten, qualitätskorrigiert", 9 | 'EZ': "Europäische quantitative Radardaten, nicht qualitätskorrigiert", 10 | 'RH': "(qualitätsgeprüfte) Radardaten nach Abschattungskorrektur und nach Anwendung der verfeinerten Z-R-Beziehung in Niederschlagshöhen umgerechnet und auf eine Stunde aufsummiert", 11 | 'RB': "Radardaten, vorangeeicht mit einem Faktor, Stundensumme", 12 | 'RW': "Radardaten nach Aneichung mit der gewichteten Mittelung aus zwei Standardverfahren, Stundensumme", 13 | 'RO': "Radardaten nach Anwendung der Standard-Z-R-Beziehung in Niederschlagshöhen umgerechnet", 14 | 'RY': "Qualitätsgeprüfte Radardaten nach Abschattungskorrektur und nach Anwendung der verfeinerten Z-R-Beziehung in Niederschlagshöhen umgerechnet", 15 | 'RZ': "Radardaten nach Abschattungskorrektur und nach Anwendung der verfeinerten Z-R-Beziehung in Niederschlagshöhen umgerechnet", 16 | 17 | # hourly/daily sums: 18 | 'S2': "2h-Niederschlagssumme", 19 | 'S3': "3h-Niederschlagssumme", 20 | 'SH': "12h-Niederschlagssumme", 21 | 'SF': "24h-Niederschlagssumme", 22 | 'D2': "48h-Niederschlagssumme", 23 | 'D3': "72h-Niederschlagssumme", 24 | 25 | "DC": "Anzahl Stunden (h) am Tag mit Clutter-Kennung", 26 | "DF": "Anzahl Stunden (h) am Tag mit Fehlwert", 27 | "DI": "Tagesniederschlagssumme aus REGNIE", 28 | "DK": u"Tagesniederschlagssumme aus RW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 29 | "DM": "Max. 1h-Niederschlagssumme des Tages", 30 | "DR": u"Anzahl Stunden (h) am Tag mit Niederschlag ausschließlich aus Bodenmessung", 31 | "DS": "Anzahl Stunden (h) am Tag mit Niederschlag ≥ 0.1 mm", 32 | "DU": u"Anzahl Stunden (h) am Tag an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 33 | "DW": "Tagesniederschlagssumme", 34 | "GA": "Skalierte Gesamtzeitraumsniederschlagssumme", 35 | "GC": "Anzahl Stunden (h) im Gesamtzeitraum mit Clutter-Kennung", 36 | "GF": "Anzahl Stunden (h) im Gesamtzeitraum mit Fehlwert", 37 | "GI": "Gesamtzeitraumsniederschlagssumme aus REGNIE", 38 | "GK": u"Gesamtzeitraumsniederschlagssumme aus DW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 39 | "GM": "Max. 1h-Niederschlagssumme des Gesamtzeitraums", 40 | "GS": "Anzahl Stunden (h) im Gesamtzeitraum mit Niederschlag ≥ 0.1 mm", 41 | "GU": u"Anzahl Stunden (h) im Gesamtzeitraum an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 42 | "GW": "Gesamtzeitraumsniederschlagssumme", 43 | "HA": "Skalierte Halbjahresniederschlagssumme", 44 | "HC": "Anzahl Stunden (h) im Halbjahr mit Clutter-Kennung", 45 | "HF": "Anzahl Stunden (h) im Halbjahr mit Fehlwert", 46 | "HI": "Halbjahresniederschlagssumme aus REGNIE", 47 | "HK": u"Halbjahresniederschlagssumme aus DW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 48 | "HM": "Max. 1h-Niederschlagssumme der Jahreszeit", 49 | "HS": "Anzahl Stunden (h) im Halbjahr mit Niederschlag ≥ 0.1 mm", 50 | "HU": u"Anzahl Stunden (h) im Halbjahr an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 51 | "HW": "Halbjahresniederschlagssumme", 52 | "JA": "Skalierte Jahresniederschlagssumme", 53 | "JC": "Anzahl Stunden (h) im Jahr mit Clutter-Kennung", 54 | "JF": "Anzahl Stunden (h) im Jahr mit Fehlwert", 55 | "JI": "Jahresniederschlagssumme aus REGNIE", 56 | "JK": u"Jahresniederschlagssumme aus DW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 57 | "JM": "Max. 1h-Niederschlagssumme des Jahres", 58 | "JS": "Anzahl Stunden (h) im Jahr mit Niederschlag ≥ 0.1 mm", 59 | "JU": u"Anzahl Stunden (h) im Jahr an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 60 | "JW": "Jahresniederschlagssumme", 61 | "MA": "Skalierte Monatsniederschlagssumme", 62 | "MC": "Anzahl Stunden (h) im Monat mit Clutter-Kennung", 63 | "MF": "Anzahl Stunden (h) im Monat mit Fehlwert", 64 | "MI": "Monatsniederschlagssumme aus REGNIE", 65 | "MK": "Monatsniederschlagssumme aus DW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 66 | "MM": "Max. 1h-Niederschlagssumme des Monats", 67 | "MS": "Anzahl Stunden (h) im Monat mit Niederschlag ≥ 0.1 mm", 68 | "MU": u"Anzahl Stunden (h) im Monat an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 69 | "MW": "Monatsniederschlagssumme", 70 | "QA": "Skalierte Jahreszeitenniederschlagssumme", 71 | "QC": "Anzahl Stunden (h) in der Jahreszeit mit Clutter-Kennung", 72 | "QF": "Anzahl Stunden (h) in der Jahreszeit mit Fehlwert", 73 | "QI": "Jahreszeitenniederschlagssumme aus REGNIE", 74 | "QK": u"Jahreszeitenniederschlagssumme aus DW. Fehlwerte werden, wenn verfügbar, mit Werten aus REGNIE aufgefüllt.", 75 | "QM": "Max. 1h-Niederschlagssumme der Jahreszeit", 76 | "QS": "Anzahl Stunden (h) in der Jahreszeit mit Niederschlag ≥ 0.1 mm", 77 | "QU": u"Anzahl Stunden (h) in der Jahreszeit an denen der Bemessungsniederschlag (T > 1a) überschritten wird", 78 | "QW": "Jahreszeitenniederschlagssumme", 79 | "SJ": "Niederschlagssumme seit Jahresbeginn", 80 | "SM": "Niederschlagssumme seit Monatsbeginn", 81 | "SQ": "6h-Niederschlagssumme", 82 | "SY": "Niederschlagssumme für das hydrologische Jahr (Beginn November)", 83 | "UA": u"Anzahl Stunden (h) am Tag mit Überschreitung der Starkregen-Warnstufe 2 (markantes Wetter)", 84 | "UB": u"Anzahl Stunden (h) am Tag mit Überschreitung der Starkregen-Warnstufe 3 (Unwetter)", 85 | "UC": u"Anzahl Stunden (h) am Tag mit Überschreitung der Starkregen-Warnstufe 4 (extremes Unwetter)", 86 | "UD": u"Anzahl Stunden (h) am Tag mit Überschreitung der Dauerregen-Warnstufe 2 (markantes Wetter)", 87 | "UE": u"Anzahl Stunden (h) am Tag mit Überschreitung der Dauerregen-Warnstufe 3 (Unwetter)", 88 | "UF": u"Anzahl Stunden (h) am Tag mit Überschreitung der Dauerregen-Warnstufe 4 (extremes Unwetter)", 89 | "W1": "7d-Niederschlagssumme basierend auf RW-Produkt", 90 | "W2": "14d-Niederschlagssumme basierend auf RW-Produkt", 91 | "W3": "21d-Niederschlagssumme basierend auf RW-Produkt", 92 | "W4": "30d-Niederschlagssumme basierend auf RW-Produkt", 93 | 'YW': "quasi-angeeichte 5-Minuten-Niederschlagsraten (RY nach Skalierung mit dem Quasianeichungsfaktor berechnet aus Verhältnis RW zu RH)", 94 | 95 | # X-products - in RVP6 units: 96 | 'RX': "Radardaten ohne Korrektur (in RVP6-Units)", 97 | 'WX': "Qualitätsgeprüfte Radardaten (in RVP6-Units)", 98 | 'EX': "Europäische Radardaten (in RVP6-Units)", 99 | 100 | # B = R-factor products: 101 | "BR": "Ereignisbasierter R-Faktor nach DIN19708 [kJm2*mm/h]", 102 | "BD": "Dauer des Ereignisses in Stunden ([h])", 103 | "BK": "Kinetische Energie des Ereignisses [kJ/m2]", 104 | "BI": "Maximale 30-Minuten-Intensität [mm/h]", 105 | "BJ": "Mittlerer Jahres-R-Faktor [kJm2*mm/h]", 106 | "BQ": "Mittlerer Jahreszeit-R-Faktor [kJm2*mm/h]", 107 | "BM": "Mittlerer Monats-R-Faktor [kJm2*mm/h]", 108 | "BY": "R-Faktor (Jahressumme) [kJm2*mm/h]", 109 | 110 | # Not RADOLAN: 111 | "HG": "Deutschlandkomposit der Niederschlagsart in 2m Höhe über Grund", 112 | "WN": "Deutschlandkomposit der Reflektivität", 113 | } 114 | -------------------------------------------------------------------------------- /classes/ActionTabBase.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | import time 4 | 5 | from qgis.core import QgsProject, QgsPrintLayout, QgsReadWriteContext 6 | from qgis.PyQt.QtWidgets import QMessageBox 7 | # For the QGIS printing template: 8 | from qgis.PyQt.QtXml import QDomDocument 9 | 10 | 11 | class ActionTabBase: 12 | """ ActionTabBase 13 | 14 | Base class of all specialized tab classes 15 | 16 | ActionTab classes act as it's own controllers 17 | 18 | Created on 19.12.2020 19 | @author: Weatherman 20 | """ 21 | 22 | def __init__(self, iface, model, dock): 23 | print(self) 24 | 25 | self._iface = iface 26 | self._model = model 27 | self.dock = dock 28 | 29 | def __str__(self): 30 | return self.__class__.__name__ 31 | 32 | def out(self, s, ok=True): 33 | if ok: 34 | print(f"{self}: {s}") 35 | else: 36 | print(f"{self}: {s}", file=sys.stderr) 37 | 38 | def _show_critical_message_box(self, msg, caption='Exception catched'): 39 | self.out(msg, False) 40 | QMessageBox.critical(self._iface.mainWindow(), caption, msg) 41 | 42 | def _enable_and_show_statistics_tab(self): # TODO 11.03.2024: replace with method in 'gui.py'? 43 | self.dock.tabWidget.setTabEnabled(self.dock.TAB_STATISTICS, True) 44 | self.dock.tabWidget.setCurrentIndex(self.dock.TAB_STATISTICS) # show statistics tab 45 | 46 | def _check_create_project(self): 47 | """ 48 | If no project file not loaded when running plugin 49 | """ 50 | project = QgsProject.instance() 51 | project_fn = project.fileName() 52 | # for inspection of the project file, to determine, if it is a real file: 53 | # QGIS 3.22: seems to be always 'True' for exists() (?) 54 | #print(f"### project={project}, Path={project_fn}, exists={Path(project_fn).exists()}") 55 | 56 | # prepare project 57 | # QGIS 3.22: seems to be always 'True' for exists() (?), so check for content too: 58 | if project_fn and Path(project_fn).exists(): 59 | return 60 | 61 | self.out("### no project open -> create a new one ###") 62 | project = self._model.create_default_project() 63 | 64 | yyyymmddHHMM_with_ext = time.strftime("%Y%m%d_%H%M") + '.qgs' 65 | new_file = self._model.data_root / yyyymmddHHMM_with_ext 66 | self.out(f"write: {new_file}") 67 | project.write(str(new_file)) 68 | 69 | def _finish(self): 70 | """ prints a unified message at end of operation """ 71 | self.out("*** Whole process finished! ***") 72 | 73 | def _load_print_layout(self, layer_name, prod_id, dt=None): 74 | window_title = "Print" # will be checked with a found composer title 75 | 76 | """ 77 | Avoid creating a new PrintComposer again and again here. 78 | Otherwise more and more composers will be added to the list - even with the same name. 79 | """ 80 | 81 | project = QgsProject.instance() 82 | # From it, you can get the current layoutManager instance and deduce the layouts 83 | layout_manager = project.layoutManager() 84 | 85 | layout = layout_manager.layoutByName(window_title) 86 | 87 | if not layout: 88 | self.out("no composer found; creating one...") 89 | 90 | # Load the template into the composer 91 | # QGIS 2: 92 | #active_composer = self.iface.createNewComposer(window_title) #createNewComposer() 93 | #active_composer = QgsComposition(QgsProject.instance()) 94 | 95 | #layout = QgsLayout(project) 96 | layout = QgsPrintLayout(project) 97 | layout.initializeDefaults() # initializes default settings for blank print layout canvas 98 | 99 | q_xmldoc = self._create_qdocument_from_print_template_content() 100 | 101 | # load layout from template and add to Layout Manager 102 | #layout.composition().loadFromTemplate(q_xmldoc) # QGIS 2 103 | layout.loadFromTemplate(q_xmldoc, QgsReadWriteContext()) 104 | layout.setName(window_title) 105 | 106 | layout_manager.addLayout(layout) 107 | 108 | # Update Logo: 109 | 110 | #logo_item = layout.getComposerItemById('logo') # QGIS 2 111 | logo_item = layout.itemById('logo') 112 | logo_image = self._model.logo_path 113 | self.out(f"Logo: {logo_image}") 114 | if logo_image.exists(): 115 | logo_item.setPicturePath(str(logo_image)) 116 | else: 117 | self.out(f" ERROR: logo '{logo_image}' not found!", False) 118 | # if 119 | 120 | 121 | """ 122 | Hier versuche ich ein für die Überschrift mit einer ID ('headline') versehenes 123 | QgsLabel aus dem Template ausfindig zu machen. Ich mache das hier sehr kompliziert, 124 | es gibt bestimmt einen einfacheren Weg. 125 | Folgendes hat NICHT funktioniert: 126 | map_item = active_composer.getComposerItemById('headline') 127 | print(active_composer.items()) 128 | liefert: [, , ... ] 129 | """ 130 | 131 | ''' other possibility: 132 | for item in list(layout.items()): 133 | #if type(item) != QgsComposerLabel: 134 | ''' 135 | 136 | # QgsComposerLabel: 137 | composer_label = layout.itemById('headline') # a QgsComposerLabel was provided with the ID 'headline' in the template 138 | # -> None if not found 139 | 140 | if composer_label: 141 | title = self._model.title(prod_id, dt) 142 | subtitle = "" 143 | if prod_id != 'RW': 144 | subtitle = f"\n{prod_id}-Produkt (Basis: RW)" 145 | 146 | composer_label.setText(title + subtitle) 147 | else: 148 | # A note that the template needs to be revised: 149 | self.out("no element with id 'headline' found!", False) 150 | 151 | 152 | legend = layout.itemById('legend') 153 | 154 | if not legend: 155 | self.out("legend couldn't created!", False) 156 | return 157 | 158 | # 159 | # Layer für die Legende ausfindig machen 160 | # 161 | 162 | # You would just need to make sure your layer has a name you can distinguish from others. Instead of: 163 | # Vorherige Version: 164 | #active_raster_layer = self.iface.activeLayer() 165 | 166 | # do: 167 | l_layer = project.mapLayersByName(layer_name) 168 | 169 | if not l_layer: 170 | self.out(f"legend: no layer found with name '{layer_name}'!", False) 171 | return 172 | 173 | active_raster_layer = l_layer[0] 174 | 175 | #print("Legend active_raster_layer id:", active_raster_layer.id()) # ok 176 | #print("Legend active_raster_layer name:", active_raster_layer.name()) # ok 177 | #legend.model().setLayerSet([layer.id() for layer in layers]) 178 | #legend.model().setLayerSet([active_raster_layer.id()]) # bringt nichts 179 | # DAS ist es! Dies fügt zumindest erstmal das interessierende Rasterlayer hinzu: 180 | #legend.modelV2().rootGroup().addLayer(active_raster_layer) 181 | #legend.updateLegend() 182 | 183 | #for layout in layout_manager.printLayouts(): # iterate layouts 184 | 185 | ''' would be ok, if we want to create a new legend -> then legend appears at the upper left corner 186 | legend = QgsLayoutItemLegend(layout) 187 | #legend.setTitle('Legend') 188 | legend.setAutoUpdateModel(False) 189 | group = legend.model().rootGroup() 190 | group.clear() 191 | group.addLayer(active_raster_layer) 192 | layout.addItem(legend) 193 | legend.adjustBoxSize() 194 | #legend.refresh() # avoids adding all other layers 195 | ''' 196 | 197 | # uses existing legend object (see above), so we preserve it's layout position: 198 | legend.setAutoUpdateModel(False) 199 | group = legend.model().rootGroup() 200 | group.clear() 201 | group.addLayer(active_raster_layer) 202 | legend.adjustBoxSize() 203 | 204 | """ By default the newly created composer items have zero position (top left corner of the page) and zero size. 205 | The position and size are always measured in millimeters. 206 | # set label 1cm from the top and 2cm from the left of the page 207 | composerLabel.setItemPosition(20, 10) 208 | # set both label’s position and size (width 10cm, height 3cm) 209 | composerLabel.setItemPosition(20, 10, 100, 30) 210 | A frame is drawn around each item by default. How to remove the frame: 211 | composerLabel.setFrame(False) 212 | """ 213 | #print(active_composer.rect().width(), active_composer.rect().height()) # 1054 911 214 | #print(self.iface.mapCanvas().size().width(), self.iface.mapCanvas().size().height()) # 1517 535 215 | # "Leinwandgröße": habe keine vernünftigen Werte oben ermittelt (vielleicht Pixel; ich brauche mm). 216 | # selbst, mittels Mauszeiger ermittelt: 217 | width = 210 218 | height = 297 219 | # Rand neben der Legende (mm): 220 | dw = 10 221 | dh = 14 222 | 223 | """ 224 | Doesn't work since QGIS 3: 225 | """ 226 | #self.out("In QGIS 3 the print layout doesn't work anymore. If you can do it ...", False) 227 | 228 | # nothing works 229 | #legendSize = legend.paintAndDetermineSize(None) 230 | #legend.setItemPosition(width - legendSize.width() - dw, height - legendSize.height() - dh) 231 | #legend.setItemPosition(width - legend.width() - dw, height - legend.height() - dh) 232 | 233 | # Also note that active_composer.composerWindow() has a hide() and show() 234 | #active_composer.composerWindow().hide() # works 235 | 236 | def _create_qdocument_from_print_template_content(self): 237 | print_template = self._model.default_print_template 238 | 239 | self.out(f"_create_qdocument_from_print_template_content(): {print_template}") 240 | 241 | if not print_template: 242 | raise FileNotFoundError(f"{print_template}") 243 | 244 | # Load template 245 | with print_template.open() as templateFile: 246 | print_template_content = templateFile.read() 247 | 248 | q_xmldoc = QDomDocument() 249 | # If namespaceProcessing is true, the parser recognizes namespaces in the XML file and sets 250 | # the prefix name, local name and namespace URI to appropriate values. If namespaceProcessing 251 | # is false, the parser does no namespace processing when it reads the XML file. 252 | q_xmldoc.setContent(print_template_content, False) # , bool namespaceProcessing 253 | 254 | return q_xmldoc 255 | -------------------------------------------------------------------------------- /classes/gui.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | from datetime import datetime 4 | 5 | from qgis.PyQt import QtGui, QtWidgets, uic 6 | from qgis.PyQt.QtCore import pyqtSignal 7 | #from qgis.PyQt.QtWidgets import QFileDialog 8 | # QWidget, QListWidget, QGridLayout, QPushButton 9 | 10 | # not in 'classes', but directly in plugin folder: 11 | plugin_dir = Path(__file__).resolve().parent.parent 12 | 13 | image_dir = plugin_dir / 'img' 14 | 15 | """ 16 | Widget with the plugin functionality (DockWidget): 17 | """ 18 | WIDGET_FORM_CLASS, _ = uic.loadUiType(plugin_dir / 'dock_widget.ui') 19 | 20 | 21 | def get_icon(img_basename): 22 | """simplifies creating a QIcon""" 23 | img_full_path = image_dir / img_basename 24 | return QtGui.QIcon(str(img_full_path)) 25 | 26 | def get_image(img_basename): 27 | """simplifies creating a Image""" 28 | img_full_path = image_dir / img_basename 29 | return QtGui.QPixmap(str(img_full_path)) 30 | 31 | 32 | class DockWidget(QtWidgets.QDockWidget, WIDGET_FORM_CLASS): 33 | """ DockWidget 34 | ----------------- 35 | begin: 2016-08-26 36 | last: 2019-11 37 | """ 38 | 39 | # Indices of tabs: so that someone can easily change the order of the tabs 40 | TAB_RADOLAN_LOADER = 0 41 | TAB_RADOLAN_ADDER = 1 42 | TAB_REGNIE = 2 43 | TAB_STATISTICS = 3 44 | TAB_SETTINGS = 4 45 | TAB_ABOUT = 5 46 | 47 | closingPlugin = pyqtSignal() 48 | 49 | 50 | def __init__(self, parent=None): 51 | """ Constructor. """ 52 | super(DockWidget, self).__init__(parent) 53 | # Set up the user interface from Designer. 54 | # After setupUI you can access any designer object by doing 55 | # self., and you can use autoconnect slots - see 56 | # http://doc.qt.io/qt-5/designer-using-a-ui-file.html 57 | # #widgets-and-dialogs-with-auto-connect 58 | self.setupUi(self) 59 | 60 | self.setFloating(False) # prevent losing the widget 61 | # -> doesn't seem to have an effect -> deselected this property in the .ui-file 62 | # seems that is impossible to set this as initial property in QT Creator. 63 | """ If you want to prevent the user from moving it to a floating window 64 | you need to set the "features" of the widget. In the example below, 65 | the widget is movable and closable, but not floatable: """ 66 | #self.setFeatures(QtWidgets.QDockWidget.DockWidgetClosable | QtWidgets.QDockWidget.DockWidgetMovable) 67 | 68 | self.btn_close.clicked.connect(self.close) # global close button 69 | self.btn_close.setIcon(get_icon('close.png')) 70 | 71 | self._tab_index = 0 72 | self.tabWidget.currentChanged.connect(self._tab_changed) 73 | 74 | ############################ 75 | # Tab "RADOLAN single mode" 76 | ############################ 77 | 78 | folder_icon = get_icon('folder.png') 79 | 80 | self.tabWidget.setTabIcon(DockWidget.TAB_RADOLAN_LOADER, get_icon('execute.png')) 81 | # set toolbar button icons: 82 | self.btn_load_project.setIcon(get_icon('new.png')) 83 | self.btn_load_radars.setIcon(get_icon('radar.png')) 84 | 85 | self.filedialog_input.setIcon(folder_icon) 86 | self.filedialog_mask.setIcon(folder_icon) 87 | self.filedialog_qml.setIcon(folder_icon) 88 | 89 | self.widget_symb.setVisible(False) 90 | # Only enabled for RX products (check at every load): 91 | self.check_rvp6tomm.setVisible(False) 92 | 93 | # connect functions: 94 | #self.btn_info.clicked.connect(self.open_about_dialog) 95 | # passing parameters to connected method only possible with keyword 'lambda': 96 | self.check_cut.stateChanged.connect(lambda: self._checkbox_state_changed(self.check_cut)) 97 | self.check_symb.stateChanged.connect(lambda: self._checkbox_state_changed(self.check_symb)) 98 | self.check_rvp6tomm.stateChanged.connect(lambda: self._checkbox_state_changed(self.check_rvp6tomm)) 99 | 100 | #if not self.dock.inputpath.text(): 101 | # #self.dock.button_box.button(QDialogButtonBox.Cancel).setEnabled(True) 102 | # self.dock.button_box.button(QDialogButtonBox.Apply).setEnabled(False) 103 | # #self.out("OK button disabled -> please load a RADOLAN binary file first!") 104 | self.btn_action.setIcon(get_icon('execute.png')) 105 | self.btn_action.setEnabled(False) # initially disabled, need to load RADOLAN file 106 | 107 | # trigger deactivating clipping: 108 | self.check_cut.setChecked(False) 109 | self._checkbox_state_changed(self.check_cut) 110 | 111 | ############################ 112 | # Tab Statistics 113 | ############################ 114 | tab_no = DockWidget.TAB_STATISTICS 115 | #self.tabWidget.setTabEnabled(tab_no, False) # second tab "statistics" 116 | # -> only disabled, still visible, grayed 117 | #self.tabWidget.setTabVisible(tab_no, False) # direct, but see comment in method 118 | self.set_statistics_tab_visible(False) # this makes it invisible first 119 | self.tabWidget.setTabIcon(tab_no, get_icon('stats.png')) 120 | 121 | ############################ 122 | # Tab TIF storage 123 | ############################ 124 | 125 | self.tabWidget.setTabIcon(DockWidget.TAB_SETTINGS, get_icon('execute.png')) 126 | self.btn_select_storage_dir.setIcon(folder_icon) 127 | self.btn_save.setIcon(get_icon('save.png')) 128 | # save button is disabled by default 129 | self.btn_save.setEnabled(False) 130 | 131 | #self.setWindowIcon(get_icon('folder.png')) 132 | 133 | ############################ 134 | # Tab REGNIE 135 | ############################ 136 | 137 | self.tabWidget.setTabIcon(DockWidget.TAB_REGNIE, get_icon('regnie.png')) 138 | self.btn_select_regnie.setIcon(folder_icon) 139 | self.btn_load_regnie.setIcon(get_icon('regnie.png')) 140 | 141 | ############################ 142 | # Tab "RADOLANAdder" 143 | ############################ 144 | 145 | self.tabWidget.setTabIcon(DockWidget.TAB_RADOLAN_ADDER, get_icon('stack.png')) 146 | self.btn_select_dir_adder.setIcon(folder_icon) 147 | self.btn_scan.setIcon(get_icon('search.png')) 148 | self.btn_run_adder.setIcon(get_icon('execute.png')) 149 | 150 | ############################ 151 | # Tab "about" 152 | ############################ 153 | 154 | self.tabWidget.setTabIcon(DockWidget.TAB_ABOUT, get_icon('info.png')) 155 | 156 | # insert images: 157 | dt_today = datetime.today() 158 | # Christmas period? 159 | if dt_today.month == 12 and 20 <= dt_today.day <= 31: 160 | # set QMovie as label: 161 | movie = QtGui.QMovie(str(image_dir / 'weihnachten.gif')) 162 | # set 'ScaledContents' in QtDesigner to False or self.label_logo.setScaledContents(False) 163 | self.label_logo.setMovie(movie) 164 | movie.start() 165 | else: 166 | self.label_logo.setPixmap(get_image('plugin_logo.png')) 167 | 168 | self.label_img_info.setPixmap(get_image('sw_info.png')) 169 | self.label_img_download.setPixmap(get_image('sw_download.png')) 170 | 171 | # fill text fields with metadata: 172 | self._fill_fields() 173 | ############################ 174 | 175 | def __str__(self): 176 | return self.__class__.__name__ 177 | 178 | def out(self, s, ok=True): 179 | if ok: 180 | print(f"{self}: {s}") 181 | else: 182 | print(f"{self}: {s}", file=sys.stderr) 183 | 184 | def _checkbox_state_changed(self, checkbox): 185 | name = checkbox.objectName() 186 | b = checkbox.isChecked() 187 | 188 | # Diag: 189 | #self.out("_checkbox_state_changed() from '{}': {}".format(name, b)) 190 | 191 | if name == 'check_cut': 192 | self.inputmask.setEnabled(b) 193 | self.filedialog_mask.setEnabled(b) 194 | elif name == 'check_symb': 195 | self.widget_symb.setVisible(b) 196 | 197 | def _tab_changed(self): 198 | index = self.tabWidget.currentIndex() 199 | 200 | # save index only, if it is a relevant function tab: 201 | if index != DockWidget.TAB_STATISTICS and index != DockWidget.TAB_ABOUT: 202 | self._tab_index = index 203 | #msg = f"Tab index changed! Save current tab index: {index}" 204 | #self.out(msg) 205 | 206 | def closeEvent(self, event): 207 | self.closingPlugin.emit() 208 | event.accept() 209 | 210 | def _fill_fields(self): 211 | metadata_file = plugin_dir / 'metadata.txt' 212 | 213 | version = "?" 214 | issue_tracker = "?" 215 | mail_link = "?" 216 | 217 | self.out("reading '{}'".format(metadata_file)) 218 | 219 | with metadata_file.open() as f: 220 | for line in f: 221 | """ filter lines from metadata file: 222 | version=0.6 223 | email=radolan2map@e.mail.de 224 | tracker=https://gitlab.com/Weatherman_/radolan2map/issues 225 | """ 226 | 227 | if line.startswith('version'): 228 | version = self.__get_value(line) 229 | elif line.startswith('email'): 230 | mailadress = self.__get_value(line) 231 | mail_link = f'{mailadress}' 232 | elif line.startswith('tracker'): 233 | issue_link = self.__get_value(line) 234 | issue_tracker = f'{issue_link}' 235 | # for 236 | # with 237 | 238 | self.text_version.setText(version) 239 | self.text_issue.setText(issue_tracker) 240 | self.text_mailaddress.setText(mail_link) 241 | 242 | def __get_value(self, line): 243 | return line.strip().split('=')[1] # version=0.6 244 | 245 | def set_statistics_tab_visible(self, b_visible): 246 | self.out(f"set statistics tab visibility = {b_visible}") 247 | no = DockWidget.TAB_STATISTICS 248 | try: 249 | self.tabWidget.setTabVisible(no, b_visible) 250 | # Problem with an older Qt version(?) occured on Windows with QGIS 3.18, 251 | # in 3.24 Prizren it works. 252 | # Tab visibility has been introduced in Qt5.15 253 | # AttributeError: 'QTabWidget' object has no attribute 'setTabVisible' 254 | except AttributeError as e: 255 | self.out(f"Exception catched:\n{e}", False) 256 | # so we must use another method instead, we cannot hide it: 257 | self.tabWidget.setTabEnabled(no, b_visible) 258 | 259 | if b_visible: 260 | self.tabWidget.setCurrentIndex(no) 261 | 262 | @property 263 | def tab_index(self): 264 | return self._tab_index 265 | 266 | @tab_index.setter 267 | def tab_index(self, i): 268 | self.tabWidget.setCurrentIndex(i) 269 | -------------------------------------------------------------------------------- /classes/NumpyRadolanAdder.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 27.11.2020 3 | 4 | @author: Weatherman 5 | ''' 6 | 7 | import sys 8 | from pathlib import Path 9 | from glob import glob 10 | from copy import copy 11 | from datetime import datetime, timedelta 12 | import numpy as np 13 | import warnings 14 | import re 15 | 16 | from .NumpyRadolanReader import NumpyRadolanReader # Input 17 | from .ASCIIGridWriter import ASCIIGridWriter # Output 18 | 19 | 20 | 21 | def test_sum2D_with_nan(): 22 | print("### test_sum2D_with_nan() ###\n") 23 | 24 | # Creates a 2D array with uninitialized values 25 | a = np.ndarray((2, 4)) 26 | # Datentyp ist Default: 'float64' 27 | a.fill(7) # Fill it in with a constant value 7 28 | # auch: B.fill(numpy.nan) # ! nur float-Array 29 | a[1][0] = np.nan 30 | a[1][1] = np.nan 31 | print("a =\n", a) 32 | 33 | b = np.ndarray((2, 4)) 34 | b.fill(1.) 35 | b[1][1] = np.nan 36 | print("b =\n", b) 37 | 38 | """ 39 | Since the inputs are 2D arrays, you can stack them along the third axis with 40 | np.dstack and then use np.nansum which would ensure NaNs are ignored, unless 41 | there are NaNs in both input arrays, in which case output would also have NaN. 42 | -> The behavior that NaN is returned when both inputs are NaN 43 | changed for NumPy version >1.9.0. In the newer versions, zero is returned! 44 | """ 45 | c = np.nansum(np.dstack((a, b)), 2) 46 | 47 | print("c =\n", c) 48 | 49 | 50 | def test_sum_same_radolan_file(): 51 | print("### test_sum_same_radolan_file() ###\n") 52 | 53 | test_data_dir = "/run/media/loki/ungesichert/Testdaten/RADOLAN_bin" 54 | 55 | rw = Path(test_data_dir) / "bin/raa01-rw_10000-1109041250-dwd---bin.gz" 56 | 57 | radolan_file = rw 58 | 59 | row = 450 60 | 61 | nrr = NumpyRadolanReader(radolan_file) # FileNotFoundError 62 | nrr.read() 63 | #nrr.print_data() 64 | f1 = nrr.data 65 | f2 = copy(f1) 66 | 67 | print(f1[row]) 68 | print() 69 | 70 | """ 71 | Since the inputs are 2D arrays, you can stack them along the third axis with 72 | np.dstack and then use np.nansum which would ensure NaNs are ignored, unless 73 | there are NaNs in both input arrays, in which case output would also have NaN. 74 | -> The behavior that NaN is returned when both inputs are NaN 75 | changed for NumPy version >1.9.0. In the newer versions, zero is returned! 76 | """ 77 | fsum = np.nansum(np.dstack((f1, f2)), 2) 78 | 79 | print(fsum[row]) 80 | 81 | 82 | 83 | 84 | class NumpyRadolanAdder: 85 | ''' 86 | classdocs 87 | ''' 88 | 89 | def __init__(self, dt_beg, dt_end, data_path, prod_id, asc_filename_path): 90 | ''' 91 | Constructor 92 | ''' 93 | 94 | self.out("dt_beg={}, dt_end={}, prod_id='{}'".format(dt_beg, dt_end, prod_id)) 95 | 96 | # In: 97 | self._dt_beg = dt_beg 98 | self._dt_end = dt_end 99 | self._data_path = data_path 100 | self._prod_id = prod_id.lower() # 'SF' -> 'sf' 101 | self._asc_filename_path = asc_filename_path 102 | 103 | self._first_run = True 104 | 105 | # determined: 106 | self._interval_minutes = 0 # of sum 107 | 108 | # Out: 109 | self._sum_field = None 110 | self._nodata_field = None 111 | 112 | 113 | def __str__(self): 114 | return self.__class__.__name__ 115 | 116 | def out(self, s, ok=True): 117 | ''' Ausgabemethode ''' 118 | 119 | if ok: 120 | print("{}: {}".format(self, s)) 121 | else: 122 | print("{}: {}".format(self, s), file=sys.stderr) 123 | 124 | 125 | 126 | def run(self): 127 | self.out("run()") 128 | 129 | # RADOLAN: raa01-rw_10000-1708020250-dwd---bin 130 | # RADKLIM: raa01-yw2017.002_10000-1006010650-dwd---bin 131 | # ...bin, ...bin.gz ? 132 | general_radolan_file_pattern = "raa01-{}*_10000-{{}}-dwd---bin*".format(self._prod_id) 133 | 134 | pattern_all_files_same_type = general_radolan_file_pattern.format('*') 135 | concrete_radolan_file_pattern = str( Path(self._data_path) / pattern_all_files_same_type ) 136 | print(" determine files...", end=" ") 137 | l_files_all_same_type = glob(concrete_radolan_file_pattern) 138 | print("{} files of '{}' type found".format(len(l_files_all_same_type), self._prod_id.upper())) 139 | 140 | if not l_files_all_same_type: 141 | raise FileNotFoundError("no RADOLAN files for adding found!") 142 | 143 | # simply take first file to determine properties one time: 144 | radolan_file = l_files_all_same_type[0] 145 | time_res_min, prec = self._read_first_file_init(radolan_file) 146 | 147 | 148 | """ 149 | Following complicated way because we don't know, if user adds standard 150 | RADOLAN data or RADKLIM data or wether the files are gzip compressed. 151 | The file patterns are slightly different. 152 | So we create a concrete and surely existing file pattern here. 153 | """ 154 | # raa01-xx_10000-{}-dwd---bin[.gz] # insert '{}' for datetime 155 | general_radolan_file_pattern = re.sub(r'(\d){10}', '{}', radolan_file) 156 | general_radolan_file_pattern_with_path = str( Path(self._data_path) / general_radolan_file_pattern ) 157 | # -> so now, we need only to substitute the placeholder {} for datetime. 158 | 159 | # datetime.timedelta([days[, seconds[, microseconds[, milliseconds[, minutes[, hours[, weeks]]]]]]]) 160 | td_min = timedelta(minutes=time_res_min) 161 | 162 | dt = self._dt_beg 163 | number_of_added_files = 0 164 | 165 | while dt <= self._dt_end: 166 | fn_path = general_radolan_file_pattern_with_path.format( dt.strftime("%y%m%d%H%M") ) 167 | 168 | if fn_path in l_files_all_same_type: 169 | self._add(fn_path) 170 | number_of_added_files += 1 171 | else: 172 | self.out("expected file '{}' doesn't exist".format(concrete_radolan_file_pattern), False) 173 | 174 | dt += td_min 175 | # while 176 | 177 | #print(self._sum_field[0]) 178 | #print(self._nodata_field[0]) 179 | 180 | self.out("{} files added".format(number_of_added_files)) 181 | 182 | # Imprint NaN values that were NaN during the entire run: 183 | nan_positions = np.where(np.isnan(self._nodata_field)) # find the remaining NaNs 184 | self._sum_field[nan_positions] = np.nan 185 | 186 | ascii_writer = ASCIIGridWriter(self._sum_field, prec, self._asc_filename_path) 187 | ascii_writer.write() 188 | 189 | 190 | def _read_first_file_init(self, radolan_file): 191 | self.out("init with first file") 192 | 193 | nrr = NumpyRadolanReader(radolan_file) # FileNotFoundError 194 | nrr._read_radolan_composite(loaddata=False) # optimize, no reading of data part neccessary 195 | 196 | # Return a new array of given shape and type, filled with fill_value. 197 | self._nodata_field = np.full(nrr.shape, np.nan) # all with NaN 198 | 199 | time_res_min = nrr.interval 200 | print(" 'time_res' determined: {} minutes".format(time_res_min)) 201 | 202 | return time_res_min, nrr.precision 203 | 204 | 205 | 206 | def _add(self, radolan_file): 207 | #self.out("_add('{}')".format(radolan_file)) 208 | 209 | nrr = NumpyRadolanReader(radolan_file) # FileNotFoundError 210 | nrr.read() 211 | cur_data = nrr.data 212 | 213 | self._interval_minutes += nrr.interval 214 | 215 | # every occurence of a pixel (for every looped file) switches a 'nan' to a 'True': 216 | # np.where return indices where the conditions are true 217 | indices_non_nan = np.where(~np.isnan(cur_data)) 218 | # switch non nan positions to True. For every loop. 219 | self._nodata_field[indices_non_nan] = True 220 | 221 | if self._first_run: 222 | self._sum_field = cur_data # Init 223 | self._first_run = False 224 | return 225 | 226 | """ 227 | Since the inputs are 2D arrays, you can stack them along the third axis with 228 | np.dstack and then use np.nansum which would ensure NaNs are ignored, unless 229 | there are NaNs in both input arrays, in which case output would also have NaN. 230 | -> The behavior that NaN is returned when both inputs are NaN 231 | changed for NumPy version >1.9.0. In the newer versions, zero is returned! 232 | """ 233 | self._sum_field = np.nansum(np.dstack((self._sum_field, cur_data)), 2) 234 | 235 | 236 | 237 | def get_statistics(self): 238 | dim = "rows={}, cols={}".format(*self._sum_field.shape) 239 | _max = np.nanmax(self._sum_field) # Maximum of the flattened array, ignoring Nan 240 | _min = np.nanmin(self._sum_field) # Minimum of the flattened array, ignoring Nan 241 | total = self._sum_field.size 242 | valid = np.count_nonzero(~np.isnan(self._sum_field)) # ~ inverts the boolean matrix returned from np.isnan 243 | nonvalid = np.count_nonzero(np.isnan(self._sum_field)) 244 | 245 | return dim, _max, _min, self.mean, total, valid, nonvalid 246 | 247 | @property 248 | def mean(self): 249 | """ nanmean: The arithmetic mean is the sum of the non-NaN elements along the axis 250 | divided by the number of non-NaN elements. 251 | When the array has nothing but nan values, it raises a warning: 252 | /usr/lib64/python3.4/site-packages/numpy/lib/nanfunctions.py:675: RuntimeWarning: Mean of empty slice 253 | warnings.warn("Mean of empty slice", RuntimeWarning) 254 | """ 255 | 256 | # necessary to catch the warning like an exception: 257 | # To handle warnings as errors simply use this: 258 | warnings.filterwarnings('error') 259 | 260 | # In order to be able to react to this state, the warning is intercepted: 261 | try: 262 | return np.nanmean(self._sum_field) 263 | 264 | except Warning: 265 | self.out("Warning catched: return np.nan", False) 266 | return np.nan 267 | 268 | @property 269 | def interval_minutes(self): 270 | return self._interval_minutes 271 | 272 | 273 | ################################################################# 274 | 275 | 276 | def test_nan(): 277 | ''' 278 | Tests, if NaN's of last added file don't overwrite valid values of the preceding file 279 | Does result preserve the values of 'f1'? 280 | ''' 281 | 282 | test_dir = "/run/media/loki/ungesichert/Testdaten/QGIS/radolan2map/nan-test" 283 | asc_filename_path = Path(test_dir) / "nan_test.asc" 284 | 285 | f1 = Path(test_dir) / "raa01-sf_10000-1807060550-dwd---bin" # more data in the north 286 | f2 = Path(test_dir) / "raa01-sf_10000-1501020550-dwd---bin" # lesser data in the north 287 | 288 | 289 | adder = NumpyRadolanAdder(None, None, test_dir, 'SF', asc_filename_path) 290 | # call internal methods directly: 291 | adder._read_first_file_init(f1) # init, no adding 292 | 293 | adder._add(f1) 294 | adder._add(f2) 295 | 296 | sum_field = adder._sum_field # fetch reference 297 | 298 | # Imprint NaN values that were NaN during the entire run: 299 | nan_positions = np.where(np.isnan(adder._nodata_field)) # find the remaining NaNs 300 | sum_field[nan_positions] = np.nan 301 | 302 | ascii_writer = ASCIIGridWriter(sum_field, 0.1, asc_filename_path) 303 | ascii_writer.write() 304 | 305 | 306 | 307 | if __name__ == '__main__': 308 | 309 | #test_sum2D_with_nan() 310 | #test_sum_same_radolan_file() 311 | #test_nan(); sys.exit() 312 | 313 | 314 | data_path = "/run/media/loki/ungesichert/Testdaten/RADOLAN_bin/viele/sf" 315 | prod_id = 'SF' 316 | asc_filename_path = "/home/loki/radolan2map/tmp/SF_201501010550-201501070550.asc" 317 | 318 | dt_beg = datetime(2015, 1, 1, 5, 50) 319 | dt_end = datetime(2015, 1, 7, 5, 50) 320 | 321 | adder = NumpyRadolanAdder(dt_beg, dt_end, data_path, prod_id, asc_filename_path) 322 | adder.run() 323 | 324 | -------------------------------------------------------------------------------- /classes/ActionTabRADOLANAdder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from copy import copy 5 | from datetime import datetime 6 | import re 7 | 8 | from qgis.PyQt.QtWidgets import QFileDialog 9 | from qgis.PyQt.QtCore import QDateTime 10 | 11 | # own classes: 12 | from .ActionTabBase import ActionTabBase # base class 13 | from .NumpyRadolanAdder import NumpyRadolanAdder 14 | from .GDALProcessing import GDALProcessing 15 | from .LayerLoader import LayerLoader 16 | 17 | """ 18 | The format of date and datetime is different from the format of QDate and QDateTime, 19 | you should not use % in the Qt format! 20 | """ 21 | df_dt = '%Y-%m-%d %H:%M' 22 | df_qt = 'yyyy-MM-dd hh:mm' 23 | 24 | 25 | class ActionTabRADOLANAdder(ActionTabBase): 26 | """ActionTabRADOLANAdder 27 | Separate __actions__ of RADOLAN summation operations from the other components 28 | 29 | Created on 02.12.2020 30 | @author: Weatherman """ 31 | 32 | def __init__(self, iface, model, dock): 33 | """ 34 | Constructor 35 | """ 36 | 37 | super().__init__(iface, model, dock) 38 | 39 | # Listener 40 | dock.btn_select_dir_adder.clicked.connect(self._select_input_dir) 41 | dock.btn_scan.clicked.connect(self._scan_for_products) 42 | dock.btn_run_adder.clicked.connect(self._run) 43 | dock.btn_set_datetime.clicked.connect(self._set_begin_end_automatically) 44 | dock.listWidget.itemSelectionChanged.connect(self._listwidget_selection_changed) # itemClicked.connect 45 | 46 | # setting date time 47 | dt = QDateTime.currentDateTime() 48 | self.dock.dateTimeEdit_beg.setDateTime(dt) 49 | self.dock.dateTimeEdit_end.setDateTime(copy(dt)) 50 | 51 | self._prod_id = None 52 | self._files = None # list of scanned RADOLAN files 53 | 54 | # for saving: 55 | self._begin = None 56 | self._end = None 57 | self._last_dir = None 58 | 59 | """ 60 | Actions 61 | """ 62 | 63 | def _select_input_dir(self): 64 | text = self.tf_path 65 | self._last_dir = text if text else str(Path.home()) 66 | 67 | # parent, caption, directory, file_filter 68 | selected_dir = QFileDialog.getExistingDirectory(self.dock, 'Select RADOLAN directory', self._last_dir, 69 | # these additional parameters are used, because QFileDialog otherwise doesn't start with the given path: 70 | QFileDialog.DontUseNativeDialog) 71 | 72 | if not selected_dir: 73 | return # preserve possibly filled line 74 | 75 | self.tf_path = selected_dir 76 | 77 | # automatically scan for products 78 | self._scan_for_products() 79 | # but therefore disable the manual scan button: 80 | self.dock.btn_scan.setEnabled(False) 81 | 82 | def _scan_for_products(self): 83 | self.out("_scan_for_products()") 84 | 85 | # an error could occur or user needs to select an item (found product) first 86 | self.dock.btn_run_adder.setEnabled(False) 87 | 88 | lwidget = self.dock.listWidget # shorten 89 | lwidget.clear() 90 | 91 | scan_path = Path(self.tf_path) 92 | print(f" scan path: '{scan_path}'") 93 | 94 | try: 95 | files = scan_path.glob('raa01-*---bin*') # possibly '.gz' 96 | if not files: 97 | self.dock.btn_set_datetime.setEnabled(False) 98 | raise FileNotFoundError("No RADOLAN products found!") 99 | except Exception as e: 100 | super()._show_critical_message_box(str(e)) 101 | return 102 | 103 | # set: 104 | self._files = list(files) # save list of Posixpath because of generator running o.o.i. 105 | # sort because order of RADOLAN first and last file for setting datetime: 106 | self._files.sort() 107 | 108 | self.dock.btn_set_datetime.setEnabled(True) 109 | 110 | # Detect product IDs and count: 111 | 112 | d_id = {} # dict of product ids 113 | # raa01-sf_10000-1501070550-dwd---bin -> 'SF' 114 | for f in self._files: # of Posixpath 115 | _id = f.name[6:8].upper() 116 | 117 | if _id[1] == 'X': # exclude RVP-products (1 Byte) like 'RX', 'WX', 'EX', ... 118 | print(f"- exclude '{_id}'") 119 | continue 120 | 121 | try: # if key exist, increment 122 | d_id[_id] += 1 123 | except KeyError: 124 | d_id[_id] = 1 125 | # for files 126 | 127 | l_id = [] # list of product ids 128 | for k, v in d_id.items(): 129 | print(f"{k}: {v}") 130 | if v > 1: 131 | l_id.append(k) 132 | # for 133 | 134 | self.out(f"IDs: {l_id}") 135 | 136 | for i in l_id: 137 | lwidget.addItem(i) 138 | 139 | # again, because '_listwidget_selection_changed()' enables 'btn_run_adder': 140 | self.dock.btn_run_adder.setEnabled(False) 141 | 142 | def _set_begin_end_automatically(self): 143 | if not self._files: 144 | raise FileNotFoundError("No RADOLAN products in list!") 145 | 146 | first_file = self._files[0].name 147 | last_file = self._files[-1].name 148 | 149 | # RADOLAN: ['01', '10000', '1605290050'] 150 | # RADKLIM: ['01', '2017', '002', '10000', '1806010050'] 151 | digits_begin = re.findall(r'\d+', first_file)[-1] 152 | digits_end = re.findall(r'\d+', last_file)[-1] 153 | 154 | # save this. These are the settings with which the user executed: 155 | self._begin = "20" + digits_begin 156 | self._end = "20" + digits_end 157 | 158 | df = '%Y%m%d%H%M' 159 | dt_beg = datetime.strptime(self._begin, df) 160 | dt_end = datetime.strptime(self._end, df) 161 | print("Begin:", dt_beg) 162 | print("End: ", dt_end) 163 | 164 | self.dock.dateTimeEdit_beg.setDateTime(dt_beg) 165 | self.dock.dateTimeEdit_end.setDateTime(dt_end) 166 | 167 | self.dock.btn_set_datetime.setEnabled(False) 168 | 169 | def _listwidget_selection_changed(self): 170 | self.dock.btn_run_adder.setEnabled(True) 171 | item = self.dock.listWidget.currentItem().text() 172 | #print(item) 173 | self._prod_id = item 174 | self.__setup_suitable_datetime_for_selected_product(item) 175 | 176 | def __setup_suitable_datetime_for_selected_product(self, prod_id): 177 | # self.dock.dateTimeEdit_beg.dateTime()): PyQt5.QtCore.QDateTime(2020, 12, 3, 20, 40, 56, 553) 178 | #df = "dd.MM.yyyy HH:mm" 179 | #print("Beginn:", self.dock.dateTimeEdit_beg.dateTime().toString(df)) 180 | #print("Ende: ", self.dock.dateTimeEdit_end.dateTime().toString(df)) 181 | #print(prod_id) 182 | 183 | # QDateTime -> datetime and smooth date: 184 | dt_beg = self.dock.dateTimeEdit_beg.dateTime().toPyDateTime() 185 | 186 | # assume hourly product: 187 | set_min = 50 188 | 189 | # 5 minute product: 'RZ', 'RY', 'YW', 'EZ', 'EY' 190 | if prod_id[1] == 'Y' or prod_id[1] == 'Z' or prod_id == 'YW': 191 | # simple defaults: 192 | if dt_beg.minute > 31: 193 | set_min = 45 194 | elif dt_beg.minute < 30: 195 | set_min = 0 196 | else: # 30 197 | set_min = dt_beg.minute 198 | # if 199 | 200 | dt_new = dt_beg.replace(minute=set_min, second=0, microsecond=0) 201 | 202 | self.begin = dt_new.strftime(df_dt) 203 | 204 | def _run(self): 205 | self.out("_run()") 206 | 207 | dock = self.dock # shorten 208 | 209 | # 210 | # Checks 211 | # 212 | 213 | # Checkbox enabled AND mask shape specified? 214 | mask_file = dock.inputmask.text() 215 | if dock.check_cut.isChecked(): 216 | # if the use of mask file will be relevant, check it 217 | if not Path(mask_file).exists(): 218 | super()._show_critical_message_box("The specified mask file doesn't exist!", 'File error') 219 | return 220 | else: 221 | mask_file = None 222 | 223 | dock.btn_run_adder.setEnabled(False) # disable run button during operation 224 | 225 | # QDateTime -> datetime and smooth date: 226 | dt_beg = dock.dateTimeEdit_beg.dateTime().toPyDateTime() 227 | dt_end = dock.dateTimeEdit_end.dateTime().toPyDateTime() 228 | 229 | # save this. These are the settings with which the user executed: 230 | self._begin = dt_beg.strftime(df_dt) 231 | self._end = dt_end.strftime(df_dt) 232 | #print("Begin:", dt_beg) 233 | #print("End: ", dt_end) 234 | 235 | # Try to catch every Exception and show it in a graphical window. 236 | 237 | df = '%Y%m%d%H%M' 238 | fn = f"{self._prod_id}_{dt_beg.strftime(df)}-{dt_end.strftime(df)}.asc" 239 | asc_filename_path = self._model.temp_dir / fn 240 | 241 | # for performance reason disable output for many files: 242 | saved_stdout = sys.stdout 243 | saved_stderr = sys.stderr 244 | self.out("disable output for performance reasons, add files...") 245 | f_devnull = open(os.devnull, 'w') 246 | sys.stdout = f_devnull 247 | sys.stderr = sys.stdout # redirect both 248 | 249 | try: 250 | # no cleaning temp, so we can check the temp result after running: 251 | self._model.create_storage_folder_structure(use_temp_dir=True) 252 | adder = NumpyRadolanAdder(dt_beg, dt_end, self.tf_path, self._prod_id, asc_filename_path) 253 | adder.run() 254 | except Exception as e: 255 | super()._show_critical_message_box(str(e)) 256 | return 257 | finally: 258 | f_devnull.close() 259 | sys.stderr = saved_stderr 260 | sys.stdout = saved_stdout 261 | self.out("output channels restored") 262 | 263 | """ 264 | From here is related to GIS layer loading 265 | """ 266 | 267 | # at GDAL processing a lot of strange errors are possible - with projection parameters and GDAL versions... 268 | try: 269 | tif_file = self.__convert_asc_tif(asc_filename_path, mask_file) 270 | except Exception as e: 271 | super()._show_critical_message_box(str(e), 'GDAL processing error') 272 | return 273 | 274 | """ 275 | If no project file not loaded when running plugin 276 | """ 277 | 278 | super()._check_create_project() 279 | 280 | # Set symbology from QML file: 281 | # 1) as given parameter by user or 282 | # 2) automatically by program 283 | qml_file = None 284 | # self defined symbology: 285 | if dock.check_symb.isChecked(): 286 | qml_file = dock.inputqml.text() 287 | # if the use of a user defined qml file will be relevant, check it 288 | if not Path(qml_file).exists(): 289 | super()._show_critical_message_box("The specified QML file doesn't exist!", 'File error') 290 | return 291 | # determine QML file automatically: 292 | else: 293 | interval_minutes = adder.interval_minutes # 'interval_minutes' for assigning a color map 294 | # Determine prepared QML-File delivered with the plugin: 295 | qml_file = self._model.qml_file(interval_minutes) # .qml or 'None' 296 | 297 | ll = LayerLoader(self._iface) # 'iface' is from 'radolan2map' 298 | 299 | if dock.check_excl_zeroes.isChecked(): 300 | ll.no_zeros = True # Set 0 values to NODATA (= transparent) 301 | 302 | ll.load_raster(tif_file, qml_file, temporal=False) 303 | 304 | dock.btn_run_adder.setEnabled(True) # re-activate 305 | 306 | dim, _max, _min, mean, total, valid, nonvalid = adder.get_statistics() 307 | 308 | s_max = str(_max) 309 | # if part after point is too long: 310 | l_max = s_max.split('.') 311 | if len(l_max[1]) > 2: 312 | s_max = f"{_max:.1f}" 313 | 314 | dock.text_filename.setText(asc_filename_path.stem) 315 | dock.text_shape.setText(dim) 316 | dock.text_max.setText(s_max) 317 | dock.text_min.setText(str(_min)) 318 | dock.text_mean.setText(f"{mean:.1f}") 319 | dock.text_total_pixels.setText(str(total)) 320 | dock.text_valid_pixels.setText(str(valid)) 321 | dock.text_nonvalid_pixels.setText(str(nonvalid)) 322 | 323 | super()._enable_and_show_statistics_tab() 324 | super()._finish() 325 | 326 | super()._load_print_layout(ll.layer_name, prod_id='Sum') # dt=None 327 | 328 | def __convert_asc_tif(self, asc_filename_path, mask_file=None): 329 | """raise Exception 330 | At GDAL processing a lot of strange errors are possible - with projection parameters and GDAL versions... 331 | """ 332 | model = self._model # shorten 333 | 334 | model.set_data_dir("sum") 335 | model.create_storage_folder_structure() 336 | 337 | tif_bn = asc_filename_path.name.replace('.asc', '.tif') # tif basename 338 | tif_filename_path = model.data_dir / tif_bn 339 | 340 | gdal_processing = GDALProcessing(model, asc_filename_path, tif_filename_path) 341 | #gdal_processing.produce_warped_tif_using_script() 342 | 343 | #l_elems = self.dock.cbbox_projections.currentText().split() # EPSG:3035 ETRS89 / LAEA Europe 344 | #epsg_code = l_elems[0] 345 | #if epsg_code == '-': # RADOLAN 346 | # epsg_code = model.projection_radolan # complete RADOLAN projection parameters 347 | prj_src = model.projection_radolan 348 | """ Finding the content of current item in combo box: 349 | Laborious over index, because otherwise the value of combo box would 350 | EPSG 3035: ETRS89 / LAEA Europe instead of 351 | EPSG:3035 """ 352 | index = self.dock.cbbox_projections.currentIndex() 353 | prj_dest = model.projections[index] 354 | #prj_dest_test = self.dock.cbbox_projections.currentText() 355 | #self.out("projection (currentText): {}".format(prj_dest_test)) 356 | 357 | gdal_processing.produce_warped_tif_by_python_gdal(prj_src, prj_dest, shapefile=mask_file) # Exception 358 | 359 | return gdal_processing.tif_file 360 | 361 | # ...................................................................... 362 | 363 | @property 364 | def tf_path(self): 365 | return self.dock.tf_path_adder.text() 366 | @tf_path.setter 367 | def tf_path(self, path): 368 | self.dock.tf_path_adder.setText(path) 369 | # after folder selection enable scan button: 370 | self.dock.btn_scan.setEnabled(True) 371 | self._last_dir = path 372 | 373 | @property 374 | def begin(self): 375 | return self._begin 376 | @begin.setter 377 | def begin(self, beg): 378 | self._begin = beg 379 | dt = QDateTime.fromString(beg, df_qt) 380 | self.dock.dateTimeEdit_beg.setDateTime(dt) 381 | 382 | @property 383 | def end(self): 384 | return self._end 385 | @end.setter 386 | def end(self, end): 387 | self._end = end 388 | dt = QDateTime.fromString(end, df_qt) 389 | self.dock.dateTimeEdit_end.setDateTime(dt) 390 | 391 | @property 392 | def last_dir(self): 393 | return self._last_dir 394 | -------------------------------------------------------------------------------- /classes/LayerLoader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | from pathlib import Path 5 | from datetime import datetime, timedelta 6 | from PyQt5.QtCore import Qt 7 | 8 | from qgis.core import ( 9 | Qgis, 10 | QgsProject, 11 | QgsLayerTreeLayer, 12 | QgsRasterTransparency, 13 | QgsVectorLayer, 14 | QgsRasterLayer, 15 | QgsDateTimeRange, 16 | QgsRasterLayerTemporalProperties, 17 | ) 18 | import processing 19 | 20 | 21 | class LayerLoader: 22 | """ 23 | LayerLoader 24 | 25 | Loads creates and load a QgsRaster- or QgsVectorLayer 26 | 27 | Created on 07.12.2020 28 | @authors: Weatherman, Tobias Rosskopf 29 | """ 30 | 31 | def __init__(self, iface): 32 | print(self) 33 | 34 | self._iface = iface 35 | 36 | # set or determined later: 37 | self._no_zeros = False # set zeros invisible 38 | self._layer_name = None 39 | self._temporal = None 40 | 41 | def __str__(self): 42 | return self.__class__.__name__ 43 | 44 | def out(self, s, ok=True): 45 | if ok: 46 | print(f"{self}: {s}") 47 | else: 48 | print(f"{self}: {s}", file=sys.stderr) 49 | 50 | def _show_message(self, qgis_state, layer_name, duration=0): 51 | """ Where the integer 0 indicates a no timeout (i.e. no duration). """ 52 | 53 | if qgis_state == Qgis.Success: 54 | title = "Success" 55 | msg = f'Layer "{layer_name}" loaded!' 56 | else: 57 | title = "Error" 58 | msg = f'Layer "{layer_name}" failed to load!' 59 | self.out(msg, False) 60 | 61 | self._iface.messageBar().pushMessage(title, msg, level=qgis_state, duration=duration) 62 | 63 | def create_and_load_layer_group(self, layergroup_name, list_of_files_and_qml): 64 | self._temporal = True 65 | 66 | qgis_groups = QgsProject.instance().layerTreeRoot() 67 | if qgis_groups.findGroup(layergroup_name): 68 | self.out(f'adding layers to existing layer group "{layergroup_name}"') 69 | else: 70 | self.out(f'create layer group "{layergroup_name}"') 71 | # obtain the layer tree of the top-level group in the project 72 | root = self._iface.layerTreeCanvasBridge().rootGroup() 73 | root.addGroup(layergroup_name) 74 | 75 | # root = QgsProject.instance().layerTreeRoot() # another possibility, 76 | # but first group layers disappear in the second run(?) 77 | tree = self._iface.layerTreeCanvasBridge().rootGroup() 78 | layer_group = tree.findGroup(layergroup_name) 79 | layer_group.setExpanded(False) # collapse group after filling 80 | 81 | # for performance reason disable output for many files: 82 | saved_stdout = sys.stdout 83 | saved_stderr = sys.stderr 84 | self.out("disable output for performance reasons, loading layers...") 85 | f_devnull = open(os.devnull, 'w') 86 | sys.stdout = f_devnull 87 | sys.stderr = sys.stdout # redirect both 88 | 89 | for tif_file, qml_file in list_of_files_and_qml: 90 | bn = Path(tif_file).name 91 | raster_layer = QgsRasterLayer(str(tif_file), bn) 92 | 93 | if not raster_layer.isValid(): 94 | self._show_message(Qgis.Critical, bn) 95 | continue 96 | 97 | raster_layer.setName(Path(tif_file).stem) 98 | self._insert_layer(raster_layer, qml_file, 0, layer_group) 99 | # for 100 | 101 | f_devnull.close() 102 | sys.stderr = saved_stderr 103 | sys.stdout = saved_stdout 104 | self.out("output channels restored") 105 | 106 | # TODO: collapse color definition 107 | #for layer in layer_group.findLayers(): 108 | # #print(layer.name()) 109 | # # filtering only raster layers 110 | # if layer.type() == QgsMapLayerType.RasterLayer: # RasterLayer? 111 | # LayerNode = root.findLayer(layer.id()) 112 | # LayerNode.setExpanded(False) # hiding the bands etc. 113 | 114 | self._show_message(Qgis.Success, layer_name=f"Group {layergroup_name}", duration=5) 115 | 116 | def load_raster(self, tif_file, qml_file=None, temporal=True): 117 | 118 | self._temporal = temporal 119 | 120 | bn = Path(tif_file).name 121 | 122 | raster_layer = QgsRasterLayer(str(tif_file), bn) 123 | 124 | # this too? Lead to layer loaded twice. 125 | # QgsMapLayerRegistry.instance().addMapLayer(raster_layer) 126 | 127 | if not raster_layer.isValid(): 128 | self._show_message(Qgis.Critical, bn) 129 | return 130 | 131 | """ 132 | The new raster layer is ok and we will continue to work with it in the following 133 | """ 134 | 135 | """ 136 | --- Clean: If necessary remove existing layer --- 137 | 138 | If a raster layer with the above name already exists, it will be removed beforehand. 139 | The "legend" apparently has nothing to do with the composer legend! 140 | """ 141 | 142 | # for layer in self._iface.legendInterface().layers(): # QGIS 2 143 | # for layer in self._iface.layerTreeView().selectedLayers(): 144 | 145 | """ the complicated way: 146 | project = QgsProject.instance() 147 | raster_layers = [rl for rl in project.mapLayers().values() if rl.type() == 1] 148 | self.out("current project: {} raster layers found.".format(len(raster_layers))) 149 | 150 | for layer in raster_layers: 151 | #if layer.type() == QgsMapLayer.RasterLayer and layer.name() == radolan_layer_name: 152 | if layer.name() == layer_name: 153 | self.out('RasterLayer with existing name "{}" found.'.format(layer.name())) 154 | ''' Ausgabe 155 | 1 Raster 156 | 0 DEU_adm0 157 | ''' 158 | # print(layerType, layer.name()) 159 | 160 | print(" removing layer \"{}\"".format(layer.name())) 161 | project.removeMapLayer( layer.id() ) 162 | """ 163 | 164 | layer_name = Path(tif_file).stem 165 | self._layer_name = layer_name 166 | raster_layer.setName(layer_name) 167 | 168 | self._remove_layer_with_same_name(layer_name) 169 | 170 | # 171 | # Load result raster 172 | # 173 | 174 | # previous version without insert at specific position (was also ok): 175 | # self._iface.addRasterLayer(raster_to_load, path.basename(raster_to_load) ) 176 | 177 | # Einfaches result_layer.setLayerName('Raster') bringt leider nichts. 178 | # Unser Rasterlayer kann zuverlässig so bestimmt werden (kann gleich noch einmal gebraucht werden): 179 | # raster_layer = self._iface.activeLayer() 180 | 181 | self._insert_layer(raster_layer, qml_file, 3) 182 | 183 | def load_vector(self, csv_file, qml_file=None): 184 | # uri = "file:///{}?encoding=UTF-8&delimiter=,&xField=LON&yField=LAT&crs=EPSG:4326".format(regnie_csv_file) 185 | # -> was working, simpler variant: 186 | # uri = "{}{}?delimiter=,&xField=LON&yField=LAT".format('file:///', regnie_csv_file) 187 | # but not on Windows - needs crs specified: 188 | 189 | uri = f"file:///{csv_file}?delimiter=,&xField=LON&yField=LAT&crs=EPSG:4326" 190 | self.out(f"uri: {uri}") 191 | 192 | # Make a vector layer: 193 | csv_layer = QgsVectorLayer(uri, csv_file.name, "delimitedtext") 194 | 195 | if not csv_layer.isValid(): 196 | self._show_message(Qgis.Critical, csv_file.name) 197 | return 198 | 199 | layer_name = Path(csv_file).stem 200 | self._layer_name = layer_name 201 | csv_layer.setName(layer_name) 202 | self._remove_layer_with_same_name(layer_name) 203 | self._insert_layer(csv_layer, qml_file, 5) 204 | 205 | def _remove_layer_with_same_name(self, layer_name): 206 | layers = QgsProject.instance().mapLayersByName(layer_name) 207 | 208 | for layer in layers: 209 | self.out(f'Layer with existing name "{layer.name()}" found - removing.') 210 | QgsProject.instance().removeMapLayer(layer.id()) 211 | 212 | def _insert_layer(self, layer, qml_file, duration, layer_group=None): 213 | 214 | # Build pyramids 215 | self.out("Building pyramids ...") 216 | layer = self._build_pyramids(layer) 217 | 218 | # Style layer with qml file 219 | if qml_file: 220 | self._set_qml(layer, qml_file) 221 | 222 | # Set opacity - also for black white (without QML): 223 | # Sets the opacity for the layer, where opacity is a value 224 | # between 0 (totally transparent) and 1.0 (fully opaque). 225 | opa = 0.6 # 0.6 = 40% transparency 226 | 227 | if isinstance(layer, QgsRasterLayer): 228 | layer.renderer().setOpacity(opa) 229 | else: 230 | # layer.setLayerTransparency(40) # % this method seems only be available for vector layer 231 | layer.setOpacity(opa) 232 | 233 | # Set zero values to transparent 234 | if self._no_zeros: 235 | self._set_zeroes_invisible(layer) 236 | 237 | # Set temporal settings for layer (since QGIS 3.14) 238 | if self._temporal and Qgis.QGIS_VERSION_INT >= 31400: 239 | self.out("Setting temporal settings ...") 240 | self._set_time_range(layer) 241 | 242 | # Insert layer at a certain position 243 | 244 | # Add the layer to the QGIS Map Layer Registry (the second argument must be set to False 245 | # to specify a custom position: 246 | QgsProject.instance().addMapLayer(layer, False) # first add the layer without showing it 247 | 248 | # Obtain the layer tree of the top-level group in the project. 249 | # if-else: root or layer group? 250 | if layer_group: 251 | root = layer_group 252 | else: 253 | root = self._iface.layerTreeCanvasBridge().rootGroup() 254 | 255 | # The position is a number starting from 0, with -1 an alias for the end. 256 | # index 0: uppermost, index 1: second position under the vector layer group: 257 | index = -1 if layer_group else 1 258 | root.insertChildNode(index, QgsLayerTreeLayer(layer)) 259 | 260 | if not layer_group: 261 | # mark the new layer and zoom to it's extent: 262 | self._iface.setActiveLayer(layer) 263 | self._iface.zoomToActiveLayer() 264 | self._show_message(Qgis.Success, layer.name(), duration) 265 | 266 | def _set_zeroes_invisible(self, layer): 267 | self.out("setting zeroes to 100% transparency") 268 | """ 269 | # Set 0 values to NODATA (= transparent): 270 | provider = active_raster_layer.dataProvider() 271 | provider.setNoDataValue(1, 0) # first one is referred to band number 272 | # -> is working 273 | """ 274 | # better, conserves 0 value: 275 | tr = QgsRasterTransparency() 276 | tr.initializeTransparentPixelList(0) 277 | layer.renderer().setRasterTransparency(tr) 278 | 279 | def _set_qml(self, layer, qml_file): 280 | """ 281 | @param layer: QgsRasterLayer 282 | """ 283 | 284 | self.out(f"using QML file '{qml_file}'") 285 | 286 | #if layer.geometryType() == QGis.Point: 287 | layer.loadNamedStyle(str(qml_file)) # str() if Path 288 | 289 | #if hasattr(raster_layer, "setCacheImage"): # OK, 09.12.2020 290 | 291 | try: 292 | layer.setCacheImage(None) 293 | except AttributeError: 294 | pass 295 | 296 | layer.triggerRepaint() # muss aufgerufen werden, Layer bleibt sonst schwarzweiß 297 | # Das ist für die QML-Farbskala im Layerfenster: 298 | # self._iface.legendInterface().refreshLayerSymbology(active_raster_layer) # QGIS 2 299 | self._iface.layerTreeView().refreshLayerSymbology(layer.id()) 300 | 301 | def _set_time_range(self, layer: QgsRasterLayer) -> None: 302 | """ 303 | Sets temporal settings for layer, especially start time and end time (since QGIS 3.14). 304 | 305 | Args: 306 | layer (QgsRasterLayer): Raster layer for which to set temporal settings 307 | """ 308 | time_delta = self._extract_time_delta_from_layer_name(layer.name()) 309 | timestamp_end = self._extract_timestamp_from_layername(layer.name()) 310 | timestamp_start = timestamp_end - time_delta 311 | self.out(f"Time range from {timestamp_start:%d.%m.%Y %H:%M} to {timestamp_end:%d.%m.%Y %H:%M}.") 312 | 313 | dt_range = QgsDateTimeRange(timestamp_start, timestamp_end) 314 | begin = dt_range.begin() 315 | begin.setTimeSpec(Qt.TimeSpec.UTC) 316 | end = dt_range.end() 317 | end.setTimeSpec(Qt.TimeSpec.UTC) 318 | dt_range = QgsDateTimeRange(begin, end) 319 | self.out(dt_range) 320 | 321 | temp_props = layer.temporalProperties() 322 | temp_props.setMode(QgsRasterLayerTemporalProperties.ModeFixedTemporalRange) 323 | #temp_props.setFixedTemporalRange(QgsDateTimeRange(timestamp_start, timestamp_end)) 324 | temp_props.setFixedTemporalRange(dt_range) 325 | temp_props.setIsActive(True) 326 | 327 | def _extract_time_delta_from_layer_name(self, layername: str) -> timedelta: 328 | """ 329 | Extracts the time delta from the layer name based on RADOLAN products. 330 | 331 | Args: 332 | layername (str): Name of the RADOLAN raster layer (ex. RW_20180131-0950) 333 | 334 | Returns: 335 | timedelta: Time delta object 336 | """ 337 | PRODUCT_DICT = { 338 | "RY": 5, 339 | "RW": 60, 340 | "SF": 1440, 341 | } 342 | product_name = layername.split("_")[0] 343 | 344 | 345 | # TODO: better pass var "INT" from RADOLAN header instead to define again(?) 346 | try: 347 | time_delta = PRODUCT_DICT[product_name] 348 | # if product not defined, eg. "HG"product: 349 | except KeyError: 350 | time_delta = 5 351 | 352 | # time delta minus 1 minute to avoid overlapping of layers 353 | # TODO: still overlapps with next layer, but why? 354 | return timedelta(minutes=time_delta-1) 355 | 356 | def _extract_timestamp_from_layername(self, layername: str) -> datetime: 357 | """ 358 | Extracts the timestamp from the layername with regular expressions. 359 | 360 | Args: 361 | layername (str): Name of the RADOLAN raster layer (ex. RW_20180131-0950) 362 | 363 | Returns: 364 | datetime: Datetime object 365 | """ 366 | groups = re.findall(r"(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})", layername) 367 | groups = map(int, groups[0]) 368 | 369 | return datetime(*groups) 370 | 371 | def _build_pyramids(self, layer: QgsRasterLayer) -> QgsRasterLayer: 372 | """ 373 | Builds pyramids (overviews) for raster layer. 374 | 375 | Args: 376 | layer (QgsRasterLayer): Raster layer for which to build pyramids 377 | 378 | Returns: 379 | QgsRasterLayer: Raster layer with pyramids 380 | """ 381 | 382 | parameters = { 383 | "INPUT": layer, 384 | "LEVELS": "2 4 8 16", 385 | "CLEAN": False, 386 | "RESAMPLING": 0, 387 | "FORMAT": 0, 388 | "OUTPUT": layer, 389 | } 390 | result = processing.run("gdal:overviews", parameters) 391 | 392 | return QgsRasterLayer(result["OUTPUT"], layer.name()) 393 | 394 | # .................................................. 395 | 396 | @property 397 | def no_zeros(self): 398 | return self._no_zeros 399 | 400 | @no_zeros.setter 401 | def no_zeros(self, b): 402 | self._no_zeros = b 403 | 404 | @property 405 | def layer_name(self): 406 | return self._layer_name 407 | -------------------------------------------------------------------------------- /classes/Model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from pathlib import Path 4 | import sys 5 | 6 | from configparser import ConfigParser, NoOptionError 7 | #import platform # to determine wether Windows or not 8 | 9 | from qgis.core import QgsApplication, QgsProject, QgsVectorLayer 10 | from qgis.core import QgsCoordinateReferenceSystem, QgsLayerTreeGroup 11 | 12 | 13 | from .NumpyRadolanReader import NumpyRadolanReader 14 | 15 | from . import def_products # File 'def_products.py' 16 | from . import def_projections # File 'def_projections.py' 17 | CONFIG_NAME = "config.ini" 18 | 19 | linux_tmp_root = Path("/dev/shm") 20 | linux_tmp = linux_tmp_root / "radolan2map" 21 | 22 | 23 | class Model: 24 | """ 25 | Model 26 | 27 | Manages everything with paths (from config), QGIS environment, 28 | working on file system (create, delete). 29 | For the special works, such as transformations, own classes exists. 30 | 31 | Created on 23.11.2017 32 | last change: 08.11.2019 33 | @author: Weatherman 34 | """ 35 | 36 | 37 | def __init__(self): 38 | print(self) 39 | 40 | #self._plugin_dir = path.abspath(path.join(path.dirname(__file__), os.pardir)) 41 | #self._plugin_dir = Path(self._plugin_dir) 42 | self._plugin_dir = Path(__file__).resolve().parent.parent # classes/Model.py -> classes -> .. 43 | 44 | config_file = self._plugin_dir / CONFIG_NAME 45 | 46 | if not config_file.exists(): 47 | raise FileNotFoundError(f"Config file '{config_file}'") 48 | 49 | # 50 | # Parameter 51 | # 52 | self._config_file = config_file 53 | 54 | # 55 | # Ermittelt 56 | # 57 | 58 | # Objekt, auf das wir im Folgenden immer zugreifen. 59 | # Statisch ging es leider aus welchen Gründen auch immer (war in anderen Methoden 'None'). 60 | self._config = ConfigParser() 61 | self._config.read(config_file) 62 | 63 | self._product_defs = def_products.dict_titles 64 | self.out(f"{def_products.__name__}: {len(self._product_defs)} product titles loaded.") 65 | 66 | self._data_root = None # Path 67 | self._data_dir = None # Path /radolan or .../radklim or .../regnie 68 | 69 | self._l_projections = [] # EPSG numbers or projection parameters 70 | self._list_of_used_files = [] 71 | 72 | self._linux_tmp = None 73 | if linux_tmp_root.exists(): 74 | self.out(f"running on Linux, can use fast memory temp dir: {linux_tmp_root}") 75 | self._linux_tmp = linux_tmp 76 | 77 | """ 78 | fill projections 79 | Qt-element is connected with the projection list, via same index 80 | """ 81 | for number, proj_desc in self.dict_projections.items(): 82 | if number > 999: # 4 digits expected 83 | projection = f"EPSG:{number}" 84 | else: # !: list type expected 85 | assert type(proj_desc) is list 86 | _, projection = proj_desc 87 | 88 | # add projection: list with epsg codes corresponds with ComboBox in 'SettingsTab.py' 89 | self._l_projections.append(projection) 90 | # for 91 | 92 | 93 | def __str__(self): 94 | return self.__class__.__name__ 95 | 96 | def out(self, s, ok=True): 97 | if ok: 98 | print(f"{self}: {s}") 99 | else: 100 | print(f"{self}: {s}", file=sys.stderr) 101 | 102 | def set_data_dir(self, subdir_name): 103 | self._data_dir = self.data_root / subdir_name 104 | self.out(f"set_data_dir(): {self._data_dir}") 105 | 106 | def create_storage_folder_structure(self, use_temp_dir=None): 107 | """ affects filesystem 108 | param: use_temp_dir: if it is set, then only create the temp_dir 109 | 110 | *** REMOVE THIS SOMEDAY *** 111 | """ 112 | 113 | self.out("create_storage_folder_structure()") 114 | 115 | if use_temp_dir: 116 | 117 | temp_dir = self.temp_dir 118 | 119 | if not temp_dir.exists(): 120 | temp_dir.mkdir(parents=True) # mode=0o777, exist_ok=True 121 | self.out(f"temp dir created: {temp_dir}") 122 | return 123 | 124 | # Remove old content: 125 | print(" remove temp dir contents...") 126 | # If ignore_errors is set, errors are ignored; otherwise, if onerror is set, it is called to handle ... 127 | #shutil.rmtree(temp_dir) 128 | #shutil.rmtree(path, ignore_errors=True) 129 | # -> Deleting a whole directory is problematic ("in use"). 130 | 131 | for f in temp_dir.glob("*"): 132 | try: 133 | f.unlink() 134 | """ Curiously an exception occurred in Windows (7) when loading data 135 | multiple times. Somehow the system does not let go of the data it touches. """ 136 | except WindowsError as e: # only available on Windows 137 | self.out(f"ERROR: {e}\n try to ignore.") 138 | # for 139 | return 140 | # if use_temp_dir 141 | 142 | """ 143 | temp dir maybe root was changed, so check again... 144 | data dir 145 | """ 146 | 147 | for d in (self.temp_dir, self._data_dir): 148 | d.mkdir(parents=True, exist_ok=True) # mode=0o777 149 | self.out(f"makedirs(): {d}") 150 | 151 | 152 | 153 | def write_history_file(self): 154 | """ 155 | hint: 'l_files' was filled in method "_init_dock()" 156 | """ 157 | 158 | if not self.list_of_used_files: 159 | return 160 | 161 | _max = 9 162 | 163 | # write the _max items back: 164 | with self.history_file.open('w') as hf: 165 | for i, line in enumerate(self.list_of_used_files): 166 | if i >= _max: 167 | break 168 | hf.write(line + '\n') 169 | 170 | self.out(f"selected file stored in '{self.history_file}'") 171 | 172 | 173 | def create_default_project(self): 174 | """ 175 | Load a prepared template project. 176 | see https://docs.qgis.org/testing/en/docs/pyqgis_developer_cookbook/loadproject.html 177 | 178 | ! Important: to make it work on other computers too,: 179 | Defined layer paths in .qgz/.qgs project file need to be defined relative 180 | (can be done manually with text editor) 181 | """ 182 | 183 | self.out("create_default_project()") 184 | 185 | # Get the project instance 186 | project = QgsProject.instance() # current project 187 | 188 | # Load another project: 189 | project_file = self.default_project_file 190 | self.out(f"loading template project: {project_file}") 191 | project.read(str(project_file)) 192 | 193 | """ Related to Windows / QGIS 3.10 194 | Phenomenon 1: after loading the template project, 195 | QGIS map canvas 'jumps outside' the layer extents, even though the template project 196 | was saved with map centered to Germany. 197 | Phenomenon 2: after loading the template project (actually) with crs EPSG:3035 198 | but the mouse pointer on the map showed WGS1984 coordinates. 199 | So the assumption was to try to fix that by extra setting up the project crs "from outside" 200 | to EPSG:3035. 201 | But the problem wasn't fixed with that. 202 | The crs setting below was actually inserted for Windows but we leave it here for all platforms. 203 | It can not hurt to set up the crs from outside again for the new project. 204 | """ 205 | crs = QgsCoordinateReferenceSystem("EPSG:3035") 206 | 207 | if crs.isValid(): 208 | self.out(f"CRS Description: {crs.description()}") 209 | self.out(f"CRS PROJ text: {crs.toProj()}") 210 | project.setCrs(crs) 211 | else: 212 | self.out("Invalid CRS!", False) 213 | 214 | return project 215 | 216 | 217 | def product_description(self, prod_id): 218 | try: 219 | return self._product_defs[prod_id] 220 | except KeyError: 221 | return "Product has not been defined yet" 222 | 223 | def title(self, prod_id, dt_date=None): 224 | """ Zugriff auf Title im Dictionary. """ 225 | s_date = dt_date.strftime("%d.%m.%Y, %H:%M UTC") if dt_date else "" 226 | return f"{self.product_description(prod_id)}, {s_date}" 227 | 228 | def qml_file(self, interval_minutes, qml_fn=None): 229 | """ 230 | param: interval_minutes: from header of binary file or determined by RADOLANAdder 231 | param: qml_fn: possibility to pass a QML filename directly independent from interval 232 | """ 233 | 234 | self.out(f"qml_file(interval_minutes={interval_minutes})") 235 | 236 | if not qml_fn: 237 | if interval_minutes == -1: # products in RVP6-Units: RX, WX, EX 238 | qml_fn = 'rvp6units' 239 | elif interval_minutes < 60: 240 | qml_fn = '5min' 241 | elif interval_minutes < 1440: 242 | qml_fn = 'hourly' 243 | elif interval_minutes < 4320: # 3 days: 1440 * 3 244 | qml_fn = 'daily' 245 | elif interval_minutes <= 10080: # 1440 * 7 246 | qml_fn = 'daily+' 247 | elif interval_minutes <= 259200: # 1440 * (30 * 6) -> approx. 6 months 248 | qml_fn = 'monthly' 249 | # greater: 250 | else: 251 | qml_fn = 'yearly' 252 | # if 253 | 254 | qml_fn += ".qml" 255 | qml_file = self.symbology_path / qml_fn 256 | 257 | # QML file exists? 258 | if not qml_file.exists(): 259 | self.out(f"qml_file(): QML file '{qml_file}' not found.", False) 260 | qml_file = None 261 | 262 | return qml_file 263 | 264 | 265 | """ 266 | Properties 267 | """ 268 | 269 | # Dirs: 270 | 271 | @property 272 | def profile_dir(self): 273 | return QgsApplication.qgisSettingsDirPath() 274 | 275 | @property 276 | def plugin_dir(self): 277 | return self._plugin_dir # Path 278 | @property 279 | def data_root(self): 280 | """ read from definition file 281 | raise FileNotFoundError """ 282 | if self._data_root: # cached? 283 | return self._data_root 284 | 285 | with self.data_root_def_file.open() as f: # FileNotFoundError 286 | self.out(f"read 'data dir' from '{f.name}':") 287 | self._data_root = Path(f.read()) 288 | print(f" {self._data_root}") 289 | return self._data_root 290 | @data_root.setter 291 | def data_root(self, d): 292 | """ update, when user writes a new path in data dir def file """ 293 | self.out(f"update 'data_root': {d}") 294 | self._data_root = Path(d) 295 | @property 296 | def data_dir(self): 297 | return self._data_dir 298 | @property 299 | def temp_dir(self): 300 | # important NOT to use '_data_root' here! 301 | return self._linux_tmp if self._linux_tmp else self.data_root / 'tmp' 302 | ''' 303 | @property 304 | def layout_dir(self): 305 | return self._plugin_dir / self._config.get('Paths', 'LAYOUT_PATH') 306 | ''' 307 | @property 308 | def list_of_used_files(self): 309 | return self._list_of_used_files 310 | @list_of_used_files.setter 311 | def list_of_used_files(self, l): 312 | self._list_of_used_files = l 313 | 314 | @property 315 | def default_border_shape(self): 316 | """ gleich mit Check """ 317 | #border_shape = path.join(self._plugin_dir, self._config.get('Paths', 'CUT_TO')) 318 | # 319 | #if not path.exists(border_shape): 320 | # self.out("default shape mask '{}' not found!".format(border_shape), False) 321 | # return None 322 | 323 | border_shape = self._plugin_dir / self._config.get('Paths', 'CUT_TO') 324 | 325 | if not border_shape.exists(): 326 | self.out(f"default shape mask '{border_shape}' not found!", False) 327 | return None 328 | 329 | return str(border_shape) # convert as string, otherwise error "Posix path" 330 | 331 | @property 332 | def default_print_template(self): 333 | # verschiedene Namen wegen internem Pfad für Logo: 334 | #fn = "Druckvorlage.qpt" if not platform.system() == 'Windows' else "Druckvorlage_win.qpt" 335 | 336 | #print_template = path.join( self._config.get('Paths', 'RESSOURCE_PATH'), "Layout/{}".format(fn) ) 337 | #print_template = Path(self._config.get('Paths', 'RESSOURCE_PATH')) / "Layout" / fn 338 | 339 | print_template = self._plugin_dir / self._config.get('Paths', 'print_layout') 340 | 341 | #if not path.exists(print_template): 342 | if not print_template.exists(): 343 | self.out(f"default print template '{print_template}' not found!", False) 344 | return None 345 | 346 | return print_template 347 | 348 | @property 349 | def default_project_file(self): 350 | #project_file = self._config.get('Paths', 'DEFAULT_PROJECT') 351 | #project_file = path.join(self._plugin_dir, project_file) 352 | project_file = self._plugin_dir / self._config.get('Paths', 'DEFAULT_PROJECT') 353 | 354 | if not project_file.exists(): 355 | self.out(f"default project '{project_file}' not found!", False) 356 | return None 357 | 358 | return project_file 359 | 360 | 361 | @property 362 | def symbology_path(self): 363 | #symb_path = self._config.get('Paths', 'STYLE_PATH') 364 | #symb_path = path.join(self._plugin_dir, symb_path) 365 | symb_path = self._plugin_dir / self._config.get('Paths', 'STYLE_PATH') 366 | 367 | #if not path.exists(symb_path): 368 | if not symb_path.exists(): 369 | self.out(f"symbology path '{symb_path}' not found!", False) 370 | return None 371 | 372 | return symb_path 373 | 374 | 375 | @property 376 | def logo_path(self): 377 | default_logo_image = self._plugin_dir / "img" / 'qgis_logo.png' 378 | 379 | try: 380 | logo_image = Path(self._config.get('Paths', 'logo_image')) 381 | except NoOptionError: # use standard logo 382 | logo_image = default_logo_image 383 | else: 384 | if not logo_image.exists(): 385 | self.out(f"logo image '{logo_image}' not found!", False) 386 | logo_image = default_logo_image 387 | 388 | return logo_image 389 | 390 | @property 391 | def check_file(self): 392 | return Path(self.plugin_dir) / '__check__' 393 | @property 394 | def news_file(self): 395 | return Path(self.plugin_dir) / 'news.txt' 396 | @property 397 | def settings_file(self): 398 | return Path(self.plugin_dir) / 'settings.json' 399 | @property 400 | def data_root_def_file(self): 401 | return Path(self.profile_dir) / self._config.get('Paths', 'datadir_deffile_basename') 402 | @property 403 | def history_file(self): 404 | return Path(self.profile_dir) / self._config.get('Paths', 'last_products_basename') 405 | 406 | 407 | # Projections 408 | 409 | @property 410 | def dict_projections(self): 411 | return def_projections.projs 412 | 413 | @property 414 | def projection_radolan(self): 415 | """ get CRS string for earth as sphere """ 416 | _, params = def_projections.projs[0] # [ desc, params ] 417 | return params 418 | 419 | @property 420 | def projection_polara_wgs(self): 421 | """ get CRS string for earth as WGS ellipsoid """ 422 | _, params = def_projections.projs[1] # [ desc, params ] 423 | return params 424 | 425 | @property 426 | def projections(self): 427 | return self._l_projections 428 | 429 | 430 | 431 | def test_product_get_id(radolan_file): 432 | """ Special function, called separately 433 | True if product is X-product (RX, WX, EX, that means coded in RVP6-units); 434 | False if not """ 435 | 436 | print(f'test_product_get_id("{radolan_file}")') 437 | 438 | nrr = NumpyRadolanReader(radolan_file) # FileNotFoundError 439 | # -> file handle open 440 | header = nrr._read_radolan_header() 441 | nrr._fobj.close() 442 | 443 | prod_id = header[:2] 444 | 445 | print(f" {header}") 446 | print(f" product id: {prod_id}") 447 | 448 | return prod_id 449 | 450 | -------------------------------------------------------------------------------- /classes/GDALProcessing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from pathlib import Path 4 | import sys 5 | import platform # running on Windows or not 6 | import subprocess 7 | 8 | import processing 9 | from processing.algs.gdal.GdalUtils import GdalUtils # only for getting the version no. 10 | from qgis.core import QgsRasterLayer, QgsCoordinateReferenceSystem, QgsProcessingUtils, QgsProcessingContext 11 | from qgis.PyQt.QtCore import QSettings 12 | 13 | from osgeo import gdal #, osr # install Paket: 'python3-gdal' 14 | 15 | gdal.UseExceptions() # Enable exceptions 16 | 17 | #convert_script = 'radolanasc_to_laeatif.py' # <- .asc file 18 | 19 | 20 | class GDALProcessing: 21 | """Created on 23.11.2017 22 | @author: Weatherman""" 23 | 24 | def __init__(self, model, full_asc_filename, full_tif_filename): 25 | 26 | self.out(f"<- model, '{full_asc_filename}', '{full_tif_filename}'") 27 | 28 | # 29 | # Input data 30 | # 31 | 32 | if not model: 33 | raise OSError("'model' is None!") 34 | 35 | self._model = model 36 | self._full_asc_filename = Path(full_asc_filename) 37 | self._full_tif_filename = Path(full_tif_filename) 38 | 39 | # 40 | # determined 41 | # 42 | 43 | self._result = None # TIF-File oder TIF-Ergebnis im Speicher zur Weiterverarbeitung 44 | 45 | ''' 46 | #convert_script_path = path.join(self._model.plugin_dir, convert_script) 47 | convert_script_path = path.join(path.dirname(__file__), convert_script) 48 | 49 | if not path.exists(convert_script_path): 50 | msg = "RADOLAN bin to tif convert script not found.\nSearched for: '{}'".format(convert_script_path) 51 | raise FileNotFoundError(msg) 52 | self._convert_script_path = convert_script_path 53 | ''' 54 | self._convert_script_path = None # only for old method 55 | 56 | def __str__(self): 57 | return self.__class__.__name__ 58 | 59 | def out(self, s, ok=True): 60 | if ok: 61 | print(f"{self}: {s}") 62 | else: 63 | print(f"{self}: {s}", file=sys.stderr) 64 | 65 | def produce_warped_tif_by_python_gdal(self, prj_src, prj_dest_epsg, shapefile=None): 66 | """Convert by OSGEO python gdal module""" 67 | 68 | self.out(f"produce_warped_tif_by_python_gdal('{prj_dest_epsg}', shapefile='{shapefile}')") 69 | 70 | #proj4_params = "+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10 +k=0.93301270189"\ 71 | # + " +x_0=0 +y_0=0 +a=6370040 +b=6370040 +units=m +no_defs" 72 | ''' 73 | # Mit dem Parameter 'k' funktionierte der Aufruf nicht (18.07.2019). 74 | proj4_params = ('+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10' 75 | '+x_0=0 +y_0=0 +a=6370040 +b=6370040' 76 | '+units=m +no_defs') 77 | ''' 78 | 79 | """\ 80 | A PRJ file contains a projected coordinate system. 81 | It begins with a name for the projected coordinate system. 82 | Then it describes the geographic coordinate system. 83 | Then it defines the projection and all the parameters needed for the projection. 84 | It then defines the linear units used in the projection. 85 | The final entry (AUTHORITY) is optional and describes any standard designation for this projection. 86 | Lines 2-8 define the geographic coordinate system. 87 | It begins with a name for the geographic coordinate system. 88 | Then it describes the datum. 89 | Then it defines the prime meridian used. Finally, it defines the angular units. 90 | Lines 3-5 define the geodetic datum. It begins with a name for the datum. Then it defines the spheroid. 91 | Line 4 defines the spheroid. It begins with a name for the spheroid. 92 | Then next parameter is the equatorial radius of the ellipsoid (in meters). 93 | The last parameter is the inverse flattening factor. 94 | """ 95 | spatial_ref = '''\ 96 | PROJCS["DWD (RADOLAN)", 97 | GEOGCS["RADOLAN Datum", 98 | DATUM["D_custom", 99 | SPHEROID["custom",6370040.0,0.0]], 100 | PRIMEM["Greenwich",0.0, AUTHORITY["EPSG","8901"]], 101 | UNIT["degree",0.017453292519943295], 102 | AXIS["Longitude",EAST], 103 | AXIS["Latitude",NORTH]], 104 | PROJECTION["polar_stereographic"], 105 | PARAMETER["latitude_of_origin",90.0], 106 | PARAMETER["central_meridian",10.0], 107 | PARAMETER["standard_parallel_1",60.0], 108 | PARAMETER["scale_factor",0.93301270189], 109 | PARAMETER["false_easting",0.0], 110 | PARAMETER["false_northing",0.0], 111 | UNIT["Meter",1.0], 112 | AXIS["X",EAST], 113 | AXIS["Y",NORTH]]''' 114 | 115 | """ 116 | Beginn 117 | """ 118 | 119 | # string type important! otherwise problems with gdal.Warp()! 120 | ds_in = gdal.Open(str(self._full_asc_filename)) 121 | #srs = osr.SpatialReference() 122 | #srs.ImportFromWkt(spatial_ref) 123 | #ds_in.SetProjection(srs.ExportToWkt()) 124 | ds_in.SetProjection(spatial_ref) 125 | 126 | """ # works: 127 | dest = osr.SpatialReference() 128 | dest.ImportFromEPSG(3035) 129 | gdal.Warp(tif_file, ds_in, srcSRS=proj4_params, dstSRS=dest) 130 | """ 131 | 132 | #gdal.Warp(tif_file, ds_in, srcSRS=proj4_params, dstSRS='EPSG:3035') 133 | 134 | self.out(f"gdal.Warp from '{prj_src}' -> '{prj_dest_epsg}'") 135 | #compress_method = 'LZW' 136 | compress_method = 'DEFLATE' # lossless 137 | 138 | # from Config 139 | 140 | # with clipping: 141 | if shapefile: 142 | gdal.Warp(str(self._full_tif_filename), ds_in, 143 | cutlineDSName=f'{shapefile}', cropToCutline=True, 144 | srcSRS=prj_src, dstSRS=prj_dest_epsg, 145 | creationOptions=[f'COMPRESS={compress_method}']) 146 | # without clipping: 147 | else: 148 | gdal.Warp(str(self._full_tif_filename), ds_in, 149 | srcSRS=prj_src, dstSRS=prj_dest_epsg, 150 | creationOptions=[f'COMPRESS={compress_method}']) 151 | 152 | ds_in = None # should one do that? 153 | 154 | """ 155 | following: old warp methods / scripts: 156 | """ 157 | 158 | def produce_warped_tif_using_script(self): 159 | """ 160 | Convert by command line 161 | """ 162 | 163 | # starting with 'python3' so that it work on Windows? 164 | cmd = f'python3 {self._convert_script_path} "{self._full_asc_filename}" -o "{self._full_tif_filename.parent}"' 165 | self.out(f'running: "{cmd}"') 166 | """ 167 | * To also capture standard error in the result, use stderr=subprocess.STDOUT 168 | * shell=True, wenn cmd=string. Sonst als [] 169 | * Since Python 3.6 you can make check_output() return a str instead 170 | of bytes by giving it an encoding parameter: ..., encoding='UTF-8' """ 171 | # Exception (wenn exit != 0) auffangen: 172 | try: 173 | out = subprocess.check_output(cmd, 174 | stderr=subprocess.STDOUT, shell=True, universal_newlines=True, 175 | encoding='UTF-8') 176 | except subprocess.CalledProcessError as e: 177 | print(e.output) 178 | else: 179 | print(out) 180 | 181 | def produce_warped_tif(self, write_result): 182 | self.out("produce_warped_tif()") 183 | 184 | """ 185 | ASCII to warped TIF 186 | """ 187 | 188 | # very important! 189 | if self._full_tif_filename.exists(): 190 | #self.out("removing old version of warped TIF before creating a new one...") 191 | self._full_tif_filename.unlink() 192 | 193 | proj_radolan = self._model.projection_radolan 194 | 195 | """ 196 | METHOD Options: 197 | 0 - near 198 | 1 - bilinear 199 | 2 - cubic 200 | 3 - cubicspline 201 | 4 - lanczos 202 | Eigentlich Default: 0 - aber man muss es angeben (QGIS 2.14 "Essen") 203 | 204 | RTYPE: default: 5 = Float32 205 | 206 | COMPRESS(GeoTIFF options. Compression type:) 207 | 0 - NONE 208 | 1 - JPEG 209 | 2 - LZW 210 | 3 - PACKBITS 211 | 4 - DEFLATE 212 | wohl kein Default, also angeben 213 | """ 214 | # Standard (certain) params: 215 | params = { 216 | "INPUT": str(self._full_asc_filename), 217 | "SOURCE_SRS": proj_radolan, 218 | #"DEST_SRS": 'EPSG:3035', # QGIS 2? 219 | "TARGET_CRS": 'EPSG:3035', 220 | "METHOD": 0, 221 | "COMPRESS": 4 222 | } 223 | 224 | """ 225 | useful tip: 226 | If you don't need to use the output for further elaborations, you may save it as memory layer. 227 | For doing this you only need to set the 'output' parameter as None: 228 | 229 | first = processing.runalg("gdalogr:warpreproject", { "INPUT": ... , "OUTPUT": None } ) 230 | 231 | Instead, if you want to use the output, you may easily do this by giving it a name and 232 | then by calling it with getObject(): 233 | 234 | second = processing.getObject( first['OUTPUT'] ) 235 | 236 | -> https://gis.stackexchange.com/questions/224389/pyqgis-processing-runalg-release-input-in-windows 237 | """ 238 | 239 | #params['OUTPUT'] = self._full_tif_filename if write_result else None 240 | params['OUTPUT'] = self._full_tif_filename if write_result else 'none' # in QGIS 3. Verrückt 241 | 242 | 243 | # New GdalUtils version with some additional parameters! (RAST_EXT, ...) 244 | #if version >= 2000000: 245 | # Other possibility: 246 | #>>> QgsExpressionContextUtils.globalScope().variableNames(): show all avail vars 247 | #>>> QgsExpressionContextUtils.globalScope().variable('qgis_version') # with name 248 | # out: u'2.99.0-Master' 249 | #>>> QgsExpressionContextUtils.globalScope().variable('qgis_version_no') # better 250 | 251 | """ .............................................................. 252 | HERE WE CAN SEND A GDAL CALL ADAPTED TO THE CORRESPONDING VERSION! 253 | .............................................................. """ 254 | 255 | add_additional_keys = False 256 | 257 | try: 258 | version = GdalUtils.version() 259 | 260 | if platform.system() == 'Windows': 261 | system_name = f"{platform.system()} {platform.release()}" 262 | raise OSError(f"Running on '{system_name}'") 263 | 264 | #except AttributeError as ex: # ex: class GdalUtils has no attribute 'version' 265 | except AttributeError: 266 | self.out("WARN: GdalUtils.version could not determined (older version?)", False) 267 | self.out("continue without dict keys 'RAST_EXT', 'EXT_CRS'", False) 268 | 269 | except OSError as e: 270 | self.out(e, False) 271 | self.out("continue without dict keys 'RAST_EXT', 'EXT_CRS'", False) 272 | 273 | # only if try OK: section for determining the extent of input raster by creating layer datatype 274 | else: 275 | self.out(f"GdalUtils.version is {version}") 276 | # Result for 277 | # Dev-Version/Linux: 2010200 278 | # 2.18.15 Las Palmas: 2020300 279 | 280 | # For this version number it didn't work! (openSUSE Leap 42.3, QGIS version see above) 281 | if version != 2020300: 282 | add_additional_keys = True 283 | 284 | # End of try-except-else-block 285 | 286 | if add_additional_keys: 287 | # Make layer type from ASCII file to determine the mandatory 'extent': 288 | asc_layer = self._create_QGSRasterLayer_with_projection(proj_radolan) # avoid projection dialog 289 | extent = asc_layer.extent() 290 | 291 | extent_params_as_string = "%f,%f,%f,%f" \ 292 | % (extent.xMinimum(), extent.xMaximum(), extent.yMinimum(), extent.yMaximum() ) 293 | 294 | # insert these params later for newer GDAL versions: 295 | params['RAST_EXT'] = extent_params_as_string ### NEW! for new gdal versions. 296 | params['EXT_CRS' ] = proj_radolan ### NEW! for new gdal versions. MUST be Radolan-Proj.!!! 297 | self.out(" -> inserting new keys 'RAST_EXT', 'EXT_CRS'") 298 | 299 | # Diagnose: 300 | print("### Dict params are:\n ", params) 301 | 302 | # For overview of the parameters use in QGIS python console: 303 | # processing.alghelp("gdalogr:warpreproject") 304 | #target = processing.runalg("gdalogr:warpreproject", params ) # return: dict QGIS 2 305 | target = processing.run("gdal:warpreproject", params) # QGIS 3 306 | 307 | fallback = False 308 | 309 | # TIF shouldt be written but doesn't exists: 310 | if write_result and not self._full_tif_filename.exists(): 311 | fallback = True 312 | 313 | # Normal mode: return Memory Layer: 314 | if not fallback: 315 | #self._result = processing.getObject(target["OUTPUT"]) # key of dict, QGIS 2 316 | context = QgsProcessingContext() 317 | self._result = QgsProcessingUtils.mapLayerFromString(target["OUTPUT"], context) 318 | #print("result =", self._result) 319 | return #return self._result 320 | 321 | """ 322 | When warped TIF wasn't created: 323 | FALLBACK MODE without dict 324 | normally not, when dict is working 325 | The normal way is positive tested on a QGIS-dev on Linux 2.12.99. 326 | But the following fallback way need to implemented for the following 327 | configuration: also on Linux, Python-Version 2.7.12 (default, Jul 01 2016, 15:36:53) [GCC] 328 | QGIS-Version: 2.8.2 "Wien", exported 329 | """ 330 | #return 331 | self._produce_warped_tif_fallback(write_result) 332 | 333 | # if success: 334 | # -> full_tif_filename is produced. 335 | 336 | def _create_QGSRasterLayer_with_projection(self, proj_radolan): 337 | """ 338 | Change QGIS-Settings to avoid the assign CRS dialog to the ascii layer 339 | (appear at the point "QgsRasterLayer(..."). 340 | asc_layer.setCrs(crs_radolan) is not sufficient! 341 | == Part 1 of 2 == 342 | 343 | see 344 | https://gis.stackexchange.com/questions/27745/how-can-i-specify-the-crs-of-a-raster-layer-in-pyqgis/27765#27765 345 | """ 346 | settings = QSettings() 347 | old_validation = settings.value("/Projections/defaultBehavior") 348 | settings.setValue("/Projections/defaultBehavior", "useGlobal") 349 | 350 | asc_layer = QgsRasterLayer(self._full_asc_filename, self._full_asc_filename.name) 351 | 352 | crs_radolan = QgsCoordinateReferenceSystem() 353 | crs_radolan.createFromProj4(proj_radolan) 354 | #asc_layer.setCrs( QgsCoordinateReferenceSystem(PROJ_RADOLAN_NATIVE, QgsCoordinateReferenceSystem.EpsgCrsId) ) 355 | # -> funktioniert nicht :-/ 356 | asc_layer.setCrs(crs_radolan) 357 | 358 | """ 359 | Change QGIS-Settings to avoid the assign CRS dialog to the ascii layer. 360 | asc_layer.setCrs(crs_radolan) is not sufficient! 361 | == Part 2 of 2 == 362 | """ 363 | settings.setValue("/Projections/defaultBehavior", old_validation) 364 | 365 | return asc_layer 366 | 367 | def _produce_warped_tif_fallback(self, write_result): 368 | """ Fallback Method. 369 | Normally not used. Uses the same handed over parameters as the normal method """ 370 | 371 | self.out("_produce_warped_tif_fallback()") 372 | 373 | self.out("ERROR: warped TIF wasn't produced, maybe there is a problem with" 374 | " the processing.runalg dict interface(?)", False) 375 | self.out("switching to fallback mode with handed over conventional arguments", False) 376 | 377 | """ 378 | !!! here we have to fill in every param in the correct order !!! 379 | 380 | ALGORITHM: Warp (reproject) 381 | 382 | INPUT 383 | SOURCE_SRS 384 | DEST_SRS 385 | NO_DATA 386 | TR 387 | METHOD 388 | RAST_EXT NEW (in this interface not used) 389 | EXT_CRS NEW (in this interface not used) 390 | RTYPE 391 | COMPRESS 392 | JPEGCOMPRESSION 393 | ZLEVEL 394 | PREDICTOR 395 | TILED 396 | BIGTIFF 397 | TFW 398 | EXTRA 399 | OUTPUT 400 | 401 | => Defaults / meaning of values see in above not fallback warp function 402 | """ 403 | 404 | result = self._full_tif_filename if write_result else None 405 | 406 | # without RAST_EXT, EXT_CRS: 407 | #processing.runalg("gdalogr:warpreproject", # QGIS 2 408 | processing.run("gdal:warpreproject", # QGIS 3 409 | self._full_asc_filename, 410 | self._model.projection_radolan, 'EPSG:3035', 411 | "", 0, 0, 5, 4, 1, 1, 1, False, 2, False, "", 412 | result) 413 | 414 | self._result = result 415 | #return result 416 | 417 | def clip_raster_by_mask(self, mask_shape): 418 | 419 | clipped_tif_name = self._full_tif_filename.name.replace('.tif', "_clipped.tif") 420 | clipped_tif_path = Path(self._model.data_dir, clipped_tif_name) 421 | 422 | self.out(f"clip_raster_by_mask('{mask_shape}')") 423 | 424 | if clipped_tif_path.exists(): 425 | self.out(" removing old version of clipped TIF before creating a new one...") 426 | clipped_tif_path.unlink() 427 | 428 | # MemoryLayer as input for next step: 429 | 430 | #compress_method = 'LZW' 431 | compress_method = 'DEFLATE' # lossless 432 | 433 | #processing.runandload("gdalogr:cliprasterbymasklayer", { 434 | # 'runandload' is useable here too, but we want to have the control, set name, position etc. 435 | processing.run("gdal:cliprasterbymasklayer", { 436 | 'INPUT': self._full_tif_filename, 437 | 'MASK': mask_shape, 438 | 'OPTIONS': f'COMPRESS={compress_method}', 439 | 'OUTPUT': str(clipped_tif_path) 440 | }) 441 | 442 | self.out(f" -> '{clipped_tif_path}'") 443 | 444 | self._full_tif_filename = clipped_tif_path # anpassen 445 | 446 | return clipped_tif_path 447 | 448 | ##--config GDALWARP_IGNORE_BAD_CUTLINE YES 449 | 450 | @property 451 | def tif_file(self): 452 | return self._full_tif_filename 453 | --------------------------------------------------------------------------------