Automating HEC-HMS Simulations with the Jython Scripting API

The single call that turns HEC-HMS into an automatable engine is Compute("RunName"), issued from a Jython script the program runs headless. This page is the focused reference for that scripting layer: the hms.model.JythonHms commands, how they assemble into a .script file, how to launch it with the -s switch, and how to wrap the whole thing in a CPython subprocess driver. It sits under the broader HEC-HMS Python Automation workflow — which covers project-file templating and HEC-DSS extraction — inside the Rainfall-Runoff Modeling & Hydrologic Simulation section. If you have not yet decided which parameters to inject before computing, start with the parent guide; this page assumes the project is already parameterized and you just need to run it.

The reason this indirection exists is that HEC-HMS has no importable CPython module. Its scripting interface is written for Jython 2.7 running inside the JVM that ships with the program, so “the API” is a set of module-level functions you call from a text file the program interprets. You never import hms from your own Python 3 interpreter; you generate a script, hand it to the launcher, and read the results back from disk.


The Two-Process Model

Automation spans a CPython process you write and a Jython process HEC-HMS runs. Keeping their responsibilities separate is the whole trick.

CPython Driver and Jython Script Two-Process Model Two panels side by side. The left panel is the CPython 3 driver that writes the run.script file and launches the HEC-HMS executable with the -s switch through subprocess. An arrow crosses to the right panel, the HEC-HMS JVM, where Jython 2.7 interprets the script: OpenProject, Compute, SaveAllProjectComponents, Exit. The JVM writes a .dss results file and a .log file, and a return arrow shows the CPython driver reading the log and DSS back. CPython 3 driver writes run.script subprocess.run([launcher, "-s", "run.script"]) reads .log + .dss back HEC-HMS JVM (Jython 2.7) OpenProject(...) Compute("RunName") SaveAllProjectComponents() Exit(0) writes project.dss + project.log launch -s results on disk

The CPython side owns orchestration: it decides parameters, writes the script, spawns the process, and checks the outcome. The Jython side owns exactly one job — open, compute, save, exit. Because the two never share memory, everything crosses through the filesystem: the script file going in, the .dss and .log files coming out.

This filesystem boundary is what makes the pattern robust. There is no long-lived server to keep warm, no socket to reconnect, and no shared interpreter state to corrupt between runs. Each Compute is a fresh JVM that lives only long enough to execute the script and terminate. The cost is latency — spinning up the JVM adds a few seconds per run — but for batch and calibration workloads that overhead is negligible against the compute itself, and the isolation means a crash in one run cannot poison the next. It also means the driver can parallelize trivially: launch several headless processes at once, each pointed at its own copy of the project so their .dss and .log files never collide.


The JythonHms Command Set

Every script begins by importing the scripting module, which exposes the commands as bare functions:

python
from hms.model.JythonHms import *

From there a minimal compute is four calls. The script below opens a project, computes one run, saves, and exits. Note it is Jython 2.7 — Python 2 syntax, print as a statement, no f-strings.

python
# run.script  --  interpreted by HEC-HMS on Jython 2.7, NOT CPython 3
from hms.model.JythonHms import *

# OpenProject takes the project NAME and the ABSOLUTE directory that holds the .hms file
OpenProject("MillCreek", "/data/projects/mill_creek")
print "Project opened"                      # Jython 2 print statement

# Compute runs the named run definition from the .run file and writes results to DSS
Compute("Design Storm 100yr")
print "Compute finished"

# Persist any state changes, then release the JVM so the process can terminate
SaveAllProjectComponents()
Exit(0)                                       # without Exit(0) the JVM stays resident

The results of Compute are not returned to the script. They are written to the project’s HEC-DSS file, which the CPython driver reads afterward with pydsstools — the extraction step is covered in the parent HEC-HMS automation guide.

Parameter Reference: Key Jython Commands

Command Signature Effect
OpenProject OpenProject(name, directory) Loads the .hms project; directory must be an absolute path to the folder, not the file
Compute Compute(runName) Executes the named run definition (basin + met + control) and writes element results to DSS
ComputeRun ComputeRun(runName) Alias used in some builds; identical compute behavior to Compute
SaveAllProjectComponents SaveAllProjectComponents() Flushes in-memory component edits back to the ASCII project files
OpenDss / CloseDss OpenDss(path) Opens a DSS file for direct record access from within the script (rarely needed when reading later from CPython)
Exit Exit(code) Terminates the JVM with an exit code; omitting it leaves the process alive and hangs your subprocess call

For non-trivial edits many teams still prefer to parameterize the ASCII files from CPython (as the parent guide shows) and keep the Jython script to just OpenProject / Compute / Exit, because that keeps all logic in Python 3 where the rest of the toolchain lives.

What Compute actually does

Compute("RunName") is a blocking call. It resolves the named run definition to its basin, meteorologic, and control components, executes the loss → transform → baseflow → route sequence for every element, and writes each element’s simulated time series into the project HEC-DSS file before returning. Nothing is streamed back to the script; the return of Compute only signals that the engine finished, not that the hydrology is sensible. That distinction is why the wrapper below reads the log rather than trusting the call to have raised on a bad model. If a gage referenced by the meteorologic model is missing, or a subbasin has an undefined method, the engine typically logs the problem and continues with a degraded result rather than aborting, so the script sees a clean return even though the DSS holds a zero or truncated hydrograph.

SaveAllProjectComponents matters when the script itself edited components in memory — for example if you set parameters through Jython rather than by templating the ASCII files. If your driver already wrote the parameters into the .basin before launching, the save is a cheap no-op, but leaving it in keeps the script correct under either strategy.


Launching Headless with the -s Switch

The launcher in the HEC-HMS install directory accepts a -s (script) argument. On Linux it is HEC-HMS.sh; on Windows it is HEC-HMS.cmd:

bash
# Linux / macOS
/opt/HEC-HMS-4.11/HEC-HMS.sh -s /data/projects/mill_creek/run.script

# Windows
"C:\Program Files\HEC\HEC-HMS\4.11\HEC-HMS.cmd" -s "C:\projects\mill_creek\run.script"

Given a script that calls Exit(0), this runs the entire compute without opening the GUI and returns control to the shell when the JVM exits. That makes it drivable from any CPython process, from a CI runner, or from a cron job on a headless server. The launcher is a thin shell/batch wrapper that sets the classpath to the bundled jars and starts the JVM; the -s argument tells it to run in script mode instead of showing the interface. There is no separate “server” or “console” binary to install — the same executable serves both the GUI and headless roles, selected entirely by whether -s is present.

One practical consequence: the launcher inherits the working directory and environment of whatever spawns it, so the driver should set cwd to the HEC-HMS home and pass any JVM flags (such as the headless AWT flag) through the environment rather than editing the launcher script in place. Editing the launcher couples your automation to one install and breaks on the next upgrade.


The CPython Subprocess Wrapper

The driver below is Python 3. It writes the Jython script (injecting absolute POSIX paths), launches the headless compute, waits, and then confirms success by scanning the .log file HEC-HMS writes next to the project — because a zero exit code alone does not prove the hydrology computed.

python
import logging
import subprocess
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def compute_run(hms_home: str, project_dir: str, project_name: str, run_name: str) -> None:
    """
    Generate a Jython script, launch HEC-HMS headless, and verify the run via its log.

    Args:
        hms_home:     HEC-HMS install directory (contains HEC-HMS.sh / HEC-HMS.cmd).
        project_dir:  Directory holding <project_name>.hms and the component files.
        project_name: Base name of the .hms project (no extension).
        run_name:     Name of the run definition in the .run file to compute.
    """
    proj_dir = Path(project_dir).resolve()
    # Emit forward slashes: backslashes in a double-quoted Jython string are escapes.
    posix_dir = proj_dir.as_posix()

    script = (
        "from hms.model.JythonHms import *\n"
        'OpenProject("%s", "%s")\n'
        'Compute("%s")\n'
        "SaveAllProjectComponents()\n"
        "Exit(0)\n"
    ) % (project_name, posix_dir, run_name)

    script_path = proj_dir / "run.script"
    script_path.write_text(script)
    logging.info("Wrote Jython script: %s", script_path)

    # Select launcher by platform; force headless AWT for GUI-less servers.
    is_windows = Path(hms_home).joinpath("HEC-HMS.cmd").exists()
    launcher = Path(hms_home) / ("HEC-HMS.cmd" if is_windows else "HEC-HMS.sh")
    env_flag = {"JAVA_TOOL_OPTIONS": "-Djava.awt.headless=true"}

    cmd = [str(launcher), "-s", str(script_path)]
    logging.info("Launching headless compute: %s", " ".join(cmd))
    import os
    proc_env = {**os.environ, **env_flag}
    result = subprocess.run(
        cmd, cwd=hms_home, env=proc_env,
        capture_output=True, text=True, timeout=1800,
    )
    if result.returncode != 0:
        logging.error("Launcher exited %s: %s", result.returncode, result.stderr[-400:])
        raise RuntimeError("HEC-HMS launcher failed; inspect stderr and the .log file.")

    # A zero exit code does not prove the compute succeeded — scan the project log.
    log_path = proj_dir / (project_name + ".log")
    if log_path.exists():
        errors = [ln for ln in log_path.read_text(errors="ignore").splitlines()
                  if "ERROR" in ln.upper()]
        if errors:
            logging.error("Compute logged %d error line(s): %s", len(errors), errors[-1])
            raise RuntimeError("HEC-HMS reported an error during compute; see %s" % log_path)
    logging.info("Compute '%s' completed and log is clean.", run_name)


if __name__ == "__main__":
    compute_run(
        hms_home="/opt/HEC-HMS-4.11",
        project_dir="/data/projects/mill_creek",
        project_name="MillCreek",
        run_name="Design Storm 100yr",
    )

Gotchas & Edge Cases

  • Jython 2.7, not Python 3. The script runs on the JVM’s Jython 2.7 interpreter. print("x") works by accident but print "x" is the idiomatic form, f-strings and pathlib are unavailable, and integer division follows Python 2 rules. Keep the script tiny and do all real logic in the CPython driver, where Python 3 and your normal libraries live.

  • Classpath and the bundled JRE. The scripting commands resolve only inside the JVM the launcher starts, which sets its own classpath to the HEC-HMS jars. Do not try to run run.script with a system jython binary — it will not find hms.model.JythonHms. Always go through HEC-HMS.sh/HEC-HMS.cmd -s.

  • Absolute paths only. OpenProject fails or silently opens nothing when given a relative path or the .hms file instead of its directory. Resolve paths with Path(...).resolve().as_posix() in the driver before writing them into the script, which also avoids Windows backslash-escape corruption.

  • License and GUI-less startup. The first launch on a machine may require an interactive registration step; complete it once in the GUI before automating. On a headless server, set -Djava.awt.headless=true (as the wrapper does) and, if the Java runtime still demands a display, run the launcher under xvfb-run.

  • Forgetting Exit(0). Without a terminal Exit, the JVM finishes the compute but keeps the process alive, so your subprocess.run blocks until the timeout fires and then reports a failure even though the DSS was written. Always end the script with Exit(0).


Frequently Asked Questions

Is the HEC-HMS scripting API Python 3?

No. The HEC-HMS script runs on Jython 2.7, an implementation of Python 2 on the JVM. Use print statements and Python 2 syntax inside the .script file, and keep any Python 3 orchestration in a separate CPython process that launches the script through subprocess. Mixing the two in one file does not work because your CPython interpreter cannot import hms.model.JythonHms.

How do I run HEC-HMS without opening the GUI?

Launch the executable with the -s switch and a script path: HEC-HMS.sh -s run.script on Linux or HEC-HMS.cmd -s run.script on Windows. The script must call Exit(0) at the end or the JVM stays resident and your subprocess call hangs. On a headless server also set java.awt.headless=true so the Java runtime does not try to initialize a display.

Why does OpenProject fail with a path error?

OpenProject needs an absolute path to the directory containing the .hms file, not a relative path and not the file itself. On Windows, backslashes in a double-quoted Jython string are read as escape sequences, so emit forward slashes with as_posix() when generating the script from CPython. Log the exact strings you write into the script so a bad path is immediately visible.