Skip to content

hydro_plot - Visualization Tools

The hydro_plot module provides specialized plotting functions for visualizing hydrological data and analysis results.

Overview

This module contains functions for:

  • Time Series Plots: Visualize streamflow, precipitation, and other time series data
  • Statistical Plots: Create plots for model performance assessment
  • Flow Analysis Plots: Flow duration curves, hydrographs, and flow statistics
  • Comparison Plots: Side-by-side comparisons of observed vs simulated data

Quick Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import hydroutils as hu
import pandas as pd
import numpy as np

# Sample data
dates = pd.date_range('2020-01-01', '2020-12-31', freq='D')
observed = 10 + 5 * np.sin(2 * np.pi * np.arange(len(dates)) / 365.25)
simulated = observed * 0.95 + np.random.normal(0, 1.5, len(dates))

# Create time series plot
fig, ax = hu.plot_timeseries(
    dates, observed, simulated,
    labels=['Observed', 'Simulated'],
    title='Streamflow Comparison'
)

# Create performance scatter plot
fig, ax = hu.plot_scatter_performance(
    observed, simulated,
    add_stats=True,  # Add NSE, R², etc.
    add_1to1_line=True
)

API Reference

Author: Wenyu Ouyang Date: 2022-12-02 10:59:30 LastEditTime: 2025-08-02 11:58:30 LastEditors: Wenyu Ouyang Description: Some common plots for hydrology FilePath: \hydroutils\hydroutils\hydro_plot.py Copyright (c) 2021-2022 Wenyu Ouyang. All rights reserved.

create_median_labels(ax, medians_value, percent25value=None, percent75value=None, size='small')

"create median labels for boxes in a boxplot Parameters


ax : plt.AxesSubplot an ax in a fig medians_value : np.array description percent25value : type, optional description, by default None percent75value : type, optional description, by default None size : str, optional the size of median-value labels, by default small

Source code in hydroutils/hydro_plot.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
def create_median_labels(
    ax, medians_value, percent25value=None, percent75value=None, size="small"
):
    """ "create median labels for boxes in a boxplot
    Parameters
    ----------
    ax : plt.AxesSubplot
        an ax in a fig
    medians_value : np.array
        _description_
    percent25value : _type_, optional
        _description_, by default None
    percent75value : _type_, optional
        _description_, by default None
    size : str, optional
        the size of median-value labels, by default small
    """
    decimal_places = "2"
    if percent25value is None or percent75value is None:
        vertical_offset = np.min(medians_value * 0.01)  # offset from median for display
    else:
        per25min = np.min(percent25value)
        per75max = np.max(percent75value)
        vertical_offset = (per75max - per25min) * 0.01
    median_labels = [format(s, f".{decimal_places}f") for s in medians_value]
    pos = range(len(medians_value))
    for xtick in ax.get_xticks():
        ax.text(
            pos[xtick],
            medians_value[xtick] + vertical_offset,
            median_labels[xtick],
            horizontalalignment="center",
            color="w",
            # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.text.html
            size=size,
            weight="semibold",
        )

plot_boxes_matplotlib(data, label1=None, label2=None, leg_col=None, colorlst='rbgcmywrbgcmyw', title=None, figsize=(8, 6), sharey=False, xticklabel=None, axin=None, ylim=None, ylabel=None, notch=False, widths=0.5, subplots_adjust_wspace=0.2, show_median=True, median_line_color='black', median_font_size='small')

Create multiple boxplots for comparing multiple indicators.

This function creates a figure with multiple boxplots, each representing a different indicator. It supports customization of appearance, labels, and layout, and can display median values and other statistics.

Parameters:

Name Type Description Default
data list

List of data arrays, each element represents one indicator and can contain multiple numpy arrays for box comparison.

required
label1 list

Names for each subplot. Defaults to None.

None
label2 list

Legend names for boxes within each subplot. Same across all subplots. Defaults to None.

None
leg_col int

Number of columns in the legend. Defaults to None.

None
colorlst str

String of color characters for boxes. Defaults to "rbgcmywrbgcmyw".

'rbgcmywrbgcmyw'
title str

Figure title. Defaults to None.

None
figsize tuple

Figure size as (width, height). Defaults to (8, 6).

(8, 6)
sharey bool

If True, all subplots share y-axis scale. Defaults to False.

False
xticklabel list

Labels for x-axis ticks. Defaults to None.

None
axin Axes

Existing axes to plot on. Defaults to None.

None
ylim list

Y-axis limits [min, max]. Defaults to None.

None
ylabel list

Y-axis labels for each subplot. Defaults to None.

None
notch bool

If True, boxes will have notches. Defaults to False.

False
widths float

Width of the boxes. Defaults to 0.5.

0.5
subplots_adjust_wspace float

Width space between subplots. Defaults to 0.2.

0.2
show_median bool

If True, show median values above boxes. Defaults to True.

True
median_line_color str

Color of median lines. Defaults to "black".

'black'
median_font_size str

Font size for median values. Defaults to "small".

'small'

Returns:

Type Description

Union[plt.Figure, Tuple[plt.Axes, dict]]: If axin is None, returns the

figure object. Otherwise, returns a tuple of (axes, boxplot_dict).

Example

data = [np.random.normal(0, 1, 100), np.random.normal(2, 1, 100)] fig = plot_boxes_matplotlib(data, ... label1=['Group A'], ... label2=['Sample 1', 'Sample 2'], ... show_median=True)

Source code in hydroutils/hydro_plot.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def plot_boxes_matplotlib(
    data: list,
    label1: list = None,
    label2: list = None,
    leg_col: int = None,
    colorlst="rbgcmywrbgcmyw",
    title=None,
    figsize=(8, 6),
    sharey=False,
    xticklabel=None,
    axin=None,
    ylim=None,
    ylabel=None,
    notch=False,
    widths=0.5,
    subplots_adjust_wspace=0.2,
    show_median=True,
    median_line_color="black",
    median_font_size="small",
):
    """Create multiple boxplots for comparing multiple indicators.

    This function creates a figure with multiple boxplots, each representing a
    different indicator. It supports customization of appearance, labels, and
    layout, and can display median values and other statistics.

    Args:
        data (list): List of data arrays, each element represents one indicator
            and can contain multiple numpy arrays for box comparison.
        label1 (list, optional): Names for each subplot. Defaults to None.
        label2 (list, optional): Legend names for boxes within each subplot.
            Same across all subplots. Defaults to None.
        leg_col (int, optional): Number of columns in the legend. Defaults to None.
        colorlst (str, optional): String of color characters for boxes.
            Defaults to "rbgcmywrbgcmyw".
        title (str, optional): Figure title. Defaults to None.
        figsize (tuple, optional): Figure size as (width, height).
            Defaults to (8, 6).
        sharey (bool, optional): If True, all subplots share y-axis scale.
            Defaults to False.
        xticklabel (list, optional): Labels for x-axis ticks. Defaults to None.
        axin (matplotlib.axes.Axes, optional): Existing axes to plot on.
            Defaults to None.
        ylim (list, optional): Y-axis limits [min, max]. Defaults to None.
        ylabel (list, optional): Y-axis labels for each subplot. Defaults to None.
        notch (bool, optional): If True, boxes will have notches.
            Defaults to False.
        widths (float, optional): Width of the boxes. Defaults to 0.5.
        subplots_adjust_wspace (float, optional): Width space between subplots.
            Defaults to 0.2.
        show_median (bool, optional): If True, show median values above boxes.
            Defaults to True.
        median_line_color (str, optional): Color of median lines.
            Defaults to "black".
        median_font_size (str, optional): Font size for median values.
            Defaults to "small".

    Returns:
        Union[plt.Figure, Tuple[plt.Axes, dict]]: If axin is None, returns the
        figure object. Otherwise, returns a tuple of (axes, boxplot_dict).

    Example:
        >>> data = [np.random.normal(0, 1, 100), np.random.normal(2, 1, 100)]
        >>> fig = plot_boxes_matplotlib(data,
        ...                           label1=['Group A'],
        ...                           label2=['Sample 1', 'Sample 2'],
        ...                           show_median=True)
    """
    nc = len(data)
    if axin is None:
        fig, axes = plt.subplots(
            ncols=nc, sharey=sharey, figsize=figsize, constrained_layout=False
        )
    else:
        axes = axin

    # the next few lines are for showing median values
    decimal_places = "2"
    for k in range(nc):
        ax = axes[k] if nc > 1 else axes
        temp = data[k]
        if type(temp) is list:
            for kk in range(len(temp)):
                tt = temp[kk]
                if tt is not None and len(tt) > 0:
                    tt = tt[~np.isnan(tt)]
                    temp[kk] = tt
                else:
                    temp[kk] = []
        else:
            temp = temp[~np.isnan(temp)]
        bp = ax.boxplot(
            temp, patch_artist=True, notch=notch, showfliers=False, widths=widths
        )
        for median in bp["medians"]:
            median.set_color(median_line_color)
        medians_value = [np.median(tmp) for tmp in temp]
        percent25value = [np.percentile(tmp, 25) for tmp in temp]
        percent75value = [np.percentile(tmp, 75) for tmp in temp]
        per25min = np.min(percent25value)
        per75max = np.max(percent75value)
        median_labels = [format(s, f".{decimal_places}f") for s in medians_value]
        pos = range(len(medians_value))
        if show_median:
            for tick, label in zip(pos, ax.get_xticklabels()):
                # params of ax.text could be seen here: https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.text.html
                ax.text(
                    pos[tick] + 1,
                    medians_value[tick] + (per75max - per25min) * 0.01,
                    median_labels[tick],
                    horizontalalignment="center",
                    # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.text.html
                    size=median_font_size,
                    weight="semibold",
                    color=median_line_color,
                )
        for kk in range(len(bp["boxes"])):
            plt.setp(bp["boxes"][kk], facecolor=colorlst[kk])

        if label1 is not None:
            ax.set_xlabel(label1[k])
        else:
            ax.set_xlabel(str(k))
        if xticklabel is None:
            ax.set_xticks([])
        else:
            ax.set_xticks([y + 1 for y in range(0, len(data[k]), 2)])
            ax.set_xticklabels(xticklabel)
        if ylabel is not None:
            ax.set_ylabel(ylabel[k])
        if ylim is not None:
            ax.set_ylim(ylim[k])
    if label2 is not None:
        plt.legend(
            bp["boxes"],
            label2,
            # explanation for bbox_to_anchor: https://zhuanlan.zhihu.com/p/101059179
            bbox_to_anchor=(1.0, 1.02, 0.25, 0.05),
            loc="upper right",
            borderaxespad=0,
            ncol=len(label2) if leg_col is None else leg_col,
            frameon=False,
            fontsize=12,
        )
    if title is not None:
        # fig.suptitle(title)
        ax.set_title(title)
    plt.tight_layout()
    plt.subplots_adjust(wspace=subplots_adjust_wspace)
    return fig if axin is None else (ax, bp)

plot_boxs(data, x_name, y_name, uniform_color=None, swarm_plot=False, hue=None, colormap=False, xlim=None, ylim=None, order=None, font='serif', rotation=45, show_median=False)

plot multiple boxes in one ax with seaborn Parameters


data : pd.DataFrame a tidy pandas dataframe; if you don't know what is "tidy data", please read: https://github.com/jizhang/pandas-tidy-data x_name : str the names of each box y_name : str what is shown uniform_color : str, optional unified color for all boxes, by default None swarm_plot : bool, optional description, by default False hue : type, optional description, by default None colormap : bool, optional description, by default False xlim : type, optional description, by default None ylim : type, optional description, by default None order : type, optional description, by default None font : str, optional description, by default "serif" rotation : int, optional rotation for labels in x-axis, by default 45 show_median: bool, optional if True, show median value for each box, by default False Returns


type description

Source code in hydroutils/hydro_plot.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
def plot_boxs(
    data: pd.DataFrame,
    x_name: str,
    y_name: str,
    uniform_color=None,
    swarm_plot=False,
    hue=None,
    colormap=False,
    xlim=None,
    ylim=None,
    order=None,
    font="serif",
    rotation=45,
    show_median=False,
):
    """plot multiple boxes in one ax with seaborn
    Parameters
    ----------
    data : pd.DataFrame
        a tidy pandas dataframe;
        if you don't know what is "tidy data", please read: https://github.com/jizhang/pandas-tidy-data
    x_name : str
        the names of each box
    y_name : str
        what is shown
    uniform_color : str, optional
        unified color for all boxes, by default None
    swarm_plot : bool, optional
        _description_, by default False
    hue : _type_, optional
        _description_, by default None
    colormap : bool, optional
        _description_, by default False
    xlim : _type_, optional
        _description_, by default None
    ylim : _type_, optional
        _description_, by default None
    order : _type_, optional
        _description_, by default None
    font : str, optional
        _description_, by default "serif"
    rotation : int, optional
        rotation for labels in x-axis, by default 45
    show_median: bool, optional
        if True, show median value for each box, by default False
    Returns
    -------
    _type_
        _description_
    """
    fig = plt.figure()
    sns.set(style="ticks", palette="pastel", font=font, font_scale=1.5)
    # Draw a nested boxplot to show bills by day and time
    if uniform_color is not None:
        sns_box = sns.boxplot(
            x=x_name,
            y=y_name,
            data=data,
            color=uniform_color,
            showfliers=False,
            order=order,
        )
    else:
        sns_box = sns.boxplot(
            x=x_name, y=y_name, data=data, showfliers=False, order=order
        )
    if swarm_plot:
        if hue is None:
            sns_box = sns.swarmplot(
                x=x_name, y=y_name, data=data, color=".2", order=order
            )
        elif colormap:
            # Create a matplotlib colormap from the sns seagreen color palette
            cmap = sns.light_palette("seagreen", reverse=False, as_cmap=True)
            # Normalize to the range of possible values from df["c"]
            norm = matplotlib.colors.Normalize(
                vmin=data[hue].min(), vmax=data[hue].max()
            )
            colors = {cval: cmap(norm(cval)) for cval in data[hue]}
            # plot the swarmplot with the colors dictionary as palette, s=2 means size is 2
            sns_box = sns.swarmplot(
                x=x_name,
                y=y_name,
                hue=hue,
                s=2,
                data=data,
                palette=colors,
                order=order,
            )
            # remove the legend, because we want to set a colorbar instead
            plt.gca().legend_.remove()
            # create colorbar
            divider = make_axes_locatable(plt.gca())
            ax_cb = divider.new_horizontal(size="5%", pad=0.05)
            fig = sns_box.get_figure()
            fig.add_axes(ax_cb)
            cb1 = matplotlib.colorbar.ColorbarBase(
                ax_cb, cmap=cmap, norm=norm, orientation="vertical"
            )
            cb1.set_label("Some Units")
        else:
            palette = sns.light_palette("seagreen", reverse=False, n_colors=10)
            sns_box = sns.swarmplot(
                x=x_name,
                y=y_name,
                hue=hue,
                s=2,
                data=data,
                palette=palette,
                order=order,
            )
    if xlim is not None:
        plt.xlim(xlim[0], xlim[1])
    if ylim is not None:
        plt.ylim(ylim[0], ylim[1])
    if show_median:
        medians = data.groupby([x_name], sort=False)[y_name].median().values
        create_median_labels(sns_box, medians_value=medians, size="x-small")
    sns.despine()
    locs, labels = plt.xticks()
    plt.setp(labels, rotation=rotation)
    # plt.show()
    return sns_box.get_figure()

plot_diff_boxes(data, row_and_col=None, y_col=None, x_col=None, hspace=0.3, wspace=1, title_str=None, title_font_size=14)

plot boxplots in rows and cols

Source code in hydroutils/hydro_plot.py
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
def plot_diff_boxes(
    data,
    row_and_col=None,
    y_col=None,
    x_col=None,
    hspace=0.3,
    wspace=1,
    title_str=None,
    title_font_size=14,
):
    """plot boxplots in rows and cols"""
    # matplotlib.use('TkAgg')
    if type(data) is not pd.DataFrame:
        data = pd.DataFrame(data)
    subplot_num = data.shape[1] if y_col is None else len(y_col)
    if row_and_col is None:
        row_num = 1
        col_num = subplot_num
        f, axes = plt.subplots(row_num, col_num)
        plt.subplots_adjust(hspace=hspace, wspace=wspace)
    else:
        assert subplot_num <= row_and_col[0] * row_and_col[1]
        row_num = row_and_col[0]
        col_num = row_and_col[1]
        f, axes = plt.subplots(row_num, col_num)
        f.tight_layout()
    for i in range(subplot_num):
        if y_col is None:
            if row_num == 1 or col_num == 1:
                sns.boxplot(
                    y=data.columns.values[i],
                    data=data,
                    width=0.5,
                    orient="v",
                    ax=axes[i],
                    showfliers=False,
                ).set(xlabel=data.columns.values[i], ylabel="")
            else:
                row_idx = int(i / col_num)
                col_idx = i % col_num
                sns.boxplot(
                    y=data.columns.values[i],
                    data=data,
                    orient="v",
                    ax=axes[row_idx, col_idx],
                    showfliers=False,
                )
        else:
            assert x_col is not None
            if row_num == 1 or col_num == 1:
                sns.boxplot(
                    x=data.columns.values[x_col],
                    y=data.columns.values[y_col[i]],
                    data=data,
                    orient="v",
                    ax=axes[i],
                    showfliers=False,
                )
            else:
                row_idx = int(i / col_num)
                col_idx = i % col_num
                sns.boxplot(
                    x=data.columns.values[x_col],
                    y=data.columns.values[y_col[i]],
                    data=data,
                    orient="v",
                    ax=axes[row_idx, col_idx],
                    showfliers=False,
                )
    if title_str is not None:
        f.suptitle(title_str, fontsize=title_font_size)
    return f

plot_ecdf(mydataframe, mycolumn, save_file=None)

Empirical cumulative distribution function

Source code in hydroutils/hydro_plot.py
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
def plot_ecdf(mydataframe, mycolumn, save_file=None):
    """Empirical cumulative distribution function"""
    x, y = ecdf(mydataframe[mycolumn])
    df = pd.DataFrame({"x": x, "y": y})
    sns.set_style("ticks", {"axes.grid": True})
    sns.lineplot(x="x", y="y", data=df, estimator=None).set(
        xlim=(0, 1), xticks=np.arange(0, 1, 0.05), yticks=np.arange(0, 1, 0.05)
    )
    plt.show()
    if save_file is not None:
        plt.savefig(save_file)

plot_ecdfs(xs, ys, legends=None, style=None, case_str='case', event_str='event', x_str='x', y_str='y', ax_as_subplot=None, interval=0.1)

Empirical cumulative distribution function

Source code in hydroutils/hydro_plot.py
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
def plot_ecdfs(
    xs,
    ys,
    legends=None,
    style=None,
    case_str="case",
    event_str="event",
    x_str="x",
    y_str="y",
    ax_as_subplot=None,
    interval=0.1,
):
    """Empirical cumulative distribution function"""
    assert isinstance(xs, list) and isinstance(ys, list)
    assert len(xs) == len(ys)
    if legends is not None:
        assert isinstance(legends, list)
        assert len(ys) == len(legends)
    if style is not None:
        assert isinstance(style, list)
        assert len(ys) == len(style)
    for y in ys:
        assert all(xi < yi for xi, yi in zip(y, y[1:]))
    frames = []
    for i in range(len(xs)):
        str_i = x_str + str(i) if legends is None else legends[i]
        assert all(xi < yi for xi, yi in zip(xs[i], xs[i][1:]))
        df_dict_i = {
            x_str: xs[i],
            y_str: ys[i],
            case_str: np.full([xs[i].size], str_i),
        }
        if style is not None:
            df_dict_i[event_str] = np.full([xs[i].size], style[i])
        df_i = pd.DataFrame(df_dict_i)
        frames.append(df_i)
    df = pd.concat(frames)
    sns.set_style("ticks", {"axes.grid": True})
    if style is None:
        return (
            sns.lineplot(x=x_str, y=y_str, hue=case_str, data=df, estimator=None).set(
                xlim=(0, 1),
                xticks=np.arange(0, 1, interval),
                yticks=np.arange(0, 1, interval),
            )
            if ax_as_subplot is None
            else sns.lineplot(
                ax=ax_as_subplot,
                x=x_str,
                y=y_str,
                hue=case_str,
                data=df,
                estimator=None,
            ).set(
                xlim=(0, 1),
                xticks=np.arange(0, 1, interval),
                yticks=np.arange(0, 1, interval),
            )
        )
    elif ax_as_subplot is None:
        return sns.lineplot(
            x=x_str,
            y=y_str,
            hue=case_str,
            style=event_str,
            data=df,
            estimator=None,
        ).set(
            xlim=(0, 1),
            xticks=np.arange(0, 1, interval),
            yticks=np.arange(0, 1, interval),
        )
    else:
        return sns.lineplot(
            ax=ax_as_subplot,
            x=x_str,
            y=y_str,
            hue=case_str,
            style=event_str,
            data=df,
            estimator=None,
        ).set(
            xlim=(0, 1),
            xticks=np.arange(0, 1, interval),
            yticks=np.arange(0, 1, interval),
        )

plot_ecdfs_matplot(xs, ys, legends=None, colors='rbkgcmy', dash_lines=None, x_str='x', y_str='y', x_interval=0.1, y_interval=0.1, x_lim=(0, 1), y_lim=(0, 1), show_legend=True, legend_font_size=16, fig_size=(8, 6))

Empirical cumulative distribution function with matplotlib Parameters


xs : type description ys : type description legends : type, optional description, by default None colors : str, optional description, by default "rbkgcmy" dash_lines : type, optional description, by default None x_str : str, optional description, by default "x" y_str : str, optional description, by default "y" x_interval : float, optional description, by default 0.1 y_interval : float, optional description, by default 0.1 x_lim : tuple, optional description, by default (0, 1) y_lim : tuple, optional description, by default (0, 1) show_legend : bool, optional description, by default True legend_font_size : int, optional description, by default 16 fig_size : tuple, optional size of the figure, by default (8, 6) Returns


type description

Source code in hydroutils/hydro_plot.py
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def plot_ecdfs_matplot(
    xs,
    ys,
    legends=None,
    colors="rbkgcmy",
    dash_lines=None,
    x_str="x",
    y_str="y",
    x_interval=0.1,
    y_interval=0.1,
    x_lim=(0, 1),
    y_lim=(0, 1),
    show_legend=True,
    legend_font_size=16,
    fig_size=(8, 6),
):
    """Empirical cumulative distribution function with matplotlib
    Parameters
    ----------
    xs : _type_
        _description_
    ys : _type_
        _description_
    legends : _type_, optional
        _description_, by default None
    colors : str, optional
        _description_, by default "rbkgcmy"
    dash_lines : _type_, optional
        _description_, by default None
    x_str : str, optional
        _description_, by default "x"
    y_str : str, optional
        _description_, by default "y"
    x_interval : float, optional
        _description_, by default 0.1
    y_interval : float, optional
        _description_, by default 0.1
    x_lim : tuple, optional
        _description_, by default (0, 1)
    y_lim : tuple, optional
        _description_, by default (0, 1)
    show_legend : bool, optional
        _description_, by default True
    legend_font_size : int, optional
        _description_, by default 16
    fig_size : tuple, optional
        size of the figure, by default (8, 6)
    Returns
    -------
    _type_
        _description_
    """
    assert isinstance(xs, list) and isinstance(ys, list)
    assert len(xs) == len(ys)
    if legends is not None:
        assert isinstance(legends, list) and len(ys) == len(legends)
    if dash_lines is not None:
        assert isinstance(dash_lines, list)
    else:
        dash_lines = np.full(len(xs), False).tolist()
    for y in ys:
        assert all(xi < yi for xi, yi in zip(y, y[1:]))
    fig = plt.figure(figsize=fig_size)
    ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])
    for i in range(len(xs)):
        if (
            np.nanmax(np.array(xs[i])) == np.inf
            or np.nanmin(np.array(xs[i])) == -np.inf
        ):
            assert all(xi <= yi for xi, yi in zip(xs[i], xs[i][1:]))
        else:
            assert all(xi <= yi for xi, yi in zip(xs[i], xs[i][1:]))
        (line_i,) = ax.plot(xs[i], ys[i], color=colors[i], label=legends[i])
        if dash_lines[i]:
            line_i.set_dashes([2, 2, 10, 2])

    plt.xlabel(x_str, fontsize=18)
    plt.ylabel(y_str, fontsize=18)
    ax.set_xlim(x_lim[0], x_lim[1])
    ax.set_ylim(y_lim[0], y_lim[1])
    # set x y number font size
    plt.xticks(np.arange(x_lim[0], x_lim[1] + x_lim[1] / 100, x_interval), fontsize=16)
    plt.yticks(np.arange(y_lim[0], y_lim[1] + y_lim[1] / 100, y_interval), fontsize=16)
    if show_legend:
        ax.legend()
        plt.legend(prop={"size": legend_font_size})
    plt.grid()
    # Hide the right and top spines
    ax.spines["right"].set_visible(False)
    ax.spines["top"].set_visible(False)
    return fig, ax

plot_event_characteristics(event_analysis, output_folder, delta_t_hours=3.0, net_rain_key='P_eff', obs_flow_key='Q_obs_eff')

Create and save a detailed flood event characteristics plot.

This function creates a comprehensive visualization of a flood event, showing both the net rainfall and direct runoff, along with key event characteristics in a text box. The runoff curve is plotted on top of the rainfall bars for better visibility.

Parameters:

Name Type Description Default
event_analysis Dict

Dictionary containing flood event data and analysis: - filepath (str): Path to source data file - peak_obs (float): Peak observed flow - runoff_volume_m3 (float): Total runoff volume in m³ - runoff_duration_hours (float): Event duration in hours - total_net_rain (float): Total net rainfall in mm - lag_time_hours (float): Time lag between peak rain and peak flow - P_eff (np.ndarray): Net rainfall time series - Q_obs_eff (np.ndarray): Observed flow time series

required
output_folder str

Directory where the plot will be saved.

required
delta_t_hours float

Time step in hours. Defaults to 3.0.

3.0
net_rain_key str

Key for net rainfall in event_analysis. Defaults to "P_eff".

'P_eff'
obs_flow_key str

Key for observed flow in event_analysis. Defaults to "Q_obs_eff".

'Q_obs_eff'
Note
  • Uses dual axes: left for runoff (m³/s), right for rainfall (mm)
  • Rainfall is plotted as blue bars from top
  • Runoff is plotted as orange line with higher z-order
  • Includes grid for better readability
  • Text box shows key event characteristics
  • Saves plot as PNG with 150 DPI
  • Uses SimSun font for Chinese characters
Example

event = { ... 'filepath': 'event_001.csv', ... 'peak_obs': 150.5, ... 'runoff_volume_m3': 2.5e6, ... 'runoff_duration_hours': 48.0, ... 'total_net_rain': 85.5, ... 'lag_time_hours': 6.0, ... 'P_eff': np.array([...]), # rainfall data ... 'Q_obs_eff': np.array([...]) # flow data ... } plot_event_characteristics(event, 'output/plots/')

Credit

Original implementation by Zheng Zhang

Source code in hydroutils/hydro_plot.py
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
def plot_event_characteristics(
    event_analysis: Dict,
    output_folder: str,
    delta_t_hours: float = 3.0,
    net_rain_key: str = "P_eff",
    obs_flow_key: str = "Q_obs_eff",
):
    """Create and save a detailed flood event characteristics plot.

    This function creates a comprehensive visualization of a flood event, showing
    both the net rainfall and direct runoff, along with key event characteristics
    in a text box. The runoff curve is plotted on top of the rainfall bars for
    better visibility.

    Args:
        event_analysis (Dict): Dictionary containing flood event data and analysis:
            - filepath (str): Path to source data file
            - peak_obs (float): Peak observed flow
            - runoff_volume_m3 (float): Total runoff volume in m³
            - runoff_duration_hours (float): Event duration in hours
            - total_net_rain (float): Total net rainfall in mm
            - lag_time_hours (float): Time lag between peak rain and peak flow
            - P_eff (np.ndarray): Net rainfall time series
            - Q_obs_eff (np.ndarray): Observed flow time series
        output_folder (str): Directory where the plot will be saved.
        delta_t_hours (float, optional): Time step in hours. Defaults to 3.0.
        net_rain_key (str, optional): Key for net rainfall in event_analysis.
            Defaults to "P_eff".
        obs_flow_key (str, optional): Key for observed flow in event_analysis.
            Defaults to "Q_obs_eff".

    Note:
        - Uses dual axes: left for runoff (m³/s), right for rainfall (mm)
        - Rainfall is plotted as blue bars from top
        - Runoff is plotted as orange line with higher z-order
        - Includes grid for better readability
        - Text box shows key event characteristics
        - Saves plot as PNG with 150 DPI
        - Uses SimSun font for Chinese characters

    Example:
        >>> event = {
        ...     'filepath': 'event_001.csv',
        ...     'peak_obs': 150.5,
        ...     'runoff_volume_m3': 2.5e6,
        ...     'runoff_duration_hours': 48.0,
        ...     'total_net_rain': 85.5,
        ...     'lag_time_hours': 6.0,
        ...     'P_eff': np.array([...]),  # rainfall data
        ...     'Q_obs_eff': np.array([...])  # flow data
        ... }
        >>> plot_event_characteristics(event, 'output/plots/')

    Credit:
        Original implementation by Zheng Zhang
    """
    net_rain = event_analysis[net_rain_key]
    direct_runoff = event_analysis[obs_flow_key]
    event_filename = os.path.basename(event_analysis["filepath"])

    fig, ax1 = plt.subplots(figsize=(15, 7))
    fig.suptitle(f"洪水事件特征分析 - {event_filename}", fontsize=16)

    # 绘制径流曲线 (左Y轴)
    x_axis = np.arange(len(direct_runoff))
    # --- 核心修改:为曲线设置一个较高的 zorder ---
    ax1.plot(
        x_axis,
        direct_runoff,
        color="orangered",
        label=r"径流 ($m^3/s$)",
        zorder=10,
        linewidth=2,
    )  # zorder=10

    ax1.set_xlabel(f"时段 (Δt = {delta_t_hours}h)", fontsize=12)
    ax1.set_ylabel(r"径流流量 ($m^3/s$)", color="orangered", fontsize=12)
    ax1.tick_params(axis="y", labelcolor="orangered")
    ax1.set_ylim(bottom=0)
    ax1.grid(True, which="major", linestyle="--", linewidth="0.5", color="gray")

    # 创建共享X轴的第二个Y轴
    ax2 = ax1.twinx()
    # 绘制净雨柱状图 (右Y轴,向下)
    # --- 核心修改:为柱状图设置一个较低的 zorder (可选,但好习惯) ---
    ax2.bar(
        x_axis,
        net_rain,
        color="steelblue",
        label="净雨 (mm)",
        width=0.8,
        zorder=5,
    )  # zorder=5

    ax2.set_ylabel("净雨量 (mm)", color="steelblue", fontsize=12)
    ax2.tick_params(axis="y", labelcolor="steelblue")
    ax2.invert_yaxis()
    ax2.set_ylim(top=0)

    # 准备并标注文本框 (与之前对齐版本相同)
    labels = [
        "洪峰流量:",
        "洪   量:",
        "洪水历时:",
        "总净雨量:",
        "洪峰雨峰延迟:",
    ]
    values = [
        f"{event_analysis['peak_obs']:.2f} " + r"$m^3/s$",
        f"{event_analysis['runoff_volume_m3'] / 1e6:.2f} " + r"$\times 10^6\ m^3$",
        f"{event_analysis['runoff_duration_hours']:.1f} 小时",
        f"{event_analysis['total_net_rain']:.2f} mm",
        f"{event_analysis['lag_time_hours']:.1f} 小时",
    ]
    label_text = "\n".join(labels)
    value_text = "\n".join(values)
    bbox_props = dict(boxstyle="round,pad=0.5", facecolor="wheat", alpha=0.8)
    ax1.text(
        0.85,
        0.95,
        "--- 水文特征 ---",
        transform=ax1.transAxes,
        fontsize=12,
        verticalalignment="top",
        horizontalalignment="center",
        bbox=bbox_props,
    )
    ax1.text(
        0.80,
        0.85,
        label_text,
        transform=ax1.transAxes,
        fontsize=12,
        verticalalignment="top",
        horizontalalignment="right",
        family="SimSun",
    )
    ax1.text(
        0.82,
        0.85,
        value_text,
        transform=ax1.transAxes,
        fontsize=12,
        verticalalignment="top",
        horizontalalignment="left",
    )

    # 合并图例
    lines1, labels1 = ax1.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax2.legend(
        lines1 + lines2,
        labels1 + labels2,
        loc="upper left",
        bbox_to_anchor=(0.01, 0.95),
    )

    # 保存图形
    output_filename = os.path.join(
        output_folder, f"{os.path.splitext(event_filename)[0]}.png"
    )
    try:
        plt.savefig(output_filename, dpi=150, bbox_inches="tight")
        print(f"  已生成图表: {output_filename}")
    except Exception as e:
        print(f"  保存图表失败: {output_filename}, 错误: {e}")

    plt.close(fig)

plot_heat_map(data, mask=None, fig_size=None, fmt='d', square=True, annot=True, xticklabels=True, yticklabels=True)

Create a heatmap visualization using seaborn.

This function creates a customizable heatmap for visualizing 2D data arrays. It uses seaborn's heatmap function with additional formatting options and supports masking specific data points.

Parameters:

Name Type Description Default
data DataFrame

2D data array to visualize.

required
mask ndarray

Boolean array of same shape as data. True values will be masked (not shown). Defaults to None.

None
fig_size tuple

Figure size as (width, height). Defaults to None.

None
fmt str

String formatting code for cell annotations. Defaults to "d" (integer).

'd'
square bool

If True, set the Axes aspect to "equal". Defaults to True.

True
annot bool

If True, write the data value in each cell. Defaults to True.

True
xticklabels bool

If True, show x-axis tick labels. Defaults to True.

True
yticklabels bool

If True, show y-axis tick labels. Defaults to True.

True
Note
  • Uses "RdBu_r" colormap (red-blue diverging)
  • Annotations are shown by default
  • Cells are square by default for better visualization
  • Based on seaborn's heatmap: https://seaborn.pydata.org/generated/seaborn.heatmap.html
Example

data = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) plot_heat_map(data, fig_size=(8, 6))

Source code in hydroutils/hydro_plot.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def plot_heat_map(
    data: pd.DataFrame,
    mask=None,
    fig_size=None,
    fmt="d",
    square=True,
    annot=True,
    xticklabels=True,
    yticklabels=True,
):
    """Create a heatmap visualization using seaborn.

    This function creates a customizable heatmap for visualizing 2D data arrays.
    It uses seaborn's heatmap function with additional formatting options and
    supports masking specific data points.

    Args:
        data (pd.DataFrame): 2D data array to visualize.
        mask (np.ndarray, optional): Boolean array of same shape as data. True
            values will be masked (not shown). Defaults to None.
        fig_size (tuple, optional): Figure size as (width, height). Defaults to None.
        fmt (str, optional): String formatting code for cell annotations.
            Defaults to "d" (integer).
        square (bool, optional): If True, set the Axes aspect to "equal".
            Defaults to True.
        annot (bool, optional): If True, write the data value in each cell.
            Defaults to True.
        xticklabels (bool, optional): If True, show x-axis tick labels.
            Defaults to True.
        yticklabels (bool, optional): If True, show y-axis tick labels.
            Defaults to True.

    Note:
        - Uses "RdBu_r" colormap (red-blue diverging)
        - Annotations are shown by default
        - Cells are square by default for better visualization
        - Based on seaborn's heatmap: https://seaborn.pydata.org/generated/seaborn.heatmap.html

    Example:
        >>> data = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
        >>> plot_heat_map(data, fig_size=(8, 6))
    """
    if fig_size is not None:
        fig = plt.figure(figsize=fig_size)
    ax = sns.heatmap(
        data=data,
        square=square,
        annot=annot,
        fmt=fmt,
        cmap="RdBu_r",
        mask=mask,
        xticklabels=xticklabels,
        yticklabels=yticklabels,
    )

plot_map_carto(data, lat, lon, fig=None, ax=None, pertile_range=None, value_range=None, fig_size=(8, 8), need_colorbar=True, colorbar_size=[0.91, 0.318, 0.02, 0.354], cmap_str='jet', idx_lst=None, markers=None, marker_size=20, is_discrete=False, colors='rbkgcmywrbkgcmyw', category_names=None, legend_font_size=None, colorbar_font_size=None)

Create a map visualization using Cartopy with data points or categories.

This function creates a map using Cartopy and plots data points on it. It supports both continuous and discrete data visualization, with options for customizing markers, colors, and legends/colorbars.

Parameters:

Name Type Description Default
data ndarray

1-D array of values to plot, one per point.

required
lat ndarray

1-D array of latitude values.

required
lon ndarray

1-D array of longitude values.

required
fig Figure

Existing figure to plot on. Defaults to None.

None
ax Axes

Existing axes to plot on. Defaults to None.

None
pertile_range list

Percentile range for color scaling as [min_percentile, max_percentile]. Example: [25, 75] for interquartile range. Defaults to None.

None
value_range list

Explicit value range for color scaling as [min_value, max_value]. Overrides pertile_range. Defaults to None.

None
fig_size tuple

Figure size as (width, height). Defaults to (8, 8).

(8, 8)
need_colorbar bool

Whether to show colorbar. Defaults to True.

True
colorbar_size list

Colorbar position and size as [left, bottom, width, height]. Defaults to [0.91, 0.318, 0.02, 0.354].

[0.91, 0.318, 0.02, 0.354]
cmap_str Union[str, list]

Colormap name(s). Can be single string or list for multiple point types. Defaults to "jet".

'jet'
idx_lst list

List of index arrays for plotting multiple point types separately. Defaults to None.

None
markers Union[str, list]

Marker style(s) for points. Can be single style or list. Defaults to None.

None
marker_size Union[int, list]

Marker size(s). Can be single value or list. Defaults to 20.

20
is_discrete bool

If True, treat data as discrete categories. Defaults to False.

False
colors str

String of color characters for discrete categories. Defaults to "rbkgcmywrbkgcmyw".

'rbkgcmywrbkgcmyw'
category_names list

Names for discrete categories. Defaults to None.

None
legend_font_size float

Font size for legend. Defaults to None.

None
colorbar_font_size float

Font size for colorbar. Defaults to None.

None

Returns:

Type Description

plt.Axes: The map axes object.

Note
  • Uses Cartopy's PlateCarree projection
  • Automatically determines map extent from data points
  • Includes state boundaries and coastlines
  • Supports both continuous (colorbar) and discrete (legend) data
  • Can plot multiple point types with different markers/colors
  • Handles NaN values appropriately
Example

Continuous data example

data = np.random.rand(100) lat = np.random.uniform(30, 45, 100) lon = np.random.uniform(-120, -100, 100) ax = plot_map_carto(data, lat, lon, ... value_range=[0, 1], ... cmap_str='viridis')

Discrete categories example

categories = np.random.randint(0, 3, 100) ax = plot_map_carto(categories, lat, lon, ... is_discrete=True, ... category_names=['Low', 'Medium', 'High'])

Source code in hydroutils/hydro_plot.py
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
def plot_map_carto(
    data,
    lat,
    lon,
    fig=None,
    ax=None,
    pertile_range=None,
    value_range=None,
    fig_size=(8, 8),
    need_colorbar=True,
    colorbar_size=[0.91, 0.318, 0.02, 0.354],
    cmap_str="jet",
    idx_lst=None,
    markers=None,
    marker_size=20,
    is_discrete=False,
    colors="rbkgcmywrbkgcmyw",
    category_names=None,
    legend_font_size=None,
    colorbar_font_size=None,
):
    """Create a map visualization using Cartopy with data points or categories.

    This function creates a map using Cartopy and plots data points on it. It supports
    both continuous and discrete data visualization, with options for customizing
    markers, colors, and legends/colorbars.

    Args:
        data (np.ndarray): 1-D array of values to plot, one per point.
        lat (np.ndarray): 1-D array of latitude values.
        lon (np.ndarray): 1-D array of longitude values.
        fig (plt.Figure, optional): Existing figure to plot on. Defaults to None.
        ax (plt.Axes, optional): Existing axes to plot on. Defaults to None.
        pertile_range (list, optional): Percentile range for color scaling as
            [min_percentile, max_percentile]. Example: [25, 75] for interquartile
            range. Defaults to None.
        value_range (list, optional): Explicit value range for color scaling as
            [min_value, max_value]. Overrides pertile_range. Defaults to None.
        fig_size (tuple, optional): Figure size as (width, height).
            Defaults to (8, 8).
        need_colorbar (bool, optional): Whether to show colorbar.
            Defaults to True.
        colorbar_size (list, optional): Colorbar position and size as
            [left, bottom, width, height]. Defaults to [0.91, 0.318, 0.02, 0.354].
        cmap_str (Union[str, list], optional): Colormap name(s). Can be single
            string or list for multiple point types. Defaults to "jet".
        idx_lst (list, optional): List of index arrays for plotting multiple
            point types separately. Defaults to None.
        markers (Union[str, list], optional): Marker style(s) for points. Can be
            single style or list. Defaults to None.
        marker_size (Union[int, list], optional): Marker size(s). Can be single
            value or list. Defaults to 20.
        is_discrete (bool, optional): If True, treat data as discrete categories.
            Defaults to False.
        colors (str, optional): String of color characters for discrete
            categories. Defaults to "rbkgcmywrbkgcmyw".
        category_names (list, optional): Names for discrete categories.
            Defaults to None.
        legend_font_size (float, optional): Font size for legend.
            Defaults to None.
        colorbar_font_size (float, optional): Font size for colorbar.
            Defaults to None.

    Returns:
        plt.Axes: The map axes object.

    Note:
        - Uses Cartopy's PlateCarree projection
        - Automatically determines map extent from data points
        - Includes state boundaries and coastlines
        - Supports both continuous (colorbar) and discrete (legend) data
        - Can plot multiple point types with different markers/colors
        - Handles NaN values appropriately

    Example:
        >>> # Continuous data example
        >>> data = np.random.rand(100)
        >>> lat = np.random.uniform(30, 45, 100)
        >>> lon = np.random.uniform(-120, -100, 100)
        >>> ax = plot_map_carto(data, lat, lon,
        ...                    value_range=[0, 1],
        ...                    cmap_str='viridis')
        >>>
        >>> # Discrete categories example
        >>> categories = np.random.randint(0, 3, 100)
        >>> ax = plot_map_carto(categories, lat, lon,
        ...                    is_discrete=True,
        ...                    category_names=['Low', 'Medium', 'High'])
    """
    if value_range is not None:
        vmin = value_range[0]
        vmax = value_range[1]
    elif pertile_range is None:
        # https://blog.csdn.net/chenirene510/article/details/111318539
        mask_data = np.ma.masked_invalid(data)
        vmin = np.min(mask_data)
        vmax = np.max(mask_data)
    else:
        assert 0 <= pertile_range[0] < pertile_range[1] <= 100
        vmin = np.nanpercentile(data, pertile_range[0])
        vmax = np.nanpercentile(data, pertile_range[1])
    llcrnrlat = (np.min(lat),)
    urcrnrlat = (np.max(lat),)
    llcrnrlon = (np.min(lon),)
    urcrnrlon = (np.max(lon),)
    extent = [llcrnrlon[0], urcrnrlon[0], llcrnrlat[0], urcrnrlat[0]]
    # Figure
    if fig is None or ax is None:
        fig, ax = plt.subplots(
            1, 1, figsize=fig_size, subplot_kw={"projection": ccrs.PlateCarree()}
        )
    ax.set_extent(extent)
    states = NaturalEarthFeature(
        category="cultural",
        scale="50m",
        facecolor="none",
        name="admin_1_states_provinces_shp",
    )
    ax.add_feature(states, linewidth=0.5, edgecolor="black")
    ax.coastlines("50m", linewidth=0.8)
    if idx_lst is not None:
        if isinstance(marker_size, list):
            assert len(marker_size) == len(idx_lst)
        else:
            marker_size = np.full(len(idx_lst), marker_size).tolist()
        if not isinstance(marker_size, list):
            markers = np.full(len(idx_lst), markers).tolist()
        else:
            assert len(markers) == len(idx_lst)
        if not isinstance(cmap_str, list):
            cmap_str = np.full(len(idx_lst), cmap_str).tolist()
        else:
            assert len(cmap_str) == len(idx_lst)
        if is_discrete:
            for i in range(len(idx_lst)):
                ax.plot(
                    lon[idx_lst[i]],
                    lat[idx_lst[i]],
                    marker=markers[i],
                    ms=marker_size[i],
                    label=category_names[i],
                    c=colors[i],
                    linestyle="",
                )
                ax.legend(prop=dict(size=legend_font_size))
        else:
            scatter = []
            for i in range(len(idx_lst)):
                scat = ax.scatter(
                    lon[idx_lst[i]],
                    lat[idx_lst[i]],
                    c=data[idx_lst[i]],
                    marker=markers[i],
                    s=marker_size[i],
                    cmap=cmap_str[i],
                    vmin=vmin,
                    vmax=vmax,
                )
                scatter.append(scat)
            if need_colorbar:
                if colorbar_size is not None:
                    cbar_ax = fig.add_axes(colorbar_size)
                    cbar = fig.colorbar(scat, cax=cbar_ax, orientation="vertical")
                else:
                    cbar = fig.colorbar(scat, ax=ax, pad=0.01)
                if colorbar_font_size is not None:
                    cbar.ax.tick_params(labelsize=colorbar_font_size)
            if category_names is not None:
                ax.legend(
                    scatter, category_names, prop=dict(size=legend_font_size), ncol=2
                )
    elif is_discrete:
        scatter = ax.scatter(lon, lat, c=data, s=marker_size)
        # produce a legend with the unique colors from the scatter
        legend1 = ax.legend(
            *scatter.legend_elements(), loc="lower left", title="Classes"
        )
        ax.add_artist(legend1)
    else:
        scat = plt.scatter(
            lon, lat, c=data, s=marker_size, cmap=cmap_str, vmin=vmin, vmax=vmax
        )
        if need_colorbar:
            if colorbar_size is not None:
                cbar_ax = fig.add_axes(colorbar_size)
                cbar = fig.colorbar(scat, cax=cbar_ax, orientation="vertical")
            else:
                cbar = fig.colorbar(scat, ax=ax, pad=0.01)
            if colorbar_font_size is not None:
                cbar.ax.tick_params(labelsize=colorbar_font_size)
    return ax

plot_rainfall_runoff(t, p, qs, fig_size=(8, 6), c_lst='rbkgcmy', leg_lst=None, dash_lines=None, title=None, xlabel=None, ylabel=None, prcp_ylabel='prcp(mm/day)', linewidth=1, prcp_interval=20)

Create a combined rainfall-runoff plot with dual axes.

This function creates a figure with two synchronized axes: one for streamflow (primary) and one for precipitation (secondary, inverted). The precipitation is plotted as filled areas from the top, while streamflow lines are plotted normally.

Parameters:

Name Type Description Default
t Union[array, list]

Time values. If list, must match length of qs.

required
p array

Precipitation time series.

required
qs Union[array, list]

Streamflow time series. Can be single array or list of arrays for multiple series.

required
fig_size tuple

Figure size as (width, height). Defaults to (8, 6).

(8, 6)
c_lst str

String of color characters for lines. Defaults to "rbkgcmy".

'rbkgcmy'
leg_lst list

Legend labels for streamflow series. Defaults to None.

None
dash_lines list[bool]

Which streamflow lines should be dashed. Defaults to None.

None
title str

Plot title. Defaults to None.

None
xlabel str

X-axis label. Defaults to None.

None
ylabel str

Primary Y-axis label (streamflow). Defaults to None.

None
prcp_ylabel str

Secondary Y-axis label (precipitation). Defaults to "prcp(mm/day)".

'prcp(mm/day)'
linewidth int

Width of streamflow lines. Defaults to 1.

1
prcp_interval int

Interval for precipitation Y-axis ticks. Defaults to 20.

20

Returns:

Type Description

Tuple[plt.Figure, plt.Axes]: The figure and primary axes objects.

Note
  • Precipitation is plotted from top with blue fill and 0.5 alpha
  • Streamflow axis range is extended by 20% at top
  • Legend is placed at upper left with fontsize 16
  • Grid is enabled on primary (streamflow) axis
  • All tick labels use fontsize 16
  • Right and top spines are hidden
Example

t = np.arange(100) p = np.random.uniform(0, 10, 100) # precipitation q1 = np.random.uniform(0, 100, 100) # streamflow 1 q2 = np.random.uniform(0, 80, 100) # streamflow 2 fig, ax = plot_rainfall_runoff(t, p, [q1, q2], ... leg_lst=['Obs', 'Sim'], ... ylabel='Streamflow (m³/s)')

Source code in hydroutils/hydro_plot.py
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
def plot_rainfall_runoff(
    t,
    p,
    qs,
    fig_size=(8, 6),
    c_lst="rbkgcmy",
    leg_lst=None,
    dash_lines=None,
    title=None,
    xlabel=None,
    ylabel=None,
    prcp_ylabel="prcp(mm/day)",
    linewidth=1,
    prcp_interval=20,
):
    """Create a combined rainfall-runoff plot with dual axes.

    This function creates a figure with two synchronized axes: one for streamflow
    (primary) and one for precipitation (secondary, inverted). The precipitation
    is plotted as filled areas from the top, while streamflow lines are plotted
    normally.

    Args:
        t (Union[np.array, list]): Time values. If list, must match length of qs.
        p (np.array): Precipitation time series.
        qs (Union[np.array, list]): Streamflow time series. Can be single array
            or list of arrays for multiple series.
        fig_size (tuple, optional): Figure size as (width, height).
            Defaults to (8, 6).
        c_lst (str, optional): String of color characters for lines.
            Defaults to "rbkgcmy".
        leg_lst (list, optional): Legend labels for streamflow series.
            Defaults to None.
        dash_lines (list[bool], optional): Which streamflow lines should be
            dashed. Defaults to None.
        title (str, optional): Plot title. Defaults to None.
        xlabel (str, optional): X-axis label. Defaults to None.
        ylabel (str, optional): Primary Y-axis label (streamflow).
            Defaults to None.
        prcp_ylabel (str, optional): Secondary Y-axis label (precipitation).
            Defaults to "prcp(mm/day)".
        linewidth (int, optional): Width of streamflow lines. Defaults to 1.
        prcp_interval (int, optional): Interval for precipitation Y-axis ticks.
            Defaults to 20.

    Returns:
        Tuple[plt.Figure, plt.Axes]: The figure and primary axes objects.

    Note:
        - Precipitation is plotted from top with blue fill and 0.5 alpha
        - Streamflow axis range is extended by 20% at top
        - Legend is placed at upper left with fontsize 16
        - Grid is enabled on primary (streamflow) axis
        - All tick labels use fontsize 16
        - Right and top spines are hidden

    Example:
        >>> t = np.arange(100)
        >>> p = np.random.uniform(0, 10, 100)  # precipitation
        >>> q1 = np.random.uniform(0, 100, 100)  # streamflow 1
        >>> q2 = np.random.uniform(0, 80, 100)   # streamflow 2
        >>> fig, ax = plot_rainfall_runoff(t, p, [q1, q2],
        ...                               leg_lst=['Obs', 'Sim'],
        ...                               ylabel='Streamflow (m³/s)')
    """
    fig, ax = plt.subplots(figsize=fig_size)
    if dash_lines is not None:
        assert isinstance(dash_lines, list)
    else:
        dash_lines = np.full(len(qs), False).tolist()
    for k in range(len(qs)):
        tt = t[k] if type(t) is list else t
        q = qs[k]
        leg_str = None
        if leg_lst is not None:
            leg_str = leg_lst[k]
        (line_i,) = ax.plot(tt, q, color=c_lst[k], label=leg_str, linewidth=linewidth)
        if dash_lines[k]:
            line_i.set_dashes([2, 2, 10, 2])

    ax.set_ylim(ax.get_ylim()[0], ax.get_ylim()[1] * 1.2)
    # Create second axes, in order to get the bars from the top you can multiply by -1
    ax2 = ax.twinx()
    # ax2.bar(tt, -p, color="b")
    ax2.fill_between(tt, 0, -p, step="mid", color="b", alpha=0.5)
    # ax2.plot(tt, -p, color="b", alpha=0.7, linewidth=1.5)

    # Now need to fix the axis labels
    # max_pre = max(p)
    max_pre = p.max().item()
    ax2.set_ylim(-max_pre * 5, 0)
    y2_ticks = np.arange(0, max_pre, prcp_interval)
    y2_ticklabels = [str(i) for i in y2_ticks]
    ax2.set_yticks(-1 * y2_ticks)
    ax2.set_yticklabels(y2_ticklabels, fontsize=16)
    # ax2.set_yticklabels([lab.get_text()[1:] for lab in ax2.get_yticklabels()])
    if title is not None:
        ax.set_title(title, loc="center", fontdict={"fontsize": 17})
    if ylabel is not None:
        ax.set_ylabel(ylabel, fontsize=18)
    if xlabel is not None:
        ax.set_xlabel(xlabel, fontsize=18)
    ax2.set_ylabel(prcp_ylabel, fontsize=8, loc="top")
    # ax2.set_ylabel("precipitation (mm/day)", fontsize=12, loc='top')
    # https://github.com/matplotlib/matplotlib/issues/12318
    ax.tick_params(axis="x", labelsize=16)
    ax.tick_params(axis="y", labelsize=16)
    ax.legend(bbox_to_anchor=(0.01, 0.85), loc="upper left", fontsize=16)
    ax.grid()
    return fig, ax

plot_scatter_with_11line(x, y, point_color='blue', line_color='black', xlim=[0.0, 1.0], ylim=[0.0, 1.0], xlabel=None, ylabel=None)

Create a scatter plot with a 1:1 line for comparing two variables.

This function creates a scatter plot comparing two variables and adds a 1:1 line to show the perfect correlation line. The plot includes customizable colors, axis limits, and labels.

Parameters:

Name Type Description Default
x array

First variable to plot (x-axis).

required
y array

Second variable to plot (y-axis).

required
point_color str

Color of scatter points. Defaults to "blue".

'blue'
line_color str

Color of 1:1 line. Defaults to "black".

'black'
xlim list

X-axis limits [min, max]. Defaults to [0.0, 1.0].

[0.0, 1.0]
ylim list

Y-axis limits [min, max]. Defaults to [0.0, 1.0].

[0.0, 1.0]
xlabel str

X-axis label. Defaults to None.

None
ylabel str

Y-axis label. Defaults to None.

None

Returns:

Type Description

tuple[plt.Figure, plt.Axes]: Matplotlib figure and axes objects.

Note
  • The plot uses a whitesmoke background
  • Right and top spines are hidden
  • Tick labels use font size 16
  • The 1:1 line is dashed
Example

x = np.array([0.1, 0.2, 0.3, 0.4]) y = np.array([0.15, 0.25, 0.35, 0.45]) fig, ax = plot_scatter_with_11line(x, y, ... xlabel='Predicted', ... ylabel='Observed')

Source code in hydroutils/hydro_plot.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def plot_scatter_with_11line(
    x: np.array,
    y: np.array,
    point_color="blue",
    line_color="black",
    xlim=[0.0, 1.0],
    ylim=[0.0, 1.0],
    xlabel=None,
    ylabel=None,
):
    """Create a scatter plot with a 1:1 line for comparing two variables.

    This function creates a scatter plot comparing two variables and adds a 1:1
    line to show the perfect correlation line. The plot includes customizable
    colors, axis limits, and labels.

    Args:
        x (np.array): First variable to plot (x-axis).
        y (np.array): Second variable to plot (y-axis).
        point_color (str, optional): Color of scatter points. Defaults to "blue".
        line_color (str, optional): Color of 1:1 line. Defaults to "black".
        xlim (list, optional): X-axis limits [min, max]. Defaults to [0.0, 1.0].
        ylim (list, optional): Y-axis limits [min, max]. Defaults to [0.0, 1.0].
        xlabel (str, optional): X-axis label. Defaults to None.
        ylabel (str, optional): Y-axis label. Defaults to None.

    Returns:
        tuple[plt.Figure, plt.Axes]: Matplotlib figure and axes objects.

    Note:
        - The plot uses a whitesmoke background
        - Right and top spines are hidden
        - Tick labels use font size 16
        - The 1:1 line is dashed

    Example:
        >>> x = np.array([0.1, 0.2, 0.3, 0.4])
        >>> y = np.array([0.15, 0.25, 0.35, 0.45])
        >>> fig, ax = plot_scatter_with_11line(x, y,
        ...                                    xlabel='Predicted',
        ...                                    ylabel='Observed')
    """
    fig, ax = plt.subplots()
    # set background color for ax
    ax.set_facecolor("whitesmoke")
    # plot the grid of the figure
    # plt.grid(color="whitesmoke")
    ax.scatter(x, y, c=point_color, s=10)
    line = mlines.Line2D([0, 1], [0, 1], color=line_color, linestyle="--")
    transform = ax.transAxes
    line.set_transform(transform)
    ax.add_line(line)
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    plt.xticks(np.arange(xlim[0], xlim[1], 0.1), fontsize=16)
    plt.yticks(np.arange(ylim[0], ylim[1], 0.1), fontsize=16)
    # set xlable and ylabel
    if xlabel is not None:
        plt.xlabel(xlabel, fontsize=16)
    if ylabel is not None:
        plt.ylabel(ylabel, fontsize=16)
    ax.spines["right"].set_visible(False)
    ax.spines["top"].set_visible(False)
    ax.spines["left"].set_visible(False)
    ax.spines["bottom"].set_visible(False)
    return fig, ax

plot_scatter_xyc(x_label, x, y_label, y, c_label=None, c=None, size=20, is_reg=False, xlim=None, ylim=None, quadrant=None)

scatter plot: x-y relationship with c as colorbar Parameters


x_label : type description x : type description y_label : type description y : type description c_label : type, optional description, by default None c : type, optional description, by default None size : int, optional size of points, by default 20 is_reg : bool, optional description, by default False xlim : type, optional description, by default None ylim : type, optional description, by default None quadrant: list, optional if it is not None, it should be a list like [0.0,0.0], the first means we put a new axis in x=0.0, second for y=0.0, so that we can build a 4-quadrant plot

Source code in hydroutils/hydro_plot.py
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
def plot_scatter_xyc(
    x_label,
    x,
    y_label,
    y,
    c_label=None,
    c=None,
    size=20,
    is_reg=False,
    xlim=None,
    ylim=None,
    quadrant=None,
):
    """
    scatter plot: x-y relationship with c as colorbar
    Parameters
    ----------
    x_label : _type_
        _description_
    x : _type_
        _description_
    y_label : _type_
        _description_
    y : _type_
        _description_
    c_label : _type_, optional
        _description_, by default None
    c : _type_, optional
        _description_, by default None
    size : int, optional
        size of points, by default 20
    is_reg : bool, optional
        _description_, by default False
    xlim : _type_, optional
        _description_, by default None
    ylim : _type_, optional
        _description_, by default None
    quadrant: list, optional
        if it is not None, it should be a list like [0.0,0.0],
        the first means we put a new axis in x=0.0, second for y=0.0,
        so that we can build a 4-quadrant plot
    """
    fig, ax = plt.subplots()
    if type(x) is list:
        for i in range(len(x)):
            ax.plot(
                x[i], y[i], marker="o", linestyle="", ms=size, label=c_label[i], c=c[i]
            )
        ax.legend()

    elif c is None:
        df = pd.DataFrame({x_label: x, y_label: y})
        points = plt.scatter(df[x_label], df[y_label], s=size)
        if quadrant is not None:
            plt.axvline(quadrant[0], c="grey", lw=1, linestyle="--")
            plt.axhline(quadrant[1], c="grey", lw=1, linestyle="--")
            q2 = df[(df[x_label] < 0) & (df[y_label] > 0)].shape[0]
            q3 = df[(df[x_label] < 0) & (df[y_label] < 0)].shape[0]
            q4 = df[(df[x_label] > 0) & (df[y_label] < 0)].shape[0]
            q5 = df[(df[x_label] == 0) & (df[y_label] == 0)].shape[0]
            q1 = df[(df[x_label] > 0) & (df[y_label] > 0)].shape[0]
            q = q1 + q2 + q3 + q4 + q5
            r1 = int(round(q1 / q, 2) * 100)
            r2 = int(round(q2 / q, 2) * 100)
            r3 = int(round(q3 / q, 2) * 100)
            r4 = int(round(q4 / q, 2) * 100)
            r5 = 100 - r1 - r2 - r3 - r4
            plt.text(
                xlim[1] - (xlim[1] - xlim[0]) * 0.1,
                ylim[1] - (ylim[1] - ylim[0]) * 0.1,
                f"{r1}%",
                fontsize=16,
            )
            plt.text(
                xlim[0] + (xlim[1] - xlim[0]) * 0.1,
                ylim[1] - (ylim[1] - ylim[0]) * 0.1,
                f"{r2}%",
                fontsize=16,
            )
            plt.text(
                xlim[0] + (xlim[1] - xlim[0]) * 0.1,
                ylim[0] + (ylim[1] - ylim[0]) * 0.1,
                f"{r3}%",
                fontsize=16,
            )
            plt.text(
                xlim[1] - (xlim[1] - xlim[0]) * 0.1,
                ylim[0] + (ylim[1] - ylim[0]) * 0.1,
                f"{r4}%",
                fontsize=16,
            )
            plt.text(0.2, 0.02, f"{str(r5)}%", fontsize=16)
    else:
        df = pd.DataFrame({x_label: x, y_label: y, c_label: c})
        points = plt.scatter(
            df[x_label], df[y_label], c=df[c_label], s=size, cmap="Spectral"
        )  # set style options
        # add a color bar
        plt.colorbar(points)

    # set limits
    if xlim is not None:
        plt.xlim(xlim[0], xlim[1])
    if ylim is not None:
        plt.ylim(ylim[0], ylim[1])
    # Hide the right and top spines
    ax.spines["right"].set_visible(False)
    ax.spines["top"].set_visible(False)
    # build the regression plot
    if is_reg:
        plot = sns.regplot(x_label, y_label, data=df, scatter=False)  # , color=".1"
        plot = plot.set(xlabel=x_label, ylabel=y_label)  # add labels
    else:
        plt.xlabel(x_label, fontsize=18)
        plt.ylabel(y_label, fontsize=18)
        plt.xticks(fontsize=16)
        plt.yticks(fontsize=16)

plot_ts(t, y, ax=None, t_bar=None, title=None, xlabel=None, ylabel=None, fig_size=(12, 4), c_lst='rbkgcmyrbkgcmyrbkgcmy', leg_lst=None, marker_lst=None, linewidth=2, linespec=None, dash_lines=None, alpha=1)

Plot multiple time series with customizable styling.

This function creates a time series plot that can handle multiple series, with extensive customization options for appearance and formatting. It supports both continuous lines and scatter plots, with optional vertical bars and legends.

Parameters:

Name Type Description Default
t Union[list, array]

Time values. Can be dates, numbers, or a list of arrays (one per series).

required
y Union[list, array]

Data values to plot. Can be a single array or list of arrays for multiple series.

required
ax Axes

Existing axes to plot on. Defaults to None.

None
t_bar Union[float, list]

Position(s) for vertical bars. Defaults to None.

None
title str

Plot title. Defaults to None.

None
xlabel str

X-axis label. Defaults to None.

None
ylabel str

Y-axis label. Defaults to None.

None
fig_size tuple

Figure size as (width, height). Defaults to (12, 4).

(12, 4)
c_lst str

String of color characters for lines. Defaults to "rbkgcmyrbkgcmyrbkgcmy".

'rbkgcmyrbkgcmyrbkgcmy'
leg_lst list

Legend labels for each series. Defaults to None.

None
marker_lst list

Marker styles for each series. Defaults to None.

None
linewidth Union[int, list]

Line width(s). Can be single value or list. Defaults to 2.

2
linespec list

Line style specifications. Defaults to None.

None
dash_lines list[bool]

Which lines should be dashed. Defaults to None.

None
alpha Union[float, list]

Opacity value(s) between 0 and 1. Defaults to 1.

1

Returns:

Type Description

Union[Tuple[plt.Figure, plt.Axes], plt.Axes]: If ax is None, returns

(figure, axes), otherwise returns just the axes.

Note
  • Automatically handles NaN values by plotting points instead of lines
  • Supports multiple line styles including solid, dashed, and markers
  • Right and top spines are hidden for cleaner appearance
  • Grid is enabled by default
  • Font size is set to 16 for tick labels
  • Legend is placed in upper right if provided
Example

t = np.arange(100) y1 = np.sin(t/10) y2 = np.cos(t/10) fig, ax = plot_ts(t, [y1, y2], ... leg_lst=['sin', 'cos'], ... xlabel='Time', ... ylabel='Value')

Source code in hydroutils/hydro_plot.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
def plot_ts(
    t: Union[list, np.array],
    y: Union[list, np.array],
    ax=None,
    t_bar=None,
    title=None,
    xlabel: str = None,
    ylabel: str = None,
    fig_size=(12, 4),
    c_lst="rbkgcmyrbkgcmyrbkgcmy",
    leg_lst=None,
    marker_lst=None,
    linewidth=2,
    linespec=None,
    dash_lines=None,
    alpha=1,
):
    """Plot multiple time series with customizable styling.

    This function creates a time series plot that can handle multiple series,
    with extensive customization options for appearance and formatting. It supports
    both continuous lines and scatter plots, with optional vertical bars and
    legends.

    Args:
        t (Union[list, np.array]): Time values. Can be dates, numbers, or a list
            of arrays (one per series).
        y (Union[list, np.array]): Data values to plot. Can be a single array or
            list of arrays for multiple series.
        ax (matplotlib.axes.Axes, optional): Existing axes to plot on.
            Defaults to None.
        t_bar (Union[float, list], optional): Position(s) for vertical bars.
            Defaults to None.
        title (str, optional): Plot title. Defaults to None.
        xlabel (str, optional): X-axis label. Defaults to None.
        ylabel (str, optional): Y-axis label. Defaults to None.
        fig_size (tuple, optional): Figure size as (width, height).
            Defaults to (12, 4).
        c_lst (str, optional): String of color characters for lines.
            Defaults to "rbkgcmyrbkgcmyrbkgcmy".
        leg_lst (list, optional): Legend labels for each series. Defaults to None.
        marker_lst (list, optional): Marker styles for each series.
            Defaults to None.
        linewidth (Union[int, list], optional): Line width(s). Can be single value
            or list. Defaults to 2.
        linespec (list, optional): Line style specifications. Defaults to None.
        dash_lines (list[bool], optional): Which lines should be dashed.
            Defaults to None.
        alpha (Union[float, list], optional): Opacity value(s) between 0 and 1.
            Defaults to 1.

    Returns:
        Union[Tuple[plt.Figure, plt.Axes], plt.Axes]: If ax is None, returns
        (figure, axes), otherwise returns just the axes.

    Note:
        - Automatically handles NaN values by plotting points instead of lines
        - Supports multiple line styles including solid, dashed, and markers
        - Right and top spines are hidden for cleaner appearance
        - Grid is enabled by default
        - Font size is set to 16 for tick labels
        - Legend is placed in upper right if provided

    Example:
        >>> t = np.arange(100)
        >>> y1 = np.sin(t/10)
        >>> y2 = np.cos(t/10)
        >>> fig, ax = plot_ts(t, [y1, y2],
        ...                  leg_lst=['sin', 'cos'],
        ...                  xlabel='Time',
        ...                  ylabel='Value')
    """
    is_new_fig = False
    if ax is None:
        fig = plt.figure(figsize=fig_size)
        ax = fig.subplots()
        is_new_fig = True
    if dash_lines is not None:
        assert isinstance(dash_lines, list)
    else:
        dash_lines = np.full(len(t), False).tolist()
        # dash_lines[-1] = True
    if type(y) is np.ndarray:
        y = [y]
    if type(linewidth) is not list:
        linewidth = [linewidth] * len(y)
    if type(alpha) is not list:
        alpha = [alpha] * len(y)
    for k in range(len(y)):
        tt = t[k] if type(t) is list else t
        yy = y[k]
        leg_str = None
        if leg_lst is not None:
            leg_str = leg_lst[k]
        if marker_lst is None:
            (line_i,) = (
                ax.plot(tt, yy, "*", color=c_lst[k], label=leg_str, alpha=alpha[k])
                if True in np.isnan(yy)
                else ax.plot(
                    tt,
                    yy,
                    color=c_lst[k],
                    label=leg_str,
                    linewidth=linewidth[k],
                    alpha=alpha[k],
                )
            )
        elif marker_lst[k] == "-":
            if linespec is not None:
                (line_i,) = ax.plot(
                    tt,
                    yy,
                    color=c_lst[k],
                    label=leg_str,
                    linestyle=linespec[k],
                    lw=linewidth[k],
                    alpha=alpha[k],
                )
            else:
                (line_i,) = ax.plot(
                    tt,
                    yy,
                    color=c_lst[k],
                    label=leg_str,
                    lw=linewidth[k],
                    alpha=alpha[k],
                )
        else:
            (line_i,) = ax.plot(
                tt,
                yy,
                color=c_lst[k],
                label=leg_str,
                marker=marker_lst[k],
                lw=linewidth[k],
                alpha=alpha[k],
            )
        if dash_lines[k]:
            line_i.set_dashes([2, 2, 10, 2])
        if ylabel is not None:
            ax.set_ylabel(ylabel, fontsize=18)
        if xlabel is not None:
            ax.set_xlabel(xlabel, fontsize=18)
    if t_bar is not None:
        ylim = ax.get_ylim()
        t_bar = [t_bar] if type(t_bar) is not list else t_bar
        for tt in t_bar:
            ax.plot([tt, tt], ylim, "-k")

    if leg_lst is not None:
        ax.legend(loc="upper right", frameon=False)
        plt.legend(prop={"size": 16})
    if title is not None:
        ax.set_title(title, loc="center", fontdict={"fontsize": 17})
    # plot the grid of the figure
    plt.grid()
    plt.xticks(fontsize=16)
    plt.yticks(fontsize=16)
    # Hide the right and top spines
    ax.spines["right"].set_visible(False)
    ax.spines["top"].set_visible(False)
    plt.tight_layout()
    return (fig, ax) if is_new_fig else ax

plot_unit_hydrograph(U_optimized, title, smoothing_factor=None, peak_violation_weight=None, delta_t_hours=3.0)

Create a unit hydrograph plot with optimization parameters.

This function visualizes a unit hydrograph (UH) as a line plot with markers. If optimization parameters are provided, they are included in the title. The plot includes a grid and appropriate axis labels.

Parameters:

Name Type Description Default
U_optimized ndarray

Optimized unit hydrograph ordinates.

required
title str

Base title for the plot.

required
smoothing_factor float

Smoothing factor used in optimization. Defaults to None.

None
peak_violation_weight float

Weight for peak violation penalty in optimization. Defaults to None.

None
delta_t_hours float

Time step in hours. Defaults to 3.0.

3.0
Note
  • Uses markers ('o') at each UH ordinate
  • Includes dashed grid lines with 0.7 alpha
  • X-axis shows time in hours
  • Y-axis shows UH ordinates in mm/3h
  • If optimization parameters are provided, they are shown in parentheses after the title
Example

uh = np.array([0.1, 0.3, 0.4, 0.2, 0.0]) plot_unit_hydrograph(uh, "Test Basin UH", ... smoothing_factor=0.1, ... peak_violation_weight=0.5)

Source code in hydroutils/hydro_plot.py
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
def plot_unit_hydrograph(
    U_optimized,
    title,
    smoothing_factor=None,
    peak_violation_weight=None,
    delta_t_hours=3.0,
):
    """Create a unit hydrograph plot with optimization parameters.

    This function visualizes a unit hydrograph (UH) as a line plot with markers.
    If optimization parameters are provided, they are included in the title.
    The plot includes a grid and appropriate axis labels.

    Args:
        U_optimized (np.ndarray): Optimized unit hydrograph ordinates.
        title (str): Base title for the plot.
        smoothing_factor (float, optional): Smoothing factor used in optimization.
            Defaults to None.
        peak_violation_weight (float, optional): Weight for peak violation penalty
            in optimization. Defaults to None.
        delta_t_hours (float, optional): Time step in hours. Defaults to 3.0.

    Note:
        - Uses markers ('o') at each UH ordinate
        - Includes dashed grid lines with 0.7 alpha
        - X-axis shows time in hours
        - Y-axis shows UH ordinates in mm/3h
        - If optimization parameters are provided, they are shown in parentheses
          after the title

    Example:
        >>> uh = np.array([0.1, 0.3, 0.4, 0.2, 0.0])
        >>> plot_unit_hydrograph(uh, "Test Basin UH",
        ...                     smoothing_factor=0.1,
        ...                     peak_violation_weight=0.5)
    """
    if U_optimized is None:
        print(f"⚠️ 无法绘制单位线:{title} - 优化失败")
        return

    time_axis_uh = np.arange(1, len(U_optimized) + 1) * delta_t_hours

    plt.figure(figsize=(12, 6))
    plt.plot(time_axis_uh, U_optimized, marker="o", linestyle="-")

    # 构建完整标题
    full_title = title
    if smoothing_factor is not None and peak_violation_weight is not None:
        full_title += f" (平滑={smoothing_factor}, 单峰罚={peak_violation_weight})"

    plt.title(full_title)
    plt.xlabel(f"时间 (小时, Δt={delta_t_hours}h)")
    plt.ylabel("1mm净雨单位线纵坐标 (mm/3h)")
    plt.grid(True, linestyle="--", alpha=0.7)
    plt.tight_layout()
    plt.show()

setup_matplotlib_chinese()

Configure matplotlib for Chinese font support and math rendering.

This function sets up matplotlib to properly display Chinese characters and mathematical expressions. It configures the font family, handles negative signs, and sets up math rendering to work harmoniously with Chinese text.

Returns:

Name Type Description
bool

True if configuration was successful, False if there was an error (usually due to missing SimHei font).

Note
  • Uses SimHei as the primary Chinese font
  • Sets up STIX fonts for math rendering to match Times New Roman
  • Handles negative sign display in Chinese context
  • Configures sans-serif as the default font family
Example

if setup_matplotlib_chinese(): ... plt.title("中文标题") ... plt.xlabel("时间 (s)") ... else: ... print("Chinese font setup failed")

Source code in hydroutils/hydro_plot.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def setup_matplotlib_chinese():
    """Configure matplotlib for Chinese font support and math rendering.

    This function sets up matplotlib to properly display Chinese characters and
    mathematical expressions. It configures the font family, handles negative signs,
    and sets up math rendering to work harmoniously with Chinese text.

    Returns:
        bool: True if configuration was successful, False if there was an error
            (usually due to missing SimHei font).

    Note:
        - Uses SimHei as the primary Chinese font
        - Sets up STIX fonts for math rendering to match Times New Roman
        - Handles negative sign display in Chinese context
        - Configures sans-serif as the default font family

    Example:
        >>> if setup_matplotlib_chinese():
        ...     plt.title("中文标题")
        ...     plt.xlabel("时间 (s)")
        ... else:
        ...     print("Chinese font setup failed")
    """
    try:
        plt.rcParams["font.sans-serif"] = ["SimHei"]
        plt.rcParams["axes.unicode_minus"] = False
        # --- 新增:为LaTeX数学公式渲染配置字体 ---
        # 这可以帮助确保数学符号和中文字体看起来更和谐
        plt.rcParams["mathtext.fontset"] = (
            "stix"  # 'stix' 是一种与Times New Roman相似的科学字体
        )
        plt.rcParams["font.family"] = "sans-serif"  # 保持其他文本为无衬线字体
        return True
    except Exception as e:
        warnings.warn(
            f"Warning: Chinese font 'SimHei' not found, Chinese text may not display correctly. Error: {e}"
        )
        return False