Skip to content

One-at-a-Time (OAT) Manning's n Sensitivity Analysis

This notebook performs a one-at-a-time (OAT) sensitivity analysis on Manning’s n values using a 2D HEC-RAS model. Each land cover class is varied individually while holding all others at their baseline values, producing sensitivity curves and a tornado diagram.

Key Concepts: - OAT sensitivity: vary one parameter at a time to isolate its effect - Manning’s n range tables with configurable interval stepping - Parallel plan execution with RasCmdr.compute_parallel() - Sensitivity curves and tornado diagram visualization

Prerequisites: HEC-RAS 6.x+ installed, ras-commander package

Python
from pathlib import Path
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

try:
    from ras_commander import (
        init_ras_project, RasCmdr, RasPlan, RasGeo, RasExamples,
        RasMap
    )
    from ras_commander.hdf import HdfMesh, HdfResultsMesh, HdfResultsPlan
except ImportError:
    current_file = Path("__file__").resolve()
    parent_directory = current_file.parent.parent
    sys.path.append(str(parent_directory))
    from ras_commander import (
        init_ras_project, RasCmdr, RasPlan, RasGeo, RasExamples,
        RasMap
    )
    from ras_commander.hdf import HdfMesh, HdfResultsMesh, HdfResultsPlan

Configuration

All parameters for the analysis are defined here. Adjust INTERVAL to control the granularity of the sensitivity sweep (smaller interval = more plans = longer runtime).

Python
# === PROJECT CONFIGURATION ===
PROJECT_NAME = "Muncie"        # RasExamples project name
RAS_VERSION = "7.0"            # HEC-RAS version string
TEMPLATE_PLAN = "04"           # Plan number with 2D geometry

# === SENSITIVITY CONFIGURATION ===
INTERVAL = 0.02                # Manning's n step size for sweep

# === POINT OF INTEREST ===
# Location in model coordinates (State Plane Indiana East, ft)
# Selected at ~p50 of max WSE across all mesh cells
POI_X = 408350.0
POI_Y = 1802550.0
POI_LABEL = "Mid-Floodplain POI"

# === EXECUTION ===
MAX_WORKERS = 4                # Parallel execution workers
NUM_CORES = 2                  # CPU cores per HEC-RAS instance

Extract Example Project and Initialize

Python
project_folder = RasExamples.extract_project(PROJECT_NAME)
ras = init_ras_project(project_folder, RAS_VERSION)

print(f"Project: {project_folder}")
print(f"Plans: {len(ras.plan_df)}")
print(f"Template plan: {TEMPLATE_PLAN}")
print(f"\nPlan DataFrame:")
ras.plan_df[["plan_number", "Plan Title", "Geom File"]].head(10)

Read Baseline Manning’s n Values

Extract the current Manning’s n values from the template geometry file. These serve as the baseline for the OAT sensitivity analysis.

Python
# Get geometry file path from template plan
template_row = ras.plan_df[ras.plan_df["plan_number"] == TEMPLATE_PLAN].iloc[0]
geom_file = Path(template_row["Geom File"])

# Read base overrides (primary Manning's n table)
base_overrides = RasGeo.get_mannings_baseoverrides(geom_file)
print("Base Manning's n Overrides:")
print(f"  Land covers: {len(base_overrides)}")
for name, n_val in base_overrides.items():
    print(f"  {name}: n = {n_val}")

# Read regional overrides
regional_overrides = RasGeo.get_mannings_regionoverrides(geom_file)
print(f"\nRegional Overrides: {len(regional_overrides)} regions")
for region_name, overrides in regional_overrides.items():
    print(f"  {region_name}:")
    for lc_name, n_val in overrides.items():
        print(f"    {lc_name}: n = {n_val}")

Build Manning’s n Range Table

Define min/max bounds for each land cover class. Obstruction values (n >= 1.0, typically buildings at n=100) are excluded from the sensitivity analysis since they represent physical barriers, not roughness.

Python
# Standard Manning's n ranges by land cover type
# Source: HEC-RAS Reference Manual, Chow (1959)
MANNINGS_RANGES = {
    "Pasture":           (0.025, 0.050),
    "Brush":             (0.040, 0.120),
    "Trees":             (0.060, 0.200),
    "Low Intensity":     (0.040, 0.120),
    "High Intensity":    (0.060, 0.200),
    "Water":             (0.025, 0.050),
    "Cropland":          (0.020, 0.060),
    "Wetland":           (0.030, 0.100),
    "Barren":            (0.010, 0.035),
}

OBSTRUCTION_THRESHOLD = 1.0  # n values >= this are obstructions

# Build range table from base overrides
range_table = {}
excluded = {}

for name, baseline_n in base_overrides.items():
    if baseline_n >= OBSTRUCTION_THRESHOLD:
        excluded[name] = baseline_n
        continue

    if name in MANNINGS_RANGES:
        n_min, n_max = MANNINGS_RANGES[name]
    else:
        # Default: +/- 50% of baseline, clamped to [0.01, 0.5]
        n_min = max(0.01, baseline_n * 0.5)
        n_max = min(0.50, baseline_n * 1.5)

    range_table[name] = {
        "baseline": baseline_n,
        "min": n_min,
        "max": n_max,
        "steps": np.arange(n_min, n_max + INTERVAL / 2, INTERVAL)
    }

print("=== Sensitivity Range Table ===")
print(f"Variable land covers: {len(range_table)}")
print(f"Excluded (obstructions): {len(excluded)}")
print()

total_plans = sum(len(v["steps"]) for v in range_table.values())
print(f"Total plans to create: {total_plans}")
print()

for name, info in range_table.items():
    print(f'  {name}: baseline={info["baseline"]:.3f}, '
          f'range=[{info["min"]:.3f}, {info["max"]:.3f}], '
          f'steps={len(info["steps"])}')

if excluded:
    print(f"\nExcluded obstructions:")
    for name, n_val in excluded.items():
        print(f"  {name}: n = {n_val} (held constant)")

Create OAT Sensitivity Plans

For each land cover, create a set of plans that vary only that land cover’s Manning’s n while keeping all others at baseline. Each plan gets a cloned geometry file with modified Manning’s n values.

Python
plan_registry = []  # Track all created plans
plan_counter = 0

for lc_name, info in range_table.items():
    print(f"\nCreating plans for: {lc_name} ({len(info['steps'])} steps)")

    for n_value in info["steps"]:
        plan_counter += 1
        plan_title = f"OAT_{lc_name[:8]}_{n_value:.3f}"

        # Clone plan and geometry from template
        new_plan_number = RasPlan.clone_plan(TEMPLATE_PLAN, ras_object=ras)
        new_geom_number = RasPlan.clone_geom(TEMPLATE_PLAN, ras_object=ras)

        # Link cloned plan to cloned geometry
        RasPlan.set_geom(new_plan_number, new_geom_number, ras_object=ras)

        # Set plan title
        plan_file = ras.project_folder / f"{ras.project_name}.p{new_plan_number}"
        RasPlan.set_plan_title(plan_file, plan_title)

        # Modify Manning's n: set target land cover to n_value, keep others at baseline
        geom_path = ras.project_folder / f"{ras.project_name}.g{new_geom_number}"
        modified_overrides = dict(base_overrides)  # Start with all baseline values
        modified_overrides[lc_name] = n_value       # Vary only this land cover
        RasGeo.set_mannings_baseoverrides(geom_path, modified_overrides)

        plan_registry.append({
            "land_cover": lc_name,
            "n_value": round(n_value, 4),
            "plan_number": new_plan_number,
            "geom_number": new_geom_number,
            "Plan Title": plan_title,
            "is_baseline": abs(n_value - info["baseline"]) < 1e-6
        })

# Refresh project state
ras = init_ras_project(project_folder, RAS_VERSION)

registry_df = pd.DataFrame(plan_registry)
print(f"\n=== Plan Registry ===")
print(f"Total plans created: {len(registry_df)}")
print(f"Land covers varied: {registry_df['land_cover'].nunique()}")
print(f"\nPlans per land cover:")
print(registry_df.groupby("land_cover").size())

Execute All Plans

Run all sensitivity plans in parallel. This may take several minutes depending on model size and number of plans.

Python
all_plan_numbers = registry_df["plan_number"].tolist()
print(f"Executing {len(all_plan_numbers)} plans with {MAX_WORKERS} workers...")

RasCmdr.compute_parallel(
    plan_numbers=all_plan_numbers,
    max_workers=MAX_WORKERS,
    num_cores=NUM_CORES,
    ras_object=ras
)

# Refresh after execution
ras = init_ras_project(project_folder, RAS_VERSION)
print("Execution complete.")

Extract Results at Point of Interest

For each executed plan, extract the maximum water surface elevation (WSE) at the point of interest. This allows us to measure the sensitivity of WSE to each land cover’s Manning’s n value.

Python
from scipy.spatial import cKDTree

results = []

for _, row in registry_df.iterrows():
    plan_num = row["plan_number"]

    try:
        # Get HDF path from plan_df
        plan_row = ras.plan_df[ras.plan_df["plan_number"] == plan_num]
        if plan_row.empty:
            print(f"  Plan {plan_num}: not found in plan_df")
            continue

        hdf_path = plan_row["HDF_Results_Path"].iloc[0]
        if pd.isna(hdf_path) or not Path(hdf_path).exists():
            print(f"  Plan {plan_num}: no HDF results")
            continue

        hdf_path = Path(hdf_path)

        # Get mesh cell locations
        cell_pts = HdfMesh.get_mesh_cell_points(plan_num, ras_object=ras)

        # Find nearest cell to POI
        coords = np.column_stack([cell_pts.geometry.x, cell_pts.geometry.y])
        tree = cKDTree(coords)
        dist, idx = tree.query([POI_X, POI_Y])

        # Extract max WSE for this cell
        ts = HdfResultsMesh.get_mesh_cells_timeseries(
            plan_num, ras_object=ras,
            mesh_name=cell_pts.iloc[idx]["mesh_name"],
            cell_ids=[cell_pts.iloc[idx]["cell_id"]],
            variable="Water Surface"
        )

        max_wse = ts.max().max()

        results.append({
            "land_cover": row["land_cover"],
            "n_value": row["n_value"],
            "plan_number": plan_num,
            "max_wse_ft": float(max_wse),
            "is_baseline": row["is_baseline"],
            "poi_cell_dist_ft": float(dist)
        })

    except Exception as e:
        print(f'  Plan {plan_num} ({row["land_cover"]} n={row["n_value"]}): {e}')

results_df = pd.DataFrame(results)
print(f"\nResults extracted: {len(results_df)} of {len(registry_df)} plans")
print(f"POI cell distance: {results_df['poi_cell_dist_ft'].iloc[0]:.1f} ft")
print(f"\nWSE range: {results_df['max_wse_ft'].min():.2f} - {results_df['max_wse_ft'].max():.2f} ft")
results_df.head(10)

Sensitivity Curves

Plot Manning’s n vs. Max WSE for each land cover class. Each subplot shows how varying one land cover’s roughness (while holding others constant) affects the water surface elevation at the POI.

Python
land_covers = results_df["land_cover"].unique()
n_lc = len(land_covers)
ncols = min(3, n_lc)
nrows = int(np.ceil(n_lc / ncols))

fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows), squeeze=False)
fig.suptitle(f"OAT Manning's n Sensitivity at {POI_LABEL}", fontsize=14, fontweight="bold")

for i, lc_name in enumerate(land_covers):
    ax = axes[i // ncols, i % ncols]
    lc_data = results_df[results_df["land_cover"] == lc_name].sort_values("n_value")

    ax.plot(lc_data["n_value"], lc_data["max_wse_ft"], "o-", color="steelblue", linewidth=2)

    # Mark baseline
    baseline_row = lc_data[lc_data["is_baseline"]]
    if not baseline_row.empty:
        ax.axvline(baseline_row["n_value"].iloc[0], color="red", linestyle="--", alpha=0.7, label="Baseline")

    ax.set_title(lc_name, fontsize=11)
    ax.set_xlabel("Manning's n")
    ax.set_ylabel("Max WSE (ft)")
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8)

# Hide unused subplots
for j in range(n_lc, nrows * ncols):
    axes[j // ncols, j % ncols].set_visible(False)

plt.tight_layout()
plt.show()

Tornado Diagram

The tornado diagram ranks land cover classes by their influence on WSE. Wider bars indicate greater sensitivity — these are the parameters that matter most for model calibration.

Python
# Calculate WSE range (max - min) for each land cover
tornado_data = []
for lc_name in land_covers:
    lc_data = results_df[results_df["land_cover"] == lc_name]
    baseline_wse = lc_data[lc_data["is_baseline"]]["max_wse_ft"]
    baseline_val = baseline_wse.iloc[0] if not baseline_wse.empty else lc_data["max_wse_ft"].median()

    tornado_data.append({
        "land_cover": lc_name,
        "wse_min": lc_data["max_wse_ft"].min(),
        "wse_max": lc_data["max_wse_ft"].max(),
        "wse_range": lc_data["max_wse_ft"].max() - lc_data["max_wse_ft"].min(),
        "wse_baseline": baseline_val,
        "n_min": lc_data["n_value"].min(),
        "n_max": lc_data["n_value"].max(),
        "n_baseline": range_table[lc_name]["baseline"]
    })

tornado_df = pd.DataFrame(tornado_data).sort_values("wse_range", ascending=True)

fig, ax = plt.subplots(figsize=(10, max(4, len(tornado_df) * 0.8)))

baseline_global = tornado_df["wse_baseline"].median()
y_pos = range(len(tornado_df))

for i, (_, row) in enumerate(tornado_df.iterrows()):
    left = row["wse_min"] - row["wse_baseline"]
    right = row["wse_max"] - row["wse_baseline"]

    ax.barh(i, right, left=0, height=0.6, color="steelblue", alpha=0.8)
    ax.barh(i, left, left=0, height=0.6, color="coral", alpha=0.8)

    ax.text(right + 0.01, i, f"+{right:.2f}", va="center", fontsize=9)
    ax.text(left - 0.01, i, f"{left:.2f}", va="center", ha="right", fontsize=9)

ax.set_yticks(list(y_pos))
ax.set_yticklabels([f'{r["land_cover"]} (n={r["n_baseline"]:.3f})' for _, r in tornado_df.iterrows()])
ax.axvline(0, color="black", linewidth=0.8)
ax.set_xlabel("Change in Max WSE from Baseline (ft)")
ax.set_title(f"Manning's n Sensitivity Tornado Diagram\n{POI_LABEL}", fontweight="bold")
ax.grid(True, axis="x", alpha=0.3)

plt.tight_layout()
plt.show()

print("\n=== Sensitivity Ranking (most to least influential) ===")
for _, row in tornado_df.iloc[::-1].iterrows():
    print(f'  {row["land_cover"]}: WSE range = {row["wse_range"]:.3f} ft '
          f'(n: {row["n_min"]:.3f} to {row["n_max"]:.3f})')

Export Results

Save the full results table and tornado summary as CSV files for further analysis or reporting.

Python
# Full results table
results_csv = project_folder / "oat_sensitivity_results.csv"
results_df.to_csv(results_csv, index=False)
print(f"Full results: {results_csv}")

# Tornado summary
tornado_csv = project_folder / "oat_sensitivity_tornado.csv"
tornado_df.to_csv(tornado_csv, index=False)
print(f"Tornado summary: {tornado_csv}")

print(f"\nTotal plans analyzed: {len(results_df)}")
print(f"Land covers varied: {results_df['land_cover'].nunique()}")
print(f"Overall WSE range: {results_df['max_wse_ft'].max() - results_df['max_wse_ft'].min():.3f} ft")

Cleanup

Remove the extracted example project to free disk space.

Python
import shutil

example_projects_dir = project_folder.parent
if example_projects_dir.name == "example_projects" and example_projects_dir.exists():
    shutil.rmtree(example_projects_dir, ignore_errors=True)
    print(f"Cleaned up: {example_projects_dir}")
else:
    print(f"Skipping cleanup: {example_projects_dir}")
CLB Engineering Corporation  ·  LLM Forward Engineering
RAS Commander is a free and open-source project maintained by CLB Engineering Corporation. For agencies and firms seeking to modernize H&H workflows with LLM Forward approaches, contact CLB to partner with the engineers who wrote the automation.