mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): domain Scenario + ScenarioPostgresRepository (#1157)
Slice 1 of the #1157 build. The FE creates a Scenario and passes only its id to the pipeline; the Modelling stage reads it back here. - domain/modelling/scenario.py: thin `Scenario(id, goal, goal_value, budget, is_default)` — the slice the stage uses today (goal/budget for the Optimiser later; is_default drives plan.is_default). No phases (ADR-0005); legacy file-path/aggregate columns not modelled. - infrastructure/postgres/scenario_table.py: `ScenarioRow` SQLModel mirror of the live `scenario` table (ADR-0017), declaring only the read columns; goal mapped as its string value. - ScenarioPostgresRepository.get_many(scenario_ids) -> list[Scenario]: bulk read, input-order-preserving, raises on a missing id. The method shape lives on the concrete repo for now; it is promoted to an @abstractmethod on the port when the real orchestrator is wired and the bare-stub instantiations retire (keeps the stubbed Modelling wiring composing meanwhile). 2 tests, pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
772cdd4f5a
commit
62a968119c
6 changed files with 192 additions and 6 deletions
27
domain/modelling/scenario.py
Normal file
27
domain/modelling/scenario.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Scenario — the named retrofit brief the Modelling stage scores against.
|
||||
|
||||
Built by a user in the scenario-builder UI and persisted before any modelling
|
||||
fires; the pipeline is handed only its id and reads it back via a
|
||||
`ScenarioRepository`. This is the thin slice the Modelling stage uses today:
|
||||
the goal + budget that the Optimiser will consume (#1160) and `is_default`
|
||||
(which drives `plan.is_default`). The legacy file-path / portfolio-aggregate
|
||||
columns are not modelled. Carries no phases — multi-phase is deferred
|
||||
(ADR-0005). See CONTEXT.md.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
"""A retrofit brief: its goal, optional budget, and whether it is the
|
||||
Property's default Scenario. `goal` / `goal_value` are the lodged target
|
||||
(e.g. "INCREASING_EPC" → band "C"); carried for the Optimiser, not yet
|
||||
enforced."""
|
||||
|
||||
id: int
|
||||
goal: str
|
||||
goal_value: str
|
||||
budget: Optional[float]
|
||||
is_default: bool
|
||||
41
infrastructure/postgres/scenario_table.py
Normal file
41
infrastructure/postgres/scenario_table.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from domain.modelling.scenario import Scenario
|
||||
|
||||
|
||||
class ScenarioRow(SQLModel, table=True):
|
||||
"""SQLModel mirror of the live ``scenario`` table (ADR-0017).
|
||||
|
||||
Declares only the columns the Modelling stage reads — the legacy
|
||||
file-path columns (`trigger_file_path`, `exclusions`, …) and the
|
||||
portfolio-level aggregates are left to the legacy SQLAlchemy model
|
||||
(`backend/app/db/models/recommendations.py::ScenarioModel`), which still
|
||||
owns the live reads. The physical table is the shared contract; this
|
||||
mirror is read-only from the rebuild's side.
|
||||
|
||||
`goal` is a Postgres enum in production; mapped here as its string value
|
||||
(the Modelling stage does not yet branch on it — #1160).
|
||||
"""
|
||||
|
||||
__tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
goal: str
|
||||
goal_value: str
|
||||
budget: Optional[float] = Field(default=None)
|
||||
is_default: bool = Field(default=False)
|
||||
|
||||
def to_domain(self) -> Scenario:
|
||||
if self.id is None:
|
||||
raise ValueError("scenario row has no id")
|
||||
return Scenario(
|
||||
id=self.id,
|
||||
goal=self.goal,
|
||||
goal_value=self.goal_value,
|
||||
budget=self.budget,
|
||||
is_default=self.is_default,
|
||||
)
|
||||
31
repositories/scenario/scenario_postgres_repository.py
Normal file
31
repositories/scenario/scenario_postgres_repository.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from domain.modelling.scenario import Scenario
|
||||
from infrastructure.postgres.scenario_table import ScenarioRow
|
||||
from repositories.scenario.scenario_repository import ScenarioRepository
|
||||
|
||||
|
||||
class ScenarioPostgresRepository(ScenarioRepository):
|
||||
"""Reads the live ``scenario`` table (via the ``ScenarioRow`` mirror) and
|
||||
maps each row to the thin domain ``Scenario`` the Modelling stage uses
|
||||
(ADR-0017). The legacy file-path / aggregate columns are not read."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def get_many(self, scenario_ids: list[int]) -> list[Scenario]:
|
||||
rows = self._session.exec(
|
||||
select(ScenarioRow).where(col(ScenarioRow.id).in_(scenario_ids))
|
||||
).all()
|
||||
by_id: dict[int, ScenarioRow] = {
|
||||
row.id: row for row in rows if row.id is not None
|
||||
}
|
||||
scenarios: list[Scenario] = []
|
||||
for scenario_id in scenario_ids:
|
||||
row = by_id.get(scenario_id)
|
||||
if row is None:
|
||||
raise ValueError(f"no scenario with id {scenario_id}")
|
||||
scenarios.append(row.to_domain())
|
||||
return scenarios
|
||||
|
|
@ -4,11 +4,16 @@ from abc import ABC
|
|||
|
||||
|
||||
class ScenarioRepository(ABC):
|
||||
"""Loads the Scenarios (and Scenario Snapshots) the Modelling stage scores
|
||||
a Property against.
|
||||
"""Loads the Scenarios the Modelling stage scores a Property against.
|
||||
|
||||
Seam only at this stage (#1136): the method shape is deferred to the
|
||||
Modelling per-service grill, where Scenario / Scenario Phase / Scenario
|
||||
Snapshot are designed (CONTEXT.md). Declared now so the pipeline can be
|
||||
composed end-to-end with Modelling stubbed.
|
||||
The FE creates a Scenario in the scenario-builder and passes only its id
|
||||
to the pipeline (#1130); the orchestrator reads it back through this port
|
||||
at modelling time.
|
||||
|
||||
The concrete method shape is ``get_many(scenario_ids) -> list[Scenario]``
|
||||
(bulk read by id, load-whole per ADR-0012), implemented by
|
||||
``ScenarioPostgresRepository``. It is promoted to an ``@abstractmethod``
|
||||
here when the real ``ModellingOrchestrator`` is wired and the bare-stub
|
||||
instantiations are retired (#1157 orchestrator slice) — until then the port
|
||||
stays instantiable so the stubbed Modelling wiring composes.
|
||||
"""
|
||||
|
|
|
|||
0
tests/repositories/scenario/__init__.py
Normal file
0
tests/repositories/scenario/__init__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Behaviour of the Postgres-backed ScenarioRepository: reading the Scenarios
|
||||
the Modelling stage scores a Property against, off the live ``scenario`` table.
|
||||
|
||||
The FE creates a Scenario in the scenario-builder and passes its id to the
|
||||
pipeline (#1130); the orchestrator reads it back here at modelling time. Only
|
||||
the fields modelling uses are mapped — goal / goal_value / budget / is_default;
|
||||
the legacy file-path columns are ignored. See CONTEXT.md (Scenario) and
|
||||
ADR-0017.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session
|
||||
|
||||
from domain.modelling.scenario import Scenario
|
||||
from infrastructure.postgres.scenario_table import ScenarioRow
|
||||
from repositories.scenario.scenario_postgres_repository import (
|
||||
ScenarioPostgresRepository,
|
||||
)
|
||||
|
||||
|
||||
def test_get_many_maps_live_scenario_rows_to_domain_in_input_order(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
# Arrange
|
||||
with Session(db_engine) as session:
|
||||
session.add(
|
||||
ScenarioRow(
|
||||
id=7,
|
||||
goal="INCREASING_EPC",
|
||||
goal_value="C",
|
||||
budget=15000.0,
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
ScenarioRow(
|
||||
id=9,
|
||||
goal="INCREASING_EPC",
|
||||
goal_value="B",
|
||||
budget=None,
|
||||
is_default=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
scenarios: list[Scenario] = ScenarioPostgresRepository(session).get_many(
|
||||
[9, 7]
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert [s.id for s in scenarios] == [9, 7] # input order preserved
|
||||
assert scenarios[0] == Scenario(
|
||||
id=9, goal="INCREASING_EPC", goal_value="B", budget=None, is_default=False
|
||||
)
|
||||
assert scenarios[1] == Scenario(
|
||||
id=7,
|
||||
goal="INCREASING_EPC",
|
||||
goal_value="C",
|
||||
budget=15000.0,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
|
||||
def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None:
|
||||
# Arrange
|
||||
with Session(db_engine) as session:
|
||||
session.add(
|
||||
ScenarioRow(
|
||||
id=7, goal="INCREASING_EPC", goal_value="C", is_default=True
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Act / Assert
|
||||
with Session(db_engine) as session:
|
||||
with pytest.raises(ValueError):
|
||||
ScenarioPostgresRepository(session).get_many([7, 404])
|
||||
Loading…
Add table
Reference in a new issue