MetPy icon indicating copy to clipboard operation
MetPy copied to clipboard

Fronts Plotting Support

Open dopplershift opened this issue 4 years ago • 9 comments

Description Of Changes

This adds path effects, mostly used for lines, to plot fronts. Still todo:

  • [ ] Fix dpi-related plotting issue
  • [ ] Integrate with #1944 or rebase after merging that one
  • [ ] Clean up example more
  • [ ] Add more docs for methods
  • [ ] Add docstring basic plotting example?

Checklist

  • [x] Closes #1153
  • [x] Tests added
  • [ ] Fully documented

dopplershift avatar Apr 08 '22 23:04 dopplershift

The problem I'm talking about, 100 DPI:

image

300 DPI: image

dopplershift avatar Apr 08 '22 23:04 dopplershift

A couple of quick observations (could be wrong as well): There could be instances when decode() function does not like CODSUS formatting. All the examples below are from this surface bulletin (https://mesonet.agron.iastate.edu/wx/afos/p.php?pil=CODSUS&e=201604301800).

For example,

LOWS 1002 2220972 1014 4421147 1009 3761203 1002 3411136 1006 3921062 1005
3270946 1004 3351042 1004 4010960 1016 3930803 1020 4310722 1009 6461225 1007
6721632 997 6050376 996 5230464 1010 7710630 1010 7020544 1005 6030464 982
5521520

If I try reading the surface bulletin as it is, I'll get the following error:

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [200], in <cell line: 1>()
----> 1 decode(ff)

Input In [184], in decode(fobj)
     33     ret['VALID'] = info[0]
     34 elif field in ('HIGHS', 'LOWS'):
---> 35     ret.setdefault(field, []).extend(parse_points(info))
     36 elif field in ('WARM', 'COLD', 'OCFNT', 'STNRY', 'TROF'):
     37     dest = ret.setdefault(field, [])

Input In [184], in parse_points(strs)
     11 for mag_str, loc_str in zip(strs[:-1:2], strs[1::2]):
     12     mag = int(mag_str)
---> 13     yield *str_to_lonlat(loc_str), mag

Input In [184], in str_to_lonlat(s)
      7 def str_to_lonlat(s):
----> 8     return -int(s[3:]) / 10, int(s[:3]) / 10

ValueError: invalid literal for int() with base 10: ''

Notice that all three lines end with the pressure value. After reformatting it to include the location along with the pressure value in the same line, we get the correct output e.g.,

LOWS 1002 2220972 1014 4421147 1009 3761203 1002 3411136 1006 3921062 1005 3270946
1004 3351042 1004 4010960 1016 3930803 1020 4310722 1009 6461225 1007 6721632
997 6050376 996 5230464 1010 7710630 1010 7020544 1005 6030464 982 5521520

decode output of formatted data

'LOWS': [(-97.2, 22.2, 1002),
  (-114.7, 44.2, 1014),
  (-120.3, 37.6, 1009),
  (-113.6, 34.1, 1002),
  (-106.2, 39.2, 1006),
  (-94.6, 32.7, 1005),
  (-104.2, 33.5, 1004),
  (-96.0, 40.1, 1004),
  (-80.3, 39.3, 1016),
  (-72.2, 43.1, 1020),
  (-122.5, 64.6, 1009),
  (-163.2, 67.2, 1007),
  (-37.6, 60.5, 997),
  (-46.4, 52.3, 996),
  (-63.0, 77.1, 1010),
  (-54.4, 70.2, 1010),
  (-46.4, 60.3, 1005),
  (-152.0, 55.2, 982)],

Another issue could be missing or abbreviated pressure values in surface bulletin e.g.,

HIGHS 1012 3601014 1024 3970743 4330679 1031 5260983 1024 4690627 1034 6130890
1028 5241162 1030 5281237 1029 7040890 1034 7911217 1023 4420601 1032 4511291
1031 4091323

Notice the missing pressure value after 3970743 (or maybe they meant that pressure was 1024 at 3970743 and 43306679 but decode had issues in parsing that info. Here's what it returns:

'HIGHS': [(-101.4, 36.0, 1012),
  (-74.3, 39.7, 1024),
  (-0.1, 10.3, 4330679),
  (-0.4, 10.2, 5260983),
  (-0.4, 10.3, 4690627),
  (-116.2, 52.4, 1028),
  (-123.7, 52.8, 1030),
  (-89.0, 70.4, 1029),
  (-121.7, 79.1, 1034),
  (-60.1, 44.2, 1023),
  (-129.1, 45.1, 1032),
  (-132.3, 40.9, 1031)],

Therefore, CODSUS string may need further scrutiny/checks before plotting fronts.

gewitterblitz avatar May 06 '22 15:05 gewitterblitz

Also, I think decode has issues parsing weak fronts, whenever present. For example, in this surface bulletin (https://mesonet.agron.iastate.edu/wx/afos/p.php?pil=CODSUS&e=202205060437):

COLD WK 3595 3494 3395 3295 3296 
OCFNT WK 5554 5555 5657 5757 5856 5954 
OCFNT WK 5954 6051 6049 6046 
WARM WK 6046 5944 5841 
STNRY WK 5251 5252 5153 

will throw an error:

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [981], in <cell line: 1>()
----> 1 decode(data)

Input In [973], in decode(fobj)
     36 elif field in ('WARM', 'COLD', 'OCFNT', 'STNRY', 'TROF'):
     37     dest = ret.setdefault(field, [])
---> 38     vals = parse_path(info)
     39     if continuing:
     40         dest[-1].extend(vals)

Input In [973], in parse_path(strs)
     15 def parse_path(strs):
---> 16     return [str_to_lonlat(s) for s in strs]

Input In [973], in <listcomp>(.0)
     15 def parse_path(strs):
---> 16     return [str_to_lonlat(s) for s in strs]

Input In [973], in str_to_lonlat(s)
      7 def str_to_lonlat(s):
----> 8     return -int(s[3:]) / 10, int(s[:3]) / 10

ValueError: invalid literal for int() with base 10: ''

gewitterblitz avatar May 06 '22 16:05 gewitterblitz

Oooh, these will be good samples to include in tests in #1944. Thanks!

dopplershift avatar May 06 '22 16:05 dopplershift

I've been plotting fronts and think that it actually works pretty well in most cases. There are some things I noticed, though. I'll add comments for each that I find.

For reference, I've been using Python 3.10.4 and matplotlib 3.5.1.

The first issue I noticed was with stationary fronts. See the attached image below where there appears to be some filling going on in the curved parts of the line. This does not happen with the other types of fronts. metpy_issue1

This snippet should be able to reproduce this:

import matplotlib.pyplot as plt
import numpy as np
from scipy import interpolate

from metpy.plots.patheffects import StationaryFront

n = 100
front_lat = [31.08, 31.76, 32.18, 32.01, 31.7, 32.44]
front_lon = [-88.08, -89.23, -90.83, -92.08, -93.27, -95.55]

index = np.arange(len(front_lon))
ii = np.linspace(0, index.max(), n)

xcurve = interpolate.interp1d(index, front_lon, 'cubic')(ii)
ycurve = interpolate.interp1d(index, front_lat, 'cubic')(ii)

plt.plot(xcurve, ycurve, path_effects=[StationaryFront(spacing=6, size=6)],
         linewidth=2)
plt.show()

From what I can tell, the problem probably occurs when creating new graphic contexts to plot each segment of the front. I can take the same coordinates and spacing, etc., and not have the issue when plotting a warm or cold front. Somehow matplotlib decides to fill areas when it should not.

nawendt avatar May 07 '22 13:05 nawendt

While fronts are not too often highly curved, there are cases, such as with highly occluded cyclones, where it can occur. This can lead to situations where the fronts barbs/scallops can appear off of the line. See the example below. metpy_issue2

Here is the snippet that should reproduce it:

import matplotlib.pyplot as plt
import numpy as np
from scipy import interpolate

from metpy.plots.patheffects import OccludedFront

n = 100
front_lat = [44.57, 44.15, 44.18, 44.57, 45.13, 45.43, 45.14,
             44.37, 43.24, 42.19, 41.43, 41.04]
front_lon = [-99.12, -99.13, -99.78, -99.94, -99.26, -98.17,
             -97.06, -95.76, -94.23, -93.18, -92.68, -92.4]

index = np.arange(len(front_lon))
ii = np.linspace(0, index.max(), n)

xcurve = interpolate.interp1d(index, front_lon, 'cubic')(ii)
ycurve = interpolate.interp1d(index, front_lat, 'cubic')(ii)

plt.plot(xcurve, ycurve, path_effects=[OccludedFront(spacing=6, size=6)],
         linewidth=0.5)
plt.show()

I toned down the linewidth to make it a little more obvious. That being said, most fronts, when smoothed, look pretty good. Setting a slightly thicker line width does help in a lot of these cases, too. I think what NMAP ends up doing is taking the curved path into account and just filling between the line and the barb/scallop.

nawendt avatar May 07 '22 13:05 nawendt

To my eye, I think the symbols on the stationary front are not exactly centered within the segments. It is not a significant amount, but it looks like there is more path on the left than the right. See the attached image for an example. metpy_issue3

This snippet should reproduce it:

import matplotlib.pyplot as plt

from metpy.plots.patheffects import StationaryFront

x = [0, 1]
y = [1, 1]

plt.plot(x, y, path_effects=[StationaryFront(spacing=5, size=15)],
         linewidth=2)
plt.show()

nawendt avatar May 22 '22 22:05 nawendt

Choosing larger linewidth values begins to reduce the amount of the scallops/pips that are visible and rounds the edges of pips. The attached image shows an example of this. metpy_issue4

It seems unlikely that you would ever want to increase the width to something this large, but it may be something that has at least some impact on more realistic examples. In my testing, you could mitigate the issue by creating a new graphics context with a different linewidth when drawing the symbols. Of course, if you still had a relatively large linewidth, the line portion could still cover up more of the symbol than you'd like. Perhaps translating the symbol a bit based on the linewidth would be the best solution? Lastly, a larger linewidth accentuates how off-center the symbols appears.

Figure reproduced with the following snippet:

import matplotlib.pyplot as plt

from metpy.plots.patheffects import StationaryFront

x = [0, 1]
y = [1, 1]

plt.plot(x, y, path_effects=[StationaryFront(spacing=5, size=15)],
         linewidth=15)
plt.show()

nawendt avatar Jun 03 '22 16:06 nawendt

Thanks for examining all these corner cases, these are definitely going to help straighten out this implementation.

dopplershift avatar Jun 03 '22 16:06 dopplershift

Ok, thanks again to @nawendt for finding some corner cases. I've been able to fix some things:

  1. Need to scale our size from points to pixels everywhere, this fixed the dpi issue as well as some of the spacing on stationary front
  2. Fixed the facecolor issue @nawendt identified
  3. Fixed the stationary front issues with linewidth by using that as an offset normal to the path.
  4. Adjusted drawing style to use 'miter' join style and 'butt' capstyle, which helped with some of the other issues like rounding and some visual "offsets"

I've incorporated some of those as tests now and you can see in the baseline images what things look like. The only outstanding issue is the aspect where the markers pull away from curved paths. Currently, this is implemented by walking along the path and stamping fixed markers that are rotated to be tangent to the path. To fix that issue, we'd have to change to do more direct generation of the markers or event a full Path of all the markers. Not a bad idea, but it would fundamentally change the approach and delay this further--therefore I'm electing to delay that so we can get something useful out there.

I'm also electing to just wait on the example on getting #1944 in, which is next on my list to wrap this up. This is useful to have as-is.

dopplershift avatar Mar 29 '23 20:03 dopplershift

In working with the initial implementation of this, I also made some variations of the front types that represented strengthening and weakening boundaries. These front symbols are used in NMAP and described here. I know we will use them from time to time at SPC. WPC probably uses them more often on their analysis charts. Would you like that part of this PR or part of separate PR down the line? I'll have to update them to use the fixes that you added recently.

nawendt avatar Mar 30 '23 20:03 nawendt

@nawendt How about as a separate PR since those are all your additions? I think what we have does everything it needs to meet what's documented for the WPC bulletins, though I'm happy to be shown additional products or samples. The GEMPAK docs are helpful, but I'm not sure what a practical sample looks like. I also want to get this across the line so long as it's considered sound technically.

dopplershift avatar Mar 30 '23 21:03 dopplershift

OK. I'll plan on opening a PR once you finalize and merge this implementation.

nawendt avatar Apr 05 '23 23:04 nawendt