Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
"ms-toolsai.jupyter-renderers",
"davidanson.vscode-markdownlint",
"bierner.markdown-mermaid",
"ms-python.vscode-pylance",
"ms-python.python",
"ms-python.debugpy",
"charliermarsh.ruff",
"mhutchie.git-graph"
"mhutchie.git-graph",
"astral-sh.ty"
]
}
59 changes: 52 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,60 @@
# UMEP Core

## Setup
## Installation

```bash
pip install umep
```

Or with uv:

```bash
uv add umep
```

## Troubleshooting

If you encounter DLL or import errors (common on Windows), run the diagnostic tool:

```bash
umep-doctor
```

### Common Issues

**OSGeo4W / QGIS Users**

Do NOT pip install into the OSGeo4W Python environment. The pre-installed GDAL binaries will conflict with rasterio's bundled DLLs, causing errors like:

```
ImportError: DLL load failed while importing _base: The specified procedure could not be found.
```

Instead, create a separate virtual environment:

```bash
uv venv --python 3.12
.venv\Scripts\activate # Windows
uv pip install umep
```

**Conda Alternative**

If you prefer conda, use conda-forge for the geospatial dependencies:

```bash
conda create -n umep -c conda-forge python=3.12 rasterio geopandas pyproj shapely
conda activate umep
pip install umep
```

## Development Setup

- Make sure you have a Python installation on your system
- Install `vscode` and `github` apps.
- Install `uv` package manager (e.g. `pip install uv`).
- Clone repo.
- Run `uv sync` from the directory where `pyproject.toml` in located to install `.venv` and packages.
- Select `.venv` Python environment.
- FYI: Recommended settings and extensions are included in the repo. Proceed if prompted to install extensions.
- Develop and commit to Github often!
- Run `uv sync` from the directory where `pyproject.toml` is located to install `.venv` and packages.
- Select `.venv` Python environment in your IDE.
- FYI: Recommended VS Code settings and extensions are included in the repo.

## Demo

Expand Down
28 changes: 28 additions & 0 deletions demos/athens-demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
cdsm_rast,
cdsm_transf.to_gdal(),
CRS.from_epsg(working_crs).to_wkt(),
coerce_f64_to_f32=True,
)
# %%
# wall info for SOLWEIG
Expand All @@ -60,6 +61,8 @@
dem_path=input_path_str + "/DEM.tif",
cdsm_path=output_folder_path_str + "/CDSM.tif",
trans_veg_perc=3,
use_tiled_loading=False,
tile_size=200,
)

# %%
Expand All @@ -68,3 +71,28 @@
"demos/data/athens/parametersforsolweig.json",
)
SRC.run()

# %%
# skyview factor for SOLWEIG - tiled
skyviewfactor_algorithm.generate_svf(
dsm_path=input_path_str + "/DSM.tif",
bbox=total_extents,
out_dir=output_folder_path_str + "/svf_tiled",
dem_path=input_path_str + "/DEM.tif",
cdsm_path=output_folder_path_str + "/CDSM.tif",
trans_veg_perc=3,
use_tiled_loading=True,
tile_size=200,
)

# %%
# Tiled
SRC = solweig_runner_core.SolweigRunCore(
"demos/data/athens/configsolweig_tiled.ini",
"demos/data/athens/parametersforsolweig.json",
use_tiled_loading=True,
tile_size=200,
)
SRC.run()

# %%
87 changes: 87 additions & 0 deletions demos/data/athens/configsolweig_tiled.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
[DEFAULT]

#---------------------------------------------------------------------------------------------------------
####### Control File #######
#---------------------------------------------------------------------------------------------------------
####### INPUTS #######
# output path
output_dir=temp/athens/output_tiled/
# working dir
working_dir=temp/athens/working/
# Input ground and building dsm
dsm_path=demos/data/athens/DSM.tif
# Input vegetation dsm
cdsm_path=temp/athens/CDSM.tif
# Input trunkzone vegetation dsm
tdsm_path=
# Input Digital Elevation Model
dem_path=demos/data/athens/DEM.tif
# Input Land cover dataset
lc_path=
# Input wall height raster
wh_path=temp/athens/walls/wall_hts.tif
# Input wall aspect raster
wa_path=temp/athens/walls/wall_aspects.tif
# Skyview factor files
svf_path=temp/athens/svf_tiled/svfs.zip
# Input file for anisotrophic sky
aniso_path=temp/athens/svf_tiled/shadowmats.npz
# Point of Interest file for ground
poi_path=
poi_field=
plot_poi_patches=0
# Input file for wall temperature scheme (Wallenberg et al. 2025)
wall_path=
# Point of Interest file for walls
woi_path=
woi_field=
# input meteorological file (i.e. forcing file)
met_path=

## input settings ##
# estimate diffuse and global shortwave radiation from global radiation (1)
only_global=0
# use vegetation scheme (lindberg and grimmond, 2011, TAAC)
use_veg_dem=1
# consider leaf on trees full year (1)
conifer=0
# consider person as a cylinder (1) or a box (0)
person_cylinder=1
# utc time given in meteorological forcing file
utc=+3
# land cover scheme activated (1) (Lindberg et al. 2016 UC)
use_landcover=0
# use dem and dsm to identify building pixels (1) else, land cover will be used (0)
use_dem_for_buildings=1
# use anisotropic sky (Wallenberg et al. XXXX and Wallenberg et al. XXXX)
use_aniso=1
# use wall surface temperature scheme (Wallenberg et al. 2025, GMD)
use_wall_scheme=0
# If building materials is not included in lc, then this is used for all buildings (Wood, Brick or Concrete)
wall_type=Concrete
# output settings
output_tmrt=1
output_kup=0
output_kdown=0
output_lup=0
output_ldown=0
output_sh=0
save_buildings=0
output_kdiff=0
output_tree_planter=0
wall_netcdf=0

#---------------------------------------------------------------------------------------------------------
# dates - used if an EPW-file is used
#---------------------------------------------------------------------------------------------------------
# Set to 1 if an EPW file is used as meteorological forcing data
use_epw_file=1
# Path to EPW file
epw_path=demos/data/athens/athens_2023.epw
# year,month,day,hour ## the start date/time for period of interest
epw_start_date=2023,01,01,00
# year,month,day,hour # end date/time
epw_end_date=2023,01,03,23
# hours to use (comma separated, optional)
epw_hours=
######################
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "umep"
version = "0.0.1b47"
version = "0.0.1a20"
description = "urban multi-scale environmental predictor"
readme = "README.md"
requires-python = ">=3.9, <3.14"
Expand Down Expand Up @@ -60,6 +60,9 @@ dev = [
"pytest>=8.4.1",
]

[project.scripts]
umep-doctor = "umep.doctor:main"

[project.urls]
homepage = "https://github.com/UMEP-dev/umep-core"
documentation = "https://github.com/UMEP-dev/umep-core"
Expand Down
55 changes: 50 additions & 5 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import pyproj
import pytest

from umep.common import load_raster, save_raster, xy_to_lnglat
from umep.common import load_raster, save_raster, shrink_bbox_to_pixel_grid, xy_to_lnglat


def _make_gt(width, height, pixel_size=1):
Expand All @@ -17,8 +17,8 @@ def test_save_and_load_raster_roundtrip(tmp_path):
crs_wkt = pyproj.CRS.from_epsg(4326).to_wkt()

# save and load
save_raster(str(out), data, trf, crs_wkt, no_data_val=-9999)
rast, trf_out, crs_out, nodata = load_raster(str(out))
save_raster(str(out), data, trf, crs_wkt, no_data_val=-9999, coerce_f64_to_f32=True)
rast, trf_out, crs_out, nodata = load_raster(str(out), coerce_f64_to_f32=True)

np.testing.assert_array_equal(rast, data)
assert isinstance(trf_out, list) and len(trf_out) == 6
Expand All @@ -34,12 +34,12 @@ def test_load_raster_with_bbox(tmp_path):
data = np.arange(100, dtype=np.float32).reshape(10, 10)
trf = _make_gt(10, 10)
crs_wkt = pyproj.CRS.from_epsg(4326).to_wkt()
save_raster(str(out), data, trf, crs_wkt, -9999)
save_raster(str(out), data, trf, crs_wkt, -9999, coerce_f64_to_f32=True)

# bbox in spatial coords: [minx, miny, maxx, maxy]
# For our geotransform bounds = [0,0,10,10]
bbox = [2, 2, 5, 5]
rast_crop, trf_crop, crs_crop, nd = load_raster(str(out), bbox=bbox)
rast_crop, trf_crop, crs_crop, nd = load_raster(str(out), bbox=bbox, coerce_f64_to_f32=True)

# Expected slice computed from implementation mapping
assert rast_crop.shape == (3, 3)
Expand All @@ -66,3 +66,48 @@ def test_xy_to_lnglat_scalar_and_array():
lons, lats = xy_to_lnglat(crs_wkt, xa, ya)
assert np.array_equal(lons, np.array([0.0, 30.0]))
assert np.array_equal(lats, np.array([0.0, -15.0]))


def test_shrink_bbox_to_pixel_grid():
"""Test bbox snapping to pixel grid by shrinking to nearest whole pixels."""
# Grid with origin at (0, 0), 1m pixels
origin_x, origin_y = 0.0, 0.0
pixel_width, pixel_height = 1.0, 1.0

# Bbox that aligns exactly with grid
bbox = (2.0, 3.0, 5.0, 7.0)
result = shrink_bbox_to_pixel_grid(bbox, origin_x, origin_y, pixel_width, pixel_height)
assert result == (2.0, 3.0, 5.0, 7.0), "Aligned bbox should not change"

# Bbox that needs inward snapping
bbox = (2.3, 3.7, 5.9, 7.2)
result = shrink_bbox_to_pixel_grid(bbox, origin_x, origin_y, pixel_width, pixel_height)
# Should snap to: minx=ceil(2.3)=3, miny=ceil(3.7)=4, maxx=floor(5.9)=5, maxy=floor(7.2)=7
assert result == (3.0, 4.0, 5.0, 7.0), "Bbox should shrink to nearest whole pixels"

# Grid with non-zero origin
origin_x, origin_y = 10.0, 20.0
bbox = (12.5, 23.2, 15.8, 26.9)
result = shrink_bbox_to_pixel_grid(bbox, origin_x, origin_y, pixel_width, pixel_height)
# Relative to origin: (2.5, 3.2, 5.8, 6.9) -> ceil/floor -> (3, 4, 5, 6) -> absolute (13, 24, 15, 26)
assert result == (13.0, 24.0, 15.0, 26.0), "Non-zero origin should be handled correctly"

# Negative pixel height (north-up raster)
origin_x, origin_y = 0.0, 100.0
pixel_width, pixel_height = 1.0, -1.0
bbox = (2.3, 93.1, 5.7, 96.8)
result = shrink_bbox_to_pixel_grid(bbox, origin_x, origin_y, pixel_width, pixel_height)
# For y-axis with negative pixel size:
# miny=93.1 -> origin_y - y = 100 - 93.1 = 6.9 -> floor(6.9) = 6 -> y = 100 - 6 = 94
# maxy=96.8 -> origin_y - y = 100 - 96.8 = 3.2 -> ceil(3.2) = 4 -> y = 100 - 4 = 96
# Expected: (3.0, 94.0, 5.0, 96.0)
assert result == (3.0, 94.0, 5.0, 96.0), "Negative pixel height should work correctly"

# Sub-pixel bbox that would collapse
bbox = (2.1, 3.2, 2.8, 3.7)
with pytest.raises(ValueError, match="collapsed"):
shrink_bbox_to_pixel_grid(bbox, origin_x, origin_y, pixel_width, pixel_height)

# Invalid bbox (min >= max)
with pytest.raises(ValueError, match="invalid"):
shrink_bbox_to_pixel_grid((5.0, 3.0, 2.0, 7.0), origin_x, origin_y, pixel_width, pixel_height)
63 changes: 63 additions & 0 deletions tests/test_solweig_rasters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from pathlib import Path
from typing import Optional

import numpy as np

from umep.class_configs import SolweigConfig
from umep.functions.SOLWEIGpython.solweig_runner_core import SolweigRunCore

PROJECT_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PATH = PROJECT_ROOT / "demos/data/athens/configsolweig.ini"
PARAMS_PATH = PROJECT_ROOT / "demos/data/athens/parametersforsolweig.json"


def _prepare_temp_config(tmp_path: Path) -> Path:
"""Return a config file that writes outputs into a temp dir."""
config = SolweigConfig()
config.from_file(str(CONFIG_PATH))

output_dir = tmp_path / "output"
working_dir = tmp_path / "working"

config.output_dir = str(output_dir)
config.working_dir = str(working_dir)

path_overrides = {
"dsm_path": PROJECT_ROOT / "demos/data/athens/DSM.tif",
"cdsm_path": PROJECT_ROOT / "temp/athens/CDSM.tif",
"dem_path": PROJECT_ROOT / "demos/data/athens/DEM.tif",
"wh_path": PROJECT_ROOT / "temp/athens/walls/wall_hts.tif",
"wa_path": PROJECT_ROOT / "temp/athens/walls/wall_aspects.tif",
"svf_path": PROJECT_ROOT / "temp/athens/svf/svfs.zip",
"aniso_path": PROJECT_ROOT / "temp/athens/svf/shadowmats.npz",
"epw_path": PROJECT_ROOT / "demos/data/athens/athens_2023.epw",
}
for attr, path in path_overrides.items():
setattr(config, attr, str(path))

tmp_config = tmp_path / "configsolweig.ini"
config.to_file(str(tmp_config))
return tmp_config


def _assert_float32(array: Optional[np.ndarray], name: str):
if array is not None:
assert array.dtype == np.float32, f"{name} dtype was {array.dtype}, expected float32"


def test_athens_runner_rasters_are_float32(tmp_path):
tmp_config = _prepare_temp_config(tmp_path)
runner = SolweigRunCore(str(tmp_config), str(PARAMS_PATH))
raster_data = runner.raster_data

_assert_float32(raster_data.dsm, "dsm")
_assert_float32(raster_data.wallheight, "wallheight")
_assert_float32(raster_data.wallaspect, "wallaspect")
_assert_float32(raster_data.dem, "dem")
_assert_float32(raster_data.cdsm, "cdsm")
_assert_float32(raster_data.tdsm, "tdsm")
_assert_float32(raster_data.bush, "bush")
_assert_float32(raster_data.svfbuveg, "svfbuveg")
_assert_float32(raster_data.buildings, "buildings")

runner.test_hook()
Loading
Loading