commit 84c4922bbcea2421acd60dfceaa25460896a7a5e Author: dlawler489 <104159223@student.swin.edu.au> Date: Wed Sep 9 11:07:01 2026 +1000 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..efb53c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Copy to .env and fill in. See SETUP.md for how to get iSolarCloud credentials. + +DB_PATH=power_compare.db + +# --- iSolarCloud (leave USE_MOCK_ISOLARCLOUD=true until these are set up) --- +USE_MOCK_ISOLARCLOUD=true +ISOLARCLOUD_AUTH_MODE=oauth2 +ISOLARCLOUD_REGION=australia +ISOLARCLOUD_PLANT_ID= + +# -- oauth2 mode (recommended -- see SETUP.md for why v1 login is unreliable) -- +ISOLARCLOUD_APP_KEY= +ISOLARCLOUD_APP_SECRET= +ISOLARCLOUD_OAUTH_APP_ID= +ISOLARCLOUD_OAUTH_REDIRECT_URI=https://localhost:8000/isolarcloud/callback + +# -- v1 mode (username/password login -- kept for reference, uncomment ISOLARCLOUD_AUTH_MODE=v1 to use) -- +# ISOLARCLOUD_USERNAME= +# ISOLARCLOUD_PASSWORD= +# Only needed if your developer portal application was issued an RSA +# public key (Settings -> Open API) -- paste it exactly as given, +# including the BEGIN/END lines, wrapped in quotes so the newlines survive: +# ISOLARCLOUD_RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- +# MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... +# -----END PUBLIC KEY-----" +# ISOLARCLOUD_RSA_PUBLIC_KEY= + +# --- Household / AER plan scoping --- +DISTRIBUTOR=Energex +POSTCODE= + +# --- Scheduler cadence --- +ISOLARCLOUD_POLL_INTERVAL_MINUTES=10 +AER_REFRESH_INTERVAL_HOURS=24 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7f36f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +*.egg-info/ +__pycache__/ +*.pyc +.pytest_cache/ +.env +*.db +.DS_Store +.claude/ +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f68b13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY pyproject.toml ./ +COPY app ./app +COPY scripts ./scripts + +RUN pip install --no-cache-dir . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..38d7ac3 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Power Compare + +Compares your Sungrow solar system's actual usage against every electricity +plan the AER publicly lists for your Queensland distributor, so you can see +which plan would actually cost you the least given how much you generate, +consume, import and export -- no battery/storage assumed. + +## How it works + +- An always-on background scheduler polls your Sungrow iSolarCloud plant + every few minutes and stores interval generation/load/import/export data + in a local SQLite database, building up history over time. +- It also periodically refreshes a cache of every current QLD electricity + plan from the AER's public "Get Generic Plans" / "Get Generic Plan + Detail" APIs. +- A costing engine evaluates every cached plan (flat or time-of-use, with + supply charge and feed-in tariff credit) against your actual usage + history and ranks them by estimated annual cost, compared against your + own plan (entered manually in Settings). +- A small web dashboard shows your usage over time and the plan comparison + table. + +## Quick start (no real solar data yet) + +The app runs entirely on synthetic mock usage data until you've registered +a Sungrow developer app (see [SETUP.md](SETUP.md)) -- useful for seeing how +the comparison works today. + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +cp .env.example .env + +# optional: instantly backfill a year of synthetic usage data instead of +# waiting for the scheduler to accumulate it in real time +python scripts/seed_sample_data.py --days 365 + +uvicorn app.main:app --reload +``` + +Then open http://localhost:8000, set your distributor/postcode and current +plan under Settings, and check the Plan Comparison page. + +## Running the tests + +```bash +pytest +``` + +The costing engine and the AER plan mapper are both unit-tested, including +against real plan data fetched from the live AER API (see +`app/aer/tests/sample_*.json`). + +## Connecting your real Sungrow system + +See [SETUP.md](SETUP.md) for registering a developer app on the iSolarCloud +portal and connecting your account. + +## Running as an always-on service + +```bash +docker compose up -d +``` + +This persists the SQLite database in a named Docker volume and restarts +automatically. See `.env.example` for configuration. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..56554b4 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,100 @@ +# Connecting your real Sungrow system + +Until you complete this, the app runs on synthetic mock usage data +(`USE_MOCK_ISOLARCLOUD=true`, the default) so the rest of the app is fully +usable in the meantime. + +## What you need + +Two separate things, both free: + +1. A **regular iSolarCloud account** -- the same one you use in the app to + watch your system (username/password). +2. A **developer application** on the [Sungrow developer + portal](https://developer-api.isolarcloud.com/), which gives you an + **App Key** and **Secret Key** (and possibly an **RSA public key** -- + see below). When creating the application, choose the **"V1 account + login"** option rather than OAuth2.0 -- this app talks to the plain + username/password login endpoint, not the OAuth2 authorization-code + flow (which uses a different set of endpoints and an Application ID / + redirect URI this app doesn't use). + +Approval is manual on Sungrow's side and can take a couple of days. + +## Configure + +In `.env`: + +``` +USE_MOCK_ISOLARCLOUD=false +ISOLARCLOUD_APP_KEY= +ISOLARCLOUD_APP_SECRET= +ISOLARCLOUD_USERNAME= +ISOLARCLOUD_PASSWORD= +ISOLARCLOUD_REGION=australia +``` + +If your application's portal page also shows an **RSA public key** +(Settings -> Open API), some accounts require the password to be +RSA-encrypted before it's sent rather than plain text. Paste it into +`ISOLARCLOUD_RSA_PUBLIC_KEY` (see the commented example in `.env.example` +for the multi-line quoting). If login fails with the key set, try leaving +it blank -- not every app type needs it, and this app tries plain-text +login as the alternative. + +Restart the app, then in Settings click **Test connection**. On success it +lists every plant visible to your account -- pick the right one if you +have more than one (most people don't, and can skip this). + +## Verifying before trusting the numbers + +The exact wire format Sungrow expects was pieced together from a +community-maintained, actively-used Home Assistant integration +([steven-g-w/HA-Sungrow](https://github.com/steven-g-w/HA-Sungrow)) built +against this same portal and app type, since Sungrow's own docs are only +visible after logging into the developer portal. Two things in +`app/isolarcloud/client.py` are explicitly flagged there as best-effort +rather than independently confirmed, and worth checking once you're +connected: + +1. **RSA password encryption**, if your app was issued a key -- the exact + padding scheme isn't published anywhere public; `_encrypt_password` + uses the common RSA/PKCS1v1.5 + base64 convention. If "Test connection" + fails, check the portal's own "Documentation" page (visible after + login) for the login endpoint's exact field/encryption spec, and adjust + `_encrypt_password` if it differs. +2. **Which device is which.** iSolarCloud reports generation, load, and + grid import/export per *device* (inverter, meter, ...), not per plant. + `fetch_interval_data` assumes one meter device (for import/export/load) + and one inverter device (for generation), which fits the common + single-inverter household setup this app targets. Run: + + ```bash + python scripts/verify_isolarcloud_cli.py devices --ps-id + ``` + + and confirm it finds exactly one of each. If your system has multiple + inverters or a different device layout, `fetch_interval_data` in + `app/isolarcloud/client.py` will need adjusting to combine them. + +Then check the actual numbers look right: + +```bash +python scripts/verify_isolarcloud_cli.py history --hours 6 +``` + +Compare the printed generation/load/import/export figures against what +the iSolarCloud app itself shows for the same window. Also worth noting at +this point, since it affects how accurate the time-of-use plan comparisons +can be: + +- **History retention**: how far back the minute-data endpoint actually + returns data for your account (days? weeks? months?). The app is + designed to be honest about small sample sizes and improve automatically + as local history accumulates either way. +- **True interval resolution**: confirm you're actually getting 5-minute + granularity, not a coarser aggregate -- time-of-use plans need at least + 30-minute resolution to be modeled accurately. + +Once satisfied, the scheduler will poll your real plant on the same +schedule it was polling mock data -- no other change needed. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/aer/__init__.py b/app/aer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/aer/cache.py b/app/aer/cache.py new file mode 100644 index 0000000..0b00a68 --- /dev/null +++ b/app/aer/cache.py @@ -0,0 +1,42 @@ +"""Refreshes the local `plan_cache` table from the live AER APIs. + +Run periodically (see scheduler/jobs.py) rather than per page-load: pulling +every configured retailer's full plan list, plus a detail call per plan, is +too slow and too polite-unfriendly to a public government API to do on +every request. +""" + +from __future__ import annotations + +import logging + +from app.aer.client import AERClient +from app.costing.aer_mapper import map_aer_plan +from app.db.repositories import upsert_plan +from app.db.session import get_session + +_LOGGER = logging.getLogger(__name__) + + +def refresh_plan_cache(distributor_filter: str | None = None) -> int: + """Fetches every retailer's current electricity plans and upserts them + into `plan_cache`. If `distributor_filter` is given (case-insensitive + substring), only plans available in that distributor's area are kept, + since fetching plan *detail* is the slow part and most retailers serve + many distributor areas we don't care about.""" + client = AERClient() + count = 0 + for list_entry in client.fetch_all_plans(): + distributors = list_entry.get("geography", {}).get("distributors", []) + if distributor_filter and not any(distributor_filter.lower() in d.lower() for d in distributors): + continue + try: + detail = client.fetch_plan_detail(list_entry["brand"], list_entry["planId"]) + plan = map_aer_plan(list_entry, detail) + except Exception: + _LOGGER.exception("Failed to fetch/parse plan %s from %s", list_entry.get("planId"), list_entry.get("brand")) + continue + with get_session() as session: + upsert_plan(session, plan, raw_detail=detail) + count += 1 + return count diff --git a/app/aer/client.py b/app/aer/client.py new file mode 100644 index 0000000..ce308ce --- /dev/null +++ b/app/aer/client.py @@ -0,0 +1,93 @@ +"""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//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"] diff --git a/app/aer/tests/__init__.py b/app/aer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/aer/tests/sample_agl_qld_plan.json b/app/aer/tests/sample_agl_qld_plan.json new file mode 100644 index 0000000..e7e9e23 --- /dev/null +++ b/app/aer/tests/sample_agl_qld_plan.json @@ -0,0 +1,731 @@ +{ + "list_entry": { + "type": "MARKET", + "brand": "agl", + "planId": "AGL1067103MRE2@EME", + "fuelType": "ELECTRICITY", + "brandName": "AGL", + "geography": { + "distributors": [ + "Energex" + ], + "includedPostcodes": [ + "4000", + "4005", + "4006", + "4007", + "4008", + "4009", + "4010", + "4011", + "4012", + "4013", + "4014", + "4017", + "4018", + "4019", + "4020", + "4021", + "4022", + "4030", + "4031", + "4032", + "4034", + "4035", + "4036", + "4037", + "4051", + "4053", + "4054", + "4055", + "4059", + "4060", + "4061", + "4064", + "4065", + "4066", + "4067", + "4068", + "4069", + "4070", + "4073", + "4074", + "4075", + "4076", + "4077", + "4078", + "4101", + "4102", + "4103", + "4104", + "4105", + "4106", + "4107", + "4108", + "4109", + "4110", + "4111", + "4112", + "4113", + "4114", + "4115", + "4116", + "4117", + "4118", + "4119", + "4120", + "4121", + "4122", + "4123", + "4124", + "4125", + "4127", + "4128", + "4129", + "4130", + "4131", + "4132", + "4133", + "4151", + "4152", + "4153", + "4154", + "4155", + "4156", + "4157", + "4158", + "4159", + "4160", + "4161", + "4163", + "4164", + "4165", + "4169", + "4170", + "4171", + "4172", + "4173", + "4174", + "4178", + "4179", + "4183", + "4184", + "4205", + "4207", + "4208", + "4209", + "4210", + "4211", + "4212", + "4213", + "4214", + "4215", + "4216", + "4217", + "4218", + "4220", + "4221", + "4223", + "4224", + "4225", + "4226", + "4227", + "4228", + "4270", + "4271", + "4272", + "4275", + "4280", + "4285", + "4287", + "4300", + "4301", + "4303", + "4304", + "4305", + "4306", + "4307", + "4309", + "4310", + "4311", + "4312", + "4313", + "4314", + "4340", + "4341", + "4342", + "4343", + "4344", + "4346", + "4347", + "4350", + "4352", + "4359", + "4500", + "4501", + "4502", + "4503", + "4504", + "4505", + "4506", + "4507", + "4508", + "4509", + "4510", + "4511", + "4512", + "4513", + "4514", + "4515", + "4516", + "4517", + "4518", + "4519", + "4520", + "4521", + "4550", + "4551", + "4552", + "4553", + "4554", + "4555", + "4556", + "4557", + "4558", + "4559", + "4560", + "4561", + "4562", + "4563", + "4564", + "4565", + "4566", + "4567", + "4568", + "4569", + "4570", + "4571", + "4572", + "4573", + "4574", + "4575", + "4580", + "4581" + ] + }, + "displayName": "Residential Smart Saver - Westpac", + "lastUpdated": "2026-07-09T14:06:31.122Z", + "customerType": "RESIDENTIAL", + "effectiveFrom": "2026-07-10T00:00:00.000Z" + }, + "detail": { + "type": "MARKET", + "brand": "agl", + "planId": "AGL1067103MRE2@EME", + "fuelType": "ELECTRICITY", + "brandName": "AGL", + "geography": { + "distributors": [ + "Energex" + ], + "includedPostcodes": [ + "4000", + "4005", + "4006", + "4007", + "4008", + "4009", + "4010", + "4011", + "4012", + "4013", + "4014", + "4017", + "4018", + "4019", + "4020", + "4021", + "4022", + "4030", + "4031", + "4032", + "4034", + "4035", + "4036", + "4037", + "4051", + "4053", + "4054", + "4055", + "4059", + "4060", + "4061", + "4064", + "4065", + "4066", + "4067", + "4068", + "4069", + "4070", + "4073", + "4074", + "4075", + "4076", + "4077", + "4078", + "4101", + "4102", + "4103", + "4104", + "4105", + "4106", + "4107", + "4108", + "4109", + "4110", + "4111", + "4112", + "4113", + "4114", + "4115", + "4116", + "4117", + "4118", + "4119", + "4120", + "4121", + "4122", + "4123", + "4124", + "4125", + "4127", + "4128", + "4129", + "4130", + "4131", + "4132", + "4133", + "4151", + "4152", + "4153", + "4154", + "4155", + "4156", + "4157", + "4158", + "4159", + "4160", + "4161", + "4163", + "4164", + "4165", + "4169", + "4170", + "4171", + "4172", + "4173", + "4174", + "4178", + "4179", + "4183", + "4184", + "4205", + "4207", + "4208", + "4209", + "4210", + "4211", + "4212", + "4213", + "4214", + "4215", + "4216", + "4217", + "4218", + "4220", + "4221", + "4223", + "4224", + "4225", + "4226", + "4227", + "4228", + "4270", + "4271", + "4272", + "4275", + "4280", + "4285", + "4287", + "4300", + "4301", + "4303", + "4304", + "4305", + "4306", + "4307", + "4309", + "4310", + "4311", + "4312", + "4313", + "4314", + "4340", + "4341", + "4342", + "4343", + "4344", + "4346", + "4347", + "4350", + "4352", + "4359", + "4500", + "4501", + "4502", + "4503", + "4504", + "4505", + "4506", + "4507", + "4508", + "4509", + "4510", + "4511", + "4512", + "4513", + "4514", + "4515", + "4516", + "4517", + "4518", + "4519", + "4520", + "4521", + "4550", + "4551", + "4552", + "4553", + "4554", + "4555", + "4556", + "4557", + "4558", + "4559", + "4560", + "4561", + "4562", + "4563", + "4564", + "4565", + "4566", + "4567", + "4568", + "4569", + "4570", + "4571", + "4572", + "4573", + "4574", + "4575", + "4580", + "4581" + ] + }, + "displayName": "Residential Smart Saver - Westpac", + "lastUpdated": "2026-07-09T14:06:31.122Z", + "customerType": "RESIDENTIAL", + "effectiveFrom": "2026-07-10T00:00:00.000Z", + "electricityContract": { + "fees": [ + { + "term": "FIXED", + "type": "CONNECTION", + "amount": "22.96", + "description": "May be charged when manually reconnecting or reading your meter when you move into a property or change retailer. Incl GST. Fees may vary." + }, + { + "term": "FIXED", + "type": "DISCONNECT_MOVE_OUT", + "amount": "22.96", + "description": "May be charged when manually disconnecting or reading your meter when you move out of a property or change retailer. Incl GST. Fees may vary." + }, + { + "term": "FIXED", + "type": "DISCONNECT_NON_PAY", + "amount": "22.96", + "description": "Fee may be charged when manually disconnecting your meter in other circumstances, such as non-payment. Includes GST. Fees may vary." + }, + { + "term": "FIXED", + "type": "RECONNECTION", + "amount": "53.05", + "description": "May be charged when manually reconnecting in other circumstances, such as after disconnection for non-payment. Includes GST. Fees may vary" + }, + { + "term": "FIXED", + "type": "LATE_PAYMENT", + "amount": "12.00", + "description": "A late payment fee may be charged when full payment has not been received by the bill due date. This amount is not subject to GST" + }, + { + "rate": "0.0054", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING", + "description": "The amount is GST inclusive and applies to card payments made at Australia Post outlets." + }, + { + "term": "FIXED", + "type": "OTHER", + "amount": "3.30", + "description": "An over the counter payment fee may apply for payments made in-person at a Post Office. Includes GST." + }, + { + "term": "FIXED", + "type": "PAPER_BILL", + "amount": "1.75", + "description": "A paper bill fee may apply for each bill sent by post. Includes GST." + }, + { + "rate": "0.0014", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING", + "description": "The amount is GST inclusive and applies to payments made by Visa debit cards." + }, + { + "rate": "0.0065", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING", + "description": "The amount is GST inclusive and applies to payments made by Visa credit cards." + }, + { + "rate": "0.0032", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING", + "description": "The amount is GST inclusive and applies to payments made by Mastercard debit cards." + }, + { + "rate": "0.0078", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING", + "description": "The amount is GST inclusive and applies to payments made by Mastercard credit cards." + } + ], + "terms": "This offer applies to customers with an applicable network tariff. For solar feed-in tariff eligibility, or for further information about the terms and conditions applicable to this energy offer, please contact AGL on 131 245 or visit agl.com.au.", + "isFixed": false, + "termType": "ONGOING", + "timeZone": "LOCAL", + "variation": "This plan has variable rates, fees and charges, and may include energy payments (e.g. Solar Feed-in Tariff) that can change with notice to you. Increases to rates, fees or charges, or reductions to energy payments, will occur in July with at least ten business days' notice. An increase to a tariff or charge or decrease in an energy payment made as a direct result of a tariff reassignment by your distributor may still occur at anytime.", + "incentives": [ + { + "category": "OTHER", + "description": "Remain with AGL for at least 30 days from sign up to receive one $50 Prepaid Digital Mastercard per customer", + "displayName": "Bonus $50 Digital Mastercard" + } + ], + "meterTypes": [ + "Type 4", + "Type 4a", + "Type 5", + "Type 6" + ], + "eligibility": [ + { + "type": "OTHER", + "description": "Offer available to Westpac credit or debit customers where AGL operates. Customers access offer via the Rewards & Offers hub from their Westpac online banking account (post login) or by calling 131 245 and quoting offer WESTPAC and remain on that plan for at least 30 days from sign up", + "information": "You're a Westpac customer" + } + ], + "pricingModel": "TIME_OF_USE_CONT_LOAD", + "tariffPeriod": [ + { + "endDate": "06-30", + "startDate": "07-01", + "displayName": "Time of Use Tariff Period", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "type": "PEAK", + "rates": [ + { + "unitPrice": "0.4127", + "measureUnit": "KWH" + } + ], + "period": "P1Y", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "20:59", + "startTime": "16:00" + } + ], + "description": "4pm - 9pm", + "displayName": "Time of Use Peak" + }, + { + "type": "OFF_PEAK", + "rates": [ + { + "unitPrice": "0.06032", + "measureUnit": "KWH" + } + ], + "period": "P1Y", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "15:59", + "startTime": "11:00" + } + ], + "description": "11am - 4pm", + "displayName": "Time of Use Off Peak" + }, + { + "type": "SHOULDER", + "rates": [ + { + "unitPrice": "0.21846", + "measureUnit": "KWH" + } + ], + "period": "P1Y", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "10:59", + "startTime": "21:00" + } + ], + "description": "9pm - 11am", + "displayName": "Time of Use Shoulder" + } + ], + "dailySupplyCharge": "1.536", + "dailySupplyChargeType": "SINGLE" + } + ], + "benefitPeriod": "Ongoing", + "billFrequency": [ + "P3M", + "P1M" + ], + "paymentOption": [ + "PAPER_BILL", + "DIRECT_DEBIT", + "CREDIT_CARD", + "BPAY" + ], + "controlledLoad": [ + { + "singleRate": { + "rates": [ + { + "unitPrice": "0.1417", + "measureUnit": "KWH" + } + ], + "period": "P1Y", + "description": "Separately metered usage. The hours of application are determined by your Distributor.", + "displayName": "Controlled Load 1", + "dailySupplyCharge": "0.00" + }, + "displayName": "Controlled Load 1", + "rateBlockUType": "singleRate" + }, + { + "singleRate": { + "rates": [ + { + "unitPrice": "0.1445", + "measureUnit": "KWH" + } + ], + "period": "P1Y", + "description": "Separately metered usage. The hours of application are determined by your Distributor.", + "displayName": "Controlled Load 2", + "dailySupplyCharge": "0.00" + }, + "displayName": "Controlled Load 2", + "rateBlockUType": "singleRate" + } + ], + "coolingOffDays": 10, + "greenPowerCharges": [ + { + "type": "FIXED_PER_UNIT", + "tiers": [ + { + "amount": "0.011", + "percentGreen": "1.0" + } + ], + "scheme": "GREENPOWER", + "description": "For 1.1c /kWh inc. GST we ensure energy equal to 100% of your usage will be fed into the grid from Accredited GreenPower generators.", + "displayName": "Green Power Charge" + } + ], + "solarFeedInTariff": [ + { + "scheme": "OTHER", + "payerType": "RETAILER", + "description": "AGL Retailer Feed-in Tariff (exc GST if any)", + "displayName": "AGL Retailer Feed-in Tariff (exc GST if any)", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [ + { + "unitPrice": "0.02", + "measureUnit": "KWH" + } + ], + "period": "P1Y" + } + }, + { + "scheme": "PREMIUM", + "payerType": "GOVERNMENT", + "description": "Solar bonus scheme - Premium FiT", + "displayName": "Solar bonus scheme - Premium FiT", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [ + { + "unitPrice": "0.44", + "measureUnit": "KWH" + } + ], + "period": "P1Y" + } + } + ], + "onExpiryDescription": "Your market contract is ongoing. From time to time, AGL reviews its offers and this offer will be reviewed with consideration to AGL's generally available market offers", + "additionalFeeInformation": "Additional fees and charges may apply. Please see the AGL fee schedules at agl.com.au/fees" + } + } +} \ No newline at end of file diff --git a/app/aer/tests/sample_ea_qld_plan.json b/app/aer/tests/sample_ea_qld_plan.json new file mode 100644 index 0000000..32b082c --- /dev/null +++ b/app/aer/tests/sample_ea_qld_plan.json @@ -0,0 +1,701 @@ +{ + "list_entry": { + "type": "MARKET", + "brand": "energyaustralia", + "planId": "ENE977080MBE6@EME", + "fuelType": "ELECTRICITY", + "brandName": "EnergyAustralia", + "geography": { + "distributors": [ + "Energex" + ], + "includedPostcodes": [ + "4000", + "4005", + "4006", + "4007", + "4008", + "4009", + "4010", + "4011", + "4012", + "4013", + "4014", + "4017", + "4018", + "4019", + "4020", + "4021", + "4022", + "4030", + "4031", + "4032", + "4034", + "4035", + "4036", + "4037", + "4051", + "4053", + "4054", + "4055", + "4059", + "4060", + "4061", + "4064", + "4065", + "4066", + "4067", + "4068", + "4069", + "4070", + "4073", + "4074", + "4075", + "4076", + "4077", + "4078", + "4101", + "4102", + "4103", + "4104", + "4105", + "4106", + "4107", + "4108", + "4109", + "4110", + "4111", + "4112", + "4113", + "4114", + "4115", + "4116", + "4117", + "4118", + "4119", + "4120", + "4121", + "4122", + "4123", + "4124", + "4125", + "4127", + "4128", + "4129", + "4130", + "4131", + "4132", + "4133", + "4151", + "4152", + "4153", + "4154", + "4155", + "4156", + "4157", + "4158", + "4159", + "4160", + "4161", + "4163", + "4164", + "4165", + "4169", + "4170", + "4171", + "4172", + "4173", + "4174", + "4178", + "4179", + "4183", + "4184", + "4205", + "4207", + "4208", + "4209", + "4210", + "4211", + "4212", + "4213", + "4214", + "4215", + "4216", + "4217", + "4218", + "4220", + "4221", + "4223", + "4224", + "4225", + "4226", + "4227", + "4228", + "4270", + "4271", + "4272", + "4275", + "4280", + "4285", + "4287", + "4300", + "4301", + "4303", + "4304", + "4305", + "4306", + "4307", + "4309", + "4310", + "4311", + "4312", + "4313", + "4314", + "4340", + "4341", + "4342", + "4343", + "4344", + "4346", + "4347", + "4350", + "4352", + "4359", + "4500", + "4501", + "4502", + "4503", + "4504", + "4505", + "4506", + "4507", + "4508", + "4509", + "4510", + "4511", + "4512", + "4513", + "4514", + "4515", + "4516", + "4517", + "4518", + "4519", + "4520", + "4521", + "4550", + "4551", + "4552", + "4553", + "4554", + "4555", + "4556", + "4557", + "4558", + "4559", + "4560", + "4561", + "4562", + "4563", + "4564", + "4565", + "4566", + "4567", + "4568", + "4569", + "4570", + "4571", + "4572", + "4573", + "4574", + "4575", + "4580", + "4581" + ] + }, + "displayName": "Business Balance Plan 12 - TOU with CL 1", + "lastUpdated": "2026-08-31T14:06:43.830Z", + "customerType": "BUSINESS", + "effectiveFrom": "2026-09-01T00:00:00.000Z" + }, + "detail": { + "type": "MARKET", + "brand": "energyaustralia", + "planId": "ENE977080MBE6@EME", + "fuelType": "ELECTRICITY", + "brandName": "EnergyAustralia", + "geography": { + "distributors": [ + "Energex" + ], + "includedPostcodes": [ + "4000", + "4005", + "4006", + "4007", + "4008", + "4009", + "4010", + "4011", + "4012", + "4013", + "4014", + "4017", + "4018", + "4019", + "4020", + "4021", + "4022", + "4030", + "4031", + "4032", + "4034", + "4035", + "4036", + "4037", + "4051", + "4053", + "4054", + "4055", + "4059", + "4060", + "4061", + "4064", + "4065", + "4066", + "4067", + "4068", + "4069", + "4070", + "4073", + "4074", + "4075", + "4076", + "4077", + "4078", + "4101", + "4102", + "4103", + "4104", + "4105", + "4106", + "4107", + "4108", + "4109", + "4110", + "4111", + "4112", + "4113", + "4114", + "4115", + "4116", + "4117", + "4118", + "4119", + "4120", + "4121", + "4122", + "4123", + "4124", + "4125", + "4127", + "4128", + "4129", + "4130", + "4131", + "4132", + "4133", + "4151", + "4152", + "4153", + "4154", + "4155", + "4156", + "4157", + "4158", + "4159", + "4160", + "4161", + "4163", + "4164", + "4165", + "4169", + "4170", + "4171", + "4172", + "4173", + "4174", + "4178", + "4179", + "4183", + "4184", + "4205", + "4207", + "4208", + "4209", + "4210", + "4211", + "4212", + "4213", + "4214", + "4215", + "4216", + "4217", + "4218", + "4220", + "4221", + "4223", + "4224", + "4225", + "4226", + "4227", + "4228", + "4270", + "4271", + "4272", + "4275", + "4280", + "4285", + "4287", + "4300", + "4301", + "4303", + "4304", + "4305", + "4306", + "4307", + "4309", + "4310", + "4311", + "4312", + "4313", + "4314", + "4340", + "4341", + "4342", + "4343", + "4344", + "4346", + "4347", + "4350", + "4352", + "4359", + "4500", + "4501", + "4502", + "4503", + "4504", + "4505", + "4506", + "4507", + "4508", + "4509", + "4510", + "4511", + "4512", + "4513", + "4514", + "4515", + "4516", + "4517", + "4518", + "4519", + "4520", + "4521", + "4550", + "4551", + "4552", + "4553", + "4554", + "4555", + "4556", + "4557", + "4558", + "4559", + "4560", + "4561", + "4562", + "4563", + "4564", + "4565", + "4566", + "4567", + "4568", + "4569", + "4570", + "4571", + "4572", + "4573", + "4574", + "4575", + "4580", + "4581" + ] + }, + "displayName": "Business Balance Plan 12 - TOU with CL 1", + "lastUpdated": "2026-08-31T14:06:43.830Z", + "customerType": "BUSINESS", + "effectiveFrom": "2026-09-01T00:00:00.000Z", + "electricityContract": { + "fees": [ + { + "term": "FIXED", + "type": "LATE_PAYMENT", + "amount": "12.00", + "description": "$12.00 (GST does not currently apply to late payment fees)." + }, + { + "rate": "0.0036", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING", + "description": "Credit card merchant service fee of 0.36% for all payments with Visa or Mastercard." + }, + { + "rate": "0.015", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING", + "description": "Credit card merchant service fee of 1.5% for all payments with American Express." + }, + { + "term": "FIXED", + "type": "DISCONNECTION", + "amount": "0.00", + "description": "A disconnection fee may apply (incl. moving or reading your meter). Fees may vary and see additional fee information for details." + }, + { + "term": "FIXED", + "type": "RECONNECTION", + "amount": "0.00", + "description": "A reconnection fee may apply (incl. moving or reading your meter). Fees may vary and see additional fee information for details." + }, + { + "term": "FIXED", + "type": "CONNECTION", + "amount": "0.00", + "description": "A connection fee may apply when you move into a property. Fees may vary and see additional fee information for more details." + } + ], + "terms": "Quarterly billing unless monthly billing is applied for eligible customers. Further information regarding billing frequency is available at https://www.energyaustralia.com.au/business/help-and-support/faqs/accounts-bills-and-payments. Please refer to www.energyaustralia.com.au for eligibility information relating to solar, as well as full terms and conditions.", + "isFixed": false, + "termType": "ONGOING", + "timeZone": "LOCAL", + "discounts": [ + { + "type": "GUARANTEED", + "description": "Discount applies to market energy charges, and not to fees and other charges such as late payment fees or GreenPower.", + "displayName": "Guaranteed Discount", + "methodUType": "percentOfBill", + "percentOfBill": { + "rate": "0.1" + } + } + ], + "variation": "Prices may be varied in line with your terms and conditions. We will give you notice before any new prices take effect.", + "meterTypes": [ + "Type 4", + "Type 4a" + ], + "pricingModel": "TIME_OF_USE_CONT_LOAD", + "tariffPeriod": [ + { + "endDate": "06-30", + "startDate": "07-01", + "displayName": "All year", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "type": "PEAK", + "rates": [ + { + "unitPrice": "0.521216", + "measureUnit": "KWH" + } + ], + "period": "P1D", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "19:59", + "startTime": "17:00" + } + ], + "description": "Between 5pm and 8pm Mon-Fri", + "displayName": "Peak" + }, + { + "type": "OFF_PEAK", + "rates": [ + { + "unitPrice": "0.079411", + "measureUnit": "KWH" + } + ], + "period": "P1D", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "12:59", + "startTime": "11:00" + } + ], + "description": "Between 11am and 1 pm, Mon-Sun", + "displayName": "Off-peak" + }, + { + "type": "SHOULDER", + "rates": [ + { + "unitPrice": "0.245935", + "measureUnit": "KWH" + } + ], + "period": "P1D", + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "10:59", + "startTime": "20:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "16:59", + "startTime": "13:00" + }, + { + "days": [ + "SAT", + "SUN" + ], + "endTime": "10:59", + "startTime": "13:00" + } + ], + "description": "All other times", + "displayName": "Shoulder" + } + ], + "dailySupplyCharge": "2.3765", + "dailySupplyChargeType": "SINGLE" + } + ], + "benefitPeriod": "1 Year", + "billFrequency": [ + "P1M", + "P3M" + ], + "paymentOption": [ + "PAPER_BILL", + "DIRECT_DEBIT", + "BPAY", + "CREDIT_CARD" + ], + "controlledLoad": [ + { + "singleRate": { + "rates": [ + { + "unitPrice": "0.149384", + "measureUnit": "KWH" + } + ], + "period": "P1D", + "description": "Separately metered usage. The hours of application are determined by your Distributor.", + "displayName": "Super Economy Tariff 31", + "dailySupplyCharge": "0.00" + }, + "displayName": "Super Economy Tariff 31", + "rateBlockUType": "singleRate" + } + ], + "coolingOffDays": 10, + "greenPowerCharges": [ + { + "type": "FIXED_PER_UNIT", + "tiers": [ + { + "amount": "0.0495", + "percentGreen": "0.3" + } + ], + "scheme": "GREENPOWER", + "description": "GreenPower 30% = $0.0495 x (30% x total usage)", + "displayName": "GreenPower" + }, + { + "type": "FIXED_PER_UNIT", + "tiers": [ + { + "amount": "0.0495", + "percentGreen": "1.0" + } + ], + "scheme": "GREENPOWER", + "description": "GreenPower 100% = $0.0495 x (100% x total usage)", + "displayName": "GreenPower" + } + ], + "solarFeedInTariff": [ + { + "scheme": "OTHER", + "payerType": "RETAILER", + "description": "EnergyAustralia Retailer Feed-in Tariff (incl. GST if any)", + "displayName": "EnergyAustralia Retailer Feed-in Tariff (incl. GST if any)", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [ + { + "unitPrice": "0.044", + "measureUnit": "KWH" + } + ], + "period": "P1Y" + } + }, + { + "scheme": "PREMIUM", + "payerType": "GOVERNMENT", + "description": "Solar Bonus Scheme pre 10/07/2012", + "displayName": "Solar Bonus Scheme pre 10/07/2012", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [ + { + "unitPrice": "0.44", + "measureUnit": "KWH" + } + ], + "period": "P1Y" + } + } + ], + "onExpiryDescription": "The Benefit Period for this plan is 12 months. We will provide you with notice of your options prior to the end of the Benefit Period. Your contract will continue until it is ended by either party.", + "additionalFeeInformation": "Additional fees and charges may apply. Please refer to www.energyaustralia.com.au/conditions-and-pricing/other-charges." + } + } +} \ No newline at end of file diff --git a/app/aer/tests/sample_energyaustralia_qld_tiered_feedin_plan.json b/app/aer/tests/sample_energyaustralia_qld_tiered_feedin_plan.json new file mode 100644 index 0000000..2c2c0b2 --- /dev/null +++ b/app/aer/tests/sample_energyaustralia_qld_tiered_feedin_plan.json @@ -0,0 +1,43 @@ +{ + "list_entry": { + "planId": "EAU000000MRE1", + "brandName": "EnergyAustralia", + "geography": { "distributors": ["Energex"] } + }, + "detail": { + "displayName": "Solar Max", + "electricityContract": { + "tariffPeriod": [ + { + "startDate": "01-01", + "endDate": "12-31", + "displayName": "Single Rate", + "rateBlockUType": "singleRate", + "dailySupplyCharge": "1.746", + "singleRate": { + "displayName": "Peak", + "rates": [{ "unitPrice": "0.254", "measureUnit": "KWH" }] + } + } + ], + "solarFeedInTariff": [ + { + "payerType": "RETAILER", + "displayName": "Solar Max Superior Feed-in-Tariff", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [{ "volume": 10, "unitPrice": "0.08", "measureUnit": "KWH" }] + } + }, + { + "payerType": "RETAILER", + "displayName": "Retailer Feed-in-Tariff", + "tariffUType": "singleTariff", + "singleTariff": { + "rates": [{ "unitPrice": "0.04", "measureUnit": "KWH" }] + } + } + ] + } + } +} diff --git a/app/aer/tests/sample_origin_qld_battery_plan.json b/app/aer/tests/sample_origin_qld_battery_plan.json new file mode 100644 index 0000000..0d53e66 --- /dev/null +++ b/app/aer/tests/sample_origin_qld_battery_plan.json @@ -0,0 +1,64 @@ +{ + "list_entry": { + "planId": "ORI1161047MRE1@EME", + "brandName": "Origin Energy", + "geography": { "distributors": ["Energex"] } + }, + "detail": { + "displayName": "Origin Battery Starter", + "electricityContract": { + "tariffPeriod": [ + { + "startDate": "07-01", + "endDate": "06-30", + "displayName": "Time of Use Tariff Period", + "rateBlockUType": "timeOfUseRates", + "dailySupplyCharge": "1.355", + "timeOfUseRates": [ + { + "type": "PEAK", + "displayName": "Peak", + "rates": [{ "unitPrice": "0.299", "measureUnit": "KWH" }], + "timeOfUse": [ + { "days": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], "startTime": "16:00", "endTime": "20:59" } + ] + }, + { + "type": "OFF_PEAK", + "displayName": "Off peak", + "rates": [{ "unitPrice": "0.259", "measureUnit": "KWH" }], + "timeOfUse": [ + { "days": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], "startTime": "21:00", "endTime": "15:59" } + ] + } + ] + } + ], + "solarFeedInTariff": [ + { + "payerType": "RETAILER", + "displayName": "Solar FIT", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "type": "PEAK", + "displayName": "PEAK", + "rates": [{ "unitPrice": "0.28", "measureUnit": "KWH" }], + "timeVariations": [ + { "days": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], "startTime": "16:00", "endTime": "20:59" } + ] + }, + { + "type": "OFF_PEAK", + "displayName": "OFF PEAK", + "rates": [{ "unitPrice": "0.03", "measureUnit": "KWH" }], + "timeVariations": [ + { "days": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], "startTime": "21:00", "endTime": "15:59" } + ] + } + ] + } + ] + } + } +} diff --git a/app/aer/tests/test_aer_mapper.py b/app/aer/tests/test_aer_mapper.py new file mode 100644 index 0000000..31486af --- /dev/null +++ b/app/aer/tests/test_aer_mapper.py @@ -0,0 +1,118 @@ +import json +from pathlib import Path +from zoneinfo import ZoneInfo +from datetime import datetime + +import pytest + +from app.costing.aer_mapper import map_aer_plan +from app.costing.engine import IntervalReading, evaluate +from app.costing.tariff_models import find_usage_coverage_gaps + +FIXTURES = Path(__file__).parent + +BNE = ZoneInfo("Australia/Brisbane") + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +def test_maps_real_agl_time_of_use_plan(): + fixture = _load("sample_agl_qld_plan.json") + plan = map_aer_plan(fixture["list_entry"], fixture["detail"]) + + assert plan.modelable + assert plan.retailer == "AGL" + assert "Energex" in plan.distributors + assert len(plan.usage_components) == 3 # peak, off-peak, shoulder + assert plan.supply_charge_c_per_day == pytest.approx(1.536 * 100) + + labels = {c.label for c in plan.usage_components} + assert {"Time of Use Peak", "Time of Use Off Peak", "Time of Use Shoulder"} <= labels + + # The three TOU windows (4pm-9pm, 9pm-11am, 11am-4pm) should fully + # partition every hour with no gap or overlap. + assert find_usage_coverage_gaps(plan.usage_components) == [] + + # Retailer FiT (2c) should be picked up; the legacy government premium + # scheme (44c) must be excluded since it's not available to a plan comparison. + assert len(plan.feed_in_tariffs) == 1 + assert plan.feed_in_tariffs[0].rate_c_per_kwh == pytest.approx(2.0) + + # Sanity-check the engine can evaluate this real plan end-to-end. + readings = [ + IntervalReading(ts=datetime(2026, 1, 1, 18, tzinfo=BNE), import_kwh=2, export_kwh=0), + IntervalReading(ts=datetime(2026, 1, 1, 8, tzinfo=BNE), import_kwh=1, export_kwh=3), + ] + result = evaluate(plan, readings) + assert result.total_dollars > 0 + # Controlled load (separately metered) and the sign-up incentive are + # real features of this plan, informational rather than costing errors. + assert any("controlled load" in note for note in result.modeling_notes) + assert any("sign-up incentive" in note for note in result.modeling_notes) + + +def test_time_varying_feed_in_tariff_only_credits_its_own_window(): + # Regression test: solarFeedInTariff's timeVaryingTariffs blocks key their + # windows as "timeVariations", not "timeOfUse" (the key used by the + # unrelated usage-side timeOfUseRates shape) -- using the wrong key made + # every time-varying FiT component's time_windows empty, which matches + # ALL day per TimeWindow semantics, so the first-listed component (here, + # the 28c PEAK rate) silently credited 100% of exported energy at the + # peak rate regardless of when it was actually exported. + fixture = _load("sample_origin_qld_battery_plan.json") + plan = map_aer_plan(fixture["list_entry"], fixture["detail"]) + + assert len(plan.feed_in_tariffs) == 2 + for component in plan.feed_in_tariffs: + assert component.time_windows, f"{component.label} has no time windows and will match all day" + + # 10 kWh exported at 9am (off-peak) must be credited at 3c, not 28c. + readings = [IntervalReading(ts=datetime(2026, 1, 1, 9, tzinfo=BNE), import_kwh=0, export_kwh=10)] + result = evaluate(plan, readings) + off_peak = next(c for c in result.feed_in_breakdown if c.label == "OFF PEAK") + peak = next(c for c in result.feed_in_breakdown if c.label == "PEAK") + assert off_peak.kwh == pytest.approx(10.0) + assert peak.kwh == pytest.approx(0.0) + + +def test_two_tier_volume_capped_feed_in_maps_to_tiered_feed_in(): + # Regression test: some plans (e.g. real-world EnergyAustralia "Solar + # Max") represent a daily-volume-based feed-in tariff as two separate + # solarFeedInTariff singleTariff entries -- a capped tier carrying a + # "volume" threshold, and an uncapped "thereafter" tier -- rather than + # multiple rates within one entry. Previously both were mapped as + # independent flat-rate FeedInComponents with no way to distinguish + # which kWh belongs to which tier, so the first-listed (higher, capped) + # rate silently won every match and credited 100% of exported energy + # at the premium rate. + fixture = _load("sample_energyaustralia_qld_tiered_feedin_plan.json") + plan = map_aer_plan(fixture["list_entry"], fixture["detail"]) + + assert plan.feed_in_tariffs == [] + assert plan.tiered_feed_in is not None + assert plan.tiered_feed_in.daily_threshold_kwh == pytest.approx(10.0) + assert plan.tiered_feed_in.rate_below_c_per_kwh == pytest.approx(8.0) + assert plan.tiered_feed_in.rate_above_c_per_kwh == pytest.approx(4.0) + + # 15 kWh exported in one day: 10 kWh at 8c + 5 kWh at 4c. + readings = [IntervalReading(ts=datetime(2026, 1, 1, 12, tzinfo=BNE), import_kwh=0, export_kwh=15)] + result = evaluate(plan, readings) + feed_in_credit = sum(c.cost_dollars for c in result.feed_in_breakdown) + assert feed_in_credit == pytest.approx(-(10 * 0.08 + 5 * 0.04)) + + +def test_maps_real_energyaustralia_plan_with_guaranteed_discount(): + fixture = _load("sample_ea_qld_plan.json") + plan = map_aer_plan(fixture["list_entry"], fixture["detail"]) + + assert len(plan.discounts) == 1 + discount = plan.discounts[0] + assert discount.auto_apply is True # "GUARANTEED" discount type + assert discount.percent == pytest.approx(10.0) + + readings = [IntervalReading(ts=datetime(2026, 1, 1, 12, tzinfo=BNE), import_kwh=5, export_kwh=0)] + result = evaluate(plan, readings) + assert result.unconditional_discount_dollars > 0 + assert result.potential_conditional_savings_dollars == 0 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/isolarcloud.py b/app/api/isolarcloud.py new file mode 100644 index 0000000..e8e0a0f --- /dev/null +++ b/app/api/isolarcloud.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from app.config import settings +from app.db.repositories import get_sync_state +from app.db.session import get_session + +router = APIRouter(prefix="/api/isolarcloud", tags=["isolarcloud"]) + + +@router.get("/status") +def status() -> dict: + token_key = "isolarcloud_oauth_access_token" if settings.isolarcloud_auth_mode == "oauth2" else "isolarcloud_token" + with get_session() as session: + has_token = bool(get_sync_state(session, token_key)) + return {"use_mock": settings.use_mock_isolarcloud, "auth_mode": settings.isolarcloud_auth_mode, "connected": has_token} + + +@router.get("/oauth/authorize-url") +def oauth_authorize_url() -> dict: + from app.isolarcloud.oauth_client import ISolarCloudOAuthClient + + return {"url": ISolarCloudOAuthClient().authorize_url()} + + +class OAuthCodeIn(BaseModel): + code: str + + +@router.post("/oauth/exchange") +def oauth_exchange(body: OAuthCodeIn) -> JSONResponse: + """Completes the OAuth2 consent flow: trades the `code` param (copied + from the address bar after the user approved access) for a token pair, + and returns the plants the user actually authorized.""" + from app.isolarcloud.oauth_client import ISolarCloudAuthError, ISolarCloudOAuthClient + + client = ISolarCloudOAuthClient() + try: + auth_ps_list = client.exchange_code(body.code) + except ISolarCloudAuthError as e: + return JSONResponse({"ok": False, "error": str(e)}, status_code=400) + except Exception as e: + return JSONResponse({"ok": False, "error": f"Unexpected error: {e}"}, status_code=502) + + plants = [] + try: + plants = client.get_plant_list() + except Exception: + pass + plants_by_id = {str(p.get("ps_id")): p.get("ps_name") for p in plants} + return JSONResponse( + { + "ok": True, + "authorized_plants": [{"ps_id": ps_id, "ps_name": plants_by_id.get(ps_id)} for ps_id in auth_ps_list], + } + ) + + +@router.post("/connect") +def connect() -> JSONResponse: + """Logs in with the credentials configured in .env and, on success, + returns the account's visible plants so the user can pick a + `isolarcloud_plant_id` in Settings if they have more than one.""" + from app.isolarcloud.client import ISolarCloudAuthError, ISolarCloudClient + + if settings.use_mock_isolarcloud: + return JSONResponse({"ok": False, "error": "USE_MOCK_ISOLARCLOUD is still true -- set it to false in .env first."}, status_code=400) + + client = ISolarCloudClient() + try: + client.login() + plants = client.get_plant_list() + except ISolarCloudAuthError as e: + return JSONResponse({"ok": False, "error": str(e)}, status_code=400) + except Exception as e: # network/HTTP errors etc. + return JSONResponse({"ok": False, "error": f"Unexpected error: {e}"}, status_code=502) + + return JSONResponse({"ok": True, "plants": [{"ps_id": p.get("ps_id"), "ps_name": p.get("ps_name")} for p in plants]}) diff --git a/app/api/plans.py b/app/api/plans.py new file mode 100644 index 0000000..8bb9584 --- /dev/null +++ b/app/api/plans.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Query + +from app.costing.compare import compare_plans +from app.db.repositories import get_current_plan, get_household_config, set_current_plan +from app.db.session import get_session + +router = APIRouter(prefix="/api/plans", tags=["plans"]) + + +@router.get("/compare") +def compare(days: int = Query(90, ge=1, le=730), limit: int = Query(50, ge=1, le=500)) -> dict: + end = datetime.now(timezone.utc) + start = end - timedelta(days=days) + result = compare_plans(start, end, limit=limit) + return { + "total_plans_considered": result.total_plans_considered, + "rows": [ + { + "is_current": row.is_current, + "savings_vs_current_dollars": row.savings_vs_current_dollars, + **asdict(row.breakdown), + "annualized_total_dollars": row.breakdown.annualized_total_dollars, + "is_estimate": row.breakdown.is_estimate, + } + for row in result.rows + ], + } + + +@router.get("/household") +def household(): + with get_session() as session: + cfg = get_household_config(session) + return {"distributor": cfg.distributor, "postcode": cfg.postcode, "isolarcloud_ps_id": cfg.isolarcloud_ps_id} diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..395a86d --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from datetime import time as time_ +from typing import Literal + +from fastapi import APIRouter +from pydantic import BaseModel + +from app.costing.tariff_models import ( + ALL_DAYS, + ConditionalDiscount, + FeedInComponent, + TariffPlan, + TieredFeedIn, + TimeWindow, + UsageComponent, +) +from app.db.repositories import get_current_plan, get_household_config, set_current_plan +from app.db.session import get_session + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class TouPeriodIn(BaseModel): + label: str + rate_c_per_kwh: float + start_time: time_ + end_time: time_ + days: list[str] | None = None # None = every day + + +class CurrentPlanIn(BaseModel): + retailer_name: str + plan_name: str + supply_charge_dollars_per_day: float + tariff_type: Literal["flat", "time_of_use"] + flat_rate_c_per_kwh: float | None = None + tou_periods: list[TouPeriodIn] | None = None + feed_in_type: Literal["flat", "tiered"] = "flat" + feed_in_tariff_c_per_kwh: float = 0.0 + tiered_daily_threshold_kwh: float | None = None + tiered_rate_below_c_per_kwh: float | None = None + tiered_rate_above_c_per_kwh: float | None = None + discount_percent: float | None = None # a discount already baked into the plan you signed up for + + +class HouseholdIn(BaseModel): + distributor: str + postcode: str + isolarcloud_ps_id: str | None = None + + +def _plan_from_form(form: CurrentPlanIn) -> TariffPlan: + if form.tariff_type == "flat": + if form.flat_rate_c_per_kwh is None: + raise ValueError("flat_rate_c_per_kwh is required for a flat tariff") + usage_components = [UsageComponent(label="Usage", rate_c_per_kwh=form.flat_rate_c_per_kwh)] + else: + if not form.tou_periods: + raise ValueError("tou_periods is required for a time_of_use tariff") + usage_components = [ + UsageComponent( + label=p.label, + rate_c_per_kwh=p.rate_c_per_kwh, + time_windows=[TimeWindow(days=p.days or list(ALL_DAYS), start_time=p.start_time, end_time=p.end_time)], + ) + for p in form.tou_periods + ] + + discounts = [] + if form.discount_percent: + discounts.append( + ConditionalDiscount(description="Plan discount", percent=form.discount_percent, applies_to="usage", auto_apply=True) + ) + + feed_in_tariffs: list[FeedInComponent] = [] + tiered_feed_in: TieredFeedIn | None = None + if form.feed_in_type == "tiered": + if form.tiered_daily_threshold_kwh is None or form.tiered_rate_below_c_per_kwh is None or form.tiered_rate_above_c_per_kwh is None: + raise ValueError("tiered_daily_threshold_kwh, tiered_rate_below_c_per_kwh and tiered_rate_above_c_per_kwh are required for a tiered feed-in tariff") + tiered_feed_in = TieredFeedIn( + daily_threshold_kwh=form.tiered_daily_threshold_kwh, + rate_below_c_per_kwh=form.tiered_rate_below_c_per_kwh, + rate_above_c_per_kwh=form.tiered_rate_above_c_per_kwh, + ) + else: + feed_in_tariffs = [FeedInComponent(label="Feed-in tariff", rate_c_per_kwh=form.feed_in_tariff_c_per_kwh)] + + return TariffPlan( + plan_id="current-plan", + retailer=form.retailer_name, + plan_name=form.plan_name, + supply_charge_c_per_day=form.supply_charge_dollars_per_day * 100, + usage_components=usage_components, + feed_in_tariffs=feed_in_tariffs, + tiered_feed_in=tiered_feed_in, + discounts=discounts, + source="manual", + ) + + +@router.get("/current-plan") +def get_current_plan_route() -> dict | None: + with get_session() as session: + plan = get_current_plan(session) + if plan is None: + return None + return plan.model_dump(mode="json") + + +@router.post("/current-plan") +def save_current_plan(form: CurrentPlanIn) -> dict: + plan = _plan_from_form(form) + with get_session() as session: + set_current_plan(session, form.retailer_name, form.plan_name, plan) + return plan.model_dump(mode="json") + + +@router.get("/household") +def get_household() -> dict: + with get_session() as session: + cfg = get_household_config(session) + return {"distributor": cfg.distributor, "postcode": cfg.postcode, "isolarcloud_ps_id": cfg.isolarcloud_ps_id} + + +@router.post("/household") +def save_household(form: HouseholdIn) -> dict: + with get_session() as session: + cfg = get_household_config(session) + cfg.distributor = form.distributor + cfg.postcode = form.postcode + if form.isolarcloud_ps_id is not None: + cfg.isolarcloud_ps_id = form.isolarcloud_ps_id + session.flush() + return {"distributor": cfg.distributor, "postcode": cfg.postcode, "isolarcloud_ps_id": cfg.isolarcloud_ps_id} diff --git a/app/api/usage.py b/app/api/usage.py new file mode 100644 index 0000000..18aa9ed --- /dev/null +++ b/app/api/usage.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, HTTPException, Query + +from app.db.repositories import get_earliest_reading_ts, get_usage_series +from app.db.session import get_session + +router = APIRouter(prefix="/api/usage", tags=["usage"]) + +BNE = ZoneInfo("Australia/Brisbane") + + +def _resolve_start(session, days: str, end: datetime) -> datetime: + if days == "all": + return get_earliest_reading_ts(session) or end + try: + n = int(days) + except ValueError: + raise HTTPException(400, "days must be an integer or 'all'") + if n < 1: + raise HTTPException(400, "days must be >= 1") + return end - timedelta(days=n) + + +def _bucket_key(local_date, granularity: str) -> str: + if granularity == "month": + return local_date.replace(day=1).isoformat() + if granularity == "week": + return (local_date - timedelta(days=local_date.weekday())).isoformat() + return local_date.isoformat() + + +@router.get("/daily") +def daily_usage(days: str = Query("30")) -> list[dict]: + end = datetime.now(timezone.utc) + with get_session() as session: + start = _resolve_start(session, days, end) + rows = get_usage_series(session, start, end) + + # Plotting one bar per day is unreadable (and slow) once the range + # stretches into years, so roll up to weekly/monthly buckets for long + # ranges -- this is a display concern only, the underlying stored data + # stays at full 5-minute resolution. + span_days = (end - start).days + granularity = "month" if span_days > 550 else "week" if span_days > 120 else "day" + + by_bucket: dict[str, dict[str, float]] = defaultdict(lambda: {"generation_kwh": 0.0, "load_kwh": 0.0, "import_kwh": 0.0, "export_kwh": 0.0}) + for row in rows: + key = _bucket_key(row.ts.astimezone(BNE).date(), granularity) + bucket = by_bucket[key] + bucket["generation_kwh"] += row.generation_kwh + bucket["load_kwh"] += row.load_kwh + bucket["import_kwh"] += row.import_kwh + bucket["export_kwh"] += row.export_kwh + + return [{"date": day, **{k: round(v, 3) for k, v in totals.items()}} for day, totals in sorted(by_bucket.items())] + + +@router.get("/summary") +def usage_summary(days: str = Query("30")) -> dict: + end = datetime.now(timezone.utc) + with get_session() as session: + start = _resolve_start(session, days, end) + rows = get_usage_series(session, start, end) + if not rows: + return {"days_with_data": 0} + + dates_seen: set[str] = set() + totals = {"generation_kwh": 0.0, "load_kwh": 0.0, "import_kwh": 0.0, "export_kwh": 0.0} + for row in rows: + dates_seen.add(row.ts.astimezone(BNE).date().isoformat()) + totals["generation_kwh"] += row.generation_kwh + totals["load_kwh"] += row.load_kwh + totals["import_kwh"] += row.import_kwh + totals["export_kwh"] += row.export_kwh + + return { + "days_with_data": len(dates_seen), + "first_day": min(dates_seen), + "last_day": max(dates_seen), + **{k: round(v, 2) for k, v in totals.items()}, + } diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6dc7114 --- /dev/null +++ b/app/config.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + # Storage + db_path: str = "power_compare.db" + + # iSolarCloud. "v1" = plain username/password login app; "oauth2" = a + # separate app type using the browser authorization-code flow. See + # SETUP.md -- these are two distinct developer-portal app registrations + # with different credentials, not two ways of using the same one. + isolarcloud_auth_mode: str = "v1" # "v1" | "oauth2" + isolarcloud_app_key: str = "" # "Appkey" from the developer portal + isolarcloud_app_secret: str = "" # "Secret key" / x-access-key from the developer portal + isolarcloud_region: str = "australia" # australia | europe | china | international + isolarcloud_plant_id: str = "" + + # -- v1 auth mode only -- + isolarcloud_username: str = "" # your regular iSolarCloud account (email/username) + isolarcloud_password: str = "" + # PEM-formatted RSA public key from the developer portal (Settings -> + # Open API), if your application was issued one. Some V1-login apps + # require the password to be RSA-encrypted before it's sent; when this + # is blank, the password is sent as plain text over HTTPS instead. + isolarcloud_rsa_public_key: str = "" + + # -- oauth2 auth mode only -- + isolarcloud_oauth_app_id: str = "" # "applicationId" from the developer portal + isolarcloud_oauth_redirect_uri: str = "https://localhost:8000/isolarcloud/callback" + + # Set to true until real credentials are configured; the scheduler then + # polls app.isolarcloud.mock_client instead of the real API. + use_mock_isolarcloud: bool = True + + # Household / AER plan scoping -- must match a substring of the + # distributor name AER plan data uses (e.g. "Energex" or "Ergon"). + distributor: str = "Energex" + postcode: str = "" + + # Scheduler cadence + isolarcloud_poll_interval_minutes: int = 10 + aer_refresh_interval_hours: int = 24 + + aer_base_url: str = "https://cdr.energymadeeasy.gov.au" + + @property + def db_url(self) -> str: + return f"sqlite:///{Path(self.db_path).resolve()}" + + +settings = Settings() diff --git a/app/costing/__init__.py b/app/costing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/costing/aer_mapper.py b/app/costing/aer_mapper.py new file mode 100644 index 0000000..c179ab7 --- /dev/null +++ b/app/costing/aer_mapper.py @@ -0,0 +1,215 @@ +"""Maps a raw AER "Get Generic Plan Detail" JSON payload into the common +`TariffPlan` shape used by the costing engine. + +Verified against real plan data while building this (AGL/EnergyAustralia +QLD plans): `electricityContract.tariffPeriod[]` holds one block per +season, each with `rateBlockUType` of "singleRate" or "timeOfUseRates" (an +"ALL" season shows as a start/end date spanning the full year, e.g. +"07-01".."06-30", which is treated as no seasonal restriction). Tiered/block +or demand-charge structures aren't represented by this shape and are marked +not-modelable rather than silently mis-costed. +""" + +from __future__ import annotations + +from datetime import time as time_ + +from app.costing.tariff_models import ( + ConditionalDiscount, + FeedInComponent, + TariffPlan, + TieredFeedIn, + TimeWindow, + UsageComponent, +) + + +def _parse_time(hhmm: str) -> time_: + hh, mm = (int(x) for x in hhmm.split(":")) + return time_(hh, mm) + + +def _end_time_exclusive(hhmm: str) -> time_: + """AER end times are inclusive of their minute (e.g. "20:59" means up + to but not including 21:00) -- shift by one minute to get the exclusive + boundary `TimeWindow` expects.""" + hh, mm = (int(x) for x in hhmm.split(":")) + total = (hh * 60 + mm + 1) % (24 * 60) + return time_(total // 60, total % 60) + + +def _season_months(start_mmdd: str, end_mmdd: str) -> list[int] | None: + """A tariffPeriod's startDate/endDate ("MM-DD") approximate to whole + months here (the engine's TimeContext is month-granular, not + day-of-month-granular). A span covering all 12 months is treated as + "all year" (None) rather than an explicit list.""" + start_month = int(start_mmdd.split("-")[0]) + end_month = int(end_mmdd.split("-")[0]) + months = [] + m = start_month + for _ in range(12): + months.append(m) + if m == end_month: + break + m = m % 12 + 1 + return None if len(months) >= 12 else months + + +def _time_windows(entries: list[dict]) -> list[TimeWindow]: + return [ + TimeWindow(days=tou["days"], start_time=_parse_time(tou["startTime"]), end_time=_end_time_exclusive(tou["endTime"])) + for tou in entries + ] + + +def _rate_cents(rates: list[dict]) -> tuple[float, bool]: + """Returns (cents_per_kwh, is_tiered). Only the first tier is used for + tiered/inclining-block rate structures -- callers should mark the plan + not-modelable when is_tiered is True, since later tiers are ignored.""" + return float(rates[0]["unitPrice"]) * 100, len(rates) > 1 + + +def map_aer_plan(list_entry: dict, detail: dict) -> TariffPlan: + plan_id = list_entry["planId"] + retailer = list_entry.get("brandName") or list_entry.get("brand", "") + plan_name = detail.get("displayName", plan_id) + distributors = list_entry.get("geography", {}).get("distributors", []) + ec = detail.get("electricityContract", {}) + + notes: list[str] = [] + modelable = True + usage_components: list[UsageComponent] = [] + supply_charges_c: list[float] = [] + + for period in ec.get("tariffPeriod", []): + season_months = _season_months(period.get("startDate", "01-01"), period.get("endDate", "12-31")) + rate_type = period.get("rateBlockUType") + supply_charges_c.append(float(period.get("dailySupplyCharge", 0) or 0) * 100) + + if rate_type == "singleRate": + block = period["singleRate"] + rate_c, tiered = _rate_cents(block["rates"]) + usage_components.append( + UsageComponent(label=block.get("displayName", "Usage"), rate_c_per_kwh=rate_c, season_months=season_months) + ) + if tiered: + modelable = False + notes.append(f"'{block.get('displayName', 'Usage')}' has tiered/step pricing; only the first tier is modeled.") + elif rate_type == "timeOfUseRates": + for tou_block in period["timeOfUseRates"]: + rate_c, tiered = _rate_cents(tou_block["rates"]) + usage_components.append( + UsageComponent( + label=tou_block.get("displayName", tou_block.get("type", "TOU")), + rate_c_per_kwh=rate_c, + time_windows=_time_windows(tou_block.get("timeOfUse", [])), + season_months=season_months, + ) + ) + if tiered: + modelable = False + notes.append(f"'{tou_block.get('displayName', 'TOU')}' has tiered/step pricing; only the first tier is modeled.") + else: + modelable = False + notes.append(f"Tariff structure '{rate_type}' is not modeled (e.g. tiered/block or demand pricing).") + + if ec.get("controlledLoad"): + notes.append( + "This plan also has a separately-metered controlled load tariff, not included " + "(usage data here is whole-house, not sub-metered)." + ) + + feed_in_tariffs: list[FeedInComponent] = [] + tiered_feed_in: TieredFeedIn | None = None + retailer_fits = [fit for fit in ec.get("solarFeedInTariff", []) if fit.get("payerType") == "RETAILER"] + # Government/legacy premium schemes aren't available to new or existing + # standard sign-ups; only the retailer's own standard feed-in tariff(s) + # apply to a household comparing plans now. + single_tariff_fits = [fit for fit in retailer_fits if fit.get("tariffUType") == "singleTariff"] + + def _has_volume(fit: dict) -> bool: + return "volume" in fit["singleTariff"]["rates"][0] + + if len(single_tariff_fits) == 2 and _has_volume(single_tariff_fits[0]) and not _has_volume(single_tariff_fits[1]): + # A daily-volume-based feed-in tariff (e.g. "first 10kWh/day at 8c, + # thereafter at 4c") is represented in this data as two separate + # singleTariff entries rather than as tiers within one -- the first + # entry's "volume" is the daily kWh threshold, and the second + # (uncapped) entry is the remainder rate. Without this, both were + # treated as independent flat components and the first one in the + # list silently won every match, crediting 100% of exported energy + # at the (higher) capped-tier rate. + below_c, _ = _rate_cents(single_tariff_fits[0]["singleTariff"]["rates"]) + above_c, _ = _rate_cents(single_tariff_fits[1]["singleTariff"]["rates"]) + tiered_feed_in = TieredFeedIn( + daily_threshold_kwh=float(single_tariff_fits[0]["singleTariff"]["rates"][0]["volume"]), + rate_below_c_per_kwh=below_c, + rate_above_c_per_kwh=above_c, + ) + else: + for fit in single_tariff_fits: + rate_c, tiered = _rate_cents(fit["singleTariff"]["rates"]) + feed_in_tariffs.append(FeedInComponent(label=fit.get("displayName", "Feed-in tariff"), rate_c_per_kwh=rate_c)) + if tiered: + modelable = False + notes.append(f"'{fit.get('displayName', 'Feed-in tariff')}' has tiered/step pricing; only the first tier is modeled.") + + for fit in retailer_fits: + if fit.get("tariffUType") == "singleTariff": + continue # handled above + elif fit.get("tariffUType") == "timeVaryingTariffs": + for block in fit.get("timeVaryingTariffs", []): + rate_c, tiered = _rate_cents(block["rates"]) + feed_in_tariffs.append( + FeedInComponent( + label=block.get("displayName", "Feed-in tariff"), + rate_c_per_kwh=rate_c, + # Unlike usage-side timeOfUseRates blocks (which key their + # windows as "timeOfUse"), the CDR solarFeedInTariff + # timeVaryingTariffs shape keys them as "timeVariations" -- + # using the wrong key here silently made every time-varying + # feed-in tariff match all day (empty time_windows == "always"). + time_windows=_time_windows(block.get("timeVariations", [])), + ) + ) + else: + notes.append(f"Feed-in tariff structure '{fit.get('tariffUType')}' not modeled; solar export credit may be understated.") + + discounts: list[ConditionalDiscount] = [] + for d in ec.get("discounts", []): + method = d.get("methodUType") + sub = d.get(method, {}) if method else {} + percent = float(sub["rate"]) * 100 if "rate" in sub else None + amount = float(sub["amount"]) if "amount" in sub else None + discounts.append( + ConditionalDiscount( + description=d.get("displayName", d.get("description", "Discount")), + percent=percent, + amount_dollars=amount, + applies_to="usage", + auto_apply=(d.get("type") == "GUARANTEED"), + ) + ) + + for incentive in ec.get("incentives", []): + label = incentive.get("displayName") or incentive.get("description", "") + notes.append(f"One-off sign-up incentive not included in the cost estimate: {label}") + + supply_charge_c_per_day = supply_charges_c[0] if supply_charges_c else 0.0 + if len(set(supply_charges_c)) > 1: + notes.append("Supply charge varies by season in the source data; using the first season's rate.") + + return TariffPlan( + plan_id=plan_id, + retailer=retailer, + plan_name=plan_name, + supply_charge_c_per_day=supply_charge_c_per_day, + usage_components=usage_components, + feed_in_tariffs=feed_in_tariffs, + tiered_feed_in=tiered_feed_in, + discounts=discounts, + distributors=distributors, + source="aer", + modelable=modelable and bool(usage_components), + modeling_notes=notes, + ) diff --git a/app/costing/compare.py b/app/costing/compare.py new file mode 100644 index 0000000..951b2c2 --- /dev/null +++ b/app/costing/compare.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from app.costing.engine import CostBreakdown, bucket_readings, evaluate_bucketed +from app.db.repositories import get_cached_plans, get_current_plan, get_interval_readings +from app.db.session import get_session + +# The AER plan cache can hold thousands of near-duplicate whitelabel/partner +# variants of the same underlying offer across all configured retailers; +# dumping every one into the comparison table is neither fast nor useful, so +# only the cheapest N modelable plans are returned (see `compare_plans`). +DEFAULT_PLAN_LIMIT = 50 + + +@dataclass +class PlanComparisonRow: + breakdown: CostBreakdown + is_current: bool + savings_vs_current_dollars: float | None # positive = cheaper than current plan + + +@dataclass +class PlanComparisonResult: + rows: list[PlanComparisonRow] + total_plans_considered: int + + +def compare_plans(start: datetime, end: datetime, limit: int = DEFAULT_PLAN_LIMIT) -> PlanComparisonResult: + with get_session() as session: + readings = get_interval_readings(session, start, end) + plans = get_cached_plans(session) + current_plan = get_current_plan(session) + + # Retailers commonly publish the same underlying offer multiple times + # under different partner/channel plan IDs (e.g. a bank-affiliated + # sign-up variant) with identical terms -- collapse those before + # evaluating so the table isn't dominated by exact duplicates. + seen: set[tuple[str, str, float, tuple[float, ...]]] = set() + deduped_plans = [] + for plan in plans: + fingerprint = ( + plan.retailer, + plan.plan_name, + plan.supply_charge_c_per_day, + tuple(sorted(c.rate_c_per_kwh for c in plan.usage_components)), + ) + if fingerprint in seen: + continue + seen.add(fingerprint) + deduped_plans.append(plan) + plans = deduped_plans + + # Bucketing once and re-matching each plan against the small bucketed + # set (instead of every raw reading) is what keeps this fast with + # hundreds of cached plans and months of 5-minute interval history. + buckets, days_with_data, daily_export_kwh = bucket_readings(readings) + + current_result = ( + evaluate_bucketed(current_plan, buckets, days_with_data, daily_export_kwh) if current_plan else None + ) + current_annual = current_result.annualized_total_dollars if current_result else None + + rows: list[PlanComparisonRow] = [] + if current_result: + rows.append(PlanComparisonRow(breakdown=current_result, is_current=True, savings_vs_current_dollars=0.0)) + + plan_rows: list[PlanComparisonRow] = [] + for plan in plans: + result = evaluate_bucketed(plan, buckets, days_with_data, daily_export_kwh) + savings = None + if current_annual is not None and result.annualized_total_dollars is not None: + savings = current_annual - result.annualized_total_dollars + plan_rows.append(PlanComparisonRow(breakdown=result, is_current=False, savings_vs_current_dollars=savings)) + + def sort_key(row: PlanComparisonRow): + if not row.breakdown.modelable or row.breakdown.annualized_total_dollars is None: + return (1, 0.0) + return (0, row.breakdown.annualized_total_dollars) + + plan_rows.sort(key=sort_key) + rows.extend(plan_rows[:limit]) + return PlanComparisonResult(rows=rows, total_plans_considered=len(plan_rows)) diff --git a/app/costing/engine.py b/app/costing/engine.py new file mode 100644 index 0000000..ae95336 --- /dev/null +++ b/app/costing/engine.py @@ -0,0 +1,244 @@ +"""Evaluates a TariffPlan against a stream of interval usage readings. + +No battery/storage modeling is needed: the household has solar-only +generation, so metered import/export values are ground truth and are used +directly rather than re-derived from generation and load. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from datetime import datetime +from datetime import time as time_ +from zoneinfo import ZoneInfo + +from app.costing.tariff_models import DAYS, FeedInComponent, TariffPlan, TimeContext, UsageComponent + +# Queensland does not observe daylight saving, but readings are still stored +# in UTC and converted here rather than assuming a fixed UTC+10 offset, so +# the engine isn't silently wrong if it's ever reused for a DST-observing state. +BRISBANE_TZ = ZoneInfo("Australia/Brisbane") + + +@dataclass +class IntervalReading: + ts: datetime # timezone-aware (UTC) + import_kwh: float + export_kwh: float + + +@dataclass +class ComponentBreakdown: + label: str + kwh: float + cost_dollars: float + + +@dataclass +class CostBreakdown: + plan_id: str + retailer: str + plan_name: str + usage_breakdown: list[ComponentBreakdown] + feed_in_breakdown: list[ComponentBreakdown] + supply_charge_dollars: float + unconditional_discount_dollars: float + potential_conditional_savings_dollars: float + total_dollars: float + days_with_data: int + modelable: bool + modeling_notes: list[str] = field(default_factory=list) + + @property + def annualized_total_dollars(self) -> float | None: + if self.days_with_data == 0: + return None + return self.total_dollars * 365 / self.days_with_data + + @property + def is_estimate(self) -> bool: + return self.days_with_data < 365 + + +def _first_match( + components: list[UsageComponent] | list[FeedInComponent], ctx: TimeContext +) -> UsageComponent | FeedInComponent | None: + for c in components: + if c.matches(ctx): + return c + return None + + +@dataclass +class TimeBucket: + """A (month, day-of-week, time-of-day) point with import/export summed + across every raw reading that shares it. Comparing many plans against + the same usage history is much cheaper when every plan re-matches this + small bucketed set instead of every raw reading -- see `bucket_readings`. + """ + + ctx: TimeContext + import_kwh: float + export_kwh: float + + +def bucket_readings(readings: Iterable[IntervalReading]) -> tuple[list[TimeBucket], int, dict[str, float]]: + """Groups readings by (month, weekday, hour, minute) and sums their + kWh, since a time-of-use tariff's cost only ever depends on that + triple, never on the specific calendar date. Also returns the count of + distinct calendar days with data (for the supply charge) and each + calendar day's total export (for tiered daily feed-in tariffs, which + depend on same-day cumulative export rather than time of day) -- + computed once up front so neither needs to be re-derived per plan. + + Bucketing turns "N plans x M raw readings" into "N plans x <= a few + thousand buckets", which matters once there are hundreds of cached AER + plans to evaluate against months of 5-minute interval history. + """ + sums: dict[tuple[int, str, int, int], list[float]] = {} + daily_export_kwh: dict[str, float] = {} + for r in readings: + local_dt = r.ts.astimezone(BRISBANE_TZ) + day = local_dt.date().isoformat() + daily_export_kwh[day] = daily_export_kwh.get(day, 0.0) + r.export_kwh + key = (local_dt.month, local_dt.weekday(), local_dt.hour, local_dt.minute) + bucket = sums.get(key) + if bucket is None: + sums[key] = [r.import_kwh, r.export_kwh] + else: + bucket[0] += r.import_kwh + bucket[1] += r.export_kwh + + buckets = [ + TimeBucket( + ctx=TimeContext(month=month, day_code=DAYS[weekday], time_of_day=time_(hour, minute)), + import_kwh=vals[0], + export_kwh=vals[1], + ) + for (month, weekday, hour, minute), vals in sums.items() + ] + return buckets, len(daily_export_kwh), daily_export_kwh + + +def _evaluate_core( + plan: TariffPlan, buckets: list[TimeBucket], days_with_data: int, daily_export_kwh: dict[str, float] +) -> CostBreakdown: + notes = list(plan.modeling_notes) + usage_kwh: dict[str, float] = {c.label: 0.0 for c in plan.usage_components} + feed_in_kwh: dict[str, float] = {c.label: 0.0 for c in plan.feed_in_tariffs} + unmatched_usage_kwh = 0.0 + unmatched_export_kwh = 0.0 + + for b in buckets: + usage_component = _first_match(plan.usage_components, b.ctx) + if usage_component is not None: + usage_kwh[usage_component.label] += b.import_kwh + else: + unmatched_usage_kwh += b.import_kwh + + if plan.tiered_feed_in or not b.export_kwh: + continue # tiered feed-in is computed below from daily totals instead + if plan.feed_in_tariffs: + fit_component = _first_match(plan.feed_in_tariffs, b.ctx) + if fit_component is not None: + feed_in_kwh[fit_component.label] += b.export_kwh + else: + unmatched_export_kwh += b.export_kwh + else: + unmatched_export_kwh += b.export_kwh + + if unmatched_usage_kwh > 1e-9: + notes.append( + f"{unmatched_usage_kwh:.2f} kWh of usage did not match any tariff window and was not " + "charged -- this plan's time windows may have gaps." + ) + if unmatched_export_kwh > 1e-9: + notes.append( + f"{unmatched_export_kwh:.2f} kWh of solar export received no feed-in credit " + "(no matching feed-in window, or this plan has no feed-in tariff)." + ) + + # A tariff period can need more than one disjoint time window under the + # same label (e.g. "Shoulder" = 7am-4pm AND 9pm-10pm) -- usage_kwh is + # already correctly summed per label above, so build the breakdown from + # unique labels only, or a component appearing twice would double-count + # its cost here. + rate_by_label = {c.label: c.rate_c_per_kwh for c in plan.usage_components} + usage_breakdown = [ + ComponentBreakdown(label=label, kwh=kwh, cost_dollars=kwh * rate_by_label[label] / 100) + for label, kwh in usage_kwh.items() + ] + + if plan.tiered_feed_in: + tier = plan.tiered_feed_in + below_kwh = sum(min(v, tier.daily_threshold_kwh) for v in daily_export_kwh.values()) + above_kwh = sum(max(v - tier.daily_threshold_kwh, 0.0) for v in daily_export_kwh.values()) + feed_in_breakdown = [ + ComponentBreakdown( + label=f"First {tier.daily_threshold_kwh:g} kWh/day", + kwh=below_kwh, + cost_dollars=-below_kwh * tier.rate_below_c_per_kwh / 100, + ), + ComponentBreakdown( + label="Remaining balance", + kwh=above_kwh, + cost_dollars=-above_kwh * tier.rate_above_c_per_kwh / 100, + ), + ] + else: + fit_rate_by_label = {c.label: c.rate_c_per_kwh for c in plan.feed_in_tariffs} + feed_in_breakdown = [ + ComponentBreakdown(label=label, kwh=kwh, cost_dollars=-kwh * fit_rate_by_label[label] / 100) + for label, kwh in feed_in_kwh.items() + ] + + usage_cost = sum(c.cost_dollars for c in usage_breakdown) + feed_in_credit = sum(c.cost_dollars for c in feed_in_breakdown) # already negative + # Supply charge is billed per day the meter has data, not per calendar + # day in the requested range, so a gap in polling doesn't overstate cost. + supply_charge = plan.supply_charge_c_per_day * days_with_data / 100 + + subtotal = usage_cost + feed_in_credit + supply_charge + + unconditional_discount = 0.0 + potential_conditional = 0.0 + for d in plan.discounts: + base = usage_cost if d.applies_to == "usage" else subtotal + amount = (base * d.percent / 100) if d.percent is not None else (d.amount_dollars or 0.0) + if d.auto_apply: + unconditional_discount += amount + else: + potential_conditional += amount + notes.append(f"Conditional discount not applied to headline total: {d.description} (~${amount:.2f})") + + total = subtotal - unconditional_discount + + return CostBreakdown( + plan_id=plan.plan_id, + retailer=plan.retailer, + plan_name=plan.plan_name, + usage_breakdown=usage_breakdown, + feed_in_breakdown=feed_in_breakdown, + supply_charge_dollars=supply_charge, + unconditional_discount_dollars=unconditional_discount, + potential_conditional_savings_dollars=potential_conditional, + total_dollars=total, + days_with_data=days_with_data, + modelable=plan.modelable, + modeling_notes=notes, + ) + + +def evaluate(plan: TariffPlan, readings: Iterable[IntervalReading]) -> CostBreakdown: + buckets, days_with_data, daily_export_kwh = bucket_readings(readings) + return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh) + + +def evaluate_bucketed( + plan: TariffPlan, buckets: list[TimeBucket], days_with_data: int, daily_export_kwh: dict[str, float] +) -> CostBreakdown: + """Like `evaluate`, but takes an already-bucketed usage history (from + `bucket_readings`) so comparing many plans against the same usage data + only pays the bucketing cost once.""" + return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh) diff --git a/app/costing/tariff_models.py b/app/costing/tariff_models.py new file mode 100644 index 0000000..05859aa --- /dev/null +++ b/app/costing/tariff_models.py @@ -0,0 +1,137 @@ +"""Common tariff-shape schema. + +Both AER-sourced plans (via aer_mapper.py) and the user's manually-entered +current plan are represented as a single `TariffPlan` shape, so the costing +engine only needs one evaluation path regardless of where a plan came from. +""" + +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Literal + +from pydantic import BaseModel, Field + +DAYS: list[str] = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] +ALL_DAYS: list[str] = list(DAYS) +WEEKDAYS: list[str] = DAYS[:5] +WEEKEND: list[str] = DAYS[5:] + + +class TimeContext(BaseModel): + """A (month, day-of-week, time-of-day) point used to match tariff windows. + + Decoupled from a real `datetime` so the coverage validator can probe + synthetic points without constructing real calendar dates. + """ + + month: int + day_code: str + time_of_day: time + + @classmethod + def from_datetime(cls, dt: datetime) -> "TimeContext": + return cls(month=dt.month, day_code=DAYS[dt.weekday()], time_of_day=dt.time()) + + +class TimeWindow(BaseModel): + days: list[str] = Field(default_factory=lambda: list(ALL_DAYS)) + start_time: time + end_time: time # exclusive; if end_time == start_time the window covers all 24h + + def contains(self, ctx: TimeContext) -> bool: + if ctx.day_code not in self.days: + return False + t = ctx.time_of_day + if self.start_time == self.end_time: + return True + if self.start_time < self.end_time: + return self.start_time <= t < self.end_time + # window wraps past midnight, e.g. 22:00-06:00 + return t >= self.start_time or t < self.end_time + + +class UsageComponent(BaseModel): + label: str + rate_c_per_kwh: float + time_windows: list[TimeWindow] = Field(default_factory=list) + season_months: list[int] | None = None # 1-12; None = all year + + def matches(self, ctx: TimeContext) -> bool: + if self.season_months is not None and ctx.month not in self.season_months: + return False + if not self.time_windows: + return True + return any(w.contains(ctx) for w in self.time_windows) + + +class FeedInComponent(UsageComponent): + pass + + +class TieredFeedIn(BaseModel): + """A feed-in tariff paid at one rate up to a daily export threshold and + a different (usually lower) rate beyond it -- e.g. Alinta's "Solar + Balance" QLD plans: the first 10 kWh exported each day at 9c/kWh, the + remainder at 2c/kWh. This depends on each calendar day's *total* export + (not the time of day), so it's evaluated separately from the + time-of-use `feed_in_tariffs` list -- a plan should use one or the + other, not both. + """ + + daily_threshold_kwh: float + rate_below_c_per_kwh: float + rate_above_c_per_kwh: float + + +class ConditionalDiscount(BaseModel): + description: str + percent: float | None = None + amount_dollars: float | None = None + applies_to: Literal["usage", "total"] = "total" + # True only for discounts that apply unconditionally (e.g. baked into the + # advertised rate already). Conditional discounts (pay-on-time, direct + # debit, first-year sign-up) default to False and are surfaced separately + # as a "potential additional saving" rather than folding into the + # headline comparison number. + auto_apply: bool = False + + +class TariffPlan(BaseModel): + plan_id: str + retailer: str + plan_name: str + supply_charge_c_per_day: float + usage_components: list[UsageComponent] + feed_in_tariffs: list[FeedInComponent] = Field(default_factory=list) + tiered_feed_in: TieredFeedIn | None = None # takes precedence over feed_in_tariffs when set + discounts: list[ConditionalDiscount] = Field(default_factory=list) + distributors: list[str] = Field(default_factory=list) + source: Literal["aer", "manual"] = "aer" + effective_from: date | None = None + # Set False (with modeling_notes explaining why) for plan structures the + # engine can't represent, e.g. tiered/block or demand tariffs, so they + # show as "not modeled" in the UI rather than silently mis-costed. + modelable: bool = True + modeling_notes: list[str] = Field(default_factory=list) + + +def find_usage_coverage_gaps(components: list[UsageComponent]) -> list[str]: + """Sample every (month, weekday, half-hour) combination and report any + point matched by zero or more than one component, so structurally broken + TOU/seasonal definitions are caught before evaluating real usage data. + """ + issues: list[str] = [] + for month in range(1, 13): + for day_code in DAYS: + for half_hour in range(48): + hh, mm = divmod(half_hour * 30, 60) + ctx = TimeContext(month=month, day_code=day_code, time_of_day=time(hh, mm)) + matches = [c.label for c in components if c.matches(ctx)] + if len(matches) == 0: + issues.append(f"no usage component matches month={month} {day_code} {hh:02d}:{mm:02d}") + elif len(matches) > 1: + issues.append( + f"overlapping usage components at month={month} {day_code} {hh:02d}:{mm:02d}: {matches}" + ) + return issues diff --git a/app/costing/tests/__init__.py b/app/costing/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/costing/tests/test_engine.py b/app/costing/tests/test_engine.py new file mode 100644 index 0000000..dc7a5c3 --- /dev/null +++ b/app/costing/tests/test_engine.py @@ -0,0 +1,292 @@ +from datetime import datetime, time +from zoneinfo import ZoneInfo + +import pytest + +from app.costing.engine import IntervalReading, evaluate +from app.costing.tariff_models import ( + ALL_DAYS, + FeedInComponent, + TariffPlan, + TieredFeedIn, + TimeWindow, + UsageComponent, + find_usage_coverage_gaps, +) + +BNE = ZoneInfo("Australia/Brisbane") + + +def bne(y, m, d, hh, mm=0) -> datetime: + return datetime(y, m, d, hh, mm, tzinfo=BNE) + + +def test_flat_plan_with_feed_in(): + plan = TariffPlan( + plan_id="flat-1", + retailer="Test Co", + plan_name="Flat Saver", + supply_charge_c_per_day=100, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=30)], + feed_in_tariffs=[FeedInComponent(label="fit", rate_c_per_kwh=8)], + ) + readings = [ + IntervalReading(ts=bne(2026, 1, 1, 10), import_kwh=5, export_kwh=2), + IntervalReading(ts=bne(2026, 1, 1, 20), import_kwh=3, export_kwh=0), + IntervalReading(ts=bne(2026, 1, 2, 10), import_kwh=4, export_kwh=1), + ] + + result = evaluate(plan, readings) + + assert result.days_with_data == 2 + assert result.usage_breakdown[0].kwh == pytest.approx(12) + assert result.feed_in_breakdown[0].kwh == pytest.approx(3) + assert result.supply_charge_dollars == pytest.approx(2.00) + assert result.total_dollars == pytest.approx(5.36) + assert result.modeling_notes == [] + + +def test_simple_time_of_use_plan(): + peak = UsageComponent( + label="peak", + rate_c_per_kwh=40, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(16, 0), end_time=time(20, 0))], + ) + offpeak = UsageComponent( + label="offpeak", + rate_c_per_kwh=20, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(20, 0), end_time=time(16, 0))], + ) + plan = TariffPlan( + plan_id="tou-1", + retailer="Test Co", + plan_name="Time of Use", + supply_charge_c_per_day=80, + usage_components=[peak, offpeak], + feed_in_tariffs=[FeedInComponent(label="fit", rate_c_per_kwh=10)], + ) + # No structural gaps: peak + offpeak windows partition the full 24h. + assert find_usage_coverage_gaps([peak, offpeak]) == [] + + readings = [ + IntervalReading(ts=bne(2026, 3, 10, 6), import_kwh=2, export_kwh=0), + IntervalReading(ts=bne(2026, 3, 10, 12), import_kwh=3, export_kwh=5), + IntervalReading(ts=bne(2026, 3, 10, 18), import_kwh=4, export_kwh=0), + IntervalReading(ts=bne(2026, 3, 10, 22), import_kwh=1, export_kwh=0), + ] + + result = evaluate(plan, readings) + + by_label = {c.label: c for c in result.usage_breakdown} + assert by_label["offpeak"].kwh == pytest.approx(6) + assert by_label["peak"].kwh == pytest.approx(4) + assert result.feed_in_breakdown[0].kwh == pytest.approx(5) + assert result.supply_charge_dollars == pytest.approx(0.80) + assert result.total_dollars == pytest.approx(3.10) + + +def test_seasonal_time_of_use_plan(): + def peak(months, rate): + return UsageComponent( + label=f"peak-{months[0]}", + rate_c_per_kwh=rate, + season_months=months, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(16, 0), end_time=time(20, 0))], + ) + + def offpeak(months, rate): + return UsageComponent( + label=f"offpeak-{months[0]}", + rate_c_per_kwh=rate, + season_months=months, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(20, 0), end_time=time(16, 0))], + ) + + summer = [12, 1, 2] + winter = [3, 4, 5, 6, 7, 8, 9, 10, 11] + plan = TariffPlan( + plan_id="seasonal-tou-1", + retailer="Test Co", + plan_name="Seasonal TOU", + supply_charge_c_per_day=90, + usage_components=[ + peak(summer, 50), + offpeak(summer, 22), + peak(winter, 35), + offpeak(winter, 18), + ], + ) + + readings = [ + IntervalReading(ts=bne(2026, 1, 15, 18), import_kwh=2, export_kwh=0), # summer peak + IntervalReading(ts=bne(2026, 1, 15, 8), import_kwh=3, export_kwh=0), # summer offpeak + IntervalReading(ts=bne(2026, 6, 15, 18), import_kwh=2, export_kwh=0), # winter peak + IntervalReading(ts=bne(2026, 6, 15, 8), import_kwh=3, export_kwh=0), # winter offpeak + ] + + result = evaluate(plan, readings) + + assert result.days_with_data == 2 + assert result.supply_charge_dollars == pytest.approx(1.80) + # 2*0.50 + 3*0.22 + 2*0.35 + 3*0.18 = 1.00 + 0.66 + 0.70 + 0.54 = 2.90 + assert sum(c.cost_dollars for c in result.usage_breakdown) == pytest.approx(2.90) + assert result.total_dollars == pytest.approx(4.70) + assert result.modeling_notes == [] + + +def test_time_of_use_feed_in_tariff(): + plan = TariffPlan( + plan_id="tou-fit-1", + retailer="Test Co", + plan_name="Solar Soak", + supply_charge_c_per_day=70, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=25)], + feed_in_tariffs=[ + FeedInComponent( + label="midday-soak", + rate_c_per_kwh=5, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(10, 0), end_time=time(15, 0))], + ), + FeedInComponent( + label="standard", + rate_c_per_kwh=12, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(15, 0), end_time=time(10, 0))], + ), + ], + ) + readings = [ + IntervalReading(ts=bne(2026, 2, 1, 8), import_kwh=1, export_kwh=0.5), + IntervalReading(ts=bne(2026, 2, 1, 12), import_kwh=0, export_kwh=4), + IntervalReading(ts=bne(2026, 2, 1, 18), import_kwh=2, export_kwh=0), + ] + + result = evaluate(plan, readings) + + by_label = {c.label: c for c in result.feed_in_breakdown} + assert by_label["midday-soak"].kwh == pytest.approx(4) + assert by_label["standard"].kwh == pytest.approx(0.5) + assert result.total_dollars == pytest.approx(1.19) + + +def test_gap_in_usage_windows_is_reported_and_uncharged(): + peak_only = UsageComponent( + label="peak", + rate_c_per_kwh=40, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(16, 0), end_time=time(20, 0))], + ) + plan = TariffPlan( + plan_id="broken-1", + retailer="Test Co", + plan_name="Missing Offpeak", + supply_charge_c_per_day=0, + usage_components=[peak_only], + ) + assert find_usage_coverage_gaps([peak_only]) != [] + + readings = [ + IntervalReading(ts=bne(2026, 1, 1, 8), import_kwh=5, export_kwh=0), # falls in the gap + IntervalReading(ts=bne(2026, 1, 1, 18), import_kwh=2, export_kwh=0), # matches peak + ] + + result = evaluate(plan, readings) + + assert result.usage_breakdown[0].kwh == pytest.approx(2) + assert result.total_dollars == pytest.approx(0.80) + assert any("did not match any tariff window" in note for note in result.modeling_notes) + + +def test_split_time_window_under_shared_label_is_not_double_counted(): + # "Shoulder" needs two disjoint windows (7am-4pm and 9pm-10pm) under one + # label -- this must sum once, not double-count the cost. + shoulder_1 = UsageComponent( + label="Shoulder", + rate_c_per_kwh=36, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(7, 0), end_time=time(16, 0))], + ) + shoulder_2 = UsageComponent( + label="Shoulder", + rate_c_per_kwh=36, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(21, 0), end_time=time(22, 0))], + ) + peak = UsageComponent( + label="Peak", + rate_c_per_kwh=48, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(16, 0), end_time=time(21, 0))], + ) + offpeak = UsageComponent( + label="Off-peak", + rate_c_per_kwh=31, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(22, 0), end_time=time(7, 0))], + ) + plan = TariffPlan( + plan_id="split-shoulder-1", + retailer="Test Co", + plan_name="Split Shoulder", + supply_charge_c_per_day=0, + usage_components=[peak, shoulder_1, shoulder_2, offpeak], + ) + assert find_usage_coverage_gaps([peak, shoulder_1, shoulder_2, offpeak]) == [] + + readings = [ + IntervalReading(ts=bne(2026, 1, 1, 9), import_kwh=2, export_kwh=0), # shoulder window 1 + IntervalReading(ts=bne(2026, 1, 1, 21, 30), import_kwh=3, export_kwh=0), # shoulder window 2 + ] + result = evaluate(plan, readings) + + shoulder_rows = [c for c in result.usage_breakdown if c.label == "Shoulder"] + assert len(shoulder_rows) == 1 + assert shoulder_rows[0].kwh == pytest.approx(5) + assert result.total_dollars == pytest.approx(5 * 0.36) + + +def test_tiered_daily_feed_in_tariff(): + # Modeled on Alinta's QLD "Solar Balance" FiT: first 10 kWh/day at 9c, + # the remainder at 2c -- a *daily total export* threshold, not a + # time-of-use one. + plan = TariffPlan( + plan_id="tiered-fit-1", + retailer="Test Co", + plan_name="Solar Balance", + supply_charge_c_per_day=100, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=20)], + tiered_feed_in=TieredFeedIn(daily_threshold_kwh=10, rate_below_c_per_kwh=9, rate_above_c_per_kwh=2), + ) + readings = [ + # Day 1: 7 kWh exported total, all below the 10 kWh threshold. + IntervalReading(ts=bne(2026, 1, 1, 8), import_kwh=2, export_kwh=3), + IntervalReading(ts=bne(2026, 1, 1, 12), import_kwh=1, export_kwh=4), + # Day 2: 15 kWh exported total -- 10 below threshold, 5 above. + IntervalReading(ts=bne(2026, 1, 2, 8), import_kwh=1, export_kwh=5), + IntervalReading(ts=bne(2026, 1, 2, 12), import_kwh=1, export_kwh=10), + ] + + result = evaluate(plan, readings) + + by_label = {c.label: c for c in result.feed_in_breakdown} + assert by_label["First 10 kWh/day"].kwh == pytest.approx(17) # 7 + min(15,10) + assert by_label["Remaining balance"].kwh == pytest.approx(5) # max(15-10, 0) + # credit = 17*0.09 + 5*0.02 = 1.53 + 0.10 = 1.63 + # usage = (2+1+1+1)*0.20 = 1.00; supply = 2 days * $1.00 = $2.00 + # total = 1.00 + 2.00 - 1.63 = 1.37 + assert result.total_dollars == pytest.approx(1.37) + assert result.modeling_notes == [] + + +def test_conditional_discount_kept_out_of_headline_total(): + plan = TariffPlan( + plan_id="discount-1", + retailer="Test Co", + plan_name="Pay On Time", + supply_charge_c_per_day=0, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=100)], + discounts=[ + {"description": "Pay on time discount", "percent": 10, "applies_to": "usage", "auto_apply": False} + ], + ) + readings = [IntervalReading(ts=bne(2026, 1, 1, 12), import_kwh=10, export_kwh=0)] + + result = evaluate(plan, readings) + + assert result.total_dollars == pytest.approx(10.00) + assert result.potential_conditional_savings_dollars == pytest.approx(1.00) + assert any("Pay on time discount" in note for note in result.modeling_notes) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..953bb68 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import TypeDecorator + + +class UTCDateTime(TypeDecorator): + """SQLite has no real timezone-aware datetime type: SQLAlchemy's + `DateTime(timezone=True)` silently stores and returns *naive* datetimes + on this backend, which then get misinterpreted as system-local time by + `.astimezone()` anywhere they're read back (e.g. every time-of-use + match in the costing engine) -- a correctness bug that only shows up + when the host's local timezone isn't UTC. This type makes the + round-trip explicit: always stores naive-UTC, always returns + timezone-aware UTC. + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect): + if value is None: + return None + if value.tzinfo is None: + raise ValueError("UTCDateTime requires a timezone-aware datetime") + return value.astimezone(timezone.utc).replace(tzinfo=None) + + def process_result_value(self, value: datetime | None, dialect): + if value is None: + return None + return value.replace(tzinfo=timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class IntervalReadingRow(Base): + __tablename__ = "interval_reading" + __table_args__ = (UniqueConstraint("ts", "source", name="uq_interval_reading_ts_source"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ts: Mapped[datetime] = mapped_column(UTCDateTime, index=True) + source: Mapped[str] = mapped_column(String) # 'isolarcloud' | 'mock' + generation_kwh: Mapped[float] = mapped_column(Float) + load_kwh: Mapped[float] = mapped_column(Float) + import_kwh: Mapped[float] = mapped_column(Float) + export_kwh: Mapped[float] = mapped_column(Float) + resolution_minutes: Mapped[int] = mapped_column(Integer) + + +class PlanCacheRow(Base): + __tablename__ = "plan_cache" + + plan_id: Mapped[str] = mapped_column(String, primary_key=True) + retailer: Mapped[str] = mapped_column(String) + plan_name: Mapped[str] = mapped_column(String) + fuel_type: Mapped[str] = mapped_column(String) + distributors_json: Mapped[str] = mapped_column(String) # JSON list + last_refreshed: Mapped[datetime] = mapped_column(UTCDateTime) + raw_detail_json: Mapped[str] = mapped_column(String) + parsed_tariff_json: Mapped[str] = mapped_column(String) + + +class CurrentPlanConfigRow(Base): + __tablename__ = "current_plan_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + retailer_name: Mapped[str] = mapped_column(String) + plan_name: Mapped[str] = mapped_column(String) + tariff_json: Mapped[str] = mapped_column(String) + + +class HouseholdConfigRow(Base): + __tablename__ = "household_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + distributor: Mapped[str] = mapped_column(String, default="Energex") + postcode: Mapped[str] = mapped_column(String, default="") + isolarcloud_ps_id: Mapped[str] = mapped_column(String, default="") + + +class SyncStateRow(Base): + __tablename__ = "sync_state" + + key: Mapped[str] = mapped_column(String, primary_key=True) + value: Mapped[str] = mapped_column(String) diff --git a/app/db/repositories.py b/app/db/repositories.py new file mode 100644 index 0000000..1ed3e1c --- /dev/null +++ b/app/db/repositories.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Session + +from app.costing.engine import IntervalReading +from app.costing.tariff_models import TariffPlan +from app.db.models import ( + CurrentPlanConfigRow, + HouseholdConfigRow, + IntervalReadingRow, + PlanCacheRow, + SyncStateRow, +) + + +_INSERT_BATCH_SIZE = 500 # keeps well under SQLite's default SQL variable limit + + +def upsert_interval_readings(session: Session, rows: list[dict]) -> int: + """Insert interval readings, ignoring rows that already exist for the + same (ts, source) so repeated polling / restarts never duplicate data.""" + inserted = 0 + for i in range(0, len(rows), _INSERT_BATCH_SIZE): + batch = rows[i : i + _INSERT_BATCH_SIZE] + stmt = sqlite_insert(IntervalReadingRow).values(batch) + stmt = stmt.on_conflict_do_nothing(index_elements=["ts", "source"]) + result = session.execute(stmt) + inserted += result.rowcount or 0 + return inserted + + +def get_interval_readings(session: Session, start: datetime, end: datetime) -> list[IntervalReading]: + stmt = ( + select(IntervalReadingRow) + .where(IntervalReadingRow.ts >= start, IntervalReadingRow.ts < end) + .order_by(IntervalReadingRow.ts) + ) + return [ + IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) + for row in session.scalars(stmt) + ] + + +def get_earliest_reading_ts(session: Session) -> datetime | None: + return session.scalar(select(func.min(IntervalReadingRow.ts))) + + +def get_usage_series(session: Session, start: datetime, end: datetime) -> list[IntervalReadingRow]: + stmt = ( + select(IntervalReadingRow) + .where(IntervalReadingRow.ts >= start, IntervalReadingRow.ts < end) + .order_by(IntervalReadingRow.ts) + ) + return list(session.scalars(stmt)) + + +def upsert_plan(session: Session, plan: TariffPlan, raw_detail: dict, fuel_type: str = "ELECTRICITY") -> None: + row = session.get(PlanCacheRow, plan.plan_id) + values = dict( + retailer=plan.retailer, + plan_name=plan.plan_name, + fuel_type=fuel_type, + distributors_json=json.dumps(plan.distributors), + last_refreshed=datetime.now(timezone.utc), + raw_detail_json=json.dumps(raw_detail), + parsed_tariff_json=plan.model_dump_json(), + ) + if row is None: + session.add(PlanCacheRow(plan_id=plan.plan_id, **values)) + else: + for k, v in values.items(): + setattr(row, k, v) + + +def get_cached_plans(session: Session) -> list[TariffPlan]: + return [TariffPlan.model_validate_json(row.parsed_tariff_json) for row in session.scalars(select(PlanCacheRow))] + + +def get_current_plan(session: Session) -> TariffPlan | None: + row = session.get(CurrentPlanConfigRow, 1) + if row is None: + return None + return TariffPlan.model_validate_json(row.tariff_json) + + +def set_current_plan(session: Session, retailer_name: str, plan_name: str, plan: TariffPlan) -> None: + row = session.get(CurrentPlanConfigRow, 1) + if row is None: + row = CurrentPlanConfigRow(id=1) + session.add(row) + row.retailer_name = retailer_name + row.plan_name = plan_name + row.tariff_json = plan.model_dump_json() + + +def get_household_config(session: Session) -> HouseholdConfigRow: + row = session.get(HouseholdConfigRow, 1) + if row is None: + row = HouseholdConfigRow(id=1) + session.add(row) + session.flush() + return row + + +def get_sync_state(session: Session, key: str) -> str | None: + row = session.get(SyncStateRow, key) + return row.value if row else None + + +def set_sync_state(session: Session, key: str, value: str) -> None: + row = session.get(SyncStateRow, key) + if row is None: + session.add(SyncStateRow(key=key, value=value)) + else: + row.value = value diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..dc7d984 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,28 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import settings +from app.db.models import Base + +engine = create_engine(settings.db_url, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) + + +def init_db() -> None: + Base.metadata.create_all(engine) + + +@contextmanager +def get_session() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/app/isolarcloud/__init__.py b/app/isolarcloud/__init__.py new file mode 100644 index 0000000..a200e7b --- /dev/null +++ b/app/isolarcloud/__init__.py @@ -0,0 +1,26 @@ +from typing import Protocol +from datetime import datetime + +from app.isolarcloud.schemas import IntervalDataPoint + + +class ISolarCloudLike(Protocol): + def fetch_interval_data(self, since: datetime, until: datetime) -> list[IntervalDataPoint]: ... + + +def get_isolarcloud_client() -> ISolarCloudLike: + from app.config import settings + + if settings.use_mock_isolarcloud: + from app.isolarcloud.mock_client import MockISolarCloudClient + + return MockISolarCloudClient() + + if settings.isolarcloud_auth_mode == "oauth2": + from app.isolarcloud.oauth_client import ISolarCloudOAuthClient + + return ISolarCloudOAuthClient() + + from app.isolarcloud.client import ISolarCloudClient + + return ISolarCloudClient() diff --git a/app/isolarcloud/client.py b/app/isolarcloud/client.py new file mode 100644 index 0000000..86e7338 --- /dev/null +++ b/app/isolarcloud/client.py @@ -0,0 +1,236 @@ +"""Real iSolarCloud client -- "V1 account login" (non-OAuth2) app. + +Implements the exact same `fetch_interval_data(since, until)` interface as +`mock_client.MockISolarCloudClient` so the scheduler can swap between them +via `settings.use_mock_isolarcloud` with no other code changes. + +Auth flow, headers and the core endpoints (`/openapi/login`, +`/openapi/getPowerStationList`, `/openapi/getDeviceList`, +`/openapi/getDevicePointMinuteDataList`, `/openapi/getDeviceRealTimeData`) +are taken from steven-g-w/HA-Sungrow (github.com/steven-g-w/HA-Sungrow), a +Home Assistant integration built directly against +https://developer-api.isolarcloud.com/ using this same "V1 login" app type +(as opposed to the separate OAuth2 app type, which uses different +`/openapi/platform/...` endpoints and an authorization-code browser flow -- +not what an app with an App Key + Secret Key + username/password uses). + +What's NOT independently verified here (confirm with +scripts/verify_isolarcloud_cli.py against your real account before trusting +the numbers -- see SETUP.md): + +- RSA password encryption. Some V1-login apps are issued an RSA public key + (Settings -> Open API in iSolarCloud) and are expected to encrypt the + password before sending it; others aren't and send it as plain text over + HTTPS. The exact padding scheme Sungrow expects isn't published anywhere + public -- `_encrypt_password` below uses the common RSA/PKCS1v1.5 + + base64 convention as a best effort. If login fails with an RSA key + configured, try without one (some accounts don't need it) or adjust the + padding here based on what the portal's own "Documentation" page (visible + after logging in) specifies for the login endpoint. +- Which device serves generation vs. import/export/load. iSolarCloud + reports these per *device* (inverter, meter, ...), not per plant, in this + API family. `fetch_interval_data` picks the first METER device for + import/export/load and the first INVERTER for generation, which is + correct for the common single-inverter, grid-meter household setup this + app targets, but should be confirmed with + `scripts/verify_isolarcloud_cli.py devices` against your real plant. +""" + +from __future__ import annotations + +import base64 +import logging +from datetime import datetime + +import httpx +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key + +from app.config import settings +from app.db.repositories import get_sync_state, set_sync_state +from app.db.session import get_session +from app.isolarcloud.parsing import POINT_EXPORT, POINT_GENERATION, POINT_IMPORT, POINT_LOAD, TS_FORMAT, merge_frames +from app.isolarcloud.schemas import IntervalDataPoint + +_LOGGER = logging.getLogger(__name__) + +REGION_GATEWAYS = { + "australia": "https://augateway.isolarcloud.com", + "europe": "https://gateway.isolarcloud.eu", + "china": "https://gateway.isolarcloud.com", + "international": "https://gateway.isolarcloud.com.hk", +} + +LOGIN_PATH = "/openapi/login" +POWER_STATION_LIST_PATH = "/openapi/getPowerStationList" +DEVICE_LIST_PATH = "/openapi/getDeviceList" +MINUTE_HISTORY_PATH = "/openapi/getDevicePointMinuteDataList" + +DEVICE_TYPE_INVERTER = 1 +DEVICE_TYPE_METER = 7 + +# result_code values meaning the token is invalid/expired -- worth one +# re-login + retry. Per HA-Sungrow, E900 covers both an expired token and +# "no permission", so a retry can be futile, but it only costs one call. +TOKEN_ERROR_CODES = {"E900"} + + +class ISolarCloudAuthError(RuntimeError): + pass + + +def _load_public_key(key_str: str): + """Accepts either a full PEM key or the raw base64 (standard or + URL-safe -- the developer portal's "Overview" page gives it without + BEGIN/END headers and with -/_ in place of +//) DER SubjectPublicKeyInfo + that the iSolarCloud portal actually hands out.""" + key_str = key_str.strip() + if "BEGIN" in key_str: + return load_pem_public_key(key_str.encode()) + # Raw base64: normalize URL-safe alphabet and padding, then DER-decode. + normalized = key_str.replace("-", "+").replace("_", "/") + normalized += "=" * (-len(normalized) % 4) + der_bytes = base64.b64decode(normalized) + return load_der_public_key(der_bytes) + + +def _encrypt_password(password: str, public_key_str: str) -> str: + """Best-effort RSA/PKCS1v1.5 encryption, base64-encoded. See the module + docstring -- this is the common convention for this class of API, not + an independently confirmed one for Sungrow specifically.""" + public_key = _load_public_key(public_key_str) + ciphertext = public_key.encrypt(password.encode(), padding.PKCS1v15()) + return base64.b64encode(ciphertext).decode() + + +class ISolarCloudClient: + def __init__(self) -> None: + self.host = REGION_GATEWAYS[settings.isolarcloud_region] + self.appkey = settings.isolarcloud_app_key + self.secret_key = settings.isolarcloud_app_secret + self.username = settings.isolarcloud_username + self.password = settings.isolarcloud_password + self.rsa_public_key = settings.isolarcloud_rsa_public_key + self._client = httpx.Client(timeout=30) + self._token: str | None = None + + def _headers(self) -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "x-access-key": self.secret_key, + "sys_code": "901", + } + if self._token: + headers["token"] = self._token + return headers + + def _post(self, path: str, payload: dict) -> dict: + body = {"appkey": self.appkey, "lang": "_en_US", **payload} + if self._token and "user_password" not in payload: + body["token"] = self._token + resp = self._client.post(f"{self.host}{path}", json=body, headers=self._headers()) + resp.raise_for_status() + return resp.json() + + def login(self) -> None: + if not self.username or not self.password: + raise ISolarCloudAuthError("iSolarCloud username/password not configured -- set them in .env.") + password = self.password + if self.rsa_public_key: + password = _encrypt_password(password, self.rsa_public_key) + + data = self._post(LOGIN_PATH, {"user_account": self.username, "user_password": password, "login_type": "1"}) + result_code = str(data.get("result_code")) + result_data = data.get("result_data") or {} + if result_code != "1": + raise ISolarCloudAuthError(f"Login failed (result_code={result_code}): {data.get('result_msg')}") + if str(result_data.get("login_state")) not in ("1", "None", ""): + raise ISolarCloudAuthError( + f"Login rejected (login_state={result_data.get('login_state')}): " + f"{result_data.get('msg') or data.get('result_msg')}" + ) + token = result_data.get("token") + if not token: + raise ISolarCloudAuthError("Login succeeded but no token was returned") + self._token = token + with get_session() as session: + set_sync_state(session, "isolarcloud_token", token) + _LOGGER.debug("Logged in to iSolarCloud, token acquired") + + def _load_cached_token(self) -> None: + if self._token is not None: + return + with get_session() as session: + self._token = get_sync_state(session, "isolarcloud_token") + + def _authenticated_post(self, path: str, payload: dict) -> dict: + self._load_cached_token() + if self._token is None: + self.login() + data = self._post(path, payload) + result_code = str(data.get("result_code")) + result_msg = str(data.get("result_msg") or "") + if result_code != "1" and (result_code in TOKEN_ERROR_CODES or "token" in result_msg.lower()): + _LOGGER.debug("Token rejected (result_code=%s, msg=%s); re-authenticating", result_code, result_msg) + self._token = None + self.login() + data = self._post(path, payload) + result_code = str(data.get("result_code")) + if result_code != "1": + raise ISolarCloudAuthError(f"iSolarCloud error on {path} (result_code={result_code}): {data.get('result_msg')}") + return data.get("result_data") or {} + + def get_plant_list(self) -> list[dict]: + result = self._authenticated_post(POWER_STATION_LIST_PATH, {"curPage": 1, "size": 100}) + return result.get("pageList") or result.get("page_list") or [] + + def get_device_list(self, ps_id: str) -> list[dict]: + result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "curPage": 1, "size": 100}) + return result.get("pageList") or result.get("page_list") or [] + + def get_minute_history(self, ps_key: str, point_ids: list[str], start: str, end: str, minute_interval: int = 5) -> list[dict]: + result = self._authenticated_post( + MINUTE_HISTORY_PATH, + { + "ps_key_list": [ps_key], + "points": ",".join(f"p{p}" for p in point_ids), + "start_time_stamp": start, + "end_time_stamp": end, + "minute_interval": str(minute_interval), + }, + ) + frames = result.get(ps_key) or [] + return [f for f in frames if isinstance(f, dict)] + + def fetch_interval_data(self, since: datetime, until: datetime) -> list[IntervalDataPoint]: + from app.db.repositories import get_household_config + + with get_session() as session: + ps_id = get_household_config(session).isolarcloud_ps_id or settings.isolarcloud_plant_id + if not ps_id: + plants = self.get_plant_list() + if not plants: + raise ISolarCloudAuthError("No plants visible to this account.") + ps_id = plants[0]["ps_id"] + + devices = self.get_device_list(ps_id) + meter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_METER), None) + inverter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_INVERTER), None) + if meter is None or inverter is None: + raise ISolarCloudAuthError( + "Could not find both a meter and an inverter device on this plant -- " + "run `python scripts/verify_isolarcloud_cli.py devices` and adjust " + "app/isolarcloud/client.py's device selection for your setup." + ) + + # The endpoint takes timestamps in the plant's local time zone, not + # UTC (consistent with the OAuth2 app family's equivalent endpoint, + # confirmed against Sungrow's official docs -- see oauth_client.py). + from app.isolarcloud.parsing import PLANT_TZ + + start_s = since.astimezone(PLANT_TZ).strftime(TS_FORMAT) + end_s = until.astimezone(PLANT_TZ).strftime(TS_FORMAT) + meter_frames = self.get_minute_history(meter["ps_key"], [POINT_LOAD, POINT_IMPORT, POINT_EXPORT], start_s, end_s) + inverter_frames = self.get_minute_history(inverter["ps_key"], [POINT_GENERATION], start_s, end_s) + + return merge_frames(meter_frames, inverter_frames) diff --git a/app/isolarcloud/mock_client.py b/app/isolarcloud/mock_client.py new file mode 100644 index 0000000..9c77edc --- /dev/null +++ b/app/isolarcloud/mock_client.py @@ -0,0 +1,67 @@ +"""Synthetic stand-in for the real iSolarCloud client. + +Lets Phases 1-4 (persistence, scheduler, web UI, costing) be built and +demoed end-to-end before a real developer App Key/Secret exists (see +SETUP.md). `RealISolarCloudClient` in client.py implements the exact same +`fetch_interval_data` interface so the scheduler can swap between them via +`settings.use_mock_isolarcloud` with no other code changes. +""" + +from __future__ import annotations + +import math +import random +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from app.isolarcloud.schemas import IntervalDataPoint + +BNE = ZoneInfo("Australia/Brisbane") +RESOLUTION_MINUTES = 5 + + +def _synthetic_point(ts: datetime) -> IntervalDataPoint: + local = ts.astimezone(BNE) + hour = local.hour + local.minute / 60 + + # Crude seasonal scaling: more sun and longer days in the QLD summer. + seasonal_factor = 1.0 + 0.25 * math.cos(math.radians((local.month - 1) * 30)) + daylight = max(0.0, math.sin(math.pi * (hour - 6) / 12)) if 6 <= hour <= 18 else 0.0 + peak_kw = 5.0 * seasonal_factor + + # Seed on the timestamp (not wall-clock random) so re-generating the same + # interval twice is stable-ish; harmless either way since the DB + # deduplicates on (ts, source) and never overwrites an existing row. + rng = random.Random(int(ts.timestamp()) // 60) + generation_kw = peak_kw * daylight * rng.uniform(0.85, 1.15) + + base_load_kw = 0.4 + morning_bump = 0.8 * math.exp(-((hour - 7) ** 2) / 2) + evening_bump = 1.6 * math.exp(-((hour - 19) ** 2) / 2) + load_kw = (base_load_kw + morning_bump + evening_bump) * rng.uniform(0.9, 1.1) + + step_hours = RESOLUTION_MINUTES / 60 + generation_kwh = generation_kw * step_hours + load_kwh = load_kw * step_hours + import_kwh = max(0.0, load_kwh - generation_kwh) + export_kwh = max(0.0, generation_kwh - load_kwh) + + return IntervalDataPoint( + ts=ts, + generation_kwh=round(generation_kwh, 4), + load_kwh=round(load_kwh, 4), + import_kwh=round(import_kwh, 4), + export_kwh=round(export_kwh, 4), + resolution_minutes=RESOLUTION_MINUTES, + ) + + +class MockISolarCloudClient: + def fetch_interval_data(self, since: datetime, until: datetime) -> list[IntervalDataPoint]: + points = [] + ts = since + step = timedelta(minutes=RESOLUTION_MINUTES) + while ts < until: + points.append(_synthetic_point(ts)) + ts += step + return points diff --git a/app/isolarcloud/oauth_client.py b/app/isolarcloud/oauth_client.py new file mode 100644 index 0000000..b113a83 --- /dev/null +++ b/app/isolarcloud/oauth_client.py @@ -0,0 +1,299 @@ +"""Real iSolarCloud client -- OAuth2.0 app type. + +This is a *separate* developer-portal app registration from the "V1 +account login" one in client.py -- different credentials (an Application +ID + redirect URI, no username/password needed at request time) and a +different, browser-based consent flow. See SETUP.md. + +Endpoint paths, auth flow and field names below are all confirmed directly +against Sungrow's own public documentation at +https://audeveloper-api.isolarcloud.com/#/document (the "OAuth2.0" tab), +not third-party reverse-engineering -- specifically: + +- Getting Started: header/body conventions (`x-access-key`, `Authorization: + Bearer `, `appkey` in every body). +- "Obtain token via authorization code" (`/openapi/apiManage/token`) -- + its response includes `auth_ps_list`, the plant IDs the user actually + granted this app access to during the consent screen. This is the + explicit authorization step that iSolarCloud's V1 login apps apparently + don't reliably get without it (see the E900 "Unauthorized access" + investigation this project went through before switching to OAuth2). +- "Refresh Token" (`/openapi/apiManage/refreshToken`). +- "Query Plant List" (`/openapi/platform/queryPowerStationList`), "Query + Device List of the Plant" (`/openapi/platform/getDeviceListByPsId`), + "Query plant real-time measuring point data" + (`/openapi/platform/getPowerStationRealTimeData`), and "Query Historical + Minute-Level Measuring Point Data of Plants" + (`/openapi/platform/getPowerStationPointMinuteDataList`) -- all + plant-level (keyed by `ps_id`, not a per-device `ps_key` like the V1 + app's endpoints), which is simpler: no device discovery needed. + +Two constraints from the historical-data endpoint's own docs that shape +`fetch_interval_data` below: +- It accepts at most a 3-hour window per call, and multiple calls are + needed to cover a longer backfill range. +- "Historical data endpoints do not return data for the current day" -- + today's figures have to come from the real-time endpoint instead, which + only returns a live snapshot (not a time series). This client derives + today's interval contribution by diffing each poll's cumulative + import/export counters against the previous poll's (persisted in + `sync_state`), the same way multiple historical frames are diffed + against each other. +""" + +from __future__ import annotations + +import logging +import time +from datetime import date, datetime, timedelta +from urllib.parse import quote_plus +from zoneinfo import ZoneInfo + +import httpx + +from app.config import settings +from app.db.repositories import get_sync_state, set_sync_state +from app.db.session import get_session +from app.isolarcloud.parsing import POINT_EXPORT, POINT_GENERATION, POINT_IMPORT, POINT_LOAD, TS_FORMAT, merge_frames +from app.isolarcloud.schemas import IntervalDataPoint + +_LOGGER = logging.getLogger(__name__) + +BRISBANE_TZ = ZoneInfo("Australia/Brisbane") + +REGION_GATEWAYS = { + "australia": "https://augateway.isolarcloud.com", + "europe": "https://gateway.isolarcloud.eu", + "china": "https://gateway.isolarcloud.com", + "international": "https://gateway.isolarcloud.com.hk", +} +# (auth-page host, cloudId) per region, used to build the browser consent URL. +REGION_AUTH_SERVERS = { + "australia": ("auweb3.isolarcloud.com", 7), + "europe": ("web3.isolarcloud.eu", 3), + "china": ("web3.isolarcloud.com", 1), + "international": ("web3.isolarcloud.com.hk", 2), +} + +TOKEN_PATH = "/openapi/apiManage/token" +REFRESH_PATH = "/openapi/apiManage/refreshToken" +PLANT_LIST_PATH = "/openapi/platform/queryPowerStationList" +DEVICE_LIST_PATH = "/openapi/platform/getDeviceListByPsId" +REALTIME_DATA_PATH = "/openapi/platform/getPowerStationRealTimeData" +MINUTE_HISTORY_PATH = "/openapi/platform/getPowerStationPointMinuteDataList" + +MAX_HISTORY_WINDOW = timedelta(hours=3) # the endpoint's own documented limit + + +class ISolarCloudAuthError(RuntimeError): + pass + + +class ISolarCloudOAuthClient: + def __init__(self) -> None: + self.host = REGION_GATEWAYS[settings.isolarcloud_region] + self.auth_server, self.cloud_id = REGION_AUTH_SERVERS[settings.isolarcloud_region] + self.appkey = settings.isolarcloud_app_key + self.secret_key = settings.isolarcloud_app_secret + self.app_id = settings.isolarcloud_oauth_app_id + self.redirect_uri = settings.isolarcloud_oauth_redirect_uri + self._client = httpx.Client(timeout=30) + self._access_token: str | None = None + + def authorize_url(self) -> str: + """URL the account owner opens once, in their own browser, to + approve this app's access to their plant(s).""" + return ( + f"https://{self.auth_server}/#/authorized-app?cloudId={self.cloud_id}" + f"&applicationId={self.app_id}&redirectUrl={quote_plus(self.redirect_uri)}" + ) + + def _headers(self, access_token: str | None = None) -> dict[str, str]: + headers = {"Content-Type": "application/json", "x-access-key": self.secret_key} + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + def _post(self, path: str, payload: dict, access_token: str | None = None) -> dict: + body = {"appkey": self.appkey, "lang": "_en_US", **payload} + resp = self._client.post(f"{self.host}{path}", json=body, headers=self._headers(access_token)) + resp.raise_for_status() + return resp.json() + + def exchange_code(self, code: str) -> list[str]: + """Completes the one-time consent flow: trades the `code` param + from the redirect for an access/refresh token pair, and returns + the list of plant IDs the user actually authorized (`auth_ps_list`) + so Settings can show them for plant selection.""" + data = self._post(TOKEN_PATH, {"code": code, "grant_type": "authorization_code", "redirect_uri": self.redirect_uri}) + result_data = data.get("result_data") or {} + if not result_data.get("access_token"): + raise ISolarCloudAuthError(f"Authorization failed: {data}") + self._store_tokens(result_data) + return result_data.get("auth_ps_list") or [] + + def _store_tokens(self, result_data: dict) -> None: + expires_at = int(time.time()) + int(result_data["expires_in"]) - 60 + with get_session() as session: + set_sync_state(session, "isolarcloud_oauth_access_token", result_data["access_token"]) + set_sync_state(session, "isolarcloud_oauth_refresh_token", result_data["refresh_token"]) + set_sync_state(session, "isolarcloud_oauth_expires_at", str(expires_at)) + self._access_token = result_data["access_token"] + + def _get_access_token(self) -> str: + with get_session() as session: + access_token = get_sync_state(session, "isolarcloud_oauth_access_token") + refresh_token = get_sync_state(session, "isolarcloud_oauth_refresh_token") + expires_at = get_sync_state(session, "isolarcloud_oauth_expires_at") + if not access_token or not refresh_token: + raise ISolarCloudAuthError( + "No iSolarCloud OAuth tokens stored yet -- connect your account from the Settings page first." + ) + if int(expires_at or 0) < int(time.time()): + data = self._post(REFRESH_PATH, {"refresh_token": refresh_token}) + result_data = data.get("result_data") or {} + if not isinstance(result_data, dict) or not result_data.get("access_token"): + raise ISolarCloudAuthError(f"Token refresh failed: {data} -- reconnect from Settings.") + self._store_tokens(result_data) + access_token = result_data["access_token"] + return access_token + + def _authenticated_post(self, path: str, payload: dict) -> dict: + access_token = self._get_access_token() + data = self._post(path, payload, access_token=access_token) + # Two distinct failure shapes are documented: the usual + # {result_code, result_msg, ...} and an OAuth-specific + # {"error": "invalid_token", "error_description": ...}. + if data.get("error"): + raise ISolarCloudAuthError(f"iSolarCloud OAuth error on {path}: {data.get('error_description') or data['error']}") + result_code = str(data.get("result_code")) + if result_code != "1": + raise ISolarCloudAuthError(f"iSolarCloud error on {path} (result_code={result_code}): {data.get('result_msg')}") + return data.get("result_data") or {} + + def get_plant_list(self) -> list[dict]: + result = self._authenticated_post(PLANT_LIST_PATH, {"page": 1, "size": 100}) + return result.get("pageList") or [] + + def get_device_list(self, ps_id: str) -> list[dict]: + result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "page": 1, "size": 100}) + return result.get("pageList") or [] + + def get_realtime_points(self, ps_id: str, point_ids: list[str]) -> dict[str, float]: + result = self._authenticated_post( + REALTIME_DATA_PATH, {"ps_id_list": [ps_id], "point_id_list": point_ids, "is_get_point_dict": "0"} + ) + device_points = result.get("device_point_list") or [] + if not device_points: + return {} + values: dict[str, float] = {} + for key, value in device_points[0].items(): + if key.startswith("p") and key[1:].isdigit(): + try: + values[key[1:]] = float(value) + except (TypeError, ValueError): + continue + return values + + def get_minute_history(self, ps_id: str, point_ids: list[str], start: datetime, end: datetime) -> list[dict]: + """Chunks the request into <=3-hour windows per the endpoint's + documented limit, and merges the resulting frames.""" + frames: list[dict] = [] + chunk_start = start + while chunk_start < end: + chunk_end = min(chunk_start + MAX_HISTORY_WINDOW, end) + result = self._authenticated_post( + MINUTE_HISTORY_PATH, + { + "ps_id_list": [ps_id], + "points": ",".join(f"p{p}" for p in point_ids), + "start_time_stamp": chunk_start.strftime(TS_FORMAT), + "end_time_stamp": chunk_end.strftime(TS_FORMAT), + "minute_interval": "5", + }, + ) + frames.extend(f for f in (result.get(str(ps_id)) or []) if isinstance(f, dict)) + chunk_start = chunk_end + return frames + + def _resolve_ps_id(self) -> str: + from app.db.repositories import get_household_config + + with get_session() as session: + ps_id = get_household_config(session).isolarcloud_ps_id or settings.isolarcloud_plant_id + if ps_id: + return ps_id + plants = self.get_plant_list() + if not plants: + raise ISolarCloudAuthError("No plants visible to this account.") + return str(plants[0]["ps_id"]) + + def fetch_interval_data(self, since: datetime, until: datetime) -> list[IntervalDataPoint]: + ps_id = self._resolve_ps_id() + points = [POINT_GENERATION, POINT_LOAD, POINT_IMPORT, POINT_EXPORT] + + today_start = datetime.now(BRISBANE_TZ).replace(hour=0, minute=0, second=0, microsecond=0) + + results: list[IntervalDataPoint] = [] + + historical_end = min(until, today_start) + if since < historical_end: + # The endpoint takes timestamps in the plant's local time zone, + # not UTC -- convert before formatting (see module docstring). + frames = self.get_minute_history(ps_id, points, since.astimezone(BRISBANE_TZ), historical_end.astimezone(BRISBANE_TZ)) + results.extend(merge_frames(frames)) + + if until > today_start: + results.extend(self._poll_today(ps_id, points, until)) + + return results + + def _poll_today(self, ps_id: str, point_ids: list[str], now: datetime) -> list[IntervalDataPoint]: + """Derives one interval point for "right now" by diffing this + poll's cumulative import/export counters against the last poll's + (historical endpoints don't cover today at all).""" + values = self.get_realtime_points(ps_id, point_ids) + if not values: + return [] + + today_key = date.today().isoformat() + with get_session() as session: + last_day = get_sync_state(session, "isolarcloud_oauth_today") + last_import_wh = get_sync_state(session, "isolarcloud_oauth_last_import_wh") + last_export_wh = get_sync_state(session, "isolarcloud_oauth_last_export_wh") + last_poll_ts = get_sync_state(session, "isolarcloud_oauth_last_poll_ts") + + import_wh = values.get(POINT_IMPORT) + export_wh = values.get(POINT_EXPORT) + is_new_day = last_day != today_key + + import_kwh = 0.0 + export_kwh = 0.0 + if not is_new_day and last_import_wh is not None and import_wh is not None: + import_kwh = max(0.0, (import_wh - float(last_import_wh)) / 1000) + if not is_new_day and last_export_wh is not None and export_wh is not None: + export_kwh = max(0.0, (export_wh - float(last_export_wh)) / 1000) + + elapsed_minutes = 5.0 + if not is_new_day and last_poll_ts: + elapsed_minutes = max(1.0, (now - datetime.fromisoformat(last_poll_ts)).total_seconds() / 60) + + with get_session() as session: + set_sync_state(session, "isolarcloud_oauth_today", today_key) + if import_wh is not None: + set_sync_state(session, "isolarcloud_oauth_last_import_wh", str(import_wh)) + if export_wh is not None: + set_sync_state(session, "isolarcloud_oauth_last_export_wh", str(export_wh)) + set_sync_state(session, "isolarcloud_oauth_last_poll_ts", now.isoformat()) + + step_hours = elapsed_minutes / 60 + return [ + IntervalDataPoint( + ts=now, + generation_kwh=values.get(POINT_GENERATION, 0.0) * step_hours / 1000, + load_kwh=values.get(POINT_LOAD, 0.0) * step_hours / 1000, + import_kwh=import_kwh, + export_kwh=export_kwh, + resolution_minutes=round(elapsed_minutes), + ) + ] diff --git a/app/isolarcloud/parsing.py b/app/isolarcloud/parsing.py new file mode 100644 index 0000000..5577d59 --- /dev/null +++ b/app/isolarcloud/parsing.py @@ -0,0 +1,98 @@ +"""Shared measure-point parsing for both the V1 and OAuth2 iSolarCloud +clients -- the `p` frame shape and cumulative-energy diffing +logic are identical regardless of which auth style fetched the frames. +""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from app.isolarcloud.schemas import IntervalDataPoint + +TS_FORMAT = "%Y%m%d%H%M%S" + +# iSolarCloud's history/real-time endpoints report `time_stamp` in "the +# time zone where the plant is located" per their own docs, not UTC -- +# for this app (QLD, no DST) that's a fixed Australia/Brisbane offset. +PLANT_TZ = ZoneInfo("Australia/Brisbane") + +# Measure-point codes for a solar-only (no battery) household, confirmed +# against Sungrow's official developer-portal documentation (Appendix 10 / +# the Monitoring endpoint examples) -- shared across both device-level (V1) +# and plant-level (OAuth2) queries, since these are plain telemetry point +# definitions, not tied to which auth style an app uses. +# +# POINT_GENERATION was originally 83067 ("Total Active Power of PV"), which +# is a documented plant-level point but returned nothing at all for this +# real single-inverter residential plant (likely a microinverter/optimizer +# -specific point that a standard string inverter doesn't populate) -- +# confirmed live against the real account that 83033 ("Plant Power") is +# what this plant actually reports, matching 83002 (Inverter AC Power) +# 1:1 for this single-inverter system. +POINT_GENERATION = "83033" # Plant Power (W, instantaneous) +POINT_LOAD = "83106" # Load Power (W, instantaneous) +POINT_IMPORT = "83105" # Total Purchased Energy (Wh, lifetime cumulative) +POINT_EXPORT = "83075" # Feed-in Energy Total (Wh, lifetime cumulative) + + +def extract_points(frame: dict) -> dict[str, float]: + values: dict[str, float] = {} + for key, value in frame.items(): + if key == "time_stamp" or not key.startswith("p"): + continue + try: + values[key[1:]] = float(value) + except (TypeError, ValueError): + continue + return values + + +def merge_frames(*frame_lists: list[dict], resolution_minutes: int = 5) -> list[IntervalDataPoint]: + """Combines one or more lists of `p`-keyed frames (sharing the same + `time_stamp` format) into IntervalDataPoints, converting the + cumulative import/export counters into per-interval kWh by diffing + consecutive frames. + """ + by_ts: dict[datetime, dict[str, float]] = {} + for frames in frame_lists: + for frame in frames: + # Naive by construction (strptime); attach the plant's local + # tz so this is a well-defined instant, not a UTC-DateTime-column + # crash waiting to happen (see UTCDateTime in app/db/models.py). + ts = datetime.strptime(frame["time_stamp"], TS_FORMAT).replace(tzinfo=PLANT_TZ) + by_ts.setdefault(ts, {}).update(extract_points(frame)) + + points: list[IntervalDataPoint] = [] + prev_import_wh: float | None = None + prev_export_wh: float | None = None + for ts in sorted(by_ts): + values = by_ts[ts] + generation_w = values.get(POINT_GENERATION, 0.0) + load_w = values.get(POINT_LOAD, 0.0) + import_wh = values.get(POINT_IMPORT) + export_wh = values.get(POINT_EXPORT) + + import_kwh = ( + max(0.0, (import_wh - prev_import_wh) / 1000) if prev_import_wh is not None and import_wh is not None else 0.0 + ) + export_kwh = ( + max(0.0, (export_wh - prev_export_wh) / 1000) if prev_export_wh is not None and export_wh is not None else 0.0 + ) + if import_wh is not None: + prev_import_wh = import_wh + if export_wh is not None: + prev_export_wh = export_wh + + step_hours = resolution_minutes / 60 + points.append( + IntervalDataPoint( + ts=ts, + generation_kwh=generation_w * step_hours / 1000, + load_kwh=load_w * step_hours / 1000, + import_kwh=import_kwh, + export_kwh=export_kwh, + resolution_minutes=resolution_minutes, + ) + ) + return points diff --git a/app/isolarcloud/schemas.py b/app/isolarcloud/schemas.py new file mode 100644 index 0000000..8f8156a --- /dev/null +++ b/app/isolarcloud/schemas.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class IntervalDataPoint(BaseModel): + ts: datetime + generation_kwh: float + load_kwh: float + import_kwh: float + export_kwh: float + resolution_minutes: int + + def as_row(self, source: str) -> dict: + return { + "ts": self.ts, + "source": source, + "generation_kwh": self.generation_kwh, + "load_kwh": self.load_kwh, + "import_kwh": self.import_kwh, + "export_kwh": self.export_kwh, + "resolution_minutes": self.resolution_minutes, + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8b9eb25 --- /dev/null +++ b/app/main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.api import isolarcloud as isolarcloud_api +from app.api import plans as plans_api +from app.api import settings as settings_api +from app.api import usage as usage_api +from app.db.session import init_db +from app.scheduler.setup import build_scheduler +from app.web.routes import router as web_router + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + scheduler = build_scheduler() + scheduler.start() + app.state.scheduler = scheduler + try: + yield + finally: + scheduler.shutdown(wait=False) + + +app = FastAPI(title="Power Compare", lifespan=lifespan) + +app.include_router(usage_api.router) +app.include_router(plans_api.router) +app.include_router(settings_api.router) +app.include_router(isolarcloud_api.router) +app.include_router(web_router) + +app.mount("/static", StaticFiles(directory="app/static"), name="static") + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} diff --git a/app/scheduler/__init__.py b/app/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py new file mode 100644 index 0000000..09d9bff --- /dev/null +++ b/app/scheduler/jobs.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from app.aer.cache import refresh_plan_cache +from app.config import settings +from app.db.repositories import get_household_config, get_sync_state, set_sync_state, upsert_interval_readings +from app.db.session import get_session +from app.isolarcloud import get_isolarcloud_client + +_LOGGER = logging.getLogger(__name__) + +POLL_STATE_KEY = "isolarcloud_last_poll_ts" +AER_STATE_KEY = "aer_last_refresh_ts" + +# Sungrow's own real-time data resolution is ~5 minutes -- don't try to +# backfill further back than this on first run; a fresh install just starts +# accumulating history from here. +DEFAULT_BACKFILL = timedelta(days=1) + + +def poll_isolarcloud_job() -> None: + now = datetime.now(timezone.utc) + with get_session() as session: + last_poll = get_sync_state(session, POLL_STATE_KEY) + since = datetime.fromisoformat(last_poll) if last_poll else now - DEFAULT_BACKFILL + + client = get_isolarcloud_client() + try: + points = client.fetch_interval_data(since, now) + except Exception: + _LOGGER.exception("iSolarCloud poll failed (since=%s)", since) + return + + source = "mock" if settings.use_mock_isolarcloud else "isolarcloud" + rows = [p.as_row(source) for p in points] + with get_session() as session: + inserted = upsert_interval_readings(session, rows) + set_sync_state(session, POLL_STATE_KEY, now.isoformat()) + _LOGGER.info("iSolarCloud poll: fetched %d points, inserted %d new rows", len(points), inserted) + + +def refresh_aer_plans_job() -> None: + with get_session() as session: + distributor = get_household_config(session).distributor or settings.distributor + try: + count = refresh_plan_cache(distributor_filter=distributor) + except Exception: + _LOGGER.exception("AER plan refresh failed") + return + with get_session() as session: + set_sync_state(session, AER_STATE_KEY, datetime.now(timezone.utc).isoformat()) + _LOGGER.info("AER plan refresh: cached %d plans for distributor '%s'", count, distributor) diff --git a/app/scheduler/setup.py b/app/scheduler/setup.py new file mode 100644 index 0000000..9372d00 --- /dev/null +++ b/app/scheduler/setup.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.config import settings +from app.scheduler.jobs import poll_isolarcloud_job, refresh_aer_plans_job + + +def build_scheduler() -> BackgroundScheduler: + scheduler = BackgroundScheduler() + scheduler.add_job( + poll_isolarcloud_job, + trigger=IntervalTrigger(minutes=settings.isolarcloud_poll_interval_minutes), + id="poll_isolarcloud", + next_run_time=datetime.now(), # run once immediately on startup + max_instances=1, + coalesce=True, + ) + scheduler.add_job( + refresh_aer_plans_job, + trigger=IntervalTrigger(hours=settings.aer_refresh_interval_hours), + id="refresh_aer_plans", + next_run_time=datetime.now(), + max_instances=1, + coalesce=True, + ) + return scheduler diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..1e716e5 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,166 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --border: #e5e7eb; + --accent: #2563eb; + --good: #16a34a; + --bad: #dc2626; + --card-bg: #f9fafb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #12141a; + --fg: #e8eaed; + --muted: #9aa1ac; + --border: #2a2d35; + --accent: #6ea8fe; + --good: #4ade80; + --bad: #f87171; + --card-bg: #1a1d24; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.topnav { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.topnav .brand { + font-weight: 700; + margin-right: 1rem; +} + +.topnav a { + color: var(--muted); + text-decoration: none; +} + +.topnav a:hover { + color: var(--fg); +} + +.content { + max-width: 1000px; + margin: 0 auto; + padding: 1.5rem; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--muted); + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; +} + +.savings-positive { color: var(--good); font-weight: 600; } +.savings-negative { color: var(--bad); } + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + background: var(--border); + color: var(--muted); +} + +.badge.current { background: var(--accent); color: white; } +.badge.not-modelable { background: var(--bad); color: white; } + +form.settings-form label { + display: block; + margin-top: 0.75rem; + font-size: 0.9rem; + color: var(--muted); +} + +form.settings-form input, form.settings-form select { + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; +} + +button { + margin-top: 1rem; + padding: 0.6rem 1.2rem; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.tou-period { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem; + margin-top: 0.75rem; +} + +.p-days-group { + margin-top: 0.75rem; + font-size: 0.9rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} + +form.settings-form label.p-day-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-top: 0; + width: auto; + color: var(--fg); + font-size: 0.85rem; +} + +form.settings-form label.p-day-label input { + width: auto; + margin-top: 0; +} + +.muted { color: var(--muted); font-size: 0.85rem; } +.chart-container { position: relative; height: 320px; margin-top: 0.75rem; } +.notes { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; } diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/routes.py b/app/web/routes.py new file mode 100644 index 0000000..e28180c --- /dev/null +++ b/app/web/routes.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +from app.config import settings + +router = APIRouter() +templates = Jinja2Templates(directory="app/web/templates") + + +@router.get("/") +def dashboard(request: Request): + return templates.TemplateResponse(request, "dashboard.html", {}) + + +@router.get("/plans") +def plans_page(request: Request): + return templates.TemplateResponse(request, "plans.html", {}) + + +@router.get("/settings") +def settings_page(request: Request): + return templates.TemplateResponse( + request, + "settings.html", + {"use_mock_isolarcloud": settings.use_mock_isolarcloud, "isolarcloud_auth_mode": settings.isolarcloud_auth_mode}, + ) diff --git a/app/web/templates/base.html b/app/web/templates/base.html new file mode 100644 index 0000000..9fccdd0 --- /dev/null +++ b/app/web/templates/base.html @@ -0,0 +1,21 @@ + + + + + + Power Compare + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html new file mode 100644 index 0000000..3848ebe --- /dev/null +++ b/app/web/templates/dashboard.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block content %} +

Usage Dashboard

+ +
+
Loading summary…
+
+ +
+
+

Generation / load / import / export

+ +
+
+ +
+
+ + +{% endblock %} diff --git a/app/web/templates/plans.html b/app/web/templates/plans.html new file mode 100644 index 0000000..52443c6 --- /dev/null +++ b/app/web/templates/plans.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block content %} +

Plan Comparison

+ +
+ Estimated cost based on your actual usage over the selected period. + +
+ +
+

+ + + + + + + + + + + + + +
RetailerPlanEst. annual costSavings vs current
Loading…
+
+ + +{% endblock %} diff --git a/app/web/templates/settings.html b/app/web/templates/settings.html new file mode 100644 index 0000000..287ea59 --- /dev/null +++ b/app/web/templates/settings.html @@ -0,0 +1,339 @@ +{% extends "base.html" %} +{% block content %} +

Settings

+ +
+

Household

+
+ + + +
+
+ +
+

iSolarCloud connection

+ {% if use_mock_isolarcloud %} +

+ Currently running on synthetic mock data (USE_MOCK_ISOLARCLOUD=true). + Once you have your App Key, Secret Key and iSolarCloud username/password filled in your + .env (see SETUP.md), set USE_MOCK_ISOLARCLOUD=false and restart, + then test the connection below. +

+ {% elif isolarcloud_auth_mode == "oauth2" %} +

Checking connection status…

+
+

+ 1. Open the authorization link below in your own browser, log in with your iSolarCloud account, and approve access.
+ 2. You'll land on a page that fails to load (that's expected -- it's an https://localhost redirect) -- copy the + code= value from the address bar.
+ 3. Paste just the code below and click Connect. +

+

Open authorization link

+ + +
+
+ {% else %} +

Checking connection status…

+ +
+ {% endif %} +
+
+ +
+

Your current plan

+

Entered manually so the comparison has a baseline to measure savings against.

+
+ + + + + + + +
+ +
+ + + + + +
+ +
+ + + + +
+
+ + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d84d06d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + power-compare: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + - DB_PATH=/data/power_compare.db + volumes: + - ./data:/data + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa11625 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "power-compare" +version = "0.1.0" +description = "Compare Sungrow solar usage against AER-listed QLD electricity plans" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "httpx>=0.27", + "apscheduler>=3.10", + "sqlalchemy>=2.0", + "jinja2>=3.1", + "python-multipart>=0.0.9", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "python-dotenv>=1.0", + "cryptography>=42.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["app"] diff --git a/scripts/backfill_isolarcloud.py b/scripts/backfill_isolarcloud.py new file mode 100644 index 0000000..f52d2e6 --- /dev/null +++ b/scripts/backfill_isolarcloud.py @@ -0,0 +1,157 @@ +"""One-off historical backfill for a real iSolarCloud account (oauth2 mode). + +Pulls day-by-day from --since (default: the plant's install date) up to +now, via the same `fetch_interval_data` the scheduler uses, and writes +straight into the `interval_reading` table. + +Safe to interrupt and re-run: progress is checkpointed in `sync_state` +after each day, and re-inserting a day already covered is a no-op (rows +dedupe on (ts, source)). + +The account's hourly API quota (2000 calls/hour on the Free plan) will be +hit partway through a multi-year backfill -- each day costs ~8 calls (one +per 3-hour chunk the historical endpoint allows). Rather than requiring a +manual re-run every time that happens, every API call in this script +(including the one-off plant-list lookup, not just the main loop) goes +through `call_with_retry`, which sleeps past the reset and retries on the +hour limit -- a full ~2.5 year backfill takes several such hourly cycles, +so expect this to run for multiple hours total, not minutes. + +Usage: + python scripts/backfill_isolarcloud.py # since plant install date + python scripts/backfill_isolarcloud.py --since 2024-01-01 + python scripts/backfill_isolarcloud.py --days 365 +""" + +from __future__ import annotations + +import argparse +import time +from datetime import datetime, timedelta, timezone +from typing import Callable, TypeVar + +import httpx + +from app.config import settings +from app.db.repositories import get_household_config, get_sync_state, set_sync_state, upsert_interval_readings +from app.db.session import get_session, init_db + +PROGRESS_KEY = "isolarcloud_backfill_progress_date" +HOUR_LIMIT_WAIT_SECONDS = 65 * 60 # a bit over an hour, to be safe about the reset boundary +NETWORK_RETRY_WAIT_SECONDS = 30 # transient timeouts/connection drops -- worth a short retry, not a full hour + +T = TypeVar("T") + + +def log(msg: str) -> None: + print(msg, flush=True) + + +def call_with_retry(fn: Callable[[], T]) -> T: + """Runs an iSolarCloud API call, sleeping past the hourly quota reset + and retrying if it's hit, or backing off briefly on a transient network + error; aborts (checkpoint intact) on the monthly quota.""" + from app.isolarcloud.oauth_client import ISolarCloudAuthError + + while True: + try: + return fn() + except ISolarCloudAuthError as e: + msg = str(e) + if "E999" in msg: + log(f"Hourly API limit hit -- sleeping {HOUR_LIMIT_WAIT_SECONDS // 60} min before retrying...") + time.sleep(HOUR_LIMIT_WAIT_SECONDS) + continue + if "E998" in msg: + raise SystemExit( + f"Monthly API limit reached: {msg}\n" + f"Progress is checkpointed -- re-run this script next month (or after a plan upgrade) to continue." + ) + raise + except httpx.TransportError as e: + log(f"Network error ({e!r}) -- sleeping {NETWORK_RETRY_WAIT_SECONDS}s before retrying...") + time.sleep(NETWORK_RETRY_WAIT_SECONDS) + continue + except httpx.HTTPStatusError as e: + if e.response.status_code >= 500: + log( + f"Server error ({e.response.status_code}) -- sleeping " + f"{NETWORK_RETRY_WAIT_SECONDS}s before retrying..." + ) + time.sleep(NETWORK_RETRY_WAIT_SECONDS) + continue + raise + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--since", help="YYYY-MM-DD to start from (default: plant install date)") + parser.add_argument("--days", type=int, help="alternative to --since: number of days back from today") + args = parser.parse_args() + + init_db() + if settings.use_mock_isolarcloud or settings.isolarcloud_auth_mode != "oauth2": + raise SystemExit("This script requires USE_MOCK_ISOLARCLOUD=false and ISOLARCLOUD_AUTH_MODE=oauth2 in .env.") + + from app.isolarcloud.oauth_client import ISolarCloudOAuthClient + + client = ISolarCloudOAuthClient() + + with get_session() as session: + ps_id = get_household_config(session).isolarcloud_ps_id or settings.isolarcloud_plant_id + + # Only auto-resume the *default* (install-date) target -- an explicit + # --since/--days is a deliberate choice of range and shouldn't be + # silently overridden by a checkpoint left by some earlier, different + # backfill run (e.g. a small smoke-test range). + resume_date: datetime | None = None + if not args.since and not args.days: + with get_session() as session: + resume_str = get_sync_state(session, PROGRESS_KEY) + if resume_str: + resume_date = datetime.fromisoformat(resume_str) + + if args.since: + start_date = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc) + elif args.days: + start_date = datetime.now(timezone.utc) - timedelta(days=args.days) + elif resume_date: + # No need to look up the install date at all -- we already know where to continue from. + start_date = resume_date + log(f"Resuming from previous progress: {start_date.date()}") + else: + plants = call_with_retry(client.get_plant_list) + if not ps_id: + ps_id = str(plants[0]["ps_id"]) + plant = next((p for p in plants if str(p["ps_id"]) == str(ps_id)), plants[0]) + start_date = datetime.strptime(plant["install_date"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + + if not ps_id: + plants = call_with_retry(client.get_plant_list) + ps_id = str(plants[0]["ps_id"]) + + now = datetime.now(timezone.utc) + total_inserted = 0 + total_points = 0 + cursor = start_date + day = timedelta(days=1) + + log(f"Backfilling {ps_id} from {start_date.date()} to {now.date()} ({(now - start_date).days} days)...") + while cursor < now: + chunk_end = min(cursor + day, now) + data_points = call_with_retry(lambda: client.fetch_interval_data(cursor, chunk_end)) + rows = [p.as_row("isolarcloud") for p in data_points] + with get_session() as session: + inserted = upsert_interval_readings(session, rows) + set_sync_state(session, PROGRESS_KEY, chunk_end.isoformat()) + total_inserted += inserted + total_points += len(data_points) + log(f" {cursor.date()}: {len(data_points)} points, {inserted} new rows (running total: {total_inserted})") + cursor = chunk_end + time.sleep(0.2) # be a polite citizen of the API's rate limit + + log(f"Done. {total_points} points fetched, {total_inserted} new rows inserted.") + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_sample_data.py b/scripts/seed_sample_data.py new file mode 100644 index 0000000..aea4ab9 --- /dev/null +++ b/scripts/seed_sample_data.py @@ -0,0 +1,38 @@ +"""One-off CLI: backfill N days of synthetic usage data immediately, so the +dashboard/plan comparison can be demoed without waiting for the scheduler to +accumulate real-time polls. Always uses the mock client, regardless of +USE_MOCK_ISOLARCLOUD, since this is for local demoing/testing only. + +Usage: python scripts/seed_sample_data.py --days 365 +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone + +from app.db.repositories import upsert_interval_readings +from app.db.session import get_session, init_db +from app.isolarcloud.mock_client import MockISolarCloudClient + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--days", type=int, default=365) + args = parser.parse_args() + + init_db() + client = MockISolarCloudClient() + until = datetime.now(timezone.utc) + since = until - timedelta(days=args.days) + points = client.fetch_interval_data(since, until) + rows = [p.as_row("mock") for p in points] + + with get_session() as session: + inserted = upsert_interval_readings(session, rows) + + print(f"Generated {len(points)} points, inserted {inserted} new rows.") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_isolarcloud_cli.py b/scripts/verify_isolarcloud_cli.py new file mode 100644 index 0000000..b02de4c --- /dev/null +++ b/scripts/verify_isolarcloud_cli.py @@ -0,0 +1,100 @@ +"""Manual verification CLI for the real iSolarCloud client (both auth modes). + +USE_MOCK_ISOLARCLOUD=false and ISOLARCLOUD_AUTH_MODE ("v1" or "oauth2") must +be set in .env along with the matching credentials -- see SETUP.md. + +Usage (v1): + python scripts/verify_isolarcloud_cli.py login + python scripts/verify_isolarcloud_cli.py plants + python scripts/verify_isolarcloud_cli.py devices --ps-id 1234567 + python scripts/verify_isolarcloud_cli.py history --hours 6 + +Usage (oauth2): + python scripts/verify_isolarcloud_cli.py authorize-url + # open the printed URL yourself, approve access, then copy the `code` + # param from the (broken, expected) redirect URL: + python scripts/verify_isolarcloud_cli.py exchange --code THE_CODE + python scripts/verify_isolarcloud_cli.py plants + python scripts/verify_isolarcloud_cli.py history --hours 6 +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timedelta, timezone + +from app.config import settings + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "action", choices=["login", "authorize-url", "exchange", "plants", "devices", "history"] + ) + parser.add_argument("--code", help="oauth2: the 'code' query param from the OAuth redirect") + parser.add_argument("--ps-id", help="plant id; auto-detected (first plant) if omitted") + parser.add_argument("--hours", type=int, default=3, help="for 'history': how far back to fetch") + args = parser.parse_args() + + if settings.isolarcloud_auth_mode == "oauth2": + from app.isolarcloud.oauth_client import ISolarCloudOAuthClient + + client = ISolarCloudOAuthClient() + if args.action == "authorize-url": + print(client.authorize_url()) + return + if args.action == "exchange": + if not args.code: + parser.error("--code is required for 'exchange'") + auth_ps_list = client.exchange_code(args.code) + print("Tokens stored. Authorized plants:", auth_ps_list) + return + else: + from app.isolarcloud.client import DEVICE_TYPE_INVERTER, DEVICE_TYPE_METER, ISolarCloudClient + + client = ISolarCloudClient() + if args.action == "login": + client.login() + print("Login succeeded, token acquired and cached.") + return + if args.action in ("authorize-url", "exchange"): + parser.error(f"'{args.action}' is only valid with ISOLARCLOUD_AUTH_MODE=oauth2") + + if args.action == "plants": + print(json.dumps(client.get_plant_list(), indent=2)) + return + + ps_id = args.ps_id + if not ps_id: + plants = client.get_plant_list() + if not plants: + print("No plants visible to this account.") + return + ps_id = str(plants[0]["ps_id"]) + print(f"(using first plant: {ps_id})") + + if args.action == "devices": + if settings.isolarcloud_auth_mode == "oauth2": + print(json.dumps(client.get_device_list(ps_id), indent=2)) + return + devices = client.get_device_list(ps_id) + print(json.dumps(devices, indent=2)) + meter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_METER), None) + inverter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_INVERTER), None) + print() + print(f"Detected meter device: {meter.get('ps_key') if meter else 'NONE FOUND'}") + print(f"Detected inverter device: {inverter.get('ps_key') if inverter else 'NONE FOUND'}") + return + + if args.action == "history": + until = datetime.now(timezone.utc) + since = until - timedelta(hours=args.hours) + points = client.fetch_interval_data(since, until) + for p in points[:20]: + print(p) + print(f"... {len(points)} points total") + + +if __name__ == "__main__": + main()