Databases - Hydrology#

This tutorial covers HydrologyDB, the database for river reaches. Each reach is a site, a point at its gauge or outlet, and it may have a catchment, the polygon that drains to it. Sites and catchments are two layers of the same GeoPackage. Every catchment needs a site, and a site has at most one catchment, but it may have none. That matters in practice: gauges are usually known from the start, while catchments arrive over time, as someone draws or generates them.

The example is a river with six gauges, of which a regional dataset covers only three.

See also

Check out the Databases - Spatial tutorial first, which introduces the columns_map and setup files.

Notebook setup#

For users running this tutorial as a Jupyter Notebook, this cell must be executed first:

import sys
import json
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import geopandas as gpd
from shapely.geometry import Point, box

# Install `plans` in `google.colab`.
# Use `pip install plans` for other environments.

if "google.colab" in sys.modules:
    import os
    os.system(f"{sys.executable} -m pip install -q plans")

from plans.databases import HydrologyDB

# This avoids warnings related to uninstalled fonts
import logging
# Set the matplotlib font manager logger to only show errors (hides warnings)
logging.getLogger('matplotlib.font_manager').setLevel(logging.ERROR)

# define output folder
OUTPUT_DIR = Path("outputs/databases")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"Outputs will be saved to: ./{OUTPUT_DIR}")
Outputs will be saved to: ./outputs/databases

The sources#

The gauges run along the river from west to east. Each catchment is simplified to a box, from the headwaters down to its gauge, so catchments overlap as they nest downstream. Lets keep a helper that makes one, with its area, in a metric CRS. The gauges come in one file, and the regional dataset in another, in a different CRS. It holds three of our gauges and two from another river:

CRS = "EPSG:4674"  # SIRGAS 2000, geographic
CRS_METRIC = "EPSG:31982"  # SIRGAS 2000 / UTM zone 22S

codes = [f"R00{i}" for i in range(1, 7)]
x_gauge = dict(zip(codes, [-53.0, -52.5, -52.0, -51.5, -51.0, -50.5]))


def make_catchment(code, column="Code"):
    # a box from the headwaters to the gauge, with its area in km2
    gdf = gpd.GeoDataFrame({column: [code]}, geometry=[box(-53.5, -30.4, x_gauge.get(code, -53.2), -29.6)], crs=CRS)
    return gdf.assign(Area=gdf.to_crs(CRS_METRIC).area.round(-6) / 1e6)


gauges = gpd.GeoDataFrame(
    data={"GaugeCode": codes, "GaugeName": ["Upper", "Bend", "Mill", "Bridge", "Ferry", "Mouth"]},
    geometry=[Point(x_gauge[c], -30.0) for c in codes],
    crs=CRS,
)
file_gauges = OUTPUT_DIR / "gauges.gpkg"
gauges.to_file(filename=file_gauges, layer="gauges", driver="GPKG")

regional = pd.concat([make_catchment(code=c, column="GaugeCode") for c in ["R001", "R002", "R003", "R901", "R902"]], ignore_index=True)
regional = regional.rename(columns={"Area": "AreaKm2"}).assign(Survey=2019).to_crs(CRS_METRIC)
file_regional = OUTPUT_DIR / "regional.gpkg"
regional.to_file(filename=file_regional, layer="catchments", driver="GPKG")
regional.drop(columns="geometry")
GaugeCode AreaKm2 Survey
0 R001 4280.0 2019
1 R002 8558.0 2019
2 R003 12834.0 2019
3 R901 2568.0 2019
4 R902 2568.0 2019

Two datasets will live here. Streamflow is measured at the gauge, while rainfall is averaged over the catchment. Both are records of the site, and the extent of the spec tells which is which:

def make_spec(name, abstract, variable, statistic, source, extent):
    # one monthly spec
    return {
        "name": name,
        "abstract": abstract,
        "method": "synthetic",
        "timestep": "1M",
        "extent": extent,
        "scale": 1,
        "offset": 0,
        "start": "2016-01-01",
        "end": None,
        "variable_name": variable,
        "statistic_name": statistic,
        "source_name": source,
    }


specs = [
    make_spec("Q_MONTHLY", "Monthly mean streamflow", "standard streamflow", "mean", "ANA", "gauge"),
    make_spec("PPT_MONTHLY_AREAL", "Monthly rainfall over the catchment", "precipitation", "sum", "CHIRPS v2.0", "catchment"),
]
file_specs = OUTPUT_DIR / "reaches_specs.json"
_ = file_specs.write_text(json.dumps(specs, indent=2), encoding="utf-8")

Creating the database#

The setup takes an optional catchments section, read like spatial. In its columns_map, the name entry is the source column holding the site names, here also called GaugeCode, and each catchment takes the name of its site. As anywhere in a setup, it can be a dict or a file:

[database]
path = "outputs/databases/reaches_db.gpkg"

[specs]
path = "outputs/databases/reaches_specs.json"

[spatial]
file = "outputs/databases/gauges.gpkg"
layer = "gauges"

[spatial.columns_map]
name = "GaugeCode"
gauge_title = "GaugeName"

[catchments]
file = "outputs/databases/regional.gpkg"
layer = "catchments"

[catchments.columns_map]
name = "GaugeCode"
area_km2 = "AreaKm2"
{
  "database": {"path": "outputs/databases/reaches_db.gpkg"},
  "specs": {"path": "outputs/databases/reaches_specs.json"},
  "spatial": {
    "file": "outputs/databases/gauges.gpkg",
    "layer": "gauges",
    "columns_map": {
      "name": "GaugeCode",
      "gauge_title": "GaugeName"
    }
  },
  "catchments": {
    "file": "outputs/databases/regional.gpkg",
    "layer": "catchments",
    "columns_map": {
      "name": "GaugeCode",
      "area_km2": "AreaKm2"
    }
  }
}

When catchments are not available yet, the section is simply left out, and the layer starts empty. Lets write the TOML version and pass its path to .new():

file_setup = OUTPUT_DIR / "reaches_setup.toml"
file_setup.write_text(
    f"""[database]
path = "{(OUTPUT_DIR / 'reaches_db.gpkg').as_posix()}"

[specs]
path = "{file_specs.as_posix()}"

[spatial]
file = "{file_gauges.as_posix()}"
layer = "gauges"

[spatial.columns_map]
name = "GaugeCode"
gauge_title = "GaugeName"

[catchments]
file = "{file_regional.as_posix()}"
layer = "catchments"

[catchments.columns_map]
name = "GaugeCode"
area_km2 = "AreaKm2"
""",
    encoding="utf-8",
)
db = HydrologyDB(name="River")
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    db.new(config=file_setup, overwrite=True)
print(*[w.message for w in caught], sep="\n")
2 feature(s) naming no site were skipped

The two features from the other river name no site, so they were skipped with a warning. With skip_unmatched = false in the section, they would be an error instead. If nothing matched at all, it is an error either way, since that usually means the name entry points to the wrong column.

The layer holds what matched, reprojected to the CRS of the sites. The Survey column stayed out, as it is not in the map. The .missing_catchments() method tells which sites still have none:

print("Missing:", db.missing_catchments())
db.get_catchments().drop(columns="geometry")
Missing: ['R004', 'R005', 'R006']
id name area_km2
0 1 R001 4280.0
1 2 R002 8558.0
2 3 R003 12834.0
def plot_network(db):
    # shaded catchments, with the gauges on top
    fig, ax = plt.subplots(figsize=(8, 3))
    db.get_catchments().plot(ax=ax, facecolor="tab:blue", edgecolor="tab:blue", alpha=0.15)
    sites = db.get_features()
    sites.plot(ax=ax, color="black", markersize=20, zorder=3)
    for site in sites.itertuples():
        ax.annotate(site.name, (site.geometry.x, site.geometry.y), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=8)
    ax.set_xlim(-53.7, -50.3)
    ax.set_ylim(-30.6, -29.4)
    plt.tight_layout()
    plt.show()


plot_network(db=db)
../_images/49ecab177a2f510c972c248d7b3d355b004d2e81cf24492f23547f2b310534d9.png

Adding catchments later#

The .append_catchment() method adds one catchment to one site, and keeps the geometry apart from the attributes. The geometry always comes from a spatial file. Attributes may come from that file or from a dict, and anything left out stays empty.

A drawn polygon#

Someone draws the catchment of R004 in a GIS and saves it. The file holds one feature, with no code and no area, so the site is given by name, and the area by value. The polygon comes in another CRS here, and is reprojected to the layer:

drawn = make_catchment(code="R004").to_crs(CRS_METRIC)
area_drawn = float(drawn["Area"].iloc[0])
file_drawn = OUTPUT_DIR / "drawn.gpkg"
drawn[["geometry"]].to_file(filename=file_drawn, driver="GPKG")

db.append_catchment(name="R004", file=file_drawn, attributes={"area_km2": area_drawn})
db.get_catchments(names="R004").drop(columns="geometry")
id name area_km2
0 4 R004 17110.0

One feature of a larger file#

Later, a national dataset with many catchments comes along. With name in the columns_map, that source column picks the feature. The method first reads only that column to find the row, and then reads only that row, so the rest of the file is never loaded. No match, or more than one, is an error:

national = pd.concat([make_catchment(code=c) for c in ["R005", "R006", "R903", "R904"]], ignore_index=True)
file_national = OUTPUT_DIR / "national.gpkg"
national.to_file(filename=file_national, layer="all", driver="GPKG")

db.append_catchment(
    name="R005",
    file=file_national,
    layer="all",
    columns_map={"name": "Code", "area_km2": "Area"},
)
db.get_catchments(names="R005").drop(columns="geometry")
id name area_km2
0 5 R005 21385.0

Generated in Python#

A catchment generated by a script is already a GeoDataFrame, so .insert_catchments() takes it directly. It also takes many at once, which suits bulk loads better than repeated appends, and skips features naming no site, as at creation:

generated = make_catchment(code="R006")
db.insert_catchments(gdf=generated, columns_map={"name": "Code", "area_km2": "Area"})
print("Missing:", db.missing_catchments())
plot_network(db=db)
Missing: []
../_images/007d9b884aa84b68f41e5320a9f529c1cc7ac2eac33c1a802ef02ca006e4dbdf.png

What is refused#

Catchments without a site and second catchments for a site are refused. These rules also live in the file itself, as a unique index and a trigger, so they hold for any tool writing through GDAL:

for name in ["R999", "R001"]:
    try:
        db.append_catchment(name=name, file=file_drawn)
    except ValueError as error:
        print(error)
Name 'R999' not in the sites layer
Site 'R001' already has a catchment

Replacing and new attributes#

A better delineation replaces the old one with overwrite=True. By default, the layer keeps the columns it was created with, so an unknown attribute is refused, and a typo does not become a column. With add_columns=True, the column is added, typed from its value, and the other rows stay empty:

values = {"area_km2": area_drawn, "delineation": "manual"}
try:
    db.append_catchment(name="R004", file=file_drawn, attributes=values, overwrite=True)
except ValueError as error:
    print(error)

db.append_catchment(name="R004", file=file_drawn, attributes=values, overwrite=True, add_columns=True)
db.get_catchments().drop(columns="geometry")
Column(s) ['delineation'] not in the catchments layer
id name area_km2 delineation
0 1 R001 4280.0 NaN
1 2 R002 8558.0 NaN
2 3 R003 12834.0 NaN
3 5 R005 21385.0 NaN
4 6 R006 25660.0 NaN
5 8 R004 17110.0 manual

A replacement is written as a new row, so the catchment takes a new id. Nothing depends on it: catchments are linked to sites by name, and the id of a catchment is not the id of its site.

Records#

Records always point to the site, by site_id or site_name, whichever spec they belong to. Rainfall falls over the catchment and streamflow is read at the gauge, but both are loaded the same way:

rng = np.random.default_rng(seed=5)  # ensure reproducibility
dates = pd.date_range(start="2016-01-01", end="2020-12-01", freq="MS")
season = np.cos(2 * np.pi * (dates.month - 1) / 12)
seconds = dates.days_in_month * 86400

areas = db.get_catchments().set_index("name")["area_km2"]
rain = 130 * (1 - 0.3 * season) * rng.gamma(shape=6, scale=1 / 6, size=len(dates))
frames_q, frames_p = [], []
for code in codes:
    flow = areas[code] * 1e6 * 0.4 * rain / 1000 / seconds
    frames_q.append(pd.DataFrame({"datetime": dates, "value": flow, "site_name": code}))
    frames_p.append(pd.DataFrame({"datetime": dates, "value": rain * rng.normal(1, 0.03, len(dates)), "site_name": code}))
with db.transaction():
    db.insert_records(dataframe=pd.concat(frames_q, ignore_index=True), spec_name="Q_MONTHLY", flag=1)
    db.insert_records(dataframe=pd.concat(frames_p, ignore_index=True), spec_name="PPT_MONTHLY_AREAL", flag=1)
db.query_records(query_dict={"site_name": "R004", "start": "2016-01-01", "end": "2016-02-01"})[["datetime", "spec_name", "value", "site_id"]]
datetime spec_name value site_id
0 2016-01-01 00:00:00+00:00 Q_MONTHLY 153.624864 4
1 2016-02-01 00:00:00+00:00 Q_MONTHLY 223.226031 4
2 2016-01-01 00:00:00+00:00 PPT_MONTHLY_AREAL 58.200084 4
3 2016-02-01 00:00:00+00:00 PPT_MONTHLY_AREAL 84.652797 4

Joining the three pieces goes through the names. Sites map site_id to name, and catchments map name to their attributes. Dividing the mean flow by the area gives the specific discharge, which is about the same along the river, as this synthetic runoff should be:

flow = db.query_records(query_dict={"spec_name": "Q_MONTHLY"}).groupby("site_id")["value"].mean().rename("q_m3s")
table = (
    db.get_features()[["id", "name", "gauge_title"]]
    .merge(flow, left_on="id", right_index=True)
    .merge(db.get_catchments()[["name", "area_km2"]], on="name", how="left")
)
table["q_ls_km2"] = 1000 * table["q_m3s"] / table["area_km2"]
table.round(1)
id name gauge_title q_m3s area_km2 q_ls_km2
0 1 R001 Upper 80.9 4280.0 18.9
1 2 R002 Bend 161.7 8558.0 18.9
2 3 R003 Mill 242.5 12834.0 18.9
3 4 R004 Bridge 323.3 17110.0 18.9
4 5 R005 Ferry 404.1 21385.0 18.9
5 6 R006 Mouth 484.9 25660.0 18.9

The left join keeps every site, so a site still missing its catchment would show an empty area, instead of dropping out of the table.

The file holds both layers, and both open in QGIS next to the tables of the database:

db.close()
gpd.list_layers(OUTPUT_DIR / "reaches_db.gpkg")
name geometry_type
0 sites Point
1 catchments MultiPolygon
2 variables NaN
3 flags NaN
4 sources NaN
5 statistics NaN
6 specs NaN
7 records NaN