pet-adjusted 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pet_adjusted/__init__.py +115 -0
- pet_adjusted/air_temperature.py +75 -0
- pet_adjusted/backend.py +29 -0
- pet_adjusted/morphology.py +152 -0
- pet_adjusted/netherlands.py +353 -0
- pet_adjusted/pet.py +226 -0
- pet_adjusted/py.typed +0 -0
- pet_adjusted/svf.py +76 -0
- pet_adjusted/tmrt.py +152 -0
- pet_adjusted/wind.py +55 -0
- pet_adjusted-0.1.5.dist-info/METADATA +227 -0
- pet_adjusted-0.1.5.dist-info/RECORD +15 -0
- pet_adjusted-0.1.5.dist-info/WHEEL +4 -0
- pet_adjusted-0.1.5.dist-info/entry_points.txt +3 -0
- pet_adjusted-0.1.5.dist-info/licenses/LICENSE +674 -0
pet_adjusted/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""PET with urban-adjusted air temperature and 1.2 m wind speed.
|
|
2
|
+
|
|
3
|
+
PET uses a GPU/array port of ``pythermalcomfort.models.pet_steady`` (Tartarini & Schiavon,
|
|
4
|
+
pythermalcomfort, SoftwareX 2020), which implements Höppe's MEMI with the corrections of
|
|
5
|
+
Walther & Goestchel (2018). See pet.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .air_temperature import diurnal_factor, is_daytime, uhi_max, urban_air_temperature
|
|
13
|
+
from .backend import gpu_available
|
|
14
|
+
from .morphology import LC_BUILDING, classify, uhi_inputs, wind_inputs
|
|
15
|
+
from .pet import pet_steady
|
|
16
|
+
from .svf import sky_view_factor
|
|
17
|
+
from .tmrt import read_raster, solweig_tmrt, write_raster
|
|
18
|
+
from .wind import urban_wind_speed, wind_reduction
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"classify", "diurnal_factor", "gpu_available", "is_daytime", "pet_map", "pet_netherlands", "pet_steady", "read_raster",
|
|
22
|
+
"sky_view_factor", "solweig_tmrt", "uhi_inputs", "uhi_max", "urban_air_temperature", "urban_pet", "urban_pet_from_rasters",
|
|
23
|
+
"urban_wind_speed", "wind_inputs", "wind_reduction", "write_raster",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def urban_pet(
|
|
28
|
+
t_ref, tr, rh, ff10, met, clo, *,
|
|
29
|
+
svf, fveg, s_down, tmax, tmin, u_mean, day: date, hour_utc,
|
|
30
|
+
lambda_b, lambda_t, h,
|
|
31
|
+
device="auto",
|
|
32
|
+
**pet_kwargs,
|
|
33
|
+
):
|
|
34
|
+
"""PET [°C] with Ta from the UHI model and v as the scaled 1.2 m urban wind.
|
|
35
|
+
|
|
36
|
+
t_ref, rh, ff10: reference station air temperature [°C], RH [%], 10-m wind [m/s].
|
|
37
|
+
tr: mean radiant temperature [°C]. met [met], clo [clo] as in pet_steady.
|
|
38
|
+
svf, fveg, s_down, tmax, tmin, u_mean, day, hour_utc: see urban_air_temperature.
|
|
39
|
+
lambda_b, lambda_t, h: see urban_wind_speed.
|
|
40
|
+
device: backend for all steps ("auto", "cpu", "gpu").
|
|
41
|
+
pet_kwargs: passed to pet_steady (p_atm, position, age, sex, weight, height, wme, chunk_size).
|
|
42
|
+
"""
|
|
43
|
+
ta = urban_air_temperature(t_ref, svf, fveg, s_down, tmax, tmin, u_mean, day, hour_utc, device)
|
|
44
|
+
v = urban_wind_speed(ff10, lambda_b, lambda_t, h, device)
|
|
45
|
+
return pet_steady(ta, tr, v, rh, met, clo, device=device, **pet_kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def urban_pet_from_rasters(
|
|
49
|
+
dsm, dem, ndvi, cell_size, *,
|
|
50
|
+
tr, t_ref, rh, ff10, wind_dir, met, clo,
|
|
51
|
+
s_down, tmax, tmin, u_mean, day: date, hour_utc,
|
|
52
|
+
svf=None, svf_radius_m=50.0, landcover=None, device="auto",
|
|
53
|
+
**pet_kwargs,
|
|
54
|
+
):
|
|
55
|
+
"""PET grid [°C] from DSM, DEM and NDVI rasters plus reference-station data for one hour.
|
|
56
|
+
|
|
57
|
+
dsm, dem, ndvi: 2D arrays on the same north-up grid, square cells of ``cell_size`` m.
|
|
58
|
+
tr: mean radiant temperature [°C], scalar or grid. wind_dir: direction the 10-m wind
|
|
59
|
+
comes from [degrees]. svf: sky-view factor grid if already computed (else from the DSM).
|
|
60
|
+
landcover: optional UMEP land-cover grid (see morphology.classify). The rest as in urban_pet. Cells with DSM/DEM nodata (NaN) come out NaN.
|
|
61
|
+
"""
|
|
62
|
+
if svf is None:
|
|
63
|
+
svf = sky_view_factor(dsm, cell_size, radius_m=svf_radius_m, device=device)
|
|
64
|
+
svf_avg, fveg = uhi_inputs(dsm, dem, ndvi, cell_size, wind_dir, ff10,
|
|
65
|
+
is_daytime(day, hour_utc), svf=svf, device=device, landcover=landcover)
|
|
66
|
+
lambda_b, lambda_t, h = wind_inputs(dsm, dem, ndvi, cell_size, wind_dir, ff10, device, landcover)
|
|
67
|
+
ta = urban_air_temperature(t_ref, svf_avg, fveg, s_down, tmax, tmin, u_mean, day, hour_utc, device)
|
|
68
|
+
v = urban_wind_speed(ff10, lambda_b, lambda_t, h, device)
|
|
69
|
+
nodata = ~(np.isfinite(dsm) & np.isfinite(dem))
|
|
70
|
+
return pet_steady(np.where(nodata, np.nan, ta), tr, v, rh, met, clo, device=device, **pet_kwargs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pet_map(dsm, dem, landcover, ndvi, cell_size, origin, epsg, day: date, weather, subject, *,
|
|
74
|
+
workdir, hours=None, svf_radius_m=50.0, device="auto"):
|
|
75
|
+
"""PET and Tmrt maps [°C] for one day, shape (n_hours, rows, cols) each.
|
|
76
|
+
|
|
77
|
+
dsm, dem, landcover, ndvi: 2D arrays on the same north-up grid (NaN = nodata), square
|
|
78
|
+
cells of ``cell_size`` m, upper-left corner ``origin`` (x, y) in EPSG ``epsg``.
|
|
79
|
+
landcover in UMEP codes (1 paved, 2 building, 3 evergreen, 4 deciduous, 5 grass,
|
|
80
|
+
6 bare soil, 7 water).
|
|
81
|
+
weather: mapping of hourly reference-station series for ``day`` (consecutive UTC hours,
|
|
82
|
+
ideally 0-23): hour_utc, t_ref [°C], rh [%], ff10 [m/s], wind_dir [°], kdown [W m-2] and
|
|
83
|
+
optionally p_atm [hPa]. Optional daily values for Eq. S.3: tmax, tmin [°C], s_down
|
|
84
|
+
[W m-2], u_mean [m/s]; by default they come from the series.
|
|
85
|
+
subject: person for pet_steady: met [met], clo [clo] and optionally age, sex, weight [kg],
|
|
86
|
+
height [m], position.
|
|
87
|
+
hours: UTC hours to return (default all hours in ``weather``).
|
|
88
|
+
Building cells (land cover 2) are NaN in both outputs: PET is for pedestrians.
|
|
89
|
+
Tmrt comes from SOLWEIG-GPU (see solweig_tmrt; inputs and outputs are kept in ``workdir``).
|
|
90
|
+
On Windows, call this under ``if __name__ == "__main__":``.
|
|
91
|
+
"""
|
|
92
|
+
w = {k: np.asarray(v, dtype=float) for k, v in weather.items()}
|
|
93
|
+
hour_utc = w["hour_utc"].astype(int)
|
|
94
|
+
p_atm = np.broadcast_to(w.get("p_atm", 1013.25), hour_utc.shape)
|
|
95
|
+
tmrt = solweig_tmrt(dsm, dem, ndvi, cell_size, origin, epsg, day, hour_utc=hour_utc, t_ref=w["t_ref"],
|
|
96
|
+
rh=w["rh"], ff10=w["ff10"], kdown=w["kdown"], p_atm=p_atm, wind_dir=w["wind_dir"],
|
|
97
|
+
landcover=landcover, workdir=workdir)
|
|
98
|
+
# shortcut: daily values from this day's series; the paper uses 08 UTC to 07 UTC next day
|
|
99
|
+
tmax, tmin = w.get("tmax", w["t_ref"].max()), w.get("tmin", w["t_ref"].min())
|
|
100
|
+
s_down, u_mean = w.get("s_down", w["kdown"].mean()), w.get("u_mean", w["ff10"].mean())
|
|
101
|
+
svf = sky_view_factor(dsm, cell_size, radius_m=svf_radius_m, device=device)
|
|
102
|
+
idx = list(range(hour_utc.size)) if hours is None else [list(hour_utc).index(h) for h in hours]
|
|
103
|
+
pet = np.stack([
|
|
104
|
+
urban_pet_from_rasters(
|
|
105
|
+
dsm, dem, ndvi, cell_size, tr=tmrt[i], t_ref=w["t_ref"][i], rh=w["rh"][i], ff10=w["ff10"][i],
|
|
106
|
+
wind_dir=w["wind_dir"][i], s_down=s_down, tmax=tmax, tmin=tmin, u_mean=u_mean, day=day,
|
|
107
|
+
hour_utc=hour_utc[i], svf=svf, landcover=landcover, device=device, p_atm=p_atm[i], **subject,
|
|
108
|
+
)
|
|
109
|
+
for i in idx
|
|
110
|
+
])
|
|
111
|
+
roof = np.asarray(landcover) == LC_BUILDING
|
|
112
|
+
return np.where(roof, np.nan, pet), np.where(roof, np.nan, tmrt[idx])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
from .netherlands import pet_netherlands # noqa: E402 (uses pet_map above)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Urban air temperature from a rural reference station (Koopmans et al. 2020, Building and Environment 181, 106984, supplement B2)."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .backend import get_xp, to_numpy
|
|
8
|
+
|
|
9
|
+
RHO_CP = 1.2 * 1005.0 # air density * heat capacity [J m-3 K-1]; W m-2 -> kinematic K m s-1
|
|
10
|
+
|
|
11
|
+
# Table S.3: start date (month, day) of each period -> sunrise/sunset column (UTC).
|
|
12
|
+
# The paper lists "1 April t/m 19 April" and "13 April t/m 20 April"; read as 1-12 April.
|
|
13
|
+
_PERIODS = [
|
|
14
|
+
((4, 1), "5/18"), ((4, 13), "5/19"), ((4, 20), "4/19"), ((5, 20), "4/20"),
|
|
15
|
+
((5, 26), "3/20"), ((7, 11), "4/20"), ((7, 31), "4/19"), ((8, 22), "5/19"),
|
|
16
|
+
((8, 31), "5/18"), ((9, 25), "5/17"), ((9, 28), "6/17"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Table S.4: UHI factor per UTC hour 0..23 for each sunrise/sunset column.
|
|
20
|
+
_UHI_FACTORS = {
|
|
21
|
+
"6/17": [0.748, 0.667, 0.602, 0.525, 0.449, 0.281, 0.127, 0.063, 0.019, -0.015, -0.020, 0.000,
|
|
22
|
+
0.030, 0.065, 0.117, 0.205, 0.335, 0.532, 0.747, 0.906, 0.975, 1.000, 0.931, 0.849],
|
|
23
|
+
"5/17": [0.728, 0.640, 0.573, 0.490, 0.355, 0.150, 0.078, 0.025, -0.013, -0.020, -0.001, 0.025,
|
|
24
|
+
0.056, 0.090, 0.165, 0.270, 0.413, 0.600, 0.803, 0.920, 0.978, 1.000, 0.925, 0.830],
|
|
25
|
+
"5/18": [0.807, 0.704, 0.617, 0.533, 0.435, 0.227, 0.095, 0.032, -0.009, -0.020, -0.003, 0.020,
|
|
26
|
+
0.048, 0.080, 0.136, 0.215, 0.325, 0.485, 0.662, 0.849, 0.932, 0.979, 1.000, 0.918],
|
|
27
|
+
"5/19": [0.910, 0.780, 0.675, 0.590, 0.490, 0.320, 0.120, 0.040, -0.005, -0.020, -0.004, 0.016,
|
|
28
|
+
0.042, 0.071, 0.111, 0.176, 0.270, 0.386, 0.546, 0.716, 0.877, 0.941, 0.981, 1.000],
|
|
29
|
+
"4/19": [0.900, 0.757, 0.710, 0.543, 0.413, 0.150, 0.057, 0.000, -0.020, -0.005, 0.013, 0.037,
|
|
30
|
+
0.063, 0.090, 0.150, 0.222, 0.318, 0.450, 0.600, 0.762, 0.890, 0.950, 0.982, 1.000],
|
|
31
|
+
"4/20": [1.000, 0.888, 0.728, 0.609, 0.490, 0.256, 0.079, 0.007, -0.020, -0.006, 0.010, 0.033,
|
|
32
|
+
0.056, 0.082, 0.128, 0.184, 0.270, 0.366, 0.506, 0.651, 0.803, 0.901, 0.958, 0.983],
|
|
33
|
+
"3/20": [1.000, 0.866, 0.690, 0.560, 0.380, 0.107, 0.015, -0.020, -0.007, 0.007, 0.029, 0.050,
|
|
34
|
+
0.074, 0.108, 0.161, 0.228, 0.312, 0.424, 0.556, 0.695, 0.838, 0.911, 0.964, 0.984],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _column(day: date):
|
|
39
|
+
if not 4 <= day.month <= 9:
|
|
40
|
+
raise ValueError("Table S.3 only covers 1 April - 30 September")
|
|
41
|
+
return [c for start, c in _PERIODS if (day.month, day.day) >= start][-1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def diurnal_factor(day: date, hour_utc):
|
|
45
|
+
"""UHI correction factor (Tables S.3/S.4) for a date and UTC hour(s) 0-23."""
|
|
46
|
+
return np.asarray(_UHI_FACTORS[_column(day)])[np.asarray(hour_utc)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_daytime(day: date, hour_utc) -> bool:
|
|
50
|
+
"""Between sunrise and sunset of Table S.3 (UTC)."""
|
|
51
|
+
sunrise, sunset = map(int, _column(day).split("/"))
|
|
52
|
+
return sunrise <= hour_utc < sunset
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _uhi_max(xp, svf, fveg, s_down, tmax, tmin, u):
|
|
56
|
+
svf, fveg, s_down, tmax, tmin, u = (xp.asarray(a, dtype=float) for a in (svf, fveg, s_down, tmax, tmin, u))
|
|
57
|
+
return (2 - svf - fveg) * (s_down / RHO_CP * (tmax - tmin) ** 3 / u) ** 0.25
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def uhi_max(svf, fveg, s_down, tmax, tmin, u, device="auto"):
|
|
61
|
+
"""Daily maximum UHI [K], Eq. S.3.
|
|
62
|
+
|
|
63
|
+
svf, fveg: source-area averaged sky-view factor and vegetation fraction [-].
|
|
64
|
+
s_down: mean daily downward shortwave radiation at the reference station [W m-2].
|
|
65
|
+
tmax, tmin: max/min reference temperature (08 UTC - 07 UTC next day) [°C or K].
|
|
66
|
+
u: mean reference wind speed over the same period [m s-1].
|
|
67
|
+
"""
|
|
68
|
+
return to_numpy(_uhi_max(get_xp(device), svf, fveg, s_down, tmax, tmin, u))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def urban_air_temperature(t_ref, svf, fveg, s_down, tmax, tmin, u, day: date, hour_utc, device="auto"):
|
|
72
|
+
"""Urban air temperature [°C], Eq. S.4: T_ref + UHImax * diurnal_cycle[h]."""
|
|
73
|
+
xp = get_xp(device)
|
|
74
|
+
factor = xp.asarray(diurnal_factor(day, hour_utc))
|
|
75
|
+
return to_numpy(xp.asarray(t_ref, dtype=float) + _uhi_max(xp, svf, fveg, s_down, tmax, tmin, u) * factor)
|
pet_adjusted/backend.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Array backend: CuPy on a CUDA GPU when available, otherwise NumPy."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import cupy as cp
|
|
7
|
+
|
|
8
|
+
_GPU = cp.cuda.runtime.getDeviceCount() > 0
|
|
9
|
+
except Exception: # CuPy not installed, or no CUDA driver/device
|
|
10
|
+
cp, _GPU = None, False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def gpu_available() -> bool:
|
|
14
|
+
return _GPU
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_xp(device="auto"):
|
|
18
|
+
"""Array module for ``device``: "auto" (GPU if available), "cpu" or "gpu"."""
|
|
19
|
+
if device not in ("auto", "cpu", "gpu"):
|
|
20
|
+
raise ValueError(f"device must be 'auto', 'cpu' or 'gpu', got {device!r}")
|
|
21
|
+
if device == "cpu" or (device == "auto" and not _GPU):
|
|
22
|
+
return np
|
|
23
|
+
if not _GPU:
|
|
24
|
+
raise RuntimeError("device='gpu' requested but CuPy/CUDA is not available")
|
|
25
|
+
return cp
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_numpy(a):
|
|
29
|
+
return cp.asnumpy(a) if cp is not None and isinstance(a, cp.ndarray) else np.asarray(a)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""UHI and wind inputs derived from DSM, DEM and NDVI rasters.
|
|
2
|
+
|
|
3
|
+
All rasters are 2D arrays on the same grid with square cells of ``cell_size`` metres and
|
|
4
|
+
north up (row 0 = north). Source areas follow Fig. S.2 (air temperature) and Fig. S.3
|
|
5
|
+
(wind): rectangles oriented to the wind, or squares around the cell when ff10 < 1.5 m/s.
|
|
6
|
+
Source-area sums are FFT convolutions, so every cell gets its own source area (the paper
|
|
7
|
+
computes on a 35 m grid and interpolates, only to save time).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy import ndimage, signal
|
|
12
|
+
|
|
13
|
+
from .backend import get_xp, to_numpy
|
|
14
|
+
from .svf import sky_view_factor
|
|
15
|
+
|
|
16
|
+
NDVI_VEG = 0.16 # vegetation threshold (B1, B2)
|
|
17
|
+
MIN_OBSTACLE = 2.0 # buildings and trees lower than this are ignored [m]
|
|
18
|
+
LOW_WIND = 1.5 # [m/s] below this the source areas are squares
|
|
19
|
+
TILE_M = 25.0 # intermediate Svf/Fveg grid for the air temperature (B2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# UMEP / SOLWEIG land-cover codes
|
|
23
|
+
LC_PAVED, LC_BUILDING, LC_EVERGREEN, LC_DECIDUOUS, LC_GRASS, LC_BARE, LC_WATER = range(1, 8)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(dsm, dem, ndvi, landcover=None):
|
|
27
|
+
"""Object height [m] and masks (building, tree, vegetation, water).
|
|
28
|
+
|
|
29
|
+
landcover: optional grid in UMEP codes (1 paved, 2 building, 3 evergreen, 4 deciduous,
|
|
30
|
+
5 grass, 6 bare soil, 7 water). With it, buildings and water come from the land cover;
|
|
31
|
+
without it, buildings are tall non-green cells and water is NDVI < 0.
|
|
32
|
+
"""
|
|
33
|
+
height = np.asarray(dsm, dtype=float) - np.asarray(dem, dtype=float)
|
|
34
|
+
ndvi = np.asarray(ndvi, dtype=float)
|
|
35
|
+
veg = ndvi > NDVI_VEG
|
|
36
|
+
tall = height > MIN_OBSTACLE
|
|
37
|
+
if landcover is None:
|
|
38
|
+
water = ndvi < 0
|
|
39
|
+
building = tall & ~veg
|
|
40
|
+
else:
|
|
41
|
+
landcover = np.asarray(landcover)
|
|
42
|
+
water = landcover == LC_WATER
|
|
43
|
+
building = tall & (landcover == LC_BUILDING)
|
|
44
|
+
tree = tall & veg & ~building
|
|
45
|
+
return height, building, tree, veg, water
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def source_kernel(cell, wind_dir, upwind, downwind, half_width):
|
|
49
|
+
"""1/0 mask of a source area around the centre cell.
|
|
50
|
+
|
|
51
|
+
wind_dir: direction the wind comes from [degrees from north]. The area reaches
|
|
52
|
+
``upwind`` metres into the wind, ``downwind`` metres behind and ``half_width`` aside.
|
|
53
|
+
"""
|
|
54
|
+
r = int(np.ceil(np.hypot(max(upwind, downwind), half_width) / cell))
|
|
55
|
+
rows, cols = np.mgrid[-r:r + 1, -r:r + 1] * cell
|
|
56
|
+
east, north = cols, -rows
|
|
57
|
+
th = np.radians(wind_dir)
|
|
58
|
+
along = east * np.sin(th) + north * np.cos(th) # > 0 towards where the wind comes from
|
|
59
|
+
across = east * np.cos(th) - north * np.sin(th)
|
|
60
|
+
return ((along <= upwind) & (along >= -downwind) & (np.abs(across) <= half_width)).astype(float)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _area_sum(xp, field, kernel):
|
|
64
|
+
"""Sum of ``field`` over the source area of every cell (zero outside the grid)."""
|
|
65
|
+
if xp is np:
|
|
66
|
+
conv = signal.fftconvolve
|
|
67
|
+
else:
|
|
68
|
+
import cupyx.scipy.signal
|
|
69
|
+
|
|
70
|
+
conv = cupyx.scipy.signal.fftconvolve
|
|
71
|
+
out = conv(xp.asarray(field, dtype=xp.float64), xp.asarray(kernel[::-1, ::-1]), mode="same")
|
|
72
|
+
return xp.maximum(out, 0.0) # FFT round-off can give tiny negatives
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def uhi_inputs(dsm, dem, ndvi, cell_size, wind_dir, ff10, daytime, svf=None,
|
|
76
|
+
svf_radius_m=50.0, device="auto", landcover=None):
|
|
77
|
+
"""Source-area averaged sky-view factor and vegetation fraction for Eq. S.3.
|
|
78
|
+
|
|
79
|
+
Svf excludes buildings, water and vegetation; water counts as vegetation by day and as
|
|
80
|
+
built-up at night. Per 25 m tile, Svf is nodata when more than 90% of it is excluded.
|
|
81
|
+
Over the source area: Svf = 1 when 10% or less of it has data, the plain mean above 20%,
|
|
82
|
+
and a linear blend in between. ``svf`` can be passed if already computed from the DSM.
|
|
83
|
+
landcover: optional UMEP land-cover grid (see classify).
|
|
84
|
+
Returns (svf, fveg) on the input grid.
|
|
85
|
+
"""
|
|
86
|
+
height, building, tree, veg, water = classify(dsm, dem, ndvi, landcover)
|
|
87
|
+
if svf is None:
|
|
88
|
+
svf = sky_view_factor(dsm, cell_size, radius_m=svf_radius_m, device=device)
|
|
89
|
+
svf = np.where(building | water | veg, np.nan, svf)
|
|
90
|
+
green = (veg | water) if daytime else (veg & ~water)
|
|
91
|
+
|
|
92
|
+
# 25 m tiles
|
|
93
|
+
t = max(int(round(TILE_M / cell_size)), 1)
|
|
94
|
+
n_rows, n_cols = svf.shape
|
|
95
|
+
pr, pc = -n_rows % t, -n_cols % t
|
|
96
|
+
svf_t = np.pad(svf, ((0, pr), (0, pc)), constant_values=np.nan).reshape(
|
|
97
|
+
(n_rows + pr) // t, t, (n_cols + pc) // t, t)
|
|
98
|
+
inside = np.pad(np.ones_like(svf), ((0, pr), (0, pc))).reshape(svf_t.shape)
|
|
99
|
+
green_t = np.pad(green.astype(float), ((0, pr), (0, pc))).reshape(svf_t.shape)
|
|
100
|
+
n_valid = np.isfinite(svf_t).sum(axis=(1, 3))
|
|
101
|
+
n_inside = inside.sum(axis=(1, 3))
|
|
102
|
+
tile_svf = np.nansum(svf_t, axis=(1, 3)) / np.maximum(n_valid, 1)
|
|
103
|
+
tile_ok = n_valid > 0.1 * n_inside
|
|
104
|
+
tile_fveg = green_t.sum(axis=(1, 3)) / n_inside
|
|
105
|
+
|
|
106
|
+
tile = t * cell_size
|
|
107
|
+
if ff10 < LOW_WIND:
|
|
108
|
+
kernel = source_kernel(tile, 0.0, 350.0, 350.0, 350.0)
|
|
109
|
+
else:
|
|
110
|
+
kernel = source_kernel(tile, wind_dir, 1000.0, 100.0, 250.0)
|
|
111
|
+
# 25 m grids are small: NumPy is enough here
|
|
112
|
+
total = _area_sum(np, np.ones_like(tile_svf), kernel)
|
|
113
|
+
n_ok = _area_sum(np, tile_ok, kernel)
|
|
114
|
+
mean = _area_sum(np, np.where(tile_ok, tile_svf, 0.0), kernel) / np.maximum(n_ok, 1e-9)
|
|
115
|
+
frac = n_ok / total
|
|
116
|
+
svf_avg = np.where(frac <= 0.1, 1.0, np.where(frac > 0.2, mean, 1 + (mean - 1) * (frac - 0.1) / 0.1))
|
|
117
|
+
fveg_avg = _area_sum(np, tile_fveg, kernel) / total
|
|
118
|
+
|
|
119
|
+
def to_grid(a): # back to the input grid, bilinear
|
|
120
|
+
return ndimage.zoom(a, t, order=1)[:n_rows, :n_cols]
|
|
121
|
+
|
|
122
|
+
return to_grid(svf_avg), to_grid(fveg_avg)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def wind_inputs(dsm, dem, ndvi, cell_size, wind_dir, ff10, device="auto", landcover=None):
|
|
126
|
+
"""Frontal area densities of buildings and trees and mean obstacle height (steps 3-8).
|
|
127
|
+
|
|
128
|
+
Frontal area of a cell = rise in obstacle height from its upwind neighbour x cell width.
|
|
129
|
+
landcover: optional UMEP land-cover grid (see classify).
|
|
130
|
+
Returns (lambda_b, lambda_t, h) on the input grid.
|
|
131
|
+
"""
|
|
132
|
+
xp = get_xp(device)
|
|
133
|
+
height, building, tree, _, _ = classify(dsm, dem, ndvi, landcover)
|
|
134
|
+
th = np.radians(wind_dir)
|
|
135
|
+
upwind_shift = (np.cos(th), -np.sin(th)) # out[x] = in[x + one cell upwind]
|
|
136
|
+
|
|
137
|
+
def frontal(mask):
|
|
138
|
+
h = np.where(mask, height, 0.0)
|
|
139
|
+
# shortcut: frontal width is one cell for any direction; exact only for N/E/S/W winds
|
|
140
|
+
return np.maximum(h - ndimage.shift(h, upwind_shift, order=1, cval=0.0), 0.0) * cell_size
|
|
141
|
+
|
|
142
|
+
if ff10 < LOW_WIND:
|
|
143
|
+
kernel = source_kernel(cell_size, 0.0, 87.5, 87.5, 87.5)
|
|
144
|
+
else:
|
|
145
|
+
kernel = source_kernel(cell_size, wind_dir, 210.0, 70.0, 70.0)
|
|
146
|
+
area = _area_sum(xp, np.ones_like(height), kernel) * cell_size**2
|
|
147
|
+
obstacle = building | tree
|
|
148
|
+
lambda_b = _area_sum(xp, frontal(building), kernel) / area
|
|
149
|
+
lambda_t = _area_sum(xp, frontal(tree), kernel) / area
|
|
150
|
+
h_sum = _area_sum(xp, np.where(obstacle, height, 0.0), kernel)
|
|
151
|
+
h_mean = h_sum / xp.maximum(_area_sum(xp, obstacle, kernel), 1e-9)
|
|
152
|
+
return to_numpy(lambda_b), to_numpy(lambda_t), to_numpy(h_mean)
|