Skip to content

terrain

Terrain discovery and consolidation for HEC-RAS projects.

Overview

The terrain module discovers terrain layers from a HEC-RAS project's rasmap configuration and can consolidate multiple terrain TIFFs into a single merged raster. This is useful for:

  • Inspecting terrain configuration: Enumerate all terrain layers, their CRS, resolution, and file locations
  • Consolidating terrain: Merge multiple terrain tiles into a single file for simplified workflows
  • Downsampling: Reduce terrain resolution for faster mapping or smaller file sizes
  • Creating HEC-RAS terrain HDFs: Generate new terrain HDF files via RasProcess.exe (required for result mapping)

How Terrain Discovery Works

  1. Reads the project's .rasmap file to get terrain names in priority order
  2. For each terrain name, locates the corresponding .hdf file in the Terrain/ directory
  3. Discovers associated .tif files by matching the HDF stem against TIF file names
  4. Optionally reads CRS and resolution from TIF files using rasterio

Terrain Name Matching

TIF files are associated with a terrain by matching the file stem against the terrain name. The matching is case-insensitive and allows suffixes separated by ., _, or -:

TIF Stem Terrain Name Match?
Terrain50 Terrain50 Yes (exact)
Terrain50.muncie_clip Terrain50 Yes (dot separator)
Terrain50_tile2 Terrain50 Yes (underscore separator)
Terrain50-highres Terrain50 Yes (dash separator)
Terrain50WithChannel Terrain50 No (alphanumeric continuation)

How Terrain Consolidation Works

  1. Discover terrain TIFs from rasmap (priority ordered)
  2. Harmonize CRS: If TIFs have different but equivalent CRS representations, reprojects to match the first TIF's CRS
  3. Merge via rasterio.merge.merge(method='first') — first terrain wins in overlapping areas
  4. Optionally downsample — reduce resolution by a factor or to a target cell size
  5. Optionally create HEC-RAS terrain HDF via RasTerrain.create_terrain_from_rasters() (requires RasProcess.exe)
  6. Optionally register the new terrain in the project's rasmap

Steps 5-6 require RasProcess.exe (Windows or Wine). Steps 1-4 are pure Python (rasterio).

API Reference

ras2cng.terrain.TerrainInfo dataclass

Information about a single terrain layer discovered from a RAS project.

Source code in ras2cng/terrain.py
@dataclass
class TerrainInfo:
    """Information about a single terrain layer discovered from a RAS project."""
    name: str
    hdf_path: Optional[Path] = None
    hdf_exists: bool = False
    tif_files: list[Path] = field(default_factory=list)
    crs: Optional[str] = None
    resolution: Optional[str] = None       # e.g. "50.0 x 50.0 ft"
    bounds: Optional[tuple] = None         # (xmin, ymin, xmax, ymax)
    total_size_mb: float = 0.0

ras2cng.terrain.discover_terrains(project_path)

Discover terrain layers from rasmap in priority order.

Uses RasMap.get_terrain_names() + rasmap_df['terrain_hdf_path']. For each terrain HDF, discovers associated .tif files in same directory.

Parameters:

Name Type Description Default
project_path Path

Path to .prj file or project directory

required

Returns:

Type Description
list[TerrainInfo]

List of TerrainInfo in rasmap priority order

Source code in ras2cng/terrain.py
def discover_terrains(project_path: Path) -> list[TerrainInfo]:
    """Discover terrain layers from rasmap in priority order.

    Uses RasMap.get_terrain_names() + rasmap_df['terrain_hdf_path'].
    For each terrain HDF, discovers associated .tif files in same directory.

    Args:
        project_path: Path to .prj file or project directory

    Returns:
        List of TerrainInfo in rasmap priority order
    """
    from ras2cng.project import resolve_project_path
    from ras_commander import init_ras_project

    project_dir, prj_file = resolve_project_path(Path(project_path))
    ras = init_ras_project(project_dir, ras_object="new", load_results_summary=False)

    terrains: list[TerrainInfo] = []

    # Try to get terrain names from rasmap
    terrain_names = _get_terrain_names_safe(project_dir)

    # Try to get terrain HDF paths from rasmap_df
    terrain_hdf_paths: dict[str, Path] = {}
    if ras.rasmap_df is not None and not ras.rasmap_df.empty:
        if "terrain_hdf_path" in ras.rasmap_df.columns:
            for _, row in ras.rasmap_df.iterrows():
                hdf_p = row.get("terrain_hdf_path")
                name = row.get("terrain_name", "")
                if hdf_p and str(hdf_p).strip():
                    terrain_hdf_paths[str(name)] = Path(str(hdf_p))

    # If no rasmap terrain info, fall back to scanning Terrain/ directory
    if not terrain_names and not terrain_hdf_paths:
        terrain_dir = project_dir / "Terrain"
        if terrain_dir.exists():
            hdf_files = sorted(terrain_dir.glob("*.hdf"))
            for hdf_f in hdf_files:
                name = hdf_f.stem
                tif_files = _discover_tifs_for_hdf(hdf_f)
                info = _get_raster_info(tif_files)
                terrains.append(TerrainInfo(
                    name=name,
                    hdf_path=hdf_f,
                    hdf_exists=hdf_f.exists(),
                    tif_files=tif_files,
                    crs=info.get("crs"),
                    resolution=info.get("resolution"),
                    bounds=info.get("bounds"),
                    total_size_mb=sum(f.stat().st_size for f in tif_files if f.exists()) / (1024 * 1024),
                ))
            # Also check for standalone TIFs
            if not hdf_files:
                tif_files = _glob_tifs(terrain_dir)
                if tif_files:
                    info = _get_raster_info(tif_files)
                    terrains.append(TerrainInfo(
                        name="Terrain",
                        tif_files=tif_files,
                        crs=info.get("crs"),
                        resolution=info.get("resolution"),
                        bounds=info.get("bounds"),
                        total_size_mb=sum(f.stat().st_size for f in tif_files if f.exists()) / (1024 * 1024),
                    ))
        return terrains

    # Build terrain info from rasmap data
    seen_names = set()
    for name in terrain_names or list(terrain_hdf_paths.keys()):
        if name in seen_names:
            continue
        seen_names.add(name)

        hdf_path = terrain_hdf_paths.get(name)
        if hdf_path and not hdf_path.is_absolute():
            hdf_path = project_dir / hdf_path

        tif_files = _discover_tifs_for_hdf(hdf_path) if hdf_path else []
        # Also check Terrain/ directory for TIFs matching the name
        if not tif_files:
            terrain_dir = project_dir / "Terrain"
            if terrain_dir.exists():
                all_tifs = _glob_tifs(terrain_dir)
                # Exact stem match: "Terrain" matches "Terrain.tif" and
                # "Terrain_tile2.tif" but NOT "TerrainWithChannel.tif"
                tif_files = sorted(
                    f for f in all_tifs
                    if _stem_matches_name(f.stem, name)
                )

        info = _get_raster_info(tif_files)
        terrains.append(TerrainInfo(
            name=name,
            hdf_path=hdf_path,
            hdf_exists=hdf_path.exists() if hdf_path else False,
            tif_files=tif_files,
            crs=info.get("crs"),
            resolution=info.get("resolution"),
            bounds=info.get("bounds"),
            total_size_mb=sum(f.stat().st_size for f in tif_files if f.exists()) / (1024 * 1024),
        ))

    return terrains

ras2cng.terrain.consolidate_terrain(project_path, output_dir, *, terrain_name='Consolidated', downsample_factor=None, target_resolution=None, terrain_names=None, units='Feet', ras_version='6.6', create_hdf=True, register_rasmap=True)

Consolidate project terrains and create a new HEC-RAS terrain HDF.

Full pipeline: 1. Discover terrain TIFFs from rasmap (priority ordered) 2. Merge via rasterio.merge.merge(method='first') -- first wins in overlaps 3. Optionally downsample (reduce resolution) 4. Create HEC-RAS terrain HDF via RasTerrain.create_terrain_from_rasters() 5. Register new terrain in rasmap via RasMap.add_terrain_layer()

Steps 4-5 require RasProcess.exe. If create_hdf=False, only produces the merged TIFF (useful for exporting to cloud-native COG pipeline).

Parameters:

Name Type Description Default
project_path Path

Path to .prj file or project directory

required
output_dir Path

Directory for output terrain files

required
terrain_name str

Name for the consolidated terrain (default: "Consolidated")

'Consolidated'
downsample_factor Optional[float]

Factor to reduce resolution (2.0 = half resolution)

None
target_resolution Optional[float]

Target cell size in project units (overrides downsample_factor)

None
terrain_names Optional[list[str]]

Specific terrain names to include (None = all from rasmap)

None
units str

Vertical units "Feet" or "Meters"

'Feet'
ras_version str

HEC-RAS version for RasProcess.exe

'6.6'
create_hdf bool

If True, create HEC-RAS terrain HDF (requires RasProcess.exe)

True
register_rasmap bool

If True, register new terrain in project rasmap

True

Returns:

Type Description
Path

Path to consolidated terrain HDF (if create_hdf) or TIFF (if not)

Source code in ras2cng/terrain.py
def consolidate_terrain(
    project_path: Path,
    output_dir: Path,
    *,
    terrain_name: str = "Consolidated",
    downsample_factor: Optional[float] = None,
    target_resolution: Optional[float] = None,
    terrain_names: Optional[list[str]] = None,
    units: str = "Feet",
    ras_version: str = "6.6",
    create_hdf: bool = True,
    register_rasmap: bool = True,
) -> Path:
    """Consolidate project terrains and create a new HEC-RAS terrain HDF.

    Full pipeline:
    1. Discover terrain TIFFs from rasmap (priority ordered)
    2. Merge via rasterio.merge.merge(method='first') -- first wins in overlaps
    3. Optionally downsample (reduce resolution)
    4. Create HEC-RAS terrain HDF via RasTerrain.create_terrain_from_rasters()
    5. Register new terrain in rasmap via RasMap.add_terrain_layer()

    Steps 4-5 require RasProcess.exe. If create_hdf=False, only produces
    the merged TIFF (useful for exporting to cloud-native COG pipeline).

    Args:
        project_path: Path to .prj file or project directory
        output_dir: Directory for output terrain files
        terrain_name: Name for the consolidated terrain (default: "Consolidated")
        downsample_factor: Factor to reduce resolution (2.0 = half resolution)
        target_resolution: Target cell size in project units (overrides downsample_factor)
        terrain_names: Specific terrain names to include (None = all from rasmap)
        units: Vertical units "Feet" or "Meters"
        ras_version: HEC-RAS version for RasProcess.exe
        create_hdf: If True, create HEC-RAS terrain HDF (requires RasProcess.exe)
        register_rasmap: If True, register new terrain in project rasmap

    Returns:
        Path to consolidated terrain HDF (if create_hdf) or TIFF (if not)
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # Step 1: Discover terrains
    terrains = discover_terrains(project_path)
    if not terrains:
        raise ValueError("No terrain data found in project")

    # Filter by name if requested
    if terrain_names:
        name_set = set(terrain_names)
        terrains = [t for t in terrains if t.name in name_set]
        if not terrains:
            raise ValueError(f"No terrains matching names: {terrain_names}")

    # Collect all TIF files in priority order
    all_tifs: list[Path] = []
    for t in terrains:
        all_tifs.extend(t.tif_files)

    if not all_tifs:
        raise ValueError("No TIFF files found for terrain consolidation")

    console.print(f"[bold]Terrain consolidation:[/bold] {len(all_tifs)} TIFF(s) from {len(terrains)} terrain(s)")

    # Step 2: Merge TIFFs
    merged_tif = output_dir / f"{terrain_name}_merged.tif"
    _merge_tifs(all_tifs, merged_tif)
    console.print(f"  Merged -> {merged_tif.name}")

    # Step 3: Optionally downsample
    final_tif = merged_tif
    if downsample_factor or target_resolution:
        downsampled_tif = output_dir / f"{terrain_name}_downsampled.tif"
        _downsample_tif(
            merged_tif, downsampled_tif,
            factor=downsample_factor,
            resolution=target_resolution,
        )
        final_tif = downsampled_tif
        console.print(f"  Downsampled -> {downsampled_tif.name}")

    # Step 4: Create HEC-RAS terrain HDF (requires RasProcess.exe)
    if not create_hdf:
        console.print(f"[green]OK[/green] TIFF-only mode: {final_tif}")
        return final_tif

    try:
        from ras_commander import RasTerrain

        from ras2cng.project import resolve_project_path
        project_dir, prj_file = resolve_project_path(Path(project_path))

        terrain_hdf = RasTerrain.create_terrain_from_rasters(
            raster_files=[str(final_tif)],
            terrain_name=terrain_name,
            project_folder=str(project_dir),
            units=units,
            ras_version=ras_version,
        )
        terrain_hdf = Path(terrain_hdf)
        console.print(f"  HEC-RAS terrain HDF -> {terrain_hdf.name}")
    except ImportError:
        console.print("[yellow]Warning:[/yellow] RasTerrain not available; returning TIFF only")
        return final_tif
    except Exception as e:
        console.print(f"[yellow]Warning:[/yellow] Terrain HDF creation failed: {e}")
        console.print("  Returning merged TIFF instead")
        return final_tif

    # Step 5: Register in rasmap
    if register_rasmap:
        try:
            from ras_commander import RasMap

            rasmap_path = project_dir / f"{prj_file.stem}.rasmap"
            if rasmap_path.exists():
                RasMap.add_terrain_layer(
                    rasmap_path=str(rasmap_path),
                    terrain_name=terrain_name,
                    terrain_hdf_path=str(terrain_hdf),
                )
                console.print(f"  Registered in rasmap: {rasmap_path.name}")
        except Exception as e:
            console.print(f"[yellow]Warning:[/yellow] Could not register terrain in rasmap: {e}")

    console.print(f"[green]OK[/green] Terrain consolidation complete: {terrain_hdf}")
    return terrain_hdf