Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Code documentation
plotting.plot_google_maps
plotting.plot_intraday_heatmap
plotting.plot_shading_heatmap
horizon.get_horizon_mines
1 change: 1 addition & 0 deletions src/solarpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
from solarpy import ( # noqa: F401
plotting,
quality,
horizon,
example,
)
1 change: 1 addition & 0 deletions src/solarpy/horizon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from solarpy.horizon.horizon_mines import get_horizon_mines # noqa: F401
97 changes: 97 additions & 0 deletions src/solarpy/horizon/horizon_mines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import io

import pandas as pd
import requests


def get_horizon_mines(
latitude: float,
longitude: float,
altitude: float | None = None,
ground_offset: float = 0,
url: str = 'http://toolbox.1.webservice-energy.org/service/wps',
**kwargs,
) -> tuple[pd.Series, dict]:
"""
Retrieve a horizon elevation profile from the MINES ParisTech SRTM web service.

Parameters
----------
latitude : float
in decimal degrees, between -90 and 90, north is positive (ISO 19115)
longitude : float
in decimal degrees, between -180 and 180, east is positive (ISO 19115)
altitude : float, optional
Altitude in meters. If None, then the altitude is determined from the
NASA SRTM database.
ground_offset : float, optional
Vertical offset in meters for the point of view for which to calculate
horizon profile. Default is ``0``.
url : str, default: 'http://toolbox.1.webservice-energy.org/service/wps'
Base URL for MINES ParisTech horizon profile API
kwargs:
Additional keyword arguments passed to ``requests.get``.

Returns
-------
horizon : pd.Series
Pandas Series of the retrived horizon elevation angles. Index is the
corresponding horizon azimuth angles.
metadata : dict
Dictionary with keys ``'data_provider'``, ``'database'``,
``'latitude'``, ``'longitude'``, ``'altitude'``, ``'ground_offset'``.

Notes
-----
The azimuthal resolution is one degree. Also, the returned horizon
elevations can also be negative.

Examples
--------
Retrieve the horizon profile for Paris, France:

>>> import solarpy
>>> horizon, meta = solarpy.horizon.get_horizon_mines(
... latitude=48.8566, longitude=2.3522, timeout=10)
"""
if altitude is None: # API will then infer altitude
altitude = -999

# Manual formatting of the input parameters separating each by a semicolon
data_inputs = f"latitude={latitude};longitude={longitude};altitude={altitude};ground_offset={ground_offset}" # noqa: E501

params = {
'service': 'WPS',
'request': 'Execute',
'identifier': 'compute_horizon_srtm',
'version': '1.0.0',
}

# The DataInputs parameter of the URL has to be manually formatted and
# added to the base URL as it contains sub-parameters seperated by
# semi-colons, which gets incorrectly formatted by the requests function
# if passed using the params argument.
res = requests.get(url + '?DataInputs=' + data_inputs, params=params,
**kwargs)
res.raise_for_status()

# The response text is first converted to a StringIO object as otherwise
# pd.read_csv raises a ValueError stating "Protocol not known:
# <!-- PyWPS 4.0.0 --> <wps:ExecuteResponse xmlns:gml="http"
# Alternatively it is possible to pass the url straight to pd.read_csv
horizon = pd.read_csv(io.StringIO(res.text), skiprows=27, nrows=360,
delimiter=';', index_col=0,
names=['horizon_azimuth', 'horizon_elevation'])
horizon = horizon['horizon_elevation'] # convert to series
# Note, there is no way to detect if the request is correct. In all cases,
# the API always returns a status code of OK/200 and no useful error
# message.

meta = {'data_provider': 'MINES ParisTech - Armines (France)',
'database': 'Shuttle Radar Topography Mission (SRTM)',
'latitude': latitude, 'longitude': longitude, 'altitude': altitude,
'ground_offset': ground_offset}

return horizon, meta
Empty file added tests/horizon/__init__.py
Empty file.
103 changes: 103 additions & 0 deletions tests/horizon/test_horizon_mines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for get_horizon_mines."""
from __future__ import annotations

from unittest.mock import MagicMock, patch

import pandas as pd
import pytest
import requests

import solarpy


def _make_response(elevations: list[float]) -> MagicMock:
"""Build a mock requests.Response with a CSV body matching the API format."""
# 27 header rows (content doesn't matter, just need the right count)
header = "\n".join(f"# header line {i}" for i in range(27))
rows = "\n".join(f"{az};{el}" for az, el in enumerate(elevations))
mock_res = MagicMock()
mock_res.text = header + "\n" + rows
mock_res.raise_for_status = MagicMock()
return mock_res


_N = 360
_ELEVATIONS = [float(i % 10) for i in range(_N)] # deterministic test data


# Tests


@pytest.fixture
def mock_get():
with patch("solarpy.horizon.horizon_mines.requests.get") as mock:
mock.return_value = _make_response(_ELEVATIONS)
yield mock


def test_returns_series_and_dict(mock_get):
horizon, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
assert isinstance(horizon, pd.Series)
assert isinstance(meta, dict)


def test_horizon_length(mock_get):
horizon, _ = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
assert len(horizon) == _N


def test_horizon_values(mock_get):
horizon, _ = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
assert list(horizon) == _ELEVATIONS


def test_meta_keys(mock_get):
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
assert set(meta.keys()) == {
"data_provider", "database",
"latitude", "longitude", "altitude", "ground_offset",
}


def test_meta_coordinates(mock_get):
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
assert meta["latitude"] == 48.8566
assert meta["longitude"] == 2.3522


def test_altitude_none_uses_sentinel(mock_get):
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522, altitude=None)
call_url = mock_get.call_args[0][0]
assert "altitude=-999" in call_url


def test_altitude_explicit(mock_get):
_, meta = solarpy.horizon.get_horizon_mines(48.8566, 2.3522, altitude=100)
call_url = mock_get.call_args[0][0]
assert "altitude=100" in call_url
assert meta["altitude"] == 100


def test_ground_offset_in_url(mock_get):
solarpy.horizon.get_horizon_mines(48.8566, 2.3522, ground_offset=2.5)
call_url = mock_get.call_args[0][0]
assert "ground_offset=2.5" in call_url


def test_raise_for_status_called(mock_get):
solarpy.horizon.get_horizon_mines(48.8566, 2.3522)
mock_get.return_value.raise_for_status.assert_called_once()


def test_http_error_propagates():
mock_res = MagicMock()
mock_res.raise_for_status.side_effect = requests.HTTPError("500 Server Error")
with patch("solarpy.horizon.horizon_mines.requests.get", return_value=mock_res):
with pytest.raises(requests.HTTPError):
solarpy.horizon.get_horizon_mines(48.8566, 2.3522)


def test_kwargs_forwarded(mock_get):
solarpy.horizon.get_horizon_mines(48.8566, 2.3522, timeout=10)
_, kwargs = mock_get.call_args
assert kwargs.get("timeout") == 10
Loading