From a94c8142375605c77e88c60696f0b0d12c0ce22f Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 20 Oct 2026 00:05:32 +0000 Subject: [PATCH 1/2] feat: add missing Python SDK endpoints Add TMY Advanced PV, utility-scale measurements, schedules, and Premium Wind site resource methods. Update SDK docs and tests, add JSON-body support to PATCH and PUT, and pin Black in CI. --- .github/workflows/black.yml | 8 +- docs/forecast.md | 4 +- docs/pv_power_sites.md | 20 +- docs/schedules.md | 51 ++++ docs/tmy.md | 14 +- docs/wind_power_sites.md | 71 +++++ mkdocs.yml | 2 + solcast/__init__.py | 4 + solcast/api.py | 14 +- solcast/pv_power_site_measurements.py | 6 +- solcast/pv_power_sites.py | 25 +- solcast/schedules.py | 122 +++++++++ solcast/tmy.py | 18 +- solcast/urls.py | 5 + solcast/wind_power_site_measurements.py | 6 +- solcast/wind_power_sites.py | 249 ++++++++++++++++++ ...st_advanced_site_and_schedule_endpoints.py | 183 +++++++++++++ tests/test_forecast_docs.py | 52 ++++ tests/test_site_measurements.py | 1 - tests/test_wind_power_sites.py | 172 ++++++++++++ 20 files changed, 1005 insertions(+), 22 deletions(-) create mode 100644 docs/schedules.md create mode 100644 docs/wind_power_sites.md create mode 100644 solcast/schedules.py create mode 100644 solcast/wind_power_sites.py create mode 100644 tests/test_advanced_site_and_schedule_endpoints.py create mode 100644 tests/test_forecast_docs.py create mode 100644 tests/test_wind_power_sites.py diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index ead2a65..0d5857a 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -7,8 +7,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: psf/black@stable + - uses: actions/setup-python@v5 with: - options: "--check --verbose" - src: "./solcast" - jupyter: false \ No newline at end of file + python-version: "3.12" + - run: python -m pip install black==24.8.0 + - run: black --check --verbose ./solcast \ No newline at end of file diff --git a/docs/forecast.md b/docs/forecast.md index c163375..3bc8f59 100644 --- a/docs/forecast.md +++ b/docs/forecast.md @@ -4,7 +4,7 @@ requested locations or resources, including premium PV and wind power forecasts, nowcasted for approx. four hours ahead) and numerical weather models (other data and longer horizons). More information in the [API docs](https://docs.solcast.com.au/#49090b36-66db-4d0f-89d5-87d19f00bec1). -The module `forecast` has 5 available methods: +The module `forecast` has 7 available methods: | Endpoint | API Docs | |-------------------------|-------------------------------------------------------------------------------------------| @@ -13,6 +13,8 @@ The module `forecast` has 5 available methods: | `advanced_pv_power` | [details](https://docs.solcast.com.au/?#0c9d3ccf-e2a4-4583-86a3-f89c8d658fde){.md-button} | | `premium_pv_power` | [details](https://docs.solcast.com.au/docs/section/premium#getDataForecastPremiumPvPower){.md-button} | | `premium_wind_power` | [details](https://docs.solcast.com.au/docs/section/premium#getDataForecastPremiumWindPower){.md-button} | +| `soiling_hsu` | [details](https://docs.solcast.com.au/docs/section/soiling#getDataForecastSoilingHsu){.md-button} | +| `soiling_kimber` | [details](https://docs.solcast.com.au/docs/section/soiling#getDataForecastSoilingKimber){.md-button} | ### Example diff --git a/docs/pv_power_sites.md b/docs/pv_power_sites.md index 202a193..44c01a9 100644 --- a/docs/pv_power_sites.md +++ b/docs/pv_power_sites.md @@ -1,13 +1,14 @@ # PV Power Sites Allows management of detailed PV power site metadata used by the `advanced_pv_power` functions. More information is in the [API docs](https://docs.solcast.com.au/#49090b36-66db-4d0f-89d5-87d19f00bec1). -The module `pv_power_sites` has 6 functions available. Use the `res.to_dict()` method to see the site metadata. +The module `pv_power_sites` has 7 functions available. Use the `res.to_dict()` method to see the site metadata. |
Endpoint
| Purpose | API Docs | |------|------|------| | `list_pv_power_sites` | List available PV power sites. | [details](https://docs.solcast.com.au/#baee4c8b-83e8-43e6-886b-98671164df10){.md-button} | | `get_pv_power_site` | Get an existing PV power site's specifications. | [details](https://docs.solcast.com.au/#27a18021-eed0-4281-8b28-9bdf1ebb2a95){.md-button} | | `create_pv_power_site` | Create PV power site to be used with Solcast's advanced PV power model. | [details](https://docs.solcast.com.au/#d3a35494-15d8-4baa-b96f-7fd3353c9f06){.md-button} | +| `create_utility_scale_site_measurements` | Submit measured power data for an Advanced PV site. | [details](https://docs.solcast.com.au/docs/section/advanced-pv-power#postUtilityScaleSitesByResourceIdMeasurements){.md-button} | | `patch_pv_power_site` | Patch an existing PV power site to partially update the site's specifications. | [details](https://docs.solcast.com.au/#ba412164-31c2-47a9-a965-c95bc9b632a6){.md-button} | | `update_pv_power_site` | Overwrite an existing PV power site's specifications. | [details](https://docs.solcast.com.au/#181cf2be-f710-49c3-8050-07be858f25e1){.md-button} | | `delete_pv_power_site` | Delete an existing PV power site. | [details](https://docs.solcast.com.au/#c2353692-36db-46b8-8508-a6d4fae65390){.md-button} | @@ -22,6 +23,23 @@ res = pv_power_sites.get_pv_power_site('ba75-e17a-7374-95ed') res.to_dict() ``` + +Submit a batch of measured power records for an Advanced PV site: + +```python +response = pv_power_sites.create_utility_scale_site_measurements( + resource_id="your-advanced-pv-resource", + measurements=[ + { + "period_end": "2026-01-01T00:05:00Z", + "period": "PT5M", + "total_power": 1.25, + } + ], +) + +response.to_dict() +``` ``` { 'resource_id': 'ba75-e17a-7374-95ed', diff --git a/docs/schedules.md b/docs/schedules.md new file mode 100644 index 0000000..8422724 --- /dev/null +++ b/docs/schedules.md @@ -0,0 +1,51 @@ +# Schedules + +Retrieve and update availability or curtailment schedules for Premium PV and Wind resources. + +| Method | Purpose | API Docs | +|---|---|---| +| `get_resource_schedule` | Retrieve schedule intervals for a time window. | [details](https://docs.solcast.com.au/docs/section/premium#getManageSchedules) | +| `patch_resource_schedule` | Merge availability and/or curtailment intervals into the schedule. | [details](https://docs.solcast.com.au/docs/section/premium#patchManageSchedules) | +| `delete_resource_schedule` | Remove schedule data for a time window and schedule type(s). | [details](https://docs.solcast.com.au/docs/section/premium#deleteManageSchedules) | + +## Retrieve a schedule + +```python +from solcast import schedules + +response = schedules.get_resource_schedule( + resource_id="your-premium-site", + period_start="2026-01-01T00:00:00Z", + period_end="2026-01-02T00:00:00Z", +) + +data = response.to_dict() +``` + +## Patch schedule intervals + +```python +response = schedules.patch_resource_schedule( + resource_id="your-premium-site", + availability_schedule=[ + { + "period_start": "2026-01-01T00:00:00Z", + "period_end": "2026-01-01T01:00:00Z", + "availability": 75, + } + ], +) +``` + +## Delete schedule data + +```python +response = schedules.delete_resource_schedule( + resource_id="your-premium-site", + period_start="2026-01-01T00:00:00Z", + period_end="2026-01-02T00:00:00Z", + schedule_type="availability,curtailment", +) +``` + +All methods return the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. \ No newline at end of file diff --git a/docs/tmy.md b/docs/tmy.md index 4e19607..5838cd4 100644 --- a/docs/tmy.md +++ b/docs/tmy.md @@ -4,12 +4,13 @@ TMY (Typical Meteorological Year) is a collation of historical weather data for The dataset is derived from a multi-year time series specifically selected so that it presents the unique weather phenomena for the location, and provides annual averages that are consistent with long term averages. See the [API docs](https://docs.solcast.com.au/#2144b738-a1f7-4647-9067-6e7f6eca32a8) -The `tmy` module has 2 methods: +The `tmy` module has 3 methods: | Endpoint | API Docs | |-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| | `radiation_and_weather` | [details](https://docs.solcast.com.au/?#e75039fa-86ec-43e4-99a3-389d3d7a4a99){.md-button} | | `rooftop_pv_power` | [details](https://docs.solcast.com.au/?#d117fde9-e410-4df0-bd07-e6168efd23cf){.md-button} | +| `advanced_pv_power` | [details](https://docs.solcast.com.au/docs/section/advanced-pv-power#getDataTmyAdvancedPvPower){.md-button} | ### Example @@ -25,6 +26,17 @@ res = tmy.rooftop_pv_power( res.to_pandas().head() ``` +Retrieve a Typical Meteorological Year for a registered Advanced PV site: + +```python +res = tmy.advanced_pv_power( + resource_id="your-advanced-pv-resource", + output_parameters=["pv_power_advanced"], +) + +res.to_pandas().head() +``` + | period_end | pv_power_rooftop | |:--------------------------|-------------------:| | 2059-01-01 01:00:00+00:00 | 1.461 | diff --git a/docs/wind_power_sites.md b/docs/wind_power_sites.md new file mode 100644 index 0000000..5bbfb47 --- /dev/null +++ b/docs/wind_power_sites.md @@ -0,0 +1,71 @@ +# Wind Power Sites + +Manage Premium Wind power site resources. + +| Method | Purpose | API Docs | +|---|---|---| +| `list_wind_power_sites` | List accessible sites, with optional search and filters. | [details](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSites) | +| `get_wind_power_site` | Retrieve a site by resource ID. | [details](https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSite) | +| `create_wind_power_site` | Create a site. Name, coordinates, and capacity are required. | [details](https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSite) | +| `patch_wind_power_site` | Partially update fields supplied in the request. | [details](https://docs.solcast.com.au/docs/section/premium#patchResourcesWindPowerSite) | +| `update_wind_power_site` | Replace a site definition. Resource ID, name, coordinates, and capacity are required. | [details](https://docs.solcast.com.au/docs/section/premium#putResourcesWindPowerSite) | +| `delete_wind_power_site` | Delete a site by resource ID. | [details](https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSite) | + +## List and retrieve + +```python +from solcast import wind_power_sites + +res = wind_power_sites.list_wind_power_sites(query="Sydney") +sites = res.to_dict()["results"] + +site = wind_power_sites.get_wind_power_site( + resource_id="your-wind-resource-id", +) +site.to_dict() +``` + +## Create and update + +Create a site with the required fields and any optional turbine settings: + +```python +res = wind_power_sites.create_wind_power_site( + name="Example wind site", + latitude=-33.8567, + longitude=151.2152, + capacity=5, + rotor_diameter=120, + hub_height=100, + number_of_turbines=2, +) +resource_id = res.to_dict()["resource_id"] +``` + +PATCH changes only the fields supplied. PUT replaces the definition and requires the full set of required fields: + +```python +wind_power_sites.patch_wind_power_site( + resource_id=resource_id, + allow_negative_power=True, +) + +wind_power_sites.update_wind_power_site( + resource_id=resource_id, + name="Example wind site", + latitude=-33.8567, + longitude=151.2152, + capacity=5, + rotor_diameter=120, + hub_height=100, + number_of_turbines=2, +) +``` + +## Delete + +```python +wind_power_sites.delete_wind_power_site(resource_id=resource_id) +``` + +Each method returns the SDK `Response` object. Use `response.to_dict()` to inspect the JSON response. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 5317bb2..c0ac4ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,7 +15,9 @@ nav: - Live: live.md - Historic: historic.md - TMY: tmy.md + - Schedules: schedules.md - PV Power Sites: pv_power_sites.md + - Wind Power Sites: wind_power_sites.md - Premium PV Site Measurements: pv_power_site_measurements.md - Premium Wind Site Measurements: wind_power_site_measurements.md - Aggregations: aggregations.md diff --git a/solcast/__init__.py b/solcast/__init__.py index 31c5ed6..4a58245 100644 --- a/solcast/__init__.py +++ b/solcast/__init__.py @@ -8,6 +8,8 @@ pv_power_sites, pv_power_site_measurements, wind_power_site_measurements, + wind_power_sites, + schedules, tmy, unmetered_locations, ) @@ -20,6 +22,8 @@ "pv_power_sites", "pv_power_site_measurements", "wind_power_site_measurements", + "wind_power_sites", + "schedules", "tmy", "unmetered_locations", ] diff --git a/solcast/api.py b/solcast/api.py index 13dc5d3..c961091 100644 --- a/solcast/api.py +++ b/solcast/api.py @@ -195,6 +195,7 @@ def post( Args: params: a dictionary of parameters that are passed in the POST request + json_body: an optional JSON object to send as the request body Returns: a Response object. @@ -202,29 +203,33 @@ def post( """ return self._make_request(params, method="POST", json_body=json_body) - def patch(self, params: dict) -> Response: + def patch( + self, params: dict, json_body: Optional[Dict[str, Any]] = None + ) -> Response: """Wrap _make_request to make a PATCH request Args: params: a dictionary of parameters that are passed in the PATCH request + json_body: an optional JSON object to send as the request body Returns: a Response object. """ - return self._make_request(params, method="PATCH") + return self._make_request(params, method="PATCH", json_body=json_body) - def put(self, params: dict) -> Response: + def put(self, params: dict, json_body: Optional[Dict[str, Any]] = None) -> Response: """Wrap _make_request to make a PUT request Args: params: a dictionary of parameters that are passed in the PUT request + json_body: an optional JSON object to send as the request body Returns: a Response object. """ - return self._make_request(params, method="PUT") + return self._make_request(params, method="PUT", json_body=json_body) def delete(self, params: dict) -> Response: """Wrap _make_request to make a DEL request @@ -246,6 +251,7 @@ def _make_request( Args: params: a dictionary of parameters that are passed in the request method: HTTP method to use + json_body: an optional JSON object to serialize as the request body Returns: a Response object. diff --git a/solcast/pv_power_site_measurements.py b/solcast/pv_power_site_measurements.py index 539e7c3..5d8d8b0 100644 --- a/solcast/pv_power_site_measurements.py +++ b/solcast/pv_power_site_measurements.py @@ -65,7 +65,7 @@ def delete_pv_site_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ - Delete site measurements + Delete Site Measurements Args: resource_id: The unique identifier of the Premium PV Power resource. @@ -86,7 +86,6 @@ def delete_pv_site_measurements( "resource_id": resource_id, "start": start, "end": end, - "format": "json", **kwargs, } ) @@ -151,7 +150,7 @@ def delete_pv_sub_unit_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ - Delete sub-unit site measurements + Delete Sub-Unit Site Measurements Args: resource_id: The unique identifier of the Premium PV Power resource. @@ -172,7 +171,6 @@ def delete_pv_sub_unit_measurements( "resource_id": resource_id, "start": start, "end": end, - "format": "json", **kwargs, } ) diff --git a/solcast/pv_power_sites.py b/solcast/pv_power_sites.py index e4447af..0a4d7da 100644 --- a/solcast/pv_power_sites.py +++ b/solcast/pv_power_sites.py @@ -1,5 +1,13 @@ +from typing import Any, Dict, List +from urllib.parse import quote + from .api import Client, Response -from .urls import base_url, pv_power_site, pv_power_sites +from .urls import ( + base_url, + pv_power_site, + pv_power_sites, + utility_scale_site_measurements, +) def list_pv_power_sites(**kwargs) -> Response: @@ -65,6 +73,21 @@ def create_pv_power_site( ) +def create_utility_scale_site_measurements( + resource_id: str, + measurements: List[Dict[str, Any]], + **kwargs, +) -> Response: + """Submit one or more measurements for an Advanced PV Power site.""" + endpoint = utility_scale_site_measurements.format( + resource_id=quote(resource_id, safe="") + ) + url = kwargs.pop("base_url", base_url) + client = Client(base_url=url, endpoint=endpoint, response_type=Response) + + return client.post(kwargs, json_body={"measurements": measurements}) + + def patch_pv_power_site(resource_id: str, **kwargs) -> Response: """ Patch Resource diff --git a/solcast/schedules.py b/solcast/schedules.py new file mode 100644 index 0000000..d253e8d --- /dev/null +++ b/solcast/schedules.py @@ -0,0 +1,122 @@ +from typing import Any, Dict, List, Optional + +from .api import Client, Response +from .urls import base_url, resource_schedule + + +def delete_resource_schedule( + resource_id: str, period_start: str, period_end: str, schedule_type: str, **kwargs +) -> Response: + """ + Remove stored schedule data for the given time window and schedule type(s). + Intervals extending beyond the window are trimmed; intervals entirely within are + dropped. + + Args: + resource_id: The resource id. + period_start: Start of the delete window (ISO 8601). Must be on a 5-minute boundary. + period_end: End of the delete window (ISO 8601). Must be on a 5-minute boundary. + schedule_type: Comma-separated list of schedule types to delete: availability, curtailment + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#deleteManageSchedules. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=resource_schedule, + response_type=Response, + ) + + return client.delete( + { + "resource_id": resource_id, + "period_start": period_start, + "period_end": period_end, + "schedule_type": schedule_type, + **kwargs, + } + ) + + +def get_resource_schedule( + resource_id: str, + period_start: str, + period_end: str, + issue_time: Optional[str] = None, + **kwargs, +) -> Response: + """ + Retrieve the current availability and curtailment schedule for a premium resource. + Use issue_time to query historical schedule state. + + Args: + resource_id: The resource id. + period_start: Start of the query window (ISO 8601). Must be on a 5-minute boundary. + period_end: End of the query window (ISO 8601). Must be on a 5-minute boundary. + issue_time: Return the schedule as it existed at this point in time (ISO 8601). + When omitted, the latest schedule is returned. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#getManageSchedules. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=resource_schedule, + response_type=Response, + ) + + return client.get( + { + "resource_id": resource_id, + "period_start": period_start, + "period_end": period_end, + **({"issue_time": issue_time} if issue_time is not None else {}), + "format": "json", + **kwargs, + } + ) + + +def patch_resource_schedule( + resource_id: str, + availability_schedule: Optional[List[Dict[str, Any]]] = None, + curtailment_schedule: Optional[List[Dict[str, Any]]] = None, + **kwargs, +) -> Response: + """ + Merge new availability and/or curtailment schedule intervals into the existing + stored schedule for a premium resource. Overlapping intervals are replaced (existing + intervals are split as needed). The request is all-or-nothing: a validation error in + any entry rejects the entire request. + + Args: + resource_id: The resource id. + availability_schedule: Availability schedule entries to merge. + curtailment_schedule: Curtailment schedule entries to merge. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#patchManageSchedules. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=resource_schedule, + response_type=Response, + ) + + request_body = { + "resource_id": resource_id, + } + if availability_schedule is not None: + request_body["availability_schedule"] = availability_schedule + if curtailment_schedule is not None: + request_body["curtailment_schedule"] = curtailment_schedule + return client.patch( + {**kwargs}, + json_body=request_body, + ) diff --git a/solcast/tmy.py b/solcast/tmy.py index 2da3141..0215fe1 100644 --- a/solcast/tmy.py +++ b/solcast/tmy.py @@ -1,5 +1,10 @@ from .api import Client, PandafiableResponse -from .urls import base_url, tmy_radiation_and_weather, tmy_rooftop_pv_power +from .urls import ( + base_url, + tmy_advanced_pv_power, + tmy_radiation_and_weather, + tmy_rooftop_pv_power, +) def radiation_and_weather( @@ -68,3 +73,14 @@ def rooftop_pv_power( **kwargs, } ) + + +def advanced_pv_power(resource_id: str, **kwargs) -> PandafiableResponse: + """Get advanced PV power for a TMY at a registered site.""" + url = kwargs.pop("base_url", base_url) + client = Client( + base_url=url, + endpoint=tmy_advanced_pv_power, + response_type=PandafiableResponse, + ) + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) diff --git a/solcast/urls.py b/solcast/urls.py index 8480e6d..daa6fb6 100644 --- a/solcast/urls.py +++ b/solcast/urls.py @@ -45,11 +45,16 @@ def _get_base_url() -> str: forecast_soiling_hsu = "data/forecast/soiling/hsu" tmy_radiation_and_weather = "data/tmy/radiation_and_weather" tmy_rooftop_pv_power = "data/tmy/rooftop_pv_power" +tmy_advanced_pv_power = "data/tmy/advanced_pv_power" pv_power_site = "resources/pv_power_site" pv_power_sites = "resources/pv_power_sites" +wind_power_site = "resources/wind_power_site" +wind_power_sites = "resources/wind_power_sites" +utility_scale_site_measurements = "utility_scale_sites/{resource_id}/measurements" 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" ) +resource_schedule = "manage/schedules" diff --git a/solcast/wind_power_site_measurements.py b/solcast/wind_power_site_measurements.py index 7dc479f..e62b3d8 100644 --- a/solcast/wind_power_site_measurements.py +++ b/solcast/wind_power_site_measurements.py @@ -65,7 +65,7 @@ def delete_wind_site_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ - Delete site measurements + Delete Site Measurements Args: resource_id: The unique identifier of the Premium Wind Power resource. @@ -86,7 +86,6 @@ def delete_wind_site_measurements( "resource_id": resource_id, "start": start, "end": end, - "format": "json", **kwargs, } ) @@ -151,7 +150,7 @@ def delete_wind_sub_unit_measurements( resource_id: str, start: str, end: str, **kwargs ) -> Response: """ - Delete sub-unit site measurements + Delete Sub-Unit Site Measurements Args: resource_id: The unique identifier of the Premium Wind Power resource. @@ -172,7 +171,6 @@ def delete_wind_sub_unit_measurements( "resource_id": resource_id, "start": start, "end": end, - "format": "json", **kwargs, } ) diff --git a/solcast/wind_power_sites.py b/solcast/wind_power_sites.py new file mode 100644 index 0000000..22b41bb --- /dev/null +++ b/solcast/wind_power_sites.py @@ -0,0 +1,249 @@ +from .api import Client, Response +from .urls import base_url, wind_power_site, wind_power_sites + + +def create_wind_power_site( + name: str, latitude: float, longitude: float, capacity: float, **kwargs +) -> Response: + """ + Create Resource + + Args: + name: The name of the resource. + latitude: The latitude of the resource. Must be a decimal number between -90 and 90. + longitude: The longitude of the resource. Must be a decimal number between -180 and 180. + capacity: The site capacity (rated power × number of turbines) in MW. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#postResourcesWindPowerSite. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_site, + response_type=Response, + ) + + request_body = { + "name": name, + "latitude": latitude, + "longitude": longitude, + "capacity": capacity, + } + body_value = kwargs.pop("rotor_diameter", None) + if body_value is not None: + request_body["rotor_diameter"] = body_value + body_value = kwargs.pop("rated_power", None) + if body_value is not None: + request_body["rated_power"] = body_value + body_value = kwargs.pop("hub_height", None) + if body_value is not None: + request_body["hub_height"] = body_value + body_value = kwargs.pop("number_of_turbines", None) + if body_value is not None: + request_body["number_of_turbines"] = body_value + body_value = kwargs.pop("cut_in_wind_speed", None) + if body_value is not None: + request_body["cut_in_wind_speed"] = body_value + body_value = kwargs.pop("cut_out_wind_speed", None) + if body_value is not None: + request_body["cut_out_wind_speed"] = body_value + body_value = kwargs.pop("rated_wind_speed", None) + if body_value is not None: + request_body["rated_wind_speed"] = body_value + body_value = kwargs.pop("allow_negative_power", None) + if body_value is not None: + request_body["allow_negative_power"] = body_value + body_value = kwargs.pop("grid_export_limit", None) + if body_value is not None: + request_body["grid_export_limit"] = body_value + return client.post( + {**kwargs}, + json_body=request_body, + ) + + +def delete_wind_power_site(resource_id: str, **kwargs) -> Response: + """ + Delete a premium wind power site. + + Args: + resource_id: The unique identifier of the resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#deleteResourcesWindPowerSite. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_site, + response_type=Response, + ) + + return client.delete({"resource_id": resource_id, **kwargs}) + + +def get_wind_power_site(resource_id: str, **kwargs) -> Response: + """ + Retrieve details of a specific premium wind power site. + + Args: + resource_id: The unique identifier of the resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSite. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_site, + response_type=Response, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def list_wind_power_sites(**kwargs) -> Response: + """ + Lists all premium wind power sites accessible to the authenticated user. + + Args: + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#getResourcesWindPowerSites. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_sites, + response_type=Response, + ) + + return client.get({"format": "json", **kwargs}) + + +def patch_wind_power_site(resource_id: str, **kwargs) -> Response: + """ + Patch Resource + + Args: + resource_id: The unique identifier of the resource. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#patchResourcesWindPowerSite. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_site, + response_type=Response, + ) + + request_body = { + "resource_id": resource_id, + } + body_value = kwargs.pop("rotor_diameter", None) + if body_value is not None: + request_body["rotor_diameter"] = body_value + body_value = kwargs.pop("rated_power", None) + if body_value is not None: + request_body["rated_power"] = body_value + body_value = kwargs.pop("hub_height", None) + if body_value is not None: + request_body["hub_height"] = body_value + body_value = kwargs.pop("number_of_turbines", None) + if body_value is not None: + request_body["number_of_turbines"] = body_value + body_value = kwargs.pop("cut_in_wind_speed", None) + if body_value is not None: + request_body["cut_in_wind_speed"] = body_value + body_value = kwargs.pop("cut_out_wind_speed", None) + if body_value is not None: + request_body["cut_out_wind_speed"] = body_value + body_value = kwargs.pop("rated_wind_speed", None) + if body_value is not None: + request_body["rated_wind_speed"] = body_value + body_value = kwargs.pop("allow_negative_power", None) + if body_value is not None: + request_body["allow_negative_power"] = body_value + body_value = kwargs.pop("grid_export_limit", None) + if body_value is not None: + request_body["grid_export_limit"] = body_value + return client.patch( + {**kwargs}, + json_body=request_body, + ) + + +def update_wind_power_site( + resource_id: str, + name: str, + latitude: float, + longitude: float, + capacity: float, + **kwargs, +) -> Response: + """ + Update Resource + + Args: + resource_id: The unique identifier of the resource. + name: The name of the resource. + latitude: The latitude of the resource. Must be a decimal number between -90 and 90. + longitude: The longitude of the resource. Must be a decimal number between -180 and 180. + capacity: The site capacity (rated power × number of turbines) in MW. + **kwargs: additional keyword arguments to be passed through as URL parameters to the Solcast API + + See https://docs.solcast.com.au/docs/section/premium#putResourcesWindPowerSite. + """ + url = kwargs.pop("base_url", base_url) + + client = Client( + base_url=url, + endpoint=wind_power_site, + response_type=Response, + ) + + request_body = { + "resource_id": resource_id, + "name": name, + "latitude": latitude, + "longitude": longitude, + "capacity": capacity, + } + body_value = kwargs.pop("rotor_diameter", None) + if body_value is not None: + request_body["rotor_diameter"] = body_value + body_value = kwargs.pop("rated_power", None) + if body_value is not None: + request_body["rated_power"] = body_value + body_value = kwargs.pop("hub_height", None) + if body_value is not None: + request_body["hub_height"] = body_value + body_value = kwargs.pop("number_of_turbines", None) + if body_value is not None: + request_body["number_of_turbines"] = body_value + body_value = kwargs.pop("cut_in_wind_speed", None) + if body_value is not None: + request_body["cut_in_wind_speed"] = body_value + body_value = kwargs.pop("cut_out_wind_speed", None) + if body_value is not None: + request_body["cut_out_wind_speed"] = body_value + body_value = kwargs.pop("rated_wind_speed", None) + if body_value is not None: + request_body["rated_wind_speed"] = body_value + body_value = kwargs.pop("allow_negative_power", None) + if body_value is not None: + request_body["allow_negative_power"] = body_value + body_value = kwargs.pop("grid_export_limit", None) + if body_value is not None: + request_body["grid_export_limit"] = body_value + return client.put( + {**kwargs}, + json_body=request_body, + ) diff --git a/tests/test_advanced_site_and_schedule_endpoints.py b/tests/test_advanced_site_and_schedule_endpoints.py new file mode 100644 index 0000000..97a1f19 --- /dev/null +++ b/tests/test_advanced_site_and_schedule_endpoints.py @@ -0,0 +1,183 @@ +import json +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from solcast import pv_power_sites, schedules, tmy +from solcast.api import Client, Response + + +class FakeResponse: + code = 200 + + def __init__(self, body=b'{"accepted": 1}'): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self.body + + +def capture_request(mock_urlopen): + request = mock_urlopen.call_args.args[0] + return request, urlsplit(request.full_url) + + +def test_client_put_serializes_json_body_as_application_json(): + body = {"resource_id": "pv-resource", "name": "PV site"} + client = Client( + base_url="https://api.solcast.com.au", + endpoint="resources/pv_power_site", + response_type=Response, + ) + + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = client.put({"api_key": "test-key"}, json_body=body) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "PUT" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == body + + +def test_tmy_advanced_pv_power_sends_resource_id_and_query_options(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = tmy.advanced_pv_power( + resource_id="pv-resource", + base_url="https://custom.example", + hours=12, + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "GET" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/data/tmy/advanced_pv_power" + assert parse_qs(parsed_url.query) == { + "resource_id": ["pv-resource"], + "hours": ["12"], + "format": ["json"], + } + + +def test_create_utility_scale_measurements_posts_json_to_resource_path(): + measurements = [{"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25}] + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_sites.create_utility_scale_site_measurements( + resource_id="pv-resource", + measurements=measurements, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "POST" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == {"measurements": measurements} + + +def test_create_utility_scale_measurements_posts_single_record_as_one_item_array(): + measurement = {"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25} + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_sites.create_utility_scale_site_measurements( + resource_id="pv-resource", + measurements=[measurement], + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" + assert json.loads(request.data) == {"measurements": [measurement]} + + +def test_get_resource_schedule_sends_required_window_and_optional_issue_time(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = schedules.get_resource_schedule( + resource_id="pv-resource", + period_start="2026-01-01T00:00:00Z", + period_end="2026-01-02T00:00:00Z", + issue_time="2026-01-03T00: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 == "/manage/schedules" + assert parse_qs(parsed_url.query) == { + "resource_id": ["pv-resource"], + "period_start": ["2026-01-01T00:00:00Z"], + "period_end": ["2026-01-02T00:00:00Z"], + "issue_time": ["2026-01-03T00:00:00Z"], + "format": ["json"], + } + + +def test_patch_resource_schedule_sends_schedule_changes_as_json(): + availability = [ + { + "period_start": "2026-01-01T00:00:00Z", + "period_end": "2026-01-01T01:00:00Z", + "availability": 75, + } + ] + curtailment = [ + { + "period_start": "2026-01-01T01:00:00Z", + "period_end": "2026-01-01T02:00:00Z", + "curtailment": 0.5, + } + ] + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = schedules.patch_resource_schedule( + resource_id="pv-resource", + availability_schedule=availability, + curtailment_schedule=curtailment, + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "PATCH" + assert parsed_url.path == "/manage/schedules" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "resource_id": "pv-resource", + "availability_schedule": availability, + "curtailment_schedule": curtailment, + } + + +def test_delete_resource_schedule_sends_schedule_type_without_format(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = schedules.delete_resource_schedule( + resource_id="pv-resource", + period_start="2026-01-01T00:00:00Z", + period_end="2026-01-02T00:00:00Z", + schedule_type="availability,curtailment", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "DELETE" + assert parsed_url.path == "/manage/schedules" + assert parse_qs(parsed_url.query) == { + "resource_id": ["pv-resource"], + "period_start": ["2026-01-01T00:00:00Z"], + "period_end": ["2026-01-02T00:00:00Z"], + "schedule_type": ["availability,curtailment"], + } diff --git a/tests/test_forecast_docs.py b/tests/test_forecast_docs.py new file mode 100644 index 0000000..e65e35c --- /dev/null +++ b/tests/test_forecast_docs.py @@ -0,0 +1,52 @@ +import ast +import re +from pathlib import Path +from urllib.parse import urlsplit + + +SDK_ROOT = Path(__file__).resolve().parents[1] + + +def test_forecast_guide_lists_every_public_method_with_api_reference_links(): + forecast_tree = ast.parse( + (SDK_ROOT / "solcast/forecast.py").read_text(encoding="utf-8") + ) + public_methods = { + node.name + for node in forecast_tree.body + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + } + guide = (SDK_ROOT / "docs/forecast.md").read_text(encoding="utf-8") + rows = re.findall( + r"^\|\s*`([^`]+)`\s*\|\s*\[details\]\(([^)]+)\)", + guide, + re.MULTILINE, + ) + documented_methods = {method for method, _href in rows} + + assert documented_methods == public_methods + assert len(rows) == len(documented_methods) + for method, href in rows: + parsed_href = urlsplit(href) + assert parsed_href.scheme == "https", method + assert parsed_href.netloc == "docs.solcast.com.au", method + assert parsed_href.fragment, method + + +def test_premium_forecast_guide_links_target_their_api_operations(): + guide = (SDK_ROOT / "docs/forecast.md").read_text(encoding="utf-8") + rows = dict( + re.findall( + r"^\|\s*`([^`]+)`\s*\|\s*\[details\]\(([^)]+)\)", + guide, + re.MULTILINE, + ) + ) + + assert ( + urlsplit(rows["premium_pv_power"]).fragment == "getDataForecastPremiumPvPower" + ) + assert ( + urlsplit(rows["premium_wind_power"]).fragment + == "getDataForecastPremiumWindPower" + ) diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py index 5e5cb97..14ad139 100644 --- a/tests/test_site_measurements.py +++ b/tests/test_site_measurements.py @@ -180,7 +180,6 @@ def test_delete_wind_site_measurements_sends_required_time_bounds(): "resource_id": ["wind-123"], "start": ["2026-01-01T00:00:00Z"], "end": ["2026-01-02T00:00:00Z"], - "format": ["json"], } diff --git a/tests/test_wind_power_sites.py b/tests/test_wind_power_sites.py new file mode 100644 index 0000000..25e8938 --- /dev/null +++ b/tests/test_wind_power_sites.py @@ -0,0 +1,172 @@ +import json +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from solcast import wind_power_sites + + +class FakeResponse: + code = 200 + + def __init__(self, body=b'{"resource_id": "wind-123"}'): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self.body + + +def capture_request(mock_urlopen): + request = mock_urlopen.call_args.args[0] + return request, urlsplit(request.full_url) + + +def test_list_wind_power_sites_sends_filters_and_custom_base_url(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.list_wind_power_sites( + show_all=False, + query="Sydney", + start="2026-01-01T00:00:00Z", + end="2026-02-01T00:00:00Z", + forecast_production=True, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "GET" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_sites" + assert parse_qs(parsed_url.query) == { + "show_all": ["False"], + "query": ["Sydney"], + "start": ["2026-01-01T00:00:00Z"], + "end": ["2026-02-01T00:00:00Z"], + "forecast_production": ["True"], + "format": ["json"], + } + + +def test_get_wind_power_site_sends_resource_id_as_query(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.get_wind_power_site( + resource_id="wind-123", + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "GET" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_site" + assert parse_qs(parsed_url.query) == { + "resource_id": ["wind-123"], + "format": ["json"], + } + + +def test_create_wind_power_site_posts_required_and_supplied_optional_fields(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.create_wind_power_site( + name="South Ridge", + latitude=-33.8567, + longitude=151.2152, + capacity=5, + rotor_diameter=120, + allow_negative_power=False, + grid_export_limit=0, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "POST" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_site" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "name": "South Ridge", + "latitude": -33.8567, + "longitude": 151.2152, + "capacity": 5, + "rotor_diameter": 120, + "allow_negative_power": False, + "grid_export_limit": 0, + } + + +def test_patch_wind_power_site_preserves_explicit_false_and_zero_values(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.patch_wind_power_site( + resource_id="wind-123", + allow_negative_power=False, + grid_export_limit=0, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "PATCH" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_site" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "resource_id": "wind-123", + "allow_negative_power": False, + "grid_export_limit": 0, + } + + +def test_update_wind_power_site_puts_required_fields_as_json(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.update_wind_power_site( + resource_id="wind-123", + name="South Ridge", + latitude=-33.8567, + longitude=151.2152, + capacity=5, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "PUT" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_site" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "resource_id": "wind-123", + "name": "South Ridge", + "latitude": -33.8567, + "longitude": 151.2152, + "capacity": 5, + } + + +def test_delete_wind_power_site_sends_resource_id_as_query(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = wind_power_sites.delete_wind_power_site( + resource_id="wind-123", + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "DELETE" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/resources/wind_power_site" + assert parse_qs(parsed_url.query) == {"resource_id": ["wind-123"]} From 86e4db158e5ed8aedd1dface523b088c9267a0de Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 23 Oct 2026 04:00:20 +0000 Subject: [PATCH 2/2] fix: align SDK measurement module and split tests --- docs/pv_power_site_measurements.md | 24 +++++- docs/pv_power_sites.md | 19 +---- mkdocs.yml | 2 +- solcast/pv_power_site_measurements.py | 19 +++++ solcast/pv_power_sites.py | 19 ----- tests/test_client.py | 43 ++++++++++ ...chedule_endpoints.py => test_schedules.py} | 78 +------------------ tests/test_site_measurements.py | 36 +++++++++ tests/test_tmy.py | 45 +++++++++++ 9 files changed, 168 insertions(+), 117 deletions(-) rename tests/{test_advanced_site_and_schedule_endpoints.py => test_schedules.py} (52%) diff --git a/docs/pv_power_site_measurements.md b/docs/pv_power_site_measurements.md index cafa6a3..f57d1e9 100644 --- a/docs/pv_power_site_measurements.md +++ b/docs/pv_power_site_measurements.md @@ -1,6 +1,6 @@ -# Premium PV Site Measurements +# PV Site Measurements -The `pv_power_site_measurements` module manages measured power data for Premium PV Power sites. +The `pv_power_site_measurements` module manages measured power data for Premium PV Power sites and utility-scale measurements for Advanced PV sites. All measurement records use ISO-8601 timestamps. Site measurement power is measured in MW. @@ -11,6 +11,7 @@ See the Premium API schemas for [site upload](https://docs.solcast.com.au/docs/s | Method | Purpose | |---|---| | `create_pv_site_measurements` | Submit site measurements. | +| `create_utility_scale_site_measurements` | Submit measured power data for an Advanced PV site. | | `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_measurements` | Submit measurements for labelled site sub-units. | @@ -40,6 +41,25 @@ response = pv_power_site_measurements.create_pv_site_measurements( ) ``` +## Create utility-scale site measurements + +Submit measured power records for an Advanced PV site: + +```python +response = pv_power_site_measurements.create_utility_scale_site_measurements( + resource_id="your-advanced-pv-resource", + measurements=[ + { + "period_end": "2026-01-01T00:05:00Z", + "period": "PT5M", + "total_power": 1.25, + } + ], +) + +response.to_dict() +``` + Create sub-unit measurements with a `sub_unit` value on every record: ```python diff --git a/docs/pv_power_sites.md b/docs/pv_power_sites.md index 44c01a9..c5a2cd6 100644 --- a/docs/pv_power_sites.md +++ b/docs/pv_power_sites.md @@ -1,14 +1,13 @@ # PV Power Sites Allows management of detailed PV power site metadata used by the `advanced_pv_power` functions. More information is in the [API docs](https://docs.solcast.com.au/#49090b36-66db-4d0f-89d5-87d19f00bec1). -The module `pv_power_sites` has 7 functions available. Use the `res.to_dict()` method to see the site metadata. +The module `pv_power_sites` has 6 functions available. Use the `res.to_dict()` method to see the site metadata. |
Endpoint
| Purpose | API Docs | |------|------|------| | `list_pv_power_sites` | List available PV power sites. | [details](https://docs.solcast.com.au/#baee4c8b-83e8-43e6-886b-98671164df10){.md-button} | | `get_pv_power_site` | Get an existing PV power site's specifications. | [details](https://docs.solcast.com.au/#27a18021-eed0-4281-8b28-9bdf1ebb2a95){.md-button} | | `create_pv_power_site` | Create PV power site to be used with Solcast's advanced PV power model. | [details](https://docs.solcast.com.au/#d3a35494-15d8-4baa-b96f-7fd3353c9f06){.md-button} | -| `create_utility_scale_site_measurements` | Submit measured power data for an Advanced PV site. | [details](https://docs.solcast.com.au/docs/section/advanced-pv-power#postUtilityScaleSitesByResourceIdMeasurements){.md-button} | | `patch_pv_power_site` | Patch an existing PV power site to partially update the site's specifications. | [details](https://docs.solcast.com.au/#ba412164-31c2-47a9-a965-c95bc9b632a6){.md-button} | | `update_pv_power_site` | Overwrite an existing PV power site's specifications. | [details](https://docs.solcast.com.au/#181cf2be-f710-49c3-8050-07be858f25e1){.md-button} | | `delete_pv_power_site` | Delete an existing PV power site. | [details](https://docs.solcast.com.au/#c2353692-36db-46b8-8508-a6d4fae65390){.md-button} | @@ -24,22 +23,6 @@ res = pv_power_sites.get_pv_power_site('ba75-e17a-7374-95ed') res.to_dict() ``` -Submit a batch of measured power records for an Advanced PV site: - -```python -response = pv_power_sites.create_utility_scale_site_measurements( - resource_id="your-advanced-pv-resource", - measurements=[ - { - "period_end": "2026-01-01T00:05:00Z", - "period": "PT5M", - "total_power": 1.25, - } - ], -) - -response.to_dict() -``` ``` { 'resource_id': 'ba75-e17a-7374-95ed', diff --git a/mkdocs.yml b/mkdocs.yml index c0ac4ff..b4d960f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,7 +18,7 @@ nav: - Schedules: schedules.md - PV Power Sites: pv_power_sites.md - Wind Power Sites: wind_power_sites.md - - Premium PV Site Measurements: pv_power_site_measurements.md + - PV Site Measurements: pv_power_site_measurements.md - Premium Wind Site Measurements: wind_power_site_measurements.md - Aggregations: aggregations.md - Example Notebooks: diff --git a/solcast/pv_power_site_measurements.py b/solcast/pv_power_site_measurements.py index 5d8d8b0..679aba3 100644 --- a/solcast/pv_power_site_measurements.py +++ b/solcast/pv_power_site_measurements.py @@ -1,9 +1,13 @@ +from typing import Any, Dict, List +from urllib.parse import quote + from ._site_measurements import MeasurementInput, measurement_records from .api import Client, PandafiableResponse, Response from .urls import ( base_url, pv_power_site_measurements, pv_power_site_measurements_sub_units, + utility_scale_site_measurements, ) @@ -41,6 +45,21 @@ def create_pv_site_measurements( ) +def create_utility_scale_site_measurements( + resource_id: str, + measurements: List[Dict[str, Any]], + **kwargs, +) -> Response: + """Submit one or more measurements for an Advanced PV Power site.""" + endpoint = utility_scale_site_measurements.format( + resource_id=quote(resource_id, safe="") + ) + url = kwargs.pop("base_url", base_url) + client = Client(base_url=url, endpoint=endpoint, response_type=Response) + + return client.post(kwargs, json_body={"measurements": measurements}) + + def get_pv_site_measurements(resource_id: str, **kwargs) -> PandafiableResponse: """ Retrieve historical measurement data for a Premium PV Power site. Supports diff --git a/solcast/pv_power_sites.py b/solcast/pv_power_sites.py index 0a4d7da..2120abc 100644 --- a/solcast/pv_power_sites.py +++ b/solcast/pv_power_sites.py @@ -1,12 +1,8 @@ -from typing import Any, Dict, List -from urllib.parse import quote - from .api import Client, Response from .urls import ( base_url, pv_power_site, pv_power_sites, - utility_scale_site_measurements, ) @@ -73,21 +69,6 @@ def create_pv_power_site( ) -def create_utility_scale_site_measurements( - resource_id: str, - measurements: List[Dict[str, Any]], - **kwargs, -) -> Response: - """Submit one or more measurements for an Advanced PV Power site.""" - endpoint = utility_scale_site_measurements.format( - resource_id=quote(resource_id, safe="") - ) - url = kwargs.pop("base_url", base_url) - client = Client(base_url=url, endpoint=endpoint, response_type=Response) - - return client.post(kwargs, json_body={"measurements": measurements}) - - def patch_pv_power_site(resource_id: str, **kwargs) -> Response: """ Patch Resource diff --git a/tests/test_client.py b/tests/test_client.py index 2385e01..e3eb702 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,9 @@ +import json import subprocess import sys from pathlib import Path +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit import pytest @@ -13,6 +16,27 @@ ) +class FakeResponse: + code = 200 + + def __init__(self, body=b'{"accepted": 1}'): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self.body + + +def capture_request(mock_urlopen): + request = mock_urlopen.call_args.args[0] + return request, urlsplit(request.full_url) + + @pytest.fixture def mock_short_key(monkeypatch): monkeypatch.setenv("SOLCAST_API_KEY", "") @@ -132,6 +156,25 @@ def test_client(): assert params["latitude"] == -33.856785 +def test_client_put_serializes_json_body_as_application_json(): + body = {"resource_id": "pv-resource", "name": "PV site"} + client = Client( + base_url="https://api.solcast.com.au", + endpoint="resources/pv_power_site", + response_type=Response, + ) + + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = client.put({"api_key": "test-key"}, json_body=body) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "PUT" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == body + + def test_response(): raw_data = b'{"estimated_actuals":[{"ghi":54,"period_end":"2023-06-22T05:30:00.0000000Z","period":"PT30M"}]}' rsp = Response( diff --git a/tests/test_advanced_site_and_schedule_endpoints.py b/tests/test_schedules.py similarity index 52% rename from tests/test_advanced_site_and_schedule_endpoints.py rename to tests/test_schedules.py index 97a1f19..c27837f 100644 --- a/tests/test_advanced_site_and_schedule_endpoints.py +++ b/tests/test_schedules.py @@ -2,8 +2,7 @@ from unittest.mock import patch from urllib.parse import parse_qs, urlsplit -from solcast import pv_power_sites, schedules, tmy -from solcast.api import Client, Response +from solcast import schedules class FakeResponse: @@ -27,81 +26,6 @@ def capture_request(mock_urlopen): return request, urlsplit(request.full_url) -def test_client_put_serializes_json_body_as_application_json(): - body = {"resource_id": "pv-resource", "name": "PV site"} - client = Client( - base_url="https://api.solcast.com.au", - endpoint="resources/pv_power_site", - response_type=Response, - ) - - with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = client.put({"api_key": "test-key"}, json_body=body) - - request, parsed_url = capture_request(mock_urlopen) - assert response.success is True - assert request.method == "PUT" - assert parse_qs(parsed_url.query) == {} - assert request.headers["Content-type"] == "application/json" - assert json.loads(request.data) == body - - -def test_tmy_advanced_pv_power_sends_resource_id_and_query_options(): - with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = tmy.advanced_pv_power( - resource_id="pv-resource", - base_url="https://custom.example", - hours=12, - api_key="test-key", - ) - - request, parsed_url = capture_request(mock_urlopen) - assert response.success is True - assert request.method == "GET" - assert parsed_url.netloc == "custom.example" - assert parsed_url.path == "/data/tmy/advanced_pv_power" - assert parse_qs(parsed_url.query) == { - "resource_id": ["pv-resource"], - "hours": ["12"], - "format": ["json"], - } - - -def test_create_utility_scale_measurements_posts_json_to_resource_path(): - measurements = [{"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25}] - with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = pv_power_sites.create_utility_scale_site_measurements( - resource_id="pv-resource", - measurements=measurements, - base_url="https://custom.example", - api_key="test-key", - ) - - request, parsed_url = capture_request(mock_urlopen) - assert response.success is True - assert request.method == "POST" - assert parsed_url.netloc == "custom.example" - assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" - assert parse_qs(parsed_url.query) == {} - assert request.headers["Content-type"] == "application/json" - assert json.loads(request.data) == {"measurements": measurements} - - -def test_create_utility_scale_measurements_posts_single_record_as_one_item_array(): - measurement = {"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25} - with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: - response = pv_power_sites.create_utility_scale_site_measurements( - resource_id="pv-resource", - measurements=[measurement], - api_key="test-key", - ) - - request, parsed_url = capture_request(mock_urlopen) - assert response.success is True - assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" - assert json.loads(request.data) == {"measurements": [measurement]} - - def test_get_resource_schedule_sends_required_window_and_optional_issue_time(): with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: response = schedules.get_resource_schedule( diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py index 14ad139..70cb8eb 100644 --- a/tests/test_site_measurements.py +++ b/tests/test_site_measurements.py @@ -57,6 +57,41 @@ def test_create_pv_site_measurements_sends_json_body(): } +def test_create_utility_scale_measurements_posts_json_to_resource_path(): + measurements = [{"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25}] + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_site_measurements.create_utility_scale_site_measurements( + resource_id="pv-resource", + measurements=measurements, + base_url="https://custom.example", + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "POST" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" + assert parse_qs(parsed_url.query) == {} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == {"measurements": measurements} + + +def test_create_utility_scale_measurements_posts_single_record_as_one_item_array(): + measurement = {"period_end": "2026-01-01T00:30:00Z", "total_power": 1.25} + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = pv_power_site_measurements.create_utility_scale_site_measurements( + resource_id="pv-resource", + measurements=[measurement], + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert parsed_url.path == "/utility_scale_sites/pv-resource/measurements" + assert json.loads(request.data) == {"measurements": [measurement]} + + def test_create_pv_site_measurements_accepts_dataframe(): measurements = pd.DataFrame( { @@ -216,6 +251,7 @@ def test_create_wind_sub_unit_measurements_sends_json_body(): def test_measurement_modules_expose_all_site_and_sub_unit_operations(): expected_pv_names = { "create_pv_site_measurements", + "create_utility_scale_site_measurements", "get_pv_site_measurements", "delete_pv_site_measurements", "create_pv_sub_unit_measurements", diff --git a/tests/test_tmy.py b/tests/test_tmy.py index cc42539..449453d 100644 --- a/tests/test_tmy.py +++ b/tests/test_tmy.py @@ -1,7 +1,31 @@ +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + from solcast import tmy from solcast.unmetered_locations import load_test_locations_coordinates +class FakeResponse: + code = 200 + + def __init__(self, body=b'{"accepted": 1}'): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self.body + + +def capture_request(mock_urlopen): + request = mock_urlopen.call_args.args[0] + return request, urlsplit(request.full_url) + + def test_radiation_and_weather(): lats, longs = load_test_locations_coordinates() @@ -30,3 +54,24 @@ def test_fail_rooftop_pv_power(): assert res.success is False assert res.code == 400 assert res.exception == "The specified condition was not met for 'array_type'." + + +def test_tmy_advanced_pv_power_sends_resource_id_and_query_options(): + with patch("solcast.api.urlopen", return_value=FakeResponse()) as mock_urlopen: + response = tmy.advanced_pv_power( + resource_id="pv-resource", + base_url="https://custom.example", + hours=12, + api_key="test-key", + ) + + request, parsed_url = capture_request(mock_urlopen) + assert response.success is True + assert request.method == "GET" + assert parsed_url.netloc == "custom.example" + assert parsed_url.path == "/data/tmy/advanced_pv_power" + assert parse_qs(parsed_url.query) == { + "resource_id": ["pv-resource"], + "hours": ["12"], + "format": ["json"], + }