"""Functions for updating VoxCity objects with new grid data."""
from __future__ import annotations
from typing import Optional, Union
import numpy as np
from ..models import (
GridMetadata,
BuildingGrid,
LandCoverGrid,
DemGrid,
VoxelGrid,
CanopyGrid,
VoxCity,
)
from .voxelizer import Voxelizer
def _ensure_active_geometry(gdf):
"""Ensure *gdf* has an active geometry column."""
try:
_ = gdf.crs
return gdf
except AttributeError:
if 'geometry' in gdf.columns:
return gdf.set_geometry('geometry')
raise
[docs]
def update_voxcity(
city: VoxCity,
*,
buildings: Optional[BuildingGrid] = None,
building_heights: Optional[np.ndarray] = None,
building_min_heights: Optional[np.ndarray] = None,
building_ids: Optional[np.ndarray] = None,
land_cover: Optional[Union[LandCoverGrid, np.ndarray]] = None,
dem: Optional[Union[DemGrid, np.ndarray]] = None,
tree_canopy: Optional[Union[CanopyGrid, np.ndarray]] = None,
canopy_top: Optional[np.ndarray] = None,
canopy_bottom: Optional[np.ndarray] = None,
building_gdf=None,
tree_gdf=None,
tree_gdf_mode: str = "replace",
land_cover_source: Optional[str] = None,
trunk_height_ratio: Optional[float] = None,
voxel_dtype=None,
max_voxel_ram_mb: Optional[float] = None,
inplace: bool = False,
) -> VoxCity:
"""
Update a VoxCity object with new grid data and regenerate the VoxelGrid.
This function allows partial updates - only the grids you provide will be
updated, while the rest will be taken from the existing VoxCity object.
The VoxelGrid is always regenerated from the (updated) component grids.
Parameters
----------
city : VoxCity
The existing VoxCity object to update.
buildings : BuildingGrid, optional
Complete replacement for the building grid. If provided, takes precedence
over individual building_heights/building_min_heights/building_ids.
building_heights : np.ndarray, optional
2D array of building heights. If buildings is not provided, this updates
only the heights while keeping existing min_heights and ids.
building_min_heights : np.ndarray, optional
2D object-dtype array of lists containing [min_height, max_height] pairs
for each building segment per cell.
building_ids : np.ndarray, optional
2D array of building IDs per cell.
land_cover : LandCoverGrid or np.ndarray, optional
New land cover data. Can be a LandCoverGrid or a raw numpy array.
dem : DemGrid or np.ndarray, optional
New DEM/elevation data. Can be a DemGrid or a raw numpy array.
tree_canopy : CanopyGrid or np.ndarray, optional
New tree canopy data. Can be a CanopyGrid (with top/bottom) or a raw
numpy array (interpreted as canopy top heights).
canopy_top : np.ndarray, optional
2D array of tree canopy top heights. Takes precedence over tree_canopy
for top heights if both are provided.
canopy_bottom : np.ndarray, optional
2D array of tree canopy bottom heights (crown base).
building_gdf : GeoDataFrame, optional
Updated building GeoDataFrame. If provided without building grids
(building_heights, building_min_heights, building_ids), the function
will automatically generate the building grids from the GeoDataFrame
using create_building_height_grid_from_gdf_polygon. The GeoDataFrame
is also stored in city.extras['building_gdf'].
tree_gdf : GeoDataFrame, optional
Updated tree GeoDataFrame. If provided without tree canopy data
(tree_canopy, canopy_top, canopy_bottom), the function will
automatically generate the canopy grids from the GeoDataFrame
using create_canopy_grids_from_tree_gdf. The GeoDataFrame must
contain 'top_height', 'bottom_height', 'crown_diameter', and
'geometry' columns. The GeoDataFrame is stored in city.extras['tree_gdf'].
tree_gdf_mode : str, default "replace"
How to combine tree_gdf with existing canopy data. Options:
- "replace": Replace the existing canopy grids with new ones from tree_gdf.
- "add": Merge the tree_gdf grids with existing (or provided) canopy grids
by taking the maximum height at each cell (preserves existing trees).
When canopy_top/canopy_bottom are also provided, the tree_gdf grids
are merged on top of those arrays instead of the existing city canopy.
land_cover_source : str, optional
The land cover source name for proper voxelization. If not provided,
attempts to use the source from city.extras or defaults to 'OpenStreetMap'.
trunk_height_ratio : float, optional
Ratio of trunk height to total tree height for canopy bottom calculation.
Default is approximately 0.588 (11.76/19.98).
voxel_dtype : dtype, optional
NumPy dtype for the voxel grid. Defaults to np.int8.
max_voxel_ram_mb : float, optional
Maximum RAM in MB for voxel grid allocation. Raises MemoryError if exceeded.
inplace : bool, default False
If True, modifies the input city object directly and returns it.
If False, creates and returns a new VoxCity object.
Returns
-------
VoxCity
The updated VoxCity object with regenerated VoxelGrid.
Examples
--------
Update building heights and regenerate voxels:
>>> import numpy as np
>>> new_heights = city.buildings.heights.copy()
>>> new_heights[10:20, 10:20] = 50.0 # Increase height in a region
>>> updated = update_voxcity(city, building_heights=new_heights)
Update with a complete new BuildingGrid:
>>> from voxcity.models import BuildingGrid
>>> new_buildings = BuildingGrid(heights=..., min_heights=..., ids=..., meta=city.buildings.meta)
>>> updated = update_voxcity(city, buildings=new_buildings)
Update land cover and DEM together:
>>> updated = update_voxcity(city, land_cover=new_lc_array, dem=new_dem_array)
Update buildings from GeoDataFrame (automatic grid generation):
>>> updated = update_voxcity(city, building_gdf=updated_building_gdf)
Update trees from GeoDataFrame (replace existing canopy):
>>> updated = update_voxcity(city, tree_gdf=updated_tree_gdf)
Add trees from GeoDataFrame to existing canopy:
>>> updated = update_voxcity(city, tree_gdf=new_tree_gdf, tree_gdf_mode="add")
"""
# Resolve metadata from existing city
meta = city.buildings.meta
meshsize = meta.meshsize
# --- Auto-generate building grids from GeoDataFrame if provided ---
if building_gdf is not None and buildings is None and building_heights is None:
# Auto-generate building grids from the GeoDataFrame
building_gdf = _ensure_active_geometry(building_gdf)
from ..geoprocessor.raster import create_building_height_grid_from_gdf_polygon
rectangle_vertices = city.extras.get("rectangle_vertices")
if rectangle_vertices is None:
raise ValueError(
"Cannot auto-generate building grids: 'rectangle_vertices' not found in city.extras. "
"Provide building_heights, building_min_heights, and building_ids explicitly."
)
# Retrieve complement_height so NaN/zero-height footprints are preserved
_selected = city.extras.get("selected_sources") or {}
_complement_height = _selected.get("building_complement_height")
if _complement_height is None:
_complement_height = 10
building_heights, building_min_heights, building_ids, _ = (
create_building_height_grid_from_gdf_polygon(
building_gdf,
meshsize,
rectangle_vertices,
complement_height=_complement_height,
)
)
# --- Auto-generate canopy grids from tree GeoDataFrame if provided ---
if tree_gdf is not None and len(tree_gdf) > 0 and tree_canopy is None:
tree_gdf = _ensure_active_geometry(tree_gdf)
# Validate tree_gdf_mode
if tree_gdf_mode not in ("replace", "add"):
raise ValueError(
f"Invalid tree_gdf_mode '{tree_gdf_mode}'. Must be 'replace' or 'add'."
)
# Auto-generate canopy grids from the tree GeoDataFrame
from ..geoprocessor.raster import create_canopy_grids_from_tree_gdf
rectangle_vertices = city.extras.get("rectangle_vertices")
if rectangle_vertices is None:
raise ValueError(
"Cannot auto-generate canopy grids: 'rectangle_vertices' not found in city.extras. "
"Provide canopy_top and canopy_bottom explicitly."
)
new_canopy_top, new_canopy_bottom = create_canopy_grids_from_tree_gdf(
tree_gdf,
meshsize,
rectangle_vertices,
)
if tree_gdf_mode == "add":
# Merge with canopy_top/canopy_bottom if provided, else existing
base_top = canopy_top if canopy_top is not None else city.tree_canopy.top
base_bottom = canopy_bottom if canopy_bottom is not None else city.tree_canopy.bottom
if base_top is not None:
canopy_top = np.maximum(base_top, new_canopy_top)
else:
canopy_top = new_canopy_top
if base_bottom is not None:
canopy_bottom = np.maximum(base_bottom, new_canopy_bottom)
else:
canopy_bottom = new_canopy_bottom
else:
# Replace mode: use new canopy grids directly
canopy_top = new_canopy_top
canopy_bottom = new_canopy_bottom
# --- Resolve building data ---
if buildings is not None:
# Use provided BuildingGrid directly
final_building_heights = buildings.heights
final_building_min_heights = buildings.min_heights
final_building_ids = buildings.ids
final_building_meta = buildings.meta
else:
# Use individual arrays or fall back to existing
final_building_heights = (
building_heights if building_heights is not None else city.buildings.heights
)
final_building_min_heights = (
building_min_heights
if building_min_heights is not None
else city.buildings.min_heights
)
final_building_ids = (
building_ids if building_ids is not None else city.buildings.ids
)
final_building_meta = meta
# --- Resolve land cover data ---
if land_cover is not None:
if isinstance(land_cover, LandCoverGrid):
final_land_cover = land_cover.classes
else:
final_land_cover = land_cover
else:
final_land_cover = city.land_cover.classes
# --- Resolve DEM data ---
if dem is not None:
if isinstance(dem, DemGrid):
final_dem = dem.elevation
else:
final_dem = dem
else:
final_dem = city.dem.elevation
# --- Resolve canopy data ---
# Priority: canopy_top/canopy_bottom > tree_canopy > existing
if canopy_top is not None:
final_canopy_top = canopy_top
elif tree_canopy is not None:
if isinstance(tree_canopy, CanopyGrid):
final_canopy_top = tree_canopy.top
else:
final_canopy_top = tree_canopy
else:
final_canopy_top = city.tree_canopy.top
if canopy_bottom is not None:
final_canopy_bottom = canopy_bottom
elif tree_canopy is not None and isinstance(tree_canopy, CanopyGrid):
final_canopy_bottom = tree_canopy.bottom
else:
final_canopy_bottom = city.tree_canopy.bottom
# --- Determine land cover source ---
if land_cover_source is None:
# Try to get from extras
land_cover_source = city.extras.get("land_cover_source")
if land_cover_source is None:
selected = city.extras.get("selected_sources", {})
land_cover_source = selected.get("land_cover_source", "OpenStreetMap")
# --- Determine trunk height ratio ---
if trunk_height_ratio is None:
trunk_height_ratio = city.extras.get("trunk_height_ratio")
# --- Build updated extras ---
new_extras = dict(city.extras)
if building_gdf is not None:
new_extras["building_gdf"] = building_gdf
if tree_gdf is not None:
new_extras["tree_gdf"] = tree_gdf
new_extras["canopy_top"] = final_canopy_top
new_extras["canopy_bottom"] = final_canopy_bottom
new_extras["land_cover_source"] = land_cover_source
new_extras["trunk_height_ratio"] = trunk_height_ratio
# --- Shape validation ---
expected_shape = final_land_cover.shape
shapes = {
"building_heights": final_building_heights.shape,
"building_min_heights": final_building_min_heights.shape,
"building_ids": final_building_ids.shape,
"land_cover": final_land_cover.shape,
"dem": final_dem.shape,
"canopy_top": final_canopy_top.shape if final_canopy_top is not None else None,
}
mismatched = {k: v for k, v in shapes.items() if v is not None and v != expected_shape}
if mismatched:
raise ValueError(
f"Grid shape mismatch! Expected {expected_shape}, but got: {mismatched}. "
f"All grids must have the same shape."
)
# --- Create Voxelizer and regenerate voxel grid ---
_voxel_dtype = voxel_dtype if voxel_dtype is not None else np.int8
voxelizer = Voxelizer(
voxel_size=meshsize,
land_cover_source=land_cover_source,
trunk_height_ratio=trunk_height_ratio,
voxel_dtype=_voxel_dtype,
max_voxel_ram_mb=max_voxel_ram_mb,
)
new_voxel_classes = voxelizer.generate_combined(
building_height_grid_ori=final_building_heights,
building_min_height_grid_ori=final_building_min_heights,
building_id_grid_ori=final_building_ids,
land_cover_grid_ori=final_land_cover,
dem_grid_ori=final_dem,
tree_grid_ori=final_canopy_top,
canopy_bottom_height_grid_ori=final_canopy_bottom,
)
# --- Assemble result ---
new_voxels = VoxelGrid(classes=new_voxel_classes, meta=meta)
new_buildings = BuildingGrid(
heights=final_building_heights,
min_heights=final_building_min_heights,
ids=final_building_ids,
meta=final_building_meta,
)
new_land_cover = LandCoverGrid(classes=final_land_cover, meta=meta)
new_dem = DemGrid(elevation=final_dem, meta=meta)
new_canopy = CanopyGrid(
top=final_canopy_top,
bottom=final_canopy_bottom,
meta=meta,
)
if inplace:
city.voxels = new_voxels
city.buildings = new_buildings
city.land_cover = new_land_cover
city.dem = new_dem
city.tree_canopy = new_canopy
city.extras = new_extras
return city
else:
return VoxCity(
voxels=new_voxels,
buildings=new_buildings,
land_cover=new_land_cover,
dem=new_dem,
tree_canopy=new_canopy,
extras=new_extras,
)
[docs]
def regenerate_voxels(
city: VoxCity,
*,
land_cover_source: Optional[str] = None,
trunk_height_ratio: Optional[float] = None,
voxel_dtype=None,
max_voxel_ram_mb: Optional[float] = None,
inplace: bool = False,
) -> VoxCity:
"""
Regenerate only the VoxelGrid from existing component grids.
This is a convenience function for when you've modified the grids in-place
and need to regenerate the voxels without passing all parameters.
Parameters
----------
city : VoxCity
The VoxCity object whose voxels should be regenerated.
land_cover_source : str, optional
Land cover source for voxelization. Defaults to source from extras.
trunk_height_ratio : float, optional
Trunk height ratio for tree canopy calculation.
voxel_dtype : dtype, optional
NumPy dtype for voxel grid.
max_voxel_ram_mb : float, optional
Maximum RAM in MB for voxel allocation.
inplace : bool, default False
If True, modifies city directly; otherwise returns a new object.
Returns
-------
VoxCity
The VoxCity object with regenerated VoxelGrid.
Examples
--------
>>> # Modify building heights in place
>>> city.buildings.heights[50:60, 50:60] = 100.0
>>> # Regenerate voxels to reflect the change
>>> city = regenerate_voxels(city, inplace=True)
"""
return update_voxcity(
city,
land_cover_source=land_cover_source,
trunk_height_ratio=trunk_height_ratio,
voxel_dtype=voxel_dtype,
max_voxel_ram_mb=max_voxel_ram_mb,
inplace=inplace,
)