Computing 7Q10 and Low-Flow Statistics in Python

Low-flow statistics carry regulatory weight — discharge permits, instream flow rules and water-supply reliability all reference them — and they are computed from the same gauge record as flood quantiles by a procedure that differs in almost every detail. This guide sets out those details, as part of the flood frequency and streamflow statistics topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

  • A daily mean discharge record, screened for approval flags and with its gaps documented.
  • At least ten climatic years; twenty or more for a defensible regulatory number.
  • pandas, numpy, scipy.stats.

Core Technique: The Climatic Year and the Rolling Minimum

Two choices distinguish a low-flow calculation from a flood one, and both push in the same direction: keep a single drought inside a single year.

The Water Year Splits a Drought in Half A recession curve reaching its minimum in early October. The water-year boundary at 1 October cuts through it, so each water year records a higher minimum than the drought actually reached. The climatic-year boundary at 1 April leaves the whole recession inside one year. true drought minimum water year boundary — 1 Oct climatic year boundary — 1 Apr Apr Jul Oct Jan Mar recession discharge

The second choice is the seven-day rolling mean. A single day’s minimum is noisy — a stage reading, a rating extrapolation, an ice-affected estimate — and the seven-day average is both more stable and more relevant to how an aquatic system experiences low flow.

Annotated Code Example

python
import logging

import numpy as np
import pandas as pd
from scipy import stats

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


def climatic_year(index: pd.DatetimeIndex, start_month: int = 4) -> pd.Series:
    """
    Label each date with its climatic year.

    A climatic year starting in April places the whole northern-hemisphere
    low-flow season inside one year, so a drought spanning the calendar
    autumn is not split across two annual minima.
    """
    return pd.Series(
        index.year - (index.month < start_month).astype(int), index=index
    )


def low_flow_statistics(
    daily_cms: pd.Series,
    window_days: int = 7,
    return_periods=(2, 10, 20),
    climatic_start_month: int = 4,
    min_coverage: float = 0.90,
) -> dict:
    """
    Compute nQm low-flow statistics from a daily discharge record.

    Parameters
    ----------
    daily_cms            : Daily mean discharge, indexed by date.
    window_days          : Averaging window; 7 gives the familiar 7Qm family.
    return_periods       : Recurrence intervals to report.
    climatic_start_month : 4 for the northern hemisphere, 10 for the southern.
    min_coverage         : Fraction of a year that must be present for its
                           minimum to be trusted.
    """
    s = pd.Series(daily_cms).dropna().sort_index()
    if s.empty:
        raise ValueError("empty discharge series")

    # --- The rolling mean must require a full window. min_periods=1 would
    # produce a "seven-day" mean from a single day at the start of a gap,
    # which lands as a spuriously low annual minimum. ---
    rolled = s.rolling(f"{window_days}D", min_periods=window_days).mean()

    cy = climatic_year(s.index, climatic_start_month)
    frame = pd.DataFrame({"rolled": rolled, "cy": cy.to_numpy()})

    annual_min, dropped = {}, []
    for year, grp in frame.groupby("cy"):
        coverage = grp["rolled"].notna().sum() / 365.0
        if coverage < min_coverage:
            dropped.append((int(year), round(float(coverage), 2)))
            continue
        annual_min[int(year)] = float(grp["rolled"].min())

    if dropped:
        log.warning("Dropped %d year(s) below %.0f %% coverage: %s",
                    len(dropped), 100 * min_coverage, dropped[:6])

    minima = pd.Series(annual_min).sort_index()
    n = len(minima)
    if n < 10:
        raise ValueError(f"{n} usable climatic years is too short for a "
                         "low-flow frequency estimate")

    n_zero = int((minima <= 0).sum())
    nonzero = minima[minima > 0]
    p_zero = n_zero / n

    if n_zero:
        log.warning("%d of %d years had a zero %d-day minimum (%.1f %%) — "
                    "using conditional probability rather than deleting them",
                    n_zero, n, window_days, 100 * p_zero)

    logs = np.log10(nonzero.to_numpy(dtype=float))
    mean_log, std_log = float(logs.mean()), float(logs.std(ddof=1))
    skew = float(stats.skew(logs, bias=False))

    results = {}
    for T in return_periods:
        # For LOW flow the target is the NON-exceedance probability: the value
        # undercut once in T years. This is the sign flip that separates a
        # low-flow fit from a flood fit.
        p_nonexceed = 1.0 / T
        if n_zero:
            # Conditional adjustment: the fitted distribution describes only
            # the non-zero years, so rescale the probability into that subset.
            if p_nonexceed <= p_zero:
                results[f"{window_days}Q{T}"] = 0.0
                log.info("%dQ%d = 0.0 m³/s — zero flow is more frequent than "
                         "1 in %d years", window_days, T, T)
                continue
            p_nonexceed = (p_nonexceed - p_zero) / (1.0 - p_zero)

        z = stats.norm.ppf(p_nonexceed)
        if abs(skew) < 1e-6:
            kt = z
        else:
            k = skew / 6.0
            kt = (2.0 / skew) * (((z - k) * k + 1.0) ** 3 - 1.0)
        value = float(10 ** (mean_log + kt * std_log))
        results[f"{window_days}Q{T}"] = value
        log.info("%dQ%d = %.4f m³/s", window_days, T, value)

    # --- Flow-duration percentiles are a different statistic entirely, but
    # they are cheap here and worth reporting alongside for context. ---
    fdc = {f"Q{p}": float(np.percentile(s, 100 - p)) for p in (50, 75, 90, 95, 99)}

    log.info("Record: %d climatic years %d–%d, %d zero-flow years",
             n, int(minima.index.min()), int(minima.index.max()), n_zero)
    log.info("Flow duration: Q50 %.3f, Q95 %.4f m³/s", fdc["Q50"], fdc["Q95"])

    return {
        "statistics": results,
        "flow_duration": fdc,
        "annual_minima": minima,
        "n_years": n,
        "n_zero_years": n_zero,
        "dropped_years": dropped,
    }


# --- Example usage ---
# out = low_flow_statistics(daily_series, window_days=7,
#                           return_periods=(2, 10, 20))
# print(out["statistics"])

Parameter Reference

Parameter Value Effect
window_days 7 1 is noisy; 30 smooths past the drought minimum
climatic_start_month 4 (north) / 10 (south) Using the water year splits droughts and inflates minima
min_periods on the rolling mean Full window 1 produces a “seven-day” mean from one day beside a gap
min_coverage 0.90 A year missing its dry season has no meaningful minimum
Zero-flow handling Conditional probability Deletion biases the statistic upward
Every Shortcut Raises the Answer A correct 7Q10 of 0.043 cubic metres per second compared against three shortcuts: using the water year gives 0.061, deleting zero-flow years gives 0.078, and using a 30-day window gives 0.055. All three overstate the low flow, which is the unsafe direction for a permit. correct 0.043 m³/s water year used 0.061 — +42 % zero years deleted 0.078 — +81 % 30-day window 0.055 — +28 % All three errors point the same way, and a permit written against an inflated 7Q10 allows a discharge the stream cannot dilute in a real drought.

Worked Example: Reading the Result

A 41-year record on a small perennial stream:

Statistic Value Note
7Q2 0.128 m³/s The typical annual low
7Q10 0.043 m³/s The regulatory number
7Q20 0.029 m³/s
Q50 (flow duration) 0.94 m³/s Median daily flow
Q95 (flow duration) 0.087 m³/s Exceeded 95 % of days
Zero-flow years 0 Perennial, as expected

The 7Q10 sits at half the Q95 value, which is the usual relationship on a perennial stream: the flow-duration statistic counts all days, while 7Q10 targets the worst week of a dry decade. If they had come out equal, it would suggest the record is dominated by a few extreme droughts; if 7Q10 exceeded Q95, something in the calculation is wrong.

The averaging window is a choice about what “low flow” means, and different regulatory instruments use different ones. Reporting the window alongside the value is the difference between a number and a statistic.

The Window Decides the Number On the same 41-year record the 10-year low flow is 0.031 cubic metres per second on a 1-day window, 0.043 on 7 days, 0.061 on 30 days and 0.092 on 90 days. Each is correct for its own definition. 0.031 0.043 0.061 0.092 1Q10 7Q10 30Q10 90Q10 Same record, same return period — a factor of three between the shortest and longest window. m³/s

Gotchas and Edge Cases

  • Water year used for low flow. Splits droughts and inflates minima. This is the single most common error in the procedure.
  • Zero-flow years deleted. Turns an ephemeral stream into a perennial one on paper.
  • min_periods=1 on the rolling mean. Produces a low “seven-day” mean beside every gap, and those spurious minima dominate the fit.
  • Ice-affected estimates included. Winter records at northern gauges are often estimated rather than measured. They are flagged; use the flag.
  • Regulated periods pooled. A reservoir raises low flows dramatically. A record spanning its construction contains two different rivers.
  • Comparing 7Q10 against Q95 as if they were the same. They answer different questions and only coincide by accident.
  • Reporting more precision than the record supports. With 20 years, the 7Q10 confidence interval is wide; quoting four decimal places implies a precision that is not there.