feat(epc): read new EPC graph for new-approach properties via resolver

The new modelling pipeline no longer writes property_details_epc. Route all
EPC reads through src/lib/services/epcSources.ts, which branches on the cutoff
(property.updated_at >= 2026-06-01): legacy rows keep reading
property_details_epc; new-approach rows read epc_property (+ children),
property_baseline_performance, and plan/recommendation.

- per-property: resolveConditionReport (building passport),
  resolveDetailsEpcMeta (/api/property-meta)
- aggregates: shared carbon/bills/energy/estimated/expired/floor-area/
  lodgement/mainfuel SQL fragments + newApproachJoins, wired into the
  portfolio list + filters, reporting metrics, and both scenario metrics routes

estimated = predicted-only EPC. Gracefully omits gap fields for new
properties (mainfuel, energyTariff, floorHeight, installed-measures bill
adjustment, SAP-05 likely-downgrade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-17 00:40:37 +00:00
parent e606006e6b
commit 6026944fe5
7 changed files with 527 additions and 93 deletions

View file

@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources";
/* =======================
Types
@ -208,26 +209,19 @@ export async function GET(
/* ---------- Carbon ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
ELSE e.co2_emissions
ELSE ${carbonSql(sql`e`)}
END AS effective_carbon,
/* ---------- Bills ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
ELSE (
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)
ELSE ${billsSql(sql`e`)}
END AS effective_bills
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
${newApproachJoins}
LEFT JOIN LATERAL (
SELECT *

View file

@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources";
/* =======================
Types
@ -150,26 +151,19 @@ export async function GET(
/* ---------- Carbon ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
ELSE e.co2_emissions
ELSE ${carbonSql(sql`e`)}
END AS effective_carbon,
/* ---------- Bills ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
ELSE (
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)
ELSE ${billsSql(sql`e`)}
END AS effective_bills
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
${newApproachJoins}
LEFT JOIN LATERAL (
SELECT *

View file

@ -1,6 +1,7 @@
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { serializeBigInt } from "@/app/utils";
import { resolveDetailsEpcMeta } from "@/lib/services/epcSources";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
@ -31,20 +32,21 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope
currentValuation: true,
},
where: eq(property.id, BigInt(propertyId)),
with: {
detailsEpc: {
columns: {
currentEnergyDemand: true,
co2Emissions: true,
estimated: true,
},
},
},
});
if (!propertyMeta) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(JSON.stringify(propertyMeta, serializeBigInt));
// `detailsEpc` is resolved via the cutoff: legacy properties read
// property_details_epc; new-approach ones read epc_property +
// property_baseline_performance. See src/lib/services/epcSources.ts.
const detailsEpc = await resolveDetailsEpcMeta(
propertyMeta.id,
propertyMeta.updatedAt,
);
return new NextResponse(
JSON.stringify({ ...propertyMeta, detailsEpc }, serializeBigInt),
);
}

View file

@ -1,6 +1,15 @@
import { db } from "@/app/db/db";
import { sql, eq } from "drizzle-orm";
import { scenario } from "@/app/db/schema/recommendations";
import {
NEW_APPROACH_CUTOFF,
newApproachJoins,
carbonSql,
billsSql,
energyConsumptionSql,
estimatedSql,
isExpiredSql,
} from "@/lib/services/epcSources";
import type {
AverageMetrics,
@ -28,19 +37,12 @@ export async function getAverages(
const result = await db.execute<AverageMetrics>(sql`
SELECT
AVG(p.current_sap_points)::float AS avg_sap,
AVG(e.co2_emissions)::float AS avg_carbon,
AVG(
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS avg_bills,
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
AVG(${carbonSql(sql`e`)})::float AS avg_carbon,
AVG(${billsSql(sql`e`)})::float AS avg_bills,
AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId};
`);
@ -50,18 +52,11 @@ export async function getAverages(
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
const result = await db.execute<TotalMetrics>(sql`
SELECT
SUM(e.co2_emissions)::float AS total_carbon,
SUM(
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS total_bills
SUM(${carbonSql(sql`e`)})::float AS total_carbon,
SUM(${billsSql(sql`e`)})::float AS total_bills
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId};
`);
@ -104,14 +99,15 @@ export async function getCountByEpcBand(
SELECT
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
COUNT(*) FILTER (
WHERE e.estimated = false OR e.estimated IS NULL
WHERE ${estimatedSql(sql`e`)} = false
)::int AS actual,
COUNT(*) FILTER (
WHERE e.estimated = true
WHERE ${estimatedSql(sql`e`)} = true
)::int AS estimated
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
GROUP BY epc
) q
@ -136,10 +132,12 @@ export async function getEstimatedCounts(
): Promise<EstimatedCounts> {
const result = await db.execute<EstimatedCounts>(sql`
SELECT
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual
FROM property_details_epc e
WHERE e.portfolio_id = ${portfolioId};
SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId};
`);
return result.rows[0];
@ -163,14 +161,16 @@ export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
const result = await db.execute<{ expired: number }>(sql`
SELECT
SUM(
CASE
WHEN is_expired = true AND estimated = false
THEN 1
ELSE 0
CASE
WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false
THEN 1
ELSE 0
END
)::int AS expired
FROM property_details_epc
WHERE portfolio_id = ${portfolioId};
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId};
`);
return result.rows[0].expired;
@ -186,6 +186,9 @@ export async function getLikelyDowngrades(
JOIN property_details_epc e
ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
-- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new
-- pipeline (handled there via Rebaseline). Restrict to legacy properties.
AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date
AND e.sap_05_overwritten = true
AND p.current_sap_points IS NOT NULL
AND e.sap_05_score IS NOT NULL

View file

@ -13,14 +13,16 @@ import { surveyDB } from "@/app/db/surveyDB/connection";
import {
Feature,
GeneralFeature,
PropertyDetailsEpc,
PropertyDetailsSpatial,
PropertyMeta,
propertyDetailsEpc,
propertyDetailsSpatial,
NonIntrusiveSurveyData,
nonInstrusiveSurvey,
} from "@/app/db/schema/property";
import {
ConditionReport,
resolveConditionReport,
} from "@/lib/services/epcSources";
import { getRating } from "@/app/utils";
import { eq, desc, and } from "drizzle-orm";
import {
@ -250,16 +252,11 @@ export async function getPropertyMeta(
export async function getConditionReport(
propertyId: string
): Promise<PropertyDetailsEpc> {
const data = await db.query.propertyDetailsEpc.findFirst({
where: eq(propertyDetailsEpc.propertyId, BigInt(propertyId)),
});
if (!data) {
throw new Error("Network response was not ok");
}
return data;
): Promise<ConditionReport> {
// Branches on the new-approach cutoff: legacy properties read
// property_details_epc; new-approach ones read the epc_property graph +
// property_baseline_performance. See src/lib/services/epcSources.ts.
return resolveConditionReport(propertyId);
}
export async function getSpatialData(
@ -298,7 +295,7 @@ export async function getNonIntrusiveSurvey(
}
export function formatGeneralFeatures(
conditionReportData: PropertyDetailsEpc,
conditionReportData: ConditionReport,
propertyType: string
): GeneralFeature[] {
// if a property is a flat/maisonette, we show heat loss coridoor information, otherwise we won't show it
@ -353,7 +350,7 @@ export function formatGeneralFeatures(
}
export function formatRetrofitFeatures(
conditionReportData: PropertyDetailsEpc
conditionReportData: ConditionReport
): Feature[] {
// Helper function to convert rating number to string
@ -437,7 +434,7 @@ export function formatRetrofitFeatures(
}
export function formatHeatDemandFeatures(
conditionReportData: PropertyDetailsEpc
conditionReportData: ConditionReport
): GeneralFeature[] {
return [
{

View file

@ -19,6 +19,14 @@ import {
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
import {
newApproachJoins,
carbonSql,
totalFloorAreaSql,
lodgementDateSql,
isExpiredSql,
mainfuelSql,
} from "@/lib/services/epcSources";
import {
FilterGroups,
PropertyFilter,
@ -528,8 +536,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
}
case "epcExpiryDate": {
const expiryExpr = sql`(epc.lodgement_date + INTERVAL '10 years')`;
const guard = sql`epc.lodgement_date IS NOT NULL`;
// Branches on the cutoff: new-approach properties derive the lodgement
// date from the lodged epc_property row. See epcSources.ts.
const lodgementExpr = lodgementDateSql(sql`epc`);
const expiryExpr = sql`(${lodgementExpr} + INTERVAL '10 years')`;
const guard = sql`${lodgementExpr} IS NOT NULL`;
if (filter.operator === "date_before") {
return sql`${guard} AND ${expiryExpr} < ${filter.value}::date`;
@ -543,7 +554,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
if (filter.operator === "date_preset") {
switch (filter.value) {
case "expired":
return sql`epc.is_expired = true`;
return sql`${isExpiredSql(sql`epc`)} = true`;
case "expires_this_year":
return sql`${guard} AND EXTRACT(YEAR FROM ${expiryExpr}) = EXTRACT(YEAR FROM CURRENT_DATE)`;
case "expires_within_1_year":
@ -578,7 +589,9 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
builtForm: sql`p.built_form`,
tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`,
mainfuel: sql`epc.mainfuel`,
// GAP: new-approach properties have no main-fuel string → NULL, so they
// only match the "no value" filter branch. See epcSources.ts.
mainfuel: mainfuelSql(sql`epc`),
};
const col = colMap[filter.field];
@ -616,18 +629,20 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
case "floorArea": {
const n = parseFloat(filter.value);
if (isNaN(n)) return null;
if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`;
if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`;
if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`;
const area = totalFloorAreaSql(sql`epc`);
if (filter.operator === "num_gte") return sql`${area} >= ${n}`;
if (filter.operator === "num_lte") return sql`${area} <= ${n}`;
if (filter.operator === "num_equals") return sql`${area} = ${n}`;
return null;
}
case "co2Emissions": {
const n = parseFloat(filter.value);
if (isNaN(n)) return null;
if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`;
if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`;
if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`;
const carbon = carbonSql(sql`epc`);
if (filter.operator === "num_gte") return sql`${carbon} >= ${n}`;
if (filter.operator === "num_lte") return sql`${carbon} <= ${n}`;
if (filter.operator === "num_equals") return sql`${carbon} = ${n}`;
return null;
}
}
@ -666,6 +681,7 @@ export async function getPropertiesCount(
SELECT COUNT(DISTINCT p.id)::int AS count
FROM property p
LEFT JOIN property_details_epc epc ON epc.property_id = p.id
${newApproachJoins}
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
@ -706,11 +722,11 @@ export async function getProperties(
p.built_form AS "builtForm",
p.tenure AS tenure,
p.year_built AS "yearBuilt",
epc.lodgement_date::text AS "epcLodgementDate",
epc.is_expired AS "epcIsExpired",
epc.total_floor_area AS "totalFloorArea",
epc.co2_emissions AS "co2Emissions",
epc.mainfuel AS mainfuel,
${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate",
${isExpiredSql(sql`epc`)} AS "epcIsExpired",
${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea",
${carbonSql(sql`epc`)} AS "co2Emissions",
${mainfuelSql(sql`epc`)} AS mainfuel,
p.lexiscore AS lexiscore
FROM property p
LEFT JOIN property_targets t
@ -728,6 +744,7 @@ export async function getProperties(
AND r.already_installed = false
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
GROUP BY
@ -748,11 +765,19 @@ export async function getProperties(
p.built_form,
p.tenure,
p.year_built,
-- legacy + new-source columns referenced by the branched EPC expressions
p.updated_at,
epc.lodgement_date,
epc.is_expired,
epc.total_floor_area,
epc.co2_emissions,
epc.mainfuel,
epl.id,
epp.id,
epl.registration_date,
epl.inspection_date,
epl.total_floor_area_m2,
bp.effective_co2_emissions_t_per_yr,
p.lexiscore
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -0,0 +1,419 @@
/**
* EPC read-model resolver.
*
* The legacy plan engine wrote a flat per-property table `property_details_epc`.
* The new modelling pipeline (Hestia-Homes/Model) does NOT write that table
* it writes the normalized EPC graph (`epc_property` + children), the modelling
* output (`plan` / `recommendation`) and `property_baseline_performance`.
*
* A property is "new approach" when `property.updated_at >= 2026-06-01` (the
* cutoff the old pipeline predates it). For such properties the app must read
* the new sources and must NOT touch `property_details_epc`. Legacy properties
* keep reading the old table unchanged.
*
* This module is the single place that knows the cutoff and the fieldsource
* mapping, so callers (building-passport, property-meta, portfolio list,
* reporting + scenario metrics) branch consistently.
*
* Mapping applied (see the handover report for the full table and flagged gaps):
* - EPC fabric descriptions + 15 ratings `epc_energy_element`
* - whole-dwelling counts / flags `epc_property` (+ `epc_flat_details`)
* - baseline carbon / energy / bills `property_baseline_performance`
* - post-retrofit data `plan` / `recommendation`
* - "estimated" flag property has only a `source='predicted'`
* EPC (no `source='lodged'` row)
*
* Gracefully-omitted gaps for new properties (no clean equivalent reported to
* the owner): `mainfuel`, `energyTariff`, `floorHeight`, the installed-measures
* bill adjustment, and the SAP-05 "likely downgrade" metric.
*/
import { db } from "@/app/db/db";
import { and, eq, sql } from "drizzle-orm";
import {
epcProperty,
epcEnergyElement,
epcFlatDetails,
propertyBaselinePerformance,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
/** The discriminator: properties updated on/after this date use the new sources. */
export const NEW_APPROACH_CUTOFF = "2026-06-01";
export function isNewApproach(updatedAt: Date | string | null | undefined): boolean {
if (!updatedAt) return false;
const d = updatedAt instanceof Date ? updatedAt : new Date(updatedAt);
return d >= new Date(`${NEW_APPROACH_CUTOFF}T00:00:00.000Z`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared SQL fragments for set-based aggregate queries.
//
// These assume the standard aliases:
// p = property
// bp = property_baseline_performance (new — baseline carbon/energy/bills)
// epl = epc_property WHERE source='lodged'
// epp = epc_property WHERE source='predicted'
// and take the legacy `property_details_epc` alias as a fragment so each query
// can keep its own (`e` vs `epc`).
//
// Add `newApproachJoins` to any query that uses one of the *Sql expressions.
// ─────────────────────────────────────────────────────────────────────────────
type Alias = ReturnType<typeof sql>;
/** TRUE for new-approach rows. */
export const isNewApproachSql = sql`p.updated_at >= ${NEW_APPROACH_CUTOFF}::date`;
/** LEFT JOINs that expose the new sources alongside the legacy table. */
export const newApproachJoins = sql`
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted'
`;
/** Baseline carbon (t CO₂/yr). New: effective performance. Legacy: e.co2_emissions. */
export const carbonSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_co2_emissions_t_per_yr ELSE ${e}.co2_emissions END`;
/** Primary energy intensity (kWh/m²/yr). New: effective performance. Legacy: e.primary_energy_consumption. */
export const energyConsumptionSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`;
/**
* Annual energy bill (£). New: property_baseline_performance.total_annual_bill_gbp
* (NULL when the bill block hasn't been derived yet excluded from AVG/SUM).
* Legacy: the old component-sum formula incl. standing charges and the
* installed-measures adjustment.
*/
export const billsSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN bp.total_annual_bill_gbp ELSE (
${e}.heating_cost_current +
${e}.hot_water_cost_current +
${e}.lighting_cost_current +
${e}.appliances_cost_current +
${e}.gas_standing_charge +
${e}.electricity_standing_charge -
COALESCE(${e}.installed_measures_total_energy_bill_adjustment, 0)
) END`;
/** Total floor area (m²). New: lodged epc_property. Legacy: e.total_floor_area. */
export const totalFloorAreaSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN epl.total_floor_area_m2 ELSE ${e}.total_floor_area END`;
/** EPC lodgement date. New: lodged epc_property registration/inspection date. Legacy: e.lodgement_date. */
export const lodgementDateSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN COALESCE(epl.registration_date, epl.inspection_date) ELSE ${e}.lodgement_date END`;
/**
* "estimated" boolean. New: the property only has a predicted EPC (no lodged
* row). Legacy: e.estimated.
*/
export const estimatedSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epp.id IS NOT NULL) ELSE COALESCE(${e}.estimated, false) END`;
/**
* "expired" boolean. New: derived from the lodged EPC age (>10 years), since the
* new graph stores no is_expired flag. Legacy: e.is_expired.
*/
export const isExpiredSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN (epl.registration_date IS NOT NULL AND epl.registration_date < (CURRENT_DATE - INTERVAL '10 years')) ELSE ${e}.is_expired END`;
/** Main fuel. GAP for new properties (no clean equivalent) → NULL. Legacy: e.mainfuel. */
export const mainfuelSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`;
// ─────────────────────────────────────────────────────────────────────────────
// Per-property resolvers (TypeScript object building).
// ─────────────────────────────────────────────────────────────────────────────
/**
* The subset of the old `PropertyDetailsEpc` shape that the building-passport
* screens actually consume. Both the legacy row and the new graph map into this.
*/
export interface ConditionReport {
totalFloorArea: number | null;
// fabric descriptions + 15 ratings
walls: string | null;
wallsRating: number | null;
roof: string | null;
roofRating: number | null;
windows: string | null;
windowsRating: number | null;
heating: string | null;
heatingRating: number | null;
heatingControls: string | null;
heatingControlsRating: number | null;
hotWater: string | null;
hotWaterRating: number | null;
lighting: string | null;
lightingRating: number | null;
floor: string | null;
floorRating: number | null;
ventilation: string | null;
mainfuel: string | null;
solarPv: number | null;
solarHotWater: boolean | null;
windTurbine: number | null;
// general features
heatLossCorridor: boolean | null;
unheatedCorridorLength: number | null;
numberStoreys: number | null;
floorHeight: number | null;
numberHeatedRooms: number | null;
numberOpenFireplaces: number | null;
numberExtensions: number | null;
mainsGas: boolean | null;
energyTariff: string | null;
// heat demand + costs
currentEnergyDemand: number | null;
primaryEnergyConsumption: number | null;
co2Emissions: number | null;
heatingEnergyCostCurrent: number | null;
hotWaterEnergyCostCurrent: number | null;
lightingEnergyCostCurrent: number | null;
appliancesEnergyCostCurrent: number | null;
// estimated-data banner
estimated: boolean | null;
}
/** Map an `epc_energy_element.energy_efficiency_rating` to a safe 15 (or null). */
function safeRating(r: number | null | undefined): number | null {
return r != null && r >= 1 && r <= 5 ? r : null;
}
/**
* Resolve a property's condition report from the new EPC graph. Prefers the
* lodged EPC; falls back to predicted (in which case `estimated = true`).
*/
async function resolveNewConditionReport(
propertyId: bigint,
): Promise<ConditionReport | null> {
const lodged = await db.query.epcProperty.findFirst({
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "lodged"),
),
});
const chosen =
lodged ??
(await db.query.epcProperty.findFirst({
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "predicted"),
),
}));
if (!chosen) return null;
const [elements, flat, baseline] = await Promise.all([
db.query.epcEnergyElement.findMany({
where: eq(epcEnergyElement.epcPropertyId, chosen.id),
}),
db.query.epcFlatDetails.findFirst({
where: eq(epcFlatDetails.epcPropertyId, chosen.id),
}),
db.query.propertyBaselinePerformance.findFirst({
where: eq(propertyBaselinePerformance.propertyId, propertyId),
}),
]);
const byType = (t: string) => elements.find((el) => el.elementType === t);
const desc = (t: string) => byType(t)?.description ?? null;
const rating = (t: string) => safeRating(byType(t)?.energyEfficiencyRating);
const sumKwh = baseline
? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) +
(baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) +
(baseline.lightingKwh ?? 0) +
(baseline.appliancesKwh ?? 0) +
(baseline.cookingKwh ?? 0) +
(baseline.pumpsFansKwh ?? 0) +
(baseline.coolingKwh ?? 0)
: null;
return {
totalFloorArea: chosen.totalFloorAreaM2 ?? null,
walls: desc("wall"),
wallsRating: rating("wall"),
roof: desc("roof"),
roofRating: rating("roof"),
windows: desc("window"),
windowsRating: rating("window"),
heating: desc("main_heating"),
heatingRating: rating("main_heating"),
heatingControls: desc("main_heating_controls"),
heatingControlsRating: rating("main_heating_controls"),
hotWater: desc("hot_water"),
hotWaterRating: rating("hot_water"),
lighting: desc("lighting"),
lightingRating: rating("lighting"),
floor: desc("floor"),
floorRating: rating("floor"),
ventilation: chosen.ventilationType ?? null,
// GAP: no clean main-fuel string in the new graph (jsonb on
// epc_main_heating_detail) — omitted for new properties.
mainfuel: null,
solarPv: chosen.photovoltaicArray ? 1 : 0,
solarHotWater: chosen.solarWaterHeating ?? null,
windTurbine: chosen.energyWindTurbinesCount ?? null,
heatLossCorridor:
flat?.heatLossCorridor != null ? flat.heatLossCorridor > 0 : null,
unheatedCorridorLength: flat?.unheatedCorridorLengthM ?? null,
numberStoreys: chosen.numberOfStoreys ?? null,
// GAP: no single dwelling floor height (only per-building-part) — omitted.
floorHeight: null,
numberHeatedRooms: chosen.heatedRoomsCount ?? null,
numberOpenFireplaces: chosen.openChimneysCount ?? null,
numberExtensions: chosen.extensionsCount ?? null,
mainsGas: chosen.energyMainsGas ?? null,
// GAP: no energy tariff in the new graph — omitted.
energyTariff: null,
currentEnergyDemand: sumKwh,
primaryEnergyConsumption:
baseline?.effectivePrimaryEnergyIntensityKwhPerM2Yr ?? null,
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
heatingEnergyCostCurrent: baseline?.heatingCostGbp ?? null,
hotWaterEnergyCostCurrent: baseline?.hotWaterCostGbp ?? null,
lightingEnergyCostCurrent: baseline?.lightingCostGbp ?? null,
appliancesEnergyCostCurrent: baseline?.appliancesCostGbp ?? null,
estimated: !lodged,
};
}
/** Map a legacy `property_details_epc` row into the `ConditionReport` shape. */
function legacyConditionReport(row: Record<string, unknown>): ConditionReport {
const r = row as Partial<ConditionReport>;
return {
totalFloorArea: r.totalFloorArea ?? null,
walls: r.walls ?? null,
wallsRating: r.wallsRating ?? null,
roof: r.roof ?? null,
roofRating: r.roofRating ?? null,
windows: r.windows ?? null,
windowsRating: r.windowsRating ?? null,
heating: r.heating ?? null,
heatingRating: r.heatingRating ?? null,
heatingControls: r.heatingControls ?? null,
heatingControlsRating: r.heatingControlsRating ?? null,
hotWater: r.hotWater ?? null,
hotWaterRating: r.hotWaterRating ?? null,
lighting: r.lighting ?? null,
lightingRating: r.lightingRating ?? null,
floor: r.floor ?? null,
floorRating: r.floorRating ?? null,
ventilation: r.ventilation ?? null,
mainfuel: r.mainfuel ?? null,
solarPv: r.solarPv ?? null,
solarHotWater: r.solarHotWater ?? null,
windTurbine: r.windTurbine ?? null,
heatLossCorridor: r.heatLossCorridor ?? null,
unheatedCorridorLength: r.unheatedCorridorLength ?? null,
numberStoreys: r.numberStoreys ?? null,
floorHeight: r.floorHeight ?? null,
numberHeatedRooms: r.numberHeatedRooms ?? null,
numberOpenFireplaces: r.numberOpenFireplaces ?? null,
numberExtensions: r.numberExtensions ?? null,
mainsGas: r.mainsGas ?? null,
energyTariff: r.energyTariff ?? null,
currentEnergyDemand: r.currentEnergyDemand ?? null,
primaryEnergyConsumption: r.primaryEnergyConsumption ?? null,
co2Emissions: r.co2Emissions ?? null,
heatingEnergyCostCurrent: r.heatingEnergyCostCurrent ?? null,
hotWaterEnergyCostCurrent: r.hotWaterEnergyCostCurrent ?? null,
lightingEnergyCostCurrent: r.lightingEnergyCostCurrent ?? null,
appliancesEnergyCostCurrent: r.appliancesEnergyCostCurrent ?? null,
estimated: r.estimated ?? null,
};
}
/**
* Resolve a property's condition report, branching on the cutoff. New-approach
* properties never touch `property_details_epc`.
*/
export async function resolveConditionReport(
propertyId: string,
): Promise<ConditionReport> {
const id = BigInt(propertyId);
const prop = await db.query.property.findFirst({
columns: { updatedAt: true },
where: eq(property.id, id),
});
if (prop && isNewApproach(prop.updatedAt)) {
const resolved = await resolveNewConditionReport(id);
if (!resolved) {
throw new Error(
`No epc_property graph found for new-approach property ${propertyId}`,
);
}
return resolved;
}
const legacy = await db.query.propertyDetailsEpc.findFirst({
where: eq(propertyDetailsEpc.propertyId, id),
});
if (!legacy) {
throw new Error(`No property_details_epc row for property ${propertyId}`);
}
return legacyConditionReport(legacy as Record<string, unknown>);
}
/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */
export interface DetailsEpcMeta {
currentEnergyDemand: number | null;
co2Emissions: number | null;
estimated: boolean;
}
/** Resolve the `detailsEpc` meta block, branching on the cutoff. */
export async function resolveDetailsEpcMeta(
propertyId: bigint,
updatedAt: Date | string | null | undefined,
): Promise<DetailsEpcMeta> {
if (isNewApproach(updatedAt)) {
const [lodged, predicted, baseline] = await Promise.all([
db.query.epcProperty.findFirst({
columns: { id: true },
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "lodged"),
),
}),
db.query.epcProperty.findFirst({
columns: { id: true },
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "predicted"),
),
}),
db.query.propertyBaselinePerformance.findFirst({
where: eq(propertyBaselinePerformance.propertyId, propertyId),
}),
]);
const currentEnergyDemand = baseline
? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) +
(baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) +
(baseline.lightingKwh ?? 0) +
(baseline.appliancesKwh ?? 0)
: null;
return {
currentEnergyDemand,
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
estimated: !lodged && !!predicted,
};
}
const legacy = await db.query.propertyDetailsEpc.findFirst({
columns: { currentEnergyDemand: true, co2Emissions: true, estimated: true },
where: eq(propertyDetailsEpc.propertyId, propertyId),
});
return {
currentEnergyDemand: legacy?.currentEnergyDemand ?? null,
co2Emissions: legacy?.co2Emissions ?? null,
estimated: legacy?.estimated ?? false,
};
}