Skip to content

Commit 111944b

Browse files
authored
Add get_horizon_mines function (#22)
* Add get_horizon_mines function * linting
1 parent 5346241 commit 111944b

6 files changed

Lines changed: 203 additions & 0 deletions

File tree

docs/source/documentation.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ Code documentation
1212
plotting.plot_google_maps
1313
plotting.plot_intraday_heatmap
1414
plotting.plot_shading_heatmap
15+
horizon.get_horizon_mines

src/solarpy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
from solarpy import ( # noqa: F401
1010
plotting,
1111
quality,
12+
horizon,
1213
example,
1314
)

src/solarpy/horizon/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from solarpy.horizon.horizon_mines import get_horizon_mines # noqa: F401
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
import io
4+
5+
import pandas as pd
6+
import requests
7+
8+
9+
def get_horizon_mines(
10+
latitude: float,
11+
longitude: float,
12+
altitude: float | None = None,
13+
ground_offset: float = 0,
14+
url: str = 'http://toolbox.1.webservice-energy.org/service/wps',
15+
**kwargs,
16+
) -> tuple[pd.Series, dict]:
17+
"""
18+
Retrieve a horizon elevation profile from the MINES ParisTech SRTM web service.
19+
20+
Parameters
21+
----------
22+
latitude : float
23+
in decimal degrees, between -90 and 90, north is positive (ISO 19115)
24+
longitude : float
25+
in decimal degrees, between -180 and 180, east is positive (ISO 19115)
26+
altitude : float, optional
27+
Altitude in meters. If None, then the altitude is determined from the
28+
NASA SRTM database.
29+
ground_offset : float, optional
30+
Vertical offset in meters for the point of view for which to calculate
31+
horizon profile. Default is ``0``.
32+
url : str, default: 'http://toolbox.1.webservice-energy.org/service/wps'
33+
Base URL for MINES ParisTech horizon profile API
34+
kwargs:
35+
Additional keyword arguments passed to ``requests.get``.
36+
37+
Returns
38+
-------
39+
horizon : pd.Series
40+
Pandas Series of the retrived horizon elevation angles. Index is the
41+
corresponding horizon azimuth angles.
42+
metadata : dict
43+
Dictionary with keys ``'data_provider'``, ``'database'``,
44+
``'latitude'``, ``'longitude'``, ``'altitude'``, ``'ground_offset'``.
45+
46+
Notes
47+
-----
48+
The azimuthal resolution is one degree. Also, the returned horizon
49+
elevations can also be negative.
50+
51+
Examples
52+
--------
53+
Retrieve the horizon profile for Paris, France:
54+
55+
>>> import solarpy
56+
>>> horizon, meta = solarpy.horizon.get_horizon_mines(
57+
... latitude=48.8566, longitude=2.3522, timeout=10)
58+
"""
59+
if altitude is None: # API will then infer altitude
60+
altitude = -999
61+
62+
# Manual formatting of the input parameters separating each by a semicolon
63+
data_inputs = f"latitude={latitude};longitude={longitude};altitude={altitude};ground_offset={ground_offset}" # noqa: E501
64+
65+
params = {
66+
'service': 'WPS',
67+
'request': 'Execute',
68+
'identifier': 'compute_horizon_srtm',
69+
'version': '1.0.0',
70+
}
71+
72+
# The DataInputs parameter of the URL has to be manually formatted and
73+
# added to the base URL as it contains sub-parameters seperated by
74+
# semi-colons, which gets incorrectly formatted by the requests function
75+
# if passed using the params argument.
76+
res = requests.get(url + '?DataInputs=' + data_inputs, params=params,
77+
**kwargs)
78+
res.raise_for_status()
79+
80+
# The response text is first converted to a StringIO object as otherwise
81+
# pd.read_csv raises a ValueError stating "Protocol not known:
82+
# <!-- PyWPS 4.0.0 --> <wps:ExecuteResponse xmlns:gml="http"
83+
# Alternatively it is possible to pass the url straight to pd.read_csv
84+
horizon = pd.read_csv(io.StringIO(res.text), skiprows=27, nrows=360,
85+
delimiter=';', index_col=0,
86+
names=['horizon_azimuth', 'horizon_elevation'])
87+
horizon = horizon['horizon_elevation'] # convert to series
88+
# Note, there is no way to detect if the request is correct. In all cases,
89+
# the API always returns a status code of OK/200 and no useful error
90+
# message.
91+
92+
meta = {'data_provider': 'MINES ParisTech - Armines (France)',
93+
'database': 'Shuttle Radar Topography Mission (SRTM)',
94+
'latitude': latitude, 'longitude': longitude, 'altitude': altitude,
95+
'ground_offset': ground_offset}
96+
97+
return horizon, meta

tests/horizon/__init__.py

Whitespace-only changes.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for get_horizon_mines."""
2+
from __future__ import annotations
3+
4+
from unittest.mock import MagicMock, patch
5+
6+
import pandas as pd
7+
import pytest
8+
import requests
9+
10+
import solarpy
11+
12+
13+
def _make_response(elevations: list[float]) -> MagicMock:
14+
"""Build a mock requests.Response with a CSV body matching the API format."""
15+
# 27 header rows (content doesn't matter, just need the right count)
16+
header = "\n".join(f"# header line {i}" for i in range(27))
17+
rows = "\n".join(f"{az};{el}" for az, el in enumerate(elevations))
18+
mock_res = MagicMock()
19+
mock_res.text = header + "\n" + rows
20+
mock_res.raise_for_status = MagicMock()
21+
return mock_res
22+
23+
24+
_N = 360
25+
_ELEVATIONS = [float(i % 10) for i in range(_N)] # deterministic test data
26+
27+
28+
# Tests
29+
30+
31+
@pytest.fixture
32+
def mock_get():
33+
with patch("solarpy.horizon.horizon_mines.requests.get") as mock:
34+
mock.return_value = _make_response(_ELEVATIONS)
35+
yield mock
36+
37+
38+
def test_returns_series_and_dict(mock_get):
39+
horizon, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
40+
assert isinstance(horizon, pd.Series)
41+
assert isinstance(meta, dict)
42+
43+
44+
def test_horizon_length(mock_get):
45+
horizon, _ = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
46+
assert len(horizon) == _N
47+
48+
49+
def test_horizon_values(mock_get):
50+
horizon, _ = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
51+
assert list(horizon) == _ELEVATIONS
52+
53+
54+
def test_meta_keys(mock_get):
55+
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
56+
assert set(meta.keys()) == {
57+
"data_provider", "database",
58+
"latitude", "longitude", "altitude", "ground_offset",
59+
}
60+
61+
62+
def test_meta_coordinates(mock_get):
63+
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
64+
assert meta["latitude"] == 48.8566
65+
assert meta["longitude"] == 2.3522
66+
67+
68+
def test_altitude_none_uses_sentinel(mock_get):
69+
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522, altitude=None)
70+
call_url = mock_get.call_args[0][0]
71+
assert "altitude=-999" in call_url
72+
73+
74+
def test_altitude_explicit(mock_get):
75+
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522, altitude=100)
76+
call_url = mock_get.call_args[0][0]
77+
assert "altitude=100" in call_url
78+
assert meta["altitude"] == 100
79+
80+
81+
def test_ground_offset_in_url(mock_get):
82+
solarpy.horizon.get_horizon_mines(48.8566, 2.3522, ground_offset=2.5)
83+
call_url = mock_get.call_args[0][0]
84+
assert "ground_offset=2.5" in call_url
85+
86+
87+
def test_raise_for_status_called(mock_get):
88+
solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
89+
mock_get.return_value.raise_for_status.assert_called_once()
90+
91+
92+
def test_http_error_propagates():
93+
mock_res = MagicMock()
94+
mock_res.raise_for_status.side_effect = requests.HTTPError("500 Server Error")
95+
with patch("solarpy.horizon.horizon_mines.requests.get", return_value=mock_res):
96+
with pytest.raises(requests.HTTPError):
97+
solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
98+
99+
100+
def test_kwargs_forwarded(mock_get):
101+
solarpy.horizon.get_horizon_mines(48.8566, 2.3522, timeout=10)
102+
_, kwargs = mock_get.call_args
103+
assert kwargs.get("timeout") == 10

0 commit comments

Comments
 (0)