mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
refactor(modelling): consolidate plan/recommendation models into infrastructure
Move the live plan, recommendation, recommendation_materials and (retiring) plan_recommendations tables into a new infrastructure/postgres/modelling/ subpackage as single SQLModel definitions (the epc_property pattern), absorbing the rebuild's partial PlanRow/RecommendationRow mirrors and carrying full legacy column parity plus recommendation.plan_id. Out-of-cluster references are plain indexed ints (mirror convention); the live FKs are owned by the Drizzle schema. backend/app/db/models/recommendations.py becomes a re-export shim (ScenarioModel/InstalledMeasure stay for a later slice). Fix the export conftest to create SQLModel-first (so Base funding_package's FK to the now-SQLModel plan resolves) and skip the redundant drop_all on its function-scoped throwaway DB (the epc enum type is now shared across both metadatas). Resolves the pre-existing dual-definition collision: the rebuild and legacy export suites are now co-runnable. No behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b76d0f814b
commit
c1c7b06f09
9 changed files with 312 additions and 287 deletions
|
|
@ -1,13 +1,23 @@
|
|||
"""Re-export shim + remaining legacy models (ADR-0017 amendment).
|
||||
|
||||
`plan`, `recommendation`, `recommendation_materials` and the retiring
|
||||
`plan_recommendations` moved to `infrastructure/postgres/modelling/` as single
|
||||
SQLModel definitions (the `epc_property` pattern). This module re-exports them
|
||||
under their legacy names so the dying `backend/` callers keep working; new code
|
||||
imports from `infrastructure.postgres.modelling` directly. `ScenarioModel` and
|
||||
`InstalledMeasure` are not yet migrated and stay here for now.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from typing import Iterable, List, NamedTuple, Optional, Type
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
BigInteger,
|
||||
String,
|
||||
Float,
|
||||
Boolean,
|
||||
TIMESTAMP,
|
||||
ForeignKey,
|
||||
Column,
|
||||
Enum,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
|
@ -16,158 +26,28 @@ from datetime import datetime
|
|||
|
||||
from backend.app.db.base import Base
|
||||
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
||||
from backend.app.db.models.materials import Material
|
||||
from datatypes.enums import QuantityUnits
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
|
||||
from infrastructure.postgres.modelling import (
|
||||
PlanRow,
|
||||
PlanType,
|
||||
PlanRecommendationRow,
|
||||
RecommendationMaterialRow,
|
||||
RecommendationRow,
|
||||
)
|
||||
|
||||
# Legacy names → the single SQLModel definitions now in
|
||||
# `infrastructure/postgres/modelling/`.
|
||||
Recommendation = RecommendationRow
|
||||
RecommendationMaterials = RecommendationMaterialRow
|
||||
PlanModel = PlanRow
|
||||
PlanRecommendations = PlanRecommendationRow
|
||||
PlanTypeEnum = PlanType
|
||||
|
||||
|
||||
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
||||
return [e.value for e in enum_cls]
|
||||
|
||||
|
||||
class Recommendation(Base):
|
||||
__tablename__ = "recommendation"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
property_id = Column(BigInteger, ForeignKey(PropertyModel.id), nullable=False)
|
||||
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
type = Column(String, nullable=False)
|
||||
measure_type = Column(String)
|
||||
description = Column(String, nullable=False)
|
||||
estimated_cost = Column(Float)
|
||||
default = Column(Boolean, nullable=False)
|
||||
starting_u_value = Column(Float)
|
||||
new_u_value = Column(Float)
|
||||
sap_points = Column(Float)
|
||||
heat_demand = Column(Float)
|
||||
kwh_savings = Column(Float)
|
||||
co2_equivalent_savings = Column(Float)
|
||||
energy_savings = Column(Float)
|
||||
energy_cost_savings = Column(Float)
|
||||
property_valuation_increase = Column(Float)
|
||||
rental_yield_increase = Column(Float)
|
||||
total_work_hours = Column(Float)
|
||||
labour_days = Column(Float)
|
||||
already_installed = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class RecommendationMaterials(Base):
|
||||
__tablename__ = "recommendation_materials"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
recommendation_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("recommendation.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
material_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey(Material.id),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
depth: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
quantity: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
quantity_unit: Mapped[QuantityUnits] = mapped_column(
|
||||
Enum(QuantityUnits, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
estimated_cost: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class PlanTypeEnum(enum.Enum): # TODO: move this to domain?
|
||||
SOLAR_ECO4 = "solar_eco4"
|
||||
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
|
||||
EMPTY_CAVITY_ECO = "empty_cavity_eco"
|
||||
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
|
||||
EXTRACTION_ECO = "extraction_eco"
|
||||
|
||||
|
||||
class PlanModel(Base):
|
||||
__tablename__ = "plan"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
name: Mapped[Optional[str]] = mapped_column(String, nullable=True, default="")
|
||||
|
||||
portfolio_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey(Portfolio.id), nullable=False
|
||||
)
|
||||
|
||||
property_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey(PropertyModel.id), nullable=False
|
||||
)
|
||||
|
||||
scenario_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger, ForeignKey("scenario.id")
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column( # type: ignore
|
||||
TIMESTAMP, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
|
||||
valuation_increase_lower_bound: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase_upper_bound: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase_average: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
plan_type: Mapped[Optional[PlanTypeEnum]] = mapped_column(
|
||||
Enum(
|
||||
PlanTypeEnum,
|
||||
name="plan_type",
|
||||
values_callable=lambda e: [m.value for m in e],
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
post_sap_points: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_epc_rating: Mapped[Optional[Epc]] = mapped_column(Enum(Epc))
|
||||
post_co2_emissions: Mapped[Optional[float]] = mapped_column(Float)
|
||||
co2_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_energy_bill: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_bill_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_energy_consumption: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_consumption_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_post_retrofit: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
# Financial metrics, excluding funding
|
||||
cost_of_works: Mapped[Optional[float]] = mapped_column(Float)
|
||||
contingency_cost: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
|
||||
class PlanRecommendations(Base):
|
||||
__tablename__ = "plan_recommendations"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
plan_id = Column(BigInteger, ForeignKey("plan.id"), nullable=False)
|
||||
recommendation_id = Column(
|
||||
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ScenarioModel(Base):
|
||||
__tablename__ = "scenario"
|
||||
|
||||
|
|
@ -282,10 +162,10 @@ class InstalledMeasure(Base):
|
|||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
def enum_values(e: Iterable[PlanTypeEnum]) -> list[str]:
|
||||
def enum_values(e: Iterable[PlanType]) -> list[str]:
|
||||
return [m.value for m in e]
|
||||
|
||||
|
||||
class PlanPersistence(NamedTuple):
|
||||
plan: PlanModel
|
||||
plan: PlanRow
|
||||
scenario: ScenarioModel
|
||||
|
|
|
|||
|
|
@ -25,17 +25,23 @@ def engine(postgresql):
|
|||
|
||||
engine = create_engine(connection_string)
|
||||
|
||||
# Create tables once per test session
|
||||
Base.metadata.create_all(engine)
|
||||
# Create tables once per test session. SQLModel first: the Modelling tables
|
||||
# (`plan` / `recommendation` / …) are SQLModel definitions, and Base tables
|
||||
# FK them (`funding_package` → `plan`), so they must exist before Base's
|
||||
# create_all runs (ADR-0017 amendment — single model per table).
|
||||
SQLModel.metadata.create_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all
|
||||
# tests have completed
|
||||
yield engine
|
||||
|
||||
# Clean-up after entire test session
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
Base.metadata.drop_all(engine)
|
||||
# The `postgresql` fixture is function-scoped — a fresh, throwaway database
|
||||
# per test — so an explicit drop_all is redundant. We skip it: the `epc`
|
||||
# Postgres enum type is now shared across both metadatas (Base `portfolio`
|
||||
# tables and the SQLModel `plan`), and a two-phase metadata drop cannot drop
|
||||
# a cross-metadata type cleanly (ADR-0017 amendment). Disposing the engine
|
||||
# and letting the fixture discard the database is correct and conflict-free.
|
||||
engine.dispose()
|
||||
|
||||
|
||||
|
|
|
|||
24
infrastructure/postgres/modelling/__init__.py
Normal file
24
infrastructure/postgres/modelling/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""SQLModel definitions of the Modelling stage's live persistence tables
|
||||
(ADR-0017 amendment).
|
||||
|
||||
One canonical SQLModel per physical table — `plan`, `recommendation`,
|
||||
`recommendation_materials` — replacing the legacy SQLAlchemy `Base` models in
|
||||
`backend/app/db/models/recommendations.py` (now a re-export shim, the
|
||||
`epc_property` pattern). `recommendation` carries `plan_id`; the
|
||||
`plan_recommendations` m2m is retired.
|
||||
"""
|
||||
|
||||
from infrastructure.postgres.modelling.plan_table import PlanRow, PlanType
|
||||
from infrastructure.postgres.modelling.recommendation_table import (
|
||||
PlanRecommendationRow,
|
||||
RecommendationMaterialRow,
|
||||
RecommendationRow,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PlanRow",
|
||||
"PlanType",
|
||||
"RecommendationRow",
|
||||
"RecommendationMaterialRow",
|
||||
"PlanRecommendationRow",
|
||||
]
|
||||
106
infrastructure/postgres/modelling/plan_table.py
Normal file
106
infrastructure/postgres/modelling/plan_table.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from sqlalchemy import Column, TIMESTAMP
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.sql import func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from domain.modelling.plan import Plan
|
||||
|
||||
# Calculator metrics are in kg CO₂/yr; the live ``plan`` columns are tonnes
|
||||
# (legacy ``emissions_kg / 1000``). Convert on the way in.
|
||||
_KG_PER_TONNE = 1000.0
|
||||
|
||||
|
||||
class PlanType(enum.Enum):
|
||||
SOLAR_ECO4 = "solar_eco4"
|
||||
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
|
||||
EMPTY_CAVITY_ECO = "empty_cavity_eco"
|
||||
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
|
||||
EXTRACTION_ECO = "extraction_eco"
|
||||
|
||||
|
||||
class PlanRow(SQLModel, table=True):
|
||||
"""The single SQLModel definition of the live ``plan`` table (ADR-0017
|
||||
amendment). Full legacy column parity; out-of-cluster references
|
||||
(``portfolio_id`` / ``property_id`` / ``scenario_id``) are plain indexed
|
||||
ints, not FK constraints (mirror convention — the live FKs are owned by the
|
||||
Drizzle schema)."""
|
||||
|
||||
__tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: Optional[str] = Field(default="")
|
||||
portfolio_id: int
|
||||
property_id: int = Field(index=True)
|
||||
scenario_id: Optional[int] = Field(default=None)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||
)
|
||||
is_default: bool = False
|
||||
|
||||
valuation_increase_lower_bound: Optional[float] = Field(default=None)
|
||||
valuation_increase_upper_bound: Optional[float] = Field(default=None)
|
||||
valuation_increase_average: Optional[float] = Field(default=None)
|
||||
|
||||
plan_type: Optional[PlanType] = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
PlanType,
|
||||
name="plan_type",
|
||||
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
post_sap_points: Optional[float] = Field(default=None)
|
||||
post_epc_rating: Optional[Epc] = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(Epc, name="epc"), nullable=True),
|
||||
)
|
||||
post_co2_emissions: Optional[float] = Field(default=None) # tonnes/yr
|
||||
co2_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||
post_energy_bill: Optional[float] = Field(default=None) # £/yr
|
||||
energy_bill_savings: Optional[float] = Field(default=None) # £/yr
|
||||
post_energy_consumption: Optional[float] = Field(default=None) # kWh/yr
|
||||
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
|
||||
valuation_post_retrofit: Optional[float] = Field(default=None)
|
||||
valuation_increase: Optional[float] = Field(default=None)
|
||||
cost_of_works: Optional[float] = Field(default=None)
|
||||
contingency_cost: Optional[float] = Field(default=None)
|
||||
|
||||
@classmethod
|
||||
def from_domain(
|
||||
cls,
|
||||
plan: Plan,
|
||||
*,
|
||||
property_id: int,
|
||||
scenario_id: int,
|
||||
portfolio_id: int,
|
||||
is_default: bool,
|
||||
) -> "PlanRow":
|
||||
return cls(
|
||||
portfolio_id=portfolio_id,
|
||||
property_id=property_id,
|
||||
scenario_id=scenario_id,
|
||||
is_default=is_default,
|
||||
post_sap_points=plan.post_sap_continuous,
|
||||
post_epc_rating=plan.post_epc_rating,
|
||||
post_co2_emissions=plan.post_retrofit.co2_kg_per_yr / _KG_PER_TONNE,
|
||||
co2_savings=plan.co2_savings_kg_per_yr / _KG_PER_TONNE,
|
||||
cost_of_works=plan.cost_of_works,
|
||||
contingency_cost=plan.contingency_cost,
|
||||
post_energy_bill=plan.post_energy_bill,
|
||||
energy_bill_savings=plan.energy_bill_savings,
|
||||
post_energy_consumption=plan.post_energy_consumption,
|
||||
energy_consumption_savings=plan.energy_consumption_savings,
|
||||
)
|
||||
139
infrastructure/postgres/modelling/recommendation_table.py
Normal file
139
infrastructure/postgres/modelling/recommendation_table.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Column, ForeignKey, TIMESTAMP
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.sql import func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from datatypes.enums import QuantityUnits
|
||||
from domain.modelling.plan import PlanMeasure
|
||||
|
||||
# Calculator metrics are in kg CO₂/yr; the live ``recommendation`` column is
|
||||
# tonnes (legacy ``emissions_kg / 1000``). Convert on the way in.
|
||||
_KG_PER_TONNE = 1000.0
|
||||
|
||||
|
||||
class RecommendationRow(SQLModel, table=True):
|
||||
"""The single SQLModel definition of the live ``recommendation`` table
|
||||
(ADR-0017 amendment) — one row per persisted Plan Measure.
|
||||
|
||||
Carries full legacy column parity (the readers iterate the columns / sum
|
||||
them) **plus** ``plan_id``, the FK that links a measure to its Plan and
|
||||
replaces the retired ``plan_recommendations`` m2m. Out-of-cluster columns
|
||||
(``property_id``) are plain indexed ints, not FK constraints, matching the
|
||||
mirror convention so ``SQLModel.metadata.create_all`` needs no foreign
|
||||
table to exist (the live FKs are owned by the Drizzle schema).
|
||||
"""
|
||||
|
||||
__tablename__: ClassVar[str] = "recommendation" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
property_id: int = Field(index=True)
|
||||
plan_id: Optional[int] = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("plan.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
type: str
|
||||
measure_type: Optional[str] = Field(default=None)
|
||||
description: str
|
||||
estimated_cost: Optional[float] = Field(default=None)
|
||||
starting_u_value: Optional[float] = Field(default=None)
|
||||
new_u_value: Optional[float] = Field(default=None)
|
||||
sap_points: Optional[float] = Field(default=None)
|
||||
heat_demand: Optional[float] = Field(default=None)
|
||||
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
|
||||
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||
energy_savings: Optional[float] = Field(default=None)
|
||||
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
|
||||
property_valuation_increase: Optional[float] = Field(default=None)
|
||||
rental_yield_increase: Optional[float] = Field(default=None)
|
||||
total_work_hours: Optional[float] = Field(default=None)
|
||||
labour_days: Optional[float] = Field(default=None)
|
||||
default: bool = True
|
||||
already_installed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_domain(
|
||||
cls, measure: PlanMeasure, *, property_id: int, plan_id: int
|
||||
) -> "RecommendationRow":
|
||||
return cls(
|
||||
property_id=property_id,
|
||||
plan_id=plan_id,
|
||||
type=measure.measure_type,
|
||||
measure_type=measure.measure_type,
|
||||
description=measure.description,
|
||||
estimated_cost=measure.cost.total,
|
||||
sap_points=measure.impact.sap_points,
|
||||
co2_equivalent_savings=(
|
||||
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||
),
|
||||
kwh_savings=measure.kwh_savings,
|
||||
energy_cost_savings=measure.energy_cost_savings,
|
||||
default=True,
|
||||
already_installed=False,
|
||||
)
|
||||
|
||||
|
||||
class RecommendationMaterialRow(SQLModel, table=True):
|
||||
"""The live ``recommendation_materials`` table — one row per material used
|
||||
by a Recommendation. ``recommendation_id`` is an intra-cluster FK;
|
||||
``material_id`` is a plain int (out-of-cluster, mirror convention)."""
|
||||
|
||||
__tablename__: ClassVar[str] = "recommendation_materials" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
recommendation_id: int = Field(
|
||||
sa_column=Column(
|
||||
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
||||
)
|
||||
)
|
||||
material_id: int = Field(index=True)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||
)
|
||||
depth: Optional[float] = Field(default=None)
|
||||
quantity: Optional[float] = Field(default=None)
|
||||
quantity_unit: Optional[QuantityUnits] = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
QuantityUnits,
|
||||
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
estimated_cost: Optional[float] = Field(default=None)
|
||||
|
||||
|
||||
class PlanRecommendationRow(SQLModel, table=True):
|
||||
"""The legacy ``plan_recommendations`` m2m — **being retired** (ADR-0017
|
||||
amendment). Kept as an intra-cluster SQLModel row only for the transition
|
||||
window while readers/writers move onto ``recommendation.plan_id``; dropped
|
||||
once no caller remains. Both FKs are intra-cluster."""
|
||||
|
||||
__tablename__: ClassVar[str] = "plan_recommendations" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
plan_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("plan.id"), nullable=False)
|
||||
)
|
||||
recommendation_id: int = Field(
|
||||
sa_column=Column(
|
||||
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
||||
)
|
||||
)
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Column, ForeignKey
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from domain.modelling.plan import Plan, PlanMeasure
|
||||
|
||||
# Calculator metrics are in kg CO₂/yr; the live `plan` / `recommendation`
|
||||
# columns are tonnes (legacy `emissions_kg / 1000`). Convert on the way in.
|
||||
_KG_PER_TONNE = 1000.0
|
||||
|
||||
|
||||
class PlanRow(SQLModel, table=True):
|
||||
"""SQLModel mirror of the live ``plan`` table (ADR-0017).
|
||||
|
||||
Declares only the columns the rebuild writes — identity, the flat
|
||||
post-retrofit headline figures, and the cost aggregates. The legacy
|
||||
SQLAlchemy model owns the live reads and the columns left for later
|
||||
slices (valuation, plan_type, the energy/bill cluster). The physical
|
||||
table is the shared contract.
|
||||
"""
|
||||
|
||||
__tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
portfolio_id: int
|
||||
property_id: int = Field(index=True)
|
||||
scenario_id: Optional[int] = Field(default=None)
|
||||
is_default: bool = False
|
||||
|
||||
post_sap_points: Optional[float] = Field(default=None)
|
||||
post_epc_rating: Optional[Epc] = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(Epc, name="epc"), nullable=True),
|
||||
)
|
||||
post_co2_emissions: Optional[float] = Field(default=None) # tonnes/yr
|
||||
co2_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||
cost_of_works: Optional[float] = Field(default=None)
|
||||
contingency_cost: Optional[float] = Field(default=None)
|
||||
post_energy_bill: Optional[float] = Field(default=None) # £/yr
|
||||
energy_bill_savings: Optional[float] = Field(default=None) # £/yr
|
||||
post_energy_consumption: Optional[float] = Field(default=None) # delivered kWh/yr
|
||||
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
|
||||
|
||||
@classmethod
|
||||
def from_domain(
|
||||
cls,
|
||||
plan: Plan,
|
||||
*,
|
||||
property_id: int,
|
||||
scenario_id: int,
|
||||
portfolio_id: int,
|
||||
is_default: bool,
|
||||
) -> "PlanRow":
|
||||
return cls(
|
||||
portfolio_id=portfolio_id,
|
||||
property_id=property_id,
|
||||
scenario_id=scenario_id,
|
||||
is_default=is_default,
|
||||
post_sap_points=plan.post_sap_continuous,
|
||||
post_epc_rating=plan.post_epc_rating,
|
||||
post_co2_emissions=plan.post_retrofit.co2_kg_per_yr / _KG_PER_TONNE,
|
||||
co2_savings=plan.co2_savings_kg_per_yr / _KG_PER_TONNE,
|
||||
cost_of_works=plan.cost_of_works,
|
||||
contingency_cost=plan.contingency_cost,
|
||||
post_energy_bill=plan.post_energy_bill,
|
||||
energy_bill_savings=plan.energy_bill_savings,
|
||||
post_energy_consumption=plan.post_energy_consumption,
|
||||
energy_consumption_savings=plan.energy_consumption_savings,
|
||||
)
|
||||
|
||||
|
||||
class RecommendationRow(SQLModel, table=True):
|
||||
"""SQLModel mirror of the live ``recommendation`` table — one row per
|
||||
persisted Plan Measure (ADR-0017). Adds the new ``plan_id`` FK linking the
|
||||
measure to its Plan (ON DELETE CASCADE), replacing the ``plan_recommendations``
|
||||
m2m for new writes. Only the impact + cost columns the tracer fills are
|
||||
declared; the energy/bill, U-value, valuation and labour columns are left
|
||||
to later slices.
|
||||
"""
|
||||
|
||||
__tablename__: ClassVar[str] = "recommendation" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
property_id: int = Field(index=True)
|
||||
plan_id: Optional[int] = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("plan.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
type: str
|
||||
measure_type: Optional[str] = Field(default=None)
|
||||
description: str
|
||||
estimated_cost: Optional[float] = Field(default=None)
|
||||
sap_points: Optional[float] = Field(default=None)
|
||||
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
|
||||
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
|
||||
default: bool = True
|
||||
already_installed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_domain(
|
||||
cls, measure: PlanMeasure, *, property_id: int, plan_id: int
|
||||
) -> "RecommendationRow":
|
||||
return cls(
|
||||
property_id=property_id,
|
||||
plan_id=plan_id,
|
||||
type=measure.measure_type,
|
||||
measure_type=measure.measure_type,
|
||||
description=measure.description,
|
||||
estimated_cost=measure.cost.total,
|
||||
sap_points=measure.impact.sap_points,
|
||||
co2_equivalent_savings=(
|
||||
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||
),
|
||||
kwh_savings=measure.kwh_savings,
|
||||
energy_cost_savings=measure.energy_cost_savings,
|
||||
default=True,
|
||||
already_installed=False,
|
||||
)
|
||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from sqlmodel import Session, col, delete
|
||||
|
||||
from domain.modelling.plan import Plan
|
||||
from infrastructure.postgres.plan_table import PlanRow, RecommendationRow
|
||||
from infrastructure.postgres.modelling import PlanRow, RecommendationRow
|
||||
from repositories.plan.plan_repository import PlanRepository
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from infrastructure.postgres.property_baseline_performance_table import (
|
|||
PropertyBaselinePerformanceModel,
|
||||
)
|
||||
from infrastructure.postgres.epc_property_table import EpcPropertyModel
|
||||
from infrastructure.postgres.plan_table import PlanRow, RecommendationRow
|
||||
from infrastructure.postgres.modelling import PlanRow, RecommendationRow
|
||||
from infrastructure.postgres.product_table import MaterialRow
|
||||
from infrastructure.postgres.property_table import PropertyRow
|
||||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from domain.modelling.scoring.package_scorer import Score
|
|||
from domain.modelling.plan import Plan, PlanMeasure
|
||||
from domain.modelling.recommendation import Cost
|
||||
from domain.modelling.scoring.scoring import MeasureImpact
|
||||
from infrastructure.postgres.plan_table import PlanRow, RecommendationRow
|
||||
from infrastructure.postgres.modelling import PlanRow, RecommendationRow
|
||||
from repositories.plan.plan_postgres_repository import PlanPostgresRepository
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue