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:
- Extracts a 2D project with Manning's n land cover regions
- Defines minimum/maximum parameter ranges for each roughness class
- Associates the land-cover sidecar HDF with each scenario geometry HDF
- Creates modified geometries with min and max Manning's n values
- Executes HEC-RAS for current, minimum, and maximum scenarios
- Verifies preprocessed Manning's n changes and full-mesh hydraulic response
- 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¶
- Chow (1959) Open-Channel Hydraulics: Table 5-6
- HEC-RAS 2D User Manual: Section 3.7
- USGS TWI 3-A1: Roughness Characteristics of Natural Channels
Setup¶
# 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__}")
PIP PACKAGE MODE: Loading installed ras-commander
Loaded ras-commander 0.98.2
Parameters¶
Configure these values to customize for your project.
# 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.
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()
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¶
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())
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¶
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")
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)¶
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}")
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.
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.")
Creating sensitivity scenarios...
Created: Minimum (plan=02, geom=03)
Created: Maximum (plan=05, geom=05)
3 scenarios ready.
Execute Min/Max Plans¶
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}")
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.
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"
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¶
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}")
Point of Interest: (408350.0, 1802550.0)
Nearest cell: id=2998, distance=0.0 ft
Mesh: 2D Interior Area
# 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.")
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.
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())
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¶
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.")
==================================================
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¶
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}")

Plot saved in project folder: mannings_bulk_sensitivity.png
Manning's n Comparison Table¶
# 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))
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.