Skip to content

feat(sensor): Phase 3.1 commit 6 — RankedAlternativesSensor#70

Merged
Artic0din merged 2 commits into
devfrom
phase-3-1-alternatives-sensor
May 17, 2026
Merged

feat(sensor): Phase 3.1 commit 6 — RankedAlternativesSensor#70
Artic0din merged 2 commits into
devfrom
phase-3-1-alternatives-sensor

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

@Artic0din Artic0din commented May 16, 2026

Summary

Final commit of Phase 3.1. Stacks on #69. Adds user-visible sensor exposing the daily ranking job's output.

What was broken

Commits 4-5 (#68, #69) wired the ranking pipeline + service but nothing exposed results to the HA frontend. Data sat on coordinator state, unreachable from sensors / dashboards / automations.

What this fixes

  1. cdr/ranking.py gains summarize_for_sensor(plan) — compresses a 5-15 KB PlanDetailV2 body to a 7-field JSON-serialisable dict.
  2. Coordinator data dict now emits ranked_alternatives (list of summaries) + ranking_last_run_at (ISO timestamp).
  3. New RankedAlternativesSensor:
    • State = count of ranked alternatives (0..top_k)
    • Attributes: alternatives (full summary list sorted ascending by score), last_run (ISO timestamp)
    • Icon: mdi:format-list-numbered

Now visible as sensor.pricehawk_ranked_alternatives in HA — dashboards, automations, and templates can consume the data without polling services manually.

Test plan

  • ruff check — clean
  • pytest tests/test_cdr_ranking.py -q — 52/52 pass (4 new TestSummarizeForSensor)
  • pytest -q — 717/717 full suite pass (was 713 + 4 new)
  • JSON serialisability test confirms HA recorder attribute contract
  • Live smoke test: install, check entity registry shows sensor.pricehawk_ranked_alternatives, verify count + attrs populate after first ranking run — deferred to runtime

Changes

File Δ Purpose
custom_components/pricehawk/cdr/ranking.py +28 summarize_for_sensor helper
custom_components/pricehawk/coordinator.py +12 ranked_alternatives + ranking_last_run_at in data dict
custom_components/pricehawk/sensor.py +44 RankedAlternativesSensor class + registration
tests/test_cdr_ranking.py +56 4 new TestSummarizeForSensor tests

Why

Summary projection (not full plan body) keeps HA recorder attribute payloads under the warning threshold (~2 KB vs ~5-15 KB per raw plan × 20 alternatives × 14-day retention = recorder bloat).

State is the count (not the cheapest plan name) because:

  • Stable integer that doesn't change every time CDR republishes a plan.
  • HA recorder retains state history forever; rate-of-state-change matters for storage.
  • Dashboards can render attrs[0] as "cheapest" if they want; state stays at "5 alternatives found".

No tests for RankedAlternativesSensor itself — thin property class consuming coordinator data, same shape as the other sensors that can't be unit-tested without HA app context. Underlying summarize_for_sensor is covered.

Breaking Changes

None. Pure additive: new sensor class, new data-dict keys, no existing entity touched.

Stacked on

Depends on #68 + #69. Open against phase-3-1-rank-service so the stack reads cleanly. Will retarget to dev after both ancestors merge.

Files Changed

  • custom_components/pricehawk/cdr/ranking.py (+28)
  • custom_components/pricehawk/coordinator.py (+12)
  • custom_components/pricehawk/sensor.py (+44)
  • tests/test_cdr_ranking.py (+56)

🤖 Generated with Claude Code

Summary by Sourcery

Expose ranked electricity plan alternatives from the daily ranking job via a new Home Assistant sensor backed by summarized plan data from the coordinator.

New Features:

  • Introduce summarize_for_sensor helper to project full PlanDetailV2 bodies into compact per-plan summaries suitable for sensor attributes.
  • Add RankedAlternativesSensor entity that reports the count of ranked alternative plans and exposes their summaries and last run time as attributes.
  • Publish ranked_alternatives summaries and ranking_last_run_at timestamp from the coordinator data for consumption by sensors.

Tests:

  • Extend cdr ranking tests with coverage for summarize_for_sensor, including JSON-serialisability and missing-field edge cases.
  • What changed

    • Added summarize_for_sensor(plan, *, score=None) in custom_components/pricehawk/cdr/ranking.py to compress PlanDetailV2 bodies into 7-field, JSON-serialisable summaries for Home Assistant sensor attributes; converts Decimal → float and returns None for missing/unextractable fields.
    • Coordinator (custom_components/pricehawk/coordinator.py) now exposes ranked_alternatives (list of summaries sorted ascending by score) and ranking_last_run_at (ISO timestamp) in its data dict.
    • Added RankedAlternativesSensor in custom_components/pricehawk/sensor.py:
      • state: count of ranked alternatives (0..top_k)
      • attributes: alternatives (full summary list) and last_run (ISO timestamp)
      • icon: mdi:format-list-numbered
      • Registered in async_setup_entry and exposed as sensor.pricehawk_ranked_alternatives.
    • Tests: 4 new tests added in tests/test_cdr_ranking.py covering summarize_for_sensor behavior (valid summarization, unscored plans, missing fields, JSON serialisability). Full suite passes; Ruff clean.
  • Why

    • Reduce payload and Home Assistant recorder growth by exposing compact, stable summaries (≈2 KB per plan summary vs ~5–15 KB for full plan bodies) and using a stable integer state to limit recorder churn. Enables dashboards/automations to consume ranking output without manual polling.
  • Breaking changes

    • None; behavior is additive and backward-compatible.
  • Files changed

File Lines Added Lines Removed
custom_components/pricehawk/cdr/ranking.py 39 0
custom_components/pricehawk/coordinator.py 12 1
custom_components/pricehawk/sensor.py 44 0
tests/test_cdr_ranking.py 72 0
Total 167 1

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 16, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee57b4c6-da49-44e2-b8ce-588f7a560543

📥 Commits

Reviewing files that changed from the base of the PR and between d07a0bb and 5003b66.

📒 Files selected for processing (4)
  • custom_components/pricehawk/cdr/ranking.py
  • custom_components/pricehawk/coordinator.py
  • custom_components/pricehawk/sensor.py
  • tests/test_cdr_ranking.py

Walkthrough

Adds a helper to summarize CDR PlanDetailV2 objects for sensor attributes, converts Decimal values to floats with None fallbacks, wires these summaries into coordinator data as ranked_alternatives with a ranking_last_run_at timestamp, and exposes them via a new RankedAlternativesSensor entity. Tests cover extraction, None handling, JSON-serializability, and score behavior.

Changes

Ranked alternatives sensor

Layer / File(s) Summary
Plan summary helper and tests
custom_components/pricehawk/cdr/ranking.py, tests/test_cdr_ranking.py
summarize_for_sensor() converts PlanDetailV2 payloads to sensor-friendly dicts with plan_id, display_name, brand, customer_type, and computed cheap-rank components (peak_c_per_kwh, supply_c_per_day, score), converting Decimalfloat and emitting None when values are missing or unscored. Unit tests validate extraction, None outputs, JSON serialization, and score override vs recompute.
Coordinator data payload integration
custom_components/pricehawk/coordinator.py
Imports summarize_for_sensor and extends _build_data_dict to expose ranked_alternatives (summaries over _cheap_ranked_alternatives) and ranking_last_run_at (ISO timestamp or None).
Ranked alternatives sensor registration
custom_components/pricehawk/sensor.py
Adds RankedAlternativesSensor whose state is the number of ranked alternatives and which exposes alternatives and last_run as extra attributes; registers the sensor in async_setup_entry.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly describes the main change: adding a RankedAlternativesSensor to expose ranking job output via a new Home Assistant sensor entity.
Description check ✅ Passed Description covers all critical sections: summary, detailed explanation of the problem and solution, test results, file-by-file changes, rationale, and dependencies. All template sections are addressed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase-3-1-alternatives-sensor
  • 🛠️ scrub-secrets
  • 🛠️ no-hardcoded-rates
  • 🛠️ amber-api-limits
  • 🛠️ dashboard-protocol-safety

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 16, 2026

Reviewer's Guide

Adds a summarized ranking projection to the coordinator and exposes it via a new RankedAlternativesSensor, allowing Home Assistant to consume ranked alternative plan data as a lightweight sensor with JSON-serialisable attributes.

File-Level Changes

Change Details Files
Introduce summarize_for_sensor helper to project full PlanDetailV2 plans into compact, JSON-serialisable summaries for sensor use.
  • Add summarize_for_sensor(plan) that extracts key headline fields (IDs, display name, brand, customer type, peak/supply rates, cheap-rank score) from a plan.
  • Convert Decimal-like monetary values and scores to plain floats or None to satisfy HA recorder JSON attribute requirements.
  • Document intent to keep attribute payloads small by avoiding full CDR bodies in sensor attributes.
custom_components/pricehawk/cdr/ranking.py
Extend coordinator data model to publish ranked alternative summaries and last run timestamp for consumption by sensors.
  • Augment coordinator _build_data_dict() to include ranked_alternatives derived from _cheap_ranked_alternatives via summarize_for_sensor().
  • Expose ranking_last_run_at as an ISO 8601 string (or None) based on the coordinator’s internal _ranking_last_run_at value.
  • Document that these values back the RankedAlternativesSensor attributes and are size-limited for HA recorder friendliness.
custom_components/pricehawk/coordinator.py
Add RankedAlternativesSensor entity that exposes ranked alternatives count as state and the per-plan summaries plus last run time as attributes, and register it in setup.
  • Implement RankedAlternativesSensor subclass of PriceHawkBaseSensor with stable integer state based on len(ranked_alternatives).
  • Expose alternatives (copy of ranked_alternatives list) and last_run (ranking_last_run_at) as sensor attributes, and set name/icon for HA UI.
  • Register RankedAlternativesSensor in async_setup_entry alongside existing sensors so HA creates sensor.pricehawk_ranked_alternatives.
custom_components/pricehawk/sensor.py
Add focused tests verifying summarize_for_sensor behaviour and JSON-serialisability.
  • Add TestSummarizeForSensor suite covering field extraction, behaviour with unscored or incomplete plans, and None defaults.
  • Verify cheap-rank based scoring matches expected weighted calculation and that missing tariff data yields None values.
  • Add JSON round-trip test to ensure summarize_for_sensor output is JSON-encodable for HA recorder compatibility.
tests/test_cdr_ranking.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In summarize_for_sensor, you recompute cheap_rank_score(plan) even though the coordinator already maintains _cheap_ranked_alternatives; consider threading the existing score through to avoid extra work and potential drift between stored rankings and the sensor summaries if the scoring inputs diverge.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `summarize_for_sensor`, you recompute `cheap_rank_score(plan)` even though the coordinator already maintains `_cheap_ranked_alternatives`; consider threading the existing score through to avoid extra work and potential drift between stored rankings and the sensor summaries if the scoring inputs diverge.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Artic0din Artic0din force-pushed the phase-3-1-rank-service branch from 1aff3eb to 6f1f923 Compare May 16, 2026 22:50
@Artic0din Artic0din force-pushed the phase-3-1-alternatives-sensor branch 2 times, most recently from 470304b to 11d580a Compare May 16, 2026 23:12
@Artic0din Artic0din force-pushed the phase-3-1-rank-service branch from 6f1f923 to d022187 Compare May 16, 2026 23:12
@Artic0din Artic0din force-pushed the phase-3-1-alternatives-sensor branch from 11d580a to d07a0bb Compare May 16, 2026 23:12
@Artic0din
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Artic0din added a commit that referenced this pull request May 17, 2026
Sourcery suggested threading the cheap-rank score through to
``summarize_for_sensor`` rather than recomputing. Recompute risk is
near-zero (both call sites use the same pure function ``cheap_rank_score``),
but the API change is small and proves the contract supports
end-to-end score propagation when callers want it.

- ``summarize_for_sensor(plan, *, score=None)`` — keyword-only
  ``score`` parameter. Default ``None`` triggers a recompute via
  ``cheap_rank_score`` (preserves single-call usage). Callers that
  already have it (future: ``cheap_rank`` could return scored
  tuples) pass it in.

Two new tests:
- ``test_pre_computed_score_threaded_through`` — passes Decimal(99.99)
  and confirms summary returns 99.99 unchanged (no recompute).
- ``test_none_score_triggers_recompute`` — confirms default recomputes
  to 51.0 for peak=0.30 / supply=1.00.

Coordinator still passes plan only (no scored tuples yet); a future
commit can thread scores end-to-end by changing ``cheap_rank`` to
return ``list[tuple[Decimal, dict]]`` without further changes to
``summarize_for_sensor``'s signature.

Tests: 54/54 ranking + 724/724 full suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Artic0din Artic0din changed the base branch from phase-3-1-rank-service to dev May 17, 2026 02:03
Artic0din and others added 2 commits May 17, 2026 12:05
Final commit of Phase 3.1. Surfaces the daily ranking job's output
as a HA sensor: state is the count of ranked alternatives;
``extra_state_attributes`` carries the per-plan summaries for the
dashboard.

New: summarize_for_sensor(plan)
-------------------------------

``cdr/ranking.py`` gains a per-plan summariser that compresses a
CDR PlanDetailV2 body (5-15 KB raw) into a 7-field dict for
attribute exposure. HA recorder warns on large attribute payloads,
so dashboards consume the summary rather than the raw plan body.

Returns: ``plan_id``, ``display_name``, ``brand``, ``customer_type``,
``peak_c_per_kwh``, ``supply_c_per_day``, ``score`` (cheap-rank).
All values are plain str / float / None — JSON-serialisable as
HA attributes require.

Coordinator data dict
---------------------

``_build_data_dict()`` now emits two new keys:
- ``ranked_alternatives``: list of summarised plan dicts (sorted
  ascending by score so [0] is cheapest).
- ``ranking_last_run_at``: ISO timestamp of the last successful
  ranking job (None until the first run completes).

RankedAlternativesSensor
------------------------

- State: count of ranked alternatives (0..top_k). 0 == job hasn't
  succeeded yet, no eligible plans for postcode, or all competitor
  retailers down.
- ``extra_state_attributes``:
  - ``alternatives``: full summary list.
  - ``last_run``: ISO timestamp.
- ``icon``: ``mdi:format-list-numbered``.
- Registered in ``async_setup_entry`` after WinnerExplanationSensor.

Tests
-----

4 new tests in ``tests/test_cdr_ranking.py::TestSummarizeForSensor``:
headline-field extraction, unscored-plan None handling, missing
top-level fields, JSON-serialisability (recorder contract).

No tests for ``RankedAlternativesSensor`` itself — it's a thin
property class consuming coordinator data, same shape as the other
sensors that aren't unit-tested (HA app context required).

Full suite: 717/717 pass (was 713 + 4 new). Ruff clean.

Closes Phase 3.1. Users can now see cheaper alternatives without
leaving their dashboard, after the daily 00:30 job populates (or
they call ``pricehawk.rank_alternatives`` service manually).

Refs PHASE-3-ROADMAP.md §3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sourcery suggested threading the cheap-rank score through to
``summarize_for_sensor`` rather than recomputing. Recompute risk is
near-zero (both call sites use the same pure function ``cheap_rank_score``),
but the API change is small and proves the contract supports
end-to-end score propagation when callers want it.

- ``summarize_for_sensor(plan, *, score=None)`` — keyword-only
  ``score`` parameter. Default ``None`` triggers a recompute via
  ``cheap_rank_score`` (preserves single-call usage). Callers that
  already have it (future: ``cheap_rank`` could return scored
  tuples) pass it in.

Two new tests:
- ``test_pre_computed_score_threaded_through`` — passes Decimal(99.99)
  and confirms summary returns 99.99 unchanged (no recompute).
- ``test_none_score_triggers_recompute`` — confirms default recomputes
  to 51.0 for peak=0.30 / supply=1.00.

Coordinator still passes plan only (no scored tuples yet); a future
commit can thread scores end-to-end by changing ``cheap_rank`` to
return ``list[tuple[Decimal, dict]]`` without further changes to
``summarize_for_sensor``'s signature.

Tests: 54/54 ranking + 724/724 full suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Artic0din Artic0din force-pushed the phase-3-1-alternatives-sensor branch from 27a3218 to 5003b66 Compare May 17, 2026 02:06
@Artic0din Artic0din merged commit cea3747 into dev May 17, 2026
3 of 4 checks passed
@Artic0din Artic0din deleted the phase-3-1-alternatives-sensor branch May 17, 2026 02:06
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