Deconvolving Observed Hydrographs to Derive a Unit Hydrograph

A synthetic unit hydrograph is a regional shape scaled by catchment properties. A derived one is the catchment’s own measured response, and where a gauge and a few clean events exist it is strictly better evidence. The obstacle is that recovering it is an ill-conditioned inverse problem, and the naive solution produces oscillating ordinates that alternate in sign. This guide covers doing it properly, as part of the unit hydrograph methods topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

Core Technique: Convolution Inverted, With a Constraint

Direct runoff is the convolution of excess rainfall with the unit hydrograph. Written as a matrix, Q = P · U, where P is a banded matrix of excess-rainfall increments and U the unknown ordinates. Solving for U is a least-squares problem, and an unconstrained solution is useless.

Why the Non-Negativity Constraint Is the Method An unconstrained least-squares solution oscillates between positive and negative ordinates with no physical meaning. The non-negative least-squares solution from the same data is a smooth single-peaked unit hydrograph. unconstrained least squares alternating sign, no physical meaning and it fits the data slightly better non-negative least squares single-peaked and physical from exactly the same data Fitting the data better is not the goal; recovering a response the catchment could have is.

Annotated Code Example

python
import logging

import numpy as np
import pandas as pd
from scipy.optimize import nnls

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


def separate_baseflow(q: pd.Series, alpha: float = 0.925, passes: int = 3) -> pd.Series:
    """
    Recursive digital filter baseflow separation (Lyne-Hollick).

    Returns direct runoff. The filter is run forward, backward and forward
    again, which is the usual three-pass convention and reduces the
    dependence on where the series happens to start.
    """
    q_arr = q.to_numpy(dtype="float64")
    direct = q_arr.copy()
    for p in range(passes):
        seq = direct if p % 2 == 0 else direct[::-1]
        out = np.zeros_like(seq)
        prev_direct, prev_q = 0.0, seq[0]
        for i, val in enumerate(seq):
            d = alpha * prev_direct + 0.5 * (1 + alpha) * (val - prev_q)
            out[i] = min(max(d, 0.0), val)   # direct runoff is bounded by total
            prev_direct, prev_q = out[i], val
        direct = out if p % 2 == 0 else out[::-1]

    result = pd.Series(direct, index=q.index, name="direct_runoff")
    frac = float(result.sum() / max(q.sum(), 1e-9))
    log.info("Baseflow separation: %.1f %% of total volume is direct runoff",
             100 * frac)
    if frac > 0.95:
        log.warning("Almost no baseflow separated — check alpha, or the event "
                    "may start on a rising limb")
    return result


def derive_unit_hydrograph(
    excess_mm: pd.Series,
    direct_runoff_cms: pd.Series,
    area_km2: float,
    n_ordinates: int | None = None,
) -> pd.Series:
    """
    Recover unit hydrograph ordinates by non-negative deconvolution.

    Parameters
    ----------
    excess_mm         : Excess rainfall per timestep.
    direct_runoff_cms : Direct runoff (baseflow already removed).
    area_km2          : Catchment area, for the volume check.
    n_ordinates       : Length of the unit hydrograph. Defaults to the
                        difference in length between runoff and excess plus one,
                        which is the maximum the data can support.
    """
    p = excess_mm.to_numpy(dtype="float64")
    q = direct_runoff_cms.to_numpy(dtype="float64")
    p = p[p > 1e-9] if (p > 1e-9).any() else p

    m = len(q)
    n = n_ordinates or (m - len(p) + 1)
    if n < 3:
        raise ValueError("the runoff series is too short relative to the "
                         "rainfall to support a unit hydrograph")

    # --- Build the convolution matrix. Row i, column j holds the excess
    # increment that, delayed by j steps, contributes to runoff at step i. ---
    P = np.zeros((m, n))
    for i in range(m):
        for j in range(n):
            k = i - j
            if 0 <= k < len(p):
                P[i, j] = p[k]

    # --- Non-negative least squares. An ordinary lstsq here returns
    # alternating-sign ordinates that fit marginally better and mean nothing. ---
    u, residual = nnls(P, q)
    log.info("Deconvolved %d ordinate(s) from %d timesteps, residual norm %.3f",
             n, m, float(residual))

    n_zero = int((u < 1e-9).sum())
    if n_zero > n // 2:
        log.warning("%d of %d ordinates are zero — the constraint is doing most "
                    "of the work, which usually means a multi-peaked event",
                    n_zero, n)

    # --- Volume check. One unit (1 mm) of excess over the catchment must equal
    # the area under the unit hydrograph. This is the check that catches a
    # units error, and it is the reason to pass area in. ---
    dt_s = (direct_runoff_cms.index[1] - direct_runoff_cms.index[0]).total_seconds()
    uh_volume_m3 = float(u.sum()) * dt_s
    expected_m3 = area_km2 * 1e6 * 0.001          # 1 mm over the catchment
    ratio = uh_volume_m3 / expected_m3
    log.info("Unit hydrograph volume %.0f m³ vs %.0f m³ for 1 mm (ratio %.3f)",
             uh_volume_m3, expected_m3, ratio)
    if abs(ratio - 1.0) > 0.05:
        log.warning("Rescaling ordinates by %.3f to enforce unit volume", 1 / ratio)
        u = u / ratio

    index = [direct_runoff_cms.index[0] + pd.Timedelta(seconds=dt_s * i)
             for i in range(n)]
    uh = pd.Series(u, index=index, name="uh_cms_per_mm")
    log.info("Peak %.3f m³/s per mm at ordinate %d of %d",
             float(uh.max()), int(np.argmax(u)) + 1, n)
    return uh


def average_unit_hydrographs(uhs: list[pd.Series]) -> pd.Series:
    """
    Average several derived unit hydrographs by aligning their peaks.

    Averaging ordinate by ordinate smears the peak whenever the individual
    peaks fall at different times, producing a result flatter than any input.
    """
    if not uhs:
        raise ValueError("no unit hydrographs supplied")
    peaks = [int(np.argmax(u.to_numpy())) for u in uhs]
    target_peak = int(round(float(np.mean(peaks))))
    length = max(len(u) for u in uhs) + max(peaks) - min(peaks)

    stack = np.full((len(uhs), length), np.nan)
    for i, (u, pk) in enumerate(zip(uhs, peaks)):
        offset = target_peak - pk
        stack[i, offset:offset + len(u)] = u.to_numpy()
    mean = np.nanmean(stack, axis=0)
    mean = np.nan_to_num(mean, nan=0.0)

    # Rescale so the average still integrates to one unit.
    scale = sum(float(u.sum()) for u in uhs) / len(uhs) / max(mean.sum(), 1e-12)
    mean = mean * scale

    log.info("Averaged %d unit hydrographs; individual peaks at ordinates %s, "
             "aligned on %d", len(uhs), peaks, target_peak)
    log.info("Averaged peak %.3f, mean of individual peaks %.3f",
             float(mean.max()), float(np.mean([u.max() for u in uhs])))
    return pd.Series(mean, name="uh_averaged")


# --- Example usage ---
# direct = separate_baseflow(observed_q)
# uh = derive_unit_hydrograph(excess, direct, area_km2=214.0)
# final = average_unit_hydrographs([uh1, uh2, uh3, uh4, uh5])

Parameter Reference

Parameter Typical Effect
Filter alpha 0.90–0.95 Higher separates less baseflow; sensitive on flashy catchments
Filter passes 3 Reduces dependence on the series start
n_ordinates m − len(p) + 1 Longer than the data supports produces oscillation
Events averaged ≥ 5 Fewer, and one event dominates the result
Minimum excess ≈ 10 mm Smaller events have too little signal above the noise
Volume tolerance 5 % Beyond it, rescale and investigate the units

Worked Example: Reading the Event Set

Five events on a 214 km² basin, derived and averaged:

Event Excess (mm) Peak ordinate Time to peak Volume ratio Used
2019-04 18.2 4 3.0 h 1.01 yes
2020-09 24.6 4 3.0 h 0.99 yes
2021-06 11.4 5 3.75 h 1.03 yes
2022-03 31.8 3 2.25 h 1.02 yes
2022-11 8.1 7 5.25 h 1.34 no — volume ratio
2023-05 22.4 4 3.0 h 1.00 yes

The rejected event is instructive. A volume ratio of 1.34 means the recovered unit hydrograph holds a third more water than 1 mm over the catchment, which happens when the excess-rainfall estimate is too low — either the loss method over-estimated infiltration or the basin-average rainfall missed a cell. Either way the event carries a forcing error, and averaging it in would propagate that into the final shape.

The four retained events peak within one ordinate of each other, which is the consistency that makes a derived unit hydrograph worth using at all.

Five Events, One Averaged Response Four individual unit hydrographs peak within one ordinate of each other and the peak-aligned average follows them closely. The rejected event peaks three ordinates later and much lower, and is shown dashed and excluded. four retained events and their average rejected — volume ratio 1.34 0 6 h 12 h time from the start of excess rainfall m³/s per mm

The baseflow filter parameter changes how much volume is attributed to direct runoff, and every derived ordinate scales with that volume. It deserves a sensitivity check rather than a default.

The Baseflow Filter Scales Every Ordinate Sweeping the filter parameter alpha from 0.90 to 0.98 changes the direct-runoff volume and therefore the derived unit hydrograph peak, from 0.41 cubic metres per second per millimetre at 0.90 to 0.29 at 0.98, a 30 percent range from one parameter. the usual range 0.41 0.29 0.90 0.92 0.925 0.95 0.98 baseflow filter parameter α UH peak, m³/s per mm

Gotchas and Edge Cases

  • Unconstrained least squares. Produces alternating-sign ordinates that fit slightly better and cannot be used.
  • Multi-peaked events. Violate the single-response assumption and produce the wildest recovered shapes. Reject them at selection rather than trying to salvage them.
  • Ordinate-by-ordinate averaging. Smears the peak whenever the individual peaks differ in timing. Align first.
  • Baseflow filter parameters left at defaults. On a flashy catchment alpha = 0.925 can separate almost nothing; on a spring-fed one it can separate too much. Check the fraction separated.
  • Volume not checked. A units error or a bad excess estimate shows up nowhere else. The check is three lines.
  • Too many ordinates. Asking for more ordinates than the data supports reintroduces the oscillation that the non-negativity constraint suppressed.
  • A derived unit hydrograph applied at a different duration. Unit hydrographs are duration-specific; converting between durations needs the S-curve method, covered in the parent topic.