@@ -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+
828956def _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+
16731857def plot_histogram_series (
16741858 cubes : iris .cube .Cube | iris .cube .CubeList ,
16751859 filename : str = None ,
0 commit comments