library(corehydror)21. Flood frequency analysis
Ported from flood_frequency_analysis.py in the USACE-RMC Numerics-Python-Examples repository (0BSD licensed). The upstream file is a standalone script, not a notebook: it drives the C# Numerics.dll through pythonnet, prints two tables to the terminal, and pops up a figure. This version uses corehydror, whose compiled core is a validated C++ port of the same library, so the deterministic fits and tables below are the values that script prints. The Python version of this example uses the same core and prints the same numbers.
What you’ll learn
- Fit several candidate distributions to annual peak flows, each with the estimation method the upstream script uses.
- Rank the fits and pick a model.
- Build a design-flow table at standard return periods and plot frequency curves.
Setup
The upstream script opens with roughly fifty lines of .NET plumbing: loading the CoreCLR runtime, resolving Numerics.dll from the NuGet cache, and converting arrays to System.Array[Double]. None of that exists here.
The data
Forty-eight years of annual peak flows in cubic feet per second, hardcoded in the upstream script.
peak_flows <- c(
6290, 2700, 13100, 16900, 14600, 9600, 7740, 8490, 8130, 12000,
17200, 15000, 12400, 6960, 6500, 5840, 10400, 18800, 21400, 22600,
14200, 11000, 12800, 15700, 4740, 6950, 11800, 12100, 20600, 14600,
14600, 8900, 10600, 14200, 14100, 14100, 12500, 7530, 13400, 17600,
13400, 19200, 16900, 15500, 14500, 21900, 10400, 7460
)
cat(sprintf(
"n = %d, min = %s, mean = %s, max = %s cfs\n",
length(peak_flows),
format(min(peak_flows), big.mark = ","),
format(round(mean(peak_flows)), big.mark = ","),
format(max(peak_flows), big.mark = ",")
))n = 48, min = 2,700, mean = 12,665, max = 22,600 cfs
Fit three candidate models
The upstream script calls dist.Estimate(data, ParameterEstimationMethod.X) on three freshly constructed C# objects. Here dist_fit(family, data, method =) does the same in one call, with the same three methods: LogNormal by maximum likelihood, GEV by L-moments, Weibull by maximum likelihood. (GEV also has a dedicated gev_fit helper whose bespoke path carries quantile standard errors.)
As in the C# library, LogNormal is the base-10 family, so its two parameters are the mean and standard deviation of log10(flow).
ln <- dist_fit("LogNormal", peak_flows, method = "mle")
gev <- dist_fit("GeneralizedExtremeValue", peak_flows, method = "lmom")
wb <- dist_fit("Weibull", peak_flows, method = "mle")
models <- list(LogNormal = ln, GEV = gev, Weibull = wb)
for (name in names(models)) {
cat(sprintf(
"%9s: %s\n", name,
paste(sprintf("%.6g", dist_params(models[[name]])), collapse = ", ")
))
}LogNormal: 4.06743, 0.186794
GEV: 10916.2, 4683.92, 0.251455
Weibull: 14197.3, 2.98284
Goodness of fit
The upstream script ranks the fits with the Kolmogorov-Smirnov statistic from Numerics.Data.Statistics.GoodnessOfFit. That helper is not part of the ported API, so this version ranks by log-likelihood and AIC instead. The log-likelihoods are the exact values the script also prints, and the conclusion is the same either way: Weibull edges out GEV, and LogNormal trails.
log_liks <- sapply(models, \(d) dist_log_likelihood(d, peak_flows))
n_params <- sapply(models, \(d) length(dist_params(d)))
fit_df <- data.frame(
Model = names(models),
LogLikelihood = log_liks,
AIC = 2 * n_params - 2 * log_liks,
row.names = NULL
)
fit_df <- fit_df[order(fit_df$AIC), ]
fit_df$dAIC <- fit_df$AIC - min(fit_df$AIC)
cat("Model fit summary (lower AIC is better):\n")Model fit summary (lower AIC is better):
print(fit_df, row.names = FALSE, digits = 8) Model LogLikelihood AIC dAIC
Weibull -473.04584 950.09168 0.0000000
GEV -473.22267 952.44533 2.3536485
LogNormal -477.15248 958.30497 8.2132838
The packages also ship this ranking as a one-liner: fit_distributions fits all 14 supported candidates by maximum likelihood and returns AIC, BIC, and RMSE for each. (It refits GEV by MLE, so its AIC differs slightly from the L-moment fit above.)
ranking <- as.data.frame(fit_distributions(peak_flows))
ranking <- ranking[order(ranking$aic), ]
print(head(ranking, 5), row.names = FALSE, digits = 6) distribution aic bic rmse converged
Weibull 950.092 953.834 560.198 TRUE
Normal 951.117 954.859 585.077 TRUE
LogPearsonTypeIII 951.472 957.086 548.624 TRUE
GeneralizedExtremeValue 952.306 957.919 563.401 TRUE
PearsonTypeIII 952.938 958.552 560.739 TRUE
Design flow table
Design flows at the standard return periods. A T-year event has annual exceedance probability 1/T, so the design flow is the quantile at 1 - 1/T; the upstream script’s InverseCDF(p) is dist_quantile(d, p) here.
return_periods <- c(2, 5, 10, 25, 50, 100)
probs <- 1 - 1 / return_periods
design <- data.frame(
ReturnPeriod = return_periods,
lapply(models, \(d) dist_quantile(d, probs))
)
cat("Design flow table (cfs):\n")Design flow table (cfs):
print(round(design, 1), row.names = FALSE) ReturnPeriod LogNormal GEV Weibull
2 11679.6 12556.2 12555.7
5 16774.1 16768.8 16653.0
10 20268.2 18965.6 18777.5
25 24799.7 21209.5 21009.4
50 28252.5 22560.5 22428.9
100 31767.1 23684.9 23689.6
The three models agree closely through the 10-year event and then diverge: the unbounded LogNormal keeps climbing while the L-moment GEV (positive shape, so upper bounded) and Weibull flatten. This tail disagreement is exactly why flood frequency practice cares so much about distribution choice.
Visualizing the fits
The upstream script ends with a 2x2 figure: fitted PDFs over a histogram, fitted CDFs against the empirical CDF, the frequency curves, and a goodness-of-fit comparison. Two substitutions here: the empirical CDF uses Weibull plotting positions i / (n + 1) from plotting_positions instead of the script’s naive i / n (which forces the largest observation to probability 1), and the goodness-of-fit panel shows dAIC instead of the unported KS statistic.
colors <- c(LogNormal = "#6b7f3f", GEV = "#b06a3b", Weibull = "#5b7a8c")
sorted_flows <- sort(peak_flows)
pp <- plotting_positions(length(peak_flows))
x_grid <- seq(min(peak_flows) * 0.8, max(peak_flows) * 1.1, length.out = 500)
op <- par(mfrow = c(2, 2), mar = c(4, 4, 2, 1))
# 1) Histogram + fitted PDFs
hist(peak_flows, breaks = 16, freq = FALSE, col = adjustcolor("#6b7f3f", 0.4),
border = "white", main = "Fitted PDFs", xlab = "Peak flow (cfs)")
for (name in names(models)) {
lines(x_grid, dist_pdf(models[[name]], x_grid), col = colors[[name]], lwd = 2)
}
legend("topleft", legend = names(models), col = colors, lwd = 2, bty = "n", cex = 0.8)
# 2) Plotting positions + fitted CDFs
plot(sorted_flows, pp, pch = 16, cex = 0.6, col = "#8c8c7a",
main = "Empirical vs fitted CDFs", xlab = "Peak flow (cfs)", ylab = "CDF")
for (name in names(models)) {
lines(x_grid, dist_cdf(models[[name]], x_grid), col = colors[[name]], lwd = 2)
}
legend("topleft", legend = c("Plotting positions", names(models)),
col = c("#8c8c7a", colors), pch = c(16, NA, NA, NA),
lwd = c(NA, 2, 2, 2), bty = "n", cex = 0.8)
# 3) Frequency curves
matplot(return_periods, design[names(models)], type = "b", pch = 16, lty = 1,
lwd = 2, col = colors, log = "x", main = "Flood frequency curves",
xlab = "Return period (years)", ylab = "Design flow (cfs)")
legend("topleft", legend = names(models), col = colors, lwd = 2, bty = "n", cex = 0.8)
# 4) Goodness-of-fit comparison
barplot(fit_df$dAIC, names.arg = fit_df$Model, col = colors[fit_df$Model],
border = NA, main = "Goodness of fit (dAIC, lower is better)",
ylab = "dAIC")
par(op)What the packages add
The upstream script assembles this analysis by hand from Numerics building blocks. The packages also ship complete flood-frequency workflows: bulletin17c_analysis runs the Bulletin 17C log-Pearson Type III analysis (the US federal standard, including confidence intervals), and univariate_analysis fits any family by Bayesian MCMC with a full credible band. The 100-year estimate below lands between the bounded GEV/Weibull tails and the LogNormal tail, and comes with uncertainty attached:
b17c <- bulletin17c_analysis(peak_flows)
i <- which(b17c$exceedance_probabilities == 0.01)
q100 <- 10^b17c$point_estimates[i] # the B17C curve is tabulated in log10 space
cat(sprintf(
"Bulletin 17C 100-yr flow: %s cfs (90%% CI %s to %s)\n",
format(round(q100), big.mark = ","),
format(round(b17c$lower_ci[i]), big.mark = ","),
format(round(b17c$upper_ci[i]), big.mark = ",")
))Bulletin 17C 100-yr flow: 23,219 cfs (90% CI 17,728 to 28,969)
Reproduction check
The upstream file is a script with no committed outputs, so there are no embedded numbers to quote; it prints its fit summary and design-flow table when run against the C# library. Every quantity in this example is deterministic (no sampling), so the ported pipeline reproduces those printed tables exactly. The assertions below pin the same literals the Python notebook asserts (the cross-language identity) and check the quantile/CDF round trip for internal consistency.
| Quantity | Upstream C# | This port | Status |
|---|---|---|---|
| LogNormal MLE parameters | printed at run time, not embedded | 4.067428, 0.186794 | exact |
| GEV L-moment parameters | printed at run time, not embedded | 10916.18, 4683.918, 0.251455 | exact |
| Weibull MLE parameters | printed at run time, not embedded | 14197.26, 2.982835 | exact |
| Log-likelihoods (LN, GEV, WB) | printed at run time, not embedded | -477.1525, -473.2227, -473.0458 | exact |
| 100-yr design flows (LN, GEV, WB) | printed at run time, not embedded | 31767.1, 23684.9, 23689.6 | exact |
dist_quantile(dist_cdf(x)) round trip |
n/a (internal consistency) | rel. error < 1e-12 | exact |
The chunk below fails the render if any value drifts.
# Upstream: examples/flood_frequency_analysis.py (standalone script; its tables are
# printed at run time, not embedded in the repo). The pipeline is deterministic, so
# these pinned values are what that script computes; the Python notebook asserts the
# same literals, which proves the cross-language identity. The values are bit-exact
# (the fixture suite enforces that); the comparisons carry a 1e-15 relative
# tolerance only because R's decimal parser can land one ulp away from the written
# literal.
near <- \(x, literal) abs(x / literal - 1) < 1e-15
stopifnot(
near(dist_params(ln)[1], 4.067428305746908),
near(dist_params(ln)[2], 0.18679432530177564),
near(dist_params(gev)[[1]], 10916.176130989406),
near(dist_params(gev)[[2]], 4683.917553015235),
near(dist_params(gev)[[3]], 0.25145450827789206),
near(dist_params(wb)[1], 14197.259246092635),
near(dist_params(wb)[2], 2.98283522441679),
near(dist_log_likelihood(ln, peak_flows), -477.15248319801253),
near(dist_log_likelihood(gev, peak_flows), -473.2226655186914),
near(dist_log_likelihood(wb, peak_flows), -473.045841282679),
near(dist_quantile(ln, 1 - 1 / 100), 31767.050051870196),
near(dist_quantile(gev, 1 - 1 / 100), 23684.9359641315),
near(dist_quantile(wb, 1 - 1 / 100), 23689.649055911825)
)
# Internal consistency: quantile inverts the CDF at every observation, and each
# frequency curve is strictly increasing in return period.
for (d in models) {
stopifnot(all(abs(dist_quantile(d, dist_cdf(d, peak_flows)) / peak_flows - 1) < 1e-12))
}
stopifnot(sapply(design[names(models)], \(q) all(diff(q) > 0)))
# Ranking: Weibull wins by AIC, both among the three fits here and among the 14
# MLE candidates in fit_distributions.
stopifnot(
fit_df$Model[1] == "Weibull",
ranking$distribution[1] == "Weibull"
)
cat("All reproduction checks passed.\n")All reproduction checks passed.