Skip to content

feat(providers): add NEMWeb DISPATCH fallback wholesale-price source (Phase 7 PR-3)#87

Merged
Artic0din merged 5 commits into
devfrom
feat/nemweb-fallback
May 22, 2026
Merged

feat(providers): add NEMWeb DISPATCH fallback wholesale-price source (Phase 7 PR-3)#87
Artic0din merged 5 commits into
devfrom
feat/nemweb-fallback

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

Summary

  • New custom_components/pricehawk/providers/nemweb.py — anonymous public-AEMO wholesale-price source. Same WholesalePrice contract as PR-2. NO API key.
  • NEM-only. WEM rejected with a message pointing at OpenElectricity for WA coverage.
  • Wraps existing aemo_api.fetch_current_rrp (no rewrite — Flow Power still depends on it).
  • Stacked on PR feat(providers): add OpenElectricity wholesale-price client (Phase 7 PR-2) #86 (feat/openelectricity-client). When PR-2 merges to dev, GitHub auto-retargets this PR's base.

Phase 7 / PR-3. Foundation for Plan 07-02b's "you don't need an OpenElectricity key" config-flow branch.

Load-bearing decision: Brisbane > Sydney for NEM dispatch timestamps

AEMO publishes NEM dispatch timestamps in AEST year-round (no DST applied per their docs). Australia/Sydney applies AEDT (+11:00) during summer DST — would silently 1-hour-shift every dispatch row from October through April. Australia/Brisbane (QLD, no DST) is permanently +10:00 and matches AEMO's publishing convention.

test_settlement_date_parsing_summer_no_dst is the regression guard: asserts a January 02:30 NEM-time row resolves to 16:30 UTC (offset −10:00), NOT 15:30 UTC. Documented as D-P7-9 in DECISIONS.md.

Scope

File Change Lines
custom_components/pricehawk/providers/nemweb.py new 130
tests/test_nemweb_provider.py new — 12 contract tests 246
CHANGELOG.md [Unreleased] > Added entry +2
DECISIONS.md D-P7-9 (Brisbane anchor) +8

Out of scope (deferred)

  • Coordinator + config-flow wiring (Plan 07-02b — needs both PR-2 AND PR-3 merged or stacked)
  • Migrating aemo_api.py to honour AEMO's 2026-04-07 HTTP decommissioning (HTTPS-only + case-sensitive URLs) — future cleanup PR
  • API-key-gated comparator live-pricing (Plan 07-04)

Test plan

  • pytest --tb=short -q839 passed (was 827; +12 new)
  • ruff check custom_components/pricehawk/providers/nemweb.py tests/test_nemweb_provider.py → all checks passed
  • grep -c 'NEMWeb\|nemweb' providers/__init__.py → 0 (same S5 boundary as PR-2 — wholesale-price sources are not Provider implementations)
  • grep -c 'Australia/Brisbane' providers/nemweb.py → 3 (used at runtime)
  • grep -c 'Australia/Sydney' providers/nemweb.py → 1 (in explanatory comment only, NOT runtime)
  • Live network smoke deferred to 07-02b

🤖 Generated with Claude Code

Artic0din and others added 2 commits May 20, 2026 15:19
…PR-2)

Standalone async client for OpenElectricity v4 — not yet wired into the
coordinator or config flow (that's PR-2 part 2 / Plan 07-02b). Foundation for
Phase 8 Silver-compliance handlers and Phase 9 external-statistics integration.

* ``custom_components/pricehawk/providers/openelectricity.py`` — new module
  exposing ``OpenElectricityPriceSource`` async client + ``WholesalePrice``
  frozen dataclass. CC BY-NC 4.0 attribution baked into every result per
  research §1.3.

* ``manifest.json:requirements`` pinned to
  ``openelectricity>=0.10.1,<0.11`` — minor-bounded per the SDK README's
  "currently under active development" notice.

Audit invariants applied (07-02-AUDIT.md):

* M1 — API key never appears in ``__repr__`` output or log messages.
  ``_scrub()`` filters the full key plus its 8-char prefix from any text
  passed to ``_LOGGER.warning``. ``test_log_scrubs_api_key_from_sdk_error``
  uses ``caplog`` to verify against a leaky SDK exception.

* M2 — ``asyncio.timeout(_FETCH_TIMEOUT_SECONDS=30.0)`` bounds every SDK
  call. Hung connection → ``None`` + WARNING, never an HA-wide stall.

* M3 — Missing ``openelectricity`` install raises ``ConfigEntryNotReady``
  (HA retries setup), not ``ImportError`` (permanent crash). Lazy import
  inside the async method.

* S1 — Auth-error detection is belt-and-braces: message-string match plus
  exception-class-name probe. Defensive against the SDK's active-development
  error-message format drift.

* S2 — HTTP 429 rate-limit has a distinct branch: ``None`` + WARNING saying
  "rate-limited", last-good cache preserved, no spurious
  ``ConfigEntryAuthFailed``.

* S5 — Module is intentionally NOT exported from ``providers/__init__.py``.
  ``OpenElectricityPriceSource`` is a wholesale-price source, not a
  ``Provider`` Protocol implementation. Consumers import directly.

Implementation diverged from plan pseudo-code: real
``openelectricity==0.10.1`` ``TimeSeriesResponse`` is nested three levels
deep (``data`` → ``results`` → ``data``), not flat. ``_extract_latest_for_region``
walks the nested structure and returns tz-aware UTC. We skip
``response.to_records()`` because it emits naive-local timestamps.

12 contract tests (``tests/test_openelectricity_provider.py``) cover AC-1
through AC-7e: happy-path NEM, happy-path WEM, attribution-verbatim, 401
mapping, generic-error cache preservation, empty-data, invalid region,
empty API key, timeout, missing SDK, 429 rate limit, repr redaction, log
scrub. Test suite: 815 → 827 passed.

``tests/conftest.py`` gained one stub: ``ConfigEntryAuthFailed`` (was only
``ConfigEntryNotReady`` before). Necessary because the new module is the
first user of the auth-failed exception in this project.

Decisions D-P7-5 through D-P7-8 logged to DECISIONS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Phase 7 PR-3)

Anonymous public-AEMO wholesale-price source at
``custom_components/pricehawk/providers/nemweb.py``. Same ``WholesalePrice``
contract as PR-2's OpenElectricity client — same return type, same
``asyncio.timeout`` discipline, same last-good cache semantics — but with
no API key (no scrubber needed, no ``ConfigEntryAuthFailed`` path).

NEM-only. WEM requests raise ``ValueError`` with a message pointing the
user at OpenElectricity for WA coverage.

Wraps the existing ``aemo_api.fetch_current_rrp`` rather than rewriting
it — Flow Power still depends on ``aemo_api`` and the wrapper keeps PR
scope tight. Future cleanup PR can migrate ``aemo_api`` to honour AEMO's
2026-04-07 HTTP decommissioning notice (HTTPS-only + case-sensitive URLs).

The load-bearing decision: settlement-date parsing is anchored to
``Australia/Brisbane`` (no DST), NOT ``Australia/Sydney`` (DST applied).
AEMO publishes NEM dispatch in AEST year-round per their docs. A
Sydney-anchored parser would silently 1-hour-shift every dispatch row
from October through April. ``test_settlement_date_parsing_summer_no_dst``
is the regression guard — asserts a January 02:30 NEM-time row resolves
to 16:30 UTC (offset −10:00), NOT 15:30 UTC.

12 contract tests in tests/test_nemweb_provider.py cover: attribution
verbatim, shared contract surface (``nemweb.WholesalePrice is
openelectricity.WholesalePrice``), happy path, WEM rejection, cache
preservation across aemo_api None returns, invalid-region rejection,
winter + summer settlement-date parsing, empty-string and quoted-string
parsing edge cases, timeout, malformed settlement date.

Test suite: 827 → 839 passed (+12 new).

NOT wired into the coordinator or config flow in this PR — that's
Plan 07-02b. ``providers/__init__.py`` is intentionally left unchanged
(same boundary as PR-2 S5: wholesale-price sources are not ``Provider``
Protocol implementations and don't belong in the package init).

Stacked on ``feat/openelectricity-client`` (PR #86). When PR-2 merges
to dev, GitHub will auto-retarget this PR's base to dev.

Decision D-P7-9 logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 22, 2026
Narrow scope: only edits dual-loop-review.yml (the Claude AI review
workflow). PRs targeting dev now trigger Claude review, which is the
core need for the stacked v3.0 PR series.

The other 5 workflows (python-ci, pr-checks, lint, docs-check,
security-scan) also have the same `[main, develop]` gate but expose
pre-existing failures when extended to dev:
- python-ci.yml mypy: 27 baseline type errors in tests
- pr-checks.yml: CodeRabbit gate still checks for CR approval despite
  CLAUDE.md saying CR is not used
- security-scan.yml gitleaks: flags string-literal test secrets in
  tests/test_reauth.py + others; needs allowlist for test fixtures
- dual-loop-review run for THIS PR hit an upstream tsconfig
  directory-mismatch bug in the anthropics/claude-code-action@v1
  action; transient

Each is a separate cleanup PR. Landing this narrow change unblocks
the Claude AI review path for the 17 stacked PRs without sweeping
unrelated debt under the rug.

After merge: the 17 stacked PRs (#87..#102) trigger dual-loop-review
on next synchronize (push, rebase, or empty commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 22, 2026
Narrow scope: only edits dual-loop-review.yml (the Claude AI review
workflow). PRs targeting dev now trigger Claude review, which is the
core need for the stacked v3.0 PR series.

The other 5 workflows (python-ci, pr-checks, lint, docs-check,
security-scan) also have the same `[main, develop]` gate but expose
pre-existing failures when extended to dev:
- python-ci.yml mypy: 27 baseline type errors in tests
- pr-checks.yml: CodeRabbit gate still checks for CR approval despite
  CLAUDE.md saying CR is not used
- security-scan.yml gitleaks: flags string-literal test secrets in
  tests/test_reauth.py + others; needs allowlist for test fixtures
- dual-loop-review run for THIS PR hit an upstream tsconfig
  directory-mismatch bug in the anthropics/claude-code-action@v1
  action; transient

Each is a separate cleanup PR. Landing this narrow change unblocks
the Claude AI review path for the 17 stacked PRs without sweeping
unrelated debt under the rug.

After merge: the 17 stacked PRs (#87..#102) trigger dual-loop-review
on next synchronize (push, rebase, or empty commit).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din and others added 2 commits May 22, 2026 15:40
# Conflicts:
#	CHANGELOG.md
#	DECISIONS.md
#	custom_components/pricehawk/manifest.json
#	tests/conftest.py
@Artic0din Artic0din merged commit 828b3a9 into dev May 22, 2026
5 of 6 checks passed
@Artic0din Artic0din deleted the feat/nemweb-fallback branch May 22, 2026 05:44
Artic0din added a commit that referenced this pull request May 23, 2026
… Jinja (#145)

Two real bugs surfaced by a Copilot-CLI retro-review of the 22 merged PRs
that the prior @claude batch couldn't reach (OIDC workflow-validation
against stale main).

statistics.py — external_statistic_id now sanitizes via regex:
  CDR-derived provider_ids carry the plan id verbatim
  (e.g. ``agl_AGL-CDR-N0001`` for AGL via CDR), so `.lower()` alone
  left hyphens that the recorder's ``[a-z0-9_]+`` regex silently
  rejected. Every CDR user's dual-write would fail and the Energy
  Dashboard would never receive their cost data — same silent-failure
  class #107 fixed for uppercase ULIDs. Added
  ``_STATISTIC_ID_OBJECT_SAFE`` compiled regex that coerces ANY
  character outside ``[a-z0-9_]`` to underscore. New regression test
  ``test_cdr_plan_id_with_hyphens_is_sanitized`` + adjusted
  ``test_entry_id_sliced_to_8_chars`` for the post-sanitization shape.
  Surfaced by retro-review of PRs #93, #95.

blueprints — variables block replaces !input inside Jinja:
  ``daily_7pm_summary.yaml`` and ``wholesale_spike_alert.yaml`` had
  templates like ``{{ states(!input today_cost_sensor) }}``. ``!input``
  is a YAML tag (resolved at YAML parse time) and is invalid inside
  Jinja ``{{ }}`` — Jinja parses ``!`` as an invalid operator and the
  template never renders. Replaced with the standard HA pattern: a
  ``variables:`` block at action level that binds blueprint inputs as
  Jinja identifiers (``today_cost_entity: !input today_cost_sensor``
  etc.), then ``{{ states(today_cost_entity) }}`` in the message.
  Surfaced by retro-review of PRs #99, #100.

22 Copilot reviews ran (PRs #85, #87-#101, #104, #105, #108-#111).
Most findings were false positives — Copilot flagged ``ServiceValidationError``
and ``async_items`` as broken HA APIs (they're both fine), and several
findings duplicated bugs already fixed by codex P0/P1 work
(token-in-URL #109, DWT reset #109). Findings library + triage notes
archived in ``.planning/copilot-retro/``.

Full test suite: 1120 passing.
Artic0din added a commit that referenced this pull request May 24, 2026
#147)

Critical hotpatch. The earlier _LEGACY fix (#107) silenced one cause of
NEMWeb listing parse failures but not the actual one. Live UAT against
the production NEMWeb directory on 2026-05-24 showed the real shape:

  <A HREF="/Reports/CURRENT/DispatchIS_Reports/PUBLIC_DISPATCHIS_202605221100_..._.zip">

The prior regex was case-insensitive (via re.IGNORECASE) but required
PUBLIC_DISPATCHIS_ to sit immediately after the opening quote — so it
matched zero files even though both the case and the _LEGACY-optional
fixes were correct. AEMO-Direct DWT and Flow Power's AEMO poll were
both silently broken in production since the PR #87 / #107 work.

Fix: insert a non-greedy ``[^"]*?`` between ``href="`` and the filename
capture group, keeping the captured filename clean so the existing
lexical-sort logic in _pick_latest_dispatch_file works unchanged.

Verified against the live directory: regex now finds 576 matches in the
2026-05-24 snapshot (was 0).

Two regression tests pin the new path-prefix tolerance — one using the
exact shape pulled from the live directory, one defensive with mixed
case + absolute URLs.

Manifest bumped to 1.6.0-beta.3 for HACS-beta re-deploy.
Silver-checklist test version assertion updated to match.

Full test suite: 1130 passing.
Artic0din added a commit that referenced this pull request May 24, 2026
…ion) (#151)

CRITICAL cost-math bug found via live UAT 2026-05-24. User reported
today_cost showing ~$66 for ~12 kWh of consumption. Investigation:

Real VIC1 dispatch at 15:40 today (PUBLIC_DISPATCHIS_202605241540...):
  D,DISPATCH,PRICE,5,"2026/05/24 15:40:00",1,VIC1,...,96.16181,...
  D,DISPATCH,REGIONSUM,9,"2026/05/24 15:40:00",1,VIC1,...,5738.11,...

Parser at aemo_api.py:197 was matching ``D,DISPATCH,REGIONSUM`` rows
and reading row[9] — which in REGIONSUM is **TOTALDEMAND** (MW), NOT
RRP. The actual RRP lives in ``D,DISPATCH,PRICE`` rows where index 9
IS the RRP in $/MWh per the AEMO ``I,DISPATCH,PRICE,5,...`` schema:

  [0] D [1] DISPATCH [2] PRICE [3] 5
  [4] SETTLEMENTDATE [5] RUNNO [6] REGIONID
  [7] DISPATCHINTERVAL [8] INTERVENTION [9] RRP
  [10] EEP [11] ROP [12] APCFLAG ...

Bug math: 5738.11 MW / 10 → 573.811 reported as "c/kWh" (~60x
inflation vs the real RRP of 9.62 c/kWh). DWT provider's update()
accumulated cost at the inflated rate → user's 12 kWh became $66
instead of ~$1.15.

The synthetic test fixture (build_test_dispatch_zip) also wrote
``D,DISPATCH,REGIONSUM`` rows, so the parser's row-type filter
matched and the tests passed. The bug was invisible to unit tests
because both sides of the contract were wrong in the same way.

Fix:
  - Parser: filter ``row[2] != "PRICE"`` (was REGIONSUM)
  - Fixture: emit ``D,DISPATCH,PRICE,5,...`` with the full PRICE schema
  - 2 new regression tests:
    - test_does_not_pick_up_regionsum_totaldemand_as_rrp — synthetic
      CSV with BOTH record types, asserts parser picks RRP not demand
    - test_real_nemweb_dispatch_csv_shape — mirrors actual NEMWeb file
      structure (REGIONSUM rows precede PRICE rows; VIC1 RRP 96.16181
      → 9.616 c/kWh)

Manifest bumped to 1.6.0-beta.7. Silver-checklist version assertion
updated to match.

Full test suite: 1140 passing.

This bug has been latent since Phase 7 PR-3 (#87) shipped NEMWeb.
Once beta.3's path-prefix regex fix landed and the parser started
seeing real dispatch data, the wrong-row-type bug surfaced as the
~60x inflated wholesale rate that cratered DWT cost accounting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant