diff --git a/docs/source/documentation.rst b/docs/source/documentation.rst index 07d66af..f4e13a2 100644 --- a/docs/source/documentation.rst +++ b/docs/source/documentation.rst @@ -12,3 +12,4 @@ Code documentation plotting.plot_google_maps plotting.plot_intraday_heatmap plotting.plot_shading_heatmap + horizon.get_horizon_mines diff --git a/src/solarpy/__init__.py b/src/solarpy/__init__.py index 6a44cbd..cc51f6d 100644 --- a/src/solarpy/__init__.py +++ b/src/solarpy/__init__.py @@ -9,5 +9,6 @@ from solarpy import ( # noqa: F401 plotting, quality, + horizon, example, ) diff --git a/src/solarpy/horizon/__init__.py b/src/solarpy/horizon/__init__.py new file mode 100644 index 0000000..0fe81ea --- /dev/null +++ b/src/solarpy/horizon/__init__.py @@ -0,0 +1 @@ +from solarpy.horizon.horizon_mines import get_horizon_mines # noqa: F401 diff --git a/src/solarpy/horizon/horizon_mines.py b/src/solarpy/horizon/horizon_mines.py new file mode 100644 index 0000000..ceacc16 --- /dev/null +++ b/src/solarpy/horizon/horizon_mines.py @@ -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: + # 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