Reading HEC-DSS Time Series into pandas

DSS is where HEC-HMS puts its results, and it is the only channel between the model and the analysis. Getting a series out of it correctly means understanding three conventions that have no analogue in a CSV: the six-part pathname, the end-of-period timestamp, and the distinction between regular and irregular series. This guide covers all three, as part of the HEC-HMS Python automation topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

  • A DSS reader for Python — hecdss or pydsstools, both wrapping the same underlying library.
  • The pathname parts your model writes, which are visible in the HMS results tree.
  • pandas.

Core Technique: The Pathname Is the Query

Anatomy of a DSS Pathname The pathname slash BASIN slash SUBBASIN-3 slash FLOW slash 01JAN2024 slash 15MIN slash RUN:100YR slash broken into parts A to F, with A the project, B the element, C the parameter, D the block start assigned by DSS, E the interval and F the run label. /BASIN/SUBBASIN-3/FLOW/01JAN2024/15MIN/RUN:100YR/ A — project or basin B — element name, exact and case-sensitive C — parameter: FLOW, PRECIP-INC, STAGE D — block start, assigned by DSS E — interval: 15MIN, 1HOUR, 1DAY F — run or scenario label Leave part D empty when querying — a specific D matches one storage block only

Part D is the trap. DSS stores long series in blocks and stamps each block with its start date, so a pathname copied from the results tree carries the D of whichever block was displayed. Querying with it returns that block and nothing else, which looks like a truncated simulation.

Annotated Code Example

python
import logging
import re

import numpy as np
import pandas as pd

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

# The DSS missing-value sentinel. Reading it as a number rather than as a gap
# is how a hydrograph acquires a -3.4e38 spike that ruins every statistic.
DSS_MISSING = -3.4028234663852886e38


def read_dss_series(
    dss_path: str,
    pathname: str,
    stamp: str = "end",
    tz: str | None = None,
) -> pd.Series:
    """
    Read one regular-interval DSS series into a pandas Series.

    Parameters
    ----------
    dss_path : Path to the .dss file.
    pathname : Six-part pathname. Part D should be EMPTY to span all blocks.
    stamp    : "end" if the series is stamped at interval ends (the DSS
               convention for regular series) — the index is shifted back to
               interval starts so it aligns with observed data read from CSV.
    tz       : Optional timezone to localise to.
    """
    from hecdss import HecDss

    parts = pathname.strip("/").split("/")
    if len(parts) != 6:
        raise ValueError(f"expected six pathname parts, got {len(parts)}")
    if parts[3]:
        log.warning("Pathname carries a D part (%r) — this matches ONE storage "
                    "block only. Blanking it to read the whole series", parts[3])
        parts[3] = ""
        pathname = "/" + "/".join(parts) + "/"

    with HecDss(dss_path) as dss:
        record = dss.get(pathname)
        if record is None:
            catalogue = [p for p in dss.get_catalog()
                         if parts[1] in p and parts[2] in p]
            log.error("No record at %s. %d similar pathname(s) exist: %s",
                      pathname, len(catalogue), catalogue[:3])
            raise KeyError(f"no DSS record at {pathname}")

        values = np.asarray(record.values, dtype="float64")
        times = pd.to_datetime(record.times)
        units = getattr(record, "units", "")

    # --- Sentinel to NaN before anything else. Every later statistic is
    # meaningless if a -3.4e38 survives into the array. ---
    n_missing = int((values <= DSS_MISSING / 2).sum())
    values = np.where(values <= DSS_MISSING / 2, np.nan, values)
    if n_missing:
        log.warning("%d missing-value sentinel(s) converted to NaN", n_missing)

    series = pd.Series(values, index=times, name=parts[2])

    # --- Shift end-stamps back to interval starts. Without this the series
    # sits one step later than an observed record read from CSV, and the
    # comparison shows a systematic timing error that is purely conventional. ---
    if stamp == "end" and len(series) > 1:
        step = series.index[1] - series.index[0]
        series.index = series.index - step
        log.info("Shifted the index back by one %s interval to interval starts",
                 step)

    if tz:
        series.index = series.index.tz_localize(tz)

    log.info("%s: %d values, %s to %s, units %r",
             parts[1], len(series), series.index.min(), series.index.max(), units)
    finite = series.dropna()
    if finite.empty:
        raise ValueError(f"{pathname} contains no finite values — the compute "
                         "almost certainly failed despite a clean exit")
    log.info("  min %.3f, max %.3f, mean %.3f", finite.min(), finite.max(),
             finite.mean())
    return series


def read_run_results(
    dss_path: str,
    elements: list[str],
    run_label: str,
    basin: str,
    parameter: str = "FLOW",
    interval: str = "15MIN",
) -> pd.DataFrame:
    """
    Read one parameter for many elements of a single run into one frame.

    Asserts that every requested element produced records, which is the only
    reliable success test for a HEC-HMS compute.
    """
    frames, missing = {}, []
    for element in elements:
        pathname = f"/{basin}/{element}/{parameter}//{interval}/{run_label}/"
        try:
            frames[element] = read_dss_series(dss_path, pathname)
        except (KeyError, ValueError) as exc:
            log.error("%s: %s", element, exc)
            missing.append(element)

    if missing:
        raise RuntimeError(
            f"{len(missing)} of {len(elements)} element(s) produced no results: "
            f"{missing[:5]} — the run did not complete, whatever its exit code")

    df = pd.DataFrame(frames)
    log.info("Assembled %d element(s) × %d timesteps", df.shape[1], df.shape[0])

    # A run whose elements have different lengths means the compute stopped
    # partway; the frame will be full of NaN in the tail rather than short.
    tail_nan = df.tail(1).isna().sum().sum()
    if tail_nan:
        log.error("%d element(s) have no value at the final timestep — the "
                  "compute terminated early", int(tail_nan))
    return df


def catalogue(dss_path: str, contains: str = "") -> list[str]:
    """List the pathnames in a DSS file — the first thing to run when a read fails."""
    from hecdss import HecDss
    with HecDss(dss_path) as dss:
        paths = [p for p in dss.get_catalog() if contains in p]
    log.info("%d pathname(s) in %s matching %r", len(paths), dss_path, contains)
    for p in paths[:10]:
        log.info("  %s", p)
    return paths


# --- Example usage ---
# catalogue("run.dss", contains="FLOW")
# df = read_run_results("run.dss", ["SUBBASIN-1", "SUBBASIN-2", "OUTLET"],
#                       run_label="RUN:100YR", basin="BASIN")

Parameter Reference

Pathname part Example Query behaviour
A BASIN Must match the project name in the model
B SUBBASIN-3 Exact, case-sensitive element name
C FLOW Also PRECIP-INC, PRECIP-CUM, STAGE, STORAGE
D (blank) Blank spans all blocks; a value matches one
E 15MIN Must match the control specification’s interval
F RUN:100YR The run name, prefixed RUN: for computed results
What the End-of-Period Convention Costs An observed hydrograph and two readings of the same simulated series. Read as interval starts the simulation aligns with the observation. Read without the shift it sits one 15-minute interval later, which appears in calibration as a systematic timing error. observed simulated, shifted to interval starts — aligned simulated, unshifted — one interval late 0 h 3 h 6 h time since the start of the storm A 15-minute shift costs about 0.06 of KGE on a flashy basin — enough to misdirect a calibration.

Worked Example: Diagnosing an Empty Read

A wrapper reported a successful run and the read returned nothing:

text
WARNING Pathname carries a D part ('01JAN2024') — blanking it
ERROR   No record at /BASIN/SUBBASIN-3/FLOW//15MIN/RUN:100YR/. 2 similar
        pathname(s) exist: ['/BASIN/SUBBASIN 3/FLOW//15MIN/RUN:100YR/',
        '/BASIN/SUBBASIN-3/FLOW//1HOUR/RUN:100YR/']

The catalogue listing is the whole diagnosis. The element is named SUBBASIN 3 with a space, not a hyphen, and a second record exists at an hourly interval from a previous control specification. Neither would have been visible from an exit code, and both are one-line fixes once the catalogue is printed. Printing the near-misses on a failed lookup, as the code above does, is worth more than any amount of careful pathname construction.

The assertions that make a run trustworthy are cheap and specific. Each one fails on a different kind of broken compute, and together they replace the exit code that proves nothing.

Four Assertions That Replace the Exit Code Record exists catches a wrong pathname or a compute that never ran. Non-zero length catches a compute that initialised and produced nothing. Finite values catch a series full of the missing sentinel. A plausible maximum catches a run with zero meteorology applied. record exists catches a wrong pathname, or a run that never started non-zero length catches a compute that initialised and produced nothing finite values catches a series full of the missing-value sentinel plausible maximum catches a run with zero meteorology applied All four pass on a healthy run in under a second, and each of the four failures above exits the HEC-HMS process with status zero. The exit code is not a success test; the records are.

Gotchas and Edge Cases

  • A D part copied from the results tree. Matches one block; the series looks truncated.
  • The missing-value sentinel read as a number. A single −3.4 × 10³⁸ turns every mean, standard deviation and efficiency into nonsense.
  • End-stamps read as start-stamps. A systematic one-interval offset that calibration compensates for by distorting the transform.
  • Midnight as 24:00. DSS writes the end of a day as 24:00 of that day rather than 00:00 of the next; a naive parser can reject it or shift it by a day.
  • Irregular series read as regular. Observed data stored irregularly has no fixed step, and shifting it by “one interval” is undefined.
  • Element names with spaces. Legal in HMS, invisible in most listings, and fatal to a constructed pathname.
  • Exit code treated as success. The process exits cleanly whether or not the compute produced anything. Assert on the records.