Skip to content

Commit b105d9e

Browse files
authored
Merge pull request #1484 from plutowing/enhance_wind_vector_plots
Add surface wind vector plots recipe and related function
2 parents 7dc7e72 + 83d03b1 commit b105d9e

7 files changed

Lines changed: 286 additions & 0 deletions

File tree

.github/workflows/pull-request-checks.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ jobs:
9191
# This links to a hosted version of the HTML report.
9292
tar -czf coverage-report.tar.gz htmlcov/
9393
report_url="$(curl -sSf --data-binary @coverage-report.tar.gz https://tmpweb.net)"
94+
echo "Report hosted at $report_url"
9495
badge_options="$(coverage json --fail-under=0 -qo - | jq -r .totals.percent_covered_display)%25-blue?style=for-the-badge"
9596
echo "[![Coverage](https://img.shields.io/badge/coverage-${badge_options})](${report_url})" >> ${{ runner.temp }}/cov-report.md
9697
# Edit last comment if it exists, else create new one.

src/CSET/operators/_colorbar_definition.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@
153153
"max": 5,
154154
"min": -5
155155
},
156+
"eastward_wind_northward_wind_magnitude": {
157+
"cmap": "YlOrRd",
158+
"max": 25.0,
159+
"min": 0.0
160+
},
156161
"exner_pressure_at_cell_interfaces": {
157162
"cmap": "coolwarm",
158163
"max": 1.1,

src/CSET/operators/plot.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,134 @@ def _plot_and_save_scatter_plot(
825825
plt.close(fig)
826826

827827

828+
def _plot_and_save_vector_plot(
829+
cube_u: iris.cube.Cube,
830+
cube_v: iris.cube.Cube,
831+
filename: str,
832+
title: str,
833+
method: Literal["contourf", "pcolormesh"],
834+
**kwargs,
835+
):
836+
"""Plot and save a 2D vector plot.
837+
838+
Parameters
839+
----------
840+
cube_u: Cube
841+
2 dimensional Cube of u component of the data.
842+
cube_v: Cube
843+
2 dimensional Cube of v component of the data.
844+
filename: str
845+
Filename of the plot to write.
846+
title: str
847+
Plot title.
848+
"""
849+
fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
850+
851+
# Create a cube containing the magnitude of the vector field.
852+
cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5
853+
cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude")
854+
855+
# Specify the color bar
856+
cmap, levels, norm = _colorbar_map_levels(cube_vec_mag)
857+
858+
if method == "contourf":
859+
# Filled contour plot of the field.
860+
plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm)
861+
elif method == "pcolormesh":
862+
try:
863+
vmin = min(levels)
864+
vmax = max(levels)
865+
except TypeError:
866+
vmin, vmax = None, None
867+
# pcolormesh plot of the field and ensure to use norm and not vmin/vmax
868+
# if levels are defined.
869+
if norm is not None:
870+
vmin = None
871+
vmax = None
872+
plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
873+
else:
874+
raise ValueError(f"Unknown plotting method: {method}")
875+
876+
# Using pyplot interface here as we need iris to generate a cartopy GeoAxes.
877+
axes = plt.gca()
878+
879+
# Add coastlines if cube contains x and y map coordinates.
880+
# If is spatial map, fix extent to keep plot tight.
881+
try:
882+
lat_axis, lon_axis = get_cube_yxcoordname(cube_vec_mag)
883+
axes.coastlines(resolution="10m")
884+
x1 = np.min(cube_vec_mag.coord(lon_axis).points)
885+
x2 = np.max(cube_vec_mag.coord(lon_axis).points)
886+
y1 = np.min(cube_vec_mag.coord(lat_axis).points)
887+
y2 = np.max(cube_vec_mag.coord(lat_axis).points)
888+
# Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe.
889+
if (x2 - x1) > 180.0:
890+
x1 = x1 - 180.0
891+
x2 = x2 - 180.0
892+
axes.set_extent([x1, x2, y1, y2])
893+
except ValueError:
894+
# Skip if no x and y map coordinates.
895+
pass
896+
897+
# Check to see if transect, and if so, adjust y axis.
898+
if is_transect(cube_vec_mag):
899+
if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]:
900+
axes.invert_yaxis()
901+
axes.set_yscale("log")
902+
axes.set_ylim(1100, 100)
903+
# If both model_level_number and level_height exists, iplt can construct
904+
# plot as a function of height above orography (NOT sea level).
905+
elif {"model_level_number", "level_height"}.issubset(
906+
{coord.name() for coord in cube_vec_mag.coords()}
907+
):
908+
axes.set_yscale("log")
909+
910+
axes.set_title(
911+
f"{title}\n"
912+
f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}"
913+
f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}"
914+
f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}"
915+
f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}",
916+
fontsize=16,
917+
)
918+
919+
else:
920+
# Add title.
921+
axes.set_title(title, fontsize=16)
922+
923+
# Add watermark with min/max/mean. Currently not user togglable.
924+
# In the bbox dictionary, fc and ec are hex colour codes for grey shade.
925+
axes.annotate(
926+
f"Min: {np.min(cube_vec_mag.data):.3g} Max: {np.max(cube_vec_mag.data):.3g} Mean: {np.mean(cube_vec_mag.data):.3g}",
927+
xy=(1, -0.05),
928+
xycoords="axes fraction",
929+
xytext=(-5, 5),
930+
textcoords="offset points",
931+
ha="right",
932+
va="bottom",
933+
size=11,
934+
bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
935+
)
936+
937+
# Add colour bar.
938+
cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
939+
cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=16)
940+
# add ticks and tick_labels for every levels if less than 20 levels exist
941+
if levels is not None and len(levels) < 20:
942+
cbar.set_ticks(levels)
943+
cbar.set_ticklabels([f"{level:.1f}" for level in levels])
944+
945+
# 30 barbs along the longest axis of the plot, or a barb per point for data
946+
# with less than 30 points.
947+
step = max(max(cube_u.shape) // 30, 1)
948+
iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle")
949+
950+
# Save plot.
951+
fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
952+
logging.info("Saved vector plot to %s", filename)
953+
plt.close(fig)
954+
955+
828956
def _plot_and_save_histogram_series(
829957
cubes: iris.cube.Cube | iris.cube.CubeList,
830958
filename: str,
@@ -1670,6 +1798,62 @@ def scatter_plot(
16701798
return iris.cube.CubeList([cube_x, cube_y])
16711799

16721800

1801+
def vector_plot(
1802+
cube_u: iris.cube.Cube,
1803+
cube_v: iris.cube.Cube,
1804+
filename: str = None,
1805+
sequence_coordinate: str = "time",
1806+
**kwargs,
1807+
) -> iris.cube.CubeList:
1808+
"""Plot a vector plot based on the input u and v components."""
1809+
recipe_title = get_recipe_metadata().get("title", "Untitled")
1810+
1811+
# Ensure we have a name for the plot file.
1812+
if filename is None:
1813+
filename = slugify(recipe_title)
1814+
1815+
# Cubes must have a matching sequence coordinate.
1816+
try:
1817+
# Check that the u and v cubes have the same sequence coordinate.
1818+
if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate):
1819+
raise ValueError("Coordinates do not match.")
1820+
except (iris.exceptions.CoordinateNotFoundError, ValueError) as err:
1821+
raise ValueError(
1822+
f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}"
1823+
) from err
1824+
1825+
# Create a plot for each value of the sequence coordinate.
1826+
plot_index = []
1827+
for cube_u_slice, cube_v_slice in zip(
1828+
cube_u.slices_over(sequence_coordinate),
1829+
cube_v.slices_over(sequence_coordinate),
1830+
strict=True,
1831+
):
1832+
# Use sequence value so multiple sequences can merge.
1833+
sequence_value = cube_u_slice.coord(sequence_coordinate).points[0]
1834+
plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1835+
coord = cube_u_slice.coord(sequence_coordinate)
1836+
# Format the coordinate value in a unit appropriate way.
1837+
title = f"{recipe_title}\n{coord.units.title(coord.points[0])}"
1838+
# Do the actual plotting.
1839+
_plot_and_save_vector_plot(
1840+
cube_u_slice,
1841+
cube_v_slice,
1842+
filename=plot_filename,
1843+
title=title,
1844+
method="contourf",
1845+
)
1846+
plot_index.append(plot_filename)
1847+
1848+
# Add list of plots to plot metadata.
1849+
complete_plot_index = _append_to_plot_index(plot_index)
1850+
1851+
# Make a page to display the plots.
1852+
_make_plot_html_page(complete_plot_index)
1853+
1854+
return iris.cube.CubeList([cube_u, cube_v])
1855+
1856+
16731857
def plot_histogram_series(
16741858
cubes: iris.cube.Cube | iris.cube.CubeList,
16751859
filename: str = None,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
category: Surface Vector Plot
2+
title: $MODEL_NAME Surface Vector Plot of $VARNAME_U and $VARNAME_V
3+
description: Extracts and plots the surface vector plot of $VARNAME_U and $VARNAME_V for all times in $MODEL_NAME.
4+
5+
steps:
6+
- operator: read.read_cubes
7+
file_paths: $INPUT_PATHS
8+
9+
- operator: plot.vector_plot
10+
cube_u:
11+
operator: filters.filter_cubes
12+
constraint:
13+
operator: constraints.combine_constraints
14+
variable_constraint:
15+
operator: constraints.generate_var_constraint
16+
varname: $VARNAME_U
17+
level_constraint:
18+
operator: constraints.generate_level_constraint
19+
coordinate: $LEVELTYPE
20+
levels: $LEVEL
21+
cube_v:
22+
operator: filters.filter_cubes
23+
constraint:
24+
operator: constraints.combine_constraints
25+
variable_constraint:
26+
operator: constraints.generate_var_constraint
27+
varname: $VARNAME_V
28+
level_constraint:
29+
operator: constraints.generate_level_constraint
30+
coordinate: $LEVELTYPE
31+
levels: $LEVEL
32+
sequence_coordinate: time

tests/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
https://docs.pytest.org/en/latest/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files
1818
"""
1919

20+
import iris.cube
2021
import pytest
2122

2223
from CSET.operators import constraints, filters, read
@@ -83,6 +84,35 @@ def vertical_profile_cube(vertical_profile_cube_readonly):
8384
return vertical_profile_cube_readonly.copy()
8485

8586

87+
@pytest.fixture(scope="session")
88+
def vector_cubes_readonly():
89+
"""Get vector plot cubes. It is NOT safe to modify."""
90+
from CSET.operators import read
91+
92+
# Read the input cubes.
93+
in_cubes = read.read_cubes("tests/test_data/u10_v10.nc")
94+
# Generate constraints for the u and v components of the wind.
95+
var_constraint_u = constraints.generate_var_constraint("eastward_wind")
96+
var_constraint_v = constraints.generate_var_constraint("northward_wind")
97+
lev_contraint = constraints.generate_level_constraint("height", 10)
98+
combined_constraint_u = constraints.combine_constraints(
99+
a=var_constraint_u, b=lev_contraint
100+
)
101+
combined_constraint_v = constraints.combine_constraints(
102+
a=var_constraint_v, b=lev_contraint
103+
)
104+
# filter the cubes using the constraints.
105+
cube_u = filters.filter_cubes(in_cubes, combined_constraint_u)
106+
cube_v = filters.filter_cubes(in_cubes, combined_constraint_v)
107+
return iris.cube.CubeList([cube_u, cube_v])
108+
109+
110+
@pytest.fixture()
111+
def vector_cubes(vector_cubes_readonly):
112+
"""Get vector plot cubes."""
113+
return vector_cubes_readonly.copy()
114+
115+
86116
@pytest.fixture(scope="session")
87117
def histogram_cube_readonly():
88118
"""Get a histogram Cube. It is NOT safe to modify."""

tests/operators/test_plots.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,40 @@ def test_contour_plot_sequence(cube, tmp_working_dir):
259259
assert Path("untitled_462149.0.png").is_file()
260260

261261

262+
def test_vector_plot_with_filename(vector_cubes, tmp_working_dir):
263+
"""Plot a vector plot of u10 and v10 components."""
264+
cube_u = vector_cubes[0].slices_over("time").next()
265+
cube_v = vector_cubes[1].slices_over("time").next()
266+
plot.vector_plot(cube_u, cube_v, filename="testvector")
267+
assert Path("testvector_0.0.png").is_file()
268+
269+
270+
def test_vector_plot_sequence(vector_cubes, tmp_working_dir):
271+
"""Plot a sequence of vector plots."""
272+
plot.vector_plot(
273+
vector_cubes[0],
274+
vector_cubes[1],
275+
filename="testvectorseq",
276+
sequence_coordinate="time",
277+
)
278+
assert Path("testvectorseq_0.0.png").is_file()
279+
assert Path("testvectorseq_6.0.png").is_file()
280+
assert Path("testvectorseq_12.0.png").is_file()
281+
282+
283+
def test_vector_plot_check(vector_cubes, tmp_working_dir):
284+
"""Check error when cubes has no time coordinate."""
285+
vector_cubes[0].remove_coord("time")
286+
vector_cubes[1].remove_coord("time")
287+
with pytest.raises(ValueError):
288+
plot.vector_plot(
289+
vector_cubes[0],
290+
vector_cubes[1],
291+
filename="testvector",
292+
sequence_coordinate="time",
293+
)
294+
295+
262296
def test_postage_stamp_contour_plot(ensemble_cube, monkeypatch, tmp_path):
263297
"""Plot postage stamp plots of ensemble data."""
264298
# Get a single time step.

tests/test_data/u10_v10.nc

44.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)