Repository: Unidata/MetPy

Issue:
`Level3File` import is a bit bloated
I am using the Level3File class to process some radar data on a server. I do not use any other features from MetPy. I have two issues with the import proess:

1. Upon importing Level3File, I always receive an error `Cannot import USCOUNTIES and USSTATES without Cartopy installed.` I intentionally do not have cartopy installed, because I don't need it. Perhaps the cartopy import could be made lazy, so that you don't get the warning unless you try to *use* USCOUNTIES or USSTATES? As a workaround, I have resorted to doing this:
```python
import logging
logging.disable(logging.ERROR)
from metpy.io.nexrad import Level3File
logging.disable(logging.NOTSET)
```

2. The import process is very slow. I have a particular script which takes 1.3 seconds to execute, of which 1.2 seconds is spent importing Level3File and 0.1 seconds is spent opening a level 3 file and doing something with the data. It's not totally obvious to me where this delay originates, but I would guess we end up importing some other modules which are not actually needed. Perhaps a lazy evaluation of whatever slows this down would be the best solution?
```
In [1]: time from metpy.io.nexrad import Level3File
Cannot import USCOUNTIES and USSTATES without Cartopy installed.
CPU times: user 1.14 s, sys: 89.6 ms, total: 1.23 s
Wall time: 1.23 s
```

Discussion:

Patch:
diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml
index d799cacf54..099599fc33 100644
--- a/.github/workflows/nightly-builds.yml
+++ b/.github/workflows/nightly-builds.yml
@@ -53,7 +53,7 @@ jobs:
       run: |
         cat ci/extra_requirements.txt >> ci/test_requirements.txt
         echo git+git://github.com/hgrecco/pint@master#egg=pint >> ci/test_requirements.txt
-        echo git+git://github.com/pydata/xarray@main#egg=xarray >> ci/test_requirements.txt
+        echo git+git://github.com/pydata/xarray@master#egg=xarray >> ci/test_requirements.txt
 
     # This installs the stuff needed to build and install Shapely and CartoPy from source.
     # Need to install numpy first to make CartoPy happy.
@@ -154,7 +154,7 @@ jobs:
       run: |
         cat ci/extra_requirements.txt >> ci/doc_requirements.txt
         echo git+git://github.com/hgrecco/pint@master#egg=pint >> ci/doc_requirements.txt
-        echo git+git://github.com/pydata/xarray@main#egg=xarray >> ci/doc_requirements.txt
+        echo git+git://github.com/pydata/xarray@master#egg=xarray >> ci/doc_requirements.txt
 
     # This installs the stuff needed to build and install Shapely and CartoPy from source.
     # Need to install numpy first to make CartoPy happy.
diff --git a/ci/requirements.txt b/ci/requirements.txt
index 10d67c90cd..c2a83384f4 100644
--- a/ci/requirements.txt
+++ b/ci/requirements.txt
@@ -1,8 +1,8 @@
 importlib_metadata==4.5.0
 importlib_resources==5.1.4
 matplotlib==3.4.2
-numpy==1.21.0
-pandas==1.2.5
+numpy==1.20.3
+pandas==1.2.4
 pooch==1.4.0
 pint==0.17
 pyproj==3.1.0
diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst
index 9f8d3bff9c..edaf33c640 100644
--- a/docs/_templates/overrides/metpy.calc.rst
+++ b/docs/_templates/overrides/metpy.calc.rst
@@ -82,7 +82,6 @@ Soundings
       parcel_profile
       parcel_profile_with_lcl
       parcel_profile_with_lcl_as_dataset
-      showalter_index
       significant_tornado
       storm_relative_helicity
       supercell_composite
diff --git a/src/metpy/calc/indices.py b/src/metpy/calc/indices.py
index 8ff4268ce4..1757eae0c1 100644
--- a/src/metpy/calc/indices.py
+++ b/src/metpy/calc/indices.py
@@ -66,22 +66,11 @@ def precipitable_water(pressure, dewpoint, *, bottom=None, top=None):
 
     pressure, dewpoint = _remove_nans(pressure, dewpoint)
 
-    min_pres = np.nanmin(pressure)
-    max_pres = np.nanmax(pressure)
-
     if top is None:
-        top = min_pres
-    elif not min_pres <= top <= max_pres:
-        raise ValueError(f'The pressure and dewpoint profile ranges from {max_pres} to '
-                         f'{min_pres}, after removing missing values. {top} is outside this '
-                         'range.')
+        top = np.nanmin(pressure)
 
     if bottom is None:
-        bottom = max_pres
-    elif not min_pres <= bottom <= max_pres:
-        raise ValueError(f'The pressure and dewpoint profile ranges from {max_pres} to '
-                         f'{min_pres}, after removing missing values. {bottom} is outside '
-                         'this range.')
+        bottom = np.nanmax(pressure)
 
     pres_layer, dewpoint_layer = get_layer(pressure, dewpoint, bottom=bottom,
                                            depth=bottom - top)
diff --git a/src/metpy/calc/thermo.py b/src/metpy/calc/thermo.py
index 0a131235a2..f69c4d1efa 100644
--- a/src/metpy/calc/thermo.py
+++ b/src/metpy/calc/thermo.py
@@ -3290,56 +3290,3 @@ def gradient_richardson_number(height, potential_temperature, u, v, vertical_dim
     dvdz = first_derivative(v, x=height, axis=vertical_dim)
 
     return (mpconsts.g / potential_temperature) * (dthetadz / (dudz ** 2 + dvdz ** 2))
-
-
-@exporter.export
-@preprocess_and_wrap()
-@check_units('[pressure]', '[temperature]', '[temperature]')
-def showalter_index(pressure, temperature, dewpt):
-    """Calculate Showalter Index from pressure temperature and 850 hPa lcl.
-
-    Showalter Index derived from [Galway1956]_:
-    SI = T500 - Tp500
-
-    where:
-    T500 is the measured temperature at 500 hPa
-    Tp500 is the temperature of the lifted parcel at 500 hPa
-
-    Parameters
-    ----------
-        pressure : `pint.Quantity`
-            Atmospheric pressure level(s) of interest, in order from highest to
-            lowest pressure
-
-        temperature : `pint.Quantity`
-            Parcel temperature for corresponding pressure
-
-        dewpt : `pint.Quantity`
-            Parcel dew point temperatures for corresponding pressure
-
-    Returns
-    -------
-    `pint.Quantity`
-        Showalter index
-
-    """
-    # find the measured temperature and dew point temperature at 850 hPa.
-    t850, td850 = interpolate_1d(units.Quantity(850, 'hPa'), pressure, temperature, dewpt)
-
-    # find the parcel profile temperature at 500 hPa.
-    tp500 = interpolate_1d(units.Quantity(500, 'hPa'), pressure, temperature)
-
-    # Calculate lcl at the 850 hPa level
-    lcl_calc, _ = lcl(units.Quantity(850, 'hPa'), t850[0], td850[0])
-
-    # Define end height for moist lapse rate calculation
-    p_end = units.Quantity(500, 'hPa')
-
-    # Calculate parcel temp when raised dry adiabatically from surface to lcl
-    dl = dry_lapse(lcl_calc, temperature[0], pressure[0])
-
-    # Calculate parcel temp when raised moist adiabatically from lcl to 500mb
-    ml = moist_lapse(p_end, dl, lcl_calc)
-
-    # Calculate the Showalter index
-    return tp500 - ml
diff --git a/src/metpy/io/metar.py b/src/metpy/io/metar.py
index f9476162c4..83deaa53d7 100644
--- a/src/metpy/io/metar.py
+++ b/src/metpy/io/metar.py
@@ -13,9 +13,7 @@ import pandas as pd
 from ._tools import open_as_needed
 from .metar_parser import parse, ParseError
 from .station_data import station_info
-from ..calc import altimeter_to_sea_level_pressure, wind_components
 from ..package_tools import Exporter
-from ..plots.wx_symbols import wx_code_map
 from ..units import units
 
 exporter = Exporter(globals())
@@ -122,6 +120,8 @@ def parse_metar_to_dataframe(metar_text, *, year=None, month=None):
     * 'northward_wind': Northward component (v-compoment) of the wind vector, measured in knots
 
     """
+    from ..calc import altimeter_to_sea_level_pressure, wind_components
+
     # Defaults year and/or month to present reported date if not provided
     if year is None or month is None:
         now = datetime.now()
@@ -242,6 +242,8 @@ def parse_metar_to_named_tuple(metar_text, station_metadata, year, month):
       Attachment IV
 
     """
+    from ..plots.wx_symbols import wx_code_map
+
     # Decode the data using the parser (built using Canopy) the parser utilizes a grammar
     # file which follows the format structure dictated by the WMO Handbook, but has the
     # flexibility to decode the METAR text when there are missing or incorrectly
@@ -498,6 +500,8 @@ def parse_metar_file(filename, *, year=None, month=None):
     * 'northward_wind': Northward component (v-compoment) of the wind vector, measured in knots
 
     """
+    from ..calc import altimeter_to_sea_level_pressure, wind_components
+
     # Defaults year and/or month to present reported date if not provided
     if year is None or month is None:
         now = datetime.now()
diff --git a/src/metpy/plots/__init__.py b/src/metpy/plots/__init__.py
index 1af06cce64..d345bff98d 100644
--- a/src/metpy/plots/__init__.py
+++ b/src/metpy/plots/__init__.py
@@ -3,10 +3,9 @@
 # SPDX-License-Identifier: BSD-3-Clause
 r"""Contains functionality for making meteorological plots."""
 
-import logging
-
 # Trigger matplotlib wrappers
 from . import _mpl  # noqa: F401
+from . import cartopy_utils
 from ._util import (add_metpy_logo, add_timestamp, add_unidata_logo,  # noqa: F401
                     convert_gempak_color)
 from .ctables import *  # noqa: F403
@@ -16,8 +15,6 @@ from .station_plot import *  # noqa: F403
 from .wx_symbols import *  # noqa: F403
 from ..package_tools import set_module
 
-logger = logging.getLogger(__name__)
-
 __all__ = ctables.__all__[:]  # pylint: disable=undefined-variable
 __all__.extend(declarative.__all__)  # pylint: disable=undefined-variable
 __all__.extend(skewt.__all__)  # pylint: disable=undefined-variable
@@ -25,10 +22,20 @@ __all__.extend(station_plot.__all__)  # pylint: disable=undefined-variable
 __all__.extend(wx_symbols.__all__)  # pylint: disable=undefined-variable
 __all__.extend(['add_metpy_logo', 'add_timestamp', 'add_unidata_logo',
                 'convert_gempak_color'])
-try:
-    from .cartopy_utils import USCOUNTIES, USSTATES  # noqa: F401
-    __all__.extend(['USCOUNTIES', 'USSTATES'])
-except ImportError:
-    logger.warning('Cannot import USCOUNTIES and USSTATES without Cartopy installed.')
 
 set_module(globals())
+
+
+def __getattr__(name):
+    """Handle warning if Cartopy map features are not available."""
+    if name in cartopy_utils.__all__:
+        try:
+            return getattr(cartopy_utils, name)
+        except AttributeError:
+            raise AttributeError(f'Cannot use {name} without Cartopy installed.') from None
+
+    raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+
+
+def __dir__():
+    return __all__ + cartopy_utils.__all__
diff --git a/src/metpy/plots/cartopy_utils.py b/src/metpy/plots/cartopy_utils.py
index 8841f025b3..55101052c5 100644
--- a/src/metpy/plots/cartopy_utils.py
+++ b/src/metpy/plots/cartopy_utils.py
@@ -54,8 +54,11 @@ try:
 
     USSTATES = MetPyMapFeature('us_states', '20m', facecolor='None', edgecolor='black')
 except ImportError:
+    # If no Cartopy is present, we just don't have map features
     pass
 
+__all__ = ['USCOUNTIES', 'USSTATES']
+
 
 def import_cartopy():
     """Import CartoPy; return a stub if unable.


Test patch:
diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py
index 8953b943d3..02865525f0 100644
--- a/tests/calc/test_indices.py
+++ b/tests/calc/test_indices.py
@@ -6,7 +6,6 @@
 from datetime import datetime
 
 import numpy as np
-import pytest
 import xarray as xr
 
 from metpy.calc import (bulk_shear, bunkers_storm_motion, critical_angle,
@@ -62,27 +61,6 @@ def test_precipitable_water_nans():
     assert_almost_equal(pw, truth, 5)
 
 
-def test_precipitable_water_descriptive_bound_error():
-    """Test that error is raised when bound is outside profile after nan have been removed."""
-    pressure = np.array([1001, 1000, 997, 977.9, 977, 957, 937.8, 925, 906, 899.3, 887, 862.5,
-                         854, 850, 800, 793.9, 785, 777, 771, 762, 731.8, 726, 703, 700, 655,
-                         630, 621.2, 602, 570.7, 548, 546.8, 539, 513, 511, 485, 481, 468,
-                         448, 439, 424, 420, 412]) * units.hPa
-    dewpoint = np.array([np.nan, np.nan, -26.8, np.nan, -27.3, -28.2, np.nan, -27.2, -26.6,
-                        np.nan, -27.4, np.nan, -23.5, -23.5, -25.1, np.nan, -22.9, -17.8,
-                        -16.6, np.nan, np.nan, -16.4, np.nan, -18.5, -21., -23.7, np.nan,
-                        -28.3, np.nan, -32.6, np.nan, -33.8, -35., -35.1, -38.1, -40.,
-                        -43.3, -44.6, -46.4, np.nan, np.nan, np.nan]) * units.degC
-
-    # Top bound is above highest pressure in profile
-    with pytest.raises(ValueError, match='The pressure and dewpoint profile ranges from'):
-        precipitable_water(pressure, dewpoint, top=units.Quantity(415, 'hPa'))
-
-    # Bottom bound is below lowest pressure in profile
-    with pytest.raises(ValueError, match='The pressure and dewpoint profile ranges from'):
-        precipitable_water(pressure, dewpoint, bottom=units.Quantity(999, 'hPa'))
-
-
 def test_mean_pressure_weighted():
     """Test pressure-weighted mean wind function with vertical interpolation."""
     data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC')
diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py
index ca86110963..1b71c169be 100644
--- a/tests/calc/test_thermo.py
+++ b/tests/calc/test_thermo.py
@@ -26,7 +26,7 @@ from metpy.calc import (brunt_vaisala_frequency, brunt_vaisala_frequency_squared
                         relative_humidity_wet_psychrometric,
                         saturation_equivalent_potential_temperature,
                         saturation_mixing_ratio,
-                        saturation_vapor_pressure, showalter_index,
+                        saturation_vapor_pressure,
                         specific_humidity_from_dewpoint,
                         specific_humidity_from_mixing_ratio, static_stability,
                         surface_based_cape_cin, temperature_from_potential_temperature,
@@ -1902,16 +1902,3 @@ def test_gradient_richardson_number_with_xarray():
     assert isinstance(result, xr.DataArray)
     xr.testing.assert_identical(result['height'], data['Ri_g']['height'])
     assert_array_almost_equal(result.data.m_as(''), data['Ri_g'].data)
-
-
-def test_showalter_index():
-    """Test the Showalter index calculation."""
-    p_upper = np.arange(1000, 200, -50) * units.hPa
-    p_lower = np.arange(175, 0, -25) * units.hPa
-    p = np.append(p_upper, p_lower,)
-    tc = np.linspace(30, -30, 25) * units.degC
-    tdc = np.linspace(10, -30, 25) * units.degC
-
-    result = showalter_index(p, tc, tdc)
-    expected = 23.73036498600014 * units.delta_degree_Celsius
-    assert_array_almost_equal(result, expected, 4)
diff --git a/tests/plots/test_cartopy_utils.py b/tests/plots/test_cartopy_utils.py
index 5ec295ff4f..e2ed5122f8 100644
--- a/tests/plots/test_cartopy_utils.py
+++ b/tests/plots/test_cartopy_utils.py
@@ -2,15 +2,12 @@
 # Distributed under the terms of the BSD 3-Clause License.
 # SPDX-License-Identifier: BSD-3-Clause
 """Test the cartopy utilities."""
-import contextlib
-
 import matplotlib
 import matplotlib.pyplot as plt
 import pytest
 
-with contextlib.suppress(ImportError):
-    from metpy.plots import USCOUNTIES, USSTATES
-from metpy.plots.cartopy_utils import import_cartopy
+import metpy.plots as mpplots
+from metpy.plots import cartopy_utils
 # Fixture to make sure we have the right backend
 from metpy.testing import set_agg_backend  # noqa: F401, I202
 
@@ -26,7 +23,7 @@ def test_us_county_defaults(ccrs):
     fig = plt.figure(figsize=(12, 9))
     ax = fig.add_subplot(1, 1, 1, projection=proj)
     ax.set_extent([270.25, 270.9, 38.15, 38.75], ccrs.Geodetic())
-    ax.add_feature(USCOUNTIES)
+    ax.add_feature(mpplots.USCOUNTIES)
     return fig
 
 
@@ -43,7 +40,7 @@ def test_us_county_scales(ccrs):
 
     for scale, axis in zip(['20m', '5m', '500k'], [ax1, ax2, ax3]):
         axis.set_extent([270.25, 270.9, 38.15, 38.75], ccrs.Geodetic())
-        axis.add_feature(USCOUNTIES.with_scale(scale))
+        axis.add_feature(mpplots.USCOUNTIES.with_scale(scale))
     return fig
 
 
@@ -55,7 +52,7 @@ def test_us_states_defaults(ccrs):
     fig = plt.figure(figsize=(12, 9))
     ax = fig.add_subplot(1, 1, 1, projection=proj)
     ax.set_extent([270, 280, 28, 39], ccrs.Geodetic())
-    ax.add_feature(USSTATES)
+    ax.add_feature(mpplots.USSTATES)
     return fig
 
 
@@ -72,7 +69,7 @@ def test_us_states_scales(ccrs):
 
     for scale, axis in zip(['20m', '5m', '500k'], [ax1, ax2, ax3]):
         axis.set_extent([270, 280, 28, 39], ccrs.Geodetic())
-        axis.add_feature(USSTATES.with_scale(scale))
+        axis.add_feature(mpplots.USSTATES.with_scale(scale))
     return fig
 
 
@@ -82,6 +79,19 @@ def test_cartopy_stub(monkeypatch):
     # This makes sure that cartopy is not found
     monkeypatch.setitem(sys.modules, 'cartopy.crs', None)
 
-    ccrs = import_cartopy()
+    ccrs = cartopy_utils.import_cartopy()
     with pytest.raises(RuntimeError, match='CartoPy is required'):
         ccrs.PlateCarree()
+
+
+def test_plots_getattr(monkeypatch):
+    """Ensure the module-level getattr works."""
+    # Make sure the feature is missing
+    monkeypatch.delattr(cartopy_utils, 'USSTATES', raising=False)
+    with pytest.raises(AttributeError, match='Cannot use USSTATES without Cartopy'):
+        assert not mpplots.USSTATES  # Should fail on attribute lookup before assert
+
+
+def test_plots_dir():
+    """Ensure dir() on metpy.plots works."""
+    assert 'USSTATES' in dir(mpplots)