Building SCS Design Storm Hyetographs in Python

The SCS type curves are the most widely used design storm distributions in North American practice, and implementing them is a short piece of interpolation with two places to go wrong: choosing the curve, and choosing the time step. This guide covers both, as part of the precipitation forcing and design storms topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

  • A 24-hour design depth for the return period required, from a depth-duration-frequency source, with any areal reduction already applied.
  • The storm type for the site.
  • The model time step, decided before the hyetograph is built.

Core Technique: A Dimensionless Mass Curve

Each type curve is a table of cumulative fraction of total depth against fraction of the 24-hour duration. Scaling it is a multiplication; the hyetograph is the first difference of the scaled curve at the model’s step.

The four curves differ in how sharply the depth concentrates. Type II is the most intense, delivering roughly half the storm total in a single hour around the midpoint. Type IA is the gentlest, spreading its peak over several hours.

The Four Type Curves as Cumulative Mass Four cumulative rainfall curves rising from zero to one over 24 hours. Type II rises almost vertically near hour 12, Type III slightly less steeply, Type I more gradually, and Type IA has the gentlest slope with its steepest section before hour 10. 0 6 h 12 h 18 h 24 h time through the storm 0 0.25 0.50 0.75 1.00 cumulative fraction Type II — most intense Type III Type I Type IA — gentlest The steepness of the curve at its midpoint is the peak intensity the model will see.

Annotated Code Example

python
import logging

import numpy as np
import pandas as pd

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

# Cumulative fraction of the 24-hour depth at each hour, for each type.
# Sampled hourly here for readability; production tables are half-hourly
# around the peak, where the curvature is steepest and matters most.
SCS_MASS_CURVES = {
    "I":   [0.000, 0.017, 0.035, 0.055, 0.076, 0.099, 0.125, 0.156, 0.194,
            0.254, 0.515, 0.624, 0.682, 0.727, 0.767, 0.799, 0.830, 0.854,
            0.878, 0.902, 0.926, 0.947, 0.968, 0.984, 1.000],
    "IA":  [0.000, 0.020, 0.040, 0.062, 0.086, 0.113, 0.143, 0.176, 0.219,
            0.283, 0.386, 0.500, 0.579, 0.635, 0.682, 0.722, 0.759, 0.792,
            0.822, 0.849, 0.874, 0.900, 0.930, 0.965, 1.000],
    "II":  [0.000, 0.011, 0.022, 0.035, 0.048, 0.064, 0.080, 0.098, 0.120,
            0.147, 0.181, 0.235, 0.663, 0.772, 0.820, 0.854, 0.880, 0.903,
            0.922, 0.938, 0.952, 0.965, 0.977, 0.989, 1.000],
    "III": [0.000, 0.010, 0.020, 0.031, 0.043, 0.057, 0.072, 0.089, 0.115,
            0.148, 0.189, 0.250, 0.500, 0.751, 0.811, 0.849, 0.874, 0.893,
            0.909, 0.923, 0.936, 0.951, 0.967, 0.984, 1.000],
}


def scs_hyetograph(
    total_depth_mm: float,
    storm_type: str = "II",
    timestep_min: int = 15,
) -> pd.Series:
    """
    Build an SCS design storm hyetograph.

    Parameters
    ----------
    total_depth_mm : 24-hour design depth, areal reduction already applied.
    storm_type     : One of "I", "IA", "II", "III".
    timestep_min   : Model time step. 1440 must be divisible by it.

    Returns
    -------
    Incremental depth in mm per step, indexed by minutes from storm start.
    """
    storm_type = storm_type.upper()
    if storm_type not in SCS_MASS_CURVES:
        raise ValueError(f"unknown storm type {storm_type!r}")
    if 1440 % timestep_min:
        raise ValueError("the 24-hour duration must divide evenly by the time step")

    hours = np.arange(25, dtype=float)
    mass = np.asarray(SCS_MASS_CURVES[storm_type], dtype=float)

    n_steps = 1440 // timestep_min
    step_hours = np.arange(1, n_steps + 1) * (timestep_min / 60.0)

    # --- Interpolate the CUMULATIVE curve at the step boundaries, then
    # difference. Interpolating the incremental series instead would smooth
    # the peak, which is exactly the error this method is prone to. ---
    cumulative = np.interp(step_hours, hours, mass) * total_depth_mm
    increments = np.diff(np.concatenate([[0.0], cumulative]))

    index = np.arange(n_steps) * timestep_min
    series = pd.Series(increments, index=index, name="depth_mm")
    series.index.name = "minutes_from_start"

    peak_mmhr = float(series.max()) * 60.0 / timestep_min
    peak_minute = int(series.idxmax())
    log.info("Type %s, %.1f mm over 24 h at a %d-min step: peak %.1f mm/h at "
             "minute %d (hour %.2f)", storm_type, total_depth_mm, timestep_min,
             peak_mmhr, peak_minute, peak_minute / 60.0)

    total = float(series.sum())
    if abs(total - total_depth_mm) > 0.01:
        log.warning("Hyetograph sums to %.3f mm, expected %.3f — check the "
                    "mass curve endpoints", total, total_depth_mm)
    else:
        log.info("Volume check passed: %.3f mm", total)
    return series


def peak_intensity_by_timestep(total_depth_mm: float, storm_type: str = "II"):
    """Show how much peak intensity the time step alone decides."""
    rows = []
    for step in (6, 10, 15, 30, 60, 120):
        h = scs_hyetograph(total_depth_mm, storm_type, step)
        rows.append({"timestep_min": step,
                     "peak_mm_per_hour": float(h.max()) * 60.0 / step})
    return pd.DataFrame(rows).set_index("timestep_min")


# --- Example usage ---
# h = scs_hyetograph(total_depth_mm=152.0, storm_type="II", timestep_min=15)
# print(h.head(20).round(2))
# print(peak_intensity_by_timestep(152.0, "II").round(1))

Parameter Reference

Parameter Values Effect
storm_type I, IA, II, III Sets peak intensity; Type II peaks roughly 2.5× higher than Type IA for the same depth
timestep_min 5–60 Decides the peak intensity the model sees; the depth is unchanged
total_depth_mm From the DDF source Must already carry any areal reduction
Interpolation target The cumulative curve Interpolating increments smooths the peak away
The Time Step Alone Changes the Peak by Threefold Peak intensity for a 152 millimetre Type II storm falls from 128 millimetres per hour at a 6-minute step to 118 at 10 minutes, 104 at 15, 76 at 30, 47 at 60 and 29 at 120 minutes, with no change to the storm depth. 128 118 104 76 47 29 6 min 10 min 15 min 30 min 60 min 120 min model time step — the storm depth is 152 mm in every case peak mm/h

Worked Example: Reading the Output

A 152 mm Type II storm at a 15-minute step over a 12 km² urban catchment with a time of concentration of 45 minutes:

Quantity Value
Total depth 152.0 mm
Peak 15-minute increment 26.0 mm
Peak intensity 104 mm/h
Time of peak hour 11.75
Depth in the peak hour 82 mm — 54 % of the storm

That last row is the signature of a Type II curve, and it is worth checking every time: if more than about 60 % of the storm arrives in one hour, the wrong curve or the wrong table has been used. If under 30 %, the interpolation was applied to increments rather than to the cumulative curve.

The storm type is a regional choice with a large consequence, and a site near a boundary deserves both curves rather than a coin toss.

The Storm Type Alone Doubles the Design Peak For one catchment with a fixed 152 millimetre 24-hour depth, the design peak is 8.4 cubic metres per second under Type IA, 12.1 under Type I, 17.6 under Type III and 19.8 under Type II. 8.4 12.1 17.6 19.8 Type IA Type I Type III Type II Same catchment, same 152 mm depth — the peak varies by a factor of 2.4. peak m³/s

Nesting a short storm inside the 24-hour curve

Where a catchment’s time of concentration is well under a day, running the full 24-hour type curve wastes most of the simulation on rainfall that has already drained away before the peak-producing burst arrives. The usual accommodation is to keep the 24-hour curve but start the model long enough before the central burst to establish antecedent conditions, rather than to truncate the curve.

Truncating is the option to avoid. Cutting the type curve to its central six hours and rescaling it to the six-hour design depth produces a hyetograph whose shape came from a 24-hour storm and whose depth came from a six-hour one, and no sub-duration window in it matches its own depth-duration-frequency value. If a short-duration storm is what the design calls for, the alternating-block construction is the method that gives it correctly.

Recording what produced the hyetograph

The hyetograph that reaches the model is three decisions deep — a return period, a regional storm type and a time step — and none of them is recoverable from the array of numbers. Writing them alongside the series, as metadata on the file or as a header comment, is what lets a reviewer reproduce it and what stops a later run from silently using a different type curve.

The same applies to the areal reduction factor. A hyetograph that has been reduced and one that has not look identical in shape and differ in every value, so the factor, and the area it was computed for, belong with the series rather than in the analyst’s head.

Gotchas and Edge Cases

  • Interpolating increments. Produces a smooth, low-peaked hyetograph whose volume is right and whose peak is 30–40 % low. Always interpolate the cumulative curve and difference afterwards.
  • Building fine and averaging to the model step. Same effect. Build at the model step.
  • The wrong regional type. Type II applied where Type IA belongs roughly doubles the design peak. The type is a regional map lookup, not a default.
  • Applying a type curve to a short-duration storm. The curves are defined for 24 hours. For shorter durations, use a nested depth-duration-frequency construction instead.
  • Point depth with no areal reduction. The hyetograph will be correct in shape and too large in volume — see precipitation forcing and design storms.
  • A time step longer than a fifth of the time of concentration. The model then cannot resolve the rising limb, and the peak discharge is understated regardless of how good the hyetograph is.