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 new file mode 100644 index 0000000..2f2196b --- /dev/null +++ b/docs/notebooks/4.2 Premium PV and Wind Measurements.ipynb @@ -0,0 +1,490 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "80c98ece", + "metadata": { + "id": "80c98ece", + "language": "markdown" + }, + "source": [ + "# 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 your Premium PV Power `resource_id`." + ] + }, + { + "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. The site example below batches a pandas DataFrame; the sub-unit example uses a list of records." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61f78dd3", + "metadata": { + "id": "61f78dd3", + "language": "python" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'accepted': 2}\n" + ] + } + ], + "source": [ + "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 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=batch,\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": 3, + "id": "59e49691", + "metadata": { + "id": "59e49691", + "language": "python" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'resource_id': 'cbf6-a872-1cb2-566f', 'accepted': 2}\n" + ] + } + ], + "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_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 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": 4, + "id": "dc123c73", + "metadata": { + "id": "dc123c73", + "language": "python" + }, + "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 = \"inverter-1\"\n", + "sub_unit_take = 100\n", + "\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", + "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}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7af043e8", + "metadata": { + "id": "7af043e8", + "language": "markdown" + }, + "source": [ + "## Download All Pages of Site Measurements\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7769e683", + "metadata": { + "id": "7769e683", + "language": "python" + }, + "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", + "
power
period_end
2026-09-28 00:30:00+00:001.25
2026-09-28 01:00:00+00:001.40
\n", + "
" + ], + "text/plain": [ + " 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": {}, + "output_type": "display_data" + } + ], + "source": [ + "site_pages = []\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=site_skip,\n", + " take=site_take,\n", + " )\n", + " if not res.success:\n", + " raise RuntimeError(res.exception)\n", + "\n", + " page = res.to_dict()\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_pages:\n", + " site_df = pd.concat(site_pages)\n", + " display(site_df)\n", + "else:\n", + " print(\"No site measurements found for this period.\")" + ] + }, + { + "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_measurements` and pass the `sub_unit` label." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45580803", + "metadata": { + "id": "45580803", + "language": "python" + }, + "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", + " 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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "294dd82a", + "metadata": { + "id": "294dd82a", + "language": "python" + }, + "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 = False\n", + "if confirm_sub_unit_delete:\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", + " 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.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv-1 (3.12.3)", + "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 new file mode 100644 index 0000000..cafa6a3 --- /dev/null +++ b/docs/pv_power_site_measurements.md @@ -0,0 +1,117 @@ +# 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. + +`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 | +|---|---| +| `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_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 + +```python +import pandas as pd + +from solcast import pv_power_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, +) +``` + +Create sub-unit measurements with a `sub_unit` value on every record: + +```python +response = pv_power_site_measurements.create_pv_sub_unit_measurements( + resource_id="your-premium-pv-site", + measurements=[ + { + "sub_unit": "inverter-1", + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 1.25, + } + ], +) +``` + +## Upload and download large datasets + +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.iloc[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") +``` + +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. Use `to_dict()` for pagination metadata and `to_pandas()` for each page: + +```python +import pandas as pd + +measurement_pages = [] +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() + 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. 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 new file mode 100644 index 0000000..236636a --- /dev/null +++ b/docs/wind_power_site_measurements.md @@ -0,0 +1,119 @@ +# 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. + +`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 | +|---|---| +| `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_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 + +```python +import pandas as pd +from solcast import wind_power_site_measurements + +measurements = pd.DataFrame( + [ + { + "period_end": "2026-01-01T00:30:00Z", + "period": "PT30M", + "power": 2.5, + "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, +) +``` + +Create sub-unit measurements with a `sub_unit` value on every record: + +```python +response = wind_power_site_measurements.create_wind_sub_unit_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, + } + ], +) +``` + +## Upload and download large datasets + +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.iloc[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") +``` + +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. Use `to_dict()` for pagination metadata and `to_pandas()` for each page: + +```python +import pandas as pd + +measurement_pages = [] +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() + 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. GET responses also support `to_pandas()` for the current page of measurement data. diff --git a/mkdocs.yml b/mkdocs.yml index 86a86f4..5317bb2 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': @@ -35,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/__init__.py b/solcast/__init__.py index a4f208e..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, @@ -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/_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 99ed5e0..13dc5d3 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 @@ -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 @@ -105,7 +116,7 @@ def __init__( self, base_url: str, endpoint: str, - response_type: Response, + response_type: Type[Response], ): """ Args: @@ -177,7 +188,9 @@ 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 +200,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 +238,9 @@ 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 +252,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..539e7c3 --- /dev/null +++ b/solcast/pv_power_site_measurements.py @@ -0,0 +1,178 @@ +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, +) + + +def create_pv_site_measurements( + resource_id: str, measurements: MeasurementInput, **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/docs/section/premium#postResourcesPvPowerSiteMeasurements. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements, + response_type=Response, + ) + + records = measurement_records(measurements) + + return client.post( + {"format": "json", **kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": records, + }, + ) + + +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. + + 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/docs/section/premium#getResourcesPvPowerSiteMeasurements. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements, + response_type=PandafiableResponse, + ) + + 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/docs/section/premium#deleteResourcesPvPowerSiteMeasurements. + """ + 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_measurements( + resource_id: str, measurements: MeasurementInput, **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/docs/section/premium#postResourcesPvPowerSiteMeasurementsSubUnits. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements_sub_units, + response_type=Response, + ) + + records = measurement_records(measurements) + + return client.post( + {"format": "json", **kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": records, + }, + ) + + +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 + 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/docs/section/premium#getResourcesPvPowerSiteMeasurementsSubUnits. + """ + client = Client( + base_url=base_url, + endpoint=pv_power_site_measurements_sub_units, + response_type=PandafiableResponse, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_pv_sub_unit_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/docs/section/premium#deleteResourcesPvPowerSiteMeasurementsSubUnits. + """ + 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..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" @@ -22,3 +47,9 @@ 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..7dc479f --- /dev/null +++ b/solcast/wind_power_site_measurements.py @@ -0,0 +1,178 @@ +from ._site_measurements import MeasurementInput, measurement_records +from .api import Client, PandafiableResponse, 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: MeasurementInput, **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/docs/section/premium#postResourcesWindPowerSiteMeasurements. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements, + response_type=Response, + ) + + records = measurement_records(measurements) + + return client.post( + {"format": "json", **kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": records, + }, + ) + + +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. + + 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/docs/section/premium#getResourcesWindPowerSiteMeasurements. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements, + response_type=PandafiableResponse, + ) + + 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/docs/section/premium#deleteResourcesWindPowerSiteMeasurements. + """ + 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_measurements( + resource_id: str, measurements: MeasurementInput, **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/docs/section/premium#postResourcesWindPowerSiteMeasurementsSubUnits. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements_sub_units, + response_type=Response, + ) + + records = measurement_records(measurements) + + return client.post( + {"format": "json", **kwargs}, + json_body={ + "resource_id": resource_id, + "measurements": records, + }, + ) + + +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 + 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/docs/section/premium#getResourcesWindPowerSiteMeasurementsSubUnits. + """ + client = Client( + base_url=base_url, + endpoint=wind_power_site_measurements_sub_units, + response_type=PandafiableResponse, + ) + + return client.get({"resource_id": resource_id, "format": "json", **kwargs}) + + +def delete_wind_sub_unit_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/docs/section/premium#deleteResourcesWindPowerSiteMeasurementsSubUnits. + """ + 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_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( diff --git a/tests/test_site_measurements.py b/tests/test_site_measurements.py new file mode 100644 index 0000000..5e5cb97 --- /dev/null +++ b/tests/test_site_measurements.py @@ -0,0 +1,236 @@ +import json +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 + + 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_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 response.to_dict()["accepted"] == 1 + assert request.method == "POST" + assert parsed_url.path == "/resources/pv_power_site_measurements" + assert parse_qs(parsed_url.query) == {"format": ["json"]} + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == { + "resource_id": "pv-123", + "measurements": measurements, + } + + +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_measurements( + resource_id="pv-123", + sub_unit="inverter-1", + start="2026-01-01T00:00:00Z", + end="2026-01-02T00:00:00Z", + skip=20, + take=50, + 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"], + "skip": ["20"], + "take": ["50"], + "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_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_measurements( + resource_id="wind-123", + measurements=measurements, + api_key="test-key", + ) + + 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) == {"format": ["json"]} + 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_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_measurements", + "get_wind_sub_unit_measurements", + "delete_wind_sub_unit_measurements", + } + + assert expected_pv_names.issubset(vars(pv_power_site_measurements)) + assert expected_wind_names.issubset(vars(wind_power_site_measurements))