Skip to content

Commit 59eccc3

Browse files
wjbenfoldbjlittle
andauthored
Implement iris.plot.fill_between (#4647)
* Remove pin, update hashes, improve script * What's new * Lockfile and whatsnew * Shareable urls * Later lockfile * Shareable... * Check for consistency before fiddling * Updates for new test data (idiff'ed) * Keep lockstep between imagerepo and data * idiff always runs after imagerepo.json has been updated anyway * Gallery test updates (also idiff'd) * Implement fill_between and write tests * Simplify error check * Test images * What's new * Image test results * Fix error message * Pre-emptive test data version update Co-authored-by: Bill Little <[email protected]>
1 parent be4fdc3 commit 59eccc3

File tree

9 files changed

+246
-9
lines changed

9 files changed

+246
-9
lines changed

.github/workflows/benchmark.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
env:
1616
IRIS_TEST_DATA_LOC_PATH: benchmarks
1717
IRIS_TEST_DATA_PATH: benchmarks/iris-test-data
18-
IRIS_TEST_DATA_VERSION: "2.13"
18+
IRIS_TEST_DATA_VERSION: "2.14"
1919
# Lets us manually bump the cache to rebuild
2020
ENV_CACHE_BUILD: "0"
2121
TEST_DATA_CACHE_BUILD: "2"

.github/workflows/ci-docs-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
session: ["doctest", "gallery", "linkcheck"]
4040

4141
env:
42-
IRIS_TEST_DATA_VERSION: "2.13"
42+
IRIS_TEST_DATA_VERSION: "2.14"
4343
ENV_NAME: "ci-docs-tests"
4444

4545
steps:

.github/workflows/ci-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
session: ["tests"]
4040

4141
env:
42-
IRIS_TEST_DATA_VERSION: "2.13"
42+
IRIS_TEST_DATA_VERSION: "2.14"
4343
ENV_NAME: "ci-tests"
4444

4545
steps:

docs/src/whatsnew/latest.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ This document explains the changes made to Iris for this release
7272
:func:`numpy.percentile` keywords through the :obj:`~iris.analysis.PERCENTILE`
7373
aggregator. (:pull:`4791`)
7474

75+
#. `@wjbenfold`_ and `@bjlittle`_ (reviewer) implemented
76+
:func:`iris.plot.fill_between` and :func:`iris.quickplot.fill_between`.
77+
(:issue:`3493`, :pull:`4647`)
78+
7579

7680
🐛 Bugs Fixed
7781
=============

lib/iris/plot.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,30 @@ def _u_object_from_v_object(v_object):
645645

646646

647647
def _get_plot_objects(args):
648-
if len(args) > 1 and isinstance(
648+
if len(args) > 2 and isinstance(
649+
args[2], (iris.cube.Cube, iris.coords.Coord)
650+
):
651+
# three arguments
652+
u_object, v_object1, v_object2 = args[:3]
653+
u1, v1 = _uv_from_u_object_v_object(u_object, v_object1)
654+
_, v2 = _uv_from_u_object_v_object(u_object, v_object2)
655+
args = args[3:]
656+
if u1.size != v1.size or u1.size != v2.size:
657+
msg = "The x and y-axis objects are not all compatible. They should have equal sizes but got ({}: {}), ({}: {}) and ({}: {})"
658+
raise ValueError(
659+
msg.format(
660+
u_object.name(),
661+
u1.size,
662+
v_object1.name(),
663+
v1.size,
664+
v_object2.name(),
665+
v2.size,
666+
)
667+
)
668+
u = u1
669+
v = (v1, v2)
670+
v_object = (v_object1, v_object2)
671+
elif len(args) > 1 and isinstance(
649672
args[1], (iris.cube.Cube, iris.coords.Coord)
650673
):
651674
# two arguments
@@ -823,6 +846,52 @@ def _draw_1d_from_points(draw_method_name, arg_func, *args, **kwargs):
823846
return result
824847

825848

849+
def _draw_two_1d_from_points(draw_method_name, arg_func, *args, **kwargs):
850+
"""
851+
This function is equivalend to _draw_two_1d_from_points but expects two
852+
y-axis variables rather than one (such as is required for .fill_between). It
853+
can't be used where the y-axis variables are string coordinates. The y-axis
854+
variable provided first has precedence where the two differ on whether the
855+
axis should be inverted or whether a map should be drawn.
856+
"""
857+
# NB. In the interests of clarity we use "u" to refer to the horizontal
858+
# axes on the matplotlib plot and "v" for the vertical axes.
859+
860+
# retrieve the objects that are plotted on the horizontal and vertical
861+
# axes (cubes or coordinates) and their respective values, along with the
862+
# argument tuple with these objects removed
863+
u_object, v_objects, u, vs, args = _get_plot_objects(args)
864+
865+
v_object1, _ = v_objects
866+
v1, v2 = vs
867+
868+
# if both u_object and v_object are coordinates then check if a map
869+
# should be drawn
870+
if (
871+
isinstance(u_object, iris.coords.Coord)
872+
and isinstance(v_object1, iris.coords.Coord)
873+
and _can_draw_map([v_object1, u_object])
874+
):
875+
# Replace non-cartopy subplot/axes with a cartopy alternative and set
876+
# the transform keyword.
877+
kwargs = _ensure_cartopy_axes_and_determine_kwargs(
878+
u_object, v_object1, kwargs
879+
)
880+
881+
axes = kwargs.pop("axes", None)
882+
draw_method = getattr(axes if axes else plt, draw_method_name)
883+
if arg_func is not None:
884+
args, kwargs = arg_func(u, v1, v2, *args, **kwargs)
885+
result = draw_method(*args, **kwargs)
886+
else:
887+
result = draw_method(u, v1, v2, *args, **kwargs)
888+
889+
# Invert y-axis if necessary.
890+
_invert_yaxis(v_object1, axes)
891+
892+
return result
893+
894+
826895
def _replace_axes_with_cartopy_axes(cartopy_proj):
827896
"""
828897
Replace non-cartopy subplot/axes with a cartopy alternative
@@ -1599,6 +1668,45 @@ def scatter(x, y, *args, **kwargs):
15991668
return _draw_1d_from_points("scatter", _plot_args, *args, **kwargs)
16001669

16011670

1671+
def fill_between(x, y1, y2, *args, **kwargs):
1672+
"""
1673+
Plots y1 and y2 against x, and fills the space between them.
1674+
1675+
Args:
1676+
1677+
* x: :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
1678+
A cube or a coordinate to plot on the x-axis.
1679+
1680+
* y1: :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
1681+
First cube or a coordinate to plot on the y-axis.
1682+
1683+
* y2: :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
1684+
Second cube or a coordinate to plot on the y-axis.
1685+
1686+
Kwargs:
1687+
1688+
* axes: :class:`matplotlib.axes.Axes`
1689+
The axes to use for drawing. Defaults to the current axes if none
1690+
provided.
1691+
1692+
See :func:`matplotlib.pyplot.fill_between` for details of additional valid
1693+
keyword arguments.
1694+
1695+
"""
1696+
# here we are more specific about argument types than generic 1d plotting
1697+
if not isinstance(x, (iris.cube.Cube, iris.coords.Coord)):
1698+
raise TypeError("x must be a cube or a coordinate.")
1699+
if not isinstance(y1, (iris.cube.Cube, iris.coords.Coord)):
1700+
raise TypeError("y1 must be a cube or a coordinate.")
1701+
if not isinstance(y1, (iris.cube.Cube, iris.coords.Coord)):
1702+
raise TypeError("y2 must be a cube or a coordinate.")
1703+
args = (x, y1, y2) + args
1704+
_plot_args = None
1705+
return _draw_two_1d_from_points(
1706+
"fill_between", _plot_args, *args, **kwargs
1707+
)
1708+
1709+
16021710
# Provide convenience show method from pyplot
16031711
show = plt.show
16041712

lib/iris/quickplot.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,5 +311,19 @@ def scatter(x, y, *args, **kwargs):
311311
return result
312312

313313

314+
def fill_between(x, y1, y2, *args, **kwargs):
315+
"""
316+
Draws a labelled fill_between plot based on the given cubes or coordinates.
317+
318+
See :func:`iris.plot.fill_between` for details of valid arguments and
319+
keyword arguments.
320+
321+
"""
322+
axes = kwargs.get("axes")
323+
result = iplt.fill_between(x, y1, y2, *args, **kwargs)
324+
_label_1d_plot(x, y1, axes=axes)
325+
return result
326+
327+
314328
# Provide a convenience show method from pyplot.
315329
show = plt.show

lib/iris/tests/results/imagerepo.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,21 @@
6868
"iris.tests.test_mapping.TestLowLevel.test_simple.0": "faa0e558855f9de7857a1ab16a85a51d36a1e55a854e58a5c13837096e8fe17a",
6969
"iris.tests.test_mapping.TestMappingSubRegion.test_simple.0": "b9913d90c66eca6ec66ec2f3689195aecf5b2f00392cb3496495e21da4db6c92",
7070
"iris.tests.test_mapping.TestUnmappable.test_simple.0": "fa81b54a817eca37817ec701857e3e64943e7bb41b806f996e817e006ee1b19b",
71+
"iris.tests.test_plot.Test1dFillBetween.test_coord_coord.0": "f31432798cebcd87723835b4a5c5c2dbcf139c6c8cf4730bf3c36d801e380378",
72+
"iris.tests.test_plot.Test1dFillBetween.test_coord_cube.0": "ea17352b92f0cbd42d6c8d25e59d36dc3a538d2bb2e42d26c6d2c2c8e4a1ce99",
73+
"iris.tests.test_plot.Test1dFillBetween.test_cube_coord.0": "aff8e44af2019b3d3d03e0d1865e272cc1643de292db4b98c53c7ce5b0c37b2c",
74+
"iris.tests.test_plot.Test1dFillBetween.test_cube_cube.0": "ea1761f695a09c0b70cc938d334b4e4f4c3671f2cd8b7996973c2c68e1c39e26",
7175
"iris.tests.test_plot.Test1dPlotMultiArgs.test_coord.0": "8bfec2577e01a5a5ed013b4ac4521c94817d4e6d91ff63369c6d61991e3278cc",
7276
"iris.tests.test_plot.Test1dPlotMultiArgs.test_coord_coord.0": "8fff941e7e01e1c2f801c878a41e5b0d85cf36e1837e2d9992c62f21769e6a4d",
7377
"iris.tests.test_plot.Test1dPlotMultiArgs.test_coord_coord_map.0": "bbe0c214cd979dc3b05e4b68db0771b48698961b7962d2446e8ca5bb36716c6e",
7478
"iris.tests.test_plot.Test1dPlotMultiArgs.test_coord_cube.0": "8ff897066a01f0f2f818ee1eb007ca41853e3b81c57e36a991fe2ca9725e29ed",
7579
"iris.tests.test_plot.Test1dPlotMultiArgs.test_cube.0": "8fffc1dc7e019c70f001b70ee4386de1814e7938837b6a7f84d07c9f15b02f21",
7680
"iris.tests.test_plot.Test1dPlotMultiArgs.test_cube_coord.0": "8fffc1dc7e019c70f001b70ee4386de1814e7938837b6a7f84d07c9f15b02f21",
7781
"iris.tests.test_plot.Test1dPlotMultiArgs.test_cube_cube.0": "8ff8c0567a01b296e4019d2ff10b464bd4da6391943678e5879f7e3903e63f1c",
82+
"iris.tests.test_plot.Test1dQuickplotFillBetween.test_coord_coord.0": "f314b2798ce3cd87723835a4a5c5c2dbcf139c6c8cf4730bd3c36d801c3c6378",
83+
"iris.tests.test_plot.Test1dQuickplotFillBetween.test_coord_cube.0": "ea17352bd2f0cbd4256c8da5e59c36dc1a538d2b92e41d26ced2c2c8eca1ce99",
84+
"iris.tests.test_plot.Test1dQuickplotFillBetween.test_cube_coord.0": "a3ffe44af6009b3d2907c8f1f6588f2cc96619e290fb4b88cd2c3ce590e3770c",
85+
"iris.tests.test_plot.Test1dQuickplotFillBetween.test_cube_cube.0": "ea17e1f695a09c0b60cc938d334b4e4f4c3671f2cd8b7996973c2c69e1c31e26",
7886
"iris.tests.test_plot.Test1dQuickplotPlotMultiArgs.test_coord.0": "83fec2777e002427e801bb4ae65a1c94813dcec999db4bbc9ccd79991f3238cc",
7987
"iris.tests.test_plot.Test1dQuickplotPlotMultiArgs.test_coord_coord.0": "83ff9d9f7e01e1c2b001c8f8f63e1b1d81cf36e1837e258982ce6f215c9a626c",
8088
"iris.tests.test_plot.Test1dQuickplotPlotMultiArgs.test_coord_coord_map.0": "bbe0c214cd979dc3b05e4b68db0771b48698961b7962d2446e8ca5bb36716c6e",

lib/iris/tests/test_image_json.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ def test_json(self):
3030
missing_from_json = test_data_name_set - repo_name_set
3131
if missing_from_json:
3232
amsg = (
33-
"Missing images: Image names are referenced in "
34-
"imagerepo.json, that are not present in the iris-test-data "
35-
"repo"
33+
"Missing images: Images are present in the iris-test-data "
34+
"repo, that are not referenced in imagerepo.json"
3635
)
3736
# Always fails when we get here: report the problem.
3837
self.assertEqual(missing_from_json, set(), msg=amsg)
3938
missing_from_test_data = repo_name_set - test_data_name_set
4039
if missing_from_test_data:
4140
amsg = (
42-
"Missing images: Images are present in the iris-test-data "
43-
"repo, that are not referenced in imagerepo.json"
41+
"Missing images: Image names are referenced in "
42+
"imagerepo.json, that are not present in the iris-test-data "
43+
"repo"
4444
)
4545
# Always fails when we get here: report the problem.
4646
self.assertEqual(missing_from_test_data, set(), msg=amsg)

lib/iris/tests/test_plot.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import numpy as np
1717

1818
import iris
19+
import iris.analysis
1920
import iris.coords as coords
2021
import iris.tests.stock
2122

@@ -341,6 +342,108 @@ def test_circular_changes(self):
341342
self.check_graphic()
342343

343344

345+
class Test1dFillBetween(tests.GraphicsTest):
346+
def setUp(self):
347+
super().setUp()
348+
self.cube = iris.load_cube(
349+
tests.get_data_path(
350+
("NetCDF", "testing", "small_theta_colpex.nc")
351+
),
352+
"air_potential_temperature",
353+
)[0, 0]
354+
self.draw_method = iplt.fill_between
355+
356+
def test_coord_coord(self):
357+
x = self.cube.coord("grid_latitude")
358+
y1 = self.cube.coord("surface_altitude")[:, 0]
359+
y2 = self.cube.coord("surface_altitude")[:, 1]
360+
self.draw_method(x, y1, y2)
361+
self.check_graphic()
362+
363+
def test_coord_cube(self):
364+
x = self.cube.coord("grid_latitude")
365+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)
366+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)
367+
self.draw_method(x, y1, y2)
368+
self.check_graphic()
369+
370+
def test_cube_coord(self):
371+
x = self.cube.collapsed("grid_longitude", iris.analysis.MEAN)
372+
y1 = self.cube.coord("surface_altitude")[:, 0]
373+
y2 = y1 + 10
374+
self.draw_method(x, y1, y2)
375+
self.check_graphic()
376+
377+
def test_cube_cube(self):
378+
x = self.cube.collapsed("grid_longitude", iris.analysis.MEAN)
379+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)
380+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)
381+
self.draw_method(x, y1, y2)
382+
self.check_graphic()
383+
384+
def test_incompatible_objects_x_odd(self):
385+
# cubes/coordinates of different sizes cannot be plotted
386+
x = self.cube.coord("grid_latitude")[:-1]
387+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)
388+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)
389+
with self.assertRaises(ValueError):
390+
self.draw_method(x, y1, y2)
391+
392+
def test_incompatible_objects_y1_odd(self):
393+
# cubes/coordinates of different sizes cannot be plotted
394+
x = self.cube.coord("grid_latitude")
395+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)[:-1]
396+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)
397+
with self.assertRaises(ValueError):
398+
self.draw_method(x, y1, y2)
399+
400+
def test_incompatible_objects_y2_odd(self):
401+
# cubes/coordinates of different sizes cannot be plotted
402+
x = self.cube.coord("grid_latitude")
403+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)
404+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)[:-1]
405+
with self.assertRaises(ValueError):
406+
self.draw_method(x, y1, y2)
407+
408+
def test_incompatible_objects_all_odd(self):
409+
# cubes/coordinates of different sizes cannot be plotted
410+
x = self.cube.coord("grid_latitude")
411+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)[:-1]
412+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)[:-2]
413+
with self.assertRaises(ValueError):
414+
self.draw_method(x, y1, y2)
415+
416+
def test_multidimensional(self):
417+
# multidimensional cubes/coordinates are not allowed
418+
x = self.cube.coord("grid_latitude")
419+
y1 = self.cube
420+
y2 = self.cube
421+
with self.assertRaises(ValueError):
422+
self.draw_method(x, y1, y2)
423+
424+
def test_not_cube_or_coord(self):
425+
# inputs must be cubes or coordinates
426+
x = np.arange(self.cube.shape[0])
427+
y1 = self.cube.collapsed("grid_longitude", iris.analysis.MIN)
428+
y2 = self.cube.collapsed("grid_longitude", iris.analysis.MAX)
429+
with self.assertRaises(TypeError):
430+
self.draw_method(x, y1, y2)
431+
432+
433+
@tests.skip_data
434+
@tests.skip_plot
435+
class Test1dQuickplotFillBetween(Test1dFillBetween):
436+
def setUp(self):
437+
tests.GraphicsTest.setUp(self)
438+
self.cube = iris.load_cube(
439+
tests.get_data_path(
440+
("NetCDF", "testing", "small_theta_colpex.nc")
441+
),
442+
"air_potential_temperature",
443+
)[0, 0]
444+
self.draw_method = qplt.fill_between
445+
446+
344447
@tests.skip_data
345448
@tests.skip_plot
346449
class TestAttributePositive(tests.GraphicsTest):

0 commit comments

Comments
 (0)