Skip to content

Commit c57c50e

Browse files
committed
Add functionality for plotting data directly on the map
1 parent 0191321 commit c57c50e

4 files changed

Lines changed: 152 additions & 83 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ usage: xcsv_plot_map [-h] [-x XIDX | -X XCOL] [-y YIDX | -Y YCOL]
7474
[--x-label XLABEL] [--y-label YLABEL] [--invert-x-axis]
7575
[--invert-y-axis] [--title TITLE] [--caption CAPTION]
7676
[--label-key LABEL_KEY] [-s FIGSIZE FIGSIZE]
77-
[-p PROJECTION] [-b BG_IMG_PATH] [-o OUT_FILE]
77+
[-p PROJECTION] [-m] [-b BG_IMG_PATH] [-o OUT_FILE]
7878
[-P PLOT_OPTS] [-S] [-V]
7979
in_file [in_file ...]
8080

@@ -110,6 +110,8 @@ optional arguments:
110110
projection to use for displaying the site coordinates
111111
on the map (one of the CRS classes provided by
112112
Cartopy)
113+
-m, --plot-on-map instead of a plot alongside a site map, show just a
114+
map and plot the coordinate data directly on the map
113115
-b BG_IMG_PATH, --background-image BG_IMG_PATH
114116
path to an image to show in the background of the plot
115117
-o OUT_FILE, --out-file OUT_FILE

tests/test_xcsv_plot_map.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,13 @@ def test_get_site_plot_extent_from_bbox_no_units_coord(short_bbox_coord_no_units
225225
actual = p.get_site_plot_extent_from_bbox(short_bbox_coord_no_units_test_data)
226226
assert actual == expected
227227

228+
@pytest.mark.parametrize(['plot_on_map','expected'], [
229+
(False, 2),
230+
(True, 1)
231+
])
232+
def test__setup_fallback_figure_and_axes(plot_on_map, expected):
233+
p = xpm.Plot()
234+
p._setup_fallback_figure_and_axes(plot_on_map=plot_on_map)
235+
actual = len(p.axs)
236+
assert actual == expected
237+

xcsv/plot_map/__init__.py

Lines changed: 128 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,12 @@ def get_site_plot_extent(self, datasets, point_test_key='longitude', bbox_test_k
171171

172172
return extent
173173

174-
def setup_site_plot(self, fig, ax, extent, crs=None, bg_img_name=None, bg_img_resolution='low', coastlines_resolution='10m', add_gridlines=True):
174+
def setup_site_plot(self, ax, extent, crs=None, bg_img_name=None, bg_img_resolution='low', coastlines_resolution='10m', add_gridlines=True, draw_gridline_labels=True):
175175
"""
176176
Setup the site map
177177
178178
This sets fixed properties of the map, such as extent and base map.
179179
180-
:param fig: The figure object
181-
:type fig: matplotlib.figure.Figure
182180
:param ax: The axis object
183181
:type ax: matplotlib.axes.Axes
184182
:param extent: The geographical bounding box extent for the map
@@ -200,6 +198,8 @@ def setup_site_plot(self, fig, ax, extent, crs=None, bg_img_name=None, bg_img_re
200198
:type coastlines_resolution: str
201199
:param add_gridlines: Add gridlines to the map
202200
:type add_gridlines: bool
201+
:param draw_gridline_labels: Draw gridline labels on the map
202+
:type draw_gridline_labels: bool
203203
"""
204204

205205
if not crs:
@@ -214,14 +214,12 @@ def setup_site_plot(self, fig, ax, extent, crs=None, bg_img_name=None, bg_img_re
214214
ax.stock_img()
215215

216216
if add_gridlines:
217-
ax.gridlines()
217+
ax.gridlines(draw_labels=draw_gridline_labels)
218218

219-
def plot_point_site(self, fig, ax, dataset, xkey='longitude', ykey='latitude', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
219+
def plot_point_site(self, ax, dataset, xkey='longitude', ykey='latitude', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
220220
"""
221221
Plot the site information for the given dataset on the map
222222
223-
:param fig: The figure object
224-
:type fig: matplotlib.figure.Figure
225223
:param ax: The axis object
226224
:type ax: matplotlib.axes.Axes
227225
:param dataset: The dataset to plot
@@ -264,12 +262,10 @@ def plot_point_site(self, fig, ax, dataset, xkey='longitude', ykey='latitude', s
264262
except KeyError:
265263
pass
266264

267-
def plot_bbox_site(self, fig, ax, dataset, xminkey='geospatial_lon_min', xmaxkey='geospatial_lon_max', yminkey='geospatial_lat_min', ymaxkey='geospatial_lat_max', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
265+
def plot_bbox_site(self, ax, dataset, xminkey='geospatial_lon_min', xmaxkey='geospatial_lon_max', yminkey='geospatial_lat_min', ymaxkey='geospatial_lat_max', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
268266
"""
269267
Plot the site information for the given dataset on the map
270268
271-
:param fig: The figure object
272-
:type fig: matplotlib.figure.Figure
273269
:param ax: The axis object
274270
:type ax: matplotlib.axes.Axes
275271
:param dataset: The dataset to plot
@@ -324,12 +320,10 @@ def plot_bbox_site(self, fig, ax, dataset, xminkey='geospatial_lon_min', xmaxkey
324320
except KeyError:
325321
pass
326322

327-
def plot_site(self, fig, ax, dataset, point_test_key='longitude', bbox_test_key='geospatial_lon_min', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
323+
def plot_site(self, ax, dataset, point_test_key='longitude', bbox_test_key='geospatial_lon_min', site_key='site', transform=None, xoffset=0, yoffset=-0.5, fontsize='large', horizontalalignment='left', opts={}):
328324
"""
329325
Plot the site information for the given dataset on the map
330326
331-
:param fig: The figure object
332-
:type fig: matplotlib.figure.Figure
333327
:param ax: The axis object
334328
:type ax: matplotlib.axes.Axes
335329
:param dataset: The dataset to plot
@@ -361,13 +355,13 @@ def plot_site(self, fig, ax, dataset, point_test_key='longitude', bbox_test_key=
361355
# Plot according to whether site coordinates are given by a point
362356
# or a bounding box
363357
if point_test_key in dataset.metadata['header']:
364-
self.plot_point_site(fig, ax, dataset, site_key=site_key, transform=transform, xoffset=xoffset, yoffset=yoffset, fontsize=fontsize, horizontalalignment=horizontalalignment, opts=opts)
358+
self.plot_point_site(ax, dataset, site_key=site_key, transform=transform, xoffset=xoffset, yoffset=yoffset, fontsize=fontsize, horizontalalignment=horizontalalignment, opts=opts)
365359
elif bbox_test_key in dataset.metadata['header']:
366-
self.plot_bbox_site(fig, ax, dataset, site_key=site_key, transform=transform, xoffset=xoffset, yoffset=yoffset, fontsize=fontsize, horizontalalignment=horizontalalignment, opts=opts)
360+
self.plot_bbox_site(ax, dataset, site_key=site_key, transform=transform, xoffset=xoffset, yoffset=yoffset, fontsize=fontsize, horizontalalignment=horizontalalignment, opts=opts)
367361
else:
368362
raise KeyError(f"Cannot plot site on the map as no spatial coordinate keys were found in the header")
369363

370-
def setup_figure_and_axes(self, figsize=None, width_ratios=[1,1], projection=None):
364+
def setup_figure_and_axes(self, figsize=None, nrows=1, ncols=2, width_ratios=[1,1], projection=None):
371365
"""
372366
Setup the figure and axes array
373367
@@ -381,9 +375,15 @@ def setup_figure_and_axes(self, figsize=None, width_ratios=[1,1], projection=Non
381375
382376
:param figsize: The figure size tuple as (width, height)
383377
:type figsize: tuple
384-
:param width_ratios: The width ratios of the two subplots - the data
385-
plot and the map, in that order. For example, [2,1] will make the
386-
plot twice the size of the map
378+
:param nrows: The number of rows for the subplots (1 or 2)
379+
:type nrows: int
380+
:param ncols: The number of columns for the subplots (1 or 2)
381+
:type ncols: int
382+
:param width_ratios: The width ratios of the subplots. If there is
383+
only one subplot, then it is the map, and this should be [1]. If
384+
there are two subplots, then these are the data plot and the map, in
385+
that order. For example, [2,1] will make the plot twice the size of
386+
the map
387387
:type width_ratios: list
388388
:param projection: The projection to transform the coordinates on the
389389
map. If not specified, it defaults to ccrs.PlateCarree()
@@ -394,12 +394,109 @@ def setup_figure_and_axes(self, figsize=None, width_ratios=[1,1], projection=Non
394394
projection = ccrs.PlateCarree()
395395

396396
self.fig = plt.figure(figsize=figsize)
397-
gs = self.fig.add_gridspec(1, 2, width_ratios=width_ratios)
397+
gs = self.fig.add_gridspec(nrows=nrows, ncols=ncols, width_ratios=width_ratios)
398+
399+
if nrows * ncols > 1:
400+
self.axs.append(self.fig.add_subplot(gs[0, 0]))
401+
self.axs.append(self.fig.add_subplot(gs[0, 1], projection=projection))
402+
else:
403+
self.axs.append(self.fig.add_subplot(gs[0, 0], projection=projection))
404+
405+
def _setup_fallback_figure_and_axes(self, fig=None, axs=None, plot_on_map=False):
406+
"""
407+
Setup a fallback figure and axes array
408+
409+
This calls setup_figure_and_axes() if it hasn't already beeen called
410+
411+
:param fig: The figure object
412+
:type fig: matplotlib.figure.Figure
413+
:param axs: The axes array
414+
:type axs: matplotlib.axes.Axes
415+
:param plot_on_map: Only setup a map, as the data are to be plotted
416+
directly on the map
417+
:type plot_on_map: bool
418+
"""
419+
420+
if fig:
421+
self.fig = fig
422+
423+
if axs:
424+
self.axs = axs
425+
426+
if not self.fig or not self.axs:
427+
fa_opts = {'nrows': 1, 'ncols': 2, 'width_ratios': [1,1]}
428+
429+
if plot_on_map:
430+
fa_opts = {'nrows': 1, 'ncols': 1, 'width_ratios': [1]}
431+
432+
self.setup_figure_and_axes(**fa_opts)
433+
434+
def _add_figure_annotations(self, axs_idx=0, map_axs_idx=1, plot_on_map=False):
435+
"""
436+
Add annotations to the figure
437+
438+
:param axs_idx: The index of the axis object in the axs array
439+
:type axs_idx: int
440+
:param map_axs_idx: The index of the map axis object in the axs array
441+
:type map_axs_idx: int
442+
:param plot_on_map: Only setup a map, as the data are to be plotted
443+
directly on the map
444+
:type plot_on_map: bool
445+
"""
446+
447+
self.add_figure_title(self.title)
448+
self.add_figure_caption(self.caption)
449+
450+
if plot_on_map:
451+
self.setup_site_plot(self.axs[axs_idx], self.get_site_plot_extent(self.datasets))
452+
else:
453+
self.setup_data_plot(self.axs[axs_idx], xlabel=self.xlabel, ylabel=self.ylabel)
454+
self.setup_site_plot(self.axs[map_axs_idx], self.get_site_plot_extent(self.datasets))
455+
456+
def _plot_datasets(self, axs_idx=0, map_axs_idx=1, plot_on_map=False, invert_xaxis=False, invert_yaxis=False, opts={}):
457+
"""
458+
Plot the data for the figure datasets
459+
460+
:param axs_idx: The index of the axis object in the axs array
461+
:type axs_idx: int
462+
:param map_axs_idx: The index of the map axis object in the axs array
463+
:type map_axs_idx: int
464+
:param plot_on_map: Only setup a map, as the data are to be plotted
465+
directly on the map
466+
:type plot_on_map: bool
467+
:param invert_xaxis: Invert the x-axis
468+
:type invert_xaxis: bool
469+
:param invert_yaxis: Invert the y-axis
470+
:type invert_yaxis: bool
471+
:param opts: Option kwargs to apply to all plots (e.g., color, marker)
472+
:type opts: dict
473+
"""
474+
475+
generate_colors = True
476+
477+
if 'color' in opts:
478+
generate_colors = False
479+
480+
for i, dataset in enumerate(self.datasets):
481+
label = dataset.get_metadata_item_value(self.label_key)
482+
483+
if generate_colors:
484+
opts.update({'color': f'C{i}'})
398485

399-
self.axs.append(self.fig.add_subplot(gs[0, 0]))
400-
self.axs.append(self.fig.add_subplot(gs[0, 1], projection=projection))
486+
if plot_on_map:
487+
projection = self.axs[axs_idx].projection
401488

402-
def plot_datasets(self, datasets, fig=None, axs=None, axs_idx=0, map_axs_idx=1, xcol=None, ycol=None, xidx=None, yidx=0, xlabel=None, ylabel=None, title=None, title_wrap=True, caption=None, label_key=None, invert_xaxis=False, invert_yaxis=False, show=True, opts={}):
489+
if not projection:
490+
projection = ccrs.PlateCarree()
491+
492+
opts.update({'transform': projection})
493+
494+
self.plot_data(self.axs[axs_idx], dataset, self.xcol, self.ycol, label=label, invert_xaxis=invert_xaxis, invert_yaxis=invert_yaxis, opts=opts)
495+
else:
496+
self.plot_data(self.axs[axs_idx], dataset, self.xcol, self.ycol, label=label, invert_xaxis=invert_xaxis, invert_yaxis=invert_yaxis, opts=opts)
497+
self.plot_site(self.axs[map_axs_idx], dataset, site_key=self.label_key, opts=opts)
498+
499+
def plot_datasets(self, datasets, fig=None, axs=None, axs_idx=0, map_axs_idx=1, xcol=None, ycol=None, xidx=None, yidx=0, xlabel=None, ylabel=None, title=None, caption=None, label_key=None, invert_xaxis=False, invert_yaxis=False, plot_on_map=False, show=True, opts={}):
403500
"""
404501
Plot the data for the given datasets
405502
@@ -442,8 +539,6 @@ def plot_datasets(self, datasets, fig=None, axs=None, axs_idx=0, map_axs_idx=1,
442539
:type ylabel: str
443540
:param title: The figure title text
444541
:type title: str
445-
:param title_wrap: Wrap the title text
446-
:type title_wrap: bool
447542
:param caption: The figure caption text
448543
:type caption: str
449544
:param label_key: The key of a header item in the XCSV header to be
@@ -453,63 +548,20 @@ def plot_datasets(self, datasets, fig=None, axs=None, axs_idx=0, map_axs_idx=1,
453548
:type invert_xaxis: bool
454549
:param invert_yaxis: Invert the y-axis
455550
:type invert_yaxis: bool
551+
:param plot_on_map: Instead of plotting the data on a plot alongside
552+
the site map, show just a map and plot the data directly on the map.
553+
This requires the data to be coordinates
554+
:type plot_on_map: bool
456555
:param show: Show the plot
457556
:type show: bool
458557
:param opts: Option kwargs to apply to all plots (e.g., color, marker)
459558
:type opts: dict
460559
"""
461-
462-
if fig:
463-
self.fig = fig
464-
465-
if axs:
466-
self.axs = axs
467-
468-
if not self.fig or not self.axs:
469-
self.setup_figure_and_axes()
470-
471-
self.datasets = datasets
472-
self.xcol = xcol
473-
self.ycol = ycol
474-
generate_colors = True
475-
476-
if not title:
477-
title = datasets[0].get_metadata_item_value(self.DEFAULTS['title_key'])
478-
479-
if not caption:
480-
caption = datasets[0].get_metadata_item_value(self.DEFAULTS['caption_key'])
481-
482-
if not label_key:
483-
label_key = self.DEFAULTS['label_key']
484-
485-
if not xcol:
486-
if xidx is not None:
487-
self.xcol = datasets[0].data.iloc[:, xidx].name
488-
489-
if not ycol:
490-
self.ycol = datasets[0].data.iloc[:, yidx].name
491-
492-
if not xlabel:
493-
xlabel = self.xcol
494-
495-
if not ylabel:
496-
ylabel = self.ycol
497-
498-
if 'color' in opts:
499-
generate_colors = False
500-
501-
self.fig.suptitle(title, wrap=title_wrap)
502-
self.setup_data_plot(self.fig, self.axs[axs_idx], caption=caption, xlabel=xlabel, ylabel=ylabel)
503-
self.setup_site_plot(self.fig, self.axs[map_axs_idx], self.get_site_plot_extent(datasets))
504-
505-
for i, dataset in enumerate(datasets):
506-
label = dataset.get_metadata_item_value(label_key)
507-
508-
if generate_colors:
509-
opts.update({'color': f'C{i}'})
510560

511-
self.plot_data(self.fig, self.axs[axs_idx], dataset, self.xcol, self.ycol, label=label, invert_xaxis=invert_xaxis, invert_yaxis=invert_yaxis, opts=opts)
512-
self.plot_site(self.fig, self.axs[map_axs_idx], dataset, site_key=label_key, opts=opts)
561+
self._setup_fallback_figure_and_axes(fig, axs, plot_on_map)
562+
self._store_figure_parameters(datasets, xcol, ycol, xidx, yidx, xlabel, ylabel, title, caption, label_key)
563+
self._add_figure_annotations(axs_idx, map_axs_idx, plot_on_map)
564+
self._plot_datasets(axs_idx, map_axs_idx, plot_on_map, invert_xaxis, invert_yaxis, opts)
513565

514566
if show:
515567
plt.show()

xcsv/plot_map/__main__.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def parse_cmdln():
9797
parser.add_argument('-s', '--figsize', help='size of the figure (width height)', dest='figsize', nargs=2, default=None, type=int)
9898
parser.add_argument('-p', '--map-projection', help='projection to use for displaying the site coordinates on the map (one of the CRS classes provided by Cartopy)', dest='projection', default=None, type=str)
9999

100+
parser.add_argument('-m', '--plot-on-map', help='instead of a plot alongside a site map, show just a map and plot the coordinate data directly on the map', dest='plot_on_map', action='store_true', default=False)
101+
100102
parser.add_argument('-b', '--background-image', help='path to an image to show in the background of the plot', dest='bg_img_path', default=None, type=str)
101103

102104
parser.add_argument('-o', '--out-file', help='output plot file')
@@ -119,15 +121,18 @@ def main():
119121
datasets = get_datasets(args.in_file)
120122
plotter = xpm.Plot()
121123

122-
if args.figsize or args.projection:
124+
if args.figsize or args.projection or args.plot_on_map:
125+
fa_opts = {'figsize': args.figsize}
126+
123127
if args.projection:
124-
projection = xpm.Plot().get_crs_class_from_string(args.projection)
125-
else:
126-
projection = None
128+
fa_opts['projection'] = xpm.Plot().get_crs_class_from_string(args.projection)
129+
130+
if args.plot_on_map:
131+
fa_opts.update({'nrows': 1, 'ncols': 1, 'width_ratios': [1]})
127132

128-
plotter.setup_figure_and_axes(figsize=args.figsize, projection=projection)
133+
plotter.setup_figure_and_axes(**fa_opts)
129134

130-
plotter.plot_datasets(datasets, xidx=args.xidx, yidx=args.yidx, xcol=args.xcol, ycol=args.ycol, xlabel=args.xlabel, ylabel=args.ylabel, title=args.title, title_wrap=True, caption=args.caption, label_key=args.label_key, invert_xaxis=args.invert_xaxis, invert_yaxis=args.invert_yaxis, show=False, opts=args.plot_opts)
135+
plotter.plot_datasets(datasets, xidx=args.xidx, yidx=args.yidx, xcol=args.xcol, ycol=args.ycol, xlabel=args.xlabel, ylabel=args.ylabel, title=args.title, caption=args.caption, label_key=args.label_key, invert_xaxis=args.invert_xaxis, invert_yaxis=args.invert_yaxis, plot_on_map=args.plot_on_map, show=False, opts=args.plot_opts)
131136

132137
if args.bg_img_path:
133138
plotter.add_plot_bg(img_path=args.bg_img_path)

0 commit comments

Comments
 (0)