-
-
Notifications
You must be signed in to change notification settings - Fork 47
feat: Add script to fetch Belgium solar data from Elia (Issue #121) #132
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,161 @@ | ||||||||||||||||
| import logging | ||||||||||||||||
| from typing import Optional, List | ||||||||||||||||
|
|
||||||||||||||||
| import pandas as pd | ||||||||||||||||
| import requests | ||||||||||||||||
| import xarray as xr | ||||||||||||||||
|
|
||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class EliaData: | ||||||||||||||||
| """ | ||||||||||||||||
| Class to handle interactions with the Elia (Belgium TSO) Open Data API. | ||||||||||||||||
|
|
||||||||||||||||
| Elia provides public solar generation data via the Opendatasoft platform. | ||||||||||||||||
| No API key is required. | ||||||||||||||||
|
|
||||||||||||||||
| Reference: https://opendata.elia.be/explore/dataset/ods087/ | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| def __init__(self) -> None: | ||||||||||||||||
| self.base_url = ( | ||||||||||||||||
| "https://opendata.elia.be/api/explore/v2.1/catalog/datasets" | ||||||||||||||||
| ) | ||||||||||||||||
| self.default_dataset = "ods087" | ||||||||||||||||
|
|
||||||||||||||||
| def get_data( | ||||||||||||||||
| self, | ||||||||||||||||
| start_date: str, | ||||||||||||||||
| end_date: str, | ||||||||||||||||
| dataset: str = "ods087", | ||||||||||||||||
| limit: int = 100, | ||||||||||||||||
| ) -> Optional[pd.DataFrame]: | ||||||||||||||||
| """ | ||||||||||||||||
| Fetch solar generation data from the Elia Open Data API. | ||||||||||||||||
|
|
||||||||||||||||
| Automatically paginates through all available results for the | ||||||||||||||||
| requested date range. | ||||||||||||||||
|
|
||||||||||||||||
| Args: | ||||||||||||||||
| start_date: Start date string (YYYY-MM-DD) | ||||||||||||||||
| end_date: End date string (YYYY-MM-DD) | ||||||||||||||||
| dataset: Elia dataset identifier (default: ods087 for solar PV) | ||||||||||||||||
| limit: Number of records per API page (max 100) | ||||||||||||||||
|
|
||||||||||||||||
| Returns: | ||||||||||||||||
| pd.DataFrame with solar generation records, or None if error/empty | ||||||||||||||||
| """ | ||||||||||||||||
| url = f"{self.base_url}/{dataset}/records" | ||||||||||||||||
|
|
||||||||||||||||
| where_clause = ( | ||||||||||||||||
| f"datetime >= '{start_date}T00:00:00Z' " | ||||||||||||||||
| f"AND datetime <= '{end_date}T23:59:59Z'" | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| params = { | ||||||||||||||||
| "where": where_clause, | ||||||||||||||||
| "order_by": "datetime ASC", | ||||||||||||||||
| "limit": limit, | ||||||||||||||||
| "offset": 0, | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+32
to
+61
|
||||||||||||||||
|
|
||||||||||||||||
| all_data: List[dict] = [] | ||||||||||||||||
| current_offset = 0 | ||||||||||||||||
|
|
||||||||||||||||
| try: | ||||||||||||||||
| while True: | ||||||||||||||||
| # Create a fresh copy to avoid mutating the original params | ||||||||||||||||
| request_params = params.copy() | ||||||||||||||||
| request_params["offset"] = current_offset | ||||||||||||||||
|
|
||||||||||||||||
| logger.info( | ||||||||||||||||
| f"Fetching data from {url}, offset={current_offset}..." | ||||||||||||||||
| ) | ||||||||||||||||
| response = requests.get(url, params=request_params) | ||||||||||||||||
| response.raise_for_status() | ||||||||||||||||
|
|
||||||||||||||||
| payload = response.json() | ||||||||||||||||
| results = payload.get("results", []) | ||||||||||||||||
|
|
||||||||||||||||
| if not results: | ||||||||||||||||
| logger.info("No more data returned from API.") | ||||||||||||||||
| break | ||||||||||||||||
|
|
||||||||||||||||
| all_data.extend(results) | ||||||||||||||||
|
|
||||||||||||||||
| if len(results) < limit: | ||||||||||||||||
| break | ||||||||||||||||
|
|
||||||||||||||||
| current_offset += limit | ||||||||||||||||
|
|
||||||||||||||||
| if not all_data: | ||||||||||||||||
| logger.warning("No data retrieved.") | ||||||||||||||||
| return None | ||||||||||||||||
|
|
||||||||||||||||
| return pd.DataFrame(all_data) | ||||||||||||||||
|
|
||||||||||||||||
| except requests.exceptions.RequestException as e: | ||||||||||||||||
| logger.error(f"Request failed: {e}") | ||||||||||||||||
| if "response" in locals() and response is not None: | ||||||||||||||||
| logger.error(f"Response: {response.text}") | ||||||||||||||||
| return None | ||||||||||||||||
|
|
||||||||||||||||
| def get_dataset( | ||||||||||||||||
| self, | ||||||||||||||||
| start_date: str, | ||||||||||||||||
| end_date: str, | ||||||||||||||||
| dataset: str = "ods087", | ||||||||||||||||
| ) -> Optional[xr.Dataset]: | ||||||||||||||||
| """ | ||||||||||||||||
| Fetch data and convert to xarray Dataset compatible with ocf-data-sampler. | ||||||||||||||||
|
|
||||||||||||||||
| Args: | ||||||||||||||||
| start_date: Start date string (YYYY-MM-DD) | ||||||||||||||||
| end_date: End date string (YYYY-MM-DD) | ||||||||||||||||
| dataset: Elia dataset identifier | ||||||||||||||||
|
|
||||||||||||||||
| Returns: | ||||||||||||||||
| xr.Dataset with datetime_utc index, or None if no data | ||||||||||||||||
| """ | ||||||||||||||||
| df = self.get_data( | ||||||||||||||||
| start_date=start_date, | ||||||||||||||||
| end_date=end_date, | ||||||||||||||||
| dataset=dataset, | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| if df is None or df.empty: | ||||||||||||||||
| return None | ||||||||||||||||
|
|
||||||||||||||||
| # Convert datetime column to proper UTC datetime | ||||||||||||||||
| if "datetime" in df.columns: | ||||||||||||||||
| df["datetime_utc"] = pd.to_datetime(df["datetime"], utc=True) | ||||||||||||||||
| df = df.drop(columns=["datetime"]) | ||||||||||||||||
|
|
||||||||||||||||
| # Select numeric columns for the dataset | ||||||||||||||||
| value_cols = [ | ||||||||||||||||
| c | ||||||||||||||||
| for c in df.columns | ||||||||||||||||
| if c not in ("datetime_utc", "resolutioncode", "mostrecent") | ||||||||||||||||
|
||||||||||||||||
| if c not in ("datetime_utc", "resolutioncode", "mostrecent") | |
| if c not in ( | |
| "datetime_utc", | |
| "resolutioncode", | |
| "mostrecent", | |
| "mostrecentforecast", | |
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,177 @@ | ||||||||||
| import pytest | ||||||||||
| import pandas as pd | ||||||||||
| from unittest.mock import Mock, patch | ||||||||||
|
|
||||||||||
| from open_data_pvnet.scripts.fetch_elia_data import EliaData | ||||||||||
|
|
||||||||||
|
|
||||||||||
| @pytest.fixture | ||||||||||
| def mock_response(): | ||||||||||
| """Fixture to mock a successful Elia API response.""" | ||||||||||
| mock = Mock() | ||||||||||
| mock.json.return_value = { | ||||||||||
| "results": [ | ||||||||||
| { | ||||||||||
| "datetime": "2024-06-15T12:00:00+00:00", | ||||||||||
| "measured": 2500.0, | ||||||||||
| "mostrecentforecast": 2450.0, | ||||||||||
| "monitoredcapacity": 7500.0, | ||||||||||
| "resolutioncode": "PT15M", | ||||||||||
| }, | ||||||||||
| { | ||||||||||
| "datetime": "2024-06-15T12:15:00+00:00", | ||||||||||
| "measured": 2520.0, | ||||||||||
| "mostrecentforecast": 2460.0, | ||||||||||
| "monitoredcapacity": 7500.0, | ||||||||||
| "resolutioncode": "PT15M", | ||||||||||
| }, | ||||||||||
| ] | ||||||||||
| } | ||||||||||
| mock.raise_for_status.return_value = None | ||||||||||
| return mock | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def test_init(): | ||||||||||
| """EliaData should initialize without any API key.""" | ||||||||||
| elia = EliaData() | ||||||||||
| assert elia.base_url == ( | ||||||||||
| "https://opendata.elia.be/api/explore/v2.1/catalog/datasets" | ||||||||||
| ) | ||||||||||
| assert elia.default_dataset == "ods087" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def test_get_data_success(mock_response): | ||||||||||
| """Should return a DataFrame with solar generation data.""" | ||||||||||
| with patch("requests.get", return_value=mock_response) as mock_get: | ||||||||||
| elia = EliaData() | ||||||||||
|
|
||||||||||
| df = elia.get_data( | ||||||||||
| start_date="2024-06-15", | ||||||||||
| end_date="2024-06-15", | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| assert isinstance(df, pd.DataFrame) | ||||||||||
| assert len(df) == 2 | ||||||||||
| assert "measured" in df.columns | ||||||||||
| assert "datetime" in df.columns | ||||||||||
|
|
||||||||||
| # Verify API call was made | ||||||||||
| mock_get.assert_called_once() | ||||||||||
| _, kwargs = mock_get.call_args | ||||||||||
| assert "ods087" in kwargs["params"]["where"] or "ods087" in _[0] | ||||||||||
|
Comment on lines
+60
to
+61
|
||||||||||
| _, kwargs = mock_get.call_args | |
| assert "ods087" in kwargs["params"]["where"] or "ods087" in _[0] | |
| args, kwargs = mock_get.call_args | |
| assert "ods087" in args[0] |
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.
default_datasetis set on the instance butget_data()/get_dataset()hard-code their own defaultdataset="ods087", so the class has two sources of truth. Consider makingdatasetoptional (defaultNone) and falling back toself.default_dataset, or otherwise referencingself.default_datasetso future updates don’t drift.