library(gloBFPr)
library(sf)
library(terra)
library(ggplot2)
gloBFPr connects three steps in one R session:
collecting urban spatial data, writing OpenFOAM configuration files, and
visualising CFD results as maps.
The package ships with a small Detroit-area dataset. No API keys are needed.
data(globfp_example)
buildings_list <- list(poly = globfp_example, binary = NULL, graduated = NULL)
foam_case <- file.path(path.expand("~"), "openfoam_quickstart")
foam_inputs <- prepare_openfoam_inputs(
case_dir = foam_case,
buildings_list = buildings_list,
height_col = "Height",
include_buildings = TRUE,
include_fused_dsm = FALSE,
include_tree_canopy = FALSE,
include_morphology = FALSE,
include_neighbors = FALSE,
include_greenspace = FALSE,
overwrite = TRUE, quiet = TRUE
)
case_files <- prepare_foam_case(
case_dir = foam_inputs$case_dir,
stl_file = foam_inputs$files$building_stl,
domain = foam_inputs$domain,
inlet_velocity = c(5, 0, 0), z_ref = 10, z0 = 0.1,
base_cell_size = 5, sim_hours = 0.5, overwrite = TRUE
)
run_openfoam_docker(case_dir = foam_inputs$case_dir,
image = "opencfd/openfoam-run:2506", wait = TRUE)
maps <- read_foam_pedestrian_slice(case_dir = foam_inputs$case_dir,
base_cell_size = 5, resolution = 2)
plot_foam_map(maps, layer = "U_mag",
palette = "YlOrRd", legend_title = "Speed (m/s)")
A case draws on two input sources: spatial layers from
prepare_openfoam_inputs() and meteorological conditions
from get_era5_met(). Set them up once, then pass the
results to any simulation.
prepare_openfoam_inputs() collects four categories of
data and writes them into a structured case folder:
| Layer | File | Used for |
|---|---|---|
| Building footprints | constant/triSurface/buildings.stl |
Solid geometry (snappyHexMesh) |
| Fused DSM | rasters/fused_dsm.tif |
Terrain source — see Section 2.2 |
| Building heights | rasters/building_height.tif |
Terrain recovery |
| Canopy height (CHM) | rasters/canopy_height.tif |
Canopy drag zone |
| Ground roughness (z0) | rasters/ground_roughness_z0.tif |
Wall roughness |
foam_inputs <- prepare_openfoam_inputs(
case_dir = "~/openfoam_cases/detroit",
bbox = c(-83.065644, 42.333792, -83.045217, 42.346988),
# target_crs is optional — the local UTM zone is chosen automatically
include_buildings = TRUE,
include_fused_dsm = TRUE, # OpenTopography DEM + buildings + canopy
include_tree_canopy = TRUE, # Meta CHM → canopy drag
canopy_source = "metachm",
min_tree_height = 2,
include_morphology = TRUE,
include_neighbors = TRUE,
include_greenspace = TRUE, # land cover → z0 raster
mask_tree_cover = TRUE,
landcover_source = "esri", # "esa" (2020-2021) or "esri" (2017-2025)
landcover_year = 2022,
opentopo_key = Sys.getenv('OPENTOPO_API'),
overwrite = TRUE
)
prepare_openfoam_inputs() writes terrain and canopy as
rasters. prepare_foam_geometry() turns them into
the STL geometry the mesher needs. This step is optional — skip it and
you get a flat ground plane. If you pass terrain or canopy files to
prepare_foam_case(), they must exist; explicitly supplied
but unreadable inputs stop the case setup instead of being skipped.
geo <- prepare_foam_geometry(
case_dir = foam_inputs$case_dir,
fused_dsm = foam_inputs$files$fused_dsm,
building_height = foam_inputs$files$building_height_raster,
canopy_height = foam_inputs$files$canopy_height_raster,
buildings = readRDS(foam_inputs$files$buildings_rds),
height_col = "Height",
domain = foam_inputs$domain,
base_cell_size = 10
)
# geo$terrain_stl → constant/triSurface/terrain.stl
# geo$canopy_stl → constant/triSurface/canopy.stl
# geo$building_stl → buildings.stl, re-based onto the terrain
# geo$dem → recovered bare-earth SpatRaster
Canopy becomes distributed drag, not solid blocks. A
tree crown passes and drags air; it does not block it. Meshing crowns as
solid over-blocks the flow. The canopy volume selects cells for a
leafAreaDensity field, which drives
atmPlantCanopyUSource and
atmPlantCanopyTurbSource in
constant/fvOptions
Drag is proportional to leaf area density, so cells outside the
canopy are left at zero and feel nothing — which is why
selectionMode all needs no cell set. Tune with
leaf_area_density (default 0.4 m⁻¹; roughly 0.2 for sparse
street trees, 0.8 for dense woodland) and plant_cd (default
0.2).
When include_greenspace = TRUE, land cover is
reclassified to aerodynamic roughness length z0 and saved to
ground_roughness_z0.tif. It sets
nutkRoughWallFunction on the ground patch, so unresolved
surface texture still slows the flow near the ground.
"esa" — ESA WorldCover (years 2020,
2021 only):
| ESA class | Description | z0 (m) |
|---|---|---|
| 10 | Tree cover | NA — canopy drag zone |
| 20 | Shrubland | 0.20 |
| 30 | Grassland | 0.05 |
| 40 | Cropland | 0.05 |
| 50 | Built-up | 0.03 — paved ground between resolved buildings |
| 60 | Bare / sparse | 0.01 |
| 70 | Snow / ice | 0.001 |
| 80 | Permanent water | 0.0002 |
| 90 | Herbaceous wetland | 0.05 |
| 95 | Mangroves | 0.50 |
| 100 | Moss and lichen | 0.01 |
"esri" — Sentinel-2 10 m ESRI LULC Time
Series (years 2017–2025). Use this when you need a year outside
2020–2021, or want consistent annual maps for before-and-after
comparisons:
| ESRI class | Name | z0 (m) |
|---|---|---|
| 2 | Trees | NA — canopy drag zone |
| 11 | Rangeland — parks, lawns, pastures | 0.10 |
| 5 | Crops | 0.05 |
| 4 | Flooded vegetation | 0.05 |
| 7 | Built area | 0.03 — paved ground between resolved buildings |
| 8 | Bare ground | 0.01 |
| 9 | Snow / ice | 0.001 |
| 1 | Water | 0.0002 |
| 10 | Clouds (no data) | 0.03 (fallback) |
Built-up z0 is 0.03 m, not the 0.5 m typical of unresolved urban terrain. Buildings here are explicit geometry, so the roughness applies only to the paved surface between them. Applying an urban-canopy z0 on top of resolved buildings would count the same drag twice. For the same reason tree cover is masked to
NA— it is handled as canopy drag instead.
z0_rast <- rast(foam_inputs$files$roughness_raster)
z0_mean <- global(z0_rast, "mean", na.rm = TRUE)[[1]]
cat(sprintf("Mean z0 = %.4f m\n", z0_mean))
get_era5_met() fetches hourly ERA5 reanalysis from the
Copernicus Climate Data Store. A wind case needs the 10-m wind
components; T_ref is used as the uniform reference
temperature that switches buoyancy off.
One-time setup: register at https://cds.climate.copernicus.eu, copy your personal access token, then store it:
# Add to ~/.Renviron so it loads automatically every session
CDS_API_KEY=your-token-here
# Daytime — fetches 10-m wind components and 2-m temperature
met <- get_era5_met(
lon = -83.05,
lat = 42.34,
datetime = "2023-07-15 14:00",
cds_key = Sys.getenv("CDS_API_KEY")
)
# met$inlet_velocity → c(u10, v10, 0) m/s, ready for prepare_foam_case()
# met$T_ref → 2-m temperature in K
case_files <- prepare_foam_case(
case_dir = foam_inputs$case_dir,
# geo$building_stl when you ran Section 2.2, otherwise the flat-ground STL
stl_file = if (exists("geo") && !is.null(geo$building_stl)) {
geo$building_stl
} else {
foam_inputs$files$building_stl
},
domain = foam_inputs$domain,
inlet_velocity = met$inlet_velocity, # ERA5 u10, v10 — any direction
z_ref = met$z_ref, # 10 m
T_ref = met$T_ref, # uniform: buoyancy inactive
z0 = z0_mean,
terrain_stl = if (exists("geo")) geo$terrain_stl else NULL,
terrain_dem = if (exists("geo")) geo$dem else NULL,
canopy_stl = if (exists("geo")) geo$canopy_stl else NULL,
base_cell_size = 15,
building_refinement = 2L,
sim_hours = 1,
overwrite = TRUE
)
case_files$params$patch_roles
#> xMin xMax yMin yMax
#> "inlet" "outlet" "lateral" "lateral"
blockMesh emits four separately named lateral patches —
xMin, xMax, yMin,
yMax — and each is assigned an inlet, outlet or lateral
role from the sign of dot(flowDir, outward_normal). A
south-westerly gives two inlets and two outlets:
prepare_foam_case(..., inlet_velocity = c(3, 3, 0))
#> Wind: 4.24 m/s at 10 m, dir (0.707 0.707)
#> patches: xMin=inlet xMax=outlet yMin=inlet yMax=outlet
Earlier versions pinned the inlet to the x-min face and expressed direction as a
flowDirvector on it. For a northerly (c(0, 5, 0)) that injected momentum through a face whose normal is −x, so the inflow normal component was zero and effectively nothing entered — silently, with no error. If you have results from an earlier non-x-aligned legacy OpenFOAM run, re-run them.
run_openfoam_docker(
case_dir = foam_inputs$case_dir,
image = "opencfd/openfoam-run:2506",
wait = TRUE
)
maps <- read_foam_pedestrian_slice(
case_dir = foam_inputs$case_dir,
T_ref = case_files$params$T_ref,
base_cell_size = case_files$params$base_cell_size,
resolution = 2
)
# Wind speed — buildings overlaid automatically
plot_foam_map(maps, layer = "U_mag",
palette = "YlOrRd", legend_title = "Speed (m/s)",
title = "Pedestrian wind speed at 1.5 m AGL")
# Speed with flow vectors
p <- plot_foam_map(maps, layer = "U_mag",
palette = "Blues", reverse = TRUE,
legend_title = "Speed (m/s)")
add_flow_vectors(p, maps, spacing = 20, colour = "grey20",
alpha = 0.7, linewidth = 0.25, arrow_size = 0.07)
# Wind speed ratio (> 1.3 = potentially uncomfortable)
plot_foam_map(maps, layer = "U_mag", palette = "RdYlGn", reverse = TRUE,
legend_title = "U / U_ref",
max_u_ref = sqrt(sum(met$inlet_velocity^2)))
All
plot_foam_map()calls return a plainggplotobject. Export withggsave("wind_map.png", width = 10, height = 8, dpi = 300).
The temperature field is uniform, so T_cool is ≈ 0 and
only the velocity layers carry information.
The map above is an instantaneous field, not a mean. The case is transient and
read_foam_pedestrian_slice()takes the latest write, so in the wakes behind large buildings it shows whichever phase of the vortex shedding the run stopped on. Pedestrian comfort is assessed on a mean.The case therefore also runs a
fieldAveragefunction object and writes a time-averaged slice of its own, starting one flow-through in (endTime / 3) so the startup transient is excluded:postProcessing/pedestrianSliceMean/<time>/z1p5m_UMean.rawSame raw format as the instantaneous slice — x, y, z then the three velocity components.
read_foam_pedestrian_slice()does not read it yet; for now parse it directly, or compare it against the instantaneous map to see how much the wakes move.
read_foam_pedestrian_slice()reads the 1.5 m slice that the case writes automatically. To sample other heights or other fields —p_rgh,k,nut— usesample_foam_slice()instead; it runspostProcessin the container after the fact, so you can re-sample without re-running the simulation:slice <- sample_foam_slice(case_dir = foam_inputs$case_dir, fields = c("U", "p_rgh"), z = 10, image = "opencfd/openfoam-run:2506", resolution = 5)Note the field is
p_rgh(pressure minus hydrostatic), notp: the buoyant solver does not carry a separate kinematic pressure. Static pressure isp_rgh + ρghif you need it.