-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add iotools functions for Meteonorm #2499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AdamRJensen
wants to merge
24
commits into
pvlib:main
Choose a base branch
from
AdamRJensen:get_meteonorm
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
7ca85bb
Create meteonorm.py
AdamRJensen 95c2602
Add get_meteonorm_tmy
AdamRJensen 611294e
Add private shared parse function
AdamRJensen 5c3c9bf
Apply suggestions from code review
AdamRJensen 5d64654
Merge branch 'main' into get_meteonorm
AdamRJensen 4a0e1b3
Apply suggestions from code review
AdamRJensen 10618a2
Improve docstring
AdamRJensen a42e68f
Improve functions
AdamRJensen acbdafa
Add first round of tests
AdamRJensen ef4f838
Update tests
AdamRJensen adda4cf
Full test coverage
AdamRJensen ea48630
Fix linter
AdamRJensen ba00423
Fix tests
AdamRJensen 83d00cb
Increase test coverage
AdamRJensen 239cff1
Implement feedback from Meteonorm review
AdamRJensen 8e3b1ec
Implement feedback from code review from kandersolar
AdamRJensen b15a170
basic endpoint only support '1h', rename terrain_situation
AdamRJensen 9129ad2
Split get_meteonorm into forecast and observation
AdamRJensen 3e9329f
Fix linter
AdamRJensen df27bbc
Split observation/forecast into four functions
AdamRJensen 172ea0a
Fix linter
AdamRJensen 20ec64a
Implement changes from review from kandersolar
AdamRJensen 6e8a3f9
Set index to be the middle of the period
AdamRJensen 0279a92
Extend test coverage
AdamRJensen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,349 @@ | ||
"""Functions for retrieving data from Meteonorm.""" | ||
|
||
import pandas as pd | ||
import requests | ||
from urllib.parse import urljoin | ||
|
||
URL = 'https://api.meteonorm.com/v1/' | ||
|
||
VARIABLE_MAP = { | ||
'global_horizontal_irradiance': 'ghi', | ||
'diffuse_horizontal_irradiance': 'dhi', | ||
'direct_normal_irradiance': 'dni', | ||
'direct_horizontal_irradiance': 'bhi', | ||
'global_clear_sky_irradiance': 'ghi_clear', | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
'diffuse_tilted_irradiance': 'poa_diffuse', | ||
'direct_tilted_irradiance': 'poa_direct', | ||
'global_tilted_irradiance': 'poa', | ||
'temperature': 'temp_air', | ||
'dew_point_temperature': 'temp_dew', | ||
} | ||
|
||
TIME_STEP_MAP = { | ||
'1h': '1_hour', | ||
'h': '1_hour', | ||
'15min': '15_minutes', | ||
'1min': '1_minute', | ||
'min': '1_minute', | ||
} | ||
|
||
|
||
def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, | ||
parameters='all', *, surface_tilt=0, surface_azimuth=180, | ||
time_step='15min', horizon='auto', interval_index=False, | ||
map_variables=True, url=URL): | ||
""" | ||
Retrieve irradiance and weather data from Meteonorm. | ||
|
||
The Meteonorm data options are described in [1]_ and the API is described | ||
in [2]_. A detailed list of API options can be found in [3]_. | ||
|
||
This function supports retrieval of historical and forecast data, but not | ||
TMY. | ||
|
||
Parameters | ||
---------- | ||
latitude : float | ||
In decimal degrees, north is positive (ISO 19115). | ||
longitude: float | ||
In decimal degrees, east is positive (ISO 19115). | ||
start : datetime like | ||
First timestamp of the requested period. If a timezone is not | ||
specified, UTC is assumed. Relative datetime strings are supported. | ||
end : datetime like | ||
Last timestamp of the requested period. If a timezone is not | ||
specified, UTC is assumed. Relative datetime strings are supported. | ||
api_key : str | ||
Meteonorm API key. | ||
endpoint : str | ||
API endpoint, see [3]_. Must be one of: | ||
|
||
* ``'observation/training'`` - historical data with a 7-day delay | ||
* ``'observation/realtime'`` - near-real time (past 7-days) | ||
* ``'forecast/basic'`` - forecast with hourly resolution | ||
* ``'forecast/precision'`` - forecast with 15-min resolution | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
parameters : list or 'all', default : 'all' | ||
List of parameters to request or `'all'` to get all parameters. | ||
surface_tilt : float, default : 0 | ||
Tilt angle from horizontal plane. | ||
surface_azimuth : float, default : 180 | ||
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north | ||
(north=0, east=90, south=180, west=270). | ||
time_step : {'1min', '15min', '1h'}, default : '15min' | ||
Frequency of the time series. The parameter is ignored when requesting | ||
forcasting data. | ||
horizon : str or list, default : 'auto' | ||
Specification of the horizon line. Can be either a 'flat', 'auto', or | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
a list of 360 integer horizon elevation angles. | ||
interval_index : bool, default : False | ||
Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. | ||
This is an experimental feature which may be removed without warning. | ||
map_variables : bool, default : True | ||
When true, renames columns of the Dataframe to pvlib variable names | ||
where applicable. See variable :const:`VARIABLE_MAP`. | ||
url : str, optional | ||
Base URL of the Meteonorm API. The ``endpoint`` parameter is | ||
appended to the url. The default is | ||
:const:`pvlib.iotools.meteonorm.URL`. | ||
|
||
Raises | ||
------ | ||
requests.HTTPError | ||
Raises an error when an incorrect request is made. | ||
|
||
Returns | ||
------- | ||
data : pd.DataFrame | ||
Time series data. The index corresponds to the start (left) of the | ||
interval unless ``interval_index`` is set to False. | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
meta : dict | ||
Metadata. | ||
|
||
Examples | ||
-------- | ||
>>> # Retrieve historical time series data | ||
>>> df, meta = get_meteonorm( # doctest: +SKIP | ||
... latitude=50, longitude=10, # doctest: +SKIP | ||
... start='2023-01-01', end='2025-01-01', # doctest: +SKIP | ||
... api_key='redacted', # doctest: +SKIP | ||
... endpoint='observation/training') # doctest: +SKIP | ||
|
||
See Also | ||
-------- | ||
pvlib.iotools.get_meteonorm_tmy | ||
|
||
References | ||
---------- | ||
.. [1] `Meteonorm | ||
<https://meteonorm.com/>`_ | ||
.. [2] `Meteonorm API | ||
<https://docs.meteonorm.com/docs/getting-started>`_ | ||
.. [3] `Meteonorm API reference | ||
<https://docs.meteonorm.com/api>`_ | ||
""" | ||
start = pd.Timestamp(start) | ||
end = pd.Timestamp(end) | ||
start = start.tz_localize('UTC') if start.tzinfo is None else start | ||
end = end.tz_localize('UTC') if end.tzinfo is None else end | ||
|
||
params = { | ||
'lat': latitude, | ||
'lon': longitude, | ||
'start': start.strftime('%Y-%m-%dT%H:%M:%SZ'), | ||
'end': end.strftime('%Y-%m-%dT%H:%M:%SZ'), | ||
'parameters': parameters, | ||
'surface_tilt': surface_tilt, | ||
'surface_azimuth': surface_azimuth, | ||
'horizon': horizon, | ||
} | ||
|
||
# Allow specifying single parameters as string | ||
if isinstance(parameters, str): | ||
parameter_list = \ | ||
list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) | ||
if parameters in parameter_list: | ||
parameters = [parameters] | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# convert list to string with values separated by commas | ||
if not isinstance(parameters, (str, type(None))): | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# allow the use of pvlib parameter names | ||
parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} | ||
parameters = [parameter_dict.get(p, p) for p in parameters] | ||
params['parameters'] = ','.join(parameters) | ||
|
||
if not isinstance(horizon, str): | ||
params['horizon'] = ','.join(map(str, horizon)) | ||
|
||
if 'forecast' not in endpoint.lower(): | ||
params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
headers = {"Authorization": f"Bearer {api_key}"} | ||
|
||
response = requests.get( | ||
urljoin(url, endpoint.lstrip('/')), headers=headers, params=params) | ||
|
||
if not response.ok: | ||
# response.raise_for_status() does not give a useful error message | ||
raise requests.HTTPError(response.json()) | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
data, meta = _parse_meteonorm(response, interval_index, map_variables) | ||
|
||
return data, meta | ||
|
||
|
||
TMY_ENDPOINT = 'climate/tmy' | ||
|
||
|
||
def get_meteonorm_tmy(latitude, longitude, api_key, | ||
parameters='all', *, surface_tilt=0, | ||
surface_azimuth=180, time_step='15min', horizon='auto', | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
terrain='open', albedo=0.2, turbidity='auto', | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
random_seed=None, clear_sky_radiation_model='esra', | ||
data_version='latest', future_scenario=None, | ||
future_year=None, interval_index=False, | ||
map_variables=True, url=URL): | ||
""" | ||
Retrieve TMY irradiance and weather data from Meteonorm. | ||
|
||
The Meteonorm data options are described in [1]_ and the API is described | ||
in [2]_. A detailed list of API options can be found in [3]_. | ||
|
||
Parameters | ||
---------- | ||
latitude : float | ||
In decimal degrees, north is positive (ISO 19115). | ||
longitude : float | ||
In decimal degrees, east is positive (ISO 19115). | ||
api_key : str | ||
Meteonorm API key. | ||
parameters : list or 'all', default : 'all' | ||
List of parameters to request or `'all'` to get all parameters. | ||
surface_tilt : float, default : 0 | ||
Tilt angle from horizontal plane. | ||
surface_azimuth : float, default : 180 | ||
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north | ||
(north=0, east=90, south=180, west=270). | ||
time_step : {'1min', '1h'}, default : '1h' | ||
Frequency of the time series. | ||
horizon : str, optional | ||
Specification of the horizon line. Can be either 'flat' or 'auto', or | ||
specified as a list of 360 integer horizon elevation angles. | ||
'auto'. | ||
terrain : str, default : 'open' | ||
Local terrain situation. Must be one of: ['open', 'depression', | ||
'cold_air_lake', 'sea_lake', 'city', 'slope_south', | ||
'slope_west_east']. | ||
albedo : float, default : 0.2 | ||
Ground albedo. Albedo changes due to snow fall are modelled. | ||
turbidity : list or 'auto', optional | ||
List of 12 monthly mean atmospheric Linke turbidity values. The default | ||
is 'auto'. | ||
random_seed : int, optional | ||
Random seed to be used for stochastic processes. Two identical requests | ||
with the same random seed will yield identical results. | ||
clear_sky_radiation_model : str, default : 'esra' | ||
Which clearsky model to use. Must be either `'esra'` or `'solis'`. | ||
data_version : str, default : 'latest' | ||
Version of Meteonorm climatological data to be used. | ||
future_scenario : str, optional | ||
Future climate scenario. | ||
future_year : int, optional | ||
Central year for a 20-year reference period in the future. | ||
interval_index : bool, default : False | ||
Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. | ||
This is an experimental feature which may be removed without warning. | ||
map_variables : bool, default : True | ||
When true, renames columns of the Dataframe to pvlib variable names | ||
where applicable. See variable :const:`VARIABLE_MAP`. | ||
url : str, optional. | ||
Base URL of the Meteonorm API. `'climate/tmy'` is | ||
appended to the URL. The default is: | ||
:const:`pvlib.iotools.meteonorm.URL`. | ||
|
||
Raises | ||
------ | ||
requests.HTTPError | ||
Raises an error when an incorrect request is made. | ||
|
||
Returns | ||
------- | ||
data : pd.DataFrame | ||
Time series data. The index corresponds to the start (left) of the | ||
interval unless ``interval_index`` is set to False. | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
meta : dict | ||
Metadata. | ||
|
||
See Also | ||
-------- | ||
pvlib.iotools.get_meteonorm | ||
|
||
References | ||
---------- | ||
.. [1] `Meteonorm | ||
<https://meteonorm.com/>`_ | ||
.. [2] `Meteonorm API | ||
<https://docs.meteonorm.com/docs/getting-started>`_ | ||
.. [3] `Meteonorm API reference | ||
<https://docs.meteonorm.com/api>`_ | ||
""" | ||
params = { | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
'lat': latitude, | ||
'lon': longitude, | ||
'surface_tilt': surface_tilt, | ||
'surface_azimuth': surface_azimuth, | ||
'frequency': time_step, | ||
'parameters': parameters, | ||
'horizon': horizon, | ||
'situation': terrain, | ||
'turbidity': turbidity, | ||
'clear_sky_radiation_model': clear_sky_radiation_model, | ||
'data_version': data_version, | ||
'random_seed': random_seed, | ||
'future_scenario': future_scenario, | ||
'future_year': future_year, | ||
} | ||
|
||
# Allow specifying single parameters as string | ||
if isinstance(parameters, str): | ||
parameter_list = \ | ||
list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) | ||
if parameters in parameter_list: | ||
parameters = [parameters] | ||
AdamRJensen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# convert list to string with values separated by commas | ||
if not isinstance(parameters, (str, type(None))): | ||
# allow the use of pvlib parameter names | ||
parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} | ||
parameters = [parameter_dict.get(p, p) for p in parameters] | ||
params['parameters'] = ','.join(parameters) | ||
|
||
if not isinstance(horizon, str): | ||
params['horizon'] = ','.join(map(str, horizon)) | ||
|
||
if not isinstance(turbidity, str): | ||
params['turbidity'] = ','.join(map(str, turbidity)) | ||
|
||
params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) | ||
|
||
headers = {"Authorization": f"Bearer {api_key}"} | ||
|
||
response = requests.get( | ||
urljoin(url, TMY_ENDPOINT.lstrip('/')), headers=headers, params=params) | ||
|
||
if not response.ok: | ||
# response.raise_for_status() does not give a useful error message | ||
raise requests.HTTPError(response.json()) | ||
|
||
data, meta = _parse_meteonorm(response, interval_index, map_variables) | ||
|
||
return data, meta | ||
|
||
|
||
def _parse_meteonorm(response, interval_index, map_variables): | ||
data_json = response.json()['values'] | ||
# identify empty columns | ||
empty_columns = [k for k, v in data_json.items() if v is None] | ||
# remove empty columns | ||
_ = [data_json.pop(k) for k in empty_columns] | ||
|
||
data = pd.DataFrame(data_json) | ||
|
||
# xxx: experimental feature - see parameter description | ||
if interval_index: | ||
data.index = pd.IntervalIndex.from_arrays( | ||
left=pd.to_datetime(response.json()['start_times']), | ||
right=pd.to_datetime(response.json()['end_times']), | ||
closed='left', | ||
) | ||
else: | ||
data.index = pd.to_datetime(response.json()['start_times']) | ||
|
||
meta = response.json()['meta'] | ||
|
||
if map_variables: | ||
data = data.rename(columns=VARIABLE_MAP) | ||
meta['latitude'] = meta.pop('lat') | ||
meta['longitude'] = meta.pop('lon') | ||
|
||
return data, meta |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Solcast there are specialized functions for retrieving TMY, historical and forecast data. For Meteonorm there is currently only a TMY and a "everthing else" getter function (for the endpoints
/observation/training
,/observation/realtime
,/forecast/basic
, and/forecast/precision
). What's the reasoning behind implementingget_meteonorm
instead ofget_meteonorm_observation_training
,get_meteonorm_observation_realtime
,get_meteonorm_forecast_basic
, andget_meteonorm_forecast_precision
? There are pros and cons for both variants, but i would argue, that for the end user it's easier if there's a specialized function for every endpoint.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be convinced to have a separate function for
get_meteonorm_observation
andget_meteonorm_forecast
with a keyword parameter to switch between the subcases. The main motivation is to reduce the duplicate docstring and reduce the maintenance burden.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what the right answer here is. Maybe @AdamRJensen's proposal is the best balance.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I see that this is a maintenance nightmare with doc-strings of this size.
I assume messing around with the
__doc__
field to share a part of the documentation is not really an option; this becomes unreadable very fast.Despite that, every request to the API costs the user real money and thus I'm critical about using parameters - instead of separate functions - to decide which endpoint to call.
@AdamRJensen How would this look like? One option would be to use the exact same signature but the value to the parameter
endpoint
has to be"basic"
/"precision"
forget_meteonorm_forecast
and"realtime"
/"training"
forget_meteonorm_observation
.Note that there is no parameter
frequency
for the/forecast/basic
endpoint, thus it would be a good idea to raise a warning or exception if the user usesendpoint="basic", time_step="1min"
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@maschwanden If we are to split it into separate forecast and observation functions, then this is exactly how I envisioned it. I'm curious what other members of @pvlib/pvlib-maintainer have to say about this.
Also, I've added an error message if the basic forecast is requested with a frequency other than '1_hour'.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@maschwanden Check out the changes in the last commit. Would this approach be ok with you?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand correctly, @maschwanden proposed
meteonorm.get_meteonorm_observation_training
is similar to the existingsolcast.get_solcast_historic
function and themeteonorm.get_meteonorm_observation_realtime
is similar to thesolcast.get_solcast_live
function. I would favor API parallelism over concerns about docstrings.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes 👍, this looks good to me!
@wholmgren : Yes, that's what I proposed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've revised it such that all end points now have their own functions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!