Model/orchestration/ara_first_run_pipeline.py
Khalim Conn-Kowlessar c7e2aa3755 feat(modelling): ModellingOrchestrator persists a Plan end-to-end (#1157)
Slice 4b — closes the #1157 tracer. ModellingOrchestrator.run(property_ids,
scenario_ids, portfolio_id) now does real work in one Unit of Work,
committed once (ADR-0011/0012/0016/0017):

  read Property (effective EPC) + Scenario via repos → recommend_cavity_wall
  → select its Option → PackageScorer.score (role-2 package total) +
  marginal_impacts (role-3 attribution) → build Plan/PlanMeasure →
  uow.plan.save → commit.

- AraFirstRunPipeline / ModellingStage thread portfolio_id from the trigger
  body (one source of truth); handler builds the real orchestrator
  (unit_of_work + Sap10Calculator), dropping the Scenario/Materials stubs.
- ScenarioRepository.get_many promoted to @abstractmethod now the bare-stub
  instantiations are gone.
- New ara_first_run-style integration test: a property with an uninsulated
  cavity wall yields a persisted Plan + one cavity_wall_insulation Plan
  Measure (priced from the Product, figures present, linked by plan_id).
  Numeric SAP correctness is pinned separately in test_elmhurst_cascade_pins.
- Existing pipeline integration test updated: seeds scenario 7 and runs the
  real Modelling stage (its already-insulated sample wall yields an empty
  package — no crash).

121 pass across repositories/modelling/orchestration/app; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:08:32 +00:00

74 lines
2.3 KiB
Python

from __future__ import annotations
from typing import Protocol
class AraFirstRunCommand(Protocol):
"""The slice of the trigger the pipeline threads downstream.
Only the business fields — UPRNs and Scenario definitions are read from
their source-of-truth tables, not carried here. ``task_id``/``sub_task_id``
are deliberately absent: the SubTask lifecycle is the decorator's concern,
not the pipeline's. ``AraFirstRunTriggerBody`` satisfies this structurally,
so ``orchestration`` need not import the application-layer event type.
"""
@property
def portfolio_id(self) -> int: ...
@property
def property_ids(self) -> list[int]: ...
@property
def scenario_ids(self) -> list[int]: ...
class IngestionStage(Protocol):
"""Stage 1 — acquires and persists each Property's external source data."""
def run(self, property_ids: list[int]) -> None: ...
class PropertyBaselineStage(Protocol):
"""Stage 2 — establishes each Property's Baseline Performance."""
def run(self, property_ids: list[int]) -> None: ...
class ModellingStage(Protocol):
"""Stage 3 — scores each Property against its Scenarios into Plans."""
def run(
self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int
) -> None: ...
class AraFirstRunPipeline:
"""Composes the First Run stages end-to-end: Ingestion -> Baseline ->
Modelling.
Threads **only** ``property_ids`` between stages (and ``scenario_ids`` into
Modelling, off the command — not a prior stage). The stages communicate
through repos, never via in-memory hand-off, which is what makes each stage
independently runnable for the single-property review flow (ADR-0011,
ADR-0003). Stage orchestrators are injected so the handler owns wiring and
tests substitute fakes.
"""
def __init__(
self,
*,
ingestion: IngestionStage,
baseline: PropertyBaselineStage,
modelling: ModellingStage,
) -> None:
self._ingestion = ingestion
self._baseline = baseline
self._modelling = modelling
def run(self, command: AraFirstRunCommand) -> None:
self._ingestion.run(command.property_ids)
self._baseline.run(command.property_ids)
self._modelling.run(
command.property_ids, command.scenario_ids, command.portfolio_id
)