Skip to content

Manning's n Bulk Sensitivity Analysis

This notebook demonstrates bulk Manning's n sensitivity analysis for HEC-RAS 2D models with spatially variable roughness. The workflow:

  1. Extracts a 2D project with Manning's n land cover regions
  2. Defines minimum/maximum parameter ranges for each roughness class
  3. Associates the land-cover sidecar HDF with each scenario geometry HDF
  4. Creates modified geometries with min and max Manning's n values
  5. Executes HEC-RAS for current, minimum, and maximum scenarios
  6. Verifies preprocessed Manning's n changes and full-mesh hydraulic response
  7. Extracts water surface elevation at a point of interest and visualizes results

Manning's n Physical Context

Land Cover Typical Range
Paved/open space 0.020 - 0.060
Mowed grass/parks 0.030 - 0.080
Residential 0.050 - 0.120
Urban (mixed) 0.060 - 0.150
Forest/trees 0.080 - 0.200

Reference

Setup

Python
# Development mode toggle
import logging
import warnings

# Keep notebook output focused on the sensitivity workflow.
logging.getLogger().setLevel(logging.WARNING)
logging.getLogger("ras_commander").setLevel(logging.WARNING)
warnings.filterwarnings("ignore", message="IProgress not found.*", category=Warning)

USE_LOCAL_SOURCE = False

if USE_LOCAL_SOURCE:
    import sys
    from pathlib import Path
    local_path = str(Path.cwd().parent)
    if local_path not in sys.path:
        sys.path.insert(0, local_path)
    print(f"LOCAL SOURCE MODE: Loading from {local_path}/ras_commander")
else:
    from pathlib import Path
    print("PIP PACKAGE MODE: Loading installed ras-commander")

from ras_commander import (
    init_ras_project, RasExamples, RasPlan, RasCmdr,
    GeomLandCover, GeomMesh, HdfLandCover, HdfMesh, HdfResultsMesh
)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from shapely.geometry import Point

import ras_commander
logging.getLogger("ras_commander").setLevel(logging.WARNING)
print(f"Loaded ras-commander {ras_commander.__version__}")
Text Only
PIP PACKAGE MODE: Loading installed ras-commander


Loaded ras-commander 0.98.2

Parameters

Configure these values to customize for your project.

Python
# Project Configuration
PROJECT_NAME = "Muncie"
RAS_VERSION = "7.0"

# Template plan with 2D Manning's n regions
TEMPLATE_PLAN = "04"  # "Unsteady Run with 2D 50ft User n Value Regions"

# Point of interest for result extraction (State Plane Indiana East, ft)
POINT_OF_INTEREST = (408350.0, 1802550.0)

# Which Manning's categories to adjust
INCLUDE_REGIONAL_OVERRIDES = True
INCLUDE_BASE_OVERRIDES = True

# Execution settings
NUM_CORES = 2

Define Manning's n Value Ranges

These ranges are based on literature values for the Muncie model's urban land cover classification. The building class uses a high obstruction value and is excluded from sensitivity variation.

Python
def create_manning_minmax_df():
    """
    Create min/max Manning's n ranges for Muncie's urban land cover types.

    The 'building' class is an obstruction (n=100) and is held constant.
    All other classes vary based on published literature ranges.
    """
    manning_data = [
        {"Land Cover Name": "building", "min_n": 100.0, "max_n": 100.0},
        {"Land Cover Name": "medium density residential", "min_n": 0.050, "max_n": 0.120},
        {"Land Cover Name": "open space", "min_n": 0.020, "max_n": 0.060},
        {"Land Cover Name": "park", "min_n": 0.030, "max_n": 0.080},
        {"Land Cover Name": "trees", "min_n": 0.080, "max_n": 0.200},
        {"Land Cover Name": "urban", "min_n": 0.060, "max_n": 0.150},
    ]
    df = pd.DataFrame(manning_data)
    df['mid_n'] = (df['min_n'] + df['max_n']) / 2
    print(f"Manning's n ranges for {len(df)} land cover types:")
    print(df.to_string(index=False))
    return df

manning_minmax_df = create_manning_minmax_df()
Text Only
Manning's n ranges for 6 land cover types:
           Land Cover Name  min_n  max_n   mid_n
                  building 100.00 100.00 100.000
medium density residential   0.05   0.12   0.085
                open space   0.02   0.06   0.040
                      park   0.03   0.08   0.055
                     trees   0.08   0.20   0.140
                     urban   0.06   0.15   0.105

Extract Project and Initialize

Python
project_folder = Path(RasExamples.extract_project(PROJECT_NAME, suffix="710"))
ras = init_ras_project(project_folder, RAS_VERSION)

landcover_hdf = project_folder / "LandCover" / "LandCoverUserShapefile.hdf"
if not landcover_hdf.exists():
    raise FileNotFoundError(
        "Expected Muncie land-cover sidecar is missing: "
        f"{landcover_hdf.relative_to(project_folder).as_posix()}"
    )

print(f"\nProject: {ras.project_name}")
print(f"Land cover sidecar: {landcover_hdf.relative_to(project_folder).as_posix()}")
print(f"Plans:")
print(ras.plan_df[['plan_number', 'Plan Title', 'geometry_number']].to_string())
Text Only
Project: Muncie
Land cover sidecar: LandCover/LandCoverUserShapefile.hdf
Plans:
  plan_number                                Plan Title geometry_number
0          01                  Unsteady Multi  9-SA run              01
1          03            Unsteady Run with 2D 50ft Grid              02
2          04  Unsteady Run with 2D 50ft User n Value R              04

Inspect Current Manning's n Values

Python
template_geom = ras.plan_df.loc[
    ras.plan_df['plan_number'] == TEMPLATE_PLAN, 'geometry_number'
].values[0]

geom_path = ras.geom_df.loc[
    ras.geom_df['geom_number'] == template_geom, 'full_path'
].values[0]

GeomMesh.set_geometry_association(
    template_geom,
    landcover_hdf_path=landcover_hdf,
    ras_object=ras,
    validate=True,
)
print(
    f"Associated template geometry {template_geom} with "
    f"{landcover_hdf.relative_to(project_folder).as_posix()}"
)

original_base = GeomLandCover.get_base_mannings_n(geom_path)
original_region = GeomLandCover.get_region_mannings_n(geom_path)

print("=== Base Manning's n Overrides ===")
print(original_base.to_string())

print(f"\n=== Regional Manning's n Overrides ===")
if not original_region.empty:
    print(original_region.to_string())
else:
    print("None found")
Text Only
Associated template geometry 04 with LandCover/LandCoverUserShapefile.hdf
=== Base Manning's n Overrides ===
  Table Number             Land Cover Name  Base Mannings n Value
0            6                    building                 100.00
1            6  medium density residential                   0.08
2            6                  open space                   0.04
3            6                        park                   0.06
4            6                       trees                   0.12
5            6                       urban                   0.10

=== Regional Manning's n Overrides ===
  Table Number             Land Cover Name  MainChannel Region Name
0            6                    building      100.000   Flat Area
1            6  medium density residential        0.072   Flat Area
2            6                  open space        0.036   Flat Area
3            6                        park        0.054   Flat Area
4            6                       trees        0.108   Flat Area
5            6                       urban        0.090   Flat Area

Execute Template Plan (Baseline)

Python
print(f"Executing template plan {TEMPLATE_PLAN} (current Manning's values)...")
result = RasCmdr.compute_plan(
    TEMPLATE_PLAN,
    ras_object=ras,
    num_cores=NUM_CORES,
    clear_geompre=True,
    force_rerun=True,
    verify=True,
)
if not result:
    raise RuntimeError(f"Template plan {TEMPLATE_PLAN} did not complete successfully")
print(f"Result: {result}")
Text Only
Executing template plan 04 (current Manning's values)...


Result: ComputeResult(SUCCESS, results_df_row=available)

Create Min/Max Scenarios

Clone the template plan and geometry, then modify Manning's n values to the minimum and maximum of the defined ranges.

Python
def create_modified_scenario(name, shortid, manning_minmax_df, use_min=False, use_max=False):
    """
    Clone template plan/geometry and apply modified Manning's n values.

    Args:
        name: Scenario name
        shortid: Plan short identifier
        manning_minmax_df: DataFrame with min_n and max_n columns
        use_min: Apply minimum values
        use_max: Apply maximum values

    Returns:
        dict with scenario metadata
    """
    new_plan = RasPlan.clone_plan(TEMPLATE_PLAN, new_plan_shortid=shortid, ras_object=ras)
    new_geom = RasPlan.clone_geom(template_geom, ras_object=ras)
    RasPlan.set_geom(new_plan, new_geom, ras_object=ras)

    new_geom_path = ras.geom_df.loc[
        ras.geom_df['geom_number'] == new_geom, 'full_path'
    ].values[0]

    # Modify base overrides
    if INCLUDE_BASE_OVERRIDES:
        modified_base = original_base.copy()
        for idx, row in modified_base.iterrows():
            match = manning_minmax_df[
                manning_minmax_df['Land Cover Name'] == row['Land Cover Name']
            ]
            if not match.empty:
                if use_min:
                    modified_base.loc[idx, 'Base Mannings n Value'] = match['min_n'].values[0]
                elif use_max:
                    modified_base.loc[idx, 'Base Mannings n Value'] = match['max_n'].values[0]
        GeomLandCover.set_base_mannings_n(new_geom_path, modified_base)

    # Modify regional overrides
    if INCLUDE_REGIONAL_OVERRIDES and not original_region.empty:
        modified_region = original_region.copy()
        for idx, row in modified_region.iterrows():
            match = manning_minmax_df[
                manning_minmax_df['Land Cover Name'] == row['Land Cover Name']
            ]
            if not match.empty:
                if use_min:
                    modified_region.loc[idx, 'MainChannel'] = match['min_n'].values[0]
                elif use_max:
                    modified_region.loc[idx, 'MainChannel'] = match['max_n'].values[0]
        GeomLandCover.set_region_mannings_n(new_geom_path, modified_region)

    GeomMesh.set_geometry_association(
        new_geom,
        landcover_hdf_path=landcover_hdf,
        ras_object=ras,
        validate=True,
    )

    print(f"  Created: {name} (plan={new_plan}, geom={new_geom})")
    return {'name': name, 'plan_number': new_plan, 'geom_number': new_geom, 'shortid': shortid}


print("Creating sensitivity scenarios...")
min_scenario = create_modified_scenario("Minimum", "Min_n", manning_minmax_df, use_min=True)
max_scenario = create_modified_scenario("Maximum", "Max_n", manning_minmax_df, use_max=True)

scenarios = [
    {'name': 'Current', 'plan_number': TEMPLATE_PLAN, 'geom_number': template_geom, 'shortid': 'Current'},
    min_scenario,
    max_scenario,
]
print(f"\n{len(scenarios)} scenarios ready.")
Text Only
Creating sensitivity scenarios...


  Created: Minimum (plan=02, geom=03)


  Created: Maximum (plan=05, geom=05)

3 scenarios ready.

Execute Min/Max Plans

Python
plans_to_run = [min_scenario['plan_number'], max_scenario['plan_number']]
print(f"Executing plans: {plans_to_run}")

results = RasCmdr.compute_parallel(
    plan_number=plans_to_run,
    ras_object=ras,
    max_workers=2,
    num_cores=NUM_CORES,
    clear_geompre=True,
    force_rerun=True,
    verify=True,
)

for plan, res in results.items():
    print(f"  Plan {plan}: {res}")

failed_plans = [plan for plan, res in results.items() if not res]
if failed_plans:
    raise RuntimeError(f"Scenario plan(s) failed: {failed_plans}")
Text Only
Executing plans: ['02', '05']


  Plan 05: True
  Plan 02: True

Verify Preprocessed Manning's n

The geometry HDF land-cover association must be present before preprocessing. This check reads the preprocessed per-cell Manning's n values that HEC-RAS wrote into each scenario geometry HDF.

Python
def geometry_hdf_path(geom_number):
    geom_file = Path(ras.geom_df.loc[
        ras.geom_df['geom_number'] == geom_number, 'full_path'
    ].values[0])
    geom_hdf = Path(str(geom_file) + ".hdf")
    if not geom_hdf.exists():
        raise FileNotFoundError(f"Geometry HDF not found for geometry {geom_number}: {geom_hdf.name}")
    return geom_hdf

preprocessed_mannings = {}
for scenario in scenarios:
    geom_hdf = geometry_hdf_path(scenario['geom_number'])
    n_df = HdfLandCover.get_preprocessed_mannings_n(geom_hdf)
    if n_df.empty:
        raise RuntimeError(f"No preprocessed Manning's n values found for {scenario['name']}")
    preprocessed_mannings[scenario['name']] = n_df
    rounded_unique = n_df['mannings_n'].round(6).nunique()
    print(
        f"{scenario['name']}: {len(n_df)} cells, "
        f"{rounded_unique} rounded values, "
        f"n range {n_df['mannings_n'].min():.3f} to {n_df['mannings_n'].max():.3f}"
    )

mannings_compare = (
    preprocessed_mannings['Minimum'][['mesh_name', 'cell_id', 'mannings_n']]
    .rename(columns={'mannings_n': 'minimum_n'})
    .merge(
        preprocessed_mannings['Maximum'][['mesh_name', 'cell_id', 'mannings_n']]
        .rename(columns={'mannings_n': 'maximum_n'}),
        on=['mesh_name', 'cell_id'],
        how='inner',
    )
)
mannings_compare['abs_diff'] = (mannings_compare['maximum_n'] - mannings_compare['minimum_n']).abs()
changed_cells = int((mannings_compare['abs_diff'] > 1e-6).sum())
total_cells = len(mannings_compare)
max_n_diff = float(mannings_compare['abs_diff'].max())

print(f"\nChanged cells, min vs max: {changed_cells} / {total_cells}")
print(f"Max absolute Manning's n difference: {max_n_diff:.3f}")
assert changed_cells > 0, "Min/max Manning's n scenarios did not change the preprocessed n field"
Text Only
Current: 5765 cells, 10 rounded values, n range 0.036 to 100.000
Minimum: 5765 cells, 6 rounded values, n range 0.020 to 100.000
Maximum: 5765 cells, 6 rounded values, n range 0.060 to 100.000

Changed cells, min vs max: 5417 / 5765
Max absolute Manning's n difference: 0.120

Extract Results at Point of Interest

Python
poi = Point(POINT_OF_INTEREST[0], POINT_OF_INTEREST[1])

# Find nearest mesh cell
mesh_cells = HdfMesh.get_mesh_cell_points(TEMPLATE_PLAN, ras_object=ras)
distances = mesh_cells.geometry.apply(lambda g: g.distance(poi))
nearest_idx = distances.idxmin()
nearest_cell = mesh_cells.loc[nearest_idx]

cell_id = nearest_cell['cell_id']
mesh_name = nearest_cell['mesh_name']

print(f"Point of Interest: {POINT_OF_INTEREST}")
print(f"Nearest cell: id={cell_id}, distance={distances[nearest_idx]:.1f} ft")
print(f"Mesh: {mesh_name}")
Text Only
Point of Interest: (408350.0, 1802550.0)
Nearest cell: id=2998, distance=0.0 ft
Mesh: 2D Interior Area
Python
# Extract water surface time series for each scenario
all_results = {}

for scenario in scenarios:
    plan_num = scenario['plan_number']
    name = scenario['name']

    try:
        results_xr = HdfResultsMesh.get_mesh_cells_timeseries(plan_num, ras_object=ras)
        ws_data = results_xr[mesh_name]['Water Surface'].sel(cell_id=int(cell_id))

        ws_df = pd.DataFrame({
            'time': ws_data.time.values,
            'water_surface': ws_data.values
        })

        max_ws = ws_df['water_surface'].max()
        all_results[name] = {'df': ws_df, 'max_ws': max_ws, 'plan': plan_num}
        print(f"  {name}: Max WSE = {max_ws:.2f} ft")
    except Exception as e:
        print(f"  {name}: ERROR - {e}")

print(f"\nSuccessfully extracted {len(all_results)}/{len(scenarios)} scenarios.")
Text Only
  Current: Max WSE = 946.00 ft


  Minimum: Max WSE = 945.85 ft


  Maximum: Max WSE = 946.04 ft

Successfully extracted 3/3 scenarios.

Compare Full-Mesh Hydraulic Response

The point-of-interest time series is useful for inspection, but hydraulic relevance is checked over the full 2D mesh for maximum WSE, maximum depth, and maximum face velocity.

Python
def read_max_depth_with_fallback(plan_num):
    depth_logger = logging.getLogger("ras_commander.hdf.HdfResultsMesh")
    previous_level = depth_logger.level
    depth_logger.setLevel(logging.ERROR)
    try:
        depth_df = HdfResultsMesh.get_mesh_max_depth(plan_num, ras_object=ras)
    except Exception:
        depth_df = pd.DataFrame()
    finally:
        depth_logger.setLevel(previous_level)

    if depth_df is not None and not depth_df.empty:
        return depth_df

    max_ws = HdfResultsMesh.get_mesh_max_ws(plan_num, ras_object=ras)
    depth_frames = []
    for mesh in sorted(max_ws['mesh_name'].dropna().unique()):
        topology = HdfMesh.get_mesh_sloped_topology(plan_num, mesh_name=mesh, ras_object=ras)
        if not topology or 'cell_min_elev' not in topology:
            raise RuntimeError(f"Cells Minimum Elevation not available for plan {plan_num}, mesh {mesh}")
        subset = max_ws[max_ws['mesh_name'] == mesh].copy()
        cell_ids = subset['cell_id'].astype(int).to_numpy()
        min_elev = np.asarray(topology['cell_min_elev'], dtype=float)
        subset['maximum_depth'] = np.maximum(
            subset['maximum_water_surface'].to_numpy(dtype=float) - min_elev[cell_ids],
            0.0,
        )
        depth_frames.append(subset[['mesh_name', 'cell_id', 'maximum_depth', 'geometry']])

    print(f"  Plan {plan_num}: native depth dataset unavailable; used WSE-minus-cell-min-elevation fallback")
    return pd.concat(depth_frames, ignore_index=True)


def compare_scenario_metric(metric_name, reader, value_column, key_columns):
    frames = []
    for scenario in scenarios:
        df = reader(scenario['plan_number'])
        if df is None or df.empty:
            raise RuntimeError(f"{metric_name} returned no rows for {scenario['name']}")
        columns = list(key_columns) + [value_column]
        if 'geometry' in df.columns:
            columns.append('geometry')
        frame = df[columns].copy().rename(
            columns={
                value_column: scenario['name'],
                'geometry': f"{scenario['name']}_geometry",
            }
        )
        frames.append(frame)

    merged = frames[0]
    for frame in frames[1:]:
        merged = merged.merge(frame, on=list(key_columns), how='inner')
    if merged.empty:
        raise RuntimeError(f"{metric_name} scenarios had no common rows")

    merged['max_minus_min'] = merged['Maximum'] - merged['Minimum']
    merged['abs_diff'] = merged['max_minus_min'].abs()
    idx = merged['abs_diff'].idxmax()
    row = merged.loc[idx]

    geometry = None
    for column in ('Maximum_geometry', 'Current_geometry', 'Minimum_geometry'):
        if column in merged.columns and row[column] is not None:
            geometry = row[column]
            break
    if geometry is not None and hasattr(geometry, 'centroid'):
        point = geometry.centroid
        x, y = float(point.x), float(point.y)
    else:
        x, y = np.nan, np.nan

    summary = {
        'metric': metric_name,
        'rows': int(len(merged)),
        'max_abs_diff': float(row['abs_diff']),
        'signed_diff_at_max_abs': float(row['max_minus_min']),
        'minimum': float(row['Minimum']),
        'current': float(row['Current']),
        'maximum': float(row['Maximum']),
        'x': x,
        'y': y,
    }
    for key in key_columns:
        summary[key] = row[key]

    location = "" if np.isnan(x) else f" at ({x:.1f}, {y:.1f})"
    print(
        f"{metric_name}: max abs diff = {summary['max_abs_diff']:.3f} "
        f"over {summary['rows']} rows{location}"
    )
    print(
        f"  min/current/max = "
        f"{summary['minimum']:.3f} / {summary['current']:.3f} / {summary['maximum']:.3f}"
    )
    assert summary['max_abs_diff'] > 0, f"{metric_name} did not change between min and max scenarios"
    return summary, merged

hydraulic_comparisons = {}
hydraulic_tables = {}

hydraulic_comparisons['maximum_water_surface'], hydraulic_tables['maximum_water_surface'] = compare_scenario_metric(
    'maximum_water_surface',
    lambda plan_num: HdfResultsMesh.get_mesh_max_ws(plan_num, ras_object=ras),
    'maximum_water_surface',
    ('mesh_name', 'cell_id'),
)
hydraulic_comparisons['maximum_depth'], hydraulic_tables['maximum_depth'] = compare_scenario_metric(
    'maximum_depth',
    read_max_depth_with_fallback,
    'maximum_depth',
    ('mesh_name', 'cell_id'),
)
hydraulic_comparisons['maximum_face_velocity'], hydraulic_tables['maximum_face_velocity'] = compare_scenario_metric(
    'maximum_face_velocity',
    lambda plan_num: HdfResultsMesh.get_mesh_max_face_v(plan_num, ras_object=ras),
    'maximum_face_velocity',
    ('mesh_name', 'face_id'),
)

hydraulic_summary_df = pd.DataFrame(hydraulic_comparisons.values())
Text Only
maximum_water_surface: max abs diff = 0.739 over 5765 rows at (411550.0, 1802250.0)
  min/current/max = 947.685 / 948.098 / 948.424
  Plan 04: native depth dataset unavailable; used WSE-minus-cell-min-elevation fallback
  Plan 02: native depth dataset unavailable; used WSE-minus-cell-min-elevation fallback


  Plan 05: native depth dataset unavailable; used WSE-minus-cell-min-elevation fallback
maximum_depth: max abs diff = 0.739 over 5765 rows at (411550.0, 1802250.0)
  min/current/max = 4.966 / 5.379 / 5.705


maximum_face_velocity: max abs diff = 16.239 over 11164 rows at (407775.0, 1803250.0)
  min/current/max = -19.436 / -4.652 / -3.197

Sensitivity Analysis Results

Python
if len(all_results) == 3 and not hydraulic_summary_df.empty:
    current_ws = all_results['Current']['max_ws']
    min_ws = all_results['Minimum']['max_ws']
    max_ws = all_results['Maximum']['max_ws']

    sensitivity_range = max_ws - min_ws

    print("=" * 50)
    print("MANNING'S n BULK SENSITIVITY SUMMARY")
    print("=" * 50)
    print(f"Point cell {cell_id} maximum WSE:")
    print(f"  Minimum n scenario WSE: {min_ws:.2f} ft")
    print(f"  Current n scenario WSE: {current_ws:.2f} ft")
    print(f"  Maximum n scenario WSE: {max_ws:.2f} ft")
    print(f"")
    print(f"  Point sensitivity range: {sensitivity_range:.2f} ft")
    print(f"  Current vs Min: +{current_ws - min_ws:.2f} ft")
    print(f"  Max vs Current: +{max_ws - current_ws:.2f} ft")

    if sensitivity_range > 0:
        position = (current_ws - min_ws) / sensitivity_range * 100
        print(f"  Current position in range: {position:.0f}%")

    print("\nAll-mesh hydraulic response (max abs Max-Min):")
    display_cols = ['metric', 'rows', 'max_abs_diff', 'minimum', 'current', 'maximum']
    print(hydraulic_summary_df[display_cols].round(3).to_string(index=False))
    print("=" * 50)
else:
    print(f"Only {len(all_results)} scenarios extracted. Check execution results above.")
Text Only
==================================================
MANNING'S n BULK SENSITIVITY SUMMARY
==================================================
Point cell 2998 maximum WSE:
  Minimum n scenario WSE: 945.85 ft
  Current n scenario WSE: 946.00 ft
  Maximum n scenario WSE: 946.04 ft

  Point sensitivity range: 0.19 ft
  Current vs Min: +0.15 ft
  Max vs Current: +0.04 ft
  Current position in range: 79%

All-mesh hydraulic response (max abs Max-Min):
               metric  rows  max_abs_diff  minimum  current  maximum
maximum_water_surface  5765         0.739  947.685  948.098  948.424
        maximum_depth  5765         0.739    4.966    5.379    5.705
maximum_face_velocity 11164        16.239  -19.436   -4.652   -3.197
==================================================

Visualize Results

Python
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Time series plot
ax1 = axes[0]
colors = {'Current': 'black', 'Minimum': 'blue', 'Maximum': 'red'}
styles = {'Current': '-', 'Minimum': '--', 'Maximum': '--'}

for name, result in all_results.items():
    df = result['df']
    ax1.plot(df['time'], df['water_surface'],
             label=f"{name} (max={result['max_ws']:.2f} ft)",
             color=colors[name], linestyle=styles[name], linewidth=1.5)

ax1.set_xlabel('Time')
ax1.set_ylabel('Water Surface Elevation (ft)')
ax1.set_title(f"WSE Sensitivity to Manning's n at Cell {cell_id}")
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.tick_params(axis='x', rotation=45)

# Bar chart
ax2 = axes[1]
names = list(all_results.keys())
values = [all_results[n]['max_ws'] for n in names]
bar_colors = [colors[n] for n in names]

bars = ax2.bar(names, values, color=bar_colors, alpha=0.7, edgecolor='black')
for bar, val in zip(bars, values):
    ax2.text(bar.get_x() + bar.get_width()/2, val + 0.02,
             f'{val:.2f}', ha='center', va='bottom', fontsize=10)

ax2.set_ylabel('Maximum WSE (ft)')
ax2.set_title("Peak WSE by Manning's n Scenario")
ax2.grid(axis='y', alpha=0.3)

# Set y-axis to show differences clearly
all_vals = values
y_margin = max(0.5, (max(all_vals) - min(all_vals)) * 0.3)
ax2.set_ylim(min(all_vals) - y_margin, max(all_vals) + y_margin)

plt.tight_layout()
plot_path = Path(project_folder) / "mannings_bulk_sensitivity.png"
plt.savefig(plot_path, dpi=150, bbox_inches='tight')
plt.show()
print(f"Plot saved in project folder: {plot_path.name}")

png

Text Only
Plot saved in project folder: mannings_bulk_sensitivity.png

Manning's n Comparison Table

Python
# Show how Manning's values changed across scenarios
comparison = manning_minmax_df[manning_minmax_df['Land Cover Name'] != 'building'].copy()
comparison = comparison.rename(columns={'min_n': 'Min Scenario', 'max_n': 'Max Scenario', 'mid_n': 'Mid'})

# Add current values
current_vals = []
for _, row in comparison.iterrows():
    match = original_base[original_base['Land Cover Name'] == row['Land Cover Name']]
    if not match.empty:
        current_vals.append(match['Base Mannings n Value'].values[0])
    else:
        current_vals.append(np.nan)
comparison['Current'] = current_vals
comparison['Range'] = comparison['Max Scenario'] - comparison['Min Scenario']

print("Manning's n values by scenario:")
print(comparison[['Land Cover Name', 'Min Scenario', 'Current', 'Max Scenario', 'Range']].to_string(index=False))
Text Only
Manning's n values by scenario:
           Land Cover Name  Min Scenario  Current  Max Scenario  Range
medium density residential          0.05     0.08          0.12   0.07
                open space          0.02     0.04          0.06   0.04
                      park          0.03     0.06          0.08   0.05
                     trees          0.08     0.12          0.20   0.12
                     urban          0.06     0.10          0.15   0.09

Interpretation

The bulk sensitivity analysis shows the total envelope of uncertainty in water surface elevation due to Manning's n selection. Key takeaways:

  • Geometry HDF association: Calibration edits enter the solver only after the scenario geometry HDF is associated with the land-cover sidecar HDF before geometry preprocessing.
  • Preprocessed roughness check: The min/max scenarios should change thousands of cells in the preprocessed Manning's n field, confirming that the land-cover calibration tables reached HEC-RAS.
  • Full-mesh response: The Muncie example typically shows roughly 0.74 ft maximum WSE response, roughly 0.74 ft maximum depth response, and roughly 16 ft/s maximum face-velocity response between the minimum and maximum roughness scenarios.
  • Point response: A single point-of-interest time series is useful for communication, but it is not sufficient by itself to prove hydraulic relevance.

For individual land cover sensitivity (one-at-a-time analysis), see notebook 711_mannings_sensitivity_multi_interval.ipynb.