Changelog
Changelog
v3.0.1 — 2026-10-13
- The npm package's WebAssembly is compiled by
python river_route/kernels/build.py --wasm <folder>, which needs nothing beyond Python's standard library.rr compile --wasm <folder>still compiles it too. river_route.kernels.build.build(llvm_bin, checks, wasm)compiles either the shared library or, givenwasm, the WebAssembly modules.build_wasmis removed, andbuildno longer returns the library's path.- npm releases are staged, then approved, before they go live.
v3.0.0 — 2026-10-13
The v2 to v3 migration guide gives the replacement for each removed class, config key, and file format.
Routing
- One class,
rr.Router, routes every forcing in place of theMuskingum,RapidMuskingum, andUnitMuskingumclasses, with static Muskingum coefficients built frommuskingumKandmuskingumX. What it routes is chosen by theforcingconfig (channel,catchment,grid, orecmwf_grib), and how each river computes by the compute optionsdt_routing('auto'or a step in seconds),use_subreaches, anduse_subcycles(TrueorFalse). - Rivers are routed one at a time from upstream to downstream, each river's whole time series before the next river, instead of every river for one time step before the next step by sparse forward substitution. A river routed whole with static coefficients is routed in one loop over eight steps at a time, held in two SIMD vectors of four lanes, with the recurrence solved as prefix sums within each vector. It routed the Amazon 1.55 times faster than taking each river through its upstream terms, runoff, recurrence, downstream inflow, and discharge in loops of their own.
- Two routing kernels route the same way: a C kernel,
river_route/kernels/c, and its numba twin.rr compilecompiles the C for this machine with the clang of LLVM, and--checksalso requires it to compile without a warning from clang's strictest set and to pass clang-tidy and clang's static analyzer.Router.route(kernel='auto'), the default, routes with the C once it is compiled and with numba until then, andkernel='c'orkernel='numba'chooses one. - The C kernel is compiled to WebAssembly by
rr compile --wasm <folder>and published to npm asriver-route, with one module for runtimes with relaxed SIMD's fused multiply-adds and one for those without. - Every river hands its discharge downstream, and records it, at the end of each runoff step, and computes within it
in a plan of its own, settled in two jobs.
dt_routingsets each river's step:'auto', the default, gives each river its own valid step, the runoff step over the fewest subcycles that keep its coefficients non-negative, and a step in seconds, a divisor of the runoff step, is the step of every river.use_subreachesanduse_subcycles, on by default, then make every coefficient non-negative at that step, and'auto'requires both. Without them every river is routed whole at its step, and a river with a negative coefficient at it is reported. The subcycles of each sub-reach are combined into one step of coefficients that give the discharge of taking them one at a time, built over the bits of the count of subcycles, so they cost one step however many there are.Network.largest_valid_dtis gone, since no step is searched for. use_subreachesanduse_subcycles, the defaults, route each river with non-negative coefficients in its own step, the condition for valid Muskingum routing, where they can: withuse_subreachesa river too long for its step is split into sub-reaches, the fewest sub-reaches in series inside the kernel, laid out byNetwork.conditioningto keep the river's mean travel time and travel time variance with x never negative (equal sub-reaches withx = 1/2 - N*(1/2 - x)where that is not negative, otherwise linear reservoirs of unequal travel times, 0.724 and 0.276 of k atx = 0.2), and withuse_subcyclesa river too short for it is routed in subcycles, shorter steps of its own. With both, a river that neither makes valid, which only a river with x above 1/3 can be, is split into the fewest sub-reaches of unequal travel times that each take subcycles of their own and keep the river's diffusion,x_i = 1/2 - (1/2 - x) k / k_i, found byriver_route.network.splitting. A river atx = 0.49with a step of 0.6 of its k splits into three. They route at any step, with every forcing, and withuse_subreachesstate files hold one row per sub-reach.Network.stabilize(dt)rewrites a network in place with the sub-reachesNetwork.conditioning(dt)splits rivers into as rows of their own, laid out asuse_subreachesanduse_subcyclesroute them, andNetwork.write_stabilized(dt)writes it as a network table, in which added reaches are numbered down from -1,000,000,syntheticmarks them, andparentRiverIdmaps each reach to the river it was split from. Such a network is routed with thechannel,grid, orecmwf_gribforcing, whose RunoffForcing classes share each river's runoff among its sub-reaches withRunoffForcing.distribute.- Routing with
use_subreachesoruse_subcycles, andNetwork.stabilize, refuse a river withmuskingumXabove 0.49,river_route.network.MAX_STABILIZED_X. A river needs up toceil(1/(1-2x))sub-reaches, 50 at 0.49 and many more above it, and at x = 0.5 it is valid only at a step equal to its travel time, so that it can be impossible to stabilize. Rivers above it are routed only whole, withdt_routingin seconds and both options off. - Rivers with a negative coefficient in their own step, outside
2*k*x <= step <= 2*k*(1-x), are reported as errors. The newinvalid_coefficientsconfig choosesraise(the default), which refuses to route them,log, which logs the error at the error level and routes on, orignore. - Negative discharge is written as routed. It was clamped to zero in the written discharge, which hid the rivers that produced it and broke the volume of their written discharge.
- The discharge written for each output interval is the discharge at its end, after routing it: that of the last
subcycle of its last runoff step. It was the mean of the discharges computed at
its routing steps. Channel routing writes the discharge at the end of each
dt_dischargerather than their mean. - A Network refuses a river with
muskingumKnot positive and finite, such as a river of zero length, which no step can route and which is resolved in the topology before routing; a river withmuskingumXbelow 0, or above 0.5, at which the scheme amplifies its inflow; and a river that drains into itself. Router.route(thread_pool=pool, threads=n)routes concurrently on aThreadPoolExecutorthe caller passes. The region's rivers are divided into blocks, contiguous index ranges of rivers, and the blocks are packed into one job per thread. The pool is used as given and never shut down, and the package never creates one: without a pool, routing is single threaded. The discharge writer is handed the same pool and thread count, andzarr_writerwrites its chunks on that pool.- The unit hydrograph transform is postponed past v3:
UnitMuskingum,river_route.uhkernels, and theuh_kernel_file,uh_state_init_file, anduh_state_final_fileconfigs are removed. - Repeated
route()calls on one Router restart fromchannel_state_init_fileinstead of continuing from the previous run's final state. - Every module logs to a child of the
river_routelogger, and a Router points that logger at its log options with one handler, which replaces the handler of the Router before it. Handlers no longer accumulate, and the runoff and weight table functions follow the log options too.
Network
rr.Networkowns the river network: the ids, topology, and parameters of the network file, the partition of the rivers into blocks, the validity analysis, and stabilization. A Router takes only a Configs and builds its Network from it, and reuses it, with its partition, for everyroute()call. A Network is built from a network file or a DataFrame, is stabilized once, sincestabilizerefuses a network that already is, and is written withto_parquet(path).Router.river_ids,k, andxare read off the network, asrouter.network.river_ids.- A Network holds
muskingumKandmuskingumXin float64, whatever the network file stores, and writes them in float64, so the travel times and x of a stabilized network are written exactly as they were split. - The params file is renamed the network file, set with the
network_fileconfig in place ofparams_file: it describes the network theNetworkclass is built from, not only its Muskingum parameters. Its columns are camelCase:riverId,nextRiverId(wasdownstream_river_id),muskingumK(wask), andmuskingumX(wasx), withsyntheticandparentRiverIdon a stabilized network. - The rivers of the network file must be in depth first search (DFS) order, where v2 required only upstream before
downstream: the rivers upstream of each river are the rows immediately before it, so every river's upstream
watershed is one contiguous range of rows. Two new required columns describe that order:
riverIndex, each river's position in it, andupstreamCount, the number of rivers upstream of each river. Every file with one entry per river lists the rivers in that order, and routing refuses catchment runoff or a grid weight table whose rivers are not the rivers of the network file in that order.examples/migrate_v2_to_v3.pyconverts v2 inputs, writing the params file as a network file in DFS order with both columns, and the grid weights, qlateral files, and channel state files in the same order. Networkanalyzes the validity of Muskingum coefficients withvalidity_window,invalid_mask,sub_reaches,subcycles,conditioning, which gives theLayoutof each river's sub-reaches, andcheck_validity, refuses withcheck_routable, before any runoff is read, a network the kernels cannot route, such as a river split into more than 256 sub-reaches, and divides the rivers into the blocks threads route withrouting_blocks, sized for the thread count. Each block is read fromupstreamCount, so aNetworkrefuses a table whoseupstreamCountdoes not describe its DFS order.river_route.toolsis removed.subset_network_to_riveris inriver_route.network.streams, withassign_blocksandanalyze_partitioning, which partition networks, and cuts a basin as the rows its outlet'sriverIndexandupstreamCountgive, without building a graph.connectivity_to_digraphandadjacency_matrixare removed.
Runoff
forcing_filesreplacesqlateral_filesandgrid_runoff_files, and is read in the formforcingnames.grid_weights_fileis required for thegridandecmwf_gribforcings. Runoff options a forcing does not read are refused unless left at their defaults:catchmentreads none,grid_weights_fileincluded.var_grid_runoffis renamedvar_runoff, andgrid_accumulation_typeis renamedaccumulation_type.- Catchment runoff files replace qlateral files. A file holds a
catchment_runoffvariable with dimensions(riverId, time), in that order, and acatchment_areavariable (m²) with dimensionriverId, linked by the CF attributecell_measures = "area: catchment_area". Theunitsattribute ofcatchment_runoffis required and marks it as volumes (m3) or depths (m,mm), which are multiplied by the catchment area when read.RunoffForcing.to_netcdfwrites this schema. - The runoff classes
CatchmentRunoffForcing,GridRunoff, andECMWFGribReducedGridread the runoff of each forcing. A Router builds the one itsforcingnames the first time it routes runoff.GridRunoffreads and indexes its weight table once for every forcing file, and routing reads each river's grid cells directly into its forcing as the river is routed.GridRunoff.to_datasetandGridRunoff.aggregate_to_filereplacerunoff.runoff_to_qlateral. ECMWFGribReducedGridroutes GRIB files on a reduced gaussian grid, such as the octahedral O1280 grid of the ECMWF IFS, reading each message with eccodes and keeping only the weight table's cells. At the 18,721 cells of the Columbia in a 145 step O1280 forecast that reads in 0.72 s with memory for one message, where xarray and cfgrib took 5.8 s and 4 GB.ReducedGaussianGrid.from_gribreads a file's grid from its metadata,ReducedGaussianGrid.cell_polygonsgives the area each cell represents, andreduced_grid_weightsbuilds a weight table that locates each cell by itscell_index. IFS forecast runoff accumulates from the start of the forecast, soecmwf_gribrunoff is always cumulative and has noaccumulation_typeoption.runoff_depth_unit,force_positive_runoff, andas_volumes, arguments ofrunoff_to_qlateralin v2, are configs.- Runoff must come in uniform time steps.
force_uniform_timesteps, which resampled irregular gridded runoff in v2, is removed: routing refuses a forcing file whose time steps are not all the same, and preparing runoff with a uniform time step is left to its provider.dt_runoffis no longer a config; it is read from the time steps of each forcing file. - NaN runoff is set to zero before it is routed: a NaN cell contributes nothing while the other cells of its catchment still count.
- A runoff
unitsattribute ofkg m-2is read as millimeters of water; v2 read it as meters. - The weight table functions
grid_weights,compute_voronoi_catchment_intersects,voronoi_diagram_from_regular_xy, andcell_xy_from_regular_gridare inriver_route.forcings.weights, and still importable fromriver_route.forcings, the package named after theforcingconfig that replacesriver_route.runoff.
Configs
Configsis frozen and keyword only. Build one from keyword arguments or withConfigs.from_json, write one withConfigs.to_json, and pass it toRouter(configs). Routers no longer take a config file path or options as keyword arguments.- Config files are JSON only. YAML config files are no longer read, and
pyyamlis no longer a dependency.examples/config.jsonlists every option. - Unrecognized keys in a config file raise a
ValueErrornaming them, and unknown keyword arguments aTypeError.Router.routevalidates the options and paths withConfigs.validate_routing, andGridRunoff.from_configswithConfigs.validate_runoff; passing one does not pass the other.Configs.deep_validate()reads the network file, grid weights, and initial state that are set and checks their contents and their consistency with each other; nothing calls it for you. - Outputs named from
discharge_diraredischarge_<input name>.zarr, taking the extension of the store the default writer writes instead of the extension of the forcing file. Input files with duplicate names are rejected rather than overwriting each other's output.discharge_dir=os.devnullroutes and discards the discharge. dt_dischargeanddt_totalconfigure channel routing only. Routing with forcing routes every step of each forcing file and writes the discharge at the end of each, and refuses either option when it is set. Runoff is checked against the network file for its rivers, in order, before it is routed.dt_dischargeanddt_totalmust be whole numbers of seconds, or 0 to leave them unset, anddt_routingmust be'auto'or a positive whole number of seconds; any other value, anddt_routing: autowithoutuse_subreachesanduse_subcycles, is refused when theConfigsis built. Runoff whose time steps do not increase is refused.
Outputs
river_route.router.writersholds the discharge writers.zarr_writeris the default: a zarr store ofQwith dimensions(riverId, time), chunked so each chunk holds every time step of 500 rivers, rounded towriters.ZARR_KEEPBITSmantissa bits (a relative error of at most 2^-13) and compressed with Blosc lz4 and bitshuffle, 2.71x smaller than the raw array on a year of the Amazon.netcdf_writerwrites an uncompressed netCDF file with dimensions(riverId, time), where v2 wrote(time, river_id).null_writerwrites nothing. The discharge variable is alwaysQ: thevar_dischargeconfig is removed, and a custom writer names it otherwise.- Every file keyed by river names its river id
riverId, as the network file does, where v2 named itriver_id: channel state files, catchment runoff files, grid weight tables, catchment polygons read by the weight table functions, and discharge. The name is not configurable: thevar_river_idconfig is removed, and a file that names it otherwise is renamed before it is read. Thevar_options that remain only name the variables of the gridded forcing files that are read; every file river-route writes uses the default names. set_write_dischargesis renamedset_discharge_writer. A writer is called aswriter(router, dates, discharge_array, discharge_file, forcing_file, thread_pool=..., threads=...): it is handed the Router first, the discharge as a C-order(river, time)array,forcing_file''for channel routing, and the thread pool and thread countRouter.routewas given.router.network.original_river_idsis the river of each row of the discharge array, which on a stabilized network leaves out the synthetic sub-reaches.- Channel state files hold a
riverIdcolumn besideQ. Final state files are written with it, and an initial state file must have it: routing refuses one whoseriverIddoes not list the rivers of the network file in order.examples/migrate_v2_to_v3.pyadds it to v2 state files. River ids may be any integer. They are written as int64 in discharge, catchment runoff files, and network tables written byNetwork, and read as int64 from grid weight tables.
Metrics
metrics.kge2012computes its variability ratio as the simulated over the observed coefficient of variation; v2 inverted it.metrics.mean_error(me) is the simulated minus the observed values, positive when the simulation is too high, as in HydroErr and hydroGOF; v2 subtracted the simulated from the observed. Every metric returns a float.
CLI
rr route config.jsonroutes a config file, replacingrr route --router <class> configand the per-class commandsrr Muskingum,rr RapidMuskingum, andrr UnitMuskingum.rr subset <riverId> --network <in> --out-network <out>cuts a network file, and optionally a grid weight table with--weightsand--out-weights, to a river and every river upstream of it.rr route --threads <n>routes on a thread pool of n threads, andrr route -reads the config as JSON from stdin.rr compilecompiles the C routing kernel, andrr appopens a window that sets up, saves, and runs routing configs.
Packaging
- Increased the minimum Python version to 3.14.
- Added lower version bounds to every dependency, and major version upper bounds to those that follow semantic
versioning:
daskandxarray, versioned by date, andpyarrowhave none. Addedzarr,eccodes, andpyproj, and removedpyyamlandnetworkx. - river-route runs on macOS and Linux only. Importing it on any other platform, such as Windows, raises an ImportError.
v2.1.1 — 2026-04-11
- Small documentation edits.
- Update build system to hatchling and pyproject.toml
v2.1.0 — 2026-04-11
- Routing state, parameters (
k,x), channel state, and all numba kernel scratch buffers now usefloat32instead offloat64. Reduces memory by half, speeds up the inner routing loop. - Fused the sparse matrix-vector multiply and forward-substitution into a single loop for modest speed gain.
- Better use of type aliases to reduce total number of annotations and improve readability.
v2.0.1 — 2026-03-10
- Adds pyarrow to dependencies which is included by conda installs as a pandas dependency but not in pip.
v2.0.0 — 2026-03-09
- Replaced
Muskingumclass with 3 separate classesMuskingum,RapidMuskingum, andUnitMuskingum. - New class
Muskingumis channel routing only with no runoff-transformation. - New class
RapidMuskingumis a reimplementation of the previous Muskingum class with routing and runoff transformation. - New class
UnitMuskingumis a new implementation of unit hydrograph runoff transformation and channel routing. - Introduced
Configsdataclass replacing the untyped config dictionary to centralize and validate configs. - Merged
connectivity_fileintoparams_file(single-file network definition). - Changed grid weights format from CSV to netCDF with
proportioncolumn. - Simplified channel state files from two columns (Q, R) to one column (Q).
- Added topological sort validation on routing parameters.
- Added
types.pyand significantly improved type annotations and static checking coverage. - Expanded
runoff.pyfunctions for Voronoi diagrams and grid weight computations. - Renamed numerous config keys (see migration guide).
- Removed conversion utilities for RAPID inputs.
- Several new tutorials, references, and examples in the docs.
- Routing now uses numba JIT-compiled forward substitution replacing the scipy sparse linear solver.
- Headwater streams are excluded from the matrix solve in
UnitMuskingum, reducing the system size roughly in half. - Progress tracking uses tqdm at the file iteration level for
RapidMuskingumandUnitMuskingum. - Increased minimum Python version to 3.12.
- Added dependency on
numba.
v1.3.0 — 2025-08-06
- Added support for routing runoff ensembles.
v1.2.3 — 2025-05-01
- Removed volume calculation keyword argument.
v1.2.2 — 2025-04-23
- Fixed catchment volume calculation to use the correct area column.
v1.2.1 — 2025-03-14
- Fixed bug where the time variable name argument was not applied correctly.
v1.2.0 — 2025-02-27
- Improved efficiency of catchment volume computations.
v1.1.0 — 2025-01-21
- Switched to a direct solver for the Muskingum linear system.
v1.0.3 — 2025-01-17
- Renamed
_MuskingumCungeto_Muskingum. - Added
metrics.pymodule. - Refactored
runoff.py. - Various bug fixes and documentation updates.
v1.0.2 — 2024-09-24
- Guaranteed consistent sort order for adjacency matrix construction.
v1.0.1 — 2024-08-19
- Fixed
input_typeconfig value not being set correctly.
v1.0.0 — 2024-08-17
- Initial stable release.
- Code cleanup and documentation restructuring.