Initial commit: solar usage vs AER plan comparison app

FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2),
caches AER Consumer Data Right electricity plan data, and compares actual
metered usage against every available plan to estimate real annual cost
(TOU, tiered feed-in, and flat tariffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-09 11:07:01 +10:00
commit 84c4922bbc
54 changed files with 5578 additions and 0 deletions

34
.env.example Normal file
View file

@ -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

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.venv/
*.egg-info/
__pycache__/
*.pyc
.pytest_cache/
.env
*.db
.DS_Store
.claude/
/data/

13
Dockerfile Normal file
View file

@ -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"]

67
README.md Normal file
View file

@ -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.

100
SETUP.md Normal file
View file

@ -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=<your App Key>
ISOLARCLOUD_APP_SECRET=<your Secret Key>
ISOLARCLOUD_USERNAME=<your iSolarCloud account email/username>
ISOLARCLOUD_PASSWORD=<your iSolarCloud account 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 <your plant 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.

0
app/__init__.py Normal file
View file

0
app/aer/__init__.py Normal file
View file

42
app/aer/cache.py Normal file
View file

@ -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

93
app/aer/client.py Normal file
View file

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

View file

View file

@ -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"
}
}
}

View file

@ -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."
}
}
}

View file

@ -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" }]
}
}
]
}
}
}

View file

@ -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" }
]
}
]
}
]
}
}
}

View file

@ -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

0
app/api/__init__.py Normal file
View file

81
app/api/isolarcloud.py Normal file
View file

@ -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]})

39
app/api/plans.py Normal file
View file

@ -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}

135
app/api/settings.py Normal file
View file

@ -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}

86
app/api/usage.py Normal file
View file

@ -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()},
}

55
app/config.py Normal file
View file

@ -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()

0
app/costing/__init__.py Normal file
View file

215
app/costing/aer_mapper.py Normal file
View file

@ -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,
)

84
app/costing/compare.py Normal file
View file

@ -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))

244
app/costing/engine.py Normal file
View file

@ -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)

View file

@ -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

View file

View file

@ -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)

0
app/db/__init__.py Normal file
View file

90
app/db/models.py Normal file
View file

@ -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)

120
app/db/repositories.py Normal file
View file

@ -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

28
app/db/session.py Normal file
View file

@ -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()

View file

@ -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()

236
app/isolarcloud/client.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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 <access_token>`, `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),
)
]

View file

@ -0,0 +1,98 @@
"""Shared measure-point parsing for both the V1 and OAuth2 iSolarCloud
clients -- the `p<point_id>` 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<id>`-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

View file

@ -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,
}

45
app/main.py Normal file
View file

@ -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"}

View file

54
app/scheduler/jobs.py Normal file
View file

@ -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)

30
app/scheduler/setup.py Normal file
View file

@ -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

166
app/static/css/style.css Normal file
View file

@ -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; }

0
app/web/__init__.py Normal file
View file

28
app/web/routes.py Normal file
View file

@ -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},
)

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Power Compare</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="topnav">
<div class="brand">Power Compare</div>
<a href="/">Dashboard</a>
<a href="/plans">Plan Comparison</a>
<a href="/settings">Settings</a>
</nav>
<main class="content">
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block content %}
<h1>Usage Dashboard</h1>
<div class="card" id="summary-card">
<div class="muted">Loading summary&hellip;</div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Generation / load / import / export</h3>
<select id="range-select">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last 365 days</option>
<option value="all">All time</option>
</select>
</div>
<div class="chart-container">
<canvas id="usage-chart"></canvas>
</div>
</div>
<script>
async function loadDashboard(days) {
const [summary, daily] = await Promise.all([
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
fetch(`/api/usage/daily?days=${days}`).then(r => r.json()),
]);
const summaryEl = document.getElementById('summary-card');
if (!summary.days_with_data) {
summaryEl.innerHTML = '<div class="muted">No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.</div>';
} else {
summaryEl.innerHTML = `
<div class="muted">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div>
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
<div><div class="muted">Generated</div><strong>${summary.generation_kwh} kWh</strong></div>
<div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div>
<div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div>
</div>
`;
}
const ctx = document.getElementById('usage-chart');
if (window.usageChart) window.usageChart.destroy();
// Chart.js can measure a stale (pre-layout) container size when created
// synchronously during initial page parse. Wait a couple of frames so the
// browser has actually settled the flex/grid layout first.
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
window.usageChart = new Chart(ctx, {
type: 'bar',
data: {
labels: daily.map(d => d.date),
datasets: [
{ label: 'Generation', data: daily.map(d => d.generation_kwh), backgroundColor: '#facc15' },
{ label: 'Load', data: daily.map(d => d.load_kwh), backgroundColor: '#60a5fa' },
{ label: 'Import', data: daily.map(d => d.import_kwh), backgroundColor: '#f87171' },
{ label: 'Export', data: daily.map(d => d.export_kwh), backgroundColor: '#4ade80' },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: { x: { stacked: false }, y: { title: { display: true, text: 'kWh' } } },
},
});
}
const rangeSelect = document.getElementById('range-select');
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value);
</script>
{% endblock %}

View file

@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block content %}
<h1>Plan Comparison</h1>
<div class="card" style="display:flex; justify-content:space-between; align-items:center;">
<span class="muted">Estimated cost based on your actual usage over the selected period.</span>
<select id="range-select">
<option value="30">Last 30 days</option>
<option value="90" selected>Last 90 days</option>
<option value="365">Last 365 days</option>
</select>
</div>
<div class="card">
<p id="plans-caption" class="muted"></p>
<table id="plans-table">
<thead>
<tr>
<th>Retailer</th>
<th>Plan</th>
<th>Est. annual cost</th>
<th>Savings vs current</th>
<th></th>
</tr>
</thead>
<tbody id="plans-tbody">
<tr><td colspan="5" class="muted">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
<script>
function fmtMoney(v) {
if (v === null || v === undefined) return '&mdash;';
return '$' + v.toFixed(2);
}
function renderBreakdown(row) {
const usage = row.usage_breakdown.map(c => `${c.label}: ${c.kwh.toFixed(1)} kWh &times; rate = $${c.cost_dollars.toFixed(2)}`).join('<br>');
const feedIn = row.feed_in_breakdown.map(c => `${c.label}: ${c.kwh.toFixed(1)} kWh credited $${(-c.cost_dollars).toFixed(2)}`).join('<br>');
const notes = row.modeling_notes.length ? `<div class="notes">${row.modeling_notes.join('<br>')}</div>` : '';
return `
<div style="font-size:0.85rem;">
<strong>Usage</strong><br>${usage || '&mdash;'}<br>
<strong>Feed-in credit</strong><br>${feedIn || '&mdash;'}<br>
<strong>Supply charge:</strong> $${row.supply_charge_dollars.toFixed(2)}<br>
${row.unconditional_discount_dollars ? `<strong>Discount applied:</strong> -$${row.unconditional_discount_dollars.toFixed(2)}<br>` : ''}
${row.potential_conditional_savings_dollars ? `<strong>Potential extra saving (conditional):</strong> $${row.potential_conditional_savings_dollars.toFixed(2)}<br>` : ''}
Based on ${row.days_with_data} day(s) of data${row.is_estimate ? ' (annualized estimate)' : ''}.
${notes}
</div>
`;
}
async function loadPlans(days) {
const { rows, total_plans_considered } = await fetch(`/api/plans/compare?days=${days}`).then(r => r.json());
const tbody = document.getElementById('plans-tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No plans cached yet, and/or no usage data yet. The AER plan cache refreshes automatically in the background.</td></tr>';
return;
}
document.getElementById('plans-caption').textContent =
total_plans_considered > rows.length
? `Showing the ${rows.length} cheapest of ${total_plans_considered} plans found for your distributor.`
: `${total_plans_considered} plan(s) found for your distributor.`;
tbody.innerHTML = rows.map((row, i) => {
const badges = [];
if (row.is_current) badges.push('<span class="badge current">Current plan</span>');
if (!row.modelable) badges.push('<span class="badge not-modelable">Not fully modeled</span>');
let savingsCell = '&mdash;';
if (row.savings_vs_current_dollars !== null && row.savings_vs_current_dollars !== undefined && !row.is_current) {
const cls = row.savings_vs_current_dollars >= 0 ? 'savings-positive' : 'savings-negative';
const sign = row.savings_vs_current_dollars >= 0 ? '+' : '';
savingsCell = `<span class="${cls}">${sign}${fmtMoney(row.savings_vs_current_dollars)}/yr</span>`;
}
return `
<tr>
<td>${row.retailer}</td>
<td>${row.plan_name} ${badges.join(' ')}</td>
<td>${fmtMoney(row.annualized_total_dollars)}/yr</td>
<td>${savingsCell}</td>
<td><button onclick="document.getElementById('detail-${i}').classList.toggle('open')" style="margin:0; padding:0.3rem 0.6rem; font-size:0.8rem;">Details</button></td>
</tr>
<tr id="detail-${i}" class="detail-row" style="display:none;">
<td colspan="5">${renderBreakdown(row)}</td>
</tr>
`;
}).join('');
document.querySelectorAll('.detail-row').forEach(el => {
el.classList.remove('open');
});
// simple toggle via class -> style, since we don't ship extra CSS for it
document.querySelectorAll('button').forEach(btn => {});
}
// Toggle handler simplified: override the inline onclick approach with real display toggling.
document.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON' && e.target.textContent === 'Details') {
const row = e.target.closest('tr').nextElementSibling;
if (row) row.style.display = row.style.display === 'table-row' ? 'none' : 'table-row';
}
});
const rangeSelect = document.getElementById('range-select');
rangeSelect.addEventListener('change', () => loadPlans(rangeSelect.value));
loadPlans(rangeSelect.value);
</script>
{% endblock %}

View file

@ -0,0 +1,339 @@
{% extends "base.html" %}
{% block content %}
<h1>Settings</h1>
<div class="card">
<h3>Household</h3>
<form class="settings-form" id="household-form">
<label>Distributor
<select name="distributor">
<option value="Energex">Energex (South East QLD)</option>
<option value="Ergon">Ergon Energy (regional QLD)</option>
</select>
</label>
<label>Postcode <input name="postcode" placeholder="e.g. 4000"></label>
<button type="submit">Save</button>
</form>
</div>
<div class="card">
<h3>iSolarCloud connection</h3>
{% if use_mock_isolarcloud %}
<p class="muted">
Currently running on <strong>synthetic mock data</strong> (<code>USE_MOCK_ISOLARCLOUD=true</code>).
Once you have your App Key, Secret Key and iSolarCloud username/password filled in your
<code>.env</code> (see SETUP.md), set <code>USE_MOCK_ISOLARCLOUD=false</code> and restart,
then test the connection below.
</p>
{% elif isolarcloud_auth_mode == "oauth2" %}
<p id="isolarcloud-status" class="muted">Checking connection status&hellip;</p>
<div id="oauth-connect-block">
<p class="muted">
1. Open the authorization link below in your own browser, log in with your iSolarCloud account, and approve access.<br>
2. You'll land on a page that fails to load (that's expected -- it's an https://localhost redirect) -- copy the
<code>code=</code> value from the address bar.<br>
3. Paste just the code below and click Connect.
</p>
<p><a id="oauth-authorize-link" href="#" target="_blank" rel="noopener">Open authorization link</a></p>
<label>Authorization code <input id="oauth-code-input" placeholder="paste the code= value here"></label>
<button type="button" id="oauth-connect-btn">Connect</button>
</div>
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
{% else %}
<p id="isolarcloud-status" class="muted">Checking connection status&hellip;</p>
<button type="button" id="isolarcloud-connect">Test connection</button>
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
{% endif %}
<div id="isolarcloud-flash"></div>
</div>
<div class="card">
<h3>Your current plan</h3>
<p class="muted">Entered manually so the comparison has a baseline to measure savings against.</p>
<form class="settings-form" id="plan-form">
<label>Retailer name <input name="retailer_name" required></label>
<label>Plan name <input name="plan_name" required></label>
<label>Supply charge ($/day) <input name="supply_charge_dollars_per_day" type="number" step="0.001" required></label>
<label>Plan discount already applied, if any (%) <input name="discount_percent" type="number" step="0.1"></label>
<label>Feed-in tariff type
<select name="feed_in_type" id="feed-in-type">
<option value="flat">Flat rate</option>
<option value="tiered">Tiered by daily export (e.g. "Solar Balance")</option>
</select>
</label>
<div id="flat-fit-fields">
<label>Feed-in tariff (c/kWh) <input name="feed_in_tariff_c_per_kwh" type="number" step="0.01" value="0"></label>
</div>
<div id="tiered-fit-fields" style="display:none;">
<label>Daily threshold (kWh/day) <input name="tiered_daily_threshold_kwh" type="number" step="0.01" placeholder="e.g. 10"></label>
<label>Rate below threshold (c/kWh) <input name="tiered_rate_below_c_per_kwh" type="number" step="0.01"></label>
<label>Rate above threshold (c/kWh) <input name="tiered_rate_above_c_per_kwh" type="number" step="0.01"></label>
</div>
<label>Tariff type
<select name="tariff_type" id="tariff-type">
<option value="flat">Flat rate</option>
<option value="time_of_use">Time of use</option>
</select>
</label>
<div id="flat-fields">
<label>Usage rate (c/kWh) <input name="flat_rate_c_per_kwh" type="number" step="0.01"></label>
</div>
<div id="tou-fields" style="display:none;">
<div id="tou-periods"></div>
<button type="button" id="add-period">+ Add time period</button>
</div>
<button type="submit">Save current plan</button>
</form>
</div>
<script>
async function loadHousehold() {
const cfg = await fetch('/api/settings/household').then(r => r.json());
const form = document.getElementById('household-form');
form.distributor.value = cfg.distributor || 'Energex';
form.postcode.value = cfg.postcode || '';
}
document.getElementById('household-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
await fetch('/api/settings/household', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distributor: form.distributor.value, postcode: form.postcode.value }),
});
alert('Saved.');
});
const tariffType = document.getElementById('tariff-type');
const flatFields = document.getElementById('flat-fields');
const touFields = document.getElementById('tou-fields');
const touPeriods = document.getElementById('tou-periods');
const feedInType = document.getElementById('feed-in-type');
const flatFitFields = document.getElementById('flat-fit-fields');
const tieredFitFields = document.getElementById('tiered-fit-fields');
feedInType.addEventListener('change', () => {
const isFlat = feedInType.value === 'flat';
flatFitFields.style.display = isFlat ? 'block' : 'none';
tieredFitFields.style.display = isFlat ? 'none' : 'block';
});
const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
function periodRow(values) {
values = values || { label: '', rate_c_per_kwh: '', start_time: '00:00', end_time: '00:00', days: null };
const days = values.days || ALL_DAYS;
const div = document.createElement('div');
div.className = 'tou-period';
const dayCheckboxes = ALL_DAYS.map(d => `
<label class="p-day-label"><input type="checkbox" class="p-day" value="${d}" ${days.includes(d) ? 'checked' : ''}>${d}</label>
`).join('');
div.innerHTML = `
<label>Label <input class="p-label" value="${values.label}" placeholder="e.g. Peak"></label>
<label>Rate (c/kWh) <input class="p-rate" type="number" step="0.01" value="${values.rate_c_per_kwh}"></label>
<label>Start time <input class="p-start" type="time" value="${values.start_time}"></label>
<label>End time <input class="p-end" type="time" value="${values.end_time}"></label>
<div class="p-days-group">Days: ${dayCheckboxes}</div>
<button type="button" class="p-remove">Remove</button>
`;
div.querySelector('.p-remove').addEventListener('click', () => div.remove());
touPeriods.appendChild(div);
}
document.getElementById('add-period').addEventListener('click', () => periodRow());
tariffType.addEventListener('change', () => {
const isFlat = tariffType.value === 'flat';
flatFields.style.display = isFlat ? 'block' : 'none';
touFields.style.display = isFlat ? 'none' : 'block';
if (!isFlat && touPeriods.children.length === 0) {
periodRow({ label: 'Peak', rate_c_per_kwh: '', start_time: '16:00', end_time: '21:00' });
periodRow({ label: 'Off-peak', rate_c_per_kwh: '', start_time: '21:00', end_time: '16:00' });
}
});
async function loadCurrentPlan() {
const plan = await fetch('/api/settings/current-plan').then(r => r.json());
if (!plan) return;
const form = document.getElementById('plan-form');
form.retailer_name.value = plan.retailer;
form.plan_name.value = plan.plan_name;
form.supply_charge_dollars_per_day.value = (plan.supply_charge_c_per_day / 100).toFixed(4);
if (plan.tiered_feed_in) {
feedInType.value = 'tiered';
form.tiered_daily_threshold_kwh.value = plan.tiered_feed_in.daily_threshold_kwh;
form.tiered_rate_below_c_per_kwh.value = plan.tiered_feed_in.rate_below_c_per_kwh;
form.tiered_rate_above_c_per_kwh.value = plan.tiered_feed_in.rate_above_c_per_kwh;
} else {
feedInType.value = 'flat';
form.feed_in_tariff_c_per_kwh.value = plan.feed_in_tariffs[0] ? plan.feed_in_tariffs[0].rate_c_per_kwh : 0;
}
feedInType.dispatchEvent(new Event('change'));
const discount = (plan.discounts || [])[0];
if (discount && discount.percent) form.discount_percent.value = discount.percent;
const isFlat = plan.usage_components.length === 1 && plan.usage_components[0].time_windows.length === 0;
tariffType.value = isFlat ? 'flat' : 'time_of_use';
tariffType.dispatchEvent(new Event('change'));
if (isFlat) {
form.flat_rate_c_per_kwh.value = plan.usage_components[0].rate_c_per_kwh;
} else {
touPeriods.innerHTML = '';
plan.usage_components.forEach(c => {
const w = c.time_windows[0] || { start_time: '00:00', end_time: '00:00', days: null };
periodRow({
label: c.label,
rate_c_per_kwh: c.rate_c_per_kwh,
start_time: w.start_time.slice(0, 5),
end_time: w.end_time.slice(0, 5),
days: w.days,
});
});
}
}
document.getElementById('plan-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = {
retailer_name: form.retailer_name.value,
plan_name: form.plan_name.value,
supply_charge_dollars_per_day: parseFloat(form.supply_charge_dollars_per_day.value),
discount_percent: form.discount_percent.value ? parseFloat(form.discount_percent.value) : null,
tariff_type: tariffType.value,
feed_in_type: feedInType.value,
};
if (tariffType.value === 'flat') {
payload.flat_rate_c_per_kwh = parseFloat(form.flat_rate_c_per_kwh.value);
} else {
payload.tou_periods = Array.from(touPeriods.children).map(div => {
const checkedDays = Array.from(div.querySelectorAll('.p-day:checked')).map(cb => cb.value);
return {
label: div.querySelector('.p-label').value,
rate_c_per_kwh: parseFloat(div.querySelector('.p-rate').value),
start_time: div.querySelector('.p-start').value,
end_time: div.querySelector('.p-end').value,
days: checkedDays.length ? checkedDays : null,
};
});
}
if (feedInType.value === 'flat') {
payload.feed_in_tariff_c_per_kwh = parseFloat(form.feed_in_tariff_c_per_kwh.value || 0);
} else {
payload.tiered_daily_threshold_kwh = parseFloat(form.tiered_daily_threshold_kwh.value);
payload.tiered_rate_below_c_per_kwh = parseFloat(form.tiered_rate_below_c_per_kwh.value);
payload.tiered_rate_above_c_per_kwh = parseFloat(form.tiered_rate_above_c_per_kwh.value);
}
const resp = await fetch('/api/settings/current-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
alert('Current plan saved.');
} else {
const err = await resp.json();
alert('Failed to save: ' + JSON.stringify(err));
}
});
async function checkIsolarcloudStatus() {
const el = document.getElementById('isolarcloud-status');
if (!el) return;
const status = await fetch('/api/isolarcloud/status').then(r => r.json());
el.textContent = status.connected
? 'Connected -- a valid token is cached.'
: 'Not connected yet.';
}
function renderPlantPicker(plants) {
const plantsEl = document.getElementById('isolarcloud-plants');
if (!plants || !plants.length) {
plantsEl.innerHTML = '';
return;
}
const options = plants.map(p => `<option value="${p.ps_id}">${p.ps_name || p.ps_id} (${p.ps_id})</option>`).join('');
plantsEl.innerHTML = `
<label>Plant to poll
<select id="plant-select">${options}</select>
</label>
<button type="button" id="save-plant">Use this plant</button>
`;
fetch('/api/settings/household').then(r => r.json()).then(household => {
if (household.isolarcloud_ps_id) document.getElementById('plant-select').value = household.isolarcloud_ps_id;
});
document.getElementById('save-plant').addEventListener('click', async () => {
const household = await fetch('/api/settings/household').then(r => r.json());
await fetch('/api/settings/household', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...household, isolarcloud_ps_id: document.getElementById('plant-select').value }),
});
alert('Saved.');
});
}
async function connectIsolarcloud() {
const flash = document.getElementById('isolarcloud-flash');
flash.innerHTML = '<p class="muted">Connecting&hellip;</p>';
document.getElementById('isolarcloud-plants').innerHTML = '';
const resp = await fetch('/api/isolarcloud/connect', { method: 'POST' });
const data = await resp.json();
if (!data.ok) {
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
return;
}
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
renderPlantPicker(data.plants);
}
const connectButton = document.getElementById('isolarcloud-connect');
if (connectButton) connectButton.addEventListener('click', connectIsolarcloud);
const oauthAuthorizeLink = document.getElementById('oauth-authorize-link');
if (oauthAuthorizeLink) {
fetch('/api/isolarcloud/oauth/authorize-url').then(r => r.json()).then(data => {
oauthAuthorizeLink.href = data.url;
});
document.getElementById('oauth-connect-btn').addEventListener('click', async () => {
const flash = document.getElementById('isolarcloud-flash');
let code = document.getElementById('oauth-code-input').value.trim();
// Accept a full pasted URL too, not just the bare code.
const match = code.match(/[?&]code=([^&]+)/);
if (match) code = decodeURIComponent(match[1]);
if (!code) {
flash.innerHTML = '<p style="color: var(--bad);">Paste the authorization code first.</p>';
return;
}
flash.innerHTML = '<p class="muted">Exchanging code&hellip;</p>';
document.getElementById('isolarcloud-plants').innerHTML = '';
const resp = await fetch('/api/isolarcloud/oauth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await resp.json();
if (!data.ok) {
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
return;
}
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
renderPlantPicker(data.authorized_plants);
});
}
loadHousehold();
loadCurrentPlan();
checkIsolarcloudStatus();
</script>
{% endblock %}

12
docker-compose.yml Normal file
View file

@ -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

26
pyproject.toml Normal file
View file

@ -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"]

View file

@ -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()

View file

@ -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()

View file

@ -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()