"""
Post-processing: extract, **per drainage basin**, the cell of maximum water
discharge and the cell of maximum sediment load from a gridded goSPL output —
i.e. each basin's **outflow point** (river mouth) and its flux. When the output
carries the Level-B geochemistry, the basin's **dissolved-solute flux** (and its
**per-species** breakdown) at the outlet is extracted alongside.
This is the natural downstream step after :mod:`gospl.analyse.gridexport`: that
tool rasterises a goSPL surface to a CF-NetCDF grid carrying ``FA`` (flow
accumulation / water discharge), ``sedLoad`` (sediment load) and ``basin`` (the
drainage-basin id of every cell). Here, for every basin, we pick the single cell
where ``FA`` peaks (the water outlet) and the single cell where ``sedLoad`` peaks
(the sediment outlet), and write their ``lon``/``lat`` + value. Looped over a
time series, this gives the migrating river-mouth fluxes used in flux maps.
The water discharge defaults to ``FA``. Pass ``flow_var="fillFA"``
(``--flow-var fillFA``) to use the **depression-filled** accumulation instead,
which routes the trunk river *through* lakes / pits so a basin's outlet carries
its full upstream discharge even when the channel crosses a depression (raw
``FA`` can drop to zero inside a lake).
Consistent variable names
-------------------------
The defaults match the names :func:`gospl.analyse.gridexport.to_netcdf` writes,
so this tool reads a ``surface*.nc`` produced by ``gospl-grid`` **directly** — no
renaming step:
================ ==================== ===========================
quantity gridexport name legacy fallback (mapOutputs)
================ ==================== ===========================
water discharge ``FA`` ``flowDischarge``
sediment load ``sedLoad`` ``sedimentLoad``
basin id ``basin`` ``basinID``
longitude ``lon`` ``longitude``
latitude ``lat`` ``latitude``
================ ==================== ===========================
Each variable falls back to the legacy name automatically if the gridexport one
is absent, so old ``fsdata*.nc`` files still work; explicit ``*_var`` arguments
override both.
Speed
-----
The per-basin maximum is a **grouped arg-max**, done once with a single
``lexsort`` over the subaerial cells (sort by ``(basin, value)``; the last cell
of each basin group is its maximum). This is :math:`O(N \\log N)` for the whole
grid, replacing the previous :math:`O(N_\\mathrm{basins} \\times N_\\mathrm{cells})`
scan (a full ``where(basin == k)`` per basin), which dominated runtime on a
global 0.1° grid (millions of cells × thousands of basins). It is fast enough to
run **serially** — the old MPI fan-out over basins is no longer needed.
Runnable as ``gospl-catchment`` (installed) or
``python -m gospl.analyse.catchment``::
gospl-catchment -i inputSedFlow.csv -o flowsed
where the index CSV has two columns ``time,netcdf``::
time,netcdf
1,results/surface1.nc
5,results/surface5.nc
10,results/surface10.nc
writes ``flowsed/flow{time}.csv`` and ``flowsed/sed{time}.csv`` (columns
``basin,lon,lat,val``) — plus ``flowsed/solute{time}.csv`` (columns
``basin,lon,lat,val`` + one per species) when the grid carries a solute flux.
In a notebook::
from gospl.analyse.catchment import (
catchment_flux, basin_outflow, basin_solute_flux)
out = catchment_flux("inputSedFlow.csv", "flowsed") # batch -> CSVs
o = basin_outflow("results/surface10.nc") # one file
flowdf, seddf = o["flow"], o["sed"]
soldf = o.get("solute") # basin,lon,lat,val,<species...>
# or directly, with an explicit total field:
soldf = basin_solute_flux("results/surface10.nc", solute_var="riverSolute")
"""
import os
import argparse
import numpy as np
# Variable-name aliases: the gridexport name first, the legacy mapOutputs name
# last. The first one present in the file is used (unless overridden). Water
# discharge defaults to the raw `FA`; `fillFA` (depression-filled accumulation)
# is kept as a fallback and can be selected explicitly with `--flow-var fillFA`.
_FLOW_ALIASES = ("FA", "fillFA", "flowDischarge")
_SED_ALIASES = ("sedLoad", "sedimentLoad")
_BASIN_ALIASES = ("basin", "basinID")
_LON_ALIASES = ("lon", "longitude")
_LAT_ALIASES = ("lat", "latitude")
# Dissolved-solute flux (Level-B geochemistry). The routed river dissolved load
# `riverSolute` is the basin's solute delivery; `soluteflux` (the raw groundwater
# seepage export, not routed downstream) is the fallback. Per-species fields are
# `<name>_<species>` and are auto-detected from the chosen total's prefix.
_SOLUTE_ALIASES = ("riverSolute", "soluteflux")
def _resolve(present, requested, aliases, kind):
"""Pick the variable name to read: explicit request, else first alias present."""
if requested is not None:
if requested not in present:
raise KeyError(
"variable %r (%s) not in NetCDF; present: %s"
% (requested, kind, sorted(present))
)
return requested
for a in aliases:
if a in present:
return a
raise KeyError(
"no %s variable found (looked for %s) in NetCDF; present: %s"
% (kind, list(aliases), sorted(present))
)
def _read_grid(ncfile, flow_var, sed_var, basin_var, lon_var, lat_var):
"""
Read the flow / sediment / basin fields and the lon-lat axes from a gridded
NetCDF. ``ncfile`` may be a path, an open ``netCDF4.Dataset`` or an
``xarray.Dataset``. Returns ``(flow2d, sed2d, basin2d, lon1d, lat1d)``.
"""
if isinstance(ncfile, (str, os.PathLike)):
import netCDF4
with netCDF4.Dataset(os.fspath(ncfile)) as ds:
return _extract(ds, list(ds.variables), flow_var, sed_var,
basin_var, lon_var, lat_var)
# already-open dataset: both netCDF4.Dataset and xarray.Dataset expose a
# `.variables` mapping (xarray's includes coords) and index by name.
return _extract(ncfile, list(ncfile.variables), flow_var, sed_var,
basin_var, lon_var, lat_var)
def _extract(ds, present, flow_var, sed_var, basin_var, lon_var, lat_var):
def arr(name):
v = ds[name]
return np.asarray(v.values if hasattr(v, "values") else v[:])
fv = _resolve(present, flow_var, _FLOW_ALIASES, "water discharge")
sv = _resolve(present, sed_var, _SED_ALIASES, "sediment load")
bv = _resolve(present, basin_var, _BASIN_ALIASES, "basin id")
lov = _resolve(present, lon_var, _LON_ALIASES, "longitude")
lav = _resolve(present, lat_var, _LAT_ALIASES, "latitude")
return (arr(fv).astype(np.float64), arr(sv).astype(np.float64),
arr(bv), arr(lov).astype(np.float64), arr(lav).astype(np.float64))
def _group_argmax(basin, value, nb, min_cells):
"""
Per-basin arg-max cell selection. For every basin id (``0..nb-1``) find the
cell of MAXIMUM ``value`` — a vectorised grouped arg-max: one stable
``lexsort`` by ``(basin, value)`` puts each basin's largest value last in its
group, so the group boundaries give the per-basin maxima in one pass.
Basins with at most ``min_cells`` cells are dropped (too small to be a
meaningful catchment — matches the legacy ``len(ids) > 10`` filter). Returns
``(sb, sel)``: the kept basin ids and the index (into the passed arrays) of
each basin's maximum cell — so any co-located field can be sampled there.
"""
# Non-finite value can never be a maximum (NaN sorts last in lexsort, so
# guard it explicitly); also lets us reject basins whose every cell is NaN.
val = np.where(np.isfinite(value), value, -np.inf)
order = np.lexsort((val, basin)) # basin asc, then value asc
bs = basin[order]
last = np.empty(bs.size, dtype=bool)
last[-1] = True
last[:-1] = bs[1:] != bs[:-1] # last cell of each basin = its max
sel = order[last]
sb = basin[sel]
counts = np.bincount(basin, minlength=nb)
keep = (counts[sb] > min_cells) & np.isfinite(val[sel])
return sb[keep], sel[keep]
def _basin_argmax(basin, value, lon, lat, nb, min_cells):
"""
Per-basin cell of MAXIMUM ``value``, as a DataFrame ``basin,lon,lat,val``
(one row per kept basin). Thin wrapper over :func:`_group_argmax`.
"""
import pandas as pd
sb, sel = _group_argmax(basin, value, nb, min_cells)
df = pd.DataFrame({
"basin": sb.astype(np.int64),
"lon": lon[sel],
"lat": lat[sel],
"val": value[sel],
})
return df.sort_values("basin").reset_index(drop=True)
[docs]
def basin_outflow(ncfile, min_cells=10, flow_var=None, sed_var=None,
basin_var=None, lon_var=None, lat_var=None, solute_var=None):
"""
Per-basin outflow points for one gridded NetCDF.
:arg ncfile: path (or open ``netCDF4``/``xarray`` dataset) of a gridded
surface — typically a ``gospl-grid`` ``surface*.nc``.
:arg min_cells: basins with at most this many cells are ignored (default 10).
:arg flow_var, sed_var, basin_var, lon_var, lat_var: override the variable
names (defaults auto-detect the gridexport names, then the legacy ones).
:arg solute_var: override the dissolved-solute-flux field (default:
``riverSolute``, else ``soluteflux``).
:return: ``{"flow": DataFrame, "sed": DataFrame}`` — each ``basin,lon,lat,val``
(``val`` in m³/yr), one row per basin, the cell where that basin's water
discharge (``flow``) / sediment load (``sed``) is largest. When the file
carries a Level-B **solute** flux, ``"solute"`` is also present — the same
``basin,lon,lat,val`` at the solute outlet plus one column **per species**
(see :func:`basin_solute_flux`).
"""
flow, sed, basin, lon, lat = _read_grid(
ncfile, flow_var, sed_var, basin_var, lon_var, lat_var)
mlon, mlat = np.meshgrid(lon, lat) # (nlat, nlon), matches the fields
basin = basin.ravel()
flow = flow.ravel()
sed = sed.ravel()
lonf = mlon.ravel()
latf = mlat.ravel()
# Subaerial cells carrying a basin id (marine / outside = -1 or NaN fill).
bint = np.where(np.isfinite(basin.astype(np.float64)), basin, -1).astype(np.int64)
sub = bint >= 0
b = bint[sub]
if b.size == 0:
import pandas as pd
empty = pd.DataFrame(columns=["basin", "lon", "lat", "val"])
return {"flow": empty, "sed": empty.copy()}
nb = int(b.max()) + 1
flowdf = _basin_argmax(b, flow[sub], lonf[sub], latf[sub], nb, min_cells)
seddf = _basin_argmax(b, sed[sub], lonf[sub], latf[sub], nb, min_cells)
out = {"flow": flowdf, "sed": seddf}
# Dissolved-solute flux (Level-B geochemistry) — only if the file carries it.
try:
out["solute"] = basin_solute_flux(
ncfile, min_cells=min_cells, solute_var=solute_var,
basin_var=basin_var, lon_var=lon_var, lat_var=lat_var)
except KeyError:
pass # no solute field: plain flow/sed file
return out
def _read_solute(ds, present, solute_var, basin_var, lon_var, lat_var):
"""Read the total solute flux + all its per-species fields + basin/lon/lat."""
def arr(name):
v = ds[name]
return np.asarray(v.values if hasattr(v, "values") else v[:])
sv = _resolve(present, solute_var, _SOLUTE_ALIASES, "solute flux")
bv = _resolve(present, basin_var, _BASIN_ALIASES, "basin id")
lov = _resolve(present, lon_var, _LON_ALIASES, "longitude")
lav = _resolve(present, lat_var, _LAT_ALIASES, "latitude")
prefix = sv + "_" # per-species fields: <total>_<species>
species = {
name[len(prefix):]: arr(name).astype(np.float64)
for name in present if name.startswith(prefix)
}
return (arr(sv).astype(np.float64), species, arr(bv),
arr(lov).astype(np.float64), arr(lav).astype(np.float64))
[docs]
def basin_solute_flux(ncfile, min_cells=10, solute_var=None, basin_var=None,
lon_var=None, lat_var=None):
"""
Per-basin **dissolved-solute flux** at the basin outlet, with a per-species
breakdown (Level-B geochemistry).
For every basin the outlet cell is the one where the **total** solute flux
peaks (the river mouth); the total flux and **each species' flux at that same
cell** are reported. The total defaults to the routed river dissolved load
``riverSolute`` (the basin's actual solute delivery), falling back to the raw
groundwater seepage export ``soluteflux``; per-species fields
(``<total>_<species>``, e.g. ``riverSolute_carbonate``) are auto-detected.
:arg ncfile: path (or open dataset) of a gridded ``gospl-grid`` surface.
:arg min_cells: basins with at most this many cells are ignored (default 10).
:arg solute_var: override the total solute-flux field.
:return: DataFrame ``basin,lon,lat,val,<species...>`` (``val`` = total solute
flux m³/yr at the outlet; one further column per species).
:raises KeyError: if the file carries no solute-flux field.
"""
import pandas as pd
if isinstance(ncfile, (str, os.PathLike)):
import netCDF4
with netCDF4.Dataset(os.fspath(ncfile)) as ds:
total, species, basin, lon, lat = _read_solute(
ds, list(ds.variables), solute_var, basin_var, lon_var, lat_var)
else:
total, species, basin, lon, lat = _read_solute(
ncfile, list(ncfile.variables), solute_var, basin_var, lon_var, lat_var)
mlon, mlat = np.meshgrid(lon, lat)
basin = basin.ravel()
total = total.ravel()
lonf = mlon.ravel()
latf = mlat.ravel()
spf = {k: v.ravel() for k, v in species.items()}
bint = np.where(np.isfinite(basin.astype(np.float64)), basin, -1).astype(np.int64)
sub = bint >= 0
b = bint[sub]
cols = ["basin", "lon", "lat", "val"] + sorted(species)
if b.size == 0:
return pd.DataFrame(columns=cols)
nb = int(b.max()) + 1
# Outlet = the cell of maximum TOTAL solute flux per basin; sample each
# species at that same cell so the breakdown is co-located (sums to `val`).
sb, sel = _group_argmax(b, total[sub], nb, min_cells)
data = {
"basin": sb.astype(np.int64),
"lon": lonf[sub][sel],
"lat": latf[sub][sel],
"val": total[sub][sel],
}
for name in sorted(species):
data[name] = spf[name][sub][sel]
return pd.DataFrame(data).sort_values("basin").reset_index(drop=True)
[docs]
def catchment_flux(index, outdir=None, min_cells=10, verbose=True,
flow_var=None, sed_var=None, basin_var=None,
lon_var=None, lat_var=None, solute_var=None):
"""
Batch :func:`basin_outflow` over a time series of gridded NetCDFs.
:arg index: a CSV path with columns ``time,netcdf`` (paths relative to the
CSV's directory are resolved), a ``pandas.DataFrame`` with those columns,
or an iterable of ``(time, ncfile)`` pairs.
:arg outdir: if given, write ``outdir/flow{time}.csv`` and
``outdir/sed{time}.csv`` (columns ``basin,lon,lat,val``), and — when the
files carry a Level-B solute flux — ``outdir/solute{time}.csv``
(``basin,lon,lat,val`` + one column per species).
:return: ``{time: {"flow": DataFrame, "sed": DataFrame[, "solute": DataFrame]}}``.
"""
import pandas as pd
from time import process_time
base = ""
if isinstance(index, (str, os.PathLike)):
base = os.path.dirname(os.path.abspath(os.fspath(index)))
rows = list(pd.read_csv(index)[["time", "netcdf"]].itertuples(
index=False, name=None))
elif isinstance(index, pd.DataFrame):
rows = list(index[["time", "netcdf"]].itertuples(index=False, name=None))
else:
rows = list(index)
if outdir is not None:
os.makedirs(outdir, exist_ok=True)
results = {}
for time, ncfile in rows:
t0 = process_time()
path = os.fspath(ncfile)
if base and not os.path.isabs(path) and not os.path.exists(path):
cand = os.path.join(base, path)
if os.path.exists(cand):
path = cand
if verbose:
print("\nOpen output", path, flush=True)
out = basin_outflow(path, min_cells=min_cells, flow_var=flow_var,
sed_var=sed_var, basin_var=basin_var,
lon_var=lon_var, lat_var=lat_var, solute_var=solute_var)
results[time] = out
if outdir is not None:
step = int(time) if float(time).is_integer() else time
out["flow"].to_csv(os.path.join(outdir, "flow%s.csv" % step),
index=False)
out["sed"].to_csv(os.path.join(outdir, "sed%s.csv" % step),
index=False)
if "solute" in out:
out["solute"].to_csv(os.path.join(outdir, "solute%s.csv" % step),
index=False)
if verbose:
extra = "" if "solute" not in out else " / %d solute" % len(out["solute"])
print(" + %d flow / %d sediment%s outlets (%.2f s)"
% (len(out["flow"]), len(out["sed"]), extra, process_time() - t0),
flush=True)
return results
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv=None):
p = argparse.ArgumentParser(
description="Extract per-basin outflow (max water-discharge and "
"max sediment-load cell) from gridded goSPL NetCDF output.",
add_help=True,
)
p.add_argument("-i", "--input", required=True,
help="index CSV with columns time,netcdf")
p.add_argument("-o", "--output", required=True,
help="output folder for flow{t}.csv / sed{t}.csv")
p.add_argument("--min-cells", type=int, default=10,
help="ignore basins with <= this many cells (default 10)")
p.add_argument("--flow-var", default=None,
help="water-discharge variable (default: FA; e.g. fillFA for "
"through-lake discharge; else flowDischarge)")
p.add_argument("--sed-var", default=None,
help="sediment-load variable (default: sedLoad, else sedimentLoad)")
p.add_argument("--basin-var", default=None,
help="basin-id variable (default: basin, else basinID)")
p.add_argument("--solute-var", default=None,
help="dissolved-solute-flux variable (default: riverSolute, "
"else soluteflux); writes solute{t}.csv with per-species "
"columns when present")
p.add_argument("-q", "--quiet", action="store_true", help="no per-file log")
args = p.parse_args(argv)
catchment_flux(args.input, args.output, min_cells=args.min_cells,
verbose=not args.quiet, flow_var=args.flow_var,
sed_var=args.sed_var, basin_var=args.basin_var,
solute_var=args.solute_var)
return 0
if __name__ == "__main__":
raise SystemExit(main())