diff --git a/.zenodo.json b/.zenodo.json index e86d254..3890dd9 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -28,6 +28,11 @@ "name": "Braun, Marco", "affiliation": "Ouranos", "orcid": "0000-0001-5061-3217" + }, + { + "name": "Dupuis, Éric", + "affiliation": "Ouranos, Montréal, Québec, Canada", + "orcid": "0000-0001-7976-4596" } ], "keywords": [ diff --git a/AUTHORS.rst b/AUTHORS.rst index 1900c4e..8026058 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -20,3 +20,4 @@ Contributors * Gabriel Rondeau-Genesse `@RondeauG `_ * Marco Braun `@vindelico `_ +* Éric Dupuis `@coxipi `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5cf149a..3272c9a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,7 @@ Changelog 0.4.0 (unreleased) ------------------ -Contributors to this version: Trevor James Smith (:user:`Zeitsperre`), Marco Braun (:user:`vindelico`), Pascal Bourgault (:user:`aulemahal`), Sarah-Claude Bourdeau-Goulet (:user:`Sarahclaude`) +Contributors to this version: Trevor James Smith (:user:`Zeitsperre`), Marco Braun (:user:`vindelico`), Pascal Bourgault (:user:`aulemahal`), Sarah-Claude Bourdeau-Goulet (:user:`Sarahclaude`), Éric Dupuis (:user:`coxipi`) New features and enhancements ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -13,6 +13,8 @@ New features and enhancements * Added style sheet ``transparent.mplstyle`` (:issue:`183`, :pull:`185`) * Fix ``NaN`` issues, extreme values in sizes legend and added edgecolors in ``fg.matplotlib.scattermap`` (:pull:`184`). * New function ``fg.data`` for fetching package data and defined `matplotlib` style definitions. (:pull:`211`). +* ``fg.taylordiagram`` can now accept datasets with many dimensions (not only `taylor_params`), provided that they all share the same `ref_std` (e.g. normalized taylor diagrams) (:pull:`214`). +* A new optional way to organize points in a `fg.taylordiagram` with `colors_key`, `markers_key` : DataArrays with a common dimension value or a common attrtibute are grouped with the same color/marker (:pull:`214`). Breaking changes ^^^^^^^^^^^^^^^^ @@ -20,6 +22,7 @@ Breaking changes * `figanos` now uses a `'src' layout `_ for the package. (:pull:`210`). * `cartopy` has been pinned above v0.23.0 due to a licensing issue. (:pull:`210`). * `twine` and `wheel` have been removed from the `dev` requirements. (:pull:`210`). +* ``fg.taylordiagram`` returns a tuple of `(fig, floating_ax, legend)` instead of only `floating_ax`. (:pull:`214`). Internal changes ^^^^^^^^^^^^^^^^ diff --git a/docs/notebooks/figanos_docs.ipynb b/docs/notebooks/figanos_docs.ipynb index ef8a543..50811bd 100644 --- a/docs/notebooks/figanos_docs.ipynb +++ b/docs/notebooks/figanos_docs.ipynb @@ -1009,23 +1009,62 @@ "source": [ "da_ref = ds_time['tx_max_p50']\n", "\n", + "# Toy data with same mean as `da_ref` & modify deviations with trigonometric functions\n", + "homogenous_ref_mean = xr.full_like(da_ref, da_ref.mean(dim=\"time\"))\n", + "simd = {}\n", + "for i, f_trig in enumerate([np.cos, lambda x: np.cos(x)**2, np.tan]):\n", + " da = homogenous_ref_mean + f_trig(da_ref.values)\n", + " da.attrs[\"units\"] = da_ref.attrs[\"units\"]\n", + " simd[f\"model{i}\"] = sdba.measures.taylordiagram(sim=da, ref=da_ref)\n", "\n", - "rands = np.arange(15)\n", - "sims = {}\n", - "for rand in rands:\n", - " name = \"model\" + str(rand)\n", - " da = xr.DataArray(data=np.random.rand(13)*20+290+rand,\n", - " dims={'time': da_ref.time.values})\n", - " da.attrs[\"units\"] = da_ref.units\n", + "fg.taylordiagram(simd, std_range=(0, 1.3), contours=5, contours_kw={'colors': 'green'}, plot_kw={'reference': {'marker':'*'}})\n" + ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "### Normalized taylor diagram\n", + "\n", + "If we normalize the standard deviation of our measures, many Taylor diagrams with difference references can be combined in a single plot. In the following example, we have datasets with two variables (`tasmax, pr`) and three location coordinates. For each location (3) and variable (2), a Taylordiagram measure is computed. Each set of correlation and standard deviation is then plotted. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "from xclim.testing import open_dataset\n", + "\n", + "ds_ref = open_dataset(\"sdba/ahccd_1950-2013.nc\")\n", + "ds_sim = open_dataset(\"sdba/nrcan_1950-2013.nc\")\n", + "for v in ds_ref.data_vars: \n", + " ds_sim[v] = xc.core.units.convert_units_to(ds_sim[v], ds_ref[v], context=\"hydro\")\n", "\n", - " sims[name] = sdba.measures.taylordiagram(da, da_ref)\n", + "# Here, we have three locations, two variables. We stack variables to convert from\n", + "# a Dataset to a DataArray.\n", + "da_ref = sdba.stack_variables(ds_ref)\n", + "da_sim = sdba.stack_variables(ds_sim)\n", "\n", - "fg.taylordiagram(sims, std_range=(0, 1.3), contours=5, contours_kw={'colors': 'green'}, plot_kw={'reference': {'marker':'*'}})\n" + "# Each location/variable will have its own set of taylor parameters\n", + "out = sdba.measures.taylordiagram(ref=da_ref, sim=da_sim, dim=\"time\")\n", + "\n", + "# If we normalize the taylor diagrams, they can be compared on the same plot\n", + "out[{\"taylor_param\":[0,1]}] = out[{\"taylor_param\":[0,1]}]/out[{\"taylor_param\":0}]\n", + "\n", + "# The `markers_key` and `colors_key` are used to separate between two different features. \n", + "# Here, the type of marker is used to distinguish between locations, and the color \n", + "# distinguishes between variables. If those parameters are not specified, then each \n", + "# pair (location, multivar) has simply its own color.\n", + "fg.taylordiagram(out, markers_key=\"location\", colors_key=\"multivar\", ref_std_line = True)\n" ] }, { "cell_type": "markdown", - "id": "63", + "id": "65", "metadata": {}, "source": [ "## Partition plots\n", @@ -1045,7 +1084,7 @@ { "cell_type": "code", "execution_count": null, - "id": "64", + "id": "66", "metadata": {}, "outputs": [], "source": [ @@ -1095,7 +1134,7 @@ }, { "cell_type": "markdown", - "id": "65", + "id": "67", "metadata": {}, "source": [ "Compute uncertainties with xclim and use `fractional_uncertainty` to have the right format to plot." @@ -1104,7 +1143,7 @@ { "cell_type": "code", "execution_count": null, - "id": "66", + "id": "68", "metadata": {}, "outputs": [], "source": [ @@ -1140,7 +1179,7 @@ { "cell_type": "code", "execution_count": null, - "id": "67", + "id": "69", "metadata": {}, "outputs": [], "source": [ @@ -1156,14 +1195,6 @@ " line_kw={'lw':2}\n", ")\n" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "68", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -1178,7 +1209,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.11.7" } }, "nbformat": 4, diff --git a/docs/notebooks/figanos_multiplots.ipynb b/docs/notebooks/figanos_multiplots.ipynb index 09a94ac..ad7c910 100644 --- a/docs/notebooks/figanos_multiplots.ipynb +++ b/docs/notebooks/figanos_multiplots.ipynb @@ -367,7 +367,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/src/figanos/matplotlib/plot.py b/src/figanos/matplotlib/plot.py index 858f888..feeb83c 100644 --- a/src/figanos/matplotlib/plot.py +++ b/src/figanos/matplotlib/plot.py @@ -1824,9 +1824,12 @@ def taylordiagram( std_range: tuple = (0, 1.5), contours: int | None = 4, contours_kw: dict[str, Any] | None = None, + ref_std_line: bool = False, legend_kw: dict[str, Any] | None = None, std_label: str | None = None, corr_label: str | None = None, + colors_key: str | None = None, + markers_key: str | None = None, ): """Build a Taylor diagram. @@ -1848,22 +1851,34 @@ def taylordiagram( Number of rsme contours to plot. contours_kw : dict, optional Arguments to pass to `plt.contour()` for the rmse contours. + ref_std_line : bool, optional + If True, draws a circular line on radius `std = ref_std`. Default: False legend_kw : dict, optional Arguments to pass to `plt.legend()`. std_label : str, optional Label for the standard deviation (x and y) axes. corr_label : str, optional Label for the correlation axis. + colors_key : str, optional + Attribute or dimension of DataArrays used to separate DataArrays into groups with different colors. If present, + it overrides the "color" key in `plot_kw`. + markers_key : str, optional + Attribute or dimension of DataArrays used to separate DataArrays into groups with different markers. If present, + it overrides the "marker" key in `plot_kw`. Returns ------- - matplotlib.axes.Axes + (plt.figure, mpl_toolkits.axisartist.floating_axes.FloatingSubplot, plt.legend) """ plot_kw = empty_dict(plot_kw) fig_kw = empty_dict(fig_kw) contours_kw = empty_dict(contours_kw) legend_kw = empty_dict(legend_kw) + # preserve order of dimensions if used for marker/color + ordered_markers_type = None + ordered_colors_type = None + # convert SSP, RCP, CMIP formats in keys if isinstance(data, dict): data = process_keys(data, convert_scen_name) @@ -1875,8 +1890,7 @@ def taylordiagram( data = {"_no_label": data} # mpl excludes labels starting with "_" from legend plot_kw = {"_no_label": empty_dict(plot_kw)} elif not plot_kw: - plot_kw = {} - + plot_kw = {k: {} for k in data.keys()} # check type for key, v in data.items(): if not isinstance(v, xr.DataArray): @@ -1886,6 +1900,35 @@ def taylordiagram( if key == "reference": raise ValueError("'reference' is not allowed as a key in data.") + # If there are other dimensions than 'taylor_param', create a bigger dict with them + data_keys = list(data.keys()) + for data_key in data_keys: + da = data[data_key] + dims = list(set(da.dims) - {"taylor_param"}) + if dims != []: + if markers_key in dims: + ordered_markers_type = da[markers_key].values + if colors_key in dims: + ordered_colors_type = da[colors_key].values + + da = da.stack(pl_dims=dims) + for i, dim_key in enumerate(da.pl_dims.values): + if isinstance(dim_key, list) or isinstance(dim_key, tuple): + dim_key = "-".join([str(k) for k in dim_key]) + da0 = da.isel(pl_dims=i) + # if colors_key/markers_key is a dimension, add it as an attribute for later use + if markers_key in dims: + da0.attrs[markers_key] = da0[markers_key].values.item() + if colors_key in dims: + da0.attrs[colors_key] = da0[colors_key].values.item() + new_data_key = ( + f"{data_key}-{dim_key}" if data_key != "_no_label" else dim_key + ) + data[new_data_key] = da0 + plot_kw[new_data_key] = empty_dict(plot_kw[f"{data_key}"]) + data.pop(data_key) + plot_kw.pop(data_key) + # remove negative correlations initial_len = len(data) removed = [ @@ -1929,10 +1972,9 @@ def taylordiagram( # make labels if not std_label: try: - std_label = ( - get_localized_term("standard deviation") - + f" ({list(data.values())[0].units})" - ) + units = list(data.values())[0].units + std_label = get_localized_term("standard deviation") + std_label = std_label if units == "" else f"{std_label} ({units})" except AttributeError: std_label = get_localized_term("standard deviation").capitalize() @@ -1949,12 +1991,11 @@ def taylordiagram( transform = PolarAxes.PolarTransform() # Setup the axis, here we map angles in degrees to angles in radius - rlocs = np.array([0, 0.2, 0.4, 0.6, 0.8, 1]) - rlocs_deg = rlocs * 90 - tlocs = rlocs_deg * np.pi / 180 # convert degrees to radians + # Correlation labels + rlocs = np.array([0, 0.2, 0.4, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1]) + tlocs = np.arccos(rlocs) # Conversion to polar angles gl1 = gf.FixedLocator(tlocs) # Positions - tf1 = gf.DictFormatter(dict(zip(tlocs, np.flip(rlocs).astype(str)))) - + tf1 = gf.DictFormatter(dict(zip(tlocs, map(str, rlocs)))) # Standard deviation axis extent radius_min = std_range[0] * max(max_std) radius_max = std_range[1] * max(max_std) @@ -1968,7 +2009,6 @@ def taylordiagram( ) fig = plt.figure(**fig_kw) - floating_ax = FloatingSubplot(fig, 111, grid_helper=ghelper) fig.add_subplot(floating_ax) @@ -2010,10 +2050,23 @@ def taylordiagram( points = [ref_pt] # set up for later + # plot a circular line along `ref_std` + if ref_std_line: + angles_for_line = np.linspace(0, np.pi / 2, 100) + radii_for_line = np.full_like(angles_for_line, ref_std) + ax.plot( + angles_for_line, + radii_for_line, + color=ref_kw["color"], + linewidth=0.5, + linestyle="-", + ) + # rmse contours from reference standard deviation if contours: radii, angles = np.meshgrid( - np.linspace(radius_min, radius_max), np.linspace(0, np.pi / 2) + np.linspace(radius_min, radius_max), + np.linspace(0, np.pi / 2), ) # Compute centered RMS difference rms = np.sqrt(ref_std**2 + radii**2 - 2 * ref_std * radii * np.cos(angles)) @@ -2023,7 +2076,8 @@ def taylordiagram( ax.clabel(ct, ct.levels, fontsize=8) - ct_line = Line2D( + # points.append(ct_line) + ct_line = ax.plot( [0], [0], ls=contours_kw["linestyles"], @@ -2031,39 +2085,84 @@ def taylordiagram( c="k" if "colors" not in contours_kw else contours_kw["colors"], label="rmse", ) - points.append(ct_line) + points.append(ct_line[0]) # get color options style_colors = matplotlib.rcParams["axes.prop_cycle"].by_key()["color"] if len(data) > len(style_colors): style_colors = style_colors * math.ceil(len(data) / len(style_colors)) cat_colors = Path(__file__).parents[1] / "data/ipcc_colors/categorical_colors.json" + # get marker options (only used if `markers_key` is set) + style_markers = "oDv^<>p*hH+x|_" + if len(data) > len(style_markers): + style_markers = style_markers * math.ceil(len(data) / len(style_markers)) + + # set colors and markers styles based on discrimnating attributes (if specified) + if colors_key or markers_key: + if colors_key: + # get_scen_color : look for SSP, RCP, CMIP model color + colors_type = ( + ordered_colors_type + if ordered_colors_type is not None + else {da.attrs[colors_key] for da in data.values()} + ) + colorsd = { + k: get_scen_color(k, cat_colors) or style_colors[i] + for i, k in enumerate(colors_type) + } + if markers_key: + markers_type = ( + ordered_markers_type + if ordered_markers_type is not None + else {da.attrs[markers_key] for da in data.values()} + ) + markersd = {k: style_markers[i] for i, k in enumerate(markers_type)} + + for key, da in data.items(): + if colors_key: + plot_kw[key]["color"] = colorsd[da.attrs[colors_key]] + if markers_key: + plot_kw[key]["marker"] = markersd[da.attrs[markers_key]] # plot scatter for (key, da), i in zip(data.items(), range(len(data))): # look for SSP, RCP, CMIP model color - if get_scen_color(key, cat_colors): - plot_kw[key].setdefault("color", get_scen_color(key, cat_colors)) - else: - plot_kw[key].setdefault("color", style_colors[i]) - - # convert corr to polar coordinates - plot_corr = (1 - da.sel(taylor_param="corr").values) * 90 * np.pi / 180 - + if colors_key is None: + plot_kw[key].setdefault( + "color", get_scen_color(key, cat_colors) or style_colors[i] + ) # set defaults plot_kw[key] = {"label": key} | plot_kw[key] + # legend will be handled later in this case + if markers_key or colors_key: + plot_kw[key]["label"] = "" + # plot pt = ax.scatter( - plot_corr, da.sel(taylor_param="sim_std").values, **plot_kw[key] + np.arccos(da.sel(taylor_param="corr").values), + da.sel(taylor_param="sim_std").values, + **plot_kw[key], ) points.append(pt) # legend legend_kw.setdefault("loc", "upper right") - fig.legend(points, [pt.get_label() for pt in points], **legend_kw) - - return floating_ax + legend = fig.legend(points, [pt.get_label() for pt in points], **legend_kw) + + # plot new legend if markers/colors represent a certain dimension + if colors_key or markers_key: + handles = list(floating_ax.get_legend_handles_labels()[0]) + if markers_key: + for k, m in markersd.items(): + handles.append(Line2D([0], [0], color="k", label=k, marker=m, ls="")) + if colors_key: + for k, c in colorsd.items(): + handles.append(Line2D([0], [0], color=c, label=k, ls="-")) + legend.remove() + legend = fig.legend(handles=handles, **legend_kw) + + return fig, floating_ax, legend def hatchmap(