Source code for mhm_tools.post.hydrograph

"""Plot discharge diagnostics for one or more mHM/mRM simulations.

The module reads simulated and optional observed discharge, derives catchment
metadata, computes summary metrics, and creates time-series, yearly,
seasonality, flow-duration, and scatter hydrograph plots.

Authors
-------
- Simon Lüdke
"""

import logging
import re
from pathlib import Path

import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
from matplotlib import gridspec

from mhm_tools.common.file_handler import get_xarray_ds_from_file
from mhm_tools.common.logger import ErrorLogger, log_arguments
from mhm_tools.common.metrics.metrics_handler import create_csv_from_dict
from mhm_tools.common.utils import dict_to_multiline_string

logger = logging.getLogger(__name__)


def _ensure_non_interactive_backend(show):
    """Switch to a non-interactive backend when plots are not shown.

    This avoids X/GUI backend issues in headless or multi-process runs.
    """
    if show:
        return
    try:
        plt.switch_backend("Agg")
    except Exception as exc:
        logger.debug(f"Failed to switch matplotlib backend to Agg: {exc}")


[docs] class Catchment: """Represents a catchment. Attributes ---------- name (str): The name of the catchment. area (float): The area of the catchment in square units. """ name = "" area = ""
[docs] class Objectives: """A class representing the objectives for evaluating river discharge data. Attributes ---------- kge (float): Kling-Gupta Efficiency. nse (float): Nash-Sutcliffe Efficiency. alpha (float): Alpha objective. beta (float): Beta objective. r (float): Pearson correlation coefficient. diff (float): Difference objective. rel_diff (float): Relative difference objective. """ kge = None nse = None alpha = None beta = None gamma = None diff = None rel_diff = None
[docs] class Hydrograph: """Represent a hydrograph and provide methods for calculating metrics and generating plots. Attributes ---------- levels (dict): A dictionary mapping log levels to their corresponding values. grid (list): A 2D list representing a grid of used and unused cells for plots. logger (logging.Logger): A logger object for logging messages. plots (list): A list indicating which plots to create. catchment (Catchment): An instance of the Catchment class. discharge_data (xarray.Dataset): A dataset containing discharge data. objectives (Objectives): An instance of the Objectives class. Methods ------- __init__(self): Initializes the Hydrograph object with the specified log level. remove_empty_values(arr1, arr2): Removes empty values from two arrays and returns the cleaned data. calc_kling_gupta_efficiency(observed, simulated): Calculates the Kling-Gupta efficiency metric. calc_nash_sutcliff_efficiency(observed, simulated): Calculates the Nash-Sutcliffe efficiency metric. calc_objectives(observed, simulated): Calculates various metrics based on observed and simulated data. get_row_col(self): Finds the first unused gridcell for the next plot. is_last_plot(self, n): Checks if the given plot is the last one. get_catchment_area(self, path, ndecimal=0): Retrieves the catchment area from a config file. get_long_time_monthly_mean(variable): Calculates the long-term average value for each month of the year. plot_on_axis(function, xvalues, yvalues, colors=None, labels=None, **arguments): Plots multiple graphs. check_which_plots_to_create(self, code): Determines which plots to create based on the given code. raise_if_not_directory(path): Raises an exception if the given path is not a directory. load_data_from_discharge_nc(self, path): Loads discharge data from the specified path. load_precipiation_data(self, path): Loads precipitation data from the specified path. gen_hydrograph(self, input_path, output_file, show, save, title, plot_code, prec_path): Generates a hydrograph. """ grid = None logging.basicConfig(format="%(asctime)s - %(levelname)-8s - %(message)s") logger = logging.getLogger(__name__) catchment = Catchment() sim_discharge_data = None obs_discharge_data = None sim_discharge_data_clean = None obs_discharge_data_clean = None sim_discharge_data_nonan = None obs_discharge_data_nonan = None pre = None objectives = Objectives() title = None output_file = None show = False save = False calc_stats = False # Colorblind-safe defaults: observed is red, simulations avoid reds. OBS_COLOR = "#D55E00" SIM_COLOR = "#0072B2" sim_name = None sim_names = None multi_sim_named = False sim_colors = None def __init__(self, simulation=None, observation=None, calc_stats=True): self.plots = [0, 0, 0, 0, 0] self.calc_stats = calc_stats self.objectives = Objectives() self.catchment = Catchment() self.sim_discharge_data_clean = None self.obs_discharge_data_clean = None self.sim_discharge_data_list = None self.sim_discharge_data_median = None self.sim_name = None self.sim_names = None self.multi_sim_named = False self.sim_colors = None if simulation is not None and observation is not None: self.set_discharge(simulation=simulation, observation=observation) def _get_sim_colors(self, n): """Return a list of distinct colors for simulations.""" if self.sim_colors is not None and len(self.sim_colors) >= n: return self.sim_colors[:n] # Colorblind-safe palette (no reds) to keep observed line distinct. colors = [ self.SIM_COLOR, # blue "#56B4E9", # sky blue "#009E73", # bluish green "#F0E442", # yellow "#CC79A7", # purple "#332288", # indigo "#117733", # dark green "#999933", # olive "#88CCEE", # light cyan "#000000", # black ] out = [colors[i % len(colors)] for i in range(n)] self.sim_colors = out return out def _infer_catchment_name(self): """Infer catchment name from available data if not set.""" if self.catchment.name: return def _from_name(name): if not name: return None ids = re.findall(r"(\d+)", str(name)) if not ids: return None return str(int(ids[-1].lstrip("0") or "0")) def _from_da(da): if da is None: return None name = _from_name(getattr(da, "name", None)) if name: return name if "id" in da.coords: try: ids = da.coords["id"].values if np.size(ids) == 1: return str(int(np.asarray(ids).item())) except Exception: return None return None for da in [self.obs_discharge_data, self.sim_discharge_data]: name = _from_da(da) if name: self.catchment.name = name return if self.sim_discharge_data_list: for da in self.sim_discharge_data_list: name = _from_da(da) if name: self.catchment.name = name return
[docs] def set_discharge(self, simulation=None, observation=None): """Set the discharge variables and remove nan values.""" if simulation is not None and observation is not None: self.sim_discharge_data = simulation self.obs_discharge_data = observation elif simulation is not None or observation is not None: msg = "Either one or none of the input must be via array." raise ValueError(msg) logger.debug(f"Simulation input data: {simulation}") logger.debug(f"Observation input data: {observation}") self.sim_discharge_data_nonan = self.sim_discharge_data.dropna( dim="time", how="all" ) self.obs_discharge_data_nonan = self.obs_discharge_data.dropna( dim="time", how="all" ) if ( self.sim_discharge_data_nonan.time.size == 0 or self.obs_discharge_data_nonan.time.size == 0 ): return False self.sim_discharge_data_clean = None self.obs_discharge_data_clean = None return True
[docs] def remove_empty_values(self, arr1, arr2, recursive=True): """Remove empty values from two arrays and return the cleaned data. Args: arr1 : list The first array. arr2 : list The second array. Returns ------- tuple A tuple containing the cleaned data arrays in the order they were given as arguments. """ arr1 = np.array(arr1) arr2 = np.array(arr2) if len(arr1) != len(arr2): exeption = f"The two timeseries do not have the same length. {arr1.shape} and {arr2.shape}" raise ValueError(exeption) data = np.transpose(np.array([arr1.flatten(), arr2.flatten()])) try: data = data[~np.isnan(data).any(1)] return data[:, 0], data[:, 1] except TypeError as te: if recursive: return self.remove_empty_values( [v if v is not None else np.nan for v in arr1], [v if v is not None else np.nan for v in arr2], recursive=False, ) msg = "While removing the empty values a wrong input type was detected." raise TypeError(msg) from te
[docs] def calc_kling_gupta_efficiency(self, observed, simulated): """Calculate the Kling-Gupta efficiency and store its components in objectives. Args: observed : list The observed discharge data. simulated : list The simulated discharge data. Returns ------- None """ alpha = np.nanstd(simulated) / np.nanstd(observed) beta = np.nanmean(simulated) / np.nanmean(observed) gamma = np.corrcoef(observed, simulated)[1, 0] self.objectives.kge = 1 - np.sqrt( (gamma - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2 ) self.objectives.alpha = alpha self.objectives.beta = beta self.objectives.gamma = gamma
[docs] def calc_nash_sutcliff_efficiency(self, observed, simulated): """Calculate the Nash-Sutcliffe efficiency and store it in objectives. Args: observed : list The observed discharge data. simulated : list The simulated discharge data. Returns ------- None """ self.objectives.nse = 1 - ( np.nansum((observed - simulated) ** 2) / np.nansum((observed - np.nanmean(observed)) ** 2) )
[docs] def calc_objectives(self, observed, simulated): """Clean the input arrays and calculate all objective metrics. Args: observed : list The observed discharge data. simulated : list The simulated discharge data. Returns ------- None """ if self.has_multi_sim(): if self.sim_discharge_data_median is None: self.sim_discharge_data_median = np.nanmedian( np.array(self.sim_discharge_data_list), axis=0 ) simulated = self.sim_discharge_data_median self.obs_discharge_data_clean, self.sim_discharge_data_clean = ( self.remove_empty_values(observed, simulated) ) if np.all(np.isnan(self.obs_discharge_data_clean)) or np.all( np.isnan(self.sim_discharge_data_clean) ): logger.warning( f"In calc objectives: obs is nan {np.all(np.isnan(self.obs_discharge_data_clean))} or sim is nan {np.all(np.isnan(self.sim_discharge_data_clean))}" ) return False self.calc_nash_sutcliff_efficiency( self.obs_discharge_data_clean, self.sim_discharge_data_clean ) self.calc_kling_gupta_efficiency( self.obs_discharge_data_clean, self.sim_discharge_data_clean ) self.logger.debug( f"sum simulated: {np.nansum(self.sim_discharge_data_clean)}, sum observed: {np.nansum(self.obs_discharge_data_clean)}" ) self.objectives.diff = np.nansum(self.sim_discharge_data_clean) - np.nansum( self.obs_discharge_data_clean ) self.objectives.rel_diff = self.objectives.diff / np.nansum( self.obs_discharge_data_clean ) return True
[docs] def get_row_col(self): """Find the first unused gridcell for the next plot. Returns ------- Tuple[int, int]: The row and column indices of the first unused gridcell. Raises ------ ValueError: If there are no unused gridcells left. """ for i, row in enumerate(self.grid): for j, v in enumerate(row): if not v: return i, j msg = "No unused gridcell left" raise ValueError(msg)
[docs] def has_multi_sim(self): """Return True if multiple simulation series are configured.""" return ( self.sim_discharge_data_list is not None and len(self.sim_discharge_data_list) > 1 )
[docs] def is_last_plot(self, n): """Check if the given plot index is the last plot. Args: - n (int): The plot index to check. Returns ------- - bool: True if the given plot index is the last plot, False otherwise. """ # finds the first unused gridcell for the next plot self.logger.debug(f"{n} is last plot {np.sum(self.plots[n:]) == 1}") return np.sum(self.plots[n:]) == 1
[docs] def get_catchment_area(self, path, ndecimal=0): """Retrieve the catchment area from the given mHM ConfigFile.log. Args: path (str): The path to the file. ndecimal (int, optional): The number of decimal places to round the catchment area to. Defaults to 0. Returns ------- None Raises ------ None """ config_file = Path(path / "ConfigFile.log") if config_file.exists(): with config_file.open() as f: doc = f.readlines() for line in doc[::-1]: if line.strip() and "Total[km2]" in line: self.catchment.area = f"{float(line.replace('Total[km2]', '').strip()):.{ndecimal}f}" self.logger.debug(f"Area: {self.catchment.area}") return self.logger.warning("Area could not be read.")
[docs] @staticmethod def get_long_time_monthly_mean(variable, long=False): """Calculate long-term monthly means for a time-indexed variable. Parameters ---------- variable : xarray.DataArray Data with a 'time' coordinate. long : bool, default False If True, prepend last month and append first month (for cyclic plots). Returns ------- numpy.ndarray Twelve monthly means (or 14 if `long` is True). """ var_ses = [[], [], [], [], [], [], [], [], [], [], [], []] for i in range(len(variable)): var_ses[int(variable.time[i].dt.month.data) - 1].append(variable[i]) var_ses = [np.nanmean(var_ses[i]) for i in range(12)] if long: var_ses = [var_ses[-1], *var_ses, var_ses[0]] return np.array(var_ses)
[docs] @staticmethod def plot_on_axis( function, yvalues: list, xvalues=None, colors=None, labels=None, **arguments ): """Plot multiple graphs for specified function. Args: function: matplotlib plot function e.g. ax.plot, plt.plot, ax.scatter, ax.errorbar, ... xvalues: list of x values yvalues: list of arrays with the y values colors: optional list of colors (default red, blue) labels: optional list of labels (default simulated discharge, observed discharge) arguments: other arguments relevant for the given input function. e.g. linewidth=0.5 for 'plt.plot' """ if labels is None: labels = ["simulated discharge", "observed discharge"] if colors is None: colors = [Hydrograph.SIM_COLOR, Hydrograph.OBS_COLOR] for i, yvalue in enumerate(yvalues): arguments["color"] = colors[i] if labels: arguments["label"] = labels[i] if xvalues is None: function(yvalue.time, yvalue, **arguments) elif len(xvalues) == len(yvalues): function(xvalues[i], yvalue, **arguments) else: function(xvalues, yvalue, **arguments)
[docs] def check_which_plots_to_create(self, code): """Determine which plots to create based on the given code. A tuple is produced and saved as member variable. It has a one at the index of the selected plots and a zero otherwise. The indices of the plots are: model timestep (0), yearly (1), seasonality (2), flow duration (3), scatter (4) Args: code (str): A string indicating which plots to create. Returns ------- None """ if not code: self.logger.warning("No plots will be produced since none were specified.") return if not self.save and not self.show: self.logger.info( 'No plots will be produced since both "save" and "show" are False.' ) return if "t" in code: self.plots[0] = 1 if "y" in code: self.plots[1] = 1 if "s" in code: self.plots[2] = 1 if "p" in code: self.plots[3] = 1 if "c" in code and self.calc_stats: self.plots[4] = 1
[docs] @staticmethod def raise_if_not_directory(path): """Raise a NotADirectoryError if the given path is not a directory. Args: path (str): The path to check. Raises ------ NotADirectoryError: If the given path is not a directory. """ p = Path(path) if not p.is_dir(): msg = f'The given path "{path}" is not a directory.' raise NotADirectoryError(msg)
[docs] def load_data_from_discharge_nc(self, path): """Load discharge data from the specified path. Args: path (str): The path to the directory containing the discharge data. Raises ------ TypeError: If the variable name in the discharge dataset is not a string. """ path = Path(path) discharge_file = path / "discharge.nc" if path.is_dir() else path if discharge_file.is_file(): with get_xarray_ds_from_file(discharge_file) as ds: discharge_data = ds.load() for v in discharge_data.variables: if not isinstance(v, str): msg = f"variable name is not a string - {v} - {type(v)}" raise TypeError(msg) if "sim" in v: self.catchment.name = str(int(v.split("_")[1])) self.sim_discharge_data = discharge_data[v] if "obs" in v: self.catchment.name = str(int(v.split("_")[1])) self.obs_discharge_data = discharge_data[v] self.set_discharge() return True return False
[docs] def load_precipiation_data(self, path): """Load precipitation data from a given file or directory path. Args: path (str): The path to the file or directory containing the precipitation data. Returns ------- xr.Dataset: The loaded precipitation data as an xarray Dataset object. Returns None if no precipitation file is found or if the path is invalid. """ if path is None: return path = Path(path) if path.is_dir(): if (path / "pre.nc").is_file(): path = path / "pre.nc" elif (path / "pre" / "pre.nc").is_file(): path = path / "pre" / "pre.nc" else: msg = f"no precipitation file found in the directory {path}" self.logger.warning(msg) return elif not path.is_file: msg = f"{path} is neither a directory nor a file" self.logger.warning(msg) return with get_xarray_ds_from_file(path) as ds: self.pre = ds.load()
[docs] def create_plot_at_timestep(self, fig, gs): """Create a discharge plot at the native temporal resolution (daily or hourly). Args: fig (matplotlib.figure.Figure): The figure object to add the plot to. gs (matplotlib.gridspec.GridSpec): The gridspec object specifying the subplot layout. Returns ------- None """ self.logger.info("generating discharge plot") r, c = self.get_row_col() ax1 = fig.add_subplot(gs[r, c:]) self.grid[r] = [True for _ in self.grid[r]] self.logger.debug(self.grid) # self.plot_on_axis( # function=ax1.scatter, # xvalues=self.sim_discharge_data["time"], # yvalues=[self.sim_discharge_data, self.obs_discharge_data], # s=1.0, # ) min_sim_time = None max_sim_time = None if self.has_multi_sim(): if self.multi_sim_named and self.sim_names: colors = self._get_sim_colors(len(self.sim_discharge_data_list)) for sim, name, color in zip( self.sim_discharge_data_list, self.sim_names, colors ): max_sim_time = ( sim.time.max().data if max_sim_time is None or sim.time.max().data < max_sim_time else max_sim_time # if multiple named simulations, use the earliest max time to set xlim ) min_sim_time = ( sim.time.min().data if min_sim_time is None or sim.time.min().data < min_sim_time else min_sim_time ) ax1.plot( sim.time, sim, color=color, linewidth=0.4, alpha=0.9, label=name, ) else: for sim in self.sim_discharge_data_list: max_sim_time = ( sim.time.max().data if max_sim_time is None or sim.time.max().data < max_sim_time else max_sim_time # if multiple unnamed simulations, use the earliest max time to set xlim ) min_sim_time = ( sim.time.min().data if min_sim_time is None or sim.time.min().data < min_sim_time else min_sim_time ) ax1.plot( sim.time, sim, color="0.7", linewidth=0.4, alpha=0.7, label=None, ) if self.sim_discharge_data_median is not None: ax1.plot( self.sim_discharge_data_median.time, self.sim_discharge_data_median, color=self.SIM_COLOR, linewidth=0.4, label="sim median", ) ax1.plot( self.obs_discharge_data.time, self.obs_discharge_data, color=self.OBS_COLOR, linewidth=0.4, label="observed discharge", ) else: max_sim_time = self.sim_discharge_data.time.max().data min_sim_time = self.sim_discharge_data.time.min().data self.plot_on_axis( function=ax1.plot, yvalues=[self.sim_discharge_data, self.obs_discharge_data], linewidth=0.4, labels=[ self.sim_name or "simulated discharge", "observed discharge", ], ) ax1.legend() title = "" if self.calc_stats: title = ( f"NSE = {self.objectives.nse:.2f}, " f"KGE = {self.objectives.kge:.2f}, " f"alpha = {self.objectives.alpha:.2f}, " f"beta = {self.objectives.beta:.2f}" ) if not self.plots[4]: title += f", r = {self.objectives.gamma:.2f}" ax1.set_title( title, horizontalalignment="center", ) ax1.set_ylabel(r"Q $[m^3 s^{-1}]$") # set xlim to the common time range of sim and obs, but only within the actual time range of the data (in case one has a shorter time range than the other) xmin = max( min_sim_time, self.obs_discharge_data_nonan.time.min(), ) xmax = min( max_sim_time, self.obs_discharge_data_nonan.time.max(), ) ax1.set_xlim(xmin, xmax) ax1.spines["top"].set_visible(False) ax1.spines["right"].set_visible(False) ax1.xaxis.set_major_formatter( mdates.ConciseDateFormatter(ax1.xaxis.get_major_locator()) )
[docs] def create_plot_yearly(self, fig, gs, pre): """Generate a yearly discharge plot. Args: fig (matplotlib.figure.Figure): The figure object to add the plot to. gs (matplotlib.gridspec.GridSpec): The grid specification for the plot layout. pre (xarray.DataArray): The precipitation data. Returns ------- None """ # calculate metrics at yearly resolution if np.all(np.isnan(self.sim_discharge_data)) or np.all( np.isnan(self.obs_discharge_data) ): logger.warning( "Cannot create yearly plot because one of the dataarrays is empty except for nan values." ) return if self.has_multi_sim(): sim_aligned = xr.align(*self.sim_discharge_data_list, join="inner") if self.multi_sim_named and self.sim_names: sim_discharge_yearly_list = [ sim.resample(time="YE").mean(skipna=True) for sim in sim_aligned ] sim_discharge_yearly = sim_discharge_yearly_list[0] sim_discharge_yearly_median = None else: sim_stack = xr.concat(sim_aligned, dim="member") sim_discharge_yearly = sim_stack.resample(time="YE").mean(skipna=True) sim_discharge_yearly_median = sim_discharge_yearly.median(dim="member") else: sim_discharge_yearly = self.sim_discharge_data_nonan.resample( time="YE" ).mean(skipna=True) obs_discharge_yearly = self.obs_discharge_data_nonan.resample(time="YE").mean( skipna=True ) time_yearly_sim = [int(y.dt.year.data) for y in sim_discharge_yearly.time] time_yearly_obs = [int(y.dt.year.data) for y in obs_discharge_yearly.time] years_combined = np.unique(time_yearly_sim + time_yearly_obs) years_combined.sort() if years_combined is None or len(years_combined) < 3: logger.warning( "Cannot create yearly plot because the data is insufficient." ) return self.logger.info("generating yearly discharge plot") r, c = self.get_row_col() self.logger.debug(f"yearly plot as row {r} and col {c}") if r == 0 or self.is_last_plot(1): outer_gs = gs[r, c:] self.grid[r] = [True for _ in self.grid[r]] else: outer_gs = gs[r, c] self.grid[r][c] = True self.logger.debug(self.grid) if pre is not None: inner_gs = gridspec.GridSpecFromSubplotSpec( 3, 1, subplot_spec=outer_gs, height_ratios=[1, 1, 1] ) ax2_pre = fig.add_subplot(inner_gs[0]) # ax2_pre.spines["top"].set_visible(False) ax2_pre.spines["right"].set_visible(False) ax2_pre.spines["bottom"].set_visible(False) # plot precipitation as bar plot pre_yearly = pre["pre"].resample(time="YE").mean(skipna=True) time_yearly_pre = [int(y.dt.year.data) for y in pre_yearly["time"]] pre_yearly = np.nanmean(pre_yearly, axis=(1, 2)) ax2_pre.bar( time_yearly_pre, -pre_yearly, color="darkblue", alpha=0.6, label="Precipitation", ) ax2_pre.set_ylim(-np.nanmax(pre_yearly) - np.nanmax(pre_yearly) / 6, 0) ax2_pre.set_yticklabels( [f"{int(abs(tick))}" for tick in ax2_pre.get_yticks()] ) ax2_pre.set_ylabel("pre [mm/day]", color="darkblue") ax2_pre.tick_params(axis="y", labelcolor="darkblue") ax2_pre.yaxis.set_label_position("right") ax2_pre.tick_params( top=False, labeltop=False, bottom=False, labelbottom=False ) inner_gs_update = gridspec.GridSpecFromSubplotSpec( 3, 1, subplot_spec=outer_gs, height_ratios=[0.75, 1.25, 1] ) ax2 = fig.add_subplot(inner_gs_update[1:], sharex=ax2_pre) if self.calc_stats: ax2_pre.set_title( f"sum(sim - obs) = {self.objectives.diff:.0f}$m^3$ or {self.objectives.rel_diff*100:.0f}%", horizontalalignment="center", ) else: ax2 = fig.add_subplot(outer_gs) if self.calc_stats: ax2.set_title( f"sum(sim - obs) = {self.objectives.diff:.0f}$m^3$ or {self.objectives.rel_diff*100:.0f}%", horizontalalignment="center", ) ax2.spines["top"].set_visible(False) ax2.spines["right"].set_visible(False) if self.has_multi_sim(): if self.multi_sim_named and self.sim_names: colors = self._get_sim_colors(len(sim_discharge_yearly_list)) for member, name, color in zip( sim_discharge_yearly_list, self.sim_names, colors ): ax2.plot( time_yearly_sim, member, color=color, linewidth=0.5, alpha=0.9, label=name, ) else: for member in sim_discharge_yearly: ax2.plot( time_yearly_sim, member, color="0.7", linewidth=0.6, alpha=0.7, ) if sim_discharge_yearly_median is not None: ax2.plot( [int(y.dt.year.data) for y in sim_discharge_yearly_median.time], sim_discharge_yearly_median, color=self.SIM_COLOR, linewidth=0.6, label="sim median", ) ax2.plot( time_yearly_obs, obs_discharge_yearly, color=self.OBS_COLOR, linewidth=0.5, label="observed discharge", ) else: self.plot_on_axis( function=ax2.scatter, xvalues=[time_yearly_sim, time_yearly_obs], yvalues=[sim_discharge_yearly, obs_discharge_yearly], s=1.0, ) self.plot_on_axis( function=ax2.plot, xvalues=[time_yearly_sim, time_yearly_obs], yvalues=[sim_discharge_yearly, obs_discharge_yearly], linewidth=0.6, labels=[ self.sim_name or "simulated discharge", "observed discharge", ], ) if r == 0: ax2.legend() ax2.set_ylabel(r"Q $[m^3 s^{-1}]$") ax2.set_xlim(np.min(years_combined), np.max(years_combined)) ax2.set_xticks( years_combined[:: len((years_combined) - np.min(years_combined)) // 3] )
[docs] def create_plot_seasonality(self, fig, gs, pre): """Generate a discharge seasonality plot. Args: fig (matplotlib.figure.Figure): The figure object to add the plot to. gs (matplotlib.gridspec.GridSpec): The grid specification for the plot layout. pre (dict): The precipitation data. Returns ------- None """ self.logger.info("generating discharge seasonality plot") if self.has_multi_sim(): sim_aligned = xr.align(*self.sim_discharge_data_list, join="inner") season_sim_all = [ self.get_long_time_monthly_mean(sim_aligned[i], long=True) for i in range(len(sim_aligned)) ] season_sim_all = np.array(season_sim_all) if self.multi_sim_named and self.sim_names: season_sim = season_sim_all[0] else: season_sim = np.nanmedian(season_sim_all, axis=0) else: season_sim = self.get_long_time_monthly_mean( self.sim_discharge_data, long=True ) # check that there are at least 4 monthly values that are not nan in both sim and obs to create the plot if ( np.sum(~np.isnan(season_sim)) < 4 or np.sum(~np.isnan(self.obs_discharge_data)) < 4 ): logger.warning( "Cannot create seasonality plot because there are less than 4 monthly values that are not nan in either sim or obs." ) return r, c = self.get_row_col() if r == 0 or self.is_last_plot(2): outer_gs = gs[r, c:] self.grid[r] = [True for _ in self.grid[r]] else: outer_gs = gs[r, c] self.grid[r][c] = True if pre is not None: inner_gs = gridspec.GridSpecFromSubplotSpec( 3, 1, subplot_spec=outer_gs, height_ratios=[1, 1, 1] ) ax3_pre = fig.add_subplot(inner_gs[0]) ax3_pre.set_title("Seasonality", horizontalalignment="center") # ax3_pre.spines["top"].set_visible(False) ax3_pre.spines["right"].set_visible(False) ax3_pre.spines["bottom"].set_visible(False) # plot precipitation as bar plot pre_monthly = self.get_long_time_monthly_mean(pre["pre"]) ax3_pre.bar( np.arange(1, 13), -pre_monthly, color="darkblue", alpha=0.6, label="Precipitation", ) ax3_pre.set_ylim(-np.nanmax(pre_monthly) - np.nanmax(pre_monthly) / 6, 0) # ax3_pre.set_yticks([-100, -75, -50, -25, 0]) # Set specific tick positions for clarity ax3_pre.set_yticklabels( [f"{int(abs(tick))}" for tick in ax3_pre.get_yticks()] ) ax3_pre.set_ylabel("pre [mm/day]", color="darkblue") ax3_pre.tick_params(axis="y", labelcolor="darkblue") # ax3_pre.set_xticks([]) # Hide x-ticks for the upper plot ax3_pre.yaxis.set_label_position("right") ax3_pre.tick_params( top=False, labeltop=False, bottom=False, labelbottom=False ) inner_gs_update = gridspec.GridSpecFromSubplotSpec( 3, 1, subplot_spec=outer_gs, height_ratios=[0.75, 1.25, 1] ) ax3 = fig.add_subplot(inner_gs_update[1:], sharex=ax3_pre) else: ax3 = fig.add_subplot(outer_gs) ax3.set_title("Seasonality", horizontalalignment="center") ax3.spines["top"].set_visible(False) ax3.spines["right"].set_visible(False) season_obs = self.get_long_time_monthly_mean(self.obs_discharge_data, long=True) self.logger.debug(f"sim: {season_sim}") self.logger.debug(f"osb: {season_obs}") if self.has_multi_sim(): if self.multi_sim_named and self.sim_names: colors = self._get_sim_colors(len(season_sim_all)) for series, name, color in zip(season_sim_all, self.sim_names, colors): ax3.plot( np.arange(0, 14), series, color=color, linewidth=0.5, alpha=0.9, label=name, ) else: for series in season_sim_all: ax3.plot( np.arange(0, 14), series, color="0.7", linewidth=0.6, alpha=0.7, ) ax3.plot( np.arange(0, 14), season_sim, color=self.SIM_COLOR, linewidth=0.6, label="sim median", ) ax3.plot( np.arange(0, 14), season_obs, color=self.OBS_COLOR, linewidth=0.5, label="observed discharge", ) else: self.plot_on_axis( function=ax3.scatter, xvalues=np.arange(0, 14), yvalues=[ season_sim, season_obs, ], s=1, ) self.plot_on_axis( function=ax3.plot, xvalues=np.arange(0, 14), yvalues=[season_sim, season_obs], linewidth=0.6, labels=[ self.sim_name or "simulated discharge", "observed discharge", ], ) if r == 0: ax3.legend() ax3.set_xlim(0.5, 12.5) ax3.set_xticks( np.arange(1, 13), ) ax3.set_ylabel(r"Q $[m^3 s^{-1}]$")
[docs] def create_plot_flow_duration(self, fig, gs): """Create a flow duration plot. Args: fig (matplotlib.figure.Figure): The figure object to add the plot to. gs (matplotlib.gridspec.GridSpec): The gridspec object specifying the subplot layout. Returns ------- None """ self.logger.info("generating flow duration plot") r, c = self.get_row_col() if r == 0 or self.is_last_plot(3): ax_p = fig.add_subplot(gs[r, c:]) self.grid[r] = [True for _ in self.grid[r]] else: ax_p = fig.add_subplot(gs[r, c]) self.grid[r][c] = True if self.has_multi_sim(): sim_series = [ np.array(sim).flatten() for sim in self.sim_discharge_data_list ] sim_series = [s[~np.isnan(s)] for s in sim_series] if self.multi_sim_named and self.sim_names: sim_median = None else: sim_median = np.array(self.sim_discharge_data_median).flatten() sim_median = sim_median[~np.isnan(sim_median)] else: sim_median = np.array(self.sim_discharge_data_nonan).flatten() sim_median = sim_median[~np.isnan(sim_median)] obs = np.array(self.obs_discharge_data_nonan).flatten() obs = obs[~np.isnan(obs)] if not (self.multi_sim_named and self.sim_names) and ( sim_median.size == 0 or obs.size == 0 ): self.logger.warning("Flow duration plot skipped: no valid discharge data.") ax_p.set_axis_off() return if self.has_multi_sim(): sim_sorted_all = [np.sort(s)[::-1] for s in sim_series if s.size > 0] if self.multi_sim_named and self.sim_names: colors = self._get_sim_colors(len(sim_sorted_all)) for series, name, color in zip(sim_sorted_all, self.sim_names, colors): sim_p = (np.arange(1, series.size + 1) / (series.size + 1)) * 100.0 ax_p.plot( sim_p, series, color=color, linewidth=0.6, alpha=0.9, label=name, ) else: for series in sim_sorted_all: sim_p = (np.arange(1, series.size + 1) / (series.size + 1)) * 100.0 ax_p.plot(sim_p, series, color="0.7", linewidth=0.6, alpha=0.7) sim_sorted = np.sort(sim_median)[::-1] sim_p = ( np.arange(1, sim_sorted.size + 1) / (sim_sorted.size + 1) ) * 100.0 ax_p.plot( sim_p, sim_sorted, color=self.SIM_COLOR, linewidth=0.6, label="sim median", ) obs_sorted = np.sort(obs)[::-1] obs_p = (np.arange(1, obs_sorted.size + 1) / (obs_sorted.size + 1)) * 100.0 ax_p.plot( obs_p, obs_sorted, color=self.OBS_COLOR, linewidth=0.5, label="observed discharge", ) else: sim_sorted = np.sort(sim_median)[::-1] obs_sorted = np.sort(obs)[::-1] sim_p = (np.arange(1, sim_sorted.size + 1) / (sim_sorted.size + 1)) * 100.0 obs_p = (np.arange(1, obs_sorted.size + 1) / (obs_sorted.size + 1)) * 100.0 self.plot_on_axis( function=ax_p.plot, xvalues=[sim_p, obs_p], yvalues=[sim_sorted, obs_sorted], linewidth=0.6, labels=[ self.sim_name or "simulated discharge", "observed discharge", ], ) if r == 0: ax_p.legend() ax_p.set_xlim(-0.5, 100) ax_p.set_xticks([0, 25, 50, 75, 100]) ax_p.set_ylim(-0.5, None) ax_p.set_xlabel("% time flow equaled or exceeded") ax_p.set_ylabel(r"Q $[m^3 s^{-1}]$") ax_p.set_title("Flow duration", horizontalalignment="center") ax_p.spines["top"].set_visible(False) ax_p.spines["right"].set_visible(False)
[docs] def create_plot_scatter(self, fig, gs): """Create a discharge plot at the native temporal resolution (daily or hourly). Args: fig (matplotlib.figure.Figure): The figure object to add the plot to. gs (matplotlib.gridspec.GridSpec): The gridspec object specifying the subplot layout. Returns ------- None """ self.logger.info("generating discharge scatter plot") r, c = self.get_row_col() if r == 0 or self.is_last_plot(4): self.logger.debug("scatter is last") ax4 = fig.add_subplot(gs[r, c:]) self.grid[r] = [True for _ in self.grid[r][c:]] else: ax4 = fig.add_subplot(gs[r, c]) self.grid[r][c] = True self.logger.debug(self.grid) # add linear regression line to scatterplot self.plot_on_axis( function=ax4.scatter, xvalues=self.obs_discharge_data_clean, yvalues=[self.sim_discharge_data_clean], s=50.0, colors=["black"], edgecolor="white", linewidth=0.6, alpha=0.1, labels=[self.sim_name or "simulated discharge"], ) xvalues = np.linspace(0, 1e6, 10000) self.plot_on_axis( function=ax4.plot, xvalues=xvalues, yvalues=[ # self.objectives.r * xvalues, xvalues, ], colors=[ "red", # "black" ], linewidth=0.5, ) if r == 0: ax4.legend() if self.calc_stats: ax4.set_title( f"correlation coeff r = {self.objectives.gamma:.2f}", horizontalalignment="center", ) ax4.set_xlabel("observed $[m^3 s^{-1}]$") # X Achsenbeschriftung ax4.set_ylabel("simulated $[m^3 s^{-1}]$") # Y Achsenbeschriftung ax4.spines["top"].set_visible(False) ax4.spines["right"].set_visible(False) lim = ( np.nanmax( [ np.nanmax(self.obs_discharge_data), np.nanmax(self.sim_discharge_data), ] ) * 1.1 ) lim = lim - lim % 5 if lim - lim % 5 >= lim / 1.1 else lim + 5 - lim % 5 ax4.set_xlim(0, lim) ax4.set_ylim(0, lim)
[docs] def crop_data_to_overlapping_time(self): """Crop data to overlapping time.""" t1 = self.sim_discharge_data.dropna(dim="time", how="all").time.values t2 = self.obs_discharge_data.dropna(dim="time", how="all").time.values # Find overlapping range only_nan_msg = "No non nan value data." if t1.any() and t2.any(): start = max(t1[0], t2[0]) end = min(t1[-1], t2[-1]) if end <= start: logger.warning( f"The two datasets are not overlapping. Sim data hass non nan data from {t1[0]} to {t1[-1]} and obs from {t2[0]} to {t2[-1]}." ) return False logger.info(f"Cropping data to timeframe {start} to {end}") # Slice both datasets to that time range self.sim_discharge_data = self.sim_discharge_data.sel( time=slice(start, end) ) self.obs_discharge_data = self.obs_discharge_data.sel( time=slice(start, end) ) if np.all(np.isnan(self.sim_discharge_data)) or np.all( np.isnan(self.sim_discharge_data) ): logger.warning( f"In crop data: obs is nan {not t1} or sim is nan {not t2}" ) raise ValueError(only_nan_msg) else: logger.warning( f"In crop data: obs is nan {t1.any()} or sim is nan {t2.any()}" ) raise ValueError(only_nan_msg) logger.info(f"sim_discharge_data: {self.sim_discharge_data.data}") logger.info(f"obs_discharge_data: {self.obs_discharge_data.data}") if self.pre is not None: self.pre = self.pre.sel(time=slice(start, end)) return True
[docs] def get_hydrograph(self): """Generate the hydrograph from the data stored in member variables. Returns ------- bool | None True on success, False/None if no plots are created or data are insufficient. """ _ensure_non_interactive_backend(self.show) # calculate metrics at timestep resolution (generally hourly) if self.calc_stats: logger.info("Crop to overlapping time.") if not self.crop_data_to_overlapping_time(): return False logger.info("Calculate objectives") if not self.calc_objectives( self.obs_discharge_data, self.sim_discharge_data ): return False if sum(self.plots) == 0: self.logger.warning("Create no plots") return True self._infer_catchment_name() # create figure and determining the number of rows and cols fig = plt.figure(figsize=(7, 8)) nrows = sum(self.plots) // 2 + 1 ncols = 2 self.logger.debug(f"nrows = {nrows} and ncols = {ncols}") # generate a grid indicating used and unused cells self.grid = [[False] * ncols for _ in range(nrows)] gs = fig.add_gridspec(nrows, ncols, width_ratios=[1, 1]) # write title logger.info(f"catchment name: {self.catchment.name}") fig.text( s=f"{self.catchment.name}", x=0.01, y=0.97, horizontalalignment="left", fontsize="x-large", ) if self.catchment.area: fig.text( s=f"Area = {self.catchment.area}" + r"$km^2$", x=0.5, y=0.97, horizontalalignment="center", fontsize="x-large", ) suptitle_text = f"\n{self.title}\n" # If seasonality is the only selected plot, show only alpha and beta in suptitle. if self.calc_stats and self.plots[2] and sum(self.plots) == 1: stats = ( f"alpha = {self.objectives.alpha:.2f}, " f"beta = {self.objectives.beta:.2f}" ) suptitle_text = ( f"\n{self.title}\n{stats}\n" if self.title else f"\n{stats}\n" ) fig.suptitle( t=suptitle_text, x=0.5, y=0.97, horizontalalignment="center", fontsize="x-large", ) # generate plots if self.plots[0]: self.create_plot_at_timestep(fig, gs) if self.plots[1]: self.create_plot_yearly(fig, gs, self.pre) if self.plots[2]: self.create_plot_seasonality(fig, gs, self.pre) if self.plots[3]: self.create_plot_flow_duration(fig, gs) if self.plots[4]: self.create_plot_scatter(fig, gs) plt.tight_layout() if self.save: # if ( # len(self.output_file.split("/")) == 1 # ): # by default the hydrograph is saved to the data directory # self.output_file = self.output_file if not self.output_file.parent.is_dir(): self.output_file.parent.mkdir(parents=True) fig.savefig(self.output_file, bbox_inches="tight", dpi=800) self.logger.info(f"saved hydrograph to {self.output_file}") if self.show: plt.show() else: plt.close() return True
[docs] def write_output(self): """Write calculated objective metrics to CSV.""" out_dict = {k: [v] for k, v in {**self.objectives.__dict__}.items()} if self.catchment.name is not None: out_dict["id"] = str(self.catchment.name) logger.info(f"generated metrics: {dict_to_multiline_string(out_dict)}") create_csv_from_dict(out_dict, self.output_file.parent / "kge.csv")
[docs] @log_arguments() def get_hydrograph_from_path( # noqa: PLR0912, PLR0915 input_path, output_file, show, save, title, plot_code, prec_path, sim_names=None ): """Read discharge data and produce a hydrograph with multiple analyses. Simulated and observed discharge are plotted for different temporal resolutions. Additionally, a seasonality plot and a scatter plot (simulated versus observed) are generated. """ input_paths = ( list(input_path) if isinstance(input_path, (list, tuple)) else [input_path] ) multi_input = len(input_paths) > 1 input_path = input_paths if multi_input else input_paths[0] def _normalize_names(names): if names is None: return None if isinstance(names, str): names = [names] names = list(names) if len(names) == 1 and "," in names[0]: names = [n.strip() for n in names[0].split(",") if n.strip()] return names or None sim_names = _normalize_names(sim_names) named_multi = bool(multi_input and sim_names and len(sim_names) == len(input_paths)) if multi_input and sim_names and not named_multi: logger.warning( f"Provided {len(sim_names)} names but {len(input_paths)} input paths; " "ignoring names." ) sim_names = None hydro = Hydrograph(calc_stats=not named_multi) hydro.show = show hydro.save = save hydro.check_which_plots_to_create(plot_code) if multi_input: hydro.plots[4] = 0 output_file = Path(output_file) if output_file.is_dir(): hydro.output_file = output_file / "hydrograph.png" elif output_file.suffix: output_file.parent.mkdir(parents=True, exist_ok=True) hydro.output_file = output_file else: # is non existing directory output_file.mkdir(parents=True, exist_ok=True) hydro.output_file = output_file / "hydrograph.png" hydro.title = title if not multi_input and sim_names: hydro.sim_name = sim_names[0] prec_path = Path(prec_path) if multi_input: input_paths = [Path(p) for p in input_path] sims = [] obs = None obs_var_name = None for path in input_paths: discharge_file = path / "discharge.nc" if path.is_dir() else path if not discharge_file.is_file(): msg = f"No discharge.nc file at {path}" with ErrorLogger(): raise ValueError(msg) with get_xarray_ds_from_file(discharge_file) as ds: discharge_data = ds.load() sim_var = None obs_var = None for v in discharge_data.variables: if not isinstance(v, str): msg = f"variable name is not a string - {v} - {type(v)}" raise TypeError(msg) if "sim" in v: sim_var = v if "obs" in v: obs_var = v if obs_var is None or sim_var is None: msg = f"Missing sim/obs variables in {discharge_file}" with ErrorLogger(): raise ValueError(msg) if obs_var_name is None: obs_var_name = obs_var obs = discharge_data[obs_var] # derive catchment name from obs variable (e.g. Qobs_0006342210) if hydro.catchment.name is None: ids = re.findall(r"(\d+)", obs_var_name) if ids: hydro.catchment.name = str(int(ids[-1].lstrip("0") or "0")) elif obs_var_name != obs_var: msg = ( "Observed discharge variable name differs between inputs: " f"{obs_var_name} vs {obs_var}" ) with ErrorLogger(): raise ValueError(msg) sims.append(discharge_data[sim_var]) sims_aligned = xr.align(*sims, join="inner") if named_multi: hydro.multi_sim_named = True hydro.sim_names = sim_names hydro.sim_discharge_data_list = list(sims_aligned) hydro.sim_discharge_data_median = None hydro.set_discharge(simulation=sims_aligned[0], observation=obs) else: sim_stack = xr.concat(sims_aligned, dim="member") sim_median = sim_stack.median(dim="member") hydro.set_discharge(simulation=sim_median, observation=obs) hydro.sim_discharge_data_list = list(sims_aligned) hydro.sim_discharge_data_median = sim_median hydro.get_catchment_area(input_paths[0], ndecimal=0) else: input_path = Path(input_path) if not hydro.load_data_from_discharge_nc(input_path): msg = f"No discharge.nc file at {input_path}" with ErrorLogger(): raise ValueError(msg) hydro.get_catchment_area(input_path, ndecimal=0) hydro.load_precipiation_data(prec_path) hydro.get_hydrograph() if named_multi: # compute per-simulation metrics and write to csv catchment_id = None if obs_var_name: ids = re.findall(r"(\d+)", obs_var_name) if ids: catchment_id = int(ids[-1].lstrip("0") or "0") metrics_rows = [] for sim, name in zip(hydro.sim_discharge_data_list, sim_names): h = Hydrograph(calc_stats=True) if not h.set_discharge(simulation=sim, observation=obs): continue try: if not h.crop_data_to_overlapping_time(): continue if not h.calc_objectives(h.obs_discharge_data, h.sim_discharge_data): continue except Exception: continue row = {**h.objectives.__dict__} if catchment_id is not None: row["id"] = catchment_id row["name"] = name metrics_rows.append(row) if metrics_rows: pd.DataFrame(metrics_rows).to_csv( hydro.output_file.parent / "kge.csv", index=False ) else: hydro.write_output()
[docs] @log_arguments() def gen_hydrograph_by_data_sets( simulations, observation, precipitation, output_file, area=None, plot_code="tysc", title="", show=False, save=True, id=None, calc_stats=False, raise_exceptions=True, **kwargs, ): """Create a hydrograph from xarray datasets for discharge and precipitation. Simulated and observed discharge are plotted at multiple temporal resolutions. Additionally, a seasonality plot and a scatter plot (simulated versus observed) are generated. """ hydro = Hydrograph(calc_stats=calc_stats) try: missing_data_error_msg = f"For {id} the hydrograph could not be created." if hydro.set_discharge(simulation=simulations, observation=observation): hydro.pre = precipitation hydro.output_file = Path(output_file) hydro.title = str(id) if not title and id is not None else title hydro.show = show hydro.save = save hydro.catchment.area = area hydro.check_which_plots_to_create(plot_code) if not hydro.get_hydrograph(): logger.debug( f"get_hydrograph returned False for {id}. Simulations: {simulations}, Observation: {observation}" ) logger.error(missing_data_error_msg) else: logger.debug( f"set_discharge returned False for {id}. Simulations: {simulations}, Observation: {observation}" ) logger.error(missing_data_error_msg) except Exception as e: logger.error(e) if raise_exceptions: raise e return {**hydro.objectives.__dict__, "id": id, **kwargs}