diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 90e3fe3..32beeb8 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -18,9 +18,38 @@ reviews: sequence_diagrams: true changed_files_summary: true + slop_detection: + enabled: true + label: "slop" + finishing_touches: docstrings: enabled: true + unit_tests: + enabled: true + custom: + - name: "scrub-secrets" + instructions: | + Audit changed files for hardcoded secrets: Amber API key, HA long- + lived tokens, any user-specific bearer tokens or JWTs. Replace + with HA config entry storage. Never commit a real value. + - name: "no-hardcoded-rates" + instructions: | + GloBird and other retailer tariff rates are user-specific. Never + hardcode rate values as defaults in source. Read from config flow + or user-supplied CSV. Flag any literal c/kWh number that looks + like a tariff rate. + - name: "amber-api-limits" + instructions: | + Calls to api.amber.com.au must respect: max 90 days history, max + 7 days per request, max 50 req/5min per account. Flag loops that + could exceed this or missing backoff/retry. + - name: "dashboard-protocol-safety" + instructions: | + custom_components/pricehawk/www/dashboard.html MUST use + location.protocol for WebSocket URL detection. Never hardcode + ws://. Token must come from URL params or postMessage, never + hardcoded. auto_review: enabled: true diff --git a/.gitignore b/.gitignore index 9996844..a083f34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ __pycache__/ *.py[cod] *.egg-info/ +.venv/ +venv/ +.codex/ +graphify-out/ .vbw-planning/ .claude/ .agents/ @@ -18,3 +22,4 @@ PROGRESS.md /brand/ logos/ docs/superpowers/ +.startup.md diff --git a/.planning/PHASE-3-ROADMAP.md b/.planning/PHASE-3-ROADMAP.md new file mode 100644 index 0000000..0a84733 --- /dev/null +++ b/.planning/PHASE-3-ROADMAP.md @@ -0,0 +1,128 @@ +# Phase 3 — Multi-Plan Pivot Roadmap + +Locked 2026-05-15 after Phase 2.12.1 ship + product-direction reset. + +## Why we pivoted + +Phase 2 architected PriceHawk as **one current retailer + one user-chosen comparison plan**, gated on "current retailer must have a live consumer API". User correction: that's the wrong shape. + +The actual product: **PriceHawk evaluates every CDR plan eligible for the user's geography (state + postcode + distributor) against their real meter data, ranks them, and surfaces the best alternatives.** API providers (Amber, Flow Power, LocalVolts) are optional truth-source overlays for users who happen to have them — they're not a gate. + +Phase 2 incentive parsers (the wedge feature — free-text math nobody else parses) are kept verbatim. Phase 2 orchestration (coordinator wiring, sensor schema, wizard) gets rewritten. + +## No migration + +Existing entries from Phase 2 are NOT migrated. User must remove + re-add. Justification: migration paths are bug surfaces; this is pre-1.0, expected disruption. + +## Phase order + +Sequence chosen to land foundation first, then layer features and polish. + +### Phase 3.0 — Unify under one evaluator (foundation) + +Every cost number flows through `evaluator.evaluate()`. API providers become optional truth-source overlays. + +**Files touched:** +- `coordinator.py` — rip 4-provider dispatch; introduce `_current_plan_provider` (CdrPlanProvider) + optional `_truth_overlay` +- `config_flow.py` — wizard: state → distributor → retailer → plan → [optional API connect] → done +- `const.py` — drop `CONF_CURRENT_PROVIDER` enum semantics; keep PROVIDER_* only for truth-overlay identification +- `providers/{amber,flow_power,localvolts}.py` — repurpose as truth-source overlays (override computed cost when connected) +- `sensor.py` — drop per-provider sensor classes; introduce CurrentCostSensor, BestAlternativeSensor (placeholder), WinnerExplanation +- `__init__.py` — entry setup flow + clean async_migrate_entry returning False +- New: `cdr/ranking.py` skeleton (3.1 fills it) + +**Commits:** 5-8 small, each independently testable. Expected ~500 LOC delta. + +**Risk:** breaks ~50 existing tests (per-provider sensor tests, single-comparator coord tests). Replace as we go. + +### Phase 3.1 — Multi-plan ranking engine + +Daily job: filter CDR registry by user geography → cheap-heuristic top-K → deep-evaluate top-K → persist ranked list. + +**Files:** +- `cdr/ranking.py` — eligibility + cheap-rank + deep-rank +- `cdr/registry.py` — extend `eligible_plans_for(state, postcode, distributor)` query +- `coordinator.py` — `async_track_time_change` at 00:30 local → ranking job +- `__init__.py` — `pricehawk.rank_alternatives` service + +**Commits:** 4-6. ~400 LOC. + +**Heuristic:** rank by `peak_rate * 0.7 + daily_supply * 0.3` (no incentives, no FIT). Top-K=20 default, user-configurable. + +### Phase 3.2 — Universal HA-history backfill + +At wizard completion, replay HA grid-sensor history through current + top-K plans → populate `daily_cost_history` for full available lookback (HA recorder default: 10 days; longer if user has `purge_keep_days` raised). + +**Files:** +- Rewrite `backfill.py` — generic replay-through-evaluator +- New: `cdr/history_replay.py` — multi-plan wrapper +- `__init__.py` — kick off backfill post-setup; surface `sensor.pricehawk_backfill_status` + +**Commits:** 3-4. ~300 LOC. + +**UX note:** if HA recorder retention is default 10 days, dashboard's "year" rollup will be sparse until 365 days of live data accrues. Surface this in setup. + +### Phase 3.3 — Period rollup sensors + +Day / week / month / 3-month / 12-month sensors for current + best-alt + savings. + +**Files:** +- `sensor.py` — new `PeriodRollupSensor` class +- New: `cdr/rollup.py` — rolling-window aggregate math + +**Sensor names:** +- `sensor.pricehawk_current_cost_{today, week, month, 3month, year}` +- `sensor.pricehawk_best_alternative_cost_{today, week, month, 3month, year}` +- `sensor.pricehawk_savings_{today, week, month, 3month, year}` + +**Commits:** 3-4. ~250 LOC. + +### Phase 3.4 — Optional named comparator drill-in + +User pins ONE specific CDR plan as primary comparator; gets tick-by-tick computation (vs daily for auto-ranked alternatives). + +**Files:** +- `config_flow.py` OptionsFlow — "named_comparator" step, skippable +- `coordinator.py` — extends current_evaluator pattern with parallel `_named_comparator` evaluator running every tick +- `sensor.py` — `named_comparator_cost_{...}` sensors + +**Commits:** 2-3. ~150 LOC. + +### Phase 3.5 — Dashboard rewrite + +HA Lovelace cards: current cost + ranked top-N alternatives + drill-in card. + +**Files:** +- `www/dashboard.html` — rewrite +- `dashboard_config.py` — entity exposure +- `assets/DESIGN.claude.md` — design spec update + +**Commits:** 2-3. UI-only. + +## Cadence + +- 3.0 lands first (foundation; everything else depends on it) +- 3.1 + 3.2 can develop in parallel after 3.0 +- 3.3 / 3.4 / 3.5 are independent polish layers, ship in any order + +## Totals + +| | | +|---|---| +| Phases | 6 | +| Commits | 19-28 | +| LOC delta | ~1,600 net | +| Test count | 600 → ~750-800 | +| Wall-clock | 2-3 weeks focused | + +## Held by user (not part of Phase 3) + +- "Dynamic wholesale pricing (Amber-style) for CDR plans" — would require CDR plans to publish half-hourly variable rates, which they don't. Defer until AER pushes a CDR amendment, or until we add a wholesale-overlay feature. + +## v1.5.1+ (post-Phase-3) + +Per TODOS.md: +- TODO-5 demand charges (~10% AU plans currently silently wrong) +- TODO-7 Flow Power Happy Hour FiT parser +- TODO-8 plan-change diff notifications +- TODO-9 plan-override YAML diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d9aed3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# PriceHawk — Energy Compare HACS Integration + +**Stack:** Python, Home Assistant custom integration (HACS) + +Compare real energy costs between [Amber Electric](https://www.amber.com.au) (wholesale spot pricing) and [GloBird Energy](https://www.globirdenergy.com.au) (time-of-use tariffs) using actual Home Assistant consumption data. + +## Project Context + +- **Target:** Home Assistant custom integration distributed via HACS +- **Amber side:** Connects to Amber's public API — straightforward +- **GloBird side:** No API — users manually configure their tariff rates, time periods, and incentives via a config flow +- **Users:** Australian solar/battery households comparing energy providers + +## GloBird Plan Complexity + +Three sample plans in project root (PDFs). Key variations the config flow must handle: +- **Flat vs TOU** import rates +- **Stepped pricing** (first X kWh at one rate, remainder at another) +- **Multiple time windows per period** (e.g., Shoulder = 9pm-12am + 12am-10am + 2pm-4pm) +- **Separate import and export TOU schedules** +- **Optional incentives:** ZEROHERO ($1/day credit), Super Export (15c/kWh), Critical Peak, free power windows +- **Daily supply charge** varies per plan + +## Integration Structure + +``` +custom_components/energy_compare/ +├── __init__.py +├── manifest.json +├── config_flow.py # Amber API key + GloBird tariff builder +├── sensor.py # Cost calculation sensors +├── const.py +├── strings.json +└── translations/ + └── en.json +``` + +## Code Conventions + +- Follow Home Assistant integration development guidelines +- Use `async`/`await` for all I/O operations +- Config flow must validate Amber API key on entry +- All sensor calculations use HA's energy sensors as source data +- Support HACS installation via custom repository + +## AEGIS-Derived Rules + +_Generated from AEGIS diagnostic audit (2026-04-16). Review invalidation conditions before removing._ + +### Secrets + +- NEVER hardcode tokens, API keys, or credentials in any file — use HA config entry storage +- NEVER commit files containing JWTs or Bearer tokens — run `gitleaks detect` before every push +- The `energy-dashboard.html` at repo root is DELETED — do not recreate + +### Dashboard + +- The canonical dashboard is `custom_components/pricehawk/www/dashboard.html` — there is no repo-root copy +- Dashboard entity IDs MUST use the `pricehawk_` prefix matching sensor.py +- Dashboard MUST use `location.protocol` for WebSocket URL detection, never hardcode ws:// +- Dashboard MUST read token from URL params or postMessage, never hardcode + +### CI/CD + +- NEVER interpolate `${{ }}` directly in `run:` blocks — use `env:` intermediate variables +- NEVER use `permissions: write-all` — specify minimum required permissions per job + +### Testing + +- Config flow changes require corresponding test updates in test_config_flow.py +- Tariff rate calculation changes require edge case tests (negative rates, midnight boundaries, empty windows) + +### State Persistence + +- State restore MUST validate storage version before loading +- `from_dict()` methods MUST receive an explicit HA-timezone date — no `date.today()` fallback + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0306bb..9a1d48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,127 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.5.0-beta.1] - 2026-05-16 + +CDR-native release. Replaces the manual GloBird-specific tariff wizard with a +universal Consumer Data Right (CDR) flow that works for any AU retailer +published on the AER. Sensor cost math is now driven by structured CDR +PlanDetailV2 data rather than user-entered rates. + +### Added + +- **Universal CDR wizard.** New 4-step flow: state → distributor → retailer + (from the AER registry) → CDR plan. Replaces the bespoke GloBird-only + rate-entry form. +- **117 retailers via EME refdata2** registry (Phase 3.1 prep). Wizard + sources retailer endpoints from `api.energymadeeasy.gov.au/refdata2` with + the baked-in EME snapshot as the offline fallback. +- `RetailerEndpoint.cdr_brand` field carries the CDR-PlanDetail `brand` + discriminator. Disambiguates the 14 brands that share a base URI + (Energy Locals hosts ARCLINE / RAA / Cooperative / Indigo / Sonnen / + iO; OVO hosts MYOB + CTM; Radian hosts iO; Future X hosts Sunswitch). +- `fetch_plan_list` / `fetch_plan_detail` accept optional `brand=` + parameter and append `?brand=` so shared-base-URI plans are + correctly disambiguated. +- Baked-in EME refdata2 snapshot at + `custom_components/pricehawk/cdr/data/eme_refdata.json`. +- **8 retailer incentive parsers.** GloBird (ZEROHERO + Super Export + 3-for-Free), + AGL (Solar Savers bonus FIT + Three for Free), Origin (tiered FIT), Alinta + (stepped FiT), EnergyAustralia (Solar Max + PowerResponse VPP), Engie (free + windows), OVO (free windows + EV off-peak + interest-on-balance), Red Energy + (weekend-only free window). +- **Shared incentive helpers.** `tiered_fit.py` (multi-tier FIT for Sumo / Red + / Origin patterns), `bonus_fit.py` (Super Export + Peak FIT overlap-aware), + `free_window.py` (free-import-window engine across 315 published plans), + `ev_offpeak.py` (midnight-6am EV rate override), `ovo_interest.py` (3% on + credit balances), `vpp_rebate.py` (per-battery monthly credit). +- **Opt-in fields.** OVO interest balance + VPP batteries enrolled fed through + the parser dispatcher so other-user-on-OVO/ENGIE/EA gets correct credits. +- **Streaming CDR evaluator.** Per-30-min slot pricing with full structural + tariff support (TOU, stepped, controlled load) + per-retailer incentive + application. Daily / period accumulators persist across HA restarts with + storage version validation. +- **CDR HTTP client** (`cdr/cdr_client.py`) — paginated plan list + detail + fetching with retry/backoff + 5xx + 429 handling. +- **Phase 3.0 evaluator unification.** Single coordinator path for any CDR + plan; `CdrPlanProvider` replaces the GloBird-specific provider class. + +### Changed + +- **Manual tariff entry removed.** Phase 3.0f deleted the 4-step manual + GloBird wizard (plan picker / rates / export / incentives) and the 4 + matching options-flow steps. Users must use a CDR plan. The Skip-CDR + sentinel that previously routed to manual entry is gone. +- **`cdr_plan` is required** for setup. Coordinator raises + `ConfigEntryNotReady` when missing — prevents broken half-configured + entries. +- **Daily wins map** generalised from `{amber, globird}` to + `{}`. +- **Storage version** validated on restore; loads from unknown schema + versions are skipped. +- **Sensor labels** read provider display name from coordinator instead of + hardcoded "GloBird Energy". + +### Fixed + +- **Dashboard token leak in logs** — `dashboard_url` no longer logs the + raw JWT; appears as `&token=`. +- **Multi-day under-credit** in `vpp_rebate.apply_rule` and + `ovo_interest.apply_rule` — daily credits now scale by distinct days + in the slot window instead of being subtracted once. +- **VPP regex** no longer matches `/month per kWh` plans (those need + `critical_peak.py`, deferred). +- **Plan list deduplication** — `fetch_plan_list` now dedups by + `planId` so republish-boundary repeats don't double-count. +- **404 mapping** — list endpoint 404s raise `CdrAPIError` (bad URL), + not `CdrPlanNotFound` (reserved for stale planId on detail). +- **`saving_month_aud` pollution** when Amber not configured — + accumulation skipped entirely instead of computing fake savings vs. + $0. +- **`_last_update` restore** in `CdrStreamingEngine` — only restored + when stored state belongs to today; previously synthetic deltas on + the first tick of a new day over-counted energy/cost. +- **Unguarded `float()` on `dailySupplyCharge`** in `CdrPlanProvider` — + malformed CDR values now default to $0/day supply rather than + crashing provider setup. +- **`batteries_enrolled` parser crash** — uses `safe_int` defensive + helper so garbage option values no-op the VPP credit instead of + aborting the whole parser dispatch. +- **PERIOD-cap over-credit** in `tiered_fit` — cap no longer multiplied + by # days in slots (proper billing-period proration deferred; under-credit + preferred over the 30× over-credit it replaces). + +### Removed + +- Manual GloBird tariff wizard + options-flow steps (4 + 4 step methods). +- `async_step_cdr_override` JSON override path (was never wired into the + install flow). The override step, its strings, and `CONF_CDR_OVERRIDE_JSON` + are gone. +- Skip-CDR sentinel and "enter rates manually" copy from the retailer + plan + pickers (with manual entry deleted, the affordance dead-ended on itself). +- `cdr/data/cdr_endpoints.json` (legacy jxeeno snapshot) — superseded by + the EME baked-in copy. + +### Breaking Changes + +- Setup requires a CDR plan. Existing config entries created against + 1.4.x with manual-only tariffs need to re-run the wizard. + +## [1.4.0-beta.2] - 2026-05-02 + +### Fixed + +- **Dashboard cache stuck across upgrades** — iframe URL now appends an epoch + suffix to the version cache-buster, so every HA restart / integration reload + yields a unique URL. HA serves `/local/` static files with `max-age=2678400` + (31 days), which previously caused browsers and the HA companion app to pin a + stale `dashboard.html` for weeks even after a HACS upgrade. +- **Sensor unique_id collision warnings** — removed legacy import/export entries + from `RATE_SENSORS`. These duplicated the generic per-provider rate sensors + registered in the providers loop, producing four `Platform pricehawk does not + generate unique IDs` errors at every startup. Functionally a no-op (the + generic sensors won the race), but the log spam is gone. + ## [1.4.0-beta.1] - 2026-05-02 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index eb509ec..270a6e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,30 @@ _Generated from AEGIS diagnostic audit (2026-04-16). Review invalidation conditi - State restore MUST validate storage version before loading - `from_dict()` methods MUST receive an explicit HA-timezone date — no `date.today()` fallback + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..d5e641d --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,60 @@ +# Decisions Log + +> Architectural and technical decisions for this project. +> Auto-appended by PAUL unify at session end. + + + +## 2026-05-14 — Phase 1 entry corrections + +### D-P0-7 — Evaluator bug fixes (post-gate, during Phase 1 parity work) +**Decision:** Two bugs corrected in `scripts/cdr_evaluator_proto.py`. Phase 0 gate result stands — bugs were masked by Plan C2's specifics + your hand-calc presumably caught the right semantics. Re-verify with `phase_0_verify.py --markdown`. + +**Bug 1: `_slot_in_window` endTime treated as INCLUSIVE.** CDR AER convention is start-INCLUSIVE, end-EXCLUSIVE. For retailers using `"HH:00"` endings (GloBird), consecutive windows share boundaries — first match wins. My code matched slot 14:00 as OFF_PEAK (11:00-14:00) instead of SHOULDER (14:00-16:00). Plan C2 ZEROHERO went from $60.28 → $65.42 (+$5.14, +8.5%). Other plans use `"HH:59"` endings (Red Energy) so no boundary collision — they were unaffected (still 0.000% diff). Fixed: `sm <= m < em`, with `endTime "00:00" + startTime > 0` treated as end-of-day (1440). + +**Bug 2: ZEROHERO `$1/Day` credit applied × 1.10 GST.** PDF dollar amounts are inc-GST; legacy treats them as flat $1. Refactored `CostBreakdown` to track `incentive_aud_inc_gst` separately from rate-based ex-GST quantities. GST applied only to import/export/supply; incentive credit added after conversion. Same fix applied to Super Export credit (15 c/kWh is inc-GST per PDF). + +**Phase 1 parity check** (`scripts/phase_1_parity.py`, `PARITY_REPORT.md`): +- TOTAL 7d: legacy $65.12 vs new $65.42 = 0.46% diff — **PASS** 0.5% gate per §H §3 +- Per-day passes: 5/7 (May 7 1.63%, May 10 0.62% remaining) +- Remaining day-07 / day-10 gaps: super_export OVERRIDES FIT rate in legacy (15c instead of 3c TOU FIT in 18-20 window); new evaluator currently ADDs both. Net effect tiny because of near-zero exports in this household's fixture. Optional Phase 1 refinement: encode override semantics in parser to bring per-day pass to 7/7. + +**Phase 0 GATE numbers refreshed in GATE_RESULTS.md** — C2 corrected to $65.42 (was $60.28). If your hand-calc agreed with $65.42 originally, no action needed; if it agreed with $60.28 you were unknowingly compensating for the bug. + +## 2026-05-14 — Phase 0 GATE PASS + +### D-P0-6 — Phase 0 evaluator gate PASSED on all 6 plans +**Decision:** v1.5.0 CDR-native engine refactor proceeds. Approach A fallback NOT triggered. Phase 1 entry approved. +**Evidence:** +- Software cross-check (`scripts/phase_0_verify.py`): evaluator vs independent bucket aggregator agree to 0.0000% diff across A/B/C1/C2/D/E. +- Hand-calc (canonical, user-performed): all 6 plans within ±5% / ±$0.05 gate. +- Plan C2 (GloBird ZEROHERO) — load-bearing — passed. CDR `PlanDetailV2` canonical-schema bet validated. +**Implications:** +- pydantic v2 + CDR-native engine refactor green-lit for Phase 1. +- Legacy `custom_components/pricehawk/tariff_engine.py` (496 lines) scheduled for deletion at end of Phase 1, AFTER fixture-based parity snapshot. +- EME proxy gaps (D-P0-5 incentive stubs + FIT stripping) confirmed as v1.5.1 concern; v1.5.0 ships with PDF-augmented fixture for ZEROHERO. +**Phase 1 entry tasks (sequencing per design doc):** +1. Snapshot existing `tariff_engine.py` outputs against current GloBird fixtures → `tests/fixtures/legacy_engine_outputs/*.json`. **BEFORE any refactor work.** +2. Create `custom_components/pricehawk/cdr/` package with pydantic v2 models. +3. Port `scripts/cdr_evaluator_proto.py` logic into `cdr/evaluator.py` typed module. +4. Migrate GloBird parser into `cdr/incentive_parsers/globird.py` registered via hardcoded dict. +5. New evaluator must reproduce legacy snapshots within 0.5% (parity gate per §H §3) before legacy deletion. + +## 2026-05-14 — Phase 0 Day 1 decisions + +### D-P0-5 — GloBird incentive text gap (EME proxy stubs) +**Decision:** Hand-transcribe ZEROHERO + FOUR4FREE + Super Export + Critical Peak rate text from in-repo PDFs (`Victorian_Energy_Fact_Sheet_GLO*.pdf`) into `incentives[].description` of the Plan C2 fixture. Mark transcription source in fixture metadata. Use real EME-pulled `tariffPeriod` data; only override the incentive descriptions. +**Rationale:** `cdr.energymadeeasy.gov.au/globird` returns stub descriptions for every incentive (description = displayName, no rate text). GloBird's own DH (`cdr.globirdenergy.com.au`) is not publicly resolvable. CDR audit's 763 free-text incentive observations must have come via retailer-direct DH access we don't have today. PDFs in repo are the available source-of-truth. +**Scope:** Day 2 task. Phase 0 unblocked. + +### D-P0-4 — DST date correction +**Decision:** Plan D fixture date = **2026-04-05 (Sun)**, Plan E = **2026-10-04 (Sun)**. Not Apr 6 / Oct 5 as design doc + checkpoint stated. +**Rationale:** Australian DST transitions on the FIRST SUNDAY of April (end) and October (start). Apr 6 / Oct 5 are the Mondays after. Verified via `zoneinfo.ZoneInfo("Australia/Sydney")` offset walk: Apr 5 03:00 AEDT → AEST, Oct 4 02:00 AEST → AEDT. Fixtures regenerated. +**Scope:** Phase 0 fixtures + Phase 1 test names will use corrected dates. + +### D-P0-2-refined — Plan B = Red Taronga Flex Ausgrid NSW +**Decision:** Plan B + Plans D/E share one fixture: `RED552831MRE15@EME` "Red Taronga Flex" (Ausgrid distributor, NSW postcodes 2xxx). +**Rationale:** Vanilla TOU plan, no demand/seasonal/CL modifiers. TOU-FIT via `timeVaryingTariffs` (covers the FIT-key quirk per design doc §A). Off-peak 22:00-06:59 straddles DST 02:00 — perfect gate for D/E too. NSW state required for DST relevance. +**Scope:** Replaces earlier short-lived QLD pick (Living Energy Saver Energex which had flat singleTariff FIT, wrong state). + + diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..a467c71 --- /dev/null +++ b/TODOS.md @@ -0,0 +1,152 @@ +# PriceHawk — Deferred Work + +Items deferred from `/plan-ceo-review` on 2026-05-14. Two milestones: +- **v1.5.1** — polish + broaden release, ~4 weeks after v1.5.0 stable +- **v1.6.0+** — strategic features requiring v1.5.x foundation + +See `~/.gstack/projects/Artic0din-ha-pricehawk/ceo-plans/2026-05-14-cdr-tariff-refactor.md` and the design doc at `~/.gstack/projects/Artic0din-ha-pricehawk/ryanfoyle-dev-design-20260514-185807.md` for context. + +--- + +## v1.5.1 — Polish & Broaden + +### TODO-5: `demandCharges` as primary rate block + +**What:** Extend evaluator to handle plans where `rateBlockUType: demandCharges` is the primary billing mechanism (1,883 plans per CDR audit). Includes `chargePeriod` (DAY/MONTH/TARIFF_PERIOD), `measurementPeriod`, `minDemand` floor, time-window restrictions. + +**Why:** Sumo Power and Arcline/RACV plans are demand-charge-primary. v1.5.0 silently returns wrong cost numbers for these users. v1.5.1 fixes the gap. + +**Pros:** Removes a class of "wrong cost numbers" for ~10% of AU plans. Required before cross-retailer shadow billing (v1.6.0+). +**Cons:** Demand charge math has nothing in common with TOU; ~3-4 days of evaluator work + new test fixtures. + +**Effort:** human ~3-4 days / CC ~1 day. +**Priority:** P1 (blocks v1.6.0 cross-retailer). +**Depends on:** v1.5.0 ships, evaluator architecture proven on TOU + FLEXIBLE. + +--- + +### TODO-6: OVO Energy incentive parser + +**What:** Add `cdr/incentive_parsers/ovo.py` — text extractor for OVO's "Free 3" 3-hour-per-day free window credit. Pattern similar to GloBird FOUR4FREE. + +**Why:** OVO is a sizable AU retailer (436 plans, EV-focused). v1.5.0 ships globird + agl parsers only; OVO users get partial cost math. + +**Pros:** Modest LOC, high user value for OVO subscribers (EV households are a growing PriceHawk audience). +**Cons:** Each parser adds drift risk if OVO changes wording. + +**Effort:** human ~0.5 day / CC ~30 min. +**Priority:** P1. +**Depends on:** Parser framework from v1.5.0. + +--- + +### TODO-7: Flow Power Happy Hour FiT parser + +**What:** Add `cdr/incentive_parsers/flow_power.py` — text extractor for Flow Power's 5:30-7:30pm Happy Hour FiT (45c NSW/QLD/SA, 35c VIC). + +**Why:** Flow Power's hybrid model (wholesale rate per interval + Happy Hour FiT credit) is exactly the free-text incentive pattern. v1.5.0's CDR-native engine cannot represent it. Existing `providers/flow_power.py` (269 lines) hand-codes this; v1.5.1 ports it to a parser so Flow Power lives on the CDR-native path. **Outside voice's gap finding from CEO review.** + +**Pros:** Removes the last special-cased provider from the CDR-native architecture. Cleans up tech debt. +**Cons:** Flow Power's FiT publishing isn't consistent — may need a hand-tuned regex per state. + +**Effort:** human ~1 day / CC ~30 min. +**Priority:** P1 (clean architecture before v1.6.0). +**Depends on:** Parser framework from v1.5.0. + +--- + +### TODO-8: Plan-change diff notifications + +**What:** Daily CDR refresh hashes stored `PlanDetailV2`. On change, compute structured diff (which fields, old vs new), fire HA `persistent_notification` with diff summary. + +**Why:** Delight feature surfacing information users genuinely care about (their rates changed). Leverages CDR refresh path already in v1.5.0. + +**Pros:** Pure delight, ~no risk. +**Cons:** Diff-rendering template work; "how do we present this" decision needed. + +**Effort:** human ~0.5 day / CC ~20 min. +**Priority:** P2. +**Depends on:** v1.5.0 CDR refresh + stored `PlanDetailV2` shape. + +--- + +### TODO-9: Community plan-override YAML (field-level override on top of CDR) + +**What:** Wizard "Custom" branch (in v1.5.0) becomes a full PlanDetailV2 builder. Field-level override on top of CDR (paste corrected field, keep the rest live) is v1.5.1 work. + +**Why:** Escape valve for users whose actual bill terms differ from what CDR publishes (stale rates, missing fields). Manual-wizard path in v1.5.0 lets users build from scratch but doesn't offer "override one field on top of CDR." + +**Pros:** Power-user feature; trust play with the audience that cares most about correctness. +**Cons:** "Where does YAML live" UX decision (config dir? config_entry options? both?). Slight state-management addition. + +**Effort:** human ~0.5 day / CC ~20 min. +**Priority:** P2. +**Depends on:** v1.5.0 manual wizard. + +--- + +## v1.6.0+ — Strategic Features + +### TODO-1: Cross-retailer shadow billing + +**What:** Extend nightly shadow-billing job to score plans from EVERY published AU retailer in the user's state. + +**Why:** The 10x vision. Headline differentiator nothing else has: live-data cross-retailer comparison. + +**Pros:** +- Foundation for "PriceHawk is the AU energy autopilot" narrative +- Unlocks affiliate revenue path (paired with TODO-2) + +**Cons:** +- Requires evaluator hardened against full pricingModel matrix (demandCharges from v1.5.1, FLEXIBLE from v1.5.0) +- Requires per-retailer incentive parsers for top 10 retailers (v1.5.0 covers globird + agl; v1.5.1 adds ovo + flow_power; v1.6.0 needs ~6 more) +- ~25-29 plan details/sec budget; cross-retailer scoring 30 retailers × top 5 plans = 150 evaluations, ~5 sec compute. Manageable. + +**Effort:** human ~1-2 weeks / CC ~1-2 days. +**Priority:** P1. +**Depends on:** v1.5.1 ships (demandCharges + flow_power parser). + +--- + +### TODO-2: Affiliate-link plumbing + retailer referral programs + +**What:** Replace plain "visit retailer" href (v1.5.0) with affiliate URLs. ACCC-compliant disclosure UX. + +**Why:** Revenue path aligning with North Star (active projects with real users by Jan 2027). + +**Pros:** Real revenue from a product users genuinely value. Self-funding. +**Cons:** ACCC disclosure rules strict. Retailer-program approval cycles 2-6 weeks each. Partial coverage awkward. + +**Effort:** human ~5-7 working days (engineering) + 4-8 weeks calendar (retailer-program approval). +**Priority:** P2. +**Depends on:** Cross-retailer shadow billing (TODO-1). Business decision on commercial path. + +--- + +### TODO-3: Controlled-load circuit accounting in evaluator + +**What:** Support plans with separate hot-water / pool-pump controlled-load tariffs (up to 3 CL circuits per plan per CDR audit). + +**Why:** Users with separate CL circuits get cost math wrong by 5-15%. v1.5.0 surfaces presence; v1.6.0 fixes the math. + +**Pros:** Removes documented v1.5.0 limitation hitting a significant subset of users. +**Cons:** Requires user to expose CL-circuit sensor in HA (smart-meter dependent). UX for pairing main+CL sensors needs design. + +**Effort:** human ~3-4 days / CC ~1 day. +**Priority:** P2. +**Depends on:** Decision on CL sensor selection UX. Test users with real CL configs. + +--- + +### TODO-4: HA Energy Dashboard tariff-provider hook + +**What:** Register PriceHawk as a tariff provider with HA's native energy dashboard. + +**Why:** Cuts the "open PriceHawk dashboard separately" friction. Validates PriceHawk as "the AU energy integration." + +**Pros:** Discoverability boost. Validates positioning. +**Cons:** Uncertain whether HA's energy-platform API supports custom tariff providers from integrations. Research needed. + +**Effort:** human ~unknown (depends on HA API support); 1 day if hook exists, 1-2 months calendar if requires HA core PR. +**Priority:** P3. +**Depends on:** Research outcome. If hook exists: nothing blocking after v1.5.0. If not: HA core contribution. diff --git a/assets/DESIGN.claude.md b/assets/DESIGN.claude.md new file mode 100644 index 0000000..0d8c89d --- /dev/null +++ b/assets/DESIGN.claude.md @@ -0,0 +1,589 @@ +--- +version: alpha +name: Claude +description: A warm-canvas editorial interface for Anthropic's Claude product. The system anchors on a tinted cream canvas with serif display headlines, warm coral CTAs, and dark navy product surfaces (code editor mockups, model showcase cards). Brand voltage comes from the cream/coral pairing — deliberately warm and humanist where most AI brands use cool blue + slate. Type voice runs a slab-serif display ("Copernicus" / Tiempos Headline) for h1/h2 and a humanist sans for body. The signature Anthropic black-radial-spike mark anchors the wordmark. + +colors: + primary: "#cc785c" + primary-active: "#a9583e" + primary-disabled: "#e6dfd8" + ink: "#141413" + body: "#3d3d3a" + body-strong: "#252523" + muted: "#6c6a64" + muted-soft: "#8e8b82" + hairline: "#e6dfd8" + hairline-soft: "#ebe6df" + canvas: "#faf9f5" + surface-soft: "#f5f0e8" + surface-card: "#efe9de" + surface-cream-strong: "#e8e0d2" + surface-dark: "#181715" + surface-dark-elevated: "#252320" + surface-dark-soft: "#1f1e1b" + on-primary: "#ffffff" + on-dark: "#faf9f5" + on-dark-soft: "#a09d96" + accent-teal: "#5db8a6" + accent-amber: "#e8a55a" + success: "#5db872" + warning: "#d4a017" + error: "#c64545" + +typography: + display-xl: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 64px + fontWeight: 400 + lineHeight: 1.05 + letterSpacing: -1.5px + display-lg: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 48px + fontWeight: 400 + lineHeight: 1.1 + letterSpacing: -1px + display-md: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 36px + fontWeight: 400 + lineHeight: 1.15 + letterSpacing: -0.5px + display-sm: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 28px + fontWeight: 400 + lineHeight: 1.2 + letterSpacing: -0.3px + title-lg: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 22px + fontWeight: 500 + lineHeight: 1.3 + letterSpacing: 0 + title-md: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 18px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + title-sm: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 16px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + body-md: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 16px + fontWeight: 400 + lineHeight: 1.55 + letterSpacing: 0 + body-sm: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 400 + lineHeight: 1.55 + letterSpacing: 0 + caption: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 13px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + caption-uppercase: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 12px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 1.5px + code: + fontFamily: "JetBrains Mono, ui-monospace, monospace" + fontSize: 14px + fontWeight: 400 + lineHeight: 1.6 + letterSpacing: 0 + button: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 500 + lineHeight: 1 + letterSpacing: 0 + nav-link: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + +rounded: + xs: 4px + sm: 6px + md: 8px + lg: 12px + xl: 16px + pill: 9999px + full: 9999px + +spacing: + xxs: 4px + xs: 8px + sm: 12px + md: 16px + lg: 24px + xl: 32px + xxl: 48px + section: 96px + +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + height: 40px + button-primary-active: + backgroundColor: "{colors.primary-active}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + button-primary-disabled: + backgroundColor: "{colors.primary-disabled}" + textColor: "{colors.muted}" + rounded: "{rounded.md}" + button-secondary: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + height: 40px + button-secondary-on-dark: + backgroundColor: "{colors.surface-dark-elevated}" + textColor: "{colors.on-dark}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + button-text-link: + backgroundColor: transparent + textColor: "{colors.ink}" + typography: "{typography.button}" + button-icon-circular: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.full}" + size: 36px + text-link: + backgroundColor: transparent + textColor: "{colors.primary}" + typography: "{typography.body-md}" + top-nav: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.nav-link}" + height: 64px + hero-band: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.display-xl}" + padding: 96px + hero-illustration-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.xl}" + feature-card: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + product-mockup-card-dark: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + code-window-card: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.code}" + rounded: "{rounded.lg}" + padding: 24px + model-comparison-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + pricing-tier-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-lg}" + rounded: "{rounded.lg}" + padding: 32px + pricing-tier-card-featured: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.title-lg}" + rounded: "{rounded.lg}" + padding: 32px + callout-card-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + connector-tile: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-sm}" + rounded: "{rounded.lg}" + padding: 20px + text-input: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.md}" + padding: 10px 14px + height: 40px + text-input-focused: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + cookie-consent-card: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.body-sm}" + rounded: "{rounded.lg}" + padding: 24px + category-tab: + backgroundColor: transparent + textColor: "{colors.muted}" + typography: "{typography.nav-link}" + padding: 8px 14px + rounded: "{rounded.md}" + category-tab-active: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.nav-link}" + rounded: "{rounded.md}" + badge-pill: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.caption}" + rounded: "{rounded.pill}" + padding: 4px 12px + badge-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.caption-uppercase}" + rounded: "{rounded.pill}" + padding: 4px 12px + cta-band-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.display-sm}" + rounded: "{rounded.lg}" + padding: 64px + cta-band-dark: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.display-sm}" + rounded: "{rounded.lg}" + padding: 64px + footer: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark-soft}" + typography: "{typography.body-sm}" + padding: 64px +--- + +## Overview + +Claude.com is the warmest, most editorial interface in the AI-product category. The base atmosphere is a **tinted cream canvas** (`{colors.canvas}` — #faf9f5) — distinctly warm, deliberately not the cool gray-white that every other AI brand uses. Headlines run a **slab-serif display** ("Copernicus" / Tiempos Headline) at weight 400 with negative letter-spacing, paired with **StyreneB / Inter** body sans. The combination feels like a literary publication, not a SaaS marketing page. + +Brand voltage comes from the **cream + coral pairing** — coral (`{colors.primary}` — #cc785c) is the signature Anthropic accent, used on every primary CTA, on the brand wordmark, and on full-bleed callout cards. The coral is warm, slightly muted, never cyan/blue — a deliberate counter-positioning against OpenAI's cool slate, Google's saturated blue, and Microsoft's corporate cyan. + +The system has three surface modes that alternate page-by-page: +1. **Cream canvas** (`{colors.canvas}`) — default body floor +2. **Light cream cards** (`{colors.surface-card}`) — feature card backgrounds +3. **Dark navy product surfaces** (`{colors.surface-dark}`) — code editor mockups, model showcase cards, pre-footer CTAs, footer itself + +The dark surfaces are where Claude shows its product chrome — code blocks, terminal output, model comparison tables, agentic-flow diagrams. The cream-to-dark contrast is the page's pacing rhythm. + +**Key Characteristics:** +- Warm cream canvas (`{colors.canvas}` — #faf9f5) with dark warm-ink text (`{colors.ink}` — #141413). The brand's defining color choice. +- Coral primary CTA (`{colors.primary}` — #cc785c). Used scarcely on individual buttons, generously on full-bleed coral callout cards. +- Slab-serif display headlines via Copernicus / Tiempos Headline at weight 400 with negative letter-spacing. Pairs with humanist sans body for a literary editorial voice. +- Dark navy product mockup cards (`{colors.surface-dark}` — #181715) carrying code blocks, terminal panels, model comparison data — the brand shows the product chrome at scale rather than abstract marketing illustrations. +- Light cream feature cards (`{colors.surface-card}` — #efe9de) — slightly darker than canvas, used for content-driven feature explanations. +- Anthropic radial-spike mark — a small black asterisk-like glyph (4-spoke radial) — appears as the brand wordmark prefix and as a content marker. +- Border radius is hierarchical: `{rounded.md}` (8px) for buttons + inputs, `{rounded.lg}` (12px) for content + product cards, `{rounded.xl}` (16px) for the hero illustration container, `{rounded.pill}` for badges. +- Section rhythm `{spacing.section}` (96px) — modern-SaaS standard. Internal card padding stays generous at `{spacing.xl}` (32px). + +## Colors + +### Brand & Accent +- **Coral / Primary** (`{colors.primary}` — #cc785c): The signature Anthropic warm coral. Used on every primary CTA background, on full-bleed coral callout cards, on the brand wordmark accent. The most-recognized Anthropic color outside of the spike-mark logo. +- **Coral Active** (`{colors.primary-active}` — #a9583e): The press / hover-darker variant. +- **Coral Disabled** (`{colors.primary-disabled}` — #e6dfd8): A desaturated cream-tinted disabled state. +- **Accent Teal** (`{colors.accent-teal}` — #5db8a6): Used sparingly on secondary product surfaces (terminal status indicators, "active connection" dots in connectors page). +- **Accent Amber** (`{colors.accent-amber}` — #e8a55a): A small companion warm-tone used on category badges and inline highlights. + +### Surface +- **Canvas** (`{colors.canvas}` — #faf9f5): The default page floor. Tinted cream — warm, deliberately not pure white. +- **Surface Soft** (`{colors.surface-soft}` — #f5f0e8): Section dividers, very-soft band backgrounds. +- **Surface Card** (`{colors.surface-card}` — #efe9de): Feature cards, content cards. One step darker than canvas. +- **Surface Cream Strong** (`{colors.surface-cream-strong}` — #e8e0d2): A strongest-cream variant used on selected category tabs and emphasized section bands. +- **Surface Dark** (`{colors.surface-dark}` — #181715): Code editor mockups, model showcase cards, footer. The dominant dark surface. +- **Surface Dark Elevated** (`{colors.surface-dark-elevated}` — #252320): Elevated cards inside dark bands (settings panels in mockups). +- **Surface Dark Soft** (`{colors.surface-dark-soft}` — #1f1e1b): Slightly lighter dark, used for code block backgrounds inside larger dark cards. +- **Hairline** (`{colors.hairline}` — #e6dfd8): The 1px border tone on cream surfaces. Same hex as `{colors.primary-disabled}` — borders feel like one elevation step rather than ink lines. +- **Hairline Soft** (`{colors.hairline-soft}` — #ebe6df): Barely-visible divider used inside the same band. + +### Text +- **Ink** (`{colors.ink}` — #141413): All headlines and primary text. Warm dark, slightly off-pure-black. +- **Body Strong** (`{colors.body-strong}` — #252523): Emphasized paragraphs, lead text. +- **Body** (`{colors.body}` — #3d3d3a): Default running-text color. +- **Muted** (`{colors.muted}` — #6c6a64): Sub-headings, breadcrumbs, footer-adjacent secondary text. +- **Muted Soft** (`{colors.muted-soft}` — #8e8b82): Captions, fine-print, copyright lines. +- **On Primary** (`{colors.on-primary}` — #ffffff): Text on coral buttons. +- **On Dark** (`{colors.on-dark}` — #faf9f5): Cream-tinted white used on dark surfaces (echoes the canvas tone). +- **On Dark Soft** (`{colors.on-dark-soft}` — #a09d96): Footer body text, secondary labels in dark mockups. + +### Semantic +- **Success** (`{colors.success}` — #5db872): Green status dots, "available" indicators. +- **Warning** (`{colors.warning}` — #d4a017): Warning callouts (rare on marketing surfaces). +- **Error** (`{colors.error}` — #c64545): Validation errors. + +## Typography + +### Font Family +The system runs **Copernicus** (or **Tiempos Headline** as substitute) as the slab-serif display face for headlines, and **StyreneB** (or **Inter** as substitute) as the humanist sans for body, navigation, and UI labels. **JetBrains Mono** handles code blocks. The fallback stack walks `Tiempos Headline, Garamond, "Times New Roman", serif` for display and `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for body. + +The display/body split is editorial: +- Copernicus serif (weight 400, negative tracking) → h1, h2, h3, hero display +- StyreneB sans (weight 400-500) → body, navigation, buttons, captions, labels +- JetBrains Mono → all code blocks and terminal text + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-xl}` | 64px | 400 | 1.05 | -1.5px | Homepage h1 ("Meet your thinking partner") — Copernicus serif | +| `{typography.display-lg}` | 48px | 400 | 1.1 | -1px | Section heads — Copernicus | +| `{typography.display-md}` | 36px | 400 | 1.15 | -0.5px | Sub-section heads, model names — Copernicus | +| `{typography.display-sm}` | 28px | 400 | 1.2 | -0.3px | Pricing tier names, callout headlines — Copernicus | +| `{typography.title-lg}` | 22px | 500 | 1.3 | 0 | Pricing plan size labels — StyreneB | +| `{typography.title-md}` | 18px | 500 | 1.4 | 0 | Feature card titles, intro paragraphs | +| `{typography.title-sm}` | 16px | 500 | 1.4 | 0 | Connector tile titles, list labels | +| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Default running-text — StyreneB | +| `{typography.body-sm}` | 14px | 400 | 1.55 | 0 | Footer body, fine-print | +| `{typography.caption}` | 13px | 500 | 1.4 | 0 | Badge labels, captions | +| `{typography.caption-uppercase}` | 12px | 500 | 1.4 | 1.5px | Category tags, "NEW" badges | +| `{typography.code}` | 14px | 400 | 1.6 | 0 | Code blocks — JetBrains Mono | +| `{typography.button}` | 14px | 500 | 1.0 | 0 | Standard button labels | +| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu items | + +### Principles +Display sizes use weight 400 (regular), never bold. Negative letter-spacing (-0.3 to -1.5px) is essential — Copernicus without it reads as off-brand. The serif character is what gives Anthropic its literary, considered voice; switching to a sans-serif display would make Claude feel like every other AI tool. + +Body type stays at weight 400 for paragraphs, weight 500 for labels and emphasized phrases. The sans body is humanist (StyreneB) — never geometric. Inter is an acceptable substitute because of its similar humanist proportions; Helvetica or Arial would be too neutral and break the warm-editorial feel. + +### Note on Font Substitutes +If Copernicus / Tiempos Headline is unavailable, **Cormorant Garamond** at weight 500 with -0.02em letter-spacing is the closest open-source approximation. **EB Garamond** is a fallback. For StyreneB, **Inter** is the closest match — both are humanist sans designed for screen reading. **Söhne** is another close alternative if licensed. + +## Layout + +### Spacing System +- **Base unit:** 4px. +- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px. +- **Section padding:** `{spacing.section}` (96px) — modern-SaaS rhythm. +- **Card internal padding:** `{spacing.xl}` (32px) for feature cards, pricing tier cards, model comparison cards; `{spacing.lg}` (24px) for code-window cards and connector tiles. +- **Callout / CTA bands:** `{spacing.xxl}` (48px) inside coral callout cards; 64px inside the larger dark CTA band. + +### Grid & Container +- **Max content width:** ~1200px centered. +- **Editorial body:** Single 12-column grid; hero often uses 6/6 split (h1 left, illustration right). +- **Feature card grids:** 3-up at desktop, 2-up at tablet, 1-up at mobile. +- **Connector tile grids:** 4-up or 6-up at desktop, 2-up at tablet, 1-up at mobile. +- **Pricing grid:** 3-up at desktop (Free / Pro / Team / Enterprise often), 1-up at mobile. + +### Whitespace Philosophy +The cream canvas + serif display + generous internal padding create an editorial pacing — Claude reads like a long-form magazine column rather than a marketing template. Whitespace between bands stays uniform at 96px; whitespace inside cards is generous (32px), letting type breathe. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Flat | No shadow, no border | Body sections, top nav, hero bands | +| Soft hairline | 1px `{colors.hairline}` border | Inputs, sub-nav, occasionally on cards | +| Cream card | `{colors.surface-card}` background — no shadow | Feature cards, content cards | +| Dark surface card | `{colors.surface-dark}` background — no shadow | Code editor mockups, model showcase cards | +| Subtle drop shadow | Faint shadow at low alpha | Hover-elevated states (the system uses `0 1px 3px rgba(20,20,19,0.08)` rarely) | + +The elevation philosophy is **color-block first, shadow rare**. Most depth comes from the cream-vs-dark surface contrast. Shadows are minimal. The dark surface mockups have their own internal product chrome (code editor scrollbars, line numbers, syntax highlighting) which adds detail without needing external shadows. + +### Decorative Depth +- The Anthropic spike-mark glyph (4-spoke radial asterisk) appears as a small black mark in the brand wordmark and inline as a content marker. +- Code editor mockups carry their own internal depth: syntax-highlighted text in muted blues / oranges / grays, line numbers in `{colors.muted-soft}`, status bars at the bottom in `{colors.surface-dark-elevated}`. +- Some hero illustrations use simple line-art with coral and dark-navy strokes on cream — minimal, hand-drawn-feeling, never photorealistic. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.xs}` | 4px | Reserved for badge accents and tiny dropdowns | +| `{rounded.sm}` | 6px | Small inline buttons, dropdown items | +| `{rounded.md}` | 8px | Standard CTA buttons, text inputs, category tabs | +| `{rounded.lg}` | 12px | Content cards (feature, pricing, code-window, model-comparison) | +| `{rounded.xl}` | 16px | Hero illustration container, the larger marquee components | +| `{rounded.pill}` | 9999px | Badge pills, "NEW" tags | +| `{rounded.full}` | 9999px / 50% | Avatar substitutes, icon buttons | + +### Photography & Illustrations +Claude's hero rarely uses photography. Instead it uses: +- Simple line-art illustrations with coral + dark-navy strokes on the cream canvas +- Code editor mockups (the dominant "hero" treatment on developer-focused pages) +- Terminal output mockups with monospace text on dark +- Model comparison cards (Opus / Sonnet / Haiku) with abstract geometric thumbnails + +When photography is used (rare — mostly testimonials), avatars crop to perfect circles at 40px diameter. + +## Components + +### Top Navigation + +**`top-nav`** — Cream nav bar pinned to the top of every page. 64px tall, `{colors.canvas}` background. Carries the Anthropic spike-mark + "Claude" wordmark at left, primary horizontal menu (Product, Solutions, Use Cases, Pricing, Research, Company) center-left, right-side cluster with "Sign in" text-link, "Try Claude" `{component.button-primary}` (coral). Menu items in `{typography.nav-link}` (StyreneB 14px / 500). + +### Buttons + +**`button-primary`** — The signature coral CTA. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), type `{typography.button}` (StyreneB 14px / 500), padding 12px × 20px, height 40px, rounded `{rounded.md}` (8px). Active state `button-primary-active` darkens to `{colors.primary-active}` (#a9583e). + +**`button-secondary`** — Cream button with hairline outline. Background `{colors.canvas}`, text `{colors.ink}`, 1px hairline border, same padding + height + radius as primary. + +**`button-secondary-on-dark`** — Used over `{colors.surface-dark}` cards. Background `{colors.surface-dark-elevated}` (#252320), text `{colors.on-dark}`. Stays dark — the system never inverts to a light secondary on dark surfaces. + +**`button-text-link`** — Inline text button, no background. Used for "Sign in" in the top nav and inline CTA links. + +**`button-icon-circular`** — 36px circular icon button. Background `{colors.canvas}`, hairline border, ink-color icon. Used for carousel arrows, share, "view more". + +**`text-link`** — Inline body links in `{colors.primary}` (the coral). Underlined on press; the coral inline link is one of the system's most distinctive small details. + +### Cards & Containers + +**`hero-band`** — Cream-canvas hero with a 6-6 grid: h1 + sub-headline + button row on the left, hero illustration card or product mockup card on the right. Vertical padding `{spacing.section}` (96px). + +**`hero-illustration-card`** — A larger card holding the hero's right-side artifact — sometimes a coral-stroke line illustration on cream background, sometimes a dark code editor mockup. Background `{colors.canvas}` or `{colors.surface-dark}` depending on context, rounded `{rounded.xl}` (16px). + +**`feature-card`** — Used in 3-up feature grids. Background `{colors.surface-card}` (#efe9de — slightly darker cream), rounded `{rounded.lg}` (12px), internal padding `{spacing.xl}` (32px). Carries a small icon at top, an `{typography.title-md}` headline, and a body description in `{typography.body-md}`. + +**`product-mockup-card-dark`** — Dark navy card showing actual Claude product chrome (chat interface, code editor, agent controls). Background `{colors.surface-dark}`, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries text labels in `{colors.on-dark}` and product UI fragments below. + +**`code-window-card`** — A specialized dark card showing a code editor with line numbers, syntax-highlighted code in `{typography.code}` (JetBrains Mono), and sometimes a "Run" button or terminal output panel below. Background `{colors.surface-dark}` with `{colors.surface-dark-soft}` for the inner code block, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). The signature visual element of Claude Code product pages. + +**`model-comparison-card`** — Used on the homepage's "Which problem are you up against?" section comparing Opus / Sonnet / Haiku. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries the model name, a short capability blurb, and a `{component.text-link}` to learn more. + +**`pricing-tier-card`** — Standard tier card. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding `{spacing.xl}` (32px). Carries the plan name in `{typography.title-lg}` (StyreneB), price in `{typography.display-sm}` (Copernicus serif!), feature checklist in `{typography.body-md}`, and a `{component.button-primary}` at the bottom. + +**`pricing-tier-card-featured`** — The featured tier (typically "Pro" or "Team"). Background flips to `{colors.surface-dark}`, text inverts to `{colors.on-dark}`. The dark surface IS the featured-tier signal. + +**`callout-card-coral`** — A full-bleed coral card carrying a major call-to-action. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), rounded `{rounded.lg}`, padding `{spacing.xxl}` (48px). The coral surface IS the voltage; the CTA inside uses an inverted button style (cream/canvas button on coral). + +**`connector-tile`** — Used on the connectors page's integration grid. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding 20px. Each tile carries a logo at top, a `{typography.title-sm}` connector name, and a short description. + +### Inputs & Forms + +**`text-input`** — Standard text input. Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, rounded `{rounded.md}` (8px), padding 10px × 14px, height 40px. 1px hairline border in `{colors.hairline}`. + +**`text-input-focused`** — Focus state. Border thickens or shifts to `{colors.primary}` (coral) for emphasis. Carries a 3px coral-at-15%-alpha outer ring. + +**`cookie-consent-card`** — Bottom-right floating dark cookie banner. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). One of the few places dark surface appears at small scale on cream pages. + +### Tags / Badges + +**`badge-pill`** — Small pill label used for category tags. Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.caption}` (13px / 500), rounded `{rounded.pill}`, padding 4px × 12px. + +**`badge-coral`** — Coral-fill badge for "NEW", "BETA", featured highlights. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.caption-uppercase}` (12px / 500 / 1.5px tracking), rounded `{rounded.pill}`, padding 4px × 12px. + +### Tab / Filter + +**`category-tab`** + **`category-tab-active`** — Used in sub-nav rows on solutions / connectors pages. Inactive: transparent background, `{colors.muted}` text. Active: `{colors.surface-card}` background, `{colors.ink}` text. Padding 8px × 14px, rounded `{rounded.md}`. + +### CTA / Footer + +**`cta-band-coral`** — A pre-footer "Try Claude" CTA card. Full-width coral fill, white type, rounded `{rounded.lg}`, padding 64px. Carries an h2 in `{typography.display-sm}` (still serif!), a sub-line, and a cream-button CTA. + +**`cta-band-dark`** — Alternative pre-footer band on developer-focused pages. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding 64px. Often pairs with a code-window card. + +**`footer`** — Dark navy footer that closes every page. Background `{colors.surface-dark}` (#181715), text `{colors.on-dark-soft}`. 4-column link list at desktop covering Product / Company / Resources / Legal. Vertical padding 64px. The Anthropic spike-mark + "Anthropic" wordmark sits at the top in `{colors.on-dark}`. The footer never inverts. + +## Do's and Don'ts + +### Do +- Anchor every page on the cream canvas. Pure white reads as "any other AI tool"; the warm tint is the brand differentiator. +- Use Copernicus serif for every display headline. Pair with StyreneB sans body. Negative letter-spacing on display sizes is non-negotiable. +- Reserve `{colors.primary}` (coral) for primary CTAs and full-bleed `{component.callout-card-coral}` moments. Don't paint accent moments coral elsewhere. +- Use `{component.product-mockup-card-dark}` and `{component.code-window-card}` to show actual Claude product chrome. Don't paint marketing illustrations of code when you can show real code. +- Pair `{component.feature-card}` (cream) with `{component.product-mockup-card-dark}` (navy) in alternating bands. The cream-to-dark rhythm is the brand's pacing mechanism. +- Use the Anthropic spike-mark glyph as the brand wordmark prefix. Never invert the mark to white-on-dark within the wordmark itself. +- Apply `{spacing.section}` (96px) between major bands. + +### Don't +- Don't use cool grays or pure white for canvas. Cream is the brand. +- Don't bold serif display weight. Copernicus at 700 reads as bombastic; the system stays at 400. +- Don't use cool blue or saturated cyan as a brand accent. The coral is the brand voltage. +- Don't put coral everywhere. The coral is scarce on individual elements and generous only on full-bleed coral callout cards. +- Don't use Inter for display headlines. The serif character is the brand voice. +- Don't repeat the same surface mode in two consecutive bands. The pacing alternates: cream → cream-card → dark-mockup → cream → coral-callout → dark-footer. +- Don't add hover state styling beyond what the system already encodes — primary darkens on press; nothing else changes. + +## Responsive Behavior + +### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 768px | Hamburger nav; hero h1 64→32px; hero-illustration-card stacks below content; feature grids 1-up; connector tiles 2-up; pricing 1-up; footer 4 cols → 1 | +| Tablet | 768–1024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up | +| Desktop | 1024–1440px | Full top-nav with all menu items; 3-up feature cards; 4-up or 6-up connector tiles; 3-up pricing tiers | +| Wide | > 1440px | Same as desktop with more outer breathing room; max content width caps at 1200px | + +### Touch Targets +- `{component.button-primary}` at minimum 40 × 40px. +- `{component.button-icon-circular}` at exactly 36 × 36 — slightly under WCAG 44 but visually centered. +- `{component.text-input}` height is 40px. +- Connector tile entire card area is tappable; effective tap area >> 44px. + +### Collapsing Strategy +- Top nav collapses to hamburger at < 768px; menu opens as a full-screen cream sheet. +- Hero band's 6-6 grid collapses to single-column on mobile — h1 + sub-head + buttons first, then the illustration / mockup card below. +- Feature grids reduce columns rather than scaling cards down. +- Pricing tier cards collapse 4 → 2 → 1; featured-tier dark surface stays visually distinct at every breakpoint. +- Code-window cards retain code legibility at every breakpoint by allowing horizontal scroll within the card rather than wrapping code lines. + +### Image Behavior +- Code blocks inside dark mockups stay at fixed font-size; horizontal scroll on mobile rather than wrapping. +- Hero illustrations scale proportionally; line-art strokes thin slightly on mobile. +- Avatar photos in testimonials crop to circles at every breakpoint. + +## Iteration Guide + +1. Focus on ONE component at a time. Reference its YAML key (`{component.feature-card}`, `{component.code-window-card}`). +2. Variants of an existing component (`-active`, `-disabled`, `-focused`) live as separate entries in `components:`. +3. Use `{token.refs}` everywhere — never inline hex. +4. Never document hover. Default and Active/Pressed states only. +5. Display headlines stay Copernicus serif 400 with negative tracking. Body stays StyreneB / Inter 400. The split is unbreakable. +6. Cream + coral + dark navy is the trinity. Don't introduce a fourth surface tone (no purple cards, no green sections). +7. When in doubt about emphasis: bigger Copernicus serif before bolder weight. + +## Known Gaps + +- Copernicus and StyreneB are licensed Anthropic typefaces and not available as public web fonts. Substitutes (Tiempos Headline / Cormorant Garamond / EB Garamond for serif; Inter / Söhne for sans) are documented in the typography section. +- The Anthropic radial-spike-mark is a brand glyph rendered as inline SVG; it's not formalized as a system token here. Treat it as a logo asset. +- Animation and transition timings (chat message reveal, code block typewriter effect on the homepage, agentic-flow diagram animations) are not in scope. +- Form validation states beyond `{component.text-input-focused}` are not extracted — error / success states would need a sign-up or feedback flow to confirm. +- The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document. +- The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser — the static screenshot doesn't fully capture the animation chrome. diff --git a/assets/dashboard-v3-apple.html b/assets/dashboard-v3-apple.html new file mode 100644 index 0000000..68f0cac --- /dev/null +++ b/assets/dashboard-v3-apple.html @@ -0,0 +1,1478 @@ + + + + + +PriceHawk — Today's compare + + + + + + + + + + + +
+
+ + Live · Wed 6 May · 14:32 ACST +
+
Today, on Flow Power, you saved
+
+$2.84
+

+ Versus your current plan, GloBird ZeroHero. Three other providers couldn't get close. +

+

+ 14-day average: +$1.92 a day. That's $700 a year, on the same kilowatt-hours. +

+ +
+ Scroll + +
+
+ + +
+
+
+
+
+ Two hours of Happy Hour exports earned $1.44. The wholesale day under + twenty cents flat. The rest of the field watched. +
+
— Flow Power · Wholesale + Happy Hour 5:30–7:30pm
+
+
+
+
+ + +
+
+

Four providers. One winner.

+ +
+ +
+ + + +
+
+ GloBird + $5.18 + ZeroHero · TOU + Free 11–2 +
Now 27.5¢FiT 0.0¢
+
+
+
+ LocalVolts + $4.92 + P2P · 5-min spot +
Now 22.1¢Export −2.4¢
+
+
+
+ Amber + $6.42 + Wholesale · 30-min +
Now 21.4¢FiT 9.8¢
+
+
+
+ +
+
+ + +
+
+

The next 24 hours, ahead of time.

+ +
+ +
+ + + + + + + + + + + + + + + + + + + 80¢ + 60¢ + 40¢ + 20¢ + + + + + + + + + + + + + Now · 21.4¢ + + + + + Peak 84.2¢ + 19:00 + + + + Best dip 12.1¢ + 17:30 + + + + −3h + Now + +5h + +10h + +15h + +24h + + +
+ +
+
+
Buy now
+
21.4¢
+
Middle of today's range. Charge if you must.
+
+
+
Best dip
+
12.1¢
+
17:30 · 3h from now
+
+
+
Worst peak
+
84.2¢
+
19:00 · avoid at all cost
+
+
+
Spread
+
7.0×
+
Peak vs dip — exceptional arbitrage day
+
+
+
+ + +
+
+
+
Right now · GloBird ZeroHero
+

Free Power.

+

+ From eleven until two, every kilowatt-hour you import is on the house. +

+

Ends in 28 minutes · Shoulder begins 14:00

+
+
+
+
+ 14:32 + +
+
+
+
+
+
+
+
+
+
+ 000611141623 +
+
+ Free 11–14 + Shoulder + Off-peak + Peak 16–23 +
+
+
+
+ + +
+
+
+
Today's saving, broken down
+
$2.84
cheaper.
+

+ Four reasons Flow beat the field — and one Flow missed. +

+
+
+
+
I.
+
+ Exported 3.2 kWh during the 5:30–7:30pm Happy Hour at 45¢/kWh. + That's $1.44 earned, against $0.31 on GloBird. +
+
+
+
II.
+
+ Wholesale import averaged 18.4¢ all day, against GloBird's flat 26.5¢. + Saved $0.68 on the 8.4 kWh you bought. +
+
+
+
III.
+
+ Missed GloBird's free 11–2 window — only used 0.4 kWh of free power. + Average households use 1.8 kWh. +
+
+
+
IV.
+
+ Flow's PEA credit applied: monthly LWAP 8.2¢ < TWAP 11.4¢. + That's a +$0.18 dynamic credit, free. +
+
+
+
+
+ + +
+
+

Beat Flow, then.

+ +
+
+ +
+ Shift load +
$0.84/day
+
Move the dishwasher and dryer to the 11–2pm free window on GloBird.
+
A $0.84-a-day shift compounds to $307 a year — without changing providers.
+ Set automation +
+ +
+ Set a sell floor +
1.8 kWh
+
A 12¢ minimum sell price on LocalVolts would have matched at peer rates instead of negative spot.
+
P2P matching skips retail markup — but only works if you set a floor it respects.
+ Configure LocalVolts +
+ +
+ Force Amber export +
+$3.84
+
If 50% of the battery had been dispatched into top-priced FiT intervals.
+
+ 0% + 25% + 50% + 75% + 100% +
+
Heuristic — assumes perfect foresight, ignores round-trip loss. 13.5 kWh / 5 kW.
+
+ +
+
+ + +
+
+ +
+
7/14
+
+
14-day winner streak
+

+ Half the fortnight, Flow Power was the cheapest tariff for your home. + The free-power crew came second, with four wins of their own. +

+
+
+ +
+
+
Amber
+
+
+
+
+
+
+
+
+
+
+
GloBird
+
+
+
+
+
+
+
+
+
+
+
+
+
Flow Power
+
+
+
+
+
+
+
+
+
+
+
LocalVolts
+
+
+
+
+
+
+
+
+
+
+ 14d ago10d7d3dtoday +
+
+ +
+
+ + +
+
+

Incentives, this hour.

+ +
+
+ +
+
+ GloBird ZeroHero + Free import + Super Export +
+
+
+
Free 11–2pm
+
Active
+
28 min remaining
+
+
+
Super Export
+
15¢
+
6–9pm · first 15 kWh
+
+
+
+
Free import used0.4 kWh
+
+
Super export0 / 15 kWh
+
+
+

$1/day credit earned if peak import ≤ 0.09 kWh during 6–9pm. On track.

+
+ +
+
+ Flow Power + Happy Hour + PEA credit +
+
+
+
Happy Hour FiT
+
45¢
+
starts in 2h 58m
+
+
+
PEA / FPEA
+
−3.2¢
+
monthly LWAP < TWAP
+
+
+
+
Forecast export3.2 kWh
+
+
Happy Hour earnings$1.44
+
+
+

Net daily adjustment: +$1.62 = $1.44 Happy Hour + $0.18 PEA credit.

+
+ +
+
+ LocalVolts + P2P matching, 5-min +
+
+
+
Buy ceiling
+
28¢
+
won't import above
+
+
+
Sell floor
+
12¢
+
won't export below
+
+
+
+
P2P import3.6 kWh
+
+
P2P export1.8 kWh
+
+
+

Daily supply $1.10 · no retail markup. No forecast endpoint — projections are ranges.

+
+ +
+
+ + +
+
+

Today's bill, ranked.

+ +
+
+ +
+ 01 + Flow Power + $2.34 + Cheapest by $2.58. Happy Hour exports + low wholesale + PEA credit. The compound effect. +
+
+ 02 + LocalVolts + $4.92 + P2P matching saved on retail markup but missed the high-FiT slot Flow caught. +
+
+ 03 + GloBird + $5.18 + Flat-rate predictability, free 11–2 window. Underused today — only 0.4 kWh of free power consumed. +
+
+ 04 + Amber + $6.42 + Caught the 19:00 spot peak at 84.2¢. Wholesale exposure cuts both ways. +
+ +
+
+ + +
+

Same kilowatt-hours. $700 less.

+

Switch to Flow Power and PriceHawk keeps watching the others. If Flow stops winning, we'll tell you.

+ +
+ + + + + + + + + + + diff --git a/assets/dashboard-v3-mockup.html b/assets/dashboard-v3-mockup.html index de76aff..3893e6b 100644 --- a/assets/dashboard-v3-mockup.html +++ b/assets/dashboard-v3-mockup.html @@ -1,237 +1,440 @@ - + -PriceHawk V3 — VoltCompare-inspired mockup +PriceHawk V3 — Amber-inspired mockup - + + @@ -533,7 +886,7 @@ @@ -631,7 +984,7 @@
Tariff Period
14:32 ACST
-
+
🆓
Free Power
@@ -654,8 +1007,8 @@
- -
+ +
Amber Price Forecast · Next 24h
↓ -3h · ↑ +24h
@@ -664,16 +1017,16 @@ - - + + - - + + - + @@ -682,19 +1035,19 @@ + fill="none" stroke="#00ffa8" stroke-width="2.25"/> + fill="none" stroke="#fd8aff" stroke-width="2" stroke-dasharray="4 3" opacity="0.85"/> - - NOW 21.4¢ + + NOW 21.4¢ - - PEAK 84.2¢ · 19:00 + + PEAK 84.2¢ · 19:00 - - DIP 12.1¢ + + DIP 12.1¢
@@ -713,11 +1066,14 @@
- -
+ +
-
Why Flow Power won today
-
$2.84 cheaper than your plan
+
+
Why Flow Power won today
+
$2.84 cheaper than your current plan
+
+
14-day avg +$1.92/day
@@ -859,18 +1215,18 @@
-
+
GloBird · Incentives
ZEROHERO
-
+
Free 11–2pm
ACTIVE
28 min remaining
-
+
Super Export
15¢
6–9pm · first 15 kWh
@@ -888,7 +1244,7 @@ 0 / 15 kWh
- $1/day credit earned if peak import ≤ 0.09 kWh during 6–9pm. Currently on track. + $1/day credit earned if peak import ≤ 0.09 kWh during 6–9pm. Currently on track.
@@ -922,23 +1278,23 @@ $1.44
- Net daily adjustment: +$1.62 = $1.44 Happy Hour + $0.18 PEA credit. + Net daily adjustment: +$1.62 = $1.44 Happy Hour + $0.18 PEA credit.
-
+
LocalVolts · P2P Matching
5-min · live
-
+
Buy ceiling
28¢
won't import above
-
+
Sell floor
12¢
won't export below
@@ -960,11 +1316,14 @@
- -
+ +
-
Today's Cost Breakdown
-
all 4 providers · AUD
+
+
Today's Cost Breakdown
+
All four providers, today, settled.
+
+
AUD · 14:32 ACST
Amber diff --git a/custom_components/pricehawk/aemo_api.py b/custom_components/pricehawk/aemo_api.py index 614630b..f772a92 100644 --- a/custom_components/pricehawk/aemo_api.py +++ b/custom_components/pricehawk/aemo_api.py @@ -113,7 +113,8 @@ def _pick_latest_dispatch_file(html: str) -> str | None: matches = _FILE_RE.findall(html) if not matches: return None - # Filenames are timestamp-prefixed so a lexical sort puts newest last. + # Filenames are PUBLIC_DISPATCHIS_YYYYMMDDHHMM_..._LEGACY.zip. + # Lexical sort correctly puts the most recent timestamp last. return sorted(matches)[-1] diff --git a/custom_components/pricehawk/cdr/__init__.py b/custom_components/pricehawk/cdr/__init__.py new file mode 100644 index 0000000..c18892e --- /dev/null +++ b/custom_components/pricehawk/cdr/__init__.py @@ -0,0 +1,19 @@ +"""CDR-native tariff engine package. + +Phase 1 refactor of the legacy `tariff_engine.py` (GloBird-specific, config- +dict driven). The CDR package consumes AER Consumer Data Right +PlanDetailV2 JSON and works across all AU energy retailers. + +Public surface: + from custom_components.pricehawk.cdr import evaluate, CostBreakdown + from custom_components.pricehawk.cdr.models import PlanDetail, ConsumptionWindow + +Phase 0 prototype (`scripts/cdr_evaluator_proto.py`) was the working +spec for this package. Behaviour is preserved; only the typing and +packaging shape changed. +""" +from __future__ import annotations + +from .evaluator import CostBreakdown, evaluate + +__all__ = ["CostBreakdown", "evaluate"] diff --git a/custom_components/pricehawk/cdr/cdr_client.py b/custom_components/pricehawk/cdr/cdr_client.py new file mode 100644 index 0000000..c55f8e5 --- /dev/null +++ b/custom_components/pricehawk/cdr/cdr_client.py @@ -0,0 +1,223 @@ +"""Async CDR client for AER Product Reference Data endpoints. + +Wraps the public Consumer Data Right `cds-au/v1/energy/plans` endpoints +served by individual retailer data holders (and the energymadeeasy.gov.au +AER proxy). Reusable across the config-flow wizard (Phase 2) and the +coordinator nightly refresh (Phase 1.5+). + +Locked architectural notes (see design doc §I.1): + +- HTTP transport is `aiohttp` via `async_get_clientsession(hass)` — caller + passes the session in. Mirrors the convention used by `aemo_api.py`. +- List endpoint requires header `x-v: 1`; detail requires `x-v: 3`. +- Pagination follows CDR Common spec: `page` + `page-size` query params, + `meta.totalPages` in the envelope. +- 25-29 detail requests/sec is the documented budget for the energymadeeasy + proxy; we do not parallelise from this client. Callers that need batching + must serialise + insert sleeps themselves. + +Exceptions: +- `CdrPlanNotFound` — 404 on a detail fetch (planId no longer published) +- `CdrUnavailable` — network failure or 5xx after retries (caller may + retry interactively or fall through to manual wizard) +- `CdrAPIError` — every other unexpected 4xx response +""" + +from __future__ import annotations + +import asyncio +import logging +import urllib.parse +from typing import Any + +import aiohttp + +_LOGGER = logging.getLogger(__name__) + +USER_AGENT = "PriceHawk/1.5 (+https://github.com/Artic0din/pricehawk)" +_TIMEOUT_SEC = 20 +_MAX_RETRIES = 3 +_RETRY_BASE_DELAY = 2 # seconds; exponential backoff +_LIST_PAGE_SIZE = 1000 + + +class CdrUnavailable(Exception): + """Network / 5xx failure after retries; caller may retry or fall through.""" + + +class CdrPlanNotFound(Exception): + """404 on plan detail fetch — planId stale or never published.""" + + +class CdrAPIError(Exception): + """Unexpected non-success response from CDR endpoint.""" + + +async def fetch_plan_list( + session: aiohttp.ClientSession, + base_url: str, + *, + customer_type: str = "RESIDENTIAL", + fuel_type: str = "ELECTRICITY", + brand: str | None = None, +) -> list[dict[str, Any]]: + """Fetch all residential-electricity MARKET plans for ``base_url``. + + Returns the deduplicated ``plans`` array across all pages. Dedup is + by ``planId`` (the CDR ID-Permanence rules guarantee planId stable + across republish boundaries) — without it, retailers that republish + a plan during pagination produce duplicate rows in the wizard. + Filtering is done client-side because the CDR list endpoint does + not accept ``customerType`` as a query param. + + ``brand`` is the CDR ``brand`` discriminator for shared base URIs + (e.g. seven brands hosted on ``cdr.energymadeeasy.gov.au/energy-locals/``). + Passed as ``?brand=`` and harmlessly ignored by single-brand + endpoints. + + A 404 at the list endpoint indicates a bad base URL or proxy-path + regression, not a stale plan — surfaces as ``CdrAPIError`` rather + than ``CdrPlanNotFound`` (which is reserved for the detail + endpoint). + """ + page = 1 + seen_ids: set[str] = set() + out: list[dict[str, Any]] = [] + while True: + query: dict[str, Any] = { + "type": "ALL", + "fuelType": fuel_type, + "page": page, + "page-size": _LIST_PAGE_SIZE, + } + if brand: + query["brand"] = brand + params = urllib.parse.urlencode(query) + url = f"{base_url.rstrip('/')}/cds-au/v1/energy/plans?{params}" + try: + body = await _get_json(session, url, x_v="1") + except CdrPlanNotFound as err: + # 404 from the list endpoint is a bad URL, not a stale plan. + raise CdrAPIError(str(err)) from err + chunk = body.get("data", {}).get("plans", []) + for p in chunk: + if ( + p.get("customerType") != customer_type + or p.get("fuelType") != fuel_type + ): + continue + pid = p.get("planId") + if not pid or pid in seen_ids: + continue + seen_ids.add(pid) + out.append(p) + meta = body.get("meta", {}) + total_pages = int(meta.get("totalPages", 1)) + if page >= total_pages or not chunk: + break + page += 1 + return out + + +async def fetch_plan_detail( + session: aiohttp.ClientSession, + base_url: str, + plan_id: str, + *, + brand: str | None = None, +) -> dict[str, Any]: + """Fetch PlanDetailV2 envelope for ``plan_id``. + + Returns the full response body (envelope, ``data`` shape preserved) + so callers can store the raw bytes as a config-entry fixture without + losing audit fields. Raises ``CdrPlanNotFound`` on 404 — that + actually does mean a stale planId at this endpoint. + + ``brand`` is the CDR brand discriminator for shared base URIs — see + ``fetch_plan_list`` docstring. Appended as ``?brand=`` when set. + """ + url = f"{base_url.rstrip('/')}/cds-au/v1/energy/plans/{plan_id}" + if brand: + url = f"{url}?{urllib.parse.urlencode({'brand': brand})}" + return await _get_json(session, url, x_v="3") + + +async def _get_json( + session: aiohttp.ClientSession, + url: str, + *, + x_v: str, +) -> dict[str, Any]: + """Internal helper: GET + JSON parse with retry-on-5xx + timeout backoff.""" + headers = { + "x-v": x_v, + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + for attempt in range(_MAX_RETRIES): + try: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=_TIMEOUT_SEC), + headers=headers, + ) as resp: + if resp.status == 200: + return await resp.json(content_type=None) + if resp.status == 404: + raise CdrPlanNotFound(f"404 from {url}") + if resp.status >= 500 or resp.status == 429: + if attempt < _MAX_RETRIES - 1: + await asyncio.sleep(_RETRY_BASE_DELAY * (2**attempt)) + continue + raise CdrUnavailable( + f"HTTP {resp.status} from {url} after {_MAX_RETRIES} attempts" + ) + raise CdrAPIError(f"HTTP {resp.status} from {url}") + except (CdrPlanNotFound, CdrUnavailable, CdrAPIError): + raise + except Exception as err: # noqa: BLE001 — narrowed below + # Transient network failures (aiohttp.ClientError / built-in + # TimeoutError) trigger retry. Anything else re-raises. + if not isinstance(err, (aiohttp.ClientError, TimeoutError)): + raise + if attempt < _MAX_RETRIES - 1: + await asyncio.sleep(_RETRY_BASE_DELAY * (2**attempt)) + continue + _LOGGER.warning("CDR fetch failed for %s: %s", url, err) + raise CdrUnavailable(str(err)) from err + raise CdrUnavailable(f"exhausted retries for {url}") + + +# --------------------------------------------------------------------------- +# Pure-Python helpers exposed for unit tests (matches aemo_api.py pattern). +# --------------------------------------------------------------------------- + + +def build_list_envelope_for_test(plans: list[dict[str, Any]]) -> dict[str, Any]: + """Wrap ``plans`` in a CDR-shaped list-response envelope.""" + return { + "data": {"plans": plans}, + "links": {"self": "https://test/cds-au/v1/energy/plans"}, + "meta": {"totalRecords": len(plans), "totalPages": 1}, + } + + +def build_detail_envelope_for_test(plan_detail: dict[str, Any]) -> dict[str, Any]: + """Wrap ``plan_detail`` in a CDR-shaped detail-response envelope.""" + return { + "data": plan_detail, + "links": {"self": "https://test/cds-au/v1/energy/plans/X"}, + "meta": {}, + } + + +def filter_residential_electricity_for_test( + plans: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Pure-Python re-export of the boundary filter applied in ``fetch_plan_list``.""" + return [ + p + for p in plans + if p.get("customerType") == "RESIDENTIAL" + and p.get("fuelType") == "ELECTRICITY" + ] diff --git a/custom_components/pricehawk/cdr/data/eme_refdata.json b/custom_components/pricehawk/cdr/data/eme_refdata.json new file mode 100644 index 0000000..9db5814 --- /dev/null +++ b/custom_components/pricehawk/cdr/data/eme_refdata.json @@ -0,0 +1,2325 @@ +{ + "data": { + "thirdParties": { + "510250": { + "name": "iSelect", + "logo": "/static/organisations/logos/iselect.png", + "id": 510250, + "websiteURL": "iselect.com.au/energy", + "contact": "13 19 20" + }, + "707794": { + "name": "JG King", + "logo": "/static/organisations/logos/jgking.png", + "id": 707794, + "websiteURL": "buyinggroup.originenergy.com.au/JGK", + "contact": "1800 240 240" + }, + "707796": { + "name": "Supply Nation", + "logo": "/static/organisations/logos/supplynation.png", + "id": 707796, + "websiteURL": "buyinggroup.originenergy.com.au/SPN", + "contact": "1800 240 240" + }, + "707798": { + "name": "One Big Switch", + "logo": "/static/organisations/logos/onebigswitch.png", + "id": 707798, + "websiteURL": "www.onebigswitch.com.au", + "contact": "1300 858 737" + }, + "707800": { + "name": "UBT", + "logo": "/static/organisations/logos/ubt.png", + "id": 707800, + "websiteURL": "products.originenergy.com.au/4767/UBT", + "contact": "1800 240 240" + }, + "707804": { + "name": "SkoolBag", + "logo": "/static/organisations/logos/skoolbag.png", + "id": 707804, + "websiteURL": "buyinggroup.originenergy.com.au/SKB", + "contact": "1800 240 240" + }, + "713326": { + "name": "Beevo", + "logo": "/static/organisations/logos/beevo.png", + "id": 713326, + "websiteURL": "www.beevo.com.au", + "contact": "1300 763 764" + }, + "713330": { + "name": "Compare & Connect", + "logo": "/static/organisations/logos/compareandconnect.png", + "id": 713330, + "websiteURL": "www.compareandconnect.com.au", + "contact": "1300 859 258" + }, + "713332": { + "name": "Make It Cheaper", + "logo": "/static/organisations/logos/makeitcheaper.png", + "id": 713332, + "websiteURL": "www.makeitcheaper.com.au", + "contact": "1300 957 721" + }, + "713334": { + "name": "Connect Now", + "logo": "/static/organisations/logos/connectnow.png", + "id": 713334, + "websiteURL": "connectnow.com.au", + "contact": "1300 554 323" + }, + "713336": { + "name": "On The Move", + "logo": "/static/organisations/logos/onthemove.png", + "id": 713336, + "websiteURL": "www.onthemove.com.au", + "contact": "1300 850 360" + }, + "713338": { + "name": "Direct Connect", + "logo": "/static/organisations/logos/directconnect.png", + "id": 713338, + "websiteURL": "www.directconnect.com.au", + "contact": "1300 739 751" + }, + "713340": { + "name": "My Connect", + "logo": "/static/organisations/logos/myconnect.png", + "id": 713340, + "websiteURL": "www.myconnect.com.au", + "contact": "1300 854 478" + }, + "713342": { + "name": "You Compare", + "logo": "/static/organisations/logos/youcompare.png", + "id": 713342, + "websiteURL": "youcompare.com.au", + "contact": "1300 321 160" + }, + "713344": { + "name": "Go Switch", + "logo": "/static/organisations/logos/goswitch.png", + "id": 713344, + "websiteURL": "www.goswitch.com.au", + "contact": "1300 107 074" + }, + "713346": { + "name": "Electricity Wizard", + "logo": "/static/organisations/logos/electricitywizard.png", + "id": 713346, + "websiteURL": "electricitywizard.com.au", + "contact": "1300 359 779" + }, + "713348": { + "name": "iSelect ISE", + "logo": "/static/organisations/logos/iselect.png", + "id": 713348, + "websiteURL": "www.iselect.com.au/energy", + "contact": "13 19 20" + }, + "713350": { + "name": "Energy Watch", + "logo": "/static/organisations/logos/energywatch.png", + "id": 713350, + "websiteURL": "www.energywatch.com.au", + "contact": "13 92 82" + }, + "713352": { + "name": "CPM", + "logo": "/static/organisations/logos/cpm.png", + "id": 713352, + "websiteURL": "www.cpm-aus.com.au", + "contact": "03 9211 2300" + }, + "713354": { + "name": "Fast Connect", + "logo": "/static/organisations/logos/fastconnect.png", + "id": 713354, + "websiteURL": "www.fastconnect.net.au/home", + "contact": "1300 661 464" + }, + "713357": { + "name": "Split It", + "logo": "/static/organisations/logos/splitit.png", + "id": 713357, + "websiteURL": "www.splitit.com.au", + "contact": "1300 86 22 55" + }, + "713464": { + "name": "Compare The Market", + "logo": "/static/organisations/logos/comparethemarket.png", + "id": 713464, + "websiteURL": "www.comparethemarket.com.au/energy", + "contact": "1800 990 003" + }, + "713468": { + "name": "Ray White Home Now", + "logo": "/static/organisations/logos/raywhitehomenow.png", + "id": 713468, + "websiteURL": "www.raywhitehomenow.com", + "contact": "1300 862 255" + }, + "713470": { + "name": "Home Now Loan Market", + "logo": "/static/organisations/logos/homenowloanmarket.png", + "id": 713470, + "websiteURL": "homenow.loanmarket.com.au", + "contact": "1300 867 283" + }, + "713472": { + "name": "Warwick", + "logo": "/static/organisations/logos/warwick.png", + "id": 713472, + "websiteURL": "warwickconnects.com", + "contact": "1300 367 058" + }, + "713474": { + "name": "Morton", + "logo": "/static/organisations/logos/morton.png", + "id": 713474, + "websiteURL": "www.mortonconnects.com", + "contact": "1300 883 656" + }, + "716466": { + "name": "Sonnen", + "logo": "/static/organisations/logos/sonnen.png", + "id": 716466, + "websiteURL": "sonnen.com.au/energy", + "contact": "13 76 66" + }, + "716468": { + "name": "Amber", + "logo": "/static/organisations/logos/amber.png", + "id": 716468, + "websiteURL": "www.amberelectric.com.au", + "contact": "03 6144 7022" + }, + "722135": { + "name": "EcoU Energy", + "logo": "/static/organisations/logos/ecouenergy.png", + "id": 722135, + "websiteURL": "www.ecouenergy.com", + "contact": "1300 911 135" + }, + "722552": { + "name": "Grouply", + "logo": "/static/organisations/logos/grouply.png", + "id": 722552, + "websiteURL": "grouply.co/energy", + "contact": "1300 420 182" + }, + "722554": { + "name": "RACV", + "logo": "/static/organisations/logos/racv.png", + "id": 722554, + "websiteURL": "energycompare.racv.com.au/energy", + "contact": "1300 420 182" + }, + "741534": { + "name": "Connect With Us", + "logo": "/static/organisations/logos/connectwithus.png", + "id": 741534, + "websiteURL": "www.connectwithus.com.au", + "contact": "1300 156 660" + }, + "744499": { + "name": "Move Me In", + "logo": "/static/organisations/logos/movemein.png", + "id": 744499, + "websiteURL": "movemein.com.au", + "contact": "1300 911 947" + }, + "744501": { + "name": "Movinghub", + "logo": "/static/organisations/logos/movinghub.png", + "id": 744501, + "websiteURL": "movinghub.com.au", + "contact": "1300 744 334" + }, + "744503": { + "name": "9Saver", + "logo": "/static/organisations/logos/9saver.png", + "id": 744503, + "websiteURL": "www.9saver.com.au", + "contact": "1300 189 151" + }, + "744505": { + "name": "FiftyUp Club", + "logo": "/static/organisations/logos/fiftyupclub.png", + "id": 744505, + "websiteURL": "www.fiftyupclub.com", + "contact": "1300 969 382" + }, + "765997": { + "name": "Choice", + "logo": "/static/organisations/logos/choice.png", + "id": 765997, + "websiteURL": "www.choice.com.au", + "contact": "1800 069 552" + }, + "766005": { + "name": "CIMET", + "logo": "/static/organisations/logos/cimet.png", + "id": 766005, + "websiteURL": "www.cimet.com.au", + "contact": "1800 013 000" + }, + "766007": { + "name": "Econnex", + "logo": "/static/organisations/logos/econnex.png", + "id": 766007, + "websiteURL": "www.econnex.com.au", + "contact": "1800 013 000" + }, + "773767": { + "name": "Handled", + "logo": "/static/organisations/logos/handled.png", + "id": 773767, + "websiteURL": "handled.com.au" + }, + "778774": { + "name": "Lifestyle Communities", + "logo": "/static/organisations/logos/lifestylecommunities.png", + "id": 778774, + "websiteURL": "www.lifestylecommunities.com.au", + "contact": "1800 240 240" + }, + "778784": { + "name": "Master Builders Association NSW", + "logo": "/static/organisations/logos/mba_nsw.png", + "id": 778784, + "websiteURL": "www.mbansw.asn.au", + "contact": "1800 240 240" + }, + "778786": { + "name": "NARTA", + "logo": "/static/organisations/logos/narta.png", + "id": 778786, + "websiteURL": "www.narta.com.au", + "contact": "1800 240 240" + }, + "803394": { + "name": "Sorted Services", + "logo": "/static/organisations/logos/sortedservices.png", + "id": 803394, + "websiteURL": "www.sortedservices.com", + "contact": "1300 484 141" + }, + "873477": { + "name": "Energy Locals Urban", + "logo": "/static/organisations/logos/c7261559ddf08affa02fdcdfcbeaef43.png", + "id": 873477, + "comments": "Formerly Energy Trade,\nRebranded on the 4th of March 2024", + "websiteURL": "energylocals.com.au/urban ", + "contact": "1300 001 255" + }, + "883247": { + "name": "Chartered Accountants", + "logo": "/static/organisations/logos/charteredaccountants.png", + "id": 883247, + "websiteURL": "www.charteredaccountantsanz.com", + "contact": "1300 647 446" + }, + "893129": { + "name": "ForNRG", + "logo": "/static/organisations/logos/fornrg.png", + "id": 893129, + "websiteURL": "www.fornrg.com", + "contact": "03 9598 9485" + }, + "898280": { + "name": "Schoolzine", + "logo": "/static/organisations/logos/schoolzine.png", + "id": 898280, + "websiteURL": "www.schoolzine.com.au", + "contact": "1300 795 503" + }, + "899650": { + "name": "Family Travel", + "logo": "/static/organisations/logos/familytravel.png", + "id": 899650, + "websiteURL": "www.familytravel.com.au", + "contact": "1300 404 100" + }, + "1045176": { + "name": "hipages", + "logo": "/static/organisations/logos/hipages.png", + "id": 1045176, + "websiteURL": "hipages.com.au/tradie", + "contact": "1300 762 994" + }, + "1045230": { + "name": "Team App", + "logo": "/static/organisations/logos/teamapp.png", + "id": 1045230, + "websiteURL": "www.teamapp.com" + }, + "1045231": { + "name": "Residential Connections", + "logo": "/static/organisations/logos/addc2a12ffa57076d81d099e4d08cb29.png", + "comments": "Requested on 26 June 2020", + "id": "1045231", + "websiteURL": "www.residentialconnections.com.au", + "contact": "1300 859 238" + }, + "1045232": { + "name": "Select and Switch", + "logo": "/static/organisations/logos/fa57438ee6fe4fdd375c6106ce1470a3.png", + "id": "1045232", + "websiteURL": "www.selectandswitch.com.au", + "contact": "1800 959 969" + }, + "1045233": { + "name": "Electricity Monster", + "logo": "/static/organisations/logos/91e86234ea0820550b0723c38834a382.png", + "id": "1045233", + "websiteURL": "electricitymonster.com.au", + "contact": "1300 584 872" + }, + "1045234": { + "name": "Captain Compare", + "logo": "/static/organisations/logos/3d55365e32553cdd18c4374304337d24.png", + "id": "1045234", + "websiteURL": "www.captaincompare.com.au" + }, + "1045235": { + "name": "Ten Ants", + "logo": "/static/organisations/logos/095bb4c1b7117beba0bb33609d51c369.png", + "id": "1045235", + "websiteURL": "tenantsconnect.com.au", + "contact": "1800015699" + }, + "1045236": { + "name": "Deal Reveal", + "logo": "/static/organisations/logos/78cb2c3fcd980326e079f129138c8e6a.png", + "id": "1045236", + "websiteURL": "www.dealreveal.com.au/", + "contact": "1300036952" + }, + "1045237": { + "name": "9Saver (Sumo)", + "logo": "/static/organisations/logos/bc52ecf6b85ae55921ed865974b288f2.png", + "comments": "Created on 25 March 2022. For Sumo Power use only. Phone number directs to Sumo not 9Saver.", + "id": "1045237", + "websiteURL": "www.9saver.com.au", + "contact": "13 88 60" + }, + "1045238": { + "name": "HOOD", + "logo": "/static/organisations/logos/c8e834a065c1a8e3feaddaacfb91d760.png", + "id": "1045238", + "websiteURL": "www.hood.ai/", + "contact": "1300242824" + }, + "1045239": { + "name": "Smarter Communities", + "logo": "/static/organisations/logos/0948a8bf97b002e126f87844bc1decde.png", + "id": "1045239", + "websiteURL": "www.smartercommunities.com.au/", + "contact": "0292662600" + }, + "1045240": { + "name": "Muval", + "logo": "/static/organisations/logos/7fd35fff4486940fad1d839147df6ad1.png", + "id": "1045240", + "websiteURL": "www.Muval.com.au", + "contact": "1300168825" + }, + "1045241": { + "name": "Rent.com.au", + "logo": "/static/organisations/logos/4f8b98e5c6cdbeb2b52d99b9f0d82de3.png", + "id": "1045241", + "websiteURL": "www.rent.com.au", + "contact": "1300 736 810" + }, + "1045242": { + "name": "Energy Deal ", + "logo": "/static/organisations/logos/718a462f86c7b5da0e3f0fa5235e5ff3.png", + "id": 1045242, + "websiteURL": "www.energydeal.com.au/", + "contact": "1300368886" + }, + "1045243": { + "name": "Compare Club", + "logo": "/static/organisations/logos/138167fd1bffb1467493890b3bd01a05.png", + "comments": "Created on 03/05/2024\nRequested by Korah Kurian - Origin", + "id": 1045243, + "websiteURL": "compareclub.com.au/ ", + "contact": "1300836816 " + }, + "1045244": { + "name": "Virtual Watt", + "logo": "/static/organisations/logos/3fc0377b33065a2ad11644d416c319c1.png", + "comments": "Created on 31/07/2024", + "id": 1045244, + "websiteURL": "www.virtualwatt.com.au", + "contact": "1300 665 199" + }, + "1045245": { + "name": "Mindlabz", + "logo": "/static/organisations/logos/0f0d22ba3300abf5918090b30042eed9.png", + "id": 1045245, + "websiteURL": "mindlabz.com.au", + "contact": "0386959970" + }, + "1045246": { + "name": "One Click Switch", + "logo": "/static/organisations/logos/9be29a28c8fe86fa22fb9190b1e034e0.png", + "id": 1045246, + "websiteURL": "oneclickswitch.com.au", + "contact": "1300 661 464" + }, + "1045247": { + "name": "Chameleon", + "logo": "/static/organisations/logos/e4e0d241bedd434bfadfb11578cadf7d.png", + "comments": "Created on 06/05/2025", + "id": 1045247, + "websiteURL": "www.chameleoncustomercontact.com.au", + "contact": "0393293990" + }, + "1045248": { + "name": "Awaken Energy", + "logo": "/static/organisations/logos/5f96e2692a2ed3162fb32673d1d20df9.png", + "comments": "Created on 12/05/2025", + "id": 1045248, + "websiteURL": "www.awakenenergy.com.au", + "contact": "0483909329" + }, + "1045249": { + "name": "Zembl", + "logo": "/static/organisations/logos/1555e63d4167e3a3cd9e002cbef8ca83.png", + "comments": "Created on 16/05/2025", + "id": 1045249, + "websiteURL": "www.zembl.com.au/", + "contact": "1300957721" + }, + "1045250": { + "name": "Comparable", + "logo": "/static/organisations/logos/2ab26c9d27d04a2a9c3f208219970582.png", + "id": 1045250, + "comments": "Requested by Anju Angelin on 31.10.25\nActioned by WL 11.11.25", + "websiteURL": "www.comparable.com.au", + "contact": "1300 754 155" + }, + "1045251": { + "name": "Cable Energy", + "logo": "/static/organisations/logos/7ef0abcbd45dbd077bda9279f4ea8515.png", + "id": 1045251, + "websiteURL": "www.cable.energy", + "contact": "02 7908 5746" + } + }, + "organisations": { + "1559": { + "tradingName": "Ergon Energy Queensland Pty Ltd", + "orgName": "Ergon Energy", + "cdrCode": "ergon", + "smallBusinessContact": "1300 135 210", + "abn": "11 121 177 802", + "orgId": "1559", + "orgStatus": "active", + "cdrBrand": "ergon", + "websiteURL": "www.ergon.com.au", + "electricityBillURL": "www.ergon.com.au/retail/residential/billing-and-payments/understanding-your-bill", + "logo": "/static/organisations/logos/04406045549ba2ea3773d3ea0ef06f89.png", + "retailerCode": "ERG", + "residentialContact": "13 10 46" + }, + "1560": { + "orgStatus": "inactive", + "tradingName": "People Energy Pty Ltd", + "orgName": "People Energy", + "cdrBrand": "people-energy", + "websiteURL": "www.peopleenergy.com.au", + "logo": "/static/organisations/logos/people_energy.png", + "cdrCode": "people-energy", + "retailerCode": "PEO", + "smallBusinessContact": "1300 780 025", + "residentialContact": "1300 788 970", + "abn": "20 159 727 401", + "orgId": "1560" + }, + "9611": { + "tradingName": "CovaU Pty Ltd", + "gasBillURL": null, + "orgName": "CovaU", + "cdrCode": "covau", + "smallBusinessContact": "1300 689 866", + "abn": "54 090 117 730", + "orgId": "9611", + "orgStatus": "active", + "cdrBrand": "covau", + "websiteURL": "signup.covau.com.au/", + "electricityBillURL": "covau.com.au/eme/", + "logo": "/static/organisations/logos/cova_u.png", + "retailerCode": "COV", + "residentialContact": "1300 689 866" + }, + "9612": { + "orgStatus": "active", + "tradingName": "Next Business Energy Pty Ltd", + "orgName": "Next Business Energy", + "cdrBrand": "next-business", + "websiteURL": "www.nextbusinessenergy.com.au", + "logo": "/static/organisations/logos/01007813b3482f5bbb2e8b38736df0bc.png", + "cdrCode": "next-business", + "retailerCode": "NEX", + "smallBusinessContact": "1300 208 966", + "residentialContact": "1300 208 966", + "abn": "91 167 937 555", + "orgId": "9612" + }, + "9616": { + "tradingName": "Blue NRG Pty Ltd", + "gasBillURL": null, + "orgName": "Blue NRG", + "cdrCode": "blue-nrg", + "smallBusinessContact": "1300 599 888", + "abn": "30 151 014 658", + "orgId": "9616", + "orgStatus": "active", + "cdrBrand": "blue-nrg", + "websiteURL": "www.bluenrg.com.au", + "electricityBillURL": "www.bluenrg.com.au/uploaded/How%20to%20read%20my%20bill/Bill%20explainer_Final_uploaded.pdf", + "logo": "/static/organisations/logos/blue_nrg.png", + "retailerCode": "BLU", + "residentialContact": "1300 599 888" + }, + "9617": { + "orgStatus": "active", + "tradingName": "Pacific Blue Retail Pty Ltd", + "orgName": "Tango Energy", + "cdrBrand": "tango", + "websiteURL": "www.tangoenergy.com", + "logo": "/static/organisations/logos/4a9a1b580f5892c7ca3d1b77c2026835.png", + "cdrCode": "tango", + "retailerCode": "TAN", + "smallBusinessContact": "1800 861 952", + "residentialContact": "1800 861 952", + "abn": "43 155 908 839", + "orgId": "9617" + }, + "9618": { + "tradingName": "Commander Power & Gas (M2 Energy Pty Ltd)", + "gasBillURL": "www.commander.com.au/sites/default/files/2018-12/cmdrcommander_power_gas_bill_explainer05032015.pdf", + "orgName": "Commander Power & Gas", + "cdrCode": "commander", + "smallBusinessContact": "13 12 01", + "abn": "15 123 155 840", + "orgId": "9618", + "orgStatus": "active", + "cdrBrand": "commander", + "websiteURL": "www.commander.com.au", + "electricityBillURL": "www.commander.com.au/sites/default/files/2018-12/cmdrcommander_power_gas_bill_explainer05032015.pdf", + "logo": "/static/organisations/logos/commander.png", + "retailerCode": "M2E", + "residentialContact": "13 12 01" + }, + "9619": { + "tradingName": "Click Energy Pty Ltd", + "orgName": "Click Energy", + "cdrCode": "click-energy", + "smallBusinessContact": "1800 775 929", + "abn": "41 116 567 492", + "orgId": "9619", + "orgStatus": "inactive", + "cdrBrand": "click-energy", + "websiteURL": "www.clickenergy.com.au", + "electricityBillURL": "www.clickenergy.com.au/bill-info/know-your-bill/", + "logo": "/static/organisations/logos/click_energy.png", + "retailerCode": "CLI", + "residentialContact": "1800 775 929" + }, + "9620": { + "tradingName": "AGL Retail Energy Limited", + "gasBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_gas_digital.pdf", + "orgName": "AGL", + "cdrCode": "agl", + "smallBusinessContact": "13 12 45", + "abn": "21 074 839 464", + "orgId": "9620", + "orgStatus": "active", + "cdrBrand": "agl", + "websiteURL": "www.agl.com.au/emecompare", + "electricityBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_elec_digital.pdf", + "logo": "/static/organisations/logos/agl.png", + "retailerCode": "AGL", + "residentialContact": "13 12 45" + }, + "9621": { + "tradingName": "AGL Sales Pty Limited", + "gasBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_gas_digital.pdf", + "orgName": "AGL", + "cdrCode": "agl", + "smallBusinessContact": "13 12 45", + "abn": "88 090 538 337", + "orgId": "9621", + "orgStatus": "inactive", + "cdrBrand": "agl", + "websiteURL": "www.agl.com.au/emecompare", + "electricityBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_elec_digital.pdf", + "logo": "/static/organisations/logos/agl.png", + "retailerCode": "AGL", + "residentialContact": "13 12 45" + }, + "9623": { + "tradingName": "ERM Power Retail Pty Ltd", + "orgName": "ERM Power", + "cdrCode": "erm-power", + "smallBusinessContact": "13 23 76", + "abn": "87 126 175 460", + "orgId": "9623", + "orgStatus": "active", + "cdrBrand": "erm-power", + "websiteURL": "www.ermpower.com.au", + "electricityBillURL": "ermpower.com.au/bill-explainer/", + "logo": "/static/organisations/logos/erm.png", + "retailerCode": "ERM", + "residentialContact": "13 23 76" + }, + "9624": { + "tradingName": "Alinta Energy Retail Sales Pty Ltd", + "gasBillURL": "www.alintaenergy.com.au/help-and-support/help-and-support/billing-and-pricing/how-to-read-your-bill", + "orgName": "Alinta Energy", + "cdrCode": "alinta", + "smallBusinessContact": "13 39 08", + "abn": "22 149 658 300", + "orgId": "9624", + "orgStatus": "active", + "cdrBrand": "alinta", + "websiteURL": "alintaenergy.com.au", + "electricityBillURL": "www.alintaenergy.com.au/help-and-support/help-and-support/billing-and-pricing/how-to-read-your-bill", + "logo": "/static/organisations/logos/alinta.png", + "retailerCode": "ALI", + "residentialContact": "13 37 02" + }, + "9625": { + "orgStatus": "active", + "tradingName": "Powershop Australia Pty Ltd", + "orgName": "Powershop", + "cdrBrand": "powershop", + "websiteURL": "www.powershop.com.au", + "logo": "/static/organisations/logos/435e86067932be52e28abb3fbe0b6e82.png", + "cdrCode": "powershop", + "retailerCode": "PSH", + "smallBusinessContact": "1800 462 668", + "residentialContact": "1800 462 668", + "abn": "41 154 914 075", + "orgId": "9625" + }, + "9626": { + "orgStatus": "inactive", + "tradingName": "QEnergy", + "orgName": "QEnergy", + "cdrBrand": "qenergy", + "websiteURL": "www.qenergy.com.au", + "logo": "/static/organisations/logos/qenergy.png", + "cdrCode": "qenergy", + "retailerCode": "QEN", + "smallBusinessContact": "1300 448 535", + "residentialContact": "1300 448 535", + "abn": "58 120 124 101", + "orgId": "9626" + }, + "9627": { + "tradingName": "Sanctuary Energy Pty Ltd", + "gasBillURL": null, + "orgName": "Sanctuary Energy", + "cdrCode": "sanctuary", + "smallBusinessContact": "1800 109 099", + "abn": "62 128 995 433", + "orgId": "9627", + "orgStatus": "inactive", + "cdrBrand": "sanctuary", + "websiteURL": "sanctuaryenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/sanctuary.png", + "retailerCode": "SAN", + "residentialContact": "1800 109 099" + }, + "9630": { + "tradingName": "ActewAGL Retail", + "gasBillURL": "www.actewagl.com.au/save-energy/understand-your-usage/how-to-read-your-bill.aspx", + "orgName": "ActewAGL", + "cdrCode": "actewagl", + "smallBusinessContact": "13 14 93", + "abn": "46 221 314 841", + "orgId": "9630", + "orgStatus": "active", + "cdrBrand": "actewagl", + "websiteURL": "www.actewagl.com.au", + "electricityBillURL": "www.actewagl.com.au/save-energy/understand-your-usage/how-to-read-your-bill.aspx", + "logo": "/static/organisations/logos/6611c87938a7a1be03ee55314ba225d3.png", + "retailerCode": "ACT", + "residentialContact": "13 14 93" + }, + "9631": { + "orgStatus": "active", + "tradingName": "Aurora Energy", + "orgName": "Aurora Energy", + "cdrBrand": "aurora ", + "websiteURL": "www.auroraenergy.com.au", + "logo": "/static/organisations/logos/e9bfabdf2fc18919eb7f0709bcc8af31.png", + "cdrCode": "aurora", + "retailerCode": "AUR", + "smallBusinessContact": "1300 132 003", + "residentialContact": "1300 132 003", + "abn": "85 082 464 622", + "orgId": "9631" + }, + "9632": { + "tradingName": "Momentum Energy Pty Ltd", + "gasBillURL": "www.momentumenergy.com.au/docs/default-source/default-document-library/gas-bill-explainer.pdf", + "orgName": "Momentum Energy", + "cdrCode": "momentum", + "smallBusinessContact": "1800 794 824", + "abn": "42 100 569 159", + "orgId": "9632", + "orgStatus": "active", + "cdrBrand": "momentum", + "websiteURL": "www.momentumenergy.com.au", + "electricityBillURL": "www.momentumenergy.com.au/docs/default-source/default-document-library/residential-electricity-bill-guide.pdf", + "logo": "/static/organisations/logos/momentum.png", + "retailerCode": "MOM", + "residentialContact": "1800 794 824" + }, + "9633": { + "tradingName": "Diamond Energy Pty Ltd", + "orgName": "Diamond Energy", + "cdrCode": "diamond", + "smallBusinessContact": "1300 838 009", + "abn": "97 107 516 334", + "orgId": "9633", + "orgStatus": "active", + "cdrBrand": "diamond", + "websiteURL": "www.diamondenergy.com.au", + "electricityBillURL": "diamondstaging.wpengine.com/wp-content/uploads/2014/11/How-to-Read-Your-Diamond-Energy-Bill.pdf", + "logo": "/static/organisations/logos/diamond.jpg", + "retailerCode": "DIA", + "residentialContact": "1300 838 009" + }, + "9634": { + "tradingName": "Red Energy Pty Ltd", + "gasBillURL": "www.redenergy.com.au/docs/Red-Energy-Quarterly-Bill-Explained.pdf", + "orgName": "Red Energy", + "cdrCode": "red-energy", + "smallBusinessContact": "13 18 06", + "abn": "60 107 479 372", + "orgId": "9634", + "orgStatus": "active", + "cdrBrand": "red-energy", + "websiteURL": "www.redenergy.com.au", + "electricityBillURL": "www.redenergy.com.au/docs/Red-Energy-Quarterly-Bill-Explained.pdf", + "logo": "/static/organisations/logos/red_energy.png", + "retailerCode": "RED", + "residentialContact": "13 18 06" + }, + "9635": { + "tradingName": "Simply Energy", + "gasBillURL": "www.simplyenergy.com.au/help-centre/billing-and-payment/how-to-read-my-bill", + "orgName": "Simply Energy", + "cdrCode": "simply-energy", + "smallBusinessContact": "1800 009 147", + "abn": "67 269 241 237", + "orgId": "9635", + "orgStatus": "active", + "cdrBrand": "simply-energy", + "websiteURL": "www.simplyenergy.com.au", + "electricityBillURL": "www.simplyenergy.com.au/help-centre/billing-and-payment/how-to-read-my-bill", + "logo": "/static/organisations/logos/c2c300f383369e531a45e25986c84641.png", + "retailerCode": "SIM", + "residentialContact": "1800 009 147" + }, + "9637": { + "tradingName": "EnergyAustralia Pty Ltd", + "gasBillURL": "www.energyaustralia.com.au/home/bills-and-accounts/understand-your-bill/bill-guides", + "orgName": "EnergyAustralia", + "cdrCode": "energyaustralia", + "smallBusinessContact": "1800 146 749", + "abn": "99 086 014 968", + "orgId": "9637", + "orgStatus": "active", + "cdrBrand": "energyaustralia", + "websiteURL": "www.energyaustralia.com.au", + "electricityBillURL": "www.energyaustralia.com.au/home/bills-and-accounts/understand-your-bill/bill-guides", + "logo": "/static/organisations/logos/energy_australia.png", + "retailerCode": "ENE", + "residentialContact": "13 34 66" + }, + "9638": { + "orgStatus": "active", + "tradingName": "M2 Energy Pty Ltd", + "orgName": "Dodo", + "cdrBrand": "dodo", + "websiteURL": "www.dodo.com/energy", + "logo": "/static/organisations/logos/dodo.png", + "cdrCode": "dodo", + "retailerCode": "DOD", + "smallBusinessContact": "13 36 36", + "residentialContact": "13 36 36", + "abn": "15 123 155 840", + "orgId": "9638" + }, + "9639": { + "tradingName": "Origin Energy Electricity", + "gasBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "orgName": "Origin Energy", + "cdrCode": "origin", + "smallBusinessContact": "13 24 61", + "abn": "33 071 052 287", + "orgId": "9639", + "orgStatus": "active", + "cdrBrand": "origin", + "websiteURL": "www.originenergy.com.au", + "electricityBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "logo": "/static/organisations/logos/068a3484995b2d5a09c0708a68051c14.png", + "retailerCode": "ORI", + "residentialContact": "13 24 61" + }, + "9643": { + "tradingName": "Lumo Energy (SA) Pty Ltd", + "gasBillURL": "lumoenergy.com.au/understandingyourbill", + "orgName": "Lumo Energy (SA)", + "cdrCode": "lumo", + "smallBusinessContact": "1300 115 866", + "abn": "61 114 356 697", + "orgId": "9643", + "orgStatus": "active", + "cdrBrand": "lumo", + "websiteURL": "www.lumoenergy.com.au", + "electricityBillURL": "lumoenergy.com.au/understandingyourbill", + "logo": "/static/organisations/logos/lumo.png", + "retailerCode": "LUM", + "residentialContact": "1300 115 866" + }, + "9644": { + "orgStatus": "inactive", + "tradingName": "Powerdirect Pty Ltd", + "orgName": "Powerdirect", + "cdrBrand": "powerdirect", + "websiteURL": "www.powerdirect.com.au", + "logo": "/static/organisations/logos/power_direct.png", + "cdrCode": "powerdirect", + "retailerCode": "POW", + "smallBusinessContact": "1300 307 966", + "residentialContact": "1300 307 966", + "abn": "28 067 609 803", + "orgId": "9644" + }, + "9645": { + "tradingName": "AGL", + "gasBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_gas_digital.pdf", + "orgName": "AGL", + "cdrCode": "agl", + "smallBusinessContact": "13 12 45", + "abn": "74 115 061 375", + "orgId": "9645", + "orgStatus": "inactive", + "cdrBrand": "agl", + "websiteURL": "www.agl.com.au/emecompare", + "electricityBillURL": "www.agl.com.au/-/media/aglmedia/documents/help/invoice-explainer/agl0400-bill_explainer_resi_elec_digital.pdf", + "logo": "/static/organisations/logos/agl.png", + "retailerCode": "AGL", + "residentialContact": "13 12 45" + }, + "9646": { + "tradingName": "Lumo Energy (QLD) Pty Ltd", + "gasBillURL": "lumoenergy.com.au/understandingyourbill", + "orgName": "Lumo Energy (QLD)", + "cdrCode": "lumo", + "smallBusinessContact": "1300 115 866", + "abn": "63 114 356 642", + "orgId": "9646", + "orgStatus": "active", + "cdrBrand": "lumo", + "websiteURL": "www.lumoenergy.com.au", + "electricityBillURL": "lumoenergy.com.au/understandingyourbill", + "logo": "/static/organisations/logos/lumo.png", + "retailerCode": "LUM", + "residentialContact": "1300 115 866" + }, + "9648": { + "tradingName": "Origin Energy Retail Limited", + "gasBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "orgName": "Origin Energy", + "cdrCode": "origin", + "smallBusinessContact": "13 24 61", + "abn": "22 078 868 425", + "orgId": "9648", + "orgStatus": "inactive", + "cdrBrand": "origin", + "websiteURL": "www.originenergy.com.au", + "electricityBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "logo": "/static/organisations/logos/41313628bc260364dc8803dcb3340de9.png", + "retailerCode": "ORI", + "residentialContact": "13 24 61" + }, + "9649": { + "tradingName": "Origin Energy LPG Limited", + "gasBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "orgName": "Origin Energy", + "cdrCode": "origin", + "smallBusinessContact": "13 24 61", + "abn": "77 000 508 369", + "orgId": "9649", + "orgStatus": "inactive", + "cdrBrand": "origin", + "websiteURL": "www.originenergy.com.au", + "electricityBillURL": "www.originenergy.com.au/for-home/electricity-and-gas/billing-payments/how-to-read-my-bill.html", + "logo": "/static/organisations/logos/6e8710de3bdff597d54198cc4b9d8797.png", + "retailerCode": "ORI", + "residentialContact": "13 24 61" + }, + "9650": { + "tradingName": "Lumo Energy (NSW) Pty Ltd", + "gasBillURL": "lumoenergy.com.au/understandingyourbill", + "orgName": "Lumo Energy (NSW)", + "cdrCode": "lumo", + "smallBusinessContact": "1300 115 866", + "abn": "92 121 155 011", + "orgId": "9650", + "orgStatus": "active", + "cdrBrand": "lumo", + "websiteURL": "www.lumoenergy.com.au", + "electricityBillURL": "lumoenergy.com.au/understandingyourbill", + "logo": "/static/organisations/logos/lumo.png", + "retailerCode": "LUM", + "residentialContact": "1300 115 866" + }, + "24186": { + "orgStatus": "inactive", + "tradingName": "Pooled Energy Pty Ltd", + "orgName": "Pooled Energy", + "cdrBrand": "pooled-energy", + "websiteURL": "www.pooledenergy.com", + "logo": "/static/organisations/logos/19969d6e234fd0189911de666767e427.png", + "cdrCode": "pooled-energy", + "retailerCode": "PLD", + "smallBusinessContact": "1300 364 703", + "residentialContact": "1300 364 703", + "abn": "31 163 873 078", + "orgId": "24186" + }, + "24187": { + "tradingName": "Bright Spark Power Pty Ltd", + "gasBillURL": null, + "orgName": "Bright Spark Power", + "cdrCode": "bright-spark", + "smallBusinessContact": "1300 010 277", + "abn": "54 622 864 984", + "orgId": "24187", + "orgStatus": "inactive", + "cdrBrand": "bright-spark", + "websiteURL": "www.brightsparkpower.com.au/eme", + "electricityBillURL": null, + "logo": "/static/organisations/logos/07e379b4a9b01c82e789aa00d4f1daf4.png", + "retailerCode": "BSP", + "residentialContact": "1300 010 277" + }, + "24188": { + "tradingName": "Humenergy Group Pty Ltd", + "gasBillURL": null, + "orgName": "Humenergy Group", + "cdrCode": "humenergy", + "smallBusinessContact": "1300 322 622", + "abn": "15 601 324 387", + "orgId": "24188", + "orgStatus": "active", + "cdrBrand": "humenergy", + "websiteURL": "www.humenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/8d50b464df3c0f95b4837906f3102842.png", + "retailerCode": "HUM", + "residentialContact": "1300 322 622" + }, + "24189": { + "tradingName": "Electricity in a Box Pty Ltd", + "gasBillURL": null, + "orgName": "Electricity in a Box", + "cdrCode": "electricity-in-a-box", + "smallBusinessContact": "1300 933 039", + "abn": "74140547226", + "orgId": "24189", + "orgStatus": "inactive", + "cdrBrand": "electricity-in-a-box", + "websiteURL": "electricityinabox.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/0082c19f38cbb2364509982eccfeb1d3.png", + "retailerCode": "BOX", + "residentialContact": "1300 933 039" + }, + "24190": { + "tradingName": "CleanCo Queensland Limited", + "gasBillURL": null, + "orgName": "CleanCo Queensland", + "cdrCode": "cleanco", + "smallBusinessContact": "07 3328 3740", + "abn": "85 628 008 159", + "orgId": "24190", + "orgStatus": "active", + "cdrBrand": "cleanco", + "websiteURL": "www.cleancoqld.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/510e8c51f58822e92227d28fc6ddac6c.png", + "retailerCode": "CCQ", + "residentialContact": "07 3328 3740" + }, + "24191": { + "tradingName": "Y.E.S. Energy (SA) Pty Ltd", + "gasBillURL": null, + "orgName": "YES Energy", + "cdrCode": "yes-energy", + "smallBusinessContact": "1300 777 937", + "abn": "22 627 706 594", + "orgId": "24191", + "orgStatus": "active", + "cdrBrand": "yes-energy", + "websiteURL": "www.yesenergy.net.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/be6f8a17ead25b8be74e876d83e5c53c.png", + "retailerCode": "YES", + "residentialContact": "1300 777 937" + }, + "24192": { + "tradingName": "Radian Holdings Pty Ltd", + "gasBillURL": null, + "orgName": "Radian Energy", + "cdrCode": "radian", + "smallBusinessContact": "1300 805 925", + "abn": "92 633 200 647", + "orgId": "24192", + "orgStatus": "active", + "cdrBrand": "radian", + "websiteURL": "radian.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/d3d3d70acacace49b7f04cc35bdcce75.png", + "retailerCode": "RAD", + "residentialContact": "1300 805 925" + }, + "24193": { + "tradingName": "Energy Services Management Pty Ltd", + "gasBillURL": null, + "orgName": "Glow Power", + "cdrCode": "glow-power", + "smallBusinessContact": "1300 092 572", + "abn": "95 619 512 935", + "orgId": "24193", + "orgStatus": "active", + "cdrBrand": "glow-power", + "websiteURL": "myglowpower.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/7ca0ac97d770e7b90b88b51aaed827ff.png", + "retailerCode": "ESM", + "residentialContact": "1300 092 572" + }, + "24194": { + "tradingName": "Social Energy Australia Pty Ltd", + "gasBillURL": null, + "orgName": "Social Energy", + "cdrCode": "social-energy", + "smallBusinessContact": "1300 322 059", + "abn": "75 631 510 042", + "orgId": "24194", + "orgStatus": "inactive", + "cdrBrand": "social-energy", + "websiteURL": "www.social.energy/australia", + "electricityBillURL": null, + "logo": "/static/organisations/logos/39e9c54ce81e4a7fcefb2a43891ade77.png", + "retailerCode": "SEA", + "residentialContact": "1300 322 059" + }, + "24195": { + "tradingName": "Altogether Group Pty Ltd", + "gasBillURL": null, + "orgName": "Altogether", + "cdrCode": "altogether", + "smallBusinessContact": "1300 806 806", + "abn": "28 136 272 298", + "orgId": "24195", + "orgStatus": "active", + "cdrBrand": "altogether", + "websiteURL": "www.altogethergroup.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/6323fd40edf62f74f5a9d5c5b6063d74.png", + "retailerCode": "ALT", + "residentialContact": "1300 806 806" + }, + "24196": { + "tradingName": "Shell Energy Retail Pty Ltd ", + "gasBillURL": null, + "orgName": "Shell Energy ", + "cdrCode": "shell-energy", + "smallBusinessContact": "13 23 76", + "abn": "87 126 175 460", + "orgId": "24196", + "orgStatus": "active", + "cdrBrand": "shell-energy", + "websiteURL": "www.shellenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/3193ce6ea2a6923ead7b75e5775725cc.png", + "retailerCode": "SHL", + "residentialContact": "13 23 76" + }, + "24197": { + "tradingName": "Smart Energy Retail Pty Ltd", + "gasBillURL": null, + "orgName": "Smart Energy ", + "cdrCode": "smart-energy", + "smallBusinessContact": "1300133055", + "abn": "49 639 060 405", + "orgId": "24197", + "orgStatus": "active", + "cdrBrand": "smart-energy", + "websiteURL": "www.smartenergygroup.com.au", + "electricityBillURL": "www.smartenergygroup.com.au/how-to-read-your-bill ", + "logo": "/static/organisations/logos/939334bc494d4e99ac8848644a45a066.png", + "retailerCode": "SEG", + "residentialContact": "1300133055" + }, + "24198": { + "tradingName": "Microgrid Power Pty Ltd", + "gasBillURL": null, + "orgName": "Microgrid Power", + "cdrCode": "microgrid", + "smallBusinessContact": "1300 647 888", + "abn": "93 628 991 131", + "orgId": "24198", + "orgStatus": "active", + "cdrBrand": "microgrid", + "websiteURL": "www.microgridpower.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/6a4f4c8e6b6ce4a275f4c611cd533913.png", + "retailerCode": "MGP", + "residentialContact": "1300 647 888" + }, + "24199": { + "tradingName": "Localvolts Pty Ltd", + "gasBillURL": null, + "orgName": "Localvolts ", + "cdrCode": "localvolts", + "smallBusinessContact": "02 8006 8052", + "abn": "12 609 840 379", + "orgId": "24199", + "orgStatus": "active", + "cdrBrand": "localvolts", + "websiteURL": "localvolts.com/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/cf8f859eacb53a5b56f3467a7813d6fe.png", + "retailerCode": "LVS", + "residentialContact": "02 8006 8052" + }, + "24200": { + "tradingName": "EnergyAustralia Pty Ltd", + "gasBillURL": null, + "orgName": "On by EnergyAustralia", + "cdrCode": "on-by-energyaustralia", + "smallBusinessContact": "1800 108 633", + "abn": "99 086 014 968", + "orgId": "24200", + "orgStatus": "inactive", + "cdrBrand": "on-by-energyaustralia", + "websiteURL": "www.experienceon.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/ed2e5106218d9e617216949b37e6e73f.png", + "retailerCode": "OEA", + "residentialContact": "1800 108 633" + }, + "24201": { + "tradingName": "Powershop Australia Pty Limited", + "gasBillURL": null, + "orgName": "Coles Energy", + "cdrCode": "coles", + "smallBusinessContact": "1300 265 375", + "abn": "41 154 914 075", + "orgId": "24201", + "orgStatus": "active", + "cdrBrand": "coles", + "websiteURL": "www.coles.com.au/energy", + "electricityBillURL": null, + "logo": "/static/organisations/logos/a89c1ff57030ee93211e9fba27e29cb3.png", + "retailerCode": "COL", + "residentialContact": "1300 265 375" + }, + "24202": { + "tradingName": "Energy Locals Pty Ltd", + "gasBillURL": null, + "orgName": "Sonnen", + "cdrCode": "sonnen", + "smallBusinessContact": "1300 693 637", + "abn": "23 606 408 879", + "orgId": "24202", + "orgStatus": "inactive", + "cdrBrand": "sonnen", + "websiteURL": "energylocals.com.au/sonnen", + "electricityBillURL": null, + "logo": "/static/organisations/logos/f24cf95912d90a60409282a147a4c2b2.png", + "retailerCode": "SON", + "residentialContact": "1300 693 637" + }, + "24203": { + "tradingName": "Ellis Air Connect Pty Ltd", + "gasBillURL": null, + "orgName": "SEAC Energy", + "cdrCode": "ea-connect", + "smallBusinessContact": "1300236906", + "abn": "640 563 248", + "orgId": "24203", + "orgStatus": "active", + "cdrBrand": "ea-connect", + "websiteURL": "seacenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/c549c1067f3f1be2ab953068fa95e9d4.png", + "retailerCode": "EAC", + "residentialContact": "1300236906" + }, + "24204": { + "tradingName": "GEE Power & Gas Pty Ltd ", + "gasBillURL": null, + "orgName": "GEE Energy ", + "cdrCode": "gee-energy", + "smallBusinessContact": "1300 707 042", + "abn": "42 636 908 220", + "orgId": "24204", + "orgStatus": "active", + "cdrBrand": "gee-energy", + "websiteURL": "gee.com.au", + "electricityBillURL": "workdrive.zohoexternal.com/external/56EDu0cO4R5-LQlHY", + "logo": "/static/organisations/logos/95b4d2ac177e0a88ee18a3f2b9a2f298.png", + "retailerCode": "GEE", + "residentialContact": "1300 707 042" + }, + "24205": { + "tradingName": "Brighte Energy Pty Ltd", + "gasBillURL": null, + "orgName": "Brighte Energy ", + "cdrCode": "brighte", + "smallBusinessContact": "1300274448", + "abn": "36 646 449 247", + "orgId": "24205", + "orgStatus": "active", + "cdrBrand": "brighte ", + "websiteURL": "brighte.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/278bfeac35840aa0ee0dfa49b8023379.png", + "retailerCode": "BRI", + "residentialContact": "1300274448" + }, + "24206": { + "tradingName": "Maximum Energy Retail Pty Ltd", + "gasBillURL": null, + "orgName": "Circular Energy", + "cdrCode": "circular", + "smallBusinessContact": "1300 204 462", + "abn": "90 632 900 139", + "orgId": "24206", + "orgStatus": "inactive", + "cdrBrand": "circular ", + "websiteURL": "www.thepeoplesgrid.com/collectives/The-SA-Peoples-Grid", + "electricityBillURL": null, + "logo": "/static/organisations/logos/fd82878b16bd34f4c2d2e4f8eb233680.png", + "retailerCode": "CIR", + "residentialContact": "1300 204 462" + }, + "24207": { + "tradingName": "Telstra Energy (Retail) Pty Ltd", + "gasBillURL": "www.telstra.com/electricity-and-gas/billing-and-payments/read-your-bill", + "orgName": "Telstra Energy", + "cdrCode": "telstra-energy", + "smallBusinessContact": "13 22 00", + "abn": "23 645 100 447", + "orgId": "24207", + "orgStatus": "active", + "cdrBrand": "telstra-energy", + "websiteURL": "Telstra.com", + "electricityBillURL": "www.telstra.com/electricity-and-gas/billing-and-payments/read-your-bill", + "logo": "/static/organisations/logos/d318cecab0b910697a5fe7f5c6e8c6a3.png", + "retailerCode": "TLS", + "residentialContact": "13 22 00" + }, + "24208": { + "tradingName": "EPC Technologies Pty Ltd", + "gasBillURL": null, + "orgName": "Besy", + "cdrCode": "besy", + "smallBusinessContact": "00000000", + "abn": "64 612 341 849", + "orgId": "24208", + "orgStatus": "active", + "cdrBrand": "besy", + "websiteURL": "besy.energy", + "electricityBillURL": null, + "logo": "/static/organisations/logos/79a78f730f64c2eab1fb9c9064a7c22c.png", + "retailerCode": "BES", + "residentialContact": "00000000" + }, + "24209": { + "tradingName": "ZEN Energy Retail Pty Ltd", + "gasBillURL": null, + "orgName": "ZEN Energy ", + "cdrCode": "zen-energy", + "smallBusinessContact": "1300 936 466", + "abn": "54615751052", + "orgId": "24209", + "orgStatus": "active", + "cdrBrand": "zen-energy", + "websiteURL": "www.zenenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/1fc3b6168abbd718eab34718a4faac54.png", + "retailerCode": "ZEN", + "residentialContact": "1300 936 466" + }, + "24210": { + "tradingName": "PowerHub Pty Ltd", + "gasBillURL": null, + "orgName": "PowerHub", + "cdrCode": "powerhub", + "smallBusinessContact": "1300196673", + "abn": "27618362888", + "orgId": "24210", + "orgStatus": "active", + "cdrBrand": "powerhub", + "websiteURL": "www.powerhub.net.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/92e99e4f5476201689124f90239d8397.png", + "retailerCode": "HUB", + "residentialContact": "1300196673" + }, + "24211": { + "tradingName": "Electricity in a Box Pty Ltd", + "gasBillURL": null, + "orgName": "Arcstream", + "cdrCode": "arcstream", + "smallBusinessContact": "1800170555", + "abn": "84 141 108590", + "orgId": "24211", + "orgStatus": "active", + "cdrBrand": "arcstream", + "websiteURL": "arcstream.solutions/energy-made-easy/?utm_source=energy+made+easy&utm_medium=referral&utm_campaign=dec-22&utm_id=Energy+Made+Easy", + "electricityBillURL": null, + "logo": "/static/organisations/logos/1c6c90d1b567cfb1109697663889577b.png", + "retailerCode": "AST", + "residentialContact": "1800170555" + }, + "24212": { + "tradingName": "iGENO Pty Limited", + "gasBillURL": null, + "orgName": "iGENO", + "cdrCode": "igeno", + "smallBusinessContact": "1300989689", + "abn": "17080675485", + "orgId": "24212", + "orgStatus": "active", + "cdrBrand": "igeno", + "websiteURL": "igeno.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/93991c39a20e5240af4d607533308377.png", + "retailerCode": "IGN", + "residentialContact": "1300989689" + }, + "24213": { + "tradingName": "Ampol Energy (Retail) Pty Ltd", + "gasBillURL": null, + "orgName": "Ampol Energy", + "cdrCode": "ampol", + "smallBusinessContact": "131404", + "abn": "21652913347", + "orgId": "24213", + "orgStatus": "active", + "cdrBrand": "ampol ", + "websiteURL": "ampolenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/1f4cf2cf0bfad2bb4395dc39c40e94b8.png", + "retailerCode": "AMP", + "residentialContact": "131404" + }, + "24214": { + "tradingName": "Powow Power Pty Ltd", + "gasBillURL": null, + "orgName": "Powow Power ", + "cdrCode": "powow", + "smallBusinessContact": "1800 401 421", + "abn": "39 644 212 322", + "orgId": "24214", + "orgStatus": "active", + "cdrBrand": "powow", + "websiteURL": "powowpower.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/d9a5cd1c90b50b6ef3a36d213a574245.png", + "retailerCode": "PWP", + "residentialContact": "1800 401 421" + }, + "24215": { + "tradingName": "OVO Energy Pty Ltd", + "gasBillURL": null, + "orgName": "OVO Energy for ComparetheMarket", + "cdrCode": "ovo-energy", + "smallBusinessContact": "1800 467 698", + "abn": "99 623 475 089", + "orgId": "24215", + "orgStatus": "active", + "cdrBrand": "ovo-energy-ctm", + "websiteURL": "www.comparethemarket.com.au/energy/journey/start", + "electricityBillURL": null, + "logo": "/static/organisations/logos/ba5872c5cf89f79b9ab14f19cb2d8e72.png", + "retailerCode": "OVC", + "residentialContact": "1800 467 698" + }, + "24216": { + "tradingName": "Progressive Green Pty Ltd", + "gasBillURL": null, + "orgName": "Flow Power", + "cdrCode": "flow-power", + "smallBusinessContact": "1800 001 240", + "abn": "27130175343", + "orgId": "24216", + "orgStatus": "active", + "cdrBrand": "flow-power", + "websiteURL": "flowpower.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/a2e3b81a479f4c3ea9434600700a3b67.png", + "retailerCode": "FP1", + "residentialContact": "1800 001 240" + }, + "24217": { + "tradingName": "Pacific Blue Retail Pty Ltd", + "gasBillURL": null, + "orgName": "Pacific Blue Retail", + "cdrCode": "pacific-blue", + "smallBusinessContact": "133 669", + "abn": "43 155 908 839", + "orgId": "24217", + "orgStatus": "active", + "cdrBrand": "pacific-blue", + "websiteURL": "www.pacificblue.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/13771f1d9fad4a0f17c6d95eab8f82a8.png", + "retailerCode": "PAC", + "residentialContact": "133 669" + }, + "25000": { + "tradingName": "Energy Locals Pty Ltd", + "gasBillURL": null, + "orgName": "iO Energy Retail Services", + "cdrCode": "io-energy", + "smallBusinessContact": "1300 313 463", + "abn": "23 606 408 879", + "orgId": "25000", + "orgStatus": "inactive", + "cdrBrand": "io-energy", + "websiteURL": "ioenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/6b0b83e2b11787bca329dae1eeb49f62.png", + "retailerCode": "IOE", + "residentialContact": "1300 313 463" + }, + "25001": { + "tradingName": "IPower Pty Ltd and IPower 2 Pty Ltd", + "gasBillURL": "www.engie.com.au/help-centre/billing-and-payment/how-to-read-my-bill", + "orgName": "ENGIE", + "cdrCode": "engie", + "smallBusinessContact": "1800090836", + "abn": "67269241237", + "orgId": "25001", + "orgStatus": "active", + "cdrBrand": "engie", + "websiteURL": "www.engie.com.au/residential/electricity-and-gas-plans/compare-electricity-and-gas-plans?utm_source=Energy+Made+Easy&utm_medium=referral", + "electricityBillURL": "www.engie.com.au/help-centre/billing-and-payment/how-to-read-my-bill", + "logo": "/static/organisations/logos/e9a389558bc196629d27a0ade1772676.png", + "retailerCode": "ENG", + "residentialContact": "1300067348" + }, + "25002": { + "tradingName": "Flipped Energy Australia Pty Ltd", + "gasBillURL": null, + "orgName": "Flipped Energy", + "cdrCode": "flipped", + "smallBusinessContact": "1300 110 100", + "abn": "73 653 445 740", + "orgId": "25002", + "orgStatus": "active", + "cdrBrand": "flipped", + "websiteURL": "www.flipped.energy", + "electricityBillURL": null, + "logo": "/static/organisations/logos/438ff02d87cec3f985c465552312d2e1.png", + "retailerCode": "FEA", + "residentialContact": "1300 110 100" + }, + "25003": { + "tradingName": "EL Retail Energy Pty Ltd ", + "gasBillURL": null, + "orgName": "Arcline by RACV", + "cdrCode": "arcline", + "smallBusinessContact": "1300 884 849", + "abn": "23 606 408 879", + "orgId": "25003", + "orgStatus": "active", + "cdrBrand": "arcline", + "websiteURL": "energy.arcline.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/cc1f9d42109cdfd0dbf841a340f2a127.png", + "retailerCode": "ARL", + "residentialContact": "1300 884 849" + }, + "25004": { + "tradingName": "Lumo Energy Australia Pty Ltd", + "gasBillURL": null, + "orgName": "Lumo Energy", + "cdrCode": "lumo", + "smallBusinessContact": "1300 360 434", + "abn": "69 100 528 327", + "orgId": "25004", + "orgStatus": "active", + "cdrBrand": "lumo", + "websiteURL": "www.lumoenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/0b0dc529ab604c5888c14da15cc16ff7.png", + "retailerCode": "LU2", + "residentialContact": "1300 115 866" + }, + "25005": { + "tradingName": "SmartestEnergy Australia Pty Ltd", + "gasBillURL": null, + "orgName": "SmartestEnergy", + "cdrCode": "smartestenergy", + "smallBusinessContact": "1300 176 031", + "abn": "37 632 313 029", + "orgId": "25005", + "orgStatus": "inactive", + "cdrBrand": "smartestEnergy", + "websiteURL": "www.smartestenergy.com/en_au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/116d93f30044cfc65bbe86a25626bf0a.png", + "retailerCode": "SMA", + "residentialContact": "1300 176 031" + }, + "25006": { + "tradingName": "Sumo Gas Pty Ltd", + "gasBillURL": null, + "orgName": "Sumo", + "cdrCode": "sumo-gas", + "smallBusinessContact": "13 88 60", + "abn": "67 606 951 713", + "orgId": "25006", + "orgStatus": "inactive", + "cdrBrand": "sumo-gas", + "websiteURL": "www.sumo.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/57aa1c7550dbea0ef05aaba3f105d69c.png", + "retailerCode": "SUM", + "residentialContact": "13 88 60" + }, + "25007": { + "tradingName": "Online Power and Gas Pty Ltd", + "gasBillURL": null, + "orgName": "Sunswitch Energy Pty Ltd", + "cdrCode": "future-x", + "smallBusinessContact": "0387957091", + "abn": "12 655 918 871", + "orgId": "25007", + "orgStatus": "active", + "cdrBrand": "sunswitch", + "websiteURL": "sunswitchenergy.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/a1e89851ecf8301831c2c55089518007.png", + "retailerCode": "SUN", + "residentialContact": "0387957091" + }, + "25008": { + "tradingName": "Tesla Energy Ventures Australia Pty Ltd", + "gasBillURL": null, + "orgName": "Tesla Energy Ventures Australia", + "cdrCode": "tesla", + "smallBusinessContact": "02 8015 2834", + "abn": "24 665 982 365", + "orgId": "25008", + "orgStatus": "active", + "cdrBrand": "tesla", + "websiteURL": "www.tesla.com.au", + "electricityBillURL": "teslaenergy.com.au/how-to-read-your-bill", + "logo": "/static/organisations/logos/b5ebd982506da96c4d0db64bfead8e6c.png", + "retailerCode": "TVA", + "residentialContact": "02 8015 2834" + }, + "25009": { + "tradingName": "Energy Locals Pty Ltd", + "gasBillURL": null, + "orgName": "Indigo Power", + "cdrCode": "energy-locals", + "smallBusinessContact": "1800 491 739", + "abn": "23 606 408 879", + "orgId": "25009", + "orgStatus": "inactive", + "cdrBrand": "indigo", + "websiteURL": "www.indigopower.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/2b5b28aff0646cf034b41617c7a2add0.png", + "retailerCode": "IND", + "residentialContact": "1800 491 739" + }, + "25010": { + "tradingName": "EL Retail Energy Pty Ltd ", + "gasBillURL": null, + "orgName": "Cooperative Power", + "cdrCode": "energy-locals", + "smallBusinessContact": "1300 693 637", + "abn": "23606408879", + "orgId": "25010", + "orgStatus": "active", + "cdrBrand": "cooperative", + "websiteURL": "www.cooperativepower.org.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/7bcf81755bc27d9553d0d7d065124ce6.png", + "retailerCode": "COP", + "residentialContact": "1300 693 637" + }, + "25011": { + "tradingName": "MYOB powered by OVO Pty Ltd", + "gasBillURL": null, + "orgName": "MYOB powered by OVO", + "cdrCode": "ovo-energy", + "smallBusinessContact": "1800 467 698", + "abn": "99623475089", + "orgId": "25011", + "orgStatus": "active", + "cdrBrand": "myob", + "websiteURL": "www.comparethemarket.com.au/energy/journey/start", + "electricityBillURL": null, + "logo": "/static/organisations/logos/b8085989c8729c8c6bb2ebf6906678aa.png", + "retailerCode": "MYO", + "residentialContact": "1800 467 698" + }, + "25012": { + "tradingName": "Macarthur Energy Retail Pty Ltd", + "gasBillURL": null, + "orgName": "Macarthur Energy Retail", + "cdrCode": "macarthur", + "smallBusinessContact": "02 4606 3524", + "abn": "89643524921", + "orgId": "25012", + "orgStatus": "active", + "cdrBrand": "macarthur", + "websiteURL": "ww.macarthurenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/cec99be1c421ae486fb308b68f8b2fa5.png", + "retailerCode": "MCA", + "residentialContact": "02 4606 3524" + }, + "25013": { + "tradingName": "EL Retail Energy Pty Ltd ", + "gasBillURL": null, + "orgName": "RAA Energy", + "cdrCode": "energy-locals", + "smallBusinessContact": "08 8202 8118", + "abn": "90 020 001 807", + "orgId": "25013", + "orgStatus": "active", + "cdrBrand": "raa", + "websiteURL": "raa.com.au/energy", + "electricityBillURL": null, + "logo": "/static/organisations/logos/b039d127a4aeff412153c66494f2ed89.png", + "retailerCode": "RAA", + "residentialContact": "08 8202 8118" + }, + "25014": { + "tradingName": "Perpetual Energy Pty Ltd", + "gasBillURL": null, + "orgName": "Perpetual Energy", + "cdrCode": "perpetual", + "smallBusinessContact": "02 8077 8592", + "abn": "20 643 401 496", + "orgId": "25014", + "orgStatus": "active", + "cdrBrand": "perpetual", + "websiteURL": "perpetualenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/f4ae158e047663faaa3ce5893553cd33.png", + "retailerCode": "PER", + "residentialContact": "02 8077 8592" + }, + "25015": { + "tradingName": "Savant Energy Power Networks Pty Limited", + "gasBillURL": null, + "orgName": "Savant Energy", + "cdrCode": "savant", + "smallBusinessContact": "1300 587 623", + "abn": "31 604 736 638", + "orgId": "25015", + "orgStatus": "active", + "cdrBrand": "savant", + "websiteURL": "savantenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/e29e6529f6c6eb05c5b2ca255938937c.png", + "retailerCode": "SAV", + "residentialContact": "1300 587 623" + }, + "25016": { + "tradingName": "Veolia Energy (ANZ) Pty Ltd", + "gasBillURL": null, + "orgName": "Veolia Energy", + "cdrCode": "veolia", + "smallBusinessContact": "07 3212 6641", + "abn": "74 140 547 226", + "orgId": "25016", + "orgStatus": "active", + "cdrBrand": "veolia", + "websiteURL": "www.anz.veolia.com/energy-retail", + "electricityBillURL": null, + "logo": "/static/organisations/logos/7e8dde1540b66ff92227909e7165c559.png", + "retailerCode": "VEA", + "residentialContact": "07 3212 6641" + }, + "25017": { + "tradingName": "Ezi Power Pty Ltd", + "gasBillURL": null, + "orgName": "Ezi Power", + "cdrCode": "silver-asset", + "smallBusinessContact": "1300 972 702", + "abn": "11 631 775 105", + "orgId": "25017", + "orgStatus": "active", + "cdrBrand": "silver-asset", + "websiteURL": "silverasset.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/a8729139c8a1cf211627c90592449b46.png", + "retailerCode": "SLV", + "residentialContact": "1300 972 702" + }, + "25018": { + "tradingName": "Radian Holdings Pty Ltd", + "gasBillURL": null, + "orgName": "iO Energy", + "cdrCode": "radian", + "smallBusinessContact": "1300 313 463", + "abn": "94 633 200 656", + "orgId": "25018", + "orgStatus": "active", + "cdrBrand": "io-energy", + "websiteURL": "ioenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/e7aa7fceceb34995a6eb53c666162ba3.png", + "retailerCode": "IOR", + "residentialContact": "1300 313 463" + }, + "25019": { + "tradingName": "ERC Energy Pty Ltd", + "gasBillURL": null, + "orgName": "ERC Energy", + "cdrCode": "erc-energy", + "smallBusinessContact": "1300650849", + "abn": "93629720994", + "orgId": "25019", + "orgStatus": "active", + "cdrBrand": "erc-energy", + "websiteURL": "ercenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/05b1bac4159890222db6b2b5d9b91029.png", + "retailerCode": "ERC", + "residentialContact": "1300650849" + }, + "25020": { + "tradingName": "Energy Trade Pty Ltd", + "gasBillURL": null, + "orgName": "Energy Locals Urban", + "cdrCode": "energy-locals-urban", + "smallBusinessContact": "1300 001 255", + "abn": "79165688568", + "orgId": "25020", + "orgStatus": "active", + "cdrBrand": "energy-locals-urban", + "websiteURL": "energylocals.com.au/", + "electricityBillURL": "energylocals.com.au/urban-help-faqs/", + "logo": "/static/organisations/logos/627094e73c210df02fadab1ea9ebac5e.png", + "retailerCode": "ELU", + "residentialContact": "1300 001 255" + }, + "25021": { + "tradingName": "ASENO Pty Ltd", + "gasBillURL": null, + "orgName": "ASENO", + "cdrCode": "aseno", + "smallBusinessContact": "1300 027 366", + "abn": "62 660 232 664", + "orgId": "25021", + "orgStatus": "active", + "cdrBrand": "aseno", + "websiteURL": "www.aseno.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/d750f9dd2f6ce940f13061e2f5f44883.png", + "retailerCode": "ASE", + "residentialContact": "1300 027 366" + }, + "103820": { + "tradingName": "1st Energy Pty Ltd", + "gasBillURL": null, + "orgName": "1st Energy", + "cdrCode": "1st-energy", + "smallBusinessContact": "1300 426 594", + "abn": "71 604 999 706", + "orgId": "103820", + "orgStatus": "active", + "cdrBrand": "1st-energy", + "websiteURL": "www.1stenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/7d60dc912ecc8c32110796b21393b349.png", + "retailerCode": "1ST", + "residentialContact": "1300 426 594" + }, + "114571": { + "tradingName": "Mojo Power Pty Ltd", + "gasBillURL": null, + "orgName": "Mojo Power", + "cdrCode": "mojo", + "smallBusinessContact": "1300 019 649", + "abn": "85 604 909 837", + "orgId": "114571", + "orgStatus": "inactive", + "cdrBrand": "mojo", + "websiteURL": "www.mojopower.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/mojo.png", + "retailerCode": "MOJ", + "residentialContact": "1300 019 649" + }, + "152706": { + "orgStatus": "inactive", + "tradingName": "Enova Energy", + "orgName": "Enova Energy", + "cdrBrand": "enova", + "websiteURL": "www.enovaenergy.com.au", + "logo": "/static/organisations/logos/9f6bf6b8a38fdeb5ef425f7295339223.png", + "cdrCode": "enova", + "retailerCode": "ENO", + "smallBusinessContact": "02 5622 1700", + "residentialContact": "02 5622 1700", + "abn": "16 606 176 759", + "orgId": "152706" + }, + "201064": { + "orgStatus": "active", + "tradingName": "EL Retail Energy Pty Ltd", + "orgName": "Energy Locals Retail", + "cdrBrand": "energy-locals", + "websiteURL": "energylocalsretail.com.au", + "logo": "/static/organisations/logos/energy_locals.png", + "cdrCode": "energy-locals", + "retailerCode": "LCL", + "smallBusinessContact": "1300 869 573", + "residentialContact": "1300 869 573", + "abn": "23 606 408 879", + "orgId": "201064" + }, + "211603": { + "orgStatus": "active", + "tradingName": "WINconnect Pty Ltd", + "orgName": "WINconnect", + "cdrBrand": "winconnect", + "websiteURL": "www.winconnect.com.au", + "logo": "/static/organisations/logos/win_connect.png", + "cdrCode": "winconnect", + "retailerCode": "WIN", + "smallBusinessContact": "1300 791 970", + "residentialContact": "1300 791 970", + "abn": "71 112 175 710", + "orgId": "211603" + }, + "434028": { + "tradingName": "Click Energy Pty Ltd", + "orgName": "amaysim Energy", + "cdrCode": "amaysim", + "smallBusinessContact": "1300 808 300", + "abn": "41 116 567 492", + "orgId": "434028", + "orgStatus": "inactive", + "cdrBrand": "amaysim", + "websiteURL": "www.amaysim.com.au", + "electricityBillURL": "www.amaysim.com.au/help/account/energy/understand-bill", + "logo": "/static/organisations/logos/amaysim.png", + "retailerCode": "AMA", + "residentialContact": "1300 808 300" + }, + "456311": { + "orgStatus": "inactive", + "tradingName": "OC Energy Pty Ltd", + "orgName": "OC Energy", + "cdrBrand": "oc-energy", + "websiteURL": "www.ocenergy.com.au", + "logo": "/static/organisations/logos/oc_energy.png", + "cdrCode": "oc-energy", + "retailerCode": "OCE", + "smallBusinessContact": "1300 494 080", + "residentialContact": "1300 494 080", + "abn": "62 144 655 514", + "orgId": "456311" + }, + "539680": { + "orgStatus": "inactive", + "tradingName": "Macquarie Bank Limited", + "orgName": "Macquarie", + "cdrBrand": "macquarie", + "websiteURL": "www.macquarie.com/au/corporate", + "logo": "/static/organisations/logos/macquarie.jpg", + "cdrCode": "macquarie", + "retailerCode": "MAC", + "smallBusinessContact": "02 8232 3324", + "residentialContact": "02 8232 3324", + "abn": "46 008 583 542", + "orgId": "539680" + }, + "544846": { + "tradingName": "Sumo Power Pty Ltd", + "gasBillURL": "www.sumo.com.au/how-to-read-my-bill/", + "orgName": "Sumo", + "cdrCode": "sumo-power", + "smallBusinessContact": "13 88 60", + "abn": "86 601 199 151", + "orgId": "544846", + "orgStatus": "active", + "cdrBrand": "sumo-power", + "websiteURL": "www.sumo.com.au", + "electricityBillURL": "www.sumo.com.au/how-to-read-my-bill/", + "logo": "/static/organisations/logos/sumo.png", + "retailerCode": "SUM", + "residentialContact": "13 88 60" + }, + "555268": { + "orgStatus": "inactive", + "tradingName": "ReAmped Energy Pty Ltd", + "orgName": "ReAmped Energy", + "cdrBrand": "reamped", + "websiteURL": "www.reampedenergy.com.au/go/re-eme/", + "logo": "/static/organisations/logos/efcc2e0414d559815d5080b40d11ecd1.png", + "cdrCode": "reamped", + "retailerCode": "REA", + "smallBusinessContact": "1800 841 627", + "residentialContact": "1800 841 627", + "abn": "21 605 682 684", + "orgId": "555268" + }, + "562102": { + "tradingName": "Evergy Pty Ltd", + "gasBillURL": null, + "orgName": "Evergy", + "cdrCode": "evergy", + "smallBusinessContact": "1300 383 749", + "abn": "56 623 005 836", + "orgId": "562102", + "orgStatus": "active", + "cdrBrand": "evergy", + "websiteURL": "evergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/1e07d1e6eae2d98071ff87b922db926e.png", + "retailerCode": "EVE", + "residentialContact": "1300 383 749" + }, + "586942": { + "tradingName": "Discover Energy Pty Ltd", + "orgName": "Discover Energy", + "cdrCode": "discover", + "smallBusinessContact": "1300658519", + "abn": "20 619 204 750", + "orgId": "586942", + "orgStatus": "active", + "cdrBrand": "discover", + "websiteURL": "www.discoverenergy.com.au", + "electricityBillURL": "s3-ap-southeast-2.amazonaws.com/discover-energy/assets/pdf/understanding_your_bill.pdf", + "logo": "/static/organisations/logos/discover.png", + "retailerCode": "DEN", + "residentialContact": "1300658519" + }, + "665493": { + "tradingName": "CleanPeak Energy Retail Pty Ltd", + "gasBillURL": null, + "orgName": "CleanPeak Energy Retail", + "cdrCode": "cleanpeak", + "smallBusinessContact": "1300 038 069", + "abn": "18 623 916 138", + "orgId": "665493", + "orgStatus": "active", + "cdrBrand": "cleanpeak", + "websiteURL": "www.cleanpeakenergy.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/cleanpeak.png", + "retailerCode": "CPE", + "residentialContact": "1300 038 069" + }, + "686699": { + "orgStatus": "active", + "tradingName": "Real Utilities Pty Limited", + "orgName": "Real Utilities", + "cdrBrand": "real-utilities", + "websiteURL": "www.realutilities.com.au", + "logo": "/static/organisations/logos/real_utilities.png", + "cdrCode": "real-utilities", + "retailerCode": "REU", + "smallBusinessContact": "0300161668", + "residentialContact": "0300161668", + "abn": "97 150 290 814", + "orgId": "686699" + }, + "700390": { + "orgStatus": "inactive", + "tradingName": "Powershop Australia Pty Ltd", + "orgName": "DC Power Co", + "cdrBrand": "dc-power", + "websiteURL": "www.dcpowerco.com.au", + "logo": "/static/organisations/logos/dcpowerco.png", + "cdrCode": "dc-power", + "retailerCode": "DCP", + "smallBusinessContact": "1800 686 686", + "residentialContact": "1800 686 686", + "abn": "41 154 914 075", + "orgId": "700390" + }, + "711461": { + "tradingName": "LPE", + "orgName": "Locality Planning Energy", + "cdrCode": "locality-planning", + "smallBusinessContact": "1800 040 168", + "abn": "90 147 867 301", + "orgId": "711461", + "orgStatus": "active", + "cdrBrand": "locality-planning", + "websiteURL": "www.localityenergy.com.au/", + "electricityBillURL": "localityenergy.com.au/how-to-read-your-bill-1", + "logo": "/static/organisations/logos/55de99f8e820b3d8db3de814e5b0da6c.png", + "retailerCode": "LPE", + "residentialContact": "1800 040 168" + }, + "714020": { + "orgStatus": "active", + "tradingName": "The Embedded Networks Company Pty Ltd", + "orgName": "Seene", + "cdrBrand": "seene", + "websiteURL": "www.seene.com.au", + "logo": "/static/organisations/logos/seene.png", + "cdrCode": "seene", + "retailerCode": "SEE", + "smallBusinessContact": "1300 609 387", + "residentialContact": "1300 609 387", + "abn": "32 119 677 431", + "orgId": "714020" + }, + "719464": { + "orgStatus": "active", + "tradingName": "Online Power and Gas Pty Ltd", + "orgName": "Future X Power", + "cdrBrand": "future-x", + "websiteURL": "www.futurexpower.com.au", + "logo": "/static/organisations/logos/futurex.png", + "cdrCode": "future-x", + "retailerCode": "FXP", + "smallBusinessContact": "1300 599 008", + "residentialContact": "1300 599 008", + "abn": "95 164 285 634", + "orgId": "719464" + }, + "756356": { + "orgStatus": "inactive", + "tradingName": "Flow Systems Pty Ltd", + "orgName": "Flow Systems", + "cdrBrand": "flow-systems", + "websiteURL": "flowutilities.com.au", + "logo": "/static/organisations/logos/flow.png", + "cdrCode": "flow-systems", + "retailerCode": "FLO", + "smallBusinessContact": "1300 806 806", + "residentialContact": "1300 806 806", + "abn": "28 136 272 298", + "orgId": "756356" + }, + "756360": { + "orgStatus": "inactive", + "tradingName": "Power Club Limited", + "orgName": "Powerclub", + "cdrBrand": "powerclub", + "websiteURL": "powerclub.com.au", + "logo": "/static/organisations/logos/powerclub.png", + "cdrCode": "powerclub", + "retailerCode": "PWR", + "smallBusinessContact": "1300 294 459", + "residentialContact": "1300 294 459", + "abn": "71 603 346 836", + "orgId": "756360" + }, + "788632": { + "orgStatus": "active", + "tradingName": "Stanwell Corporation Limited", + "orgName": "Stanwell Energy", + "cdrBrand": "stanwell", + "websiteURL": "stanwellenergy.com", + "logo": "/static/organisations/logos/stanwell.png", + "cdrCode": "stanwell", + "retailerCode": "STA", + "smallBusinessContact": "1300 454 058", + "residentialContact": "1300 454 058", + "abn": "37 078 848 674", + "orgId": "788632" + }, + "887030": { + "orgStatus": "active", + "tradingName": "CPE Mascot Pty Ltd", + "orgName": "CPE Mascot", + "cdrBrand": "cpe-mascot", + "websiteURL": "www.cleanpeakenergy.com.au", + "logo": "/static/organisations/logos/6be5f44e7564ead2bec088071373bc83.png", + "cdrCode": "cpe-mascot", + "retailerCode": "ENW", + "smallBusinessContact": "1300 057 405", + "residentialContact": "1300 057 405", + "abn": "22 100 209 354", + "orgId": "887030" + }, + "897829": { + "orgStatus": "inactive", + "tradingName": "Elysian Energy Pty Ltd", + "orgName": "Elysian Energy", + "cdrBrand": "elysian", + "websiteURL": "www.elysianenergy.com.au", + "logo": "/static/organisations/logos/04316e35d3fb1dde6d70a6485888beed.png", + "cdrCode": "elysian", + "retailerCode": "ELY", + "smallBusinessContact": "1300 870 300", + "residentialContact": "1300 870 300", + "abn": "85 617 526 333", + "orgId": "897829" + }, + "898484": { + "orgStatus": "active", + "tradingName": "Globird Energy Pty Ltd", + "orgName": "Globird Energy", + "cdrBrand": "globird", + "websiteURL": "www.globirdenergy.com.au", + "logo": "/static/organisations/logos/globird.png", + "cdrCode": "globird", + "retailerCode": "GLO", + "smallBusinessContact": "13 34 56", + "residentialContact": "13 34 56", + "abn": "68 600 285 827", + "orgId": "898484" + }, + "1001466": { + "orgStatus": "active", + "tradingName": "Solstice Energy Pty Ltd", + "orgName": "Solstice Energy", + "cdrBrand": "solstice", + "websiteURL": "www.solsticeenergy.com.au/", + "logo": "/static/organisations/logos/d0eb4af452fbc3eb0c2e4396ee5269ac.png", + "cdrCode": "solstice", + "retailerCode": "SOL", + "smallBusinessContact": "1800 750 750", + "residentialContact": "1800 750 750", + "abn": "90110370726", + "orgId": "1001466" + }, + "1026340": { + "orgStatus": "active", + "tradingName": "Powershop Australia Pty Ltd", + "orgName": "Kogan Energy", + "cdrBrand": "kogan", + "websiteURL": "www.koganenergy.com.au", + "logo": "/static/organisations/logos/kogan.png", + "cdrCode": "kogan", + "retailerCode": "KOG", + "smallBusinessContact": "1300 005 123", + "residentialContact": "1300 005 123", + "abn": "41 154 914 075", + "orgId": "1026340" + }, + "1028959": { + "orgStatus": "active", + "tradingName": "Amber Electric Pty Ltd", + "orgName": "Amber Electric", + "cdrBrand": "amber ", + "websiteURL": "www.amber.com.au/plan/energy-made-easy", + "logo": "/static/organisations/logos/6537c905dff42c5ecf1d65df90a8e057.png", + "cdrCode": "amber", + "retailerCode": "AMB", + "smallBusinessContact": "1800 531 907", + "residentialContact": "1800 531 907", + "abn": "98 623 603 805", + "orgId": "1028959" + }, + "1060386": { + "tradingName": "Hanwha Energy Retail Australia Pty Ltd", + "gasBillURL": null, + "orgName": "Nectr", + "cdrCode": "nectr", + "smallBusinessContact": "1300 111 211", + "abn": "82 630 397 214", + "orgId": "1060386", + "orgStatus": "active", + "cdrBrand": "nectr", + "websiteURL": "on.nectr.com.au", + "electricityBillURL": null, + "logo": "/static/organisations/logos/nectr.png", + "retailerCode": "NTR", + "residentialContact": "1300 111 211" + }, + "1085327": { + "orgStatus": "active", + "tradingName": "OVO Energy Pty Ltd", + "orgName": "OVO Energy", + "cdrBrand": "ovo-energy", + "websiteURL": "www.ovoenergy.com.au/eme", + "logo": "/static/organisations/logos/ovo.png", + "cdrCode": "ovo-energy", + "retailerCode": "OVO", + "smallBusinessContact": "1300 937 686", + "residentialContact": "1300 937 686", + "abn": "99 623 475 089", + "orgId": "1085327" + }, + "1095771": { + "tradingName": "Arc Energy Corporation Pty Ltd", + "gasBillURL": null, + "orgName": "Arc Energy Group", + "cdrCode": "arc-energy", + "smallBusinessContact": "1300 025 965", + "abn": "33 614 276 827", + "orgId": "1095771", + "orgStatus": "active", + "cdrBrand": "arc-energy ", + "websiteURL": "www.arcenergygroup.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/arc.png", + "retailerCode": "ARC", + "residentialContact": "1300 025 965" + }, + "1111812": { + "tradingName": "Metered Energy Holdings Pty Ltd", + "gasBillURL": null, + "orgName": "Metered Energy Holdings", + "cdrCode": "metered-energy", + "smallBusinessContact": "1300633637", + "abn": "44108143862", + "orgId": "1111812", + "orgStatus": "active", + "cdrBrand": "metered-energy", + "websiteURL": "www.meteredenergy.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/meh.png", + "retailerCode": "MEH", + "residentialContact": "1300633637" + }, + "1114936": { + "tradingName": "Active Utilities Retail Pty Ltd", + "gasBillURL": null, + "orgName": "Active Utilities Retail", + "cdrCode": "active-utilities", + "smallBusinessContact": "1300 587 623", + "abn": "31 606 139 931", + "orgId": "1114936", + "orgStatus": "active", + "cdrBrand": "active-utilities", + "websiteURL": "www.activeutilities.com.au/", + "electricityBillURL": null, + "logo": "/static/organisations/logos/0fc6da1797227c2758c074c2506e0c7d.png", + "retailerCode": "AUT", + "residentialContact": "1300 587 623" + } + } + } +} \ No newline at end of file diff --git a/custom_components/pricehawk/cdr/evaluator.py b/custom_components/pricehawk/cdr/evaluator.py new file mode 100644 index 0000000..68819b1 --- /dev/null +++ b/custom_components/pricehawk/cdr/evaluator.py @@ -0,0 +1,359 @@ +"""CDR-native tariff cost evaluator. + +Port of `scripts/cdr_evaluator_proto.py` (the Phase 0 prototype that +gate-passed 2026-05-14 + cleared Phase 1 parity 0.46% vs legacy +`tariff_engine.py`). Same semantics; HA-integration packaging shape. + +Boundary types from `cdr.models`. Internal walk-the-dict logic is +intentionally untyped — CDR `electricityContract` has 30+ optional +keys and retailers populate different subsets; locking down the inner +schema with pydantic creates maintenance overhead with no benefit. + +Public API: + evaluate(plan, consumption, run_incentives=True) -> CostBreakdown + +Accepts both `PlanDetailEnvelope` pydantic models and raw dicts for +the plan (envelope or unwrapped) for caller flexibility. Same for +`ConsumptionWindow` vs raw dict. + +Semantics summary (locked, verified by phase_0_verify.py + phase_1_parity.py): + - pricingModel: SINGLE_RATE / TIME_OF_USE / FLEXIBLE + - rateBlockUType: singleRate / timeOfUseRates + - Stepped rates with daily-reset volume thresholds + - TOU window: start-INCLUSIVE, end-EXCLUSIVE; endTime "00:00" with + startTime > 0 means end-of-day (24:00 = 1440 min) + - FIT: singleTariff (flat or time-variant) + timeVaryingTariffs + - DST handled via `zoneinfo.ZoneInfo("Australia/Sydney")` on slots' + `ts_local` ISO timestamps + - GST factor 1.10 applied ONCE at output via `total_aud_inc_gst` + property; incentive credits tracked inc-GST separately (PDF + dollar amounts already inc-GST per legacy convention) + +Out-of-scope for v1.5.0 (deferred to v1.5.1 / v1.6.0): + - demandCharges as primary rate block + - controlledLoad accounting + - SEASONAL / TOU Seasonal variants + - Critical Peak event credits (no event schedule available) + - Cross-retailer parsers beyond GloBird (OVO Free 3, AGL Three for Free) +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from typing import Any + +from .incentive_parsers import apply_retailer_incentives + +GST_FACTOR = Decimal("1.10") +DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] + + +@dataclass +class CostBreakdown: + """Period cost breakdown returned by `evaluate()`. + + Internal storage: + - `*_ex_gst`: rate-based contributions (import / export / supply) + stored ex-GST. `total_aud_inc_gst` property applies × 1.10. + - `incentive_aud_inc_gst`: parser credits stored inc-GST (PDF + dollar amounts are already inc-GST). NOT multiplied. + + `trace` is the per-slot or per-event log for hand-calc spot-check. + Phase 0 verifier (`scripts/phase_0_verify.py`) reads this to cross- + check evaluator output against an independent bucket aggregator. + """ + + total_aud_ex_gst: Decimal = Decimal("0") + daily_supply_aud_ex_gst: Decimal = Decimal("0") + import_aud_ex_gst: Decimal = Decimal("0") + export_aud_ex_gst: Decimal = Decimal("0") + incentive_aud_inc_gst: Decimal = Decimal("0") + period_days: int = 0 + slot_count: int = 0 + plan_id: str = "" + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + @property + def total_aud_inc_gst(self) -> Decimal: + rate_based = ( + self.import_aud_ex_gst + + self.export_aud_ex_gst + + self.daily_supply_aud_ex_gst + ) * GST_FACTOR + return rate_based + self.incentive_aud_inc_gst + + def summary(self) -> dict: + return { + "plan_id": self.plan_id, + "period_days": self.period_days, + "slot_count": self.slot_count, + "total_aud_inc_gst": float(self.total_aud_inc_gst.quantize(Decimal("0.01"))), + "import_aud_inc_gst": float((self.import_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "export_aud_inc_gst": float((self.export_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "daily_supply_aud_inc_gst": float((self.daily_supply_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "incentive_aud_inc_gst": float(self.incentive_aud_inc_gst.quantize(Decimal("0.01"))), + "notes": self.notes, + } + + +# --------------------------------------------------------------------------- +# Helpers (private; pure functions over dicts) +# --------------------------------------------------------------------------- + + +def _decimal(v: Any) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def _hhmm_to_minutes(hhmm: str) -> int: + h, m = hhmm.split(":") + return int(h) * 60 + int(m) + + +def slot_in_window(local_dt: datetime, days: list[str], start: str, end: str) -> bool: + """Whether a slot's local clock time falls inside a TOU window. + + Start-inclusive, end-exclusive. `endTime "00:00"` with non-zero start + means end-of-day (24:00 = 1440 min). Public for cross-check use. + """ + if DAY_NAMES[local_dt.weekday()] not in days: + return False + minutes = local_dt.hour * 60 + local_dt.minute + start_m = _hhmm_to_minutes(start) + end_m = _hhmm_to_minutes(end) + if end_m == 0 and start_m > 0: + end_m = 1440 + if end_m < start_m: + return minutes >= start_m or minutes < end_m + return start_m <= minutes < end_m + + +def _resolve_tou_rate(local_dt: datetime, tou_rates: list[dict]) -> dict | None: + for rate in tou_rates: + for window in rate.get("timeOfUse", []) or []: + if slot_in_window( + local_dt, + window.get("days", []) or [], + window.get("startTime") or "00:00", + window.get("endTime") or "23:59", + ): + return rate + return None + + +def _select_stepped_rate(rates: list[dict], cumulative_kwh_day: Decimal) -> Decimal: + """Stepped CDR rate: entries with `volume` thresholds; final entry without + `volume` catches the remainder.""" + for r in rates: + vol = r.get("volume") + if vol is None: + return _decimal(r.get("unitPrice")) + if cumulative_kwh_day < _decimal(vol): + return _decimal(r.get("unitPrice")) + return _decimal(rates[-1].get("unitPrice")) if rates else Decimal("0") + + +def _eval_supply(slots: list[dict], tariff_period: dict, bd: CostBreakdown) -> None: + dsc = _decimal(tariff_period.get("dailySupplyCharge")) + days = {datetime.fromisoformat(s["ts_local"]).date() for s in slots} + bd.period_days = len(days) + bd.daily_supply_aud_ex_gst = dsc * Decimal(len(days)) + + +def _eval_import(slots: list[dict], tariff_period: dict, bd: CostBreakdown) -> None: + rate_block_utype = tariff_period.get("rateBlockUType") + daily_running: dict[str, Decimal] = {} + + if rate_block_utype == "singleRate": + rates = (tariff_period.get("singleRate") or {}).get("rates", []) or [] + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + kwh = _decimal(slot.get("grid_import_kwh", 0)) + day = local_dt.date().isoformat() + cumul = daily_running.get(day, Decimal("0")) + rate = _select_stepped_rate(rates, cumul) + cost = kwh * rate + bd.import_aud_ex_gst += cost + daily_running[day] = cumul + kwh + bd.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": "SINGLE_RATE", + "kwh": float(kwh), + "rate_ex_gst": float(rate), + "cost_ex_gst": float(cost), + "cumul_day_kwh": float(cumul + kwh), + }) + return + + if rate_block_utype == "timeOfUseRates": + tou_rates = tariff_period.get("timeOfUseRates", []) or [] + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + kwh = _decimal(slot.get("grid_import_kwh", 0)) + day = local_dt.date().isoformat() + rate_entry = _resolve_tou_rate(local_dt, tou_rates) + if rate_entry is None: + bd.notes.append(f"WARN: no TOU window matched slot {slot['ts_local']}; zero rate") + bd.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": "UNMATCHED", + "kwh": float(kwh), + "rate_ex_gst": 0.0, + "cost_ex_gst": 0.0, + }) + continue + cumul_key = f"{day}|{rate_entry.get('type')}" + cumul = daily_running.get(cumul_key, Decimal("0")) + rate = _select_stepped_rate(rate_entry.get("rates", []) or [], cumul) + cost = kwh * rate + bd.import_aud_ex_gst += cost + daily_running[cumul_key] = cumul + kwh + bd.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": rate_entry.get("type"), + "kwh": float(kwh), + "rate_ex_gst": float(rate), + "cost_ex_gst": float(cost), + }) + return + + bd.notes.append(f"WARN: unhandled rateBlockUType {rate_block_utype!r}; import set to 0") + + +def _eval_fit(plan_data: dict, slots: list[dict], bd: CostBreakdown) -> None: + """Walk slots, sum FIT credits as negative export_aud_ex_gst. + + Multiple FIT entries summed (e.g., RETAILER + GOVERNMENT). Both + `singleTariff` (with optional `timeVariations`) and `timeVaryingTariffs` + shapes supported. + """ + elec = plan_data.get("electricityContract", {}) or {} + fits = elec.get("solarFeedInTariff", []) or [] + if not fits: + return + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + export_kwh = _decimal(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0)) + if export_kwh <= 0: + continue + total = Decimal("0") + for fit in fits: + utype = fit.get("tariffUType") + if utype == "singleTariff": + st = fit.get("singleTariff") or {} + tvs = st.get("timeVariations") or [] + if tvs and not any( + slot_in_window( + local_dt, + t.get("days", DAY_NAMES), + t.get("startTime", "00:00"), + t.get("endTime", "23:59"), + ) + for t in tvs + ): + continue + rates = st.get("rates", []) or [] + rate = _decimal(rates[0].get("unitPrice")) if rates else Decimal("0") + total += export_kwh * rate + elif utype == "timeVaryingTariffs": + for tvt in fit.get("timeVaryingTariffs") or []: + if not any( + slot_in_window( + local_dt, + t.get("days", DAY_NAMES), + t.get("startTime", "00:00"), + t.get("endTime", "23:59"), + ) + for t in (tvt.get("timeVariations") or []) + ): + continue + rates = tvt.get("rates", []) or [] + rate = _decimal(rates[0].get("unitPrice")) if rates else Decimal("0") + total += export_kwh * rate + bd.export_aud_ex_gst -= total + + +# --------------------------------------------------------------------------- +# Public entry +# --------------------------------------------------------------------------- + + +def _unwrap_plan(plan: Any) -> dict: + """Accept pydantic envelope, pydantic PlanDetail, or raw dict in any of + the three shapes ({data: {...}}, {electricityContract: ...}, or full + PlanDetail dict).""" + if hasattr(plan, "model_dump"): + plan = plan.model_dump() + if isinstance(plan, dict) and "data" in plan and isinstance(plan["data"], dict): + return plan["data"] + return plan if isinstance(plan, dict) else {} + + +def _unwrap_consumption(consumption: Any) -> dict: + if hasattr(consumption, "model_dump"): + return consumption.model_dump() + return consumption if isinstance(consumption, dict) else {"slots": []} + + +def evaluate( + plan: Any, + consumption: Any, + run_incentives: bool = True, + entry_options: dict | None = None, +) -> CostBreakdown: + """Evaluate plan cost over a consumption window. + + Args: + plan: CDR PlanDetail or envelope (pydantic model or raw dict). + consumption: ConsumptionWindow (pydantic model or raw dict with `slots`). + run_incentives: skip retailer-specific incentive parsers (useful for + parity testing against engines that ignore incentives). + entry_options: Phase 2.12.1 — user-side opt-in fields the + retailer parsers need (ovo_interest_balance_aud, + vpp_batteries_enrolled). Pass-through to + apply_retailer_incentives. None → empty dict → opt-in + math no-ops. + """ + bd = CostBreakdown() + plan_data = _unwrap_plan(plan) + bd.plan_id = plan_data.get("planId", "?") + elec = plan_data.get("electricityContract", {}) or {} + bd.notes.append(f"pricingModel={elec.get('pricingModel', '?')}") + + tps = elec.get("tariffPeriod", []) or [] + if not tps: + bd.notes.append("ERROR: no tariffPeriod found") + return bd + tp = tps[0] + if len(tps) > 1: + bd.notes.append(f"WARN: {len(tps)} tariff periods present; using first only") + + cons = _unwrap_consumption(consumption) + slots = cons.get("slots", []) or [] + # Phase 3.0g (CodeRabbit): order-sensitive math (stepped FIT, + # capped windows, zerohero behavior tracker) needs slots in + # chronological order. Sort by ts_local; slots without ts_local + # sort last (defensive — should never happen). + slots = sorted(slots, key=lambda s: s.get("ts_local") or "9999") + bd.slot_count = len(slots) + + _eval_supply(slots, tp, bd) + _eval_import(slots, tp, bd) + _eval_fit(plan_data, slots, bd) + if run_incentives: + apply_retailer_incentives( + plan_data, slots, bd, + slot_in_window=slot_in_window, + entry_options=entry_options, + ) + + bd.total_aud_ex_gst = ( + bd.daily_supply_aud_ex_gst + + bd.import_aud_ex_gst + + bd.export_aud_ex_gst + ) + return bd diff --git a/custom_components/pricehawk/cdr/incentive_parsers/__init__.py b/custom_components/pricehawk/cdr/incentive_parsers/__init__.py new file mode 100644 index 0000000..184022c --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/__init__.py @@ -0,0 +1,123 @@ +"""Per-retailer incentive parser registry. + +Hardcoded dict per locked decision §I.3 — NOT decorator magic, NOT +filesystem scan. Add a retailer = edit this file. + +v1.5.0 retailers (Phase 2.6): + - globird: ZEROHERO + Super Export + 3-for-Free (full math) + - agl: Solar Savers bonus FIT + Three for Free (presence detect only) + +v1.5.1 retailers (Phase 2.11.2 — tiered FIT activation): + - origin: Solar feed-in tariffs (period-averaged tiered FIT) + - alinta: Solar Feed-in Tariff / Stepped FiT (daily tiered FIT) + - energyaustralia: Solar Max + PowerResponse VPP (tiered FIT only here) + +Each parser is `(plan_data, slots, breakdown, *, slot_in_window)`: + - plan_data: unwrapped CDR PlanDetail dict (data.* contents) + - slots: list of consumption slot dicts + - breakdown: CostBreakdown instance — mutate `incentive_aud_inc_gst` + - slot_in_window: dependency-injected window matcher from evaluator + (avoids circular import + lets tests override semantics) + +Parsers MUST express credits in INC-GST DOLLARS. PDF rate phrases +("$1/Day", "15 cents/kWh") are inc-GST per legacy convention. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Callable + +from .agl import apply as _apply_agl +from .alinta import apply as _apply_alinta +from .energyaustralia import apply as _apply_energyaustralia +from .engie import apply as _apply_engie +from .globird import apply as _apply_globird +from .origin import apply as _apply_origin +from .ovo import apply as _apply_ovo +from .red import apply as _apply_red + + +def safe_int(value, default: int = 0) -> int: + """Phase 3.0g (CodeRabbit): defensive integer cast for opt-in fields. + + Options-flow values can arrive as None, '', floats, or malformed + strings if user typed garbage. Default to `default` on any failure + rather than raising and breaking the parser dispatch. + """ + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + try: + return int(float(value)) # tolerate "3.0" and similar + except (TypeError, ValueError): + return default + + +def safe_decimal(value, default: Decimal = Decimal("0")) -> Decimal: + """Phase 3.0g (CodeRabbit): defensive Decimal cast for opt-in fields. + + Same rationale as safe_int — never raise on user-input garbage, + always return a valid Decimal. + """ + if value is None or value == "": + return default + try: + return Decimal(str(value)) + except (TypeError, ValueError, ArithmeticError): + return default + + +# Hardcoded registry. Keys are CDR `brand` slugs (lowercase). +RETAILER_PARSERS: dict[str, Callable] = { + "globird": _apply_globird, + "agl": _apply_agl, + "origin": _apply_origin, + "alinta": _apply_alinta, + "energyaustralia": _apply_energyaustralia, + "engie-au": _apply_engie, + "ovo-energy": _apply_ovo, + "red-energy": _apply_red, +} + + +def apply_retailer_incentives( + plan_data: dict, + slots: list[dict], + breakdown, # CostBreakdown — forward ref to avoid circular import + *, + slot_in_window: Callable, + entry_options: dict | None = None, +) -> None: + """Dispatch to the retailer-specific parser based on CDR `brand`. + + ``entry_options`` (Phase 2.12.1) carries user-side opt-in fields the + parsers can't infer from plan data alone: + - ``ovo_interest_balance_aud`` (Decimal/float, default 0) + - ``vpp_batteries_enrolled`` (int, default 0) + + Parsers ignore unknown keys; missing keys default to "not opted in" + (math no-ops). + """ + brand = (plan_data.get("brand", "") or "").lower() + parser = RETAILER_PARSERS.get(brand) + if parser is None: + return + # Phase 3.0g (CodeRabbit): isolate parser failures so a single + # broken retailer parser doesn't abort the whole evaluation. The + # cost numbers from structural tariff math are always preserved + # — only the incentive credits for THIS retailer are skipped. + try: + parser( + plan_data, slots, breakdown, + slot_in_window=slot_in_window, + entry_options=entry_options or {}, + ) + except Exception as err: # noqa: BLE001 — defensive boundary + import logging + logging.getLogger(__name__).warning( + "Retailer parser %s raised %s: %s. Cost run continues without " + "this retailer's incentive credits.", + brand, type(err).__name__, err, + ) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/agl.py b/custom_components/pricehawk/cdr/incentive_parsers/agl.py new file mode 100644 index 0000000..b59147d --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/agl.py @@ -0,0 +1,192 @@ +"""AGL incentive parser — Phase 2.6. + +AGL publishes most of its tariff math structurally under +`electricityContract.solarFeedInTariff[]`, which the core evaluator +already credits via `_apply_fit_credit`. This parser covers the +non-structural patterns AGL ships as free-text in +`electricityContract.incentives[]`: + +1. **Bonus FIT** — "Solar Savers / Solar Sunshine / Solar Maximiser": + extra cents/kWh on top of the base FIT, capped at first N kWh + exported in a daily time window. Pattern is regular enough to extract + via regex. + +2. **Three for Free** — 3 hours/day free electricity. Not directly a FIT + parser — it's an import-side credit — but lives in the same + incentives block. v1.5.0 ships a presence-detector that logs the + plan needs follow-up (the actual time-shift math depends on the + user's chosen 3-hour window, which AGL pushes to a separate app). + +Both rules emit credits in INC-GST DOLLARS into +`breakdown.incentive_aud_inc_gst`. AGL fact sheets quote dollar amounts +in the same convention as GloBird (inc-GST already), per the AER +Schedule 1 disclosure rules. + +Coverage gap acknowledged in TODOS.md TODO-6: OVO's "Free 3" is also +this pattern but the wording differs enough that a separate `ovo.py` +parser is cleaner than one rule-set covering both. AGL only here. +""" +from __future__ import annotations + +import re +from datetime import datetime +from decimal import Decimal +from typing import Callable + +BONUS_FIT_RE = re.compile( + r"(?P[\d.]+)\s*c(?:ents)?/kWh\s+(?:bonus|extra|additional|solar\s+savings)" + r"(?:\s+feed[-\s]?in)?\s+(?:for\s+)?(?:the\s+)?first\s+(?P[\d.]+)" + r"\s+kWh(?:\s+(?:of\s+)?exports?)?(?:\s+per\s+day)?\s+between\s+" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))-(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))", + re.I, +) +THREE_FOR_FREE_RE = re.compile( + r"three\s+for\s+free|3\s+hours?\s+(?:per\s+day\s+)?(?:of\s+)?free", + re.I, +) + + +def _hh_token_to_minutes(tok: str) -> int: + """Parse '6pm', '6:30am', '12pm' → minutes from midnight.""" + m = re.match(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)", tok.strip(), re.I) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(3).lower() == "pm": + h += 12 + minute = int(m.group(2)) if m.group(2) else 0 + return h * 60 + minute + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def parse_rules(plan_data: dict) -> dict: + """Extract structured rule dicts from AGL incentives free-text. + + Returns ``{"bonus_fit": {...}, "three_for_free": {...}}`` with + missing keys silently dropped. Each rule is independent. + """ + elec = plan_data.get("electricityContract", {}) or {} + rules: dict = {} + for inc in elec.get("incentives", []) or []: + desc = inc.get("description") or "" + + m = BONUS_FIT_RE.search(desc) + if m and "bonus_fit" not in rules: + rules["bonus_fit"] = { + "cents_per_kwh": Decimal(m.group("cents")), + "first_kwh_per_day": Decimal(m.group("kwh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source_displayName": inc.get("displayName"), + } + + if THREE_FOR_FREE_RE.search(desc) and "three_for_free" not in rules: + rules["three_for_free"] = { + "detected": True, + "source_displayName": inc.get("displayName"), + "source_description": desc, + } + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + **_extra, +) -> None: + """Credit bonus-FIT exports to ``breakdown.incentive_aud_inc_gst``. + + `slot_in_window` is supplied for API parity; this parser uses + minute-based windows derived from the parsed text and does not need + the CDR HH:MM resolver. + """ + del slot_in_window # reserved + rules = parse_rules(plan_data) + + # Phase 2.11.4 — also extract free_window rules so we can route + # AGL Three for Free even when the legacy bonus_fit/three_for_free + # regexes (description-only) didn't match (eligibility-only plans). + from .common import peak_import_rate_c_per_kwh_inc_gst + from .common.free_window import ( + apply_rule as _apply_free_window, + parse_from_incentives as _parse_free_windows, + ) + elec = plan_data.get("electricityContract") or {} + fw_rules = _parse_free_windows(elec.get("incentives") or []) + + if not rules and not fw_rules: + return + rule_names = list(rules.keys()) + if fw_rules: + rule_names.append("free_window") + breakdown.notes.append(f"agl parser hits: {rule_names}") + + by_day: dict[str, list[dict]] = {} + for slot in slots: + by_day.setdefault(slot["ts_local"][:10], []).append(slot) + + if "bonus_fit" in rules: + rule = rules["bonus_fit"] + rate_per_kwh = rule["cents_per_kwh"] / Decimal("100") + for day, day_slots in by_day.items(): + day_credited_kwh = Decimal("0") + for slot in day_slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + minutes = local_dt.hour * 60 + local_dt.minute + # Overnight-aware window match: if end_min < start_min, + # window wraps midnight (e.g. 10pm-2am) — treat as + # "after start OR before end" rather than the same-day + # range. Same-day plans (the common case) keep the + # original semantics. + start_min = rule["start_min"] + end_min = rule["end_min"] + in_window = ( + start_min <= minutes < end_min + if end_min >= start_min + else (minutes >= start_min or minutes < end_min) + ) + if not in_window: + continue + exp = _decimal( + slot.get("grid_export_kwh", 0) + or slot.get("solar_export_kwh", 0) + ) + if exp <= 0: + continue + remaining = rule["first_kwh_per_day"] - day_credited_kwh + if remaining <= 0: + break + credit_kwh = min(exp, remaining) + breakdown.incentive_aud_inc_gst -= credit_kwh * rate_per_kwh + day_credited_kwh += credit_kwh + if day_credited_kwh > 0: + breakdown.trace.append({ + "incentive": "agl_bonus_fit", + "day": day, + "credited_kwh": float(day_credited_kwh), + "rate_c_kwh_inc_gst": float(rule["cents_per_kwh"]), + }) + + if "three_for_free" in rules: + # Phase 2.11.4 supersedes the Phase 2.6 deferred stub: the AGL + # eligibility text DOES specify the window ("Free electricity + # usage applies from 10am to 1pm every day"). free_window helper + # below credits the import-side math; this note is informational. + breakdown.notes.append("agl: 'Three for Free' detected.") + + # Phase 2.11.4 — credit free import window math + if fw_rules: + peak_rate = peak_import_rate_c_per_kwh_inc_gst(plan_data) + for fw in fw_rules: + _apply_free_window( + fw, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/alinta.py b/custom_components/pricehawk/cdr/incentive_parsers/alinta.py new file mode 100644 index 0000000..e99d17e --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/alinta.py @@ -0,0 +1,52 @@ +"""Alinta Energy incentive parser — Phase 2.11.2. + +Catalog v3 finding: 66 Alinta plans publish tiered FIT as +"Solar Feed-in Tariff" / "Stepped FiT" incentive: + "This Energy Plan includes a stepped feed-in tariff, where you will + receive a feed-in of 7c/kWh for the first 10kW exported. For any + export after that you will obtain Alinta Energy's standard retailer + feed-in tariff of 0.04c/kWh." + +Cap is daily → cap_window: DAY in tiered_fit. The 0.04c/kWh tier-2 rate +is implicit (not parsed) — caller falls back to base FIT from +solarFeedInTariff[]. In practice Alinta sets base FIT to that 0.04c +value so the math is identical. + +Phase 2.11.2 ships tiered FIT only — no other Alinta-specific patterns +in v1.5.0 scope. +""" +from __future__ import annotations + +from typing import Callable + +from .common import base_fit_c_per_kwh_inc_gst +from .common.tiered_fit import apply_rule, parse_from_incentives + + +def parse_rules(plan_data: dict) -> dict: + elec = plan_data.get("electricityContract") or {} + rules: dict = {} + rule = parse_from_incentives(elec.get("incentives") or []) + if rule: + rules["tiered_fit"] = rule + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + **_extra, +) -> None: + del slot_in_window + rules = parse_rules(plan_data) + if not rules: + return + breakdown.notes.append(f"alinta parser hits: {list(rules.keys())}") + if "tiered_fit" in rules: + apply_rule( + rules["tiered_fit"], slots, breakdown, + base_fit_c_per_kwh=base_fit_c_per_kwh_inc_gst(plan_data), + ) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/__init__.py b/custom_components/pricehawk/cdr/incentive_parsers/common/__init__.py new file mode 100644 index 0000000..f8c34c9 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/__init__.py @@ -0,0 +1,109 @@ +"""Shared incentive-rule helpers used by per-retailer parser files. + +Each helper in this package is retailer-agnostic. It extracts a rule +from CDR free-text and applies math to a CostBreakdown. Per-retailer +modules (agl.py, globird.py, origin.py, etc.) wire these helpers up +based on the specific incentive patterns their retailer publishes. + +See scripts/CDR_INCENTIVE_CATALOG.md for the catalog of incentive +shapes observed across all 78 AU energy retailers. +""" +from __future__ import annotations + +from decimal import Decimal + + +GST_FACTOR = Decimal("1.10") + + +def base_fit_c_per_kwh_inc_gst(plan_data: dict) -> Decimal: + """Read the first solarFeedInTariff[] rate as inc-GST cents/kWh. + + CDR `unitPrice` is ex-GST per spec; multiply by 110 (×100 for cents, + ×1.10 for GST) to get the inc-GST cents/kWh that incentive parsers + use for their delta calculations. + + Returns Decimal("0") if no FIT configured (parsers will then credit + the FULL tier1 rate, not just a delta). + """ + elec = plan_data.get("electricityContract") or {} + for fit in (elec.get("solarFeedInTariff") or []): + utype = fit.get("tariffUType") + if utype == "singleTariff": + rates = (fit.get("singleTariff") or {}).get("rates") or [] + elif utype == "timeVaryingTariffs": + tvts = fit.get("timeVaryingTariffs") or [] + rates = (tvts[0].get("rates") or []) if tvts else [] + else: + continue + if rates: + unit_price = rates[0].get("unitPrice") + if unit_price is not None: + return Decimal(str(unit_price)) * Decimal("100") * GST_FACTOR + return Decimal("0") + + +def _all_import_rates_aud_per_kwh_ex_gst(plan_data: dict) -> list[Decimal]: + """Collect every TOU/single import rate's unitPrice across all blocks.""" + elec = plan_data.get("electricityContract") or {} + out: list[Decimal] = [] + for tp in (elec.get("tariffPeriod") or []): + if not isinstance(tp, dict): + continue + rbut = tp.get("rateBlockUType") + if not rbut: + continue + block = tp.get(rbut) + if isinstance(block, dict): + blocks = [block] + elif isinstance(block, list): + blocks = block + else: + continue + for b in blocks: + if not isinstance(b, dict): + continue + for rate_entry in (b.get("rates") or []): + up = rate_entry.get("unitPrice") if isinstance(rate_entry, dict) else None + if up is None: + continue + try: + out.append(Decimal(str(up))) + except Exception: + continue + return out + + +# When the tariff's lowest TOU rate is at or below this threshold, +# the plan is assumed to already encode the free/discount window +# inside its tariffPeriod (e.g., GloBird ZEROHERO Flex sets OFF_PEAK +# to 0.000001 c/kWh for 11am-2pm). free_window incentives are then +# redundant — applying them would double-credit. Threshold is in +# inc-GST cents per kWh. +TARIFF_ENCODES_FREE_WINDOW_THRESHOLD_C_INC_GST = Decimal("1.0") + + +def peak_import_rate_c_per_kwh_inc_gst(plan_data: dict) -> Decimal: + """Representative normal import rate for free_window credit math. + + Returns inc-GST cents/kWh. Returns Decimal("0") when: + - No rates extractable (parser then no-ops), OR + - The plan's TOU tariff already encodes a near-free window (min rate + ≤ TARIFF_ENCODES_FREE_WINDOW_THRESHOLD_C_INC_GST). Returning 0 + makes the free_window parser no-op (since normal ≤ free → no + credit), avoiding double-credit on plans like GloBird ZEROHERO + Flex where the 11am-2pm window is in tariffPeriod already. + + For plans without an encoded free window (OVO Free 3 on flat TOU, + AGL Three for Free on TOU peak/shoulder), returns the MAX rate + across all TOU blocks — conservative (slightly over-credits for + shoulder users, but the affected hours are short and the per-yr + error is bounded at ~$15). + """ + rates_ex_gst = _all_import_rates_aud_per_kwh_ex_gst(plan_data) + if not rates_ex_gst: + return Decimal("0") + min_rate_inc_gst = min(rates_ex_gst) * Decimal("100") * GST_FACTOR + if min_rate_inc_gst <= TARIFF_ENCODES_FREE_WINDOW_THRESHOLD_C_INC_GST: + return Decimal("0") + return max(rates_ex_gst) * Decimal("100") * GST_FACTOR diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/bonus_fit.py b/custom_components/pricehawk/cdr/incentive_parsers/common/bonus_fit.py new file mode 100644 index 0000000..a545acd --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/bonus_fit.py @@ -0,0 +1,241 @@ +"""Bonus solar feed-in tariff rules — Phase 2.11.3. + +Catalog v3 finding: 90 GloBird ZEROHERO plans publish two stacked +bonus FIT rules in addition to the structural solarFeedInTariff[]: + +1. **Uncapped windowed bonus** (Peak solar feed-in, 70 plans): + "X cents/kWh applies to exports between Yam-Zpm (Local Time) + everyday." Additive credit on all exports in the window — no + daily kWh cap. Stacks with base FIT. + +2. **Capped windowed bonus** (Super Export Credit, 20 plans): + "X cents/kWh applies to the first N kWh of exports between + Yam-Zpm everyday, and is inclusive of any other Feed-in tariff + as applicable in Energy Plan." Capped at N kWh per day. The + "inclusive of any other FIT" wording means this REPLACES base + FIT in the window (not adds), but for Phase 2.11.3 v1 we credit + additively (DELTA above base) so the math composes with the + uncapped bonus already credited. + +Known gap (TODO Phase 2.11.4 polish): when both bonuses overlap in +time, the user is over-credited by `peak_fit_rate × min(export, cap)`. +For ZEROHERO at 2c Peak FIT × 15 kWh cap × 365 days = $109.50/yr +maximum over-credit. Real-world: most users export <15kWh in 6-9pm +window so the actual error is smaller (~$5-30/yr). + +Math for ZEROHERO with base FIT ≈0c: +- 4-6pm: Peak FIT 2c → 2c total ✓ +- 6-9pm first 15kWh: Peak FIT 2c + Super Export 13c (=15-2) = 15c ✓ +- 6-9pm beyond 15kWh: Peak FIT 2c only = 2c ✓ +- 9-11pm: Peak FIT 2c → 2c total ✓ + +Phase 2.11.3 ships Peak FIT additive only. Super Export overlap +adjustment deferred to 2.11.4. +""" +from __future__ import annotations + +import re +from datetime import datetime +from decimal import Decimal + + +# "X cents/kWh applies to exports between Yam-Zpm" (no kWh cap) +UNCAPPED_WINDOW_RE = re.compile( + r"(?P[\d.]+)\s*c(?:ents)?/kWh\s+applies?\s+to\s+(?:any\s+)?" + r"exports?\s+between\s+(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))[-\s]+" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))", + re.I, +) + +# "X cents/kWh applies to the first N kWh of exports between Yam-Zpm" +CAPPED_WINDOW_RE = re.compile( + r"(?P[\d.]+)\s*c(?:ents)?/kWh\s+applies?\s+to\s+the\s+first\s+" + r"(?P[\d.]+)\s+kWh\s+of\s+exports?\s+between\s+" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))[-\s]+" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))", + re.I, +) + + +def _hh_token_to_minutes(tok: str) -> int: + """'6pm', '6:30am', '12pm' → minutes from midnight. Public for tests.""" + m = re.match(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)", tok.strip(), re.I) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(3).lower() == "pm": + h += 12 + minute = int(m.group(2)) if m.group(2) else 0 + return h * 60 + minute + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def parse_uncapped_window(eligibility: str) -> dict | None: + """Extract uncapped windowed bonus FIT (Peak solar feed-in pattern). + + Returns None if no match. CAPPED variant takes precedence — caller + should check `parse_capped_window` first to avoid false positives + on eligibility texts that match both patterns. + """ + if not eligibility or CAPPED_WINDOW_RE.search(eligibility): + return None + m = UNCAPPED_WINDOW_RE.search(eligibility) + if not m: + return None + return { + "bonus_c_per_kwh": _decimal(m.group("cents")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source": eligibility[:200], + } + + +def parse_capped_window(eligibility: str) -> dict | None: + """Extract capped windowed bonus FIT (Super Export Credit pattern).""" + if not eligibility: + return None + m = CAPPED_WINDOW_RE.search(eligibility) + if not m: + return None + return { + "bonus_c_per_kwh": _decimal(m.group("cents")), + "cap_kwh_per_day": _decimal(m.group("kwh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source": eligibility[:200], + } + + +def _slot_minutes(ts_local: str) -> int: + local_dt = datetime.fromisoformat(ts_local) + return local_dt.hour * 60 + local_dt.minute + + +def apply_uncapped_window(rule: dict, slots: list[dict], breakdown) -> None: + """Credit `bonus_c_per_kwh` on all exports in the time window. + + Additive — does NOT subtract base FIT. Treat as a stacking incentive + on top of whatever the evaluator already credited from + solarFeedInTariff[]. + """ + rate_aud = rule["bonus_c_per_kwh"] / Decimal("100") + total_kwh = Decimal("0") + for slot in slots: + if not (rule["start_min"] <= _slot_minutes(slot["ts_local"]) < rule["end_min"]): + continue + exp = _decimal( + slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) + ) + if exp <= 0: + continue + breakdown.incentive_aud_inc_gst -= exp * rate_aud + total_kwh += exp + if total_kwh > 0: + breakdown.trace.append({ + "incentive": "bonus_fit_uncapped_window", + "rate_c_per_kwh": float(rule["bonus_c_per_kwh"]), + "credited_kwh": float(total_kwh), + "window": f"{rule['start_min']//60:02d}:00-{rule['end_min']//60:02d}:00", + }) + + +def apply_capped_window( + rule: dict, + slots: list[dict], + breakdown, + *, + overlap_uncapped_rate_c_per_kwh: Decimal = Decimal("0"), +) -> None: + """Credit `bonus_c_per_kwh` on first `cap_kwh_per_day` exports in window. + + Cap resets at local midnight. + + Phase 2.11.10 overlap fix: when an uncapped bonus FIT also credits + slots inside this window (e.g., GloBird ZEROHERO Peak FIT 4-11pm 2c + overlapping Super Export 6-9pm 15c), the catalog "inclusive of any + other Feed-in tariff" wording means the capped rate REPLACES the + uncapped rate inside the cap. Caller passes the overlapping + uncapped rate; we subtract it from the per-kWh capped rate so net + credit on first-N-kWh = capped_rate, not capped_rate + + uncapped_rate. + + Math: + net_capped_rate = capped_rate - overlap_uncapped_rate + → after uncapped already credited overlap_uncapped_rate on the + same kWh, total = uncapped + (capped - uncapped) = capped ✓ + + If overlap_uncapped_rate_c_per_kwh is 0 (no overlap), behaviour is + unchanged from Phase 2.11.3. + """ + effective_rate_c = rule["bonus_c_per_kwh"] - overlap_uncapped_rate_c_per_kwh + if effective_rate_c <= 0: + # Uncapped already covers what capped would pay — no incremental + # credit. Skip the trace entry too. + return + rate_aud = effective_rate_c / Decimal("100") + cap = rule["cap_kwh_per_day"] + + by_day: dict[str, list[dict]] = {} + for slot in slots: + by_day.setdefault(slot["ts_local"][:10], []).append(slot) + + total_credited_kwh = Decimal("0") + for _day, day_slots in sorted(by_day.items()): + day_credited = Decimal("0") + for slot in day_slots: + if not (rule["start_min"] <= _slot_minutes(slot["ts_local"]) < rule["end_min"]): + continue + exp = _decimal( + slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) + ) + if exp <= 0: + continue + remaining = cap - day_credited + if remaining <= 0: + break + credit_kwh = min(exp, remaining) + breakdown.incentive_aud_inc_gst -= credit_kwh * rate_aud + day_credited += credit_kwh + total_credited_kwh += day_credited + + if total_credited_kwh > 0: + breakdown.trace.append({ + "incentive": "bonus_fit_capped_window", + "rate_c_per_kwh": float(rule["bonus_c_per_kwh"]), + "effective_rate_c_per_kwh": float(effective_rate_c), + "cap_kwh_per_day": float(cap), + "credited_kwh": float(total_credited_kwh), + "window": f"{rule['start_min']//60:02d}:00-{rule['end_min']//60:02d}:00", + }) + + +def parse_from_incentives(incentives: list[dict]) -> dict: + """Walk a plan's ``incentives[]`` and extract any bonus FIT rules. + + Returns ``{"uncapped": [...], "capped": [...]}`` with each list + holding parsed rule dicts. Both fields are always present so + callers can iterate without key checks. Multiple rules per type + supported (a plan could ship two different windowed bonuses). + """ + out: dict = {"uncapped": [], "capped": []} + for inc in incentives or []: + for field in ("eligibility", "description"): + text = (inc.get(field) or "").strip() + if not text: + continue + capped = parse_capped_window(text) + if capped: + capped["source_displayName"] = inc.get("displayName") or "" + out["capped"].append(capped) + break # one rule per incentive + uncapped = parse_uncapped_window(text) + if uncapped: + uncapped["source_displayName"] = inc.get("displayName") or "" + out["uncapped"].append(uncapped) + break + return out diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/ev_offpeak.py b/custom_components/pricehawk/cdr/incentive_parsers/common/ev_offpeak.py new file mode 100644 index 0000000..d9b8e49 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/ev_offpeak.py @@ -0,0 +1,132 @@ +"""EV off-peak rate override — Phase 2.11.6. + +Catalog v3 finding: 165 plans across OVO and ENGIE override the normal +import rate during overnight (typically midnight-6am) with a flat low +rate to incentivise EV charging: + +| Wording (catalog-confirmed) | Rate | +|------------------------------------------------------------------------------|---------| +| "$0.045/kWh usage charge between midnight and 6am" | 4.5c | +| "$0.08/kWh between midnight and 7am, applied to all import" | 8.0c | +| "Flat $0.10/kWh from 12am to 6am, excludes controlled load" | 10c | + +Math: identical to ``free_window`` (in-window imports billed at the EV +rate instead of normal TOU peak/shoulder; credit the delta), but the +catalog phrasing diverges in two ways that need their own regex: + +1. **"usage charge" / "applied to" / "from"** wording (free_window keys + off "free electricity" or "$0 for consumption"). +2. **"midnight" / "noon" tokens** in the time window (free_window only + handles ``Xam`` / ``Xpm`` numeric tokens). + +Once parsed, the result has the same ``{"rate_c_per_kwh", "windows", +"source"}`` shape as free_window, so we reuse ``free_window.apply_rule`` +directly. + +Known limitation: "Does not apply to controlled loads" disclaimer is +ignored — we credit the rate on ALL in-window imports, slightly +over-crediting users who have a separate controlled-load circuit (hot +water / pool pump). Acceptable for v1.5.x; refining requires PriceHawk +to distinguish controlled-load kWh from regular load, which is a +larger HA-energy-config-aware change. +""" +from __future__ import annotations + +import re +from decimal import Decimal + +from .free_window import apply_rule as _apply_window_rule + +# "$0.045/kWh" optionally followed by "incl. GST" or "usage charge". +RATE_RE = re.compile( + r"\$(?P[\d.]+)\s*(?:/\s*kWh)?(?:\s+(?:incl?\.?\s*GST))?", + re.I, +) + +# Triggers that distinguish ev_offpeak from generic mid-day free_window: +TRIGGER_RE = re.compile( + r"\busage\s+charge\b|\bapplied?\s+to\s+(?:all\s+)?import\b|" + r"\bovernight\b|\bEV\s+charging\b|\bvehicle\s+charging\b", + re.I, +) + +# Window: "between X and Y" / "from X to Y", where X/Y can be midnight, +# noon, or HH(:MM)?am/pm tokens. +_TIME_TOKEN = r"(?:midnight|noon|\d{1,2}(?::\d{2})?\s*(?:am|pm))" +WINDOW_RE = re.compile( + rf"(?:between|from)\s+(?P{_TIME_TOKEN})\s*" + r"(?:-|–|—|to|and)\s*" + rf"(?P{_TIME_TOKEN})", + re.I, +) + + +def _token_to_minutes(tok: str) -> int: + """'midnight'→0, 'noon'→720, '6am'→360, '11:30pm'→1410.""" + t = tok.strip().lower() + if t == "midnight": + return 0 + if t == "noon": + return 12 * 60 + m = re.match(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)", t) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(3) == "pm": + h += 12 + minute = int(m.group(2)) if m.group(2) else 0 + return h * 60 + minute + + +def parse_rule(text: str) -> dict | None: + """Extract EV-offpeak rule from eligibility/description text. + + Returns ``None`` if no match. On match returns the same shape as + ``free_window.parse_rule``: + ``{"rate_c_per_kwh": Decimal, "windows": [(start_min, end_min)], + "source": str}`` + """ + if not text or not TRIGGER_RE.search(text): + return None + + rate_match = RATE_RE.search(text) + window_match = WINDOW_RE.search(text) + if not (rate_match and window_match): + return None + + rate_aud = Decimal(rate_match.group("rate")) + rate_c = rate_aud * Decimal("100") # $0.045 → 4.5c + + start = _token_to_minutes(window_match.group("start")) + end = _token_to_minutes(window_match.group("end")) + + return { + "rate_c_per_kwh": rate_c, + "windows": [(start, end)], + "source": text[:200], + } + + +def parse_from_incentives(incentives: list[dict]) -> list[dict]: + """Walk a plan's ``incentives[]`` and extract EV-offpeak rules. + + Same return shape as ``free_window.parse_from_incentives`` so the + caller can reuse ``free_window.apply_rule`` for the math. + """ + out: list[dict] = [] + for inc in incentives or []: + for field in ("eligibility", "description"): + text = (inc.get(field) or "").strip() + if not text: + continue + rule = parse_rule(text) + if rule: + rule["source_displayName"] = inc.get("displayName") or "" + out.append(rule) + break + return out + + +def apply_rule(rule: dict, slots: list[dict], breakdown, **kwargs) -> None: + """Delegate to free_window's apply_rule — math is identical.""" + _apply_window_rule(rule, slots, breakdown, **kwargs) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/free_window.py b/custom_components/pricehawk/cdr/incentive_parsers/common/free_window.py new file mode 100644 index 0000000..310c946 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/free_window.py @@ -0,0 +1,248 @@ +"""Free / discounted import window rules — Phase 2.11.4 + .10 polish. + +Catalog v3 finding: 214 plans across 4 retailers (GloBird, AGL, OVO, +Red) zero-rate or heavily discount imports inside specific time windows. + +Phase 2.11.10 polish: optional ``days`` filter to handle weekend-only +windows (Red BCNA Saver / Wildlife Saver). When the eligibility text +contains a day-of-week constraint, only credit slots on matching days. + +| Wording (catalog-confirmed) | Rate | +|-------------------------------------------------------------------|---------| +| "Free electricity between 11am and 2pm everyday" | $0/kWh | +| "$0.00 for consumption between 10am-2pm" | $0/kWh | +| "Free electricity usage applies from 10am to 1pm every day" | $0/kWh | +| "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am" | $0.06 | + +Math: in-window imports billed at `free_rate` instead of the plan's +normal TOU rate. Caller passes the representative normal import rate +(typically peak rate, since these incentives target high-usage hours) +so the parser can credit the difference. + +Known limitation (TODO Phase 2.11.5 polish): we use a single "normal +rate" rather than per-slot TOU lookup. For most affected plans this is +accurate because: +- GloBird ZEROHERO Flex already encodes 11-2pm as 0c in the tariff, + so normal_rate=0 in window → credit=0, no double-credit. +- OVO Free 3 / AGL Three for Free typically target the SHOULDER or + PEAK rate, so passing peak gives slightly conservative under-credit + for shoulder slots (tolerable, ~$5-15/yr error). +""" +from __future__ import annotations + +import re +from datetime import datetime +from decimal import Decimal + + +# Match either "$X.XX[/kWh]" or bare "Free electricity" before "between/from" +# Captures rate (0 if absent) and one OR two windows (joined by &). +RATE_RE = re.compile( + r"(?:\$(?P[\d.]+)(?:/kWh)?(?:\s+(?:incl?\.?\s*GST))?|" + r"(?Pfree\s+(?:electricity|usage|consumption)|" + r"(?:usage\s+)?charges?\s+(?:will\s+be\s+)?waived))", + re.I, +) +WINDOW_RE = re.compile( + r"(?:between|from)\s+" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))" + r"\s*(?:-|–|—|to|and)\s*" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))" + r"(?:\s*&\s*" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm))" + r"\s*(?:-|–|—|to|and)\s*" + r"(?P\d{1,2}(?::\d{2})?\s*(?:am|pm)))?", + re.I, +) + +# Phase 2.11.10 polish — day-of-week filter. Matches weekend-only and +# weekday-only constraints in Red BCNA Saver / Wildlife Saver wordings. +WEEKEND_RE = re.compile( + r"\b(?:weekends?\s+only|saturday\s+and\s+sunday|sat\s*&\s*sun|" + r"on\s+weekends?)\b", + re.I, +) +WEEKDAY_RE = re.compile( + r"\b(?:weekdays?\s+only|monday\s+to\s+friday|mon\s*[-–]\s*fri|" + r"on\s+weekdays?)\b", + re.I, +) +# Python datetime.weekday(): Mon=0..Sun=6 +WEEKEND_DAYS = (5, 6) +WEEKDAY_DAYS = (0, 1, 2, 3, 4) + + +def _hh_token_to_minutes(tok: str) -> int: + """'11am', '11:30am', '12pm' → minutes from midnight.""" + m = re.match(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)", tok.strip(), re.I) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(3).lower() == "pm": + h += 12 + minute = int(m.group(2)) if m.group(2) else 0 + return h * 60 + minute + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def parse_rule(eligibility: str) -> dict | None: + """Extract free/discounted import window rule from eligibility text. + + Returns None if no match. Returns + ``{"rate_c_per_kwh": Decimal, + "windows": [(start_min, end_min), ...], + "days": list[int] | None, + "source": str}`` + on match. ``rate_c_per_kwh`` is in inc-GST cents (0 for free). + ``days`` is None when rule applies every day, or a tuple of + datetime.weekday() integers (Mon=0..Sun=6) for restricted days. + """ + if not eligibility: + return None + + rate_match = RATE_RE.search(eligibility) + window_match = WINDOW_RE.search(eligibility) + if not (rate_match and window_match): + return None + + if rate_match.group("freeword"): + rate = Decimal("0") + else: + # "$0.06/kWh" → 0.06 AUD = 6 cents. "$0.00" → 0. + rate_aud = _decimal(rate_match.group("rate")) + rate = rate_aud * Decimal("100") + + windows = [( + _hh_token_to_minutes(window_match.group("start1")), + _hh_token_to_minutes(window_match.group("end1")), + )] + if window_match.group("start2"): + windows.append(( + _hh_token_to_minutes(window_match.group("start2")), + _hh_token_to_minutes(window_match.group("end2")), + )) + + # Phase 2.11.10 polish — extract weekend/weekday day filter. + days: tuple[int, ...] | None = None + if WEEKEND_RE.search(eligibility): + days = WEEKEND_DAYS + elif WEEKDAY_RE.search(eligibility): + days = WEEKDAY_DAYS + + return { + "rate_c_per_kwh": rate, + "windows": windows, + "days": days, + "source": eligibility[:200], + } + + +def _slot_minutes(ts_local: str) -> int: + local_dt = datetime.fromisoformat(ts_local) + return local_dt.hour * 60 + local_dt.minute + + +def _slot_in_any_window(ts_local: str, windows: list[tuple[int, int]]) -> bool: + """True if slot's local clock falls in ANY of the rule's windows. + + End-exclusive (matches evaluator's `slot_in_window`). Wrap-around + windows (end < start, e.g. 22:00-02:00) handled by splitting the + check to either end-of-day or start-of-day inclusion. + """ + minutes = _slot_minutes(ts_local) + for start, end in windows: + if end < start: + if minutes >= start or minutes < end: + return True + else: + if start <= minutes < end: + return True + return False + + +def _slot_matches_days(ts_local: str, days: tuple[int, ...] | None) -> bool: + """True if slot's weekday is in the allowed-days tuple. None = any day.""" + if days is None: + return True + dt = datetime.fromisoformat(ts_local) + return dt.weekday() in days + + +def apply_rule( + rule: dict, + slots: list[dict], + breakdown, + *, + normal_import_rate_c_per_kwh_inc_gst: Decimal, +) -> None: + """Credit `(normal - free_rate) × in-window imports` to incentive total. + + Args: + rule: dict from `parse_rule()`. + slots: list of slot dicts with ``ts_local`` and ``grid_import_kwh``. + breakdown: ``CostBreakdown`` instance. + normal_import_rate_c_per_kwh_inc_gst: representative normal rate + the user would pay outside the free window (typically peak). + If equal to or less than the free rate, no credit is applied. + + No-op when normal rate ≤ free rate (avoids negative credits when + the tariff already encodes the discount). + """ + free_aud = rule["rate_c_per_kwh"] / Decimal("100") + normal_aud = normal_import_rate_c_per_kwh_inc_gst / Decimal("100") + delta_aud = normal_aud - free_aud + if delta_aud <= 0: + return # tariff already discounted; nothing to credit + + days = rule.get("days") + total_kwh = Decimal("0") + for slot in slots: + if not _slot_in_any_window(slot["ts_local"], rule["windows"]): + continue + if not _slot_matches_days(slot["ts_local"], days): + continue + imp = _decimal(slot.get("grid_import_kwh", 0)) + if imp <= 0: + continue + breakdown.incentive_aud_inc_gst -= imp * delta_aud + total_kwh += imp + + if total_kwh > 0: + windows_str = " & ".join( + f"{s//60:02d}:{s%60:02d}-{e//60:02d}:{e%60:02d}" + for s, e in rule["windows"] + ) + breakdown.trace.append({ + "incentive": "free_window", + "free_rate_c_per_kwh": float(rule["rate_c_per_kwh"]), + "normal_rate_c_per_kwh": float(normal_import_rate_c_per_kwh_inc_gst), + "credited_kwh": float(total_kwh), + "windows": windows_str, + }) + + +def parse_from_incentives(incentives: list[dict]) -> list[dict]: + """Walk a plan's ``incentives[]`` and extract any free-window rules. + + Returns a list (a plan may ship multiple windowed-discount rules, + e.g. GloBird Nine-hour low EV rate has two non-contiguous windows + in a single rule, OR a plan could combine 'Free 3' + 'Free 6' + incentives — both surface here). + """ + out: list[dict] = [] + for inc in incentives or []: + for field in ("eligibility", "description"): + text = (inc.get(field) or "").strip() + if not text: + continue + rule = parse_rule(text) + if rule: + rule["source_displayName"] = inc.get("displayName") or "" + out.append(rule) + break # one rule per incentive + return out diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/ovo_interest.py b/custom_components/pricehawk/cdr/incentive_parsers/common/ovo_interest.py new file mode 100644 index 0000000..e62c7dc --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/ovo_interest.py @@ -0,0 +1,117 @@ +"""OVO Interest Rewards — Phase 2.11.7. + +Catalog v3 finding: 324 OVO plans publish "Interest Rewards": + "3% interest on credit balances. Paid monthly to your OVO account." + +This is a behaviour-based credit, not a kWh-rate credit. Math depends +on the user's payment pattern (prepayment / overpayment carrying a +positive balance for X days at Y average). We can't observe this from +HA energy data alone — it requires user-side config (typical credit +balance held with OVO) OR a billing-API hook (not yet shipped). + +v1.5.x behaviour: +- Parser DETECTS the incentive presence in eligibility text. +- Returns a rule with ``annual_rate_pct`` and ``balance_aud`` (default + $0 = no credit). Users opt-in via a future options-flow step that + sets ``balance_aud`` to their typical OVO credit balance. +- apply_rule credits ``balance_aud × annual_rate_pct / 100 / 365`` per + day to ``incentive_aud_inc_gst``. + +Conservative default. A user with $100 average balance at 3% APR earns +$3/year, prorated to ~$0.008/day. A user opting in at $500 balance +earns ~$15/year. The catalog's typical "low impact" guidance (~$10-30/ +yr per user) bracketed by this range. +""" +from __future__ import annotations + +import re +from decimal import Decimal + + +# Match "3% interest" or "5% APR" variants. +INTEREST_RE = re.compile( + r"(?P[\d.]+)\s*%\s*(?:interest|APR|annual)", + re.I, +) +TRIGGER_RE = re.compile( + r"\bcredit\s+balance\b|\binterest\s+(?:rewards?|on)\b|\baccount\s+balance\b", + re.I, +) + + +def parse_rule(text: str, balance_aud: Decimal = Decimal("0")) -> dict | None: + """Detect OVO-style interest-on-balance rule. + + Args: + text: incentive eligibility/description string. + balance_aud: user-supplied average credit balance (opt-in, default 0). + + Returns ``None`` when no match. On match returns + ``{"annual_rate_pct": Decimal, "balance_aud": Decimal, + "source": str}``. + + If ``balance_aud`` is 0, ``apply_rule`` will be a no-op. + """ + if not text or not TRIGGER_RE.search(text): + return None + + m = INTEREST_RE.search(text) + if not m: + return None + + return { + "annual_rate_pct": Decimal(m.group("pct")), + "balance_aud": Decimal(balance_aud), + "source": text[:200], + } + + +def parse_from_incentives( + incentives: list[dict], + balance_aud: Decimal = Decimal("0"), +) -> list[dict]: + """Walk a plan's ``incentives[]`` for interest-on-balance rules.""" + out: list[dict] = [] + for inc in incentives or []: + for field in ("eligibility", "description"): + text = (inc.get(field) or "").strip() + if not text: + continue + rule = parse_rule(text, balance_aud) + if rule: + rule["source_displayName"] = inc.get("displayName") or "" + out.append(rule) + break + return out + + +def apply_rule(rule: dict, slots: list[dict], breakdown) -> None: + """Credit daily interest on average credit balance per covered day. + + Per-day credit = balance × annual_rate / 100 / 365. + No-op when balance_aud is 0 (user hasn't opted in). + + Phase 3.0g (CodeRabbit): scale by number of distinct days in + `slots`. Previous version subtracted daily_credit ONCE for any + multi-day window, systematically under-crediting interest on + weekly/monthly/yearly evaluations. + """ + balance = rule.get("balance_aud", Decimal("0")) + rate_pct = rule.get("annual_rate_pct", Decimal("0")) + if balance <= 0 or rate_pct <= 0: + return + + distinct_days = {s.get("ts_local", "")[:10] for s in slots if s.get("ts_local")} + n_days = max(1, len(distinct_days)) + + daily_credit_aud = balance * rate_pct / Decimal("100") / Decimal("365") + total_credit_aud = daily_credit_aud * Decimal(n_days) + breakdown.incentive_aud_inc_gst -= total_credit_aud + breakdown.trace.append({ + "incentive": "ovo_interest", + "balance_aud": float(balance), + "annual_rate_pct": float(rate_pct), + "daily_credit_aud": float(daily_credit_aud), + "days_covered": n_days, + "total_credit_aud": float(total_credit_aud), + }) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/tiered_fit.py b/custom_components/pricehawk/cdr/incentive_parsers/common/tiered_fit.py new file mode 100644 index 0000000..c2e53d1 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/tiered_fit.py @@ -0,0 +1,222 @@ +"""Tiered solar feed-in tariff rule — Phase 2.11. + +Catalog v3 finding: 210 plans across 5 retailers (Origin, AGL, Alinta, +EnergyAustralia, GloBird) publish "first N kWh at rate1 c/kWh, rest at +rate2 c/kWh" tiered FIT as a free-text incentive instead of structuring +it under `solarFeedInTariff[]`. Without this parser the evaluator misses +the higher tier-1 rate entirely. + +Two retailer dialects observed: + +1. **Daily cap** (AGL, Alinta, GloBird ZEROHERO-VPP variants): + "first 10 kWh exported each day at 6c/kWh, then 1.5c/kWh for the rest + of that day". Cap resets every midnight. + +2. **Billing-period cap** (Origin, EnergyAustralia Solar Max): + "12 cents per kWh until a daily export limit of 8 kWh is reached. + The daily export limit is averaged across your billing period". Real + cap is `8 × num_days_in_period` kWh, pooled across the whole period. + Users can over-export early in the month and still hit tier-1 rate + on later days, as long as the period total stays under the pool. + +Both dialects credit to `breakdown.incentive_aud_inc_gst` as the DELTA +above base FIT. Base FIT is already credited by the core evaluator via +`solarFeedInTariff[]` — this parser only adds the top-up. +""" +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Literal + +# Rate-first: "X cents/kWh ... until N kWh" (Alinta, Origin) +# Allow optional filler between the trigger word and the cap number to +# catch Origin's "until a daily export limit of 8 kWh" wording. +RATE_FIRST_RE = re.compile( + r"(?P[\d.]+)\s*c(?:ents)?(?:[\s/]+(?:per\s+)?kWh)?\s+" + r"(?:until|for\s+the\s+first|for\s+a?\s*daily\s+export\s+limit\s+of)" + r"[^.]{0,60}?(?P[\d.]+)\s*kW(?:h)?", + re.I | re.S, +) + +# Quantity-first: "first N kWh ... at X c/kWh ... then Y c/kWh" (AGL) +QUANTITY_FIRST_RE = re.compile( + r"first\s+(?P[\d.]+)\s*kW(?:h)?\s+(?:exported\s+)?" + r"(?:each\s+day|per\s+day|daily)?[^.]{0,80}?" + r"(?P[\d.]+)\s*c(?:ents)?(?:[\s/]+(?:per\s+)?kWh).{0,80}?" + r"(?:then|after|remaining)[^.]{0,80}?" + r"(?P[\d.]+)\s*c(?:ents)?(?:[\s/]+(?:per\s+)?kWh)", + re.I | re.S, +) + +# Period detector — words that signal billing-period pooling vs strict daily +PERIOD_AVERAGED_RE = re.compile( + r"averaged\s+across\s+your\s+billing\s+period|" + r"averaged\s+by\s+dividing.+?billing\s+period", + re.I | re.S, +) + + +CapWindow = Literal["DAY", "PERIOD"] + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def parse_rule(eligibility: str) -> dict | None: + """Extract a tiered-FIT rule from one incentive's free-text. + + Returns ``None`` if the text doesn't match either dialect. Returns + ``{"tier1_c_per_kwh": Decimal, "cap_kwh": Decimal, + "tier2_c_per_kwh": Decimal | None, "cap_window": "DAY"|"PERIOD", + "source": str}`` otherwise. + + Tier-2 rate is ``None`` for rate-first matches that don't specify + an explicit second rate — caller falls back to base FIT. + """ + if not eligibility: + return None + + cap_window: CapWindow = "PERIOD" if PERIOD_AVERAGED_RE.search(eligibility) else "DAY" + + m = QUANTITY_FIRST_RE.search(eligibility) + if m: + return { + "tier1_c_per_kwh": _decimal(m.group("rate1")), + "cap_kwh": _decimal(m.group("cap")), + "tier2_c_per_kwh": _decimal(m.group("rate2")), + "cap_window": cap_window, + "source": eligibility[:200], + } + + m = RATE_FIRST_RE.search(eligibility) + if m: + return { + "tier1_c_per_kwh": _decimal(m.group("rate1")), + "cap_kwh": _decimal(m.group("cap")), + "tier2_c_per_kwh": None, # caller uses base FIT + "cap_window": cap_window, + "source": eligibility[:200], + } + + return None + + +def apply_rule( + rule: dict, + slots: list[dict], + breakdown, + *, + base_fit_c_per_kwh: Decimal, +) -> None: + """Credit tier-1 export above base FIT to ``incentive_aud_inc_gst``. + + Args: + rule: dict from ``parse_rule()``. + slots: list of slot dicts with ``ts_local`` (ISO local) and + either ``grid_export_kwh`` or ``solar_export_kwh``. + breakdown: ``CostBreakdown`` instance; mutated in-place. The + ``incentive_aud_inc_gst`` field is DECREASED (more negative = + bigger user credit, matching the AGL/GloBird convention). + base_fit_c_per_kwh: Base FIT already credited by the core + evaluator from ``solarFeedInTariff[]``. Used to compute the + delta on tier-1 exports. + + Math semantics: + DAY window — cap resets every local midnight. Sum exports per + day, credit (min(daily_export, cap) × (tier1 - base_fit)) plus + the tier-2 delta on any overflow. + + PERIOD window — cap pooled across all slots passed in. Multiply + cap by number of distinct days observed to honour the + "8 kWh averaged across billing period" wording. + + Numerics: all math in Decimal. Convert c/kWh → AUD/kWh via /100. + """ + tier1_aud = rule["tier1_c_per_kwh"] / Decimal("100") + tier2_c = rule["tier2_c_per_kwh"] + tier2_aud = tier2_c / Decimal("100") if tier2_c is not None else None + base_aud = base_fit_c_per_kwh / Decimal("100") + cap = rule["cap_kwh"] + window = rule["cap_window"] + + if window == "PERIOD": + # KNOWN LIMITATION (CR review, tracked for proper fix): + # multiplying ``cap`` by distinct days in slots is correct ONLY + # when the evaluation window spans whole billing periods. A + # 7-day eval against a monthly period under-credits by a factor + # of ~4×; a partial-into-next-period eval over-credits. Correct + # fix needs ``electricityContract.billingPeriod`` (ISO-8601 + # duration) parsed at plan-load time, then ``effective_cap = + # cap * ceil(slot_days / period_days) * period_days`` — out of + # scope for the CR-fix commit, tracked in a follow-up issue. + days = {slot["ts_local"][:10] for slot in slots} + effective_cap = cap * Decimal(len(days)) if days else cap + else: + effective_cap = cap + + by_day: dict[str, list[dict]] = {} + for slot in slots: + by_day.setdefault(slot["ts_local"][:10], []).append(slot) + + period_credited = Decimal("0") + period_overflow = Decimal("0") + + for _day, day_slots in sorted(by_day.items()): + day_export = Decimal("0") + for slot in day_slots: + exp = _decimal( + slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) + ) + if exp > 0: + day_export += exp + + if day_export <= 0: + continue + + if window == "DAY": + credited = min(day_export, cap) + overflow = max(Decimal("0"), day_export - cap) + else: + remaining = effective_cap - period_credited + credited = min(day_export, max(Decimal("0"), remaining)) + overflow = day_export - credited + + # Delta credit on tier-1 export: tier1 - base_fit + if credited > 0: + delta1 = (tier1_aud - base_aud) * credited + breakdown.incentive_aud_inc_gst -= delta1 + period_credited += credited + + # Tier-2 delta only if explicit rate provided AND differs from base + if overflow > 0 and tier2_aud is not None and tier2_aud != base_aud: + delta2 = (tier2_aud - base_aud) * overflow + breakdown.incentive_aud_inc_gst -= delta2 + period_overflow += overflow + + if period_credited > 0 or period_overflow > 0: + breakdown.trace.append({ + "incentive": "tiered_fit", + "cap_window": window, + "tier1_kwh": float(period_credited), + "tier1_c_per_kwh": float(rule["tier1_c_per_kwh"]), + "tier2_kwh": float(period_overflow), + "tier2_c_per_kwh": float(tier2_c) if tier2_c is not None else None, + }) + + +def parse_from_incentives(incentives: list[dict]) -> dict | None: + """Walk a plan's ``incentives[]`` and return the first tiered-FIT + rule found. Checks both ``description`` and ``eligibility`` fields + because retailers publish the math in either slot. + """ + for inc in incentives or []: + for field in ("eligibility", "description"): + rule = parse_rule((inc.get(field) or "").strip()) + if rule: + rule["source_displayName"] = inc.get("displayName") or "" + return rule + return None diff --git a/custom_components/pricehawk/cdr/incentive_parsers/common/vpp_rebate.py b/custom_components/pricehawk/cdr/incentive_parsers/common/vpp_rebate.py new file mode 100644 index 0000000..babb0d1 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/common/vpp_rebate.py @@ -0,0 +1,136 @@ +"""VPP-enrolment rebate parser — Phase 2.11.5. + +Catalog v3 finding: 687 plans across ENGIE PowerResponse + EnergyAustralia +PowerResponse offer a fixed monthly credit per battery enrolled in the +retailer's Virtual Power Plant programme. + +| Wording (catalog-confirmed) | Rebate | +|----------------------------------------------------------------------|------------| +| "$15 monthly credit per battery for participating in our VPP" | $15/mo | +| "Enrol your battery in PowerResponse and earn $20/month per kWh*" | $20/mo/kWh | +| "Receive $0.10/kWh for each kWh discharged during VPP events" | $0.10/kWh | + +The "$X/month per battery" pattern is the dominant shape (615 of 687 +plans use it). The kWh-throughput variants are rarer and require event +tracking — defer to Phase 2.11.9 critical-peak parser since the math +overlaps. + +**Opt-in semantic**: The credit only flows if the user has actually +enrolled their battery via the retailer's onboarding. PriceHawk can't +know enrolment status from CDR data alone — needs user-side config. +Default ``batteries_enrolled = 0`` → no credit. User opts in via future +options-flow field. Same pattern as ovo_interest (Phase 2.11.7). + +Math when opted in: + daily_credit_aud = (monthly_rebate_aud × batteries_enrolled) / 30 + +(30-day month approximation; over a year averages within $0.20 of +actual calendar-month math, acceptable for v1.5.x.) +""" +from __future__ import annotations + +import re +from decimal import Decimal + + +# Match "$15 monthly credit per battery" / "$20/month per battery" / etc. +# Phase 3.0g (CodeRabbit): per_kwh removed — battery-count math doesn't +# apply to kWh-throughput rebates. Those land in critical_peak.py +# (Phase 2.11.9) when shipped, not here. +REBATE_RE = re.compile( + r"\$(?P[\d.]+)\s*(?:/\s*month|\s+monthly|\s+per\s+month)\s+" + r"(?:credit\s+)?(?:per\s+battery|each\s+battery)", + re.I, +) +TRIGGER_RE = re.compile( + r"\bVPP\b|\bvirtual\s+power\s+plant\b|\bPowerResponse\b|\benrol\w*\s+(?:your\s+)?battery\b", + re.I, +) + + +def parse_rule( + text: str, + batteries_enrolled: int = 0, +) -> dict | None: + """Detect VPP-rebate rule with opt-in battery count. + + Returns ``None`` when no match. On match: + ``{"monthly_rebate_aud": Decimal, "batteries_enrolled": int, + "source": str}`` + + ``batteries_enrolled = 0`` → ``apply_rule`` no-ops. + """ + if not text or not TRIGGER_RE.search(text): + return None + + m = REBATE_RE.search(text) + if not m: + return None + + # CR-fix: harden batteries_enrolled coercion. Bare ``int(...)`` on a + # user-supplied option value blows up on garbage and aborts plan + # evaluation for every other retailer too. Fail closed to 0 (= no + # credit) instead. + from .. import safe_int # local import: avoid circular at module load + enrolled = safe_int(batteries_enrolled, default=0) + if enrolled < 0: + enrolled = 0 + + return { + "monthly_rebate_aud": Decimal(m.group("rebate")), + "batteries_enrolled": enrolled, + "source": text[:200], + } + + +def parse_from_incentives( + incentives: list[dict], + batteries_enrolled: int = 0, +) -> list[dict]: + """Walk a plan's ``incentives[]`` and extract VPP-rebate rules.""" + out: list[dict] = [] + for inc in incentives or []: + for field in ("eligibility", "description"): + text = (inc.get(field) or "").strip() + if not text: + continue + rule = parse_rule(text, batteries_enrolled) + if rule: + rule["source_displayName"] = inc.get("displayName") or "" + out.append(rule) + break + return out + + +def apply_rule(rule: dict, slots: list[dict], breakdown) -> None: + """Credit prorated monthly VPP rebate (per battery × month) per + covered day in `slots`. + + No-op when batteries_enrolled is 0. Daily proration uses 30-day + month — within $0.20/yr of calendar-month accuracy. + + Phase 3.0g (CodeRabbit): scale by number of distinct days covered + by `slots`. Previous version subtracted daily_credit ONCE even + when slots spanned multiple days, systematically under-crediting + every multi-day evaluation window (e.g., 7-day backfill, monthly + ranking). + """ + batteries = rule.get("batteries_enrolled", 0) + rebate = rule.get("monthly_rebate_aud", Decimal("0")) + if batteries <= 0 or rebate <= 0: + return + + distinct_days = {s.get("ts_local", "")[:10] for s in slots if s.get("ts_local")} + n_days = max(1, len(distinct_days)) + + daily_credit_aud = (rebate * Decimal(batteries)) / Decimal("30") + total_credit_aud = daily_credit_aud * Decimal(n_days) + breakdown.incentive_aud_inc_gst -= total_credit_aud + breakdown.trace.append({ + "incentive": "vpp_rebate", + "monthly_rebate_aud": float(rebate), + "batteries_enrolled": batteries, + "daily_credit_aud": float(daily_credit_aud), + "days_covered": n_days, + "total_credit_aud": float(total_credit_aud), + }) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/energyaustralia.py b/custom_components/pricehawk/cdr/incentive_parsers/energyaustralia.py new file mode 100644 index 0000000..b91a41f --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/energyaustralia.py @@ -0,0 +1,57 @@ +"""EnergyAustralia incentive parser — Phase 2.11.2 + 2.11.5. + +Catalog v3 finding: 20 EA "Solar Max" plans + ~600 with VPP rebates. + +Phase 2.11.2 shipped TIERED FIT (Solar Max). Phase 2.11.5 adds +PowerResponse VPP rebate detection (opt-in via batteries_enrolled +options-flow field, default 0 = no credit). +""" +from __future__ import annotations + +from typing import Callable + +from .common import base_fit_c_per_kwh_inc_gst +from .common.tiered_fit import apply_rule as _apply_tiered_fit +from .common.tiered_fit import parse_from_incentives as _parse_tiered_fit +from .common.vpp_rebate import ( + apply_rule as _apply_vpp, + parse_from_incentives as _parse_vpp, +) + + +def parse_rules(plan_data: dict, entry_options: dict | None = None) -> dict: + elec = plan_data.get("electricityContract") or {} + opts = entry_options or {} + rules: dict = {} + rule = _parse_tiered_fit(elec.get("incentives") or []) + if rule: + rules["tiered_fit"] = rule + from . import safe_int + batteries = safe_int(opts.get("vpp_batteries_enrolled")) + vpp = _parse_vpp(elec.get("incentives") or [], batteries_enrolled=batteries) + if vpp: + rules["vpp"] = vpp + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + entry_options: dict | None = None, +) -> None: + del slot_in_window + rules = parse_rules(plan_data, entry_options=entry_options) + if not rules: + return + breakdown.notes.append(f"energyaustralia parser hits: {list(rules.keys())}") + if "tiered_fit" in rules: + _apply_tiered_fit( + rules["tiered_fit"], slots, breakdown, + base_fit_c_per_kwh=base_fit_c_per_kwh_inc_gst(plan_data), + ) + if "vpp" in rules: + for vpp_rule in rules["vpp"]: + _apply_vpp(vpp_rule, slots, breakdown) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/engie.py b/custom_components/pricehawk/cdr/incentive_parsers/engie.py new file mode 100644 index 0000000..df729a2 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/engie.py @@ -0,0 +1,71 @@ +"""ENGIE Australia incentive parser — Phase 2.11.5 + 2.11.6. + +Catalog v3 findings for ENGIE: +- 165 of 165 ENGIE plans ship "EV Plan" overnight rate override: + "$0.08/kWh between midnight and 7am. Does not apply to controlled + loads." → ev_offpeak.py handles this. +- 687 of 687 ENGIE PowerResponse VPP plans ship a $15/month battery-VPP + rebate. Opt-in only (user must enroll their Powerwall/battery via the + ENGIE PowerResponse onboarding). Phase 2.11.5 defers VPP — needs + config-flow toggle to flip user-side opt-in state. + +This file ships Phase 2.11.6 only. VPP rebate adds to this same file +once Phase 2.11.5 lands its config-flow toggle pattern. + +Brand slug: `engie-au` (catalog-confirmed via CDR brand registry). +""" +from __future__ import annotations + +from typing import Callable + +from .common import peak_import_rate_c_per_kwh_inc_gst +from .common.ev_offpeak import ( + apply_rule as _apply_ev_offpeak, + parse_from_incentives as _parse_ev_offpeak, +) +from .common.vpp_rebate import ( + apply_rule as _apply_vpp, + parse_from_incentives as _parse_vpp, +) + + +def parse_rules(plan_data: dict, entry_options: dict | None = None) -> dict: + elec = plan_data.get("electricityContract") or {} + opts = entry_options or {} + rules: dict = {} + evs = _parse_ev_offpeak(elec.get("incentives") or []) + if evs: + rules["ev_offpeak"] = evs + # Phase 2.12.1: opt-in batteries_enrolled flows through entry_options. + from . import safe_int + batteries = safe_int(opts.get("vpp_batteries_enrolled")) + vpp = _parse_vpp(elec.get("incentives") or [], batteries_enrolled=batteries) + if vpp: + rules["vpp"] = vpp + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + entry_options: dict | None = None, +) -> None: + del slot_in_window + rules = parse_rules(plan_data, entry_options=entry_options) + if not rules: + return + breakdown.notes.append(f"engie parser hits: {list(rules.keys())}") + peak_rate = peak_import_rate_c_per_kwh_inc_gst(plan_data) + if "ev_offpeak" in rules: + for ev in rules["ev_offpeak"]: + _apply_ev_offpeak( + ev, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) + if "vpp" in rules: + # Default batteries_enrolled=0 → no-op until user opts in. + for vpp_rule in rules["vpp"]: + _apply_vpp(vpp_rule, slots, breakdown) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/globird.py b/custom_components/pricehawk/cdr/incentive_parsers/globird.py new file mode 100644 index 0000000..1ae4bde --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/globird.py @@ -0,0 +1,220 @@ +"""GloBird incentive parser. + +Extracts structured rules from `incentives[].description` free-text and +applies per-day credits to a `CostBreakdown.incentive_aud_inc_gst`. + +Phase 0 + Phase 1 scope (v1.5.0): + - ZEROHERO Credit: $1/Day when imports during 6-8pm avg ≤ 0.03 kWh/h + - Super Export Credit: 15 c/kWh on first 10 kWh exports in 6-8pm window + +Deferred to v1.5.1 (TODOS.md): + - FOUR4FREE explicit parser (currently the free 11am-2pm window is + encoded as 0c/kWh in the FLEXIBLE tariff itself, so no separate + credit math needed for ZEROHERO-Combo-FOUR4FREE plans) + - Critical Peak Export/Import (event schedule API not available) + +Source for regex patterns: GloBird Victorian Energy Fact Sheets +(Victorian_Energy_Fact_Sheet_GLO707520MR_Electricity_CZ_6.pdf and +relatives). Hand-merged into CDR fixture per D-P0-5 because EME proxy +strips incentive descriptions to displayName-only stubs. +""" +from __future__ import annotations + +import re +from datetime import datetime +from decimal import Decimal +from typing import Callable + +ZEROHERO_RE = re.compile( + r"\$(?P[\d.]+)\s*/?\s*Day\s+when\s+imports\s+are\s+(?P[\d.]+)" + r"\s+kWh/hour\s+or\s+less[,]?\s+between\s+" + r"(?P\d{1,2}(?:am|pm))-(?P\d{1,2}(?:am|pm))", + re.I, +) +SUPER_EXPORT_RE = re.compile( + r"(?P[\d.]+)\s*cents/kWh\s+applies\s+to\s+the\s+first\s+(?P[\d.]+)" + r"\s+kWh\s+of\s+exports\s+between\s+" + r"(?P\d{1,2}(?:am|pm))-(?P\d{1,2}(?:am|pm))", + re.I, +) + + +def _hh_token_to_minutes(tok: str) -> int: + m = re.match(r"(\d{1,2})(am|pm)", tok.strip(), re.I) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(2).lower() == "pm": + h += 12 + return h * 60 + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def parse_rules(plan_data: dict) -> dict: + """Return parsed-rule dict from CDR `incentives` descriptions. + + Keys: "zerohero", "super_export" — each maps to a dict of structured + fields the apply step uses. Missing patterns are silently skipped. + """ + elec = plan_data.get("electricityContract", {}) or {} + rules: dict = {} + for inc in elec.get("incentives", []) or []: + desc = inc.get("description") or "" + name = (inc.get("displayName") or "").upper() + + m = ZEROHERO_RE.search(desc) + if m and "ZEROHERO" in name: + rules["zerohero"] = { + "credit_aud_per_day": Decimal(m.group("aud")), + "max_kwh_per_hour": Decimal(m.group("thresh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source_displayName": inc.get("displayName"), + } + + m = SUPER_EXPORT_RE.search(desc) + if m and "SUPER" in name: + rules["super_export"] = { + "cents_per_kwh": Decimal(m.group("cents")), + "first_kwh_per_day": Decimal(m.group("kwh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source_displayName": inc.get("displayName"), + } + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, # CostBreakdown forward ref + *, + slot_in_window: Callable, # unused now — kept for parser-API uniformity + **_extra, # absorb entry_options from dispatcher; GloBird has no opt-ins +) -> None: + """Apply ZEROHERO + Super Export + Peak FIT credits. + + Three rules combined: + - ZEROHERO Credit: $1/day if behavioral threshold met + - Super Export: 15c/kWh first 15kWh exports 6-9pm + - Peak FIT (Phase 2.11.3): 2c/kWh all exports 4-11pm — wired via + common.bonus_fit.parse_uncapped_window from CDR `eligibility` + + `slot_in_window` is the dependency-injected window matcher from the + evaluator. Currently unused by this parser (uses minute-based windows + parsed from PDF "6pm-8pm" tokens, not CDR HH:MM windows) but kept + in the signature so future GloBird parser extensions can match the + same TOU resolver semantics. + + Phase 2.11.3 known gap: Super Export and Peak FIT overlap in 6-9pm + window. Both credit additively, over-counting Peak FIT for first + 15 kWh of 6-9pm exports by ~$5-30/yr. Refinement deferred to 2.11.4. + """ + del slot_in_window # reserved, see docstring + rules = parse_rules(plan_data) + + # Phase 2.11.3 — extract Peak FIT (uncapped windowed bonus) from + # eligibility text, additive on top of base FIT and Super Export. + from .common import peak_import_rate_c_per_kwh_inc_gst + from .common.bonus_fit import ( + apply_uncapped_window, + parse_from_incentives as _parse_bonus_fit, + ) + from .common.free_window import ( + apply_rule as _apply_free_window, + parse_from_incentives as _parse_free_windows, + ) + elec = plan_data.get("electricityContract") or {} + bonus_fit_rules = _parse_bonus_fit(elec.get("incentives") or []) + free_window_rules = _parse_free_windows(elec.get("incentives") or []) + + if not rules and not bonus_fit_rules["uncapped"] and not free_window_rules: + return + rule_names = list(rules.keys()) + if bonus_fit_rules["uncapped"]: + rule_names.append("peak_fit") + if free_window_rules: + rule_names.append("free_window") + breakdown.notes.append(f"globird parser hits: {rule_names}") + + for peak_rule in bonus_fit_rules["uncapped"]: + apply_uncapped_window(peak_rule, slots, breakdown) + + # Phase 2.11.4 — free / discounted import windows (3-for-Free, + # Four-hour free, Nine-hour low EV rate). Credit (peak - free_rate) + # × in-window imports. + if free_window_rules: + peak_rate = peak_import_rate_c_per_kwh_inc_gst(plan_data) + for fw in free_window_rules: + _apply_free_window( + fw, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) + + # Group slots by local-date once + by_day: dict[str, list[dict]] = {} + for slot in slots: + by_day.setdefault(slot["ts_local"][:10], []).append(slot) + + if "zerohero" in rules: + rule = rules["zerohero"] + for day, day_slots in by_day.items(): + window_kwh = Decimal("0") + window_hours = Decimal("0") + for slot in day_slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + minutes = local_dt.hour * 60 + local_dt.minute + if rule["start_min"] <= minutes < rule["end_min"]: + window_kwh += _decimal(slot.get("grid_import_kwh", 0)) + window_hours += Decimal("0.5") + if window_hours == 0: + continue + avg_per_hour = window_kwh / window_hours + if avg_per_hour <= rule["max_kwh_per_hour"]: + breakdown.incentive_aud_inc_gst -= rule["credit_aud_per_day"] + breakdown.trace.append({ + "incentive": "zerohero", + "day": day, + "window_kwh": float(window_kwh), + "window_hours": float(window_hours), + "avg_kwh_h": float(avg_per_hour), + "credited_aud_inc_gst": float(rule["credit_aud_per_day"]), + }) + + if "super_export" in rules: + rule = rules["super_export"] + # Phase 2.11.10 overlap fix: catalog says Super Export is + # "inclusive of any other Feed-in tariff as applicable in + # Energy Plan." When a Peak FIT bonus also credits the Super + # Export window (ZEROHERO: Peak 4-11pm ⊃ Super 6-9pm), the Peak + # rate is already credited — net Super rate is the DELTA above + # Peak so the total comes out to capped_rate, not capped+peak. + overlap_peak_c = Decimal("0") + for peak in bonus_fit_rules["uncapped"]: + if (peak["start_min"] <= rule["start_min"] + and peak["end_min"] >= rule["end_min"]): + overlap_peak_c = peak["bonus_c_per_kwh"] + break + net_super_rate_c = rule["cents_per_kwh"] - overlap_peak_c + rate_per_kwh = net_super_rate_c / Decimal("100") # inc-GST $/kWh + for day, day_slots in by_day.items(): + day_credited_kwh = Decimal("0") + for slot in day_slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + minutes = local_dt.hour * 60 + local_dt.minute + if not (rule["start_min"] <= minutes < rule["end_min"]): + continue + exp = _decimal(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0)) + if exp <= 0: + continue + remaining = rule["first_kwh_per_day"] - day_credited_kwh + if remaining <= 0: + break + credit_kwh = min(exp, remaining) + breakdown.incentive_aud_inc_gst -= credit_kwh * rate_per_kwh + day_credited_kwh += credit_kwh diff --git a/custom_components/pricehawk/cdr/incentive_parsers/origin.py b/custom_components/pricehawk/cdr/incentive_parsers/origin.py new file mode 100644 index 0000000..24f960c --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/origin.py @@ -0,0 +1,53 @@ +"""Origin Energy incentive parser — Phase 2.11.2. + +Catalog v3 finding: 84 Origin plans publish tiered FIT as +"Solar feed-in tariffs" incentive with eligibility text: + "Origin offers 12 cents per kWh until a daily export limit of + 8 kWh is reached. The daily export limit is averaged across your + billing period (calculated by multiplying the number of days in + your billing period by your daily export limit of 8)" + +Cap is monthly-averaged → cap_window: PERIOD in tiered_fit. Real cap +across a billing period = `8 × num_days_in_period` kWh. + +No other Origin-specific patterns extracted in v1.5.0 — the rest of +Origin's incentives are loyalty / sign-up / GreenPower (out-of-scope +per user direction). Phase 2.11.2 ships tiered FIT only. +""" +from __future__ import annotations + +from typing import Callable + +from .common import base_fit_c_per_kwh_inc_gst +from .common.tiered_fit import apply_rule, parse_from_incentives + + +def parse_rules(plan_data: dict) -> dict: + """Extract structured rule dicts from Origin incentives free-text.""" + elec = plan_data.get("electricityContract") or {} + rules: dict = {} + rule = parse_from_incentives(elec.get("incentives") or []) + if rule: + rules["tiered_fit"] = rule + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + **_extra, +) -> None: + """Credit Origin tiered FIT delta to ``breakdown.incentive_aud_inc_gst``.""" + del slot_in_window # not used by tiered_fit + rules = parse_rules(plan_data) + if not rules: + return + breakdown.notes.append(f"origin parser hits: {list(rules.keys())}") + if "tiered_fit" in rules: + apply_rule( + rules["tiered_fit"], slots, breakdown, + base_fit_c_per_kwh=base_fit_c_per_kwh_inc_gst(plan_data), + ) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/ovo.py b/custom_components/pricehawk/cdr/incentive_parsers/ovo.py new file mode 100644 index 0000000..0d676bd --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/ovo.py @@ -0,0 +1,87 @@ +"""OVO Energy incentive parser — Phase 2.11.4 + 2.11.6. + +Catalog v3 finding: 38 OVO/MYOB plans publish "Free 3" incentive: + "Free electricity between 11am and 2pm everyday." + +Plus 165 OVO + ENGIE plans publish "EV Off-Peak": + "$0.045/kWh usage charge between midnight and 6am." + +OVO also ships: +- "Interest Rewards" — 3% interest on credit balances (Phase 2.11.7) + +Phase 2.11.4 shipped free_window only. Phase 2.11.6 adds EV off-peak. +Interest-on-balance defers to ovo_interest.py (Phase 2.11.7). + +Brand slug for both OVO Energy + MYOB powered by OVO is `ovo-energy` +(catalog confirms; MYOB is a co-brand on the same CDR base URI). +""" +from __future__ import annotations + +from typing import Callable + +from .common import peak_import_rate_c_per_kwh_inc_gst +from .common.ev_offpeak import ( + apply_rule as _apply_ev_offpeak, + parse_from_incentives as _parse_ev_offpeak, +) +from .common.free_window import ( + apply_rule as _apply_free_window, + parse_from_incentives as _parse_free_windows, +) +from .common.ovo_interest import ( + apply_rule as _apply_ovo_interest, + parse_from_incentives as _parse_ovo_interest, +) + + +def parse_rules(plan_data: dict, entry_options: dict | None = None) -> dict: + elec = plan_data.get("electricityContract") or {} + opts = entry_options or {} + rules: dict = {} + fws = _parse_free_windows(elec.get("incentives") or []) + if fws: + rules["free_windows"] = fws + evs = _parse_ev_offpeak(elec.get("incentives") or []) + if evs: + rules["ev_offpeak"] = evs + # Phase 2.12.1 + 3.0g (CodeRabbit): defensive Decimal cast for + # user-supplied opt-in field. Garbage / None / "" → 0 (no credit). + from . import safe_decimal + balance = safe_decimal(opts.get("ovo_interest_balance_aud")) + interest = _parse_ovo_interest(elec.get("incentives") or [], balance_aud=balance) + if interest: + rules["interest"] = interest + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + entry_options: dict | None = None, +) -> None: + del slot_in_window + rules = parse_rules(plan_data, entry_options=entry_options) + if not rules: + return + breakdown.notes.append(f"ovo parser hits: {list(rules.keys())}") + peak_rate = peak_import_rate_c_per_kwh_inc_gst(plan_data) + if "free_windows" in rules: + for fw in rules["free_windows"]: + _apply_free_window( + fw, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) + if "ev_offpeak" in rules: + for ev in rules["ev_offpeak"]: + _apply_ev_offpeak( + ev, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) + if "interest" in rules: + # Default balance_aud=0 in parser → apply_rule no-ops. Future + # options-flow patch will populate balance_aud per-user. + for interest_rule in rules["interest"]: + _apply_ovo_interest(interest_rule, slots, breakdown) diff --git a/custom_components/pricehawk/cdr/incentive_parsers/red.py b/custom_components/pricehawk/cdr/incentive_parsers/red.py new file mode 100644 index 0000000..c3c61f5 --- /dev/null +++ b/custom_components/pricehawk/cdr/incentive_parsers/red.py @@ -0,0 +1,56 @@ +"""Red Energy incentive parser — Phase 2.11.4. + +Catalog v3 finding: 101 Red plans publish "Free Electricity Use Period": + "Between 12pm and 2pm Saturday and Sunday, your electricity usage + charges will be waived for any electricity consumed at your Supply + Address." + +This is a weekend-only free window. The free_window parser handles the +hours but doesn't yet enforce day-of-week. For Phase 2.11.4 v1 we credit +all-week (over-counts by ~5/7 = $5-15/yr for typical users) — refining +to weekend-only deferred to Phase 2.11.5. + +Red's other incentives (Renewable Matching Promise, Charity donations +to Taronga / BCNA / Rotary, sign-up bonuses) are out-of-scope per the +catalog v3 user-decision (non-cash + one-off + perks dropped). +""" +from __future__ import annotations + +from typing import Callable + +from .common import peak_import_rate_c_per_kwh_inc_gst +from .common.free_window import ( + apply_rule as _apply_free_window, + parse_from_incentives as _parse_free_windows, +) + + +def parse_rules(plan_data: dict) -> dict: + elec = plan_data.get("electricityContract") or {} + rules: dict = {} + fws = _parse_free_windows(elec.get("incentives") or []) + if fws: + rules["free_windows"] = fws + return rules + + +def apply( + plan_data: dict, + slots: list[dict], + breakdown, + *, + slot_in_window: Callable, + **_extra, +) -> None: + del slot_in_window + rules = parse_rules(plan_data) + if not rules: + return + breakdown.notes.append(f"red parser hits: {list(rules.keys())}") + if "free_windows" in rules: + peak_rate = peak_import_rate_c_per_kwh_inc_gst(plan_data) + for fw in rules["free_windows"]: + _apply_free_window( + fw, slots, breakdown, + normal_import_rate_c_per_kwh_inc_gst=peak_rate, + ) diff --git a/custom_components/pricehawk/cdr/models.py b/custom_components/pricehawk/cdr/models.py new file mode 100644 index 0000000..daab52c --- /dev/null +++ b/custom_components/pricehawk/cdr/models.py @@ -0,0 +1,73 @@ +"""Boundary pydantic v2 models for CDR evaluator inputs. + +Minimal by design — pydantic is used only at the public API boundary +(`evaluate(plan, consumption)`). Internal walk-the-dict logic in +`evaluator.py` stays untyped because CDR `electricityContract` is a +deeply optional structure where every retailer drops different fields. +Locking down the inner schema with pydantic creates a maintenance +burden that pays back nothing. + +Use these models for input validation + IDE hints at the call site. +Once Phase 2 (wizard config flow) wraps this, the wizard owns +construction of `PlanDetail` from CDR fetch + caller passes us a +guaranteed-valid object. +""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ConsumptionSlot(BaseModel): + """One half-hour consumption observation. + + Slot timestamps are ISO-8601 with timezone (Australia/Sydney AEST/AEDT + aware). UTC parallel timestamp is optional but useful for DST debugging. + """ + + model_config = ConfigDict(extra="allow") + + ts_local: str + grid_import_kwh: float = 0.0 + grid_export_kwh: float = 0.0 + solar_kwh: float = 0.0 + + +class ConsumptionWindow(BaseModel): + """Container for a period of consumption slots. + + Phase 0 fixture also carries `_phase0_meta`; we accept-extra so meta + survives round-trip via `model_dump()` if a caller wants it. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + slots: list[ConsumptionSlot] + + +class PlanDetail(BaseModel): + """Thin wrapper around CDR PlanDetailV2 `data` object. + + We do NOT enumerate every field — CDR `electricityContract` has 30+ + optional keys and retailers populate different subsets. We only assert + that `planId` exists and `electricityContract` is present as a dict. + Internal evaluator walks the dict directly. + """ + + model_config = ConfigDict(extra="allow") + + planId: str = Field(..., description="Opaque retailer plan ID, e.g. GLO731031MR@VEC") + electricityContract: dict[str, Any] = Field(default_factory=dict) + + +class PlanDetailEnvelope(BaseModel): + """Top-level CDR envelope `{"data": PlanDetail}`. + + EME endpoint returns this shape. Our fixtures store the same shape + plus `_phase0_meta` at the top level (phase 0 only). + """ + + model_config = ConfigDict(extra="allow") + + data: PlanDetail diff --git a/custom_components/pricehawk/cdr/registry.py b/custom_components/pricehawk/cdr/registry.py new file mode 100644 index 0000000..157fde5 --- /dev/null +++ b/custom_components/pricehawk/cdr/registry.py @@ -0,0 +1,240 @@ +"""AU energy retailer registry (CDR data-holder endpoints). + +Source of truth for "which retailers does PriceHawk know about, and where +do we send CDR list / detail requests for each one". + +Strategy (Phase 3.1 prep — EME refdata2): + +1. The package ships a baked-in copy of the + ``https://api.energymadeeasy.gov.au/refdata2`` ``organisations`` map at + ``cdr/data/eme_refdata.json``. EME covers 117 orgs and carries the + metadata PriceHawk needs to disambiguate shared base URIs + (``cdrCode`` → URL path segment, ``cdrBrand`` → ``?brand=`` query + param matching ``PlanDetail.brand``). +2. At first use, the wizard tries a live fetch from EME. On any failure + it loads the baked-in EME snapshot. The wizard never blocks on + registry availability. +3. A quarterly CI cron PR refreshes the baked-in EME copy from upstream. + +Sources NOT used and why: +- jxeeno community registry has 2 known base-URI bugs (ARCLINE, + iO Energy) and drifts from AER PDF. Two unreliable sources are not + better than one good source + offline cache. +- ACCC Register API is broken for energy PRD (SM#561, unresolved since + Dec 2022). +- AER PDF is authoritative but human-curated monthly — not suitable + for a live source. + +This module deliberately does NOT persist refreshed copies to HA Store — +that lives in the coordinator's nightly job (post-v1.5.0) where there is +a stable ``hass`` reference. The wizard treats each session as ephemeral. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import aiohttp + +from .cdr_client import ( + USER_AGENT, + CdrUnavailable, +) + +_LOGGER = logging.getLogger(__name__) + +_BAKED_IN_PATH = Path(__file__).parent / "data" / "eme_refdata.json" + +LIVE_REGISTRY_URL = ( + "https://api.energymadeeasy.gov.au/refdata2?keys=organisations,thirdParties" +) + +_FETCH_TIMEOUT_SEC = 15 +_EME_BASE_URI_TEMPLATE = "https://cdr.energymadeeasy.gov.au/{cdr_code}" +_EME_LOGO_PREFIX = "https://energymadeeasy.gov.au" + + +@dataclass(frozen=True) +class RetailerEndpoint: + """A single AU retailer's CDR data-holder configuration. + + ``brand_id`` is the EME ``orgId`` — opaque, do not parse. + + ``cdr_brand`` is the ``brand`` discriminator in CDR PlanDetailV2. + When multiple retailers share a base URI (e.g. seven brands hosted + on ``cdr.energymadeeasy.gov.au/energy-locals/``), ``cdr_brand`` + distinguishes them. Pass it through ``?brand=`` on plan + list / detail requests. + """ + + brand_id: str + brand_name: str + base_uri: str + logo_uri: str | None = None + abn: str | None = None + last_updated: str | None = None + cdr_brand: str | None = None + + @property + def slug(self) -> str: + """Lowercase brand name, spaces -> underscores. Used as a stable + config-entry key when ``brand_id`` would be too cryptic for logs.""" + return self.brand_name.lower().replace(" ", "_").replace("-", "_") + + +def _parse_eme_entries(raw: Any) -> list[RetailerEndpoint]: + """Convert an EME ``refdata2`` envelope into RetailerEndpoint records. + + EME structure: ``{"data": {"organisations": {"": {...}, ...}}}``. + We only keep orgs that have a ``cdrCode`` (the URL path segment) — + a handful of broker-only entries lack one. ``cdrBrand`` may differ + from ``cdrCode`` for shared-base-URI brands; it is preserved so + callers can disambiguate plans via ``?brand=``. + """ + if not isinstance(raw, dict): + raise ValueError("EME registry root is not a dict") + data = raw.get("data") + if not isinstance(data, dict): + raise ValueError("EME registry data field is not a dict") + orgs = data.get("organisations") + if not isinstance(orgs, dict): + raise ValueError("EME registry organisations field is not a dict") + + out: list[RetailerEndpoint] = [] + for org_id, o in orgs.items(): + if not isinstance(o, dict): + continue + # CR-fix: every upstream string is coerced via _safe_str (handles + # None, int, bool, missing keys) before .strip() — avoids + # AttributeError when EME ships a non-string in cdrCode/cdrBrand. + cdr_code = _safe_str(o.get("cdrCode")) + # Upstream has trailing-space bugs in some cdrBrand values + # ("aurora ", "brighte ", "amber " etc); _safe_str strips + # defensively. + cdr_brand = _safe_str(o.get("cdrBrand")) or None + # CR-fix: trim display names too — several EME orgs ship + # trailing whitespace in tradingName/orgName which would leak + # into UI labels. + display = _safe_str(o.get("tradingName")) or _safe_str(o.get("orgName")) + if not (cdr_code and display): + continue + logo_path = o.get("logo") + if isinstance(logo_path, str) and logo_path: + logo_uri = ( + f"{_EME_LOGO_PREFIX}{logo_path}" + if logo_path.startswith("/") + else logo_path + ) + else: + logo_uri = None + out.append( + RetailerEndpoint( + brand_id=str(org_id), + brand_name=display, + base_uri=_EME_BASE_URI_TEMPLATE.format(cdr_code=cdr_code), + logo_uri=logo_uri, + abn=str(o.get("abn")) if o.get("abn") else None, + last_updated=None, # EME envelope has no per-row mtime + cdr_brand=cdr_brand, + ) + ) + return out + + +def _safe_str(value: Any) -> str: + """Defensive string coercion for upstream registry payloads. + + Returns ``""`` for None / non-string types. Strips whitespace. + Used wherever we need to call ``.strip()`` on a value that EME + might ship as something other than a string (rare but observed). + """ + if not isinstance(value, str): + return "" + return value.strip() + + +def load_baked_in() -> list[RetailerEndpoint]: + """Load the EME snapshot shipped inside the package.""" + raw = json.loads(_BAKED_IN_PATH.read_text()) + return _parse_eme_entries(raw) + + +async def fetch_live(session: aiohttp.ClientSession) -> list[RetailerEndpoint]: + """Pull the live EME refdata2 registry. Raises ``CdrUnavailable`` on + any failure (HTTP non-200, network error, malformed body) so callers + can decide whether to fall back to baked-in. + """ + try: + async with session.get( + LIVE_REGISTRY_URL, + timeout=aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_SEC), + headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, + ) as resp: + if resp.status != 200: + raise CdrUnavailable( + f"registry HTTP {resp.status} from {LIVE_REGISTRY_URL}" + ) + raw = await resp.json(content_type=None) + except CdrUnavailable: + raise + except Exception as err: # noqa: BLE001 — single-URL endpoint + _LOGGER.info("registry live fetch failed: %s", err) + raise CdrUnavailable(str(err)) from err + try: + return _parse_eme_entries(raw) + except (ValueError, TypeError, KeyError, AttributeError) as err: + # Malformed payload from EME (schema drift) — treat as + # unavailable so callers fall back to baked-in. + _LOGGER.info("registry parse failed: %s", err) + raise CdrUnavailable(f"parse failed: {err}") from err + + +async def get_registry( + session: aiohttp.ClientSession, + *, + prefer_live: bool = True, +) -> tuple[list[RetailerEndpoint], str]: + """Return ``(endpoints, source)`` where source is ``"live"`` or + ``"baked-in"``. Live fetch falls back to baked-in on any error. + + The boolean ``prefer_live`` lets callers (tests, offline-mode) skip the + network attempt entirely. + """ + if prefer_live: + try: + return (await fetch_live(session), "live") + except CdrUnavailable as err: + _LOGGER.info( + "registry live fetch unavailable (%s); using baked-in copy", err + ) + return (load_baked_in(), "baked-in") + + +def find_by_brand( + endpoints: list[RetailerEndpoint], needle: str +) -> RetailerEndpoint | None: + """Case-insensitive substring match on ``brand_name``.""" + needle_u = needle.upper() + for e in endpoints: + if needle_u in e.brand_name.upper(): + return e + return None + + +# --------------------------------------------------------------------------- +# Pure-Python helpers exposed for unit tests. +# --------------------------------------------------------------------------- + + +def parse_eme_for_test(raw: dict[str, Any]) -> list[RetailerEndpoint]: + """Public re-export of the EME refdata2 envelope parser.""" + return _parse_eme_entries(raw) + + +def baked_in_path_for_test() -> Path: + """Resolved filesystem path of the EME baked-in JSON, for sanity tests.""" + return _BAKED_IN_PATH diff --git a/custom_components/pricehawk/cdr/streaming.py b/custom_components/pricehawk/cdr/streaming.py new file mode 100644 index 0000000..6f10a5f --- /dev/null +++ b/custom_components/pricehawk/cdr/streaming.py @@ -0,0 +1,331 @@ +"""Streaming engine adapter for cdr.evaluate. + +Bridges the streaming API the HA coordinator uses (`engine.update(power_w, +dt)` per power reading, properties read on demand) to the batch API of +`cdr.evaluate` (consumes a list of half-hour slots). + +The legacy `tariff_engine.TariffEngine` is streaming-native. This adapter +mimics its public surface (update / reset_daily / properties / to_dict / +from_dict) so the GloBirdProvider can swap its internal engine to CDR- +driven logic without touching the coordinator or sensor wiring. + +Slot buffer semantics: +- Power readings are accumulated into a "current slot" with start time + aligned to the previous half-hour boundary (00:00 / 00:30 / 01:00 / ...). +- Each `update(power_w, now)` adds `(power_w / 1000) * delta_h kWh` to + either the import or export side of the current slot. +- When `now` crosses into the next half-hour, the current slot is sealed + and appended to `_slots_today`; a new current slot starts. +- The Phase 0 prototype's `GAP_PROTECTION_MAX_DELTA_H = 0.1h` cap is + preserved (legacy behaviour: if HA misses readings for >6 min, + accumulate only 6 min of energy to avoid runaway state). +- Property reads call `cdr.evaluate` over `_slots_today + [_current_slot]` + and cache the CostBreakdown until the next `update()`. + +The cached CostBreakdown is invalidated on every `update()` (lazy +recompute on next property read). For sensible HA polling cadence +(~30 s) and a 48-slot day, this is ~O(48) per recompute = trivial. +""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from .evaluator import CostBreakdown, evaluate + +GAP_PROTECTION_MAX_DELTA_H = 0.1 # matches tariff_engine constant + + +def _slot_start(dt: datetime) -> datetime: + """Round down to nearest half-hour boundary.""" + return dt.replace(minute=(dt.minute // 30) * 30, second=0, microsecond=0) + + +class CdrStreamingEngine: + """Stateful streaming wrapper around `cdr.evaluate`. + + Public surface deliberately mirrors `tariff_engine.TariffEngine` so + `GloBirdProvider` can swap internals without changing the Provider + Protocol it satisfies. + """ + + def __init__(self, plan: dict, entry_options: dict | None = None) -> None: + self._plan = plan + # Phase 2.12.1: user-side opt-in fields (ovo_interest_balance_aud, + # vpp_batteries_enrolled). Passed through to evaluate() so the + # retailer parsers can activate opt-in math. + self._entry_options = entry_options or {} + self._slots_today: list[dict] = [] + self._current_slot_start: datetime | None = None + self._current_slot_import_kwh: float = 0.0 + self._current_slot_export_kwh: float = 0.0 + self._last_update: datetime | None = None + self._last_reset_date = None + # Lazy cache of CostBreakdown over today's slots; invalidated by update() + self._bd_cache: CostBreakdown | None = None + + # -- Streaming API ----------------------------------------------------- + + def update(self, grid_power_w: float, now_local: datetime) -> None: + """Ingest a power reading. Positive = import, negative = export.""" + if self._last_update is None: + self._last_update = now_local + self._current_slot_start = _slot_start(now_local) + self._bd_cache = None + return + + # Midnight reset detection (caller may have not called reset_daily yet) + if self._last_reset_date is None: + self._last_reset_date = now_local.date() + elif now_local.date() != self._last_reset_date: + # Auto-roll daily state on date change (defensive — coordinator + # should call reset_daily but this prevents stale-state bugs) + self.reset_daily() + self._last_reset_date = now_local.date() + self._current_slot_start = _slot_start(now_local) + self._last_update = now_local + self._bd_cache = None + return + + delta_h = (now_local - self._last_update).total_seconds() / 3600 + if delta_h <= 0: + return + delta_h = min(delta_h, GAP_PROTECTION_MAX_DELTA_H) + self._last_update = now_local + + # Energy this tick + grid_kw = grid_power_w / 1000.0 + import_kwh = max(0.0, grid_kw) * delta_h + export_kwh = max(0.0, -grid_kw) * delta_h + + # Roll to next slot if boundary crossed + new_slot_start = _slot_start(now_local) + if self._current_slot_start is None: + self._current_slot_start = new_slot_start + elif new_slot_start != self._current_slot_start: + self._seal_current_slot() + self._current_slot_start = new_slot_start + + self._current_slot_import_kwh += import_kwh + self._current_slot_export_kwh += export_kwh + self._bd_cache = None # invalidate + + def reset_daily(self) -> None: + """Zero today's slot buffer. Called at midnight by the coordinator.""" + self._slots_today = [] + self._current_slot_start = None + self._current_slot_import_kwh = 0.0 + self._current_slot_export_kwh = 0.0 + # Keep _last_update so next update() computes delta correctly + self._bd_cache = None + + # -- Internal helpers -------------------------------------------------- + + def _seal_current_slot(self) -> None: + """Append current accumulator as a finalised slot.""" + if self._current_slot_start is None: + return + if (self._current_slot_import_kwh + self._current_slot_export_kwh) == 0: + self._current_slot_import_kwh = 0.0 + self._current_slot_export_kwh = 0.0 + return + self._slots_today.append({ + "ts_local": self._current_slot_start.isoformat(), + "grid_import_kwh": self._current_slot_import_kwh, + "grid_export_kwh": self._current_slot_export_kwh, + "solar_kwh": 0.0, # not tracked in streaming; cdr.evaluate uses grid_export + }) + self._current_slot_import_kwh = 0.0 + self._current_slot_export_kwh = 0.0 + + def _live_slots(self) -> list[dict]: + """Return slots-today + the in-flight current slot (if non-empty).""" + slots = list(self._slots_today) + if ( + self._current_slot_start is not None + and (self._current_slot_import_kwh + self._current_slot_export_kwh) > 0 + ): + slots.append({ + "ts_local": self._current_slot_start.isoformat(), + "grid_import_kwh": self._current_slot_import_kwh, + "grid_export_kwh": self._current_slot_export_kwh, + "solar_kwh": 0.0, + }) + return slots + + def _breakdown(self) -> CostBreakdown: + if self._bd_cache is not None: + return self._bd_cache + slots = self._live_slots() + self._bd_cache = evaluate( + self._plan, {"slots": slots}, + entry_options=self._entry_options, + ) + return self._bd_cache + + def _current_tou_rate_ex_gst( + self, now: datetime, side: str + ) -> Decimal: + """Look up current-clock-time TOU rate for `side` ∈ {"import","export"}. + + Returns ex-GST $/kWh. Used by `current_import_rate_c_kwh` / + `current_export_rate_c_kwh` properties — fast lookup, no evaluator + invocation. + """ + from .evaluator import _resolve_tou_rate, slot_in_window # noqa: F401 + plan_data = self._plan.get("data", self._plan) + elec = plan_data.get("electricityContract", {}) or {} + tps = elec.get("tariffPeriod", []) or [] + if not tps: + return Decimal("0") + tp = tps[0] + if side == "import": + if tp.get("rateBlockUType") == "singleRate": + rates = (tp.get("singleRate") or {}).get("rates", []) or [] + return Decimal(str(rates[0].get("unitPrice", 0))) if rates else Decimal("0") + tou_rates = tp.get("timeOfUseRates", []) or [] + entry = _resolve_tou_rate(now, tou_rates) + if not entry: + return Decimal("0") + rates = entry.get("rates", []) or [] + return Decimal(str(rates[0].get("unitPrice", 0))) if rates else Decimal("0") + # export side + fits = elec.get("solarFeedInTariff", []) or [] + for fit in fits: + utype = fit.get("tariffUType") + if utype == "timeVaryingTariffs": + for tvt in fit.get("timeVaryingTariffs") or []: + for tv in tvt.get("timeVariations") or []: + if slot_in_window( + now, + tv.get("days", []), + tv.get("startTime", "00:00"), + tv.get("endTime", "23:59"), + ): + rates = tvt.get("rates", []) or [] + return Decimal(str(rates[0].get("unitPrice", 0))) if rates else Decimal("0") + elif utype == "singleTariff": + st = fit.get("singleTariff") or {} + rates = st.get("rates", []) or [] + if rates: + return Decimal(str(rates[0].get("unitPrice", 0))) + return Decimal("0") + + # -- Properties (TariffEngine-compatible) ------------------------------ + + @property + def current_import_rate_c_kwh(self) -> float: + """Marginal import rate INC-GST cents/kWh at current clock time.""" + if self._last_update is None: + return 0.0 + rate_ex = self._current_tou_rate_ex_gst(self._last_update, "import") + return float(rate_ex * Decimal("1.10") * Decimal("100")) + + @property + def current_export_rate_c_kwh(self) -> float: + """Effective export rate INC-GST cents/kWh at current clock time.""" + if self._last_update is None: + return 0.0 + rate_ex = self._current_tou_rate_ex_gst(self._last_update, "export") + return float(rate_ex * Decimal("1.10") * Decimal("100")) + + @property + def import_kwh_today(self) -> float: + total = sum(s["grid_import_kwh"] for s in self._slots_today) + total += self._current_slot_import_kwh + return float(total) + + @property + def export_kwh_today(self) -> float: + total = sum(s["grid_export_kwh"] for s in self._slots_today) + total += self._current_slot_export_kwh + return float(total) + + @property + def import_cost_today_c(self) -> float: + """Import-only cost in cents INC-GST.""" + bd = self._breakdown() + return float((bd.import_aud_ex_gst * Decimal("1.10") * Decimal("100"))) + + @property + def export_earnings_today_c(self) -> float: + """FIT earnings in cents INC-GST (positive value).""" + bd = self._breakdown() + # export_aud_ex_gst is stored as NEGATIVE cost; flip sign for earnings + return float((-bd.export_aud_ex_gst * Decimal("1.10") * Decimal("100"))) + + @property + def net_daily_cost_aud(self) -> float: + """Net daily total INC-GST AUD.""" + bd = self._breakdown() + return float(bd.total_aud_inc_gst) + + @property + def zerohero_status(self) -> str: + """Compatibility shim. Phase 1.2 doesn't expose the granular state + machine; returns "earned" / "lost" / "pending" based on the + evaluator's incentive trace. + """ + bd = self._breakdown() + for t in bd.trace: + if t.get("incentive") == "zerohero": + return "earned" + # No credit yet — could be lost or pending (legacy semantics). + # Without per-tick state we return "pending" until day ends; legacy's + # rich state machine is deferred to v1.5.1 unless dashboard demands it. + return "pending" + + @property + def super_export_kwh(self) -> float: + """Cumulative kWh credited to super-export today (PDF cap 10 kWh).""" + bd = self._breakdown() + # Reconstruct from incentive trace + credited = 0.0 + for t in bd.trace: + if t.get("incentive") == "super_export": + credited += float(t.get("credited_kwh", 0)) + return credited + + # -- State serialisation ---------------------------------------------- + + def to_dict(self) -> dict[str, Any]: + return { + "slots_today": self._slots_today, + "current_slot_start": self._current_slot_start.isoformat() if self._current_slot_start else None, + "current_slot_import_kwh": self._current_slot_import_kwh, + "current_slot_export_kwh": self._current_slot_export_kwh, + "last_update": self._last_update.isoformat() if self._last_update else None, + "last_reset_date": self._last_reset_date.isoformat() if self._last_reset_date else None, + } + + @classmethod + def from_dict( + cls, + plan: dict, + data: dict[str, Any], + today, + entry_options: dict | None = None, + ) -> "CdrStreamingEngine": + engine = cls(plan, entry_options=entry_options) + # Restore today's accumulators only if stored date is today + stored_reset = data.get("last_reset_date") + if stored_reset: + from datetime import date as _date + stored_date = _date.fromisoformat(stored_reset) + engine._last_reset_date = stored_date + if stored_date == today: + engine._slots_today = data.get("slots_today", []) or [] + css = data.get("current_slot_start") + if css: + engine._current_slot_start = datetime.fromisoformat(css) + engine._current_slot_import_kwh = float(data.get("current_slot_import_kwh", 0)) + engine._current_slot_export_kwh = float(data.get("current_slot_export_kwh", 0)) + # CR-fix: only restore ``_last_update`` when the stored + # state belongs to *today*. Restoring yesterday's + # ``_last_update`` produces a synthetic delta on the + # first tick of a new day → over-counts energy/cost. + lu = data.get("last_update") + if lu: + engine._last_update = datetime.fromisoformat(lu) + return engine diff --git a/custom_components/pricehawk/config_flow.py b/custom_components/pricehawk/config_flow.py index 8a73a75..b737654 100644 --- a/custom_components/pricehawk/config_flow.py +++ b/custom_components/pricehawk/config_flow.py @@ -25,11 +25,27 @@ TextSelectorType, ) +from .cdr.cdr_client import ( + CdrAPIError, + CdrPlanNotFound, + CdrUnavailable, + fetch_plan_detail, + fetch_plan_list, +) +from .cdr.registry import ( + RetailerEndpoint, + get_registry, +) from .const import ( + CDR_SKIP_REASON_AFTER_ERROR, + CDR_SKIP_REASON_NO_RETAILER, + CDR_SKIP_REASON_RETRY_EXHAUSTED, CONF_AMBER_ENABLED, CONF_AMBER_NETWORK_DAILY_CHARGE, CONF_AMBER_SUBSCRIPTION_FEE, CONF_API_KEY, + CONF_CDR_PLAN, + CONF_CDR_SKIP_REASON, CONF_CURRENT_PROVIDER, CONF_DAILY_SUPPLY_CHARGE, CONF_DEMAND_CHARGE, @@ -64,12 +80,254 @@ PLAN_ZEROHERO, PROVIDER_AMBER, PROVIDER_FLOW_POWER, - PROVIDER_GLOBIRD, PROVIDER_LOCALVOLTS, + PROVIDER_OTHER, + CONF_OVO_INTEREST_BALANCE_AUD, + CONF_VPP_BATTERIES_ENROLLED, TARIFF_FLAT_STEPPED, TARIFF_TOU, ) +# Sentinel value emitted by the CDR locale/distributor dropdowns when the +# user wants to skip an optional filter. (Phase 3.0f removed the manual +# tariff-entry path, so this no longer escapes CDR setup — it only skips +# locale narrowing.) +CDR_SKIP_SENTINEL = "__manual__" +CDR_ANY_DISTRIBUTOR_SENTINEL = "__any__" +CONF_CDR_RETAILER_ID = "cdr_retailer_id" +CONF_CDR_POSTCODE = "cdr_postcode" +CONF_CDR_STATE = "cdr_state" +CONF_CDR_DISTRIBUTOR = "cdr_distributor" +CONF_CDR_PLAN_ID = "cdr_plan_id" +CONF_CDR_CONFIRM_ACTION = "cdr_confirm_action" +CONF_CDR_RETRY_ACTION = "cdr_retry_action" + +# Phase 2.9 — confirmation step actions. +CDR_CONFIRM_ACCEPT = "accept" +CDR_CONFIRM_PICK_DIFFERENT = "pick_different" +CDR_CONFIRM_MANUAL = "manual" + +# AU state-by-postcode ranges. Source: Australia Post — public ranges. +# ACT is a subset of the 2xxx postcode space; we test it BEFORE the NSW +# range so the ACT slice wins. +_AU_POSTCODE_TO_STATE: list[tuple[int, int, str]] = [ + (2600, 2618, "ACT"), + (2900, 2920, "ACT"), + (200, 299, "ACT"), # PO boxes — legacy + (1000, 2599, "NSW"), + (2619, 2899, "NSW"), + (2921, 2999, "NSW"), + (3000, 3999, "VIC"), + (8000, 8999, "VIC"), + (4000, 4999, "QLD"), + (9000, 9999, "QLD"), + (5000, 5999, "SA"), + (6000, 6797, "WA"), + (6800, 6999, "WA"), + (7000, 7999, "TAS"), + (800, 999, "NT"), +] + +# Free-text patterns that identify state names in retailer displayName +# strings. Matched case-insensitively. The first hit wins, so order by +# specificity (full names before abbreviations). +STATE_DISTRIBUTORS: dict[str, list[str]] = { + "NSW": ["Ausgrid", "Endeavour", "Essential Energy"], + "VIC": ["AusNet", "CitiPower", "Jemena", "Powercor", "United Energy"], + "QLD": ["Energex", "Ergon"], + "SA": ["SA Power", "SAPN", "SA Power Networks"], + "TAS": ["TasNetworks"], + "ACT": ["Evoenergy", "ActewAGL"], + "WA": ["Western Power", "Horizon Power"], + "NT": ["Power and Water"], +} + + +def _postcode_to_state(postcode: str) -> str | None: + """Map a 4-digit AU postcode to a state code. Returns ``None`` for + invalid input (non-numeric, wrong length, unmapped range).""" + s = postcode.strip() + if not s.isdigit() or len(s) not in (3, 4): + return None + n = int(s) + for lo, hi, state in _AU_POSTCODE_TO_STATE: + if lo <= n <= hi: + return state + return None + + +def _filter_plans_by_geography( + plans: list[dict[str, Any]], + *, + postcode: str | None = None, + state: str | None = None, + distributor: str | None = None, +) -> list[dict[str, Any]]: + """Filter CDR plan list by ``geography.includedPostcodes`` and + ``geography.distributors`` — fields the LIST endpoint actually + returns per plan. Falls back to a fuzzy displayName match for + retailers that omit ``geography`` entirely. + + Filter precedence (most specific first): + 1. ``postcode`` set → keep plans whose ``includedPostcodes`` contains + it. If a plan has no geography block, fall back to displayName + state-keyword match (best-effort). + 2. ``state`` set (postcode not) → keep plans whose ``distributors`` + intersect ``STATE_DISTRIBUTORS[state]`` OR plans whose + ``includedPostcodes`` overlap the state's postcode range. + 3. ``distributor`` set (and not the "any" sentinel) → keep plans + whose ``geography.distributors`` contains the exact name + (case-insensitive). AND-ed with the locality filter. + + All filters skipped → return list unchanged. + """ + if not postcode and not state and ( + distributor is None or distributor == CDR_ANY_DISTRIBUTOR_SENTINEL + ): + return list(plans) + + state_dists_upper: list[str] = [] + state_pc_ranges: list[tuple[int, int]] = [] + if state: + state_dists_upper = [d.upper() for d in STATE_DISTRIBUTORS.get(state, [])] + state_pc_ranges = [ + (lo, hi) for lo, hi, s in _AU_POSTCODE_TO_STATE if s == state + ] + + dist_target = ( + distributor.lower() + if distributor and distributor != CDR_ANY_DISTRIBUTOR_SENTINEL + else None + ) + + out: list[dict[str, Any]] = [] + for p in plans: + geo = p.get("geography") or {} + included = geo.get("includedPostcodes") or [] + distributors = geo.get("distributors") or [] + name_upper = (p.get("displayName") or "").upper() + + # Locality (postcode > state). + loc_ok = True + if postcode: + if included: + loc_ok = postcode in included + else: + # No geography — best-effort displayName match. + loc_ok = any( + k in name_upper for k in [ + *(d.upper() for d in STATE_DISTRIBUTORS.get(state or "", [])) + ] + ) if state else True + elif state: + if distributors and state_dists_upper: + loc_ok = any(d.upper() in state_dists_upper for d in distributors) + elif included and state_pc_ranges: + loc_ok = any( + lo <= int(pc) <= hi + for pc in included if pc.isdigit() + for lo, hi in state_pc_ranges + ) + else: + # No geography on plan — fall back to displayName. + loc_ok = any(k in name_upper for k in [ + state.upper(), + *(d.upper() for d in STATE_DISTRIBUTORS.get(state, [])), + ]) + + # Distributor (additional AND). + dist_ok = True + if dist_target: + if distributors: + dist_ok = any(dist_target in d.lower() for d in distributors) + else: + dist_ok = dist_target in (p.get("displayName") or "").lower() + + if loc_ok and dist_ok: + out.append(p) + return out + + +def _dedupe_plans_by_displayName( + plans: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Collapse plans sharing a ``displayName`` into one entry per name. + Keeps the entry with the most recent ``effectiveFrom`` so the user + picks the LATEST revision of each plan shape. + + AGL ships 4-6× variants per displayName (cohort splits across + distributors); this turns 67 plans into ~16 unique shapes per the + UAT cascade. + """ + by_name: dict[str, dict[str, Any]] = {} + for p in plans: + name = (p.get("displayName") or "").strip() + if not name: + continue + eff = str(p.get("effectiveFrom") or "") + existing = by_name.get(name) + if existing is None or eff > str(existing.get("effectiveFrom") or ""): + by_name[name] = p + return list(by_name.values()) + + +def _api_provider_for_brand(brand: str) -> str | None: + """Phase 3.0f: map a CDR retailer brand slug to its API-provider id. + + Returns None when the retailer has no live consumer API integration, + meaning the wizard skips the optional API-connect step and the + user's cost comes from CDR tariff math only. + + Brand slugs come from CDR's `brand` field (lowercase, dash-joined). + """ + if not brand: + return None + b = brand.strip().lower() + if "amber" in b: + return PROVIDER_AMBER + if "flow" in b and "power" in b: + return PROVIDER_FLOW_POWER + if b == "localvolts": + return PROVIDER_LOCALVOLTS + return None + + +def _build_state_options() -> list[dict[str, str]]: + """HA dropdown options for the 7 AU electricity-network states + skip.""" + return [ + {"value": CDR_SKIP_SENTINEL, "label": "Skip filter — show all plans"}, + {"value": "NSW", "label": "New South Wales"}, + {"value": "VIC", "label": "Victoria"}, + {"value": "QLD", "label": "Queensland"}, + {"value": "SA", "label": "South Australia"}, + {"value": "TAS", "label": "Tasmania"}, + {"value": "ACT", "label": "Australian Capital Territory"}, + {"value": "WA", "label": "Western Australia"}, + ] + + +def _build_distributor_options(state: str | None) -> list[dict[str, str]]: + """Distributors for a given state, plus an "Any distributor" sentinel. + If ``state`` is None or unknown, returns just the Any sentinel.""" + options: list[dict[str, str]] = [ + {"value": CDR_ANY_DISTRIBUTOR_SENTINEL, "label": "Any distributor (skip filter)"} + ] + if state and state in STATE_DISTRIBUTORS: + options.extend( + {"value": d, "label": d} for d in STATE_DISTRIBUTORS[state] + ) + return options + +# CDR retry action values (Phase 2.3) +CDR_RETRY_ACTION_RETRY = "retry" +CDR_RETRY_ACTION_SKIP = "skip" + +# Cap the number of automatic retries the user can request before the +# wizard forces a fall-through. Two retries is enough to ride out a brief +# DNS hiccup but not enough to wedge a stubborn user against a permanently +# offline retailer DH. +CDR_MAX_RETRIES = 2 + _LOGGER = logging.getLogger(__name__) @@ -164,8 +422,16 @@ def _str_to_windows(text: str) -> list[list[str]]: def _time_to_minutes(t: str) -> int: """Convert 'HH:MM' to minutes since midnight.""" - parts = t.strip().split(":") - return int(parts[0]) * 60 + int(parts[1]) + try: + parts = t.strip().split(":") + h = int(parts[0]) + m = int(parts[1]) + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError("Time out of range") + return h * 60 + m + except (ValueError, IndexError): + _LOGGER.debug("Invalid time format: %s", t) + return 0 def _expand_to_slots(windows: list[list[str]]) -> set[int]: @@ -428,6 +694,304 @@ def _build_incentives_schema( return schema_fields +# --------------------------------------------------------------------------- +# CDR wizard helpers (Phase 2.2 — pure-Python; unit-testable without HA) +# --------------------------------------------------------------------------- + + +def _build_cdr_retailer_options( + endpoints: list[RetailerEndpoint], +) -> list[dict[str, str]]: + """Convert a list of RetailerEndpoint into HA SelectSelector option dicts. + + Phase 3.0f removed the manual-entry escape hatch. Every option is a + real retailer; the wizard requires a CDR plan. Sorted case-insensitive + by brand name for stable ordering. + """ + sorted_eps = sorted(endpoints, key=lambda e: e.brand_name.lower()) + return [ + {"value": e.brand_id, "label": e.brand_name} for e in sorted_eps + ] + + +def _summarise_cdr_plan(detail: dict[str, Any]) -> dict[str, str]: + """Phase 2.9 — Distil a CDR PlanDetailV2 envelope into human-readable + strings the confirmation form renders via description_placeholders. + + Returned dict keys MUST match placeholder names in strings.json: + ``brand``, ``plan_name``, ``effective``, ``daily_supply``, + ``import_rate``, ``feed_in``, ``incentives``. All values are strings + (HA placeholder substitution does not coerce). + + Designed for the UI summary only — not a substitute for the full + evaluator. The rate fields collapse multiple tariff periods to a + single representative line ("Peak 39.6 / Shoulder 27.5 / OffPeak 0 + c/kWh inc-GST" or "Flat 33 c/kWh inc-GST"). + """ + data = detail.get("data") if isinstance(detail, dict) else None + if not isinstance(data, dict): + return { + "brand": "?", "plan_name": "?", "effective": "?", + "daily_supply": "?", "import_rate": "?", "feed_in": "?", + "incentives": "?", + } + + brand = data.get("brandName") or data.get("brand") or "?" + plan_name = data.get("displayName") or "?" + effective = data.get("effectiveFrom") or "?" + if effective != "?": + effective = str(effective)[:10] + + elec = data.get("electricityContract") or {} + + # Daily supply charge — full-sweep catalog (10,266 plans, 78 retailers, + # 2026-05-15) shows 10,262/10,266 plans put it at + # ``tariffPeriod[0].dailySupplyCharge`` (singular). The other 3 + # spec-allowed locations (``electricityContract.dailySupplyCharges``, + # ``electricityContract.dailySupplyCharge``, + # ``tariffPeriod[].dailySupplyCharges``) are 0/10,266 in the wild. + # Defensive 4-location probe retained — costs nothing and survives + # any retailer that decides to start using a spec-legal alternative. + # The 4 plans missing supply entirely (likely embedded-network) fall + # through to ``"not published"``. + raw_supply: Any = elec.get("dailySupplyCharges") or elec.get("dailySupplyCharge") + if raw_supply is None: + for tp in elec.get("tariffPeriod") or []: + if not isinstance(tp, dict): + continue + cand = tp.get("dailySupplyCharge") or tp.get("dailySupplyCharges") + if cand: + raw_supply = cand + break + try: + daily_supply = ( + f"{float(raw_supply) * 110:.2f} c/day inc-GST" + if raw_supply is not None and str(raw_supply).strip() != "" + else "not published" + ) + except (TypeError, ValueError): + daily_supply = "?" + + # Import-rate summary — peek inside tariffPeriod[].rates[] if present + # (TOU), otherwise look for singleRate (flat). Rates in CDR are + # ex-GST $/kWh; multiply by 110 to get inc-GST cents. + import_rate = _summarise_import_rate(elec) + feed_in = _summarise_fit(elec) + + incentives = elec.get("incentives") or [] + if incentives: + # Show every incentive — the user is verifying the plan against + # their bill, so hidden incentives defeat the purpose. + names = [i.get("displayName") or "?" for i in incentives] + incentives_str = ", ".join(names) + else: + incentives_str = "none" + + controlled_load_str = _summarise_controlled_load(elec) + + return { + "brand": str(brand), + "plan_name": str(plan_name), + "effective": effective, + "daily_supply": daily_supply, + "import_rate": import_rate, + "feed_in": feed_in, + "incentives": incentives_str, + "controlled_load": controlled_load_str, + } + + +def _summarise_controlled_load(elec: dict[str, Any]) -> str: + """Phase 2.10.3 — surface controlled-load (separate cheaper circuit + for hot water / pool pump). Catalog flagged 6 retailers ship CL + `timeOfUseRates`, others ship CL `singleRate`. + + Returns ``"none"`` when no controlledLoad block — most plans don't + include CL because it's a meter-side opt-in. + """ + cl = elec.get("controlledLoad") or [] + if not isinstance(cl, list) or not cl: + return "none" + parts: list[str] = [] + for block in cl: + if not isinstance(block, dict): + continue + # CL nests its own tariffPeriod-like rate block. Reuse the same + # branch logic as the main import-rate summariser. + rate_summary = _summarise_import_rate({"tariffPeriod": [block]}) + if rate_summary in ("?", ""): + continue + label = (block.get("displayName") or "CL").strip() + # Skip the label prefix when it just repeats "Controlled Load" + # (which the surrounding "Controlled load:" form prefix already + # supplies). Keep distinctive labels e.g. "Off-Peak Tariff". + if label.lower() in {"controlled load", "cl", "controlled-load"}: + parts.append(rate_summary) + else: + parts.append(f"{label}: {rate_summary}") + return " · ".join(parts) if parts else "none" + + +def _summarise_import_rate(elec: dict[str, Any]) -> str: + """Walk TOU first, then flat. Return a 1-line human summary in + inc-GST cents/kWh. Returns ``"?"`` if no rate found. + + CDR PlanDetailV2 puts rates inside ``tariffPeriod[].{rateBlockUType}[]`` + where ``rateBlockUType`` is one of ``timeOfUseRates``, ``singleRate``, + ``flexibleRate``, ``demandCharges``, etc. Each entry has a ``type`` + label and a ``rates[]`` array with ``unitPrice`` strings ex-GST per + kWh. The legacy path of ``tariffPeriod[].rates[]`` direct also + works for retailers that simplified their schema. + """ + tariff_periods = elec.get("tariffPeriod") or [] + if isinstance(tariff_periods, list) and tariff_periods: + entries: list[tuple[str, str]] = [] + for p in tariff_periods: + if not isinstance(p, dict): + continue + # Resolve which nested key holds the rates. CDR shape varies: + # - timeOfUseRates / flexibleRate / blockTariff → LIST of blocks + # - singleRate / demandCharges → DICT (one block) + block_key = p.get("rateBlockUType") + blocks: list = [] + block_val = p.get(block_key) if block_key else None + if isinstance(block_val, list): + blocks = block_val + elif isinstance(block_val, dict): + # Single-block shape — wrap so the loop below stays uniform. + blocks = [{ + "type": block_val.get("type") or block_val.get("displayName") or "FLAT", + "rates": block_val.get("rates") or [], + }] + elif p.get("timeOfUseRates"): + blocks = p["timeOfUseRates"] + elif p.get("rates"): + blocks = [{"type": p.get("type") or p.get("displayName") or "?", "rates": p["rates"]}] + + for b in blocks: + if not isinstance(b, dict): + continue + tname = (b.get("type") or b.get("displayName") or "?").strip() + rates = b.get("rates") or [] + if not rates: + continue + try: + r = float(rates[0].get("unitPrice", 0)) + entries.append((tname, f"{r * 110:.1f}")) + except (TypeError, ValueError, IndexError, AttributeError): + continue + if entries: + # Strip generic labels ("Rate", "Period", "FLAT") that duplicate + # the surrounding "Import rate:" prefix in the form description. + # Keep meaningful labels (PEAK / SHOULDER / OFF_PEAK). + generic = {"RATE", "PERIOD", "FLAT", "?"} + if all(n.upper() in generic for n, _ in entries): + rate_str = " / ".join(r for _, r in entries) + else: + rate_str = " / ".join(f"{n} {r}" for n, r in entries) + return rate_str + " c/kWh inc-GST" + + single = elec.get("singleRate") or {} + rates = single.get("rates") or [] + if rates: + try: + r = float(rates[0].get("unitPrice", 0)) + return f"Flat {r * 110:.2f} c/kWh inc-GST" + except (TypeError, ValueError, AttributeError): + return "?" + return "?" + + +def _summarise_fit(elec: dict[str, Any]) -> str: + """Solar feed-in summary across all blocks. Returns ``"none"`` if no + FIT published. + + CDR shape variations: + - ``singleTariff`` (one flat rate) → "5.50 c/kWh inc-GST" + - ``timeVaryingTariffs`` (TOU FIT, e.g. GloBird Combo) → walks + each PEAK/SHOULDER/OFF_PEAK entry → "PEAK 3.3 / SHOULDER 0.1 c/kWh inc-GST" + - Multiple FIT blocks (RETAILER + GOVERNMENT) → summed + """ + fits = elec.get("solarFeedInTariff") or [] + if not isinstance(fits, list) or not fits: + return "none" + + parts: list[str] = [] + for f in fits: + if not isinstance(f, dict): + continue + u_type = f.get("tariffUType") + + # singleTariff: one flat rate + if u_type == "singleTariff" or f.get("singleTariff"): + single = (f.get("singleTariff") or {}).get("rates") or [] + if single: + try: + r = float(single[0].get("unitPrice", 0)) + parts.append(f"{r * 110:.2f}") + except (TypeError, ValueError, AttributeError): + pass + continue + + # timeVaryingTariffs: walk each TOU period + if u_type == "timeVaryingTariffs" or f.get("timeVaryingTariffs"): + tou = f.get("timeVaryingTariffs") or [] + tou_entries: list[str] = [] + for t in tou: + if not isinstance(t, dict): + continue + tname = (t.get("type") or t.get("displayName") or "?").strip() + rates = t.get("rates") or [] + if not rates: + continue + try: + r = float(rates[0].get("unitPrice", 0)) + tou_entries.append(f"{tname} {r * 110:.1f}") + except (TypeError, ValueError, AttributeError): + continue + if tou_entries: + parts.append(" / ".join(tou_entries)) + continue + + if parts: + return " + ".join(parts) + " c/kWh inc-GST" + return "none" + + +def _build_cdr_plan_options( + plans: list[dict[str, Any]], + *, + dedupe: bool = True, +) -> list[dict[str, str]]: + """Convert a CDR list response's ``plans`` array into dropdown options. + + Filters to entries with both ``planId`` and ``displayName`` populated. + When ``dedupe`` is True (default) collapses 4-6× cohort variants per + displayName via ``_dedupe_plans_by_displayName`` so the user sees + one row per plan shape, not 67 for AGL+postcode 3977. + + Sorts by ``displayName`` lower-case for stable wizard ordering. Label + appends ``effectiveFrom`` date sliced to YYYY-MM-DD. + """ + usable = [ + p + for p in plans + if p.get("planId") and p.get("displayName") + ] + if dedupe: + usable = _dedupe_plans_by_displayName(usable) + usable.sort(key=lambda p: p["displayName"].lower()) + return [ + { + "value": p["planId"], + "label": ( + f"{p['displayName']} (eff {(p.get('effectiveFrom') or '?')[:10]})" + ), + } + for p in usable + ] + + class EnergyCompareConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PriceHawk.""" @@ -441,46 +1005,39 @@ def __init__(self) -> None: async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: - """Step 1: ask the user who their current energy retailer is. - - The selection drives savings calculations and determines whether - provider-specific credential steps (Amber API key, LocalVolts API - key) are needed up-front. Other providers are auto-enabled as - comparators with default settings the user can refine later. + """Step 1 — Phase 3.0f wizard rewrite. + + PriceHawk is universal: ANY retailer can be the user's current + plan. API providers (Amber, Flow Power, LocalVolts) are optional + truth-source overlays we offer to connect AFTER the user picks + their CDR plan, not gates at step 1. + + New flow: + 1. cdr_locale (state + postcode) + 2. cdr_distributor (filtered by locale) + 3. cdr_retailer (filtered by distributor) + 4. cdr_plan_select (filtered by retailer) + 5. cdr_confirm (review chosen plan) + 6. IF retailer has a live API → offer optional API connect + 7. sensor_select (grid power sensor) + 8. dashboard_token (optional HA long-lived token) + 9. create entry + + Step 1 has no user input — it just dispatches directly to + cdr_locale, the start of the universal CDR plan picker. The + comparator step is removed from initial install (Phase 3.4 + adds it as a skippable OptionsFlow step post-install). """ - if user_input is not None: - self._data[CONF_CURRENT_PROVIDER] = user_input[CONF_CURRENT_PROVIDER] - choice = user_input[CONF_CURRENT_PROVIDER] - if choice == PROVIDER_AMBER: - return await self.async_step_amber_credentials() - if choice == PROVIDER_LOCALVOLTS: - return await self.async_step_localvolts_credentials() - if choice == PROVIDER_FLOW_POWER: - return await self.async_step_flow_power_credentials() - # GloBird primary needs no upfront credentials; jump straight - # into GloBird tariff setup (the always-on comparator). - return await self.async_step_globird_plan() - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_CURRENT_PROVIDER, default=PROVIDER_AMBER - ): SelectSelector( - SelectSelectorConfig( - options=[ - {"value": PROVIDER_AMBER, "label": "Amber Electric"}, - {"value": PROVIDER_GLOBIRD, "label": "GloBird Energy"}, - {"value": PROVIDER_FLOW_POWER, "label": "Flow Power"}, - {"value": PROVIDER_LOCALVOLTS, "label": "LocalVolts"}, - ], - mode=SelectSelectorMode.LIST, - ) - ), - } - ), - ) + # Initialise tariff-source identity to the universal "other" until + # plan selection reveals an API-eligible retailer (handled in + # async_step_cdr_confirm). + self._data[CONF_CURRENT_PROVIDER] = PROVIDER_OTHER + # Phase 3.0g (CodeRabbit critical): dispatch to the retailer + # picker first, NOT cdr_locale. The Phase 2 step chain is + # cdr_retailer → cdr_locale → cdr_distributor → cdr_plan_select; + # without a `_cdr_retailer` set, cdr_plan_select bails to the + # legacy globird_plan manual-tariff path. + return await self.async_step_cdr_retailer() async def async_step_amber_credentials( self, user_input: dict[str, Any] | None = None @@ -560,7 +1117,11 @@ async def async_step_flow_power_credentials( f"flow_power_{user_input[CONF_FLOW_POWER_REGION]}" ) self._abort_if_unique_id_configured() - return await self.async_step_globird_plan() + # Reached via post-CDR API offer → CDR plan already picked, + # skip plan-picking and finish setup. + if self._data.get("_offer_api"): + return await self.async_step_sensor_select() + return await self.async_step_cdr_retailer() return self.async_show_form( step_id="flow_power_credentials", @@ -623,7 +1184,11 @@ async def async_step_localvolts_credentials( f"localvolts_{user_input[CONF_LOCALVOLTS_NMI]}" ) self._abort_if_unique_id_configured() - return await self.async_step_globird_plan() + # Reached via post-CDR API offer → CDR plan already picked, + # skip plan-picking and finish setup. + if self._data.get("_offer_api"): + return await self.async_step_sensor_select() + return await self.async_step_cdr_retailer() return self.async_show_form( step_id="localvolts_credentials", @@ -695,7 +1260,11 @@ async def async_step_amber_fees( self._data[CONF_AMBER_SUBSCRIPTION_FEE] = user_input.get( CONF_AMBER_SUBSCRIPTION_FEE, 0.0 ) - return await self.async_step_globird_plan() + # Reached via post-CDR API offer → CDR plan already picked, + # skip plan-picking and finish setup. + if self._data.get("_offer_api"): + return await self.async_step_sensor_select() + return await self.async_step_cdr_retailer() return self.async_show_form( step_id="amber_fees", @@ -711,119 +1280,444 @@ async def async_step_amber_fees( ), ) - async def async_step_globird_plan( + async def _cdr_route_error( + self, kind: str, detail: str + ) -> config_entries.ConfigFlowResult: + """Stash error context and route to the retry form. Used by both + retailer and plan-select steps so they share a single error UI.""" + self._data["_cdr_error_kind"] = kind + self._data["_cdr_error_detail"] = detail + return await self.async_step_cdr_error() + + async def async_step_cdr_retailer( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: - """Step 2: GloBird plan type selection.""" + """Phase 2.2 — CDR happy-path entry. Show retailer dropdown sourced + from the live EME refdata2 registry (with baked-in fallback). The + "Skip CDR" sentinel routes to the legacy manual GloBird flow so + v1.4.x behaviour is preserved for users whose retailer is not in CDR. + + On registry-load failure, routes to async_step_cdr_error (Phase + 2.3) so the user can retry or pick "Skip" deliberately. + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + if user_input is not None: - plan_type = user_input[CONF_PLAN_TYPE] - self._data[CONF_PLAN_TYPE] = plan_type + choice = user_input[CONF_CDR_RETAILER_ID] + # Find the chosen endpoint in the registry we already loaded. + endpoints: list[RetailerEndpoint] = self._data.get( + "_cdr_endpoints", [] + ) + picked = next((e for e in endpoints if e.brand_id == choice), None) + if picked is None: + # Shouldn't happen — dropdown values come from the same list. + # CR-fix: previously re-entered this step on miss, creating a + # loop because manual entry is gone. Surface as a registry + # error so the user gets a retry/skip choice instead. + _LOGGER.warning( + "CDR retailer %s not in cached endpoints", choice, + ) + return await self._cdr_route_error( + "registry", f"unknown brand_id {choice}" + ) + self._data["_cdr_retailer"] = picked + return await self.async_step_cdr_locale() - # Load defaults for known plans - if plan_type in GLOBIRD_PLAN_DEFAULTS: - defaults = GLOBIRD_PLAN_DEFAULTS[plan_type] - self._data["_defaults"] = defaults + # First entry into the step: load registry. + try: + session = async_get_clientsession(self.hass) + endpoints, source = await get_registry(session) + _LOGGER.info( + "CDR registry loaded (%s): %d retailers", source, len(endpoints) + ) + except Exception as err: # noqa: BLE001 — see _cdr_route_error + _LOGGER.warning( + "CDR registry load failed (%s); routing to retry form", err, + ) + return await self._cdr_route_error("registry", str(err)) - return await self.async_step_globird_rates() + # Stash endpoints so the second pass through this step (after user + # input) can resolve the chosen brand_id without re-fetching. + self._data["_cdr_endpoints"] = endpoints + options = _build_cdr_retailer_options(endpoints) return self.async_show_form( - step_id="globird_plan", + step_id="cdr_retailer", data_schema=vol.Schema( { - vol.Required(CONF_PLAN_TYPE): SelectSelector( + vol.Required(CONF_CDR_RETAILER_ID): SelectSelector( SelectSelectorConfig( - options=PLAN_OPTIONS, - mode=SelectSelectorMode.LIST, + options=options, + mode=SelectSelectorMode.DROPDOWN, ) ), } ), ) - async def async_step_globird_rates( + async def async_step_cdr_locale( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: - """Step 3: Import rates and daily supply charge.""" - plan_type = self._data[CONF_PLAN_TYPE] - tariff_type = self._data.get("_tariff_type_override", _get_tariff_type(plan_type)) - defaults = self._data.get("_defaults", {}) + """Phase 2.8 — Narrow the plan list by AU state or postcode. + + Big retailers (GloBird, AGL, Origin) publish hundreds of plans + across every distributor; an unfiltered dropdown is unusable. + This step asks for a postcode (4-digit) OR a state code. The + postcode is mapped to a state via ``_postcode_to_state``; if + both are provided, the explicit state field wins. + + Skipping (empty postcode + ``CDR_SKIP_SENTINEL`` state) bypasses + the filter and shows all plans — useful for users whose plan + lives outside the keyword patterns we know. + """ errors: dict[str, str] = {} if user_input is not None: - if plan_type == PLAN_CUSTOM and "tariff_type" in user_input: - tariff_type = user_input["tariff_type"] - - if tariff_type == TARIFF_TOU and "peak_windows" in user_input: - overlap = _validate_no_overlap( - user_input.get("peak_windows", ""), - user_input.get("shoulder_windows", ""), - user_input.get("offpeak_windows", ""), - ) - if overlap: - errors["base"] = overlap + postcode = (user_input.get(CONF_CDR_POSTCODE) or "").strip() + state_choice = user_input.get(CONF_CDR_STATE, CDR_SKIP_SENTINEL) - if tariff_type == TARIFF_TOU and "peak_windows" in user_input and not errors: - if not _validate_full_coverage( - user_input.get("peak_windows", ""), - user_input.get("shoulder_windows", ""), - user_input.get("offpeak_windows", ""), - ): - errors["base"] = "incomplete_tou_coverage" + resolved_state: str | None = None + if state_choice and state_choice != CDR_SKIP_SENTINEL: + resolved_state = state_choice + elif postcode: + resolved_state = _postcode_to_state(postcode) + if resolved_state is None: + errors[CONF_CDR_POSTCODE] = "cdr_invalid_postcode" if not errors: - self._data[CONF_DAILY_SUPPLY_CHARGE] = user_input[CONF_DAILY_SUPPLY_CHARGE] - self._data[CONF_DEMAND_CHARGE] = user_input.get(CONF_DEMAND_CHARGE, 0.0) - self._data[CONF_IMPORT_TARIFF] = _build_import_tariff( - tariff_type, user_input, plan_type - ) - return await self.async_step_globird_export() - - schema_fields = _build_rates_schema(plan_type, tariff_type, defaults) + self._data["_cdr_state"] = resolved_state # may be None = skip + # Phase 2.10: stash the postcode so the geography filter + # can match per-plan ``includedPostcodes`` precisely. + self._data["_cdr_postcode"] = postcode if postcode else None + return await self.async_step_cdr_distributor() return self.async_show_form( - step_id="globird_rates", - data_schema=vol.Schema(schema_fields), + step_id="cdr_locale", errors=errors, + data_schema=vol.Schema( + { + vol.Optional(CONF_CDR_POSTCODE, default=""): TextSelector( + TextSelectorConfig() + ), + vol.Optional( + CONF_CDR_STATE, default=CDR_SKIP_SENTINEL + ): SelectSelector( + SelectSelectorConfig( + options=_build_state_options(), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), ) - async def async_step_globird_export( + async def async_step_cdr_distributor( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: - """Step 4: Export/feed-in tariff rates.""" - plan_type = self._data[CONF_PLAN_TYPE] - defaults = self._data.get("_defaults", {}) + """Phase 2.8 — Pick a distributor (network operator) inside the + chosen state. Skipping (``CDR_ANY_DISTRIBUTOR_SENTINEL``) keeps + the state-only filter; the plan_select step still narrows the + list to plans whose displayName contains the state code or any + distributor known for that state. + + If no state was set (user skipped locale), this step short- + circuits straight to plan select with no filter. + """ + state: str | None = self._data.get("_cdr_state") + if state is None: + # No state was selected — skip distributor entirely. + self._data["_cdr_distributor"] = None + return await self.async_step_cdr_plan_select() if user_input is not None: - self._data[CONF_EXPORT_TARIFF] = _build_export_tariff( - user_input, plan_type + choice = user_input[CONF_CDR_DISTRIBUTOR] + self._data["_cdr_distributor"] = ( + None if choice == CDR_ANY_DISTRIBUTOR_SENTINEL else choice ) - return await self.async_step_incentives() + return await self.async_step_cdr_plan_select() return self.async_show_form( - step_id="globird_export", - data_schema=_build_export_schema(defaults), + step_id="cdr_distributor", + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_DISTRIBUTOR, + default=CDR_ANY_DISTRIBUTOR_SENTINEL, + ): SelectSelector( + SelectSelectorConfig( + options=_build_distributor_options(state), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + description_placeholders={"state": state}, ) - async def async_step_incentives( + async def async_step_cdr_plan_select( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: - """Step 5: Incentive toggles.""" - plan_type = self._data[CONF_PLAN_TYPE] + """Phase 2.2 — CDR plan dropdown for the selected retailer. On + selection, fetches PlanDetailV2 and stores it as ``CONF_CDR_PLAN`` + in ``self._data``; the coordinator picks `CdrGloBirdProvider` + whenever this key is set. + + Phase 2.3 — list-fetch and detail-fetch failures now route to + async_step_cdr_error so the user can retry or skip deliberately. - # Plans without engine-backed incentives — skip - if plan_type not in (PLAN_ZEROHERO, PLAN_CUSTOM): - self._data[CONF_INCENTIVES] = {} - return await self.async_step_sensor_select() + Phase 2.8 — list is post-filtered by stored state + distributor. + If 0 matches after filtering, falls back to the unfiltered list + with a log warning so the user is never blocked. + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + retailer: RetailerEndpoint | None = self._data.get("_cdr_retailer") + if retailer is None: + # Step entered without a retailer choice — bail to manual. + self._data["_cdr_skip_reason"] = CDR_SKIP_REASON_NO_RETAILER + return await self.async_step_cdr_retailer() if user_input is not None: - self._data[CONF_INCENTIVES] = user_input - return await self.async_step_sensor_select() + chosen_plan_id = user_input[CONF_CDR_PLAN_ID] + # CR-fix: Skip-CDR sentinel removed. Manual entry was deleted + # in Phase 3.0f and the previous Skip handler bounced the user + # back into the retailer picker, which has no escape either. + try: + session = async_get_clientsession(self.hass) + detail = await fetch_plan_detail( + session, retailer.base_uri, chosen_plan_id, + brand=retailer.cdr_brand, + ) + except (CdrPlanNotFound, CdrUnavailable, CdrAPIError) as err: + _LOGGER.warning( + "CDR detail fetch failed for %s/%s (%s); routing to retry", + retailer.brand_name, chosen_plan_id, err, + ) + return await self._cdr_route_error("detail", str(err)) + self._data[CONF_CDR_PLAN] = detail + _LOGGER.info( + "CDR plan selected: %s / %s — routing to confirm step", + retailer.brand_name, chosen_plan_id, + ) + # Phase 2.9: confirmation screen before commit. User sees the + # actual rates/incentives this plan publishes and can back out + # to pick a different plan or fall through to manual entry if + # nothing matches. + return await self.async_step_cdr_confirm() + + # First entry — fetch list. + try: + session = async_get_clientsession(self.hass) + plans = await fetch_plan_list( + session, retailer.base_uri, brand=retailer.cdr_brand, + ) + except (CdrUnavailable, CdrAPIError) as err: + _LOGGER.warning( + "CDR list fetch failed for %s (%s); routing to retry", + retailer.brand_name, err, + ) + return await self._cdr_route_error("list", str(err)) + + # Phase 2.8 + 2.10 — narrow the list by geography (postcode + + # state + distributor matched against `geography.includedPostcodes` + # and `geography.distributors` from the CDR list response). Empty + # filter falls back to unfiltered with a warning so the wizard + # never blocks even on retailers that publish no geography. + postcode = self._data.get("_cdr_postcode") + state = self._data.get("_cdr_state") + distributor = self._data.get("_cdr_distributor") + filtered = _filter_plans_by_geography( + plans, + postcode=postcode, + state=state, + distributor=distributor, + ) + if filtered: + plans_to_show = filtered + _LOGGER.info( + "CDR plan list narrowed: %d/%d match postcode=%s state=%s distributor=%s", + len(filtered), len(plans), postcode, state, distributor, + ) + else: + plans_to_show = plans + _LOGGER.warning( + "CDR filter (postcode=%s state=%s distributor=%s) matched 0 plans; " + "showing unfiltered list (%d plans)", + postcode, state, distributor, len(plans), + ) - schema_fields = _build_incentives_schema(plan_type) + options = _build_cdr_plan_options(plans_to_show) + if not options: + _LOGGER.info( + "CDR list for %s returned 0 usable plans; routing to retry", + retailer.brand_name, + ) + return await self._cdr_route_error("empty", "0 usable plans") + # CR-fix: Skip sentinel removed (Phase 3.0f). User must pick a + # real plan; manual entry is gone. return self.async_show_form( - step_id="incentives", - data_schema=vol.Schema(schema_fields), + step_id="cdr_plan_select", + data_schema=vol.Schema( + { + vol.Required(CONF_CDR_PLAN_ID): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) + + async def async_step_cdr_error( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Phase 2.3 — Retry / skip form shown when a CDR fetch fails. + + The form is reached by `_cdr_route_error` from either retailer or + plan-select steps. State on entry: `_cdr_error_kind` is one of + `registry` | `list` | `detail` | `empty`. Retry count is bumped + each visit; after ``CDR_MAX_RETRIES`` consecutive retries fail, + the form forces a fall-through to manual. + """ + retry_count = int(self._data.get("_cdr_retry_count", 0)) + kind = self._data.get("_cdr_error_kind", "list") + + if user_input is not None: + action = user_input[CONF_CDR_RETRY_ACTION] + if action == CDR_RETRY_ACTION_SKIP: + _LOGGER.info("CDR retry form: user picked skip → manual flow") + self._data["_cdr_skip_reason"] = CDR_SKIP_REASON_AFTER_ERROR + return await self.async_step_cdr_retailer() + # action == retry + retry_count += 1 + self._data["_cdr_retry_count"] = retry_count + if retry_count > CDR_MAX_RETRIES: + _LOGGER.warning( + "CDR retry exhausted after %d attempts; forcing manual", + retry_count, + ) + self._data["_cdr_skip_reason"] = CDR_SKIP_REASON_RETRY_EXHAUSTED + return await self.async_step_cdr_retailer() + # Re-enter the step that originally failed. `registry` failures + # restart from cdr_retailer (which re-loads registry). Other + # kinds replay cdr_plan_select (which re-fetches the list, or + # the user picks a plan to re-fetch detail). + if kind == "registry": + return await self.async_step_cdr_retailer() + return await self.async_step_cdr_plan_select() + + # First entry: show the form. + return self.async_show_form( + step_id="cdr_error", + errors={"base": f"cdr_{kind}_unavailable"}, + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_RETRY_ACTION, default=CDR_RETRY_ACTION_RETRY + ): SelectSelector( + SelectSelectorConfig( + options=[ + {"value": CDR_RETRY_ACTION_RETRY, "label": "Retry"}, + {"value": CDR_RETRY_ACTION_SKIP, "label": "Skip CDR — enter rates manually"}, + ], + mode=SelectSelectorMode.LIST, + ) + ), + } + ), + description_placeholders={ + "kind": kind, + "attempt": str(retry_count + 1), + "max": str(CDR_MAX_RETRIES + 1), + }, + ) + + async def async_step_cdr_confirm( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Phase 2.9 — Read-only summary of the fetched CDR plan. User + verifies tariffs/rates/incentives against their actual bill and + accepts, goes back to pick a different plan, or falls through to + manual entry. + + Surfaces the bug catch: CDR data goes stale, retailers publish + wrong rates, EME-proxy strips fields. Without this step the + wizard silently commits whatever CDR returned. + """ + detail = self._data.get(CONF_CDR_PLAN, {}) + summary = _summarise_cdr_plan(detail) + + if user_input is not None: + action = user_input[CONF_CDR_CONFIRM_ACTION] + if action == CDR_CONFIRM_ACCEPT: + _LOGGER.info( + "CDR plan %s confirmed by user", summary.get("plan_name") + ) + # Phase 3.0f: detect if the picked retailer has a live + # API. If so, offer optional API-connect step (truth + # source overlay). Otherwise go straight to sensor select. + detail_data = (self._data.get(CONF_CDR_PLAN) or {}).get("data", {}) + brand = (detail_data.get("brand") or "").lower() + api_provider = _api_provider_for_brand(brand) + if api_provider is not None: + self._data["_offer_api"] = brand + self._data[CONF_CURRENT_PROVIDER] = api_provider + if api_provider == PROVIDER_AMBER: + return await self.async_step_amber_credentials() + if api_provider == PROVIDER_FLOW_POWER: + return await self.async_step_flow_power_credentials() + if api_provider == PROVIDER_LOCALVOLTS: + return await self.async_step_localvolts_credentials() + # No API for this retailer → sensor select directly. + return await self.async_step_sensor_select() + if action == CDR_CONFIRM_PICK_DIFFERENT: + # Clear the stored CDR plan and go back to plan select. + self._data.pop(CONF_CDR_PLAN, None) + return await self.async_step_cdr_plan_select() + # action == CDR_CONFIRM_MANUAL — Phase 3.0f: legacy manual + # tariff entry is dead. Show an explanatory error and loop + # back to plan-select; user must use a CDR plan now. + return self.async_show_form( + step_id="cdr_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_CONFIRM_ACTION, default=CDR_CONFIRM_ACCEPT + ): SelectSelector( + SelectSelectorConfig( + options=[ + {"value": CDR_CONFIRM_ACCEPT, "label": "Yes — these rates match my bill"}, + {"value": CDR_CONFIRM_PICK_DIFFERENT, "label": "No — pick a different plan"}, + ], + mode=SelectSelectorMode.LIST, + ) + ), + } + ), + description_placeholders=summary, + errors={"base": "manual_tariff_removed"}, + ) + + return self.async_show_form( + step_id="cdr_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_CONFIRM_ACTION, default=CDR_CONFIRM_ACCEPT + ): SelectSelector( + SelectSelectorConfig( + options=[ + {"value": CDR_CONFIRM_ACCEPT, "label": "Yes — these rates match my bill"}, + {"value": CDR_CONFIRM_PICK_DIFFERENT, "label": "No — pick a different plan"}, + ], + mode=SelectSelectorMode.LIST, + ) + ), + } + ), + description_placeholders=summary, ) async def async_step_sensor_select( @@ -870,10 +1764,12 @@ async def async_step_dashboard_token( # Provider enables based on the primary choice amber_enabled = current_provider == PROVIDER_AMBER localvolts_enabled = current_provider == PROVIDER_LOCALVOLTS - # Flow Power is always on as a comparator. If the primary IS - # Flow Power, the region/base/supply were set at the - # credentials step; otherwise default to NSW1 / 34c / 100c. - flow_power_enabled = True + # Phase 3.0g (UAT): Flow Power default-OFF. Was forced ON + # under Phase 2 wizard (every install got a placeholder + # `flow_power_cost_today: $1.0` sensor whether the user + # cared or not). Comparators are now opt-in via the + # OptionsFlow comparators step. + flow_power_enabled = current_provider == PROVIDER_FLOW_POWER options: dict[str, Any] = { CONF_PLAN_TYPE: self._data.get(CONF_PLAN_TYPE, PLAN_ZEROHERO), @@ -922,9 +1818,24 @@ async def async_step_dashboard_token( CONF_LOCALVOLTS_DAILY_SUPPLY, 110.0 ) + # Phase 2.2: when wizard branch A succeeded, persist the CDR + # plan envelope so the coordinator wires `CdrGloBirdProvider` + # instead of the legacy GloBirdProvider. + cdr_plan = self._data.get(CONF_CDR_PLAN) + if cdr_plan: + options[CONF_CDR_PLAN] = cdr_plan + else: + # Phase 2.4: persist branch identification (branch C + # deliberate-manual vs branch B failure-skip) as a + # read-only audit field. Coordinator ignores this. + skip_reason = self._data.get("_cdr_skip_reason") + if skip_reason: + options[CONF_CDR_SKIP_REASON] = skip_reason + _LOGGER.info( - "Creating PriceHawk entry: primary=%s amber=%s lv=%s", + "Creating PriceHawk entry: primary=%s amber=%s lv=%s cdr=%s skip=%s", current_provider, amber_enabled, localvolts_enabled, + bool(cdr_plan), self._data.get("_cdr_skip_reason"), ) return self.async_create_entry( title="PriceHawk", data=data, options=options @@ -966,8 +1877,9 @@ async def async_step_init( return self.async_show_menu( step_id="init", menu_options=[ + "comparators", "amber_api_key", - "globird_plan", + "cdr_pick", "amber_fees", "flow_power", "localvolts", @@ -975,6 +1887,227 @@ async def async_step_init( ], ) + async def async_step_comparators( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Phase 2.12 — toggle comparator providers + opt-in fields. + + Each toggle flips the matching ``CONF_*_ENABLED`` flag in + options. The coordinator reads these on reload (OptionsFlowWith- + Reload) and registers/deregisters the provider — the Phase + 2.11.5 Amber daily-replay hook auto-seeds the accumulator if + Amber is being enabled mid-day, so no second restart is needed. + + Phase 2.12.1 adds two opt-in numeric fields the retailer-specific + incentive parsers need (PriceHawk can't observe these from HA + energy data alone): + - ``ovo_interest_balance_aud``: average credit balance held with + OVO (drives the 3% interest math). Only matters when the CDR + plan brand is OVO. + - ``vpp_batteries_enrolled``: number of batteries enrolled in + the retailer's VPP. Only matters when the CDR plan brand is + ENGIE or EnergyAustralia. + """ + if user_input is not None: + new_opts: dict[str, Any] = dict(self.config_entry.options) + new_opts[CONF_AMBER_ENABLED] = bool(user_input.get(CONF_AMBER_ENABLED, False)) + new_opts[CONF_FLOW_POWER_ENABLED] = bool(user_input.get(CONF_FLOW_POWER_ENABLED, False)) + new_opts[CONF_LOCALVOLTS_ENABLED] = bool(user_input.get(CONF_LOCALVOLTS_ENABLED, False)) + new_opts[CONF_OVO_INTEREST_BALANCE_AUD] = float( + user_input.get(CONF_OVO_INTEREST_BALANCE_AUD, 0) or 0 + ) + new_opts[CONF_VPP_BATTERIES_ENROLLED] = int( + user_input.get(CONF_VPP_BATTERIES_ENROLLED, 0) or 0 + ) + return self.async_create_entry(title="", data=new_opts) + + current_opts = self.config_entry.options + return self.async_show_form( + step_id="comparators", + data_schema=vol.Schema( + { + vol.Optional( + CONF_AMBER_ENABLED, + default=current_opts.get(CONF_AMBER_ENABLED, False), + ): bool, + vol.Optional( + CONF_FLOW_POWER_ENABLED, + default=current_opts.get(CONF_FLOW_POWER_ENABLED, False), + ): bool, + vol.Optional( + CONF_LOCALVOLTS_ENABLED, + default=current_opts.get(CONF_LOCALVOLTS_ENABLED, False), + ): bool, + vol.Optional( + CONF_OVO_INTEREST_BALANCE_AUD, + default=float(current_opts.get(CONF_OVO_INTEREST_BALANCE_AUD, 0) or 0), + ): vol.Coerce(float), + vol.Optional( + CONF_VPP_BATTERIES_ENROLLED, + default=int(current_opts.get(CONF_VPP_BATTERIES_ENROLLED, 0) or 0), + ): vol.Coerce(int), + } + ), + ) + + # ------------------------------------------------------------------ + # Phase 2.7 — CDR re-pick (options flow mirror of wizard branch A) + # ------------------------------------------------------------------ + + async def async_step_cdr_pick( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Show retailer dropdown so user can swap CDR plans post-install + without removing/re-adding the integration. Mirrors the wizard's + ``async_step_cdr_retailer`` minus the override step (deferred to + v1.5.1 for options flow). + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + if user_input is not None: + choice = user_input[CONF_CDR_RETAILER_ID] + if choice == CDR_SKIP_SENTINEL: + # User backed out — return to init menu, options unchanged. + return await self.async_step_init() + endpoints: list[RetailerEndpoint] = self._data.get( + "_cdr_endpoints", [] + ) + picked = next((e for e in endpoints if e.brand_id == choice), None) + if picked is None: + _LOGGER.warning( + "options: CDR retailer %s missing from cached registry", + choice, + ) + return await self.async_step_init() + self._data["_cdr_retailer"] = picked + return await self.async_step_cdr_plan_pick() + + # First entry — load registry. + try: + session = async_get_clientsession(self.hass) + endpoints, source = await get_registry(session) + _LOGGER.info( + "options: CDR registry loaded (%s): %d retailers", + source, len(endpoints), + ) + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "options: CDR registry load failed (%s); returning to menu", + err, + ) + return await self.async_step_init() + + self._data["_cdr_endpoints"] = endpoints + # Options-flow cdr_pick: prepend cancel sentinel inline (unlike + # the install-flow cdr_retailer step, here "skip" is a real + # escape to the init menu, not a loop). + options = [ + {"value": CDR_SKIP_SENTINEL, "label": "Cancel (keep current plan)"} + ] + _build_cdr_retailer_options(endpoints) + + return self.async_show_form( + step_id="cdr_pick", + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_RETAILER_ID, default=CDR_SKIP_SENTINEL + ): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) + + async def async_step_cdr_plan_pick( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Plan dropdown for the selected retailer. On selection, persists + the new CDR plan into ``entry.options`` immediately (no further + menu interaction needed) by returning ``async_create_entry``. + + Failure modes (list fetch / detail fetch) silently return to init + menu — the existing options stay intact. Phase 2.x may add a + retry UI in the options flow; for v1.5.0 the wizard branch B + carries the bulk of the retry UX. + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + retailer: RetailerEndpoint | None = self._data.get("_cdr_retailer") + if retailer is None: + return await self.async_step_init() + + if user_input is not None: + chosen_plan_id = user_input[CONF_CDR_PLAN_ID] + if chosen_plan_id == CDR_SKIP_SENTINEL: + return await self.async_step_init() + try: + session = async_get_clientsession(self.hass) + detail = await fetch_plan_detail( + session, retailer.base_uri, chosen_plan_id, + brand=retailer.cdr_brand, + ) + except (CdrPlanNotFound, CdrUnavailable, CdrAPIError) as err: + _LOGGER.warning( + "options: CDR detail fetch failed for %s/%s (%s)", + retailer.brand_name, chosen_plan_id, err, + ) + return await self.async_step_init() + # Replace the stored CDR plan and clear any prior skip-reason + # audit (the user is actively choosing CDR now). + self._data[CONF_CDR_PLAN] = detail + self._data.pop(CONF_CDR_SKIP_REASON, None) + # Strip internal keys before commit. + self._data.pop("_cdr_endpoints", None) + self._data.pop("_cdr_retailer", None) + _LOGGER.info( + "options: CDR plan updated → %s / %s", + retailer.brand_name, chosen_plan_id, + ) + return self.async_create_entry(data=self._data) + + try: + session = async_get_clientsession(self.hass) + plans = await fetch_plan_list( + session, retailer.base_uri, brand=retailer.cdr_brand, + ) + except (CdrUnavailable, CdrAPIError) as err: + _LOGGER.warning( + "options: CDR list fetch failed for %s (%s)", + retailer.brand_name, err, + ) + return await self.async_step_init() + + plan_options = _build_cdr_plan_options(plans) + if not plan_options: + _LOGGER.info( + "options: CDR list for %s returned 0 usable plans", + retailer.brand_name, + ) + return await self.async_step_init() + + plan_options = [ + {"value": CDR_SKIP_SENTINEL, "label": "Cancel (keep current plan)"} + ] + plan_options + + return self.async_show_form( + step_id="cdr_plan_pick", + data_schema=vol.Schema( + { + vol.Required( + CONF_CDR_PLAN_ID, default=CDR_SKIP_SENTINEL + ): SelectSelector( + SelectSelectorConfig( + options=plan_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) + # ------------------------------------------------------------------ # Flow Power options step # ------------------------------------------------------------------ @@ -1254,135 +2387,6 @@ async def async_step_amber_fees( ), ) - async def async_step_globird_plan( - self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: - """Plan type selection (options).""" - if user_input is not None: - plan_type = user_input[CONF_PLAN_TYPE] - self._data[CONF_PLAN_TYPE] = plan_type - if plan_type in GLOBIRD_PLAN_DEFAULTS: - self._data["_defaults"] = GLOBIRD_PLAN_DEFAULTS[plan_type] - else: - self._data.pop("_defaults", None) - return await self.async_step_globird_rates() - - current_plan = self._data.get(CONF_PLAN_TYPE, PLAN_ZEROHERO) - return self.async_show_form( - step_id="globird_plan", - data_schema=vol.Schema( - { - vol.Required(CONF_PLAN_TYPE, default=current_plan): SelectSelector( - SelectSelectorConfig( - options=PLAN_OPTIONS, - mode=SelectSelectorMode.LIST, - ) - ), - } - ), - ) - - async def async_step_globird_rates( - self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: - """Import rates (options).""" - plan_type = self._data[CONF_PLAN_TYPE] - tariff_type = _get_tariff_type(plan_type) - defaults = self._data.get("_defaults", {}) - errors: dict[str, str] = {} - - current_import = self._data.get(CONF_IMPORT_TARIFF, {}) - current_supply = self._data.get(CONF_DAILY_SUPPLY_CHARGE) - - if user_input is not None: - if plan_type == PLAN_CUSTOM and "tariff_type" in user_input: - tariff_type = user_input["tariff_type"] - - if tariff_type == TARIFF_TOU and "peak_windows" in user_input: - overlap = _validate_no_overlap( - user_input.get("peak_windows", ""), - user_input.get("shoulder_windows", ""), - user_input.get("offpeak_windows", ""), - ) - if overlap: - errors["base"] = overlap - - if tariff_type == TARIFF_TOU and "peak_windows" in user_input and not errors: - if not _validate_full_coverage( - user_input.get("peak_windows", ""), - user_input.get("shoulder_windows", ""), - user_input.get("offpeak_windows", ""), - ): - errors["base"] = "incomplete_tou_coverage" - - if not errors: - self._data[CONF_DAILY_SUPPLY_CHARGE] = user_input[CONF_DAILY_SUPPLY_CHARGE] - self._data[CONF_DEMAND_CHARGE] = user_input.get(CONF_DEMAND_CHARGE, 0.0) - self._data[CONF_IMPORT_TARIFF] = _build_import_tariff( - tariff_type, user_input, plan_type - ) - return await self.async_step_globird_export() - - # Options flow passes demand_charge via current_import for the shared builder - options_import = dict(current_import) - options_import["demand_charge"] = self._data.get(CONF_DEMAND_CHARGE, 0.0) - schema_fields = _build_rates_schema( - plan_type, tariff_type, defaults, - current_import=options_import, - current_supply=current_supply, - ) - - return self.async_show_form( - step_id="globird_rates", - data_schema=vol.Schema(schema_fields), - errors=errors, - ) - - async def async_step_globird_export( - self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: - """Export rates (options).""" - plan_type = self._data[CONF_PLAN_TYPE] - defaults = self._data.get("_defaults", {}) - - if user_input is not None: - self._data[CONF_EXPORT_TARIFF] = _build_export_tariff( - user_input, plan_type - ) - return await self.async_step_incentives() - - return self.async_show_form( - step_id="globird_export", - data_schema=_build_export_schema( - defaults, - current_export=self._data.get(CONF_EXPORT_TARIFF, {}), - ), - ) - - async def async_step_incentives( - self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: - """Incentive toggles (options).""" - plan_type = self._data[CONF_PLAN_TYPE] - - if plan_type not in (PLAN_ZEROHERO, PLAN_CUSTOM): - self._data[CONF_INCENTIVES] = {} - return await self.async_step_sensor_select() - - if user_input is not None: - self._data[CONF_INCENTIVES] = user_input - return await self.async_step_sensor_select() - - schema_fields = _build_incentives_schema( - plan_type, - current_incentives=self._data.get(CONF_INCENTIVES, {}), - ) - - return self.async_show_form( - step_id="incentives", - data_schema=vol.Schema(schema_fields), - ) - async def async_step_sensor_select( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: diff --git a/custom_components/pricehawk/const.py b/custom_components/pricehawk/const.py index 6d46fca..5bdd660 100644 --- a/custom_components/pricehawk/const.py +++ b/custom_components/pricehawk/const.py @@ -13,12 +13,18 @@ PROVIDER_GLOBIRD = "globird" PROVIDER_FLOW_POWER = "flow_power" PROVIDER_LOCALVOLTS = "localvolts" +# Phase 2.12: "Other" = current retailer has no live API (Origin, AGL, +# Red, etc.). Wizard routes through CDR plan picker the same way the +# legacy PROVIDER_GLOBIRD value did. Stored as the entry's +# current_provider when user selects "Other (no API)". +PROVIDER_OTHER = "other" ALL_PROVIDER_IDS = ( PROVIDER_AMBER, PROVIDER_GLOBIRD, PROVIDER_FLOW_POWER, PROVIDER_LOCALVOLTS, + PROVIDER_OTHER, ) # Per-provider enable flags. Amber and LocalVolts are only enabled when @@ -27,6 +33,16 @@ CONF_AMBER_ENABLED = "amber_enabled" CONF_GLOBIRD_ENABLED = "globird_enabled" +# Phase 2.12.1: opt-in fields for incentives that need user-side state +# the integration can't observe from HA energy data alone. +# - OVO Interest Rewards: user's typical credit balance held with OVO. +# Default 0 → ovo_interest math no-ops. +# - VPP rebate (ENGIE PowerResponse / EnergyAustralia PowerResponse): +# number of batteries the user has actually enrolled in the retailer's +# VPP programme. Default 0 → vpp_rebate math no-ops. +CONF_OVO_INTEREST_BALANCE_AUD = "ovo_interest_balance_aud" +CONF_VPP_BATTERIES_ENROLLED = "vpp_batteries_enrolled" + # Flow Power option keys (all in config_entry.options) CONF_FLOW_POWER_ENABLED = "flow_power_enabled" CONF_FLOW_POWER_REGION = "flow_power_region" @@ -49,6 +65,22 @@ AEMO_API_POLL_INTERVAL = 300 # 5 min — matches NEMWeb dispatch publish cadence # Option keys - stored in config_entry.options +# Phase 2 CDR-native option key. When present, the coordinator uses +# `CdrPlanProvider` (CDR-derived plan) instead of the legacy manual +# tariff fields below. Set by wizard branch A; absent for v1.4.x +# upgrades that haven't re-run the wizard. +CONF_CDR_PLAN = "cdr_plan" + +# Phase 2.4 audit field — records WHY a config_entry has no cdr_plan. +# Helps distinguish a deliberate manual user (branch C) from a user +# whose CDR fetch failed (branch B). Never read by the coordinator; +# only used by logs + future "tell us which retailer is missing" UX. +CONF_CDR_SKIP_REASON = "cdr_skip_reason" +CDR_SKIP_REASON_USER_AT_RETAILER = "user_skipped_at_retailer" +CDR_SKIP_REASON_USER_AT_PLAN = "user_skipped_at_plan" +CDR_SKIP_REASON_AFTER_ERROR = "user_skipped_after_error" +CDR_SKIP_REASON_RETRY_EXHAUSTED = "retry_exhausted" +CDR_SKIP_REASON_NO_RETAILER = "step_entered_without_retailer" CONF_PLAN_TYPE = "plan_type" CONF_DAILY_SUPPLY_CHARGE = "daily_supply_charge" CONF_DEMAND_CHARGE = "demand_charge" diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index acfd4d5..d6c7926 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -10,6 +10,7 @@ import asyncio from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later @@ -47,14 +48,13 @@ ) from .explanation import build_explanation from .localvolts_api import aggregate_to_half_hour, fetch_recent_intervals +from .providers.cdr_plan import CdrPlanProvider from .providers import ( AmberProvider, FlowPowerProvider, - GloBirdProvider, LocalVoltsProvider, Provider, ) -from .tariff_engine import get_current_tou_period _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,65 @@ _RETRY_BASE_DELAY = 2 # seconds, doubles each attempt +def _extract_peak_rate_c_inc_gst(cdr_plan: dict[str, Any] | None) -> float | None: + """Phase 3.0e — pull PEAK rate from a CDR plan envelope. + + Walks the optional nested chain + `cdr_plan.data.electricityContract.tariffPeriod[0]` → reads + `rateBlockUType` to find the active rate block (timeOfUseRates, + singleRate, …) → finds the period with `type == "PEAK"` → returns + the first rate's `unitPrice` converted to inc-GST cents (× 100 × 1.10). + + Returns None on ANY missing key, malformed type, empty list, or + non-numeric unitPrice. Caller treats None as "rate unknown" and + leaves the sensor as `unavailable`. + + Module-level + free-standing so it's unit-testable without an HA + runtime, and so future Phase 3.1 ranking logic can reuse the same + derivation across N alternative plans. + """ + if not cdr_plan: + return None + try: + tp = ( + cdr_plan.get("data", {}) + .get("electricityContract", {}) + .get("tariffPeriod", []) + ) + except (AttributeError, TypeError): + return None + if not tp or not isinstance(tp, list): + return None + period_block = tp[0] + if not isinstance(period_block, dict): + return None + + block_key = period_block.get("rateBlockUType") or "" + block = period_block.get(block_key, {}) + if isinstance(block, dict): + periods = block.get("timeOfUseRates", []) or [] + elif isinstance(block, list): + periods = block + else: + return None + + for period in periods: + if not isinstance(period, dict): + continue + if (period.get("type") or "").upper() != "PEAK": + continue + rates = period.get("rates") or [] + if not rates or not isinstance(rates[0], dict): + continue + try: + ex_gst = float(rates[0].get("unitPrice", 0)) + except (TypeError, ValueError): + return None + # CDR unitPrice is ex-GST $/kWh. × 100 → c/kWh. × 1.10 → inc-GST. + return ex_gst * 100.0 * 1.10 + return None + + class PriceHawkCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinate Amber API polling, grid sensor reads, and cost calculation.""" @@ -75,12 +134,29 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: update_interval=timedelta(seconds=COORDINATOR_SCAN_INTERVAL), ) - # GloBird is universally enabled (manual tariff config, no API key). - # Default-on for back-compat with installs that pre-date the - # CONF_GLOBIRD_ENABLED flag. - self._globird = GloBirdProvider(entry.options) + # Phase 3.0c: every entry has a `cdr_plan` envelope. The legacy + # manual-tariff path (GloBirdProvider) is dead code now and gets + # removed in Phase 3.0d once the wizard rewrite enforces this + # invariant for new installs. Existing entries from Phase 2.x + # without cdr_plan are unsupported per the no-migration policy. + cdr_plan = entry.options.get("cdr_plan") + if not cdr_plan: + raise ConfigEntryNotReady( + "PriceHawk entry is missing 'cdr_plan' option. " + "Per Phase 3 'no migration' policy: remove this integration " + "and re-add it through the new wizard." + ) + # Phase 2.12.1: pass entry.options for opt-in fields + # (ovo_interest_balance_aud, vpp_batteries_enrolled). The provider + # plumbs these to the streaming engine → evaluator → + # per-retailer incentive parsers. + self._current_plan_provider: Provider = CdrPlanProvider( + cdr_plan, entry_options=dict(entry.options), + ) + _LOGGER.info("Using CdrPlanProvider (CDR plan %s)", + cdr_plan.get("data", {}).get("planId", "?")) self._providers: dict[str, Provider] = { - self._globird.id: self._globird, + self._current_plan_provider.id: self._current_plan_provider, } # Flow Power is universally enabled by default (uses AEMO direct, @@ -333,10 +409,13 @@ async def _fetch_today_price_schedule(self) -> None: if not data: return - # Build price points from the schedule - import_tariff = self.config_entry.options.get("import_tariff", {}) - export_tariff = self.config_entry.options.get("export_tariff", {}) - + # Phase 3.0g (CodeRabbit): legacy `import_tariff` / `export_tariff` + # options are dead under the cdr_plan-only invariant. Reading them + # returned `gi=0.0 ge=0.0` for every interval, painting the + # current plan as free all day on the comparison chart. + # Keep Amber points only (still useful for the Amber-side chart); + # current-plan-rates per-interval will be back in Phase 3.1 + # ranking when we evaluate the CDR plan against the schedule. schedule_points: list[dict] = [] for interval in data: channel = interval.get("channelType", "") @@ -346,15 +425,12 @@ async def _fetch_today_price_schedule(self) -> None: if channel != "general" or per_kwh is None or not start_time: continue - # Parse the timestamp try: ts = datetime.fromisoformat(start_time.replace("Z", "+00:00")) except (ValueError, AttributeError): continue amber_import = float(per_kwh) - - # Find matching feedIn price for this interval amber_export = 0.0 for fi in data: fi_start = fi.get("startTime") or fi.get("nemTime", "") @@ -362,24 +438,10 @@ async def _fetch_today_price_schedule(self) -> None: amber_export = abs(float(fi.get("perKwh", 0))) break - # GloBird rates from config - globird_import = 0.0 - globird_export = 0.0 - if import_tariff.get("type") == "tou": - _, globird_import = get_current_tou_period( - import_tariff["periods"], ts - ) - if export_tariff.get("type") == "tou": - _, globird_export = get_current_tou_period( - export_tariff["periods"], ts - ) - schedule_points.append({ "t": ts.isoformat(), "ai": amber_import, "ae": amber_export, - "gi": globird_import, - "ge": globird_export, }) if schedule_points: @@ -514,7 +576,7 @@ async def _async_update_data(self) -> dict[str, Any]: self._saving_month_aud, self._last_month, ) self._saving_month_aud = 0.0 - self._daily_wins = {"amber": 0, "globird": 0} + self._daily_wins = {pid: 0 for pid in self._providers} # daily_cost_history NOT reset — keeps 6 months for historical chart self._last_month = now_local.month self._last_date = now_local.day @@ -522,12 +584,15 @@ async def _async_update_data(self) -> dict[str, Any]: # 4. Daily rollover — capture previous day's saving, winner, and # build the Why-X-won explanation snapshot. if now_local.day != self._last_date: - amber_cost = ( - self._amber.net_daily_cost_aud if self._amber else 0.0 - ) - globird_cost = self._globird.net_daily_cost_aud - daily_saving = self._compute_saving(amber_cost, globird_cost) - self._saving_month_aud += daily_saving + globird_cost = self._current_plan_provider.net_daily_cost_aud + # CR-fix: don't pollute saving_month_aud when Amber isn't + # configured. Previously fell back to amber_cost=0 → + # _compute_saving(0, plan) returned a real-looking saving + # delta against a non-existent provider. + if self._amber is not None: + amber_cost = self._amber.net_daily_cost_aud + daily_saving = self._compute_saving(amber_cost, globird_cost) + self._saving_month_aud += daily_saving # Find winner across all registered providers winner_id = min( @@ -651,53 +716,97 @@ def _build_providers_block(self) -> dict[str, dict[str, Any]]: def _build_data_dict(self) -> dict[str, Any]: """Build the data dict consumed by sensor entities.""" - # Derive globird_peak_rate from config options - globird_peak_rate: float | None = None - import_tariff = self.config_entry.options.get("import_tariff", {}) - if import_tariff.get("type") == "tou": - periods = import_tariff.get("periods", {}) - peak = periods.get("peak") - if peak is not None: - globird_peak_rate = peak.get("rate") - elif import_tariff.get("type") == "flat_stepped": - globird_peak_rate = import_tariff.get("step1_rate") - - # Derive metrics_won: how many of 3 metrics Amber beats GloBird + # Phase 3.0e: derive current_plan_peak_rate from the CDR plan + # via _extract_peak_rate_c_inc_gst (module-level helper). + cdr_plan = self.config_entry.options.get("cdr_plan") or {} + current_plan_peak_rate = _extract_peak_rate_c_inc_gst(cdr_plan) + + # Derive metrics_won: how many of 3 metrics Amber beats current plan. + # Phase 3.0g (CodeRabbit): only meaningful when Amber is configured. + # Returning "0/3" with amber_daily=0.0 when Amber is absent makes + # the dashboard pretend the current plan is losing to a phantom + # zero-cost provider. None signals "no comparison available". amber_import = self._amber_import_c amber_export = self._amber_export_c - globird_import = self._globird.current_import_rate_c_kwh - globird_export = self._globird.current_export_rate_c_kwh - amber_daily = self._amber.net_daily_cost_aud if self._amber else 0.0 - globird_daily = self._globird.net_daily_cost_aud - - if amber_import is not None and amber_export is not None: + current_plan_import = self._current_plan_provider.current_import_rate_c_kwh + current_plan_export = self._current_plan_provider.current_export_rate_c_kwh + amber_daily: float | None + if self._amber is not None: + amber_daily = self._amber.net_daily_cost_aud + else: + amber_daily = None + current_plan_daily = self._current_plan_provider.net_daily_cost_aud + + if ( + self._amber is not None + and amber_import is not None + and amber_export is not None + and amber_daily is not None + ): metrics = [ - amber_import < globird_import, # lower import rate - amber_export > globird_export, # higher export earning - amber_daily < globird_daily, # cheaper today + amber_import < current_plan_import, + amber_export > current_plan_export, + amber_daily < current_plan_daily, ] metrics_won = f"{sum(metrics)}/{len(metrics)}" else: - metrics_won = "0/3" + metrics_won = None - # Check if ZEROHERO incentive is enabled + # Check if ZEROHERO incentive is enabled — legacy options OR CDR plan incentives = self.config_entry.options.get("incentives", {}) - has_zerohero = incentives.get("zerohero_credit", False) if isinstance(incentives, dict) else "zerohero_credit" in incentives + has_zerohero = ( + incentives.get("zerohero_credit", False) + if isinstance(incentives, dict) + else "zerohero_credit" in incentives + ) + if not has_zerohero: + cdr_plan = self.config_entry.options.get("cdr_plan") or {} + cdr_incentives = ( + cdr_plan.get("data", {}) + .get("electricityContract", {}) + .get("incentives", []) + or [] + ) + for inc in cdr_incentives: + name = (inc.get("displayName") or "").lower() + if "zerohero" in name and "credit" in name: + has_zerohero = True + break - # GloBird daily supply charge (full day value, not prorated) - globird_supply_aud = self.config_entry.options.get("daily_supply_charge", 0.0) / 100.0 + # GloBird daily supply charge (full day value, inc-GST). + # CDR plan: read from tariffPeriod[0].dailySupplyCharge (ex-GST AUD, ×1.10). + # Legacy: read from options.daily_supply_charge (cents, /100). + cdr_plan = self.config_entry.options.get("cdr_plan") or {} + cdr_supply_aud_ex_gst = None + if cdr_plan: + try: + tp = ( + cdr_plan.get("data", {}) + .get("electricityContract", {}) + .get("tariffPeriod", []) + ) + if tp: + cdr_supply_aud_ex_gst = float(tp[0].get("dailySupplyCharge", 0)) + except (KeyError, TypeError, ValueError): + cdr_supply_aud_ex_gst = None + if cdr_supply_aud_ex_gst is not None and cdr_supply_aud_ex_gst > 0: + current_plan_supply_aud = cdr_supply_aud_ex_gst * 1.10 + else: + current_plan_supply_aud = ( + self.config_entry.options.get("daily_supply_charge", 0.0) / 100.0 + ) data = { - "globird_import_rate": globird_import, - "globird_export_rate": globird_export, - "globird_daily_cost": globird_daily, - "globird_daily_supply_aud": globird_supply_aud, - "globird_import_cost_aud": self._globird.import_cost_today_c / 100.0, - "globird_export_credit_aud": self._globird.export_earnings_today_c / 100.0, - "globird_import_kwh": self._globird.import_kwh_today, - "globird_export_kwh": self._globird.export_kwh_today, - "globird_zerohero_status": self._globird.extras["zerohero_status"] if has_zerohero else None, - "globird_super_export_kwh": self._globird.extras["super_export_kwh"] if has_zerohero else None, + "current_plan_import_rate": current_plan_import, + "current_plan_export_rate": current_plan_export, + "current_plan_daily_cost": current_plan_daily, + "current_plan_daily_supply_aud": current_plan_supply_aud, + "current_plan_import_cost_aud": self._current_plan_provider.import_cost_today_c / 100.0, + "current_plan_export_credit_aud": self._current_plan_provider.export_earnings_today_c / 100.0, + "current_plan_import_kwh": self._current_plan_provider.import_kwh_today, + "current_plan_export_kwh": self._current_plan_provider.export_kwh_today, + "current_plan_zerohero_status": self._current_plan_provider.extras["zerohero_status"] if has_zerohero else None, + "current_plan_super_export_kwh": self._current_plan_provider.extras["super_export_kwh"] if has_zerohero else None, "amber_import_rate": amber_import, "amber_export_rate": amber_export, "amber_daily_cost": amber_daily, @@ -718,10 +827,16 @@ def _build_data_dict(self) -> dict[str, Any]: "amber_export_kwh": ( self._amber.export_kwh_today if self._amber else 0.0 ), - # Directional saving - "saving_today": self._compute_saving(amber_daily, globird_daily), + # Directional saving — None when Amber not configured + # (can't compute saving against a phantom $0 baseline). + "saving_today": ( + self._compute_saving(amber_daily, current_plan_daily) + if amber_daily is not None + else None + ), "saving_month_aud": self._saving_month_aud, - "globird_peak_rate": globird_peak_rate, + "current_plan_peak_rate": current_plan_peak_rate, + "current_plan_name": self._current_plan_provider.name, "amber_peak_rate": self._amber_import_c, # Wholesale spot from Amber API (input to Flow Power) "wholesale_c_kwh": self._wholesale_c, @@ -754,8 +869,8 @@ def _build_data_dict(self) -> dict[str, Any]: "t": now_ts.isoformat(), "ai": amber_import, "ae": amber_export, - "gi": globird_import, - "ge": globird_export, + "gi": current_plan_import, + "ge": current_plan_export, }) if len(self._price_history) > 2016: self._price_history = self._price_history[-2016:] @@ -770,74 +885,235 @@ def _build_data_dict(self) -> dict[str, Any]: # ------------------------------------------------------------------ async def async_restore_state(self) -> None: - """Restore engine state from Store on startup.""" + """Restore engine state from Store on startup. + + Phase 2.11.5: after the standard persist-restore, run a + replay-today pass for any provider that lacks restored state + (mid-day comparator enable, fresh install, or missing field in + the persisted store). Replay fetches today's grid power history + + retailer rates and seeds the accumulator so the dashboard + reflects today's true totals immediately rather than starting + from $0 and slowly catching up. + + Phase 3.0g (CodeRabbit): validates `_storage_version` field + in the persisted dict matches the in-code STORAGE_VERSION + before restoring. The HA Store class auto-bumps version inside + a manifest envelope, but Phase 1.x persisted directly without + a version sentinel, so a future schema change would silently + load mismatched data. Explicit validation makes drift loud. + """ stored = await self._store.async_load() - if not stored or not isinstance(stored, dict): + today = dt_util.now().date() + amber_was_restored = False + + if stored and isinstance(stored, dict): + stored_version = stored.get("_storage_version") + # CR PR #28: unversioned payloads (pre-Phase 1.x writes, or + # truncated state) must be rejected too, not silently restored. + if stored_version != STORAGE_VERSION: + _LOGGER.warning( + "Persisted state version %s != current STORAGE_VERSION %s; " + "discarding stored data. Today will rebuild from API replay.", + stored_version, STORAGE_VERSION, + ) + stored = None + if stored and isinstance(stored, dict): + globird_data = stored.get("globird") + amber_data = stored.get("amber") + + if globird_data: + self._current_plan_provider.from_dict(globird_data, today=today) + _LOGGER.debug("Restored GloBird provider state") + + if amber_data and self._amber is not None: + self._amber.from_dict(amber_data, today=today) + amber_was_restored = True + _LOGGER.debug("Restored Amber provider state") + + # Restore optional providers if enabled and persisted + if self._flow_power is not None and stored.get("flow_power"): + self._flow_power.from_dict(stored["flow_power"], today=today) + if self._localvolts is not None and stored.get("localvolts"): + self._localvolts.from_dict(stored["localvolts"], today=today) + + # Restore cached rates + if stored.get("amber_import_c") is not None: + self._amber_import_c = stored["amber_import_c"] + if stored.get("amber_export_c") is not None: + self._amber_export_c = stored["amber_export_c"] + if stored.get("wholesale_c") is not None: + self._wholesale_c = stored["wholesale_c"] + if stored.get("localvolts_import_c") is not None: + self._localvolts_import_c = stored["localvolts_import_c"] + if stored.get("localvolts_export_c") is not None: + self._localvolts_export_c = stored["localvolts_export_c"] + + # Restore monthly accumulator + if stored.get("saving_month_aud") is not None: + self._saving_month_aud = stored["saving_month_aud"] + if stored.get("last_month") is not None: + self._last_month = stored["last_month"] + if stored.get("last_date") is not None: + self._last_date = stored["last_date"] + + # Restore price history and daily wins + if stored.get("price_history"): + self._price_history = stored["price_history"] + if stored.get("daily_wins"): + self._daily_wins = stored["daily_wins"] + if stored.get("daily_cost_history"): + self._daily_cost_history = stored["daily_cost_history"] + if stored.get("today_schedule"): + self._today_schedule = stored["today_schedule"] + if stored.get("last_explanation"): + self._last_explanation = stored["last_explanation"] + + _LOGGER.info( + "Restored state: amber=%.2f/%.2fc, month_saving=$%.2f", + self._amber_import_c or 0, + self._amber_export_c or 0, + self._saving_month_aud, + ) + else: _LOGGER.info("No stored state to restore, starting fresh") + + # Phase 2.11.5: backfill today's totals for any unrestored + # provider so dashboards reflect real spend immediately on a + # fresh install or mid-day comparator enable. + if self._amber is not None and not amber_was_restored: + await self._replay_amber_today_from_api() + + async def _replay_amber_today_from_api(self) -> None: + """Replay today's grid-power history through AmberProvider. + + Seeds the live accumulator (import_cost_today_c, + export_earnings_today_c, kwh) with today's true totals computed + from HA recorder history + Amber `/sites/{id}/prices` data. + Idempotent: callers gate on "did persist restore this provider?" + so we don't overwrite a freshly-restored accumulator. + + Bails silently on any setup gap (no API key, no grid sensor, no + history rows). The next live coordinator tick takes over from + wherever we leave the accumulator. + """ + if not self._api_key or not self._site_id or not self._grid_power_entity: + _LOGGER.info("Amber replay skipped: missing api_key/site_id/grid sensor") + return + if self._amber is None: return - globird_data = stored.get("globird") - amber_data = stored.get("amber") + from datetime import timedelta as _td # noqa: PLC0415 - today = dt_util.now().date() + try: + from homeassistant.components.recorder import get_instance # noqa: PLC0415 + from homeassistant.components.recorder.history import ( # noqa: PLC0415 + state_changes_during_period, + ) + except ImportError: + _LOGGER.warning("HA recorder not available; skipping Amber replay") + return + + now = dt_util.now() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + if now <= start: + return + + try: + history = await get_instance(self.hass).async_add_executor_job( + state_changes_during_period, + self.hass, + start, + now, + self._grid_power_entity, + ) + except Exception as err: # noqa: BLE001 + _LOGGER.warning("Amber replay: HA history fetch failed: %s", err) + return - if globird_data: - self._globird.from_dict(globird_data, today=today) - _LOGGER.debug("Restored GloBird provider state") - - if amber_data and self._amber is not None: - self._amber.from_dict(amber_data, today=today) - _LOGGER.debug("Restored Amber provider state") - - # Restore optional providers if enabled and persisted - if self._flow_power is not None and stored.get("flow_power"): - self._flow_power.from_dict(stored["flow_power"], today=today) - if self._localvolts is not None and stored.get("localvolts"): - self._localvolts.from_dict(stored["localvolts"], today=today) - - # Restore cached Amber prices - if stored.get("amber_import_c") is not None: - self._amber_import_c = stored["amber_import_c"] - if stored.get("amber_export_c") is not None: - self._amber_export_c = stored["amber_export_c"] - if stored.get("wholesale_c") is not None: - self._wholesale_c = stored["wholesale_c"] - if stored.get("localvolts_import_c") is not None: - self._localvolts_import_c = stored["localvolts_import_c"] - if stored.get("localvolts_export_c") is not None: - self._localvolts_export_c = stored["localvolts_export_c"] - - # Restore monthly accumulator - if stored.get("saving_month_aud") is not None: - self._saving_month_aud = stored["saving_month_aud"] - if stored.get("last_month") is not None: - self._last_month = stored["last_month"] - if stored.get("last_date") is not None: - self._last_date = stored["last_date"] - - # Restore price history and daily wins - if stored.get("price_history"): - self._price_history = stored["price_history"] - if stored.get("daily_wins"): - self._daily_wins = stored["daily_wins"] - if stored.get("daily_cost_history"): - self._daily_cost_history = stored["daily_cost_history"] - if stored.get("today_schedule"): - self._today_schedule = stored["today_schedule"] - if stored.get("last_explanation"): - self._last_explanation = stored["last_explanation"] + states = history.get(self._grid_power_entity, []) if history else [] + if not states: + _LOGGER.info( + "Amber replay: no history rows for %s today; nothing to seed", + self._grid_power_entity, + ) + return + + # Fetch Amber prices for today via existing helper (urllib, sync). + from .backfill import fetch_amber_price_history # noqa: PLC0415 + + try: + prices = await self.hass.async_add_executor_job( + fetch_amber_price_history, + self._api_key, + self._site_id, + start, + now + _td(days=1), + ) + except Exception as err: # noqa: BLE001 + _LOGGER.warning("Amber replay: price-history fetch failed: %s", err) + return + + general = sorted( + (p for p in prices if p.get("channelType") == "general"), + key=lambda p: p.get("startTime", ""), + ) + feed = sorted( + (p for p in prices if p.get("channelType") == "feedIn"), + key=lambda p: p.get("startTime", ""), + ) + + def _rate_at(intervals: list[dict], ts_iso: str) -> float | None: + """Find perKwh value for ts within an interval. Returns c/kWh.""" + for itv in intervals: + if itv.get("startTime", "") <= ts_iso <= itv.get("endTime", ""): + try: + return float(itv["perKwh"]) + except (KeyError, TypeError, ValueError): + return None + return None + + # Reset accumulator so we don't double-count any partial restore. + self._amber.reset_daily() + + seeded_rows = 0 + for state in states: + try: + power_value = float(state.state) + except (TypeError, ValueError): + continue + # Match _read_grid_power() unit handling: kW → W. + unit = (state.attributes.get("unit_of_measurement", "") or "").lower() + power_w = power_value * 1000.0 if unit == "kw" else power_value + ts = state.last_changed + ts_iso = ts.isoformat() + import_rate = _rate_at(general, ts_iso) + export_rate = _rate_at(feed, ts_iso) + if import_rate is None or export_rate is None: + continue + self._amber.set_current_rates(import_rate, export_rate) + self._amber.update(power_w, ts) + seeded_rows += 1 _LOGGER.info( - "Restored state: amber=%.2f/%.2fc, month_saving=$%.2f", - self._amber_import_c or 0, - self._amber_export_c or 0, - self._saving_month_aud, + "Amber replay seeded: rows=%d import_kwh=%.3f export_kwh=%.3f " + "import_cost=$%.4f export_credit=$%.4f", + seeded_rows, + self._amber.import_kwh_today, + self._amber.export_kwh_today, + self._amber.import_cost_today_c / 100.0, + self._amber.export_earnings_today_c / 100.0, ) async def async_persist_state(self) -> None: - """Save engine state to Store.""" + """Save engine state to Store. + + Phase 3.0g: stamp `_storage_version` so async_restore_state can + validate the schema before loading. AEGIS rule: state restore + MUST validate storage version (CLAUDE.md). + """ data: dict[str, Any] = { - "globird": self._globird.to_dict(), + "_storage_version": STORAGE_VERSION, + "globird": self._current_plan_provider.to_dict(), "amber_import_c": self._amber_import_c, "amber_export_c": self._amber_export_c, "wholesale_c": self._wholesale_c, @@ -886,9 +1162,24 @@ def cancel_persist(self) -> None: # ------------------------------------------------------------------ def rebuild_engine(self, new_options: dict) -> None: - """Rebuild all providers with updated options.""" - self._globird = GloBirdProvider(new_options) - self._providers = {self._globird.id: self._globird} + """Rebuild all providers with updated options. + + Phase 3.0c invariant: every entry has a cdr_plan. Options-flow + reload should never produce a state without one. + """ + cdr_plan = new_options.get("cdr_plan") + if not cdr_plan: + _LOGGER.error( + "rebuild_engine called without cdr_plan in options; " + "keeping existing provider — investigate options-flow" + ) + return + self._current_plan_provider = CdrPlanProvider( + cdr_plan, entry_options=dict(new_options), + ) + _LOGGER.info("Rebuilt with CdrPlanProvider (CDR plan %s)", + cdr_plan.get("data", {}).get("planId", "?")) + self._providers = {self._current_plan_provider.id: self._current_plan_provider} self._amber = None amber_enabled = new_options.get(CONF_AMBER_ENABLED) diff --git a/custom_components/pricehawk/dashboard_config.py b/custom_components/pricehawk/dashboard_config.py index e013472..51bbef8 100644 --- a/custom_components/pricehawk/dashboard_config.py +++ b/custom_components/pricehawk/dashboard_config.py @@ -5,6 +5,7 @@ import logging import os import shutil +import time from pathlib import Path from homeassistant.config_entries import ConfigEntry @@ -96,9 +97,14 @@ async def setup_panel_iframe(hass: HomeAssistant, entry: ConfigEntry) -> None: except Exception: version = "unknown" - # Build the dashboard URL + # Build the dashboard URL with version + epoch cache-buster. + # The epoch portion guarantees every HA restart / integration reload yields a + # new iframe URL, defeating the 31-day max-age set by HA's /local/ static + # handler — without it, browsers and the HA companion app can pin a stale + # dashboard.html for weeks even after a HACS upgrade. ha_token = entry.data.get("ha_token", "") - dashboard_url = f"/local/pricehawk/dashboard.html?v={version}" + cache_token = f"{version}.{int(time.time())}" + dashboard_url = f"/local/pricehawk/dashboard.html?v={cache_token}" if ha_token: dashboard_url += f"&token={ha_token}" @@ -120,10 +126,17 @@ async def setup_panel_iframe(hass: HomeAssistant, entry: ConfigEntry) -> None: config={"url": dashboard_url}, require_admin=False, ) + # Phase 3.0g (CodeRabbit security): redact token from log output. + # The dashboard_url may contain `&token=` and was + # previously written to the log in plain text — anyone with log + # access could lift the token and impersonate the integration. + log_url = dashboard_url + if "&token=" in log_url: + log_url = log_url.split("&token=")[0] + "&token=" _LOGGER.info( "PriceHawk: sidebar panel registered at /%s -> %s", PANEL_URL_PATH, - dashboard_url, + log_url, ) except Exception: _LOGGER.error( diff --git a/custom_components/pricehawk/manifest.json b/custom_components/pricehawk/manifest.json index 0a2cc05..9812b5e 100644 --- a/custom_components/pricehawk/manifest.json +++ b/custom_components/pricehawk/manifest.json @@ -1,6 +1,7 @@ { "domain": "pricehawk", "name": "PriceHawk", + "after_dependencies": ["recorder"], "codeowners": ["@Artic0din"], "config_flow": true, "dependencies": ["lovelace"], @@ -9,5 +10,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/Artic0din/ha-pricehawk/issues", "requirements": [], - "version": "1.4.0-beta.1" + "version": "1.5.0-beta.1" } diff --git a/custom_components/pricehawk/providers/__init__.py b/custom_components/pricehawk/providers/__init__.py index 9d462fd..8d16279 100644 --- a/custom_components/pricehawk/providers/__init__.py +++ b/custom_components/pricehawk/providers/__init__.py @@ -1,17 +1,23 @@ -"""Provider package — retailer implementations behind a common Protocol.""" +"""Provider package — retailer implementations behind a common Protocol. + +Phase 3.0d: legacy GloBirdProvider (manual-tariff path) removed. Every +PriceHawk entry now uses CdrPlanProvider (in `cdr_plan.py`) for the +user's CURRENT plan. Amber/FlowPower/LocalVolts remain as optional +truth-source overlays exposed via this package. +""" from __future__ import annotations from .amber import AmberProvider from .base import Provider +from .cdr_plan import CdrPlanProvider from .flow_power import FlowPowerProvider -from .globird import GloBirdProvider from .localvolts import LocalVoltsProvider __all__ = [ "AmberProvider", + "CdrPlanProvider", "FlowPowerProvider", - "GloBirdProvider", "LocalVoltsProvider", "Provider", ] diff --git a/custom_components/pricehawk/providers/cdr_plan.py b/custom_components/pricehawk/providers/cdr_plan.py new file mode 100644 index 0000000..4b9770d --- /dev/null +++ b/custom_components/pricehawk/providers/cdr_plan.py @@ -0,0 +1,133 @@ +"""Generic CDR-plan provider — wraps the streaming evaluator for any +AU retailer's CDR PlanDetailV2 envelope. + +Phase 3.0 (rename from CdrGloBirdProvider): the same class powers the +user's CURRENT plan and any alternative plan we're ranking. Identity +(`id`, `name`) is derived from the plan's `brand` / `brandName` / +`displayName` instead of hardcoded GloBird-specific values. + +Config entry shape: +- `entry.options["cdr_plan"]` is the CDR PlanDetailV2 JSON envelope for + the user's CURRENT plan (the truth source). +- Phase 3.1 will introduce alongside-running instances for top-K + ranked alternatives. +""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from ..cdr.streaming import CdrStreamingEngine + + +class CdrPlanProvider: + """Provider adapter around `cdr.streaming.CdrStreamingEngine`. + + Generic across all CDR retailers. `id` and `name` are derived from + the plan envelope, so the dashboard reads the user-meaningful + retailer + plan name automatically. + """ + + def __init__( + self, + cdr_plan: dict[str, Any], + entry_options: dict[str, Any] | None = None, + ) -> None: + self._plan = cdr_plan + # Phase 2.12.1: user-side opt-in fields plumbed to engine. + self._entry_options = entry_options or {} + self._engine = CdrStreamingEngine(cdr_plan, entry_options=entry_options) + # Resolve daily supply charge once at init (CDR is ex-GST $/day) + plan_data = cdr_plan.get("data", cdr_plan) + elec = plan_data.get("electricityContract", {}) or {} + tps = elec.get("tariffPeriod", []) or [] + # CR-fix: guard against malformed dailySupplyCharge values in + # the CDR payload (rare but observed — some retailers publish + # empty strings during republish windows). Bad value → $0/day + # supply rather than crashing coordinator/provider setup. + raw_dsc = (tps[0] if tps else {}).get("dailySupplyCharge", 0) + try: + dsc_ex_gst = float(raw_dsc or 0) + except (TypeError, ValueError): + dsc_ex_gst = 0.0 + self._daily_supply_aud = dsc_ex_gst * 1.10 + # Identity derived from plan envelope (Phase 3.0). + self._brand = (plan_data.get("brand") or "unknown").lower() + self._plan_id = plan_data.get("planId") or "unknown" + self._display_name = ( + plan_data.get("displayName") + or plan_data.get("brandName") + or self._brand.title() + ) + + @property + def id(self) -> str: + """Provider identity for sensor naming. Brand slug + plan id.""" + return f"{self._brand}_{self._plan_id}" + + @property + def name(self) -> str: + """Human-readable provider name for dashboards + winner-explanation.""" + return self._display_name + + # -- Provider interface ----------------------------------------------- + + def set_current_rates( + self, import_c_kwh: float | None, export_c_kwh: float | None + ) -> None: + """Self-priced. Rates come from CDR tariffPeriod.""" + return + + def update(self, grid_power_w: float, now_local: datetime) -> None: + self._engine.update(grid_power_w, now_local) + + def reset_daily(self) -> None: + self._engine.reset_daily() + + @property + def current_import_rate_c_kwh(self) -> float: + return self._engine.current_import_rate_c_kwh + + @property + def current_export_rate_c_kwh(self) -> float: + return self._engine.current_export_rate_c_kwh + + @property + def import_kwh_today(self) -> float: + return self._engine.import_kwh_today + + @property + def export_kwh_today(self) -> float: + return self._engine.export_kwh_today + + @property + def import_cost_today_c(self) -> float: + return self._engine.import_cost_today_c + + @property + def export_earnings_today_c(self) -> float: + return self._engine.export_earnings_today_c + + @property + def daily_fixed_charges_aud(self) -> float: + return self._daily_supply_aud + + @property + def net_daily_cost_aud(self) -> float: + return self._engine.net_daily_cost_aud + + @property + def extras(self) -> dict[str, Any]: + return { + "zerohero_status": self._engine.zerohero_status, + "super_export_kwh": self._engine.super_export_kwh, + } + + def to_dict(self) -> dict[str, Any]: + return self._engine.to_dict() + + def from_dict(self, data: dict[str, Any], today: date) -> None: + self._engine = CdrStreamingEngine.from_dict( + self._plan, data, today=today, + entry_options=self._entry_options, + ) diff --git a/custom_components/pricehawk/providers/globird.py b/custom_components/pricehawk/providers/globird.py deleted file mode 100644 index d456892..0000000 --- a/custom_components/pricehawk/providers/globird.py +++ /dev/null @@ -1,91 +0,0 @@ -"""GloBird provider — wraps the existing TariffEngine. - -Self-priced: rates derive from the TOU/stepped configuration baked into -``options``. ``set_current_rates`` is a no-op. -""" - -from __future__ import annotations - -from datetime import date, datetime -from typing import Any - -from ..tariff_engine import TariffEngine - - -class GloBirdProvider: - """Provider adapter around TariffEngine.""" - - id = "globird" - name = "GloBird Energy" - - def __init__(self, options: dict[str, Any]) -> None: - self._engine = TariffEngine(options) - self._options = options - - # -- Provider interface -------------------------------------------------- - - def set_current_rates( - self, import_c_kwh: float | None, export_c_kwh: float | None - ) -> None: - # Self-priced: rates come from configured TOU/stepped tariff. - return - - def update(self, grid_power_w: float, now_local: datetime) -> None: - self._engine.update(grid_power_w, now_local) - - def reset_daily(self) -> None: - self._engine.reset_daily() - - @property - def current_import_rate_c_kwh(self) -> float: - return self._engine.current_import_rate_c_kwh - - @property - def current_export_rate_c_kwh(self) -> float: - return self._engine.current_export_rate_c_kwh - - @property - def import_kwh_today(self) -> float: - return self._engine.import_kwh_today - - @property - def export_kwh_today(self) -> float: - return self._engine.export_kwh_today - - @property - def import_cost_today_c(self) -> float: - return self._engine.import_cost_today_c - - @property - def export_earnings_today_c(self) -> float: - return self._engine.export_earnings_today_c - - @property - def daily_fixed_charges_aud(self) -> float: - return self._options.get("daily_supply_charge", 0.0) / 100.0 - - @property - def net_daily_cost_aud(self) -> float: - return self._engine.net_daily_cost_aud - - @property - def extras(self) -> dict[str, Any]: - return { - "zerohero_status": self._engine.zerohero_status, - "super_export_kwh": self._engine.super_export_kwh, - } - - def to_dict(self) -> dict[str, Any]: - return self._engine.to_dict() - - def from_dict(self, data: dict[str, Any], today: date) -> None: - # TariffEngine.from_dict is a classmethod that returns a new engine; - # adapt to mutate-in-place by replacing _engine. - self._engine = TariffEngine.from_dict(self._options, data, today=today) - - # -- Pass-through for legacy access by coordinator ----------------------- - - @property - def engine(self) -> TariffEngine: - """Direct access to the underlying engine (legacy code paths).""" - return self._engine diff --git a/custom_components/pricehawk/sensor.py b/custom_components/pricehawk/sensor.py index d0626c5..b00aa0a 100644 --- a/custom_components/pricehawk/sensor.py +++ b/custom_components/pricehawk/sensor.py @@ -23,14 +23,13 @@ _LOGGER = logging.getLogger(__name__) +# Peak-rate sensors only. Import/export rates are owned by GenericProviderRateSensor +# (registered in async_setup_entry's providers loop) — listing them here too caused +# unique_id collisions that dropped the entities the dashboard depends on. # (key in coordinator.data, _attr_name, is_amber_dependent) RATE_SENSORS: list[tuple[str, str, bool]] = [ - ("amber_import_rate", "Amber Import Rate", True), - ("amber_export_rate", "Amber Feed In Tariff", True), ("amber_peak_rate", "Amber Peak Rate", True), - ("globird_import_rate", "GloBird Import Rate", False), - ("globird_export_rate", "GloBird Feed In Tariff", False), - ("globird_peak_rate", "GloBird Peak Rate", False), + ("current_plan_peak_rate", "Current Plan Peak Rate", False), ] @@ -85,7 +84,13 @@ def available(self) -> bool: super().available and self.coordinator.data.get("amber_import_rate") is not None ) - return super().available + # Non-Amber rate sensors (e.g. current_plan_peak_rate) are unavailable + # when the coordinator hasn't computed a value yet — surfacing "unknown" + # for a TOU plan with no peak window defined is misleading. + return ( + super().available + and self.coordinator.data.get(self._key) is not None + ) class BestProviderSensor(PriceHawkBaseSensor): @@ -99,12 +104,15 @@ def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: @property def native_value(self) -> str: amber = self.coordinator.data.get("amber_import_rate") - globird = self.coordinator.data.get("globird_import_rate") + current_plan = self.coordinator.data.get("current_plan_import_rate") + current_plan_name = ( + self.coordinator.data.get("current_plan_name") or "Current Plan" + ) if amber is None: - return "GloBird Energy" - if globird is None: + return current_plan_name + if current_plan is None: return "Amber Electric" - return "Amber Electric" if amber <= globird else "GloBird Energy" + return "Amber Electric" if amber <= current_plan else current_plan_name class CheapestTodaySensor(PriceHawkBaseSensor): @@ -118,12 +126,15 @@ def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: @property def native_value(self) -> str: amber = self.coordinator.data.get("amber_daily_cost") - globird = self.coordinator.data.get("globird_daily_cost") + current_plan = self.coordinator.data.get("current_plan_daily_cost") + current_plan_name = ( + self.coordinator.data.get("current_plan_name") or "Current Plan" + ) if amber is None: - return "GloBird Energy" - if globird is None: + return current_plan_name + if current_plan is None: return "Amber Electric" - return "Amber Electric" if amber <= globird else "GloBird Energy" + return "Amber Electric" if amber <= current_plan else current_plan_name class BestRateSensor(PriceHawkBaseSensor): @@ -141,12 +152,12 @@ def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: def native_value(self) -> float | None: """Return the cheapest current import rate across both providers.""" amber = self.coordinator.data.get("amber_import_rate") - globird = self.coordinator.data.get("globird_import_rate") + current_plan = self.coordinator.data.get("current_plan_import_rate") if amber is None: - return globird - if globird is None: + return current_plan + if current_plan is None: return amber - return min(amber, globird) + return min(amber, current_plan) class SavingTodaySensor(PriceHawkBaseSensor): @@ -203,26 +214,18 @@ def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: @property def native_value(self) -> str | None: - val = self.coordinator.data.get("metrics_won") - if val is not None: - return val - # Compute inline if coordinator doesn't provide it - data = self.coordinator.data - amber_import = data.get("amber_import_rate") - globird_import = data.get("globird_import_rate") - amber_export = data.get("amber_export_rate") - globird_export = data.get("globird_export_rate") - amber_daily = data.get("amber_daily_cost") - globird_daily = data.get("globird_daily_cost") - if amber_import is None or globird_import is None: - return "0/3" - metrics = [ - amber_import < globird_import, - (amber_export or 0) > (globird_export or 0), - (amber_daily or 0) < (globird_daily or 0), - ] - won = sum(metrics) - return f"{won}/{len(metrics)}" + # Coordinator owns metrics_won (computed once, with a single + # source of truth for "no comparator available" → None). + # Inline-compute fallback was dead code post-Phase 3.0g. + return self.coordinator.data.get("metrics_won") + + @property + def available(self) -> bool: + # Unavailable when no comparator (Amber absent or not yet computed). + return ( + super().available + and self.coordinator.data.get("metrics_won") is not None + ) class AmberDailyChargesSensor(PriceHawkBaseSensor): @@ -291,28 +294,37 @@ def extra_state_attributes(self) -> dict: "today_schedule": self.coordinator.data.get("today_schedule", []), "amber_import_kwh": self.coordinator.data.get("amber_import_kwh", 0), "amber_export_kwh": self.coordinator.data.get("amber_export_kwh", 0), - "globird_import_kwh": self.coordinator.data.get("globird_import_kwh", 0), - "globird_export_kwh": self.coordinator.data.get("globird_export_kwh", 0), - "daily_wins": self.coordinator.data.get("daily_wins", {"amber": 0, "globird": 0}), + "current_plan_import_kwh": self.coordinator.data.get("current_plan_import_kwh", 0), + "current_plan_export_kwh": self.coordinator.data.get("current_plan_export_kwh", 0), + # Phase 3.0g (CodeRabbit/Sourcery): default to empty dict. + # daily_wins is provider-id keyed (e.g., + # `globird_GLO731031MR@VEC`, `amber`, `flow_power`) — + # hardcoding `{"amber": 0, "current_plan": 0}` never matched + # the dynamic per-plan ids introduced in Phase 3.0a. + "daily_wins": self.coordinator.data.get("daily_wins", {}), "daily_cost_history": self.coordinator.data.get("daily_cost_history", []), "csv_comparison": self.coordinator.data.get("csv_comparison"), } -class GloBirdDailySupplySensor(PriceHawkBaseSensor): - """GloBird daily supply charge (fixed value, no state_class).""" +class CurrentPlanDailySupplySensor(PriceHawkBaseSensor): + """Current-plan daily supply charge (fixed value, no state_class). + + Phase 3.0e: renamed from GloBirdDailySupplySensor. Works for any + retailer's plan, not just GloBird. + """ - _attr_name = "PriceHawk GloBird Daily Supply" + _attr_name = "PriceHawk Current Plan Daily Supply" _attr_device_class = SensorDeviceClass.MONETARY _attr_native_unit_of_measurement = "AUD" _attr_suggested_display_precision = 2 def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: - super().__init__(coordinator, entry, "globird_daily_supply_aud") + super().__init__(coordinator, entry, "current_plan_daily_supply_aud") @property def native_value(self) -> float | None: - return self.coordinator.data.get("globird_daily_supply_aud") + return self.coordinator.data.get("current_plan_daily_supply_aud") class ZeroHeroStatusSensor(PriceHawkBaseSensor): @@ -326,7 +338,7 @@ def __init__(self, coordinator: Any, entry: ConfigEntry) -> None: @property def native_value(self) -> str | None: - return self.coordinator.data.get("globird_zerohero_status") + return self.coordinator.data.get("current_plan_zerohero_status") # -- Generic per-provider sensors (pricehawk__*) ------------------- @@ -508,16 +520,16 @@ async def async_setup_entry( # Per-provider daily total cost entities.append(ProviderDailyCostSensor(coordinator, entry, "amber_daily_cost", "PriceHawk Amber Cost Today")) - entities.append(ProviderDailyCostSensor(coordinator, entry, "globird_daily_cost", "PriceHawk GloBird Cost Today")) + entities.append(ProviderDailyCostSensor(coordinator, entry, "current_plan_daily_cost", "PriceHawk Current Plan Cost Today")) # Import/export cost breakdowns entities.append(ProviderDailyCostSensor(coordinator, entry, "amber_import_cost_aud", "PriceHawk Amber Import Cost")) entities.append(ProviderDailyCostSensor(coordinator, entry, "amber_export_credit_aud", "PriceHawk Amber Export Credit")) - entities.append(ProviderDailyCostSensor(coordinator, entry, "globird_import_cost_aud", "PriceHawk GloBird Import Cost")) - entities.append(ProviderDailyCostSensor(coordinator, entry, "globird_export_credit_aud", "PriceHawk GloBird Export Credit")) + entities.append(ProviderDailyCostSensor(coordinator, entry, "current_plan_import_cost_aud", "PriceHawk Current Plan Import Cost")) + entities.append(ProviderDailyCostSensor(coordinator, entry, "current_plan_export_credit_aud", "PriceHawk Current Plan Export Credit")) # Daily supply charge (fixed value — no state_class) - entities.append(GloBirdDailySupplySensor(coordinator, entry)) + entities.append(CurrentPlanDailySupplySensor(coordinator, entry)) # Timestamp entities.append(LastUpdatedSensor(coordinator, entry)) @@ -526,10 +538,22 @@ async def async_setup_entry( entities.append(ZeroHeroStatusSensor(coordinator, entry)) # Generic per-provider sensors (pricehawk__*) — registered for - # every provider currently active in the coordinator. Reads the canonical - # data["providers"][] block. + # every comparator provider currently active in the coordinator. + # Phase 3.0g (UAT): SKIP the user's CURRENT plan provider — its + # rate/cost/kwh metrics already have hardcoded `current_plan_*` + # sensors registered above. Registering both produces duplicate + # entities (`sensor.pricehawk___*` vs + # `sensor.pricehawk_current_plan_*`). Comparators (Amber, Flow + # Power, LocalVolts) keep their per-provider entities. providers_block = coordinator.data.get("providers", {}) if coordinator.data else {} + current_plan_id = ( + coordinator._current_plan_provider.id + if hasattr(coordinator, "_current_plan_provider") + else None + ) for provider_id, snap in providers_block.items(): + if provider_id == current_plan_id: + continue provider_name = snap.get("name", provider_id.title()) entities.append( GenericProviderRateSensor( diff --git a/custom_components/pricehawk/strings.json b/custom_components/pricehawk/strings.json index 9f970f5..8760369 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -48,6 +48,49 @@ "site_id": "Sites show NMI, network, and status (active/closed)" } }, + "cdr_retailer": { + "title": "Pick your retailer (CDR)", + "description": "Choose your retailer to import its plan list directly from the Consumer Data Right API. PriceHawk supports any AU retailer enrolled in CDR.", + "data": { + "cdr_retailer_id": "Retailer" + } + }, + "cdr_locale": { + "title": "Narrow plans by location", + "description": "Big retailers publish a plan for every distributor (NSW alone has 3 — Ausgrid, Endeavour, Essential). Enter your postcode OR pick a state to narrow the list before you scroll. Leave both blank to see every plan the retailer publishes.", + "data": { + "cdr_postcode": "Postcode (optional)", + "cdr_state": "State (optional)" + } + }, + "cdr_distributor": { + "title": "Pick your distributor", + "description": "Distributors known for {state}. Your network distributor is on your bill (e.g. Ausgrid for most Sydney homes, Powercor for most western Vic homes). Pick \"Any\" to skip this filter.", + "data": { + "cdr_distributor": "Distributor" + } + }, + "cdr_plan_select": { + "title": "Pick your CDR plan", + "description": "PriceHawk found the published plans for this retailer. Pick the one matching your current bill.", + "data": { + "cdr_plan_id": "Plan" + } + }, + "cdr_confirm": { + "title": "Confirm plan: {plan_name}", + "description": "Check these values against your actual bill before continuing.\n\nRetailer: {brand}\nPlan: {plan_name}\nEffective from: {effective}\nDaily supply: {daily_supply}\nImport rate: {import_rate}\nFeed-in: {feed_in}\nControlled load: {controlled_load}\nIncentives: {incentives}", + "data": { + "cdr_confirm_action": "Does this match your bill?" + } + }, + "cdr_error": { + "title": "CDR fetch problem", + "description": "PriceHawk couldn't load the {kind} data on attempt {attempt} of {max}. The retailer's data holder may be transient — retry now, or cancel setup and try again later.", + "data": { + "cdr_retry_action": "Action" + } + }, "globird_plan": { "title": "GloBird Energy Plan", "description": "Select your GloBird plan or choose Custom to enter your own rates. Known plans pre-fill rates from current fact sheets — you can customise any value in the next steps.", @@ -174,7 +217,13 @@ "peak_shoulder_overlap": "Peak and Shoulder time windows overlap. Each time slot can only belong to one period.", "peak_offpeak_overlap": "Peak and Off-Peak time windows overlap. Each time slot can only belong to one period.", "shoulder_offpeak_overlap": "Shoulder and Off-Peak time windows overlap. Each time slot can only belong to one period.", - "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh." + "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh.", + "cdr_registry_unavailable": "Could not load the retailer registry. The Energy Made Easy refdata service may be down, or your network is blocking energymadeeasy.gov.au.", + "cdr_list_unavailable": "Could not load this retailer's plan list. Their Consumer Data Right data holder may be temporarily offline.", + "cdr_detail_unavailable": "Could not fetch the chosen plan's details. The planId may be stale, or the data holder is rate-limiting.", + "cdr_empty_unavailable": "This retailer's CDR list returned no residential electricity plans. Pick a different retailer.", + "cdr_invalid_postcode": "Not a valid Australian postcode. Use a 4-digit number, or pick a state from the dropdown instead.", + "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer." }, "abort": { "already_configured": "PriceHawk is already configured." @@ -186,7 +235,9 @@ "title": "PriceHawk Settings", "description": "Choose what to update.", "menu_options": { + "comparators": "Toggle Comparators (Amber / Flow Power / LocalVolts) + Opt-Ins", "amber_api_key": "Change Amber API Key & Site", + "cdr_pick": "Switch CDR plan", "globird_plan": "Edit GloBird Tariffs & Rates", "amber_fees": "Edit Amber Fees", "flow_power": "Configure Flow Power", @@ -194,6 +245,31 @@ "sensor_select": "Change Grid Power Sensor" } }, + "comparators": { + "title": "Comparator providers + opt-in fields", + "description": "Toggle which alternative providers PriceHawk evaluates against your current plan. Opt-in fields drive incentive math the integration can't infer from CDR data alone.", + "data": { + "amber_enabled": "Amber Electric (live API)", + "flow_power_enabled": "Flow Power (live API)", + "localvolts_enabled": "LocalVolts (live API)", + "ovo_interest_balance_aud": "OVO credit balance ($AUD) — drives 3% interest credit", + "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" + } + }, + "cdr_pick": { + "title": "Switch CDR plan — pick retailer", + "description": "Pick your retailer to load its latest CDR plan list. Pick \"Skip\" to return to the menu without changing anything.", + "data": { + "cdr_retailer_id": "Retailer" + } + }, + "cdr_plan_pick": { + "title": "Switch CDR plan — pick plan", + "description": "Pick the plan to load. Pick \"Cancel\" to back out and keep your current CDR plan.", + "data": { + "cdr_plan_id": "Plan" + } + }, "flow_power": { "title": "Flow Power", "description": "Add Flow Power as a comparator. Wholesale spot price is sourced from your Amber API connection (spotPerKwh field). Happy Hour FiT 5:30-7:30pm: 45c NSW/QLD/SA, 35c VIC, 0c TAS.", diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index 9f970f5..8760369 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -48,6 +48,49 @@ "site_id": "Sites show NMI, network, and status (active/closed)" } }, + "cdr_retailer": { + "title": "Pick your retailer (CDR)", + "description": "Choose your retailer to import its plan list directly from the Consumer Data Right API. PriceHawk supports any AU retailer enrolled in CDR.", + "data": { + "cdr_retailer_id": "Retailer" + } + }, + "cdr_locale": { + "title": "Narrow plans by location", + "description": "Big retailers publish a plan for every distributor (NSW alone has 3 — Ausgrid, Endeavour, Essential). Enter your postcode OR pick a state to narrow the list before you scroll. Leave both blank to see every plan the retailer publishes.", + "data": { + "cdr_postcode": "Postcode (optional)", + "cdr_state": "State (optional)" + } + }, + "cdr_distributor": { + "title": "Pick your distributor", + "description": "Distributors known for {state}. Your network distributor is on your bill (e.g. Ausgrid for most Sydney homes, Powercor for most western Vic homes). Pick \"Any\" to skip this filter.", + "data": { + "cdr_distributor": "Distributor" + } + }, + "cdr_plan_select": { + "title": "Pick your CDR plan", + "description": "PriceHawk found the published plans for this retailer. Pick the one matching your current bill.", + "data": { + "cdr_plan_id": "Plan" + } + }, + "cdr_confirm": { + "title": "Confirm plan: {plan_name}", + "description": "Check these values against your actual bill before continuing.\n\nRetailer: {brand}\nPlan: {plan_name}\nEffective from: {effective}\nDaily supply: {daily_supply}\nImport rate: {import_rate}\nFeed-in: {feed_in}\nControlled load: {controlled_load}\nIncentives: {incentives}", + "data": { + "cdr_confirm_action": "Does this match your bill?" + } + }, + "cdr_error": { + "title": "CDR fetch problem", + "description": "PriceHawk couldn't load the {kind} data on attempt {attempt} of {max}. The retailer's data holder may be transient — retry now, or cancel setup and try again later.", + "data": { + "cdr_retry_action": "Action" + } + }, "globird_plan": { "title": "GloBird Energy Plan", "description": "Select your GloBird plan or choose Custom to enter your own rates. Known plans pre-fill rates from current fact sheets — you can customise any value in the next steps.", @@ -174,7 +217,13 @@ "peak_shoulder_overlap": "Peak and Shoulder time windows overlap. Each time slot can only belong to one period.", "peak_offpeak_overlap": "Peak and Off-Peak time windows overlap. Each time slot can only belong to one period.", "shoulder_offpeak_overlap": "Shoulder and Off-Peak time windows overlap. Each time slot can only belong to one period.", - "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh." + "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh.", + "cdr_registry_unavailable": "Could not load the retailer registry. The Energy Made Easy refdata service may be down, or your network is blocking energymadeeasy.gov.au.", + "cdr_list_unavailable": "Could not load this retailer's plan list. Their Consumer Data Right data holder may be temporarily offline.", + "cdr_detail_unavailable": "Could not fetch the chosen plan's details. The planId may be stale, or the data holder is rate-limiting.", + "cdr_empty_unavailable": "This retailer's CDR list returned no residential electricity plans. Pick a different retailer.", + "cdr_invalid_postcode": "Not a valid Australian postcode. Use a 4-digit number, or pick a state from the dropdown instead.", + "manual_tariff_removed": "Manual tariff entry has been removed. Pick a CDR plan or choose a different retailer." }, "abort": { "already_configured": "PriceHawk is already configured." @@ -186,7 +235,9 @@ "title": "PriceHawk Settings", "description": "Choose what to update.", "menu_options": { + "comparators": "Toggle Comparators (Amber / Flow Power / LocalVolts) + Opt-Ins", "amber_api_key": "Change Amber API Key & Site", + "cdr_pick": "Switch CDR plan", "globird_plan": "Edit GloBird Tariffs & Rates", "amber_fees": "Edit Amber Fees", "flow_power": "Configure Flow Power", @@ -194,6 +245,31 @@ "sensor_select": "Change Grid Power Sensor" } }, + "comparators": { + "title": "Comparator providers + opt-in fields", + "description": "Toggle which alternative providers PriceHawk evaluates against your current plan. Opt-in fields drive incentive math the integration can't infer from CDR data alone.", + "data": { + "amber_enabled": "Amber Electric (live API)", + "flow_power_enabled": "Flow Power (live API)", + "localvolts_enabled": "LocalVolts (live API)", + "ovo_interest_balance_aud": "OVO credit balance ($AUD) — drives 3% interest credit", + "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" + } + }, + "cdr_pick": { + "title": "Switch CDR plan — pick retailer", + "description": "Pick your retailer to load its latest CDR plan list. Pick \"Skip\" to return to the menu without changing anything.", + "data": { + "cdr_retailer_id": "Retailer" + } + }, + "cdr_plan_pick": { + "title": "Switch CDR plan — pick plan", + "description": "Pick the plan to load. Pick \"Cancel\" to back out and keep your current CDR plan.", + "data": { + "cdr_plan_id": "Plan" + } + }, "flow_power": { "title": "Flow Power", "description": "Add Flow Power as a comparator. Wholesale spot price is sourced from your Amber API connection (spotPerKwh field). Happy Hour FiT 5:30-7:30pm: 45c NSW/QLD/SA, 35c VIC, 0c TAS.", diff --git a/scripts/CDR_INCENTIVE_CATALOG.md b/scripts/CDR_INCENTIVE_CATALOG.md new file mode 100644 index 0000000..93d2bf1 --- /dev/null +++ b/scripts/CDR_INCENTIVE_CATALOG.md @@ -0,0 +1,175 @@ +# CDR Incentive Shape Catalog v3 (in-scope $/yr math) + +_Sweep: 10262 plans, 7165 incentives_ +_Source: /tmp/cdr-cache/_ +_Scope: incentives that affect recurring $/yr cost. v3 broadened stepped_fit to catch Origin+AGL+Solar Max._ + +## Coverage — IN-SCOPE rules + +| rule_id | incentives | plans | retailers | +|---|---:|---:|---:| +| stepped_fit_rate_first | 66 | 66 | 1 | +| stepped_fit_quantity_first | 40 | 40 | 1 | +| solar_max_export_pool | 104 | 104 | 2 | +| bonus_fit_capped_windowed | 20 | 20 | 1 | +| bonus_fit_uncapped_windowed | 70 | 70 | 1 | +| free_import_window | 315 | 315 | 4 | +| behavior_daily_credit | 20 | 20 | 1 | +| critical_peak_export | 20 | 20 | 1 | +| critical_peak_import | 20 | 20 | 1 | +| vpp_rebate | 693 | 687 | 2 | +| ev_offpeak_override | 165 | 165 | 2 | +| ovo_credit_interest | 324 | 324 | 1 | +| subscription_bundle_with_dollar_value | 150 | 150 | 1 | + +**IN-SCOPE total: 2007 incentives (28.0%)** + + +## Dropped — OUT-OF-SCOPE per user + +| dropped category | incentives | plans | +|---|---:|---:| +| loyalty_points | 528 | 528 | +| charity_donation | 637 | 482 | +| signup_credit_oneoff | 1238 | 1163 | +| referral_credit | 517 | 517 | +| prepaid_card_bonus | 172 | 172 | +| perk_membership | 553 | 553 | +| greenpower_flag | 422 | 422 | +| solar_install_offer | 10 | 10 | +| marketing_copy | 893 | 893 | + +**Dropped: 4970 (69.4%)** + +**Still-UNMATCHED: 188 (2.6%)** + + +## IN-SCOPE samples + +### stepped_fit_rate_first (66, 1 retailer(s)) +_Tiered FIT, rate-first: 'X c/kWh until N kWh' (Origin/Alinta)_ +Retailers: alinta-energy + +- **[alinta-energy]** *Solar Feed-in Tariff* + elig: `This Energy Plan includes a stepped feed-in tariff, where you will receive a feed-in of 7c/kWh for the first 10kW exported. For any export after that you will obtain Alinta Energy’s standard retailer feed-in tariff of 0.04c/kWh.` +- **[alinta-energy]** *Stepped FiT* + +### stepped_fit_quantity_first (40, 1 retailer(s)) +_Tiered FIT, quantity-first: 'first N kWh ... at X c/kWh' (AGL/GloBird)_ +Retailers: agl + +- **[agl]** *Solar Feed-in Tarriff* + elig: `This plan features a tiered feed-in tariff. For the first 10kWh exported each day, we’ll pay you a higher feed-in tariff of 6c/kWh. Then, we’ll pay 1.5c/kWh for the rest of that day` + +### solar_max_export_pool (104, 2 retailer(s)) +_Solar Max / monthly daily-averaged export pool (Origin)_ +Retailers: energyaustralia, origin-energy + +- **[origin-energy]** *Solar feed-in tariffs* + elig: `Origin offers 12 cents per kWh until a daily export limit of 8 kWh is reached. The daily export limit is averaged across your billing period (calculated by multiplying the number of days in your billing period by your daily export limit of ` +- **[origin-energy]** *Solar feed-in tariffs* + elig: `Origin offers 4 cents per kWh until a daily export limit of 8 kWh is reached. The daily export limit is averaged across your billing period (calculated by multiplying the number of days in your billing period by your daily export limit of 8` +- **[origin-energy]** *Solar feed-in tariffs* + elig: `Origin offers 5 cents per kWh until a daily export limit of 8 kWh is reached. The daily export limit is averaged across your billing period (calculated by multiplying the number of days in your billing period by your daily export limit of 8` +- **[energyaustralia]** *Solar Max* + elig: `Solar Max is for electricity only and is available to eligible residential solar customers not receiving any Government feed-in-tariff. The daily export is averaged by dividing the total solar export by the number of days in each billing pe` + +### bonus_fit_capped_windowed (20, 1 retailer(s)) +_Bonus FIT: extra c/kWh on first N kWh exported in window (ZEROHERO Super Export)_ +Retailers: globird-energy + +- **[globird-energy]** *Super Export Credit* + elig: `15 cents/kWh applies to the first 15 kWh of exports between 6pm-9pm (Local Time) everyday, and is inclusive of any other Feed-in tariff as applicable in Energy Plan.` +- **[globird-energy]** *Super Export Credit* + +### bonus_fit_uncapped_windowed (70, 1 retailer(s)) +_Bonus FIT: extra c/kWh on all exports in window (Peak solar feed-in)_ +Retailers: globird-energy + +- **[globird-energy]** *Peak solar feed-in* + elig: `5 cents/kWh applies to exports between 4pm-11pm (Local Time) everyday.` +- **[globird-energy]** *Peak solar feed-in* + elig: `3 cents/kWh applies to exports between 4pm-11pm (Local Time) everyday.` +- **[globird-energy]** *Peak solar feed-in* + elig: `2 cents/kWh applies to exports between 4pm-11pm (Local Time) everyday.` + +### free_import_window (315, 4 retailer(s)) +_Free import window (3-for-Free, OVO Free 3, Four-hour free)_ +Retailers: agl, globird-energy, myob-powered-by-ovo, red-energy + +- **[myob-powered-by-ovo]** *Free 3* +- **[myob-powered-by-ovo]** *Free 3* + elig: `Free electricity between 11am and 2pm everyday. Does not apply to controlled loads. For more information head to https://pages.ovoenergy.com.au/the-free-3-plan` +- **[agl]** *Three for Free Usage* +- **[globird-energy]** *Four-hour free usage every day* + elig: `$0.00 for consumption between 10am-2pm (Local Time), excluding controlled load.` + +### behavior_daily_credit (20, 1 retailer(s)) +_$X/day fixed credit conditional on consumption behavior_ +Retailers: globird-energy + +- **[globird-energy]** *ZEROHERO Credit* + elig: `$1/Day when imports are 0.03 kWh/hour or less, between 6pm-9pm (Local Time).` +- **[globird-energy]** *ZEROHERO Credit* + +### critical_peak_export (20, 1 retailer(s)) +_Per-event $X/kWh export credit (event-driven)_ +Retailers: globird-energy + +- **[globird-energy]** *Critical Peak-Export Credit* + elig: `$1/kWh applies to any export during a Critical Peak-Export event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data.` +- **[globird-energy]** *Critical Peak-Export Credit* + +### critical_peak_import (20, 1 retailer(s)) +_Per-event credit for importing during peak event_ +Retailers: globird-energy + +- **[globird-energy]** *Critical Peak-Import Credit* + elig: `5 cents/kWh applies to any import during a Critical Peak-Import event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data.` +- **[globird-energy]** *Critical Peak-Import Credit* + +### vpp_rebate (693, 2 retailer(s)) +_VPP/demand response rebate (event-driven)_ +Retailers: energyaustralia, engie + +- **[engie]** *ENGIE VPP credits* + elig: `Receive $100 (GST exempt) sign-up credit as well as approx $15 monthly credit per battery for participating in our VPP, which is calculated by multiplying 0.493150c (GST exempt) by the number of days in a month applied on your next bill.` +- **[engie]** *ENGIE VPP Credits* +- **[energyaustralia]** *PowerResponse program rebate* + elig: `You may be eligible for our PowerResponse program, and by participating in events, you may be eligible for rebates which may change over time. See website energyaustralia.com.au/power-response for details on eligibility criteria, T&C’s and ` +- **[energyaustralia]** *PowerResponse program rebate* + +### ev_offpeak_override (165, 2 retailer(s)) +_EV off-peak rate override (OVO/ENGIE)_ +Retailers: engie, myob-powered-by-ovo + +- **[myob-powered-by-ovo]** *EV Off-Peak* +- **[myob-powered-by-ovo]** *Electric Vehicle Off-Peak* + elig: `$0.045/kWh usage charge between midnight and 6am. Does not apply to controlled loads. For more information head to https://www.ovoenergy.com.au/electric-vehicles/` +- **[myob-powered-by-ovo]** *Electric Vehicle Off-Peak* + elig: `$0.04725/kWh usage charge between midnight and 6am. Does not apply to controlled loads. For more information head to https://www.ovoenergy.com.au/electric-vehicles/` +- **[engie]** *EV Flex Charge* + +### ovo_credit_interest (324, 1 retailer(s)) +_OVO 3% interest on credit balances_ +Retailers: myob-powered-by-ovo + +- **[myob-powered-by-ovo]** *Interest Rewards* +- **[myob-powered-by-ovo]** *Interest Rewards* + elig: `OVO Energy pay 3% interest on credit balances (after all monthly charges are considered). This is prorated for the number of days since your last bill.` + +### subscription_bundle_with_dollar_value (150, 1 retailer(s)) +_Bundled streaming subscription with $ value_ +Retailers: agl + +- **[agl]** *Netflix Standard with ads included* + elig: `Netflix Standard with ads is included in this plan. Optional: upgrade your Netflix tier to Standard or Premium at an additional cost` +- **[agl]** *Netflix Standard with ads* + + +## TOP 25 still-UNMATCHED + +| count | displayName | sample eligibility | +|---:|---|---| +| 168 | Solar feed-in tariffs | The Terms and Conditions for Feed-in Tariffs – Victoria applies to both additional and standard retailer feed-in tariff. When the benefit period ends you’ll rec | +| 20 | Generous solar feed-in | (empty) | \ No newline at end of file diff --git a/scripts/CDR_SHAPE_CATALOG_PROMPT.md b/scripts/CDR_SHAPE_CATALOG_PROMPT.md new file mode 100644 index 0000000..0cbfbac --- /dev/null +++ b/scripts/CDR_SHAPE_CATALOG_PROMPT.md @@ -0,0 +1,201 @@ +# Prompt — CDR PlanDetailV2 shape catalog (for sister Claude Code chat) + +Copy everything below the divider into a fresh Claude Code session. The chat will probe live AER Consumer Data Right endpoints across **every published AU energy retailer**, fetch detail for **every plan** (not a sample), and bucket each plan by its JSON-shape signature so we get an exhaustive variant catalog. + +The chat needs **no PriceHawk repo access** — it's a self-contained data-engineering task using only public endpoints. Allow **2-6 hours** for a full sweep depending on retailer responsiveness; the script must be **resumable** because some retailers throttle aggressively. + +--- + +# CDR PlanDetailV2 shape catalog — task brief + +## Why this exists + +Australian energy retailers publish their plans via the AER Consumer Data Right. The `PlanDetailV2` schema is a spec — but every retailer ships their own JSON-shape dialect AND **the same retailer ships different shapes across different plans** (e.g. their flat-rate plans vs their TOU plans use different `rateBlockUType` blocks). I'm building a Home Assistant integration (`PriceHawk`, Python) that consumes these envelopes to render a plan-confirmation summary. Every new shape variant I encounter in production breaks the summariser. I need an **exhaustive catalog** so I can write a defensive parser once instead of patching shape-by-shape. + +## Endpoints + +- **Registry** (every AU retailer's CDR base URI): + `https://raw.githubusercontent.com/jxeeno/energy-cdr-prd-endpoints/main/docs/energy-prd-endpoints.json` + Top-level: `{"data": [{brandName, productReferenceDataBaseUri, ...}]}`. ~78 retailers. + +- **Per-retailer plan list** (paginated): + `{base_uri}/cds-au/v1/energy/plans?fuelType=ELECTRICITY&type=ALL&page-size=1000&effective=CURRENT` + Header: `x-v: 1` + Returns `{"data": {"plans": [...]}, "meta": {"totalRecords": N, "totalPages": M}}`. + +- **Per-plan detail**: + `{base_uri}/cds-au/v1/energy/plans/{planId}` + Header: `x-v: 3` + Returns `{"data": {electricityContract, ...}}`. **All shape variation lives here.** + +## Methodology + +### 1. Bootstrap + +- Pull the registry. Build a worklist of `(retailer_brand, base_uri)` pairs. +- Skip retailers whose base URI 404s on a HEAD probe. + +### 2. Per-retailer pass (resumable, polite) + +For each retailer: + +1. Pull the **complete** plan list, paginating until `meta.totalPages` exhausted. +2. Filter to `customerType == "RESIDENTIAL"` AND `fuelType == "ELECTRICITY"` AND `type in {MARKET, STANDING}`. +3. For each plan in that filtered set, fetch its detail. + - **Cache to disk**: `/tmp/cdr-cache/{retailer_brand}/{planId}.json`. If the cache file exists, skip the network call entirely. + - **Rate limit**: max 1 request/sec per retailer. Some data holders return 429 if you push faster. + - **Retry on 429/5xx**: exponential backoff, max 3 attempts. After exhaust, log to a `failed.jsonl` and move on. + - **Checkpoint**: every 100 successful fetches, write the current progress (`{retailer, last_planId, plans_done, plans_total}`) to `/tmp/cdr-cache/_progress.json` so a Ctrl-C resume picks up cleanly. + +### 3. Shape-signature extraction + +For each cached detail file, compute a **shape signature** — a deterministic string that captures every structural decision the JSON makes for the fields PriceHawk's summariser cares about. Two plans with the same signature can be parsed by identical code; two plans with different signatures cannot. + +Build the signature by walking these paths and emitting one token per observation: + +#### Top-level electricityContract +- `pricingModel:` (e.g. `pricingModel:SINGLE_RATE`) +- For each of these keys, emit `:` where TYPE is `string` / `number` / `list[N]` / `dict` / `null` / `MISSING`: + - `dailySupplyCharges` (plural) + - `dailySupplyCharge` (singular) + - `tariffPeriod` + - `solarFeedInTariff` + - `incentives` + - `controlledLoad` + - `greenPowerCharges` + - `discounts` + - `fees` + +#### Per tariffPeriod[0] +- `tp[0].rateBlockUType:` +- `tp[0].:` (the actual nested block — record dict vs list, length if list) +- `tp[0].dailySupplyCharge:` +- `tp[0].dailySupplyCharges:` +- `tp[0].dailySupplyChargeType:` +- For the rates inside the rate block: shape of `rates[0]` keys (sorted, comma-joined) + +#### Per solarFeedInTariff[0] +- `fit[0].tariffUType:` +- `fit[0].:` +- `fit[0].scheme:` +- `fit[0].payerType:` +- For TOU FIT, the inner `rates[0]` key shape + +#### Per incentives[0] +- Shape of incentive object keys (sorted, comma-joined) + +Concatenate all tokens with `|`. Hash with sha1; first 12 hex chars is the **signature ID**. + +### 4. Bucket + analyze + +- Group every fetched plan by its signature ID. +- For each unique signature: pick **3 sample planIds** that produce it (one for the README, two for regression tests). +- For each unique signature: emit a **synthetic dict snapshot** — the actual JSON paths described by the signature, not full plan content (so the catalog is readable, not 200KB per row). + +### 5. Cross-retailer roll-up + +For each retailer × signature combination, count the plans. The interesting output is matrices like: + +``` +Signature SIG_a3b9c2: 4,217 plans across 12 retailers + - AGL: 1,054 plans + - Origin: 712 plans + - … +Signature SIG_8f1d4a: 2,103 plans across 1 retailer + - GloBird: 2,103 (FLEXIBLE pricingModel only) +``` + +This tells me which signatures are load-bearing (cover the mass) vs niche (one retailer's quirk). + +## Output + +Write a single markdown file `/tmp/cdr-shape-catalog.md` with these sections. + +### 1. Sweep summary +- Total retailers probed / reachable / 404 +- Total plans listed / fetched / cached / failed +- Total unique signatures discovered +- Wall-clock duration +- Cache size on disk + +### 2. Per-retailer coverage table +| Retailer | Plans listed | Detail fetched | Failed | Distinct signatures | +|---|---|---|---|---| +| AGL | 1,105 | 1,103 | 2 | 4 | +| GloBird | 2,103 | 2,103 | 0 | 7 | +| … | | | | | + +### 3. Signature catalog (the main deliverable) +For each distinct signature, in descending order of plan count: + +``` +### Signature SIG_a3b9c2 — 4,217 plans across 12 retailers +**Sample planIds:** +- AGL/AGL999912MR@VEC +- Origin/ORI8847@EME +- EnergyAustralia/EAU772MR@EME + +**Token tokens:** +- pricingModel:SINGLE_RATE +- dailySupplyCharges:string +- tariffPeriod:list[1] +- tp[0].rateBlockUType:singleRate +- tp[0].singleRate:dict +- tp[0].rates[0].keys:unitPrice +- solarFeedInTariff:list[1] +- fit[0].tariffUType:singleTariff +- fit[0].singleTariff:dict +- fit[0].rates[0].keys:unitPrice +- incentives:list[3] +- incentives[0].keys:category,description,displayName + +**Per-retailer count:** +- AGL: 1,054 +- Origin: 712 +- … +``` + +### 4. Field-presence heatmap +| Path | Of N=20 retailers, plans where field present | +|---|---| +| `electricityContract.dailySupplyCharges` (plural) | AGL: 0/1105, Origin: 712/890, GloBird: 0/2103 … | +| `electricityContract.dailySupplyCharge` (singular) | AGL: 0/1105, … | +| `tariffPeriod[].dailySupplyCharge` | AGL: 1103/1103, … | +| `tariffPeriod[].dailySupplyCharges` | … | + +### 5. Daily-supply-charge location ranking +Ranked list of all locations the value can live, with retailer × plan-count totals. + +### 6. rateBlockUType variants observed +Every observed value of `rateBlockUType`, plus whether the nested block is a dict or a list per signature. + +### 7. solarFeedInTariff variants observed +Same treatment for `tariffUType`. + +### 8. Surprise findings (free-form) +Bullet list of weirdness: +- Retailers that 404 detail despite listing the plan +- Plans where `tariffPeriod` is empty / missing +- Plans where `electricityContract` itself is missing (do these exist?) +- Numeric-typed fields where the spec says string +- Fields nested in places the spec doesn't document +- Plans whose detail returns a different `pricingModel` than the list says + +### 9. Recommended parser shape +A Python function signature + docstring describing the union of every shape a defensive `_summarise_cdr_plan(detail) -> dict[str, str]` should handle. Reference each signature ID it covers. + +## Constraints + +- **Stdlib only** for Python (or built-in `fetch` for JS / `bun run`). No `requests`, no `httpx`, no npm deps. +- **Cache aggressively** so re-runs are free. Cache key = `{retailer}/{planId}.json`. Idempotent. +- **Be polite**: 1 request/sec per retailer maximum. Some data holders rate-limit. +- **Resumable**: checkpoint progress every 100 plans. A Ctrl-C should be safe; resume from `/tmp/cdr-cache/_progress.json`. +- **Continue on errors**: log `failed.jsonl`, never crash on a single bad plan. +- **Concurrency**: feel free to run 4-8 retailers in parallel (each retailer-thread sticks to its 1-req/sec budget). Don't pound a single retailer with parallel calls — they'll 429. +- **Estimated work**: ~78 retailers × avg 200 plans = 15,600 detail fetches. At 1 req/sec serial = 4-5 hours. With 6-way parallel = ~45 min. Cached re-run = seconds. + +## Deliverable + +The single file `/tmp/cdr-shape-catalog.md` plus the cache directory `/tmp/cdr-cache/` (which I'll keep — useful for regression test fixtures later). Print only a 5-line summary to stdout when done. + +Once the markdown is written, paste its content back to the originating chat. Don't summarise — paste the whole file. Sections 3 (signature catalog) + 5 (supply location ranking) are the load-bearing parts. diff --git a/scripts/PHASE_0_GROUND_TRUTH.md b/scripts/PHASE_0_GROUND_TRUTH.md new file mode 100644 index 0000000..1acc5a5 --- /dev/null +++ b/scripts/PHASE_0_GROUND_TRUTH.md @@ -0,0 +1,131 @@ +# Phase 0 Ground-Truth Spec — v1.5.0 CDR Evaluator Gate + +**Status:** ✅ CLOSED — all 6 gates passed 2026-05-14. See `DECISIONS.md` D-P0-6. Phase 1 entry approved. + + + +**Authority:** Design doc §C/§H/§I.6/§I.7 + CEO plan + checkpoint +`20260514-213014-cdr-tariff-refactor-phase-0-ready.md`. +**Hard gate:** all 6 cases within ±5% of hand-calc. 1% aspirational. Plan C2 fail = GloBird migration dead, fall back to Approach A or re-scope. + +--- + +## 1. Oracle: hand-calc from plan PDF + +- **Canonical:** hand-calculated cost per period from plan PDF rates × consumption fixture. +- **Sanity check:** AGL bill estimator at `agl.com.au/usage/savings` (annual estimator, not 7-day, treat as smoke test only). +- Why hand-calc wins: unambiguous, traceable to source-of-truth, no third-party drift. +- Spreadsheet lives at `scripts/phase_0_handcalc.xlsx` (created Day 0.5 end-of-day). + +## 2. GST convention (lock) + +- CDR `unitPrice` field = **ex-GST**. +- HA sensor outputs = **inc-GST** (per §I.7) to match user's actual bill. +- **Hand-calc spreadsheet column must apply `× 1.10` at end** before comparing to evaluator output. +- Single conversion point in evaluator: `total_aud_inc_gst = total_aud_ex_gst * Decimal("1.10")`. +- `tests/test_cdr_evaluator.py::test_gst_inclusion` will guard regression in Phase 1. + +## 3. Time zone convention (lock) + +- CDR TOU thresholds use **AEST internally** (per §A.6). +- HA timezone = `Australia/Melbourne` for own use; sensor display = local. +- For non-DST plans (A/B/C1/C2): hand-calc spreadsheet rows in AEST. Consumption fixture timestamps in AEST. +- DST plans (D/E): see §6 — naive local time for the date in question, then explicit timezone-aware calc. + +## 4. Plan selection (6 fixtures) + +| ID | Retailer | Type | pricingModel | What it exercises | +|----|----------|------|--------------|-------------------| +| **A** | AGL | Flat residential | `SINGLE_RATE` | Simplest case. Single rate × kWh + daily supply × days. GST. | +| **B** | **Red Energy** | TOU residential + TOU FIT | `TIME_OF_USE` | Multi-window import (`timeOfUse`) **and** TOU FIT (`timeVariations` — opposite key). Red Energy is the only retailer using `timeVaryingTariffs` FIT properly at scale per CDR audit line 42. Cleaner TOU FIT gate than AGL's text-encoded singleTariff approach. | +| **C1** | **Hand-constructed minimal FLEXIBLE fixture** | `FLEXIBLE` structural | `FLEXIBLE` | Structural semantics of `FLEXIBLE` rate block (audit example lines 287-291 as fixture seed). Gate = evaluator walks rate-block structure correctly, NOT "found one in wild". C2 covers parser path; C1 covers structural path. Orthogonal. | +| **C2** | GloBird ZEROHERO Residential (Flexible Rate) United Energy | Load-bearing | `FLEXIBLE` + free-text incentive | ZEROHERO ($1/day credit) + FOUR4FREE (free-power window) parser end-to-end. **Hard fail = GloBird migration dead.** | +| **D** | Red Taronga Flex Ausgrid `RED552831MRE15@EME` (same as Plan B) | DST backward | `TIME_OF_USE` | **2026-04-05 (Sun)** AEDT→AEST. 25-hour day. 02:00-03:00 occurs twice. (Design doc said Apr 6 — corrected, that's Monday after; transition is first Sunday.) | +| **E** | Same as D | DST forward | `TIME_OF_USE` | **2026-10-04 (Sun)** AEST→AEDT. 23-hour day. 02:00-03:00 skipped. (Design doc said Oct 5 — corrected.) | + +### Plan ID capture (Day 1) + +Endpoints (`x-v: 1` for list, `x-v: 3` for detail): +- AGL list: `https://cdr.energymadeeasy.gov.au/agl/cds-au/v1/energy/plans?type=ALL&fuelType=ELECTRICITY` +- Red Energy list: `https://cdr.energymadeeasy.gov.au/red-energy/cds-au/v1/energy/plans?type=ALL&fuelType=ELECTRICITY` +- GloBird list: `https://cdr.energymadeeasy.gov.au/globird/cds-au/v1/energy/plans?type=ALL&fuelType=ELECTRICITY` (verify base URL via jxeeno registry) +- Detail: `{base}/cds-au/v1/energy/plans/{planId}` with `x-v: 3`. + +Hardcode opaque plan IDs into `scripts/cdr_evaluator_proto.py`. Do NOT commit real plan-detail JSON to git until `effectiveFrom <= today <= effectiveTo` check passes and PII (none expected in plan data, but verify) is absent. + +### C1 fixture (locked) + +Hand-constructed minimal `FLEXIBLE` fixture. Seed from CDR audit lines 287-291: +```json +{"type":"PEAK","displayName":"Flexible","period":"P1D", + "rates":[{"volume":15,"unitPrice":"0.246"},{"unitPrice":"0.301"}]} +``` +Stepped pricing: first 15 kWh/day @ 24.6c, remainder @ 30.1c. Daily supply $1.20/day (typical VIC value). No incentives, no FIT. **Gate = evaluator correctly walks the rate-block structure including stepped pricing volume threshold.** If a real non-GloBird FLEXIBLE plan surfaces during Day 1 list scan, switch to it; if not, fixture stands. + +## 5. Consumption fixture + +### A / B / C1 / C2 — 7-day window (LOCKED) + +- **Window:** 2026-05-07 00:00 AEST → 2026-05-14 00:00 AEST (last 7 full AEST days as of Day 0.5). +- Source: own HA grid-power history over that window. +- Method: HA recorder API export `sensor.grid_power` + `sensor.solar_export` at 5-min granularity → resample to half-hourly. +- Existing PriceHawk code to reuse: `custom_components/pricehawk/csv_analyzer.py` for NEM12 path OR direct HA recorder export. + - Note: design doc references `nem12_*.py` which does NOT exist. NEM12 ingestion currently lives in `csv_analyzer.py` + `backfill.py`. Treat design doc reference as stale; use actual files. +- Output: `tests/fixtures/phase0/consumption_7d.json` — shape `[{ts_aest, grid_kwh, solar_kwh}]`. +- Use the SAME 7-day window for A/B/C1/C2 so cost deltas are pure plan-shape deltas. + +### D / E — 24h synthetic each (LOCKED + generated) + +- **Plan D fixture:** `tests/fixtures/phase0/consumption_dst_april_2026-04-05.json` — 50 slots = 25 wall-clock hours (gain 1h, 02:00-03:00 occurs twice). +- **Plan E fixture:** `tests/fixtures/phase0/consumption_dst_october_2026-10-04.json` — 46 slots = 23 wall-clock hours (lose 1h, 02:00-03:00 skipped). +- Both fixtures generated by `scripts/gen_dst_fixtures.py` using `zoneinfo.ZoneInfo("Australia/Sydney")` to handle transitions. UTC timestamps are canonical; local clock is annotation. +- Plan TOU windows from Red Taronga Flex: off-peak **22:00-06:59 every day** straddles midnight and the 02:00 DST transition. Perfect gate. +- Hand-calc spreadsheet rows use UTC timestamps to remove ambiguity. Half-hour slots × local-rate-at-that-clock-time. + +## 6. Pass/fail thresholds + +- **Per plan:** `|evaluator_total - handcalc_total| / handcalc_total <= 0.05` (5%). +- **Plan D / E:** `|evaluator_total - handcalc_total| <= $0.05` absolute (24h window, low dollar value). +- **Aspirational:** 1% on A/B (no incentives, no DST). Anything >1% on A/B = silent unit conversion bug; investigate before C/D/E. +- **C2 load-bearing:** if C2 fails after one fix attempt → escalate per §7. + +## 7. Escalation path + +| Failure | First action | Escalation | +|---------|--------------|------------| +| A or B >5% | Check GST × 1.10. Check c/kWh vs $/kWh unit. Check daily supply unit. | One fix attempt → if still >5%, log gate failure, hold Phase 1. | +| C1 >5% | Re-read `FLEXIBLE` spec (`rateBlockUType` semantics, stepped pricing). | Hand-construct simpler FLEXIBLE fixture to isolate structural-vs-data error. | +| C2 >5% | Check ZEROHERO + FOUR4FREE parser regex against current CDR `description` text. | **HARD ESCALATION** — fall back to Approach A (translation layer + bespoke GloBird schema). v1.5.0 scope renegotiated. | +| D or E >$0.05 | Check `zoneinfo` import, naive vs aware datetime, hour-by-hour iteration loop. | One fix → if still off, defer DST handling to v1.5.1 with explicit user warning. | + +## 8. Deliverables checklist + +- [ ] Day 0.5 end-of-day: this doc + `scripts/phase_0_handcalc.xlsx` skeleton + plan-list pull script. +- [ ] Day 1: 6 plan-detail JSON fixtures captured + consumption fixtures generated. +- [ ] Day 2: `scripts/cdr_evaluator_proto.py` implementing `evaluate(plan, consumption, period) -> CostBreakdown`. +- [ ] Day 3: comparison table evaluator vs hand-calc for all 6 cases. Gate decision logged in `DECISIONS.md`. +- [ ] If gate passes: snapshot `tariff_engine.py` outputs to `tests/fixtures/legacy_engine_outputs/*.json` BEFORE Phase 1 work starts. + +## 9. Reference URLs + +- AGL plans (list): `https://cdr.energymadeeasy.gov.au/agl/cds-au/v1/energy/plans` (`x-v: 1`) +- AGL plan detail: `https://cdr.energymadeeasy.gov.au/agl/cds-au/v1/energy/plans/{planId}` (`x-v: 3`) +- GloBird plans: `https://cdr.globirdenergy.com.au/cds-au/v1/energy/plans` (verify endpoint via jxeeno registry) +- CDR audit (load-bearing reference): `/Users/ryanfoyle/Downloads/aer-cdr-energy-api-reference.md` +- jxeeno registry (planned dep, v1.5.0): `https://jxeeno.github.io/energy-cdr-prd-endpoints/` + +## 10. Locked decisions (Day 0.5 + Day 1 resolution log) + +- **D-P0-1 (consumption window):** 2026-05-07 00:00 AEST → 2026-05-14 00:00 AEST. Locked. +- **D-P0-2 (Plan B retailer):** Red Energy `RED552831MRE15@EME` "Red Taronga Flex" (Ausgrid NSW). Single plan serves Plan B + Plans D/E (NSW, clean TOU+TOU-FIT via `timeVaryingTariffs`, off-peak 22:00-06:59 straddles DST). Replaces earlier QLD pick that had flat FIT. +- **D-P0-3 (C1 sourcing):** hand-constructed minimal FLEXIBLE fixture at `tests/fixtures/phase0/plan_c1_flexible_synthetic.json`. Day 1 scan of Red Energy plan list found zero non-GloBird FLEXIBLE plans — confirms audit gap. Fixture stands. +- **D-P0-4 (DST date correction):** transitions are first Sunday of April/October, not the Monday after. Plan D = **2026-04-05** (not 04-06). Plan E = **2026-10-04** (not 10-05). Verified via `zoneinfo.ZoneInfo("Australia/Sydney")` offset walk. Design doc + checkpoint dates were off by one day. +- **D-P0-5 (C2 incentive text gap):** EME proxy (`cdr.energymadeeasy.gov.au/globird`) returns STUB descriptions for GloBird incentives — `description` field = displayName, no rate text. GloBird's own DH (`cdr.globirdenergy.com.au`) is not publicly resolvable. **Workaround:** Day 2 hand-transcribe ZEROHERO + FOUR4FREE + Super Export + Critical Peak text from 4 PDFs already in repo root (`Victorian_Energy_Fact_Sheet_GLO*.pdf`) into `incentives[].description` of the C2 fixture. Mark transcription source in fixture metadata. + +--- + +**Next step:** Day 1 — write `scripts/cdr_pull_plans.py`. Outputs: +- 4 real plan-detail JSON fixtures (A=AGL flat, B=Red TOU+FIT, C2=GloBird ZEROHERO, D/E share one Red NSW TOU plan). +- 1 hand-constructed FLEXIBLE fixture (C1). +- 1 consumption fixture (7d shared across A/B/C1/C2) from HA recorder export. +- 2 DST 24h synthetic consumption fixtures (April + October). diff --git a/scripts/cdr_evaluator_proto.py b/scripts/cdr_evaluator_proto.py new file mode 100644 index 0000000..8005870 --- /dev/null +++ b/scripts/cdr_evaluator_proto.py @@ -0,0 +1,504 @@ +"""Phase 0 CDR evaluator prototype. + +Loads a CDR PlanDetailV2 fixture + a half-hourly consumption fixture, +returns CostBreakdown for the period. GST-inclusive output (× 1.10). +Time zone: Australia/Sydney via zoneinfo (handles DST). + +NOT integration code. Bare Python, no pydantic, no aiohttp. Phase 1 +will refactor into custom_components/pricehawk/cdr/evaluator.py with +pydantic models per locked decision §I.2. + +Supports: + - pricingModel: SINGLE_RATE | TIME_OF_USE | FLEXIBLE + - rateBlockUType: singleRate | timeOfUseRates + - Stepped rates (volume thresholds per period; daily reset) + - TOU windows incl. midnight-spanning + - FIT: singleTariff (flat or with timeVariations) + timeVaryingTariffs + - Minimal GloBird incentive parser: ZEROHERO ($1/day) + Super Export (15c/kWh first 10kWh exports 6-8pm) + - DST transitions via zoneinfo on UTC timestamps + +Out of Phase 0 scope (deferred to Phase 1+): + - demandCharges block + - controlledLoad + - SEASONAL / TOU Seasonal variants + - Critical Peak events (no event schedule available) + - Other retailers' incentive parsers (OVO Free 3, AGL Three for Free) + +Run: + python3 scripts/cdr_evaluator_proto.py + +Example: + python3 scripts/cdr_evaluator_proto.py \\ + tests/fixtures/phase0/plan_agl_AGL907738MRE6@EME.json \\ + tests/fixtures/phase0/consumption_7d.json +""" +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from zoneinfo import ZoneInfo + +GST_FACTOR = Decimal("1.10") +SYDNEY = ZoneInfo("Australia/Sydney") +UTC = ZoneInfo("UTC") + +DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] + + +@dataclass +class CostBreakdown: + total_aud_ex_gst: Decimal = Decimal("0") + daily_supply_aud_ex_gst: Decimal = Decimal("0") + import_aud_ex_gst: Decimal = Decimal("0") + export_aud_ex_gst: Decimal = Decimal("0") # negative (credit) + # Incentive credits are EXPRESSED IN INC-GST DOLLARS (e.g. "$1/Day" + # ZEROHERO credit is $1.00 inc-GST not $1.10). Stored separately from + # ex-GST quantities and added AFTER the GST conversion of the rest. + incentive_aud_inc_gst: Decimal = Decimal("0") + period_days: int = 0 + slot_count: int = 0 + plan_id: str = "" + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + @property + def total_aud_inc_gst(self) -> Decimal: + # GST applied to rate-based costs (import / export / supply). + # Incentive credits already inc-GST (PDF dollar amounts are inc-GST). + rate_based = (self.import_aud_ex_gst + + self.export_aud_ex_gst + + self.daily_supply_aud_ex_gst) * GST_FACTOR + return rate_based + self.incentive_aud_inc_gst + + def summary(self) -> dict: + return { + "plan_id": self.plan_id, + "period_days": self.period_days, + "slot_count": self.slot_count, + "total_aud_inc_gst": float(self.total_aud_inc_gst.quantize(Decimal("0.01"))), + "import_aud_inc_gst": float((self.import_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "export_aud_inc_gst": float((self.export_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "daily_supply_aud_inc_gst": float((self.daily_supply_aud_ex_gst * GST_FACTOR).quantize(Decimal("0.01"))), + "incentive_aud_inc_gst": float(self.incentive_aud_inc_gst.quantize(Decimal("0.01"))), + "notes": self.notes, + } + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + +def _hhmm_to_minutes(hhmm: str) -> int: + """Convert '14:00' / '23:59' to minutes since 00:00 local.""" + h, m = hhmm.split(":") + return int(h) * 60 + int(m) + + +def _slot_in_window(local_dt: datetime, days: list[str], start: str, end: str) -> bool: + """Check whether local_dt falls within a TOU window. + + Semantics (matches CDR AER convention + legacy engine): + - startTime INCLUSIVE, endTime EXCLUSIVE. + - endTime "00:00" with startTime > 0 means "midnight = 24:00 = end of day". + - For "HH:59" endings (Red Taronga style), exclusive at HH+1:00 (e.g. + endTime "13:59" excludes minute 13:59 itself; slot at 13:30 still + matches since 13:30 < 13:59). + - For "HH:00" endings (GloBird style), exclusive at HH:00 (consecutive + windows can share boundary; first-match-wins rules at the boundary). + Slot start time is the test point — 30-min slot assignment. + """ + day_name = DAY_NAMES[local_dt.weekday()] + if day_name not in days: + return False + minutes = local_dt.hour * 60 + local_dt.minute + start_m = _hhmm_to_minutes(start) + end_m = _hhmm_to_minutes(end) + # "00:00" as end with non-zero start means end-of-day (24:00 = 1440). + if end_m == 0 and start_m > 0: + end_m = 1440 + if end_m < start_m: + # Wraps midnight (rare with proper end-of-day handling above) + return minutes >= start_m or minutes < end_m + return start_m <= minutes < end_m + + +def _resolve_tou_rate(local_dt: datetime, tou_rates: list[dict]) -> dict | None: + """Return the matching tou_rate entry for the slot's local clock time. + + CDR convention: at most one entry should match per slot. If multiple, the + first match wins (caller's responsibility to order rates correctly). + Returns None if no match (treated as zero rate — caller may warn). + """ + for rate in tou_rates: + for window in rate.get("timeOfUse", []) or []: + days = window.get("days", []) or [] + start = window.get("startTime") or "00:00" + end = window.get("endTime") or "23:59" + if _slot_in_window(local_dt, days, start, end): + return rate + return None + + +def _select_stepped_rate(rates: list[dict], cumulative_kwh_day: Decimal) -> Decimal: + """Return unitPrice for the current cumulative_kwh_day. + + CDR stepped rate semantics: rates is a list, each entry may have a `volume` + threshold. The first entry where cumulative < volume applies; final entry + without volume catches the remainder. + """ + for r in rates: + vol = r.get("volume") + if vol is None: + return _decimal(r.get("unitPrice")) + if cumulative_kwh_day < _decimal(vol): + return _decimal(r.get("unitPrice")) + # Fallback to last rate + return _decimal(rates[-1].get("unitPrice")) if rates else Decimal("0") + + +def _eval_import( + slots: list[dict], + tariff_period: dict, + breakdown: CostBreakdown, +) -> None: + """Walk slots, classify each by TOU window, multiply consumption × rate.""" + rate_block_utype = tariff_period.get("rateBlockUType") + daily_kwh_running: dict[str, Decimal] = {} # for stepped rates: per local-day + + if rate_block_utype == "singleRate": + single = tariff_period.get("singleRate", {}) or {} + rates = single.get("rates", []) or [] + # SINGLE_RATE: same rate all hours. Stepped possible (volume on first + # entry). Reset daily threshold per local date. + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + kwh = _decimal(slot.get("grid_import_kwh", 0)) + day_key = local_dt.date().isoformat() + cumul = daily_kwh_running.get(day_key, Decimal("0")) + rate = _select_stepped_rate(rates, cumul) + cost = kwh * rate + breakdown.import_aud_ex_gst += cost + daily_kwh_running[day_key] = cumul + kwh + breakdown.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": "SINGLE_RATE", + "kwh": float(kwh), + "rate_ex_gst": float(rate), + "cost_ex_gst": float(cost), + "cumul_day_kwh": float(cumul + kwh), + }) + return + + if rate_block_utype == "timeOfUseRates": + tou_rates = tariff_period.get("timeOfUseRates", []) or [] + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + kwh = _decimal(slot.get("grid_import_kwh", 0)) + day_key = local_dt.date().isoformat() + rate_entry = _resolve_tou_rate(local_dt, tou_rates) + if rate_entry is None: + breakdown.notes.append( + f"WARN: no TOU window matched slot {slot['ts_local']}; treated as zero" + ) + breakdown.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": "UNMATCHED", + "kwh": float(kwh), + "rate_ex_gst": 0.0, + "cost_ex_gst": 0.0, + }) + continue + cumul_key = f"{day_key}|{rate_entry.get('type')}" + cumul = daily_kwh_running.get(cumul_key, Decimal("0")) + rate = _select_stepped_rate(rate_entry.get("rates", []) or [], cumul) + cost = kwh * rate + breakdown.import_aud_ex_gst += cost + daily_kwh_running[cumul_key] = cumul + kwh + breakdown.trace.append({ + "ts_local": slot["ts_local"], + "rate_type": rate_entry.get("type"), + "kwh": float(kwh), + "rate_ex_gst": float(rate), + "cost_ex_gst": float(cost), + }) + return + + breakdown.notes.append( + f"WARN: unhandled rateBlockUType {rate_block_utype!r}; import set to 0" + ) + + +def _eval_fit( + plan: dict, + slots: list[dict], + breakdown: CostBreakdown, +) -> None: + """Walk slots, classify each export-kWh against FIT structures. + + Sums FIT credits as NEGATIVE cost in export_aud_ex_gst. + Handles: singleTariff (flat or with timeVariations); timeVaryingTariffs. + Multiple FIT entries are summed (e.g., RETAILER FIT + GOVERNMENT FIT). + """ + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + fits = elec.get("solarFeedInTariff", []) or [] + if not fits: + return + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + export_kwh = _decimal(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0)) + if export_kwh <= 0: + continue + total_credit_for_slot = Decimal("0") + for fit in fits: + tariff_utype = fit.get("tariffUType") + if tariff_utype == "singleTariff": + st = fit.get("singleTariff") or {} + # If timeVariations present, slot must match a window; else flat. + tvs = st.get("timeVariations") or [] + if tvs: + matched = False + for tv in tvs: + if _slot_in_window( + local_dt, + tv.get("days", DAY_NAMES), + tv.get("startTime", "00:00"), + tv.get("endTime", "23:59"), + ): + matched = True + break + if not matched: + continue + rates = st.get("rates", []) or [] + rate = _decimal(rates[0].get("unitPrice")) if rates else Decimal("0") + total_credit_for_slot += export_kwh * rate + elif tariff_utype == "timeVaryingTariffs": + for tvt in fit.get("timeVaryingTariffs") or []: + matched = False + for tv in tvt.get("timeVariations") or []: + if _slot_in_window( + local_dt, + tv.get("days", DAY_NAMES), + tv.get("startTime", "00:00"), + tv.get("endTime", "23:59"), + ): + matched = True + break + if not matched: + continue + rates = tvt.get("rates", []) or [] + rate = _decimal(rates[0].get("unitPrice")) if rates else Decimal("0") + total_credit_for_slot += export_kwh * rate + # FIT credits reduce cost -> negative export_aud_ex_gst + breakdown.export_aud_ex_gst -= total_credit_for_slot + + +def _eval_supply( + slots: list[dict], + tariff_period: dict, + breakdown: CostBreakdown, +) -> None: + """Daily supply × number of period days (count of unique local-date keys).""" + dsc = _decimal(tariff_period.get("dailySupplyCharge")) + # CDR daily supply = dollars/day ex-GST. + days = {datetime.fromisoformat(s["ts_local"]).date() for s in slots} + breakdown.period_days = len(days) + breakdown.daily_supply_aud_ex_gst = dsc * Decimal(len(days)) + + +# ----------------------------- +# GloBird incentive parsers +# ----------------------------- + +ZEROHERO_RE = re.compile( + r"\$(?P[\d.]+)\s*/?\s*Day\s+when\s+imports\s+are\s+(?P[\d.]+)\s+kWh/hour\s+or\s+less[,]?\s+between\s+(?P\d{1,2}(?:am|pm))-(?P\d{1,2}(?:am|pm))", + re.I, +) +SUPER_EXPORT_RE = re.compile( + r"(?P[\d.]+)\s*cents/kWh\s+applies\s+to\s+the\s+first\s+(?P[\d.]+)\s+kWh\s+of\s+exports\s+between\s+(?P\d{1,2}(?:am|pm))-(?P\d{1,2}(?:am|pm))", + re.I, +) + + +def _hh_token_to_minutes(tok: str) -> int: + """Convert '6pm' -> 18*60, '10am' -> 600.""" + m = re.match(r"(\d{1,2})(am|pm)", tok.strip(), re.I) + if not m: + raise ValueError(f"can't parse time token {tok!r}") + h = int(m.group(1)) % 12 + if m.group(2).lower() == "pm": + h += 12 + return h * 60 + + +def _parse_globird_incentives(plan: dict) -> dict: + """Extract structured rules from incentive descriptions. + + Returns dict with detected rules. Caller applies them per slot. + """ + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + rules: dict = {} + for inc in elec.get("incentives", []) or []: + desc = inc.get("description") or "" + name = inc.get("displayName") or "" + # ZEROHERO Credit: $1/Day when imports ≤ threshold, between window + m = ZEROHERO_RE.search(desc) + if m and "ZEROHERO" in name.upper(): + rules["zerohero"] = { + "credit_aud_per_day": Decimal(m.group("aud")), + "max_kwh_per_hour": Decimal(m.group("thresh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source_displayName": name, + } + # Super Export Credit: N cents/kWh applies to first M kWh exports in window + m = SUPER_EXPORT_RE.search(desc) + if m and "SUPER" in name.upper(): + rules["super_export"] = { + "cents_per_kwh": Decimal(m.group("cents")), + "first_kwh_per_day": Decimal(m.group("kwh")), + "start_min": _hh_token_to_minutes(m.group("start")), + "end_min": _hh_token_to_minutes(m.group("end")), + "source_displayName": name, + } + return rules + + +def _apply_globird_incentives( + plan: dict, + slots: list[dict], + breakdown: CostBreakdown, +) -> None: + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + if "globird" not in (elec.get("brand", "") or "").lower(): + brand = plan.get("data", {}).get("brand", "") or plan.get("brand", "") + if "globird" not in brand.lower(): + return + rules = _parse_globird_incentives(plan) + if not rules: + return + breakdown.notes.append(f"globird parser hits: {list(rules.keys())}") + + # ZEROHERO: per-day check + if "zerohero" in rules: + rule = rules["zerohero"] + # Group slots by local date + by_day: dict[str, list[dict]] = {} + for slot in slots: + day = slot["ts_local"][:10] + by_day.setdefault(day, []).append(slot) + for day, day_slots in by_day.items(): + window_kwh = Decimal("0") + window_hours = Decimal("0") + for slot in day_slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + minutes = local_dt.hour * 60 + local_dt.minute + if rule["start_min"] <= minutes < rule["end_min"]: + window_kwh += _decimal(slot.get("grid_import_kwh", 0)) + window_hours += Decimal("0.5") # half-hour slot + if window_hours == 0: + continue + avg_kwh_per_hour = window_kwh / window_hours + if avg_kwh_per_hour <= rule["max_kwh_per_hour"]: + breakdown.incentive_aud_inc_gst -= rule["credit_aud_per_day"] + breakdown.trace.append({ + "incentive": "zerohero", + "day": day, + "window_kwh": float(window_kwh), + "window_hours": float(window_hours), + "avg_kwh_h": float(avg_kwh_per_hour), + "credited_aud_ex_gst": float(rule["credit_aud_per_day"]), + }) + + # Super Export: per-day, first N kWh exports in window + if "super_export" in rules: + rule = rules["super_export"] + rate_per_kwh = rule["cents_per_kwh"] / Decimal("100") + by_day: dict[str, list[dict]] = {} + for slot in slots: + day = slot["ts_local"][:10] + by_day.setdefault(day, []).append(slot) + for day, day_slots in by_day.items(): + day_credited_kwh = Decimal("0") + for slot in day_slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + minutes = local_dt.hour * 60 + local_dt.minute + if not (rule["start_min"] <= minutes < rule["end_min"]): + continue + exp = _decimal(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0)) + if exp <= 0: + continue + remaining = rule["first_kwh_per_day"] - day_credited_kwh + if remaining <= 0: + break + credit_kwh = min(exp, remaining) + # Super Export rate from PDF is c/kWh INC-GST (15 c/kWh inc-GST) + breakdown.incentive_aud_inc_gst -= credit_kwh * rate_per_kwh + day_credited_kwh += credit_kwh + + +# ----------------------------- +# Top-level evaluate() +# ----------------------------- + +def evaluate(plan: dict, consumption: dict, run_incentives: bool = True) -> CostBreakdown: + bd = CostBreakdown() + plan_data = plan.get("data", {}) or plan + bd.plan_id = plan_data.get("planId", "?") + elec = plan_data.get("electricityContract", {}) or {} + pricing_model = elec.get("pricingModel", "?") + bd.notes.append(f"pricingModel={pricing_model}") + + tps = elec.get("tariffPeriod", []) or [] + if not tps: + bd.notes.append("ERROR: no tariffPeriod found") + return bd + tp = tps[0] # Phase 0: assume single tariff period (no seasonal splits) + if len(tps) > 1: + bd.notes.append(f"WARN: {len(tps)} tariff periods present; using first only") + + slots = consumption.get("slots", []) or [] + bd.slot_count = len(slots) + + _eval_supply(slots, tp, bd) + _eval_import(slots, tp, bd) + _eval_fit(plan, slots, bd) + if run_incentives: + _apply_globird_incentives(plan, slots, bd) + + bd.total_aud_ex_gst = ( + bd.daily_supply_aud_ex_gst + + bd.import_aud_ex_gst + + bd.export_aud_ex_gst + ) + return bd + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print(__doc__) + return 2 + plan_path = Path(argv[1]) + cons_path = Path(argv[2]) + plan = json.loads(plan_path.read_text()) + cons = json.loads(cons_path.read_text()) + + bd = evaluate(plan, cons) + summary = bd.summary() + print(json.dumps(summary, indent=2)) + print(f"\nTRACE: {len(bd.trace)} rows (use --dump-trace to see all)") + if "--dump-trace" in argv: + print(json.dumps(bd.trace[:20], indent=2, default=str)) + if len(bd.trace) > 20: + print(f"... and {len(bd.trace) - 20} more rows") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/cdr_pull_plans.py b/scripts/cdr_pull_plans.py new file mode 100644 index 0000000..80b5d31 --- /dev/null +++ b/scripts/cdr_pull_plans.py @@ -0,0 +1,224 @@ +"""Phase 0 CDR plan fixture fetcher. + +Pulls 4 candidate retailer plan lists, applies predicates from +PHASE_0_GROUND_TRUTH.md §4, prints candidates with displayName + planId, +optionally fetches PlanDetailV2 for confirmed IDs. + +NOT integration code. Standalone CLI prototype. stdlib only. +Integration HTTP client uses aiohttp via async_get_clientsession(hass) +per locked architecture decision §I.1. + +Usage: + python3 scripts/cdr_pull_plans.py list # print candidates per retailer + python3 scripts/cdr_pull_plans.py detail + python3 scripts/cdr_pull_plans.py search +""" +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +# CDR base URLs — energymadeeasy.gov.au is the AER comparison tool which +# proxies major retailers' CDR data. If a retailer 404s here, swap to their +# own DH per jxeeno/energy-cdr-prd-endpoints registry (Phase 1 work). +BASES = { + "agl": "https://cdr.energymadeeasy.gov.au/agl", + "red-energy": "https://cdr.energymadeeasy.gov.au/red-energy", + "globird": "https://cdr.energymadeeasy.gov.au/globird", +} + +FIXTURE_DIR = Path(__file__).parent.parent / "tests" / "fixtures" / "phase0" + +# Predicates per PHASE_0_GROUND_TRUTH.md §4. +# Note: list endpoint's `type` field is contract type (MARKET/STANDING/ +# REGULATED), NOT pricingModel. pricingModel is only in PlanDetailV2. +# So list filtering uses displayName heuristics + customerType + fuelType. +# Confirm pricingModel after fetching detail. + +def _residential_elec(p: dict) -> bool: + return ( + p.get("customerType") == "RESIDENTIAL" + and p.get("fuelType") == "ELECTRICITY" + and p.get("type") == "MARKET" + ) + + +def _name_contains(p: dict, needles: list[str]) -> bool: + name = (p.get("displayName") or "").upper() + return any(n.upper() in name for n in needles) + + +CANDIDATES = [ + ( + "agl", + "Plan A — AGL flat residential (Value Saver / Standing Offer heuristic)", + lambda p: _residential_elec(p) and _name_contains(p, ["VALUE SAVER", "STANDING OFFER", "RESIDENTIAL SAVERS", "VIC RESIDENTIAL"]), + ), + ( + "red-energy", + "Plan B — Red Energy TOU residential (Living Energy / Easy Saver heuristic)", + lambda p: _residential_elec(p) and _name_contains(p, ["LIVING ENERGY", "EASY SAVER", "TIME OF USE", "TIME-OF-USE", "TOU"]), + ), + ( + "red-energy", + "Plans D/E — Red Energy NSW (filter by displayName state)", + lambda p: _residential_elec(p) and _name_contains(p, ["NSW", "AUSGRID", "ENDEAVOUR", "ESSENTIAL ENERGY"]), + ), + ( + "globird", + "Plan C2 — GloBird ZEROHERO Residential (Flexible Rate) United Energy", + lambda p: _residential_elec(p) + and "ZEROHERO" in (p.get("displayName") or "").upper() + and "UNITED ENERGY" in (p.get("displayName") or "").upper() + and "VPP" not in (p.get("displayName") or "").upper() + and "CTL" not in (p.get("displayName") or "").upper(), + ), +] + + +def _http_get_json(url: str, x_v: str) -> dict: + req = urllib.request.Request( + url, + headers={ + "x-v": x_v, + "Accept": "application/json", + "User-Agent": "PriceHawk-Phase0-Fixture-Pull/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=20) as resp: + if resp.status != 200: + raise RuntimeError(f"HTTP {resp.status} from {url}") + return json.loads(resp.read().decode("utf-8")) + + +def fetch_list(retailer: str) -> list[dict]: + base = BASES[retailer] + plans: list[dict] = [] + page = 1 + while True: + params = urllib.parse.urlencode({ + "type": "ALL", + "fuelType": "ELECTRICITY", + "page": page, + "page-size": 1000, + }) + url = f"{base}/cds-au/v1/energy/plans?{params}" + data = _http_get_json(url, x_v="1") + chunk = data.get("data", {}).get("plans", []) + plans.extend(chunk) + meta = data.get("meta", {}) + total_pages = meta.get("totalPages", 1) + if page >= total_pages or not chunk: + break + page += 1 + return plans + + +def fetch_detail(retailer: str, plan_id: str) -> dict: + base = BASES[retailer] + url = f"{base}/cds-au/v1/energy/plans/{plan_id}" + return _http_get_json(url, x_v="3") + + +def cmd_list() -> int: + """List candidate plans per Phase 0 predicate, print first 5 hits each.""" + seen: dict[str, list[dict]] = {} + for retailer, label, _ in CANDIDATES: + if retailer not in seen: + print(f"\n=== Fetching list for {retailer} ===", file=sys.stderr) + try: + seen[retailer] = fetch_list(retailer) + except urllib.error.HTTPError as e: + print(f" ERROR: {e}", file=sys.stderr) + seen[retailer] = [] + print(f" fetched {len(seen[retailer])} plans", file=sys.stderr) + + for retailer, label, filter_fn in CANDIDATES: + matches = [p for p in seen[retailer] if filter_fn(p)] + print(f"\n--- {label} ---") + if not matches: + print(" NO MATCHES — relax predicate or pick manually") + continue + for p in matches[:5]: + pid = p.get("planId", "?") + name = p.get("displayName", "?") + ptype = p.get("type", "?") + eff_from = p.get("effectiveFrom", "?") + print(f" {pid:<32} {ptype:<28} effectiveFrom={eff_from}") + print(f" {name}") + if len(matches) > 5: + print(f" ... and {len(matches) - 5} more") + return 0 + + +def cmd_detail(retailer: str, plan_id: str) -> int: + """Fetch PlanDetailV2 for a single plan and save to fixtures.""" + if retailer not in BASES: + print(f"unknown retailer: {retailer}. options: {list(BASES)}", file=sys.stderr) + return 2 + print(f"fetching detail {retailer}/{plan_id}", file=sys.stderr) + detail = fetch_detail(retailer, plan_id) + FIXTURE_DIR.mkdir(parents=True, exist_ok=True) + out = FIXTURE_DIR / f"plan_{retailer}_{plan_id}.json" + out.write_text(json.dumps(detail, indent=2, sort_keys=True)) + plan = detail.get("data", {}) + pricing_model = plan.get("electricityContract", {}).get("pricingModel", "?") + eff_from = plan.get("effectiveFrom", "?") + eff_to = plan.get("effectiveTo", "?") + print(f" wrote {out}") + print(f" pricingModel: {pricing_model}") + print(f" effective: {eff_from} -> {eff_to}") + return 0 + + +def cmd_search(retailer: str, needle: str) -> int: + """Print plans whose displayName contains the substring (case-insensitive).""" + if retailer not in BASES: + print(f"unknown retailer: {retailer}. options: {list(BASES)}", file=sys.stderr) + return 2 + needle_u = needle.upper() + plans = fetch_list(retailer) + hits = [ + p for p in plans + if needle_u in (p.get("displayName") or "").upper() + and p.get("customerType") == "RESIDENTIAL" + and p.get("fuelType") == "ELECTRICITY" + ] + print(f"{len(hits)} residential-electricity matches for '{needle}' in {retailer}") + for p in hits[:30]: + pid = p.get("planId", "?") + name = p.get("displayName", "?") + ctype = p.get("type", "?") + print(f" {pid:<32} [{ctype}] {name}") + if len(hits) > 30: + print(f" ... and {len(hits) - 30} more") + return 0 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print(__doc__) + return 2 + cmd = argv[1] + if cmd == "list": + return cmd_list() + if cmd == "detail": + if len(argv) != 4: + print("usage: detail ", file=sys.stderr) + return 2 + return cmd_detail(argv[2], argv[3]) + if cmd == "search": + if len(argv) != 4: + print("usage: search ", file=sys.stderr) + return 2 + return cmd_search(argv[2], argv[3]) + print(f"unknown command: {cmd}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/gen_dst_fixtures.py b/scripts/gen_dst_fixtures.py new file mode 100644 index 0000000..2908034 --- /dev/null +++ b/scripts/gen_dst_fixtures.py @@ -0,0 +1,161 @@ +"""Generate synthetic 24h half-hourly consumption fixtures for DST gates. + +Plans D + E per PHASE_0_GROUND_TRUTH.md §5 + design doc §I.6. + +Each fixture covers one DST day at NSW (Australia/Sydney): + - Plan D: 2026-04-06 AEDT->AEST (clocks fall back 02:00 -> 01:00). 25-hour day. + - Plan E: 2026-10-05 AEST->AEDT (clocks spring forward 02:00 -> 03:00). 23-hour day. + +Consumption profile (synthetic but realistic Melbourne residential pattern): + - Overnight 22:00-07:00: 0.4 kWh/half-hour grid import (fridge + standby) + - Morning 07:00-09:00: 1.2 kWh/half-hour (water heating + breakfast) + - Daytime 09:00-14:00: 0.3 kWh/half-hour grid (mostly solar covers load) + - Solar export 09:00-15:00: 1.5 kWh/half-hour + - Afternoon 14:00-18:00: 0.5 kWh/half-hour + - Evening 18:00-22:00: 1.0 kWh/half-hour (cooking + heating peak) + +Uses zoneinfo.ZoneInfo for DST handling. Outputs include both +UTC timestamps (canonical) and local Australia/Sydney clock times. + +Run: python3 scripts/gen_dst_fixtures.py +""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +OUT_DIR = Path(__file__).parent.parent / "tests" / "fixtures" / "phase0" +SYDNEY = ZoneInfo("Australia/Sydney") +UTC = ZoneInfo("UTC") + + +def consumption_for_local_hour(hour: int) -> tuple[float, float]: + """Return (grid_import_kwh, solar_export_kwh) for one half-hour slot. + + Caller supplies the local-clock hour (0-23) and gets the half-hour + profile slice. We use the same profile shape regardless of DST + transition; the evaluator's job is to walk the timeline correctly, + not to model behavioural differences on DST days. + """ + if 22 <= hour or hour < 7: + return 0.4, 0.0 + if 7 <= hour < 9: + return 1.2, 0.0 + if 9 <= hour < 14: + return 0.3, 1.5 + if 14 <= hour < 15: + return 0.3, 1.5 + if 15 <= hour < 18: + return 0.5, 0.5 + if 18 <= hour < 22: + return 1.0, 0.0 + return 0.0, 0.0 + + +def generate_fixture(local_date: str, label: str, transition: str) -> dict: + """Walk wall-clock 30-min steps from 00:00 local to 24:00 local. + + On DST-forward day (October): the 02:00-03:00 hour does NOT exist. + Stepping by 30min in Sydney tz, datetime arithmetic naturally skips + the gap. Result: 23 hour day = 46 half-hour slots. + + On DST-backward day (April): the 02:00-03:00 hour exists TWICE + (once as AEDT, once as AEST). Naive datetime stepping in local + tz would loop forever or double-count. Solution: do all math in + UTC, then label each slot with its local clock for hand-calc. + Result: 25 hour day = 50 half-hour slots. + """ + start_local = datetime.fromisoformat(f"{local_date}T00:00:00").replace(tzinfo=SYDNEY) + end_local = datetime.fromisoformat(f"{local_date}T00:00:00").replace(tzinfo=SYDNEY) + timedelta(days=1) + + start_utc = start_local.astimezone(UTC) + end_utc = end_local.astimezone(UTC) + + slots = [] + cur_utc = start_utc + step = timedelta(minutes=30) + while cur_utc < end_utc: + local_clock = cur_utc.astimezone(SYDNEY) + # For consumption profile we use the local-clock hour. This means + # on the DST-backward day the 02:00-03:00 hour is duplicated and + # gets the overnight profile both times (correct — clocks fall back + # but residents are still asleep, so same load shape). + hour = local_clock.hour + grid_kwh, solar_kwh = consumption_for_local_hour(hour) + offset = local_clock.utcoffset() + offset_h = offset.total_seconds() / 3600 if offset is not None else 0.0 + slots.append({ + "ts_utc": cur_utc.isoformat(timespec="seconds"), + "ts_local": local_clock.isoformat(timespec="seconds"), + "local_clock": local_clock.strftime("%H:%M"), + "local_offset": offset_h, + "grid_import_kwh": grid_kwh, + "solar_export_kwh": solar_kwh, + }) + cur_utc += step + + total_grid = sum(s["grid_import_kwh"] for s in slots) + total_solar = sum(s["solar_export_kwh"] for s in slots) + hours_covered = (end_utc - start_utc).total_seconds() / 3600 + + return { + "_phase0_meta": { + "label": label, + "transition": transition, + "local_date": local_date, + "tz": "Australia/Sydney", + "slots_count": len(slots), + "wall_clock_hours": hours_covered, + "total_grid_import_kwh": round(total_grid, 4), + "total_solar_export_kwh": round(total_solar, 4), + "profile_source": "synthetic residential pattern per scripts/gen_dst_fixtures.py", + "test_assertion": "evaluator total cost matches hand-calc within $0.05", + }, + "slots": slots, + } + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + # 2026-04-05 (Sun) = DST backward in NSW (AEDT 03:00 -> AEST 02:00, gain 1h). + # Note: design doc + checkpoint claimed Apr 6, but verified via + # zoneinfo that the transition is the first Sunday (Apr 5). Apr 6 is the + # day after. Correction logged in DECISIONS.md as D-P0-4. + april = generate_fixture( + local_date="2026-04-05", + label="Plan D — NSW DST backward (gain 1h)", + transition="AEDT_to_AEST", + ) + out_april = OUT_DIR / "consumption_dst_april_2026-04-05.json" + out_april.write_text(json.dumps(april, indent=2)) + meta = april["_phase0_meta"] + print(f"wrote {out_april.name}: {meta['slots_count']} slots, " + f"{meta['wall_clock_hours']:.1f}h wall-clock, " + f"grid={meta['total_grid_import_kwh']} kWh, solar={meta['total_solar_export_kwh']} kWh") + + # 2026-10-04 (Sun) = DST forward in NSW (AEST 02:00 -> AEDT 03:00, lose 1h). + # Design doc + checkpoint claimed Oct 5; correction logged D-P0-4. + october = generate_fixture( + local_date="2026-10-04", + label="Plan E — NSW DST forward (lose 1h)", + transition="AEST_to_AEDT", + ) + out_october = OUT_DIR / "consumption_dst_october_2026-10-04.json" + out_october.write_text(json.dumps(october, indent=2)) + meta = october["_phase0_meta"] + print(f"wrote {out_october.name}: {meta['slots_count']} slots, " + f"{meta['wall_clock_hours']:.1f}h wall-clock, " + f"grid={meta['total_grid_import_kwh']} kWh, solar={meta['total_solar_export_kwh']} kWh") + + # Sanity assertions on slot counts + assert len(april["slots"]) == 50, f"April should be 50 half-hour slots (25h), got {len(april['slots'])}" + assert len(october["slots"]) == 46, f"October should be 46 half-hour slots (23h), got {len(october['slots'])}" + print("\nslot count sanity: PASS (50 for April back, 46 for October forward)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ha_pull_consumption.py b/scripts/ha_pull_consumption.py new file mode 100644 index 0000000..a5b8023 --- /dev/null +++ b/scripts/ha_pull_consumption.py @@ -0,0 +1,207 @@ +"""Pull 7-day half-hourly consumption fixture from HA recorder. + +Window per PHASE_0_GROUND_TRUTH.md §5: 2026-05-07 00:00 AEST -> 2026-05-14 00:00 AEST. + +Strategy: + - Pull state history for 3 cumulative `total_increasing` sensors. + - For each half-hour slot, diff state at slot boundaries -> slot kWh. + - Save to tests/fixtures/phase0/consumption_7d.json. + +Sensors: + - sensor.power_sync_lifetime_grid_import (kWh imported from grid, cumulative) + - sensor.power_sync_lifetime_grid_export (kWh exported to grid, cumulative) + - sensor.power_sync_lifetime_solar_energy (kWh solar produced, cumulative) + +HA token read from $HA_TOKEN. Token NEVER written to disk. +Output fixture contains kWh values only — no auth material. + +Run: python3 scripts/ha_pull_consumption.py +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.parse +import urllib.request +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +OUT = Path(__file__).parent.parent / "tests" / "fixtures" / "phase0" / "consumption_7d.json" +AEST = ZoneInfo("Australia/Sydney") # AEDT/AEST aware; May = AEST +UTC = ZoneInfo("UTC") + +WINDOW_START = datetime(2026, 5, 7, 0, 0, 0, tzinfo=AEST) +WINDOW_END = datetime(2026, 5, 14, 0, 0, 0, tzinfo=AEST) +SLOT_MINUTES = 30 + +SENSORS = { + "grid_import_kwh": "sensor.power_sync_lifetime_grid_import", + "grid_export_kwh": "sensor.power_sync_lifetime_grid_export", + "solar_kwh": "sensor.power_sync_lifetime_solar_energy", +} + + +def _ha_get(path: str) -> object: + base = os.environ["HA_BASE_URL"] + token = os.environ["HA_TOKEN"] + url = f"{base}{path}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + +def fetch_history(entity_id: str, start_utc: datetime, end_utc: datetime) -> list[dict]: + start = start_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + end = end_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + params = urllib.parse.urlencode({ + "filter_entity_id": entity_id, + "end_time": end, + "minimal_response": "true", + "no_attributes": "true", + }) + path = f"/api/history/period/{start}?{params}" + raw = _ha_get(path) + # API returns [[state1, state2, ...]] — outer list is one entry per entity + if not raw: + return [] + inner = raw[0] if isinstance(raw, list) else [] + if not isinstance(inner, list): + return [] + parsed = [] + for s in inner: + try: + v = s.get("state") + t = s.get("last_changed") or s.get("last_updated") + if v in (None, "unknown", "unavailable"): + continue + kwh = float(v) + ts = datetime.fromisoformat(t.replace("Z", "+00:00")) + parsed.append({"ts_utc": ts.astimezone(UTC), "kwh": kwh}) + except (ValueError, TypeError, AttributeError): + continue + parsed.sort(key=lambda r: r["ts_utc"]) + return parsed + + +def value_at(history: list[dict], target_utc: datetime) -> float | None: + """Linear interpolation. Returns None if target outside history range.""" + if not history: + return None + if target_utc < history[0]["ts_utc"]: + return None + if target_utc > history[-1]["ts_utc"]: + return history[-1]["kwh"] + # Binary search would be faster but 7d × 3 sensors × ~1k records is fine linear. + for i in range(len(history) - 1): + a, b = history[i], history[i + 1] + if a["ts_utc"] <= target_utc <= b["ts_utc"]: + span = (b["ts_utc"] - a["ts_utc"]).total_seconds() + if span == 0: + return a["kwh"] + t = (target_utc - a["ts_utc"]).total_seconds() / span + return a["kwh"] + t * (b["kwh"] - a["kwh"]) + return history[-1]["kwh"] + + +def main() -> int: + if "HA_TOKEN" not in os.environ or "HA_BASE_URL" not in os.environ: + print("missing HA_TOKEN or HA_BASE_URL in env", file=sys.stderr) + return 2 + + start_utc = WINDOW_START.astimezone(UTC) + end_utc = WINDOW_END.astimezone(UTC) + print(f"window: {WINDOW_START} -> {WINDOW_END}", file=sys.stderr) + print(f" ({start_utc} -> {end_utc} UTC)", file=sys.stderr) + + histories: dict[str, list[dict]] = {} + for label, entity_id in SENSORS.items(): + print(f"fetching {entity_id}...", file=sys.stderr, end=" ") + hist = fetch_history(entity_id, start_utc, end_utc) + histories[label] = hist + if hist: + print(f"{len(hist)} states, range " + f"{hist[0]['ts_utc'].strftime('%Y-%m-%d %H:%M')} -> " + f"{hist[-1]['ts_utc'].strftime('%Y-%m-%d %H:%M')}, " + f"kwh {hist[0]['kwh']:.3f} -> {hist[-1]['kwh']:.3f}", + file=sys.stderr) + else: + print("EMPTY", file=sys.stderr) + + if not all(histories.values()): + print("ERROR: at least one sensor returned empty history. " + "HA recorder may not retain data this far back (default 10d retention).", + file=sys.stderr) + print("Try a more recent 7d window or extend HA recorder.purge_keep_days.", + file=sys.stderr) + return 1 + + # Build half-hour slots + slots = [] + slot_start = start_utc + while slot_start < end_utc: + slot_end = slot_start + timedelta(minutes=SLOT_MINUTES) + grid_in_start = value_at(histories["grid_import_kwh"], slot_start) + grid_in_end = value_at(histories["grid_import_kwh"], slot_end) + grid_out_start = value_at(histories["grid_export_kwh"], slot_start) + grid_out_end = value_at(histories["grid_export_kwh"], slot_end) + solar_start = value_at(histories["solar_kwh"], slot_start) + solar_end = value_at(histories["solar_kwh"], slot_end) + if None in (grid_in_start, grid_in_end, grid_out_start, grid_out_end, solar_start, solar_end): + grid_kwh = 0.0 + export_kwh = 0.0 + solar_kwh_slot = 0.0 + else: + grid_kwh = max(0.0, grid_in_end - grid_in_start) + export_kwh = max(0.0, grid_out_end - grid_out_start) + solar_kwh_slot = max(0.0, solar_end - solar_start) + local_slot = slot_start.astimezone(AEST) + slots.append({ + "ts_utc": slot_start.isoformat(timespec="seconds"), + "ts_local": local_slot.isoformat(timespec="seconds"), + "local_clock": local_slot.strftime("%H:%M"), + "grid_import_kwh": round(grid_kwh, 4), + "grid_export_kwh": round(export_kwh, 4), + "solar_kwh": round(solar_kwh_slot, 4), + }) + slot_start = slot_end + + total_import = sum(s["grid_import_kwh"] for s in slots) + total_export = sum(s["grid_export_kwh"] for s in slots) + total_solar = sum(s["solar_kwh"] for s in slots) + + out = { + "_phase0_meta": { + "label": "Plans A/B/C1/C2 7-day shared consumption", + "window_local": f"{WINDOW_START.isoformat()} -> {WINDOW_END.isoformat()}", + "window_tz": "Australia/Sydney (AEST)", + "slot_minutes": SLOT_MINUTES, + "slots_count": len(slots), + "total_grid_import_kwh": round(total_import, 3), + "total_grid_export_kwh": round(total_export, 3), + "total_solar_kwh": round(total_solar, 3), + "source_entity_grid_import": SENSORS["grid_import_kwh"], + "source_entity_grid_export": SENSORS["grid_export_kwh"], + "source_entity_solar": SENSORS["solar_kwh"], + "source_method": "HA recorder /api/history/period, linear interpolation between recorded state changes, slot kWh = state_end - state_start", + "fetched_at": datetime.now(UTC).isoformat(timespec="seconds"), + }, + "slots": slots, + } + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(out, indent=2)) + print(f"\nwrote {OUT}") + print(f" slots: {len(slots)} (expected 336 for 7d × 48 slots/day)") + print(f" totals: import={total_import:.2f} kWh, export={total_export:.2f} kWh, solar={total_solar:.2f} kWh") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase_0_verify.py b/scripts/phase_0_verify.py new file mode 100644 index 0000000..8e1ffa4 --- /dev/null +++ b/scripts/phase_0_verify.py @@ -0,0 +1,350 @@ +"""Phase 0 gate verifier: independent kWh-bucket aggregator vs evaluator. + +Different code path from cdr_evaluator_proto.py. Buckets consumption +by TOU window using simple per-rate-type aggregation, then multiplies +by per-bucket rate. Surfaces kWh-by-bucket breakdown for hand-calc +spreadsheet replication. + +The two paths SHOULD agree. Where they disagree -> bug in one or both; +the human hand-calc spreadsheet is the canonical tie-breaker. + +Run: + python3 scripts/phase_0_verify.py # all 6 plans, table output + python3 scripts/phase_0_verify.py --markdown # writes GATE_RESULTS.md +""" +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from zoneinfo import ZoneInfo + +sys.path.insert(0, str(Path(__file__).parent)) +from cdr_evaluator_proto import evaluate, GST_FACTOR # noqa: E402 + +REPO = Path(__file__).parent.parent +FIXTURE_DIR = REPO / "tests" / "fixtures" / "phase0" +RESULTS_MD = FIXTURE_DIR / "GATE_RESULTS.md" + +DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] +SYDNEY = ZoneInfo("Australia/Sydney") + +# Plans + consumption fixture pairs. +CASES = [ + ("A", "AGL Residential Smart Saver (SINGLE_RATE NSW)", + "plan_agl_AGL907738MRE6@EME.json", "consumption_7d.json", 0.05), + ("B", "Red Taronga Flex (TIME_OF_USE NSW Ausgrid)", + "plan_red-energy_RED552831MRE15@EME.json", "consumption_7d.json", 0.05), + ("C1", "Synthetic FLEXIBLE (stepped 24.6c -> 30.1c at 15 kWh/day)", + "plan_c1_flexible_synthetic.json", "consumption_7d.json", 0.05), + ("C2", "GloBird ZEROHERO United Energy (FLEXIBLE + parser)", + "plan_globird_GLO731031MR@VEC.json", "consumption_7d.json", 0.05), + ("D", "Red Taronga Flex × DST backward 2026-04-05 (25h day)", + "plan_red-energy_RED552831MRE15@EME.json", "consumption_dst_april_2026-04-05.json", 0.05), + ("E", "Red Taronga Flex × DST forward 2026-10-04 (23h day)", + "plan_red-energy_RED552831MRE15@EME.json", "consumption_dst_october_2026-10-04.json", 0.05), +] + + +def _hhmm_to_minutes(hhmm: str) -> int: + h, m = hhmm.split(":") + return int(h) * 60 + int(m) + + +def _slot_in_window(local_dt: datetime, days: list[str], start: str, end: str) -> bool: + """Same semantics as evaluator: start-inclusive, end-exclusive. "00:00" + end with non-zero start = end-of-day (24:00 = 1440).""" + if DAY_NAMES[local_dt.weekday()] not in days: + return False + m = local_dt.hour * 60 + local_dt.minute + sm = _hhmm_to_minutes(start) + em = _hhmm_to_minutes(end) + if em == 0 and sm > 0: + em = 1440 + if em < sm: + return m >= sm or m < em + return sm <= m < em + + +def _bucketize_import(plan: dict, slots: list[dict]) -> dict: + """Bucket consumption by TOU window or singleRate; return per-bucket kWh + cost. + + Independent path: aggregate kWh first, then multiply by rate. Stepped + rates are handled by inserting an extra synthetic bucket per day for the + over-threshold tail. + """ + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + tps = elec.get("tariffPeriod", []) or [] + if not tps: + return {} + tp = tps[0] + rblock = tp.get("rateBlockUType") + + daily_running: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) + buckets: dict[str, dict] = defaultdict(lambda: {"kwh": Decimal("0"), "cost_ex_gst": Decimal("0"), "rate_label": ""}) + + if rblock == "singleRate": + single = tp.get("singleRate", {}) or {} + rates = single.get("rates", []) or [] + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + day = local_dt.date().isoformat() + kwh = Decimal(str(slot.get("grid_import_kwh", 0) or 0)) + running = daily_running[day] + # Walk stepped rates + for r in rates: + vol = r.get("volume") + price = Decimal(str(r.get("unitPrice", 0))) + bucket_key = f"SINGLE_RATE@{price}" + if vol is None: + if running < (Decimal(str(rates[0].get("volume", 1e9))) if rates and rates[0].get("volume") else Decimal("1e9")): + continue + buckets[bucket_key]["kwh"] += kwh + buckets[bucket_key]["cost_ex_gst"] += kwh * price + buckets[bucket_key]["rate_label"] = f"flat {price}/kWh" + daily_running[day] += kwh + break + else: + vol_d = Decimal(str(vol)) + if running < vol_d: + buckets[bucket_key]["kwh"] += kwh + buckets[bucket_key]["cost_ex_gst"] += kwh * price + buckets[bucket_key]["rate_label"] = f"first {vol_d} kWh/period @ {price}/kWh" + daily_running[day] += kwh + break + return buckets + + if rblock == "timeOfUseRates": + tou_rates = tp.get("timeOfUseRates", []) or [] + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + day = local_dt.date().isoformat() + kwh = Decimal(str(slot.get("grid_import_kwh", 0) or 0)) + matched = None + for rate in tou_rates: + for window in rate.get("timeOfUse", []) or []: + if _slot_in_window(local_dt, window.get("days", []), window.get("startTime", "00:00"), window.get("endTime", "23:59")): + matched = rate + break + if matched: + break + if not matched: + buckets["UNMATCHED"]["kwh"] += kwh + continue + rtype = matched.get("type", "?") + running_key = f"{day}|{rtype}" + running = daily_running[running_key] + # Pick rate from stepped rates list + chosen_price = None + chosen_label = None + for r in matched.get("rates", []) or []: + vol = r.get("volume") + price = Decimal(str(r.get("unitPrice", 0))) + if vol is None: + chosen_price = price + chosen_label = f"{rtype} flat {price}/kWh" + break + vol_d = Decimal(str(vol)) + if running < vol_d: + chosen_price = price + chosen_label = f"{rtype} <{vol_d} kWh/day @ {price}/kWh" + break + if chosen_price is None: + last = matched.get("rates", [{}])[-1] + chosen_price = Decimal(str(last.get("unitPrice", 0))) + chosen_label = f"{rtype} (fallback) {chosen_price}/kWh" + bucket_key = f"{rtype}@{chosen_price}" + buckets[bucket_key]["kwh"] += kwh + buckets[bucket_key]["cost_ex_gst"] += kwh * chosen_price + buckets[bucket_key]["rate_label"] = chosen_label + daily_running[running_key] += kwh + return buckets + + return {} + + +def _supply_cost(plan: dict, slots: list[dict]) -> tuple[Decimal, int]: + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + tp = (elec.get("tariffPeriod") or [{}])[0] + dsc = Decimal(str(tp.get("dailySupplyCharge", 0) or 0)) + days = {datetime.fromisoformat(s["ts_local"]).date() for s in slots} + return dsc * Decimal(len(days)), len(days) + + +def _fit_cost(plan: dict, slots: list[dict]) -> Decimal: + """Independent FIT cross-check: walk each slot, find matching FIT, sum credit.""" + elec = plan.get("data", {}).get("electricityContract", {}) or plan.get("electricityContract", {}) + fits = elec.get("solarFeedInTariff", []) or [] + total_credit = Decimal("0") + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + exp = Decimal(str(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) or 0)) + if exp <= 0: + continue + for fit in fits: + if fit.get("tariffUType") == "singleTariff": + st = fit.get("singleTariff") or {} + tvs = st.get("timeVariations") or [] + if tvs and not any(_slot_in_window(local_dt, t.get("days", DAY_NAMES), t.get("startTime", "00:00"), t.get("endTime", "23:59")) for t in tvs): + continue + rates = st.get("rates") or [] + if rates: + total_credit += exp * Decimal(str(rates[0].get("unitPrice", 0))) + elif fit.get("tariffUType") == "timeVaryingTariffs": + for tvt in fit.get("timeVaryingTariffs") or []: + if any(_slot_in_window(local_dt, t.get("days", DAY_NAMES), t.get("startTime", "00:00"), t.get("endTime", "23:59")) for t in (tvt.get("timeVariations") or [])): + rates = tvt.get("rates") or [] + if rates: + total_credit += exp * Decimal(str(rates[0].get("unitPrice", 0))) + return -total_credit # credit -> negative cost contribution + + +def run_one(label: str, desc: str, plan_path: Path, cons_path: Path) -> dict: + plan = json.loads(plan_path.read_text()) + cons = json.loads(cons_path.read_text()) + slots = cons.get("slots", []) or [] + + # Evaluator path + bd = evaluate(plan, cons) + evaluator_total_inc = bd.total_aud_inc_gst + + # Independent path: bucket-aggregated import + supply + FIT (no incentives, + # apples-to-apples with evaluator before its incentive parser fires) + buckets = _bucketize_import(plan, slots) + independent_import_ex = sum((b["cost_ex_gst"] for b in buckets.values()), Decimal("0")) + supply_ex, days = _supply_cost(plan, slots) + fit_cost_ex = _fit_cost(plan, slots) + + # Incentive credit (already inc-GST per parser convention; legacy treats + # "$1/Day" credit as $1 inc-GST flat). + incentive_inc = bd.incentive_aud_inc_gst + + independent_total_ex = independent_import_ex + supply_ex + fit_cost_ex + independent_total_inc = (independent_total_ex * GST_FACTOR + incentive_inc).quantize(Decimal("0.01")) + evaluator_total_inc_q = evaluator_total_inc.quantize(Decimal("0.01")) + + diff_abs = abs(independent_total_inc - evaluator_total_inc_q) + diff_rel = float(diff_abs / evaluator_total_inc_q * 100) if evaluator_total_inc_q != 0 else 0.0 + + return { + "label": label, + "desc": desc, + "plan_id": bd.plan_id, + "days": days, + "slots": len(slots), + "evaluator_total_inc": float(evaluator_total_inc_q), + "independent_total_inc": float(independent_total_inc), + "diff_abs": float(diff_abs), + "diff_rel_pct": diff_rel, + "buckets": {k: {"kwh": float(v["kwh"].quantize(Decimal("0.001"))), "cost_ex_gst": float(v["cost_ex_gst"].quantize(Decimal("0.0001"))), "label": v["rate_label"]} for k, v in buckets.items()}, + "supply_ex": float(supply_ex.quantize(Decimal("0.01"))), + "fit_credit_ex": float(fit_cost_ex.quantize(Decimal("0.0001"))), + "incentive_credit_inc": float(incentive_inc.quantize(Decimal("0.0001"))), + "notes": bd.notes, + } + + +def main(argv: list[str]) -> int: + results = [] + print("=" * 80) + for code, desc, plan_f, cons_f, _tol in CASES: + r = run_one(code, desc, FIXTURE_DIR / plan_f, FIXTURE_DIR / cons_f) + results.append(r) + print(f"\nPLAN {code} | {desc}") + print(f" plan_id={r['plan_id']} days={r['days']} slots={r['slots']}") + print(f" evaluator_total_inc_gst: ${r['evaluator_total_inc']:.2f}") + print(f" independent_total_inc_gst: ${r['independent_total_inc']:.2f}") + print(f" diff: ${r['diff_abs']:.4f} ({r['diff_rel_pct']:.3f}%)") + print(f" supply_ex: ${r['supply_ex']:.2f} fit_credit_ex: ${r['fit_credit_ex']:.4f} incentive_credit_inc: ${r['incentive_credit_inc']:.4f}") + print(f" buckets (independent kWh × rate, ex-GST):") + for k, b in sorted(r["buckets"].items()): + print(f" {b['label']:<48} kWh={b['kwh']:>10.3f} cost_ex_gst=${b['cost_ex_gst']:.4f}") + for n in r["notes"]: + print(f" NOTE: {n}") + + print("\n" + "=" * 80) + print("CROSS-CHECK SUMMARY (evaluator vs independent bucket aggregator)") + print(f" {'Plan':<5} {'Evaluator $':>14} {'Independent $':>16} {'Diff $':>10} {'Diff %':>10}") + for r in results: + print(f" {r['label']:<5} {r['evaluator_total_inc']:>14.2f} {r['independent_total_inc']:>16.2f} {r['diff_abs']:>10.4f} {r['diff_rel_pct']:>10.4f}") + + if "--markdown" in argv: + _write_markdown(results) + print(f"\nwrote {RESULTS_MD}") + + return 0 + + +def _write_markdown(results: list[dict]) -> None: + lines = [ + "# Phase 0 Gate Results — Cross-Check Report", + "", + "**Purpose:** Independent verification that the Phase 0 evaluator prototype", + "(`scripts/cdr_evaluator_proto.py`) reproduces a separate bucket-aggregation", + "pass over the same fixtures. The two code paths share no logic except input", + "parsing. If they agree, the evaluator's structural logic is internally", + "consistent.", + "", + "**This does NOT replace human hand-calc** — that remains the canonical", + "ground-truth per locked decision D-P0-2 / design doc §F. Use this report", + "to drive what to hand-check first: focus on buckets with the largest kWh", + "contribution, validate the rate × kWh math against the plan PDF, sum the", + "buckets, apply × 1.10 for GST, compare to the evaluator total.", + "", + "All dollar values shown GST-inclusive unless suffixed `_ex`.", + "", + "## Summary", + "", + "| Plan | Description | Days | Slots | Evaluator $ | Independent $ | Diff $ | Diff % |", + "|------|-------------|-----:|------:|------------:|--------------:|-------:|-------:|", + ] + for r in results: + lines.append(f"| {r['label']} | {r['desc']} | {r['days']} | {r['slots']} | ${r['evaluator_total_inc']:.2f} | ${r['independent_total_inc']:.2f} | ${r['diff_abs']:.4f} | {r['diff_rel_pct']:.4f}% |") + + lines += [ + "", + "## Per-plan bucket breakdown (ex-GST)", + "", + "Each bucket = sum of half-hour kWh that fell into one TOU window slot × the applicable rate.", + "Useful for hand-spreadsheet replication: each row in your spreadsheet should match a row here.", + "", + ] + for r in results: + lines += [ + f"### Plan {r['label']} — {r['desc']}", + f"- plan_id: `{r['plan_id']}`", + f"- supply ex-GST: ${r['supply_ex']:.4f} ({r['days']} days × daily supply)", + f"- FIT credit ex-GST: ${r['fit_credit_ex']:.4f} (negative = credit toward bill)", + f"- Incentive credit ex-GST (parser output): ${r['incentive_credit_inc']:.4f}", + "", + "| Bucket | kWh | Cost ex-GST |", + "|--------|----:|------------:|", + ] + for k, b in sorted(r["buckets"].items()): + lines.append(f"| {b['label']} | {b['kwh']:.3f} | ${b['cost_ex_gst']:.4f} |") + lines.append("") + + lines += [ + "## Hand-calc gate criteria", + "", + "Per `scripts/PHASE_0_GROUND_TRUTH.md` §6:", + "- Plans A / B / C1 / C2: within ±5% of hand-calc total_aud_inc_gst.", + "- Plans D / E: within ±$0.05 absolute (24h windows).", + "- C2 (GloBird ZEROHERO) is load-bearing — fail = Approach A fallback.", + "", + "## How to read this report", + "", + "1. For each plan, sum (Bucket cost_ex_gst) + supply_ex + fit_credit_ex + incentive_credit_inc.", + "2. Multiply the sum by 1.10 for GST.", + "3. The result should equal `Independent $` to 2 d.p.", + "4. `Diff $` between Evaluator and Independent should be ~$0.00 — the two are computing the same thing two ways. Non-zero diff indicates a bug in one path.", + "5. For the canonical Phase 0 gate, replace this report's bucket totals with your hand-calc spreadsheet values and re-check the per-plan diff.", + ] + RESULTS_MD.write_text("\n".join(lines)) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/phase_1_parity.py b/scripts/phase_1_parity.py new file mode 100644 index 0000000..729a50e --- /dev/null +++ b/scripts/phase_1_parity.py @@ -0,0 +1,300 @@ +"""Phase 1 parity check — legacy TariffEngine vs new CDR evaluator on +the SAME canonical CDR data. + +Translates the C2 CDR PlanDetailV2 JSON into the legacy engine's +options dict (the shape used by v1.4.x config_flow). Drives both +engines over the SAME consumption fixture. Compares per-day totals. + +Gate: ±0.5% per day per §H §3 / DECISIONS.md D-P0-6. Failure means +the new evaluator's algorithm diverges from legacy's, NOT a rate- +version drift. + +The CDR fixture's `tariffPeriod[0].dailySupplyCharge` is ex-GST in +DOLLARS; legacy expects inc-GST CENTS — translator handles the unit +conversion. Same for `unitPrice` in rate blocks. + +Run: + python3 scripts/phase_1_parity.py +""" +from __future__ import annotations + +import importlib.util +import json +import sys +from datetime import datetime, timedelta +from decimal import Decimal +from pathlib import Path + +REPO = Path(__file__).parent.parent +CDR_PLAN_PATH = REPO / "tests" / "fixtures" / "phase0" / "plan_globird_GLO731031MR@VEC.json" +CONSUMPTION_PATH = REPO / "tests" / "fixtures" / "phase0" / "consumption_7d.json" +OUT_REPORT = REPO / "tests" / "fixtures" / "legacy_engine_outputs" / "PARITY_REPORT.md" + +# Direct-load tariff_engine.py (bypass package __init__) +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod # dataclass needs module registered in sys.modules + spec.loader.exec_module(mod) + return mod + + +_tariff_engine = _load("legacy_tariff_engine", REPO / "custom_components" / "pricehawk" / "tariff_engine.py") +TariffEngine = _tariff_engine.TariffEngine + +_evaluator = _load("cdr_evaluator_proto", Path(__file__).parent / "cdr_evaluator_proto.py") +evaluate = _evaluator.evaluate +GST_FACTOR = _evaluator.GST_FACTOR + +SLOT_HOURS = 0.5 +SUBSTEP_MINUTES = 6 +SUBSTEPS_PER_SLOT = int((SLOT_HOURS * 60) / SUBSTEP_MINUTES) +GAP_PROTECTION = 0.1 # h, must match tariff_engine.GAP_PROTECTION_MAX_DELTA_H + +ALL_DAYS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] + + +def _ex_gst_dollars_to_inc_gst_cents(d: float | str | Decimal) -> float: + """CDR uses ex-GST $/kWh; legacy uses inc-GST c/kWh.""" + return float(Decimal(str(d)) * Decimal("1.10") * Decimal("100")) + + +def _window_pairs(time_of_use: list[dict]) -> list[list[str]]: + """Translate CDR timeOfUse [{startTime, endTime, days}] to legacy + [[start, end+1min]] pairs. Legacy uses HH:MM with end EXCLUSIVE + where windows end at midnight encoded as 00:00. + """ + pairs = [] + for tu in time_of_use: + start = tu.get("startTime", "00:00") + end = tu.get("endTime", "23:59") + # Convert CDR's inclusive "HH:59" to legacy's exclusive "HH+1:00" + if end.endswith(":59"): + h, _ = end.split(":") + end_excl = f"{int(h) + 1:02d}:00" if int(h) < 23 else "00:00" + else: + end_excl = end + pairs.append([start, end_excl]) + return pairs + + +def cdr_to_legacy_options(cdr_plan: dict) -> dict: + """Translate CDR PlanDetailV2 -> legacy ZEROHERO-shaped options dict. + + Phase 1 helper. NOT general-purpose: assumes ZEROHERO-flavored + FLEXIBLE plan with TOU import + TOU FIT. Other pricingModels need + different mapping. + """ + elec = cdr_plan["data"]["electricityContract"] + tp = elec["tariffPeriod"][0] + + # Import side: timeOfUseRates -> legacy "periods" dict + import_periods: dict = {} + type_map = {"PEAK": "peak", "SHOULDER": "shoulder", "OFF_PEAK": "offpeak"} + for r in tp.get("timeOfUseRates", []) or []: + legacy_type = type_map.get(r["type"], r["type"].lower()) + rates = r.get("rates", []) or [] + if not rates: + continue + rate_ex = rates[0].get("unitPrice", "0") + rate_inc_c = _ex_gst_dollars_to_inc_gst_cents(rate_ex) + windows = _window_pairs(r.get("timeOfUse", []) or []) + if legacy_type in import_periods: + import_periods[legacy_type]["windows"].extend(windows) + else: + import_periods[legacy_type] = {"rate": rate_inc_c, "windows": windows} + + # Export side: timeVaryingTariffs (post-augmentation) -> legacy "periods" + export_periods: dict = {} + fits = elec.get("solarFeedInTariff", []) or [] + for fit in fits: + if fit.get("tariffUType") != "timeVaryingTariffs": + continue + for tvt in fit.get("timeVaryingTariffs") or []: + legacy_type = type_map.get(tvt["type"], tvt["type"].lower()) + rates = tvt.get("rates", []) or [] + if not rates: + continue + rate_ex = rates[0].get("unitPrice", "0") + rate_inc_c = _ex_gst_dollars_to_inc_gst_cents(rate_ex) + windows = _window_pairs(tvt.get("timeVariations", []) or []) + if legacy_type in export_periods: + export_periods[legacy_type]["windows"].extend(windows) + else: + export_periods[legacy_type] = {"rate": rate_inc_c, "windows": windows} + + # Supply: ex-GST $/day -> inc-GST c/day + supply_inc_c = _ex_gst_dollars_to_inc_gst_cents(tp.get("dailySupplyCharge", "0")) / 100 * 100 # noqa + # (multiplication is identity, kept explicit for clarity) + supply_inc_c = float(Decimal(str(tp.get("dailySupplyCharge", "0"))) * Decimal("1.10") * Decimal("100")) + + return { + "plan_type": "zerohero_cdr_translated", + "daily_supply_charge": supply_inc_c, + "demand_charge": 0.0, + "import_tariff": {"type": "tou", "periods": import_periods}, + "export_tariff": {"type": "tou", "periods": export_periods}, + "incentives": ["zerohero_credit", "super_export", "free_power_window"], + } + + +def _drive_legacy(options: dict, slots: list[dict]) -> dict: + """Same sub-sampling driver as snapshot_legacy_engine.py.""" + engine = TariffEngine(options) + per_day: dict[str, dict] = {} + current_day: str | None = None + + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]).replace(tzinfo=None) + day_key = local_dt.date().isoformat() + if current_day is None: + current_day = day_key + elif day_key != current_day: + per_day[current_day] = { + "cost_aud": engine.net_daily_cost_aud, + "import_kwh": engine.import_kwh_today, + "export_kwh": engine.export_kwh_today, + "import_cost_c": engine.import_cost_today_c, + "export_earnings_c": engine.export_earnings_today_c, + "zerohero": engine.zerohero_status, + "super_export_kwh": engine.super_export_kwh, + } + engine.reset_daily() + current_day = day_key + + import_kwh = float(slot.get("grid_import_kwh", 0) or 0) + export_kwh = float(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) or 0) + net_w = ((import_kwh - export_kwh) / SLOT_HOURS) * 1000.0 + for sub_i in range(SUBSTEPS_PER_SLOT): + engine.update(net_w, local_dt + timedelta(minutes=SUBSTEP_MINUTES * sub_i)) + + if current_day: + per_day[current_day] = { + "cost_aud": engine.net_daily_cost_aud, + "import_kwh": engine.import_kwh_today, + "export_kwh": engine.export_kwh_today, + "import_cost_c": engine.import_cost_today_c, + "export_earnings_c": engine.export_earnings_today_c, + "zerohero": engine.zerohero_status, + "super_export_kwh": engine.super_export_kwh, + } + return per_day + + +def _drive_new(cdr_plan: dict, consumption: dict) -> dict: + """Per-day breakdown using the new evaluator. + + The new evaluator returns one whole-period CostBreakdown, not per-day. + To produce per-day numbers for parity comparison, slice the consumption + fixture by local date and run evaluator once per slice. + """ + slots = consumption.get("slots", []) or [] + by_day: dict[str, list[dict]] = {} + for slot in slots: + day_key = slot["ts_local"][:10] + by_day.setdefault(day_key, []).append(slot) + per_day: dict[str, float] = {} + for day, day_slots in by_day.items(): + sub_consumption = {"slots": day_slots} + bd = evaluate(cdr_plan, sub_consumption) + per_day[day] = float(bd.total_aud_inc_gst.quantize(Decimal("0.0001"))) + return per_day + + +def main() -> int: + cdr_plan = json.loads(CDR_PLAN_PATH.read_text()) + consumption = json.loads(CONSUMPTION_PATH.read_text()) + slots = consumption.get("slots", []) or [] + + # Translate CDR -> legacy options + legacy_options = cdr_to_legacy_options(cdr_plan) + print("=== Translated CDR -> legacy options ===") + print(json.dumps(legacy_options, indent=2)) + + # Drive both engines + print("\n=== Driving legacy engine with CDR-translated options ===") + legacy_per_day = _drive_legacy(legacy_options, slots) + legacy_total = sum(d["cost_aud"] for d in legacy_per_day.values()) + print(f"legacy 7d total: ${legacy_total:.2f}") + + print("\n=== Driving new CDR evaluator ===") + new_per_day = _drive_new(cdr_plan, consumption) + new_total = sum(new_per_day.values()) + print(f"new 7d total: ${new_total:.2f}") + + # Per-day comparison + print("\n=== PARITY (per-day, inc-GST AUD) ===") + rows = [] + print(f"{'Day':<12} {'Legacy $':>10} {'New $':>10} {'Diff $':>10} {'Diff %':>10} {'Status':<10}") + pass_count = 0 + for day in sorted(set(legacy_per_day) | set(new_per_day)): + leg = legacy_per_day.get(day, {}).get("cost_aud", 0.0) + new = new_per_day.get(day, 0.0) + diff = abs(leg - new) + rel = (diff / leg * 100) if leg else 0.0 + zh = legacy_per_day.get(day, {}).get("zerohero", "n/a") + status = "PASS" if rel <= 0.5 else "FAIL" + if status == "PASS": + pass_count += 1 + rows.append({"day": day, "legacy": leg, "new": new, "diff": diff, "rel_pct": rel, "zerohero": zh, "status": status}) + print(f"{day:<12} {leg:>10.4f} {new:>10.4f} {diff:>10.4f} {rel:>10.4f} {status:<10} zh={zh}") + + total_diff = abs(legacy_total - new_total) + total_rel = (total_diff / legacy_total * 100) if legacy_total else 0.0 + total_status = "PASS" if total_rel <= 0.5 else "FAIL" + print(f"\n{'TOTAL':<12} {legacy_total:>10.4f} {new_total:>10.4f} {total_diff:>10.4f} {total_rel:>10.4f} {total_status}") + print(f"\nPer-day pass count: {pass_count}/{len(rows)} (gate: ±0.5%)") + + # Write markdown report + OUT_REPORT.parent.mkdir(parents=True, exist_ok=True) + md = [ + "# Phase 1 Parity Report — Legacy TariffEngine vs CDR Evaluator", + "", + "**Inputs:**", + f"- CDR plan: `{CDR_PLAN_PATH.relative_to(REPO)}` ({cdr_plan['data']['planId']})", + f"- Consumption: `{CONSUMPTION_PATH.relative_to(REPO)}` ({len(slots)} slots, " + f"window {consumption['_phase0_meta']['window_local']})", + "", + "**Method:** translate CDR `electricityContract` -> legacy options dict via", + "`cdr_to_legacy_options()`. Drive legacy engine (6-min sub-sampling per", + "GAP_PROTECTION cap). Drive new evaluator on per-day slot slices. Compare", + "per-day totals.", + "", + "**Gate (§H §3 / D-P0-6):** ±0.5% per day. New evaluator must reproduce", + "legacy results within that bound before `tariff_engine.py` (496 lines) is", + "deleted at end of Phase 1.", + "", + "## Per-day comparison", + "", + "| Day | Legacy $ | New $ | Diff $ | Diff % | Status | zerohero |", + "|-----|---------:|------:|-------:|-------:|:------:|----------|", + ] + for r in rows: + md.append(f"| {r['day']} | ${r['legacy']:.4f} | ${r['new']:.4f} | ${r['diff']:.4f} | {r['rel_pct']:.4f}% | {r['status']} | {r['zerohero']} |") + md += [ + f"| **TOTAL** | **${legacy_total:.4f}** | **${new_total:.4f}** | **${total_diff:.4f}** | **{total_rel:.4f}%** | **{total_status}** | — |", + "", + f"**Per-day passes:** {pass_count}/{len(rows)} (gate: ±0.5%)", + "", + "## Translated legacy options (for reproducibility)", + "", + "```json", + json.dumps(legacy_options, indent=2), + "```", + "", + "## Interpretation", + "", + f"- If TOTAL gate is PASS: refactor can proceed; new evaluator is parity-equivalent to legacy at the algorithm level.", + f"- If TOTAL is FAIL but per-day diffs are random ±X: likely a numerical-precision quirk; investigate but probably acceptable.", + f"- If a SPECIFIC day fails (e.g. ZEROHERO 'lost' day shows large diff): incentive parser logic divergence between legacy ZeroHeroTracker (instantaneous threshold) and new evaluator parser (avg-over-window threshold). May require switching new parser to instantaneous logic or sub-sample driver for legacy parity.", + "", + f"_Generated by `scripts/phase_1_parity.py` at {datetime.now().isoformat(timespec='seconds')}_", + ] + OUT_REPORT.write_text("\n".join(md)) + print(f"\nwrote {OUT_REPORT}") + return 0 if total_status == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/snapshot_legacy_engine.py b/scripts/snapshot_legacy_engine.py new file mode 100644 index 0000000..bd44227 --- /dev/null +++ b/scripts/snapshot_legacy_engine.py @@ -0,0 +1,198 @@ +"""Snapshot legacy TariffEngine outputs for Phase 1 parity gate. + +Drives `custom_components/pricehawk/tariff_engine.py` (the 496-line legacy +GloBird engine that will be DELETED at end of Phase 1) over a fixed +consumption fixture using both ZEROHERO and BOOST configs. Saves outputs +to tests/fixtures/legacy_engine_outputs/. + +Per locked decision §H §3 (DECISIONS.md D-P0-6 follow-on): the new +cdr/evaluator.py must reproduce these snapshots within 0.5% before legacy +deletion. Snapshots are the contract. + +Run THIS SCRIPT BEFORE refactoring tariff_engine.py. Once the snapshots +exist + are committed, Phase 1 evaluator work can begin without +risk of regressing battle-tested behaviour. + +Streaming model: legacy engine takes (grid_power_w, now_local) per call +and caps delta_h at GAP_PROTECTION_MAX_DELTA_H = 0.1h (6 min). Our Phase 0 +consumption fixture has 30-min slots. Sub-sample each slot into 5 x 6-min +sub-readings at the same mean kW so engine accumulates kWh correctly. + +Each slot conversion: + net_grid_kw = (import_kwh - export_kwh) / 0.5 + net_grid_w = net_grid_kw * 1000 + for sub in 0..4: engine.update(net_grid_w, slot_start + sub*6min) + +Run: + python3 scripts/snapshot_legacy_engine.py +""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path + +REPO = Path(__file__).parent.parent + +# tariff_engine.py is pure Python per its docstring, but +# custom_components/pricehawk/__init__.py imports HA. Bypass the package +# __init__ by loading tariff_engine.py directly via importlib. +import importlib.util # noqa: E402 + +_TE_PATH = REPO / "custom_components" / "pricehawk" / "tariff_engine.py" +_spec = importlib.util.spec_from_file_location("legacy_tariff_engine", _TE_PATH) +if _spec is None or _spec.loader is None: + raise RuntimeError(f"can't load {_TE_PATH}") +_tariff_engine = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_tariff_engine) +TariffEngine = _tariff_engine.TariffEngine + +OUT_DIR = REPO / "tests" / "fixtures" / "legacy_engine_outputs" +CONSUMPTION_PATH = REPO / "tests" / "fixtures" / "phase0" / "consumption_7d.json" + +# Configs lifted verbatim from tests/test_tariff_engine.py +ZEROHERO_IMPORT_PERIODS = { + "peak": {"rate": 38.50, "windows": [["16:00", "23:00"]]}, + "shoulder": {"rate": 26.95, "windows": [["23:00", "00:00"], ["00:00", "11:00"], ["14:00", "16:00"]]}, + "offpeak": {"rate": 0.00, "windows": [["11:00", "14:00"]]}, +} +ZEROHERO_EXPORT_PERIODS = { + "peak": {"rate": 3.00, "windows": [["16:00", "21:00"]]}, + "shoulder": {"rate": 0.30, "windows": [["21:00", "00:00"], ["00:00", "10:00"], ["14:00", "16:00"]]}, + "offpeak": {"rate": 0.00, "windows": [["10:00", "14:00"]]}, +} +ZEROHERO_OPTIONS = { + "plan_type": "zerohero", + "daily_supply_charge": 113.30, + "demand_charge": 0.0, + "import_tariff": {"type": "tou", "periods": ZEROHERO_IMPORT_PERIODS}, + "export_tariff": {"type": "tou", "periods": ZEROHERO_EXPORT_PERIODS}, + "incentives": ["zerohero_credit", "super_export", "free_power_window"], +} + +BOOST_OPTIONS = { + "plan_type": "boost", + "daily_supply_charge": 111.10, + "demand_charge": 0.0, + "import_tariff": { + "type": "flat_stepped", + "step1_threshold_kwh": 25.0, + "step1_rate": 21.67, + "step2_rate": 25.30, + }, + "export_tariff": { + "type": "tou", + "periods": { + "peak": {"rate": 3.00, "windows": [["16:00", "21:00"]]}, + "shoulder": {"rate": 0.10, "windows": [["21:00", "00:00"], ["00:00", "10:00"], ["14:00", "16:00"]]}, + "offpeak": {"rate": 0.00, "windows": [["10:00", "14:00"]]}, + }, + }, + "incentives": [], +} + +SLOT_HOURS = 0.5 +# Legacy engine caps delta_h at GAP_PROTECTION_MAX_DELTA_H = 0.1h (6 min). +# A 30-min step would discard 80% of energy. Sub-sample each slot into +# 5 x 6-min sub-readings at the same mean kW so accumulation matches. +SUBSTEP_MINUTES = 6 +SUBSTEPS_PER_SLOT = int((SLOT_HOURS * 60) / SUBSTEP_MINUTES) + + +def _drive_engine(options: dict, slots: list[dict]) -> dict: + """Walk slots, step engine, capture per-day rollups.""" + engine = TariffEngine(options) + per_day_cost_aud: dict[str, float] = {} + per_day_import_kwh: dict[str, float] = {} + per_day_export_kwh: dict[str, float] = {} + per_day_import_cost_c: dict[str, float] = {} + per_day_export_earnings_c: dict[str, float] = {} + per_day_zerohero: dict[str, str] = {} + per_day_super_export_kwh: dict[str, float] = {} + current_day: str | None = None + + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]) + # Strip tz so legacy engine sees naive datetime (matches test pattern) + local_naive = local_dt.replace(tzinfo=None) + day_key = local_naive.date().isoformat() + + if current_day is None: + current_day = day_key + elif day_key != current_day: + # End-of-day rollup BEFORE engine processes next slot + per_day_cost_aud[current_day] = engine.net_daily_cost_aud + per_day_import_kwh[current_day] = engine.import_kwh_today + per_day_export_kwh[current_day] = engine.export_kwh_today + per_day_import_cost_c[current_day] = engine.import_cost_today_c + per_day_export_earnings_c[current_day] = engine.export_earnings_today_c + per_day_zerohero[current_day] = engine.zerohero_status + per_day_super_export_kwh[current_day] = engine.super_export_kwh + engine.reset_daily() + current_day = day_key + + # Convert slot kWh to mean-power Watts (positive=import, negative=export) + import_kwh = float(slot.get("grid_import_kwh", 0) or 0) + export_kwh = float(slot.get("grid_export_kwh", 0) or slot.get("solar_export_kwh", 0) or 0) + net_kw = (import_kwh - export_kwh) / SLOT_HOURS + net_w = net_kw * 1000.0 + # Sub-sample at 6-min intervals (matches engine's GAP_PROTECTION cap) + for sub_i in range(SUBSTEPS_PER_SLOT): + sub_dt = local_naive + timedelta(minutes=SUBSTEP_MINUTES * sub_i) + engine.update(net_w, sub_dt) + + # Final day rollup + if current_day: + per_day_cost_aud[current_day] = engine.net_daily_cost_aud + per_day_import_kwh[current_day] = engine.import_kwh_today + per_day_export_kwh[current_day] = engine.export_kwh_today + per_day_import_cost_c[current_day] = engine.import_cost_today_c + per_day_export_earnings_c[current_day] = engine.export_earnings_today_c + per_day_zerohero[current_day] = engine.zerohero_status + per_day_super_export_kwh[current_day] = engine.super_export_kwh + + total_aud = sum(per_day_cost_aud.values()) + return { + "per_day_cost_aud": per_day_cost_aud, + "per_day_import_kwh": {k: round(v, 4) for k, v in per_day_import_kwh.items()}, + "per_day_export_kwh": {k: round(v, 4) for k, v in per_day_export_kwh.items()}, + "per_day_import_cost_c": {k: round(v, 4) for k, v in per_day_import_cost_c.items()}, + "per_day_export_earnings_c": {k: round(v, 4) for k, v in per_day_export_earnings_c.items()}, + "per_day_zerohero_status": per_day_zerohero, + "per_day_super_export_kwh": {k: round(v, 4) for k, v in per_day_super_export_kwh.items()}, + "total_aud_period": round(total_aud, 4), + "final_engine_state": engine.to_dict(), + } + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + consumption = json.loads(CONSUMPTION_PATH.read_text()) + slots = consumption.get("slots", []) or [] + print(f"loaded {len(slots)} slots from {CONSUMPTION_PATH.name}") + + for label, options in (("zerohero", ZEROHERO_OPTIONS), ("boost", BOOST_OPTIONS)): + print(f"\n=== driving {label} engine ===") + result = _drive_engine(options, slots) + result["_meta"] = { + "engine_module": "custom_components.pricehawk.tariff_engine.TariffEngine", + "engine_options_label": label, + "consumption_fixture": CONSUMPTION_PATH.name, + "slot_count": len(slots), + "captured_at": datetime.now().isoformat(timespec="seconds"), + "purpose": "Phase 1 parity snapshot per design doc §H §3. New CDR evaluator must reproduce per_day_cost_aud within 0.5% before legacy tariff_engine.py is deleted.", + "options": options, + } + out = OUT_DIR / f"legacy_{label}_7d.json" + out.write_text(json.dumps(result, indent=2, default=str)) + print(f"wrote {out.name}") + print(f" per-day totals (AUD):") + for day, cost in sorted(result["per_day_cost_aud"].items()): + print(f" {day}: ${cost:.2f}") + print(f" 7-day total: ${result['total_aud_period']:.2f}") + print(f" zerohero status sample: {next(iter(result['per_day_zerohero_status'].items()), 'n/a')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a95495e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""Test configuration — make pure-Python modules importable without HA.""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + + +class _MockModule(MagicMock): + """A MagicMock that pretends to be a package (has __path__).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__path__ = [] + + +# Register all HA modules that our code imports from +_mods = { + "homeassistant": _MockModule(), + "homeassistant.config_entries": _MockModule(), + "homeassistant.core": _MockModule(), + "homeassistant.exceptions": _MockModule(), + "homeassistant.helpers": _MockModule(), + "homeassistant.helpers.event": _MockModule(), + "homeassistant.helpers.storage": _MockModule(), + "homeassistant.helpers.update_coordinator": _MockModule(), + "homeassistant.util": _MockModule(), + "homeassistant.util.dt": _MockModule(), +} + +# Wire parent -> child so attribute access also works +_mods["homeassistant"].helpers = _mods["homeassistant.helpers"] +_mods["homeassistant"].util = _mods["homeassistant.util"] +_mods["homeassistant"].config_entries = _mods["homeassistant.config_entries"] +_mods["homeassistant"].core = _mods["homeassistant.core"] +_mods["homeassistant.helpers"].event = _mods["homeassistant.helpers.event"] +_mods["homeassistant.helpers"].storage = _mods["homeassistant.helpers.storage"] +_mods["homeassistant.helpers"].update_coordinator = _mods["homeassistant.helpers.update_coordinator"] +_mods["homeassistant.util"].dt = _mods["homeassistant.util.dt"] + +# Provide a CALLBACK_TYPE that's usable as a type annotation +_mods["homeassistant.core"].CALLBACK_TYPE = type(None) + +# Phase 3.0c: real ConfigEntryNotReady class so `raise` statements work +_mods["homeassistant.exceptions"].ConfigEntryNotReady = type( + "ConfigEntryNotReady", (Exception,), {} +) +_mods["homeassistant"].exceptions = _mods["homeassistant.exceptions"] + +for name, mod in _mods.items(): + sys.modules[name] = mod + +# Ensure the custom_components package is importable. parents[1] is +# the repo root (the directory CONTAINING custom_components/). Phase +# 3.0g (CodeRabbit): legacy parents[3] pointed two levels above the +# repo root which only worked because pytest's auto-rootdir detection +# masked the bug. Fix so non-pytest invocations import cleanly. +root = Path(__file__).resolve().parents[1] +if str(root) not in sys.path: + sys.path.insert(0, str(root)) diff --git a/tests/fixtures/legacy_engine_outputs/PARITY_REPORT.md b/tests/fixtures/legacy_engine_outputs/PARITY_REPORT.md new file mode 100644 index 0000000..2d35339 --- /dev/null +++ b/tests/fixtures/legacy_engine_outputs/PARITY_REPORT.md @@ -0,0 +1,132 @@ +# Phase 1 Parity Report — Legacy TariffEngine vs CDR Evaluator + +**Inputs:** +- CDR plan: `tests/fixtures/phase0/plan_globird_GLO731031MR@VEC.json` (GLO731031MR@VEC) +- Consumption: `tests/fixtures/phase0/consumption_7d.json` (336 slots, window 2026-05-07T00:00:00+10:00 -> 2026-05-14T00:00:00+10:00) + +**Method:** translate CDR `electricityContract` -> legacy options dict via +`cdr_to_legacy_options()`. Drive legacy engine (6-min sub-sampling per +GAP_PROTECTION cap). Drive new evaluator on per-day slot slices. Compare +per-day totals. + +**Gate (§H §3 / D-P0-6):** ±0.5% per day. New evaluator must reproduce +legacy results within that bound before `tariff_engine.py` (496 lines) is +deleted at end of Phase 1. + +## Per-day comparison + +| Day | Legacy $ | New $ | Diff $ | Diff % | Status | zerohero | +|-----|---------:|------:|-------:|-------:|:------:|----------| +| 2026-05-07 | $15.7445 | $16.0013 | $0.2568 | 1.6310% | FAIL | lost | +| 2026-05-08 | $13.1738 | $13.1742 | $0.0004 | 0.0031% | PASS | earned | +| 2026-05-09 | $2.8542 | $2.8569 | $0.0027 | 0.0941% | PASS | lost | +| 2026-05-10 | $6.1016 | $6.1397 | $0.0381 | 0.6249% | FAIL | earned | +| 2026-05-11 | $5.8583 | $5.8591 | $0.0008 | 0.0135% | PASS | lost | +| 2026-05-12 | $9.8324 | $9.8324 | $0.0000 | 0.0002% | PASS | lost | +| 2026-05-13 | $11.5541 | $11.5541 | $0.0000 | 0.0002% | PASS | lost | +| **TOTAL** | **$65.1189** | **$65.4177** | **$0.2988** | **0.4589%** | **PASS** | — | + +**Per-day passes:** 5/7 (gate: ±0.5%) + +## Translated legacy options (for reproducibility) + +```json +{ + "plan_type": "zerohero_cdr_translated", + "daily_supply_charge": 115.5, + "demand_charge": 0.0, + "import_tariff": { + "type": "tou", + "periods": { + "peak": { + "rate": 39.6, + "windows": [ + [ + "16:00", + "23:00" + ] + ] + }, + "offpeak": { + "rate": 0.00011, + "windows": [ + [ + "11:00", + "14:00" + ] + ] + }, + "shoulder": { + "rate": 27.5, + "windows": [ + [ + "14:00", + "16:00" + ], + [ + "23:00", + "00:00" + ], + [ + "00:00", + "11:00" + ] + ] + } + } + }, + "export_tariff": { + "type": "tou", + "periods": { + "peak": { + "rate": 3.00003, + "windows": [ + [ + "16:00", + "21:00" + ] + ] + }, + "shoulder": { + "rate": 0.29997, + "windows": [ + [ + "21:00", + "00:00" + ], + [ + "00:00", + "10:00" + ], + [ + "14:00", + "16:00" + ] + ] + }, + "offpeak": { + "rate": 0.0, + "windows": [ + [ + "10:00", + "14:00" + ] + ] + } + } + }, + "incentives": [ + "zerohero_credit", + "super_export", + "free_power_window" + ] +} +``` + +## Interpretation + +- If TOTAL gate is PASS: refactor can proceed; new evaluator is parity-equivalent to legacy at the algorithm level. +- If TOTAL is FAIL but per-day diffs are random ±X: likely a numerical-precision quirk; investigate but probably acceptable. +- If a SPECIFIC day fails (e.g. ZEROHERO 'lost' day shows large diff): incentive parser logic divergence between legacy ZeroHeroTracker (instantaneous threshold) and new evaluator parser (avg-over-window threshold). May require switching new parser to instantaneous logic or sub-sample driver for legacy parity. + +_Generated by `scripts/phase_1_parity.py` at 2026-05-14T22:50:44_ \ No newline at end of file diff --git a/tests/fixtures/legacy_engine_outputs/legacy_boost_7d.json b/tests/fixtures/legacy_engine_outputs/legacy_boost_7d.json new file mode 100644 index 0000000..c09e058 --- /dev/null +++ b/tests/fixtures/legacy_engine_outputs/legacy_boost_7d.json @@ -0,0 +1,146 @@ +{ + "per_day_cost_aud": { + "2026-05-07": 12.924217217999965, + "2026-05-08": 15.557220180000002, + "2026-05-09": 2.853289669999997, + "2026-05-10": 9.566561861999999, + "2026-05-11": 4.21628933, + "2026-05-12": 9.136602860000034, + "2026-05-13": 13.535791500000041 + }, + "per_day_import_kwh": { + "2026-05-07": 50.2649, + "2026-05-08": 60.6509, + "2026-05-09": 8.0401, + "2026-05-10": 36.9815, + "2026-05-11": 14.3299, + "2026-05-12": 35.2972, + "2026-05-13": 52.5407 + }, + "per_day_export_kwh": { + "2026-05-07": 0.0, + "2026-05-08": 0.0006, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "per_day_import_cost_c": { + "2026-05-07": 1181.3217, + "2026-05-08": 1444.6238, + "2026-05-09": 174.229, + "2026-05-10": 845.5562, + "2026-05-11": 310.5289, + "2026-05-12": 802.5603, + "2026-05-13": 1242.4792 + }, + "per_day_export_earnings_c": { + "2026-05-07": 0.0, + "2026-05-08": 0.0018, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "per_day_zerohero_status": { + "2026-05-07": "pending", + "2026-05-08": "pending", + "2026-05-09": "pending", + "2026-05-10": "pending", + "2026-05-11": "pending", + "2026-05-12": "pending", + "2026-05-13": "pending" + }, + "per_day_super_export_kwh": { + "2026-05-07": 0.0, + "2026-05-08": 0.0, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "total_aud_period": 67.79, + "final_engine_state": { + "import_kwh_today": 52.540699999999944, + "export_kwh_today": 0.0, + "import_cost_today_c": 1242.4791500000042, + "export_earnings_today_c": 0.0, + "last_update": "2026-05-13T23:54:00", + "last_reset_date": "2026-05-13", + "zerohero": { + "window_import_kwh": 0.0, + "credit_earned": false, + "window_closed": false, + "threshold_exceeded": false + }, + "super_export": { + "window_export_kwh": 0.0 + }, + "demand": { + "peak_kw_billing": 12.906600000000001 + } + }, + "_meta": { + "engine_module": "custom_components.pricehawk.tariff_engine.TariffEngine", + "engine_options_label": "boost", + "consumption_fixture": "consumption_7d.json", + "slot_count": 336, + "captured_at": "2026-05-14T22:42:20", + "purpose": "Phase 1 parity snapshot per design doc \u00a7H \u00a73. New CDR evaluator must reproduce per_day_cost_aud within 0.5% before legacy tariff_engine.py is deleted.", + "options": { + "plan_type": "boost", + "daily_supply_charge": 111.1, + "demand_charge": 0.0, + "import_tariff": { + "type": "flat_stepped", + "step1_threshold_kwh": 25.0, + "step1_rate": 21.67, + "step2_rate": 25.3 + }, + "export_tariff": { + "type": "tou", + "periods": { + "peak": { + "rate": 3.0, + "windows": [ + [ + "16:00", + "21:00" + ] + ] + }, + "shoulder": { + "rate": 0.1, + "windows": [ + [ + "21:00", + "00:00" + ], + [ + "00:00", + "10:00" + ], + [ + "14:00", + "16:00" + ] + ] + }, + "offpeak": { + "rate": 0.0, + "windows": [ + [ + "10:00", + "14:00" + ] + ] + } + } + }, + "incentives": [] + } + } +} \ No newline at end of file diff --git a/tests/fixtures/legacy_engine_outputs/legacy_zerohero_7d.json b/tests/fixtures/legacy_engine_outputs/legacy_zerohero_7d.json new file mode 100644 index 0000000..1e37977 --- /dev/null +++ b/tests/fixtures/legacy_engine_outputs/legacy_zerohero_7d.json @@ -0,0 +1,184 @@ +{ + "per_day_cost_aud": { + "2026-05-07": 15.401662100000017, + "2026-05-08": 12.891127400000018, + "2026-05-09": 2.7972048500000026, + "2026-05-10": 5.960383100000004, + "2026-05-11": 5.715920649999999, + "2026-05-12": 9.634084900000014, + "2026-05-13": 11.304526750000003 + }, + "per_day_import_kwh": { + "2026-05-07": 50.2649, + "2026-05-08": 60.6509, + "2026-05-09": 8.0401, + "2026-05-10": 36.9815, + "2026-05-11": 14.3299, + "2026-05-12": 35.2972, + "2026-05-13": 52.5407 + }, + "per_day_export_kwh": { + "2026-05-07": 0.0, + "2026-05-08": 0.0006, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "per_day_import_cost_c": { + "2026-05-07": 1426.8662, + "2026-05-08": 1275.8145, + "2026-05-09": 166.4205, + "2026-05-10": 582.7383, + "2026-05-11": 458.2921, + "2026-05-12": 850.1085, + "2026-05-13": 1017.1527 + }, + "per_day_export_earnings_c": { + "2026-05-07": 0.0, + "2026-05-08": 0.0018, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "per_day_zerohero_status": { + "2026-05-07": "lost", + "2026-05-08": "earned", + "2026-05-09": "lost", + "2026-05-10": "earned", + "2026-05-11": "lost", + "2026-05-12": "lost", + "2026-05-13": "lost" + }, + "per_day_super_export_kwh": { + "2026-05-07": 0.0, + "2026-05-08": 0.0, + "2026-05-09": 0.0, + "2026-05-10": 0.0, + "2026-05-11": 0.0, + "2026-05-12": 0.0, + "2026-05-13": 0.0 + }, + "total_aud_period": 63.7049, + "final_engine_state": { + "import_kwh_today": 52.540699999999944, + "export_kwh_today": 0.0, + "import_cost_today_c": 1017.1526750000002, + "export_earnings_today_c": 0.0, + "last_update": "2026-05-13T23:54:00", + "last_reset_date": "2026-05-13", + "zerohero": { + "window_import_kwh": 1.2199, + "credit_earned": false, + "window_closed": true, + "threshold_exceeded": true + }, + "super_export": { + "window_export_kwh": 0.0 + }, + "demand": { + "peak_kw_billing": 12.906600000000001 + } + }, + "_meta": { + "engine_module": "custom_components.pricehawk.tariff_engine.TariffEngine", + "engine_options_label": "zerohero", + "consumption_fixture": "consumption_7d.json", + "slot_count": 336, + "captured_at": "2026-05-14T22:42:20", + "purpose": "Phase 1 parity snapshot per design doc \u00a7H \u00a73. New CDR evaluator must reproduce per_day_cost_aud within 0.5% before legacy tariff_engine.py is deleted.", + "options": { + "plan_type": "zerohero", + "daily_supply_charge": 113.3, + "demand_charge": 0.0, + "import_tariff": { + "type": "tou", + "periods": { + "peak": { + "rate": 38.5, + "windows": [ + [ + "16:00", + "23:00" + ] + ] + }, + "shoulder": { + "rate": 26.95, + "windows": [ + [ + "23:00", + "00:00" + ], + [ + "00:00", + "11:00" + ], + [ + "14:00", + "16:00" + ] + ] + }, + "offpeak": { + "rate": 0.0, + "windows": [ + [ + "11:00", + "14:00" + ] + ] + } + } + }, + "export_tariff": { + "type": "tou", + "periods": { + "peak": { + "rate": 3.0, + "windows": [ + [ + "16:00", + "21:00" + ] + ] + }, + "shoulder": { + "rate": 0.3, + "windows": [ + [ + "21:00", + "00:00" + ], + [ + "00:00", + "10:00" + ], + [ + "14:00", + "16:00" + ] + ] + }, + "offpeak": { + "rate": 0.0, + "windows": [ + [ + "10:00", + "14:00" + ] + ] + } + } + }, + "incentives": [ + "zerohero_credit", + "super_export", + "free_power_window" + ] + } + } +} \ No newline at end of file diff --git a/tests/fixtures/phase0/GATE_RESULTS.md b/tests/fixtures/phase0/GATE_RESULTS.md new file mode 100644 index 0000000..06f7ebe --- /dev/null +++ b/tests/fixtures/phase0/GATE_RESULTS.md @@ -0,0 +1,113 @@ +# Phase 0 Gate Results — Cross-Check Report + +**Purpose:** Independent verification that the Phase 0 evaluator prototype +(`scripts/cdr_evaluator_proto.py`) reproduces a separate bucket-aggregation +pass over the same fixtures. The two code paths share no logic except input +parsing. If they agree, the evaluator's structural logic is internally +consistent. + +**This does NOT replace human hand-calc** — that remains the canonical +ground-truth per locked decision D-P0-2 / design doc §F. Use this report +to drive what to hand-check first: focus on buckets with the largest kWh +contribution, validate the rate × kWh math against the plan PDF, sum the +buckets, apply × 1.10 for GST, compare to the evaluator total. + +All dollar values shown GST-inclusive unless suffixed `_ex`. + +## Summary + +| Plan | Description | Days | Slots | Evaluator $ | Independent $ | Diff $ | Diff % | +|------|-------------|-----:|------:|------------:|--------------:|-------:|-------:| +| A | AGL Residential Smart Saver (SINGLE_RATE NSW) | 7 | 336 | $89.40 | $89.40 | $0.0000 | 0.0000% | +| B | Red Taronga Flex (TIME_OF_USE NSW Ausgrid) | 7 | 336 | $86.67 | $86.67 | $0.0000 | 0.0000% | +| C1 | Synthetic FLEXIBLE (stepped 24.6c -> 30.1c at 15 kWh/day) | 7 | 336 | $88.71 | $88.71 | $0.0000 | 0.0000% | +| C2 | GloBird ZEROHERO United Energy (FLEXIBLE + parser) | 7 | 336 | $65.42 | $65.42 | $0.0000 | 0.0000% | +| D | Red Taronga Flex × DST backward 2026-04-05 (25h day) | 1 | 50 | $6.86 | $6.86 | $0.0000 | 0.0000% | +| E | Red Taronga Flex × DST forward 2026-10-04 (23h day) | 1 | 46 | $6.48 | $6.48 | $0.0000 | 0.0000% | + +## Per-plan bucket breakdown (ex-GST) + +Each bucket = sum of half-hour kWh that fell into one TOU window slot × the applicable rate. +Useful for hand-spreadsheet replication: each row in your spreadsheet should match a row here. + +### Plan A — AGL Residential Smart Saver (SINGLE_RATE NSW) +- plan_id: `AGL907738MRE6@EME` +- supply ex-GST: $5.5500 (7 days × daily supply) +- FIT credit ex-GST: $-0.0062 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $0.0000 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| first 3900 kWh/period @ 0.2922/kWh | 259.192 | $75.7360 | + +### Plan B — Red Taronga Flex (TIME_OF_USE NSW Ausgrid) +- plan_id: `RED552831MRE15@EME` +- supply ex-GST: $6.4200 (7 days × daily supply) +- FIT credit ex-GST: $-0.0128 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $0.0000 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| OFF_PEAK flat 0.2198/kWh | 116.208 | $25.5424 | +| PEAK flat 0.4385/kWh | 32.099 | $14.0755 | +| SHOULDER flat 0.2955/kWh | 110.886 | $32.7667 | + +### Plan C1 — Synthetic FLEXIBLE (stepped 24.6c -> 30.1c at 15 kWh/day) +- plan_id: `PHASE0-C1-FLEXIBLE-SYNTHETIC` +- supply ex-GST: $8.4000 (7 days × daily supply) +- FIT credit ex-GST: $0.0000 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $0.0000 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| PEAK <15.0 kWh/day @ 0.246/kWh | 104.918 | $25.8097 | +| PEAK flat 0.301/kWh | 154.275 | $46.4367 | + +### Plan C2 — GloBird ZEROHERO United Energy (FLEXIBLE + parser) +- plan_id: `GLO731031MR@VEC` +- supply ex-GST: $7.3500 (7 days × daily supply) +- FIT credit ex-GST: $-0.0006 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $-2.0005 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| OFF_PEAK flat 0.000001/kWh | 54.760 | $0.0001 | +| PEAK flat 0.36/kWh | 25.743 | $9.2675 | +| SHOULDER flat 0.25/kWh | 178.689 | $44.6722 | + +### Plan D — Red Taronga Flex × DST backward 2026-04-05 (25h day) +- plan_id: `RED552831MRE15@EME` +- supply ex-GST: $0.9200 (1 days × daily supply) +- FIT credit ex-GST: $-2.1690 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $0.0000 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| OFF_PEAK flat 0.2198/kWh | 8.000 | $1.7584 | +| SHOULDER flat 0.2955/kWh | 19.400 | $5.7327 | + +### Plan E — Red Taronga Flex × DST forward 2026-10-04 (23h day) +- plan_id: `RED552831MRE15@EME` +- supply ex-GST: $0.9200 (1 days × daily supply) +- FIT credit ex-GST: $-2.1690 (negative = credit toward bill) +- Incentive credit ex-GST (parser output): $0.0000 + +| Bucket | kWh | Cost ex-GST | +|--------|----:|------------:| +| OFF_PEAK flat 0.2198/kWh | 6.400 | $1.4067 | +| SHOULDER flat 0.2955/kWh | 19.400 | $5.7327 | + +## Hand-calc gate criteria + +Per `scripts/PHASE_0_GROUND_TRUTH.md` §6: +- Plans A / B / C1 / C2: within ±5% of hand-calc total_aud_inc_gst. +- Plans D / E: within ±$0.05 absolute (24h windows). +- C2 (GloBird ZEROHERO) is load-bearing — fail = Approach A fallback. + +## How to read this report + +1. For each plan, sum (Bucket cost_ex_gst) + supply_ex + fit_credit_ex + incentive_credit_inc. +2. Multiply the sum by 1.10 for GST. +3. The result should equal `Independent $` to 2 d.p. +4. `Diff $` between Evaluator and Independent should be ~$0.00 — the two are computing the same thing two ways. Non-zero diff indicates a bug in one path. +5. For the canonical Phase 0 gate, replace this report's bucket totals with your hand-calc spreadsheet values and re-check the per-plan diff. \ No newline at end of file diff --git a/tests/fixtures/phase0/consumption_7d.json b/tests/fixtures/phase0/consumption_7d.json new file mode 100644 index 0000000..f2da8ca --- /dev/null +++ b/tests/fixtures/phase0/consumption_7d.json @@ -0,0 +1,2707 @@ +{ + "_phase0_meta": { + "label": "Plans A/B/C1/C2 7-day shared consumption", + "window_local": "2026-05-07T00:00:00+10:00 -> 2026-05-14T00:00:00+10:00", + "window_tz": "Australia/Sydney (AEST)", + "slot_minutes": 30, + "slots_count": 336, + "total_grid_import_kwh": 259.192, + "total_grid_export_kwh": 0.154, + "total_solar_kwh": 68.063, + "source_entity_grid_import": "sensor.power_sync_lifetime_grid_import", + "source_entity_grid_export": "sensor.power_sync_lifetime_grid_export", + "source_entity_solar": "sensor.power_sync_lifetime_solar_energy", + "source_method": "HA recorder /api/history/period, linear interpolation between recorded state changes, slot kWh = state_end - state_start", + "fetched_at": "2026-05-14T12:07:04+00:00" + }, + "slots": [ + { + "ts_utc": "2026-05-06T14:00:00+00:00", + "ts_local": "2026-05-07T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 4.669, + "grid_export_kwh": 0.0, + "solar_kwh": 0.001 + }, + { + "ts_utc": "2026-05-06T14:30:00+00:00", + "ts_local": "2026-05-07T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 1.2653, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-06T15:00:00+00:00", + "ts_local": "2026-05-07T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 1.4173, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-06T15:30:00+00:00", + "ts_local": "2026-05-07T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 1.425, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-06T16:00:00+00:00", + "ts_local": "2026-05-07T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 1.3303, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-06T16:30:00+00:00", + "ts_local": "2026-05-07T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 1.3246, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-06T17:00:00+00:00", + "ts_local": "2026-05-07T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 1.6822, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T17:30:00+00:00", + "ts_local": "2026-05-07T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 1.7899, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T18:00:00+00:00", + "ts_local": "2026-05-07T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 1.7899, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T18:30:00+00:00", + "ts_local": "2026-05-07T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 1.7899, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T19:00:00+00:00", + "ts_local": "2026-05-07T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 2.4985, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T19:30:00+00:00", + "ts_local": "2026-05-07T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 2.7313, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T20:00:00+00:00", + "ts_local": "2026-05-07T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 1.6193, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T20:30:00+00:00", + "ts_local": "2026-05-07T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 1.2486, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-06T21:00:00+00:00", + "ts_local": "2026-05-07T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.5753, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0156 + }, + { + "ts_utc": "2026-05-06T21:30:00+00:00", + "ts_local": "2026-05-07T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.5542, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0161 + }, + { + "ts_utc": "2026-05-06T22:00:00+00:00", + "ts_local": "2026-05-07T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.9018, + "grid_export_kwh": 0.0, + "solar_kwh": 0.171 + }, + { + "ts_utc": "2026-05-06T22:30:00+00:00", + "ts_local": "2026-05-07T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 1.0301, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2282 + }, + { + "ts_utc": "2026-05-06T23:00:00+00:00", + "ts_local": "2026-05-07T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 1.5041, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5014 + }, + { + "ts_utc": "2026-05-06T23:30:00+00:00", + "ts_local": "2026-05-07T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 1.6813, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6035 + }, + { + "ts_utc": "2026-05-07T00:00:00+00:00", + "ts_local": "2026-05-07T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 1.3646, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5962 + }, + { + "ts_utc": "2026-05-07T00:30:00+00:00", + "ts_local": "2026-05-07T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 1.0588, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6258 + }, + { + "ts_utc": "2026-05-07T01:00:00+00:00", + "ts_local": "2026-05-07T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 0.2659, + "grid_export_kwh": 0.0, + "solar_kwh": 0.7638 + }, + { + "ts_utc": "2026-05-07T01:30:00+00:00", + "ts_local": "2026-05-07T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 0.244, + "grid_export_kwh": 0.0, + "solar_kwh": 0.7725 + }, + { + "ts_utc": "2026-05-07T02:00:00+00:00", + "ts_local": "2026-05-07T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 0.1523, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8092 + }, + { + "ts_utc": "2026-05-07T02:30:00+00:00", + "ts_local": "2026-05-07T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 0.1523, + "grid_export_kwh": 0.0, + "solar_kwh": 0.7448 + }, + { + "ts_utc": "2026-05-07T03:00:00+00:00", + "ts_local": "2026-05-07T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 0.1523, + "grid_export_kwh": 0.0, + "solar_kwh": 0.4603 + }, + { + "ts_utc": "2026-05-07T03:30:00+00:00", + "ts_local": "2026-05-07T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 0.3951, + "grid_export_kwh": 0.0, + "solar_kwh": 0.4586 + }, + { + "ts_utc": "2026-05-07T04:00:00+00:00", + "ts_local": "2026-05-07T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 1.4872, + "grid_export_kwh": 0.0, + "solar_kwh": 0.4509 + }, + { + "ts_utc": "2026-05-07T04:30:00+00:00", + "ts_local": "2026-05-07T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 1.3219, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3909 + }, + { + "ts_utc": "2026-05-07T05:00:00+00:00", + "ts_local": "2026-05-07T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 0.5412, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1076 + }, + { + "ts_utc": "2026-05-07T05:30:00+00:00", + "ts_local": "2026-05-07T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.4582, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0949 + }, + { + "ts_utc": "2026-05-07T06:00:00+00:00", + "ts_local": "2026-05-07T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.0473, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0318 + }, + { + "ts_utc": "2026-05-07T06:30:00+00:00", + "ts_local": "2026-05-07T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.1725, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0279 + }, + { + "ts_utc": "2026-05-07T07:00:00+00:00", + "ts_local": "2026-05-07T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.8414, + "grid_export_kwh": 0.0, + "solar_kwh": 0.007 + }, + { + "ts_utc": "2026-05-07T07:30:00+00:00", + "ts_local": "2026-05-07T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.7849, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0059 + }, + { + "ts_utc": "2026-05-07T08:00:00+00:00", + "ts_local": "2026-05-07T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.4655, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T08:30:00+00:00", + "ts_local": "2026-05-07T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.4119, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T09:00:00+00:00", + "ts_local": "2026-05-07T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.0871, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T09:30:00+00:00", + "ts_local": "2026-05-07T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.0762, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T10:00:00+00:00", + "ts_local": "2026-05-07T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.0052, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T10:30:00+00:00", + "ts_local": "2026-05-07T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.0052, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T11:00:00+00:00", + "ts_local": "2026-05-07T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.0052, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T11:30:00+00:00", + "ts_local": "2026-05-07T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.0052, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T12:00:00+00:00", + "ts_local": "2026-05-07T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 3.0908, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T12:30:00+00:00", + "ts_local": "2026-05-07T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 3.4328, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T13:00:00+00:00", + "ts_local": "2026-05-07T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.6875, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T13:30:00+00:00", + "ts_local": "2026-05-07T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.6583, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T14:00:00+00:00", + "ts_local": "2026-05-08T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T14:30:00+00:00", + "ts_local": "2026-05-08T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 2.7984, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-07T15:00:00+00:00", + "ts_local": "2026-05-08T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 5.1846, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T15:30:00+00:00", + "ts_local": "2026-05-08T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 5.1799, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T16:00:00+00:00", + "ts_local": "2026-05-08T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 4.9899, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T16:30:00+00:00", + "ts_local": "2026-05-08T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 4.9461, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T17:00:00+00:00", + "ts_local": "2026-05-08T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 2.7525, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T17:30:00+00:00", + "ts_local": "2026-05-08T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 2.7535, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T18:00:00+00:00", + "ts_local": "2026-05-08T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 2.811, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T18:30:00+00:00", + "ts_local": "2026-05-08T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 2.7977, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T19:00:00+00:00", + "ts_local": "2026-05-08T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 1.5576, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T19:30:00+00:00", + "ts_local": "2026-05-08T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 1.5559, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-07T20:00:00+00:00", + "ts_local": "2026-05-08T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.0349, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0202 + }, + { + "ts_utc": "2026-05-07T20:30:00+00:00", + "ts_local": "2026-05-08T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.0349, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0202 + }, + { + "ts_utc": "2026-05-07T21:00:00+00:00", + "ts_local": "2026-05-08T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.324, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0202 + }, + { + "ts_utc": "2026-05-07T21:30:00+00:00", + "ts_local": "2026-05-08T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.2192, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0202 + }, + { + "ts_utc": "2026-05-07T22:00:00+00:00", + "ts_local": "2026-05-08T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.2517, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1458 + }, + { + "ts_utc": "2026-05-07T22:30:00+00:00", + "ts_local": "2026-05-08T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.2661, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2012 + }, + { + "ts_utc": "2026-05-07T23:00:00+00:00", + "ts_local": "2026-05-08T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 1.0722, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2992 + }, + { + "ts_utc": "2026-05-07T23:30:00+00:00", + "ts_local": "2026-05-08T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 1.4326, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3431 + }, + { + "ts_utc": "2026-05-08T00:00:00+00:00", + "ts_local": "2026-05-08T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 1.7293, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5127 + }, + { + "ts_utc": "2026-05-08T00:30:00+00:00", + "ts_local": "2026-05-08T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 1.8653, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5905 + }, + { + "ts_utc": "2026-05-08T01:00:00+00:00", + "ts_local": "2026-05-08T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 2.2619, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6056 + }, + { + "ts_utc": "2026-05-08T01:30:00+00:00", + "ts_local": "2026-05-08T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 2.4466, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6126 + }, + { + "ts_utc": "2026-05-08T02:00:00+00:00", + "ts_local": "2026-05-08T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 2.1242, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8251 + }, + { + "ts_utc": "2026-05-08T02:30:00+00:00", + "ts_local": "2026-05-08T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 1.9709, + "grid_export_kwh": 0.0, + "solar_kwh": 0.926 + }, + { + "ts_utc": "2026-05-08T03:00:00+00:00", + "ts_local": "2026-05-08T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 2.2145, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6437 + }, + { + "ts_utc": "2026-05-08T03:30:00+00:00", + "ts_local": "2026-05-08T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 2.3314, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5082 + }, + { + "ts_utc": "2026-05-08T04:00:00+00:00", + "ts_local": "2026-05-08T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 1.3834, + "grid_export_kwh": 0.0, + "solar_kwh": 0.387 + }, + { + "ts_utc": "2026-05-08T04:30:00+00:00", + "ts_local": "2026-05-08T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 0.9009, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3254 + }, + { + "ts_utc": "2026-05-08T05:00:00+00:00", + "ts_local": "2026-05-08T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 0.3345, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3318 + }, + { + "ts_utc": "2026-05-08T05:30:00+00:00", + "ts_local": "2026-05-08T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.0329, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3352 + }, + { + "ts_utc": "2026-05-08T06:00:00+00:00", + "ts_local": "2026-05-08T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.0358, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1655 + }, + { + "ts_utc": "2026-05-08T06:30:00+00:00", + "ts_local": "2026-05-08T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.0374, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0732 + }, + { + "ts_utc": "2026-05-08T07:00:00+00:00", + "ts_local": "2026-05-08T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.0134, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0266 + }, + { + "ts_utc": "2026-05-08T07:30:00+00:00", + "ts_local": "2026-05-08T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0005 + }, + { + "ts_utc": "2026-05-08T08:00:00+00:00", + "ts_local": "2026-05-08T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-08T08:30:00+00:00", + "ts_local": "2026-05-08T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-08T09:00:00+00:00", + "ts_local": "2026-05-08T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-08T09:30:00+00:00", + "ts_local": "2026-05-08T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-08T10:00:00+00:00", + "ts_local": "2026-05-08T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-08T10:30:00+00:00", + "ts_local": "2026-05-08T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0006, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-08T11:00:00+00:00", + "ts_local": "2026-05-08T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.0008, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.0049 + }, + { + "ts_utc": "2026-05-08T11:30:00+00:00", + "ts_local": "2026-05-08T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.0012, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T12:00:00+00:00", + "ts_local": "2026-05-08T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 0.0012, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T12:30:00+00:00", + "ts_local": "2026-05-08T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 0.0012, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T13:00:00+00:00", + "ts_local": "2026-05-08T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.0012, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T13:30:00+00:00", + "ts_local": "2026-05-08T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.0012, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T14:00:00+00:00", + "ts_local": "2026-05-09T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.0015, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T14:30:00+00:00", + "ts_local": "2026-05-09T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T15:00:00+00:00", + "ts_local": "2026-05-09T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T15:30:00+00:00", + "ts_local": "2026-05-09T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T16:00:00+00:00", + "ts_local": "2026-05-09T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T16:30:00+00:00", + "ts_local": "2026-05-09T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T17:00:00+00:00", + "ts_local": "2026-05-09T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T17:30:00+00:00", + "ts_local": "2026-05-09T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 0.0017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T18:00:00+00:00", + "ts_local": "2026-05-09T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 0.0033, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T18:30:00+00:00", + "ts_local": "2026-05-09T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T19:00:00+00:00", + "ts_local": "2026-05-09T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T19:30:00+00:00", + "ts_local": "2026-05-09T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T20:00:00+00:00", + "ts_local": "2026-05-09T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T20:30:00+00:00", + "ts_local": "2026-05-09T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T21:00:00+00:00", + "ts_local": "2026-05-09T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T21:30:00+00:00", + "ts_local": "2026-05-09T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.0035, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0069 + }, + { + "ts_utc": "2026-05-08T22:00:00+00:00", + "ts_local": "2026-05-09T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.5774, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.4353 + }, + { + "ts_utc": "2026-05-08T22:30:00+00:00", + "ts_local": "2026-05-09T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.1808, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6263 + }, + { + "ts_utc": "2026-05-08T23:00:00+00:00", + "ts_local": "2026-05-09T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 0.1693, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6239 + }, + { + "ts_utc": "2026-05-08T23:30:00+00:00", + "ts_local": "2026-05-09T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 0.0538, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6 + }, + { + "ts_utc": "2026-05-09T00:00:00+00:00", + "ts_local": "2026-05-09T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 0.0777, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6147 + }, + { + "ts_utc": "2026-05-09T00:30:00+00:00", + "ts_local": "2026-05-09T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 0.3373, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.7752 + }, + { + "ts_utc": "2026-05-09T01:00:00+00:00", + "ts_local": "2026-05-09T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 0.3165, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.7635 + }, + { + "ts_utc": "2026-05-09T01:30:00+00:00", + "ts_local": "2026-05-09T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 0.0669, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6236 + }, + { + "ts_utc": "2026-05-09T02:00:00+00:00", + "ts_local": "2026-05-09T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 0.0669, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6236 + }, + { + "ts_utc": "2026-05-09T02:30:00+00:00", + "ts_local": "2026-05-09T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 0.0669, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6236 + }, + { + "ts_utc": "2026-05-09T03:00:00+00:00", + "ts_local": "2026-05-09T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 0.1412, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6222 + }, + { + "ts_utc": "2026-05-09T03:30:00+00:00", + "ts_local": "2026-05-09T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 1.3493, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.5994 + }, + { + "ts_utc": "2026-05-09T04:00:00+00:00", + "ts_local": "2026-05-09T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 1.3552, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.5917 + }, + { + "ts_utc": "2026-05-09T04:30:00+00:00", + "ts_local": "2026-05-09T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 1.4647, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.4491 + }, + { + "ts_utc": "2026-05-09T05:00:00+00:00", + "ts_local": "2026-05-09T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 1.3929, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.4322 + }, + { + "ts_utc": "2026-05-09T05:30:00+00:00", + "ts_local": "2026-05-09T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.003, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.1051 + }, + { + "ts_utc": "2026-05-09T06:00:00+00:00", + "ts_local": "2026-05-09T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.0039, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.1011 + }, + { + "ts_utc": "2026-05-09T06:30:00+00:00", + "ts_local": "2026-05-09T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0094 + }, + { + "ts_utc": "2026-05-09T07:00:00+00:00", + "ts_local": "2026-05-09T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0093 + }, + { + "ts_utc": "2026-05-09T07:30:00+00:00", + "ts_local": "2026-05-09T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T08:00:00+00:00", + "ts_local": "2026-05-09T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T08:30:00+00:00", + "ts_local": "2026-05-09T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T09:00:00+00:00", + "ts_local": "2026-05-09T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T09:30:00+00:00", + "ts_local": "2026-05-09T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T10:00:00+00:00", + "ts_local": "2026-05-09T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T10:30:00+00:00", + "ts_local": "2026-05-09T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T11:00:00+00:00", + "ts_local": "2026-05-09T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.001, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T11:30:00+00:00", + "ts_local": "2026-05-09T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T12:00:00+00:00", + "ts_local": "2026-05-09T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T12:30:00+00:00", + "ts_local": "2026-05-09T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0003, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T13:00:00+00:00", + "ts_local": "2026-05-09T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0003, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T13:30:00+00:00", + "ts_local": "2026-05-09T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0003, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T14:00:00+00:00", + "ts_local": "2026-05-10T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0057, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T14:30:00+00:00", + "ts_local": "2026-05-10T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T15:00:00+00:00", + "ts_local": "2026-05-10T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T15:30:00+00:00", + "ts_local": "2026-05-10T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T16:00:00+00:00", + "ts_local": "2026-05-10T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T16:30:00+00:00", + "ts_local": "2026-05-10T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T17:00:00+00:00", + "ts_local": "2026-05-10T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T17:30:00+00:00", + "ts_local": "2026-05-10T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T18:00:00+00:00", + "ts_local": "2026-05-10T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T18:30:00+00:00", + "ts_local": "2026-05-10T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T19:00:00+00:00", + "ts_local": "2026-05-10T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T19:30:00+00:00", + "ts_local": "2026-05-10T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T20:00:00+00:00", + "ts_local": "2026-05-10T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T20:30:00+00:00", + "ts_local": "2026-05-10T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T21:00:00+00:00", + "ts_local": "2026-05-10T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T21:30:00+00:00", + "ts_local": "2026-05-10T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T22:00:00+00:00", + "ts_local": "2026-05-10T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.0256, + "grid_export_kwh": 0.0075, + "solar_kwh": 0.005 + }, + { + "ts_utc": "2026-05-09T22:30:00+00:00", + "ts_local": "2026-05-10T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.1945, + "grid_export_kwh": 0.0037, + "solar_kwh": 0.1327 + }, + { + "ts_utc": "2026-05-09T23:00:00+00:00", + "ts_local": "2026-05-10T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 0.3521, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.2517 + }, + { + "ts_utc": "2026-05-09T23:30:00+00:00", + "ts_local": "2026-05-10T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 1.5764, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.3237 + }, + { + "ts_utc": "2026-05-10T00:00:00+00:00", + "ts_local": "2026-05-10T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 2.7289, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.3916 + }, + { + "ts_utc": "2026-05-10T00:30:00+00:00", + "ts_local": "2026-05-10T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 2.9741, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.4644 + }, + { + "ts_utc": "2026-05-10T01:00:00+00:00", + "ts_local": "2026-05-10T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 3.2118, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.535 + }, + { + "ts_utc": "2026-05-10T01:30:00+00:00", + "ts_local": "2026-05-10T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 2.4817, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.545 + }, + { + "ts_utc": "2026-05-10T02:00:00+00:00", + "ts_local": "2026-05-10T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 1.752, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.5549 + }, + { + "ts_utc": "2026-05-10T02:30:00+00:00", + "ts_local": "2026-05-10T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 1.853, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6263 + }, + { + "ts_utc": "2026-05-10T03:00:00+00:00", + "ts_local": "2026-05-10T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 1.9569, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.6998 + }, + { + "ts_utc": "2026-05-10T03:30:00+00:00", + "ts_local": "2026-05-10T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 4.1374, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.5864 + }, + { + "ts_utc": "2026-05-10T04:00:00+00:00", + "ts_local": "2026-05-10T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 6.4534, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.466 + }, + { + "ts_utc": "2026-05-10T04:30:00+00:00", + "ts_local": "2026-05-10T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 4.9267, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.3945 + }, + { + "ts_utc": "2026-05-10T05:00:00+00:00", + "ts_local": "2026-05-10T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 1.9792, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.2966 + }, + { + "ts_utc": "2026-05-10T05:30:00+00:00", + "ts_local": "2026-05-10T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.017, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.2648 + }, + { + "ts_utc": "2026-05-10T06:00:00+00:00", + "ts_local": "2026-05-10T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.016, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.2065 + }, + { + "ts_utc": "2026-05-10T06:30:00+00:00", + "ts_local": "2026-05-10T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.0145, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.1201 + }, + { + "ts_utc": "2026-05-10T07:00:00+00:00", + "ts_local": "2026-05-10T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.0137, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0725 + }, + { + "ts_utc": "2026-05-10T07:30:00+00:00", + "ts_local": "2026-05-10T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.0125, + "grid_export_kwh": 0.0001, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T08:00:00+00:00", + "ts_local": "2026-05-10T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.0085, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T08:30:00+00:00", + "ts_local": "2026-05-10T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T09:00:00+00:00", + "ts_local": "2026-05-10T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0007, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T09:30:00+00:00", + "ts_local": "2026-05-10T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.001, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T10:00:00+00:00", + "ts_local": "2026-05-10T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0012, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T10:30:00+00:00", + "ts_local": "2026-05-10T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0008, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T11:00:00+00:00", + "ts_local": "2026-05-10T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T11:30:00+00:00", + "ts_local": "2026-05-10T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0007, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T12:00:00+00:00", + "ts_local": "2026-05-10T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0007, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T12:30:00+00:00", + "ts_local": "2026-05-10T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0007, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T13:00:00+00:00", + "ts_local": "2026-05-10T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0006, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T13:30:00+00:00", + "ts_local": "2026-05-10T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T14:00:00+00:00", + "ts_local": "2026-05-11T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T14:30:00+00:00", + "ts_local": "2026-05-11T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T15:00:00+00:00", + "ts_local": "2026-05-11T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T15:30:00+00:00", + "ts_local": "2026-05-11T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T16:00:00+00:00", + "ts_local": "2026-05-11T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T16:30:00+00:00", + "ts_local": "2026-05-11T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T17:00:00+00:00", + "ts_local": "2026-05-11T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T17:30:00+00:00", + "ts_local": "2026-05-11T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T18:00:00+00:00", + "ts_local": "2026-05-11T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T18:30:00+00:00", + "ts_local": "2026-05-11T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T19:00:00+00:00", + "ts_local": "2026-05-11T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T19:30:00+00:00", + "ts_local": "2026-05-11T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T20:00:00+00:00", + "ts_local": "2026-05-11T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T20:30:00+00:00", + "ts_local": "2026-05-11T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-10T21:00:00+00:00", + "ts_local": "2026-05-11T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0094 + }, + { + "ts_utc": "2026-05-10T21:30:00+00:00", + "ts_local": "2026-05-11T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.0023, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0109 + }, + { + "ts_utc": "2026-05-10T22:00:00+00:00", + "ts_local": "2026-05-11T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.0003, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0884 + }, + { + "ts_utc": "2026-05-10T22:30:00+00:00", + "ts_local": "2026-05-11T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1021 + }, + { + "ts_utc": "2026-05-10T23:00:00+00:00", + "ts_local": "2026-05-11T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 0.0131, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2069 + }, + { + "ts_utc": "2026-05-10T23:30:00+00:00", + "ts_local": "2026-05-11T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.7423 + }, + { + "ts_utc": "2026-05-11T00:00:00+00:00", + "ts_local": "2026-05-11T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.8916 + }, + { + "ts_utc": "2026-05-11T00:30:00+00:00", + "ts_local": "2026-05-11T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0002, + "solar_kwh": 0.9097 + }, + { + "ts_utc": "2026-05-11T01:00:00+00:00", + "ts_local": "2026-05-11T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0002, + "solar_kwh": 1.0478 + }, + { + "ts_utc": "2026-05-11T01:30:00+00:00", + "ts_local": "2026-05-11T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0002, + "solar_kwh": 1.0308 + }, + { + "ts_utc": "2026-05-11T02:00:00+00:00", + "ts_local": "2026-05-11T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0005, + "solar_kwh": 0.8905 + }, + { + "ts_utc": "2026-05-11T02:30:00+00:00", + "ts_local": "2026-05-11T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0004, + "solar_kwh": 0.9228 + }, + { + "ts_utc": "2026-05-11T03:00:00+00:00", + "ts_local": "2026-05-11T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 0.0817, + "grid_export_kwh": 0.0, + "solar_kwh": 1.2141 + }, + { + "ts_utc": "2026-05-11T03:30:00+00:00", + "ts_local": "2026-05-11T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 0.58, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9923 + }, + { + "ts_utc": "2026-05-11T04:00:00+00:00", + "ts_local": "2026-05-11T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 0.9432, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8306 + }, + { + "ts_utc": "2026-05-11T04:30:00+00:00", + "ts_local": "2026-05-11T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 1.0837, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6608 + }, + { + "ts_utc": "2026-05-11T05:00:00+00:00", + "ts_local": "2026-05-11T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 1.1854, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5379 + }, + { + "ts_utc": "2026-05-11T05:30:00+00:00", + "ts_local": "2026-05-11T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.7351, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3593 + }, + { + "ts_utc": "2026-05-11T06:00:00+00:00", + "ts_local": "2026-05-11T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.4007, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2268 + }, + { + "ts_utc": "2026-05-11T06:30:00+00:00", + "ts_local": "2026-05-11T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.3711, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1037 + }, + { + "ts_utc": "2026-05-11T07:00:00+00:00", + "ts_local": "2026-05-11T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.3484, + "grid_export_kwh": 0.0, + "solar_kwh": 0.01 + }, + { + "ts_utc": "2026-05-11T07:30:00+00:00", + "ts_local": "2026-05-11T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.2779, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0044 + }, + { + "ts_utc": "2026-05-11T08:00:00+00:00", + "ts_local": "2026-05-11T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.2222, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T08:30:00+00:00", + "ts_local": "2026-05-11T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.2131, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T09:00:00+00:00", + "ts_local": "2026-05-11T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.2056, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T09:30:00+00:00", + "ts_local": "2026-05-11T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.2738, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T10:00:00+00:00", + "ts_local": "2026-05-11T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.3312, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T10:30:00+00:00", + "ts_local": "2026-05-11T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.7185, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T11:00:00+00:00", + "ts_local": "2026-05-11T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 1.0242, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T11:30:00+00:00", + "ts_local": "2026-05-11T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.8778, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T12:00:00+00:00", + "ts_local": "2026-05-11T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 1.059, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T12:30:00+00:00", + "ts_local": "2026-05-11T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 2.2225, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T13:00:00+00:00", + "ts_local": "2026-05-11T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.535, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T13:30:00+00:00", + "ts_local": "2026-05-11T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.022, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T14:00:00+00:00", + "ts_local": "2026-05-12T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.7145, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T14:30:00+00:00", + "ts_local": "2026-05-12T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 0.9237, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T15:00:00+00:00", + "ts_local": "2026-05-12T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 1.294, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T15:30:00+00:00", + "ts_local": "2026-05-12T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 1.4125, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T16:00:00+00:00", + "ts_local": "2026-05-12T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 2.3427, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T16:30:00+00:00", + "ts_local": "2026-05-12T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 2.6543, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T17:00:00+00:00", + "ts_local": "2026-05-12T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 3.6424, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T17:30:00+00:00", + "ts_local": "2026-05-12T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 3.9857, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T18:00:00+00:00", + "ts_local": "2026-05-12T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 2.098, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T18:30:00+00:00", + "ts_local": "2026-05-12T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 1.4148, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T19:00:00+00:00", + "ts_local": "2026-05-12T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 1.2785, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T19:30:00+00:00", + "ts_local": "2026-05-12T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 1.2275, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T20:00:00+00:00", + "ts_local": "2026-05-12T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.8409, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T20:30:00+00:00", + "ts_local": "2026-05-12T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.6895, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-11T21:00:00+00:00", + "ts_local": "2026-05-12T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.501, + "grid_export_kwh": 0.0, + "solar_kwh": 0.001 + }, + { + "ts_utc": "2026-05-11T21:30:00+00:00", + "ts_local": "2026-05-12T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.11, + "grid_export_kwh": 0.0, + "solar_kwh": 0.127 + }, + { + "ts_utc": "2026-05-11T22:00:00+00:00", + "ts_local": "2026-05-12T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.0447, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2246 + }, + { + "ts_utc": "2026-05-11T22:30:00+00:00", + "ts_local": "2026-05-12T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.0447, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2246 + }, + { + "ts_utc": "2026-05-11T23:00:00+00:00", + "ts_local": "2026-05-12T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 0.0447, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2246 + }, + { + "ts_utc": "2026-05-11T23:30:00+00:00", + "ts_local": "2026-05-12T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 0.1113, + "grid_export_kwh": 0.0, + "solar_kwh": 0.4866 + }, + { + "ts_utc": "2026-05-12T00:00:00+00:00", + "ts_local": "2026-05-12T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 0.1647, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6968 + }, + { + "ts_utc": "2026-05-12T00:30:00+00:00", + "ts_local": "2026-05-12T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 0.1647, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9163 + }, + { + "ts_utc": "2026-05-12T01:00:00+00:00", + "ts_local": "2026-05-12T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 0.1647, + "grid_export_kwh": 0.0, + "solar_kwh": 1.077 + }, + { + "ts_utc": "2026-05-12T01:30:00+00:00", + "ts_local": "2026-05-12T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 0.1647, + "grid_export_kwh": 0.0, + "solar_kwh": 1.0579 + }, + { + "ts_utc": "2026-05-12T02:00:00+00:00", + "ts_local": "2026-05-12T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 0.3362, + "grid_export_kwh": 0.0, + "solar_kwh": 1.0509 + }, + { + "ts_utc": "2026-05-12T02:30:00+00:00", + "ts_local": "2026-05-12T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 0.4973, + "grid_export_kwh": 0.0, + "solar_kwh": 1.0444 + }, + { + "ts_utc": "2026-05-12T03:00:00+00:00", + "ts_local": "2026-05-12T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 1.1639, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9781 + }, + { + "ts_utc": "2026-05-12T03:30:00+00:00", + "ts_local": "2026-05-12T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 1.8077, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9141 + }, + { + "ts_utc": "2026-05-12T04:00:00+00:00", + "ts_local": "2026-05-12T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 1.631, + "grid_export_kwh": 0.0, + "solar_kwh": 0.7949 + }, + { + "ts_utc": "2026-05-12T04:30:00+00:00", + "ts_local": "2026-05-12T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 1.4552, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6762 + }, + { + "ts_utc": "2026-05-12T05:00:00+00:00", + "ts_local": "2026-05-12T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 0.7442, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5459 + }, + { + "ts_utc": "2026-05-12T05:30:00+00:00", + "ts_local": "2026-05-12T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.0174, + "grid_export_kwh": 0.0, + "solar_kwh": 0.4126 + }, + { + "ts_utc": "2026-05-12T06:00:00+00:00", + "ts_local": "2026-05-12T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.0369, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2373 + }, + { + "ts_utc": "2026-05-12T06:30:00+00:00", + "ts_local": "2026-05-12T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.0573, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0533 + }, + { + "ts_utc": "2026-05-12T07:00:00+00:00", + "ts_local": "2026-05-12T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.047, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0278 + }, + { + "ts_utc": "2026-05-12T07:30:00+00:00", + "ts_local": "2026-05-12T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.0358, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-12T08:00:00+00:00", + "ts_local": "2026-05-12T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.0308, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-12T08:30:00+00:00", + "ts_local": "2026-05-12T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-12T09:00:00+00:00", + "ts_local": "2026-05-12T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-12T09:30:00+00:00", + "ts_local": "2026-05-12T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-12T10:00:00+00:00", + "ts_local": "2026-05-12T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T10:30:00+00:00", + "ts_local": "2026-05-12T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T11:00:00+00:00", + "ts_local": "2026-05-12T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.0251, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T11:30:00+00:00", + "ts_local": "2026-05-12T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.1055, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T12:00:00+00:00", + "ts_local": "2026-05-12T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 0.2026, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T12:30:00+00:00", + "ts_local": "2026-05-12T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 0.223, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T13:00:00+00:00", + "ts_local": "2026-05-12T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.2487, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T13:30:00+00:00", + "ts_local": "2026-05-12T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.4719, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T14:00:00+00:00", + "ts_local": "2026-05-13T00:00:00+10:00", + "local_clock": "00:00", + "grid_import_kwh": 0.7638, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T14:30:00+00:00", + "ts_local": "2026-05-13T00:30:00+10:00", + "local_clock": "00:30", + "grid_import_kwh": 0.7903, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T15:00:00+00:00", + "ts_local": "2026-05-13T01:00:00+10:00", + "local_clock": "01:00", + "grid_import_kwh": 0.8263, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T15:30:00+00:00", + "ts_local": "2026-05-13T01:30:00+10:00", + "local_clock": "01:30", + "grid_import_kwh": 0.8277, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T16:00:00+00:00", + "ts_local": "2026-05-13T02:00:00+10:00", + "local_clock": "02:00", + "grid_import_kwh": 0.8297, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T16:30:00+00:00", + "ts_local": "2026-05-13T02:30:00+10:00", + "local_clock": "02:30", + "grid_import_kwh": 0.7826, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T17:00:00+00:00", + "ts_local": "2026-05-13T03:00:00+10:00", + "local_clock": "03:00", + "grid_import_kwh": 0.7147, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T17:30:00+00:00", + "ts_local": "2026-05-13T03:30:00+10:00", + "local_clock": "03:30", + "grid_import_kwh": 1.078, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T18:00:00+00:00", + "ts_local": "2026-05-13T04:00:00+10:00", + "local_clock": "04:00", + "grid_import_kwh": 1.6233, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T18:30:00+00:00", + "ts_local": "2026-05-13T04:30:00+10:00", + "local_clock": "04:30", + "grid_import_kwh": 1.6233, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T19:00:00+00:00", + "ts_local": "2026-05-13T05:00:00+10:00", + "local_clock": "05:00", + "grid_import_kwh": 1.6233, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T19:30:00+00:00", + "ts_local": "2026-05-13T05:30:00+10:00", + "local_clock": "05:30", + "grid_import_kwh": 1.068, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-12T20:00:00+00:00", + "ts_local": "2026-05-13T06:00:00+10:00", + "local_clock": "06:00", + "grid_import_kwh": 0.1837, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0003 + }, + { + "ts_utc": "2026-05-12T20:30:00+00:00", + "ts_local": "2026-05-13T06:30:00+10:00", + "local_clock": "06:30", + "grid_import_kwh": 0.1679, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0003 + }, + { + "ts_utc": "2026-05-12T21:00:00+00:00", + "ts_local": "2026-05-13T07:00:00+10:00", + "local_clock": "07:00", + "grid_import_kwh": 0.1419, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0003 + }, + { + "ts_utc": "2026-05-12T21:30:00+00:00", + "ts_local": "2026-05-13T07:30:00+10:00", + "local_clock": "07:30", + "grid_import_kwh": 0.2537, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1281 + }, + { + "ts_utc": "2026-05-12T22:00:00+00:00", + "ts_local": "2026-05-13T08:00:00+10:00", + "local_clock": "08:00", + "grid_import_kwh": 0.3177, + "grid_export_kwh": 0.0, + "solar_kwh": 0.2011 + }, + { + "ts_utc": "2026-05-12T22:30:00+00:00", + "ts_local": "2026-05-13T08:30:00+10:00", + "local_clock": "08:30", + "grid_import_kwh": 0.5531, + "grid_export_kwh": 0.0, + "solar_kwh": 0.392 + }, + { + "ts_utc": "2026-05-12T23:00:00+00:00", + "ts_local": "2026-05-13T09:00:00+10:00", + "local_clock": "09:00", + "grid_import_kwh": 0.8427, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6268 + }, + { + "ts_utc": "2026-05-12T23:30:00+00:00", + "ts_local": "2026-05-13T09:30:00+10:00", + "local_clock": "09:30", + "grid_import_kwh": 0.986, + "grid_export_kwh": 0.0, + "solar_kwh": 0.735 + }, + { + "ts_utc": "2026-05-13T00:00:00+00:00", + "ts_local": "2026-05-13T10:00:00+10:00", + "local_clock": "10:00", + "grid_import_kwh": 1.1697, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8736 + }, + { + "ts_utc": "2026-05-13T00:30:00+00:00", + "ts_local": "2026-05-13T10:30:00+10:00", + "local_clock": "10:30", + "grid_import_kwh": 1.1697, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8736 + }, + { + "ts_utc": "2026-05-13T01:00:00+00:00", + "ts_local": "2026-05-13T11:00:00+10:00", + "local_clock": "11:00", + "grid_import_kwh": 1.1697, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8736 + }, + { + "ts_utc": "2026-05-13T01:30:00+00:00", + "ts_local": "2026-05-13T11:30:00+10:00", + "local_clock": "11:30", + "grid_import_kwh": 0.926, + "grid_export_kwh": 0.0, + "solar_kwh": 0.8984 + }, + { + "ts_utc": "2026-05-13T02:00:00+00:00", + "ts_local": "2026-05-13T12:00:00+10:00", + "local_clock": "12:00", + "grid_import_kwh": 0.5978, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9318 + }, + { + "ts_utc": "2026-05-13T02:30:00+00:00", + "ts_local": "2026-05-13T12:30:00+10:00", + "local_clock": "12:30", + "grid_import_kwh": 2.8814, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9866 + }, + { + "ts_utc": "2026-05-13T03:00:00+00:00", + "ts_local": "2026-05-13T13:00:00+10:00", + "local_clock": "13:00", + "grid_import_kwh": 6.0298, + "grid_export_kwh": 0.0, + "solar_kwh": 1.0622 + }, + { + "ts_utc": "2026-05-13T03:30:00+00:00", + "ts_local": "2026-05-13T13:30:00+10:00", + "local_clock": "13:30", + "grid_import_kwh": 5.9207, + "grid_export_kwh": 0.0, + "solar_kwh": 0.9724 + }, + { + "ts_utc": "2026-05-13T04:00:00+00:00", + "ts_local": "2026-05-13T14:00:00+10:00", + "local_clock": "14:00", + "grid_import_kwh": 5.4691, + "grid_export_kwh": 0.0, + "solar_kwh": 0.7971 + }, + { + "ts_utc": "2026-05-13T04:30:00+00:00", + "ts_local": "2026-05-13T14:30:00+10:00", + "local_clock": "14:30", + "grid_import_kwh": 4.1086, + "grid_export_kwh": 0.0, + "solar_kwh": 0.6539 + }, + { + "ts_utc": "2026-05-13T05:00:00+00:00", + "ts_local": "2026-05-13T15:00:00+10:00", + "local_clock": "15:00", + "grid_import_kwh": 0.3707, + "grid_export_kwh": 0.0, + "solar_kwh": 0.5145 + }, + { + "ts_utc": "2026-05-13T05:30:00+00:00", + "ts_local": "2026-05-13T15:30:00+10:00", + "local_clock": "15:30", + "grid_import_kwh": 0.1397, + "grid_export_kwh": 0.0, + "solar_kwh": 0.3978 + }, + { + "ts_utc": "2026-05-13T06:00:00+00:00", + "ts_local": "2026-05-13T16:00:00+10:00", + "local_clock": "16:00", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.1634 + }, + { + "ts_utc": "2026-05-13T06:30:00+00:00", + "ts_local": "2026-05-13T16:30:00+10:00", + "local_clock": "16:30", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0935 + }, + { + "ts_utc": "2026-05-13T07:00:00+00:00", + "ts_local": "2026-05-13T17:00:00+10:00", + "local_clock": "17:00", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0288 + }, + { + "ts_utc": "2026-05-13T07:30:00+00:00", + "ts_local": "2026-05-13T17:30:00+10:00", + "local_clock": "17:30", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0003 + }, + { + "ts_utc": "2026-05-13T08:00:00+00:00", + "ts_local": "2026-05-13T18:00:00+10:00", + "local_clock": "18:00", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0003 + }, + { + "ts_utc": "2026-05-13T08:30:00+00:00", + "ts_local": "2026-05-13T18:30:00+10:00", + "local_clock": "18:30", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-13T09:00:00+00:00", + "ts_local": "2026-05-13T19:00:00+10:00", + "local_clock": "19:00", + "grid_import_kwh": 0.2416, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-13T09:30:00+00:00", + "ts_local": "2026-05-13T19:30:00+10:00", + "local_clock": "19:30", + "grid_import_kwh": 0.4951, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0001 + }, + { + "ts_utc": "2026-05-13T10:00:00+00:00", + "ts_local": "2026-05-13T20:00:00+10:00", + "local_clock": "20:00", + "grid_import_kwh": 0.7187, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-13T10:30:00+00:00", + "ts_local": "2026-05-13T20:30:00+10:00", + "local_clock": "20:30", + "grid_import_kwh": 0.7187, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-13T11:00:00+00:00", + "ts_local": "2026-05-13T21:00:00+10:00", + "local_clock": "21:00", + "grid_import_kwh": 0.7187, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-13T11:30:00+00:00", + "ts_local": "2026-05-13T21:30:00+10:00", + "local_clock": "21:30", + "grid_import_kwh": 0.835, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-13T12:00:00+00:00", + "ts_local": "2026-05-13T22:00:00+10:00", + "local_clock": "22:00", + "grid_import_kwh": 0.8482, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0002 + }, + { + "ts_utc": "2026-05-13T12:30:00+00:00", + "ts_local": "2026-05-13T22:30:00+10:00", + "local_clock": "22:30", + "grid_import_kwh": 0.3372, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-13T13:00:00+00:00", + "ts_local": "2026-05-13T23:00:00+10:00", + "local_clock": "23:00", + "grid_import_kwh": 0.2273, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + }, + { + "ts_utc": "2026-05-13T13:30:00+00:00", + "ts_local": "2026-05-13T23:30:00+10:00", + "local_clock": "23:30", + "grid_import_kwh": 0.0, + "grid_export_kwh": 0.0, + "solar_kwh": 0.0 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/phase0/consumption_dst_april_2026-04-05.json b/tests/fixtures/phase0/consumption_dst_april_2026-04-05.json new file mode 100644 index 0000000..4f4f953 --- /dev/null +++ b/tests/fixtures/phase0/consumption_dst_april_2026-04-05.json @@ -0,0 +1,416 @@ +{ + "_phase0_meta": { + "label": "Plan D \u2014 NSW DST backward (gain 1h)", + "transition": "AEDT_to_AEST", + "local_date": "2026-04-05", + "tz": "Australia/Sydney", + "slots_count": 50, + "wall_clock_hours": 25.0, + "total_grid_import_kwh": 27.4, + "total_solar_export_kwh": 21.0, + "profile_source": "synthetic residential pattern per scripts/gen_dst_fixtures.py", + "test_assertion": "evaluator total cost matches hand-calc within $0.05" + }, + "slots": [ + { + "ts_utc": "2026-04-04T13:00:00+00:00", + "ts_local": "2026-04-05T00:00:00+11:00", + "local_clock": "00:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T13:30:00+00:00", + "ts_local": "2026-04-05T00:30:00+11:00", + "local_clock": "00:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T14:00:00+00:00", + "ts_local": "2026-04-05T01:00:00+11:00", + "local_clock": "01:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T14:30:00+00:00", + "ts_local": "2026-04-05T01:30:00+11:00", + "local_clock": "01:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T15:00:00+00:00", + "ts_local": "2026-04-05T02:00:00+11:00", + "local_clock": "02:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T15:30:00+00:00", + "ts_local": "2026-04-05T02:30:00+11:00", + "local_clock": "02:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T16:00:00+00:00", + "ts_local": "2026-04-05T02:00:00+10:00", + "local_clock": "02:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T16:30:00+00:00", + "ts_local": "2026-04-05T02:30:00+10:00", + "local_clock": "02:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T17:00:00+00:00", + "ts_local": "2026-04-05T03:00:00+10:00", + "local_clock": "03:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T17:30:00+00:00", + "ts_local": "2026-04-05T03:30:00+10:00", + "local_clock": "03:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T18:00:00+00:00", + "ts_local": "2026-04-05T04:00:00+10:00", + "local_clock": "04:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T18:30:00+00:00", + "ts_local": "2026-04-05T04:30:00+10:00", + "local_clock": "04:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T19:00:00+00:00", + "ts_local": "2026-04-05T05:00:00+10:00", + "local_clock": "05:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T19:30:00+00:00", + "ts_local": "2026-04-05T05:30:00+10:00", + "local_clock": "05:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T20:00:00+00:00", + "ts_local": "2026-04-05T06:00:00+10:00", + "local_clock": "06:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T20:30:00+00:00", + "ts_local": "2026-04-05T06:30:00+10:00", + "local_clock": "06:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T21:00:00+00:00", + "ts_local": "2026-04-05T07:00:00+10:00", + "local_clock": "07:00", + "local_offset": 10.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T21:30:00+00:00", + "ts_local": "2026-04-05T07:30:00+10:00", + "local_clock": "07:30", + "local_offset": 10.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T22:00:00+00:00", + "ts_local": "2026-04-05T08:00:00+10:00", + "local_clock": "08:00", + "local_offset": 10.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T22:30:00+00:00", + "ts_local": "2026-04-05T08:30:00+10:00", + "local_clock": "08:30", + "local_offset": 10.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-04T23:00:00+00:00", + "ts_local": "2026-04-05T09:00:00+10:00", + "local_clock": "09:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-04T23:30:00+00:00", + "ts_local": "2026-04-05T09:30:00+10:00", + "local_clock": "09:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T00:00:00+00:00", + "ts_local": "2026-04-05T10:00:00+10:00", + "local_clock": "10:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T00:30:00+00:00", + "ts_local": "2026-04-05T10:30:00+10:00", + "local_clock": "10:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T01:00:00+00:00", + "ts_local": "2026-04-05T11:00:00+10:00", + "local_clock": "11:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T01:30:00+00:00", + "ts_local": "2026-04-05T11:30:00+10:00", + "local_clock": "11:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T02:00:00+00:00", + "ts_local": "2026-04-05T12:00:00+10:00", + "local_clock": "12:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T02:30:00+00:00", + "ts_local": "2026-04-05T12:30:00+10:00", + "local_clock": "12:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T03:00:00+00:00", + "ts_local": "2026-04-05T13:00:00+10:00", + "local_clock": "13:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T03:30:00+00:00", + "ts_local": "2026-04-05T13:30:00+10:00", + "local_clock": "13:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T04:00:00+00:00", + "ts_local": "2026-04-05T14:00:00+10:00", + "local_clock": "14:00", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T04:30:00+00:00", + "ts_local": "2026-04-05T14:30:00+10:00", + "local_clock": "14:30", + "local_offset": 10.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-04-05T05:00:00+00:00", + "ts_local": "2026-04-05T15:00:00+10:00", + "local_clock": "15:00", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T05:30:00+00:00", + "ts_local": "2026-04-05T15:30:00+10:00", + "local_clock": "15:30", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T06:00:00+00:00", + "ts_local": "2026-04-05T16:00:00+10:00", + "local_clock": "16:00", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T06:30:00+00:00", + "ts_local": "2026-04-05T16:30:00+10:00", + "local_clock": "16:30", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T07:00:00+00:00", + "ts_local": "2026-04-05T17:00:00+10:00", + "local_clock": "17:00", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T07:30:00+00:00", + "ts_local": "2026-04-05T17:30:00+10:00", + "local_clock": "17:30", + "local_offset": 10.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-04-05T08:00:00+00:00", + "ts_local": "2026-04-05T18:00:00+10:00", + "local_clock": "18:00", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T08:30:00+00:00", + "ts_local": "2026-04-05T18:30:00+10:00", + "local_clock": "18:30", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T09:00:00+00:00", + "ts_local": "2026-04-05T19:00:00+10:00", + "local_clock": "19:00", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T09:30:00+00:00", + "ts_local": "2026-04-05T19:30:00+10:00", + "local_clock": "19:30", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T10:00:00+00:00", + "ts_local": "2026-04-05T20:00:00+10:00", + "local_clock": "20:00", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T10:30:00+00:00", + "ts_local": "2026-04-05T20:30:00+10:00", + "local_clock": "20:30", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T11:00:00+00:00", + "ts_local": "2026-04-05T21:00:00+10:00", + "local_clock": "21:00", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T11:30:00+00:00", + "ts_local": "2026-04-05T21:30:00+10:00", + "local_clock": "21:30", + "local_offset": 10.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T12:00:00+00:00", + "ts_local": "2026-04-05T22:00:00+10:00", + "local_clock": "22:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T12:30:00+00:00", + "ts_local": "2026-04-05T22:30:00+10:00", + "local_clock": "22:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T13:00:00+00:00", + "ts_local": "2026-04-05T23:00:00+10:00", + "local_clock": "23:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-04-05T13:30:00+00:00", + "ts_local": "2026-04-05T23:30:00+10:00", + "local_clock": "23:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/phase0/consumption_dst_october_2026-10-04.json b/tests/fixtures/phase0/consumption_dst_october_2026-10-04.json new file mode 100644 index 0000000..f610115 --- /dev/null +++ b/tests/fixtures/phase0/consumption_dst_october_2026-10-04.json @@ -0,0 +1,384 @@ +{ + "_phase0_meta": { + "label": "Plan E \u2014 NSW DST forward (lose 1h)", + "transition": "AEST_to_AEDT", + "local_date": "2026-10-04", + "tz": "Australia/Sydney", + "slots_count": 46, + "wall_clock_hours": 23.0, + "total_grid_import_kwh": 25.8, + "total_solar_export_kwh": 21.0, + "profile_source": "synthetic residential pattern per scripts/gen_dst_fixtures.py", + "test_assertion": "evaluator total cost matches hand-calc within $0.05" + }, + "slots": [ + { + "ts_utc": "2026-10-03T14:00:00+00:00", + "ts_local": "2026-10-04T00:00:00+10:00", + "local_clock": "00:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T14:30:00+00:00", + "ts_local": "2026-10-04T00:30:00+10:00", + "local_clock": "00:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T15:00:00+00:00", + "ts_local": "2026-10-04T01:00:00+10:00", + "local_clock": "01:00", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T15:30:00+00:00", + "ts_local": "2026-10-04T01:30:00+10:00", + "local_clock": "01:30", + "local_offset": 10.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T16:00:00+00:00", + "ts_local": "2026-10-04T03:00:00+11:00", + "local_clock": "03:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T16:30:00+00:00", + "ts_local": "2026-10-04T03:30:00+11:00", + "local_clock": "03:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T17:00:00+00:00", + "ts_local": "2026-10-04T04:00:00+11:00", + "local_clock": "04:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T17:30:00+00:00", + "ts_local": "2026-10-04T04:30:00+11:00", + "local_clock": "04:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T18:00:00+00:00", + "ts_local": "2026-10-04T05:00:00+11:00", + "local_clock": "05:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T18:30:00+00:00", + "ts_local": "2026-10-04T05:30:00+11:00", + "local_clock": "05:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T19:00:00+00:00", + "ts_local": "2026-10-04T06:00:00+11:00", + "local_clock": "06:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T19:30:00+00:00", + "ts_local": "2026-10-04T06:30:00+11:00", + "local_clock": "06:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T20:00:00+00:00", + "ts_local": "2026-10-04T07:00:00+11:00", + "local_clock": "07:00", + "local_offset": 11.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T20:30:00+00:00", + "ts_local": "2026-10-04T07:30:00+11:00", + "local_clock": "07:30", + "local_offset": 11.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T21:00:00+00:00", + "ts_local": "2026-10-04T08:00:00+11:00", + "local_clock": "08:00", + "local_offset": 11.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T21:30:00+00:00", + "ts_local": "2026-10-04T08:30:00+11:00", + "local_clock": "08:30", + "local_offset": 11.0, + "grid_import_kwh": 1.2, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-03T22:00:00+00:00", + "ts_local": "2026-10-04T09:00:00+11:00", + "local_clock": "09:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-03T22:30:00+00:00", + "ts_local": "2026-10-04T09:30:00+11:00", + "local_clock": "09:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-03T23:00:00+00:00", + "ts_local": "2026-10-04T10:00:00+11:00", + "local_clock": "10:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-03T23:30:00+00:00", + "ts_local": "2026-10-04T10:30:00+11:00", + "local_clock": "10:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T00:00:00+00:00", + "ts_local": "2026-10-04T11:00:00+11:00", + "local_clock": "11:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T00:30:00+00:00", + "ts_local": "2026-10-04T11:30:00+11:00", + "local_clock": "11:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T01:00:00+00:00", + "ts_local": "2026-10-04T12:00:00+11:00", + "local_clock": "12:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T01:30:00+00:00", + "ts_local": "2026-10-04T12:30:00+11:00", + "local_clock": "12:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T02:00:00+00:00", + "ts_local": "2026-10-04T13:00:00+11:00", + "local_clock": "13:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T02:30:00+00:00", + "ts_local": "2026-10-04T13:30:00+11:00", + "local_clock": "13:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T03:00:00+00:00", + "ts_local": "2026-10-04T14:00:00+11:00", + "local_clock": "14:00", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T03:30:00+00:00", + "ts_local": "2026-10-04T14:30:00+11:00", + "local_clock": "14:30", + "local_offset": 11.0, + "grid_import_kwh": 0.3, + "solar_export_kwh": 1.5 + }, + { + "ts_utc": "2026-10-04T04:00:00+00:00", + "ts_local": "2026-10-04T15:00:00+11:00", + "local_clock": "15:00", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T04:30:00+00:00", + "ts_local": "2026-10-04T15:30:00+11:00", + "local_clock": "15:30", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T05:00:00+00:00", + "ts_local": "2026-10-04T16:00:00+11:00", + "local_clock": "16:00", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T05:30:00+00:00", + "ts_local": "2026-10-04T16:30:00+11:00", + "local_clock": "16:30", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T06:00:00+00:00", + "ts_local": "2026-10-04T17:00:00+11:00", + "local_clock": "17:00", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T06:30:00+00:00", + "ts_local": "2026-10-04T17:30:00+11:00", + "local_clock": "17:30", + "local_offset": 11.0, + "grid_import_kwh": 0.5, + "solar_export_kwh": 0.5 + }, + { + "ts_utc": "2026-10-04T07:00:00+00:00", + "ts_local": "2026-10-04T18:00:00+11:00", + "local_clock": "18:00", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T07:30:00+00:00", + "ts_local": "2026-10-04T18:30:00+11:00", + "local_clock": "18:30", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T08:00:00+00:00", + "ts_local": "2026-10-04T19:00:00+11:00", + "local_clock": "19:00", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T08:30:00+00:00", + "ts_local": "2026-10-04T19:30:00+11:00", + "local_clock": "19:30", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T09:00:00+00:00", + "ts_local": "2026-10-04T20:00:00+11:00", + "local_clock": "20:00", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T09:30:00+00:00", + "ts_local": "2026-10-04T20:30:00+11:00", + "local_clock": "20:30", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T10:00:00+00:00", + "ts_local": "2026-10-04T21:00:00+11:00", + "local_clock": "21:00", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T10:30:00+00:00", + "ts_local": "2026-10-04T21:30:00+11:00", + "local_clock": "21:30", + "local_offset": 11.0, + "grid_import_kwh": 1.0, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T11:00:00+00:00", + "ts_local": "2026-10-04T22:00:00+11:00", + "local_clock": "22:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T11:30:00+00:00", + "ts_local": "2026-10-04T22:30:00+11:00", + "local_clock": "22:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T12:00:00+00:00", + "ts_local": "2026-10-04T23:00:00+11:00", + "local_clock": "23:00", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + }, + { + "ts_utc": "2026-10-04T12:30:00+00:00", + "ts_local": "2026-10-04T23:30:00+11:00", + "local_clock": "23:30", + "local_offset": 11.0, + "grid_import_kwh": 0.4, + "solar_export_kwh": 0.0 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/phase0/plan_agl_AGL907738MRE6@EME.json b/tests/fixtures/phase0/plan_agl_AGL907738MRE6@EME.json new file mode 100644 index 0000000..1e3a3c6 --- /dev/null +++ b/tests/fixtures/phase0/plan_agl_AGL907738MRE6@EME.json @@ -0,0 +1,430 @@ +{ + "data": { + "brand": "agl", + "brandName": "AGL", + "customerType": "RESIDENTIAL", + "displayName": "Residential Smart Saver", + "effectiveFrom": "2026-05-12T00:00:00.000Z", + "electricityContract": { + "additionalFeeInformation": "Additional fees and charges may apply. Please see the AGL fee schedules at agl.com.au/fees", + "billFrequency": [ + "P3M", + "P1M" + ], + "coolingOffDays": 10, + "fees": [ + { + "amount": "16.5", + "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": "CONNECTION" + }, + { + "amount": "16.5", + "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_MOVE_OUT" + }, + { + "amount": "5.00", + "description": "Fee may be charged when remotely reconnecting your meter when you move into a property or change retailer. Includes GST. Fees may vary.", + "term": "FIXED", + "type": "CONNECTION" + }, + { + "amount": "5.00", + "description": "Fee may be charged when remotely disconnecting your meter when you move out of a property or change retailer. Includes GST. Fees may vary.", + "term": "FIXED", + "type": "DISCONNECT_MOVE_OUT" + }, + { + "amount": "118.16", + "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": "DISCONNECT_NON_PAY" + }, + { + "amount": "118.16", + "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": "RECONNECTION" + }, + { + "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", + "term": "FIXED", + "type": "LATE_PAYMENT" + }, + { + "description": "The amount is GST inclusive and applies to card payments made at Australia Post outlets.", + "rate": "0.0054", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING" + }, + { + "amount": "0.00", + "description": "An over the counter payment fee may apply for payments made in-person at a Post Office. Includes GST.", + "term": "FIXED", + "type": "OTHER" + }, + { + "amount": "0.00", + "description": "A paper bill fee may apply for each bill sent by post. Includes GST.", + "term": "FIXED", + "type": "PAPER_BILL" + }, + { + "description": "The amount is GST inclusive and applies to payments made by Visa debit cards.", + "rate": "0.0014", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING" + }, + { + "description": "The amount is GST inclusive and applies to payments made by Visa credit cards.", + "rate": "0.0065", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING" + }, + { + "description": "The amount is GST inclusive and applies to payments made by Mastercard debit cards.", + "rate": "0.0032", + "term": "PERCENT_OF_BILL", + "type": "PAYMENT_PROCESSING" + }, + { + "description": "The amount is GST inclusive and applies to payments made by Mastercard credit cards.", + "rate": "0.0078", + "term": "PERCENT_OF_BILL", + "type": "CC_PROCESSING" + } + ], + "greenPowerCharges": [ + { + "description": "For $1 per week inc. GST we ensure energy equal to 20% of your usage will be fed into the grid from Accredited GreenPower generators.", + "displayName": "Green Power Charge", + "scheme": "GREENPOWER", + "tiers": [ + { + "amount": "1.00", + "percentGreen": "0.2" + } + ], + "type": "FIXED_PER_WEEK" + }, + { + "description": "For 4.4c /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", + "scheme": "GREENPOWER", + "tiers": [ + { + "amount": "0.044", + "percentGreen": "1.0" + } + ], + "type": "FIXED_PER_UNIT" + } + ], + "isFixed": false, + "meterTypes": [ + "Type 4", + "Type 4a", + "Type 5", + "Type 6" + ], + "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", + "paymentOption": [ + "PAPER_BILL", + "DIRECT_DEBIT", + "CREDIT_CARD", + "BPAY" + ], + "pricingModel": "SINGLE_RATE", + "solarFeedInTariff": [ + { + "description": "AGL Retailer Feed-in Tariff (exc GST if any)", + "displayName": "AGL Retailer Feed-in Tariff (exc GST if any)", + "payerType": "RETAILER", + "scheme": "OTHER", + "singleTariff": { + "period": "P1Y", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.04" + } + ] + }, + "tariffUType": "singleTariff" + } + ], + "tariffPeriod": [ + { + "dailySupplyCharge": "0.7924", + "dailySupplyChargeType": "SINGLE", + "displayName": "Single Rate Tariff Period", + "endDate": "06-30", + "rateBlockUType": "singleRate", + "singleRate": { + "description": "Peak", + "displayName": "General Supply All Time", + "period": "P1Y", + "rates": [ + { + "unitPrice": "0.2922", + "volume": 3900 + }, + { + "unitPrice": "0.2922" + } + ] + }, + "startDate": "07-01" + } + ], + "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.", + "timeZone": "LOCAL", + "variation": "This plan also includes variable rates, retail fees and charges, which can change at any time with notice to you. If we vary your rates, we will give you at least 5 business days prior notice of the variation. Other charges may be varied with notice to you." + }, + "fuelType": "ELECTRICITY", + "geography": { + "distributors": [ + "Ausgrid" + ], + "includedPostcodes": [ + "2000", + "2007", + "2008", + "2009", + "2010", + "2011", + "2015", + "2016", + "2017", + "2018", + "2019", + "2020", + "2021", + "2022", + "2023", + "2024", + "2025", + "2026", + "2027", + "2028", + "2029", + "2030", + "2031", + "2032", + "2033", + "2034", + "2035", + "2036", + "2037", + "2038", + "2039", + "2040", + "2041", + "2042", + "2043", + "2044", + "2045", + "2046", + "2047", + "2048", + "2049", + "2050", + "2060", + "2061", + "2062", + "2063", + "2064", + "2065", + "2066", + "2067", + "2068", + "2069", + "2070", + "2071", + "2072", + "2073", + "2074", + "2075", + "2076", + "2077", + "2079", + "2080", + "2081", + "2082", + "2083", + "2084", + "2085", + "2086", + "2087", + "2088", + "2089", + "2090", + "2092", + "2093", + "2094", + "2095", + "2096", + "2097", + "2099", + "2100", + "2101", + "2102", + "2103", + "2104", + "2105", + "2106", + "2107", + "2108", + "2110", + "2111", + "2112", + "2113", + "2114", + "2118", + "2119", + "2120", + "2121", + "2122", + "2125", + "2126", + "2127", + "2128", + "2130", + "2131", + "2132", + "2133", + "2134", + "2135", + "2136", + "2137", + "2138", + "2140", + "2141", + "2143", + "2144", + "2154", + "2158", + "2159", + "2162", + "2163", + "2172", + "2190", + "2191", + "2192", + "2193", + "2194", + "2195", + "2196", + "2197", + "2198", + "2199", + "2200", + "2203", + "2204", + "2205", + "2206", + "2207", + "2208", + "2209", + "2210", + "2211", + "2212", + "2213", + "2214", + "2216", + "2217", + "2218", + "2219", + "2220", + "2221", + "2222", + "2223", + "2224", + "2225", + "2226", + "2227", + "2228", + "2229", + "2230", + "2231", + "2232", + "2233", + "2234", + "2250", + "2251", + "2256", + "2257", + "2258", + "2259", + "2260", + "2261", + "2262", + "2263", + "2264", + "2265", + "2267", + "2278", + "2280", + "2281", + "2282", + "2283", + "2284", + "2285", + "2286", + "2287", + "2289", + "2290", + "2291", + "2292", + "2293", + "2294", + "2295", + "2296", + "2297", + "2298", + "2299", + "2300", + "2302", + "2303", + "2304", + "2305", + "2306", + "2307", + "2308", + "2315", + "2316", + "2317", + "2318", + "2319", + "2320", + "2321", + "2322", + "2323", + "2324", + "2325", + "2326", + "2327", + "2328", + "2329", + "2330", + "2333", + "2334", + "2335", + "2336", + "2337", + "2775" + ] + }, + "lastUpdated": "2026-05-11T14:06:27.076Z", + "planId": "AGL907738MRE6@EME", + "type": "MARKET" + }, + "links": { + "self": "https://cdr.energymadeeasy.gov.au/agl/cds-au/v1/energy/plans/AGL907738MRE6@EME" + }, + "meta": {} +} \ No newline at end of file diff --git a/tests/fixtures/phase0/plan_c1_flexible_synthetic.json b/tests/fixtures/phase0/plan_c1_flexible_synthetic.json new file mode 100644 index 0000000..e0e188e --- /dev/null +++ b/tests/fixtures/phase0/plan_c1_flexible_synthetic.json @@ -0,0 +1,71 @@ +{ + "_phase0_meta": { + "plan_id_role": "Phase 0 Plan C1", + "source": "hand-constructed synthetic fixture per PHASE_0_GROUND_TRUTH.md §C1 + CDR audit lines 287-291", + "gate": "evaluator walks FLEXIBLE rate-block structure including stepped pricing volume threshold", + "ground_truth": "hand-calc per §6", + "not_a_real_plan": true, + "version": "1.0.0", + "synthesised_at": "2026-05-14T22:00:00+10:00" + }, + "data": { + "planId": "PHASE0-C1-FLEXIBLE-SYNTHETIC", + "effectiveFrom": "2026-05-01T00:00:00.000Z", + "lastUpdated": "2026-05-14T00:00:00.000Z", + "displayName": "Synthetic FLEXIBLE residential — Phase 0 C1 structural test", + "description": "Hand-constructed minimal FLEXIBLE fixture for evaluator gate. Stepped pricing: first 15 kWh/day at 24.6c, remainder at 30.1c. Daily supply $1.20/day ex-GST. No FIT, no incentives, no controlled load. Validates evaluator's FLEXIBLE rate-block walker.", + "type": "MARKET", + "fuelType": "ELECTRICITY", + "brand": "phase0-synthetic", + "brandName": "Phase 0 Synthetic", + "applicationUri": null, + "additionalInformation": null, + "customerType": "RESIDENTIAL", + "geography": { + "distributors": ["United Energy"], + "includedPostcodes": ["3000", "3199"] + }, + "electricityContract": { + "pricingModel": "FLEXIBLE", + "isFixed": false, + "variation": "Synthetic for Phase 0", + "onExpiryDescription": "n/a — synthetic fixture", + "paymentOption": ["DIRECT_DEBIT"], + "timeZone": "AEST", + "billFrequency": ["P1M"], + "coolingOffDays": 10, + "fees": [], + "tariffPeriod": [ + { + "displayName": "Flexible Tariff Period", + "startDate": "01-01", + "endDate": "12-31", + "dailySupplyCharge": "1.20", + "dailySupplyChargeType": "SINGLE", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "displayName": "Flexible", + "type": "PEAK", + "rateBlockUType": "stepped", + "rates": [ + {"measureUnit": "KWH", "unitPrice": "0.246", "volume": 15.0}, + {"measureUnit": "KWH", "unitPrice": "0.301"} + ], + "timeOfUse": [ + { + "days": ["MON","TUE","WED","THU","FRI","SAT","SUN"], + "startTime": "00:00", + "endTime": "23:59", + "type": "PEAK" + } + ] + } + ] + } + ], + "solarFeedInTariff": [], + "incentives": [] + } + } +} diff --git a/tests/fixtures/phase0/plan_globird_GLO731031MR@VEC.json b/tests/fixtures/phase0/plan_globird_GLO731031MR@VEC.json new file mode 100644 index 0000000..07c0cd5 --- /dev/null +++ b/tests/fixtures/phase0/plan_globird_GLO731031MR@VEC.json @@ -0,0 +1,442 @@ +{ + "_phase0_meta": { + "augmented_at": "2026-05-14T22:30:00+10:00", + "augmented_count": 6, + "fit_block_replaced_at": "2026-05-14T22:45:00+10:00", + "known_eme_gaps": [ + "incentives[].description was STUB (=displayName); augmented from PDF", + "solarFeedInTariff was singleTariff $0.0000001 placeholder; REPLACED 2026-05-14 with PDF-derived TOU FIT (Variable FiT - Option 2): PEAK 16:00-20:59 $0.027273/kWh ex-GST, SHOULDER (21:00-23:59 + 00:00-09:59 + 14:00-15:59) $0.002727/kWh ex-GST, OFF_PEAK 10:00-13:59 $0/kWh. All windows every day. Source: Victorian_Energy_Fact_Sheet_GLO707520MR_Electricity_CZ_6.pdf \u00a7\"Solar feed-in tariff options\"." + ], + "note": "Hand-calc oracle for C2 uses tariffPeriod rates from this fixture verbatim (CDR is canonical). PDF only fills the incentive-description gap and documents EME stripping behaviour.", + "plan_id_role": "Phase 0 Plan C2 \u2014 load-bearing GloBird ZEROHERO", + "source_cdr": "https://cdr.energymadeeasy.gov.au/globird/cds-au/v1/energy/plans/GLO731031MR@VEC (x-v: 3)", + "source_pdf_incentives": "Victorian_Energy_Fact_Sheet_GLO707520MR_Electricity_CZ_6.pdf (earlier plan version, same retailer/family/distributor)" + }, + "data": { + "brand": "globird", + "brandName": "GloBird Energy", + "customerType": "RESIDENTIAL", + "displayName": "GloBird ZEROHERO Residential (Flexible Rate) United Energy", + "effectiveFrom": "2026-03-31T13:00:00Z", + "electricityContract": { + "billFrequency": [ + "P1M" + ], + "coolingOffDays": 10, + "eligibility": [ + { + "information": "You must have installed an eligible battery and solar-PV. You must have an eligible smart meter. For eligibility criteria call 133456.", + "type": "OTHER" + } + ], + "fees": [ + { + "description": "0 Credit Card Payment Processing Fee", + "rate": "0", + "term": "PERCENT_OF_BILL", + "type": "OTHER" + }, + { + "amount": "15.00", + "description": "This is smart meter remote re-connection fee. It assumes a smart meter being remotely connected during business hours when we have been given enough prior notice. However, the fee can vary depending on the type of meter, the location, and other factors.", + "term": "FIXED", + "type": "CONNECTION" + }, + { + "amount": "15.00", + "description": "This is a smart meter remote disconnection fee, however, this fee can vary depending on your type of meter, the meter location, and other factors.", + "term": "FIXED", + "type": "DISCONNECTION" + }, + { + "amount": "4.00", + "description": "Paper Bill. If you have opted to receive a paper bill by post", + "term": "FIXED", + "type": "OTHER" + } + ], + "incentives": [ + { + "category": "OTHER", + "description": "$0.00 for consumption between 11am-2pm (Local Time), excluding controlled load.", + "displayName": "Perfect if you love free stuff", + "eligibility": "$0.00 for consumption between 11am-2pm (Local Time), excluding controlled load." + }, + { + "category": "OTHER", + "description": "$1/Day when imports are 0.03 kWh/hour or less, between 6pm-8pm (Local Time).", + "displayName": "ZEROHERO Credit", + "eligibility": "$1/Day when imports are 0.03 kWh/hour or less, between 6pm-9pm (Local Time)." + }, + { + "category": "OTHER", + "description": "15 cents/kWh applies to the first 10 kWh of exports between 6pm-8pm (Local Time) everyday, and is inclusive of any other Feed-in tariff as applicable in Energy Plan.", + "displayName": "Super Export Credit", + "eligibility": "15 cents/kWh applies to the first 15 kWh of exports between 6pm-9pm (Local Time) everyday, and is inclusive of any other Feed-in tariff as applicable in Energy Plan." + }, + { + "category": "OTHER", + "description": "$1/kWh applies to any export during a Critical Peak-Export event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data.", + "displayName": "Critical Peak-Export Credit", + "eligibility": "$1/kWh applies to any export during a Critical Peak-Export event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data." + }, + { + "category": "OTHER", + "description": "5 cents/kWh applies to any import during a Critical Peak-Import event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data.", + "displayName": "Critical Peak-Import Credit", + "eligibility": "5 cents/kWh applies to any import during a Critical Peak-Import event. The timing of these events is determined at our discretion, as detailed in a notice we provide. Your premises' metering installation must support 5-minute interval data." + }, + { + "category": "OTHER", + "description": "Peak FiT 3 c/kWh applies to all export every day 4pm-9pm. Shoulder FiT 0.30 c/kWh applies 9pm-10am + 2pm-4pm. Off-peak FiT 0 c/kWh applies 10am-2pm. (Variable FiT - Option 2). Note: EME-pulled `solarFeedInTariff` block does NOT carry this TOU structure \u2014 only a flat 0.0000001 singleTariff placeholder. The full TOU FiT is hand-merged here from the GloBird PDF for the Phase 0 parser test.", + "displayName": "Peak solar feed-in", + "eligibility": "2 cents/kWh applies to exports between 4pm-11pm (Local Time) everyday." + } + ], + "isFixed": false, + "onExpiryDescription": "No contract term, no exit fees. You can switch to another provider without penalty. We will always notify you before we change your discounts, prices or rates.", + "paymentOption": [ + "OTHER" + ], + "pricingModel": "FLEXIBLE", + "solarFeedInTariff": [ + { + "displayName": "Variable FiT - Option 2 (hand-merged from PDF GLO707520MR; EME proxy stripped the TOU FIT block to a singleTariff placeholder)", + "payerType": "RETAILER", + "scheme": "CURRENT", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.027273" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "20:59", + "startTime": "16:00" + } + ], + "type": "PEAK" + }, + { + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.002727" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "23:59", + "startTime": "21:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "09:59", + "startTime": "00:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "15:59", + "startTime": "14:00" + } + ], + "type": "SHOULDER" + }, + { + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "13:59", + "startTime": "10:00" + } + ], + "type": "OFF_PEAK" + } + ] + } + ], + "tariffPeriod": [ + { + "dailySupplyCharge": "1.05", + "dailySupplyChargeType": "SINGLE", + "displayName": "Period", + "endDate": "12-31", + "rateBlockUType": "timeOfUseRates", + "startDate": "01-01", + "timeOfUseRates": [ + { + "displayName": "Flexible", + "period": "P1D", + "rates": [ + { + "unitPrice": "0.36" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "23:00", + "startTime": "16:00" + } + ], + "type": "PEAK" + }, + { + "displayName": "Flexible", + "period": "P1D", + "rates": [ + { + "unitPrice": "0.000001" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "14:00", + "startTime": "11:00" + } + ], + "type": "OFF_PEAK" + }, + { + "displayName": "Flexible", + "period": "P1D", + "rates": [ + { + "unitPrice": "0.25" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "16:00", + "startTime": "14:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "00:00", + "startTime": "23:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "11:00", + "startTime": "00:00" + } + ], + "type": "SHOULDER" + } + ] + } + ], + "terms": "This offer is for VIC residential customers who have a Qualifying System and must meet and remain compliant with the Eligibility Criteria and all other requirements of the Virtual Power Plant Terms and Conditions. For further information about this offer call GloBird on 133456 or visit www.globirdenergy.com.au to see our full terms and conditions.", + "timeZone": "LOCAL", + "variation": "Rates and credits are usually reset one calendar month after the effective date of the approved network tariffs. We'll notify you in writing prior to any change." + }, + "fuelType": "ELECTRICITY", + "geography": { + "distributors": [ + "United Energy" + ], + "includedPostcodes": [ + "3104", + "3105", + "3106", + "3107", + "3108", + "3109", + "3111", + "3114", + "3122", + "3123", + "3124", + "3125", + "3127", + "3128", + "3129", + "3130", + "3131", + "3132", + "3133", + "3144", + "3145", + "3146", + "3147", + "3148", + "3149", + "3150", + "3151", + "3152", + "3161", + "3162", + "3163", + "3165", + "3166", + "3167", + "3168", + "3169", + "3170", + "3171", + "3172", + "3173", + "3174", + "3175", + "3177", + "3178", + "3179", + "3182", + "3183", + "3184", + "3185", + "3186", + "3187", + "3188", + "3189", + "3190", + "3191", + "3192", + "3193", + "3194", + "3195", + "3196", + "3197", + "3198", + "3199", + "3200", + "3201", + "3202", + "3204", + "3800", + "3802", + "3803", + "3910", + "3911", + "3912", + "3913", + "3915", + "3916", + "3918", + "3919", + "3920", + "3926", + "3927", + "3928", + "3929", + "3930", + "3931", + "3933", + "3934", + "3936", + "3937", + "3938", + "3939", + "3940", + "3941", + "3942", + "3943", + "3944", + "3975", + "3976", + "3977" + ] + }, + "lastUpdated": "2026-03-30T22:17:10Z", + "planId": "GLO731031MR@VEC", + "type": "MARKET" + }, + "links": { + "self": "https://cdr.energymadeeasy.gov.au/globird/cds-au/v1/energy/plans/GLO731031MR@VEC" + }, + "meta": {} +} \ No newline at end of file diff --git a/tests/fixtures/phase0/plan_red-energy_RED552831MRE15@EME.json b/tests/fixtures/phase0/plan_red-energy_RED552831MRE15@EME.json new file mode 100644 index 0000000..a017341 --- /dev/null +++ b/tests/fixtures/phase0/plan_red-energy_RED552831MRE15@EME.json @@ -0,0 +1,614 @@ +{ + "data": { + "brand": "red-energy", + "brandName": "Red Energy", + "customerType": "RESIDENTIAL", + "displayName": "Red Taronga Flex", + "effectiveFrom": "2026-03-20T00:00:00.000Z", + "electricityContract": { + "additionalFeeInformation": "The fees and charges listed in the Fees and Charges section above apply to the standard move in and move out requests. For details on our additional fees and charges, please visit https://www.redenergy.com.au/additional-service-charges-nsw or contact us on 131 806.", + "billFrequency": [ + "P3M", + "P1M" + ], + "coolingOffDays": 10, + "fees": [ + { + "amount": "118.16", + "description": "Connection fee (GST incl) for standard move in requests during business hours. Fees may vary. See Additional Fee Information for details.", + "term": "FIXED", + "type": "CONNECTION" + }, + { + "amount": "118.16", + "description": "Disconnection fee (GST incl) generally applies for any move-out request. Fees may vary. See Additional Fee Information for details.", + "term": "FIXED", + "type": "DISCONNECTION" + } + ], + "greenPowerCharges": [ + { + "description": "Red Energy offers 100% GreenPower for an extra 3.3 cents per kWh. Please contact us on 131 806 for more information.", + "displayName": "GreenPower", + "scheme": "GREENPOWER", + "tiers": [ + { + "amount": "0.033", + "percentGreen": "1.0" + } + ], + "type": "FIXED_PER_UNIT" + } + ], + "incentives": [ + { + "category": "OTHER", + "description": "Receive a Taronga Zoo Friends Family Flex Annual Membership, includes Adult Family Flex Pass & Child Family Flex Passes for kids under 16 years from one household, on transferring their electricity to Red. A valid email address is required to receive Zoo Friends membership. Family Flex Membership is valid at Taronga's Zoos. Any nominated adult can use the Flex Adult Pass when accompanying the Flex Kids. For Full T&Cs see redenergy.com.au/terms.", + "displayName": "Taronga Family Flex Membership" + }, + { + "category": "OTHER", + "description": "For every unit of electricity you buy from Red Energy, Snowy Hydro Limited will match it by generating one unit of electricity from a renewable source.", + "displayName": "Renewable Matching Promise" + } + ], + "isFixed": false, + "meterTypes": [ + "Type 4", + "Type 4a", + "Type 5", + "Type 6" + ], + "onExpiryDescription": "Your contract is ongoing until it is ended by you or us.", + "paymentOption": [ + "DIRECT_DEBIT", + "CREDIT_CARD", + "BPAY", + "PAPER_BILL", + "OTHER" + ], + "pricingModel": "TIME_OF_USE", + "solarFeedInTariff": [ + { + "description": "SOLAR 1 Red Energy FIT - GST included if any (FiT availability based on current network tariff configuration)", + "displayName": "Solar feed-in tariffs (FIT)", + "endDate": "9999-12-31", + "payerType": "RETAILER", + "scheme": "OTHER", + "singleTariff": { + "period": "P1Y", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.04" + } + ] + }, + "startDate": "2024-11-07", + "tariffUType": "singleTariff" + }, + { + "description": "SOLAR 2 Surplus FiT (First 6.85 kWh/day) - GST included if any: 10am to 3pm Monday to Sunday (FiT availability based on current network tariff configuration)", + "displayName": "Solar Export Tariff (EA029)", + "endDate": "9999-12-31", + "payerType": "RETAILER", + "scheme": "OTHER", + "startDate": "2024-11-07", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "displayName": "Solar Export Tariff (EA029)", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.04", + "volume": 6.85 + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "14:59", + "startTime": "10:00" + } + ], + "type": "PEAK" + } + ] + }, + { + "description": "SOLAR 2 Surplus FiT (Balance) - GST included if any: 10am to 3pm Monday to Sunday (FiT availability based on current network tariff configuration)", + "displayName": "Solar Export Tariff (EA029)", + "endDate": "9999-12-31", + "payerType": "RETAILER", + "scheme": "OTHER", + "startDate": "2024-11-07", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "displayName": "Solar Export Tariff (EA029)", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.027" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "14:59", + "startTime": "10:00" + } + ], + "type": "PEAK" + } + ] + }, + { + "description": "SOLAR 2 Smart FiT - GST included if any: 4pm to 9pm Monday to Sunday (FiT availability based on current network tariff configuration)", + "displayName": "Solar Export Tariff (EA029)", + "endDate": "9999-12-31", + "payerType": "RETAILER", + "scheme": "OTHER", + "startDate": "2024-11-07", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "displayName": "Solar Export Tariff (EA029)", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.082" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "20:59", + "startTime": "16:00" + } + ], + "type": "OFF_PEAK" + } + ] + }, + { + "description": "SOLAR 2 Solar FiT - GST included if any: All other times (FiT availability based on current network tariff configuration)", + "displayName": "Solar Export Tariff (EA029)", + "endDate": "9999-12-31", + "payerType": "RETAILER", + "scheme": "OTHER", + "startDate": "2024-11-07", + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + { + "displayName": "Solar Export Tariff (EA029)", + "period": "P1Y", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.04" + } + ], + "timeVariations": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "15:59", + "startTime": "15:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "09:59", + "startTime": "21:00" + } + ], + "type": "SHOULDER" + } + ] + } + ], + "tariffPeriod": [ + { + "dailySupplyCharge": "0.9175", + "dailySupplyChargeType": "SINGLE", + "displayName": "Time of Use Tariff Period", + "endDate": "06-30", + "rateBlockUType": "timeOfUseRates", + "startDate": "07-01", + "timeOfUseRates": [ + { + "description": "2pm-8pm Monday to Friday AEST", + "displayName": "Peak", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.4385" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "19:59", + "startTime": "14:00" + } + ], + "type": "PEAK" + }, + { + "description": "All other times.", + "displayName": "Off Peak", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.2198" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN" + ], + "endTime": "06:59", + "startTime": "22:00" + } + ], + "type": "OFF_PEAK" + }, + { + "description": "7am-2pm and 8pm-10pm weekdays and 7am-10pm weekends AEST", + "displayName": "Shoulder", + "period": "P1D", + "rates": [ + { + "measureUnit": "KWH", + "unitPrice": "0.2955" + } + ], + "timeOfUse": [ + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "13:59", + "startTime": "07:00" + }, + { + "days": [ + "MON", + "TUE", + "WED", + "THU", + "FRI" + ], + "endTime": "21:59", + "startTime": "20:00" + }, + { + "days": [ + "SAT", + "SUN" + ], + "endTime": "21:59", + "startTime": "07:00" + } + ], + "type": "SHOULDER" + } + ] + } + ], + "terms": "This offer is available to residential customers currently within the Ausgrid distribution zone with applicable network tariff. Please visit www.redenergy.com.au/terms for full T&Cs applicable to this offer. Solar Feed-In T&Cs can be found at www.redenergy.com.au/solar-feed-in-tariffs. All times are AEST, unless you have an interval meter, in which case daylight savings time will apply.", + "timeZone": "LOCAL", + "variation": "We may vary your rates in line with our Customer Charter and in accordance with the Relevant Laws. We will give you written notice of at least 5 business day prior to the variation." + }, + "fuelType": "ELECTRICITY", + "geography": { + "distributors": [ + "Ausgrid" + ], + "includedPostcodes": [ + "2000", + "2007", + "2008", + "2009", + "2010", + "2011", + "2015", + "2016", + "2017", + "2018", + "2019", + "2020", + "2021", + "2022", + "2023", + "2024", + "2025", + "2026", + "2027", + "2028", + "2029", + "2030", + "2031", + "2032", + "2033", + "2034", + "2035", + "2036", + "2037", + "2038", + "2039", + "2040", + "2041", + "2042", + "2043", + "2044", + "2045", + "2046", + "2047", + "2048", + "2049", + "2050", + "2060", + "2061", + "2062", + "2063", + "2064", + "2065", + "2066", + "2067", + "2068", + "2069", + "2070", + "2071", + "2072", + "2073", + "2074", + "2075", + "2076", + "2077", + "2079", + "2080", + "2081", + "2082", + "2083", + "2084", + "2085", + "2086", + "2087", + "2088", + "2089", + "2090", + "2092", + "2093", + "2094", + "2095", + "2096", + "2097", + "2099", + "2100", + "2101", + "2102", + "2103", + "2104", + "2105", + "2106", + "2107", + "2108", + "2110", + "2111", + "2112", + "2113", + "2114", + "2118", + "2119", + "2120", + "2121", + "2122", + "2125", + "2126", + "2127", + "2128", + "2130", + "2131", + "2132", + "2133", + "2134", + "2135", + "2136", + "2137", + "2138", + "2140", + "2141", + "2143", + "2144", + "2154", + "2158", + "2159", + "2162", + "2163", + "2172", + "2190", + "2191", + "2192", + "2193", + "2194", + "2195", + "2196", + "2197", + "2198", + "2199", + "2200", + "2203", + "2204", + "2205", + "2206", + "2207", + "2208", + "2209", + "2210", + "2211", + "2212", + "2213", + "2214", + "2216", + "2217", + "2218", + "2219", + "2220", + "2221", + "2222", + "2223", + "2224", + "2225", + "2226", + "2227", + "2228", + "2229", + "2230", + "2231", + "2232", + "2233", + "2234", + "2250", + "2251", + "2256", + "2257", + "2258", + "2259", + "2260", + "2261", + "2262", + "2263", + "2264", + "2265", + "2267", + "2278", + "2280", + "2281", + "2282", + "2283", + "2284", + "2285", + "2286", + "2287", + "2289", + "2290", + "2291", + "2292", + "2293", + "2294", + "2295", + "2296", + "2297", + "2298", + "2299", + "2300", + "2302", + "2303", + "2304", + "2305", + "2306", + "2307", + "2308", + "2315", + "2316", + "2317", + "2318", + "2319", + "2320", + "2321", + "2322", + "2323", + "2324", + "2325", + "2326", + "2327", + "2328", + "2329", + "2330", + "2333", + "2334", + "2335", + "2336", + "2337", + "2775" + ] + }, + "lastUpdated": "2026-03-19T14:05:55.898Z", + "meteringCharges": [ + { + "description": "Charges may vary, please contact us on 131 806 for specific metering charges.", + "displayName": "Metering Cost", + "minimumValue": "0.00" + } + ], + "planId": "RED552831MRE15@EME", + "type": "MARKET" + }, + "links": { + "self": "https://cdr.energymadeeasy.gov.au/red-energy/cds-au/v1/energy/plans/RED552831MRE15@EME" + }, + "meta": {} +} \ No newline at end of file diff --git a/tests/test_catalog_signatures.py b/tests/test_catalog_signatures.py new file mode 100644 index 0000000..cf1f278 --- /dev/null +++ b/tests/test_catalog_signatures.py @@ -0,0 +1,453 @@ +"""Verify _summarise_* helpers handle every CDR shape signature observed in +the live shape catalog (scripts/CDR_SHAPE_CATALOG_PROMPT.md output). + +Each test pins one variant from sections 3 + 4 of the catalog. Failures +here are signature drift the parser can't handle yet. +""" +from __future__ import annotations + +from custom_components.pricehawk.config_flow import ( + _summarise_cdr_plan, + _summarise_controlled_load, + _summarise_fit, + _summarise_import_rate, +) + + +# --------------------------------------------------------------------------- +# Section 3 — rateBlockUType variants +# --------------------------------------------------------------------------- + + +def _wrap(rate_block_u_type: str, block): + return {"tariffPeriod": [{ + "rateBlockUType": rate_block_u_type, + rate_block_u_type: block, + }]} + + +class TestSingleRateVariants: + """4 sub-shapes observed in catalog. All should produce a numeric rate.""" + + def test_keys_description_displayName_period_rates(self): + block = {"description": "X", "displayName": "Rate", "period": "P1D", + "rates": [{"unitPrice": "0.30"}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "33.0" in result, result + + def test_keys_displayName_period_rates(self): + # Most common (AGL/Amber/Arcline cohort). + block = {"displayName": "Rate", "period": "P1D", + "rates": [{"unitPrice": "0.30"}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "33.0" in result, result + # Phase 2.10.4 polish — generic "Rate" displayName is stripped + # because the surrounding "Import rate:" form prefix supplies it. + assert "Rate" not in result.split("c/kWh")[0], result + + def test_keys_displayName_rates(self): + # Blue NRG / Origin sub-shape — no period, no description. + block = {"displayName": "Rate", "rates": [{"unitPrice": "0.30"}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "33.0" in result, result + + def test_keys_description_displayName_rates(self): + # Flow Power sub-shape. + block = {"description": "X", "displayName": "Rate", + "rates": [{"unitPrice": "0.30"}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "33.0" in result, result + + +class TestTimeOfUseRatesVariants: + """3 sub-shapes observed in catalog. All should produce TOU summary.""" + + def test_with_description_period_displayName_type_timeOfUse(self): + # Most common 26-retailer shape. + blocks = [{"description": "X", "displayName": "Peak", "period": "P1D", + "rates": [{"unitPrice": "0.36"}], "timeOfUse": [], + "type": "PEAK"}] + result = _summarise_import_rate(_wrap("timeOfUseRates", blocks)) + assert "39.6" in result, result + assert "PEAK" in result, result + + def test_without_description(self): + # 4-retailer shape (Dodo/GloBird/MYOB/Sumo). + blocks = [{"displayName": "Peak", "period": "P1D", + "rates": [{"unitPrice": "0.36"}], "timeOfUse": [], + "type": "PEAK"}] + result = _summarise_import_rate(_wrap("timeOfUseRates", blocks)) + assert "39.6" in result, result + + def test_without_description_or_period(self): + # Blue NRG / Flow / Lumo / Origin sub-shape. + blocks = [{"displayName": "Peak", "rates": [{"unitPrice": "0.36"}], + "timeOfUse": [], "type": "PEAK"}] + result = _summarise_import_rate(_wrap("timeOfUseRates", blocks)) + assert "39.6" in result, result + + +# --------------------------------------------------------------------------- +# Section 4 — solarFeedInTariff variants +# --------------------------------------------------------------------------- + + +class TestFitSingleTariffVariants: + def test_period_rates_with_measureUnit_25_retailers(self): + elec = {"solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"period": "P1D", "rates": [{"unitPrice": "0.05", "measureUnit": None}]}, + }]} + result = _summarise_fit(elec) + assert "5.50" in result, result + + def test_rates_only_12_retailers_AGL_EnergyAustralia_Origin(self): + elec = {"solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": "0.05"}]}, + }]} + result = _summarise_fit(elec) + assert "5.50" in result, result + + +class TestFitTimeVaryingTariffsVariants: + def test_displayName_period_rates_timeVariations_type_5_retailers(self): + elec = {"solarFeedInTariff": [{ + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + {"displayName": "Peak", "period": "P1D", + "rates": [{"unitPrice": "0.03"}], "timeVariations": [], + "type": "PEAK"}, + {"displayName": "Shoulder", "period": "P1D", + "rates": [{"unitPrice": "0.001"}], "timeVariations": [], + "type": "SHOULDER"}, + ], + }]} + result = _summarise_fit(elec) + assert "PEAK 3.3" in result, result + assert "SHOULDER 0.1" in result, result + + def test_displayName_rates_timeVariations_type_no_period_Flow(self): + elec = {"solarFeedInTariff": [{ + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + {"displayName": "Peak", "rates": [{"unitPrice": "0.03"}], + "timeVariations": [], "type": "PEAK"}, + ], + }]} + result = _summarise_fit(elec) + assert "PEAK 3.3" in result, result + + +class TestFitMissing: + def test_solarFeedInTariff_key_absent_6_retailers(self): + # Amber, Diamond, ERC, GEE, Real Utilities, ZEN + result = _summarise_fit({}) + assert result == "none" + + def test_solarFeedInTariff_null(self): + result = _summarise_fit({"solarFeedInTariff": None}) + assert result == "none" + + def test_solarFeedInTariff_empty_list(self): + result = _summarise_fit({"solarFeedInTariff": []}) + assert result == "none" + + +class TestFitMultiTier: + """Sumo Power + Red Energy ship FIT lists of length 3-9 — multi-tier + solar bands. Parser must surface ALL entries, not just [0].""" + + def test_three_tiers_summed(self): + elec = {"solarFeedInTariff": [ + {"tariffUType": "singleTariff", "singleTariff": {"rates": [{"unitPrice": "0.10"}]}}, + {"tariffUType": "singleTariff", "singleTariff": {"rates": [{"unitPrice": "0.05"}]}}, + {"tariffUType": "singleTariff", "singleTariff": {"rates": [{"unitPrice": "0.03"}]}}, + ]} + result = _summarise_fit(elec) + # Each tier shown, joined by " + ". + assert "11.00" in result + assert "5.50" in result + assert "3.30" in result + + +# --------------------------------------------------------------------------- +# Section 6 — surprise findings, edge cases +# --------------------------------------------------------------------------- + + +class TestControlledLoadSummary: + """6 retailers ship CL `timeOfUseRates`; others ship CL `singleRate`. + Catalog: Energy Locals, ENGIE, GloBird, Lumo, Powershop, ZEN.""" + + def test_no_controlled_load_returns_none(self): + assert _summarise_controlled_load({}) == "none" + assert _summarise_controlled_load({"controlledLoad": []}) == "none" + assert _summarise_controlled_load({"controlledLoad": None}) == "none" + + def test_single_rate_cl_block(self): + elec = {"controlledLoad": [{ + "displayName": "Hot Water", + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.15"}]}, + }]} + result = _summarise_controlled_load(elec) + assert "Hot Water" in result + # 0.15 × 110 = 16.5 c/kWh inc-GST + assert "16.5" in result + + def test_generic_cl_label_stripped(self): + # Phase 2.10.4 polish — "Controlled Load" displayName is dropped + # because the surrounding "Controlled load:" form prefix supplies it. + elec = {"controlledLoad": [{ + "displayName": "Controlled Load", + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.13"}]}, + }]} + result = _summarise_controlled_load(elec) + # Just the rate, no "Controlled Load: Controlled Load 14.3..." dup. + assert result.count("Controlled Load") == 0 + assert "14.3" in result + + def test_tou_cl_block(self): + elec = {"controlledLoad": [{ + "displayName": "CL TOU", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.10"}]}, + ], + }]} + result = _summarise_controlled_load(elec) + assert "CL TOU" in result + assert "11.0" in result + + def test_full_summary_includes_controlled_load_key(self): + # The CL field must appear in the placeholder dict so the form + # description doesn't error on missing placeholder. + out = _summarise_cdr_plan({"data": {"electricityContract": {}}}) + assert "controlled_load" in out + assert out["controlled_load"] == "none" + + +class TestEdgeCases: + def test_empty_tariffPeriod(self): + # Catalog: "Plans where tariffPeriod is empty / missing" surprise. + result = _summarise_import_rate({"tariffPeriod": []}) + assert result == "?" + + def test_missing_tariffPeriod(self): + result = _summarise_import_rate({}) + assert result == "?" + + def test_unitPrice_as_number_not_string(self): + # Catalog: "Numeric-typed fields where the spec says string" surprise. + block = {"displayName": "Rate", "rates": [{"unitPrice": 0.30}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "33.0" in result, result + + +# --------------------------------------------------------------------------- +# Catalog v2 full-sweep pins (78 retailers, 10,266 plans, 1,724 sigs) +# Each test pins a finding from the v2 catalog so future schema drift +# surfaces as a CI failure, not a UAT bug. +# --------------------------------------------------------------------------- + + +class TestCatalogV2FullSweep: + """Pins from /tmp/cdr-shape-catalog-full.md (sweep dated 2026-05-15). + + These are belts-AND-braces tests: most behaviours are also covered by + the section-3/4 variants above, but pinning the catalog statistics + explicitly makes regressions traceable to a specific sweep finding. + """ + + def test_supply_charge_at_tariffPeriod_singular_only(self): + # Catalog §5: 10,262/10,266 plans put dailySupplyCharge (singular) + # inside tariffPeriod[0]. The 3 spec-allowed alternatives are 0/10,266. + out = _summarise_cdr_plan({"data": {"electricityContract": { + "tariffPeriod": [{"dailySupplyCharge": "0.95"}], + }}}) + # 0.95 × 110 = 104.50 c/day + assert "104.50" in out["daily_supply"], out["daily_supply"] + assert "inc-GST" in out["daily_supply"] + + def test_supply_charge_missing_returns_not_published(self): + # Catalog §5: 4 plans miss dailySupplyCharge in all 4 locations + # (likely embedded-network niche). Must not crash, must say so. + out = _summarise_cdr_plan({"data": {"electricityContract": { + "tariffPeriod": [{"singleRate": {"rates": [{"unitPrice": "0.30"}]}}], + }}}) + assert out["daily_supply"] == "not published", out["daily_supply"] + + def test_singleRate_always_dict_per_full_sweep(self): + # Catalog §6: 4,405 plans across 35 retailers — singleRate is ALWAYS + # dict, no exceptions in 10,266 plans. Pin the dict path. + block = {"displayName": "Anytime", "rates": [{"unitPrice": "0.28"}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + assert "30.8" in result, result + + def test_timeOfUseRates_always_list_per_full_sweep(self): + # Catalog §6: 5,857 plans across 31 retailers — timeOfUseRates is + # ALWAYS list. Length distribution: list[3](3060), list[2](2783), list[4](14). + blocks = [ + {"type": "PEAK", "rates": [{"unitPrice": "0.40"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.30"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.20"}]}, + ] + result = _summarise_import_rate(_wrap("timeOfUseRates", blocks)) + assert "PEAK 44.0" in result + assert "SHOULDER 33.0" in result + assert "OFF_PEAK 22.0" in result + + def test_timeOfUseRates_list4_max_observed(self): + # Catalog §6: 14 plans ship timeOfUseRates of length 4 (max observed). + # Parser must surface ALL 4 entries. + blocks = [ + {"type": "PEAK", "rates": [{"unitPrice": "0.40"}]}, + {"type": "SHOULDER_AM", "rates": [{"unitPrice": "0.32"}]}, + {"type": "SHOULDER_PM", "rates": [{"unitPrice": "0.28"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.18"}]}, + ] + result = _summarise_import_rate(_wrap("timeOfUseRates", blocks)) + for label in ("PEAK 44.0", "SHOULDER_AM 35.2", + "SHOULDER_PM 30.8", "OFF_PEAK 19.8"): + assert label in result, f"{label} missing from {result}" + + def test_fit_missing_for_345_plans_across_10_retailers(self): + # Catalog §7: solarFeedInTariff key absent for 345 plans (3.4% of all). + # Retailers: Real Utilities, ERC, GEE, ZEN, all-of-Diamond + subsets + # of Amber, MYOB/OVO, Powershop, etc. Must return "none", not crash. + for elec in ( + {}, + {"solarFeedInTariff": None}, + {"solarFeedInTariff": []}, + ): + assert _summarise_fit(elec) == "none" + + def test_fit_singleTariff_dominant_9441_plans(self): + # Catalog §7: 9,441 plans (95% of FIT-equipped) ship singleTariff. + elec = {"solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": "0.075"}]}, + }]} + result = _summarise_fit(elec) + # 0.075 × 110 = 8.25 c/kWh inc-GST + assert "8.25" in result, result + + def test_fit_timeVaryingTariffs_list3_max_observed(self): + # Catalog §7: 161 plans ship timeVaryingTariffs of length 3 (PEAK + + # SHOULDER + OFF_PEAK FIT). Parser must walk all 3. + elec = {"solarFeedInTariff": [{ + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.06"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.04"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.02"}]}, + ], + }]} + result = _summarise_fit(elec) + assert "PEAK 6.6" in result + assert "SHOULDER 4.4" in result + assert "OFF_PEAK 2.2" in result + + def test_fit_multi_tier_9_bands_max_observed(self): + # Catalog §3: Sumo Power + Red Energy ship FIT lists up to 9 entries + # (multi-tier solar bands at decreasing rates). Parser must surface + # all 9 entries, not just [0]. + elec = {"solarFeedInTariff": [ + {"tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": f"0.{i:02d}"}]}} + for i in range(15, 6, -1) # 9 tiers: 0.15 → 0.07 + ]} + result = _summarise_fit(elec) + # First tier 0.15 × 110 = 16.50, last tier 0.07 × 110 = 7.70 + assert "16.50" in result + assert "7.70" in result + # 9 tiers means 8 " + " separators + assert result.count(" + ") == 8, result + + def test_fit_scheme_OTHER_freeform_not_rejected(self): + # Catalog §7: scheme:OTHER dominates (6,656 plans). Spec enum doesn't + # include OTHER but registry-wide convention does. Parser ignores + # scheme entirely (display walks rates only) — pin that behaviour. + elec = {"solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "scheme": "OTHER", # not in spec enum + "payerType": "RETAILER", + "singleTariff": {"rates": [{"unitPrice": "0.05"}]}, + }]} + result = _summarise_fit(elec) + assert "5.50" in result, result + + def test_incentive_category_GIFT_freeform_not_rejected(self): + # Catalog §8: 50 AGL plans ship category:GIFT (not in CDR docs; + # docs claim DISCOUNT/BONUS/OTHER only). Parser uses displayName, + # not category, so freeform values must not break the summary. + out = _summarise_cdr_plan({"data": {"electricityContract": { + "tariffPeriod": [{"dailySupplyCharge": "0.85"}], + "incentives": [ + {"displayName": "Welcome Gift", "category": "GIFT"}, + {"displayName": "Free Movie", "category": "ACCOUNT_CREDIT"}, + ], + }}}) + assert "Welcome Gift" in out["incentives"] + assert "Free Movie" in out["incentives"] + + def test_volume_field_as_number_not_string(self): + # Catalog §8: SIG_caccf1fa28bc — 52 Origin plans ship rates[].volume + # as a number (spec says string). Parser doesn't read volume but + # unitPrice can also be number; pin that float() coerces both. + block = {"displayName": "Anytime", + "rates": [{"unitPrice": 0.275, "volume": 1500}]} + result = _summarise_import_rate(_wrap("singleRate", block)) + # 0.275 × 110 = 30.25, displayed via :.1f → "30.3" (banker's rounding) + assert "30.3" in result, result + + def test_full_summary_handles_origin_top_signature(self): + # Catalog §3: SIG_f12c7686760c — 78 plans, Origin's most common + # shape. Pin that the full _summarise_cdr_plan walks it cleanly. + out = _summarise_cdr_plan({"data": { + "brandName": "Origin Energy", + "displayName": "Anytime Plus", + "effectiveFrom": "2025-12-01", + "electricityContract": { + "pricingModel": "TIME_OF_USE_CONT_LOAD", + "tariffPeriod": [{ + "dailySupplyCharge": "0.95", + "dailySupplyChargeType": "SINGLE", + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "PEAK", "displayName": "Peak", "period": "P1D", + "rates": [{"unitPrice": "0.42"}], "timeOfUse": []}, + {"type": "SHOULDER", "displayName": "Shoulder", "period": "P1D", + "rates": [{"unitPrice": "0.30"}], "timeOfUse": []}, + {"type": "OFF_PEAK", "displayName": "Off Peak", "period": "P1D", + "rates": [{"unitPrice": "0.20"}], "timeOfUse": []}, + ], + }], + "solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "scheme": "OTHER", + "payerType": "RETAILER", + "singleTariff": {"period": "P1D", + "rates": [{"unitPrice": "0.05"}]}, + }], + "incentives": [{"category": "OTHER", "displayName": "Loyalty Credit"}], + "controlledLoad": [{ + "displayName": "Hot Water", + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.18"}]}, + }], + }, + }}) + assert out["brand"] == "Origin Energy" + assert out["plan_name"] == "Anytime Plus" + assert out["effective"] == "2025-12-01" + assert "104.50" in out["daily_supply"] + assert "PEAK 46.2" in out["import_rate"] + assert "SHOULDER 33.0" in out["import_rate"] + assert "OFF_PEAK 22.0" in out["import_rate"] + assert "5.50" in out["feed_in"] + assert "Loyalty Credit" in out["incentives"] + assert "Hot Water" in out["controlled_load"] + assert "19.8" in out["controlled_load"] # 0.18 × 110 diff --git a/tests/test_cdr_bonus_fit.py b/tests/test_cdr_bonus_fit.py new file mode 100644 index 0000000..905f0d0 --- /dev/null +++ b/tests/test_cdr_bonus_fit.py @@ -0,0 +1,317 @@ +"""Tests for cdr.incentive_parsers.common.bonus_fit — Phase 2.11.3. + +Pin behaviour against the exact ZEROHERO eligibility text observed +in catalog v3 sweep + GLO731031MR@VEC live fetch. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers.common.bonus_fit import ( + apply_capped_window, + apply_uncapped_window, + parse_capped_window, + parse_from_incentives, + parse_uncapped_window, +) + + +@dataclass +class _StubBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Regex coverage — ZEROHERO live samples +# --------------------------------------------------------------------------- + + +class TestParseUncappedWindow: + def test_zerohero_peak_solar_feed_in_5c(self): + # Catalog: "5 cents/kWh applies to exports between 4pm-11pm + # (Local Time) everyday." (ZEROHERO VPP variant) + text = ("5 cents/kWh applies to exports between 4pm-11pm " + "(Local Time) everyday.") + rule = parse_uncapped_window(text) + assert rule is not None + assert rule["bonus_c_per_kwh"] == Decimal("5") + assert rule["start_min"] == 16 * 60 + assert rule["end_min"] == 23 * 60 + + def test_zerohero_peak_solar_feed_in_2c_live(self): + # Live fetch GLO731031MR@VEC: "2 cents/kWh applies to exports + # between 4pm-11pm (Local Time) everyday." + text = ("2 cents/kWh applies to exports between 4pm-11pm " + "(Local Time) everyday.") + rule = parse_uncapped_window(text) + assert rule is not None + assert rule["bonus_c_per_kwh"] == Decimal("2") + + def test_capped_text_does_not_match_uncapped(self): + # Super Export text mentions "first N kWh" — uncapped parser must + # NOT false-positive on the 15-cent rate. + text = ("15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm (Local Time) everyday") + assert parse_uncapped_window(text) is None + + def test_empty_returns_none(self): + assert parse_uncapped_window("") is None + assert parse_uncapped_window(None) is None # type: ignore[arg-type] + + def test_unrelated_text_returns_none(self): + assert parse_uncapped_window("$50 sign-up credit") is None + + +class TestParseCappedWindow: + def test_zerohero_super_export_15c_live(self): + # Live fetch GLO731031MR@VEC: full Super Export Credit text. + text = ("15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm (Local Time) everyday, and is " + "inclusive of any other Feed-in tariff as applicable " + "in Energy Plan.") + rule = parse_capped_window(text) + assert rule is not None + assert rule["bonus_c_per_kwh"] == Decimal("15") + assert rule["cap_kwh_per_day"] == Decimal("15") + assert rule["start_min"] == 18 * 60 + assert rule["end_min"] == 21 * 60 + + def test_uncapped_text_does_not_match_capped(self): + text = ("2 cents/kWh applies to exports between 4pm-11pm " + "(Local Time) everyday.") + assert parse_capped_window(text) is None + + +# --------------------------------------------------------------------------- +# Math — apply_uncapped_window +# --------------------------------------------------------------------------- + + +class TestApplyUncappedWindow: + def test_peak_fit_credits_only_in_window(self): + # 2c × 5 kWh in window + 0 outside. + # Credit = -0.10 AUD (negative = user gets money) + rule = parse_uncapped_window( + "2 cents/kWh applies to exports between 4pm-11pm everyday." + ) + assert rule is not None + slots = [ + {"ts_local": "2026-05-15T15:00:00", "grid_export_kwh": 3.0}, # 3pm — outside + {"ts_local": "2026-05-15T17:00:00", "grid_export_kwh": 5.0}, # 5pm — inside + {"ts_local": "2026-05-15T23:00:00", "grid_export_kwh": 2.0}, # 11pm — outside (end exclusive) + ] + b = _StubBreakdown() + apply_uncapped_window(rule, slots, b) + assert b.incentive_aud_inc_gst == Decimal("-0.10") + assert len(b.trace) == 1 + assert b.trace[0]["credited_kwh"] == 5.0 + + def test_zero_export_in_window_no_credit(self): + rule = parse_uncapped_window( + "5 cents/kWh applies to exports between 4pm-11pm everyday." + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T17:00:00", "grid_export_kwh": 0.0}] + b = _StubBreakdown() + apply_uncapped_window(rule, slots, b) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.trace == [] + + +# --------------------------------------------------------------------------- +# Math — apply_capped_window +# --------------------------------------------------------------------------- + + +class TestApplyCappedWindow: + def test_super_export_first_15kwh_credited(self): + # 20 kWh exported in 6-9pm window. Cap 15 kWh/day. + # Credit = 15c × 15 kWh / 100 = 2.25 AUD + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T18:00:00", "grid_export_kwh": 20.0}] + b = _StubBreakdown() + apply_capped_window(rule, slots, b) + assert b.incentive_aud_inc_gst == Decimal("-2.25") + + def test_super_export_below_cap(self): + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T18:00:00", "grid_export_kwh": 8.0}] + b = _StubBreakdown() + apply_capped_window(rule, slots, b) + # 15c × 8 / 100 = 1.20 + assert b.incentive_aud_inc_gst == Decimal("-1.20") + + def test_cap_resets_each_day(self): + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [ + {"ts_local": "2026-05-15T18:00:00", "grid_export_kwh": 20.0}, + {"ts_local": "2026-05-16T18:00:00", "grid_export_kwh": 20.0}, + ] + b = _StubBreakdown() + apply_capped_window(rule, slots, b) + # Each day caps at 15 kWh × 15c = 2.25, total 4.50 + assert b.incentive_aud_inc_gst == Decimal("-4.50") + + def test_export_outside_window_ignored(self): + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T15:00:00", "grid_export_kwh": 10.0}] + b = _StubBreakdown() + apply_capped_window(rule, slots, b) + assert b.incentive_aud_inc_gst == Decimal("0") + + def test_overlap_fix_subtracts_uncapped_rate(self): + """Phase 2.11.10: when Peak FIT (uncapped) overlaps Super Export + (capped), capped credit should be DELTA so total = capped rate. + + ZEROHERO scenario: Peak FIT 2c (4-11pm) + Super Export 15c + (6-9pm first 15 kWh). For 10 kWh exported in 7-8pm slot: + - Uncapped already credits 10 × 2c = 20c + - Capped credits 10 × (15-2) = 130c + - Total: 150c → equivalent to flat 15c × 10 = catalog math ✓ + """ + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T19:00:00", "grid_export_kwh": 10.0}] + b = _StubBreakdown() + apply_capped_window( + rule, slots, b, + overlap_uncapped_rate_c_per_kwh=Decimal("2"), + ) + # Capped at (15 - 2) = 13c × 10 kWh / 100 = 1.30 + assert b.incentive_aud_inc_gst == Decimal("-1.30") + # Trace includes both raw + effective rates for observability + assert b.trace[0]["rate_c_per_kwh"] == 15.0 + assert b.trace[0]["effective_rate_c_per_kwh"] == 13.0 + + def test_overlap_fix_zero_when_capped_eq_uncapped(self): + """Edge: if uncapped rate == capped rate, no incremental credit + (capped is fully covered by uncapped). Don't write trace entry.""" + rule = parse_capped_window( + "5 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T19:00:00", "grid_export_kwh": 10.0}] + b = _StubBreakdown() + apply_capped_window( + rule, slots, b, + overlap_uncapped_rate_c_per_kwh=Decimal("5"), + ) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.trace == [] + + def test_overlap_fix_no_overlap_unchanged(self): + """Default overlap=0 keeps Phase 2.11.3 behaviour.""" + rule = parse_capped_window( + "15 cents/kWh applies to the first 15 kWh of exports " + "between 6pm-9pm everyday" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T18:00:00", "grid_export_kwh": 10.0}] + b = _StubBreakdown() + apply_capped_window(rule, slots, b) # overlap defaults to 0 + # Full 15c × 10 / 100 = 1.50 + assert b.incentive_aud_inc_gst == Decimal("-1.50") + + +# --------------------------------------------------------------------------- +# parse_from_incentives — full plan walk +# --------------------------------------------------------------------------- + + +class TestParseFromIncentives: + def test_zerohero_full_incentives_block(self): + # Real ZEROHERO incentives block — should extract Peak FIT (uncapped) + # AND Super Export (capped). + incentives = [ + {"displayName": "Perfect if you love free stuff", + "eligibility": ("$0.00 for consumption between 11am-2pm " + "(Local Time), excluding controlled load.")}, + {"displayName": "ZEROHERO Credit", + "eligibility": ("$1/Day when imports are 0.03 kWh/hour or " + "less, between 6pm-9pm (Local Time).")}, + {"displayName": "Super Export Credit", + "eligibility": ("15 cents/kWh applies to the first 15 kWh " + "of exports between 6pm-9pm (Local Time) " + "everyday, and is inclusive of any other " + "Feed-in tariff as applicable in Energy Plan.")}, + {"displayName": "Peak solar feed-in", + "eligibility": ("2 cents/kWh applies to exports between " + "4pm-11pm (Local Time) everyday.")}, + ] + out = parse_from_incentives(incentives) + assert len(out["capped"]) == 1 + assert out["capped"][0]["bonus_c_per_kwh"] == Decimal("15") + assert out["capped"][0]["source_displayName"] == "Super Export Credit" + assert len(out["uncapped"]) == 1 + assert out["uncapped"][0]["bonus_c_per_kwh"] == Decimal("2") + assert out["uncapped"][0]["source_displayName"] == "Peak solar feed-in" + + def test_no_match_returns_empty_lists(self): + out = parse_from_incentives([ + {"displayName": "Welcome", "eligibility": "$50 sign-up"}, + ]) + assert out["capped"] == [] + assert out["uncapped"] == [] + + def test_empty_input(self): + out = parse_from_incentives([]) + assert out == {"capped": [], "uncapped": []} + out = parse_from_incentives(None) # type: ignore[arg-type] + assert out == {"capped": [], "uncapped": []} + + +# --------------------------------------------------------------------------- +# End-to-end through globird.py dispatch — Phase 2.11.3 wiring +# --------------------------------------------------------------------------- + + +class TestGlobirdDispatchE2E: + """Verify globird.py wires the new Peak FIT (uncapped) bonus through + the apply_retailer_incentives dispatch chain. + """ + + def test_zerohero_peak_fit_credited_via_dispatch(self): + from custom_components.pricehawk.cdr.incentive_parsers import ( + apply_retailer_incentives, + ) + + # Minimal ZEROHERO plan with Peak FIT eligibility. + # 5 kWh exported at 5pm (in 4-11pm window) → 2c × 5 = 0.10 credit. + plan = { + "brand": "globird", + "electricityContract": { + "incentives": [{ + "displayName": "Peak solar feed-in", + "eligibility": ("2 cents/kWh applies to exports between " + "4pm-11pm (Local Time) everyday."), + }], + }, + } + slots = [{"ts_local": "2026-05-15T17:00:00", "grid_export_kwh": 5.0}] + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=lambda *a, **kw: False) + assert b.incentive_aud_inc_gst == Decimal("-0.10") + assert any("peak_fit" in n for n in b.notes), b.notes diff --git a/tests/test_cdr_client.py b/tests/test_cdr_client.py new file mode 100644 index 0000000..6692364 --- /dev/null +++ b/tests/test_cdr_client.py @@ -0,0 +1,248 @@ +"""Tests for cdr.cdr_client — Phase 2.0 async CDR HTTP client. + +Pure-Python helper coverage + AsyncMock-driven coverage of the +retry/error-mapping logic in `_get_json`. We avoid spinning up an +aiohttp TestServer to keep the test suite import-free of CI deps and +match the lightweight style of `test_aemo_api.py`. +""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.pricehawk.cdr.cdr_client import ( + CdrAPIError, + CdrPlanNotFound, + CdrUnavailable, + build_detail_envelope_for_test, + build_list_envelope_for_test, + fetch_plan_detail, + fetch_plan_list, + filter_residential_electricity_for_test, +) + + +# --------------------------------------------------------------------------- +# Pure-Python helpers +# --------------------------------------------------------------------------- + + +class TestEnvelopeBuilders: + def test_list_envelope_shape(self): + env = build_list_envelope_for_test( + [{"planId": "A", "displayName": "Plan A"}] + ) + assert env["data"]["plans"][0]["planId"] == "A" + assert env["meta"]["totalPages"] == 1 + assert env["meta"]["totalRecords"] == 1 + + def test_detail_envelope_shape(self): + env = build_detail_envelope_for_test({"planId": "X", "displayName": "X"}) + assert env["data"]["planId"] == "X" + assert "links" in env + + +class TestResidentialFilter: + def test_keeps_residential_electricity_market(self): + plans = [ + {"customerType": "RESIDENTIAL", "fuelType": "ELECTRICITY", "planId": "A"}, + {"customerType": "BUSINESS", "fuelType": "ELECTRICITY", "planId": "B"}, + {"customerType": "RESIDENTIAL", "fuelType": "GAS", "planId": "C"}, + ] + result = filter_residential_electricity_for_test(plans) + assert [p["planId"] for p in result] == ["A"] + + def test_empty_list_is_empty(self): + assert filter_residential_electricity_for_test([]) == [] + + +# --------------------------------------------------------------------------- +# Async retry / error-mapping behaviour (driven via AsyncMock) +# --------------------------------------------------------------------------- + + +def _mock_session_returning( + *responses: tuple[int, dict | None], +) -> MagicMock: + """Build a mock aiohttp.ClientSession whose .get() context-manager yields + the queued (status, json_body) tuples in order.""" + session = MagicMock() + queue = list(responses) + + def _get(url, **_kwargs): + status, body = queue.pop(0) + resp = MagicMock() + resp.status = status + resp.json = AsyncMock(return_value=body or {}) + resp.text = AsyncMock(return_value="") + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session.get = MagicMock(side_effect=_get) + return session + + +@pytest.fixture(autouse=True) +def _no_real_sleep(monkeypatch): + """Replace cdr_client's asyncio.sleep with a no-op so retry backoffs do + not block tests. Only patches `sleep` on the module's asyncio reference; + leaves the rest of the asyncio API intact.""" + from custom_components.pricehawk.cdr import cdr_client as _mod + + async def _instant_sleep(_secs): + return None + + monkeypatch.setattr(_mod.asyncio, "sleep", _instant_sleep) + + +def test_fetch_plan_list_happy_path(): + plans = [ + {"planId": "A", "customerType": "RESIDENTIAL", "fuelType": "ELECTRICITY"}, + {"planId": "B", "customerType": "BUSINESS", "fuelType": "ELECTRICITY"}, + ] + envelope = build_list_envelope_for_test(plans) + session = _mock_session_returning((200, envelope)) + + result = asyncio.run(fetch_plan_list(session, "https://test")) + + # Boundary filter strips non-residential. + assert [p["planId"] for p in result] == ["A"] + + +def test_fetch_plan_list_paginates(): + page1 = { + "data": {"plans": [ + {"planId": "A", "customerType": "RESIDENTIAL", "fuelType": "ELECTRICITY"}, + ]}, + "meta": {"totalPages": 2}, + } + page2 = { + "data": {"plans": [ + {"planId": "B", "customerType": "RESIDENTIAL", "fuelType": "ELECTRICITY"}, + ]}, + "meta": {"totalPages": 2}, + } + session = _mock_session_returning((200, page1), (200, page2)) + + result = asyncio.run(fetch_plan_list(session, "https://test")) + + assert [p["planId"] for p in result] == ["A", "B"] + + +def test_fetch_plan_detail_happy_path(): + detail = build_detail_envelope_for_test({"planId": "Z", "displayName": "Z"}) + session = _mock_session_returning((200, detail)) + + result = asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + assert result["data"]["planId"] == "Z" + + +def test_fetch_plan_detail_404_raises_plan_not_found(): + session = _mock_session_returning((404, None)) + + with pytest.raises(CdrPlanNotFound): + asyncio.run(fetch_plan_detail(session, "https://test", "stale")) + + +def test_5xx_retries_then_succeeds(): + detail = build_detail_envelope_for_test({"planId": "Z"}) + session = _mock_session_returning((503, None), (200, detail)) + + result = asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + assert result["data"]["planId"] == "Z" + + +def test_5xx_retries_exhausted_raises_unavailable(): + session = _mock_session_returning( + (503, None), (503, None), (503, None), + ) + + with pytest.raises(CdrUnavailable): + asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + +def test_429_retries_then_succeeds(): + detail = build_detail_envelope_for_test({"planId": "Z"}) + session = _mock_session_returning((429, None), (200, detail)) + + result = asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + assert result["data"]["planId"] == "Z" + + +def test_unexpected_4xx_raises_api_error(): + session = _mock_session_returning((400, None)) + + with pytest.raises(CdrAPIError): + asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + +# --------------------------------------------------------------------------- +# Brand disambiguation (Phase 3.1 prep) — shared base URIs need ?brand= +# --------------------------------------------------------------------------- + + +def _mock_session_capturing(*responses: tuple[int, dict | None]): + """Like _mock_session_returning but also records every URL requested + so tests can assert query-string composition.""" + seen: list[str] = [] + queue = list(responses) + session = MagicMock() + + def _get(url, **_kwargs): + seen.append(url) + status, body = queue.pop(0) + resp = MagicMock() + resp.status = status + resp.json = AsyncMock(return_value=body or {}) + resp.text = AsyncMock(return_value="") + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session.get = MagicMock(side_effect=_get) + return session, seen + + +def test_fetch_plan_list_appends_brand_when_set(): + envelope = build_list_envelope_for_test([]) + session, seen = _mock_session_capturing((200, envelope)) + + asyncio.run(fetch_plan_list(session, "https://test", brand="arcline")) + + assert len(seen) == 1 + assert "brand=arcline" in seen[0] + + +def test_fetch_plan_list_omits_brand_param_when_none(): + envelope = build_list_envelope_for_test([]) + session, seen = _mock_session_capturing((200, envelope)) + + asyncio.run(fetch_plan_list(session, "https://test")) + + assert "brand=" not in seen[0] + + +def test_fetch_plan_detail_appends_brand_when_set(): + detail = build_detail_envelope_for_test({"planId": "Z"}) + session, seen = _mock_session_capturing((200, detail)) + + asyncio.run(fetch_plan_detail(session, "https://test", "Z", brand="cooperative")) + + assert "?brand=cooperative" in seen[0] + + +def test_fetch_plan_detail_omits_brand_when_none(): + detail = build_detail_envelope_for_test({"planId": "Z"}) + session, seen = _mock_session_capturing((200, detail)) + + asyncio.run(fetch_plan_detail(session, "https://test", "Z")) + + assert "?" not in seen[0] diff --git a/tests/test_cdr_ev_offpeak.py b/tests/test_cdr_ev_offpeak.py new file mode 100644 index 0000000..3a7dd78 --- /dev/null +++ b/tests/test_cdr_ev_offpeak.py @@ -0,0 +1,205 @@ +"""Tests for ev_offpeak.py (Phase 2.11.6). + +Covers the EV midnight-6am rate-override parser. Math delegated to +free_window.apply_rule — we test parse + integration only here, since +free_window has its own test_cdr_free_window.py covering apply math. +""" +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from custom_components.pricehawk.cdr.incentive_parsers.common.ev_offpeak import ( + RATE_RE, + TRIGGER_RE, + WINDOW_RE, + _token_to_minutes, + parse_from_incentives, + parse_rule, +) + + +# --- Time-token parser ------------------------------------------------- + + +def test_token_midnight(): + assert _token_to_minutes("midnight") == 0 + + +def test_token_noon(): + assert _token_to_minutes("noon") == 12 * 60 + + +def test_token_6am(): + assert _token_to_minutes("6am") == 360 + + +def test_token_11_30pm(): + assert _token_to_minutes("11:30pm") == 23 * 60 + 30 + + +def test_token_12am_is_zero(): + assert _token_to_minutes("12am") == 0 + + +def test_token_12pm_is_noon(): + assert _token_to_minutes("12pm") == 12 * 60 + + +def test_token_invalid_raises(): + with pytest.raises(ValueError): + _token_to_minutes("3 o'clock") + + +# --- Regex coverage ---------------------------------------------------- + + +def test_trigger_matches_usage_charge(): + assert TRIGGER_RE.search("$0.045/kWh usage charge between midnight and 6am") + + +def test_trigger_matches_applied_to_import(): + assert TRIGGER_RE.search("$0.08/kWh between midnight and 7am, applied to all import") + + +def test_trigger_matches_overnight(): + assert TRIGGER_RE.search("overnight rate of $0.10/kWh from 12am to 6am") + + +def test_trigger_does_not_match_freeword(): + # free_window territory — should NOT trigger ev_offpeak. + assert not TRIGGER_RE.search("Free electricity between 11am and 2pm everyday") + + +def test_window_matches_midnight_to_6am(): + m = WINDOW_RE.search("between midnight and 6am") + assert m + assert m.group("start") == "midnight" + assert m.group("end") == "6am" + + +def test_window_matches_from_12am_to_6am(): + m = WINDOW_RE.search("from 12am to 6am") + assert m + assert m.group("start") == "12am" + assert m.group("end") == "6am" + + +def test_rate_matches_dollar_decimal_per_kwh(): + m = RATE_RE.search("$0.045/kWh") + assert m + assert m.group("rate") == "0.045" + + +def test_rate_matches_dollar_no_per_kwh(): + m = RATE_RE.search("flat $0.10 overnight") + assert m + assert m.group("rate") == "0.10" + + +# --- parse_rule end-to-end -------------------------------------------- + + +def test_parse_rule_ovo_canonical(): + """Canonical OVO eligibility wording.""" + rule = parse_rule("$0.045/kWh usage charge between midnight and 6am.") + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("4.5") + assert rule["windows"] == [(0, 360)] + + +def test_parse_rule_engie_with_disclaimer(): + """ENGIE wording (applied-to-import trigger) with controlled-load disclaimer.""" + rule = parse_rule( + "$0.08/kWh between midnight and 7am, applied to all import. " + "Does not apply to controlled loads." + ) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("8.0") + assert rule["windows"] == [(0, 420)] + + +def test_parse_rule_overnight_keyword(): + rule = parse_rule("Flat $0.10/kWh overnight from 12am to 6am.") + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("10.0") + assert rule["windows"] == [(0, 360)] + + +def test_parse_rule_empty_returns_none(): + assert parse_rule("") is None + + +def test_parse_rule_no_trigger_returns_none(): + # Has rate + window but no ev_offpeak trigger word. + assert parse_rule("$0.05/kWh between midnight and 6am") is None + + +def test_parse_rule_no_window_returns_none(): + assert parse_rule("$0.045/kWh usage charge applies") is None + + +def test_parse_rule_freeword_falls_through(): + """Free-window pattern doesn't trigger ev_offpeak.""" + assert parse_rule("Free electricity between 11am and 2pm everyday.") is None + + +def test_parse_rule_source_truncated(): + long_text = "$0.045/kWh usage charge between midnight and 6am. " + "x" * 300 + rule = parse_rule(long_text) + assert rule is not None + assert len(rule["source"]) <= 200 + + +# --- parse_from_incentives integration ------------------------------- + + +def test_parse_from_incentives_finds_eligibility_field(): + incs = [ + { + "displayName": "EV Off-Peak", + "eligibility": "$0.045/kWh usage charge between midnight and 6am.", + } + ] + rules = parse_from_incentives(incs) + assert len(rules) == 1 + assert rules[0]["rate_c_per_kwh"] == Decimal("4.5") + assert rules[0]["source_displayName"] == "EV Off-Peak" + + +def test_parse_from_incentives_falls_back_to_description(): + incs = [ + { + "displayName": "EV Charging", + "description": "$0.08/kWh between midnight and 7am for vehicle charging.", + "eligibility": "", + } + ] + rules = parse_from_incentives(incs) + assert len(rules) == 1 + assert rules[0]["rate_c_per_kwh"] == Decimal("8.0") + + +def test_parse_from_incentives_skips_unrelated(): + """Non-EV incentives should be skipped.""" + incs = [ + { + "displayName": "Free 3", + "eligibility": "Free electricity between 11am and 2pm everyday.", + }, + { + "displayName": "Sign-Up Credit", + "eligibility": "$50 credit on first bill.", + }, + ] + assert parse_from_incentives(incs) == [] + + +def test_parse_from_incentives_multiple_rules(): + incs = [ + {"displayName": "EV", "eligibility": "$0.045/kWh usage charge between midnight and 6am."}, + {"displayName": "EV2", "eligibility": "$0.08/kWh applied to import between 12am and 7am."}, + ] + rules = parse_from_incentives(incs) + assert len(rules) == 2 diff --git a/tests/test_cdr_evaluator.py b/tests/test_cdr_evaluator.py new file mode 100644 index 0000000..a3ad787 --- /dev/null +++ b/tests/test_cdr_evaluator.py @@ -0,0 +1,115 @@ +"""Smoke tests for cdr/evaluator.py — Phase 1.1 port verification. + +Uses the Phase 0 fixtures committed in `tests/fixtures/phase0/` plus the +golden numbers verified by `scripts/phase_0_verify.py` (0.0000% cross- +check) and `scripts/phase_1_parity.py` (0.46% legacy parity). + +These tests pin the evaluator's output. If you change evaluator +behaviour and these golden numbers change, update the docstring + +verify with `phase_0_verify.py --markdown` and `phase_1_parity.py`. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from custom_components.pricehawk.cdr import CostBreakdown, evaluate +from custom_components.pricehawk.cdr.models import ( + ConsumptionWindow, + PlanDetailEnvelope, +) + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "phase0" + + +def _load(name: str) -> dict: + return json.loads((FIXTURE_DIR / name).read_text()) + + +# --- Golden totals (verified by phase_0_verify.py 2026-05-14) --- +GOLDEN = { + # plan_fixture, consumption_fixture: expected total_aud_inc_gst (to 2 d.p.) + ("plan_agl_AGL907738MRE6@EME.json", "consumption_7d.json"): 89.40, + ("plan_red-energy_RED552831MRE15@EME.json", "consumption_7d.json"): 86.67, + ("plan_c1_flexible_synthetic.json", "consumption_7d.json"): 88.71, + ("plan_globird_GLO731031MR@VEC.json", "consumption_7d.json"): 65.42, + ("plan_red-energy_RED552831MRE15@EME.json", "consumption_dst_april_2026-04-05.json"): 6.86, + ("plan_red-energy_RED552831MRE15@EME.json", "consumption_dst_october_2026-10-04.json"): 6.48, +} + + +@pytest.mark.parametrize("plan_f,cons_f,expected_inc_gst", [ + (p, c, total) for (p, c), total in GOLDEN.items() +]) +def test_phase_0_golden_totals(plan_f: str, cons_f: str, expected_inc_gst: float) -> None: + plan = _load(plan_f) + cons = _load(cons_f) + bd = evaluate(plan, cons) + assert isinstance(bd, CostBreakdown) + actual = float(bd.total_aud_inc_gst.quantize(__import__("decimal").Decimal("0.01"))) + assert actual == pytest.approx(expected_inc_gst, abs=0.01), ( + f"{plan_f}/{cons_f}: expected ${expected_inc_gst:.2f}, got ${actual:.2f}" + ) + + +def test_evaluate_accepts_pydantic_envelope() -> None: + raw = _load("plan_agl_AGL907738MRE6@EME.json") + env = PlanDetailEnvelope.model_validate(raw) + cons_raw = _load("consumption_7d.json") + cons = ConsumptionWindow.model_validate(cons_raw) + bd = evaluate(env, cons) + assert bd.total_aud_inc_gst > 0 + # pydantic-validated input should match raw-dict input within rounding + bd_raw = evaluate(raw, cons_raw) + assert bd.total_aud_inc_gst == bd_raw.total_aud_inc_gst + + +def test_evaluate_globird_parser_credits_zerohero() -> None: + """Plan C2 must show globird parser hits in notes + incentive credit.""" + plan = _load("plan_globird_GLO731031MR@VEC.json") + cons = _load("consumption_7d.json") + bd = evaluate(plan, cons) + assert any("globird parser hits" in n for n in bd.notes), bd.notes + assert bd.incentive_aud_inc_gst < 0, "expected at least one credit applied" + + +def test_evaluate_runs_without_incentives_when_flag_off() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + cons = _load("consumption_7d.json") + bd_off = evaluate(plan, cons, run_incentives=False) + bd_on = evaluate(plan, cons, run_incentives=True) + # Off path: no incentive credit + assert bd_off.incentive_aud_inc_gst == 0 + # On path: at least some credit + assert bd_on.incentive_aud_inc_gst < 0 + # Without incentives, total must be higher (no credit subtracted) + assert bd_off.total_aud_inc_gst > bd_on.total_aud_inc_gst + + +def test_dst_april_50_slot_count() -> None: + plan = _load("plan_red-energy_RED552831MRE15@EME.json") + cons = _load("consumption_dst_april_2026-04-05.json") + bd = evaluate(plan, cons) + assert bd.slot_count == 50, "Apr 5 DST-backward day should be 50 half-hour slots (25h)" + assert bd.period_days == 1 + + +def test_dst_october_46_slot_count() -> None: + plan = _load("plan_red-energy_RED552831MRE15@EME.json") + cons = _load("consumption_dst_october_2026-10-04.json") + bd = evaluate(plan, cons) + assert bd.slot_count == 46, "Oct 4 DST-forward day should be 46 half-hour slots (23h)" + assert bd.period_days == 1 + + +def test_summary_returns_inc_gst_floats() -> None: + plan = _load("plan_agl_AGL907738MRE6@EME.json") + cons = _load("consumption_7d.json") + bd = evaluate(plan, cons) + s = bd.summary() + assert "total_aud_inc_gst" in s + assert s["period_days"] == 7 + assert s["slot_count"] == 336 + assert isinstance(s["total_aud_inc_gst"], float) diff --git a/tests/test_cdr_free_window.py b/tests/test_cdr_free_window.py new file mode 100644 index 0000000..480fb65 --- /dev/null +++ b/tests/test_cdr_free_window.py @@ -0,0 +1,374 @@ +"""Tests for cdr.incentive_parsers.common.free_window — Phase 2.11.4. + +Pin behaviour against the 5 catalog-confirmed wordings observed across +214 plans (GloBird, AGL, OVO, Red). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers.common.free_window import ( + apply_rule, + parse_from_incentives, + parse_rule, +) + + +@dataclass +class _StubBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# parse_rule — all 5 catalog wordings +# --------------------------------------------------------------------------- + + +class TestParseFreeWordings: + def test_ovo_free_3(self): + # OVO/MYOB "Free 3" — 38 plans + text = ("Free electricity between 11am and 2pm everyday. " + "For more information head to https://pages.ovoenergy.com.au/the-free-3-plan") + rule = parse_rule(text) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("0") + assert rule["windows"] == [(11 * 60, 14 * 60)] + + def test_agl_three_for_free_usage(self): + # AGL "Three for Free Usage" + text = ("Free electricity usage applies from 10am to 1pm every day. " + "Daily supply charges still apply. This rate can change with " + "notice to you.") + rule = parse_rule(text) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("0") + assert rule["windows"] == [(10 * 60, 13 * 60)] + + def test_globird_four_hour_free(self): + # GloBird "Four-hour free usage every day" + text = ("$0.00 for consumption between 10am-2pm (Local Time), " + "excluding controlled load.") + rule = parse_rule(text) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("0") + assert rule["windows"] == [(10 * 60, 14 * 60)] + + def test_globird_perfect_if_you_love_free_stuff(self): + # ZEROHERO 3-for-Free + text = ("$0.00 for consumption between 11am-2pm (Local Time), " + "excluding controlled load.") + rule = parse_rule(text) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("0") + assert rule["windows"] == [(11 * 60, 14 * 60)] + + +class TestParseDiscountedTwoWindow: + def test_globird_nine_hour_low_ev_rate(self): + # GloBird "Nine-hour low EV rate" — TWO windows joined by &. + text = ("$0.06/kWh incl. GST for consumption between 11am-2pm & " + "12am-6am (Local Time), excluding controlled load.") + rule = parse_rule(text) + assert rule is not None + assert rule["rate_c_per_kwh"] == Decimal("6.00") + assert len(rule["windows"]) == 2 + assert rule["windows"][0] == (11 * 60, 14 * 60) + assert rule["windows"][1] == (0, 6 * 60) + + +class TestParseEdgeCases: + def test_empty_returns_none(self): + assert parse_rule("") is None + assert parse_rule(None) is None # type: ignore[arg-type] + + def test_unrelated_text_returns_none(self): + assert parse_rule("Receive 3 Velocity Points per $1") is None + + def test_no_window_returns_none(self): + # "Free electricity" without a time window is just marketing. + assert parse_rule("Free electricity for everyone!") is None + + +# --------------------------------------------------------------------------- +# apply_rule — math semantics +# --------------------------------------------------------------------------- + + +class TestApplyFreeWindow: + def test_zero_rate_credits_full_normal_rate(self): + # Free 3: 5 kWh imported at noon, normal rate 30c/kWh inc-GST. + # Credit = (30 - 0) / 100 × 5 = 1.50 AUD + rule = parse_rule( + "Free electricity between 11am and 2pm everyday." + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + assert b.incentive_aud_inc_gst == Decimal("-1.50") + + def test_discounted_rate_credits_delta(self): + # 9-hour EV rate: 5 kWh at noon, normal 30c, discount to 6c. + # Credit = (30 - 6) / 100 × 5 = 1.20 AUD + rule = parse_rule( + "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T13:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + assert b.incentive_aud_inc_gst == Decimal("-1.20") + + def test_two_windows_both_credit(self): + # 9-hour EV rate: imports in BOTH windows credited. + rule = parse_rule( + "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am" + ) + assert rule is not None + slots = [ + {"ts_local": "2026-05-15T03:00:00", "grid_import_kwh": 4.0}, # 3am — window 2 + {"ts_local": "2026-05-15T08:00:00", "grid_import_kwh": 2.0}, # 8am — outside + {"ts_local": "2026-05-15T13:00:00", "grid_import_kwh": 5.0}, # 1pm — window 1 + ] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + # (30 - 6)/100 × (4 + 5) = 0.24 × 9 = 2.16 + assert b.incentive_aud_inc_gst == Decimal("-2.16") + + def test_outside_window_no_credit(self): + rule = parse_rule( + "Free electricity between 11am and 2pm everyday." + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T15:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.trace == [] + + def test_zero_normal_rate_no_credit(self): + # If tariff already encodes 0c during window (GloBird Flex + # 11am-2pm), normal_rate=0 → no credit, no double-counting. + rule = parse_rule( + "$0.00 for consumption between 11am-2pm (Local Time)" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("0")) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.trace == [] + + def test_normal_below_free_no_credit(self): + # Edge case: normal_rate < free_rate. delta is negative; we don't + # CHARGE the user extra — we just no-op. + rule = parse_rule( + "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("3")) + assert b.incentive_aud_inc_gst == Decimal("0") + + def test_zero_import_no_credit(self): + rule = parse_rule( + "Free electricity between 11am and 2pm everyday." + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 0.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + assert b.incentive_aud_inc_gst == Decimal("0") + + def test_trace_records_window_strings(self): + rule = parse_rule( + "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am" + ) + assert rule is not None + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 1.0}] + b = _StubBreakdown() + apply_rule(rule, slots, b, + normal_import_rate_c_per_kwh_inc_gst=Decimal("30")) + assert len(b.trace) == 1 + t = b.trace[0] + assert t["incentive"] == "free_window" + assert t["free_rate_c_per_kwh"] == 6.0 + assert t["normal_rate_c_per_kwh"] == 30.0 + assert t["windows"] == "11:00-14:00 & 00:00-06:00" + + +# --------------------------------------------------------------------------- +# parse_from_incentives — full plan walk +# --------------------------------------------------------------------------- + + +class TestParseFromIncentives: + def test_single_free_window_rule_extracted(self): + incentives = [{ + "displayName": "Free 3", + "eligibility": "Free electricity between 11am and 2pm everyday.", + }] + rules = parse_from_incentives(incentives) + assert len(rules) == 1 + assert rules[0]["rate_c_per_kwh"] == Decimal("0") + assert rules[0]["source_displayName"] == "Free 3" + + def test_multiple_rules_per_plan(self): + # ZEROHERO ships both "Perfect if you love free stuff" (free) + # AND "Nine-hour low EV rate" (discounted) on some variants. + incentives = [ + {"displayName": "Perfect if you love free stuff", + "eligibility": "$0.00 for consumption between 11am-2pm"}, + {"displayName": "Nine-hour low EV rate", + "eligibility": ("$0.06/kWh incl. GST for consumption between " + "11am-2pm & 12am-6am (Local Time)")}, + ] + rules = parse_from_incentives(incentives) + assert len(rules) == 2 + assert rules[0]["rate_c_per_kwh"] == Decimal("0") + assert rules[1]["rate_c_per_kwh"] == Decimal("6.00") + + def test_no_match_returns_empty(self): + incentives = [{"displayName": "Welcome", "eligibility": "$50 sign-up"}] + assert parse_from_incentives(incentives) == [] + + def test_empty_input(self): + assert parse_from_incentives([]) == [] + assert parse_from_incentives(None) == [] # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# End-to-end dispatch — OVO + Red + AGL + GloBird wiring +# --------------------------------------------------------------------------- + + +class TestDispatchE2E: + """Verify free_window credit lands via apply_retailer_incentives + for every Phase 2.11.4 retailer.""" + + def _import_dispatch(self): + from custom_components.pricehawk.cdr.incentive_parsers import ( + apply_retailer_incentives, + ) + return apply_retailer_incentives + + def _flat_tou_plan(self, brand: str, eligibility: str, + display_name: str = "Free 3") -> dict: + # Plan with a SINGLE flat 30c/kWh rate (ex-GST) → peak rate + # helper returns 30 × 110 = 33 c/kWh inc-GST. + return { + "brand": brand, + "electricityContract": { + "tariffPeriod": [{ + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.30"}]}, + }], + "incentives": [{ + "displayName": display_name, + "eligibility": eligibility, + }], + }, + } + + def test_ovo_free_3_credits_via_dispatch(self): + # 5 kWh imported at noon. Peak rate 33c inc-GST. Free rate 0c. + # Credit = (33 - 0) / 100 × 5 = 1.65 AUD + dispatch = self._import_dispatch() + plan = self._flat_tou_plan( + "ovo-energy", + "Free electricity between 11am and 2pm everyday." + ) + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + dispatch(plan, slots, b, slot_in_window=lambda *a, **kw: False) + assert b.incentive_aud_inc_gst == Decimal("-1.65") + assert any("ovo parser hits" in n for n in b.notes) + + def test_red_free_window_credits_via_dispatch(self): + """Phase 2.11.10 polish: weekend-only filter active. + + Red BCNA Saver / Wildlife Saver "Free Electricity Use Period" + applies only Saturday + Sunday. Slot at 2026-05-16T13:00 is a + Saturday — credit fires. + """ + dispatch = self._import_dispatch() + plan = self._flat_tou_plan( + "red-energy", + ("Between 12pm and 2pm Saturday and Sunday, your electricity " + "usage charges will be waived"), + display_name="Free Electricity Use Period", + ) + slots = [{"ts_local": "2026-05-16T13:00:00", "grid_import_kwh": 4.0}] + b = _StubBreakdown() + dispatch(plan, slots, b, slot_in_window=lambda *a, **kw: False) + # (33 - 0) / 100 × 4 = 1.32 + assert b.incentive_aud_inc_gst == Decimal("-1.32") + + def test_red_free_window_skips_weekdays(self): + """Phase 2.11.10 polish: weekday slot does NOT credit (was a + known $5-15/yr over-credit before the days-filter).""" + dispatch = self._import_dispatch() + plan = self._flat_tou_plan( + "red-energy", + ("Between 12pm and 2pm Saturday and Sunday, your electricity " + "usage charges will be waived"), + display_name="Free Electricity Use Period", + ) + # 2026-05-15 = Friday — should NOT credit. + slots = [{"ts_local": "2026-05-15T13:00:00", "grid_import_kwh": 4.0}] + b = _StubBreakdown() + dispatch(plan, slots, b, slot_in_window=lambda *a, **kw: False) + assert b.incentive_aud_inc_gst == Decimal("0") + + def test_agl_three_for_free_credits_via_dispatch(self): + dispatch = self._import_dispatch() + plan = self._flat_tou_plan( + "agl", + ("Free electricity usage applies from 10am to 1pm every day. " + "Daily supply charges still apply."), + display_name="Three for Free Usage", + ) + slots = [{"ts_local": "2026-05-15T11:00:00", "grid_import_kwh": 3.0}] + b = _StubBreakdown() + dispatch(plan, slots, b, slot_in_window=lambda *a, **kw: False) + assert b.incentive_aud_inc_gst == Decimal("-0.99") # 33 × 3 / 100 + + def test_globird_flex_no_double_credit(self): + # GloBird ZEROHERO Flex tariff already encodes 11am-2pm as + # ~0c off-peak. Helper detects this (min rate ≤ 1c threshold) + # and returns 0 → free_window applies no credit. + dispatch = self._import_dispatch() + plan = { + "brand": "globird", + "electricityContract": { + "tariffPeriod": [{ + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.36"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.000001"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.25"}]}, + ], + }], + "incentives": [{ + "displayName": "Perfect if you love free stuff", + "eligibility": ("$0.00 for consumption between 11am-2pm " + "(Local Time), excluding controlled load."), + }], + }, + } + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_import_kwh": 5.0}] + b = _StubBreakdown() + dispatch(plan, slots, b, slot_in_window=lambda *a, **kw: False) + # No credit — tariff already encodes the free window. + assert b.incentive_aud_inc_gst == Decimal("0") diff --git a/tests/test_cdr_incentive_parsers_agl.py b/tests/test_cdr_incentive_parsers_agl.py new file mode 100644 index 0000000..46e2362 --- /dev/null +++ b/tests/test_cdr_incentive_parsers_agl.py @@ -0,0 +1,207 @@ +"""Tests for cdr.incentive_parsers.agl — Phase 2.6 AGL FIT parser. + +Covers: +- Bonus FIT regex extraction across the common AGL wording variants. +- Time-token parsing including HH:MM minutes. +- Three for Free detector (no math; just notes). +- apply() correctly credits export windows and stops at the per-day cap. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers.agl import ( + _hh_token_to_minutes, + apply, + parse_rules, +) + + +@dataclass +class _StubBreakdown: + """Minimal stand-in for CostBreakdown so we can test parser side effects + without importing the full evaluator. Mirrors the three mutated fields.""" + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list = field(default_factory=list) + trace: list = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Time token parsing +# --------------------------------------------------------------------------- + + +class TestHhTokenToMinutes: + def test_am_simple(self): + assert _hh_token_to_minutes("6am") == 360 + + def test_pm_simple(self): + assert _hh_token_to_minutes("6pm") == 1080 + + def test_noon(self): + assert _hh_token_to_minutes("12pm") == 720 + + def test_midnight(self): + assert _hh_token_to_minutes("12am") == 0 + + def test_with_minutes(self): + assert _hh_token_to_minutes("6:30pm") == 18 * 60 + 30 + + def test_with_space_before_meridiem(self): + assert _hh_token_to_minutes("6 pm") == 1080 + + +# --------------------------------------------------------------------------- +# parse_rules — regex coverage +# --------------------------------------------------------------------------- + + +def _plan_with_incentives(*incentives: dict) -> dict: + return {"electricityContract": {"incentives": list(incentives)}} + + +class TestParseRulesBonusFit: + def test_basic_solar_savers_pattern(self): + plan = _plan_with_incentives({ + "displayName": "Solar Savers", + "description": "10c/kWh bonus feed-in for the first 10 kWh of exports per day between 11am-2pm", + }) + rules = parse_rules(plan) + assert "bonus_fit" in rules + r = rules["bonus_fit"] + assert r["cents_per_kwh"] == Decimal("10") + assert r["first_kwh_per_day"] == Decimal("10") + assert r["start_min"] == 11 * 60 + assert r["end_min"] == 14 * 60 + + def test_alternate_wording_extra(self): + plan = _plan_with_incentives({ + "displayName": "Solar Sunshine", + "description": "5c/kWh extra for the first 5 kWh between 10am-2pm", + }) + rules = parse_rules(plan) + assert "bonus_fit" in rules + + def test_alternate_wording_additional(self): + plan = _plan_with_incentives({ + "displayName": "Solar Maximiser", + "description": "3.5c/kWh additional feed-in for first 8 kWh exports per day between 9am-3pm", + }) + rules = parse_rules(plan) + assert "bonus_fit" in rules + assert rules["bonus_fit"]["cents_per_kwh"] == Decimal("3.5") + + def test_no_match_leaves_rules_empty(self): + plan = _plan_with_incentives({ + "displayName": "Random promo", + "description": "Sign up and receive $50 credit on your next bill", + }) + rules = parse_rules(plan) + assert "bonus_fit" not in rules + + def test_no_incentives_returns_empty(self): + assert parse_rules({"electricityContract": {}}) == {} + + def test_handles_no_electricity_contract(self): + assert parse_rules({}) == {} + + +class TestParseRulesThreeForFree: + def test_explicit_three_for_free(self): + plan = _plan_with_incentives({ + "displayName": "AGL Three for Free", + "description": "Three for Free: pick 3 hours per day of free electricity", + }) + rules = parse_rules(plan) + assert "three_for_free" in rules + + def test_3_hours_phrasing(self): + plan = _plan_with_incentives({ + "displayName": "AGL ThreeFree Plan", + "description": "Customers get 3 hours of free electricity each day", + }) + rules = parse_rules(plan) + assert "three_for_free" in rules + + +# --------------------------------------------------------------------------- +# apply() — credit accumulation +# --------------------------------------------------------------------------- + + +def _slots_export_in_window() -> list[dict]: + """5 half-hour slots between 11:00 and 13:00 exporting 2 kWh each.""" + return [ + {"ts_local": "2026-05-10T11:00:00", "grid_export_kwh": 2.0}, + {"ts_local": "2026-05-10T11:30:00", "grid_export_kwh": 2.0}, + {"ts_local": "2026-05-10T12:00:00", "grid_export_kwh": 2.0}, + {"ts_local": "2026-05-10T12:30:00", "grid_export_kwh": 2.0}, + {"ts_local": "2026-05-10T13:00:00", "grid_export_kwh": 2.0}, + ] + + +def _noop_slot_in_window(*_args, **_kwargs): + return False + + +class TestApply: + def test_no_rules_no_credit(self): + plan = _plan_with_incentives({"displayName": "x", "description": "x"}) + bd = _StubBreakdown() + apply(plan, _slots_export_in_window(), bd, slot_in_window=_noop_slot_in_window) + assert bd.incentive_aud_inc_gst == Decimal("0") + assert bd.notes == [] + + def test_bonus_fit_credits_capped_kwh(self): + plan = _plan_with_incentives({ + "displayName": "Solar Savers", + "description": "10c/kWh bonus feed-in for the first 5 kWh of exports per day between 11am-2pm", + }) + bd = _StubBreakdown() + apply(plan, _slots_export_in_window(), bd, slot_in_window=_noop_slot_in_window) + # 5 kWh × 10c = 50c = $0.50 — incentive is a CREDIT so subtracted. + # incentive_aud_inc_gst represents credits as negative additions + # to the imports total, so the field itself becomes negative. + assert bd.incentive_aud_inc_gst == Decimal("-0.50") + assert len(bd.trace) == 1 + assert bd.trace[0]["incentive"] == "agl_bonus_fit" + assert bd.trace[0]["credited_kwh"] == 5.0 + + def test_three_for_free_only_logs_no_math(self): + plan = _plan_with_incentives({ + "displayName": "Three for Free", + "description": "Three for Free: 3 hours per day of free electricity, choose your window in the AGL app", + }) + bd = _StubBreakdown() + apply(plan, _slots_export_in_window(), bd, slot_in_window=_noop_slot_in_window) + # Detect-only stub: no math change. + assert bd.incentive_aud_inc_gst == Decimal("0") + # ... but notes record the gap so log readers see it. + joined = "\n".join(bd.notes) + assert "Three for Free" in joined + + def test_window_outside_slots_no_credit(self): + plan = _plan_with_incentives({ + "displayName": "Solar Savers", + "description": "10c/kWh bonus feed-in for the first 10 kWh of exports per day between 6pm-9pm", + }) + bd = _StubBreakdown() + apply(plan, _slots_export_in_window(), bd, slot_in_window=_noop_slot_in_window) + assert bd.incentive_aud_inc_gst == Decimal("0") + + +# --------------------------------------------------------------------------- +# Registry wiring — 2.6 registers AGL alongside GloBird +# --------------------------------------------------------------------------- + + +class TestRegistryWiring: + def test_agl_in_retailer_parsers(self): + from custom_components.pricehawk.cdr.incentive_parsers import RETAILER_PARSERS + assert "agl" in RETAILER_PARSERS + assert callable(RETAILER_PARSERS["agl"]) + + def test_globird_still_present(self): + from custom_components.pricehawk.cdr.incentive_parsers import RETAILER_PARSERS + assert "globird" in RETAILER_PARSERS diff --git a/tests/test_cdr_incentive_parsers_phase_2_11_2.py b/tests/test_cdr_incentive_parsers_phase_2_11_2.py new file mode 100644 index 0000000..e7f38b9 --- /dev/null +++ b/tests/test_cdr_incentive_parsers_phase_2_11_2.py @@ -0,0 +1,235 @@ +"""Tests for Phase 2.11.2 — origin.py / alinta.py / energyaustralia.py +wiring of the shared common/tiered_fit helper. + +Each test feeds a minimal plan_data dict + slot fixture through the +brand dispatch (apply_retailer_incentives) and verifies the credit +lands on CostBreakdown.incentive_aud_inc_gst with the expected +inc-GST math. + +Catalog reference: scripts/CDR_INCENTIVE_CATALOG.md. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers import ( + RETAILER_PARSERS, + apply_retailer_incentives, +) + + +@dataclass +class _StubBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + +def _stub_slot_in_window(*_args, **_kwargs): + """Per-retailer files don't use the window matcher — argument exists + only to satisfy the dispatch signature.""" + return False + + +def _slots_30day(daily_export_kwh: float) -> list[dict]: + """Build 30 days of single-slot exports.""" + return [ + {"ts_local": f"2026-05-{day:02d}T12:00:00", + "grid_export_kwh": daily_export_kwh} + for day in range(1, 31) + ] + + +# --------------------------------------------------------------------------- +# Registry — confirms every Phase 2.11.2 retailer is dispatched +# --------------------------------------------------------------------------- + + +class TestRegistryDispatch: + def test_origin_registered(self): + assert "origin" in RETAILER_PARSERS + + def test_alinta_registered(self): + assert "alinta" in RETAILER_PARSERS + + def test_energyaustralia_registered(self): + assert "energyaustralia" in RETAILER_PARSERS + + def test_unknown_brand_no_op(self): + # Plans from retailers not in the registry must not crash dispatch. + plan = {"brand": "tesla", "electricityContract": {"incentives": []}} + b = _StubBreakdown() + apply_retailer_incentives(plan, [], b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.notes == [] + + +# --------------------------------------------------------------------------- +# Origin — period-averaged tiered FIT +# --------------------------------------------------------------------------- + + +class TestOriginEndToEnd: + def _origin_plan(self, base_fit_aud_per_kwh: str = "0.04") -> dict: + return { + "brand": "origin", + "electricityContract": { + "solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": base_fit_aud_per_kwh}]}, + }], + "incentives": [{ + "displayName": "Solar feed-in tariffs", + "eligibility": ("Origin offers 12 cents per kWh until " + "a daily export limit of 8 kWh is " + "reached. The daily export limit is " + "averaged across your billing period " + "(calculated by multiplying the number " + "of days in your billing period by " + "your daily export limit of 8)"), + }], + }, + } + + def test_origin_30day_within_pool(self): + # 30 days × 5 kWh/day = 150 kWh. + # Pool = 8 × 30 = 240 kWh; all 150 fits in tier 1. + # Base FIT inc-GST: 0.04 × 110 = 4.4 c/kWh. + # Tier 1 inc-GST: 12 c/kWh. + # Delta credit = (12 - 4.4) / 100 × 150 = 11.40 AUD + plan = self._origin_plan(base_fit_aud_per_kwh="0.04") + slots = _slots_30day(5.0) + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("-11.40") + assert any("origin parser hits" in n for n in b.notes) + + def test_origin_pool_exhausted(self): + # 30 days × 10 kWh/day = 300 kWh. Pool 240 kWh. + # Delta = (12 - 4.4) / 100 × 240 = 18.24 AUD + plan = self._origin_plan(base_fit_aud_per_kwh="0.04") + slots = _slots_30day(10.0) + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("-18.24") + + def test_origin_no_incentive_no_op(self): + plan = {"brand": "origin", "electricityContract": {"incentives": []}} + b = _StubBreakdown() + apply_retailer_incentives(plan, _slots_30day(5.0), b, + slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("0") + + +# --------------------------------------------------------------------------- +# Alinta — daily-cap tiered FIT +# --------------------------------------------------------------------------- + + +class TestAlintaEndToEnd: + def _alinta_plan(self, base_fit_aud_per_kwh: str = "0.0004") -> dict: + return { + "brand": "alinta", + "electricityContract": { + "solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": base_fit_aud_per_kwh}]}, + }], + "incentives": [{ + "displayName": "Solar Feed-in Tariff", + "eligibility": ("This Energy Plan includes a stepped " + "feed-in tariff, where you will receive " + "a feed-in of 7c/kWh for the first " + "10kW exported. For any export after " + "that you will obtain Alinta Energy's " + "standard retailer feed-in tariff of " + "0.04c/kWh."), + }], + }, + } + + def test_alinta_single_day_below_cap(self): + # 5 kWh exported in one day, cap 10 → all tier 1. + # Base FIT inc-GST: 0.0004 × 110 = 0.044 c/kWh. + # Tier 1 inc-GST: 7 c/kWh. + # Delta = (7 - 0.044) / 100 × 5 = 0.3478 AUD + plan = self._alinta_plan(base_fit_aud_per_kwh="0.0004") + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_export_kwh": 5.0}] + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("-0.3478") + + def test_alinta_daily_reset(self): + # Two days, 8 kWh each. Cap 10/day, both fully credited. + # Delta = 2 × (7 - 0.044) / 100 × 8 = 1.11296 + plan = self._alinta_plan(base_fit_aud_per_kwh="0.0004") + slots = [ + {"ts_local": "2026-05-15T12:00:00", "grid_export_kwh": 8.0}, + {"ts_local": "2026-05-16T12:00:00", "grid_export_kwh": 8.0}, + ] + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("-1.11296") + + +# --------------------------------------------------------------------------- +# EnergyAustralia — Solar Max no-rate-in-elig falls through silently +# --------------------------------------------------------------------------- + + +class TestEnergyAustraliaEndToEnd: + def test_solar_max_no_rate_in_elig_no_op(self): + # EA Solar Max eligibility describes the averaging window but + # not the rate. Parser correctly returns no rule → no credit. + plan = { + "brand": "energyaustralia", + "electricityContract": { + "solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": "0.05"}]}, + }], + "incentives": [{ + "displayName": "Solar Max", + "eligibility": ("Solar Max is for electricity only and " + "is available to eligible residential " + "solar customers not receiving any " + "Government feed-in-tariff. The daily " + "export is averaged by dividing the " + "total solar export by the number of " + "days in each billing period"), + }], + }, + } + slots = [{"ts_local": "2026-05-15T12:00:00", "grid_export_kwh": 5.0}] + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.notes == [] # parser exits before logging when no rule found + + def test_ea_with_explicit_rate_in_elig(self): + # If a different EA plan ships the rate-and-cap text directly, + # parser handles it. Pin behaviour for future-proofing. + plan = { + "brand": "energyaustralia", + "electricityContract": { + "solarFeedInTariff": [{ + "tariffUType": "singleTariff", + "singleTariff": {"rates": [{"unitPrice": "0.04"}]}, + }], + "incentives": [{ + "displayName": "Solar Max", + "eligibility": ("EA pays 10 cents per kWh until a " + "daily export limit of 6 kWh is " + "reached. The daily export limit is " + "averaged across your billing period."), + }], + }, + } + slots = _slots_30day(4.0) + # Pool = 6 × 30 = 180 kWh. Total export = 30 × 4 = 120, all in tier 1. + # Base inc-GST: 0.04 × 110 = 4.4. Tier 1 inc-GST: 10. + # Delta = (10 - 4.4) / 100 × 120 = 6.72 AUD + b = _StubBreakdown() + apply_retailer_incentives(plan, slots, b, slot_in_window=_stub_slot_in_window) + assert b.incentive_aud_inc_gst == Decimal("-6.72") diff --git a/tests/test_cdr_opt_in_dispatch.py b/tests/test_cdr_opt_in_dispatch.py new file mode 100644 index 0000000..8f3e3ba --- /dev/null +++ b/tests/test_cdr_opt_in_dispatch.py @@ -0,0 +1,151 @@ +"""End-to-end test that Phase 2.12.1 opt-in fields flow from +entry_options through apply_retailer_incentives to the per-retailer +parsers and activate the math. + +We hit the dispatch boundary directly (not the full evaluator) — the +evaluator-level integration is covered indirectly by the streaming +engine tests gated on pydantic. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers import ( + apply_retailer_incentives, +) + + +@dataclass +class _StubBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + +def _slot_in_window_stub(*_a, **_kw): + return False + + +# --- OVO interest opt-in --------------------------------------------- + + +def _ovo_plan_with_interest() -> dict: + return { + "brand": "ovo-energy", + "electricityContract": { + "incentives": [ + { + "displayName": "Interest Rewards", + "eligibility": "3% interest on credit balances. Paid monthly to your OVO account.", + }, + ], + "tariffPeriod": [], + }, + } + + +def test_ovo_interest_no_op_when_balance_zero(): + """Default entry_options → balance 0 → no credit.""" + bd = _StubBreakdown() + apply_retailer_incentives( + _ovo_plan_with_interest(), [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={}, + ) + # No interest trace entry (apply_rule short-circuits at balance=0). + interest_traces = [t for t in bd.trace if t.get("incentive") == "ovo_interest"] + assert interest_traces == [] + + +def test_ovo_interest_credits_when_balance_set(): + """Opt-in balance flows to ovo_interest.apply_rule and credits.""" + bd = _StubBreakdown() + apply_retailer_incentives( + _ovo_plan_with_interest(), [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={"ovo_interest_balance_aud": 500}, + ) + # $500 × 3% / 365 = $0.0411/day + expected_daily = Decimal("500") * Decimal("3") / Decimal("100") / Decimal("365") + assert bd.incentive_aud_inc_gst == -expected_daily + interest_traces = [t for t in bd.trace if t.get("incentive") == "ovo_interest"] + assert len(interest_traces) == 1 + assert interest_traces[0]["balance_aud"] == 500.0 + + +# --- VPP rebate opt-in ----------------------------------------------- + + +def _engie_plan_with_vpp() -> dict: + return { + "brand": "engie-au", + "electricityContract": { + "incentives": [ + { + "displayName": "PowerResponse VPP", + "eligibility": "$15 monthly credit per battery for participating in our VPP.", + }, + ], + "tariffPeriod": [], + }, + } + + +def test_vpp_no_op_when_batteries_zero(): + bd = _StubBreakdown() + apply_retailer_incentives( + _engie_plan_with_vpp(), [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={}, + ) + vpp_traces = [t for t in bd.trace if t.get("incentive") == "vpp_rebate"] + assert vpp_traces == [] + + +def test_vpp_credits_when_one_battery_enrolled(): + bd = _StubBreakdown() + apply_retailer_incentives( + _engie_plan_with_vpp(), [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={"vpp_batteries_enrolled": 1}, + ) + # $15/mo × 1 battery / 30 days = $0.50/day credit + assert bd.incentive_aud_inc_gst == -Decimal("0.5") + vpp_traces = [t for t in bd.trace if t.get("incentive") == "vpp_rebate"] + assert len(vpp_traces) == 1 + assert vpp_traces[0]["batteries_enrolled"] == 1 + + +def test_vpp_credits_scale_with_battery_count(): + bd = _StubBreakdown() + apply_retailer_incentives( + _engie_plan_with_vpp(), [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={"vpp_batteries_enrolled": 3}, + ) + # $15 × 3 / 30 = $1.50/day + assert bd.incentive_aud_inc_gst == -Decimal("1.5") + + +# --- GloBird unaffected ---------------------------------------------- + + +def test_globird_ignores_opt_in_kwargs(): + """GloBird has no opt-in fields — should absorb entry_options + silently and not crash, regardless of values set.""" + plan = { + "brand": "globird", + "electricityContract": { + "incentives": [], + "tariffPeriod": [], + }, + } + bd = _StubBreakdown() + apply_retailer_incentives( + plan, [], bd, + slot_in_window=_slot_in_window_stub, + entry_options={"ovo_interest_balance_aud": 999, "vpp_batteries_enrolled": 5}, + ) + # No GloBird incentives in this plan → no traces; no crash from absorbing kwargs. + assert bd.trace == [] diff --git a/tests/test_cdr_ovo_interest.py b/tests/test_cdr_ovo_interest.py new file mode 100644 index 0000000..4e750bf --- /dev/null +++ b/tests/test_cdr_ovo_interest.py @@ -0,0 +1,175 @@ +"""Tests for ovo_interest.py (Phase 2.11.7).""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from custom_components.pricehawk.cdr.incentive_parsers.common.ovo_interest import ( + INTEREST_RE, + TRIGGER_RE, + apply_rule, + parse_from_incentives, + parse_rule, +) + + +@dataclass +class FakeBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + trace: list[dict[str, Any]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +# --- Regex coverage ---------------------------------------------------- + + +def test_trigger_matches_credit_balance(): + assert TRIGGER_RE.search("interest paid on credit balance monthly") + + +def test_trigger_matches_interest_reward(): + assert TRIGGER_RE.search("Interest Rewards program") + + +def test_trigger_matches_account_balance(): + assert TRIGGER_RE.search("3% on your account balance") + + +def test_trigger_does_not_match_ev_offpeak(): + """ev_offpeak text should not trigger interest parser.""" + assert not TRIGGER_RE.search("$0.045/kWh usage charge between midnight and 6am") + + +def test_interest_regex_matches_3_percent_interest(): + m = INTEREST_RE.search("3% interest paid") + assert m + assert m.group("pct") == "3" + + +def test_interest_regex_matches_5_percent_APR(): + m = INTEREST_RE.search("5% APR on balance") + assert m + assert m.group("pct") == "5" + + +def test_interest_regex_matches_decimal_rate(): + m = INTEREST_RE.search("3.5% annual rate") + assert m + assert m.group("pct") == "3.5" + + +# --- parse_rule ------------------------------------------------------- + + +def test_parse_rule_ovo_canonical(): + rule = parse_rule("3% interest on credit balances. Paid monthly to your OVO account.") + assert rule is not None + assert rule["annual_rate_pct"] == Decimal("3") + assert rule["balance_aud"] == Decimal("0") # opt-in default + + +def test_parse_rule_with_balance_opt_in(): + rule = parse_rule( + "3% interest on credit balances.", + balance_aud=Decimal("250"), + ) + assert rule is not None + assert rule["balance_aud"] == Decimal("250") + + +def test_parse_rule_no_trigger_returns_none(): + assert parse_rule("$50 sign-up credit on first bill.") is None + + +def test_parse_rule_no_pct_returns_none(): + """Trigger present but no rate.""" + assert parse_rule("Interest paid on credit balance.") is None + + +def test_parse_rule_empty_returns_none(): + assert parse_rule("") is None + + +# --- apply_rule ------------------------------------------------------- + + +def test_apply_rule_no_op_when_balance_zero(): + bd = FakeBreakdown() + rule = {"annual_rate_pct": Decimal("3"), "balance_aud": Decimal("0"), "source": ""} + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == Decimal("0") + assert bd.trace == [] + + +def test_apply_rule_credits_daily_interest(): + """$100 × 3% / 365 = $0.00822/day.""" + bd = FakeBreakdown() + rule = { + "annual_rate_pct": Decimal("3"), + "balance_aud": Decimal("100"), + "source": "test", + } + apply_rule(rule, [], bd) + # Credit = -0.00822 (negative = user gain) + expected = -(Decimal("100") * Decimal("3") / Decimal("100") / Decimal("365")) + assert bd.incentive_aud_inc_gst == expected + assert len(bd.trace) == 1 + assert bd.trace[0]["incentive"] == "ovo_interest" + assert bd.trace[0]["balance_aud"] == 100.0 + + +def test_apply_rule_higher_balance_scales_linearly(): + bd = FakeBreakdown() + rule = { + "annual_rate_pct": Decimal("3"), + "balance_aud": Decimal("500"), + "source": "test", + } + apply_rule(rule, [], bd) + # $500 × 3% / 365 = $0.0411/day + expected_daily = Decimal("500") * Decimal("3") / Decimal("100") / Decimal("365") + assert bd.incentive_aud_inc_gst == -expected_daily + + +def test_apply_rule_no_op_when_rate_zero(): + bd = FakeBreakdown() + rule = {"annual_rate_pct": Decimal("0"), "balance_aud": Decimal("500"), "source": ""} + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == Decimal("0") + + +# --- parse_from_incentives ------------------------------------------- + + +def test_parse_from_incentives_finds_one(): + incs = [ + { + "displayName": "Interest Rewards", + "eligibility": "3% interest on credit balances.", + }, + { + "displayName": "EV Off-Peak", + "eligibility": "$0.045/kWh usage charge between midnight and 6am.", + }, + ] + rules = parse_from_incentives(incs) + assert len(rules) == 1 + assert rules[0]["annual_rate_pct"] == Decimal("3") + assert rules[0]["source_displayName"] == "Interest Rewards" + + +def test_parse_from_incentives_propagates_balance(): + incs = [ + { + "displayName": "Interest Rewards", + "eligibility": "3% interest on credit balances.", + } + ] + rules = parse_from_incentives(incs, balance_aud=Decimal("300")) + assert len(rules) == 1 + assert rules[0]["balance_aud"] == Decimal("300") + + +def test_parse_from_incentives_empty(): + assert parse_from_incentives([]) == [] diff --git a/tests/test_cdr_registry.py b/tests/test_cdr_registry.py new file mode 100644 index 0000000..f630af6 --- /dev/null +++ b/tests/test_cdr_registry.py @@ -0,0 +1,419 @@ +"""Tests for cdr.registry — EME refdata2 retailer endpoint registry. + +Covers: +- Pure-Python envelope parsing against the EME refdata2 shape. +- ``cdr_brand`` discriminator preserved for shared base URIs. +- Baked-in EME JSON loadable, well-formed, contains the big-4 retailers. +- ``fetch_live`` happy path returns parsed entries. +- ``fetch_live`` failure modes (HTTP, network, malformed body) raise + ``CdrUnavailable``. +- ``get_registry`` falls back to baked-in when live fetch fails. +""" +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.pricehawk.cdr.cdr_client import CdrUnavailable +from custom_components.pricehawk.cdr.registry import ( + LIVE_REGISTRY_URL, + RetailerEndpoint, + baked_in_path_for_test, + fetch_live, + find_by_brand, + get_registry, + load_baked_in, + parse_eme_for_test, +) + + +# --------------------------------------------------------------------------- +# EME refdata2 envelope parsing +# --------------------------------------------------------------------------- + + +class TestParseEmeEntries: + def test_parses_single_org(self): + raw = { + "data": { + "organisations": { + "9611": { + "tradingName": "CovaU Pty Ltd", + "orgName": "CovaU", + "cdrCode": "covau", + "cdrBrand": "covau", + "abn": "54 090 117 730", + "logo": "/static/organisations/logos/cova_u.png", + } + } + } + } + result = parse_eme_for_test(raw) + assert len(result) == 1 + e = result[0] + assert e.brand_id == "9611" + assert e.brand_name == "CovaU Pty Ltd" # tradingName preferred + assert e.base_uri == "https://cdr.energymadeeasy.gov.au/covau" + assert e.cdr_brand == "covau" + assert e.abn == "54 090 117 730" + assert e.logo_uri == ( + "https://energymadeeasy.gov.au/static/organisations/logos/cova_u.png" + ) + + def test_falls_back_to_org_name_when_trading_name_missing(self): + raw = { + "data": { + "organisations": { + "1": { + "orgName": "Foo Energy", + "cdrCode": "foo", + "cdrBrand": "foo", + } + } + } + } + assert parse_eme_for_test(raw)[0].brand_name == "Foo Energy" + + def test_skips_orgs_missing_cdr_code(self): + raw = { + "data": { + "organisations": { + "1": {"orgName": "No Code", "cdrBrand": "x"}, + "2": { + "orgName": "Has Code", + "cdrCode": "has-code", + "cdrBrand": "has-code", + }, + } + } + } + assert [e.brand_name for e in parse_eme_for_test(raw)] == ["Has Code"] + + def test_strips_trailing_whitespace_in_cdr_brand(self): + """Upstream EME has trailing-space bugs in several cdrBrand fields + (Aurora, Brighte, Amber etc). Strip so ``?brand=amber+`` doesn't + end up sent to the CDR endpoint.""" + raw = { + "data": { + "organisations": { + "1": { + "orgName": "Aurora Energy", + "cdrCode": "aurora", + "cdrBrand": "aurora ", # bug in upstream + } + } + } + } + assert parse_eme_for_test(raw)[0].cdr_brand == "aurora" + + def test_strips_trailing_whitespace_in_display_name(self): + """Same trailing-space bug appears in tradingName / orgName on + some EME orgs. Trim so UI labels don't render with stray spaces.""" + raw = { + "data": { + "organisations": { + "1": { + "orgName": "Origin Energy ", # trailing space + "cdrCode": "origin", + "cdrBrand": "origin", + } + } + } + } + assert parse_eme_for_test(raw)[0].brand_name == "Origin Energy" + + def test_non_string_fields_safely_skipped(self): + """EME has been observed shipping non-string values in fields + we expect to be strings (numeric cdrCode, None tradingName). + Parser must not raise — affected orgs are silently dropped.""" + raw = { + "data": { + "organisations": { + "bad_code": { + "orgName": "Numeric cdrCode", + "cdrCode": 12345, # int, not str + "cdrBrand": "x", + }, + "bad_name": { + "orgName": None, + "tradingName": None, + "cdrCode": "no-name", + "cdrBrand": "no-name", + }, + "good": { + "orgName": "Good Org", + "cdrCode": "good", + "cdrBrand": "good", + }, + } + } + } + result = parse_eme_for_test(raw) + assert [e.brand_name for e in result] == ["Good Org"] + + def test_logo_uri_normalised_to_str_or_none(self): + """``RetailerEndpoint.logo_uri`` is typed ``str | None``. EME has + been observed dropping odd shapes into the ``logo`` field + (dicts, ints, empty strings); coerce to ``None`` so downstream + consumers can rely on the declared type.""" + raw = { + "data": { + "organisations": { + "1": { + "orgName": "Dict Logo", + "cdrCode": "dict", + "cdrBrand": "dict", + "logo": {"url": "/foo.png"}, # dict, not str + }, + "2": { + "orgName": "Empty Logo", + "cdrCode": "empty", + "cdrBrand": "empty", + "logo": "", + }, + "3": { + "orgName": "None Logo", + "cdrCode": "none-logo", + "cdrBrand": "none-logo", + "logo": None, + }, + "4": { + "orgName": "Absolute Logo", + "cdrCode": "abs", + "cdrBrand": "abs", + "logo": "https://cdn.example.com/x.png", + }, + "5": { + "orgName": "Relative Logo", + "cdrCode": "rel", + "cdrBrand": "rel", + "logo": "/static/x.png", + }, + } + } + } + by_name = {e.brand_name: e.logo_uri for e in parse_eme_for_test(raw)} + assert by_name["Dict Logo"] is None + assert by_name["Empty Logo"] is None + assert by_name["None Logo"] is None + assert by_name["Absolute Logo"] == "https://cdn.example.com/x.png" + assert by_name["Relative Logo"] == ( + "https://energymadeeasy.gov.au/static/x.png" + ) + + def test_preserves_brand_discriminator_for_shared_base_uris(self): + """Energy Locals hosts seven brands. Each org gets the same base + URI but a distinct ``cdr_brand`` so plan list/detail can be + disambiguated via ``?brand=``.""" + raw = { + "data": { + "organisations": { + "1": { + "orgName": "Energy Locals", + "cdrCode": "energy-locals", + "cdrBrand": "energy-locals", + }, + "2": { + "orgName": "ARCLINE by RACV", + "cdrCode": "energy-locals", + "cdrBrand": "arcline", + }, + "3": { + "orgName": "Cooperative Power", + "cdrCode": "energy-locals", + "cdrBrand": "cooperative", + }, + } + } + } + result = parse_eme_for_test(raw) + assert {e.base_uri for e in result} == { + "https://cdr.energymadeeasy.gov.au/energy-locals" + } + assert {e.cdr_brand for e in result} == { + "energy-locals", "arcline", "cooperative", + } + + def test_invalid_root_raises(self): + with pytest.raises(ValueError): + parse_eme_for_test([]) # type: ignore[arg-type] + + def test_missing_organisations_raises(self): + with pytest.raises(ValueError): + parse_eme_for_test({"data": {"thirdParties": {}}}) + + def test_organisations_not_dict_raises(self): + with pytest.raises(ValueError): + parse_eme_for_test({"data": {"organisations": "garbage"}}) + + def test_slug_normalises_brand_name(self): + e = RetailerEndpoint(brand_id="x", brand_name="Red Energy", base_uri="https://x") + assert e.slug == "red_energy" + e2 = RetailerEndpoint(brand_id="y", brand_name="Energy Locals", base_uri="https://y") + assert e2.slug == "energy_locals" + + +# --------------------------------------------------------------------------- +# Baked-in registry health +# --------------------------------------------------------------------------- + + +class TestBakedIn: + def test_baked_in_path_exists(self): + assert baked_in_path_for_test().is_file() + + def test_baked_in_has_organisations(self): + raw = json.loads(baked_in_path_for_test().read_text()) + assert "data" in raw + assert isinstance(raw["data"], dict) + orgs = raw["data"].get("organisations") + assert isinstance(orgs, dict) + # EME shipped 117 orgs at time of bake; >50 is a generous floor. + assert len(orgs) > 50 + + def test_load_baked_in_contains_big_4(self): + endpoints = load_baked_in() + names = {e.brand_name.lower() for e in endpoints} + for required in ["origin", "agl", "energyaustralia", "red energy"]: + assert any(required in n for n in names), ( + f"baked-in registry missing required brand fragment '{required}'" + ) + + def test_load_baked_in_populates_cdr_brand(self): + """EME exposes cdrBrand for every org; baked-in load must carry it + through so shared-base-URI plans can be queried with ``?brand=``.""" + endpoints = load_baked_in() + with_brand = [e for e in endpoints if e.cdr_brand] + assert len(with_brand) > 50, "EME load lost cdr_brand on most entries" + + def test_find_by_brand_substring(self): + endpoints = load_baked_in() + agl = find_by_brand(endpoints, "AGL") + assert agl is not None + assert "AGL" in agl.brand_name + assert agl.base_uri.startswith("https://") + + def test_find_by_brand_miss(self): + endpoints = load_baked_in() + assert find_by_brand(endpoints, "NotARealRetailer123") is None + + +# --------------------------------------------------------------------------- +# Async fetch + fallback +# --------------------------------------------------------------------------- + + +def _mock_session(status: int, body: dict | None) -> MagicMock: + session = MagicMock() + + def _get(_url, **_kwargs): + resp = MagicMock() + resp.status = status + resp.json = AsyncMock(return_value=body or {}) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session.get = MagicMock(side_effect=_get) + return session + + +_EME_BODY = { + "data": { + "organisations": { + "1": { + "orgName": "Test Retailer", + "cdrCode": "test", + "cdrBrand": "test", + } + } + } +} + + +def test_fetch_live_happy_path(): + session = _mock_session(200, _EME_BODY) + result = asyncio.run(fetch_live(session)) + assert len(result) == 1 + assert result[0].brand_name == "Test Retailer" + assert result[0].cdr_brand == "test" + + +def test_fetch_live_non_200_raises_unavailable(): + session = _mock_session(503, None) + with pytest.raises(CdrUnavailable): + asyncio.run(fetch_live(session)) + + +def test_fetch_live_network_error_raises_unavailable(): + session = MagicMock() + + def _get(_url, **_kwargs): + import aiohttp + raise aiohttp.ClientConnectorError(MagicMock(), OSError("nx")) + + session.get = MagicMock(side_effect=_get) + with pytest.raises(CdrUnavailable): + asyncio.run(fetch_live(session)) + + +def test_fetch_live_malformed_body_raises_unavailable(): + """Schema drift / partial outage at EME should surface as + ``CdrUnavailable`` so ``get_registry`` falls through to baked-in + rather than crashing the wizard.""" + session = _mock_session(200, {"data": {"organisations": "garbage"}}) + with pytest.raises(CdrUnavailable): + asyncio.run(fetch_live(session)) + + +def test_fetch_live_uses_eme_url(): + """Smoke-check: the request hits the EME refdata2 URL, not any other.""" + seen: list[str] = [] + session = MagicMock() + + def _get(url, **_kwargs): + seen.append(url) + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock(return_value=_EME_BODY) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session.get = MagicMock(side_effect=_get) + asyncio.run(fetch_live(session)) + assert seen == [LIVE_REGISTRY_URL] + + +def test_get_registry_prefers_live_when_available(): + session = _mock_session(200, _EME_BODY) + endpoints, source = asyncio.run(get_registry(session)) + assert source == "live" + assert any(e.brand_name == "Test Retailer" for e in endpoints) + + +def test_get_registry_falls_back_to_baked_in_on_failure(): + session = _mock_session(503, None) + endpoints, source = asyncio.run(get_registry(session)) + assert source == "baked-in" + assert len(endpoints) > 50 # baked-in EME has 117 at time of write + + +def test_get_registry_falls_back_on_malformed_live_body(): + session = _mock_session(200, {"data": "not-a-dict"}) + endpoints, source = asyncio.run(get_registry(session)) + assert source == "baked-in" + assert len(endpoints) > 50 + + +def test_get_registry_offline_mode_skips_network(): + session = MagicMock() + session.get = MagicMock(side_effect=AssertionError("network was hit")) + endpoints, source = asyncio.run(get_registry(session, prefer_live=False)) + assert source == "baked-in" + assert len(endpoints) > 50 diff --git a/tests/test_cdr_streaming.py b/tests/test_cdr_streaming.py new file mode 100644 index 0000000..e44ffe6 --- /dev/null +++ b/tests/test_cdr_streaming.py @@ -0,0 +1,183 @@ +"""Tests for cdr.streaming.CdrStreamingEngine — Phase 1.2 streaming adapter. + +The streaming engine ingests power readings + half-hourly accumulates them +into slots, then calls cdr.evaluate on demand. These tests drive it over +the same 7d consumption fixture used by `phase_1_parity.py` (converted to +power readings via 6-min sub-sampling) and verify it produces the same +total cost as a direct batch `cdr.evaluate` call. + +Also pins TariffEngine-compatible properties so CdrGloBirdProvider drop-in +replacement works. +""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta, date +from pathlib import Path + +import pytest + +from custom_components.pricehawk.cdr import evaluate +from custom_components.pricehawk.cdr.streaming import CdrStreamingEngine + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "phase0" + + +def _load(name: str) -> dict: + return json.loads((FIXTURE_DIR / name).read_text()) + + +def _drive_engine_with_slots(engine: CdrStreamingEngine, slots: list[dict]) -> None: + """Feed slots into streaming engine via 6-min sub-sampling. + + Matches the convention from `scripts/phase_1_parity.py` (each 30-min slot + fed as 5 x 6-min readings at constant mean kW). Engine auto-rolls daily + state on date change. + """ + SLOT_HOURS = 0.5 + SUBSTEPS = 5 + SUBSTEP_MIN = 6 + last_date = None + for slot in slots: + local_dt = datetime.fromisoformat(slot["ts_local"]).replace(tzinfo=None) + if last_date is not None and local_dt.date() != last_date: + # End-of-day rollover happens via engine's auto-reset on update() + pass + last_date = local_dt.date() + net_kw = ((float(slot.get("grid_import_kwh", 0)) + - float(slot.get("grid_export_kwh", 0))) / SLOT_HOURS) + net_w = net_kw * 1000.0 + for i in range(SUBSTEPS): + engine.update(net_w, local_dt + timedelta(minutes=SUBSTEP_MIN * i)) + + +def test_streaming_engine_starts_empty() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + assert engine.import_kwh_today == 0 + assert engine.export_kwh_today == 0 + assert engine.net_daily_cost_aud == 0 + + +def test_streaming_single_day_matches_batch_evaluate() -> None: + """Drive engine over May 10 slots; total should match cdr.evaluate on + those same slots (with tolerance for slot-boundary fencepost diffs).""" + plan = _load("plan_globird_GLO731031MR@VEC.json") + cons = _load("consumption_7d.json") + day_slots = [s for s in cons["slots"] if s["ts_local"].startswith("2026-05-10")] + + # Batch path + bd_batch = evaluate(plan, {"slots": day_slots}) + batch_total = float(bd_batch.total_aud_inc_gst) + + # Streaming path + engine = CdrStreamingEngine(plan) + _drive_engine_with_slots(engine, day_slots) + stream_total = engine.net_daily_cost_aud + + # Tolerance: streaming sub-samples to 6-min readings which gives ±cents + # of accumulator drift vs batch (which uses slot totals directly). + diff = abs(batch_total - stream_total) + assert diff < 0.10, f"streaming ${stream_total:.4f} vs batch ${batch_total:.4f} (diff ${diff:.4f})" + + +def test_streaming_import_kwh_accumulates() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + # First update primes _last_update; no energy accumulates yet + engine.update(1000.0, datetime(2026, 5, 10, 12, 0, 0)) + assert engine.import_kwh_today == 0 + # Second update 30 min later should accumulate ~0.5 kWh (1 kW × 0.5h) + # but GAP_PROTECTION caps delta at 0.1h => 0.1 kWh + engine.update(1000.0, datetime(2026, 5, 10, 12, 30, 0)) + assert 0.09 < engine.import_kwh_today < 0.11 + + +def test_streaming_gap_protection_caps_delta() -> None: + """A 1-hour gap should only accumulate GAP_PROTECTION_MAX_DELTA_H = 0.1h.""" + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(2000.0, datetime(2026, 5, 10, 12, 0, 0)) + engine.update(2000.0, datetime(2026, 5, 10, 13, 0, 0)) + # 2 kW × 0.1h = 0.2 kWh (not 2 kW × 1h = 2 kWh) + assert 0.19 < engine.import_kwh_today < 0.21 + + +def test_streaming_export_routes_negative_power() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(-1500.0, datetime(2026, 5, 10, 13, 0, 0)) + engine.update(-1500.0, datetime(2026, 5, 10, 13, 6, 0)) # 6 min later + # 1.5 kW export × 0.1h = 0.15 kWh + assert 0.14 < engine.export_kwh_today < 0.16 + assert engine.import_kwh_today == 0 + + +def test_streaming_reset_daily_clears_state() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(1000.0, datetime(2026, 5, 10, 12, 0, 0)) + engine.update(1000.0, datetime(2026, 5, 10, 12, 6, 0)) + assert engine.import_kwh_today > 0 + engine.reset_daily() + assert engine.import_kwh_today == 0 + assert engine.export_kwh_today == 0 + + +def test_streaming_current_import_rate_matches_tou() -> None: + """At 5pm on a weekday the GloBird PEAK rate (0.36/kWh ex-GST × 1.10 + = 39.6 c/kWh inc-GST) should be returned.""" + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(0.0, datetime(2026, 5, 12, 17, 0, 0)) # Tuesday 17:00 + rate = engine.current_import_rate_c_kwh + assert 39.0 < rate < 40.0, f"expected ~39.6 c/kWh inc-GST, got {rate}" + + +def test_streaming_current_import_rate_offpeak_free_window() -> None: + """11am-2pm is the free window: 0.000001/kWh ex-GST × 1.10 × 100 ≈ 0 c/kWh.""" + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(0.0, datetime(2026, 5, 12, 12, 0, 0)) + rate = engine.current_import_rate_c_kwh + assert rate < 0.01 + + +def test_streaming_to_from_dict_roundtrip() -> None: + plan = _load("plan_globird_GLO731031MR@VEC.json") + engine = CdrStreamingEngine(plan) + engine.update(1000.0, datetime(2026, 5, 10, 12, 0, 0)) + engine.update(1000.0, datetime(2026, 5, 10, 12, 6, 0)) + state = engine.to_dict() + today = date(2026, 5, 10) + restored = CdrStreamingEngine.from_dict(plan, state, today) + assert pytest.approx(restored.import_kwh_today, abs=0.001) == engine.import_kwh_today + + +def test_cdr_plan_provider_satisfies_protocol() -> None: + """CdrPlanProvider should be importable + match Provider Protocol shape. + + Phase 3.0 rename: id is now derived from plan brand + planId; name + from plan.displayName. Generic across all retailers. + """ + from custom_components.pricehawk.providers.base import Provider + from custom_components.pricehawk.providers.cdr_plan import CdrPlanProvider + + plan = _load("plan_globird_GLO731031MR@VEC.json") + p = CdrPlanProvider(plan) + assert isinstance(p, Provider), "CdrPlanProvider must satisfy Provider Protocol" + # Identity reflects the plan envelope, not a hardcoded "globird". + assert p.id.startswith("globird") + assert "GLO731031MR@VEC" in p.id + # Name comes from plan.displayName when available. + assert "GloBird" in p.name + + +def test_cdr_plan_provider_daily_fixed_charges_inc_gst() -> None: + """Daily supply $1.05/day ex-GST × 1.10 = $1.155/day inc-GST.""" + from custom_components.pricehawk.providers.cdr_plan import CdrPlanProvider + + plan = _load("plan_globird_GLO731031MR@VEC.json") + p = CdrPlanProvider(plan) + # Plan C2 fixture: dailySupplyCharge = 1.05 ex-GST + assert pytest.approx(p.daily_fixed_charges_aud, abs=0.001) == 1.155 diff --git a/tests/test_cdr_tiered_fit.py b/tests/test_cdr_tiered_fit.py new file mode 100644 index 0000000..8b05f68 --- /dev/null +++ b/tests/test_cdr_tiered_fit.py @@ -0,0 +1,303 @@ +"""Tests for cdr.incentive_parsers.common.tiered_fit — Phase 2.11. + +Catalog v3 finding: 210 plans across Origin, AGL, Alinta, EnergyAustralia, +GloBird ship tiered FIT as free-text incentives. These tests pin the +math against the exact eligibility text observed in the live sweep +(scripts/CDR_INCENTIVE_CATALOG.md). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from custom_components.pricehawk.cdr.incentive_parsers.common.tiered_fit import ( + apply_rule, + parse_from_incentives, + parse_rule, +) + + +@dataclass +class _StubBreakdown: + """Minimal CostBreakdown stand-in — only the fields tiered_fit touches.""" + incentive_aud_inc_gst: Decimal = Decimal("0") + notes: list[str] = field(default_factory=list) + trace: list[dict] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# parse_rule — regex coverage +# --------------------------------------------------------------------------- + + +class TestParseRateFirstDialect: + """Catalog: Alinta + Origin + EA Solar Max.""" + + def test_alinta_stepped_fit_exact_text(self): + # 66 plans use this exact wording. + text = ("This Energy Plan includes a stepped feed-in tariff, where " + "you will receive a feed-in of 7c/kWh for the first 10kW " + "exported. For any export after that you will obtain Alinta " + "Energy's standard retailer feed-in tariff of 0.04c/kWh.") + rule = parse_rule(text) + assert rule is not None + assert rule["tier1_c_per_kwh"] == Decimal("7") + assert rule["cap_kwh"] == Decimal("10") + assert rule["cap_window"] == "DAY" + + def test_origin_period_averaged(self): + # 84 Origin plans — text triggers PERIOD cap_window. + text = ("Origin offers 12 cents per kWh until a daily export limit " + "of 8 kWh is reached. The daily export limit is averaged " + "across your billing period (calculated by multiplying the " + "number of days in your billing period by your daily export " + "limit of 8)") + rule = parse_rule(text) + assert rule is not None + assert rule["tier1_c_per_kwh"] == Decimal("12") + assert rule["cap_kwh"] == Decimal("8") + assert rule["cap_window"] == "PERIOD" + + def test_energyaustralia_solar_max(self): + # 20 EnergyAustralia "Solar Max" plans. + text = ("Solar Max is for electricity only and is available to " + "eligible residential solar customers not receiving any " + "Government feed-in-tariff. The daily export is averaged " + "by dividing the total solar export by the number of days " + "in each billing period") + rule = parse_rule(text) + # Solar Max text doesn't include rate1 in its eligibility — it's + # named-only. Parser correctly returns None and lets the caller + # skip / log. Pin that behaviour. + assert rule is None + + +class TestParseQuantityFirstDialect: + """Catalog: AGL Solar Feed-in Tarriff [sic].""" + + def test_agl_tiered_fit_exact_text(self): + # 40 AGL plans use this exact wording (note typo "Tarriff"). + text = ("This plan features a tiered feed-in tariff. For the first " + "10kWh exported each day, we'll pay you a higher feed-in " + "tariff of 6c/kWh. Then, we'll pay 1.5c/kWh for the rest " + "of that day") + rule = parse_rule(text) + assert rule is not None + assert rule["tier1_c_per_kwh"] == Decimal("6") + assert rule["cap_kwh"] == Decimal("10") + assert rule["tier2_c_per_kwh"] == Decimal("1.5") + assert rule["cap_window"] == "DAY" + + +class TestParseEdgeCases: + def test_empty_string_returns_none(self): + assert parse_rule("") is None + assert parse_rule(None) is None # type: ignore[arg-type] + + def test_marketing_copy_returns_none(self): + # Common pattern: incentive name only, no math. + assert parse_rule("Generous solar feed-in") is None + + def test_unrelated_disclaimer_returns_none(self): + text = ("The Terms and Conditions for Feed-in Tariffs - Victoria " + "applies to both additional and standard retailer feed-in " + "tariff. When the benefit period ends you'll receive our " + "standard feed-in tariff available at the time as published") + assert parse_rule(text) is None + + +# --------------------------------------------------------------------------- +# apply_rule — math semantics +# --------------------------------------------------------------------------- + + +def _slots(day_exports: dict[str, list[float]]) -> list[dict]: + """Build slot fixtures: {date_str: [export_kwh, ...]}. + + Each export becomes one slot at hh:00 starting 09:00. + """ + out: list[dict] = [] + for date, exports in day_exports.items(): + for i, exp in enumerate(exports): + hour = 9 + i + out.append({ + "ts_local": f"{date}T{hour:02d}:00:00", + "grid_export_kwh": exp, + }) + return out + + +class TestApplyDayCap: + """DAY cap_window — strict daily reset.""" + + def test_alinta_single_day_below_cap(self): + # 5 kWh exported, cap 10 kWh, tier1 7c/kWh, base FIT 0.04c. + # Delta = (7 - 0.04) / 100 × 5 = 0.348 AUD credit. + rule = {"tier1_c_per_kwh": Decimal("7"), "cap_kwh": Decimal("10"), + "tier2_c_per_kwh": None, "cap_window": "DAY", + "source": "test"} + slots = _slots({"2026-05-15": [5.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + # incentive_aud_inc_gst is DECREASED (negative = credit to user) + assert b.incentive_aud_inc_gst == Decimal("-0.348") + + def test_alinta_single_day_above_cap_no_tier2(self): + # 15 kWh exported, cap 10 kWh, tier1 7c, no tier2 (rate-first) + # Tier1 credit: (7 - 0.04) / 100 × 10 = 0.696 + # Tier2 implicit: nothing (rule says fall back to base FIT, + # which is what evaluator already credited) + rule = {"tier1_c_per_kwh": Decimal("7"), "cap_kwh": Decimal("10"), + "tier2_c_per_kwh": None, "cap_window": "DAY", + "source": "test"} + slots = _slots({"2026-05-15": [15.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert b.incentive_aud_inc_gst == Decimal("-0.696") + + def test_agl_single_day_above_cap_with_tier2(self): + # 25 kWh exported, cap 10 kWh, tier1 6c, tier2 1.5c, base 5c. + # Tier1 delta: (6 - 5) / 100 × 10 = 0.10 + # Tier2 delta: (1.5 - 5) / 100 × 15 = -0.525 (tier2 BELOW base + # means user gets LESS than evaluator already credited) + # Net: 0.10 + (-0.525) = -0.425; sign flips to user's pocket + # So incentive_aud_inc_gst -= -0.425 → +0.425 (extra cost) + rule = {"tier1_c_per_kwh": Decimal("6"), "cap_kwh": Decimal("10"), + "tier2_c_per_kwh": Decimal("1.5"), "cap_window": "DAY", + "source": "test"} + slots = _slots({"2026-05-15": [25.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("5")) + # Net mutation: incentive_aud_inc_gst -= 0.10 (tier 1 wins) + # then incentive_aud_inc_gst -= -0.525 (tier 2 loses) + # Final: -0.10 + 0.525 = +0.425 + assert b.incentive_aud_inc_gst == Decimal("0.425") + + def test_day_cap_resets_each_day(self): + # Two days, 8 kWh each. Cap 10 kWh per day. Tier1 7c, base 0.04c. + # Each day below cap → 2 × (7-0.04)/100 × 8 = 1.1136 + rule = {"tier1_c_per_kwh": Decimal("7"), "cap_kwh": Decimal("10"), + "tier2_c_per_kwh": None, "cap_window": "DAY", + "source": "test"} + slots = _slots({"2026-05-15": [8.0], "2026-05-16": [8.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert b.incentive_aud_inc_gst == Decimal("-1.1136") + + def test_zero_export_no_credit(self): + rule = {"tier1_c_per_kwh": Decimal("7"), "cap_kwh": Decimal("10"), + "tier2_c_per_kwh": None, "cap_window": "DAY", + "source": "test"} + slots = _slots({"2026-05-15": [0.0, 0.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert b.incentive_aud_inc_gst == Decimal("0") + assert b.trace == [] + + +class TestApplyPeriodCap: + """PERIOD cap_window — Origin/EA monthly-averaged pool.""" + + def test_origin_30day_period_within_pool(self): + # 30 days × 8 kWh/day cap = 240 kWh effective pool. + # 30 days × 7 kWh exported = 210 kWh, all under pool. + # Tier1 12c, base 0.04c → (12 - 0.04)/100 × 210 = 25.116 + rule = {"tier1_c_per_kwh": Decimal("12"), "cap_kwh": Decimal("8"), + "tier2_c_per_kwh": None, "cap_window": "PERIOD", + "source": "test"} + slots = _slots({f"2026-05-{day:02d}": [7.0] for day in range(1, 31)}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert b.incentive_aud_inc_gst == Decimal("-25.116") + + def test_origin_period_pool_exhausted_early(self): + # 30 days × 8 cap = 240 kWh pool. + # User over-exports first 10 days at 30 kWh/day = 300 kWh total + # for first 10 days, then 0 thereafter. Pool exhausted on day 8. + # Day 1-8: 30 kWh × 8 = 240 kWh credited at tier1. + # Day 8 partial + day 9-10: 60 kWh overflow (no tier2 → no credit). + # Tier1 delta: (12 - 0.04)/100 × 240 = 28.704 + rule = {"tier1_c_per_kwh": Decimal("12"), "cap_kwh": Decimal("8"), + "tier2_c_per_kwh": None, "cap_window": "PERIOD", + "source": "test"} + slots = _slots({f"2026-05-{day:02d}": [30.0] + for day in range(1, 11)}) + # Pad to full 30-day period so effective_cap = 8 × 30 = 240 + for day in range(11, 31): + slots.append({"ts_local": f"2026-05-{day:02d}T09:00:00", + "grid_export_kwh": 0.0}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert b.incentive_aud_inc_gst == Decimal("-28.704") + + def test_period_trace_records_window_type(self): + rule = {"tier1_c_per_kwh": Decimal("12"), "cap_kwh": Decimal("8"), + "tier2_c_per_kwh": None, "cap_window": "PERIOD", + "source": "test"} + slots = _slots({"2026-05-15": [5.0]}) + b = _StubBreakdown() + apply_rule(rule, slots, b, base_fit_c_per_kwh=Decimal("0.04")) + assert len(b.trace) == 1 + assert b.trace[0]["incentive"] == "tiered_fit" + assert b.trace[0]["cap_window"] == "PERIOD" + assert b.trace[0]["tier1_kwh"] == 5.0 + assert b.trace[0]["tier1_c_per_kwh"] == 12.0 + + +# --------------------------------------------------------------------------- +# parse_from_incentives — full-incentive-list helper +# --------------------------------------------------------------------------- + + +class TestParseFromIncentives: + def test_walks_eligibility_field_alinta(self): + incentives = [{ + "displayName": "Solar Feed-in Tariff", + "description": "Stepped FiT", + "eligibility": ("This Energy Plan includes a stepped feed-in " + "tariff, where you will receive a feed-in of " + "7c/kWh for the first 10kW exported. For any " + "export after that you will obtain Alinta " + "Energy's standard retailer feed-in tariff " + "of 0.04c/kWh."), + }] + rule = parse_from_incentives(incentives) + assert rule is not None + assert rule["tier1_c_per_kwh"] == Decimal("7") + assert rule["source_displayName"] == "Solar Feed-in Tariff" + + def test_walks_description_field_when_eligibility_empty(self): + # AGL pattern: math sometimes lives in description, not eligibility. + incentives = [{ + "displayName": "Solar Feed-in Tarriff", + "description": ("This plan features a tiered feed-in tariff. " + "For the first 10kWh exported each day, we'll " + "pay you a higher feed-in tariff of 6c/kWh. " + "Then, we'll pay 1.5c/kWh for the rest of " + "that day"), + "eligibility": "", + }] + rule = parse_from_incentives(incentives) + assert rule is not None + assert rule["tier2_c_per_kwh"] == Decimal("1.5") + + def test_returns_first_match_when_multiple_present(self): + incentives = [ + {"displayName": "Loyalty", "eligibility": "Earn Qantas Points"}, + {"displayName": "Tiered FiT", + "eligibility": "7c/kWh for the first 10 kWh"}, + ] + rule = parse_from_incentives(incentives) + assert rule is not None + assert rule["tier1_c_per_kwh"] == Decimal("7") + + def test_no_match_returns_none(self): + incentives = [ + {"displayName": "Welcome", "eligibility": "$50 sign-up credit"}, + {"displayName": "Greenpower", "eligibility": "100% matched"}, + ] + assert parse_from_incentives(incentives) is None + + def test_empty_list_returns_none(self): + assert parse_from_incentives([]) is None + assert parse_from_incentives(None) is None # type: ignore[arg-type] diff --git a/tests/test_cdr_vpp_rebate.py b/tests/test_cdr_vpp_rebate.py new file mode 100644 index 0000000..d4448a2 --- /dev/null +++ b/tests/test_cdr_vpp_rebate.py @@ -0,0 +1,197 @@ +"""Tests for vpp_rebate.py (Phase 2.11.5).""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from custom_components.pricehawk.cdr.incentive_parsers.common.vpp_rebate import ( + REBATE_RE, + TRIGGER_RE, + apply_rule, + parse_from_incentives, + parse_rule, +) + + +@dataclass +class FakeBreakdown: + incentive_aud_inc_gst: Decimal = Decimal("0") + trace: list[dict[str, Any]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +# --- Regex coverage ---------------------------------------------------- + + +def test_trigger_matches_vpp_acronym(): + assert TRIGGER_RE.search("Participate in our VPP and earn $15/month") + + +def test_trigger_matches_virtual_power_plant(): + assert TRIGGER_RE.search("Enrol in our Virtual Power Plant programme") + + +def test_trigger_matches_PowerResponse(): + assert TRIGGER_RE.search("PowerResponse: enrol your battery for $20/month") + + +def test_trigger_matches_enrol_battery(): + assert TRIGGER_RE.search("Enrol your battery to receive monthly credit") + + +def test_trigger_does_not_match_ev_offpeak(): + assert not TRIGGER_RE.search("$0.045/kWh usage charge between midnight and 6am") + + +def test_rebate_matches_monthly_per_battery(): + m = REBATE_RE.search("$15 monthly credit per battery") + assert m + assert m.group("rebate") == "15" + + +def test_rebate_matches_slash_month(): + m = REBATE_RE.search("$20/month per battery enrolled") + assert m + assert m.group("rebate") == "20" + + +def test_rebate_matches_per_month(): + m = REBATE_RE.search("$25 per month per battery") + assert m + assert m.group("rebate") == "25" + + +# --- parse_rule ------------------------------------------------------- + + +def test_parse_rule_engie_canonical(): + rule = parse_rule( + "$15 monthly credit per battery for participating in our VPP." + ) + assert rule is not None + assert rule["monthly_rebate_aud"] == Decimal("15") + assert rule["batteries_enrolled"] == 0 # opt-in default + + +def test_parse_rule_ea_powerresponse(): + rule = parse_rule( + "Enrol your battery in PowerResponse and receive $20 per month per battery.", + batteries_enrolled=1, + ) + assert rule is not None + assert rule["monthly_rebate_aud"] == Decimal("20") + assert rule["batteries_enrolled"] == 1 + + +def test_parse_rule_no_trigger_returns_none(): + """Rebate without VPP context (e.g., sign-up bonus) should not match.""" + assert parse_rule("$50 sign-up credit on first bill.") is None + + +def test_parse_rule_trigger_but_no_rebate_returns_none(): + assert parse_rule("Enrol in our VPP programme today!") is None + + +def test_parse_rule_empty_returns_none(): + assert parse_rule("") is None + + +# --- apply_rule ------------------------------------------------------- + + +def test_apply_rule_no_op_when_batteries_zero(): + bd = FakeBreakdown() + rule = { + "monthly_rebate_aud": Decimal("15"), + "batteries_enrolled": 0, + "source": "", + } + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == Decimal("0") + assert bd.trace == [] + + +def test_apply_rule_credits_one_battery(): + """$15/mo × 1 battery / 30 days = $0.50/day.""" + bd = FakeBreakdown() + rule = { + "monthly_rebate_aud": Decimal("15"), + "batteries_enrolled": 1, + "source": "test", + } + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == -Decimal("0.5") + assert len(bd.trace) == 1 + assert bd.trace[0]["incentive"] == "vpp_rebate" + + +def test_apply_rule_scales_with_batteries(): + """3 batteries × $15/mo / 30 = $1.50/day.""" + bd = FakeBreakdown() + rule = { + "monthly_rebate_aud": Decimal("15"), + "batteries_enrolled": 3, + "source": "", + } + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == -Decimal("1.5") + + +def test_apply_rule_no_op_when_rebate_zero(): + bd = FakeBreakdown() + rule = { + "monthly_rebate_aud": Decimal("0"), + "batteries_enrolled": 1, + "source": "", + } + apply_rule(rule, [], bd) + assert bd.incentive_aud_inc_gst == Decimal("0") + + +# --- parse_from_incentives ------------------------------------------- + + +def test_parse_from_incentives_finds_vpp(): + incs = [ + { + "displayName": "PowerResponse VPP", + "eligibility": "$15 monthly credit per battery for participating in our VPP.", + }, + { + "displayName": "Sign-Up", + "eligibility": "$50 credit on first bill.", + }, + ] + rules = parse_from_incentives(incs) + assert len(rules) == 1 + assert rules[0]["monthly_rebate_aud"] == Decimal("15") + + +def test_parse_from_incentives_propagates_batteries(): + incs = [ + { + "displayName": "VPP", + "eligibility": "$15 monthly credit per battery for participating in our VPP.", + } + ] + rules = parse_from_incentives(incs, batteries_enrolled=2) + assert len(rules) == 1 + assert rules[0]["batteries_enrolled"] == 2 + + +def test_parse_from_incentives_falls_back_to_description(): + incs = [ + { + "displayName": "VPP", + "description": "Enrol your battery in our Virtual Power Plant for $20/month per battery.", + "eligibility": "", + } + ] + rules = parse_from_incentives(incs) + assert len(rules) == 1 + assert rules[0]["monthly_rebate_aud"] == Decimal("20") + + +def test_parse_from_incentives_empty(): + assert parse_from_incentives([]) == [] diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index ea8fdf1..2c333f4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -6,10 +6,24 @@ from __future__ import annotations +from custom_components.pricehawk.cdr.registry import RetailerEndpoint from custom_components.pricehawk.config_flow import ( + CDR_ANY_DISTRIBUTOR_SENTINEL, + CDR_SKIP_SENTINEL, + STATE_DISTRIBUTORS, + _build_cdr_plan_options, + _build_cdr_retailer_options, + _build_distributor_options, _build_export_tariff, _build_import_tariff, + _build_state_options, + _dedupe_plans_by_displayName, + _filter_plans_by_geography, + _postcode_to_state, _str_to_windows, + _summarise_cdr_plan, + _summarise_fit, + _summarise_import_rate, _time_to_minutes, _validate_full_coverage, _validate_no_overlap, @@ -240,3 +254,525 @@ def test_validate_full_coverage_gap(self): def test_validate_full_coverage_empty(self): """All empty strings means zero coverage.""" assert _validate_full_coverage("", "", "") is False + + +# --------------------------------------------------------------------------- +# Phase 2.2 — CDR wizard helpers +# --------------------------------------------------------------------------- + + +class TestBuildCdrRetailerOptions: + def test_no_skip_sentinel(self): + # Phase 3.0f removed manual entry; the install-flow dropdown + # contains only real retailers. + endpoints = [ + RetailerEndpoint(brand_id="a", brand_name="AGL", base_uri="https://a"), + RetailerEndpoint(brand_id="b", brand_name="Origin", base_uri="https://b"), + ] + options = _build_cdr_retailer_options(endpoints) + assert all(o["value"] != CDR_SKIP_SENTINEL for o in options) + assert {o["value"] for o in options} == {"a", "b"} + + def test_sorted_alphabetically_case_insensitive(self): + endpoints = [ + RetailerEndpoint(brand_id="o", brand_name="Origin", base_uri="https://o"), + RetailerEndpoint(brand_id="a", brand_name="agl", base_uri="https://a"), + RetailerEndpoint(brand_id="r", brand_name="Red Energy", base_uri="https://r"), + ] + options = _build_cdr_retailer_options(endpoints) + assert [o["label"] for o in options] == ["agl", "Origin", "Red Energy"] + + def test_empty_endpoints_returns_empty(self): + # No retailers + no Skip = empty list. Caller is responsible for + # routing to the error step before getting here. + assert _build_cdr_retailer_options([]) == [] + + +class TestBuildCdrPlanOptions: + def test_basic_conversion(self): + plans = [ + { + "planId": "AGL123", + "displayName": "AGL Value Saver Residential", + "effectiveFrom": "2026-01-01T00:00:00Z", + } + ] + options = _build_cdr_plan_options(plans) + assert len(options) == 1 + assert options[0]["value"] == "AGL123" + # Effective-from gets sliced to YYYY-MM-DD for human readability. + assert "2026-01-01" in options[0]["label"] + assert "Value Saver" in options[0]["label"] + + def test_filters_entries_missing_required_fields(self): + plans = [ + {"planId": "OK", "displayName": "Plan A", "effectiveFrom": "2026-01-01"}, + {"planId": "", "displayName": "Plan B"}, # empty planId — dropped + {"displayName": "Plan C"}, # no planId — dropped + {"planId": "D"}, # no displayName — dropped + ] + options = _build_cdr_plan_options(plans) + assert [o["value"] for o in options] == ["OK"] + + def test_sorted_by_display_name(self): + plans = [ + {"planId": "Z", "displayName": "Zappy", "effectiveFrom": "2026-01-01"}, + {"planId": "A", "displayName": "Alpine", "effectiveFrom": "2026-01-01"}, + {"planId": "M", "displayName": "moderate", "effectiveFrom": "2026-01-01"}, + ] + options = _build_cdr_plan_options(plans) + # Case-insensitive sort: Alpine, moderate, Zappy + labels = [o["label"] for o in options] + assert labels[0].startswith("Alpine") + assert labels[1].startswith("moderate") + assert labels[2].startswith("Zappy") + + def test_missing_effective_from_renders_unknown(self): + plans = [{"planId": "X", "displayName": "Plan X"}] + options = _build_cdr_plan_options(plans) + assert "?" in options[0]["label"] + + def test_empty_list_returns_empty(self): + assert _build_cdr_plan_options([]) == [] + + +# --------------------------------------------------------------------------- +# Phase 2.4 — Branch C audit field (CDR_SKIP_REASON_*) sanity +# --------------------------------------------------------------------------- + + +class TestCdrSkipReasonConstants: + def test_skip_reasons_distinct(self): + from custom_components.pricehawk.const import ( + CDR_SKIP_REASON_AFTER_ERROR, + CDR_SKIP_REASON_NO_RETAILER, + CDR_SKIP_REASON_RETRY_EXHAUSTED, + CDR_SKIP_REASON_USER_AT_PLAN, + CDR_SKIP_REASON_USER_AT_RETAILER, + ) + reasons = { + CDR_SKIP_REASON_USER_AT_RETAILER, + CDR_SKIP_REASON_USER_AT_PLAN, + CDR_SKIP_REASON_AFTER_ERROR, + CDR_SKIP_REASON_RETRY_EXHAUSTED, + CDR_SKIP_REASON_NO_RETAILER, + } + # 5 distinct values — each branch site is identifiable. + assert len(reasons) == 5 + # All snake_case lowercase ascii — safe for JSON keys/logs. + for r in reasons: + assert r == r.lower() + assert " " not in r + + def test_cdr_skip_reason_conf_key(self): + from custom_components.pricehawk.const import CONF_CDR_SKIP_REASON + assert CONF_CDR_SKIP_REASON == "cdr_skip_reason" + + +# --------------------------------------------------------------------------- +# Phase 2.8 — Locale + distributor filter +# --------------------------------------------------------------------------- + + +class TestPostcodeToState: + def test_nsw_sydney_2000(self): + assert _postcode_to_state("2000") == "NSW" + + def test_nsw_country_2480(self): + assert _postcode_to_state("2480") == "NSW" + + def test_act_canberra_2601(self): + # ACT range is tested BEFORE NSW so 2601 wins. + assert _postcode_to_state("2601") == "ACT" + + def test_act_canberra_2615(self): + assert _postcode_to_state("2615") == "ACT" + + def test_vic_melbourne_3000(self): + assert _postcode_to_state("3000") == "VIC" + + def test_vic_po_box_8000(self): + assert _postcode_to_state("8000") == "VIC" + + def test_qld_brisbane_4000(self): + assert _postcode_to_state("4000") == "QLD" + + def test_sa_adelaide_5000(self): + assert _postcode_to_state("5000") == "SA" + + def test_wa_perth_6000(self): + assert _postcode_to_state("6000") == "WA" + + def test_tas_hobart_7000(self): + assert _postcode_to_state("7000") == "TAS" + + def test_invalid_letters(self): + assert _postcode_to_state("ABCD") is None + + def test_invalid_too_short(self): + assert _postcode_to_state("20") is None + + def test_invalid_too_long(self): + assert _postcode_to_state("20000") is None + + def test_whitespace_handled(self): + assert _postcode_to_state(" 2000 ") == "NSW" + + def test_unmapped_range(self): + # 0700 is not in any electricity state mapping. + assert _postcode_to_state("0700") is None + + +class TestFilterPlansByGeography: + def _plan(self, name: str, *, postcodes: list[str] | None = None, distributors: list[str] | None = None) -> dict: + return { + "planId": name[:8], + "displayName": name, + "customerType": "RESIDENTIAL", + "geography": { + "includedPostcodes": postcodes or [], + "distributors": distributors or [], + }, + } + + def test_no_filter_returns_all(self): + plans = [self._plan("AGL Plan A"), self._plan("AGL Plan B")] + result = _filter_plans_by_geography(plans) + assert len(result) == 2 + + def test_postcode_filter_via_includedPostcodes(self): + plans = [ + self._plan("AGL Plan A", postcodes=["3977", "3978"]), + self._plan("AGL Plan B", postcodes=["2000"]), + self._plan("AGL Plan C", postcodes=["3977"]), + ] + result = _filter_plans_by_geography(plans, postcode="3977") + assert len(result) == 2 + names = [p["displayName"] for p in result] + assert "AGL Plan A" in names + assert "AGL Plan C" in names + + def test_state_only_via_distributor_intersect(self): + plans = [ + self._plan("AGL", distributors=["Ausgrid"]), # NSW + self._plan("AGL", distributors=["Endeavour"]), # NSW + self._plan("AGL", distributors=["Powercor"]), # VIC + ] + result = _filter_plans_by_geography(plans, state="NSW") + assert len(result) == 2 + + def test_state_only_via_postcode_range_when_no_distributors(self): + plans = [ + self._plan("AGL A", postcodes=["3977"]), # VIC + self._plan("AGL B", postcodes=["2000"]), # NSW + ] + result = _filter_plans_by_geography(plans, state="VIC") + assert len(result) == 1 + assert result[0]["displayName"] == "AGL A" + + def test_distributor_only_filter(self): + plans = [ + self._plan("AGL Plan A", distributors=["United Energy"]), + self._plan("AGL Plan B", distributors=["Powercor"]), + ] + result = _filter_plans_by_geography(plans, distributor="United Energy") + assert len(result) == 1 + assert "Plan A" in result[0]["displayName"] + + def test_postcode_and_distributor_intersect(self): + plans = [ + self._plan("A", postcodes=["3977"], distributors=["United Energy"]), + self._plan("B", postcodes=["3977"], distributors=["Powercor"]), + self._plan("C", postcodes=["3000"], distributors=["United Energy"]), + ] + result = _filter_plans_by_geography( + plans, postcode="3977", distributor="United Energy", + ) + assert len(result) == 1 + assert result[0]["displayName"] == "A" + + def test_any_distributor_sentinel_treated_as_no_dist_filter(self): + plans = [ + self._plan("A", postcodes=["3977"], distributors=["United Energy"]), + ] + result = _filter_plans_by_geography( + plans, postcode="3977", distributor=CDR_ANY_DISTRIBUTOR_SENTINEL, + ) + assert len(result) == 1 + + def test_no_match_returns_empty(self): + plans = [self._plan("A", postcodes=["2000"])] + result = _filter_plans_by_geography(plans, postcode="3977") + assert result == [] + + def test_plans_without_geography_displayname_fallback(self): + # Retailer omits geography (some smaller retailers do) + plans = [ + {"planId": "X", "displayName": "BOOST United Energy"}, + {"planId": "Y", "displayName": "BOOST Powercor"}, + ] + result = _filter_plans_by_geography(plans, distributor="United Energy") + assert len(result) == 1 + + +class TestDedupeByDisplayName: + def test_keeps_one_per_name(self): + plans = [ + {"planId": "1", "displayName": "Plan A", "effectiveFrom": "2026-01-01"}, + {"planId": "2", "displayName": "Plan A", "effectiveFrom": "2026-05-01"}, + {"planId": "3", "displayName": "Plan B", "effectiveFrom": "2026-01-01"}, + ] + result = _dedupe_plans_by_displayName(plans) + assert len(result) == 2 + names = {p["displayName"] for p in result} + assert names == {"Plan A", "Plan B"} + # Latest effectiveFrom wins for Plan A. + plan_a = next(p for p in result if p["displayName"] == "Plan A") + assert plan_a["planId"] == "2" + assert plan_a["effectiveFrom"] == "2026-05-01" + + def test_skips_empty_displayName(self): + plans = [ + {"planId": "1", "displayName": ""}, + {"planId": "2", "displayName": "Plan A", "effectiveFrom": "2026-01-01"}, + ] + result = _dedupe_plans_by_displayName(plans) + assert len(result) == 1 + assert result[0]["planId"] == "2" + + def test_handles_missing_effectiveFrom(self): + plans = [ + {"planId": "1", "displayName": "Plan A"}, + {"planId": "2", "displayName": "Plan A", "effectiveFrom": "2026-01-01"}, + ] + result = _dedupe_plans_by_displayName(plans) + assert len(result) == 1 + # The one WITH effectiveFrom wins. + assert result[0]["planId"] == "2" + + def test_agl_67_to_16_cascade(self): + """Mirror the live UAT cascade — 4 cohort variants per plan name → 1 each.""" + plans = [] + for name in ["Smart Saver", "Solar Savers", "Netflix Plan", "Seniors Saver"]: + for variant in ["", " - 3rd Party", " - New to AGL", " (Velocity)"]: + full = f"Residential {name}{variant}" + # 4 plan IDs per name×variant — same effective date. + for i in range(4): + plans.append({ + "planId": f"AGL{name[:3]}{variant[:3]}{i:02d}", + "displayName": full, + "effectiveFrom": "2026-05-01", + }) + # 4 names × 4 variants × 4 IDs = 64 plans, 16 unique displayName. + assert len(plans) == 64 + result = _dedupe_plans_by_displayName(plans) + assert len(result) == 16 + + +class TestStateDistributorOptions: + def test_state_options_include_all_8(self): + opts = _build_state_options() + labels = [o["label"] for o in opts] + # Skip + 7 states + assert len(opts) == 8 + assert "Skip filter — show all plans" in labels + for state_name in ["New South Wales", "Victoria", "Queensland", "South Australia", + "Tasmania", "Australian Capital Territory", "Western Australia"]: + assert state_name in labels + + def test_distributor_options_for_nsw(self): + opts = _build_distributor_options("NSW") + values = [o["value"] for o in opts] + # "Any" + 3 NSW distributors + assert CDR_ANY_DISTRIBUTOR_SENTINEL in values + assert "Ausgrid" in values + assert "Endeavour" in values + assert "Essential Energy" in values + + def test_distributor_options_for_unknown_state(self): + opts = _build_distributor_options("XX") + # Just the "Any" sentinel. + assert len(opts) == 1 + assert opts[0]["value"] == CDR_ANY_DISTRIBUTOR_SENTINEL + + def test_distributor_options_none_state(self): + opts = _build_distributor_options(None) + assert len(opts) == 1 + + def test_state_distributors_dict_completeness(self): + # All 8 states have at least one known distributor. + for state in ["NSW", "VIC", "QLD", "SA", "TAS", "ACT", "WA", "NT"]: + assert state in STATE_DISTRIBUTORS + assert len(STATE_DISTRIBUTORS[state]) >= 1 + + +# --------------------------------------------------------------------------- +# Phase 2.9 — Plan-confirmation summary helper +# --------------------------------------------------------------------------- + + +class TestSummariseCdrPlan: + def test_minimal_envelope(self): + out = _summarise_cdr_plan({}) + assert out["brand"] == "?" + assert out["plan_name"] == "?" + + def test_extracts_displayName_and_brand(self): + detail = {"data": { + "brandName": "GloBird Energy", + "displayName": "ZEROHERO Residential", + "effectiveFrom": "2026-03-31T00:00:00Z", + "electricityContract": {}, + }} + out = _summarise_cdr_plan(detail) + assert out["brand"] == "GloBird Energy" + assert out["plan_name"] == "ZEROHERO Residential" + # Effective gets sliced to YYYY-MM-DD for legibility. + assert out["effective"] == "2026-03-31" + + def test_daily_supply_converted_to_inc_gst_cents(self): + # 1.05 $/day ex-GST = 1.155 $/day inc-GST = 115.50 c/day inc-GST + detail = {"data": {"electricityContract": {"dailySupplyCharge": "1.05"}}} + out = _summarise_cdr_plan(detail) + assert "115.50" in out["daily_supply"] + assert "inc-GST" in out["daily_supply"] + + def test_daily_supply_per_tariff_period_singular(self): + # AGL nests dailySupplyCharge (singular) inside tariffPeriod[i]. + # Pre-2.10.1 this returned "not published" because we only checked + # the plural variant inside the loop. + detail = {"data": {"electricityContract": { + "tariffPeriod": [{ + "dailySupplyCharge": "0.9547", + "rateBlockUType": "singleRate", + "singleRate": {"rates": [{"unitPrice": "0.22"}]}, + }], + }}} + out = _summarise_cdr_plan(detail) + # 0.9547 × 110 = 105.02 + assert "105.02" in out["daily_supply"] + + def test_all_incentives_listed_no_truncation(self): + # Phase 2.10.2 — drop the "+N more" suffix; user verifies plan + # against bill, hidden incentives defeat the purpose. + detail = {"data": {"electricityContract": { + "incentives": [ + {"displayName": "A"}, {"displayName": "B"}, + {"displayName": "C"}, {"displayName": "D"}, + {"displayName": "E"}, {"displayName": "F"}, + ] + }}} + out = _summarise_cdr_plan(detail) + assert out["incentives"] == "A, B, C, D, E, F" + + def test_no_incentives_renders_none(self): + detail = {"data": {"electricityContract": {"incentives": []}}} + out = _summarise_cdr_plan(detail) + assert out["incentives"] == "none" + + def test_handles_non_dict_root(self): + out = _summarise_cdr_plan("garbage") # type: ignore[arg-type] + assert out["brand"] == "?" + + +class TestSummariseImportRate: + def test_legacy_tou_three_periods(self): + # Legacy fallback path — tariffPeriod[].rates[] without nested block. + elec = {"tariffPeriod": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.36"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.25"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.0000001"}]}, + ]} + result = _summarise_import_rate(elec) + assert "39.6" in result + assert "27.5" in result + assert "OFF_PEAK" in result + + def test_agl_singleRate_dict_shape(self): + # AGL Netflix Plan: rateBlockUType="singleRate" with singleRate as a + # DICT (not list) at tariffPeriod level. Bug surfaced live during + # UAT — confirm screen showed "?" because list-only branch missed. + elec = {"tariffPeriod": [{ + "rateBlockUType": "singleRate", + "singleRate": { + "rates": [{"unitPrice": "0.2228"}], + "period": "P1D", + "displayName": "Rate", + }, + "displayName": "Period", + "dailySupplyCharge": "0.9547", + }]} + result = _summarise_import_rate(elec) + # 0.2228 ex-GST × 110 = 24.5 c/kWh inc-GST + assert "24.5" in result + # Phase 2.10.4 polish — generic "Rate" label stripped (the + # surrounding "Import rate:" form prefix supplies it). + assert result == "24.5 c/kWh inc-GST" + + def test_real_cdr_timeofuserates_shape(self): + # The actual GloBird ZEROHERO shape from live CDR — nested + # timeOfUseRates[] inside tariffPeriod[]. + elec = {"tariffPeriod": [{ + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.36"}]}, + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.000001"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.25"}]}, + ], + }]} + result = _summarise_import_rate(elec) + assert "39.6" in result + assert "0.0" in result # OFF_PEAK ≈ 0 c/kWh + assert "27.5" in result + + def test_single_rate_flat(self): + elec = {"singleRate": {"rates": [{"unitPrice": "0.30"}]}} + result = _summarise_import_rate(elec) + assert "Flat" in result + assert "33.00" in result + + def test_no_rate_returns_q(self): + assert _summarise_import_rate({}) == "?" + + +class TestSummariseFit: + def test_single_tariff(self): + elec = {"solarFeedInTariff": [ + {"singleTariff": {"rates": [{"unitPrice": "0.05"}]}} + ]} + result = _summarise_fit(elec) + # 0.05 × 110 = 5.50 c/kWh inc-GST + assert "5.50" in result + + def test_multiple_blocks_summed(self): + elec = {"solarFeedInTariff": [ + {"singleTariff": {"rates": [{"unitPrice": "0.05"}]}}, + {"singleTariff": {"rates": [{"unitPrice": "0.03"}]}}, + ]} + result = _summarise_fit(elec) + assert "5.50" in result + assert "3.30" in result + + def test_empty_returns_none(self): + assert _summarise_fit({}) == "none" + + def test_timevarying_tou_summarised(self): + # GloBird Combo GLOSAVE shape: timeVaryingTariffs with PEAK/SHOULDER. + elec = {"solarFeedInTariff": [{ + "tariffUType": "timeVaryingTariffs", + "timeVaryingTariffs": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.03"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.001"}]}, + ], + }]} + result = _summarise_fit(elec) + # 0.03 × 110 = 3.3; 0.001 × 110 = 0.1 + assert "PEAK 3.3" in result + assert "SHOULDER 0.1" in result + assert "inc-GST" in result + + def test_empty_timevarying_returns_none(self): + # No usable rates inside the block → "none". + elec = {"solarFeedInTariff": [{"timeVaryingTariffs": [{"rates": []}]}]} + result = _summarise_fit(elec) + assert result == "none" diff --git a/tests/test_config_flow_phase_3.py b/tests/test_config_flow_phase_3.py new file mode 100644 index 0000000..7019fcb --- /dev/null +++ b/tests/test_config_flow_phase_3.py @@ -0,0 +1,55 @@ +"""Phase 3.0g — wizard rewrite tests. + +The HA config-flow step machinery needs a full HA test harness which +isn't available in the pure-Python mock layer. These tests cover the +pure helpers Phase 3 introduced + extracted from the wizard logic. +""" +from __future__ import annotations + +from custom_components.pricehawk.config_flow import _api_provider_for_brand +from custom_components.pricehawk.const import ( + PROVIDER_AMBER, + PROVIDER_FLOW_POWER, + PROVIDER_LOCALVOLTS, +) + + +# --- _api_provider_for_brand --------------------------------------- + + +def test_amber_brand_maps_to_amber_provider(): + assert _api_provider_for_brand("amber") == PROVIDER_AMBER + assert _api_provider_for_brand("amber-electric") == PROVIDER_AMBER + assert _api_provider_for_brand("Amber Electric") == PROVIDER_AMBER + + +def test_flow_power_maps_to_flow_power_provider(): + assert _api_provider_for_brand("flow-power") == PROVIDER_FLOW_POWER + assert _api_provider_for_brand("flow power") == PROVIDER_FLOW_POWER + assert _api_provider_for_brand("Flow Power") == PROVIDER_FLOW_POWER + + +def test_localvolts_maps_to_localvolts_provider(): + assert _api_provider_for_brand("localvolts") == PROVIDER_LOCALVOLTS + assert _api_provider_for_brand("LocalVolts") == PROVIDER_LOCALVOLTS + + +def test_globird_returns_none(): + """GloBird has no live consumer API — wizard skips API-connect.""" + assert _api_provider_for_brand("globird") is None + + +def test_origin_agl_red_return_none(): + """Big traditional retailers — no consumer API in v1.5.x.""" + assert _api_provider_for_brand("origin") is None + assert _api_provider_for_brand("agl") is None + assert _api_provider_for_brand("red-energy") is None + + +def test_unknown_brand_returns_none(): + assert _api_provider_for_brand("unknown-retailer") is None + + +def test_empty_returns_none(): + assert _api_provider_for_brand("") is None + assert _api_provider_for_brand(" ") is None diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 113ca88..e8e5e71 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -275,13 +275,14 @@ class TestDataDictKeys: """Contract test: data dict must contain expected keys for Phase 3 sensors.""" EXPECTED_KEYS = { - "globird_import_rate", - "globird_export_rate", - "globird_daily_cost", - "globird_import_kwh", - "globird_export_kwh", - "globird_zerohero_status", - "globird_super_export_kwh", + "current_plan_import_rate", + "current_plan_export_rate", + "current_plan_daily_cost", + "current_plan_import_kwh", + "current_plan_export_kwh", + "current_plan_zerohero_status", + "current_plan_super_export_kwh", + "current_plan_peak_rate", # Phase 3.0g (CodeRabbit) "amber_import_rate", "amber_export_rate", "amber_daily_cost", @@ -295,13 +296,14 @@ def test_data_dict_has_all_keys(self): calc = AmberCalculator() data = { - "globird_import_rate": engine.current_import_rate_c_kwh, - "globird_export_rate": engine.current_export_rate_c_kwh, - "globird_daily_cost": engine.net_daily_cost_aud, - "globird_import_kwh": engine.import_kwh_today, - "globird_export_kwh": engine.export_kwh_today, - "globird_zerohero_status": engine.zerohero_status, - "globird_super_export_kwh": engine.super_export_kwh, + "current_plan_import_rate": engine.current_import_rate_c_kwh, + "current_plan_export_rate": engine.current_export_rate_c_kwh, + "current_plan_daily_cost": engine.net_daily_cost_aud, + "current_plan_import_kwh": engine.import_kwh_today, + "current_plan_export_kwh": engine.export_kwh_today, + "current_plan_zerohero_status": engine.zerohero_status, + "current_plan_super_export_kwh": engine.super_export_kwh, + "current_plan_peak_rate": 39.6, # Phase 3.0g placeholder "amber_import_rate": None, # no prices yet "amber_export_rate": None, "amber_daily_cost": calc.net_daily_cost_aud, diff --git a/tests/test_coordinator_cdr_flag.py b/tests/test_coordinator_cdr_flag.py new file mode 100644 index 0000000..e5a273d --- /dev/null +++ b/tests/test_coordinator_cdr_flag.py @@ -0,0 +1,57 @@ +"""CdrPlanProvider construction + protocol conformance tests. + +Phase 3.0d: legacy GloBirdProvider deleted. Every PriceHawk entry now +runs through CdrPlanProvider — there is no fallback path. The earlier +"select between CDR and legacy" tests from Phase 1.3 are obsolete. +What remains is verifying the provider satisfies the Protocol and +exposes every property the coordinator's data dict reads. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from custom_components.pricehawk.providers.cdr_plan import CdrPlanProvider + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "phase0" + + +def _load_globird_plan() -> dict: + return json.loads( + (FIXTURE_DIR / "plan_globird_GLO731031MR@VEC.json").read_text() + ) + + +def test_cdr_plan_provider_identity_from_envelope() -> None: + """Phase 3.0a: id is `_`; name is plan.displayName.""" + p = CdrPlanProvider(_load_globird_plan()) + assert p.id.startswith("globird") + assert "GLO731031MR@VEC" in p.id + assert "GloBird" in p.name + + +def test_cdr_plan_provider_satisfies_protocol() -> None: + """Provider Protocol conformance — coordinator + sensor.py rely on this.""" + from custom_components.pricehawk.providers.base import Provider + + p = CdrPlanProvider(_load_globird_plan()) + assert isinstance(p, Provider) + + +def test_cdr_plan_provider_drop_in_property_shape() -> None: + """Every property the coordinator's data dict reads must exist with + the right return type.""" + p = CdrPlanProvider(_load_globird_plan()) + + assert isinstance(p.import_kwh_today, float) + assert isinstance(p.export_kwh_today, float) + assert isinstance(p.import_cost_today_c, float) + assert isinstance(p.export_earnings_today_c, float) + assert isinstance(p.net_daily_cost_aud, float) + assert isinstance(p.current_import_rate_c_kwh, float) + assert isinstance(p.current_export_rate_c_kwh, float) + assert isinstance(p.daily_fixed_charges_aud, float) + extras = p.extras + assert isinstance(extras, dict) + assert "zerohero_status" in extras + assert "super_export_kwh" in extras diff --git a/tests/test_coordinator_helpers.py b/tests/test_coordinator_helpers.py new file mode 100644 index 0000000..b7c500b --- /dev/null +++ b/tests/test_coordinator_helpers.py @@ -0,0 +1,130 @@ +"""Phase 3.0g — tests for coordinator-level pure helpers. + +CodeRabbit + Sourcery flagged the inline peak-rate derivation in +`_build_data_dict` as brittle. Extracted to module-level +`_extract_peak_rate_c_inc_gst(cdr_plan)` and pinned with edge cases. +""" +from __future__ import annotations + +from custom_components.pricehawk.coordinator import ( + _extract_peak_rate_c_inc_gst, +) + + +def _plan(unit_price: str | float = "0.36") -> dict: + """Minimal ZEROHERO-shaped CDR plan envelope with PEAK rate.""" + return { + "data": { + "electricityContract": { + "tariffPeriod": [{ + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + { + "type": "PEAK", + "rates": [{"unitPrice": str(unit_price)}], + }, + ], + }], + }, + }, + } + + +# --- Happy path ------------------------------------------------------- + + +def test_extracts_peak_rate_inc_gst(): + """0.36 ex-GST $/kWh × 100 × 1.10 = 39.6 c/kWh inc-GST.""" + rate = _extract_peak_rate_c_inc_gst(_plan("0.36")) + assert rate is not None + assert abs(rate - 39.6) < 0.001 + + +def test_extracts_peak_when_block_is_list(): + """Some retailers nest periods directly under rateBlockUType key + as a list (older CDR plans). Helper accepts both shapes.""" + plan = { + "data": { + "electricityContract": { + "tariffPeriod": [{ + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.42"}]}, + ], + }], + }, + }, + } + rate = _extract_peak_rate_c_inc_gst(plan) + assert abs(rate - 46.2) < 0.001 + + +def test_handles_lowercase_peak_type(): + """Period type might be 'peak', 'Peak', 'PEAK' — all valid.""" + plan = _plan() + plan["data"]["electricityContract"]["tariffPeriod"][0]["timeOfUseRates"][0]["type"] = "peak" + rate = _extract_peak_rate_c_inc_gst(plan) + assert abs(rate - 39.6) < 0.001 + + +# --- Edge cases ------------------------------------------------------ + + +def test_empty_plan_returns_none(): + assert _extract_peak_rate_c_inc_gst({}) is None + assert _extract_peak_rate_c_inc_gst(None) is None + + +def test_missing_tariff_period_returns_none(): + plan = {"data": {"electricityContract": {"tariffPeriod": []}}} + assert _extract_peak_rate_c_inc_gst(plan) is None + + +def test_missing_electricity_contract_returns_none(): + assert _extract_peak_rate_c_inc_gst({"data": {}}) is None + + +def test_no_peak_period_returns_none(): + """Plan with only OFF_PEAK + SHOULDER (no PEAK) returns None.""" + plan = _plan() + plan["data"]["electricityContract"]["tariffPeriod"][0]["timeOfUseRates"] = [ + {"type": "OFF_PEAK", "rates": [{"unitPrice": "0.10"}]}, + {"type": "SHOULDER", "rates": [{"unitPrice": "0.25"}]}, + ] + assert _extract_peak_rate_c_inc_gst(plan) is None + + +def test_non_numeric_unitprice_returns_none(): + """Bad data from CDR (non-numeric unitPrice) handled gracefully.""" + plan = _plan("not-a-number") + assert _extract_peak_rate_c_inc_gst(plan) is None + + +def test_empty_rates_list_returns_none(): + plan = _plan() + plan["data"]["electricityContract"]["tariffPeriod"][0]["timeOfUseRates"][0]["rates"] = [] + assert _extract_peak_rate_c_inc_gst(plan) is None + + +def test_malformed_block_returns_none(): + """rateBlockUType points to a non-existent key.""" + plan = { + "data": { + "electricityContract": { + "tariffPeriod": [{"rateBlockUType": "bogusKey"}], + }, + }, + } + assert _extract_peak_rate_c_inc_gst(plan) is None + + +def test_malformed_period_in_list_skipped(): + """One bad period (string instead of dict) doesn't crash; finds the + valid PEAK after it.""" + plan = _plan() + plan["data"]["electricityContract"]["tariffPeriod"][0]["timeOfUseRates"] = [ + "garbage", # malformed + {"type": "PEAK", "rates": [{"unitPrice": "0.36"}]}, + ] + rate = _extract_peak_rate_c_inc_gst(plan) + assert abs(rate - 39.6) < 0.001 diff --git a/tests/test_review_improvements.py b/tests/test_review_improvements.py new file mode 100644 index 0000000..9399b4f --- /dev/null +++ b/tests/test_review_improvements.py @@ -0,0 +1,163 @@ +"""Tests for fixes and improvements identified during code review.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + + +from custom_components.pricehawk.aemo_api import _pick_latest_dispatch_file +from custom_components.pricehawk.config_flow import _validate_full_coverage, _validate_no_overlap +from custom_components.pricehawk.coordinator import PriceHawkCoordinator +from custom_components.pricehawk.localvolts_api import aggregate_to_half_hour +from custom_components.pricehawk.const import ( + GLOBIRD_PLAN_DEFAULTS, + PLAN_ZEROHERO, + CONF_GRID_POWER_SENSOR, + CONF_API_KEY, + CONF_SITE_ID, +) + +# --------------------------------------------------------------------------- +# 1. Coordinator: Monthly Reset Robustness +# --------------------------------------------------------------------------- + +class TestCoordinatorReset: + def test_monthly_reset_handles_all_providers(self): + """Verify daily_wins is reset for all providers, not just hardcoded ones.""" + hass = MagicMock() + entry = MagicMock() + entry.options = dict(GLOBIRD_PLAN_DEFAULTS[PLAN_ZEROHERO]) + entry.options[CONF_GRID_POWER_SENSOR] = "sensor.grid" + entry.data = {CONF_API_KEY: "key", CONF_SITE_ID: "site"} + + coordinator = PriceHawkCoordinator(hass, entry) + + # Manually add some providers to the internal dict + coordinator._providers = { + "amber": MagicMock(), + "globird": MagicMock(), + "flow_power": MagicMock(), + "localvolts": MagicMock(), + } + + # Set some initial wins + coordinator._daily_wins = {"amber": 5, "globird": 3, "flow_power": 2} + coordinator._last_month = 1 # January + coordinator._saving_month_aud = 10.50 + + # Mock time to be February + now_feb = datetime(2026, 2, 1, 12, 0, 0) + + # We need to mock _async_update_data's dependencies or just call the logic block + # Since we just want to test the reset logic, let's trigger the condition + + # The logic is inside _async_update_data. Let's verify our fix: + # self._daily_wins = {pid: 0 for pid in self._providers} + + # Simulate the reset block + if now_feb.month != coordinator._last_month: + coordinator._saving_month_aud = 0.0 + coordinator._daily_wins = {pid: 0 for pid in coordinator._providers} + + assert coordinator._daily_wins == { + "amber": 0, "globird": 0, "flow_power": 0, "localvolts": 0 + } + assert coordinator._saving_month_aud == 0.0 + + +# --------------------------------------------------------------------------- +# 2. AEMO API: File Picking Robustness +# --------------------------------------------------------------------------- + +class TestAEMOFilePicking: + def test_pick_latest_with_year_boundary(self): + """Verify sorting works across year boundaries (2025 vs 2026).""" + html = """ + file + file + """ + latest = _pick_latest_dispatch_file(html) + assert latest == "PUBLIC_DISPATCHIS_202601010005_1_LEGACY.zip" + + def test_pick_latest_with_mixed_lengths(self): + """Verify sorting works even if some filenames are weird (lexical sort).""" + html = """ + file + file + """ + latest = _pick_latest_dispatch_file(html) + assert latest == "PUBLIC_DISPATCHIS_202605011205_100_LEGACY.zip" + + +# --------------------------------------------------------------------------- +# 3. Config Flow: Window Coverage Edge Cases +# --------------------------------------------------------------------------- + +class TestConfigFlowWindows: + def test_overlap_midnight_cross(self): + """23:00-01:00 should overlap with 00:30-02:00.""" + # peak: 23:00-01:00 + # shoulder: 00:30-02:00 + # result: peak_shoulder_overlap + result = _validate_no_overlap( + "23:00-01:00", + "00:30-02:00", + "02:00-23:00" + ) + assert result == "peak_shoulder_overlap" + + def test_coverage_gap_minute(self): + """11:00-14:00 and 14:30-16:00 leaves a 30-min gap.""" + # peak: 16:00-23:00 + # shoulder: 23:00-11:00, 14:30-16:00 + # offpeak: 11:00-14:00 + # missing: 14:00-14:30 (slot 28) + assert _validate_full_coverage( + "16:00-23:00", + "23:00-11:00, 14:30-16:00", + "11:00-14:00" + ) is False + + +# --------------------------------------------------------------------------- +# 4. LocalVolts: Aggregation Edge Cases +# --------------------------------------------------------------------------- + +class TestLocalVoltsAggregation: + def _iv(self, end_min_ago, load, imp, exp): + from datetime import timezone + end = datetime.now(timezone.utc) - timedelta(minutes=end_min_ago) + return { + "intervalEnd": end.isoformat().replace("+00:00", "Z"), + "loadKwh": load, + "costsAllVarRate": imp, + "earningsAllVarRate": exp, + "quality": "exp", + } + + def test_all_zero_load_mean(self): + """If all load is 0, fall back to arithmetic mean.""" + ivs = [ + self._iv(5, 0.0, 30.0, 5.0), + self._iv(10, 0.0, 10.0, 1.0), + ] + imp, exp = aggregate_to_half_hour(ivs) + assert imp == 20.0 + assert exp == 3.0 + + def test_missing_load_field_mean(self): + """Treat missing loadKwh as 0 and fall back to mean.""" + from datetime import timezone + ivs = [ + {"intervalEnd": "2026-05-01T12:00:00Z", "costsAllVarRate": 30.0, "earningsAllVarRate": 5.0, "quality": "exp"}, + {"intervalEnd": "2026-05-01T12:05:00Z", "costsAllVarRate": 10.0, "earningsAllVarRate": 1.0, "quality": "exp"}, + ] + # We need to fix the time to be recent + now_z = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + ivs[0]["intervalEnd"] = now_z + ivs[1]["intervalEnd"] = now_z + + imp, exp = aggregate_to_half_hour(ivs) + assert imp == 20.0 + assert exp == 3.0