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>
118 lines
5.4 KiB
Python
118 lines
5.4 KiB
Python
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
|