mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
The first cut of elmhurst_input_sheet.py introspected the `schema` dataclasses (rdsap_schema_*.py) but the mapper emits the `epc_property_data` domain types, whose fields differ (wall_thickness_mm not wall_thickness; total_floor_area_m2 not total_floor_area; frame_material not pvc_frame; cylinder_insulation_thickness_mm; SapRoomInRoof has gable_*_length_m not insulation/roof_room_connected). Worse, the getattr-with-None-default helper printed None over real data, nearly sending a debug session chasing a non-existent "dimensions dropped" mapper bug on cert 2100 (the dims map fine; that cert's error is elsewhere). Switched to direct attribute access so a future rename fails loudly, fixed every field name against the live domain objects, and added roof_construction_type / floor_type for context. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Render a human-readable "Elmhurst SAP input sheet" for one or more certs.
|
||
|
||
WHAT THIS IS FOR
|
||
----------------
|
||
The debugging companion to `eval_api_sap_accuracy.py`: once that script names a
|
||
worst-offender cert, this dumps everything the mapper hands the calculator —
|
||
the *codes the calculator actually sees* (`from_api_response` →
|
||
`EpcPropertyData`) — in the same readable layout as the worked
|
||
`sap worksheets/golden fixture debugging/6035_elmhurst_input_sheet.md`, plus
|
||
the lodged reference outputs the worksheet must reproduce and our own
|
||
continuous SAP next to the lodged value. You read it side-by-side with the
|
||
real Elmhurst Summary / P960 worksheet PDF to localise where we diverge.
|
||
|
||
USAGE
|
||
-----
|
||
PYTHONPATH=/workspaces/model python scripts/elmhurst_input_sheet.py <cert> [<cert> ...]
|
||
|
||
# write each sheet to a file instead of stdout:
|
||
PYTHONPATH=/workspaces/model python scripts/elmhurst_input_sheet.py --out-dir "sap worksheets/golden fixture debugging" <cert>
|
||
|
||
Certs are read from the cache built by `fetch_2026_epc_sample.py` (default
|
||
`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). A bare cert
|
||
number resolves to `<cache>/<cert>.json`; an explicit path is also accepted.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
|
||
|
||
CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample"))
|
||
|
||
|
||
def _num(v: Any) -> Any:
|
||
"""Unwrap a Measurement (`.value`) or pass an int/float/str through."""
|
||
return getattr(v, "value", v)
|
||
|
||
|
||
def _resolve(cert_arg: str) -> Path:
|
||
p = Path(cert_arg)
|
||
if p.suffix == ".json" and p.exists():
|
||
return p
|
||
cached = CACHE / f"{cert_arg}.json"
|
||
if cached.exists():
|
||
return cached
|
||
raise FileNotFoundError(
|
||
f"No cached JSON for {cert_arg!r} (looked at {cached}). "
|
||
f"Run scripts/fetch_2026_epc_sample.py or set EPC_SAMPLE_CACHE."
|
||
)
|
||
|
||
|
||
def _our_sap(epc: Any) -> str:
|
||
"""Our continuous SAP, or the exception that blocks it."""
|
||
try:
|
||
result = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES))
|
||
cont: float = result.sap_score_continuous
|
||
return f"{cont:.4f}" if math.isfinite(cont) else f"non-finite ({cont})"
|
||
except Exception as e: # debugging tool — surface, don't swallow
|
||
return f"RAISED {type(e).__name__}: {e}"
|
||
|
||
|
||
def render(cert: str, doc: dict[str, Any]) -> str:
|
||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||
out: list[str] = []
|
||
w = out.append
|
||
|
||
# --- header --------------------------------------------------------
|
||
w(f"# Cert {cert} — Elmhurst SAP input sheet\n")
|
||
addr = ", ".join(
|
||
str(x) for x in (epc.address_line_1, epc.post_town, epc.postcode) if x
|
||
)
|
||
w(f"Address: {addr}")
|
||
w(
|
||
f"Dwelling: {epc.dwelling_type} built_form={epc.built_form} "
|
||
f"property_type={epc.property_type}"
|
||
)
|
||
w(
|
||
f"TFA: {epc.total_floor_area_m2} m² "
|
||
f"habitable_rooms={epc.habitable_rooms_count} "
|
||
f"heated_rooms={epc.heated_rooms_count}"
|
||
)
|
||
w(
|
||
f"Extensions: {epc.extensions_count} region_code={epc.region_code} "
|
||
f"measurement_type={epc.measurement_type}"
|
||
)
|
||
w(
|
||
f"Pressure test: {epc.pressure_test if epc.pressure_test is not None else '(not tested)'} "
|
||
f"door_count={epc.door_count}"
|
||
)
|
||
w(
|
||
f"Conservatory: type={epc.conservatory_type} "
|
||
f"heated_sep_consv={str(epc.has_heated_separate_conservatory).lower()}"
|
||
)
|
||
|
||
# --- our vs lodged (debug aid) -------------------------------------
|
||
lodged = doc.get("energy_rating_current")
|
||
our = _our_sap(epc)
|
||
delta = ""
|
||
try:
|
||
if lodged is not None and not our.startswith(("RAISED", "non-finite")):
|
||
delta = f" Δ={float(our) - float(lodged):+.4f} (we − lodged)"
|
||
except ValueError:
|
||
pass
|
||
w(f"\n## SAP: OURS={our} LODGED={lodged}{delta}")
|
||
|
||
# --- element descriptions (lodged) ---------------------------------
|
||
w("\n## Element descriptions (lodged)")
|
||
for label, elems in (
|
||
("WALL", epc.walls), ("ROOF", epc.roofs), ("FLOOR", epc.floors),
|
||
):
|
||
for el in elems or []:
|
||
w(f" {label}: {el.description}")
|
||
if epc.window:
|
||
w(f" WINDOW: {epc.window.description}")
|
||
for el in epc.main_heating or []:
|
||
w(f" MAIN HEATING: {el.description}")
|
||
if epc.hot_water:
|
||
w(f" HOT WATER: {epc.hot_water.description}")
|
||
if epc.lighting:
|
||
w(f" LIGHTING: {epc.lighting.description}")
|
||
if epc.secondary_heating:
|
||
w(f" SECONDARY: {epc.secondary_heating.description}")
|
||
|
||
# --- building parts / dimensions -----------------------------------
|
||
# NB direct attribute access (not getattr-with-default) so a future
|
||
# domain rename fails loudly here rather than silently printing None
|
||
# over real data. Field names are the `epc_property_data` domain
|
||
# types the mapper emits (NOT the `schema` dataclasses).
|
||
w("\n## Building parts / dimensions")
|
||
for bp in epc.sap_building_parts or []:
|
||
w(f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})")
|
||
w(
|
||
f" wall_construction={bp.wall_construction} "
|
||
f"insulation_type={bp.wall_insulation_type} "
|
||
f"ins_thick={bp.wall_insulation_thickness} "
|
||
f"wall_thickness={bp.wall_thickness_mm}mm "
|
||
f"measured={bp.wall_thickness_measured} "
|
||
f"dry_lined={bp.wall_dry_lined}"
|
||
)
|
||
w(f" party_wall_construction={bp.party_wall_construction}")
|
||
w(
|
||
f" roof_construction={bp.roof_construction} ({bp.roof_construction_type}) "
|
||
f"ins_location={bp.roof_insulation_location} "
|
||
f"ins_thick={bp.roof_insulation_thickness}"
|
||
)
|
||
w(
|
||
f" floor_heat_loss={bp.floor_heat_loss} ({bp.floor_type}) "
|
||
f"floor_ins_thick={bp.floor_insulation_thickness}"
|
||
)
|
||
rir = bp.sap_room_in_roof
|
||
if rir is not None:
|
||
w(
|
||
f" ROOM-IN-ROOF: floor_area={_num(rir.floor_area)} "
|
||
f"age={rir.construction_age_band} "
|
||
f"gable1={rir.gable_1_length_m}x{rir.gable_1_height_m}m "
|
||
f"gable2={rir.gable_2_length_m}x{rir.gable_2_height_m}m "
|
||
f"common_wall={rir.common_wall_length_m}m"
|
||
)
|
||
for fd in bp.sap_floor_dimensions or []:
|
||
w(
|
||
f" floor {fd.floor}: area={fd.total_floor_area_m2} "
|
||
f"height={fd.room_height_m} "
|
||
f"HLP={fd.heat_loss_perimeter_m} "
|
||
f"party_wall_len={fd.party_wall_length_m} "
|
||
f"floor_constr={fd.floor_construction} floor_ins={fd.floor_insulation} "
|
||
f"exposed={fd.is_exposed_floor}"
|
||
)
|
||
|
||
# --- windows -------------------------------------------------------
|
||
windows = epc.sap_windows or []
|
||
w(f"\n## Windows ({len(windows)})")
|
||
for i, win in enumerate(windows):
|
||
w(
|
||
f" W{i}: {win.window_width}x{win.window_height}m "
|
||
f"orient={win.orientation} "
|
||
f"glazing_type={win.glazing_type} "
|
||
f"gap={win.glazing_gap} "
|
||
f"frame={win.frame_material} "
|
||
f"draught={win.draught_proofed} "
|
||
f"loc(bp)={win.window_location} "
|
||
f"wall_type={win.window_wall_type} "
|
||
f"frame_factor={win.frame_factor}"
|
||
)
|
||
|
||
# --- doors / heating / water / vent --------------------------------
|
||
w("\n## Doors / heating / water / vent")
|
||
w(
|
||
f" door_count={epc.door_count} "
|
||
f"insulated_door_count={epc.insulated_door_count}"
|
||
)
|
||
sh = epc.sap_heating
|
||
for mh in sh.main_heating_details or []:
|
||
w(
|
||
f" MAIN: sap_code={mh.sap_main_heating_code} "
|
||
f"fuel={mh.main_fuel_type} "
|
||
f"category={mh.main_heating_category} "
|
||
f"emitter={mh.heat_emitter_type} "
|
||
f"emit_temp={mh.emitter_temperature} "
|
||
f"control={mh.main_heating_control} "
|
||
f"fghrs={mh.has_fghrs} "
|
||
f"fan_flue={mh.fan_flue_present} "
|
||
f"flue_type={mh.boiler_flue_type} "
|
||
f"pump_age={mh.central_heating_pump_age} "
|
||
f"data_source={mh.main_heating_data_source} "
|
||
f"idx={mh.main_heating_index_number} "
|
||
f"fraction={mh.main_heating_fraction}"
|
||
)
|
||
w(
|
||
f" WATER: code={sh.water_heating_code} "
|
||
f"fuel={sh.water_heating_fuel} "
|
||
f"cylinder_size={sh.cylinder_size} "
|
||
f"has_cyl={str(epc.has_hot_water_cylinder).lower()} "
|
||
f"cyl_ins_type={sh.cylinder_insulation_type} "
|
||
f"cyl_ins_thick={sh.cylinder_insulation_thickness_mm} "
|
||
f"immersion={sh.immersion_heating_type} "
|
||
f"solar_wh={str(epc.solar_water_heating).lower()} "
|
||
f"secondary_fuel={sh.secondary_fuel_type} "
|
||
f"secondary_type={sh.secondary_heating_type}"
|
||
)
|
||
es = epc.sap_energy_source
|
||
w(
|
||
f" ENERGY SOURCE: mains_gas={es.mains_gas} "
|
||
f"meter_type={es.meter_type} "
|
||
f"wind_turbines={es.wind_turbines_count} "
|
||
f"pv_raw={json.dumps(doc.get('sap_energy_source', {}).get('photovoltaic_supply'))}"
|
||
)
|
||
w(
|
||
f" VENT: fixed_AC={str(epc.has_fixed_air_conditioning).lower()} "
|
||
f"LIGHTING: led={epc.led_fixed_lighting_bulbs_count} "
|
||
f"cfl={epc.cfl_fixed_lighting_bulbs_count} "
|
||
f"incandescent={epc.incandescent_fixed_lighting_bulbs_count}"
|
||
)
|
||
|
||
# --- lodged reference outputs (the target) -------------------------
|
||
w("\n## Lodged reference outputs (the target a worksheet must reproduce)")
|
||
|
||
def _d(k: str) -> Any:
|
||
return doc.get(k)
|
||
|
||
w(
|
||
f" energy_rating_current={_d('energy_rating_current')} "
|
||
f"env_impact_current={_d('environmental_impact_current')}"
|
||
)
|
||
w(
|
||
f" energy_consumption_current={_d('energy_consumption_current')} "
|
||
f"co2_emissions_current={_d('co2_emissions_current')} "
|
||
f"(per_floor_area={_d('co2_emissions_current_per_floor_area')})"
|
||
)
|
||
w(
|
||
f" heating_cost_current={_d('heating_cost_current')} "
|
||
f"hot_water_cost_current={_d('hot_water_cost_current')} "
|
||
f"lighting_cost_current={_d('lighting_cost_current')}"
|
||
)
|
||
return "\n".join(out) + "\n"
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
args = [a for a in argv if not a.startswith("--")]
|
||
out_dir: Optional[Path] = None
|
||
if "--out-dir" in argv:
|
||
i = argv.index("--out-dir")
|
||
out_dir = Path(argv[i + 1])
|
||
args = [a for a in args if a != str(out_dir)]
|
||
if not args:
|
||
print(__doc__)
|
||
return 2
|
||
for cert_arg in args:
|
||
path = _resolve(cert_arg)
|
||
cert = path.stem
|
||
doc = json.loads(path.read_text())
|
||
sheet = render(cert, doc)
|
||
if out_dir is not None:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
dest = out_dir / f"{cert}_elmhurst_input_sheet.md"
|
||
dest.write_text(sheet)
|
||
print(f"wrote {dest}")
|
||
else:
|
||
print(sheet)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv[1:]))
|