mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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
|
import enum
|
||||||
from typing import Iterable, List, NamedTuple, Optional, Type
|
from typing import Iterable, List, NamedTuple, Optional, Type
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
|
||||||
BigInteger,
|
BigInteger,
|
||||||
String,
|
String,
|
||||||
Float,
|
Float,
|
||||||
Boolean,
|
Boolean,
|
||||||
TIMESTAMP,
|
TIMESTAMP,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Column,
|
||||||
Enum,
|
Enum,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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.base import Base
|
||||||
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
||||||
from backend.app.db.models.materials import Material
|
|
||||||
from datatypes.enums import QuantityUnits
|
from infrastructure.postgres.modelling import (
|
||||||
from datatypes.epc.domain.epc import Epc
|
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]:
|
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
||||||
return [e.value for e in enum_cls]
|
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):
|
class ScenarioModel(Base):
|
||||||
__tablename__ = "scenario"
|
__tablename__ = "scenario"
|
||||||
|
|
||||||
|
|
@ -282,10 +162,10 @@ class InstalledMeasure(Base):
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
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]
|
return [m.value for m in e]
|
||||||
|
|
||||||
|
|
||||||
class PlanPersistence(NamedTuple):
|
class PlanPersistence(NamedTuple):
|
||||||
plan: PlanModel
|
plan: PlanRow
|
||||||
scenario: ScenarioModel
|
scenario: ScenarioModel
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,23 @@ def engine(postgresql):
|
||||||
|
|
||||||
engine = create_engine(connection_string)
|
engine = create_engine(connection_string)
|
||||||
|
|
||||||
# Create tables once per test session
|
# Create tables once per test session. SQLModel first: the Modelling tables
|
||||||
Base.metadata.create_all(engine)
|
# (`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)
|
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
|
# Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all
|
||||||
# tests have completed
|
# tests have completed
|
||||||
yield engine
|
yield engine
|
||||||
|
|
||||||
# Clean-up after entire test session
|
# The `postgresql` fixture is function-scoped — a fresh, throwaway database
|
||||||
SQLModel.metadata.drop_all(engine)
|
# per test — so an explicit drop_all is redundant. We skip it: the `epc`
|
||||||
Base.metadata.drop_all(engine)
|
# 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()
|
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 sqlmodel import Session, col, delete
|
||||||
|
|
||||||
from domain.modelling.plan import Plan
|
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
|
from repositories.plan.plan_repository import PlanRepository
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from infrastructure.postgres.property_baseline_performance_table import (
|
||||||
PropertyBaselinePerformanceModel,
|
PropertyBaselinePerformanceModel,
|
||||||
)
|
)
|
||||||
from infrastructure.postgres.epc_property_table import EpcPropertyModel
|
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.product_table import MaterialRow
|
||||||
from infrastructure.postgres.property_table import PropertyRow
|
from infrastructure.postgres.property_table import PropertyRow
|
||||||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
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.plan import Plan, PlanMeasure
|
||||||
from domain.modelling.recommendation import Cost
|
from domain.modelling.recommendation import Cost
|
||||||
from domain.modelling.scoring.scoring import MeasureImpact
|
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
|
from repositories.plan.plan_postgres_repository import PlanPostgresRepository
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue