HEC-HMS Python Automation
HEC-HMS is the U.S. Army Corps of Engineers standard for event and continuous rainfall-runoff simulation, and it sits squarely inside the Rainfall-Runoff Modeling & Hydrologic Simulation domain — but unlike a pure-Python model it exposes no native CPython API you can import. Automating it therefore means treating the model as two cooperating surfaces: a set of human-readable ASCII project files you can template and parameterize with ordinary text tools, and a headless Compute you trigger by handing the HEC-HMS executable a Jython script. Results never come back as return values; they are written to a HEC-DSS database that you read afterward with pydsstools. This guide covers the whole loop — project structure, .basin templating, headless invocation, DSS extraction into pandas, validation, and the failure modes that consume the most debugging time.
Because the parameters you inject are the same hydrologic quantities produced elsewhere in the pipeline, HEC-HMS automation is rarely a standalone task. Subbasin curve numbers come from curve number runoff estimation, transform lag times come from a unit hydrograph method, and the whole simulation is usually wrapped in an outer loop driven by model calibration objective functions. The lowest-level mechanics of the Jython call itself are covered in the companion guide on automating HEC-HMS simulations with the Jython scripting API.
Prerequisites & Environment Setup
Automating HEC-HMS crosses two runtimes — a CPython process you control and a Java/Jython process HEC-HMS controls — so the environment has two halves.
HEC-HMS install:
- HEC-HMS 4.11 or newer, installed with its bundled OpenJDK. The 4.11+ builds ship a stable Jython 2.7 scripting layer and a command-line
-s(script) switch on bothHEC-HMS.cmd(Windows) andHEC-HMS.sh(Linux). - A known-good, GUI-validated project to use as your template. Never generate a
.hmsproject from scratch by hand; start from one that already computes cleanly in the GUI.
CPython stack:
| Library | Minimum version | Role |
|---|---|---|
python |
3.9 | Orchestration runtime |
pydsstools |
2.1 | Reading and writing HEC-DSS records |
pandas |
1.5 | Time series assembly and validation |
numpy |
1.23 | Volume and peak arithmetic |
jinja2 |
3.1 | Optional: templating the ASCII project files |
conda create -n hec-hms python=3.10 pandas numpy -c conda-forge
conda activate hec-hms
pip install pydsstools jinja2
pydsstools bundles the native HEC-DSS libraries, so it does not need a separate DSS install, but it must match your platform’s C runtime. On Windows use the wheel from PyPI; on Linux confirm libgfortran is present. The Java side is entirely self-contained inside the HEC-HMS install directory — you do not manage a separate JDK.
Algorithm Mechanics: What HMS Computes and How It Stores It
Before templating anything, it helps to know what the compute engine actually does with the numbers you inject, because every parameter you script maps to one stage of the runoff transformation. For each subbasin, HEC-HMS evaluates a fixed sequence of process models, then routes the resulting hydrographs down the basin network.
The compute order is fixed per subbasin: loss → transform → baseflow → route. The loss method (usually SCS curve number) subtracts abstractions from the incoming precipitation to produce excess rainfall. The transform method (an SCS, Snyder, or Clark unit hydrograph) convolves that excess into a direct-runoff hydrograph. Baseflow (recession or constant-monthly) is added, and the combined hydrograph is passed downstream through the routing method assigned to each reach. Every one of these choices, and its numeric parameters, lives as a labeled line in the .basin file — which is exactly why text templating works.
Project file structure
A HEC-HMS project is a directory of ASCII files sharing a base name, coordinated by a top-level .hms file:
| File | Contents | What you template |
|---|---|---|
.hms |
Project index: lists basin, met, control, and run components | Rarely; keep it stable |
.basin |
Subbasins, reaches, junctions, and their method parameters | Curve number, lag, area, routing coefficients |
.met |
Meteorologic model: precipitation depths, gages, hyetographs | Storm depth, gage weights |
.control |
Control specification: start/end time and time step | Simulation window and interval |
.run |
Run definitions binding a basin + met + control triple | Which components a run points at |
.dss |
HEC-DSS binary database of input and output time series | Read only, via pydsstools |
The .basin, .met, and .control files are the three you parameterize most; the .run file is the glue that names a specific combination for the compute engine to execute.
Step-by-Step Workflow
The automation loop has five stages. Each run copies a template, injects parameters, computes headless, extracts DSS, and validates — and only then does the calling process decide whether to accept the result or adjust parameters and repeat.
Step 1 — Template the project
Copy the validated project to a fresh working directory per run so concurrent runs never share a .dss file or a .run state. Placeholders in the template are ordinary tokens (for example ``) that Jinja2 or a regex substitution fills in.
import logging
import shutil
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def stage_project(template_dir: str, work_dir: str) -> Path:
"""Copy a validated HEC-HMS project template into an isolated working directory."""
src = Path(template_dir)
dst = Path(work_dir)
if dst.exists():
logging.info("Clearing stale working directory: %s", dst)
shutil.rmtree(dst)
shutil.copytree(src, dst)
logging.info("Staged project from %s into %s", src, dst)
return dst
Step 2 — Parameterize the .basin file
The .basin file lists each subbasin as an indented block. Rewriting a parameter means finding the labeled line inside the right subbasin block and replacing its value. A targeted line rewrite is safer than a global replace because the same label (Curve Number:) appears once per subbasin.
import logging
import re
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def set_subbasin_parameter(basin_path: Path, subbasin: str, label: str, value) -> None:
"""
Rewrite a single labeled parameter line inside one subbasin block of a .basin file.
HEC-HMS .basin blocks start with 'Subbasin: <name>' and end with 'End:'.
"""
text = basin_path.read_text()
# Match the target subbasin block only, then substitute the labeled line within it.
block_pattern = re.compile(
rf"(Subbasin:\s*{re.escape(subbasin)}\b.*?End:)", re.DOTALL
)
match = block_pattern.search(text)
if not match:
raise KeyError(f"Subbasin '{subbasin}' not found in {basin_path.name}")
block = match.group(1)
line_pattern = re.compile(rf"({re.escape(label)}:\s*)([^\n]*)")
if not line_pattern.search(block):
raise KeyError(f"Label '{label}' not found in subbasin '{subbasin}'")
new_block = line_pattern.sub(rf"\g<1>{value}", block, count=1)
basin_path.write_text(text.replace(block, new_block, 1))
logging.info("Set %s = %s for subbasin %s", label, value, subbasin)
Common .basin parameters worth exposing are Curve Number and Initial Abstraction on the loss method, Lag on an SCS transform, Area on the subbasin, and Muskingum K/Muskingum X on routed reaches.
Step 3 — Run a headless Compute
HEC-HMS runs a Jython .script file when launched with the -s switch. The script opens the project, computes a named run, and exits. From CPython you write that script, then call the platform launcher through subprocess. The mechanics of the Jython calls themselves are detailed in the Jython scripting API guide.
import logging
import subprocess
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def run_headless_compute(hms_home: str, project_hms: Path, run_name: str) -> Path:
"""
Write a Jython .script and launch HEC-HMS headless to compute one run.
Returns the path to the script for auditing.
"""
project_hms = project_hms.resolve() # HEC-HMS needs absolute paths in the script
script_path = project_hms.parent / "run.script"
jython = f'''
from hms.model.JythonHms import *
OpenProject("{project_hms.stem}", "{project_hms.parent.as_posix()}")
Compute("{run_name}")
SaveAllProjectComponents()
Exit(0)
'''
script_path.write_text(jython.strip() + "\n")
logging.info("Wrote compute script: %s", script_path)
launcher = Path(hms_home) / "HEC-HMS.sh" # use HEC-HMS.cmd on Windows
cmd = [str(launcher), "-s", str(script_path)]
logging.info("Launching headless Compute: %s", " ".join(cmd))
result = subprocess.run(cmd, cwd=hms_home, capture_output=True, text=True, timeout=1800)
if result.returncode != 0:
logging.error("HEC-HMS exited %s: %s", result.returncode, result.stderr[-500:])
raise RuntimeError("Headless Compute failed; inspect the project .log file.")
logging.info("Compute finished with return code 0")
return script_path
Step 4 — Extract results from HEC-DSS
The compute engine writes each element’s hydrograph to the project .dss file under a structured pathname /A/B/C/D/E/F/. For a run output the B part is the element name, the C part is the variable (FLOW), the E part is the interval, and the F part carries the run label. pydsstools reads a record by that pathname.
import logging
import pandas as pd
from pydsstools.heclib.dss import HecDss
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def read_dss_flow(dss_path: str, pathname: str) -> pd.Series:
"""Read a regular-interval FLOW record from a HEC-DSS file into a pandas Series."""
logging.info("Reading DSS record %s from %s", pathname, dss_path)
with HecDss.Open(dss_path) as dss:
ts = dss.read_ts(pathname)
series = pd.Series(ts.values, index=pd.to_datetime(ts.pytimes), name="flow_cms")
series = series[~series.index.duplicated(keep="first")].sort_index()
logging.info("Read %d values; peak = %.2f", len(series), float(series.max()))
return series
Step 5 — Validate the hydrograph
Never accept a run on the basis of a zero exit code alone. A compute can succeed while producing a physically implausible hydrograph if a parameter was written into the wrong block. The validation protocol below runs before the result is handed back to a calibration loop.
Production-Ready Code
The function below ties the five stages into one callable that parameterizes a project, computes it headless, reads the outlet hydrograph, and returns it as a validated pandas Series.
import logging
import shutil
import subprocess
from pathlib import Path
import numpy as np
import pandas as pd
from pydsstools.heclib.dss import HecDss
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def run_hms_scenario(
template_dir: str,
work_dir: str,
hms_home: str,
project_name: str,
run_name: str,
subbasin_params: dict,
outlet_pathname: str,
) -> pd.Series:
"""
Parameterize, compute, and read one HEC-HMS scenario.
Args:
template_dir: Directory holding a GUI-validated project template.
work_dir: Isolated directory for this run's copy of the project.
hms_home: HEC-HMS install directory containing HEC-HMS.sh/.cmd.
project_name: Base name of the .hms project file (without extension).
run_name: Name of the run definition in the .run file to compute.
subbasin_params: {subbasin: {"Curve Number": 75, "Lag": 110, ...}, ...}.
outlet_pathname: HEC-DSS pathname of the outlet FLOW record to extract.
Returns:
A pandas Series of simulated flow indexed by timestamp.
"""
# --- Stage an isolated copy so concurrent runs never share DSS or .run state ---
src, dst = Path(template_dir), Path(work_dir)
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
logging.info("Staged project into %s", dst)
basin_path = next(dst.glob("*.basin"))
# --- Parameterize each subbasin block ---
import re
text = basin_path.read_text()
for subbasin, params in subbasin_params.items():
block_re = re.compile(rf"(Subbasin:\s*{re.escape(subbasin)}\b.*?End:)", re.DOTALL)
m = block_re.search(text)
if not m:
raise KeyError(f"Subbasin '{subbasin}' absent from {basin_path.name}")
block = m.group(1)
for label, value in params.items():
line_re = re.compile(rf"({re.escape(label)}:\s*)([^\n]*)")
if not line_re.search(block):
raise KeyError(f"Label '{label}' missing in subbasin '{subbasin}'")
block = line_re.sub(rf"\g<1>{value}", block, count=1)
logging.info("Set %s=%s on %s", label, value, subbasin)
text = text.replace(m.group(1), block, 1)
basin_path.write_text(text)
# --- Write the Jython compute script with absolute POSIX paths ---
project_hms = (dst / f"{project_name}.hms").resolve()
script_path = dst / "run.script"
jython = (
"from hms.model.JythonHms import *\n"
f'OpenProject("{project_name}", "{project_hms.parent.as_posix()}")\n'
f'Compute("{run_name}")\n'
"SaveAllProjectComponents()\n"
"Exit(0)\n"
)
script_path.write_text(jython)
logging.info("Wrote compute script %s", script_path)
# --- Launch headless ---
launcher = Path(hms_home) / "HEC-HMS.sh" # HEC-HMS.cmd on Windows
cmd = [str(launcher), "-s", str(script_path)]
logging.info("Running headless Compute for run '%s'", run_name)
result = subprocess.run(cmd, cwd=hms_home, capture_output=True, text=True, timeout=1800)
if result.returncode != 0:
logging.error("HEC-HMS stderr tail: %s", result.stderr[-500:])
raise RuntimeError(f"Compute failed (exit {result.returncode}); check the .log file.")
# --- Read the outlet hydrograph from DSS ---
dss_path = str(next(dst.glob("*.dss")))
logging.info("Reading outlet record %s", outlet_pathname)
with HecDss.Open(dss_path) as dss:
ts = dss.read_ts(outlet_pathname)
flow = pd.Series(ts.values, index=pd.to_datetime(ts.pytimes), name="flow_cms")
flow = flow[~flow.index.duplicated(keep="first")].sort_index()
# --- Validate before returning ---
if flow.empty or np.all(np.isnan(flow.values)):
raise ValueError("Outlet record empty or all-NaN; verify the DSS pathname and run.")
peak = float(np.nanmax(flow.values))
if peak <= 0:
raise ValueError(f"Non-positive peak flow ({peak}); parameters likely mis-written.")
logging.info("Scenario OK: peak=%.2f cms over %d steps", peak, len(flow))
return flow
if __name__ == "__main__":
hydrograph = run_hms_scenario(
template_dir="templates/mill_creek",
work_dir="runs/mill_creek_cn78",
hms_home="/opt/HEC-HMS-4.11",
project_name="MillCreek",
run_name="Design Storm 100yr",
subbasin_params={"Sub-1": {"Curve Number": 78, "Lag": 110},
"Sub-2": {"Curve Number": 82, "Lag": 95}},
outlet_pathname="//OUTLET/FLOW//1Hour/RUN:DESIGN STORM 100YR/",
)
print(hydrograph.describe())
Validation Protocol
A successful compute and a correct compute are different claims. Confirm all four before feeding results into any downstream decision.
Exit code and log scan. The subprocess return code catches launcher failures, but HEC-HMS also writes a .log next to the project. Grep it for ERROR and WARN lines; a compute can complete with return code 0 while logging a missing gage or an unfilled parameter.
Record presence. Confirm the exact /A/B/C/D/E/F/ pathname you read exists in the output .dss. Enumerate the DSS catalog once and assert your outlet path is in it — a silent typo in the F part (the run label) is the single most common reason a read returns nothing.
Volume balance. Integrate the outlet hydrograph and compare total runoff volume against the excess-rainfall volume implied by your curve numbers and basin areas. A ratio far from unity signals that a Curve Number or Area value landed in the wrong subbasin block.
Peak and timing plausibility. Compare simulated peak magnitude and time-to-peak against expectation or observed data. When you have a gage record, quantify the fit with the objective functions described under model calibration objective functions rather than eyeballing the overlay.
Common Failure Modes & Mitigation
DSS pathname / catalog mismatch. HEC-HMS names output records with the run label uppercased in the F part and the element name in the B part. If you construct the read pathname from a differently-cased run name, read_ts returns an empty record with no error. Mitigate by listing the DSS catalog after the compute and matching your outlet path case-insensitively, then reading the exact catalog entry.
Headless licensing / Java display errors. On a server with no display, the bundled Java runtime may fail to initialize an AWT toolkit even for a headless Compute. Set -Djava.awt.headless=true in the launcher environment, run under xvfb-run if a headless flag is not honored, and confirm the HEC-HMS license or registration step completed once interactively before automating.
Windows vs POSIX path separators. The Jython layer is forgiving of forward slashes on both platforms, but backslashes in a double-quoted Jython string are escape sequences. Always emit project paths with Path(...).as_posix() into the script, and select HEC-HMS.cmd versus HEC-HMS.sh by platform rather than hardcoding one.
Stale .run state. HEC-HMS caches the last-computed state in and around the .run file. Recomputing in place can reuse stale results or refuse to recompute if it believes nothing changed. Mitigate by staging a fresh copy of the whole project per run (as the production function does) so every Compute starts from clean state and writes to its own .dss.
Template drift. A .basin edited by hand in the GUI after you built the template can rename a subbasin or change a method, silently breaking your regex substitution. Keep the template under version control and re-validate it in the GUI whenever HEC-HMS is upgraded.
When to Use HEC-HMS vs. Alternatives
HEC-HMS automation is the right tool when your deliverable is a lumped or semi-distributed rainfall-runoff model of natural or mixed watersheds — design storms, dam safety, floodplain studies, and continuous simulation where regulators expect Corps-standard methods. Its strength is exactly the process library shown in the mechanics diagram: standardized loss, transform, baseflow, and routing methods that reviewers already trust.
It is the wrong tool for detailed urban drainage. When the problem is pipe networks, inlets, surcharging, and pressurized conduits, the physics HEC-HMS abstracts away are the whole point, and you should reach for the dynamic-wave engine covered in SWMM model integration with pyswmm, which offers a genuine step-by-step CPython API rather than a headless file-templating loop. And whichever engine you drive, the automation loop shown here exists to be wrapped: the natural outer layer is an optimizer that adjusts parameters against observed flow using model calibration objective functions. The inputs you inject — composite curve numbers and lag times — are themselves derived upstream in curve number runoff estimation and the unit hydrograph methods section.
Frequently Asked Questions
Does HEC-HMS expose a native Python (CPython) API?
No. HEC-HMS ships a Jython 2.7 scripting interface that runs inside its bundled Java runtime, not a CPython package you can pip install. From CPython you automate it two ways: edit the ASCII project files (.basin, .met, .control, .run) as text, and launch a headless Compute by calling the HEC-HMS executable with a .script file through subprocess. Results come back through the HEC-DSS output file, which you read with pydsstools.
How do I read HEC-HMS results into pandas?
HEC-HMS writes simulated time series to a HEC-DSS database. Use pydsstools to open the output .dss file and read a record by its pathname (the /A/B/C/D/E/F/ parts), then wrap the values and times in a pandas Series. The C part of the pathname identifies the variable, for example FLOW, and the E part is the time interval. The F part carries the run label, so it changes with the run name you computed.
Why does my headless HEC-HMS Compute produce no DSS output?
The most common causes are a stale .run state that points at an old simulation, a DSS pathname mismatch between what the script writes and what you read, and a licensing or display error when the Java runtime cannot start headless. Check the .log file HEC-HMS writes next to the project before parsing DSS, and stage a fresh copy of the project per run so no cached state survives.
Related Topics
- Automating HEC-HMS simulations with the Jython scripting API — the exact Jython commands, the
.scriptfile, and the CPython subprocess wrapper in depth - SWMM model integration with pyswmm — the urban/pipe-network alternative with a true step-by-step CPython API
- Model calibration objective functions — the outer optimization loop that adjusts HEC-HMS parameters against observed flow
- Curve number runoff estimation — where the subbasin curve numbers you inject into the
.basinfile come from - Rainfall-Runoff Modeling & Hydrologic Simulation — the domain overview linking simulation engines, loss methods, and calibration