From 266749ef42e17dd24da33fc06807a7f3ab1bcf5b Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 15 Sep 2026 16:40:14 +1000 Subject: [PATCH 1/5] Add premium site measurement endpoints --- docs/pv_power_site_measurements.md | 49 +++++++ docs/wind_power_site_measurements.md | 52 +++++++ mkdocs.yml | 2 + solcast/__init__.py | 4 + solcast/api.py | 24 +++- solcast/pv_power_site_measurements.py | 175 ++++++++++++++++++++++++ solcast/urls.py | 4 + solcast/wind_power_site_measurements.py | 175 ++++++++++++++++++++++++ tests/test_site_measurements.py | 146 ++++++++++++++++++++ 9 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 docs/pv_power_site_measurements.md create mode 100644 docs/wind_power_site_measurements.md create mode 100644 solcast/pv_power_site_measurements.py create mode 100644 solcast/wind_power_site_measurements.py create mode 100644 tests/test_site_measurements.py diff --git a/docs/pv_power_site_measurements.md b/docs/pv_power_site_measurements.md new file mode 100644 index 0000000..54e8092 --- /dev/null +++ b/docs/pv_power_site_measurements.md @@ -0,0 +1,49 @@ +# Premium PV Site Measurements + +The `pv_power_site_measurements` module manages measured power data for Premium PV Power sites. + +All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. See the [Premium PV Power API documentation](https://docs.solcast.com.au/) for the complete request and response schemas. + +| Method | Purpose | +|---|---| +| `create_pv_site_measurements` | Submit site measurements. | +| `get_pv_site_measurements` | Retrieve site measurements, with optional `start` and `end` filters. | +| `delete_pv_site_measurements` | Delete site measurements between required `start` and `end` timestamps. | +| `create_pv_sub_unit_site_measurements` | Submit measurements for labelled site sub-units. | +| `get_pv_sub_unit_site_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | +| `delete_pv_sub_unit_site_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | + +## Create site measurements + +```python +from solcast import pv_power_site_measurements + +response = pv_power_site_measurements.create_pv_site_measurements( + resource_id="your-premium-pv-site", + measurements=[ + { + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 1.25, + } + ], +) +``` + +Create sub-unit measurements with a `sub_unit` value on every record: + +```python +response = pv_power_site_measurements.create_pv_sub_unit_site_measurements( + resource_id="your-premium-pv-site", + measurements=[ + { + "sub_unit": "inverter-1", + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 1.25, + } + ], +) +``` + +All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/docs/wind_power_site_measurements.md b/docs/wind_power_site_measurements.md new file mode 100644 index 0000000..5938f96 --- /dev/null +++ b/docs/wind_power_site_measurements.md @@ -0,0 +1,52 @@ +# Premium Wind Site Measurements + +The `wind_power_site_measurements` module manages measured power data for Premium Wind Power sites. + +All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. See the [Premium Wind Power API documentation](https://docs.solcast.com.au/) for the complete request and response schemas. + +| Method | Purpose | +|---|---| +| `create_wind_site_measurements` | Submit site measurements. | +| `get_wind_site_measurements` | Retrieve site measurements, with optional `start` and `end` filters. | +| `delete_wind_site_measurements` | Delete site measurements between required `start` and `end` timestamps. | +| `create_wind_sub_unit_site_measurements` | Submit measurements for labelled site sub-units. | +| `get_wind_sub_unit_site_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | +| `delete_wind_sub_unit_site_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | + +## Create site measurements + +```python +from solcast import wind_power_site_measurements + +response = wind_power_site_measurements.create_wind_site_measurements( + resource_id="your-premium-wind-site", + measurements=[ + { + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 2.5, + "wind_speed_hub_height": 8.2, + "wind_direction_hub_height": 180, + } + ], +) +``` + +Create sub-unit measurements with a `sub_unit` value on every record: + +```python +response = wind_power_site_measurements.create_wind_sub_unit_site_measurements( + resource_id="your-premium-wind-site", + measurements=[ + { + "sub_unit": "turbine-1", + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 2.5, + "wind_speed_hub_height": 8.2, + } + ], +) +``` + +All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/mkdocs.yml b/mkdocs.yml index 86a86f4..fc3560e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,8 @@ nav: - Historic: historic.md - TMY: tmy.md - PV Power Sites: pv_power_sites.md + - Premium PV Site Measurements: pv_power_site_measurements.md + - Premium Wind Site Measurements: wind_power_site_measurements.md - Aggregations: aggregations.md - Example Notebooks: - '1. Getting Data': diff --git a/solcast/__init__.py b/solcast/__init__.py index a4f208e..eaf50f4 100644 --- a/solcast/__init__.py +++ b/solcast/__init__.py @@ -6,6 +6,8 @@ historic, live, pv_power_sites, + pv_power_site_measurements, + wind_power_site_measurements, tmy, unmetered_locations, ) @@ -16,6 +18,8 @@ "historic", "live", "pv_power_sites", + "pv_power_site_measurements", + "wind_power_site_measurements", "tmy", "unmetered_locations", ] diff --git a/solcast/api.py b/solcast/api.py index 99ed5e0..813bc9a 100644 --- a/solcast/api.py +++ b/solcast/api.py @@ -4,7 +4,7 @@ import urllib.error import urllib.parse from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Type from urllib.request import Request, urlopen import solcast @@ -105,7 +105,7 @@ def __init__( self, base_url: str, endpoint: str, - response_type: Response, + response_type: Type[Response], ): """ Args: @@ -177,7 +177,7 @@ def get(self, params: dict) -> Response: """ return self._make_request(params, method="GET") - def post(self, params: dict) -> Response: + def post(self, params: dict, json_body: Optional[Dict[str, Any]] = None) -> Response: """Wrap _make_request to make a POST request Args: @@ -187,7 +187,7 @@ def post(self, params: dict) -> Response: a Response object. """ - return self._make_request(params, method="POST") + return self._make_request(params, method="POST", json_body=json_body) def patch(self, params: dict) -> Response: """Wrap _make_request to make a PATCH request @@ -225,7 +225,7 @@ def delete(self, params: dict) -> Response: """ return self._make_request(params, method="DELETE") - def _make_request(self, params: dict, method: str) -> Response: + def _make_request(self, params: dict, method: str, json_body: Optional[Dict[str, Any]] = None) -> Response: """Make a request using urllib with the HTTP method specified Args: @@ -237,10 +237,20 @@ def _make_request(self, params: dict, method: str) -> Response: """ params, key = self._check_params(params) - url = self.url + "?" + urllib.parse.urlencode(params) + query = urllib.parse.urlencode(params) + url = self.url + (f"?{query}" if query else "") + headers = { + "Authorization": f"Bearer {key}", + "User-Agent": self.user_agent, + } + data = None + if json_body is not None: + data = json.dumps(json_body).encode("utf-8") + headers["Content-Type"] = "application/json" req = Request( url, - headers={"Authorization": f"Bearer {key}", "User-Agent": self.user_agent}, + data=data, + headers=headers, method=method, ) try: diff --git a/solcast/pv_power_site_measurements.py b/solcast/pv_power_site_measurements.py new file mode 100644 index 0000000..232cb4d --- /dev/null +++ b/solcast/pv_power_site_measurements.py @@ -0,0 +1,175 @@ +from typing import Any, Dict, List + +from .api import Client, Response +from .urls import ( + base_url, + pv_power_site_measurements, + pv_power_site_measurements_sub_units, +) + + +def create_pv_site_measurements( + resource_id: str, measurements: List[Dict[str, Any]], **kwargs +) -> Response: + """ + Submit measurement data for a Premium PV Power site. Measurements are used for model + training. Maximum 1000 measurements per request. Power units (AC) are measured in + MW. Power is the actual measured power output by the site. If there are availability + and curtailment constraints in place, those fields should also be included with the + measurements. + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + measurements: Array of measurement records (1-1000). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements, + response_type=Response, + ) + + return client.post( + {**kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": measurements, + }, + ) + + +def get_pv_site_measurements(resource_id: str, **kwargs) -> Response: + """ + Retrieve historical measurement data for a Premium PV Power site. Supports + pagination via skip/take and date filtering via start/end. + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements, + response_type=Response, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_pv_site_measurements( + resource_id: str, start: str, end: str, **kwargs +) -> Response: + """ + Delete site measurements + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + start: Delete lower bound timestamp (ISO-8601). + end: Delete upper bound timestamp (ISO-8601). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements, + response_type=Response, + ) + + return client.delete( + { + "resource_id": resource_id, + "start": start, + "end": end, + "format": "json", + **kwargs, + } + ) + + +def create_pv_sub_unit_site_measurements( + resource_id: str, measurements: List[Dict[str, Any]], **kwargs +) -> Response: + """ + Submit sub-unit measurement data for a Premium PV Power site. A sub-unit represents + a subsection of the site, such as an individual inverter or a subset of inverters. + Each measurement must include a 'sub_unit' label. Maximum 1000 measurements per + request. Power units (AC) are measured in MW. + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + measurements: List of sub-unit measurement records (1-1000). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.post( + {**kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": measurements, + }, + ) + + +def get_pv_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: + """ + Retrieve historical sub-unit measurement data for a Premium PV Power site. A + sub-unit represents a subsection of the site, such as an individual inverter or a + subset of inverters. Supports pagination via skip/take, date filtering via + start/end, and an optional sub_unit filter. + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_pv_sub_unit_site_measurements( + resource_id: str, start: str, end: str, **kwargs +) -> Response: + """ + Delete sub-unit site measurements + + Args: + resource_id: The unique identifier of the Premium PV Power resource. + start: Delete lower bound timestamp (ISO-8601). + end: Delete upper bound timestamp (ISO-8601). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.delete( + { + "resource_id": resource_id, + "start": start, + "end": end, + "format": "json", + **kwargs, + } + ) diff --git a/solcast/urls.py b/solcast/urls.py index e0bc78d..fb710c9 100644 --- a/solcast/urls.py +++ b/solcast/urls.py @@ -22,3 +22,7 @@ tmy_rooftop_pv_power = "data/tmy/rooftop_pv_power" pv_power_site = "resources/pv_power_site" pv_power_sites = "resources/pv_power_sites" +pv_power_site_measurements = "resources/pv_power_site_measurements" +pv_power_site_measurements_sub_units = "resources/pv_power_site_measurements/sub_units" +wind_power_site_measurements = "resources/wind_power_site_measurements" +wind_power_site_measurements_sub_units = "resources/wind_power_site_measurements/sub_units" diff --git a/solcast/wind_power_site_measurements.py b/solcast/wind_power_site_measurements.py new file mode 100644 index 0000000..8bc92f3 --- /dev/null +++ b/solcast/wind_power_site_measurements.py @@ -0,0 +1,175 @@ +from typing import Any, Dict, List + +from .api import Client, Response +from .urls import ( + base_url, + wind_power_site_measurements, + wind_power_site_measurements_sub_units, +) + + +def create_wind_site_measurements( + resource_id: str, measurements: List[Dict[str, Any]], **kwargs +) -> Response: + """ + Submit measurement data for a Premium Wind Power site. Measurements are used for + model training. Maximum 1000 measurements per request. Power units (AC) are measured + in MW. Power is the actual measured power output by the site. If there are + availability and curtailment constraints in place, those fields should also be + included with the measurements. + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + measurements: Array of measurement records (1-1000). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements, + response_type=Response, + ) + + return client.post( + {**kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": measurements, + }, + ) + + +def get_wind_site_measurements(resource_id: str, **kwargs) -> Response: + """ + Retrieve historical measurement data for a Premium Wind Power site. Supports + pagination via skip/take and date filtering via start/end. + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements, + response_type=Response, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_wind_site_measurements( + resource_id: str, start: str, end: str, **kwargs +) -> Response: + """ + Delete site measurements + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + start: Delete lower bound timestamp (ISO-8601). + end: Delete upper bound timestamp (ISO-8601). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements, + response_type=Response, + ) + + return client.delete( + { + "resource_id": resource_id, + "start": start, + "end": end, + "format": "json", + **kwargs, + } + ) + + +def create_wind_sub_unit_site_measurements( + resource_id: str, measurements: List[Dict[str, Any]], **kwargs +) -> Response: + """ + Submit sub-unit measurement data for a Premium Wind Power site. A sub-unit + represents a subsection of the site, such as an individual wind turbine or a subset + of turbines. Each measurement must include a 'sub_unit' label. Maximum 1000 + measurements per request. Power units (AC) are measured in MW. + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + measurements: List of sub-unit measurement records (1-1000). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.post( + {**kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": measurements, + }, + ) + + +def get_wind_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: + """ + Retrieve historical sub-unit measurement data for a Premium Wind Power site. A + sub-unit represents a subsection of the site, such as an individual wind turbine or + a subset of turbines. Supports pagination via skip/take, date filtering via + start/end, and an optional sub_unit filter. + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_wind_sub_unit_site_measurements( + resource_id: str, start: str, end: str, **kwargs +) -> Response: + """ + Delete sub-unit site measurements + + Args: + resource_id: The unique identifier of the Premium Wind Power resource. + start: Delete lower bound timestamp (ISO-8601). + end: Delete upper bound timestamp (ISO-8601). + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/ for full list of parameters. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements_sub_units, + response_type=Response, + ) + + return client.delete( + { + "resource_id": resource_id, + "start": start, + "end": end, + "format": "json", + **kwargs, + } + ) diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py new file mode 100644 index 0000000..01bf051 --- /dev/null +++ b/tests/test_site_measurements.py @@ -0,0 +1,146 @@ +import json +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from solcast import pv_power_site_measurements, wind_power_site_measurements + + +class FakeResponse: + code = 200 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return b'{"accepted": 1}' + + +def capture_request(mock_urlopen): + request = mock_urlopen.call_args.args[0] + return request, urlsplit(request.full_url) + + +def test_create_pv_site_measurements_sends_json_body(): + measurements = [ + { + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 1.25, + } + ] + + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_site_measurements.create_pv_site_measurements( + resource_id="pv-123", + measurements=measurements, + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "POST" + assert parsed_url.path == "/resources/pv_power_site_measurements" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "resource_id": "pv-123", + "measurements": measurements, + } + + +def test_get_pv_sub_unit_site_measurements_sends_filters_as_query(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_site_measurements.get_pv_sub_unit_site_measurements( + resource_id="pv-123", + sub_unit="inverter-1", + start="2026-01-01T00:00:00Z", + end="2026-01-02T00:00:00Z", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "GET" + assert parsed_url.path == "/resources/pv_power_site_measurements/sub_units" + assert parse_qs(parsed_url.query) == { + "resource_id": ["pv-123"], + "sub_unit": ["inverter-1"], + "start": ["2026-01-01T00:00:00Z"], + "end": ["2026-01-02T00:00:00Z"], + "format": ["json"], + } + + +def test_delete_wind_site_measurements_sends_required_time_bounds(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_site_measurements.delete_wind_site_measurements( + resource_id="wind-123", + start="2026-01-01T00:00:00Z", + end="2026-01-02T00:00:00Z", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "DELETE" + assert parsed_url.path == "/resources/wind_power_site_measurements" + assert parse_qs(parsed_url.query) == { + "resource_id": ["wind-123"], + "start": ["2026-01-01T00:00:00Z"], + "end": ["2026-01-02T00:00:00Z"], + "format": ["json"], + } + + +def test_create_wind_sub_unit_site_measurements_sends_json_body(): + measurements = [ + { + "sub_unit": "turbine-1", + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 2.5, + "wind_speed_hub_height": 8.2, + } + ] + + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_site_measurements.create_wind_sub_unit_site_measurements( + resource_id="wind-123", + measurements=measurements, + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "POST" + assert parsed_url.path == "/resources/wind_power_site_measurements/sub_units" + assert parse_qs(parsed_url.query) == {} + assert json.loads(request.data) == { + "resource_id": "wind-123", + "measurements": measurements, + } + + +def test_measurement_modules_expose_all_site_and_sub_unit_operations(): + expected_pv_names = { + "create_pv_site_measurements", + "get_pv_site_measurements", + "delete_pv_site_measurements", + "create_pv_sub_unit_site_measurements", + "get_pv_sub_unit_site_measurements", + "delete_pv_sub_unit_site_measurements", + } + expected_wind_names = { + "create_wind_site_measurements", + "get_wind_site_measurements", + "delete_wind_site_measurements", + "create_wind_sub_unit_site_measurements", + "get_wind_sub_unit_site_measurements", + "delete_wind_sub_unit_site_measurements", + } + + assert expected_pv_names.issubset(vars(pv_power_site_measurements)) + assert expected_wind_names.issubset(vars(wind_power_site_measurements)) From efe58f30d455f9fb682d7dc98e188f6da725ab36 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 15 Sep 2026 16:45:00 +1000 Subject: [PATCH 2/5] Format generated measurement client --- solcast/api.py | 8 ++++++-- solcast/urls.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/solcast/api.py b/solcast/api.py index 813bc9a..2b58cad 100644 --- a/solcast/api.py +++ b/solcast/api.py @@ -177,7 +177,9 @@ def get(self, params: dict) -> Response: """ return self._make_request(params, method="GET") - def post(self, params: dict, json_body: Optional[Dict[str, Any]] = None) -> Response: + def post( + self, params: dict, json_body: Optional[Dict[str, Any]] = None + ) -> Response: """Wrap _make_request to make a POST request Args: @@ -225,7 +227,9 @@ def delete(self, params: dict) -> Response: """ return self._make_request(params, method="DELETE") - def _make_request(self, params: dict, method: str, json_body: Optional[Dict[str, Any]] = None) -> Response: + def _make_request( + self, params: dict, method: str, json_body: Optional[Dict[str, Any]] = None + ) -> Response: """Make a request using urllib with the HTTP method specified Args: diff --git a/solcast/urls.py b/solcast/urls.py index fb710c9..4e70f30 100644 --- a/solcast/urls.py +++ b/solcast/urls.py @@ -25,4 +25,6 @@ pv_power_site_measurements = "resources/pv_power_site_measurements" pv_power_site_measurements_sub_units = "resources/pv_power_site_measurements/sub_units" wind_power_site_measurements = "resources/wind_power_site_measurements" -wind_power_site_measurements_sub_units = "resources/wind_power_site_measurements/sub_units" +wind_power_site_measurements_sub_units = ( + "resources/wind_power_site_measurements/sub_units" +) From 59f66862375883f4bd9d47eb2802e44011ec61c8 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 29 Sep 2026 13:36:47 +1000 Subject: [PATCH 3/5] Add premium site measurement endpoints and examples --- ...4.2 Premium PV and Wind Measurements.ipynb | 350 ++++++++++++++++++ docs/pv_power_site_measurements.md | 48 +++ docs/wind_power_site_measurements.md | 48 +++ mkdocs.yml | 3 +- solcast/pv_power_site_measurements.py | 4 +- solcast/wind_power_site_measurements.py | 4 +- tests/test_site_measurements.py | 10 +- 7 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb diff --git a/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb new file mode 100644 index 0000000..0afbc16 --- /dev/null +++ b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb @@ -0,0 +1,350 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "80c98ece", + "metadata": { + "id": "80c98ece", + "language": "markdown" + }, + "source": [ + "Premium PV and Wind Power sites use measured power to train the site model. Measurements are managed with the [`pv_power_site_measurements`](https://solcast.github.io/solcast-api-python-sdk/pv_power_site_measurements/) and [`wind_power_site_measurements`](https://solcast.github.io/solcast-api-python-sdk/wind_power_site_measurements/) modules.\n", + "\n", + "In this example, we will use the SDK for the following.\n", + "\n", + "* Upload measurements in batches of up to 1,000 records\n", + "* Download all measurements for a period with `skip` and `take`\n", + "\n", + "The upload example writes data to the site. Replace `your-premium-pv-site` with the `resource_id` of your own Premium PV Power site." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ff043c16", + "metadata": { + "id": "ff043c16", + "language": "python" + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "from solcast import pv_power_site_measurements" + ] + }, + { + "cell_type": "markdown", + "id": "796544c2", + "metadata": { + "id": "796544c2", + "language": "markdown" + }, + "source": [ + "## Upload Measurements in Batches\n", + "\n", + "Each create request accepts up to 1,000 measurements. Split a larger list into batches of 1,000 and send one request for each batch." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61f78dd3", + "metadata": { + "id": "61f78dd3", + "language": "python" + }, + "outputs": [], + "source": [ + "resource_id = \"cbf6-a872-1cb2-566f\"\n", + "measurements = [\n", + " {\"period_end\": \"2026-09-28T00:30:00Z\", \"period\": \"PT30M\", \"power\": 1.25},\n", + " {\"period_end\": \"2026-09-28T01:00:00Z\", \"period\": \"PT30M\", \"power\": 1.4},\n", + "]\n", + "\n", + "# NOTE: this writes measurements to the site!\n", + "for i in range(0, len(measurements), 1000):\n", + " res = pv_power_site_measurements.create_pv_site_measurements(\n", + " resource_id=resource_id,\n", + " measurements=measurements[i : i + 1000],\n", + " format=\"json\",\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + " print(res.to_dict())" + ] + }, + { + "cell_type": "markdown", + "id": "4d86443c", + "metadata": { + "id": "4d86443c", + "language": "markdown" + }, + "source": [ + "Sub-unit measurements use the same batch limit. Each record must include the `sub_unit` label for its inverter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59e49691", + "metadata": { + "id": "59e49691", + "language": "python" + }, + "outputs": [], + "source": [ + "sub_unit_measurements = [\n", + " {\n", + " \"sub_unit\": \"inverter-1\",\n", + " \"period_end\": \"2026-09-28T00:30:00Z\",\n", + " \"period\": \"PT30M\",\n", + " \"power\": 0.65,\n", + " },\n", + " {\n", + " \"sub_unit\": \"inverter-1\",\n", + " \"period_end\": \"2026-09-28T01:00:00Z\",\n", + " \"period\": \"PT30M\",\n", + " \"power\": 0.72,\n", + " },\n", + "]\n", + "\n", + "# NOTE: this writes sub-unit measurements to the site!\n", + "for i in range(0, len(sub_unit_measurements), 1000):\n", + " res = pv_power_site_measurements.create_pv_sub_unit_site_measurements(\n", + " resource_id=resource_id,\n", + " measurements=sub_unit_measurements[i : i + 1000],\n", + " format=\"json\",\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + " print(res.to_dict())" + ] + }, + { + "cell_type": "markdown", + "id": "052162aa", + "metadata": { + "id": "052162aa", + "language": "markdown" + }, + "source": [ + "## Display Sub-Unit Measurements\n", + "\n", + "Pass a `sub_unit` label to retrieve measurements for one inverter. The query uses the same `skip` and `take` pagination as site measurements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc123c73", + "metadata": { + "id": "dc123c73", + "language": "python" + }, + "outputs": [], + "source": [ + "sub_unit_results = []\n", + "sub_unit_skip = 0\n", + "sub_unit_take = 100\n", + "sub_unit = \"inverter-1\"\n", + "\n", + "while True:\n", + " res = pv_power_site_measurements.get_pv_sub_unit_site_measurements(\n", + " resource_id=resource_id,\n", + " sub_unit=sub_unit,\n", + " start=\"2026-09-28T00:00:00Z\",\n", + " end=\"2026-09-29T00:00:00Z\",\n", + " skip=sub_unit_skip,\n", + " take=sub_unit_take,\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + "\n", + " page = res.to_dict()\n", + " page_records = page[\"results\"]\n", + " sub_unit_results.extend(page_records)\n", + " sub_unit_skip = page[\"offset\"] + len(page_records)\n", + " if not page_records or sub_unit_skip >= page[\"total\"]:\n", + " break\n", + "\n", + "if sub_unit_results:\n", + " sub_unit_df = pd.DataFrame(sub_unit_results).set_index(\"period_end\")\n", + " sub_unit_df.index = pd.to_datetime(sub_unit_df.index)\n", + " display(sub_unit_df)\n", + "else:\n", + " print(f\"No measurements found for sub-unit: {sub_unit}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7af043e8", + "metadata": { + "id": "7af043e8", + "language": "markdown" + }, + "source": [ + "Use the same pattern with `create_pv_sub_unit_site_measurements` for inverter-level data. Each sub-unit record must include a `sub_unit` label.\n", + "\n", + "## Download All Pages\n", + "\n", + "Measurements are returned in pages. Each response has `offset`, `total`, and `results` fields. Increase `skip` after each page until all records are collected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7769e683", + "metadata": { + "id": "7769e683", + "language": "python" + }, + "outputs": [], + "source": [ + "results = []\n", + "skip, take = 0, 100\n", + "\n", + "while True:\n", + " res = pv_power_site_measurements.get_pv_site_measurements(\n", + " resource_id=resource_id,\n", + " start=\"2026-09-28T00:00:00Z\",\n", + " end=\"2026-09-29T00:00:00Z\",\n", + " skip=skip,\n", + " take=take,\n", + " )\n", + " page = res.to_dict()\n", + " records = page[\"results\"]\n", + " results.extend(records)\n", + " skip = page[\"offset\"] + len(records)\n", + " if not records or skip >= page[\"total\"]:\n", + " break\n", + "\n", + "if results:\n", + " df = pd.DataFrame(results).set_index(\"period_end\")\n", + " df.index = pd.to_datetime(df.index)\n", + " df[\"power\"].plot(figsize=[15, 4])\n", + "else:\n", + " print(\"No PV measurements were returned for this time range.\")" + ] + }, + { + "cell_type": "markdown", + "id": "9223be80", + "metadata": { + "id": "9223be80", + "language": "markdown" + }, + "source": [ + "## Delete Measurements\n", + "\n", + "Deletion removes records in the selected time range. Review the resource ID and bounds before setting `confirm_delete` to `True`. For sub-unit data, use `delete_pv_sub_unit_site_measurements` and pass the `sub_unit` label." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45580803", + "metadata": { + "id": "45580803", + "language": "python" + }, + "outputs": [], + "source": [ + "confirm_delete = False\n", + "if confirm_delete:\n", + " res = pv_power_site_measurements.delete_pv_site_measurements(\n", + " resource_id=resource_id,\n", + " start=\"2026-09-28T00:00:00Z\",\n", + " end=\"2026-09-28T02:00:00Z\",\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + " print(res.to_dict())\n", + "else:\n", + " print(\"Deletion is disabled.\")" + ] + }, + { + "cell_type": "markdown", + "id": "628a1dd1", + "metadata": { + "id": "628a1dd1", + "language": "markdown" + }, + "source": [ + "## Delete Sub-Unit Measurements\n", + "\n", + "Set `sub_unit` and the required time range to delete only that inverter's records. Keep the confirmation flag false until you are ready to delete. For Wind, use `delete_wind_sub_unit_site_measurements`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "294dd82a", + "metadata": { + "id": "294dd82a", + "language": "python" + }, + "outputs": [], + "source": [ + "confirm_sub_unit_delete = True\n", + "if confirm_sub_unit_delete:\n", + " res = pv_power_site_measurements.delete_pv_sub_unit_site_measurements(\n", + " resource_id=resource_id,\n", + " sub_unit=sub_unit,\n", + " start=\"2026-09-28T00:00:00Z\",\n", + " end=\"2026-09-28T02:00:00Z\",\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + " print(res.to_dict())\n", + "else:\n", + " print(\"Sub-unit deletion is disabled.\")" + ] + }, + { + "cell_type": "markdown", + "id": "60b7ea70", + "metadata": { + "id": "60b7ea70", + "language": "markdown" + }, + "source": [ + "## Wind Sites\n", + "\n", + "The `wind_power_site_measurements` module uses the same workflow with a Premium Wind Power `resource_id`.\n", + "\n", + "* Upload site records with `create_wind_site_measurements`.\n", + "* Retrieve site records with `get_wind_site_measurements` and `skip`/`take`.\n", + "* Upload turbine records with `create_wind_sub_unit_site_measurements`; add a `sub_unit` label to each record.\n", + "* Retrieve turbine records with `get_wind_sub_unit_site_measurements` and pass `sub_unit`, `skip`, and `take`.\n", + "* Delete site records with `delete_wind_site_measurements` and required `start`/`end` bounds.\n", + "* Delete turbine records with `delete_wind_sub_unit_site_measurements`, `sub_unit`, and required `start`/`end` bounds.\n", + "\n", + "Wind measurements can also include `wind_speed_hub_height` and `wind_direction_hub_height`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv-1 (3.12.3.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/pv_power_site_measurements.md b/docs/pv_power_site_measurements.md index 54e8092..cad8475 100644 --- a/docs/pv_power_site_measurements.md +++ b/docs/pv_power_site_measurements.md @@ -46,4 +46,52 @@ response = pv_power_site_measurements.create_pv_sub_unit_site_measurements( ) ``` +## Upload and download large datasets + +Each create request accepts up to 1,000 measurements. Split larger lists into batches and check each response: + +```python +# measurements is the complete list of records to upload. +for start_index in range(0, len(measurements), 1000): + batch = measurements[start_index : start_index + 1000] + response = pv_power_site_measurements.create_pv_site_measurements( + resource_id="your-premium-pv-site", + measurements=batch, + format="json", + ) + if not response.success: + raise RuntimeError(response.exception) + print(f"Accepted {response.to_dict()['accepted']} measurements") +``` + +Use the same batching pattern with `create_pv_sub_unit_site_measurements`; include a `sub_unit` label in each record. + +Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: + +```python +all_measurements = [] +skip = 0 +take = 100 + +while True: + response = pv_power_site_measurements.get_pv_site_measurements( + resource_id="your-premium-pv-site", + start="2026-01-01T00:00:00Z", + end="2026-02-01T00:00:00Z", + skip=skip, + take=take, + ) + if not response.success: + raise RuntimeError(response.exception) + + page = response.to_dict() + records = page["results"] + all_measurements.extend(records) + skip = page["offset"] + len(records) + if not records or skip >= page["total"]: + break +``` + +For sub-unit data, use `get_pv_sub_unit_site_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one inverter. + All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/docs/wind_power_site_measurements.md b/docs/wind_power_site_measurements.md index 5938f96..58bd5b1 100644 --- a/docs/wind_power_site_measurements.md +++ b/docs/wind_power_site_measurements.md @@ -49,4 +49,52 @@ response = wind_power_site_measurements.create_wind_sub_unit_site_measurements( ) ``` +## Upload and download large datasets + +Each create request accepts up to 1,000 measurements. Split larger lists into batches and check each response: + +```python +# measurements is the complete list of records to upload. +for start_index in range(0, len(measurements), 1000): + batch = measurements[start_index : start_index + 1000] + response = wind_power_site_measurements.create_wind_site_measurements( + resource_id="your-premium-wind-site", + measurements=batch, + format="json", + ) + if not response.success: + raise RuntimeError(response.exception) + print(f"Accepted {response.to_dict()['accepted']} measurements") +``` + +Use the same batching pattern with `create_wind_sub_unit_site_measurements`; include a `sub_unit` label in each record. + +Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: + +```python +all_measurements = [] +skip = 0 +take = 100 + +while True: + response = wind_power_site_measurements.get_wind_site_measurements( + resource_id="your-premium-wind-site", + start="2026-01-01T00:00:00Z", + end="2026-02-01T00:00:00Z", + skip=skip, + take=take, + ) + if not response.success: + raise RuntimeError(response.exception) + + page = response.to_dict() + records = page["results"] + all_measurements.extend(records) + skip = page["offset"] + len(records) + if not records or skip >= page["total"]: + break +``` + +For sub-unit data, use `get_wind_sub_unit_site_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one turbine. + All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/mkdocs.yml b/mkdocs.yml index fc3560e..5317bb2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,8 +37,9 @@ nav: - '3.3 Comparing to Measurements: Accuracy Metrics': 'notebooks/3.3 Comparing to Measurements - Accuracy Metrics.ipynb' - '3.4 Rooftop PV Tuning': 'notebooks/3.4 Rooftop PV Tuning.ipynb' - '3.4b Rooftop Shading Corrections': 'notebooks/3.4b Rooftop Shading Corrections.ipynb' - - '4. Advanced PV Power': + - '4. Power Sites and Measurements': - '4.1 Advanced PV Power: Setting up and Using PV Sites': 'notebooks/4.1 Setting up and Using PV Sites.ipynb' + - '4.2 Premium PV and Wind: Site Measurements': 'notebooks/4.2 Premium PV and Wind Measurements.ipynb' - data: - '3.1_sample_measurements.csv': 'notebooks/data/3.1_sample_measurements.csv' - '3.2_sample_measurements.csv': 'notebooks/data/3.2_sample_measurements.csv' diff --git a/solcast/pv_power_site_measurements.py b/solcast/pv_power_site_measurements.py index 232cb4d..1591e3d 100644 --- a/solcast/pv_power_site_measurements.py +++ b/solcast/pv_power_site_measurements.py @@ -32,7 +32,7 @@ def create_pv_site_measurements( ) return client.post( - {**kwargs}, + {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, "measurements": measurements, @@ -114,7 +114,7 @@ def create_pv_sub_unit_site_measurements( ) return client.post( - {**kwargs}, + {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, "measurements": measurements, diff --git a/solcast/wind_power_site_measurements.py b/solcast/wind_power_site_measurements.py index 8bc92f3..81908f2 100644 --- a/solcast/wind_power_site_measurements.py +++ b/solcast/wind_power_site_measurements.py @@ -32,7 +32,7 @@ def create_wind_site_measurements( ) return client.post( - {**kwargs}, + {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, "measurements": measurements, @@ -114,7 +114,7 @@ def create_wind_sub_unit_site_measurements( ) return client.post( - {**kwargs}, + {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, "measurements": measurements, diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py index 01bf051..1ae5eb1 100644 --- a/tests/test_site_measurements.py +++ b/tests/test_site_measurements.py @@ -41,9 +41,10 @@ def test_create_pv_site_measurements_sends_json_body(): request, parsed_url = capture_request(mock_urlopen) assert response.success is True + assert response.to_dict()["accepted"] == 1 assert request.method == "POST" assert parsed_url.path == "/resources/pv_power_site_measurements" - assert parse_qs(parsed_url.query) == {} + assert parse_qs(parsed_url.query) == {"format": ["json"]} assert request.headers["Content-type"] == "application/json" assert json.loads(request.data) == { "resource_id": "pv-123", @@ -58,6 +59,8 @@ def test_get_pv_sub_unit_site_measurements_sends_filters_as_query(): sub_unit="inverter-1", start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z", + skip=20, + take=50, api_key="test-key", ) @@ -70,6 +73,8 @@ def test_get_pv_sub_unit_site_measurements_sends_filters_as_query(): "sub_unit": ["inverter-1"], "start": ["2026-01-01T00:00:00Z"], "end": ["2026-01-02T00:00:00Z"], + "skip": ["20"], + "take": ["50"], "format": ["json"], } @@ -115,9 +120,10 @@ def test_create_wind_sub_unit_site_measurements_sends_json_body(): request, parsed_url = capture_request(mock_urlopen) assert response.success is True + assert response.to_dict()["accepted"] == 1 assert request.method == "POST" assert parsed_url.path == "/resources/wind_power_site_measurements/sub_units" - assert parse_qs(parsed_url.query) == {} + assert parse_qs(parsed_url.query) == {"format": ["json"]} assert json.loads(request.data) == { "resource_id": "wind-123", "measurements": measurements, From 88190805947226a71e7f9c3e2ffb9fd73871737b Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 30 Sep 2026 08:51:41 +1000 Subject: [PATCH 4/5] Updated Premium measurement support for pandas uploads and pagination --- ...4.2 Premium PV and Wind Measurements.ipynb | 317 +++++++++++++----- docs/pv_power_site_measurements.md | 16 +- docs/wind_power_site_measurements.md | 16 +- solcast/_site_measurements.py | 42 +++ solcast/api.py | 19 +- solcast/pv_power_site_measurements.py | 41 +-- solcast/wind_power_site_measurements.py | 41 +-- tests/test_site_measurements.py | 106 +++++- 8 files changed, 444 insertions(+), 154 deletions(-) create mode 100644 solcast/_site_measurements.py diff --git a/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb index 0afbc16..5dd6ec6 100644 --- a/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb +++ b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb @@ -8,14 +8,16 @@ "language": "markdown" }, "source": [ - "Premium PV and Wind Power sites use measured power to train the site model. Measurements are managed with the [`pv_power_site_measurements`](https://solcast.github.io/solcast-api-python-sdk/pv_power_site_measurements/) and [`wind_power_site_measurements`](https://solcast.github.io/solcast-api-python-sdk/wind_power_site_measurements/) modules.\n", + "# Premium Measurements\n", + "\n", + "This example shows Premium PV site measurement uploads, downloads, and sub-unit records. Premium Wind uses the same workflow through `wind_power_site_measurements`; see the [Wind measurement guide](../wind_power_site_measurements.md) for its fields.\n", "\n", "In this example, we will use the SDK for the following.\n", "\n", "* Upload measurements in batches of up to 1,000 records\n", "* Download all measurements for a period with `skip` and `take`\n", "\n", - "The upload example writes data to the site. Replace `your-premium-pv-site` with the `resource_id` of your own Premium PV Power site." + "The upload example writes data to the site. Replace `your-premium-pv-site` with your Premium PV Power `resource_id`." ] }, { @@ -43,7 +45,7 @@ "source": [ "## Upload Measurements in Batches\n", "\n", - "Each create request accepts up to 1,000 measurements. Split a larger list into batches of 1,000 and send one request for each batch." + "Each create request accepts up to 1,000 measurements. The site example below batches a pandas DataFrame; the sub-unit example uses a list of records." ] }, { @@ -54,19 +56,38 @@ "id": "61f78dd3", "language": "python" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'accepted': 2}\n" + ] + } + ], "source": [ - "resource_id = \"cbf6-a872-1cb2-566f\"\n", - "measurements = [\n", - " {\"period_end\": \"2026-09-28T00:30:00Z\", \"period\": \"PT30M\", \"power\": 1.25},\n", - " {\"period_end\": \"2026-09-28T01:00:00Z\", \"period\": \"PT30M\", \"power\": 1.4},\n", - "]\n", + "resource_id = \"your-premium-pv-site\"\n", + "measurements = pd.DataFrame(\n", + " [\n", + " {\n", + " \"period_end\": \"2026-09-28T00:30:00Z\",\n", + " \"period\": \"PT30M\",\n", + " \"power\": 1.25,\n", + " },\n", + " {\n", + " \"period_end\": \"2026-09-28T01:00:00Z\",\n", + " \"period\": \"PT30M\",\n", + " \"power\": 1.4,\n", + " },\n", + " ]\n", + ")\n", "\n", "# NOTE: this writes measurements to the site!\n", - "for i in range(0, len(measurements), 1000):\n", + "for start_index in range(0, len(measurements), 1000):\n", + " batch = measurements.iloc[start_index : start_index + 1000]\n", " res = pv_power_site_measurements.create_pv_site_measurements(\n", " resource_id=resource_id,\n", - " measurements=measurements[i : i + 1000],\n", + " measurements=batch,\n", " format=\"json\",\n", " )\n", " if not res.success:\n", @@ -87,13 +108,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "59e49691", "metadata": { "id": "59e49691", "language": "python" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'accepted': 2}\n" + ] + } + ], "source": [ "sub_unit_measurements = [\n", " {\n", @@ -112,7 +141,7 @@ "\n", "# NOTE: this writes sub-unit measurements to the site!\n", "for i in range(0, len(sub_unit_measurements), 1000):\n", - " res = pv_power_site_measurements.create_pv_sub_unit_site_measurements(\n", + " res = pv_power_site_measurements.create_pv_sub_unit_measurements(\n", " resource_id=resource_id,\n", " measurements=sub_unit_measurements[i : i + 1000],\n", " format=\"json\",\n", @@ -132,46 +161,108 @@ "source": [ "## Display Sub-Unit Measurements\n", "\n", - "Pass a `sub_unit` label to retrieve measurements for one inverter. The query uses the same `skip` and `take` pagination as site measurements." + "Pass a `sub_unit` label to fetch the first page of up to 100 measurements for one inverter. Change `skip` to request later pages. Use the next section to download all matching site measurements." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "dc123c73", "metadata": { "id": "dc123c73", "language": "python" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Showing 2 of 2 matching records.\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sub_unitpowerperiod
period_end
2026-09-28 00:30:00+00:00inverter-10.65PT30M
2026-09-28 01:00:00+00:00inverter-10.72PT30M
\n", + "
" + ], + "text/plain": [ + " sub_unit power period\n", + "period_end \n", + "2026-09-28 00:30:00+00:00 inverter-1 0.65 PT30M\n", + "2026-09-28 01:00:00+00:00 inverter-1 0.72 PT30M" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ - "sub_unit_results = []\n", - "sub_unit_skip = 0\n", - "sub_unit_take = 100\n", "sub_unit = \"inverter-1\"\n", + "sub_unit_take = 100\n", "\n", - "while True:\n", - " res = pv_power_site_measurements.get_pv_sub_unit_site_measurements(\n", - " resource_id=resource_id,\n", - " sub_unit=sub_unit,\n", - " start=\"2026-09-28T00:00:00Z\",\n", - " end=\"2026-09-29T00:00:00Z\",\n", - " skip=sub_unit_skip,\n", - " take=sub_unit_take,\n", - " )\n", - " if not res.success:\n", - " raise RuntimeError(res.exception)\n", - "\n", - " page = res.to_dict()\n", - " page_records = page[\"results\"]\n", - " sub_unit_results.extend(page_records)\n", - " sub_unit_skip = page[\"offset\"] + len(page_records)\n", - " if not page_records or sub_unit_skip >= page[\"total\"]:\n", - " break\n", + "res = pv_power_site_measurements.get_pv_sub_unit_measurements(\n", + " resource_id=resource_id,\n", + " sub_unit=sub_unit,\n", + " start=\"2026-09-28T00:00:00Z\",\n", + " end=\"2026-09-29T00:00:00Z\",\n", + " skip=0,\n", + " take=sub_unit_take,\n", + ")\n", + "if not res.success:\n", + " raise RuntimeError(res.exception)\n", "\n", - "if sub_unit_results:\n", - " sub_unit_df = pd.DataFrame(sub_unit_results).set_index(\"period_end\")\n", + "sub_unit_page = res.to_dict()\n", + "sub_unit_records = sub_unit_page[\"results\"]\n", + "if sub_unit_records:\n", + " sub_unit_df = pd.DataFrame(sub_unit_records).set_index(\"period_end\")\n", " sub_unit_df.index = pd.to_datetime(sub_unit_df.index)\n", + " print(\n", + " f\"Showing {len(sub_unit_records)} of {sub_unit_page['total']} matching records.\"\n", + " )\n", " display(sub_unit_df)\n", "else:\n", " print(f\"No measurements found for sub-unit: {sub_unit}\")" @@ -185,47 +276,105 @@ "language": "markdown" }, "source": [ - "Use the same pattern with `create_pv_sub_unit_site_measurements` for inverter-level data. Each sub-unit record must include a `sub_unit` label.\n", - "\n", "## Download All Pages\n", "\n", - "Measurements are returned in pages. Each response has `offset`, `total`, and `results` fields. Increase `skip` after each page until all records are collected." + "The previous cell shows up to 100 measurements for one inverter. This example downloads all matching site measurements, one page at a time, using the response's `offset` and `total` metadata." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "7769e683", "metadata": { "id": "7769e683", "language": "python" }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerperiod
period_end
2026-09-28 00:30:00+00:001.25PT30M
2026-09-28 01:00:00+00:001.40PT30M
\n", + "
" + ], + "text/plain": [ + " power period\n", + "period_end \n", + "2026-09-28 00:30:00+00:00 1.25 PT30M\n", + "2026-09-28 01:00:00+00:00 1.40 PT30M" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ - "results = []\n", - "skip, take = 0, 100\n", + "site_results = []\n", + "site_skip = 0\n", + "site_take = 100\n", "\n", "while True:\n", " res = pv_power_site_measurements.get_pv_site_measurements(\n", " resource_id=resource_id,\n", " start=\"2026-09-28T00:00:00Z\",\n", " end=\"2026-09-29T00:00:00Z\",\n", - " skip=skip,\n", - " take=take,\n", + " skip=site_skip,\n", + " take=site_take,\n", " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + "\n", " page = res.to_dict()\n", - " records = page[\"results\"]\n", - " results.extend(records)\n", - " skip = page[\"offset\"] + len(records)\n", - " if not records or skip >= page[\"total\"]:\n", + " page_records = page[\"results\"]\n", + " site_results.extend(page_records)\n", + " site_skip = page[\"offset\"] + len(page_records)\n", + " if not page_records or site_skip >= page[\"total\"]:\n", " break\n", "\n", - "if results:\n", - " df = pd.DataFrame(results).set_index(\"period_end\")\n", - " df.index = pd.to_datetime(df.index)\n", - " df[\"power\"].plot(figsize=[15, 4])\n", + "if site_results:\n", + " site_df = pd.DataFrame(site_results).set_index(\"period_end\")\n", + " site_df.index = pd.to_datetime(site_df.index)\n", + " display(site_df)\n", "else:\n", - " print(\"No PV measurements were returned for this time range.\")" + " print(\"No site measurements found for this period.\")" ] }, { @@ -238,7 +387,7 @@ "source": [ "## Delete Measurements\n", "\n", - "Deletion removes records in the selected time range. Review the resource ID and bounds before setting `confirm_delete` to `True`. For sub-unit data, use `delete_pv_sub_unit_site_measurements` and pass the `sub_unit` label." + "Deletion removes records in the selected time range. Review the resource ID and bounds before setting `confirm_delete` to `True`. For sub-unit data, use `delete_pv_sub_unit_measurements` and pass the `sub_unit` label." ] }, { @@ -249,7 +398,15 @@ "id": "45580803", "language": "python" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'start': '2026-09-28T00:00:00.0000000Z', 'end': '2026-09-28T02:00:00.0000000Z', 'deleted': 2}\n" + ] + } + ], "source": [ "confirm_delete = False\n", "if confirm_delete:\n", @@ -275,7 +432,7 @@ "source": [ "## Delete Sub-Unit Measurements\n", "\n", - "Set `sub_unit` and the required time range to delete only that inverter's records. Keep the confirmation flag false until you are ready to delete. For Wind, use `delete_wind_sub_unit_site_measurements`." + "Set `sub_unit` and the required time range to delete only that inverter's records. Keep the confirmation flag false until you are ready to delete." ] }, { @@ -286,11 +443,19 @@ "id": "294dd82a", "language": "python" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'start': '2026-09-28T00:00:00.0000000Z', 'end': '2026-09-28T02:00:00.0000000Z', 'deleted': 2}\n" + ] + } + ], "source": [ - "confirm_sub_unit_delete = True\n", + "confirm_sub_unit_delete = False\n", "if confirm_sub_unit_delete:\n", - " res = pv_power_site_measurements.delete_pv_sub_unit_site_measurements(\n", + " res = pv_power_site_measurements.delete_pv_sub_unit_measurements(\n", " resource_id=resource_id,\n", " sub_unit=sub_unit,\n", " start=\"2026-09-28T00:00:00Z\",\n", @@ -302,33 +467,11 @@ "else:\n", " print(\"Sub-unit deletion is disabled.\")" ] - }, - { - "cell_type": "markdown", - "id": "60b7ea70", - "metadata": { - "id": "60b7ea70", - "language": "markdown" - }, - "source": [ - "## Wind Sites\n", - "\n", - "The `wind_power_site_measurements` module uses the same workflow with a Premium Wind Power `resource_id`.\n", - "\n", - "* Upload site records with `create_wind_site_measurements`.\n", - "* Retrieve site records with `get_wind_site_measurements` and `skip`/`take`.\n", - "* Upload turbine records with `create_wind_sub_unit_site_measurements`; add a `sub_unit` label to each record.\n", - "* Retrieve turbine records with `get_wind_sub_unit_site_measurements` and pass `sub_unit`, `skip`, and `take`.\n", - "* Delete site records with `delete_wind_site_measurements` and required `start`/`end` bounds.\n", - "* Delete turbine records with `delete_wind_sub_unit_site_measurements`, `sub_unit`, and required `start`/`end` bounds.\n", - "\n", - "Wind measurements can also include `wind_speed_hub_height` and `wind_direction_hub_height`." - ] } ], "metadata": { "kernelspec": { - "display_name": ".venv-1 (3.12.3.final.0)", + "display_name": ".venv-1 (3.12.3)", "language": "python", "name": "python3" }, diff --git a/docs/pv_power_site_measurements.md b/docs/pv_power_site_measurements.md index cad8475..be6c713 100644 --- a/docs/pv_power_site_measurements.md +++ b/docs/pv_power_site_measurements.md @@ -2,16 +2,18 @@ The `pv_power_site_measurements` module manages measured power data for Premium PV Power sites. -All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. See the [Premium PV Power API documentation](https://docs.solcast.com.au/) for the complete request and response schemas. +All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. + +See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurements), [site retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurements), and [site deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurements). For sub-unit data, see [sub-unit upload](https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurementsSubUnits), [sub-unit retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurementsSubUnits), and [sub-unit deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurementsSubUnits). | Method | Purpose | |---|---| | `create_pv_site_measurements` | Submit site measurements. | | `get_pv_site_measurements` | Retrieve site measurements, with optional `start` and `end` filters. | | `delete_pv_site_measurements` | Delete site measurements between required `start` and `end` timestamps. | -| `create_pv_sub_unit_site_measurements` | Submit measurements for labelled site sub-units. | -| `get_pv_sub_unit_site_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | -| `delete_pv_sub_unit_site_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | +| `create_pv_sub_unit_measurements` | Submit measurements for labelled site sub-units. | +| `get_pv_sub_unit_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | +| `delete_pv_sub_unit_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | ## Create site measurements @@ -33,7 +35,7 @@ response = pv_power_site_measurements.create_pv_site_measurements( Create sub-unit measurements with a `sub_unit` value on every record: ```python -response = pv_power_site_measurements.create_pv_sub_unit_site_measurements( +response = pv_power_site_measurements.create_pv_sub_unit_measurements( resource_id="your-premium-pv-site", measurements=[ { @@ -64,7 +66,7 @@ for start_index in range(0, len(measurements), 1000): print(f"Accepted {response.to_dict()['accepted']} measurements") ``` -Use the same batching pattern with `create_pv_sub_unit_site_measurements`; include a `sub_unit` label in each record. +Use the same batching pattern with `create_pv_sub_unit_measurements`; include a `sub_unit` label in each record. Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: @@ -92,6 +94,6 @@ while True: break ``` -For sub-unit data, use `get_pv_sub_unit_site_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one inverter. +For sub-unit data, use `get_pv_sub_unit_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one inverter. All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/docs/wind_power_site_measurements.md b/docs/wind_power_site_measurements.md index 58bd5b1..3baf5e4 100644 --- a/docs/wind_power_site_measurements.md +++ b/docs/wind_power_site_measurements.md @@ -2,16 +2,18 @@ The `wind_power_site_measurements` module manages measured power data for Premium Wind Power sites. -All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. See the [Premium Wind Power API documentation](https://docs.solcast.com.au/) for the complete request and response schemas. +All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. + +See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurements), [site retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurements), and [site deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurements). For sub-unit data, see [sub-unit upload](https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurementsSubUnits), [sub-unit retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurementsSubUnits), and [sub-unit deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurementsSubUnits). | Method | Purpose | |---|---| | `create_wind_site_measurements` | Submit site measurements. | | `get_wind_site_measurements` | Retrieve site measurements, with optional `start` and `end` filters. | | `delete_wind_site_measurements` | Delete site measurements between required `start` and `end` timestamps. | -| `create_wind_sub_unit_site_measurements` | Submit measurements for labelled site sub-units. | -| `get_wind_sub_unit_site_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | -| `delete_wind_sub_unit_site_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | +| `create_wind_sub_unit_measurements` | Submit measurements for labelled site sub-units. | +| `get_wind_sub_unit_measurements` | Retrieve sub-unit measurements, optionally filtered by `sub_unit`, `start`, and `end`. | +| `delete_wind_sub_unit_measurements` | Delete sub-unit measurements between required `start` and `end` timestamps. | ## Create site measurements @@ -35,7 +37,7 @@ response = wind_power_site_measurements.create_wind_site_measurements( Create sub-unit measurements with a `sub_unit` value on every record: ```python -response = wind_power_site_measurements.create_wind_sub_unit_site_measurements( +response = wind_power_site_measurements.create_wind_sub_unit_measurements( resource_id="your-premium-wind-site", measurements=[ { @@ -67,7 +69,7 @@ for start_index in range(0, len(measurements), 1000): print(f"Accepted {response.to_dict()['accepted']} measurements") ``` -Use the same batching pattern with `create_wind_sub_unit_site_measurements`; include a `sub_unit` label in each record. +Use the same batching pattern with `create_wind_sub_unit_measurements`; include a `sub_unit` label in each record. Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: @@ -95,6 +97,6 @@ while True: break ``` -For sub-unit data, use `get_wind_sub_unit_site_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one turbine. +For sub-unit data, use `get_wind_sub_unit_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one turbine. All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. diff --git a/solcast/_site_measurements.py b/solcast/_site_measurements.py new file mode 100644 index 0000000..37e6114 --- /dev/null +++ b/solcast/_site_measurements.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Union, cast + +if TYPE_CHECKING: + import pandas as pd + +MeasurementInput = Union[List[Dict[str, Any]], "pd.DataFrame"] +MAX_MEASUREMENTS_PER_REQUEST = 1000 + + +def measurement_records(measurements: MeasurementInput) -> List[Dict[str, Any]]: + if isinstance(measurements, list): + records = measurements + else: + import pandas as pd + + if not isinstance(measurements, pd.DataFrame): + raise TypeError("measurements must be a list or pandas DataFrame") + + frame = measurements + if "period_end" not in frame.columns and frame.index.name == "period_end": + frame = frame.reset_index() + + records = cast(List[Dict[str, Any]], frame.to_dict(orient="records")) + for record in records: + for field_name, value in record.items(): + if pd.api.types.is_scalar(value): + if pd.isna(value): + record[field_name] = None + elif hasattr(value, "isoformat"): + record[field_name] = value.isoformat() + elif hasattr(value, "item"): + record[field_name] = value.item() + + if len(records) > MAX_MEASUREMENTS_PER_REQUEST: + raise ValueError( + f"measurements cannot contain more than " + f"{MAX_MEASUREMENTS_PER_REQUEST} records per request" + ) + + return records diff --git a/solcast/api.py b/solcast/api.py index 2b58cad..13dc5d3 100644 --- a/solcast/api.py +++ b/solcast/api.py @@ -79,10 +79,21 @@ def to_pandas(self): if self.code != 200: raise Exception(self.exception) - dfs = [ - pd.DataFrame.from_records(self.to_dict()[k]) for k in self.to_dict().keys() - ] - dfs = pd.concat(dfs) + response_data = self.to_dict() + if "results" in response_data: + records = response_data["results"] + else: + records = [ + record + for result_list in response_data.values() + for record in result_list + ] + + dfs = pd.DataFrame.from_records(records) + if dfs.empty: + dfs.index = pd.DatetimeIndex([], tz="UTC", name="period_end") + return dfs + dfs.index = pd.DatetimeIndex(dfs["period_end"]) # to make it work with different Pandas versions diff --git a/solcast/pv_power_site_measurements.py b/solcast/pv_power_site_measurements.py index 1591e3d..539e7c3 100644 --- a/solcast/pv_power_site_measurements.py +++ b/solcast/pv_power_site_measurements.py @@ -1,6 +1,5 @@ -from typing import Any, Dict, List - -from .api import Client, Response +from ._site_measurements import MeasurementInput, measurement_records +from .api import Client, PandafiableResponse, Response from .urls import ( base_url, pv_power_site_measurements, @@ -9,7 +8,7 @@ def create_pv_site_measurements( - resource_id: str, measurements: List[Dict[str, Any]], **kwargs + resource_id: str, measurements: MeasurementInput, **kwargs ) -> Response: """ Submit measurement data for a Premium PV Power site. Measurements are used for model @@ -23,7 +22,7 @@ def create_pv_site_measurements( measurements: Array of measurement records (1-1000). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurements. """ client = Client( base_url=base_url, @@ -31,16 +30,18 @@ def create_pv_site_measurements( response_type=Response, ) + records = measurement_records(measurements) + return client.post( {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, - "measurements": measurements, + "measurements": records, }, ) -def get_pv_site_measurements(resource_id: str, **kwargs) -> Response: +def get_pv_site_measurements(resource_id: str, **kwargs) -> PandafiableResponse: """ Retrieve historical measurement data for a Premium PV Power site. Supports pagination via skip/take and date filtering via start/end. @@ -49,12 +50,12 @@ def get_pv_site_measurements(resource_id: str, **kwargs) -> Response: resource_id: The unique identifier of the Premium PV Power resource. **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurements. """ client = Client( base_url=base_url, endpoint=pv_power_site_measurements, - response_type=Response, + response_type=PandafiableResponse, ) return client.get({"resource_id": resource_id, "format": "json", **kwargs}) @@ -72,7 +73,7 @@ def delete_pv_site_measurements( end: Delete upper bound timestamp (ISO-8601). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurements. """ client = Client( base_url=base_url, @@ -91,8 +92,8 @@ def delete_pv_site_measurements( ) -def create_pv_sub_unit_site_measurements( - resource_id: str, measurements: List[Dict[str, Any]], **kwargs +def create_pv_sub_unit_measurements( + resource_id: str, measurements: MeasurementInput, **kwargs ) -> Response: """ Submit sub-unit measurement data for a Premium PV Power site. A sub-unit represents @@ -105,7 +106,7 @@ def create_pv_sub_unit_site_measurements( measurements: List of sub-unit measurement records (1-1000). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, @@ -113,16 +114,18 @@ def create_pv_sub_unit_site_measurements( response_type=Response, ) + records = measurement_records(measurements) + return client.post( {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, - "measurements": measurements, + "measurements": records, }, ) -def get_pv_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: +def get_pv_sub_unit_measurements(resource_id: str, **kwargs) -> PandafiableResponse: """ Retrieve historical sub-unit measurement data for a Premium PV Power site. A sub-unit represents a subsection of the site, such as an individual inverter or a @@ -133,18 +136,18 @@ def get_pv_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: resource_id: The unique identifier of the Premium PV Power resource. **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, endpoint=pv_power_site_measurements_sub_units, - response_type=Response, + response_type=PandafiableResponse, ) return client.get({"resource_id": resource_id, "format": "json", **kwargs}) -def delete_pv_sub_unit_site_measurements( +def delete_pv_sub_unit_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ @@ -156,7 +159,7 @@ def delete_pv_sub_unit_site_measurements( end: Delete upper bound timestamp (ISO-8601). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, diff --git a/solcast/wind_power_site_measurements.py b/solcast/wind_power_site_measurements.py index 81908f2..7dc479f 100644 --- a/solcast/wind_power_site_measurements.py +++ b/solcast/wind_power_site_measurements.py @@ -1,6 +1,5 @@ -from typing import Any, Dict, List - -from .api import Client, Response +from ._site_measurements import MeasurementInput, measurement_records +from .api import Client, PandafiableResponse, Response from .urls import ( base_url, wind_power_site_measurements, @@ -9,7 +8,7 @@ def create_wind_site_measurements( - resource_id: str, measurements: List[Dict[str, Any]], **kwargs + resource_id: str, measurements: MeasurementInput, **kwargs ) -> Response: """ Submit measurement data for a Premium Wind Power site. Measurements are used for @@ -23,7 +22,7 @@ def create_wind_site_measurements( measurements: Array of measurement records (1-1000). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurements. """ client = Client( base_url=base_url, @@ -31,16 +30,18 @@ def create_wind_site_measurements( response_type=Response, ) + records = measurement_records(measurements) + return client.post( {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, - "measurements": measurements, + "measurements": records, }, ) -def get_wind_site_measurements(resource_id: str, **kwargs) -> Response: +def get_wind_site_measurements(resource_id: str, **kwargs) -> PandafiableResponse: """ Retrieve historical measurement data for a Premium Wind Power site. Supports pagination via skip/take and date filtering via start/end. @@ -49,12 +50,12 @@ def get_wind_site_measurements(resource_id: str, **kwargs) -> Response: resource_id: The unique identifier of the Premium Wind Power resource. **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurements. """ client = Client( base_url=base_url, endpoint=wind_power_site_measurements, - response_type=Response, + response_type=PandafiableResponse, ) return client.get({"resource_id": resource_id, "format": "json", **kwargs}) @@ -72,7 +73,7 @@ def delete_wind_site_measurements( end: Delete upper bound timestamp (ISO-8601). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurements. """ client = Client( base_url=base_url, @@ -91,8 +92,8 @@ def delete_wind_site_measurements( ) -def create_wind_sub_unit_site_measurements( - resource_id: str, measurements: List[Dict[str, Any]], **kwargs +def create_wind_sub_unit_measurements( + resource_id: str, measurements: MeasurementInput, **kwargs ) -> Response: """ Submit sub-unit measurement data for a Premium Wind Power site. A sub-unit @@ -105,7 +106,7 @@ def create_wind_sub_unit_site_measurements( measurements: List of sub-unit measurement records (1-1000). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, @@ -113,16 +114,18 @@ def create_wind_sub_unit_site_measurements( response_type=Response, ) + records = measurement_records(measurements) + return client.post( {"format": "json", **kwargs}, json_body={ "resource_id": resource_id, - "measurements": measurements, + "measurements": records, }, ) -def get_wind_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: +def get_wind_sub_unit_measurements(resource_id: str, **kwargs) -> PandafiableResponse: """ Retrieve historical sub-unit measurement data for a Premium Wind Power site. A sub-unit represents a subsection of the site, such as an individual wind turbine or @@ -133,18 +136,18 @@ def get_wind_sub_unit_site_measurements(resource_id: str, **kwargs) -> Response: resource_id: The unique identifier of the Premium Wind Power resource. **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, endpoint=wind_power_site_measurements_sub_units, - response_type=Response, + response_type=PandafiableResponse, ) return client.get({"resource_id": resource_id, "format": "json", **kwargs}) -def delete_wind_sub_unit_site_measurements( +def delete_wind_sub_unit_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ @@ -156,7 +159,7 @@ def delete_wind_sub_unit_site_measurements( end: Delete upper bound timestamp (ISO-8601). **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API - See https://docs.solcast.com.au/ for full list of parameters. + See https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurementsSubUnits. """ client = Client( base_url=base_url, diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py index 1ae5eb1..5e5cb97 100644 --- a/tests/test_site_measurements.py +++ b/tests/test_site_measurements.py @@ -2,12 +2,17 @@ from unittest.mock import patch from urllib.parse import parse_qs, urlsplit +import pandas as pd + from solcast import pv_power_site_measurements, wind_power_site_measurements class FakeResponse: code = 200 + def __init__(self, body=b'{"accepted": 1}'): + self.body = body + def __enter__(self): return self @@ -15,7 +20,7 @@ def __exit__(self, *args): return False def read(self): - return b'{"accepted": 1}' + return self.body def capture_request(mock_urlopen): @@ -52,9 +57,88 @@ def test_create_pv_site_measurements_sends_json_body(): } -def test_get_pv_sub_unit_site_measurements_sends_filters_as_query(): +def test_create_pv_site_measurements_accepts_dataframe(): + measurements = pd.DataFrame( + { + "period_end": [pd.Timestamp("2026-01-01T00:30:00Z")], + "period": ["PT30M"], + "power": [1.25], + } + ) + + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_site_measurements.create_pv_site_measurements( + resource_id="pv-123", + measurements=measurements, + api_key="test-key", + ) + + request, _ = capture_request(mock_urlopen) + assert response.to_dict()["accepted"] == 1 + assert json.loads(request.data) == { + "resource_id": "pv-123", + "measurements": [ + { + "period_end": "2026-01-01T00:30:00+00:00", + "period": "PT30M", + "power": 1.25, + } + ], + } + + +def test_get_pv_site_measurements_pandas_preserves_page_metadata(): + response_body = { + "offset": 10, + "total": 12, + "results": [ + { + "period_end": "2026-01-01T00:30:00+00:00", + "period": "PT30M", + "power": 1.25, + }, + { + "period_end": "2026-01-01T01:00:00+00:00", + "period": "PT30M", + "power": 1.4, + }, + ], + } + with patch( + "solcast.api.urlopen", + return_value=FakeResponse(json.dumps(response_body).encode("utf-8")), + ): + response = pv_power_site_measurements.get_pv_site_measurements( + resource_id="pv-123", + skip=10, + take=2, + api_key="test-key", + ) + + frame = response.to_pandas() + assert frame["power"].tolist() == [1.25, 1.4] + assert frame.index.tz is not None + assert response.to_dict()["offset"] == 10 + assert response.to_dict()["total"] == 12 + + +def test_get_pv_site_measurements_pandas_handles_empty_page(): + response_body = {"offset": 0, "total": 0, "results": []} + with patch( + "solcast.api.urlopen", + return_value=FakeResponse(json.dumps(response_body).encode("utf-8")), + ): + response = pv_power_site_measurements.get_pv_site_measurements( + resource_id="pv-123", + api_key="test-key", + ) + + assert response.to_pandas().empty + + +def test_get_pv_sub_unit_measurements_sends_filters_as_query(): with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = pv_power_site_measurements.get_pv_sub_unit_site_measurements( + response = pv_power_site_measurements.get_pv_sub_unit_measurements( resource_id="pv-123", sub_unit="inverter-1", start="2026-01-01T00:00:00Z", @@ -100,7 +184,7 @@ def test_delete_wind_site_measurements_sends_required_time_bounds(): } -def test_create_wind_sub_unit_site_measurements_sends_json_body(): +def test_create_wind_sub_unit_measurements_sends_json_body(): measurements = [ { "sub_unit": "turbine-1", @@ -112,7 +196,7 @@ def test_create_wind_sub_unit_site_measurements_sends_json_body(): ] with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = wind_power_site_measurements.create_wind_sub_unit_site_measurements( + response = wind_power_site_measurements.create_wind_sub_unit_measurements( resource_id="wind-123", measurements=measurements, api_key="test-key", @@ -135,17 +219,17 @@ def test_measurement_modules_expose_all_site_and_sub_unit_operations(): "create_pv_site_measurements", "get_pv_site_measurements", "delete_pv_site_measurements", - "create_pv_sub_unit_site_measurements", - "get_pv_sub_unit_site_measurements", - "delete_pv_sub_unit_site_measurements", + "create_pv_sub_unit_measurements", + "get_pv_sub_unit_measurements", + "delete_pv_sub_unit_measurements", } expected_wind_names = { "create_wind_site_measurements", "get_wind_site_measurements", "delete_wind_site_measurements", - "create_wind_sub_unit_site_measurements", - "get_wind_sub_unit_site_measurements", - "delete_wind_sub_unit_site_measurements", + "create_wind_sub_unit_measurements", + "get_wind_sub_unit_measurements", + "delete_wind_sub_unit_measurements", } assert expected_pv_names.issubset(vars(pv_power_site_measurements)) From a4f00fba0c3984bef110669978f2890d82e916e4 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Thu, 1 Oct 2026 15:41:12 +1000 Subject: [PATCH 5/5] Prepare 1.6.0 release with measurement docs and configurable API base --- CHANGELOG.md | 6 ++ ...ing - HSU (Live, Forecast, Historic).ipynb | 21 +++--- ... - Kimber (Live, Forecast, Historic).ipynb | 11 ++-- ...4.2 Premium PV and Wind Measurements.ipynb | 33 +++++----- docs/pv_power_site_measurements.md | 46 +++++++++---- docs/wind_power_site_measurements.md | 45 +++++++++---- solcast/__init__.py | 2 +- solcast/urls.py | 27 +++++++- tests/test_client.py | 66 +++++++++++++++++++ 9 files changed, 191 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c29074d..5599ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.6.0] - 2026-09-30 + +- Add Premium PV and Wind site measurement endpoints for site and sub-unit uploads, retrieval, and deletion. +- Support pandas DataFrame uploads and paginated pandas responses for measurement data. +- Add PV and Wind measurement documentation and notebook examples. + ## [1.5.0] - 2026-09-01 - Add `premium_pv_power` and `premium_wind_power` forecast endpoints for premium PV and wind power forecasts. diff --git a/docs/notebooks/1.4 Dust Soiling - HSU (Live, Forecast, Historic).ipynb b/docs/notebooks/1.4 Dust Soiling - HSU (Live, Forecast, Historic).ipynb index 2d34158..758c2af 100755 --- a/docs/notebooks/1.4 Dust Soiling - HSU (Live, Forecast, Historic).ipynb +++ b/docs/notebooks/1.4 Dust Soiling - HSU (Live, Forecast, Historic).ipynb @@ -38,7 +38,8 @@ "### Dependencies\n", "- Solcast API key with access to soiling endpoints.\n", "- Python with `solcast`, `pandas`, `matplotlib` installed.\n", - "- Set environment variable `SOLCAST_API_KEY`." + "- Set environment variable `SOLCAST_API_KEY`.\n", + "- Optional: set `SOLCAST_API_BASE_URL` before importing the SDK to use another exact API base. If unset, the SDK uses production." ] }, { @@ -68,13 +69,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "79106465", "metadata": {}, "outputs": [], "source": [ - "API_BASE = os.environ.get(\"SOLCAST_API_BASE\", \"https://api.solcast.com.au\")\n", - "\n", "API_KEY = os.environ.get(\"SOLCAST_API_KEY\", \"\")\n", "\n", "# Using unmetered location to avoid API key usage\n", @@ -161,7 +160,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "407cc217", "metadata": {}, "outputs": [ @@ -195,7 +194,7 @@ "}\n", "print(live_params)\n", "\n", - "live_resp = solcast_live.soiling_hsu(base_url=API_BASE, api_key=API_KEY, **live_params)\n", + "live_resp = solcast_live.soiling_hsu(api_key=API_KEY, **live_params)\n", "live_resp" ] }, @@ -349,7 +348,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "48b93466", "metadata": {}, "outputs": [ @@ -383,7 +382,7 @@ "}\n", "print(fc_params)\n", "\n", - "fc_resp = solcast_forecast.soiling_hsu(base_url=API_BASE, api_key=API_KEY, **fc_params)\n", + "fc_resp = solcast_forecast.soiling_hsu(api_key=API_KEY, **fc_params)\n", "fc_resp" ] }, @@ -537,7 +536,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "dd37e442", "metadata": {}, "outputs": [ @@ -574,9 +573,9 @@ " \"cleaning_threshold\": 2.0,\n", "}\n", "print(hist_params)\n", - "hist_resp = solcast_historic.radiation_and_weather(base_url=API_BASE, api_key=API_KEY, **hist_params)\n", + "hist_resp = solcast_historic.radiation_and_weather(api_key=API_KEY, **hist_params)\n", "print(soiling_params)\n", - "soiling_resp = solcast_historic.soiling_hsu(base_url=API_BASE, api_key=API_KEY, **soiling_params)\n", + "soiling_resp = solcast_historic.soiling_hsu(api_key=API_KEY, **soiling_params)\n", "soiling_resp" ] }, diff --git a/docs/notebooks/1.5 Dust Soiling - Kimber (Live, Forecast, Historic).ipynb b/docs/notebooks/1.5 Dust Soiling - Kimber (Live, Forecast, Historic).ipynb index a81929f..cbdc646 100755 --- a/docs/notebooks/1.5 Dust Soiling - Kimber (Live, Forecast, Historic).ipynb +++ b/docs/notebooks/1.5 Dust Soiling - Kimber (Live, Forecast, Historic).ipynb @@ -60,22 +60,19 @@ "metadata": {}, "source": [ "### Configurations\n", - "Set API base and key. Default base: https://dev-api.solcast.com.au" + "Set `SOLCAST_API_KEY` for authentication. The API base defaults to `https://api.solcast.com.au`; set `SOLCAST_API_BASE_URL` to the exact base URL for another environment before importing the SDK." ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "0b97adfa", "metadata": {}, "outputs": [], "source": [ - "API_BASE = os.environ.get(\"SOLCAST_API_BASE\", \"https://api.solcast.com.au\")\n", + "API_BASE = os.environ.get(\"SOLCAST_API_BASE_URL\", \"https://api.solcast.com.au\")\n", "\n", - "API_KEY = os.environ.get(\"SOLCAST_API_KEY\", \"\")\n", - "\n", - "# Using unmetered location metadata to keep examples consistent without sharing site details\n", - "sydney = UNMETERED_LOCATIONS[\"Sydney Opera House\"]" + "API_KEY = os.environ.get(\"SOLCAST_API_KEY\", \"\")" ] }, { diff --git a/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb index 5dd6ec6..2f2196b 100644 --- a/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb +++ b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb @@ -276,9 +276,9 @@ "language": "markdown" }, "source": [ - "## Download All Pages\n", + "## Download All Pages of Site Measurements\n", "\n", - "The previous cell shows up to 100 measurements for one inverter. This example downloads all matching site measurements, one page at a time, using the response's `offset` and `total` metadata." + "Use `to_pandas()` to convert each response page to a DataFrame. Use `to_dict()` to read the `offset` and `total` metadata needed to request all matching site measurements." ] }, { @@ -312,34 +312,30 @@ " \n", " \n", " power\n", - " period\n", " \n", " \n", " period_end\n", " \n", - " \n", " \n", " \n", " \n", " \n", " 2026-09-28 00:30:00+00:00\n", " 1.25\n", - " PT30M\n", " \n", " \n", " 2026-09-28 01:00:00+00:00\n", " 1.40\n", - " PT30M\n", " \n", " \n", "\n", "" ], "text/plain": [ - " power period\n", - "period_end \n", - "2026-09-28 00:30:00+00:00 1.25 PT30M\n", - "2026-09-28 01:00:00+00:00 1.40 PT30M" + " power\n", + "period_end \n", + "2026-09-28 00:30:00+00:00 1.25\n", + "2026-09-28 01:00:00+00:00 1.40" ] }, "metadata": {}, @@ -347,7 +343,7 @@ } ], "source": [ - "site_results = []\n", + "site_pages = []\n", "site_skip = 0\n", "site_take = 100\n", "\n", @@ -363,15 +359,16 @@ " raise RuntimeError(res.exception)\n", "\n", " page = res.to_dict()\n", - " page_records = page[\"results\"]\n", - " site_results.extend(page_records)\n", - " site_skip = page[\"offset\"] + len(page_records)\n", - " if not page_records or site_skip >= page[\"total\"]:\n", + " page_frame = res.to_pandas()\n", + " if not page_frame.empty:\n", + " site_pages.append(page_frame)\n", + "\n", + " site_skip = page[\"offset\"] + len(page_frame)\n", + " if page_frame.empty or site_skip >= page[\"total\"]:\n", " break\n", "\n", - "if site_results:\n", - " site_df = pd.DataFrame(site_results).set_index(\"period_end\")\n", - " site_df.index = pd.to_datetime(site_df.index)\n", + "if site_pages:\n", + " site_df = pd.concat(site_pages)\n", " display(site_df)\n", "else:\n", " print(\"No site measurements found for this period.\")" diff --git a/docs/pv_power_site_measurements.md b/docs/pv_power_site_measurements.md index be6c713..cafa6a3 100644 --- a/docs/pv_power_site_measurements.md +++ b/docs/pv_power_site_measurements.md @@ -4,6 +4,8 @@ The `pv_power_site_measurements` module manages measured power data for Premium All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. +`pandas` is optional. DataFrame uploads and `to_pandas()` need it. Install it with `pip install pandas` or `pip install 'solcast[all]'`; list uploads and `to_dict()` do not need pandas. + See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurements), [site retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurements), and [site deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurements). For sub-unit data, see [sub-unit upload](https://docs.solcast.com.au/docs/section/premium#postResourcesPvPowerSiteMeasurementsSubUnits), [sub-unit retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesPvPowerSiteMeasurementsSubUnits), and [sub-unit deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesPvPowerSiteMeasurementsSubUnits). | Method | Purpose | @@ -18,17 +20,23 @@ See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/s ## Create site measurements ```python +import pandas as pd + from solcast import pv_power_site_measurements -response = pv_power_site_measurements.create_pv_site_measurements( - resource_id="your-premium-pv-site", - measurements=[ +measurements = pd.DataFrame( + [ { "period_end": "2026-01-01T00:30:00Z", "period": "PT30M", "power": 1.25, } - ], + ] +) + +response = pv_power_site_measurements.create_pv_site_measurements( + resource_id="your-premium-pv-site", + measurements=measurements, ) ``` @@ -50,12 +58,12 @@ response = pv_power_site_measurements.create_pv_sub_unit_measurements( ## Upload and download large datasets -Each create request accepts up to 1,000 measurements. Split larger lists into batches and check each response: +Each create request accepts up to 1,000 measurements. Split a DataFrame into batches with `.iloc` and check each response: ```python # measurements is the complete list of records to upload. for start_index in range(0, len(measurements), 1000): - batch = measurements[start_index : start_index + 1000] + batch = measurements.iloc[start_index : start_index + 1000] response = pv_power_site_measurements.create_pv_site_measurements( resource_id="your-premium-pv-site", measurements=batch, @@ -66,12 +74,14 @@ for start_index in range(0, len(measurements), 1000): print(f"Accepted {response.to_dict()['accepted']} measurements") ``` -Use the same batching pattern with `create_pv_sub_unit_measurements`; include a `sub_unit` label in each record. +For a list of sub-unit records, use ordinary list slicing instead of `.iloc`. Include a `sub_unit` label in each record and use `create_pv_sub_unit_measurements`. -Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: +Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. Use `to_dict()` for pagination metadata and `to_pandas()` for each page: ```python -all_measurements = [] +import pandas as pd + +measurement_pages = [] skip = 0 take = 100 @@ -87,13 +97,21 @@ while True: raise RuntimeError(response.exception) page = response.to_dict() - records = page["results"] - all_measurements.extend(records) - skip = page["offset"] + len(records) - if not records or skip >= page["total"]: + page_frame = response.to_pandas() + if not page_frame.empty: + measurement_pages.append(page_frame) + + skip = page["offset"] + len(page_frame) + if page_frame.empty or skip >= page["total"]: break + +if measurement_pages: + all_measurements = pd.concat(measurement_pages) + display(all_measurements) +else: + print("No measurements found.") ``` For sub-unit data, use `get_pv_sub_unit_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one inverter. -All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. +All methods return the SDK `Response` object. GET responses also support `to_pandas()` for the current page of measurement data. diff --git a/docs/wind_power_site_measurements.md b/docs/wind_power_site_measurements.md index 3baf5e4..236636a 100644 --- a/docs/wind_power_site_measurements.md +++ b/docs/wind_power_site_measurements.md @@ -4,6 +4,8 @@ The `wind_power_site_measurements` module manages measured power data for Premiu All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. +`pandas` is optional. DataFrame uploads and `to_pandas()` need it. Install it with `pip install pandas` or `pip install 'solcast[all]'`; list uploads and `to_dict()` do not need pandas. + See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurements), [site retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurements), and [site deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurements). For sub-unit data, see [sub-unit upload](https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSiteMeasurementsSubUnits), [sub-unit retrieval](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSiteMeasurementsSubUnits), and [sub-unit deletion](https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSiteMeasurementsSubUnits). | Method | Purpose | @@ -18,11 +20,11 @@ See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/s ## Create site measurements ```python +import pandas as pd from solcast import wind_power_site_measurements -response = wind_power_site_measurements.create_wind_site_measurements( - resource_id="your-premium-wind-site", - measurements=[ +measurements = pd.DataFrame( + [ { "period_end": "2026-01-01T00:30:00Z", "period": "PT30M", @@ -30,7 +32,12 @@ response = wind_power_site_measurements.create_wind_site_measurements( "wind_speed_hub_height": 8.2, "wind_direction_hub_height": 180, } - ], + ] +) + +response = wind_power_site_measurements.create_wind_site_measurements( + resource_id="your-premium-wind-site", + measurements=measurements, ) ``` @@ -53,12 +60,12 @@ response = wind_power_site_measurements.create_wind_sub_unit_measurements( ## Upload and download large datasets -Each create request accepts up to 1,000 measurements. Split larger lists into batches and check each response: +Each create request accepts up to 1,000 measurements. Split a DataFrame into batches with `.iloc` and check each response: ```python # measurements is the complete list of records to upload. for start_index in range(0, len(measurements), 1000): - batch = measurements[start_index : start_index + 1000] + batch = measurements.iloc[start_index : start_index + 1000] response = wind_power_site_measurements.create_wind_site_measurements( resource_id="your-premium-wind-site", measurements=batch, @@ -69,12 +76,14 @@ for start_index in range(0, len(measurements), 1000): print(f"Accepted {response.to_dict()['accepted']} measurements") ``` -Use the same batching pattern with `create_wind_sub_unit_measurements`; include a `sub_unit` label in each record. +For a list of sub-unit records, use ordinary list slicing instead of `.iloc`. Include a `sub_unit` label in each record and use `create_wind_sub_unit_measurements`. -Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. The response has `offset`, `total`, and `results` fields: +Retrieve all matching records with `skip` and `take`. This example requests 100 records per page. Use `to_dict()` for pagination metadata and `to_pandas()` for each page: ```python -all_measurements = [] +import pandas as pd + +measurement_pages = [] skip = 0 take = 100 @@ -90,13 +99,21 @@ while True: raise RuntimeError(response.exception) page = response.to_dict() - records = page["results"] - all_measurements.extend(records) - skip = page["offset"] + len(records) - if not records or skip >= page["total"]: + page_frame = response.to_pandas() + if not page_frame.empty: + measurement_pages.append(page_frame) + + skip = page["offset"] + len(page_frame) + if page_frame.empty or skip >= page["total"]: break + +if measurement_pages: + all_measurements = pd.concat(measurement_pages) + display(all_measurements) +else: + print("No measurements found.") ``` For sub-unit data, use `get_wind_sub_unit_measurements` with the same `skip` and `take` parameters. Add `sub_unit` to filter to one turbine. -All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. +All methods return the SDK `Response` object. GET responses also support `to_pandas()` for the current page of measurement data. diff --git a/solcast/__init__.py b/solcast/__init__.py index eaf50f4..31c5ed6 100644 --- a/solcast/__init__.py +++ b/solcast/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.5.0" +__version__ = "1.6.0" from . import ( aggregations, diff --git a/solcast/urls.py b/solcast/urls.py index 4e70f30..8480e6d 100644 --- a/solcast/urls.py +++ b/solcast/urls.py @@ -1,4 +1,29 @@ -base_url = "https://api.solcast.com.au" +import os +from urllib.parse import urlsplit + + +def _get_base_url() -> str: + configured_url = os.getenv( + "SOLCAST_API_BASE_URL", "https://api.solcast.com.au" + ).rstrip("/") + parsed_url = urlsplit(configured_url) + hostname = parsed_url.hostname + if ( + parsed_url.scheme not in {"http", "https"} + or hostname is None + or "*" in hostname + or parsed_url.query + or parsed_url.fragment + ): + raise ValueError( + "SOLCAST_API_BASE_URL must be an absolute HTTP(S) URL without a " + "wildcard hostname, query, or fragment." + ) + + return configured_url + + +base_url = _get_base_url() live_radiation_and_weather = "data/live/radiation_and_weather" live_rooftop_pv_power = "data/live/rooftop_pv_power" live_advanced_pv_power = "data/live/advanced_pv_power" diff --git a/tests/test_client.py b/tests/test_client.py index f07f195..2385e01 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,12 @@ +import subprocess +import sys +from pathlib import Path + import pytest from solcast.api import Client, PandafiableResponse, Response from solcast.urls import ( + _get_base_url, base_url, historic_radiation_and_weather, live_radiation_and_weather, @@ -13,6 +18,67 @@ def mock_short_key(monkeypatch): monkeypatch.setenv("SOLCAST_API_KEY", "") +@pytest.mark.parametrize( + ("configured_url", "expected_url"), + [ + ("https://example.test/", "https://example.test"), + ("http://localhost:8080/", "http://localhost:8080"), + ], +) +def test_base_url_normalizes_configured_url(monkeypatch, configured_url, expected_url): + monkeypatch.setenv("SOLCAST_API_BASE_URL", configured_url) + + assert _get_base_url() == expected_url + + +@pytest.mark.parametrize( + "configured_url", + [ + "https://*.example.test", + "ftp://example.test", + "https:///path", + "https://example.test?environment=dev", + "https://example.test#fragment", + ], +) +def test_base_url_rejects_invalid_url(monkeypatch, configured_url): + monkeypatch.setenv("SOLCAST_API_BASE_URL", configured_url) + + with pytest.raises(ValueError, match="absolute HTTP"): + _get_base_url() + + +def test_base_url_defaults_to_production(monkeypatch): + monkeypatch.delenv("SOLCAST_API_BASE_URL", raising=False) + + assert _get_base_url() == "https://api.solcast.com.au" + + +def test_configured_base_url_is_shared_by_endpoint_modules(monkeypatch): + monkeypatch.setenv("SOLCAST_API_BASE_URL", "https://example.test") + code = """ +import solcast + +modules = ( + solcast.aggregations, + solcast.forecast, + solcast.historic, + solcast.live, + solcast.pv_power_sites, + solcast.pv_power_site_measurements, + solcast.wind_power_site_measurements, + solcast.tmy, +) +assert all(module.base_url == "https://example.test" for module in modules) +""" + + subprocess.run( + [sys.executable, "-c", code], + check=True, + cwd=Path(__file__).resolve().parents[1], + ) + + def test_fail_short_key(mock_short_key): with pytest.raises(ValueError, match="API key is too short."): cls = Client(