The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.

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.

1 Quick start

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)")

2 Inputs

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.

2.1 Spatial layers

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
)

2.2 Terrain and canopy geometry

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).

2.3 Ground roughness z0 from land-cover data

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))

2.4 ERA5 weather conditions

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

3 Wind simulation

3.1 Write the case

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"

Any wind direction works

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 flowDir vector 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.

3.2 Run via Docker

run_openfoam_docker(
  case_dir = foam_inputs$case_dir,
  image    = "opencfd/openfoam-run:2506",
  wait     = TRUE
)

3.3 Visualise

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 plain ggplot object. Export with ggsave("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 fieldAverage function 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.raw

Same 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 fieldsp_rgh, k, nut — use sample_foam_slice() instead; it runs postProcess in 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), not p: the buoyant solver does not carry a separate kinematic pressure. Static pressure is p_rgh + ρgh if you need it.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.