power-compare/app/aer/client.py
dlawler489 84c4922bbc Initial commit: solar usage vs AER plan comparison app
FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2),
caches AER Consumer Data Right electricity plan data, and compares actual
metered usage against every available plan to estimate real annual cost
(TOU, tiered feed-in, and flat tariffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:07:01 +10:00

93 lines
3.6 KiB
Python

"""Client for the AER's public "Get Generic Plans" / "Get Generic Plan
Detail" Consumer Data Right APIs.
These are unauthenticated, government-operated endpoints (no accreditation
required), but they are hosted per-retailer-brand rather than as one
aggregated feed: each retailer publishes its own plans under
`https://cdr.energymadeeasy.gov.au/<brand-slug>/cds-au/v1/energy/plans`.
There is no single "give me every QLD plan" call -- this client queries a
maintained list of known retailer brand slugs and merges the results.
Confirmed against the live API while building this:
- The plans-list endpoint only supports API version 1 (`x-v: 1`); the
plan-detail endpoint supports version 3 (`x-v: 3`). Using the wrong
version returns an HTTP 406.
- Distributor names in `geography.distributors` are plain strings like
"Energex" (QLD's south-east distributor); Ergon's exact string should be
confirmed for a given retailer/plan once one is found, hence the
case-insensitive substring match used when filtering by distributor.
"""
from __future__ import annotations
import time
import httpx
BASE_HOST = "https://cdr.energymadeeasy.gov.au"
# Seed list of retailer CDR "brand" slugs confirmed to respond while
# building this. Not exhaustive, and retailers can add/rename/retire brands.
# To add one: find the retailer's own published CDR base URI (usually
# linked from their "Consumer Data Right" policy page) or check the AER's
# register at
# https://www.aer.gov.au/documents/consumer-data-right-energy-retailer-base-uris-and-cdr-brands
KNOWN_RETAILER_BRANDS: list[str] = [
"agl",
"origin",
"energyaustralia",
"alintaenergy",
"redenergy",
"powershop",
"tango",
"globird",
"sumo",
"dodo",
"click",
"ovoenergy",
"simplyenergy",
"qenergy",
"nectr",
"amberelectric",
]
class AERClient:
def __init__(self, brands: list[str] | None = None) -> None:
self.brands = brands or KNOWN_RETAILER_BRANDS
self._client = httpx.Client(timeout=30)
def fetch_all_plans(self, *, fuel_type: str = "ELECTRICITY") -> list[dict]:
"""Fetch every current, generally-available plan summary across all
configured retailer brands (paginating each retailer's own list)."""
plans: list[dict] = []
for brand in self.brands:
plans.extend(self._fetch_brand_plans(brand, fuel_type))
return plans
def _fetch_brand_plans(self, brand: str, fuel_type: str) -> list[dict]:
url = f"{BASE_HOST}/{brand}/cds-au/v1/energy/plans"
params = {"type": "ALL", "fuelType": fuel_type, "effective": "CURRENT", "page-size": 100, "page": 1}
plans: list[dict] = []
while True:
resp = self._client.get(url, params=params, headers={"x-v": "1"})
if resp.status_code == 404:
# Brand slug no longer valid, or this retailer hasn't published a CDR endpoint.
return plans
resp.raise_for_status()
body = resp.json()
for plan in body["data"]["plans"]:
plan.setdefault("brand", brand)
plans.append(plan)
total_pages = body.get("meta", {}).get("totalPages", 1)
if params["page"] >= total_pages:
break
params["page"] += 1
time.sleep(0.2) # be a polite citizen of a public government API
return plans
def fetch_plan_detail(self, brand: str, plan_id: str) -> dict:
url = f"{BASE_HOST}/{brand}/cds-au/v1/energy/plans/{plan_id}"
resp = self._client.get(url, headers={"x-v": "3"})
resp.raise_for_status()
return resp.json()["data"]