diff --git a/doc/gallery/gridded/barbs.ipynb b/doc/gallery/gridded/barbs.ipynb new file mode 100644 index 000000000..71a1fa809 --- /dev/null +++ b/doc/gallery/gridded/barbs.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c73d803c-a005-496f-9812-7eb414ce6449", + "metadata": {}, + "source": [ + "# Wind Barbs\n", + "\n", + "A wind barbs plot showing wind speed and direction using meteorological wind barb symbols.\n", + "\n", + ":::{note}\n", + "Wind barb plots require `geoviews` to be installed.\n", + ":::" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac5ea091-6484-4fc8-9852-d48acd90cc74", + "metadata": {}, + "outputs": [], + "source": [ + "import hvplot.xarray # noqa\n", + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "def sample_wind_data(shape=(20, 30)):\n", + " x = np.linspace(311.9, 391.1, shape[1])\n", + " y = np.linspace(-23.6, 24.8, shape[0])\n", + " x2d, y2d = np.meshgrid(x, y)\n", + " u = 10 * (2 * np.cos(2 * np.deg2rad(x2d) + 3 * np.deg2rad(y2d + 30)) ** 2)\n", + " v = 20 * np.cos(6 * np.deg2rad(x2d))\n", + " return x, y, u, v\n", + "\n", + "xs, ys, U, V = sample_wind_data()\n", + "# Calculate magnitude and angle for wind barbs\n", + "mag = np.sqrt(U**2 + V**2)\n", + "angle = (np.pi/2.) - np.arctan2(U/mag, V/mag)\n", + "\n", + "ds = xr.Dataset({\n", + " 'speed': xr.DataArray(mag, dims=('y', 'x'), coords={'y': ys, 'x': xs}),\n", + " 'angle': xr.DataArray(angle, dims=('y', 'x'), coords={'y': ys, 'x': xs})\n", + "})\n", + "\n", + "ds.hvplot.windbarbs(\n", + " x='x',\n", + " y='y',\n", + " angle='angle',\n", + " mag='speed',\n", + " color='speed',\n", + " cmap='viridis',\n", + " colorbar=True,\n", + " title='Wind Barbs Plot',\n", + " width=700,\n", + " height=400\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4f0cd955-3e03-42a5-b581-f828036e1716", + "metadata": {}, + "source": [ + ":::{seealso}\n", + "- [Wind Barbs reference documentation](../../ref/api/manual/hvplot.hvPlot.barbs.ipynb).\n", + ":::" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/ref/api/index.md b/doc/ref/api/index.md index 2d4e91ee5..bcef3b678 100644 --- a/doc/ref/api/index.md +++ b/doc/ref/api/index.md @@ -94,6 +94,7 @@ This section documents all the plotting methods of the `hvPlot` class, which as .. autosummary:: + hvPlot.windbarbs hvPlot.contour hvPlot.contourf hvPlot.image @@ -123,6 +124,7 @@ hvPlot's structure is based on Pandas' plotting API and as such provides special hvplot.hvPlot.area hvplot.hvPlot.bar +hvplot.hvPlot.windbarbs hvplot.hvPlot.barh hvplot.hvPlot.box hvplot.hvPlot.bivariate diff --git a/doc/ref/api/manual/hvplot.hvPlot.barbs.ipynb b/doc/ref/api/manual/hvplot.hvPlot.barbs.ipynb new file mode 100644 index 000000000..0c988350f --- /dev/null +++ b/doc/ref/api/manual/hvplot.hvPlot.barbs.ipynb @@ -0,0 +1,157 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# hvPlot.windbarbs\n", + "\n", + "```{eval-rst}\n", + ".. currentmodule:: hvplot\n", + "\n", + ".. automethod:: hvPlot.windbarbs\n", + "```\n", + "\n", + ":::{note}\n", + "Wind barb plots require `geoviews` to be installed.\n", + ":::\n", + "\n", + "## Backend-specific styling options\n", + "\n", + "```{eval-rst}\n", + ".. backend-styling-options:: windbarbs\n", + "```\n", + "\n", + "## Examples\n", + "\n", + "### Basic wind barbs plot\n", + "\n", + "In this example we create a simple DataFrame with 4 columns `x`, `y`, `angle` and `speed`. `x` and `y` represent the location of the wind barbs. `angle` defines the wind direction expressed in radians (meteorological convention: direction FROM which the wind is blowing, with 0 being North). `speed` defines the wind speed magnitude." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import hvplot.pandas\n", + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "df = pd.DataFrame(dict(\n", + " x=[0, -1, 0, 1, 0, 1, 1, -1, -1],\n", + " y=[0, 0, -1, 0, 1, 1, -1, -1, 1],\n", + " angle=[0, 0, np.pi/2, np.pi, 3*np.pi/2, np.pi/4, np.pi/3, np.pi/6, np.pi/8],\n", + " speed=[0, 5, 10, 15, 20, 50, 75, 100, 150],\n", + "))\n", + "\n", + "df.hvplot.windbarbs(\n", + " x=\"x\", y=\"y\", angle=\"angle\", mag=\"speed\",\n", + " data_aspect=1, padding=0.2, width=400, height=400,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Xarray example\n", + "\n", + "In this example we show how to create a barb plot using Xarray data with hvPlot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import hvplot.xarray # noqa\n", + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "def sample_wind_data(shape=(20, 30)):\n", + " x = np.linspace(311.9, 391.1, shape[1])\n", + " y = np.linspace(-23.6, 24.8, shape[0])\n", + " x2d, y2d = np.meshgrid(x, y)\n", + " u = 10 * (2 * np.cos(2 * np.deg2rad(x2d) + 3 * np.deg2rad(y2d + 30)) ** 2)\n", + " v = 20 * np.cos(6 * np.deg2rad(x2d))\n", + " return x, y, u, v\n", + "\n", + "xs, ys, U, V = sample_wind_data()\n", + "mag = np.sqrt(U**2 + V**2)\n", + "angle = (np.pi/2.) - np.arctan2(U/mag, V/mag)\n", + "ds = xr.Dataset({\n", + " 'speed': xr.DataArray(mag, dims=('y', 'x'), coords={'y': ys, 'x': xs}),\n", + " 'angle': xr.DataArray(angle, dims=('y', 'x'), coords={'y': ys, 'x': xs})\n", + "})\n", + "\n", + "ds.hvplot.windbarbs(\n", + " x='x', y='y', angle='angle', mag='speed',\n", + " width=700, height=400, scale=0.3\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Geographic example with Xarray\n", + "\n", + "The `xarray.Dataset` constructed in this example has a `'crs'` key in its `attrs` dictionary, which lets us simply set `geo=True` to turn this plot into a correctly projected geographic plot overlaid on web map tiles." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cartopy.crs as ccrs\n", + "import hvplot.xarray # noqa\n", + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "def sample_data(shape=(20, 30)):\n", + " \"\"\"\n", + " Return ``(x, y, u, v, crs)`` of some vector data computed mathematically.\n", + " The returned crs will be a rotated pole CRS, meaning that the vectors\n", + " will be unevenly spaced in regular PlateCarree space.\n", + " \"\"\"\n", + " crs = ccrs.RotatedPole(pole_longitude=177.5, pole_latitude=37.5)\n", + "\n", + " x = np.linspace(311.9, 391.1, shape[1])\n", + " y = np.linspace(-23.6, 24.8, shape[0])\n", + " x2d, y2d = np.meshgrid(x, y)\n", + " u = 10 * (2 * np.cos(2 * np.deg2rad(x2d) + 3 * np.deg2rad(y2d + 30)) ** 2)\n", + " v = 20 * np.cos(6 * np.deg2rad(x2d))\n", + " return x, y, u, v, crs\n", + "\n", + "xs, ys, U, V, crs = sample_data()\n", + "mag = np.sqrt(U**2 + V**2)\n", + "angle = (np.pi/2.) - np.arctan2(U/mag, V/mag)\n", + "ds = xr.Dataset(\n", + " {\n", + " 'speed': xr.DataArray(mag, dims=('y', 'x'), coords={'y': ys, 'x': xs}),\n", + " 'angle': xr.DataArray(angle, dims=('y', 'x'), coords={'y': ys, 'x': xs})\n", + " },\n", + " attrs={'crs': crs},\n", + ")\n", + "\n", + "ds.hvplot.windbarbs(\n", + " x=\"x\", y=\"y\", angle=\"angle\", mag=\"speed\",\n", + " geo=True, tiles=\"CartoLight\", width=700, height=400\n", + ").opts(\"WindBarbs\", scale=0.3)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/hvplot/converter.py b/hvplot/converter.py index 35a5260fb..ac16b539e 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -574,7 +574,9 @@ class HoloViewsConverter: _geom_types = ['paths', 'polygons'] _geo_types = sorted( - _gridded_types + _geom_types + ['points', 'vectorfield', 'labels', 'hexbin', 'bivariate'] + _gridded_types + + _geom_types + + ['points', 'windbarbs', 'vectorfield', 'labels', 'hexbin', 'bivariate'] ) _stats_types = ['hist', 'kde', 'violin', 'box', 'density'] @@ -745,6 +747,7 @@ class HoloViewsConverter: 'area': ['x', 'y', 'y2', 'stacked'], 'bar': ['x', 'y', 'stacked'], 'barh': ['x', 'y', 'stacked'], + 'windbarbs': ['x', 'y', 'angle', 'mag', 'scale'], 'box': ['x', 'y'], 'errorbars': ['x', 'y', 'yerr1', 'yerr2'], 'bivariate': ['x', 'y', 'bandwidth', 'cut', 'filled', 'levels'], @@ -3275,15 +3278,56 @@ def contourf(self, x=None, y=None, z=None, data=None): else: return contourf + def windbarbs(self, x=None, y=None, angle=None, mag=None, data=None): + self._error_if_unavailable('windbarbs') + data, x, y, _ = self._process_gridded_args(data, x, y, z=None) + + if not (x and y): + if hasattr(data, 'coords'): + x, y = list(k for k, v in data.coords.items() if v.size > 1) + else: + x, y = data.columns[:2] + + angle = self.kwds.get('angle') + mag = self.kwds.get('mag') + + if (angle is None) != (mag is None): + raise ValueError("windbarbs requires either both 'angle' and 'mag' or neither") + if angle is None and mag is None: + raise ValueError("windbarbs requires 'angle' and 'mag' parameters") + + z = [angle, mag] + self.hover_cols + redim = self._merge_redim({z[1]: self._dim_ranges['c']}) + params = dict(self._relabel) + + element = self._get_element('windbarbs') + cur_opts, compat_opts = self._get_compat_opts('WindBarbs') + if self.geo: + params['crs'] = self.crs + + return redim_( + element(data, [x, y], z, **params), + **redim, + ).apply(self._set_backends_opts, cur_opts=cur_opts, compat_opts=compat_opts) + def vectorfield(self, x=None, y=None, angle=None, mag=None, data=None): self._error_if_unavailable('vectorfield') data, x, y, _ = self._process_gridded_args(data, x, y, z=None) if not (x and y): - x, y = list(k for k, v in data.coords.items() if v.size > 1) + if hasattr(data, 'coords'): + x, y = list(k for k, v in data.coords.items() if v.size > 1) + else: + x, y = data.columns[:2] angle = self.kwds.get('angle') mag = self.kwds.get('mag') + + if (angle is None) != (mag is None): + raise ValueError("vectorfield requires either both 'angle' and 'mag' or neither") + if angle is None and mag is None: + raise ValueError("vectorfield requires 'angle' and 'mag' parameters") + z = [angle, mag] + self.hover_cols redim = self._merge_redim({z[1]: self._dim_ranges['c']}) params = dict(self._relabel) diff --git a/hvplot/plotting/core.py b/hvplot/plotting/core.py index a4724469b..b71ad0fd7 100644 --- a/hvplot/plotting/core.py +++ b/hvplot/plotting/core.py @@ -13,7 +13,7 @@ panel_available = False from ..converter import HoloViewsConverter -from ..util import is_list_like, process_dynamic_args +from ..util import is_list_like, process_dynamic_args, import_geoviews # Color palette for examples: https://www.color-hex.com/color-palette/1018056 # light green: #55a194 @@ -101,6 +101,13 @@ def _get_converter(self, x=None, y=None, kind=None, **kwds): x = x or params.pop('x', None) y = y or params.pop('y', None) kind = kind or params.pop('kind', None) + + # Ensure windbarbs is registered in _kind_mapping (it's added dynamically + # when geoviews is available) + if kind == 'windbarbs' and 'windbarbs' not in HoloViewsConverter._kind_mapping: + gv = import_geoviews() + HoloViewsConverter._kind_mapping['windbarbs'] = gv.WindBarbs + return HoloViewsConverter(self._data, x, y, kind=kind, **params) def __dir__(self): @@ -250,6 +257,7 @@ class hvPlotTabular(hvPlotBase): 'table', 'dataset', 'points', + 'windbarbs', 'vectorfield', 'polygons', 'paths', @@ -1312,10 +1320,57 @@ def points(self, x=None, y=None, **kwds): """ return self(x, y, kind='points', **kwds) + def windbarbs(self, x=None, y=None, angle=None, mag=None, **kwds): + """ + A windbarbs plot visualizes wind barbs given by the (``x``, ``y``) starting point, + a magnitude (``mag``) and an ``angle``. + + .. versionadded:: 1.0.0 + + Reference: https://hvplot.holoviz.org/ref/api/manual/hvplot.hvPlot.windbarbs.html + + Plotting options: https://hvplot.holoviz.org/ref/plotting_options/index.html + + Parameters + ---------- + x : string + Field name to draw x-positions from + y : string + Field name to draw y-positions from + mag : string + Magnitude. + angle : string + Angle in radians. + **kwds : optional + Additional keywords arguments are documented in :ref:`plot-options`. + Run ``hvplot.help('windbarbs')`` for the full method documentation. + + Returns + ------- + :class:`geoviews:geoviews.element.Barbs` / Panel object + You can `print` the object to study its composition and run: + + .. code-block:: + + import geoviews as gv + gv.help(the_geoviews_object) + + to learn more about its parameters and options. + + References + ---------- + + - GeoViews: https://geoviews.org/gallery/bokeh/wind_barbs_example.html + - Matplotlib: https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.barbs.html + """ + gv = import_geoviews() + HoloViewsConverter._kind_mapping['windbarbs'] = gv.WindBarbs + return self(x, y, angle=angle, mag=mag, kind='windbarbs', **kwds) + def vectorfield(self, x=None, y=None, angle=None, mag=None, **kwds): """ vectorfield visualizes vectors given by the (``x``, ``y``) starting point, - a magnitude (``mag``) and an `angle`. A ``vectorfield`` plot is also known + a magnitude (``mag``) and an ``angle``. A ``vectorfield`` plot is also known as a ``quiver`` plot. Reference: https://hvplot.holoviz.org/ref/api/manual/hvplot.hvPlot.vectorfield.html @@ -1721,6 +1776,7 @@ class hvPlot(hvPlotTabular): 'table', 'dataset', 'points', + 'windbarbs', 'vectorfield', 'polygons', 'paths', diff --git a/hvplot/tests/plotting/testcore.py b/hvplot/tests/plotting/testcore.py index 3e5986d54..3d80a7525 100644 --- a/hvplot/tests/plotting/testcore.py +++ b/hvplot/tests/plotting/testcore.py @@ -28,7 +28,7 @@ class pl: TYPES = {t for t in dir(hvPlotTabular) if not t.startswith('_') and t != 'explorer'} -FRAME_TYPES = TYPES - {'bivariate', 'heatmap', 'hexbin', 'labels', 'vectorfield'} +FRAME_TYPES = TYPES - {'bivariate', 'heatmap', 'hexbin', 'labels', 'vectorfield', 'windbarbs'} SERIES_TYPES = FRAME_TYPES - {'points', 'polygons', 'ohlc', 'paths'} frame_kinds = pytest.mark.parametrize('kind', sorted(FRAME_TYPES)) series_kinds = pytest.mark.parametrize('kind', sorted(SERIES_TYPES)) diff --git a/hvplot/tests/testgeo.py b/hvplot/tests/testgeo.py index 15663a23c..5d4d1ec11 100644 --- a/hvplot/tests/testgeo.py +++ b/hvplot/tests/testgeo.py @@ -554,3 +554,47 @@ def test_proj_to_cartopy_nearsided_perspective(self): crs = proj_to_cartopy(proj4_string) assert isinstance(crs, self.ccrs.NearsidePerspective) + + +class TestWindBarbs(TestCase): + def setUp(self): + if sys.platform == 'win32': + raise SkipTest('Skip geo tests on windows for now') + try: + import geoviews as gv # noqa + + self.gv = gv + except ImportError: + raise SkipTest('geoviews not available') + import hvplot.pandas # noqa + + def test_barbs_with_angle_mag(self): + """Test wind barbs plot with angle and magnitude""" + df = pd.DataFrame( + { + 'lon': np.linspace(-10, 10, 20), + 'lat': np.linspace(-10, 10, 20), + 'angle': np.random.uniform(0, 2 * np.pi, 20), + 'mag': np.random.uniform(0, 10, 20), + } + ) + + plot = df.hvplot.windbarbs(x='lon', y='lat', angle='angle', mag='mag', geo=True) + assert isinstance(plot, self.gv.WindBarbs) + assert plot.kdims[0].name == 'lon' + assert plot.kdims[1].name == 'lat' + assert plot.vdims[0].name == 'angle' + assert plot.vdims[1].name == 'mag' + + def test_barbs_invalid_incomplete_angle_mag(self): + """Test that providing only angle or mag raises an error""" + df = pd.DataFrame( + { + 'lon': np.linspace(-10, 10, 10), + 'lat': np.linspace(-10, 10, 10), + 'angle': np.random.uniform(0, 2 * np.pi, 10), + } + ) + + with pytest.raises(ValueError, match='requires either both'): + df.hvplot.windbarbs(x='lon', y='lat', angle='angle') diff --git a/hvplot/ui.py b/hvplot/ui.py index b1024cf5e..611af0c57 100644 --- a/hvplot/ui.py +++ b/hvplot/ui.py @@ -26,7 +26,7 @@ } KINDS['2d'] = ( - ['bivariate', 'heatmap', 'hexbin', 'labels', 'vectorfield', 'points', 'paths'] + ['bivariate', 'heatmap', 'hexbin', 'labels', 'windbarbs', 'vectorfield', 'points', 'paths'] + KINDS['gridded'] + KINDS['geom'] ) diff --git a/hvplot/util.py b/hvplot/util.py index eb08acbbd..a1a286c5b 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -1121,6 +1121,12 @@ def __call__(self): for cls in [hvPlot, hvPlotTabular]: for _kind in HoloViewsConverter._kind_mapping: if hasattr(cls, _kind): + # Handle dynamically added kinds (e.g., 'windbarbs' is added + # to _kind_mapping at runtime when first called) + if (cls, _kind) not in self.orig: + method = getattr(cls, _kind) + sig = inspect.signature(method) + self.orig[(cls, _kind)] = (sig, method.__doc__) signature = self.orig[(cls, _kind)][0] _patch_doc(cls, _kind, signature=signature)