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",
+ " sub_unit | \n",
+ " power | \n",
+ " period | \n",
+ "
\n",
+ " \n",
+ " | period_end | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 2026-09-28 00:30:00+00:00 | \n",
+ " inverter-1 | \n",
+ " 0.65 | \n",
+ " PT30M | \n",
+ "
\n",
+ " \n",
+ " | 2026-09-28 01:00:00+00:00 | \n",
+ " inverter-1 | \n",
+ " 0.72 | \n",
+ " PT30M | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " power | \n",
+ "
\n",
+ " \n",
+ " | period_end | \n",
+ " | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 2026-09-28 00:30:00+00:00 | \n",
+ " 1.25 | \n",
+ "
\n",
+ " \n",
+ " | 2026-09-28 01:00:00+00:00 | \n",
+ " 1.40 | \n",
+ "
\n",
+ " \n",
+ "
\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))