Repository: pvlib/pvlib-python

Issue:
Altitude lookup table
Currently, altitude for `pvlib.location` based algorithms defaults to zero, but if we include a low-resolution altitude lookup, we can provide better results when altitude is not specified.
We can make this altitude lookup the same format as [LinkeTurbidities.h5](https://github.com/pvlib/pvlib-python/blob/master/pvlib/data/LinkeTurbidities.h5), so it wouldn't require that much new code or any new dependencies.
I was able to build an altitude map using [open data aggregated by tilezen](https://github.com/tilezen/joerd/blob/master/docs/data-sources.md). My test H5 file is currently `13 mb` using `4320x2160` resolution, `uint16` altitude, and `gzip` compression. We are free to distribute this data, but we do need to do is add [this attribution](https://github.com/tilezen/joerd/blob/master/docs/attribution.md) somewhere in the documentation.
Would you guys be interested in this feature? Should I make a pull request?

Here is a plot of my sample
![altitude](https://user-images.githubusercontent.com/17040442/182914007-aedbdd53-5f74-4657-b0cb-60158b6aa26d.png)
:


Discussion:
Is there an api we can use instead?
Cool idea. I can't really comment on the cost (if any) of distributing another relatively large data file.

Some thoughts: the technical improvement to modeling would be minor: the only use of 'altitude' is to estimate air pressure for a clear-sky model or for adjusting geometric solar position to apparent position. But there's an intangible benefit from not having to look up a value, or having to explain why the default 0m is good enough. To me the intangible benefit is likely greater than the improvement in model results.

As alternatives, several of the weather data sources (PSM3, others), but not all, return altitude. 
Could file size be reduced by omitting the ocean bathymetry data?  I'm not really familiar with h5 files.  If accuracy is not so important, maybe cutting the values down to 8 bits would be acceptable. 

> Cool idea

Yes!  Makes me wonder if there are other such datasets we should consider. 
> Accuracy is not so important

Good point. Altitude rounded to 100m increments is probably good enough.
I'm unsure about packaging an elevation map with pvlib. But a higher resolution data set could be useful for shading. I thought there were some PR discussions along those lines.
I think getting altitude is probably something we can outsource to an API like the Google Maps maybe, ESRI, or MapQuest? Seems a lot easier than adding 10 mb to the package maybe?
Found these:
* [Google map elevation API](https://developers.google.com/maps/documentation/elevation/start), [guide](https://developers.google.com/maps/documentation/elevation/overview)
* [open elevation api](https://open-elevation.com/)
* [Air Map API](https://developers.airmap.com/docs/elevation-api)
* [open topo data api](https://www.opentopodata.org/)
* [DEM.NET](https://elevationapi.com/faq) EG

    > This URL will give you (quickly!) the elevation near Notre-Dame de Paris (39 meters), using SRTM GL3 dataset : 
[`https://api.elevationapi.com/api/Elevation?lat=48.854624&lon=2.3436793&dataSet=SRTM_GL3`](https://api.elevationapi.com/api/Elevation?lat=48.854624&lon=2.3436793&dataSet=SRTM_GL3)

And more… I googled “elevation api lookup”
I tried the suggestions to reduce the size of the altitude lookup map.
If I set the oceans data to zero, it compresses much better and goes down to `3.9 mb`.
I also tried encoding the remaining land data as `uint8` in `35m` increments.
This `uint8` version is `1.2 mb` after gzip compression.

I agree that an API would be good for high-resolution use cases like shading. 
But I think having a fallback built-in is still valid.
An external API has more friction; You need API keys, there can be costs or limits, and it adds more latency.
Also, where I got the original data can be helpful in high-resolution use-cases. It is hosted for free by aws in their [open data initiative](https://registry.opendata.aws/terrain-tiles/).
You can fetch terrain tiles using [slippy tilenames](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) from this URL template
`https://elevation-tiles-prod.s3.amazonaws.com/v2/terrarium/{zoom}/{x}/{y}.png`.
And [here](https://github.com/tilezen/joerd/blob/master/docs/formats.md) are the docs for how to convert RGB -> meters
I'm -1 to having `Location.get_solarposition()` make web requests, if that's being proposed.  I'm cautiously open to bundling a local dataset if it's only a ~1MB expense (our [current dist](https://pypi.org/project/pvlib/#files) is ~30 MB).

I'm also open to `iotools` functions for elevation data, although I'm not sure I see a lot of value in it if the relevant API calls are as simple as `requests.get("url?lon=X&lat=Y").json()['elevation']`.  I think I'd rather see a gallery example showing how to DIY in that case. 
This discussion suggests there are two, perhaps complementary, enhancements:
- provide a low-resolution, low-accuracy altitude to supplement the current default of 0. for clearsky and solar position calculations.
- via iotools, provide access to an API for high-resolution elevation data for detailed modeling of shading, tracker steering, and (in future pvlib) spatially varying module orientation.

Access to an API for detailed elevation data will not relieve those who want a simple alternative to the current default. Similarly, a low-resolution file included with pvlib will not satisfy those looking for high accuracy elevation.

I'm not opposed to having all three (current default, low-resolution internal lookup, high resolution via API) in pvlib. 
I think it'd be great to have the option of something like this: 

    pvlib.location.Location(latitude=55, longitude=10, altitude='SRTM')

and then have the location object call an API (using a function from iotools) that retrieved the elevation.
It looks like the 1.2 mb version might be acceptable. I will make a pull request early next week and you can make the final decision then. I also will play with the encoding a bit, there might be ways to increase accuracy while staying ~1 mb.
I wonder if folks will start to cannibalize  pvlib for elevation data, and then send issues complaining that it's only accurate to 35m :rofl:
> I wonder if folks will start to cannibalize pvlib for elevation data, and then send issues complaining that it's only accurate to 35m 🤣

Assuredly, yes.

Patch:
diff --git a/pvlib/clearsky.py b/pvlib/clearsky.py
--- a/pvlib/clearsky.py
+++ b/pvlib/clearsky.py
@@ -14,6 +14,7 @@
 import h5py
 
 from pvlib import atmosphere, tools
+from pvlib.tools import _degrees_to_index
 
 
 def ineichen(apparent_zenith, airmass_absolute, linke_turbidity,
@@ -286,67 +287,6 @@ def _calendar_month_middles(year):
     return middles
 
 
-def _degrees_to_index(degrees, coordinate):
-    """Transform input degrees to an output index integer. The Linke
-    turbidity lookup tables have three dimensions, latitude, longitude, and
-    month. Specify a degree value and either 'latitude' or 'longitude' to get
-    the appropriate index number for the first two of these index numbers.
-
-    Parameters
-    ----------
-    degrees : float or int
-        Degrees of either latitude or longitude.
-    coordinate : string
-        Specify whether degrees arg is latitude or longitude. Must be set to
-        either 'latitude' or 'longitude' or an error will be raised.
-
-    Returns
-    -------
-    index : np.int16
-        The latitude or longitude index number to use when looking up values
-        in the Linke turbidity lookup table.
-    """
-    # Assign inputmin, inputmax, and outputmax based on degree type.
-    if coordinate == 'latitude':
-        inputmin = 90
-        inputmax = -90
-        outputmax = 2160
-    elif coordinate == 'longitude':
-        inputmin = -180
-        inputmax = 180
-        outputmax = 4320
-    else:
-        raise IndexError("coordinate must be 'latitude' or 'longitude'.")
-
-    inputrange = inputmax - inputmin
-    scale = outputmax/inputrange  # number of indices per degree
-    center = inputmin + 1 / scale / 2  # shift to center of index
-    outputmax -= 1  # shift index to zero indexing
-    index = (degrees - center) * scale
-    err = IndexError('Input, %g, is out of range (%g, %g).' %
-                     (degrees, inputmin, inputmax))
-
-    # If the index is still out of bounds after rounding, raise an error.
-    # 0.500001 is used in comparisons instead of 0.5 to allow for a small
-    # margin of error which can occur when dealing with floating point numbers.
-    if index > outputmax:
-        if index - outputmax <= 0.500001:
-            index = outputmax
-        else:
-            raise err
-    elif index < 0:
-        if -index <= 0.500001:
-            index = 0
-        else:
-            raise err
-    # If the index wasn't set to outputmax or 0, round it and cast it as an
-    # integer so it can be used in integer-based indexing.
-    else:
-        index = int(np.around(index))
-
-    return index
-
-
 def haurwitz(apparent_zenith):
     '''
     Determine clear sky GHI using the Haurwitz model.
diff --git a/pvlib/location.py b/pvlib/location.py
--- a/pvlib/location.py
+++ b/pvlib/location.py
@@ -4,13 +4,16 @@
 
 # Will Holmgren, University of Arizona, 2014-2016.
 
+import os
 import datetime
 import warnings
 
 import pandas as pd
 import pytz
+import h5py
 
 from pvlib import solarposition, clearsky, atmosphere, irradiance
+from pvlib.tools import _degrees_to_index
 
 class Location:
     """
@@ -356,3 +359,88 @@ def get_sun_rise_set_transit(self, times, method='pyephem', **kwargs):
                              'one of pyephem, spa, geometric'
                              .format(method))
         return result
+
+
+def lookup_altitude(latitude, longitude):
+    """
+    Look up location altitude from low-resolution altitude map
+    supplied with pvlib. The data for this map comes from multiple open data
+    sources with varying resolutions aggregated by Mapzen.
+
+    More details can be found here
+    https://github.com/tilezen/joerd/blob/master/docs/data-sources.md
+
+    Altitudes from this map are a coarse approximation and can have
+    significant errors (100+ meters) introduced by downsampling and
+    source data resolution.
+
+    Parameters
+    ----------
+    latitude : float.
+        Positive is north of the equator.
+        Use decimal degrees notation.
+
+    longitude : float.
+        Positive is east of the prime meridian.
+        Use decimal degrees notation.
+
+    Returns
+    -------
+    altitude : float
+        The altitude of the location in meters.
+
+    Notes
+    -----------
+    Attributions:
+
+    * ArcticDEM terrain data DEM(s) were created from DigitalGlobe, Inc.,
+      imagery and funded under National Science Foundation awards 1043681,
+      1559691, and 1542736;
+    * Australia terrain data © Commonwealth of Australia
+      (Geoscience Australia) 2017;
+    * Austria terrain data © offene Daten Österreichs - Digitales
+      Geländemodell (DGM) Österreich;
+    * Canada terrain data contains information licensed under the Open
+      Government Licence - Canada;
+    * Europe terrain data produced using Copernicus data and information
+      funded by the European Union - EU-DEM layers;
+    * Global ETOPO1 terrain data U.S. National Oceanic and Atmospheric
+      Administration
+    * Mexico terrain data source: INEGI, Continental relief, 2016;
+    * New Zealand terrain data Copyright 2011 Crown copyright (c) Land
+      Information New Zealand and the New Zealand Government
+      (All rights reserved);
+    * Norway terrain data © Kartverket;
+    * United Kingdom terrain data © Environment Agency copyright and/or
+      database right 2015. All rights reserved;
+    * United States 3DEP (formerly NED) and global GMTED2010 and SRTM
+      terrain data courtesy of the U.S. Geological Survey.
+
+    References
+    ----------
+    .. [1] `Mapzen, Linux foundation project for open data maps
+        <https://www.mapzen.com/>`_
+    .. [2] `Joerd, tool for downloading and processing DEMs, Used by Mapzen
+        <https://github.com/tilezen/joerd/>`_
+    .. [3] `AWS, Open Data Registry Terrain Tiles
+        <https://registry.opendata.aws/terrain-tiles/>`_
+
+    """
+
+    pvlib_path = os.path.dirname(os.path.abspath(__file__))
+    filepath = os.path.join(pvlib_path, 'data', 'Altitude.h5')
+
+    latitude_index = _degrees_to_index(latitude, coordinate='latitude')
+    longitude_index = _degrees_to_index(longitude, coordinate='longitude')
+
+    with h5py.File(filepath, 'r') as alt_h5_file:
+        alt = alt_h5_file['Altitude'][latitude_index, longitude_index]
+
+    # 255 is a special value that means nodata. Fallback to 0 if nodata.
+    if alt == 255:
+        return 0
+    # Altitude is encoded in 28 meter steps from -450 meters to 6561 meters
+    # There are 0-254 possible altitudes, with 255 reserved for nodata.
+    alt *= 28
+    alt -= 450
+    return float(alt)
diff --git a/pvlib/tools.py b/pvlib/tools.py
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -412,3 +412,61 @@ def _get_sample_intervals(times, win_length):
             'periods, leap days, etc.'
         )
         raise NotImplementedError(message)
+
+
+def _degrees_to_index(degrees, coordinate):
+    """Transform input degrees to an output index integer.
+    Specify a degree value and either 'latitude' or 'longitude' to get
+    the appropriate index number for these two index numbers.
+    Parameters
+    ----------
+    degrees : float or int
+        Degrees of either latitude or longitude.
+    coordinate : string
+        Specify whether degrees arg is latitude or longitude. Must be set to
+        either 'latitude' or 'longitude' or an error will be raised.
+    Returns
+    -------
+    index : np.int16
+        The latitude or longitude index number to use when looking up values
+        in the Linke turbidity lookup table.
+    """
+    # Assign inputmin, inputmax, and outputmax based on degree type.
+    if coordinate == 'latitude':
+        inputmin = 90
+        inputmax = -90
+        outputmax = 2160
+    elif coordinate == 'longitude':
+        inputmin = -180
+        inputmax = 180
+        outputmax = 4320
+    else:
+        raise IndexError("coordinate must be 'latitude' or 'longitude'.")
+
+    inputrange = inputmax - inputmin
+    scale = outputmax/inputrange  # number of indices per degree
+    center = inputmin + 1 / scale / 2  # shift to center of index
+    outputmax -= 1  # shift index to zero indexing
+    index = (degrees - center) * scale
+    err = IndexError('Input, %g, is out of range (%g, %g).' %
+                     (degrees, inputmin, inputmax))
+
+    # If the index is still out of bounds after rounding, raise an error.
+    # 0.500001 is used in comparisons instead of 0.5 to allow for a small
+    # margin of error which can occur when dealing with floating point numbers.
+    if index > outputmax:
+        if index - outputmax <= 0.500001:
+            index = outputmax
+        else:
+            raise err
+    elif index < 0:
+        if -index <= 0.500001:
+            index = 0
+        else:
+            raise err
+    # If the index wasn't set to outputmax or 0, round it and cast it as an
+    # integer so it can be used in integer-based indexing.
+    else:
+        index = int(np.around(index))
+
+    return index


Test patch:
diff --git a/pvlib/tests/test_clearsky.py b/pvlib/tests/test_clearsky.py
--- a/pvlib/tests/test_clearsky.py
+++ b/pvlib/tests/test_clearsky.py
@@ -511,13 +511,6 @@ def monthly_lt_nointerp(lat, lon, time=months):
         monthly_lt_nointerp(38.2, -181)  # exceeds min longitude
 
 
-def test_degrees_to_index_1():
-    """Test that _degrees_to_index raises an error when something other than
-    'latitude' or 'longitude' is passed."""
-    with pytest.raises(IndexError):  # invalid value for coordinate argument
-        clearsky._degrees_to_index(degrees=22.0, coordinate='width')
-
-
 @pytest.fixture
 def detect_clearsky_data():
     data_file = DATA_DIR / 'detect_clearsky_data.csv'
diff --git a/pvlib/tests/test_location.py b/pvlib/tests/test_location.py
--- a/pvlib/tests/test_location.py
+++ b/pvlib/tests/test_location.py
@@ -12,7 +12,7 @@
 from pytz.exceptions import UnknownTimeZoneError
 
 import pvlib
-from pvlib.location import Location
+from pvlib.location import Location, lookup_altitude
 from pvlib.solarposition import declination_spencer71
 from pvlib.solarposition import equation_of_time_spencer71
 from .conftest import requires_ephem
@@ -326,3 +326,23 @@ def test_get_sun_rise_set_transit_valueerror(golden):
 def test_extra_kwargs():
     with pytest.raises(TypeError, match='arbitrary_kwarg'):
         Location(32.2, -111, arbitrary_kwarg='value')
+
+
+def test_lookup_altitude():
+    max_alt_error = 125
+    # location name, latitude, longitude, altitude
+    test_locations = [
+        ('Tucson, USA', 32.2540, -110.9742, 724),
+        ('Lusaka, Zambia', -15.3875, 28.3228, 1253),
+        ('Tokio, Japan', 35.6762, 139.6503, 40),
+        ('Canberra, Australia', -35.2802, 149.1310, 566),
+        ('Bogota, Colombia', 4.7110, -74.0721, 2555),
+        ('Dead Sea, West Bank', 31.525849, 35.449214, -415),
+        ('New Delhi, India', 28.6139, 77.2090, 214),
+        ('Null Island,  Atlantic Ocean', 0, 0, 0),
+    ]
+
+    for name, lat, lon, expected_alt in test_locations:
+        alt_found = lookup_altitude(lat, lon)
+        assert abs(alt_found - expected_alt) < max_alt_error, \
+            f'Max error exceded for {name} - e: {expected_alt} f: {alt_found}'
diff --git a/pvlib/tests/test_tools.py b/pvlib/tests/test_tools.py
--- a/pvlib/tests/test_tools.py
+++ b/pvlib/tests/test_tools.py
@@ -72,3 +72,10 @@ def test__golden_sect_DataFrame_nans():
     v, x = tools._golden_sect_DataFrame(params, lower, upper,
                                         _obj_test_golden_sect)
     assert np.allclose(x, expected, atol=1e-8, equal_nan=True)
+
+
+def test_degrees_to_index_1():
+    """Test that _degrees_to_index raises an error when something other than
+    'latitude' or 'longitude' is passed."""
+    with pytest.raises(IndexError):  # invalid value for coordinate argument
+        tools._degrees_to_index(degrees=22.0, coordinate='width')