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.
Annotated Code Example
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 |
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.
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.
Related Topics
- Precipitation Forcing and Design Storms — the parent topic: sources, areal reduction and the alternating-block alternative
- Converting NOAA Atlas 14 IDF Data into Model Forcing — where the 24-hour depth comes from
- SCS Curve Number Runoff Estimation — the loss method this hyetograph usually drives
- Deriving SCS Dimensionless Unit Hydrographs in Python — the transform that converts the resulting excess rainfall into a hydrograph