Repository: Unidata/MetPy

Issue:
parse_metar fails when vertical visibility report is truncated
### What went wrong?

When a METAR vertical visibility group (VV) reports ceiling below station level (///) but the number of slashes is less than 3, parse_metar() returns:  ValueError: invalid literal for int() with base 10: ''

In the example below, replacing "VV//" with "VV///" does not throw an error.

### Operating System

MacOS

### Version

1.1.0

### Python Version

3.9

### Code to Reproduce

```python
from metpy.io.metar import parse_metar
str1 = 'METAR ORER 172000Z 30006KT 0400 FG VV// 12/12 Q1013 NOSIG='
parse_metar(str1,year=2021,month=12)
```


### Errors, Traceback, and Logs

```pytb
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/var/folders/vh/c4w866w92g5dl03w6dyrpjzc0000gp/T/ipykernel_93760/169496173.py in <module>
      1 from metpy.io.metar import parse_metar
      2 str1 = 'METAR ORER 172000Z 30006KT 0400 FG VV// 12/12 Q1013 NOSIG='
----> 3 parse_metar(str1,year=2021,month=12)

~/miniconda3/envs/MetPy/lib/python3.8/site-packages/metpy/io/metar.py in parse_metar(metar_text, year, month, station_metadata)
    246         skyc[0] = 'VV'
    247         level = tree.skyc.text.strip()[2:5]
--> 248         skylev[0] = np.nan if '/' in level else 100 * int(level)
    249     else:
    250         for ind, part in enumerate(tree.skyc.text.strip().split(maxsplit=3)):

ValueError: invalid literal for int() with base 10: ''
```


Discussion:

Patch:
diff --git a/ci/doc_requirements.txt b/ci/doc_requirements.txt
index 97749bc88c..6a8a691e28 100644
--- a/ci/doc_requirements.txt
+++ b/ci/doc_requirements.txt
@@ -1,4 +1,4 @@
-sphinx==4.3.2
+sphinx==4.3.1
 pydata-sphinx-theme==0.7.2
 sphinx-gallery==0.10.1
 myst-parser==0.16.1
diff --git a/ci/requirements.txt b/ci/requirements.txt
index 10bf68a1ea..7980fd0cba 100644
--- a/ci/requirements.txt
+++ b/ci/requirements.txt
@@ -1,4 +1,4 @@
-importlib_metadata==4.10.0
+importlib_metadata==4.9.0
 importlib_resources==5.4.0
 matplotlib==3.5.1
 numpy==1.21.4
diff --git a/src/metpy/io/_metar_parser/metar_parser.peg b/src/metpy/io/_metar_parser/metar_parser.peg
index 0406c4f13f..de6d6a566f 100644
--- a/src/metpy/io/_metar_parser/metar_parser.peg
+++ b/src/metpy/io/_metar_parser/metar_parser.peg
@@ -16,7 +16,7 @@ run      <- ((sep "R" [LRC]? [\d] [\d] [LRC]? "/" ([\d] [\d] [\d] [\d] "V")? ["M
 curwx    <- (((sep "//") / (sep "NSW") / (sep (wx))*))?
 wx       <- (([-+] " "?)? "VC"? ("MI" / "BC" / "PR" / "DR" / "BL" / "SH" / "TS" / "FZ" / "DZ" / "RA" / "SN" / "SG" / "PL" / "GR" / "GS" / "UP" / "BR" / "FG" / "FU" / "VA" / "DU" / "SA" / "HZ" / "PO" / "SQ" / "FC" / "SS" / "DS" / "IC" / "PY")+)
 skyc     <- ((sep (cover))*)?
-cover    <- (("FEW" / "SCT" / "BKN" / "OVC" / "VV" / "///") ([\d]*)? ("TCU" / "CB" / "///")?) / ("CLR" / "SKC" / "NSC" / "NCD") / wx / "//"
+cover    <- (("FEW" / "SCT" / "BKN" / "OVC" / "VV" / "///") ([\d]*)? ("TCU" / "CB" / "//" "/"?)?) / ("CLR" / "SKC" / "NSC" / "NCD") / wx / "//"
 temp_dewp <- ((sep "//"? temp "/" dewp "//"?))?
 temp     <- (([M])?  [\d]? [\d]?)
 dewp     <- (([M])?  [\d]? [\d]?)
diff --git a/src/metpy/io/_metar_parser/metar_parser.py b/src/metpy/io/_metar_parser/metar_parser.py
index 6470dd9ee3..c35e1939d4 100644
--- a/src/metpy/io/_metar_parser/metar_parser.py
+++ b/src/metpy/io/_metar_parser/metar_parser.py
@@ -3128,19 +3128,54 @@ class Grammar(object):
                             self._expected.append('"CB"')
                     if address4 is FAILURE:
                         self._offset = index7
-                        chunk9, max9 = None, self._offset + 3
+                        index8, elements2 = self._offset, []
+                        address5 = FAILURE
+                        chunk9, max9 = None, self._offset + 2
                         if max9 <= self._input_size:
                             chunk9 = self._input[self._offset:max9]
-                        if chunk9 == '///':
-                            address4 = TreeNode(self._input[self._offset:self._offset + 3], self._offset, [])
-                            self._offset = self._offset + 3
+                        if chunk9 == '//':
+                            address5 = TreeNode(self._input[self._offset:self._offset + 2], self._offset, [])
+                            self._offset = self._offset + 2
                         else:
-                            address4 = FAILURE
+                            address5 = FAILURE
                             if self._offset > self._failure:
                                 self._failure = self._offset
                                 self._expected = []
                             if self._offset == self._failure:
-                                self._expected.append('"///"')
+                                self._expected.append('"//"')
+                        if address5 is not FAILURE:
+                            elements2.append(address5)
+                            address6 = FAILURE
+                            index9 = self._offset
+                            chunk10, max10 = None, self._offset + 1
+                            if max10 <= self._input_size:
+                                chunk10 = self._input[self._offset:max10]
+                            if chunk10 == '/':
+                                address6 = TreeNode(self._input[self._offset:self._offset + 1], self._offset, [])
+                                self._offset = self._offset + 1
+                            else:
+                                address6 = FAILURE
+                                if self._offset > self._failure:
+                                    self._failure = self._offset
+                                    self._expected = []
+                                if self._offset == self._failure:
+                                    self._expected.append('"/"')
+                            if address6 is FAILURE:
+                                address6 = TreeNode(self._input[index9:index9], index9, [])
+                                self._offset = index9
+                            if address6 is not FAILURE:
+                                elements2.append(address6)
+                            else:
+                                elements2 = None
+                                self._offset = index8
+                        else:
+                            elements2 = None
+                            self._offset = index8
+                        if elements2 is None:
+                            address4 = FAILURE
+                        else:
+                            address4 = TreeNode(self._input[index8:self._offset], index8, elements2)
+                            self._offset = self._offset
                         if address4 is FAILURE:
                             self._offset = index7
                 if address4 is FAILURE:
@@ -3164,11 +3199,11 @@ class Grammar(object):
             self._offset = self._offset
         if address0 is FAILURE:
             self._offset = index1
-            index8 = self._offset
-            chunk10, max10 = None, self._offset + 3
-            if max10 <= self._input_size:
-                chunk10 = self._input[self._offset:max10]
-            if chunk10 == 'CLR':
+            index10 = self._offset
+            chunk11, max11 = None, self._offset + 3
+            if max11 <= self._input_size:
+                chunk11 = self._input[self._offset:max11]
+            if chunk11 == 'CLR':
                 address0 = TreeNode(self._input[self._offset:self._offset + 3], self._offset, [])
                 self._offset = self._offset + 3
             else:
@@ -3179,11 +3214,11 @@ class Grammar(object):
                 if self._offset == self._failure:
                     self._expected.append('"CLR"')
             if address0 is FAILURE:
-                self._offset = index8
-                chunk11, max11 = None, self._offset + 3
-                if max11 <= self._input_size:
-                    chunk11 = self._input[self._offset:max11]
-                if chunk11 == 'SKC':
+                self._offset = index10
+                chunk12, max12 = None, self._offset + 3
+                if max12 <= self._input_size:
+                    chunk12 = self._input[self._offset:max12]
+                if chunk12 == 'SKC':
                     address0 = TreeNode(self._input[self._offset:self._offset + 3], self._offset, [])
                     self._offset = self._offset + 3
                 else:
@@ -3194,11 +3229,11 @@ class Grammar(object):
                     if self._offset == self._failure:
                         self._expected.append('"SKC"')
                 if address0 is FAILURE:
-                    self._offset = index8
-                    chunk12, max12 = None, self._offset + 3
-                    if max12 <= self._input_size:
-                        chunk12 = self._input[self._offset:max12]
-                    if chunk12 == 'NSC':
+                    self._offset = index10
+                    chunk13, max13 = None, self._offset + 3
+                    if max13 <= self._input_size:
+                        chunk13 = self._input[self._offset:max13]
+                    if chunk13 == 'NSC':
                         address0 = TreeNode(self._input[self._offset:self._offset + 3], self._offset, [])
                         self._offset = self._offset + 3
                     else:
@@ -3209,11 +3244,11 @@ class Grammar(object):
                         if self._offset == self._failure:
                             self._expected.append('"NSC"')
                     if address0 is FAILURE:
-                        self._offset = index8
-                        chunk13, max13 = None, self._offset + 3
-                        if max13 <= self._input_size:
-                            chunk13 = self._input[self._offset:max13]
-                        if chunk13 == 'NCD':
+                        self._offset = index10
+                        chunk14, max14 = None, self._offset + 3
+                        if max14 <= self._input_size:
+                            chunk14 = self._input[self._offset:max14]
+                        if chunk14 == 'NCD':
                             address0 = TreeNode(self._input[self._offset:self._offset + 3], self._offset, [])
                             self._offset = self._offset + 3
                         else:
@@ -3224,16 +3259,16 @@ class Grammar(object):
                             if self._offset == self._failure:
                                 self._expected.append('"NCD"')
                         if address0 is FAILURE:
-                            self._offset = index8
+                            self._offset = index10
             if address0 is FAILURE:
                 self._offset = index1
                 address0 = self._read_wx()
                 if address0 is FAILURE:
                     self._offset = index1
-                    chunk14, max14 = None, self._offset + 2
-                    if max14 <= self._input_size:
-                        chunk14 = self._input[self._offset:max14]
-                    if chunk14 == '//':
+                    chunk15, max15 = None, self._offset + 2
+                    if max15 <= self._input_size:
+                        chunk15 = self._input[self._offset:max15]
+                    if chunk15 == '//':
                         address0 = TreeNode(self._input[self._offset:self._offset + 2], self._offset, [])
                         self._offset = self._offset + 2
                     else:
diff --git a/src/metpy/io/metar.py b/src/metpy/io/metar.py
index 53fea540cb..3b84c79647 100644
--- a/src/metpy/io/metar.py
+++ b/src/metpy/io/metar.py
@@ -261,7 +261,7 @@ def parse_metar(metar_text, year, month, station_metadata=station_info):
     if tree.skyc.text[1:3] == 'VV':
         skyc[0] = 'VV'
         level = tree.skyc.text.strip()[2:5]
-        skylev[0] = np.nan if '/' in level else 100 * int(level)
+        skylev[0] = np.nan if not level or '/' in level else 100 * int(level)
     else:
         for ind, part in enumerate(tree.skyc.text.strip().split(maxsplit=3)):
             cover = part[:3]


Test patch:
diff --git a/tests/io/test_metar.py b/tests/io/test_metar.py
index ec342e5631..d44930bf62 100644
--- a/tests/io/test_metar.py
+++ b/tests/io/test_metar.py
@@ -176,12 +176,18 @@ from metpy.units import units
     ('SKCG 031730Z 13004KT 0500 + TSRA BKN010 25/25 Q1012 RMK A2990',
      Metar('SKCG', 10.43, -75.52, 1, datetime(2017, 5, 3, 17, 30), 130, 4, np.nan, 500,
            '+TSRA', np.nan, np.nan, 'BKN', 1000, np.nan, np.nan, np.nan, np.nan, np.nan,
-           np.nan, 6, 25, 25, units.Quantity(1012, 'hPa').m_as('inHg'), 1097, 0, 0, 'A2990'))],
+           np.nan, 6, 25, 25, units.Quantity(1012, 'hPa').m_as('inHg'), 1097, 0, 0, 'A2990')),
+    # Truncated VV group
+    ('METAR ORER 172000Z 30006KT 0400 FG VV// 12/12 Q1013 NOSIG=',
+     Metar('ORER', 36.22, 43.97, 409, datetime(2017, 5, 17, 20, 0), 300, 6.0, np.nan,
+           400, 'FG', np.nan, np.nan, 'VV', np.nan, np.nan, np.nan, np.nan, np.nan,
+           np.nan, np.nan, 8, 12, 12, units.Quantity(1013, 'hPa').m_as('inHg'), 45, 0, 0,
+           'NOSIG'))],
     ids=['missing station', 'BKN', 'FEW', 'current weather', 'smoke', 'CAVOK', 'vis fraction',
          'missing temps', 'missing data', 'vertical vis', 'missing vertical vis', 'BCFG',
          '-DZ', 'sky cover CB', '5 sky levels', '-FZUP', 'VV group', 'COR placement',
          'M1/4SM vis', 'variable vis', 'runway vis', 'odd COR', 'IC', 'NSW',
-         'variable vis no dir', 'swapped wind and vis', 'space in wx code'])
+         'variable vis no dir', 'swapped wind and vis', 'space in wx code', 'truncated VV'])
 def test_metar_parser(metar, truth):
     """Test parsing individual METARs."""
     assert parse_metar(metar, 2017, 5) == truth
@@ -250,7 +256,7 @@ def test_parse_file():
     assert counts.cloud_coverage == 8980
     assert counts.air_temperature == 8779
     assert counts.dew_point_temperature == 8740
-    assert counts.altimeter == 8450
+    assert counts.altimeter == 8458
     assert counts.remarks == 8980
     assert (df.current_wx1_symbol != 0).sum() == counts.current_wx1
     assert (df.current_wx2_symbol != 0).sum() == counts.current_wx2
@@ -313,7 +319,7 @@ def test_parse_file_bad_encoding():
     assert counts.cloud_coverage == 8802
     assert counts.air_temperature == 8597
     assert counts.dew_point_temperature == 8536
-    assert counts.altimeter == 8246
+    assert counts.altimeter == 8252
     assert counts.remarks == 8802
     assert (df.current_wx1_symbol != 0).sum() == counts.current_wx1
     assert (df.current_wx2_symbol != 0).sum() == counts.current_wx2