A
last analyzed

Complexity

Total Complexity 20

Size/Duplication

Total Lines 233
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 20
dl 0
loc 233
rs 10
c 0
b 0
f 0
1
# Copyright (c) 2016 MetPy Developers.
2
# Distributed under the terms of the BSD 3-Clause License.
3
# SPDX-License-Identifier: BSD-3-Clause
4
"""Create Station-model plots."""
5
6
try:
7
    from enum import Enum
8
except ImportError:
9
    from enum34 import Enum
10
11
import matplotlib
12
import numpy as np
13
14
from .wx_symbols import (current_weather, high_clouds, low_clouds, mid_clouds,
15
                         pressure_tendency, sky_cover, wx_symbol_font)
16
from ..cbook import is_string_like
17
from ..package_tools import Exporter
18
19
exporter = Exporter(globals())
20
21
22
@exporter.export
23
class StationPlot(object):
24
    """Make a standard meteorological station plot.
25
26
    Plots values, symbols, or text spaced around a central location. Can also plot wind
27
    barbs as the center of the location.
28
    """
29
30
    location_names = {'C': (0, 0), 'N': (0, 1), 'NE': (1, 1), 'E': (1, 0), 'SE': (1, -1),
31
                      'S': (0, -1), 'SW': (-1, -1), 'W': (-1, 0), 'NW': (-1, 1)}
32
33
    def __init__(self, ax, x, y, fontsize=10, spacing=None, transform=None, **kwargs):
34
        """Initialize the StationPlot with items that do not change.
35
36
        This sets up the axes and station locations. The `fontsize` and `spacing`
37
        are also specified here to ensure that they are consistent between individual
38
        station elements.
39
40
        Parameters
41
        ----------
42
        ax : matplotlib.axes.Axes
43
            The :class:`~matplotlib.axes.Axes` for plotting
44
        x : array_like
45
            The x location of the stations in the plot
46
        y : array_like
47
            The y location of the stations in the plot
48
        fontsize : int
49
            The fontsize to use for drawing text
50
        spacing : int
51
            The spacing, in points, that corresponds to a single increment between
52
            station plot elements.
53
        transform : matplotlib.transforms.Transform (or compatible)
54
            The default transform to apply to the x and y positions when plotting.
55
        kwargs
56
            Additional keyword arguments to use for matplotlib's plotting functions.
57
            These will be passed to all the plotting methods, and thus need to be valid
58
            for all plot types, such as `clip_on`.
59
60
        """
61
        self.ax = ax
62
        self.x = x
63
        self.y = y
64
        self.fontsize = fontsize
65
        self.spacing = fontsize if spacing is None else spacing
66
        self.transform = transform
67
        self.items = {}
68
        self.barbs = None
69
        self.default_kwargs = kwargs
70
71
    def plot_symbol(self, location, codes, symbol_mapper, **kwargs):
72
        """At the specified location in the station model plot a set of symbols.
73
74
        This specifies that at the offset `location`, the data in `codes` should be
75
        converted to unicode characters (for our :data:`wx_symbol_font`) using `symbol_mapper`,
76
        and plotted.
77
78
        Additional keyword arguments given will be passed onto the actual plotting
79
        code; this is useful for specifying things like color or font properties.
80
81
        If something has already been plotted at this location, it will be replaced.
82
83
        Parameters
84
        ----------
85
        location : str or tuple[float, float]
86
            The offset (relative to center) to plot this parameter. If str, should be one of
87
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
88
            specifying the number of increments in the x and y directions; increments
89
            are multiplied by `spacing` to give offsets in x and y relative to the center.
90
        codes : array_like
91
            The numeric values that should be converted to unicode characters for plotting.
92
        symbol_mapper : callable
93
            Controls converting data values to unicode code points for the
94
            :data:`wx_symbol_font` font. This should take a value and return a single unicode
95
            character. See :mod:`metpy.plots.wx_symbols` for included mappers.
96
        kwargs
97
            Additional keyword arguments to use for matplotlib's plotting functions.
98
99
100
        .. plot::
101
102
           import matplotlib.pyplot as plt
103
           import numpy as np
104
           from math import ceil
105
106
           from metpy.plots import StationPlot
107
           from metpy.plots.wx_symbols import current_weather, current_weather_auto
108
           from metpy.plots.wx_symbols import low_clouds, mid_clouds, high_clouds
109
           from metpy.plots.wx_symbols import sky_cover, pressure_tendency
110
111
112
           def plot_symbols(mapper, name, nwrap=12, figsize=(10, 1.4)):
113
114
               # Determine how many symbols there are and layout in rows of nwrap
115
               # if there are more than nwrap symbols
116
               num_symbols = len(mapper)
117
               codes = np.arange(len(mapper))
118
               ncols = nwrap
119
               if num_symbols <= nwrap:
120
                   nrows = 1
121
                   x = np.linspace(0, 1, len(mapper))
122
                   y = np.ones_like(x)
123
                   ax_height = 0.8
124
               else:
125
                   nrows = int(ceil(num_symbols / ncols))
126
                   x = np.tile(np.linspace(0, 1, ncols), nrows)[:num_symbols]
127
                   y = np.repeat(np.arange(nrows, 0, -1), ncols)[:num_symbols]
128
                   figsize = (10, 1 * nrows + 0.4)
129
                   ax_height = 0.8 + 0.018 * nrows
130
131
               fig = plt.figure(figsize=figsize,  dpi=300)
132
               ax = fig.add_axes([0, 0, 1, ax_height])
133
               ax.set_title(name, size=20)
134
               ax.xaxis.set_ticks([])
135
               ax.yaxis.set_ticks([])
136
               ax.set_frame_on(False)
137
138
               # Plot
139
               sp = StationPlot(ax, x, y, fontsize=36)
140
               sp.plot_symbol('C', codes, mapper)
141
               sp.plot_parameter((0, -1), codes, fontsize=18)
142
143
               ax.set_ylim(-0.05, nrows + 0.5)
144
145
               plt.show()
146
147
148
           plot_symbols(current_weather, "Current Weather Symbols")
149
           plot_symbols(current_weather_auto, "Current Weather Auto Reported Symbols")
150
           plot_symbols(low_clouds, "Low Cloud Symbols")
151
           plot_symbols(mid_clouds, "Mid Cloud Symbols")
152
           plot_symbols(high_clouds, "High Cloud Symbols")
153
           plot_symbols(sky_cover, "Sky Cover Symbols")
154
           plot_symbols(pressure_tendency, "Pressure Tendency Symbols")
155
156
        See Also
157
        --------
158
        plot_barb, plot_parameter, plot_text
159
160
        """
161
        # Make sure we use our font for symbols
162
        kwargs['fontproperties'] = wx_symbol_font.copy()
163
        return self.plot_parameter(location, codes, symbol_mapper, **kwargs)
164
165
    def plot_parameter(self, location, parameter, formatter='.0f', **kwargs):
166
        """At the specified location in the station model plot a set of values.
167
168
        This specifies that at the offset `location`, the data in `parameter` should be
169
        plotted. The conversion of the data values to a string is controlled by `formatter`.
170
171
        Additional keyword arguments given will be passed onto the actual plotting
172
        code; this is useful for specifying things like color or font properties.
173
174
        If something has already been plotted at this location, it will be replaced.
175
176
        Parameters
177
        ----------
178
        location : str or tuple[float, float]
179
            The offset (relative to center) to plot this parameter. If str, should be one of
180
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
181
            specifying the number of increments in the x and y directions; increments
182
            are multiplied by `spacing` to give offsets in x and y relative to the center.
183
        parameter : array_like
184
            The numeric values that should be plotted
185
        formatter : str or callable, optional
186
            How to format the data as a string for plotting. If a string, it should be
187
            compatible with the :func:`format` builtin. If a callable, this should take a
188
            value and return a string. Defaults to '0.f'.
189
        kwargs
190
            Additional keyword arguments to use for matplotlib's plotting functions.
191
192
193
        See Also
194
        --------
195
        plot_barb, plot_symbol, plot_text
196
197
        """
198
        text = self._to_string_list(parameter, formatter)
199
        return self.plot_text(location, text, **kwargs)
200
201
    def plot_text(self, location, text, **kwargs):
202
        """At the specified location in the station model plot a collection of text.
203
204
        This specifies that at the offset `location`, the strings in `text` should be
205
        plotted.
206
207
        Additional keyword arguments given will be passed onto the actual plotting
208
        code; this is useful for specifying things like color or font properties.
209
210
        If something has already been plotted at this location, it will be replaced.
211
212
        Parameters
213
        ----------
214
        location : str or tuple[float, float]
215
            The offset (relative to center) to plot this parameter. If str, should be one of
216
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
217
            specifying the number of increments in the x and y directions; increments
218
            are multiplied by `spacing` to give offsets in x and y relative to the center.
219
        text : list (or array) of strings
220
            The strings that should be plotted
221
        kwargs
222
            Additional keyword arguments to use for matplotlib's plotting functions.
223
224
        See Also
225
        --------
226
        plot_barb, plot_parameter, plot_symbol
227
228
        """
229
        location = self._handle_location(location)
230
231
        kwargs = self._make_kwargs(kwargs)
232
        text_collection = self.ax.scattertext(self.x, self.y, text, loc=location,
233
                                              size=kwargs.pop('fontsize', self.fontsize),
234
                                              **kwargs)
235
        if location in self.items:
236
            self.items[location].remove()
237
        self.items[location] = text_collection
238
        return text_collection
239
240
    def plot_barb(self, u, v, **kwargs):
241
        r"""At the center of the station model plot wind barbs.
242
243
        Additional keyword arguments given will be passed onto matplotlib's
244
        :meth:`~matplotlib.axes.Axes.barbs` function; this is useful for specifying things
245
        like color or line width.
246
247
        Parameters
248
        ----------
249
        u : array-like
250
            The data to use for the u-component of the barbs.
251
        v : array-like
252
            The data to use for the v-component of the barbs.
253
        kwargs
254
            Additional keyword arguments to pass to matplotlib's
255
            :meth:`~matplotlib.axes.Axes.barbs` function.
256
257
        See Also
258
        --------
259
        plot_parameter, plot_symbol, plot_text
260
261
        """
262
        kwargs = self._make_kwargs(kwargs)
263
264
        # Strip units, CartoPy transform doesn't like
265
        u = np.array(u)
266
        v = np.array(v)
267
268
        # Empirically determined
269
        pivot = 0.51 * np.sqrt(self.fontsize)
270
        length = 1.95 * np.sqrt(self.fontsize)
271
        defaults = {'sizes': {'spacing': .15, 'height': 0.5, 'emptybarb': 0.35},
272
                    'length': length, 'pivot': pivot}
273
        defaults.update(kwargs)
274
275
        # Remove old barbs
276
        if self.barbs:
277
            self.barbs.remove()
278
279
        # Handle transforming our center points. CartoPy doesn't like 1D barbs
280
        # TODO: This can be removed for cartopy > 0.14.3
281
        if hasattr(self.ax, 'projection') and 'transform' in kwargs:
282
            trans = kwargs['transform']
283
            try:
284
                kwargs['transform'] = trans._as_mpl_transform(self.ax)
285
            except AttributeError:
286
                pass
287
            u, v = self.ax.projection.transform_vectors(trans, self.x, self.y, u, v)
288
289
            # Since we've re-implemented CartoPy's barbs, we need to skip calling it here
290
            self.barbs = matplotlib.axes.Axes.barbs(self.ax, self.x, self.y, u, v, **defaults)
291
        else:
292
            self.barbs = self.ax.barbs(self.x, self.y, u, v, **defaults)
293
294
    def _make_kwargs(self, kwargs):
295
        """Assemble kwargs as necessary.
296
297
        Inserts our defaults as well as ensures transform is present when appropriate.
298
        """
299
        # Use default kwargs and update with additional ones
300
        all_kw = self.default_kwargs.copy()
301
        all_kw.update(kwargs)
302
303
        # Pass transform if necessary
304
        if 'transform' not in all_kw and self.transform:
305
            all_kw['transform'] = self.transform
306
307
        return all_kw
308
309
    @staticmethod
310
    def _to_string_list(vals, fmt):
311
        """Convert a sequence of values to a list of strings."""
312
        if not callable(fmt):
313
            def formatter(s):
314
                """Turn a format string into a callable."""
315
                if hasattr(s, 'units'):
316
                    s = np.asscalar(s)
317
                return format(s, fmt)
318
        else:
319
            formatter = fmt
320
321
        return [formatter(v) if np.isfinite(v) else '' for v in vals]
322
323
    def _handle_location(self, location):
324
        """Process locations to get a consistent set of tuples for location."""
325
        if is_string_like(location):
326
            location = self.location_names[location]
327
        xoff, yoff = location
328
        return xoff * self.spacing, yoff * self.spacing
329
330
331
@exporter.export
332
class StationPlotLayout(dict):
333
    r"""make a layout to encapsulate plotting using :class:`StationPlot`.
334
335
    This class keeps a collection of offsets, plot formats, etc. for a parameter based
336
    on its name. This then allows a dictionary of data (or any object that allows looking
337
    up of arrays based on a name) to be passed to :meth:`plot()` to plot the data all at once.
338
339
    See Also
340
    --------
341
    StationPlot
342
343
    """
344
345
    class PlotTypes(Enum):
346
        r"""Different plotting types for the layout.
347
348
        Controls how items are displayed (e.g. converting values to symbols).
349
        """
350
351
        value = 1
352
        symbol = 2
353
        text = 3
354
        barb = 4
355
356
    def add_value(self, location, name, fmt='.0f', units=None, **kwargs):
357
        r"""Add a numeric value to the station layout.
358
359
        This specifies that at the offset `location`, data should be pulled from the data
360
        container using the key `name` and plotted. The conversion of the data values to
361
        a string is controlled by `fmt`. The units required for plotting can also
362
        be passed in using `units`, which will cause the data to be converted before
363
        plotting.
364
365
        Additional keyword arguments given will be passed onto the actual plotting
366
        code; this is useful for specifying things like color or font properties.
367
368
        Parameters
369
        ----------
370
        location : str or tuple[float, float]
371
            The offset (relative to center) to plot this value. If str, should be one of
372
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
373
            specifying the number of increments in the x and y directions.
374
        name : str
375
            The name of the parameter, which is used as a key to pull data out of the
376
            data container passed to :meth:`plot`.
377
        fmt : str or callable, optional
378
            How to format the data as a string for plotting. If a string, it should be
379
            compatible with the :func:`format` builtin. If a callable, this should take a
380
            value and return a string. Defaults to '0.f'.
381
        units : pint-compatible unit, optional
382
            The units to use for plotting. Data will be converted to this unit before
383
            conversion to a string. If not specified, no conversion is done.
384
        kwargs
385
            Additional keyword arguments to use for matplotlib's plotting functions.
386
387
        See Also
388
        --------
389
        add_barb, add_symbol, add_text
390
391
        """
392
        self[location] = (self.PlotTypes.value, name, (fmt, units, kwargs))
393
394
    def add_symbol(self, location, name, symbol_mapper, **kwargs):
395
        r"""Add a symbol to the station layout.
396
397
        This specifies that at the offset `location`, data should be pulled from the data
398
        container using the key `name` and plotted. Data values will converted to glyphs
399
        appropriate for MetPy's symbol font using the callable `symbol_mapper`.
400
401
        Additional keyword arguments given will be passed onto the actual plotting
402
        code; this is useful for specifying things like color or font properties.
403
404
        Parameters
405
        ----------
406
        location : str or tuple[float, float]
407
            The offset (relative to center) to plot this value. If str, should be one of
408
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
409
            specifying the number of increments in the x and y directions.
410
        name : str
411
            The name of the parameter, which is used as a key to pull data out of the
412
            data container passed to :meth:`plot`.
413
        symbol_mapper : callable
414
            Controls converting data values to unicode code points for the
415
            :data:`wx_symbol_font` font. This should take a value and return a single unicode
416
            character. See :mod:`metpy.plots.wx_symbols` for included mappers.
417
        kwargs
418
            Additional keyword arguments to use for matplotlib's plotting functions.
419
420
        See Also
421
        --------
422
        add_barb, add_text, add_value
423
424
        """
425
        self[location] = (self.PlotTypes.symbol, name, (symbol_mapper, kwargs))
426
427
    def add_text(self, location, name, **kwargs):
428
        r"""Add a text field to the  station layout.
429
430
        This specifies that at the offset `location`, data should be pulled from the data
431
        container using the key `name` and plotted directly as text with no conversion
432
        applied.
433
434
        Additional keyword arguments given will be passed onto the actual plotting
435
        code; this is useful for specifying things like color or font properties.
436
437
        Parameters
438
        ----------
439
        location : str or tuple(float, float)
440
            The offset (relative to center) to plot this value. If str, should be one of
441
            'C', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', or 'NW'. Otherwise, should be a tuple
442
            specifying the number of increments in the x and y directions.
443
        name : str
444
            The name of the parameter, which is used as a key to pull data out of the
445
            data container passed to :meth:`plot`.
446
        kwargs
447
            Additional keyword arguments to use for matplotlib's plotting functions.
448
449
        See Also
450
        --------
451
        add_barb, add_symbol, add_value
452
453
        """
454
        self[location] = (self.PlotTypes.text, name, kwargs)
455
456
    def add_barb(self, u_name, v_name, units=None, **kwargs):
457
        r"""Add a wind barb to the center of the station layout.
458
459
        This specifies that u- and v-component data should be pulled from the data
460
        container using the keys `u_name` and `v_name`, respectively, and plotted as
461
        a wind barb at the center of the station plot. If `units` are given, both
462
        components will be converted to these units.
463
464
        Additional keyword arguments given will be passed onto the actual plotting
465
        code; this is useful for specifying things like color or line width.
466
467
        Parameters
468
        ----------
469
        u_name : str
470
            The name of the parameter for the u-component for `barbs`, which is used as
471
            a key to pull data out of the data container passed to :meth:`plot`.
472
        v_name : str
473
            The name of the parameter for the v-component for `barbs`, which is used as
474
            a key to pull data out of the data container passed to :meth:`plot`.
475
        units : pint-compatible unit, optional
476
            The units to use for plotting. Data will be converted to this unit before
477
            conversion to a string. If not specified, no conversion is done.
478
        kwargs
479
            Additional keyword arguments to use for matplotlib's
480
            :meth:`~matplotlib.axes.Axes.barbs` function.
481
482
        See Also
483
        --------
484
        add_symbol, add_text, add_value
485
486
        """
487
        # Not sure if putting the v_name as a plot-specific option is appropriate,
488
        # but it seems simpler than making name code in plot handle tuples
489
        self['barb'] = (self.PlotTypes.barb, (u_name, v_name), (units, kwargs))
490
491
    def names(self):
492
        """Get the list of names used by the layout.
493
494
        Returns
495
        -------
496
        list[str]
497
            the list of names of variables used by the layout
498
499
        """
500
        ret = []
501
        for item in self.values():
502
            if item[0] == self.PlotTypes.barb:
503
                ret.extend(item[1])
504
            else:
505
                ret.append(item[1])
506
        return ret
507
508
    def plot(self, plotter, data_dict):
509
        """Plot a collection of data using this layout for a station plot.
510
511
        This function iterates through the entire specified layout, pulling the fields named
512
        in the layout from `data_dict` and plotting them using `plotter` as specified
513
        in the layout. Fields present in the layout, but not in `data_dict`, are ignored.
514
515
        Parameters
516
        ----------
517
        plotter : StationPlot
518
            :class:`StationPlot` to use to plot the data. This controls the axes,
519
            spacing, station locations, etc.
520
        data_dict : dict[str, array-like]
521
            Data container that maps a name to an array of data. Data from this object
522
            will be used to fill out the station plot.
523
524
        """
525
        def coerce_data(dat, u):
526
            try:
527
                return dat.to(u).magnitude
528
            except AttributeError:
529
                return dat
530
531
        for loc, info in self.items():
532
            typ, name, args = info
533
            if typ == self.PlotTypes.barb:
534
                # Try getting the data
535
                u_name, v_name = name
536
                u_data = data_dict.get(u_name)
537
                v_data = data_dict.get(v_name)
538
539
                # Plot if we have the data
540
                if not (v_data is None or u_data is None):
541
                    units, kwargs = args
542
                    plotter.plot_barb(coerce_data(u_data, units), coerce_data(v_data, units),
543
                                      **kwargs)
544
            else:
545
                # Check that we have the data for this location
546
                data = data_dict.get(name)
547
                if data is not None:
548
                    # If we have it, hand it to the appropriate method
549
                    if typ == self.PlotTypes.value:
550
                        fmt, units, kwargs = args
551
                        plotter.plot_parameter(loc, coerce_data(data, units), fmt, **kwargs)
552
                    elif typ == self.PlotTypes.symbol:
553
                        mapper, kwargs = args
554
                        plotter.plot_symbol(loc, data, mapper, **kwargs)
555
                    elif typ == self.PlotTypes.text:
556
                        plotter.plot_text(loc, data, **args)
557
558
    def __repr__(self):
559
        """Return string representation of layout."""
560
        return ('{' +
561
                ', '.join('{0}: ({1[0].name}, {1[1]}, ...)'.format(loc, info)
562
                          for loc, info in sorted(self.items())) +
563
                '}')
564
565
566
with exporter:
567
    #: :desc: Simple station plot layout
568
    simple_layout = StationPlotLayout()
569
    simple_layout.add_barb('eastward_wind', 'northward_wind', 'knots')
570
    simple_layout.add_value('NW', 'air_temperature', units='degC')
571
    simple_layout.add_value('SW', 'dew_point_temperature', units='degC')
572
    simple_layout.add_value('NE', 'air_pressure_at_sea_level', units='mbar',
573
                            fmt=lambda v: format(10 * v, '03.0f')[-3:])
574
    simple_layout.add_symbol('C', 'cloud_coverage', sky_cover)
575
    simple_layout.add_symbol('W', 'present_weather', current_weather)
576
577
    #: Full NWS station plot `layout`__
578
    #:
579
    #: __ http://oceanservice.noaa.gov/education/yos/resource/JetStream/synoptic/wxmaps.htm
580
    nws_layout = StationPlotLayout()
581
    nws_layout.add_value((-1, 1), 'air_temperature', units='degF')
582
    nws_layout.add_symbol((0, 2), 'high_cloud_type', high_clouds)
583
    nws_layout.add_symbol((0, 1), 'medium_cloud_type', mid_clouds)
584
    nws_layout.add_symbol((0, -1), 'low_cloud_type', low_clouds)
585
    nws_layout.add_value((1, 1), 'air_pressure_at_sea_level', units='mbar',
586
                         fmt=lambda v: format(10 * v, '03.0f')[-3:])
587
    nws_layout.add_value((-2, 0), 'visibility_in_air', fmt='.0f', units='miles')
588
    nws_layout.add_symbol((-1, 0), 'present_weather', current_weather)
589
    nws_layout.add_symbol((0, 0), 'cloud_coverage', sky_cover)
590
    nws_layout.add_value((1, 0), 'tendency_of_air_pressure', units='mbar',
591
                         fmt=lambda v: ('-' if v < 0 else '') + format(10 * abs(v), '02.0f'))
592
    nws_layout.add_symbol((2, 0), 'tendency_of_air_pressure_symbol', pressure_tendency)
593
    nws_layout.add_barb('eastward_wind', 'northward_wind', units='knots')
594
    nws_layout.add_value((-1, -1), 'dew_point_temperature', units='degF')
595
596
    # TODO: Fix once we have the past weather symbols converted
597
    nws_layout.add_symbol((1, -1), 'past_weather', current_weather)
598