Skip to content

Commit

Permalink
Merge pull request #32 from agrenott/dev
Browse files Browse the repository at this point in the history
Add Sonny's LiDAR DTM source
  • Loading branch information
agrenott authored Nov 18, 2023
2 parents bd2137e + 14daf44 commit 192dadb
Show file tree
Hide file tree
Showing 15 changed files with 769 additions and 39 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/pythonpackage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
platform: [ubuntu-latest, macos-latest] #, windows-latest]

runs-on: ${{ matrix.platform }}
Expand All @@ -26,9 +26,10 @@ jobs:
with:
auto-update-conda: true
python-version: ${{ matrix.python-version }}
miniforge-version: latest
- name: Install dependencies
run: |
conda install gdal
conda install -c conda-forge gdal
python -m pip install --upgrade pip hatch
- name: Test with coverage with all optional dependencies
run: |
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[![Python: 3.9, 3.10](https://img.shields.io/badge/python-3.9%20%7C%203.10-blue)](https://www.python.org)
[![Python: 3.9, 3.10, 3.11, 3.12](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue)](https://www.python.org)
![GitHub](https://img.shields.io/github/license/agrenott/pyhgtmap)
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/agrenott/pyhgtmap/pythonpackage.yaml)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
Expand Down Expand Up @@ -28,6 +28,32 @@ simple form of parallelization and the advance will be dramatical.
Note that the intended use is not to upload generated contour OSM data to the
OSM servers but to use it for fancy maps.

# Sources

pyhgtmap supports several HGT data sources. Those are identified by a 4-character "nickname" + one digit resolution suffix, which can be specified with the `--source` parameter of pyhgtmap.

## SRTM

[NASA Shuttle Radar Topography Mission v3.0](https://www.earthdata.nasa.gov/news/nasa-shuttle-radar-topography-mission-srtm-version-3-0-global-1-arc-second-data-released-over-asia-and-australia)

*Available for 1" and 3" resolutions.*

This source requires creating an earthexplorer account on https://ers.cr.usgs.gov/register/.

## VIEW

[VIEWFINDER PANORAMAS DIGITAL ELEVATION DATA](http://viewfinderpanoramas.org/dem3.html)

*Available for 1" and 3" resolutions.*

## SONN

[Sonny's LiDAR Digital Terrain Models (DTM) of European countries](https://sonny.4lima.de/)

*Available for 1" and 3" resolutions.*

This source require usage of Google Drive API. To use it, you have to generate API client OAuth secret as described in [pydrive2's documentation](https://docs.iterative.ai/PyDrive2/quickstart/) and save the client secrets JSON file in pyhgtmap config directory (`~/.pyhgtmap/client-secret.json`).

# Installation

For ubuntu-like system:
Expand Down
55 changes: 36 additions & 19 deletions pyhgtmap/NASASRTMUtil.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
import base64
import os
import sys
from typing import List, Optional, Tuple
import urllib
import zipfile
from http import cookiejar as cookielib
from typing import List, Optional, Tuple

import numpy
from bs4 import BeautifulSoup
from matplotlib.path import Path as PolygonPath

from pyhgtmap.configUtil import CONFIG_DIR
from pyhgtmap.sources.pool import Pool


class NASASRTMUtilConfigClass(object):
"""The config is stored in a class, to be configurable from outside
Expand Down Expand Up @@ -660,16 +663,16 @@ def getDirNames(source):
return NASASRTMUtilConfig.hgtSaveDir, hgtSaveSubDir


def initDirs(sources):
def initDirs(sources: List[str]) -> None:
mkdir(NASASRTMUtilConfig.hgtSaveDir)
for source in sources:
sourceType, sourceResolution = source[:4], int(source[4])
if sourceType == "srtm":
source_type, source_resolution = source[:4], int(source[4])
if source_type == "srtm":
srtmVersion = float(source[6:])
NASAhgtSaveSubDir = os.path.join(
NASASRTMUtilConfig.hgtSaveDir,
NASASRTMUtilConfig.NASAhgtSaveSubDirRe.format(
sourceResolution, srtmVersion
source_resolution, srtmVersion
),
)
if srtmVersion == 2.1:
Expand All @@ -696,10 +699,10 @@ def initDirs(sources):
# we can try the create the directory no matter if we already renamed
# an old directory to this name
mkdir(NASAhgtSaveSubDir)
elif sourceType == "view":
elif source_type == "view":
VIEWhgtSaveSubDir = os.path.join(
NASASRTMUtilConfig.hgtSaveDir,
NASASRTMUtilConfig.VIEWhgtSaveSubDirRe.format(sourceResolution),
NASASRTMUtilConfig.VIEWhgtSaveSubDirRe.format(source_resolution),
)
mkdir(VIEWhgtSaveSubDir)

Expand Down Expand Up @@ -864,17 +867,30 @@ def downloadAndUnzip_Zip(opener, url, area, source):
return None


def getFile(opener, area, source):
fileResolution = int(source[4])
if source.startswith("srtm"):
srtmVersion = float(source[6:])
url = getNASAUrl(area, fileResolution, srtmVersion)
elif source.startswith("view"):
url = getViewUrl(area, fileResolution)
if not url:
return None
else:
return downloadAndUnzip(opener, url, area, source)
class SourcesPool:
"""Stateful pool of various HGT data sources."""

# TODO get rid of this layer once existing sources are migrated to the new framework

def __init__(self) -> None:
self._real_pool = Pool(NASASRTMUtilConfig.hgtSaveDir, CONFIG_DIR)

def get_file(self, opener, area: str, source: str):
fileResolution = int(source[4])
if source.startswith("srtm"):
srtmVersion = float(source[6:])
url = getNASAUrl(area, fileResolution, srtmVersion)
elif source.startswith("view"):
url = getViewUrl(area, fileResolution)
elif source.startswith("sonn"):
file_name = self._real_pool.get_source("sonn").get_file(
area, fileResolution
)
return file_name
if not url:
return None
else:
return downloadAndUnzip(opener, url, area, source)


def getFiles(
Expand All @@ -888,14 +904,15 @@ def getFiles(
bbox = calcBbox(area, corrx, corry)
areaPrefixes = makeFileNamePrefixes(bbox, polygon, corrx, corry)
files = []
sources_pool = SourcesPool()
if anySRTMsources(sources):
opener = earthexplorerLogin()
else:
opener = None
for area, checkPoly in areaPrefixes:
for source in sources:
print("{0:s}: trying {1:s} ...".format(area, source))
saveFilename = getFile(opener, area, source)
saveFilename = sources_pool.get_file(opener, area, source)
if saveFilename:
files.append((saveFilename, checkPoly))
break
Expand Down
14 changes: 12 additions & 2 deletions pyhgtmap/configUtil.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
# -*- encoding: utf-8 -*-

import base64
import pathlib
import os

CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".pyhgtmap")
CONFIG_FILENAME = os.path.join(CONFIG_DIR, ".pyhgtmaprc")


def create_config_dir() -> None:
"""Create configuration directory if it doesn't exist."""
pathlib.Path(CONFIG_DIR).mkdir(exist_ok=True)


class Config(object):
def __init__(self, filename):
self.filename = filename
def __init__(self):
self.filename = CONFIG_FILENAME
self.parse()
self.needsWrite = False

Expand Down
18 changes: 8 additions & 10 deletions pyhgtmap/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
from pyhgtmap.hgt.processor import HgtFilesProcessor
from pyhgtmap.logger import configure_logging

configFilename = os.path.join(os.path.expanduser("~"), ".pyhgtmaprc")

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -354,11 +352,11 @@ def parseCommandLine(sys_args: List[str]) -> Tuple[Values, List[str]]:
"--data-source",
help="specify a list of"
"\nsources to use as comma-seperated string. Available sources are"
"\n'srtm1', 'srtm3', 'view1' and 'view3'. If specified, the data source"
"\nwill be selected using this option as preference list. Specifying"
"\n--source=view3,srtm3 for example will prefer viewfinder 3 arc second"
"\ndata to NASA SRTM 3 arc second data. Also see the --srtm-version"
"\noption for different versions of SRTM data.",
"\n'srtm1', 'srtm3', 'sonn1', 'sonn3' 'view1' and 'view3'. If specified,"
"\nthe data source will be selected using this option as preference list."
"\nSpecifying --source=view3,srtm3 for example will prefer viewfinder 3"
"\narc second data to NASA SRTM 3 arc second data. Also see the"
"\n--srtm-version option for different versions of SRTM data.",
metavar="DATA-SOURCE",
action="store",
default=None,
Expand Down Expand Up @@ -484,7 +482,7 @@ def parseCommandLine(sys_args: List[str]) -> Tuple[Values, List[str]]:
if opts.dataSource:
opts.dataSource = [el.strip() for el in opts.dataSource.lower().split(",")]
for s in opts.dataSource:
if s[:5] not in ["view1", "view3", "srtm1", "srtm3"]:
if s[:5] not in ["view1", "view3", "srtm1", "srtm3", "sonn1", "sonn3"]:
print("Unknown data source: {0:s}".format(s))
sys.exit(1)
elif s in ["srtm1", "srtm3"]:
Expand All @@ -509,10 +507,10 @@ def parseCommandLine(sys_args: List[str]) -> Tuple[Values, List[str]]:
needsEarthexplorerLogin = True
if needsEarthexplorerLogin:
# we need earthexplorer login credentials handling then
earthexplorerUser = configUtil.Config(configFilename).setOrGet(
earthexplorerUser = configUtil.Config().setOrGet(
"earthexplorer_credentials", "user", opts.earthexplorerUser
)
earthexplorerPassword = configUtil.Config(configFilename).setOrGet(
earthexplorerPassword = configUtil.Config().setOrGet(
"earthexplorer_credentials", "password", opts.earthexplorerPassword
)
if not all((earthexplorerUser, earthexplorerPassword)):
Expand Down
9 changes: 8 additions & 1 deletion pyhgtmap/output/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ def make_osm_filename(
for srcName in input_files_names
]
for srcNameMiddle in set(srcNameMiddles):
if srcNameMiddle.lower()[:5] in ["srtm1", "srtm3", "view1", "view3"]:
if srcNameMiddle.lower()[:5] in [
"srtm1",
"srtm3",
"view1",
"view3",
"sonn1",
"sonn3",
]:
continue
elif not opts.dataSource:
# files from the command line, this could be something custom
Expand Down
95 changes: 95 additions & 0 deletions pyhgtmap/sources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Common HGT sources utilities: Source base class and registry."""

import logging
import os
import pathlib
from abc import ABC, abstractmethod
from typing import List, Optional

from class_registry import AutoRegister, ClassRegistry

__all__: List[str] = []

LOGGER: logging.Logger = logging.getLogger(__name__)


# This registry will return a new instance for each get
SOURCES_TYPES_REGISTRY = ClassRegistry(attr_name="NICKNAME", unique=True)


class Source(ABC, metaclass=AutoRegister(SOURCES_TYPES_REGISTRY)): # type: ignore # Mypy does not understand dynamically-computed metaclasses
"""HGT source base class"""

# Source's 'nickname', used to identify it from the command line and
# cache folders management.
# MUST be 4 alphanum for backward compatibility.
NICKNAME: str

def __init__(self, cache_dir_root: str, config_dir: str) -> None:
"""
Args:
cache_dir_root (str): Root directory to store cached HGT files
config_dir (str): Root directory to store configuration (if any)
"""
if len(self.NICKNAME) != 4:
raise ValueError("Downloader nickname must be exactly 4 char long")
self.cache_dir_root: str = cache_dir_root
self.config_dir: str = config_dir

def get_cache_dir(self, resolution: int) -> str:
"""Get the cache directory for given resolution"""
return os.path.join(self.cache_dir_root, f"{self.NICKNAME.upper()}{resolution}")

def check_cached_file(self, file_name: str, resolution: int) -> None:
"""
Check HGT file exists and its size corresponds to current resolution.
Raises exception if not.
"""
wanted_size: int = 2 * (3600 // resolution + 1) ** 2
found_size: int = os.path.getsize(file_name)
if found_size != wanted_size:
raise IOError(
f"Wrong size: expected {wanted_size}, found {found_size} for {file_name}"
)

def get_file(self, area: str, resolution: int) -> Optional[str]:
"""Get HGT file corresponding to requested area, from cache if already downloaded.
Args:
area (str): Area to cover, eg. "N42E004"
resolution (int): Resolution (in arc second)
Returns:
str | None: file name of the corresponding file if available, None otherwise
"""
file_name = os.path.join(self.get_cache_dir(resolution), f"{area}.hgt")
try:
# Check if file already exists in cache and is valid
self.check_cached_file(file_name, resolution)
LOGGER.debug("%s: using existing file %s.", area, file_name)

except IOError:
try:
# Missing file or corrupted, download it
pathlib.Path(self.get_cache_dir(resolution)).mkdir(
parents=True, exist_ok=True
)
self.download_missing_file(area, resolution, file_name)
self.check_cached_file(file_name, resolution)
except (FileNotFoundError, IOError):
return None

return file_name

@abstractmethod
def download_missing_file(
self, area: str, resolution: int, output_file_name: str
) -> None:
"""Actually download and save HGT file into cache.
Args:
area (str): Area to cover, eg. "N42E004"
resolution (int): Resolution (in arc second)
output_file_name (str): file name to save the content to
"""
raise NotImplementedError
Loading

0 comments on commit 192dadb

Please sign in to comment.