From f26e4c32f07bcb367c43d5aa89328b21aee23944 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 04:29:31 +0000 Subject: [PATCH 01/18] Add semi-flat wall construction schema and translators Co-authored-by: Sam Wolk --- benchmarking/benchmark.py | 7 +- .../sbem/flat_constructions/__init__.py | 17 + .../sbem/flat_constructions/assemblies.py | 226 +++++++++ .../sbem/flat_constructions/materials.py | 155 ++++++ epinterface/sbem/flat_constructions/walls.py | 445 ++++++++++++++++++ epinterface/sbem/flat_model.py | 381 ++------------- tests/test_flat_constructions/test_walls.py | 82 ++++ 7 files changed, 966 insertions(+), 347 deletions(-) create mode 100644 epinterface/sbem/flat_constructions/__init__.py create mode 100644 epinterface/sbem/flat_constructions/assemblies.py create mode 100644 epinterface/sbem/flat_constructions/materials.py create mode 100644 epinterface/sbem/flat_constructions/walls.py create mode 100644 tests/test_flat_constructions/test_walls.py diff --git a/benchmarking/benchmark.py b/benchmarking/benchmark.py index 53ac38c..b44a2ff 100644 --- a/benchmarking/benchmark.py +++ b/benchmarking/benchmark.py @@ -27,7 +27,12 @@ def benchmark() -> None: Rotation=45, WWR=0.3, NFloors=2, - FacadeRValue=3.0, + FacadeStructuralSystem="cmu", + FacadeCavityInsulationRValue=1.2, + FacadeExteriorInsulationRValue=1.0, + FacadeInteriorInsulationRValue=0.0, + FacadeInteriorFinish="drywall", + FacadeExteriorFinish="brick_veneer", RoofRValue=3.0, SlabRValue=3.0, WindowUValue=3.0, diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py new file mode 100644 index 0000000..a4c50ba --- /dev/null +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -0,0 +1,17 @@ +"""Semi-flat construction translators for SBEM flat models.""" + +from epinterface.sbem.flat_constructions.assemblies import build_envelope_assemblies +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + WallExteriorFinish, + WallInteriorFinish, + WallStructuralSystem, +) + +__all__ = [ + "SemiFlatWallConstruction", + "WallExteriorFinish", + "WallInteriorFinish", + "WallStructuralSystem", + "build_envelope_assemblies", +] diff --git a/epinterface/sbem/flat_constructions/assemblies.py b/epinterface/sbem/flat_constructions/assemblies.py new file mode 100644 index 0000000..5ad5abb --- /dev/null +++ b/epinterface/sbem/flat_constructions/assemblies.py @@ -0,0 +1,226 @@ +"""Envelope assembly builders for semi-flat construction definitions.""" + +from epinterface.sbem.components.envelope import ( + ConstructionAssemblyComponent, + ConstructionLayerComponent, + EnvelopeAssemblyComponent, +) +from epinterface.sbem.flat_constructions.materials import ( + CEMENT_MORTAR, + CERAMIC_TILE, + CONCRETE_MC_LIGHT, + CONCRETE_RC_DENSE, + GYPSUM_BOARD, + GYPSUM_PLASTER, + SOFTWOOD_GENERAL, + URETHANE_CARPET, + XPS_BOARD, +) +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + build_facade_assembly, +) + +MIN_INSULATION_THICKNESS_M = 0.003 + + +def _set_insulation_layer_for_target_r_value( + *, + construction: ConstructionAssemblyComponent, + insulation_layer_index: int, + target_r_value: float, + context: str, +) -> ConstructionAssemblyComponent: + """Back-solve insulation thickness so an assembly meets a target total R-value.""" + insulation_layer = construction.sorted_layers[insulation_layer_index] + non_insulation_r = construction.r_value - insulation_layer.r_value + r_delta = target_r_value - non_insulation_r + required_thickness = insulation_layer.ConstructionMaterial.Conductivity * r_delta + + if required_thickness < MIN_INSULATION_THICKNESS_M: + msg = ( + f"Required {context} insulation thickness is less than " + f"{MIN_INSULATION_THICKNESS_M * 1000:.0f} mm because the desired total " + f"R-value is {target_r_value} m²K/W but the non-insulation layers already " + f"sum to {non_insulation_r} m²K/W." + ) + raise ValueError(msg) + + insulation_layer.Thickness = required_thickness + return construction + + +def build_flat_roof_assembly( + *, + target_r_value: float, + name: str = "Roof", +) -> ConstructionAssemblyComponent: + """Build the flat-roof assembly and tune insulation to a target R-value.""" + roof = ConstructionAssemblyComponent( + Name=name, + Type="FlatRoof", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=XPS_BOARD, + Thickness=0.1, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=CONCRETE_MC_LIGHT, + Thickness=0.15, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=CONCRETE_RC_DENSE, + Thickness=0.2, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=GYPSUM_BOARD, + Thickness=0.02, + LayerOrder=3, + ), + ], + ) + return _set_insulation_layer_for_target_r_value( + construction=roof, + insulation_layer_index=0, + target_r_value=target_r_value, + context="Roof", + ) + + +def build_partition_assembly( + *, name: str = "Partition" +) -> ConstructionAssemblyComponent: + """Build the default interior partition assembly.""" + return ConstructionAssemblyComponent( + Name=name, + Type="Partition", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=GYPSUM_PLASTER, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=SOFTWOOD_GENERAL, + Thickness=0.02, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=GYPSUM_PLASTER, + Thickness=0.02, + LayerOrder=2, + ), + ], + ) + + +def build_floor_ceiling_assembly( + *, + name: str = "FloorCeiling", +) -> ConstructionAssemblyComponent: + """Build the default interstitial floor/ceiling assembly.""" + return ConstructionAssemblyComponent( + Name=name, + Type="FloorCeiling", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=URETHANE_CARPET, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=CEMENT_MORTAR, + Thickness=0.02, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=CONCRETE_RC_DENSE, + Thickness=0.15, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=GYPSUM_BOARD, + Thickness=0.02, + LayerOrder=3, + ), + ], + ) + + +def build_ground_slab_assembly( + *, + target_r_value: float, + name: str = "GroundSlabAssembly", +) -> ConstructionAssemblyComponent: + """Build the ground slab assembly and tune insulation to a target R-value.""" + slab = ConstructionAssemblyComponent( + Name=name, + Type="GroundSlab", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=XPS_BOARD, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=CONCRETE_RC_DENSE, + Thickness=0.15, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=CONCRETE_MC_LIGHT, + Thickness=0.04, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=CEMENT_MORTAR, + Thickness=0.03, + LayerOrder=3, + ), + ConstructionLayerComponent( + ConstructionMaterial=CERAMIC_TILE, + Thickness=0.02, + LayerOrder=4, + ), + ], + ) + return _set_insulation_layer_for_target_r_value( + construction=slab, + insulation_layer_index=0, + target_r_value=target_r_value, + context="Ground slab", + ) + + +def build_envelope_assemblies( + *, + facade_wall: SemiFlatWallConstruction, + roof_r_value: float, + slab_r_value: float, +) -> EnvelopeAssemblyComponent: + """Build envelope assemblies from the flat model construction semantics.""" + facade = build_facade_assembly(facade_wall, name="Facade") + roof = build_flat_roof_assembly(target_r_value=roof_r_value, name="Roof") + partition = build_partition_assembly(name="Partition") + floor_ceiling = build_floor_ceiling_assembly(name="FloorCeiling") + ground_slab = build_ground_slab_assembly( + target_r_value=slab_r_value, + name="GroundSlabAssembly", + ) + + return EnvelopeAssemblyComponent( + Name="EnvelopeAssemblies", + FacadeAssembly=facade, + FlatRoofAssembly=roof, + AtticRoofAssembly=roof, + PartitionAssembly=partition, + FloorCeilingAssembly=floor_ceiling, + AtticFloorAssembly=floor_ceiling, + BasementCeilingAssembly=floor_ceiling, + GroundSlabAssembly=ground_slab, + GroundWallAssembly=ground_slab, + ExternalFloorAssembly=ground_slab, + ) diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py new file mode 100644 index 0000000..e12fff9 --- /dev/null +++ b/epinterface/sbem/flat_constructions/materials.py @@ -0,0 +1,155 @@ +"""Shared opaque materials used by semi-flat construction builders.""" + +from epinterface.sbem.components.materials import ConstructionMaterialComponent + + +def _material( + *, + name: str, + conductivity: float, + density: float, + specific_heat: float, + mat_type: str, +) -> ConstructionMaterialComponent: + """Create a construction material component with common optical defaults.""" + return ConstructionMaterialComponent( + Name=name, + Conductivity=conductivity, + Density=density, + SpecificHeat=specific_heat, + ThermalAbsorptance=0.9, + SolarAbsorptance=0.6, + VisibleAbsorptance=0.6, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="MediumRough", + Type=mat_type, # pyright: ignore[reportArgumentType] + ) + + +XPS_BOARD = _material( + name="XPSBoard", + conductivity=0.037, + density=40, + specific_heat=1200, + mat_type="Insulation", +) + +CONCRETE_MC_LIGHT = _material( + name="ConcreteMC_Light", + conductivity=1.65, + density=2100, + specific_heat=1040, + mat_type="Concrete", +) + +CONCRETE_RC_DENSE = _material( + name="ConcreteRC_Dense", + conductivity=1.75, + density=2400, + specific_heat=840, + mat_type="Concrete", +) + +GYPSUM_BOARD = _material( + name="GypsumBoard", + conductivity=0.16, + density=950, + specific_heat=840, + mat_type="Finishes", +) + +GYPSUM_PLASTER = _material( + name="GypsumPlaster", + conductivity=0.42, + density=900, + specific_heat=840, + mat_type="Finishes", +) + +SOFTWOOD_GENERAL = _material( + name="SoftwoodGeneral", + conductivity=0.13, + density=496, + specific_heat=1630, + mat_type="Timber", +) + +CLAY_BRICK = _material( + name="ClayBrick", + conductivity=0.41, + density=1000, + specific_heat=920, + mat_type="Masonry", +) + +CONCRETE_BLOCK_H = _material( + name="ConcreteBlockH", + conductivity=1.25, + density=880, + specific_heat=840, + mat_type="Concrete", +) + +FIBERGLASS_BATTS = _material( + name="FiberglassBatt", + conductivity=0.043, + density=12, + specific_heat=840, + mat_type="Insulation", +) + +CEMENT_MORTAR = _material( + name="CementMortar", + conductivity=0.8, + density=1900, + specific_heat=840, + mat_type="Other", +) + +CERAMIC_TILE = _material( + name="CeramicTile", + conductivity=0.8, + density=2243, + specific_heat=840, + mat_type="Finishes", +) + +URETHANE_CARPET = _material( + name="UrethaneCarpet", + conductivity=0.045, + density=110, + specific_heat=840, + mat_type="Finishes", +) + +STEEL_PANEL = _material( + name="SteelPanel", + conductivity=45.0, + density=7850, + specific_heat=500, + mat_type="Metal", +) + +RAMMED_EARTH = _material( + name="RammedEarth", + conductivity=1.30, + density=2000, + specific_heat=1000, + mat_type="Masonry", +) + +SIP_CORE = _material( + name="SIPCore", + conductivity=0.026, + density=35, + specific_heat=1400, + mat_type="Insulation", +) + +FIBER_CEMENT_BOARD = _material( + name="FiberCementBoard", + conductivity=0.30, + density=1300, + specific_heat=840, + mat_type="Siding", +) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py new file mode 100644 index 0000000..b236fc8 --- /dev/null +++ b/epinterface/sbem/flat_constructions/walls.py @@ -0,0 +1,445 @@ +"""Semi-flat wall schema and translators for SBEM assemblies.""" + +from dataclasses import dataclass +from typing import Literal, get_args + +from pydantic import BaseModel, Field, model_validator + +from epinterface.sbem.components.envelope import ( + ConstructionAssemblyComponent, + ConstructionLayerComponent, +) +from epinterface.sbem.flat_constructions.materials import ( + CEMENT_MORTAR, + CLAY_BRICK, + CONCRETE_BLOCK_H, + CONCRETE_RC_DENSE, + FIBER_CEMENT_BOARD, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + GYPSUM_PLASTER, + RAMMED_EARTH, + SIP_CORE, + SOFTWOOD_GENERAL, + STEEL_PANEL, + XPS_BOARD, +) + +WallStructuralSystem = Literal[ + "none", + "sheet_metal", + "light_gauge_steel", + "structural_steel", + "woodframe", + "deep_woodframe", + "woodframe_24oc", + "deep_woodframe_24oc", + "engineered_timber", + "cmu", + "double_layer_cmu", + "precast_concrete", + "poured_concrete", + "masonry", + "rammed_earth", + "reinforced_concrete", + "sip", +] + +WallInteriorFinish = Literal["none", "drywall", "plaster", "wood_panel"] +WallExteriorFinish = Literal[ + "none", + "brick_veneer", + "stucco", + "fiber_cement", + "metal_panel", +] + +ALL_WALL_STRUCTURAL_SYSTEMS = get_args(WallStructuralSystem) +ALL_WALL_INTERIOR_FINISHES = get_args(WallInteriorFinish) +ALL_WALL_EXTERIOR_FINISHES = get_args(WallExteriorFinish) + + +@dataclass(frozen=True) +class StructuralTemplate: + """Default structural wall assumptions for a structural system.""" + + material_name: str + thickness_m: float + supports_cavity_insulation: bool + cavity_depth_m: float | None + + +@dataclass(frozen=True) +class FinishTemplate: + """Default finish material and thickness assumptions.""" + + material_name: str + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[WallStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name=GYPSUM_BOARD.Name, + thickness_m=0.005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sheet_metal": StructuralTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.001, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "light_gauge_steel": StructuralTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.002, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + ), + "structural_steel": StructuralTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.006, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "woodframe": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.090, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + ), + "deep_woodframe": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.140, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + ), + "woodframe_24oc": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.090, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + ), + "deep_woodframe_24oc": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.140, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + ), + "engineered_timber": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.160, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "cmu": StructuralTemplate( + material_name=CONCRETE_BLOCK_H.Name, + thickness_m=0.200, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + ), + "double_layer_cmu": StructuralTemplate( + material_name=CONCRETE_BLOCK_H.Name, + thickness_m=0.300, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + ), + "precast_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "poured_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "masonry": StructuralTemplate( + material_name=CLAY_BRICK.Name, + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "rammed_earth": StructuralTemplate( + material_name=RAMMED_EARTH.Name, + thickness_m=0.350, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "reinforced_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.250, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sip": StructuralTemplate( + material_name=SIP_CORE.Name, + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), +} + +INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { + "none": None, + "drywall": FinishTemplate( + material_name=GYPSUM_BOARD.Name, + thickness_m=0.0127, + ), + "plaster": FinishTemplate( + material_name=GYPSUM_PLASTER.Name, + thickness_m=0.013, + ), + "wood_panel": FinishTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.012, + ), +} + +EXTERIOR_FINISH_TEMPLATES: dict[WallExteriorFinish, FinishTemplate | None] = { + "none": None, + "brick_veneer": FinishTemplate( + material_name=CLAY_BRICK.Name, + thickness_m=0.090, + ), + "stucco": FinishTemplate( + material_name=CEMENT_MORTAR.Name, + thickness_m=0.020, + ), + "fiber_cement": FinishTemplate( + material_name=FIBER_CEMENT_BOARD.Name, + thickness_m=0.012, + ), + "metal_panel": FinishTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.001, + ), +} + +MATERIAL_LOOKUP = { + mat.Name: mat + for mat in ( + XPS_BOARD, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + GYPSUM_PLASTER, + SOFTWOOD_GENERAL, + CLAY_BRICK, + CONCRETE_BLOCK_H, + CONCRETE_RC_DENSE, + CEMENT_MORTAR, + STEEL_PANEL, + RAMMED_EARTH, + SIP_CORE, + FIBER_CEMENT_BOARD, + ) +} + + +class SemiFlatWallConstruction(BaseModel): + """Semantic wall representation for fixed-length flat model vectors.""" + + structural_system: WallStructuralSystem = Field( + default="cmu", + title="Structural system for thermal mass assumptions", + ) + nominal_cavity_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal cavity insulation R-value [m²K/W]", + ) + nominal_exterior_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal exterior continuous insulation R-value [m²K/W]", + ) + nominal_interior_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal interior continuous insulation R-value [m²K/W]", + ) + interior_finish: WallInteriorFinish = Field( + default="drywall", + title="Interior finish selection", + ) + exterior_finish: WallExteriorFinish = Field( + default="none", + title="Exterior finish selection", + ) + + @property + def effective_nominal_cavity_insulation_r(self) -> float: + """Return cavity insulation R-value after applying compatibility defaults.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if not template.supports_cavity_insulation: + return 0.0 + return self.nominal_cavity_insulation_r + + @property + def ignored_feature_names(self) -> tuple[str, ...]: + """Return feature names that are semantic no-ops for this wall.""" + ignored: list[str] = [] + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_cavity_insulation + and self.nominal_cavity_insulation_r > 0 + ): + ignored.append("nominal_cavity_insulation_r") + return tuple(ignored) + + @model_validator(mode="after") + def validate_cavity_r_against_assumed_depth(self): + """Guard impossible cavity R-values for cavity-compatible systems.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_cavity_insulation + or template.cavity_depth_m is None + or self.nominal_cavity_insulation_r == 0 + ): + return self + + assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity + max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity + tolerance_r = 0.2 + if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: + msg = ( + f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " + f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " + f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." + ) + raise ValueError(msg) + return self + + def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: + """Return a fixed-length numeric feature dictionary for ML workflows.""" + features: dict[str, float] = { + f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, + f"{prefix}NominalExteriorInsulationRValue": self.nominal_exterior_insulation_r, + f"{prefix}NominalInteriorInsulationRValue": self.nominal_interior_insulation_r, + f"{prefix}EffectiveNominalCavityInsulationRValue": ( + self.effective_nominal_cavity_insulation_r + ), + } + + for structural_system in ALL_WALL_STRUCTURAL_SYSTEMS: + features[f"{prefix}StructuralSystem__{structural_system}"] = float( + self.structural_system == structural_system + ) + for interior_finish in ALL_WALL_INTERIOR_FINISHES: + features[f"{prefix}InteriorFinish__{interior_finish}"] = float( + self.interior_finish == interior_finish + ) + for exterior_finish in ALL_WALL_EXTERIOR_FINISHES: + features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( + self.exterior_finish == exterior_finish + ) + return features + + +def _make_layer( + *, + material_name: str, + thickness_m: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a construction layer from a registered material.""" + return ConstructionLayerComponent( + ConstructionMaterial=MATERIAL_LOOKUP[material_name], + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + +def _nominal_r_insulation_layer( + *, + material_name: str, + nominal_r_value: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a layer by back-solving thickness from nominal R-value.""" + material = MATERIAL_LOOKUP[material_name] + thickness_m = nominal_r_value * material.Conductivity + return _make_layer( + material_name=material_name, + thickness_m=thickness_m, + layer_order=layer_order, + ) + + +def build_facade_assembly( + wall: SemiFlatWallConstruction, + *, + name: str = "Facade", +) -> ConstructionAssemblyComponent: + """Translate semi-flat wall inputs into a concrete facade assembly.""" + template = STRUCTURAL_TEMPLATES[wall.structural_system] + layers: list[ConstructionLayerComponent] = [] + layer_order = 0 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[wall.exterior_finish] + if exterior_finish is not None: + layers.append( + _make_layer( + material_name=exterior_finish.material_name, + thickness_m=exterior_finish.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if wall.nominal_exterior_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=XPS_BOARD.Name, + nominal_r_value=wall.nominal_exterior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + layers.append( + _make_layer( + material_name=template.material_name, + thickness_m=template.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if wall.effective_nominal_cavity_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=wall.effective_nominal_cavity_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if wall.nominal_interior_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=wall.nominal_interior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + interior_finish = INTERIOR_FINISH_TEMPLATES[wall.interior_finish] + if interior_finish is not None: + layers.append( + _make_layer( + material_name=interior_finish.material_name, + thickness_m=interior_finish.thickness_m, + layer_order=layer_order, + ) + ) + + return ConstructionAssemblyComponent( + Name=name, + Type="Facade", + Layers=layers, + ) diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index b74e125..aa9bf45 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -10,14 +10,10 @@ from epinterface.geometry import ShoeboxGeometry from epinterface.sbem.builder import AtticAssumptions, BasementAssumptions, Model from epinterface.sbem.components.envelope import ( - ConstructionAssemblyComponent, - ConstructionLayerComponent, - EnvelopeAssemblyComponent, GlazingConstructionSimpleComponent, InfiltrationComponent, ZoneEnvelopeComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.components.operations import ZoneOperationsComponent from epinterface.sbem.components.schedules import ( DayComponent, @@ -47,163 +43,14 @@ ZoneHVACComponent, ) from epinterface.sbem.components.zones import ZoneComponent -from epinterface.weather import WeatherUrl - -xps_board = ConstructionMaterialComponent( - Name="XPSBoard", - Conductivity=0.037, - Density=40, - SpecificHeat=1200, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Insulation", -) - -concrete_mc_light = ConstructionMaterialComponent( - Name="ConcreteMC_Light", - Conductivity=1.65, - Density=2100, - SpecificHeat=1040, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Concrete", -) - -concrete_rc_dense = ConstructionMaterialComponent( - Name="ConcreteRC_Dense", - Conductivity=1.75, - Density=2400, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Concrete", -) - -gypsum_board = ConstructionMaterialComponent( - Name="GypsumBoard", - Conductivity=0.16, - Density=950, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Finishes", -) - -gypsum_plaster = ConstructionMaterialComponent( - Name="GypsumPlaster", - Conductivity=0.42, - Density=900, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Finishes", -) - -softwood_general = ConstructionMaterialComponent( - Name="SoftwoodGeneral", - Conductivity=0.13, - Density=496, - SpecificHeat=1630, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Timber", -) - -clay_brick = ConstructionMaterialComponent( - Name="ClayBrick", - Conductivity=0.41, - Density=1000, - SpecificHeat=920, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Masonry", -) - -concrete_block_h = ConstructionMaterialComponent( - Name="ConcreteBlockH", - Conductivity=1.25, - Density=880, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Concrete", -) - -fiberglass_batts = ConstructionMaterialComponent( - Name="FiberglassBatt", - Conductivity=0.043, - Density=12, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Insulation", -) - -cement_mortar = ConstructionMaterialComponent( - Name="CementMortar", - Conductivity=0.8, - Density=1900, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Other", -) - -ceramic_tile = ConstructionMaterialComponent( - Name="CeramicTile", - Conductivity=0.8, - Density=2243, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Finishes", -) - -urethane_carpet = ConstructionMaterialComponent( - Name="UrethaneCarpet", - Conductivity=0.045, - Density=110, - SpecificHeat=840, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Finishes", +from epinterface.sbem.flat_constructions import ( + SemiFlatWallConstruction, + WallExteriorFinish, + WallInteriorFinish, + WallStructuralSystem, + build_envelope_assemblies, ) +from epinterface.weather import WeatherUrl class ParametericYear(BaseModel): @@ -915,7 +762,12 @@ class FlatModel(BaseModel): WindowSHGF: float WindowTVis: float - FacadeRValue: float + FacadeStructuralSystem: WallStructuralSystem + FacadeCavityInsulationRValue: float = Field(ge=0) + FacadeExteriorInsulationRValue: float = Field(ge=0) + FacadeInteriorInsulationRValue: float = Field(ge=0) + FacadeInteriorFinish: WallInteriorFinish + FacadeExteriorFinish: WallExteriorFinish RoofRValue: float SlabRValue: float @@ -928,6 +780,18 @@ class FlatModel(BaseModel): EPWURI: WeatherUrl | Path + @property + def facade_wall(self) -> SemiFlatWallConstruction: + """Return the semantic facade-wall specification.""" + return SemiFlatWallConstruction( + structural_system=self.FacadeStructuralSystem, + nominal_cavity_insulation_r=self.FacadeCavityInsulationRValue, + nominal_exterior_insulation_r=self.FacadeExteriorInsulationRValue, + nominal_interior_insulation_r=self.FacadeInteriorInsulationRValue, + interior_finish=self.FacadeInteriorFinish, + exterior_finish=self.FacadeExteriorFinish, + ) + def to_zone(self) -> ZoneComponent: """Convert the flat model to a full zone.""" # occ_regular_workday = DayComponent( @@ -1880,190 +1744,10 @@ def to_zone(self) -> ZoneComponent: FlowPerExteriorSurfaceArea=0.0, ) - # TODO: verify interior/exterior - # TODO: are we okaky with mass assumptions? - facade = ConstructionAssemblyComponent( - Name="Facade", - Type="Facade", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=clay_brick, - Thickness=0.002, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_block_h, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=fiberglass_batts, - Thickness=0.05, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.015, - LayerOrder=3, - ), - ], - ) - - facade_r_value_without_fiberglass = ( - facade.r_value - facade.sorted_layers[2].r_value - ) - - facade_r_value_delta = self.FacadeRValue - facade_r_value_without_fiberglass - required_fiberglass_thickness = ( - fiberglass_batts.Conductivity * facade_r_value_delta - ) - - if required_fiberglass_thickness < 0.003: - msg = f"Required Facade Fiberglass thickness is less than 3mm because the desired total facade R-value is {self.FacadeRValue} m²K/W but the concrete and gypsum layers already have a total R-value of {facade_r_value_without_fiberglass} m²K/W." - raise ValueError(msg) - - facade.sorted_layers[2].Thickness = required_fiberglass_thickness - - roof = ConstructionAssemblyComponent( - Name="Roof", - Type="FlatRoof", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=xps_board, - Thickness=0.1, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_mc_light, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.2, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.02, - LayerOrder=3, - ), - ], - ) - - roof_r_value_without_xps = roof.r_value - roof.sorted_layers[0].r_value - roof_r_value_delta = self.RoofRValue - roof_r_value_without_xps - required_xps_thickness = xps_board.Conductivity * roof_r_value_delta - if required_xps_thickness < 0.003: - msg = f"Required Roof XPS thickness is less than 3mm because the desired total roof R-value is {self.RoofRValue} m²K/W but the concrete layers already have a total R-value of {roof_r_value_without_xps} m²K/W." - raise ValueError(msg) - - roof.sorted_layers[0].Thickness = required_xps_thickness - - partition = ConstructionAssemblyComponent( - Name="Partition", - Type="Partition", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=gypsum_plaster, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=softwood_general, - Thickness=0.02, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_plaster, - Thickness=0.02, - LayerOrder=2, - ), - ], - ) - - floor_ceiling = ConstructionAssemblyComponent( - Name="FloorCeiling", - Type="FloorCeiling", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=urethane_carpet, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=cement_mortar, - Thickness=0.02, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.15, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.02, - LayerOrder=3, - ), - ], - ) - - ground_slab_assembly = ConstructionAssemblyComponent( - Name="GroundSlabAssembly", - Type="GroundSlab", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=xps_board, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_mc_light, - Thickness=0.04, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=cement_mortar, - Thickness=0.03, - LayerOrder=3, - ), - ConstructionLayerComponent( - ConstructionMaterial=ceramic_tile, - Thickness=0.02, - LayerOrder=4, - ), - ], - ) - - ground_slab_r_value_without_xps = ( - ground_slab_assembly.r_value - ground_slab_assembly.sorted_layers[0].r_value - ) - ground_slab_r_value_delta = self.SlabRValue - ground_slab_r_value_without_xps - required_xps_thickness = xps_board.Conductivity * ground_slab_r_value_delta - if required_xps_thickness < 0.003: - msg = f"Required Ground Slab XPS thickness is less than 3mm because the desired total slab R-value is {self.SlabRValue} m²K/W but the concrete layers already have a total R-value of {ground_slab_r_value_without_xps} m²K/W." - raise ValueError(msg) - - ground_slab_assembly.sorted_layers[0].Thickness = required_xps_thickness - - assemblies = EnvelopeAssemblyComponent( - Name="EnvelopeAssemblies", - FacadeAssembly=facade, - FlatRoofAssembly=roof, - AtticRoofAssembly=roof, - PartitionAssembly=partition, - FloorCeilingAssembly=floor_ceiling, - AtticFloorAssembly=floor_ceiling, - BasementCeilingAssembly=floor_ceiling, - GroundSlabAssembly=ground_slab_assembly, - GroundWallAssembly=ground_slab_assembly, - ExternalFloorAssembly=ground_slab_assembly, + assemblies = build_envelope_assemblies( + facade_wall=self.facade_wall, + roof_r_value=self.RoofRValue, + slab_r_value=self.SlabRValue, ) basement_infiltration = infiltration.model_copy(deep=True) @@ -2148,7 +1832,12 @@ def simulate( Rotation=45, WWR=0.3, NFloors=2, - FacadeRValue=3.0, + FacadeStructuralSystem="cmu", + FacadeCavityInsulationRValue=1.2, + FacadeExteriorInsulationRValue=1.0, + FacadeInteriorInsulationRValue=0.0, + FacadeInteriorFinish="drywall", + FacadeExteriorFinish="brick_veneer", RoofRValue=3.0, SlabRValue=3.0, WindowUValue=3.0, diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py new file mode 100644 index 0000000..68c55fb --- /dev/null +++ b/tests/test_flat_constructions/test_walls.py @@ -0,0 +1,82 @@ +"""Tests for semi-flat wall construction translation.""" + +import pytest + +from epinterface.sbem.flat_constructions.walls import ( + ALL_WALL_EXTERIOR_FINISHES, + ALL_WALL_INTERIOR_FINISHES, + ALL_WALL_STRUCTURAL_SYSTEMS, + SemiFlatWallConstruction, + build_facade_assembly, +) + + +def test_build_facade_assembly_from_nominal_r_values() -> None: + """Facade assembly should reflect nominal wall insulation inputs.""" + wall = SemiFlatWallConstruction( + structural_system="cmu", + nominal_cavity_insulation_r=1.0, + nominal_exterior_insulation_r=2.0, + nominal_interior_insulation_r=0.5, + interior_finish="drywall", + exterior_finish="stucco", + ) + + assembly = build_facade_assembly(wall) + + # R_total = stucco + ext_ins + cmu + cavity + int_ins + drywall + expected_r = 0.025 + 2.0 + (0.2 / 1.25) + 1.0 + 0.5 + (0.0127 / 0.16) + assert assembly.Type == "Facade" + assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) + + +def test_validator_rejects_unrealistic_cavity_r_for_depth() -> None: + """Cavity insulation R should be limited by assumed cavity depth.""" + with pytest.raises( + ValueError, + match="cavity-depth-compatible limit", + ): + SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=3.0, + ) + + +def test_non_cavity_structural_system_treats_cavity_r_as_dead_feature() -> None: + """Cavity R should become a no-op when structural system has no cavity.""" + wall = SemiFlatWallConstruction( + structural_system="poured_concrete", + nominal_cavity_insulation_r=2.0, + ) + assembly = build_facade_assembly(wall) + layer_material_names = [ + layer.ConstructionMaterial.Name for layer in assembly.sorted_layers + ] + + assert wall.effective_nominal_cavity_insulation_r == 0.0 + assert "nominal_cavity_insulation_r" in wall.ignored_feature_names + assert "FiberglassBatt" not in layer_material_names + + +def test_wall_feature_dict_has_fixed_length() -> None: + """Feature dictionary should remain fixed-length across wall variants.""" + wall = SemiFlatWallConstruction( + structural_system="deep_woodframe_24oc", + nominal_cavity_insulation_r=2.8, + nominal_exterior_insulation_r=0.5, + nominal_interior_insulation_r=0.0, + interior_finish="plaster", + exterior_finish="fiber_cement", + ) + features = wall.to_feature_dict(prefix="Facade") + + expected_length = ( + 4 + + len(ALL_WALL_STRUCTURAL_SYSTEMS) + + len(ALL_WALL_INTERIOR_FINISHES) + + len(ALL_WALL_EXTERIOR_FINISHES) + ) + assert len(features) == expected_length + assert features["FacadeStructuralSystem__deep_woodframe_24oc"] == 1.0 + assert features["FacadeInteriorFinish__plaster"] == 1.0 + assert features["FacadeExteriorFinish__fiber_cement"] == 1.0 From de84c07f735331c4fbba4204a204634d5b9a7952 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 04:31:03 +0000 Subject: [PATCH 02/18] Add defaults for semi-flat facade fields Co-authored-by: Sam Wolk --- epinterface/sbem/flat_model.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index aa9bf45..6e9eef7 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -762,12 +762,12 @@ class FlatModel(BaseModel): WindowSHGF: float WindowTVis: float - FacadeStructuralSystem: WallStructuralSystem - FacadeCavityInsulationRValue: float = Field(ge=0) - FacadeExteriorInsulationRValue: float = Field(ge=0) - FacadeInteriorInsulationRValue: float = Field(ge=0) - FacadeInteriorFinish: WallInteriorFinish - FacadeExteriorFinish: WallExteriorFinish + FacadeStructuralSystem: WallStructuralSystem = "cmu" + FacadeCavityInsulationRValue: float = Field(default=0, ge=0) + FacadeExteriorInsulationRValue: float = Field(default=0, ge=0) + FacadeInteriorInsulationRValue: float = Field(default=0, ge=0) + FacadeInteriorFinish: WallInteriorFinish = "drywall" + FacadeExteriorFinish: WallExteriorFinish = "none" RoofRValue: float SlabRValue: float From b5e5223fe405f46dc1bc4d61ddb2c94b8b4000e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 04:46:54 +0000 Subject: [PATCH 03/18] Add dedicated semi-flat roof and slab constructors Co-authored-by: Sam Wolk --- benchmarking/benchmark.py | 14 +- .../sbem/flat_constructions/__init__.py | 26 ++ .../sbem/flat_constructions/assemblies.py | 138 +------ .../sbem/flat_constructions/materials.py | 58 +++ epinterface/sbem/flat_constructions/roofs.py | 386 ++++++++++++++++++ epinterface/sbem/flat_constructions/slabs.py | 378 +++++++++++++++++ epinterface/sbem/flat_constructions/walls.py | 24 +- epinterface/sbem/flat_model.py | 78 +++- .../test_roofs_slabs.py | 202 +++++++++ 9 files changed, 1152 insertions(+), 152 deletions(-) create mode 100644 epinterface/sbem/flat_constructions/roofs.py create mode 100644 epinterface/sbem/flat_constructions/slabs.py create mode 100644 tests/test_flat_constructions/test_roofs_slabs.py diff --git a/benchmarking/benchmark.py b/benchmarking/benchmark.py index b44a2ff..88a6b2e 100644 --- a/benchmarking/benchmark.py +++ b/benchmarking/benchmark.py @@ -33,8 +33,18 @@ def benchmark() -> None: FacadeInteriorInsulationRValue=0.0, FacadeInteriorFinish="drywall", FacadeExteriorFinish="brick_veneer", - RoofRValue=3.0, - SlabRValue=3.0, + RoofStructuralSystem="poured_concrete", + RoofCavityInsulationRValue=0.0, + RoofExteriorInsulationRValue=2.5, + RoofInteriorInsulationRValue=0.2, + RoofInteriorFinish="gypsum_board", + RoofExteriorFinish="epdm_membrane", + SlabStructuralSystem="slab_on_grade", + SlabUnderInsulationRValue=2.2, + SlabAboveInsulationRValue=0.0, + SlabCavityInsulationRValue=0.0, + SlabInteriorFinish="tile", + SlabExteriorFinish="none", WindowUValue=3.0, WindowSHGF=0.7, WindowTVis=0.5, diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py index a4c50ba..78287ff 100644 --- a/epinterface/sbem/flat_constructions/__init__.py +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -1,17 +1,43 @@ """Semi-flat construction translators for SBEM flat models.""" from epinterface.sbem.flat_constructions.assemblies import build_envelope_assemblies +from epinterface.sbem.flat_constructions.roofs import ( + RoofExteriorFinish, + RoofInteriorFinish, + RoofStructuralSystem, + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.slabs import ( + SemiFlatSlabConstruction, + SlabExteriorFinish, + SlabInteriorFinish, + SlabStructuralSystem, + build_slab_assembly, +) from epinterface.sbem.flat_constructions.walls import ( SemiFlatWallConstruction, WallExteriorFinish, WallInteriorFinish, WallStructuralSystem, + build_facade_assembly, ) __all__ = [ + "RoofExteriorFinish", + "RoofInteriorFinish", + "RoofStructuralSystem", + "SemiFlatRoofConstruction", + "SemiFlatSlabConstruction", "SemiFlatWallConstruction", + "SlabExteriorFinish", + "SlabInteriorFinish", + "SlabStructuralSystem", "WallExteriorFinish", "WallInteriorFinish", "WallStructuralSystem", "build_envelope_assemblies", + "build_facade_assembly", + "build_roof_assembly", + "build_slab_assembly", ] diff --git a/epinterface/sbem/flat_constructions/assemblies.py b/epinterface/sbem/flat_constructions/assemblies.py index 5ad5abb..a332291 100644 --- a/epinterface/sbem/flat_constructions/assemblies.py +++ b/epinterface/sbem/flat_constructions/assemblies.py @@ -7,88 +7,25 @@ ) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, - CERAMIC_TILE, - CONCRETE_MC_LIGHT, CONCRETE_RC_DENSE, GYPSUM_BOARD, GYPSUM_PLASTER, SOFTWOOD_GENERAL, URETHANE_CARPET, - XPS_BOARD, +) +from epinterface.sbem.flat_constructions.roofs import ( + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.slabs import ( + SemiFlatSlabConstruction, + build_slab_assembly, ) from epinterface.sbem.flat_constructions.walls import ( SemiFlatWallConstruction, build_facade_assembly, ) -MIN_INSULATION_THICKNESS_M = 0.003 - - -def _set_insulation_layer_for_target_r_value( - *, - construction: ConstructionAssemblyComponent, - insulation_layer_index: int, - target_r_value: float, - context: str, -) -> ConstructionAssemblyComponent: - """Back-solve insulation thickness so an assembly meets a target total R-value.""" - insulation_layer = construction.sorted_layers[insulation_layer_index] - non_insulation_r = construction.r_value - insulation_layer.r_value - r_delta = target_r_value - non_insulation_r - required_thickness = insulation_layer.ConstructionMaterial.Conductivity * r_delta - - if required_thickness < MIN_INSULATION_THICKNESS_M: - msg = ( - f"Required {context} insulation thickness is less than " - f"{MIN_INSULATION_THICKNESS_M * 1000:.0f} mm because the desired total " - f"R-value is {target_r_value} m²K/W but the non-insulation layers already " - f"sum to {non_insulation_r} m²K/W." - ) - raise ValueError(msg) - - insulation_layer.Thickness = required_thickness - return construction - - -def build_flat_roof_assembly( - *, - target_r_value: float, - name: str = "Roof", -) -> ConstructionAssemblyComponent: - """Build the flat-roof assembly and tune insulation to a target R-value.""" - roof = ConstructionAssemblyComponent( - Name=name, - Type="FlatRoof", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=XPS_BOARD, - Thickness=0.1, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=CONCRETE_MC_LIGHT, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=CONCRETE_RC_DENSE, - Thickness=0.2, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=GYPSUM_BOARD, - Thickness=0.02, - LayerOrder=3, - ), - ], - ) - return _set_insulation_layer_for_target_r_value( - construction=roof, - insulation_layer_index=0, - target_r_value=target_r_value, - context="Roof", - ) - def build_partition_assembly( *, name: str = "Partition" @@ -150,72 +87,27 @@ def build_floor_ceiling_assembly( ) -def build_ground_slab_assembly( - *, - target_r_value: float, - name: str = "GroundSlabAssembly", -) -> ConstructionAssemblyComponent: - """Build the ground slab assembly and tune insulation to a target R-value.""" - slab = ConstructionAssemblyComponent( - Name=name, - Type="GroundSlab", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=XPS_BOARD, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=CONCRETE_RC_DENSE, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=CONCRETE_MC_LIGHT, - Thickness=0.04, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=CEMENT_MORTAR, - Thickness=0.03, - LayerOrder=3, - ), - ConstructionLayerComponent( - ConstructionMaterial=CERAMIC_TILE, - Thickness=0.02, - LayerOrder=4, - ), - ], - ) - return _set_insulation_layer_for_target_r_value( - construction=slab, - insulation_layer_index=0, - target_r_value=target_r_value, - context="Ground slab", - ) - - def build_envelope_assemblies( *, facade_wall: SemiFlatWallConstruction, - roof_r_value: float, - slab_r_value: float, + roof: SemiFlatRoofConstruction, + slab: SemiFlatSlabConstruction, ) -> EnvelopeAssemblyComponent: """Build envelope assemblies from the flat model construction semantics.""" facade = build_facade_assembly(facade_wall, name="Facade") - roof = build_flat_roof_assembly(target_r_value=roof_r_value, name="Roof") + roof_assembly = build_roof_assembly(roof, name="Roof") partition = build_partition_assembly(name="Partition") floor_ceiling = build_floor_ceiling_assembly(name="FloorCeiling") - ground_slab = build_ground_slab_assembly( - target_r_value=slab_r_value, + ground_slab = build_slab_assembly( + slab, name="GroundSlabAssembly", ) return EnvelopeAssemblyComponent( Name="EnvelopeAssemblies", FacadeAssembly=facade, - FlatRoofAssembly=roof, - AtticRoofAssembly=roof, + FlatRoofAssembly=roof_assembly, + AtticRoofAssembly=roof_assembly, PartitionAssembly=partition, FloorCeilingAssembly=floor_ceiling, AtticFloorAssembly=floor_ceiling, diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index e12fff9..3aa5aba 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -34,6 +34,14 @@ def _material( mat_type="Insulation", ) +POLYISO_BOARD = _material( + name="PolyisoBoard", + conductivity=0.024, + density=32, + specific_heat=1400, + mat_type="Insulation", +) + CONCRETE_MC_LIGHT = _material( name="ConcreteMC_Light", conductivity=1.65, @@ -153,3 +161,53 @@ def _material( specific_heat=840, mat_type="Siding", ) + +ROOF_MEMBRANE = _material( + name="RoofMembrane", + conductivity=0.16, + density=1200, + specific_heat=900, + mat_type="Sealing", +) + +COOL_ROOF_MEMBRANE = _material( + name="CoolRoofMembrane", + conductivity=0.16, + density=1200, + specific_heat=900, + mat_type="Sealing", +) + +ACOUSTIC_TILE = _material( + name="AcousticTile", + conductivity=0.06, + density=300, + specific_heat=840, + mat_type="Boards", +) + +MATERIALS_BY_NAME = { + mat.Name: mat + for mat in ( + XPS_BOARD, + POLYISO_BOARD, + CONCRETE_MC_LIGHT, + CONCRETE_RC_DENSE, + GYPSUM_BOARD, + GYPSUM_PLASTER, + SOFTWOOD_GENERAL, + CLAY_BRICK, + CONCRETE_BLOCK_H, + FIBERGLASS_BATTS, + CEMENT_MORTAR, + CERAMIC_TILE, + URETHANE_CARPET, + STEEL_PANEL, + RAMMED_EARTH, + SIP_CORE, + FIBER_CEMENT_BOARD, + ROOF_MEMBRANE, + COOL_ROOF_MEMBRANE, + ACOUSTIC_TILE, + ) +} diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py new file mode 100644 index 0000000..2a22251 --- /dev/null +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -0,0 +1,386 @@ +"""Semi-flat roof schema and translators for SBEM assemblies.""" + +from dataclasses import dataclass +from typing import Literal, get_args + +from pydantic import BaseModel, Field, model_validator + +from epinterface.sbem.components.envelope import ( + ConstructionAssemblyComponent, + ConstructionLayerComponent, +) +from epinterface.sbem.flat_constructions.materials import ( + ACOUSTIC_TILE, + CEMENT_MORTAR, + CERAMIC_TILE, + CONCRETE_RC_DENSE, + COOL_ROOF_MEMBRANE, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + MATERIALS_BY_NAME, + POLYISO_BOARD, + ROOF_MEMBRANE, + SIP_CORE, + SOFTWOOD_GENERAL, + STEEL_PANEL, +) + +RoofStructuralSystem = Literal[ + "none", + "light_wood_truss", + "deep_wood_truss", + "steel_joist", + "metal_deck", + "mass_timber", + "precast_concrete", + "poured_concrete", + "reinforced_concrete", + "sip", +] + +RoofInteriorFinish = Literal[ + "none", + "gypsum_board", + "acoustic_tile", + "wood_panel", +] +RoofExteriorFinish = Literal[ + "none", + "epdm_membrane", + "cool_membrane", + "built_up_roof", + "metal_roof", + "tile_roof", +] + +ALL_ROOF_STRUCTURAL_SYSTEMS = get_args(RoofStructuralSystem) +ALL_ROOF_INTERIOR_FINISHES = get_args(RoofInteriorFinish) +ALL_ROOF_EXTERIOR_FINISHES = get_args(RoofExteriorFinish) + + +@dataclass(frozen=True) +class StructuralTemplate: + """Default structural roof assumptions for a structural system.""" + + material_name: str + thickness_m: float + supports_cavity_insulation: bool + cavity_depth_m: float | None + + +@dataclass(frozen=True) +class FinishTemplate: + """Default roof finish material and thickness assumptions.""" + + material_name: str + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[RoofStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name=GYPSUM_BOARD.Name, + thickness_m=0.005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "light_wood_truss": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.140, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + ), + "deep_wood_truss": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.240, + supports_cavity_insulation=True, + cavity_depth_m=0.240, + ), + "steel_joist": StructuralTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.004, + supports_cavity_insulation=True, + cavity_depth_m=0.180, + ), + "metal_deck": StructuralTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.008, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "mass_timber": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "precast_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "poured_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "reinforced_concrete": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.220, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sip": StructuralTemplate( + material_name=SIP_CORE.Name, + thickness_m=0.160, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), +} + +INTERIOR_FINISH_TEMPLATES: dict[RoofInteriorFinish, FinishTemplate | None] = { + "none": None, + "gypsum_board": FinishTemplate( + material_name=GYPSUM_BOARD.Name, + thickness_m=0.0127, + ), + "acoustic_tile": FinishTemplate( + material_name=ACOUSTIC_TILE.Name, + thickness_m=0.019, + ), + "wood_panel": FinishTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.012, + ), +} + +EXTERIOR_FINISH_TEMPLATES: dict[RoofExteriorFinish, FinishTemplate | None] = { + "none": None, + "epdm_membrane": FinishTemplate( + material_name=ROOF_MEMBRANE.Name, + thickness_m=0.005, + ), + "cool_membrane": FinishTemplate( + material_name=COOL_ROOF_MEMBRANE.Name, + thickness_m=0.005, + ), + "built_up_roof": FinishTemplate( + material_name=CEMENT_MORTAR.Name, + thickness_m=0.02, + ), + "metal_roof": FinishTemplate( + material_name=STEEL_PANEL.Name, + thickness_m=0.001, + ), + "tile_roof": FinishTemplate( + material_name=CERAMIC_TILE.Name, + thickness_m=0.02, + ), +} + + +class SemiFlatRoofConstruction(BaseModel): + """Semantic roof representation for fixed-length flat model vectors.""" + + structural_system: RoofStructuralSystem = Field( + default="poured_concrete", + title="Structural roof system for thermal-mass assumptions", + ) + nominal_cavity_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal cavity insulation R-value [m²K/W]", + ) + nominal_exterior_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal exterior continuous roof insulation R-value [m²K/W]", + ) + nominal_interior_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal interior continuous roof insulation R-value [m²K/W]", + ) + interior_finish: RoofInteriorFinish = Field( + default="gypsum_board", + title="Interior roof finish selection", + ) + exterior_finish: RoofExteriorFinish = Field( + default="epdm_membrane", + title="Exterior roof finish selection", + ) + + @property + def effective_nominal_cavity_insulation_r(self) -> float: + """Return cavity insulation R-value after applying compatibility defaults.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if not template.supports_cavity_insulation: + return 0.0 + return self.nominal_cavity_insulation_r + + @property + def ignored_feature_names(self) -> tuple[str, ...]: + """Return feature names that are semantic no-ops for this roof.""" + ignored: list[str] = [] + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_cavity_insulation + and self.nominal_cavity_insulation_r > 0 + ): + ignored.append("nominal_cavity_insulation_r") + return tuple(ignored) + + @model_validator(mode="after") + def validate_cavity_r_against_assumed_depth(self): + """Guard impossible cavity R-values for cavity-compatible systems.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_cavity_insulation + or template.cavity_depth_m is None + or self.nominal_cavity_insulation_r == 0 + ): + return self + + assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity + max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity + tolerance_r = 0.2 + if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: + msg = ( + f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " + f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " + f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." + ) + raise ValueError(msg) + return self + + def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: + """Return a fixed-length numeric feature dictionary for ML workflows.""" + features: dict[str, float] = { + f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, + f"{prefix}NominalExteriorInsulationRValue": self.nominal_exterior_insulation_r, + f"{prefix}NominalInteriorInsulationRValue": self.nominal_interior_insulation_r, + f"{prefix}EffectiveNominalCavityInsulationRValue": ( + self.effective_nominal_cavity_insulation_r + ), + } + for structural_system in ALL_ROOF_STRUCTURAL_SYSTEMS: + features[f"{prefix}StructuralSystem__{structural_system}"] = float( + self.structural_system == structural_system + ) + for interior_finish in ALL_ROOF_INTERIOR_FINISHES: + features[f"{prefix}InteriorFinish__{interior_finish}"] = float( + self.interior_finish == interior_finish + ) + for exterior_finish in ALL_ROOF_EXTERIOR_FINISHES: + features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( + self.exterior_finish == exterior_finish + ) + return features + + +def _make_layer( + *, + material_name: str, + thickness_m: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a construction layer from a registered material.""" + return ConstructionLayerComponent( + ConstructionMaterial=MATERIALS_BY_NAME[material_name], + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + +def _nominal_r_insulation_layer( + *, + material_name: str, + nominal_r_value: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a layer by back-solving thickness from nominal R-value.""" + material = MATERIALS_BY_NAME[material_name] + thickness_m = nominal_r_value * material.Conductivity + return _make_layer( + material_name=material_name, + thickness_m=thickness_m, + layer_order=layer_order, + ) + + +def build_roof_assembly( + roof: SemiFlatRoofConstruction, + *, + name: str = "Roof", +) -> ConstructionAssemblyComponent: + """Translate semi-flat roof inputs into a concrete roof assembly.""" + template = STRUCTURAL_TEMPLATES[roof.structural_system] + layers: list[ConstructionLayerComponent] = [] + layer_order = 0 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[roof.exterior_finish] + if exterior_finish is not None: + layers.append( + _make_layer( + material_name=exterior_finish.material_name, + thickness_m=exterior_finish.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if roof.nominal_exterior_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=POLYISO_BOARD.Name, + nominal_r_value=roof.nominal_exterior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + layers.append( + _make_layer( + material_name=template.material_name, + thickness_m=template.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if roof.effective_nominal_cavity_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=roof.effective_nominal_cavity_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if roof.nominal_interior_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=roof.nominal_interior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + interior_finish = INTERIOR_FINISH_TEMPLATES[roof.interior_finish] + if interior_finish is not None: + layers.append( + _make_layer( + material_name=interior_finish.material_name, + thickness_m=interior_finish.thickness_m, + layer_order=layer_order, + ) + ) + + return ConstructionAssemblyComponent( + Name=name, + Type="FlatRoof", + Layers=layers, + ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py new file mode 100644 index 0000000..d30daee --- /dev/null +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -0,0 +1,378 @@ +"""Semi-flat slab schema and translators for SBEM assemblies.""" + +from dataclasses import dataclass +from typing import Literal, get_args + +from pydantic import BaseModel, Field, model_validator + +from epinterface.sbem.components.envelope import ( + ConstructionAssemblyComponent, + ConstructionLayerComponent, +) +from epinterface.sbem.flat_constructions.materials import ( + CEMENT_MORTAR, + CERAMIC_TILE, + CONCRETE_MC_LIGHT, + CONCRETE_RC_DENSE, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + GYPSUM_PLASTER, + MATERIALS_BY_NAME, + SIP_CORE, + SOFTWOOD_GENERAL, + URETHANE_CARPET, + XPS_BOARD, +) + +SlabStructuralSystem = Literal[ + "none", + "slab_on_grade", + "thickened_edge_slab", + "reinforced_concrete_suspended", + "precast_hollow_core", + "mass_timber_deck", + "sip_floor", +] + +SlabInteriorFinish = Literal[ + "none", + "polished_concrete", + "tile", + "carpet", + "wood_floor", +] +SlabExteriorFinish = Literal["none", "gypsum_board", "plaster"] + +ALL_SLAB_STRUCTURAL_SYSTEMS = get_args(SlabStructuralSystem) +ALL_SLAB_INTERIOR_FINISHES = get_args(SlabInteriorFinish) +ALL_SLAB_EXTERIOR_FINISHES = get_args(SlabExteriorFinish) + + +@dataclass(frozen=True) +class StructuralTemplate: + """Default structural slab assumptions for a structural system.""" + + material_name: str + thickness_m: float + supports_under_insulation: bool + supports_cavity_insulation: bool + cavity_depth_m: float | None + + +@dataclass(frozen=True) +class FinishTemplate: + """Default slab finish material and thickness assumptions.""" + + material_name: str + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[SlabStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name=CONCRETE_MC_LIGHT.Name, + thickness_m=0.05, + supports_under_insulation=False, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "slab_on_grade": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.15, + supports_under_insulation=True, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "thickened_edge_slab": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.20, + supports_under_insulation=True, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "reinforced_concrete_suspended": StructuralTemplate( + material_name=CONCRETE_RC_DENSE.Name, + thickness_m=0.18, + supports_under_insulation=False, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "precast_hollow_core": StructuralTemplate( + material_name=CONCRETE_MC_LIGHT.Name, + thickness_m=0.20, + supports_under_insulation=False, + supports_cavity_insulation=True, + cavity_depth_m=0.08, + ), + "mass_timber_deck": StructuralTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.18, + supports_under_insulation=False, + supports_cavity_insulation=True, + cavity_depth_m=0.12, + ), + "sip_floor": StructuralTemplate( + material_name=SIP_CORE.Name, + thickness_m=0.18, + supports_under_insulation=False, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), +} + +INTERIOR_FINISH_TEMPLATES: dict[SlabInteriorFinish, FinishTemplate | None] = { + "none": None, + "polished_concrete": FinishTemplate( + material_name=CEMENT_MORTAR.Name, + thickness_m=0.015, + ), + "tile": FinishTemplate( + material_name=CERAMIC_TILE.Name, + thickness_m=0.015, + ), + "carpet": FinishTemplate( + material_name=URETHANE_CARPET.Name, + thickness_m=0.012, + ), + "wood_floor": FinishTemplate( + material_name=SOFTWOOD_GENERAL.Name, + thickness_m=0.015, + ), +} + +EXTERIOR_FINISH_TEMPLATES: dict[SlabExteriorFinish, FinishTemplate | None] = { + "none": None, + "gypsum_board": FinishTemplate( + material_name=GYPSUM_BOARD.Name, + thickness_m=0.0127, + ), + "plaster": FinishTemplate( + material_name=GYPSUM_PLASTER.Name, + thickness_m=0.013, + ), +} + + +class SemiFlatSlabConstruction(BaseModel): + """Semantic slab representation for fixed-length flat model vectors.""" + + structural_system: SlabStructuralSystem = Field( + default="slab_on_grade", + title="Slab structural system for mass assumptions", + ) + nominal_under_slab_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal under-slab insulation R-value [m²K/W]", + ) + nominal_above_slab_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal above-slab insulation R-value [m²K/W]", + ) + nominal_cavity_insulation_r: float = Field( + default=0.0, + ge=0, + title="Nominal slab cavity insulation R-value [m²K/W]", + ) + interior_finish: SlabInteriorFinish = Field( + default="tile", + title="Interior slab finish selection", + ) + exterior_finish: SlabExteriorFinish = Field( + default="none", + title="Exterior slab finish selection", + ) + + @property + def effective_nominal_under_slab_insulation_r(self) -> float: + """Return under-slab insulation R-value after compatibility defaults.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if not template.supports_under_insulation: + return 0.0 + return self.nominal_under_slab_insulation_r + + @property + def effective_nominal_cavity_insulation_r(self) -> float: + """Return cavity insulation R-value after compatibility defaults.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if not template.supports_cavity_insulation: + return 0.0 + return self.nominal_cavity_insulation_r + + @property + def ignored_feature_names(self) -> tuple[str, ...]: + """Return feature names that are semantic no-ops for this slab.""" + ignored: list[str] = [] + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_under_insulation + and self.nominal_under_slab_insulation_r > 0 + ): + ignored.append("nominal_under_slab_insulation_r") + if ( + not template.supports_cavity_insulation + and self.nominal_cavity_insulation_r > 0 + ): + ignored.append("nominal_cavity_insulation_r") + return tuple(ignored) + + @model_validator(mode="after") + def validate_cavity_r_against_assumed_depth(self): + """Guard impossible cavity R-values for cavity-compatible systems.""" + template = STRUCTURAL_TEMPLATES[self.structural_system] + if ( + not template.supports_cavity_insulation + or template.cavity_depth_m is None + or self.nominal_cavity_insulation_r == 0 + ): + return self + + assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity + max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity + tolerance_r = 0.2 + if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: + msg = ( + f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " + f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " + f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." + ) + raise ValueError(msg) + return self + + def to_feature_dict(self, prefix: str = "Slab") -> dict[str, float]: + """Return a fixed-length numeric feature dictionary for ML workflows.""" + features: dict[str, float] = { + f"{prefix}NominalUnderSlabInsulationRValue": ( + self.nominal_under_slab_insulation_r + ), + f"{prefix}NominalAboveSlabInsulationRValue": ( + self.nominal_above_slab_insulation_r + ), + f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, + f"{prefix}EffectiveNominalUnderSlabInsulationRValue": ( + self.effective_nominal_under_slab_insulation_r + ), + f"{prefix}EffectiveNominalCavityInsulationRValue": ( + self.effective_nominal_cavity_insulation_r + ), + } + for structural_system in ALL_SLAB_STRUCTURAL_SYSTEMS: + features[f"{prefix}StructuralSystem__{structural_system}"] = float( + self.structural_system == structural_system + ) + for interior_finish in ALL_SLAB_INTERIOR_FINISHES: + features[f"{prefix}InteriorFinish__{interior_finish}"] = float( + self.interior_finish == interior_finish + ) + for exterior_finish in ALL_SLAB_EXTERIOR_FINISHES: + features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( + self.exterior_finish == exterior_finish + ) + return features + + +def _make_layer( + *, + material_name: str, + thickness_m: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a construction layer from a registered material.""" + return ConstructionLayerComponent( + ConstructionMaterial=MATERIALS_BY_NAME[material_name], + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + +def _nominal_r_insulation_layer( + *, + material_name: str, + nominal_r_value: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a layer by back-solving thickness from nominal R-value.""" + material = MATERIALS_BY_NAME[material_name] + thickness_m = nominal_r_value * material.Conductivity + return _make_layer( + material_name=material_name, + thickness_m=thickness_m, + layer_order=layer_order, + ) + + +def build_slab_assembly( + slab: SemiFlatSlabConstruction, + *, + name: str = "GroundSlabAssembly", +) -> ConstructionAssemblyComponent: + """Translate semi-flat slab inputs into a concrete slab assembly.""" + template = STRUCTURAL_TEMPLATES[slab.structural_system] + layers: list[ConstructionLayerComponent] = [] + layer_order = 0 + + interior_finish = INTERIOR_FINISH_TEMPLATES[slab.interior_finish] + if interior_finish is not None: + layers.append( + _make_layer( + material_name=interior_finish.material_name, + thickness_m=interior_finish.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if slab.nominal_above_slab_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=XPS_BOARD.Name, + nominal_r_value=slab.nominal_above_slab_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + layers.append( + _make_layer( + material_name=template.material_name, + thickness_m=template.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if slab.effective_nominal_cavity_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=slab.effective_nominal_cavity_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if slab.effective_nominal_under_slab_insulation_r > 0: + layers.append( + _nominal_r_insulation_layer( + material_name=XPS_BOARD.Name, + nominal_r_value=slab.effective_nominal_under_slab_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[slab.exterior_finish] + if exterior_finish is not None: + layers.append( + _make_layer( + material_name=exterior_finish.material_name, + thickness_m=exterior_finish.thickness_m, + layer_order=layer_order, + ) + ) + + return ConstructionAssemblyComponent( + Name=name, + Type="GroundSlab", + Layers=layers, + ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index b236fc8..94ef7d3 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -18,6 +18,7 @@ FIBERGLASS_BATTS, GYPSUM_BOARD, GYPSUM_PLASTER, + MATERIALS_BY_NAME, RAMMED_EARTH, SIP_CORE, SOFTWOOD_GENERAL, @@ -218,25 +219,6 @@ class FinishTemplate: ), } -MATERIAL_LOOKUP = { - mat.Name: mat - for mat in ( - XPS_BOARD, - FIBERGLASS_BATTS, - GYPSUM_BOARD, - GYPSUM_PLASTER, - SOFTWOOD_GENERAL, - CLAY_BRICK, - CONCRETE_BLOCK_H, - CONCRETE_RC_DENSE, - CEMENT_MORTAR, - STEEL_PANEL, - RAMMED_EARTH, - SIP_CORE, - FIBER_CEMENT_BOARD, - ) -} - class SemiFlatWallConstruction(BaseModel): """Semantic wall representation for fixed-length flat model vectors.""" @@ -346,7 +328,7 @@ def _make_layer( ) -> ConstructionLayerComponent: """Create a construction layer from a registered material.""" return ConstructionLayerComponent( - ConstructionMaterial=MATERIAL_LOOKUP[material_name], + ConstructionMaterial=MATERIALS_BY_NAME[material_name], Thickness=thickness_m, LayerOrder=layer_order, ) @@ -359,7 +341,7 @@ def _nominal_r_insulation_layer( layer_order: int, ) -> ConstructionLayerComponent: """Create a layer by back-solving thickness from nominal R-value.""" - material = MATERIAL_LOOKUP[material_name] + material = MATERIALS_BY_NAME[material_name] thickness_m = nominal_r_value * material.Conductivity return _make_layer( material_name=material_name, diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index 6e9eef7..cbb09a6 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -44,12 +44,32 @@ ) from epinterface.sbem.components.zones import ZoneComponent from epinterface.sbem.flat_constructions import ( + RoofExteriorFinish as RoofExteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + RoofInteriorFinish as RoofInteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + RoofStructuralSystem as RoofStructuralSystemType, +) +from epinterface.sbem.flat_constructions import ( + SemiFlatRoofConstruction, + SemiFlatSlabConstruction, SemiFlatWallConstruction, WallExteriorFinish, WallInteriorFinish, WallStructuralSystem, build_envelope_assemblies, ) +from epinterface.sbem.flat_constructions import ( + SlabExteriorFinish as SlabExteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + SlabInteriorFinish as SlabInteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + SlabStructuralSystem as SlabStructuralSystemType, +) from epinterface.weather import WeatherUrl @@ -768,8 +788,20 @@ class FlatModel(BaseModel): FacadeInteriorInsulationRValue: float = Field(default=0, ge=0) FacadeInteriorFinish: WallInteriorFinish = "drywall" FacadeExteriorFinish: WallExteriorFinish = "none" - RoofRValue: float - SlabRValue: float + + RoofStructuralSystem: RoofStructuralSystemType = "poured_concrete" + RoofCavityInsulationRValue: float = Field(default=0, ge=0) + RoofExteriorInsulationRValue: float = Field(default=2.5, ge=0) + RoofInteriorInsulationRValue: float = Field(default=0, ge=0) + RoofInteriorFinish: RoofInteriorFinishType = "gypsum_board" + RoofExteriorFinish: RoofExteriorFinishType = "epdm_membrane" + + SlabStructuralSystem: SlabStructuralSystemType = "slab_on_grade" + SlabUnderInsulationRValue: float = Field(default=1.5, ge=0) + SlabAboveInsulationRValue: float = Field(default=0, ge=0) + SlabCavityInsulationRValue: float = Field(default=0, ge=0) + SlabInteriorFinish: SlabInteriorFinishType = "tile" + SlabExteriorFinish: SlabExteriorFinishType = "none" WWR: float F2FHeight: float @@ -792,6 +824,30 @@ def facade_wall(self) -> SemiFlatWallConstruction: exterior_finish=self.FacadeExteriorFinish, ) + @property + def roof_construction(self) -> SemiFlatRoofConstruction: + """Return the semantic roof specification.""" + return SemiFlatRoofConstruction( + structural_system=self.RoofStructuralSystem, + nominal_cavity_insulation_r=self.RoofCavityInsulationRValue, + nominal_exterior_insulation_r=self.RoofExteriorInsulationRValue, + nominal_interior_insulation_r=self.RoofInteriorInsulationRValue, + interior_finish=self.RoofInteriorFinish, + exterior_finish=self.RoofExteriorFinish, + ) + + @property + def slab_construction(self) -> SemiFlatSlabConstruction: + """Return the semantic slab specification.""" + return SemiFlatSlabConstruction( + structural_system=self.SlabStructuralSystem, + nominal_under_slab_insulation_r=self.SlabUnderInsulationRValue, + nominal_above_slab_insulation_r=self.SlabAboveInsulationRValue, + nominal_cavity_insulation_r=self.SlabCavityInsulationRValue, + interior_finish=self.SlabInteriorFinish, + exterior_finish=self.SlabExteriorFinish, + ) + def to_zone(self) -> ZoneComponent: """Convert the flat model to a full zone.""" # occ_regular_workday = DayComponent( @@ -1746,8 +1802,8 @@ def to_zone(self) -> ZoneComponent: assemblies = build_envelope_assemblies( facade_wall=self.facade_wall, - roof_r_value=self.RoofRValue, - slab_r_value=self.SlabRValue, + roof=self.roof_construction, + slab=self.slab_construction, ) basement_infiltration = infiltration.model_copy(deep=True) @@ -1838,8 +1894,18 @@ def simulate( FacadeInteriorInsulationRValue=0.0, FacadeInteriorFinish="drywall", FacadeExteriorFinish="brick_veneer", - RoofRValue=3.0, - SlabRValue=3.0, + RoofStructuralSystem="poured_concrete", + RoofCavityInsulationRValue=0.0, + RoofExteriorInsulationRValue=2.5, + RoofInteriorInsulationRValue=0.2, + RoofInteriorFinish="gypsum_board", + RoofExteriorFinish="epdm_membrane", + SlabStructuralSystem="slab_on_grade", + SlabUnderInsulationRValue=2.2, + SlabAboveInsulationRValue=0.0, + SlabCavityInsulationRValue=0.0, + SlabInteriorFinish="tile", + SlabExteriorFinish="none", WindowUValue=3.0, WindowSHGF=0.7, WindowTVis=0.5, diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py new file mode 100644 index 0000000..0771aaf --- /dev/null +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -0,0 +1,202 @@ +"""Tests for semi-flat roof and slab construction translation.""" + +import pytest + +from epinterface.sbem.flat_constructions import build_envelope_assemblies +from epinterface.sbem.flat_constructions.roofs import ( + ALL_ROOF_EXTERIOR_FINISHES, + ALL_ROOF_INTERIOR_FINISHES, + ALL_ROOF_STRUCTURAL_SYSTEMS, + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.slabs import ( + ALL_SLAB_EXTERIOR_FINISHES, + ALL_SLAB_INTERIOR_FINISHES, + ALL_SLAB_STRUCTURAL_SYSTEMS, + SemiFlatSlabConstruction, + build_slab_assembly, +) +from epinterface.sbem.flat_constructions.walls import SemiFlatWallConstruction + + +def test_build_roof_assembly_from_nominal_r_values() -> None: + """Roof assembly should reflect nominal roof insulation inputs.""" + roof = SemiFlatRoofConstruction( + structural_system="poured_concrete", + nominal_cavity_insulation_r=0.0, + nominal_exterior_insulation_r=2.0, + nominal_interior_insulation_r=0.3, + interior_finish="gypsum_board", + exterior_finish="epdm_membrane", + ) + assembly = build_roof_assembly(roof) + + # R_total = membrane + ext_ins + concrete + int_ins + gypsum + expected_r = (0.005 / 0.16) + 2.0 + (0.18 / 1.75) + 0.3 + (0.0127 / 0.16) + assert assembly.Type == "FlatRoof" + assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) + + +def test_roof_validator_rejects_unrealistic_cavity_r_for_depth() -> None: + """Roof cavity insulation R should be limited by assumed cavity depth.""" + with pytest.raises( + ValueError, + match="cavity-depth-compatible limit", + ): + SemiFlatRoofConstruction( + structural_system="deep_wood_truss", + nominal_cavity_insulation_r=6.0, + ) + + +def test_non_cavity_roof_treats_cavity_r_as_dead_feature() -> None: + """Roof cavity R should become a no-op for non-cavity systems.""" + roof = SemiFlatRoofConstruction( + structural_system="reinforced_concrete", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_roof_assembly(roof) + layer_material_names = [ + layer.ConstructionMaterial.Name for layer in assembly.sorted_layers + ] + + assert roof.effective_nominal_cavity_insulation_r == 0.0 + assert "nominal_cavity_insulation_r" in roof.ignored_feature_names + assert "FiberglassBatt" not in layer_material_names + + +def test_roof_feature_dict_has_fixed_length() -> None: + """Roof feature dictionary should remain fixed-length across variants.""" + roof = SemiFlatRoofConstruction( + structural_system="steel_joist", + nominal_cavity_insulation_r=1.5, + nominal_exterior_insulation_r=0.5, + nominal_interior_insulation_r=0.2, + interior_finish="acoustic_tile", + exterior_finish="cool_membrane", + ) + features = roof.to_feature_dict(prefix="Roof") + + expected_length = ( + 4 + + len(ALL_ROOF_STRUCTURAL_SYSTEMS) + + len(ALL_ROOF_INTERIOR_FINISHES) + + len(ALL_ROOF_EXTERIOR_FINISHES) + ) + assert len(features) == expected_length + assert features["RoofStructuralSystem__steel_joist"] == 1.0 + assert features["RoofInteriorFinish__acoustic_tile"] == 1.0 + assert features["RoofExteriorFinish__cool_membrane"] == 1.0 + + +def test_build_slab_assembly_from_nominal_r_values() -> None: + """Slab assembly should reflect nominal slab insulation inputs.""" + slab = SemiFlatSlabConstruction( + structural_system="slab_on_grade", + nominal_under_slab_insulation_r=1.5, + nominal_above_slab_insulation_r=0.5, + nominal_cavity_insulation_r=0.0, + interior_finish="tile", + exterior_finish="none", + ) + assembly = build_slab_assembly(slab) + + # R_total = interior tile + above insulation + concrete slab + under insulation + expected_r = (0.015 / 0.8) + 0.5 + (0.15 / 1.75) + 1.5 + assert assembly.Type == "GroundSlab" + assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) + + +def test_non_ground_slab_treats_under_slab_r_as_dead_feature() -> None: + """Under-slab insulation should become a no-op for suspended slabs.""" + slab = SemiFlatSlabConstruction( + structural_system="reinforced_concrete_suspended", + nominal_under_slab_insulation_r=2.0, + nominal_above_slab_insulation_r=0.0, + nominal_cavity_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_slab_assembly(slab) + layer_material_names = [ + layer.ConstructionMaterial.Name for layer in assembly.sorted_layers + ] + + assert slab.effective_nominal_under_slab_insulation_r == 0.0 + assert "nominal_under_slab_insulation_r" in slab.ignored_feature_names + assert "XPSBoard" not in layer_material_names + + +def test_slab_validator_rejects_unrealistic_cavity_r_for_depth() -> None: + """Slab cavity insulation R should be limited by assumed cavity depth.""" + with pytest.raises( + ValueError, + match="cavity-depth-compatible limit", + ): + SemiFlatSlabConstruction( + structural_system="mass_timber_deck", + nominal_cavity_insulation_r=3.3, + ) + + +def test_slab_feature_dict_has_fixed_length() -> None: + """Slab feature dictionary should remain fixed-length across variants.""" + slab = SemiFlatSlabConstruction( + structural_system="precast_hollow_core", + nominal_under_slab_insulation_r=0.0, + nominal_above_slab_insulation_r=0.8, + nominal_cavity_insulation_r=1.6, + interior_finish="carpet", + exterior_finish="gypsum_board", + ) + features = slab.to_feature_dict(prefix="Slab") + + expected_length = ( + 5 + + len(ALL_SLAB_STRUCTURAL_SYSTEMS) + + len(ALL_SLAB_INTERIOR_FINISHES) + + len(ALL_SLAB_EXTERIOR_FINISHES) + ) + assert len(features) == expected_length + assert features["SlabStructuralSystem__precast_hollow_core"] == 1.0 + assert features["SlabInteriorFinish__carpet"] == 1.0 + assert features["SlabExteriorFinish__gypsum_board"] == 1.0 + + +def test_build_envelope_assemblies_with_surface_specific_specs() -> None: + """Envelope assemblies should use dedicated wall/roof/slab constructors.""" + envelope_assemblies = build_envelope_assemblies( + facade_wall=SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.5, + nominal_interior_insulation_r=0.0, + interior_finish="drywall", + exterior_finish="fiber_cement", + ), + roof=SemiFlatRoofConstruction( + structural_system="steel_joist", + nominal_cavity_insulation_r=1.5, + nominal_exterior_insulation_r=1.2, + nominal_interior_insulation_r=0.0, + interior_finish="acoustic_tile", + exterior_finish="cool_membrane", + ), + slab=SemiFlatSlabConstruction( + structural_system="slab_on_grade", + nominal_under_slab_insulation_r=1.4, + nominal_above_slab_insulation_r=0.2, + nominal_cavity_insulation_r=0.0, + interior_finish="tile", + exterior_finish="none", + ), + ) + + assert envelope_assemblies.FacadeAssembly.Type == "Facade" + assert envelope_assemblies.FlatRoofAssembly.Type == "FlatRoof" + assert envelope_assemblies.GroundSlabAssembly.Type == "GroundSlab" From 99dfb274e1600f65e70e35ff401b37723186f18c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 05:02:56 +0000 Subject: [PATCH 04/18] Simplify slab insulation inputs and add physical-sanity audit Co-authored-by: Sam Wolk --- benchmarking/benchmark.py | 5 +- .../sbem/flat_constructions/__init__.py | 8 + epinterface/sbem/flat_constructions/audit.py | 224 ++++++++++++++++++ .../sbem/flat_constructions/materials.py | 54 ++--- epinterface/sbem/flat_constructions/roofs.py | 22 +- epinterface/sbem/flat_constructions/slabs.py | 142 ++++------- epinterface/sbem/flat_constructions/walls.py | 36 ++- epinterface/sbem/flat_model.py | 18 +- .../test_physical_sanity_audit.py | 12 + .../test_roofs_slabs.py | 76 +++--- tests/test_flat_constructions/test_walls.py | 16 +- 11 files changed, 431 insertions(+), 182 deletions(-) create mode 100644 epinterface/sbem/flat_constructions/audit.py create mode 100644 tests/test_flat_constructions/test_physical_sanity_audit.py diff --git a/benchmarking/benchmark.py b/benchmarking/benchmark.py index 88a6b2e..1c559a1 100644 --- a/benchmarking/benchmark.py +++ b/benchmarking/benchmark.py @@ -40,9 +40,8 @@ def benchmark() -> None: RoofInteriorFinish="gypsum_board", RoofExteriorFinish="epdm_membrane", SlabStructuralSystem="slab_on_grade", - SlabUnderInsulationRValue=2.2, - SlabAboveInsulationRValue=0.0, - SlabCavityInsulationRValue=0.0, + SlabInsulationRValue=2.2, + SlabInsulationPlacement="auto", SlabInteriorFinish="tile", SlabExteriorFinish="none", WindowUValue=3.0, diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py index 78287ff..c553ddc 100644 --- a/epinterface/sbem/flat_constructions/__init__.py +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -1,6 +1,10 @@ """Semi-flat construction translators for SBEM flat models.""" from epinterface.sbem.flat_constructions.assemblies import build_envelope_assemblies +from epinterface.sbem.flat_constructions.audit import ( + AuditIssue, + run_physical_sanity_audit, +) from epinterface.sbem.flat_constructions.roofs import ( RoofExteriorFinish, RoofInteriorFinish, @@ -11,6 +15,7 @@ from epinterface.sbem.flat_constructions.slabs import ( SemiFlatSlabConstruction, SlabExteriorFinish, + SlabInsulationPlacement, SlabInteriorFinish, SlabStructuralSystem, build_slab_assembly, @@ -24,6 +29,7 @@ ) __all__ = [ + "AuditIssue", "RoofExteriorFinish", "RoofInteriorFinish", "RoofStructuralSystem", @@ -31,6 +37,7 @@ "SemiFlatSlabConstruction", "SemiFlatWallConstruction", "SlabExteriorFinish", + "SlabInsulationPlacement", "SlabInteriorFinish", "SlabStructuralSystem", "WallExteriorFinish", @@ -40,4 +47,5 @@ "build_facade_assembly", "build_roof_assembly", "build_slab_assembly", + "run_physical_sanity_audit", ] diff --git a/epinterface/sbem/flat_constructions/audit.py b/epinterface/sbem/flat_constructions/audit.py new file mode 100644 index 0000000..8e1fcc8 --- /dev/null +++ b/epinterface/sbem/flat_constructions/audit.py @@ -0,0 +1,224 @@ +"""Physical-sanity audit checks for semi-flat constructions.""" + +from dataclasses import dataclass +from typing import Literal + +from epinterface.sbem.flat_constructions.materials import MATERIALS_BY_NAME +from epinterface.sbem.flat_constructions.roofs import ( + STRUCTURAL_TEMPLATES as ROOF_STRUCTURAL_TEMPLATES, +) +from epinterface.sbem.flat_constructions.roofs import ( + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.slabs import ( + STRUCTURAL_TEMPLATES as SLAB_STRUCTURAL_TEMPLATES, +) +from epinterface.sbem.flat_constructions.slabs import ( + SemiFlatSlabConstruction, + build_slab_assembly, +) +from epinterface.sbem.flat_constructions.walls import ( + STRUCTURAL_TEMPLATES as WALL_STRUCTURAL_TEMPLATES, +) +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + build_facade_assembly, +) + +AuditSeverity = Literal["error", "warning"] + + +@dataclass(frozen=True) +class AuditIssue: + """A physical-sanity issue found by the audit.""" + + severity: AuditSeverity + scope: str + message: str + + +_CONDUCTIVITY_RANGES = { + "Insulation": (0.02, 0.08), + "Concrete": (0.4, 2.5), + "Timber": (0.08, 0.25), + "Masonry": (0.3, 1.6), + "Metal": (10.0, 70.0), + "Boards": (0.04, 0.25), + "Other": (0.2, 1.5), + "Plaster": (0.2, 1.0), + "Finishes": (0.04, 1.5), + "Siding": (0.1, 0.8), + "Sealing": (0.1, 0.3), +} + +_DENSITY_RANGES = { + "Insulation": (8, 100), + "Concrete": (800, 2600), + "Timber": (300, 900), + "Masonry": (900, 2400), + "Metal": (6500, 8500), + "Boards": (100, 1200), + "Other": (500, 2600), + "Plaster": (600, 1800), + "Finishes": (80, 2600), + "Siding": (600, 2000), + "Sealing": (700, 1800), +} + + +def audit_materials() -> list[AuditIssue]: + """Audit base material properties for physically sensible ranges.""" + issues: list[AuditIssue] = [] + for name, mat in MATERIALS_BY_NAME.items(): + conductivity_bounds = _CONDUCTIVITY_RANGES.get(mat.Type) + if conductivity_bounds is not None: + low, high = conductivity_bounds + if not (low <= mat.Conductivity <= high): + issues.append( + AuditIssue( + severity="error", + scope=f"material:{name}", + message=( + f"Conductivity {mat.Conductivity} W/mK is outside expected " + f"range [{low}, {high}] for type {mat.Type}." + ), + ) + ) + + density_bounds = _DENSITY_RANGES.get(mat.Type) + if density_bounds is not None: + low, high = density_bounds + if not (low <= mat.Density <= high): + issues.append( + AuditIssue( + severity="error", + scope=f"material:{name}", + message=( + f"Density {mat.Density} kg/m³ is outside expected " + f"range [{low}, {high}] for type {mat.Type}." + ), + ) + ) + + return issues + + +def _check_assembly_bounds( + *, + scope: str, + thickness_m: float, + r_value: float, + min_thickness_m: float, + max_thickness_m: float, + min_r_value: float, + max_r_value: float, +) -> list[AuditIssue]: + issues: list[AuditIssue] = [] + if not (min_thickness_m <= thickness_m <= max_thickness_m): + issues.append( + AuditIssue( + severity="error", + scope=scope, + message=( + f"Total thickness {thickness_m:.3f} m is outside expected range " + f"[{min_thickness_m:.3f}, {max_thickness_m:.3f}] m." + ), + ) + ) + if not (min_r_value <= r_value <= max_r_value): + issues.append( + AuditIssue( + severity="error", + scope=scope, + message=( + f"Total R-value {r_value:.3f} m²K/W is outside expected range " + f"[{min_r_value:.3f}, {max_r_value:.3f}] m²K/W." + ), + ) + ) + return issues + + +def audit_layups() -> list[AuditIssue]: + """Audit default layups for sensible thickness and thermal bounds.""" + issues: list[AuditIssue] = [] + + for structural_system, template in WALL_STRUCTURAL_TEMPLATES.items(): + wall = SemiFlatWallConstruction( + structural_system=structural_system, + nominal_cavity_insulation_r=( + 2.0 if template.supports_cavity_insulation else 0.0 + ), + nominal_exterior_insulation_r=1.0, + nominal_interior_insulation_r=0.2, + interior_finish="drywall", + exterior_finish="fiber_cement", + ) + assembly = build_facade_assembly(wall) + total_thickness = sum(layer.Thickness for layer in assembly.sorted_layers) + issues.extend( + _check_assembly_bounds( + scope=f"wall:{structural_system}", + thickness_m=total_thickness, + r_value=assembly.r_value, + min_thickness_m=0.04, + max_thickness_m=0.80, + min_r_value=0.20, + max_r_value=12.0, + ) + ) + + for structural_system, template in ROOF_STRUCTURAL_TEMPLATES.items(): + roof = SemiFlatRoofConstruction( + structural_system=structural_system, + nominal_cavity_insulation_r=( + 2.5 if template.supports_cavity_insulation else 0.0 + ), + nominal_exterior_insulation_r=2.0, + nominal_interior_insulation_r=0.2, + interior_finish="gypsum_board", + exterior_finish="epdm_membrane", + ) + assembly = build_roof_assembly(roof) + total_thickness = sum(layer.Thickness for layer in assembly.sorted_layers) + issues.extend( + _check_assembly_bounds( + scope=f"roof:{structural_system}", + thickness_m=total_thickness, + r_value=assembly.r_value, + min_thickness_m=0.04, + max_thickness_m=1.00, + min_r_value=0.20, + max_r_value=14.0, + ) + ) + + for structural_system in SLAB_STRUCTURAL_TEMPLATES: + slab = SemiFlatSlabConstruction( + structural_system=structural_system, + nominal_insulation_r=1.5, + insulation_placement="auto", + interior_finish="tile", + exterior_finish="none", + ) + assembly = build_slab_assembly(slab) + total_thickness = sum(layer.Thickness for layer in assembly.sorted_layers) + issues.extend( + _check_assembly_bounds( + scope=f"slab:{structural_system}", + thickness_m=total_thickness, + r_value=assembly.r_value, + min_thickness_m=0.04, + max_thickness_m=1.00, + min_r_value=0.15, + max_r_value=12.0, + ) + ) + + return issues + + +def run_physical_sanity_audit() -> list[AuditIssue]: + """Run all physical-sanity checks for semi-flat constructions.""" + return [*audit_materials(), *audit_layups()] diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index 3aa5aba..65270db 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -44,17 +44,17 @@ def _material( CONCRETE_MC_LIGHT = _material( name="ConcreteMC_Light", - conductivity=1.65, - density=2100, - specific_heat=1040, + conductivity=1.20, + density=1700, + specific_heat=900, mat_type="Concrete", ) CONCRETE_RC_DENSE = _material( name="ConcreteRC_Dense", - conductivity=1.75, - density=2400, - specific_heat=840, + conductivity=1.95, + density=2300, + specific_heat=900, mat_type="Concrete", ) @@ -76,24 +76,24 @@ def _material( SOFTWOOD_GENERAL = _material( name="SoftwoodGeneral", - conductivity=0.13, - density=496, + conductivity=0.12, + density=500, specific_heat=1630, mat_type="Timber", ) CLAY_BRICK = _material( name="ClayBrick", - conductivity=0.41, - density=1000, - specific_heat=920, + conductivity=0.69, + density=1700, + specific_heat=840, mat_type="Masonry", ) CONCRETE_BLOCK_H = _material( name="ConcreteBlockH", - conductivity=1.25, - density=880, + conductivity=0.51, + density=1100, specific_heat=840, mat_type="Concrete", ) @@ -108,24 +108,24 @@ def _material( CEMENT_MORTAR = _material( name="CementMortar", - conductivity=0.8, - density=1900, + conductivity=0.72, + density=1850, specific_heat=840, mat_type="Other", ) CERAMIC_TILE = _material( name="CeramicTile", - conductivity=0.8, - density=2243, + conductivity=1.05, + density=2000, specific_heat=840, mat_type="Finishes", ) URETHANE_CARPET = _material( name="UrethaneCarpet", - conductivity=0.045, - density=110, + conductivity=0.06, + density=160, specific_heat=840, mat_type="Finishes", ) @@ -140,8 +140,8 @@ def _material( RAMMED_EARTH = _material( name="RammedEarth", - conductivity=1.30, - density=2000, + conductivity=1.10, + density=1900, specific_heat=1000, mat_type="Masonry", ) @@ -156,15 +156,15 @@ def _material( FIBER_CEMENT_BOARD = _material( name="FiberCementBoard", - conductivity=0.30, - density=1300, + conductivity=0.35, + density=1350, specific_heat=840, mat_type="Siding", ) ROOF_MEMBRANE = _material( name="RoofMembrane", - conductivity=0.16, + conductivity=0.17, density=1200, specific_heat=900, mat_type="Sealing", @@ -172,7 +172,7 @@ def _material( COOL_ROOF_MEMBRANE = _material( name="CoolRoofMembrane", - conductivity=0.16, + conductivity=0.17, density=1200, specific_heat=900, mat_type="Sealing", @@ -180,8 +180,8 @@ def _material( ACOUSTIC_TILE = _material( name="AcousticTile", - conductivity=0.06, - density=300, + conductivity=0.065, + density=280, specific_heat=840, mat_type="Boards", ) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 2a22251..5c96a5f 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -66,6 +66,7 @@ class StructuralTemplate: thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None + cavity_r_correction_factor: float = 1.0 @dataclass(frozen=True) @@ -85,25 +86,28 @@ class FinishTemplate: ), "light_wood_truss": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.140, + thickness_m=0.040, supports_cavity_insulation=True, cavity_depth_m=0.140, + cavity_r_correction_factor=0.82, ), "deep_wood_truss": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.240, + thickness_m=0.060, supports_cavity_insulation=True, cavity_depth_m=0.240, + cavity_r_correction_factor=0.82, ), "steel_joist": StructuralTemplate( material_name=STEEL_PANEL.Name, - thickness_m=0.004, + thickness_m=0.006, supports_cavity_insulation=True, cavity_depth_m=0.180, + cavity_r_correction_factor=0.62, ), "metal_deck": StructuralTemplate( material_name=STEEL_PANEL.Name, - thickness_m=0.008, + thickness_m=0.0015, supports_cavity_insulation=False, cavity_depth_m=None, ), @@ -115,7 +119,7 @@ class FinishTemplate: ), "precast_concrete": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, - thickness_m=0.200, + thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), @@ -127,7 +131,7 @@ class FinishTemplate: ), "reinforced_concrete": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, - thickness_m=0.220, + thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), @@ -350,10 +354,14 @@ def build_roof_assembly( layer_order += 1 if roof.effective_nominal_cavity_insulation_r > 0: + effective_cavity_r = ( + roof.effective_nominal_cavity_insulation_r + * template.cavity_r_correction_factor + ) layers.append( _nominal_r_insulation_layer( material_name=FIBERGLASS_BATTS.Name, - nominal_r_value=roof.effective_nominal_cavity_insulation_r, + nominal_r_value=effective_cavity_r, layer_order=layer_order, ) ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index d30daee..16a292c 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Literal, get_args -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field from epinterface.sbem.components.envelope import ( ConstructionAssemblyComponent, @@ -14,7 +14,6 @@ CERAMIC_TILE, CONCRETE_MC_LIGHT, CONCRETE_RC_DENSE, - FIBERGLASS_BATTS, GYPSUM_BOARD, GYPSUM_PLASTER, MATERIALS_BY_NAME, @@ -33,6 +32,7 @@ "mass_timber_deck", "sip_floor", ] +SlabInsulationPlacement = Literal["auto", "under_slab", "above_slab"] SlabInteriorFinish = Literal[ "none", @@ -44,6 +44,7 @@ SlabExteriorFinish = Literal["none", "gypsum_board", "plaster"] ALL_SLAB_STRUCTURAL_SYSTEMS = get_args(SlabStructuralSystem) +ALL_SLAB_INSULATION_PLACEMENTS = get_args(SlabInsulationPlacement) ALL_SLAB_INTERIOR_FINISHES = get_args(SlabInteriorFinish) ALL_SLAB_EXTERIOR_FINISHES = get_args(SlabExteriorFinish) @@ -55,8 +56,6 @@ class StructuralTemplate: material_name: str thickness_m: float supports_under_insulation: bool - supports_cavity_insulation: bool - cavity_depth_m: float | None @dataclass(frozen=True) @@ -72,50 +71,36 @@ class FinishTemplate: material_name=CONCRETE_MC_LIGHT.Name, thickness_m=0.05, supports_under_insulation=False, - supports_cavity_insulation=False, - cavity_depth_m=None, ), "slab_on_grade": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, thickness_m=0.15, supports_under_insulation=True, - supports_cavity_insulation=False, - cavity_depth_m=None, ), "thickened_edge_slab": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, thickness_m=0.20, supports_under_insulation=True, - supports_cavity_insulation=False, - cavity_depth_m=None, ), "reinforced_concrete_suspended": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, thickness_m=0.18, supports_under_insulation=False, - supports_cavity_insulation=False, - cavity_depth_m=None, ), "precast_hollow_core": StructuralTemplate( material_name=CONCRETE_MC_LIGHT.Name, thickness_m=0.20, supports_under_insulation=False, - supports_cavity_insulation=True, - cavity_depth_m=0.08, ), "mass_timber_deck": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, thickness_m=0.18, supports_under_insulation=False, - supports_cavity_insulation=True, - cavity_depth_m=0.12, ), "sip_floor": StructuralTemplate( material_name=SIP_CORE.Name, thickness_m=0.18, supports_under_insulation=False, - supports_cavity_insulation=False, - cavity_depth_m=None, ), } @@ -159,20 +144,14 @@ class SemiFlatSlabConstruction(BaseModel): default="slab_on_grade", title="Slab structural system for mass assumptions", ) - nominal_under_slab_insulation_r: float = Field( + nominal_insulation_r: float = Field( default=0.0, ge=0, - title="Nominal under-slab insulation R-value [m²K/W]", + title="Nominal slab insulation R-value [m²K/W]", ) - nominal_above_slab_insulation_r: float = Field( - default=0.0, - ge=0, - title="Nominal above-slab insulation R-value [m²K/W]", - ) - nominal_cavity_insulation_r: float = Field( - default=0.0, - ge=0, - title="Nominal slab cavity insulation R-value [m²K/W]", + insulation_placement: SlabInsulationPlacement = Field( + default="auto", + title="Slab insulation placement", ) interior_finish: SlabInteriorFinish = Field( default="tile", @@ -184,20 +163,25 @@ class SemiFlatSlabConstruction(BaseModel): ) @property - def effective_nominal_under_slab_insulation_r(self) -> float: - """Return under-slab insulation R-value after compatibility defaults.""" + def effective_insulation_placement(self) -> SlabInsulationPlacement: + """Return insulation placement after applying compatibility defaults.""" + if self.insulation_placement != "auto": + return self.insulation_placement template = STRUCTURAL_TEMPLATES[self.structural_system] - if not template.supports_under_insulation: - return 0.0 - return self.nominal_under_slab_insulation_r + return "under_slab" if template.supports_under_insulation else "above_slab" @property - def effective_nominal_cavity_insulation_r(self) -> float: - """Return cavity insulation R-value after compatibility defaults.""" + def effective_nominal_insulation_r(self) -> float: + """Return insulation R-value after applying compatibility defaults.""" + if self.nominal_insulation_r == 0: + return 0.0 template = STRUCTURAL_TEMPLATES[self.structural_system] - if not template.supports_cavity_insulation: + if ( + self.effective_insulation_placement == "under_slab" + and not template.supports_under_insulation + ): return 0.0 - return self.nominal_cavity_insulation_r + return self.nominal_insulation_r @property def ignored_feature_names(self) -> tuple[str, ...]: @@ -205,61 +189,33 @@ def ignored_feature_names(self) -> tuple[str, ...]: ignored: list[str] = [] template = STRUCTURAL_TEMPLATES[self.structural_system] if ( - not template.supports_under_insulation - and self.nominal_under_slab_insulation_r > 0 - ): - ignored.append("nominal_under_slab_insulation_r") - if ( - not template.supports_cavity_insulation - and self.nominal_cavity_insulation_r > 0 + self.insulation_placement == "under_slab" + and not template.supports_under_insulation + and self.nominal_insulation_r > 0 ): - ignored.append("nominal_cavity_insulation_r") + ignored.append("nominal_insulation_r") + ignored.append("insulation_placement") return tuple(ignored) - @model_validator(mode="after") - def validate_cavity_r_against_assumed_depth(self): - """Guard impossible cavity R-values for cavity-compatible systems.""" - template = STRUCTURAL_TEMPLATES[self.structural_system] - if ( - not template.supports_cavity_insulation - or template.cavity_depth_m is None - or self.nominal_cavity_insulation_r == 0 - ): - return self - - assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity - max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity - tolerance_r = 0.2 - if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: - msg = ( - f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " - f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " - f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." - ) - raise ValueError(msg) - return self - def to_feature_dict(self, prefix: str = "Slab") -> dict[str, float]: """Return a fixed-length numeric feature dictionary for ML workflows.""" features: dict[str, float] = { - f"{prefix}NominalUnderSlabInsulationRValue": ( - self.nominal_under_slab_insulation_r - ), - f"{prefix}NominalAboveSlabInsulationRValue": ( - self.nominal_above_slab_insulation_r - ), - f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, - f"{prefix}EffectiveNominalUnderSlabInsulationRValue": ( - self.effective_nominal_under_slab_insulation_r - ), - f"{prefix}EffectiveNominalCavityInsulationRValue": ( - self.effective_nominal_cavity_insulation_r + f"{prefix}NominalInsulationRValue": self.nominal_insulation_r, + f"{prefix}EffectiveNominalInsulationRValue": ( + self.effective_nominal_insulation_r ), } for structural_system in ALL_SLAB_STRUCTURAL_SYSTEMS: features[f"{prefix}StructuralSystem__{structural_system}"] = float( self.structural_system == structural_system ) + for placement in ALL_SLAB_INSULATION_PLACEMENTS: + features[f"{prefix}InsulationPlacement__{placement}"] = float( + self.insulation_placement == placement + ) + features[f"{prefix}EffectiveInsulationPlacement__{placement}"] = float( + self.effective_insulation_placement == placement + ) for interior_finish in ALL_SLAB_INTERIOR_FINISHES: features[f"{prefix}InteriorFinish__{interior_finish}"] = float( self.interior_finish == interior_finish @@ -322,11 +278,14 @@ def build_slab_assembly( ) layer_order += 1 - if slab.nominal_above_slab_insulation_r > 0: + if ( + slab.effective_insulation_placement == "above_slab" + and slab.effective_nominal_insulation_r > 0 + ): layers.append( _nominal_r_insulation_layer( material_name=XPS_BOARD.Name, - nominal_r_value=slab.nominal_above_slab_insulation_r, + nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) ) @@ -341,21 +300,14 @@ def build_slab_assembly( ) layer_order += 1 - if slab.effective_nominal_cavity_insulation_r > 0: - layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, - nominal_r_value=slab.effective_nominal_cavity_insulation_r, - layer_order=layer_order, - ) - ) - layer_order += 1 - - if slab.effective_nominal_under_slab_insulation_r > 0: + if ( + slab.effective_insulation_placement == "under_slab" + and slab.effective_nominal_insulation_r > 0 + ): layers.append( _nominal_r_insulation_layer( material_name=XPS_BOARD.Name, - nominal_r_value=slab.effective_nominal_under_slab_insulation_r, + nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 94ef7d3..8578008 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -68,6 +68,7 @@ class StructuralTemplate: thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None + cavity_r_correction_factor: float = 1.0 @dataclass(frozen=True) @@ -93,9 +94,10 @@ class FinishTemplate: ), "light_gauge_steel": StructuralTemplate( material_name=STEEL_PANEL.Name, - thickness_m=0.002, + thickness_m=0.004, supports_cavity_insulation=True, cavity_depth_m=0.090, + cavity_r_correction_factor=0.55, ), "structural_steel": StructuralTemplate( material_name=STEEL_PANEL.Name, @@ -105,27 +107,31 @@ class FinishTemplate: ), "woodframe": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.090, + thickness_m=0.030, supports_cavity_insulation=True, cavity_depth_m=0.090, + cavity_r_correction_factor=0.78, ), "deep_woodframe": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.140, + thickness_m=0.045, supports_cavity_insulation=True, cavity_depth_m=0.140, + cavity_r_correction_factor=0.78, ), "woodframe_24oc": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.090, + thickness_m=0.024, supports_cavity_insulation=True, cavity_depth_m=0.090, + cavity_r_correction_factor=0.84, ), "deep_woodframe_24oc": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.140, + thickness_m=0.036, supports_cavity_insulation=True, cavity_depth_m=0.140, + cavity_r_correction_factor=0.84, ), "engineered_timber": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, @@ -135,31 +141,33 @@ class FinishTemplate: ), "cmu": StructuralTemplate( material_name=CONCRETE_BLOCK_H.Name, - thickness_m=0.200, + thickness_m=0.190, supports_cavity_insulation=True, cavity_depth_m=0.090, + cavity_r_correction_factor=0.90, ), "double_layer_cmu": StructuralTemplate( material_name=CONCRETE_BLOCK_H.Name, - thickness_m=0.300, + thickness_m=0.290, supports_cavity_insulation=True, cavity_depth_m=0.140, + cavity_r_correction_factor=0.92, ), "precast_concrete": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, - thickness_m=0.200, + thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "poured_concrete": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, - thickness_m=0.200, + thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "masonry": StructuralTemplate( material_name=CLAY_BRICK.Name, - thickness_m=0.200, + thickness_m=0.190, supports_cavity_insulation=False, cavity_depth_m=None, ), @@ -171,7 +179,7 @@ class FinishTemplate: ), "reinforced_concrete": StructuralTemplate( material_name=CONCRETE_RC_DENSE.Name, - thickness_m=0.250, + thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), @@ -391,10 +399,14 @@ def build_facade_assembly( layer_order += 1 if wall.effective_nominal_cavity_insulation_r > 0: + effective_cavity_r = ( + wall.effective_nominal_cavity_insulation_r + * template.cavity_r_correction_factor + ) layers.append( _nominal_r_insulation_layer( material_name=FIBERGLASS_BATTS.Name, - nominal_r_value=wall.effective_nominal_cavity_insulation_r, + nominal_r_value=effective_cavity_r, layer_order=layer_order, ) ) diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index cbb09a6..36a0f0f 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -64,6 +64,9 @@ from epinterface.sbem.flat_constructions import ( SlabExteriorFinish as SlabExteriorFinishType, ) +from epinterface.sbem.flat_constructions import ( + SlabInsulationPlacement as SlabInsulationPlacementType, +) from epinterface.sbem.flat_constructions import ( SlabInteriorFinish as SlabInteriorFinishType, ) @@ -797,9 +800,8 @@ class FlatModel(BaseModel): RoofExteriorFinish: RoofExteriorFinishType = "epdm_membrane" SlabStructuralSystem: SlabStructuralSystemType = "slab_on_grade" - SlabUnderInsulationRValue: float = Field(default=1.5, ge=0) - SlabAboveInsulationRValue: float = Field(default=0, ge=0) - SlabCavityInsulationRValue: float = Field(default=0, ge=0) + SlabInsulationRValue: float = Field(default=1.5, ge=0) + SlabInsulationPlacement: SlabInsulationPlacementType = "auto" SlabInteriorFinish: SlabInteriorFinishType = "tile" SlabExteriorFinish: SlabExteriorFinishType = "none" @@ -841,9 +843,8 @@ def slab_construction(self) -> SemiFlatSlabConstruction: """Return the semantic slab specification.""" return SemiFlatSlabConstruction( structural_system=self.SlabStructuralSystem, - nominal_under_slab_insulation_r=self.SlabUnderInsulationRValue, - nominal_above_slab_insulation_r=self.SlabAboveInsulationRValue, - nominal_cavity_insulation_r=self.SlabCavityInsulationRValue, + nominal_insulation_r=self.SlabInsulationRValue, + insulation_placement=self.SlabInsulationPlacement, interior_finish=self.SlabInteriorFinish, exterior_finish=self.SlabExteriorFinish, ) @@ -1901,9 +1902,8 @@ def simulate( RoofInteriorFinish="gypsum_board", RoofExteriorFinish="epdm_membrane", SlabStructuralSystem="slab_on_grade", - SlabUnderInsulationRValue=2.2, - SlabAboveInsulationRValue=0.0, - SlabCavityInsulationRValue=0.0, + SlabInsulationRValue=2.2, + SlabInsulationPlacement="auto", SlabInteriorFinish="tile", SlabExteriorFinish="none", WindowUValue=3.0, diff --git a/tests/test_flat_constructions/test_physical_sanity_audit.py b/tests/test_flat_constructions/test_physical_sanity_audit.py new file mode 100644 index 0000000..0354c42 --- /dev/null +++ b/tests/test_flat_constructions/test_physical_sanity_audit.py @@ -0,0 +1,12 @@ +"""Physical-sanity audit tests for semi-flat construction defaults.""" + +from epinterface.sbem.flat_constructions.audit import run_physical_sanity_audit + + +def test_physical_sanity_audit_has_no_errors() -> None: + """Material properties and default layups should stay in plausible ranges.""" + issues = run_physical_sanity_audit() + errors = [issue for issue in issues if issue.severity == "error"] + assert not errors, "\n".join([ + f"[{issue.scope}] {issue.message}" for issue in errors + ]) diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 0771aaf..3e78633 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -3,6 +3,12 @@ import pytest from epinterface.sbem.flat_constructions import build_envelope_assemblies +from epinterface.sbem.flat_constructions.materials import ( + CERAMIC_TILE, + CONCRETE_RC_DENSE, + GYPSUM_BOARD, + ROOF_MEMBRANE, +) from epinterface.sbem.flat_constructions.roofs import ( ALL_ROOF_EXTERIOR_FINISHES, ALL_ROOF_INTERIOR_FINISHES, @@ -10,13 +16,20 @@ SemiFlatRoofConstruction, build_roof_assembly, ) +from epinterface.sbem.flat_constructions.roofs import ( + STRUCTURAL_TEMPLATES as ROOF_STRUCTURAL_TEMPLATES, +) from epinterface.sbem.flat_constructions.slabs import ( ALL_SLAB_EXTERIOR_FINISHES, + ALL_SLAB_INSULATION_PLACEMENTS, ALL_SLAB_INTERIOR_FINISHES, ALL_SLAB_STRUCTURAL_SYSTEMS, SemiFlatSlabConstruction, build_slab_assembly, ) +from epinterface.sbem.flat_constructions.slabs import ( + STRUCTURAL_TEMPLATES as SLAB_STRUCTURAL_TEMPLATES, +) from epinterface.sbem.flat_constructions.walls import SemiFlatWallConstruction @@ -33,7 +46,14 @@ def test_build_roof_assembly_from_nominal_r_values() -> None: assembly = build_roof_assembly(roof) # R_total = membrane + ext_ins + concrete + int_ins + gypsum - expected_r = (0.005 / 0.16) + 2.0 + (0.18 / 1.75) + 0.3 + (0.0127 / 0.16) + poured_template = ROOF_STRUCTURAL_TEMPLATES["poured_concrete"] + expected_r = ( + (0.005 / ROOF_MEMBRANE.Conductivity) + + 2.0 + + (poured_template.thickness_m / CONCRETE_RC_DENSE.Conductivity) + + 0.3 + + (0.0127 / GYPSUM_BOARD.Conductivity) + ) assert assembly.Type == "FlatRoof" assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) @@ -98,16 +118,20 @@ def test_build_slab_assembly_from_nominal_r_values() -> None: """Slab assembly should reflect nominal slab insulation inputs.""" slab = SemiFlatSlabConstruction( structural_system="slab_on_grade", - nominal_under_slab_insulation_r=1.5, - nominal_above_slab_insulation_r=0.5, - nominal_cavity_insulation_r=0.0, + nominal_insulation_r=1.5, + insulation_placement="auto", interior_finish="tile", exterior_finish="none", ) assembly = build_slab_assembly(slab) - # R_total = interior tile + above insulation + concrete slab + under insulation - expected_r = (0.015 / 0.8) + 0.5 + (0.15 / 1.75) + 1.5 + slab_template = SLAB_STRUCTURAL_TEMPLATES["slab_on_grade"] + # R_total = interior tile + concrete slab + under-slab insulation + expected_r = ( + (0.015 / CERAMIC_TILE.Conductivity) + + (slab_template.thickness_m / CONCRETE_RC_DENSE.Conductivity) + + 1.5 + ) assert assembly.Type == "GroundSlab" assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) @@ -116,9 +140,8 @@ def test_non_ground_slab_treats_under_slab_r_as_dead_feature() -> None: """Under-slab insulation should become a no-op for suspended slabs.""" slab = SemiFlatSlabConstruction( structural_system="reinforced_concrete_suspended", - nominal_under_slab_insulation_r=2.0, - nominal_above_slab_insulation_r=0.0, - nominal_cavity_insulation_r=0.0, + nominal_insulation_r=2.0, + insulation_placement="under_slab", interior_finish="none", exterior_finish="none", ) @@ -127,38 +150,36 @@ def test_non_ground_slab_treats_under_slab_r_as_dead_feature() -> None: layer.ConstructionMaterial.Name for layer in assembly.sorted_layers ] - assert slab.effective_nominal_under_slab_insulation_r == 0.0 - assert "nominal_under_slab_insulation_r" in slab.ignored_feature_names + assert slab.effective_nominal_insulation_r == 0.0 + assert "nominal_insulation_r" in slab.ignored_feature_names assert "XPSBoard" not in layer_material_names -def test_slab_validator_rejects_unrealistic_cavity_r_for_depth() -> None: - """Slab cavity insulation R should be limited by assumed cavity depth.""" - with pytest.raises( - ValueError, - match="cavity-depth-compatible limit", - ): - SemiFlatSlabConstruction( - structural_system="mass_timber_deck", - nominal_cavity_insulation_r=3.3, - ) +def test_slab_auto_placement_uses_under_slab_for_ground_supported_system() -> None: + """Auto placement should use under-slab insulation when support exists.""" + slab = SemiFlatSlabConstruction( + structural_system="slab_on_grade", + nominal_insulation_r=1.0, + insulation_placement="auto", + ) + assert slab.effective_insulation_placement == "under_slab" def test_slab_feature_dict_has_fixed_length() -> None: """Slab feature dictionary should remain fixed-length across variants.""" slab = SemiFlatSlabConstruction( structural_system="precast_hollow_core", - nominal_under_slab_insulation_r=0.0, - nominal_above_slab_insulation_r=0.8, - nominal_cavity_insulation_r=1.6, + nominal_insulation_r=0.8, + insulation_placement="above_slab", interior_finish="carpet", exterior_finish="gypsum_board", ) features = slab.to_feature_dict(prefix="Slab") expected_length = ( - 5 + 2 + len(ALL_SLAB_STRUCTURAL_SYSTEMS) + + len(ALL_SLAB_INSULATION_PLACEMENTS) * 2 + len(ALL_SLAB_INTERIOR_FINISHES) + len(ALL_SLAB_EXTERIOR_FINISHES) ) @@ -189,9 +210,8 @@ def test_build_envelope_assemblies_with_surface_specific_specs() -> None: ), slab=SemiFlatSlabConstruction( structural_system="slab_on_grade", - nominal_under_slab_insulation_r=1.4, - nominal_above_slab_insulation_r=0.2, - nominal_cavity_insulation_r=0.0, + nominal_insulation_r=1.4, + insulation_placement="auto", interior_finish="tile", exterior_finish="none", ), diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index 68c55fb..4a4bfea 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -2,10 +2,16 @@ import pytest +from epinterface.sbem.flat_constructions.materials import ( + CEMENT_MORTAR, + CONCRETE_BLOCK_H, + GYPSUM_BOARD, +) from epinterface.sbem.flat_constructions.walls import ( ALL_WALL_EXTERIOR_FINISHES, ALL_WALL_INTERIOR_FINISHES, ALL_WALL_STRUCTURAL_SYSTEMS, + STRUCTURAL_TEMPLATES, SemiFlatWallConstruction, build_facade_assembly, ) @@ -25,7 +31,15 @@ def test_build_facade_assembly_from_nominal_r_values() -> None: assembly = build_facade_assembly(wall) # R_total = stucco + ext_ins + cmu + cavity + int_ins + drywall - expected_r = 0.025 + 2.0 + (0.2 / 1.25) + 1.0 + 0.5 + (0.0127 / 0.16) + cmu_template = STRUCTURAL_TEMPLATES["cmu"] + expected_r = ( + 0.020 / CEMENT_MORTAR.Conductivity + + 2.0 + + (cmu_template.thickness_m / CONCRETE_BLOCK_H.Conductivity) + + (1.0 * cmu_template.cavity_r_correction_factor) + + 0.5 + + (0.0127 / GYPSUM_BOARD.Conductivity) + ) assert assembly.Type == "Facade" assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) From a79c54d7336b4fc96bfb667b63a85e2aef1afb86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 05:08:25 +0000 Subject: [PATCH 05/18] Use consolidated framed cavity layers for wood systems Co-authored-by: Sam Wolk --- epinterface/sbem/flat_constructions/audit.py | 36 +++++ epinterface/sbem/flat_constructions/roofs.py | 130 +++++++++++++--- epinterface/sbem/flat_constructions/walls.py | 146 +++++++++++++++--- .../test_roofs_slabs.py | 29 ++++ tests/test_flat_constructions/test_walls.py | 29 ++++ 5 files changed, 330 insertions(+), 40 deletions(-) diff --git a/epinterface/sbem/flat_constructions/audit.py b/epinterface/sbem/flat_constructions/audit.py index 8e1fcc8..5d63ab8 100644 --- a/epinterface/sbem/flat_constructions/audit.py +++ b/epinterface/sbem/flat_constructions/audit.py @@ -168,6 +168,24 @@ def audit_layups() -> list[AuditIssue]: max_r_value=12.0, ) ) + if template.framing_fraction is not None: + has_consolidated_cavity = any( + layer.ConstructionMaterial.Name.startswith( + f"ConsolidatedCavity_{structural_system}" + ) + for layer in assembly.sorted_layers + ) + if not has_consolidated_cavity: + issues.append( + AuditIssue( + severity="error", + scope=f"wall:{structural_system}", + message=( + "Expected consolidated framed cavity layer for a framed wall " + "system but did not find one." + ), + ) + ) for structural_system, template in ROOF_STRUCTURAL_TEMPLATES.items(): roof = SemiFlatRoofConstruction( @@ -193,6 +211,24 @@ def audit_layups() -> list[AuditIssue]: max_r_value=14.0, ) ) + if template.framing_fraction is not None: + has_consolidated_cavity = any( + layer.ConstructionMaterial.Name.startswith( + f"ConsolidatedCavity_{structural_system}" + ) + for layer in assembly.sorted_layers + ) + if not has_consolidated_cavity: + issues.append( + AuditIssue( + severity="error", + scope=f"roof:{structural_system}", + message=( + "Expected consolidated framed cavity layer for a framed roof " + "system but did not find one." + ), + ) + ) for structural_system in SLAB_STRUCTURAL_TEMPLATES: slab = SemiFlatSlabConstruction( diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 5c96a5f..6ccf636 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -9,6 +9,7 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.materials import ( ACOUSTIC_TILE, CEMENT_MORTAR, @@ -66,6 +67,9 @@ class StructuralTemplate: thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None + framing_material_name: str | None = None + framing_fraction: float | None = None + uninsulated_cavity_r_value: float = 0.17 cavity_r_correction_factor: float = 1.0 @@ -86,17 +90,19 @@ class FinishTemplate: ), "light_wood_truss": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.040, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - cavity_r_correction_factor=0.82, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.14, ), "deep_wood_truss": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.060, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.240, - cavity_r_correction_factor=0.82, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.12, ), "steel_joist": StructuralTemplate( material_name=STEEL_PANEL.Name, @@ -297,6 +303,20 @@ def _make_layer( ) +def _make_layer_from_material( + *, + material: ConstructionMaterialComponent, + thickness_m: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a construction layer from a material component.""" + return ConstructionLayerComponent( + ConstructionMaterial=material, + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + def _nominal_r_insulation_layer( *, material_name: str, @@ -313,6 +333,53 @@ def _nominal_r_insulation_layer( ) +def _make_consolidated_cavity_material( + *, + structural_system: RoofStructuralSystem, + cavity_depth_m: float, + framing_material_name: str, + framing_fraction: float, + nominal_cavity_insulation_r: float, + uninsulated_cavity_r_value: float, +) -> ConstructionMaterialComponent: + """Create an equivalent material for a framed roof cavity layer.""" + framing_material = MATERIALS_BY_NAME[framing_material_name] + fill_r = ( + nominal_cavity_insulation_r + if nominal_cavity_insulation_r > 0 + else uninsulated_cavity_r_value + ) + framing_r = cavity_depth_m / framing_material.Conductivity + u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r + r_eq = 1 / u_eq + conductivity_eq = cavity_depth_m / r_eq + + density_eq = ( + framing_fraction * framing_material.Density + + (1 - framing_fraction) * FIBERGLASS_BATTS.Density + ) + specific_heat_eq = ( + framing_fraction * framing_material.SpecificHeat + + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat + ) + + return ConstructionMaterialComponent( + Name=( + f"ConsolidatedCavity_{structural_system}_" + f"Rfill{fill_r:.3f}_f{framing_fraction:.3f}" + ), + Conductivity=conductivity_eq, + Density=density_eq, + SpecificHeat=specific_heat_eq, + ThermalAbsorptance=0.9, + SolarAbsorptance=0.6, + VisibleAbsorptance=0.6, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="MediumRough", + Type="Other", + ) + + def build_roof_assembly( roof: SemiFlatRoofConstruction, *, @@ -344,28 +411,55 @@ def build_roof_assembly( ) layer_order += 1 - layers.append( - _make_layer( - material_name=template.material_name, - thickness_m=template.thickness_m, - layer_order=layer_order, - ) + uses_framed_cavity_consolidation = ( + template.supports_cavity_insulation + and template.cavity_depth_m is not None + and template.framing_material_name is not None + and template.framing_fraction is not None ) - layer_order += 1 - if roof.effective_nominal_cavity_insulation_r > 0: - effective_cavity_r = ( - roof.effective_nominal_cavity_insulation_r - * template.cavity_r_correction_factor + if uses_framed_cavity_consolidation: + consolidated_cavity_material = _make_consolidated_cavity_material( + structural_system=roof.structural_system, + cavity_depth_m=template.cavity_depth_m or 0.0, + framing_material_name=template.framing_material_name + or SOFTWOOD_GENERAL.Name, + framing_fraction=template.framing_fraction or 0.0, + nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, + uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, - nominal_r_value=effective_cavity_r, + _make_layer_from_material( + material=consolidated_cavity_material, + thickness_m=template.cavity_depth_m or 0.0, layer_order=layer_order, ) ) layer_order += 1 + else: + if template.thickness_m > 0: + layers.append( + _make_layer( + material_name=template.material_name, + thickness_m=template.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if roof.effective_nominal_cavity_insulation_r > 0: + effective_cavity_r = ( + roof.effective_nominal_cavity_insulation_r + * template.cavity_r_correction_factor + ) + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=effective_cavity_r, + layer_order=layer_order, + ) + ) + layer_order += 1 if roof.nominal_interior_insulation_r > 0: layers.append( diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 8578008..3d7160e 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -9,6 +9,7 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CLAY_BRICK, @@ -68,6 +69,9 @@ class StructuralTemplate: thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None + framing_material_name: str | None = None + framing_fraction: float | None = None + uninsulated_cavity_r_value: float = 0.17 cavity_r_correction_factor: float = 1.0 @@ -107,31 +111,35 @@ class FinishTemplate: ), "woodframe": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.030, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - cavity_r_correction_factor=0.78, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.23, ), "deep_woodframe": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.045, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - cavity_r_correction_factor=0.78, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.23, ), "woodframe_24oc": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.024, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - cavity_r_correction_factor=0.84, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.17, ), "deep_woodframe_24oc": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, - thickness_m=0.036, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - cavity_r_correction_factor=0.84, + framing_material_name=SOFTWOOD_GENERAL.Name, + framing_fraction=0.17, ), "engineered_timber": StructuralTemplate( material_name=SOFTWOOD_GENERAL.Name, @@ -342,6 +350,20 @@ def _make_layer( ) +def _make_layer_from_material( + *, + material: ConstructionMaterialComponent, + thickness_m: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a construction layer from a material component.""" + return ConstructionLayerComponent( + ConstructionMaterial=material, + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + def _nominal_r_insulation_layer( *, material_name: str, @@ -358,6 +380,59 @@ def _nominal_r_insulation_layer( ) +def _make_consolidated_cavity_material( + *, + structural_system: WallStructuralSystem, + cavity_depth_m: float, + framing_material_name: str, + framing_fraction: float, + nominal_cavity_insulation_r: float, + uninsulated_cavity_r_value: float, +) -> ConstructionMaterialComponent: + """Create an equivalent material for a framed cavity layer. + + Uses a simple parallel-path estimate: + U_eq = f_frame / R_frame + (1-f_frame) / R_fill + where R_fill is the user-provided nominal cavity insulation R-value + (or a default uninsulated cavity R-value when nominal is 0). + """ + framing_material = MATERIALS_BY_NAME[framing_material_name] + fill_r = ( + nominal_cavity_insulation_r + if nominal_cavity_insulation_r > 0 + else uninsulated_cavity_r_value + ) + framing_r = cavity_depth_m / framing_material.Conductivity + u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r + r_eq = 1 / u_eq + conductivity_eq = cavity_depth_m / r_eq + + density_eq = ( + framing_fraction * framing_material.Density + + (1 - framing_fraction) * FIBERGLASS_BATTS.Density + ) + specific_heat_eq = ( + framing_fraction * framing_material.SpecificHeat + + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat + ) + + return ConstructionMaterialComponent( + Name=( + f"ConsolidatedCavity_{structural_system}_" + f"Rfill{fill_r:.3f}_f{framing_fraction:.3f}" + ), + Conductivity=conductivity_eq, + Density=density_eq, + SpecificHeat=specific_heat_eq, + ThermalAbsorptance=0.9, + SolarAbsorptance=0.6, + VisibleAbsorptance=0.6, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="MediumRough", + Type="Other", + ) + + def build_facade_assembly( wall: SemiFlatWallConstruction, *, @@ -389,28 +464,55 @@ def build_facade_assembly( ) layer_order += 1 - layers.append( - _make_layer( - material_name=template.material_name, - thickness_m=template.thickness_m, - layer_order=layer_order, - ) + uses_framed_cavity_consolidation = ( + template.supports_cavity_insulation + and template.cavity_depth_m is not None + and template.framing_material_name is not None + and template.framing_fraction is not None ) - layer_order += 1 - if wall.effective_nominal_cavity_insulation_r > 0: - effective_cavity_r = ( - wall.effective_nominal_cavity_insulation_r - * template.cavity_r_correction_factor + if uses_framed_cavity_consolidation: + consolidated_cavity_material = _make_consolidated_cavity_material( + structural_system=wall.structural_system, + cavity_depth_m=template.cavity_depth_m or 0.0, + framing_material_name=template.framing_material_name + or SOFTWOOD_GENERAL.Name, + framing_fraction=template.framing_fraction or 0.0, + nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, + uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, - nominal_r_value=effective_cavity_r, + _make_layer_from_material( + material=consolidated_cavity_material, + thickness_m=template.cavity_depth_m or 0.0, layer_order=layer_order, ) ) layer_order += 1 + else: + if template.thickness_m > 0: + layers.append( + _make_layer( + material_name=template.material_name, + thickness_m=template.thickness_m, + layer_order=layer_order, + ) + ) + layer_order += 1 + + if wall.effective_nominal_cavity_insulation_r > 0: + effective_cavity_r = ( + wall.effective_nominal_cavity_insulation_r + * template.cavity_r_correction_factor + ) + layers.append( + _nominal_r_insulation_layer( + material_name=FIBERGLASS_BATTS.Name, + nominal_r_value=effective_cavity_r, + layer_order=layer_order, + ) + ) + layer_order += 1 if wall.nominal_interior_insulation_r > 0: layers.append( diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 3e78633..98e01f2 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -8,6 +8,7 @@ CONCRETE_RC_DENSE, GYPSUM_BOARD, ROOF_MEMBRANE, + SOFTWOOD_GENERAL, ) from epinterface.sbem.flat_constructions.roofs import ( ALL_ROOF_EXTERIOR_FINISHES, @@ -114,6 +115,34 @@ def test_roof_feature_dict_has_fixed_length() -> None: assert features["RoofExteriorFinish__cool_membrane"] == 1.0 +def test_light_wood_truss_uses_consolidated_cavity_layer() -> None: + """Wood truss roofs should use a consolidated parallel-path cavity layer.""" + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + nominal_cavity_insulation_r=3.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_roof_assembly(roof) + truss_template = ROOF_STRUCTURAL_TEMPLATES["light_wood_truss"] + cavity_layer = assembly.sorted_layers[0] + + assert cavity_layer.ConstructionMaterial.Name.startswith( + "ConsolidatedCavity_light_wood_truss" + ) + assert truss_template.cavity_depth_m is not None + assert truss_template.framing_fraction is not None + + framing_r = truss_template.cavity_depth_m / SOFTWOOD_GENERAL.Conductivity + r_eq_expected = 1 / ( + truss_template.framing_fraction / framing_r + + (1 - truss_template.framing_fraction) / 3.0 + ) + assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) + + def test_build_slab_assembly_from_nominal_r_values() -> None: """Slab assembly should reflect nominal slab insulation inputs.""" slab = SemiFlatSlabConstruction( diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index 4a4bfea..b0a64cc 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -6,6 +6,7 @@ CEMENT_MORTAR, CONCRETE_BLOCK_H, GYPSUM_BOARD, + SOFTWOOD_GENERAL, ) from epinterface.sbem.flat_constructions.walls import ( ALL_WALL_EXTERIOR_FINISHES, @@ -72,6 +73,34 @@ def test_non_cavity_structural_system_treats_cavity_r_as_dead_feature() -> None: assert "FiberglassBatt" not in layer_material_names +def test_woodframe_uses_consolidated_cavity_layer() -> None: + """Woodframe cavities should be modeled as parallel-path consolidated layers.""" + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_facade_assembly(wall) + woodframe_template = STRUCTURAL_TEMPLATES["woodframe"] + cavity_layer = assembly.sorted_layers[0] + + assert cavity_layer.ConstructionMaterial.Name.startswith( + "ConsolidatedCavity_woodframe" + ) + assert woodframe_template.cavity_depth_m is not None + assert woodframe_template.framing_fraction is not None + + framing_r = woodframe_template.cavity_depth_m / SOFTWOOD_GENERAL.Conductivity + r_eq_expected = 1 / ( + woodframe_template.framing_fraction / framing_r + + (1 - woodframe_template.framing_fraction) / 2.0 + ) + assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) + + def test_wall_feature_dict_has_fixed_length() -> None: """Feature dictionary should remain fixed-length across wall variants.""" wall = SemiFlatWallConstruction( From 523115649169151f5edfbaa87456aba2aba90a33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 12:04:20 +0000 Subject: [PATCH 06/18] Refine steel framing thermal-bridge assumptions with references Co-authored-by: Sam Wolk --- .../flat_constructions/REFERENCE_NOTES.md | 38 +++++++++++++++++++ epinterface/sbem/flat_constructions/roofs.py | 19 ++++++++-- epinterface/sbem/flat_constructions/walls.py | 19 ++++++++-- .../test_roofs_slabs.py | 27 +++++++++++++ tests/test_flat_constructions/test_walls.py | 27 +++++++++++++ 5 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 epinterface/sbem/flat_constructions/REFERENCE_NOTES.md diff --git a/epinterface/sbem/flat_constructions/REFERENCE_NOTES.md b/epinterface/sbem/flat_constructions/REFERENCE_NOTES.md new file mode 100644 index 0000000..5bcf9c9 --- /dev/null +++ b/epinterface/sbem/flat_constructions/REFERENCE_NOTES.md @@ -0,0 +1,38 @@ +# Semi-flat construction reference notes + +This folder intentionally uses simplified/consolidated thermal methods for ML-friendly +inputs. The assumptions below document where steel/wood framing adjustments come from. + +## Framed cavity modeling + +For framed wall/roof systems we use an equivalent parallel-path layer: + +- `U_eq = f_frame / R_frame + (1 - f_frame) / R_fill` +- `R_eq = 1 / U_eq` + +For wood framing, `R_frame` is computed directly from framing material conductivity and +cavity depth. + +For steel framing, `R_frame` is treated as an **effective framing-path R-value** to +account for 2D/3D heat-flow effects and thermal-bridge behavior not captured by pure +1D steel conductivity. The calibrated values are chosen to reproduce expected effective +batt performance ranges from code-table methods. + +## Primary references + +1. ASHRAE Standard 90.1 (Appendix A, envelope assembly/U-factor methodology for metal framing) + + - https://www.ashrae.org/technical-resources/bookstore/standard-90-1 + +2. COMcheck envelope U-factor workflow and datasets (steel-framed assemblies) + + - https://www.energycodes.gov/comcheck + +3. PNNL Building America Solution Center (thermal bridging in metal-stud walls) + - https://basc.pnnl.gov/resource-guides/continuous-insulation-metal-stud-wall + +## Scope note + +These assumptions are intended for rapid parametric modeling and fixed-length feature +vectors, not project-specific code compliance documentation. For high-fidelity design, +replace defaults with project-calibrated values (e.g., THERM/ISO 10211 workflows). diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 6ccf636..8ac2caf 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -69,6 +69,7 @@ class StructuralTemplate: cavity_depth_m: float | None framing_material_name: str | None = None framing_fraction: float | None = None + framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 cavity_r_correction_factor: float = 1.0 @@ -106,10 +107,16 @@ class FinishTemplate: ), "steel_joist": StructuralTemplate( material_name=STEEL_PANEL.Name, - thickness_m=0.006, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.180, - cavity_r_correction_factor=0.62, + framing_material_name=STEEL_PANEL.Name, + framing_fraction=0.08, + # Calibrated to reproduce ~60-65% effective batt R for steel-joist roofs. + # References: + # - ASHRAE Standard 90.1 Appendix A (metal-framing correction methodology) + # - COMcheck steel-framed roof U-factor datasets (effective-R behavior) + framing_path_r_value=0.35, ), "metal_deck": StructuralTemplate( material_name=STEEL_PANEL.Name, @@ -339,6 +346,7 @@ def _make_consolidated_cavity_material( cavity_depth_m: float, framing_material_name: str, framing_fraction: float, + framing_path_r_value: float | None, nominal_cavity_insulation_r: float, uninsulated_cavity_r_value: float, ) -> ConstructionMaterialComponent: @@ -349,7 +357,11 @@ def _make_consolidated_cavity_material( if nominal_cavity_insulation_r > 0 else uninsulated_cavity_r_value ) - framing_r = cavity_depth_m / framing_material.Conductivity + framing_r = ( + framing_path_r_value + if framing_path_r_value is not None + else cavity_depth_m / framing_material.Conductivity + ) u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r r_eq = 1 / u_eq conductivity_eq = cavity_depth_m / r_eq @@ -425,6 +437,7 @@ def build_roof_assembly( framing_material_name=template.framing_material_name or SOFTWOOD_GENERAL.Name, framing_fraction=template.framing_fraction or 0.0, + framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 3d7160e..f504aa9 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -71,6 +71,7 @@ class StructuralTemplate: cavity_depth_m: float | None framing_material_name: str | None = None framing_fraction: float | None = None + framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 cavity_r_correction_factor: float = 1.0 @@ -98,10 +99,16 @@ class FinishTemplate: ), "light_gauge_steel": StructuralTemplate( material_name=STEEL_PANEL.Name, - thickness_m=0.004, + thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - cavity_r_correction_factor=0.55, + framing_material_name=STEEL_PANEL.Name, + framing_fraction=0.12, + # Calibrated to reproduce ~55% effective batt R for 3.5in steel-stud walls. + # References: + # - ASHRAE Standard 90.1 Appendix A (metal-framing correction methodology) + # - COMcheck steel-framed wall U-factor datasets (effective-R behavior) + framing_path_r_value=0.26, ), "structural_steel": StructuralTemplate( material_name=STEEL_PANEL.Name, @@ -386,6 +393,7 @@ def _make_consolidated_cavity_material( cavity_depth_m: float, framing_material_name: str, framing_fraction: float, + framing_path_r_value: float | None, nominal_cavity_insulation_r: float, uninsulated_cavity_r_value: float, ) -> ConstructionMaterialComponent: @@ -402,7 +410,11 @@ def _make_consolidated_cavity_material( if nominal_cavity_insulation_r > 0 else uninsulated_cavity_r_value ) - framing_r = cavity_depth_m / framing_material.Conductivity + framing_r = ( + framing_path_r_value + if framing_path_r_value is not None + else cavity_depth_m / framing_material.Conductivity + ) u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r r_eq = 1 / u_eq conductivity_eq = cavity_depth_m / r_eq @@ -478,6 +490,7 @@ def build_facade_assembly( framing_material_name=template.framing_material_name or SOFTWOOD_GENERAL.Name, framing_fraction=template.framing_fraction or 0.0, + framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 98e01f2..e1c591d 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -143,6 +143,33 @@ def test_light_wood_truss_uses_consolidated_cavity_layer() -> None: assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) +def test_steel_joist_uses_effective_framing_path() -> None: + """Steel joists should use an effective framing-path R-value model.""" + roof = SemiFlatRoofConstruction( + structural_system="steel_joist", + nominal_cavity_insulation_r=3.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_roof_assembly(roof) + joist_template = ROOF_STRUCTURAL_TEMPLATES["steel_joist"] + cavity_layer = assembly.sorted_layers[0] + + assert cavity_layer.ConstructionMaterial.Name.startswith( + "ConsolidatedCavity_steel_joist" + ) + assert joist_template.framing_fraction is not None + assert joist_template.framing_path_r_value is not None + + r_eq_expected = 1 / ( + joist_template.framing_fraction / joist_template.framing_path_r_value + + (1 - joist_template.framing_fraction) / 3.0 + ) + assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) + + def test_build_slab_assembly_from_nominal_r_values() -> None: """Slab assembly should reflect nominal slab insulation inputs.""" slab = SemiFlatSlabConstruction( diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index b0a64cc..284f118 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -101,6 +101,33 @@ def test_woodframe_uses_consolidated_cavity_layer() -> None: assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) +def test_light_gauge_steel_uses_effective_framing_path() -> None: + """Steel framing should use an effective framing-path R, not solid steel conduction.""" + wall = SemiFlatWallConstruction( + structural_system="light_gauge_steel", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + assembly = build_facade_assembly(wall) + steel_template = STRUCTURAL_TEMPLATES["light_gauge_steel"] + cavity_layer = assembly.sorted_layers[0] + + assert cavity_layer.ConstructionMaterial.Name.startswith( + "ConsolidatedCavity_light_gauge_steel" + ) + assert steel_template.framing_fraction is not None + assert steel_template.framing_path_r_value is not None + + r_eq_expected = 1 / ( + steel_template.framing_fraction / steel_template.framing_path_r_value + + (1 - steel_template.framing_fraction) / 2.0 + ) + assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) + + def test_wall_feature_dict_has_fixed_length() -> None: """Feature dictionary should remain fixed-length across wall variants.""" wall = SemiFlatWallConstruction( From 177da351372a8a346172c366e47ca72253e6866a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 14:16:19 +0000 Subject: [PATCH 07/18] Extract shared layer utilities for flat constructions Co-authored-by: Sam Wolk --- .../sbem/flat_constructions/__init__.py | 12 ++ epinterface/sbem/flat_constructions/layers.py | 114 +++++++++++++ epinterface/sbem/flat_constructions/roofs.py | 152 ++++------------- epinterface/sbem/flat_constructions/slabs.py | 67 +++----- epinterface/sbem/flat_constructions/walls.py | 158 ++++-------------- .../test_layers_utils.py | 36 ++++ 6 files changed, 240 insertions(+), 299 deletions(-) create mode 100644 epinterface/sbem/flat_constructions/layers.py create mode 100644 tests/test_flat_constructions/test_layers_utils.py diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py index c553ddc..8922562 100644 --- a/epinterface/sbem/flat_constructions/__init__.py +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -5,6 +5,13 @@ AuditIssue, run_physical_sanity_audit, ) +from epinterface.sbem.flat_constructions.layers import ( + MaterialName, + MaterialRef, + equivalent_framed_cavity_material, + layer_from_nominal_r, + resolve_material, +) from epinterface.sbem.flat_constructions.roofs import ( RoofExteriorFinish, RoofInteriorFinish, @@ -30,6 +37,8 @@ __all__ = [ "AuditIssue", + "MaterialName", + "MaterialRef", "RoofExteriorFinish", "RoofInteriorFinish", "RoofStructuralSystem", @@ -47,5 +56,8 @@ "build_facade_assembly", "build_roof_assembly", "build_slab_assembly", + "equivalent_framed_cavity_material", + "layer_from_nominal_r", + "resolve_material", "run_physical_sanity_audit", ] diff --git a/epinterface/sbem/flat_constructions/layers.py b/epinterface/sbem/flat_constructions/layers.py new file mode 100644 index 0000000..d5c985d --- /dev/null +++ b/epinterface/sbem/flat_constructions/layers.py @@ -0,0 +1,114 @@ +"""Shared layer and equivalent-material helpers for flat constructions.""" + +from typing import Literal + +from epinterface.sbem.components.envelope import ConstructionLayerComponent +from epinterface.sbem.components.materials import ConstructionMaterialComponent +from epinterface.sbem.flat_constructions.materials import ( + FIBERGLASS_BATTS, + MATERIALS_BY_NAME, +) + +MaterialName = Literal[ + "XPSBoard", + "PolyisoBoard", + "ConcreteMC_Light", + "ConcreteRC_Dense", + "GypsumBoard", + "GypsumPlaster", + "SoftwoodGeneral", + "ClayBrick", + "ConcreteBlockH", + "FiberglassBatt", + "CementMortar", + "CeramicTile", + "UrethaneCarpet", + "SteelPanel", + "RammedEarth", + "SIPCore", + "FiberCementBoard", + "RoofMembrane", + "CoolRoofMembrane", + "AcousticTile", +] +# Keep `str` in the union so callers with broader string types remain ergonomic, +# while still documenting preferred known names via `MaterialName`. +type MaterialRef = ConstructionMaterialComponent | MaterialName | str + + +def resolve_material(material: MaterialRef) -> ConstructionMaterialComponent: + """Resolve a material name or component into a material component.""" + return MATERIALS_BY_NAME[material] if isinstance(material, str) else material + + +def layer_from_nominal_r( + *, + material: MaterialRef, + nominal_r_value: float, + layer_order: int, +) -> ConstructionLayerComponent: + """Create a layer by back-solving thickness from nominal R-value.""" + resolved_material = resolve_material(material) + thickness_m = nominal_r_value * resolved_material.Conductivity + return ConstructionLayerComponent( + ConstructionMaterial=resolved_material, + Thickness=thickness_m, + LayerOrder=layer_order, + ) + + +def equivalent_framed_cavity_material( + *, + structural_system: str, + cavity_depth_m: float, + framing_material: MaterialRef, + framing_fraction: float, + nominal_cavity_insulation_r: float, + uninsulated_cavity_r_value: float, + framing_path_r_value: float | None = None, +) -> ConstructionMaterialComponent: + """Create an equivalent material for a framed cavity layer. + + Uses a parallel-path estimate: + U_eq = f_frame / R_frame + (1-f_frame) / R_fill + where R_fill is nominal cavity insulation R (or an uninsulated fallback). + """ + resolved_framing_material = resolve_material(framing_material) + fill_r = ( + nominal_cavity_insulation_r + if nominal_cavity_insulation_r > 0 + else uninsulated_cavity_r_value + ) + framing_r = ( + framing_path_r_value + if framing_path_r_value is not None + else cavity_depth_m / resolved_framing_material.Conductivity + ) + u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r + r_eq = 1 / u_eq + conductivity_eq = cavity_depth_m / r_eq + + density_eq = ( + framing_fraction * resolved_framing_material.Density + + (1 - framing_fraction) * FIBERGLASS_BATTS.Density + ) + specific_heat_eq = ( + framing_fraction * resolved_framing_material.SpecificHeat + + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat + ) + + return ConstructionMaterialComponent( + Name=( + f"ConsolidatedCavity_{structural_system}_" + f"Rfill{fill_r:.3f}_f{framing_fraction:.3f}" + ), + Conductivity=conductivity_eq, + Density=density_eq, + SpecificHeat=specific_heat_eq, + ThermalAbsorptance=0.9, + SolarAbsorptance=0.6, + VisibleAbsorptance=0.6, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="MediumRough", + Type="Other", + ) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 8ac2caf..197b668 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -9,7 +9,11 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent +from epinterface.sbem.flat_constructions.layers import ( + equivalent_framed_cavity_material, + layer_from_nominal_r, + resolve_material, +) from epinterface.sbem.flat_constructions.materials import ( ACOUSTIC_TILE, CEMENT_MORTAR, @@ -18,7 +22,6 @@ COOL_ROOF_MEMBRANE, FIBERGLASS_BATTS, GYPSUM_BOARD, - MATERIALS_BY_NAME, POLYISO_BOARD, ROOF_MEMBRANE, SIP_CORE, @@ -296,102 +299,6 @@ def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: return features -def _make_layer( - *, - material_name: str, - thickness_m: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a construction layer from a registered material.""" - return ConstructionLayerComponent( - ConstructionMaterial=MATERIALS_BY_NAME[material_name], - Thickness=thickness_m, - LayerOrder=layer_order, - ) - - -def _make_layer_from_material( - *, - material: ConstructionMaterialComponent, - thickness_m: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a construction layer from a material component.""" - return ConstructionLayerComponent( - ConstructionMaterial=material, - Thickness=thickness_m, - LayerOrder=layer_order, - ) - - -def _nominal_r_insulation_layer( - *, - material_name: str, - nominal_r_value: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a layer by back-solving thickness from nominal R-value.""" - material = MATERIALS_BY_NAME[material_name] - thickness_m = nominal_r_value * material.Conductivity - return _make_layer( - material_name=material_name, - thickness_m=thickness_m, - layer_order=layer_order, - ) - - -def _make_consolidated_cavity_material( - *, - structural_system: RoofStructuralSystem, - cavity_depth_m: float, - framing_material_name: str, - framing_fraction: float, - framing_path_r_value: float | None, - nominal_cavity_insulation_r: float, - uninsulated_cavity_r_value: float, -) -> ConstructionMaterialComponent: - """Create an equivalent material for a framed roof cavity layer.""" - framing_material = MATERIALS_BY_NAME[framing_material_name] - fill_r = ( - nominal_cavity_insulation_r - if nominal_cavity_insulation_r > 0 - else uninsulated_cavity_r_value - ) - framing_r = ( - framing_path_r_value - if framing_path_r_value is not None - else cavity_depth_m / framing_material.Conductivity - ) - u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r - r_eq = 1 / u_eq - conductivity_eq = cavity_depth_m / r_eq - - density_eq = ( - framing_fraction * framing_material.Density - + (1 - framing_fraction) * FIBERGLASS_BATTS.Density - ) - specific_heat_eq = ( - framing_fraction * framing_material.SpecificHeat - + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat - ) - - return ConstructionMaterialComponent( - Name=( - f"ConsolidatedCavity_{structural_system}_" - f"Rfill{fill_r:.3f}_f{framing_fraction:.3f}" - ), - Conductivity=conductivity_eq, - Density=density_eq, - SpecificHeat=specific_heat_eq, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Other", - ) - - def build_roof_assembly( roof: SemiFlatRoofConstruction, *, @@ -405,18 +312,18 @@ def build_roof_assembly( exterior_finish = EXTERIOR_FINISH_TEMPLATES[roof.exterior_finish] if exterior_finish is not None: layers.append( - _make_layer( - material_name=exterior_finish.material_name, - thickness_m=exterior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(exterior_finish.material_name), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 if roof.nominal_exterior_insulation_r > 0: layers.append( - _nominal_r_insulation_layer( - material_name=POLYISO_BOARD.Name, + layer_from_nominal_r( + material=POLYISO_BOARD.Name, nominal_r_value=roof.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -431,31 +338,30 @@ def build_roof_assembly( ) if uses_framed_cavity_consolidation: - consolidated_cavity_material = _make_consolidated_cavity_material( + consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=roof.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material_name=template.framing_material_name - or SOFTWOOD_GENERAL.Name, + framing_material=template.framing_material_name or SOFTWOOD_GENERAL.Name, framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) layers.append( - _make_layer_from_material( - material=consolidated_cavity_material, - thickness_m=template.cavity_depth_m or 0.0, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=consolidated_cavity_material, + Thickness=template.cavity_depth_m or 0.0, + LayerOrder=layer_order, ) ) layer_order += 1 else: if template.thickness_m > 0: layers.append( - _make_layer( - material_name=template.material_name, - thickness_m=template.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 @@ -466,8 +372,8 @@ def build_roof_assembly( * template.cavity_r_correction_factor ) layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, + layer_from_nominal_r( + material=FIBERGLASS_BATTS.Name, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -476,8 +382,8 @@ def build_roof_assembly( if roof.nominal_interior_insulation_r > 0: layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, + layer_from_nominal_r( + material=FIBERGLASS_BATTS.Name, nominal_r_value=roof.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -487,10 +393,10 @@ def build_roof_assembly( interior_finish = INTERIOR_FINISH_TEMPLATES[roof.interior_finish] if interior_finish is not None: layers.append( - _make_layer( - material_name=interior_finish.material_name, - thickness_m=interior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=layer_order, ) ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index 16a292c..6d265ae 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -9,6 +9,10 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.flat_constructions.layers import ( + layer_from_nominal_r, + resolve_material, +) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CERAMIC_TILE, @@ -16,7 +20,6 @@ CONCRETE_RC_DENSE, GYPSUM_BOARD, GYPSUM_PLASTER, - MATERIALS_BY_NAME, SIP_CORE, SOFTWOOD_GENERAL, URETHANE_CARPET, @@ -227,36 +230,6 @@ def to_feature_dict(self, prefix: str = "Slab") -> dict[str, float]: return features -def _make_layer( - *, - material_name: str, - thickness_m: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a construction layer from a registered material.""" - return ConstructionLayerComponent( - ConstructionMaterial=MATERIALS_BY_NAME[material_name], - Thickness=thickness_m, - LayerOrder=layer_order, - ) - - -def _nominal_r_insulation_layer( - *, - material_name: str, - nominal_r_value: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a layer by back-solving thickness from nominal R-value.""" - material = MATERIALS_BY_NAME[material_name] - thickness_m = nominal_r_value * material.Conductivity - return _make_layer( - material_name=material_name, - thickness_m=thickness_m, - layer_order=layer_order, - ) - - def build_slab_assembly( slab: SemiFlatSlabConstruction, *, @@ -270,10 +243,10 @@ def build_slab_assembly( interior_finish = INTERIOR_FINISH_TEMPLATES[slab.interior_finish] if interior_finish is not None: layers.append( - _make_layer( - material_name=interior_finish.material_name, - thickness_m=interior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 @@ -283,8 +256,8 @@ def build_slab_assembly( and slab.effective_nominal_insulation_r > 0 ): layers.append( - _nominal_r_insulation_layer( - material_name=XPS_BOARD.Name, + layer_from_nominal_r( + material=XPS_BOARD.Name, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -292,10 +265,10 @@ def build_slab_assembly( layer_order += 1 layers.append( - _make_layer( - material_name=template.material_name, - thickness_m=template.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 @@ -305,8 +278,8 @@ def build_slab_assembly( and slab.effective_nominal_insulation_r > 0 ): layers.append( - _nominal_r_insulation_layer( - material_name=XPS_BOARD.Name, + layer_from_nominal_r( + material=XPS_BOARD.Name, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -316,10 +289,10 @@ def build_slab_assembly( exterior_finish = EXTERIOR_FINISH_TEMPLATES[slab.exterior_finish] if exterior_finish is not None: layers.append( - _make_layer( - material_name=exterior_finish.material_name, - thickness_m=exterior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(exterior_finish.material_name), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, ) ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index f504aa9..c14e805 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -9,7 +9,11 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent +from epinterface.sbem.flat_constructions.layers import ( + equivalent_framed_cavity_material, + layer_from_nominal_r, + resolve_material, +) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CLAY_BRICK, @@ -19,7 +23,6 @@ FIBERGLASS_BATTS, GYPSUM_BOARD, GYPSUM_PLASTER, - MATERIALS_BY_NAME, RAMMED_EARTH, SIP_CORE, SOFTWOOD_GENERAL, @@ -343,108 +346,6 @@ def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: return features -def _make_layer( - *, - material_name: str, - thickness_m: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a construction layer from a registered material.""" - return ConstructionLayerComponent( - ConstructionMaterial=MATERIALS_BY_NAME[material_name], - Thickness=thickness_m, - LayerOrder=layer_order, - ) - - -def _make_layer_from_material( - *, - material: ConstructionMaterialComponent, - thickness_m: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a construction layer from a material component.""" - return ConstructionLayerComponent( - ConstructionMaterial=material, - Thickness=thickness_m, - LayerOrder=layer_order, - ) - - -def _nominal_r_insulation_layer( - *, - material_name: str, - nominal_r_value: float, - layer_order: int, -) -> ConstructionLayerComponent: - """Create a layer by back-solving thickness from nominal R-value.""" - material = MATERIALS_BY_NAME[material_name] - thickness_m = nominal_r_value * material.Conductivity - return _make_layer( - material_name=material_name, - thickness_m=thickness_m, - layer_order=layer_order, - ) - - -def _make_consolidated_cavity_material( - *, - structural_system: WallStructuralSystem, - cavity_depth_m: float, - framing_material_name: str, - framing_fraction: float, - framing_path_r_value: float | None, - nominal_cavity_insulation_r: float, - uninsulated_cavity_r_value: float, -) -> ConstructionMaterialComponent: - """Create an equivalent material for a framed cavity layer. - - Uses a simple parallel-path estimate: - U_eq = f_frame / R_frame + (1-f_frame) / R_fill - where R_fill is the user-provided nominal cavity insulation R-value - (or a default uninsulated cavity R-value when nominal is 0). - """ - framing_material = MATERIALS_BY_NAME[framing_material_name] - fill_r = ( - nominal_cavity_insulation_r - if nominal_cavity_insulation_r > 0 - else uninsulated_cavity_r_value - ) - framing_r = ( - framing_path_r_value - if framing_path_r_value is not None - else cavity_depth_m / framing_material.Conductivity - ) - u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r - r_eq = 1 / u_eq - conductivity_eq = cavity_depth_m / r_eq - - density_eq = ( - framing_fraction * framing_material.Density - + (1 - framing_fraction) * FIBERGLASS_BATTS.Density - ) - specific_heat_eq = ( - framing_fraction * framing_material.SpecificHeat - + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat - ) - - return ConstructionMaterialComponent( - Name=( - f"ConsolidatedCavity_{structural_system}_" - f"Rfill{fill_r:.3f}_f{framing_fraction:.3f}" - ), - Conductivity=conductivity_eq, - Density=density_eq, - SpecificHeat=specific_heat_eq, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, - TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", - Type="Other", - ) - - def build_facade_assembly( wall: SemiFlatWallConstruction, *, @@ -458,18 +359,18 @@ def build_facade_assembly( exterior_finish = EXTERIOR_FINISH_TEMPLATES[wall.exterior_finish] if exterior_finish is not None: layers.append( - _make_layer( - material_name=exterior_finish.material_name, - thickness_m=exterior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(exterior_finish.material_name), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 if wall.nominal_exterior_insulation_r > 0: layers.append( - _nominal_r_insulation_layer( - material_name=XPS_BOARD.Name, + layer_from_nominal_r( + material=XPS_BOARD.Name, nominal_r_value=wall.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -484,31 +385,30 @@ def build_facade_assembly( ) if uses_framed_cavity_consolidation: - consolidated_cavity_material = _make_consolidated_cavity_material( + consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=wall.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material_name=template.framing_material_name - or SOFTWOOD_GENERAL.Name, + framing_material=template.framing_material_name or SOFTWOOD_GENERAL.Name, framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, ) layers.append( - _make_layer_from_material( - material=consolidated_cavity_material, - thickness_m=template.cavity_depth_m or 0.0, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=consolidated_cavity_material, + Thickness=template.cavity_depth_m or 0.0, + LayerOrder=layer_order, ) ) layer_order += 1 else: if template.thickness_m > 0: layers.append( - _make_layer( - material_name=template.material_name, - thickness_m=template.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, ) ) layer_order += 1 @@ -519,8 +419,8 @@ def build_facade_assembly( * template.cavity_r_correction_factor ) layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, + layer_from_nominal_r( + material=FIBERGLASS_BATTS.Name, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -529,8 +429,8 @@ def build_facade_assembly( if wall.nominal_interior_insulation_r > 0: layers.append( - _nominal_r_insulation_layer( - material_name=FIBERGLASS_BATTS.Name, + layer_from_nominal_r( + material=FIBERGLASS_BATTS.Name, nominal_r_value=wall.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -540,10 +440,10 @@ def build_facade_assembly( interior_finish = INTERIOR_FINISH_TEMPLATES[wall.interior_finish] if interior_finish is not None: layers.append( - _make_layer( - material_name=interior_finish.material_name, - thickness_m=interior_finish.thickness_m, - layer_order=layer_order, + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=layer_order, ) ) diff --git a/tests/test_flat_constructions/test_layers_utils.py b/tests/test_flat_constructions/test_layers_utils.py new file mode 100644 index 0000000..1ce6203 --- /dev/null +++ b/tests/test_flat_constructions/test_layers_utils.py @@ -0,0 +1,36 @@ +"""Tests for shared flat-construction layer helpers.""" + +import pytest + +from epinterface.sbem.flat_constructions.layers import ( + layer_from_nominal_r, + resolve_material, +) +from epinterface.sbem.flat_constructions.materials import XPS_BOARD + + +def test_layer_from_nominal_r_accepts_material_name_literal() -> None: + """Nominal-R helper should resolve named materials safely.""" + layer = layer_from_nominal_r( + material="XPSBoard", + nominal_r_value=2.0, + layer_order=1, + ) + assert layer.ConstructionMaterial.Name == "XPSBoard" + assert layer.Thickness == pytest.approx(2.0 * XPS_BOARD.Conductivity) + + +def test_layer_from_nominal_r_accepts_material_component() -> None: + """Nominal-R helper should also accept material components directly.""" + layer = layer_from_nominal_r( + material=XPS_BOARD, + nominal_r_value=1.5, + layer_order=2, + ) + assert layer.ConstructionMaterial is XPS_BOARD + assert layer.Thickness == pytest.approx(1.5 * XPS_BOARD.Conductivity) + + +def test_resolve_material_returns_same_component_for_objects() -> None: + """Material resolver should pass through component inputs unchanged.""" + assert resolve_material(XPS_BOARD) is XPS_BOARD From bb5aa189684e3573b3547bfbd1ada2c19a0d6ca9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 14:33:37 +0000 Subject: [PATCH 08/18] Tighten material typing and centralize material name aliases Co-authored-by: Sam Wolk --- .../sbem/flat_constructions/__init__.py | 6 +- epinterface/sbem/flat_constructions/layers.py | 29 +------ .../sbem/flat_constructions/materials.py | 86 +++++++++++++------ epinterface/sbem/flat_constructions/roofs.py | 64 +++++++------- epinterface/sbem/flat_constructions/slabs.py | 42 ++++----- epinterface/sbem/flat_constructions/walls.py | 80 ++++++++--------- 6 files changed, 162 insertions(+), 145 deletions(-) diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py index 8922562..ddeebe4 100644 --- a/epinterface/sbem/flat_constructions/__init__.py +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -6,12 +6,15 @@ run_physical_sanity_audit, ) from epinterface.sbem.flat_constructions.layers import ( - MaterialName, MaterialRef, equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, ) +from epinterface.sbem.flat_constructions.materials import ( + MATERIAL_NAME_VALUES, + MaterialName, +) from epinterface.sbem.flat_constructions.roofs import ( RoofExteriorFinish, RoofInteriorFinish, @@ -36,6 +39,7 @@ ) __all__ = [ + "MATERIAL_NAME_VALUES", "AuditIssue", "MaterialName", "MaterialRef", diff --git a/epinterface/sbem/flat_constructions/layers.py b/epinterface/sbem/flat_constructions/layers.py index d5c985d..3f1ef47 100644 --- a/epinterface/sbem/flat_constructions/layers.py +++ b/epinterface/sbem/flat_constructions/layers.py @@ -1,39 +1,14 @@ """Shared layer and equivalent-material helpers for flat constructions.""" -from typing import Literal - from epinterface.sbem.components.envelope import ConstructionLayerComponent from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.materials import ( FIBERGLASS_BATTS, MATERIALS_BY_NAME, + MaterialName, ) -MaterialName = Literal[ - "XPSBoard", - "PolyisoBoard", - "ConcreteMC_Light", - "ConcreteRC_Dense", - "GypsumBoard", - "GypsumPlaster", - "SoftwoodGeneral", - "ClayBrick", - "ConcreteBlockH", - "FiberglassBatt", - "CementMortar", - "CeramicTile", - "UrethaneCarpet", - "SteelPanel", - "RammedEarth", - "SIPCore", - "FiberCementBoard", - "RoofMembrane", - "CoolRoofMembrane", - "AcousticTile", -] -# Keep `str` in the union so callers with broader string types remain ergonomic, -# while still documenting preferred known names via `MaterialName`. -type MaterialRef = ConstructionMaterialComponent | MaterialName | str +type MaterialRef = ConstructionMaterialComponent | MaterialName def resolve_material(material: MaterialRef) -> ConstructionMaterialComponent: diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index 65270db..e93f413 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -1,7 +1,34 @@ """Shared opaque materials used by semi-flat construction builders.""" +from typing import Literal, cast, get_args + from epinterface.sbem.components.materials import ConstructionMaterialComponent +MaterialName = Literal[ + "XPSBoard", + "PolyisoBoard", + "ConcreteMC_Light", + "ConcreteRC_Dense", + "GypsumBoard", + "GypsumPlaster", + "SoftwoodGeneral", + "ClayBrick", + "ConcreteBlockH", + "FiberglassBatt", + "CementMortar", + "CeramicTile", + "UrethaneCarpet", + "SteelPanel", + "RammedEarth", + "SIPCore", + "FiberCementBoard", + "RoofMembrane", + "CoolRoofMembrane", + "AcousticTile", +] + +MATERIAL_NAME_VALUES: tuple[MaterialName, ...] = get_args(MaterialName) + def _material( *, @@ -186,28 +213,39 @@ def _material( mat_type="Boards", ) -MATERIALS_BY_NAME = { - mat.Name: mat - for mat in ( - XPS_BOARD, - POLYISO_BOARD, - CONCRETE_MC_LIGHT, - CONCRETE_RC_DENSE, - GYPSUM_BOARD, - GYPSUM_PLASTER, - SOFTWOOD_GENERAL, - CLAY_BRICK, - CONCRETE_BLOCK_H, - FIBERGLASS_BATTS, - CEMENT_MORTAR, - CERAMIC_TILE, - URETHANE_CARPET, - STEEL_PANEL, - RAMMED_EARTH, - SIP_CORE, - FIBER_CEMENT_BOARD, - ROOF_MEMBRANE, - COOL_ROOF_MEMBRANE, - ACOUSTIC_TILE, - ) +_ALL_MATERIALS = ( + XPS_BOARD, + POLYISO_BOARD, + CONCRETE_MC_LIGHT, + CONCRETE_RC_DENSE, + GYPSUM_BOARD, + GYPSUM_PLASTER, + SOFTWOOD_GENERAL, + CLAY_BRICK, + CONCRETE_BLOCK_H, + FIBERGLASS_BATTS, + CEMENT_MORTAR, + CERAMIC_TILE, + URETHANE_CARPET, + STEEL_PANEL, + RAMMED_EARTH, + SIP_CORE, + FIBER_CEMENT_BOARD, + ROOF_MEMBRANE, + COOL_ROOF_MEMBRANE, + ACOUSTIC_TILE, +) + +MATERIALS_BY_NAME: dict[MaterialName, ConstructionMaterialComponent] = { + cast(MaterialName, mat.Name): mat for mat in _ALL_MATERIALS } + +_names_in_map = set(MATERIALS_BY_NAME.keys()) +_expected_names = set(MATERIAL_NAME_VALUES) +if _names_in_map != _expected_names: + missing = sorted(_expected_names - _names_in_map) + extra = sorted(_names_in_map - _expected_names) + msg = ( + f"Material name definitions are out of sync. Missing={missing}, Extra={extra}." + ) + raise ValueError(msg) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 197b668..8afeccb 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -9,10 +9,10 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( equivalent_framed_cavity_material, layer_from_nominal_r, - resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( ACOUSTIC_TILE, @@ -66,11 +66,11 @@ class StructuralTemplate: """Default structural roof assumptions for a structural system.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None - framing_material_name: str | None = None + framing_material_name: ConstructionMaterialComponent | None = None framing_fraction: float | None = None framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 @@ -81,39 +81,39 @@ class StructuralTemplate: class FinishTemplate: """Default roof finish material and thickness assumptions.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float STRUCTURAL_TEMPLATES: dict[RoofStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=GYPSUM_BOARD.Name, + material_name=GYPSUM_BOARD, thickness_m=0.005, supports_cavity_insulation=False, cavity_depth_m=None, ), "light_wood_truss": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.14, ), "deep_wood_truss": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.240, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.12, ), "steel_joist": StructuralTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.180, - framing_material_name=STEEL_PANEL.Name, + framing_material_name=STEEL_PANEL, framing_fraction=0.08, # Calibrated to reproduce ~60-65% effective batt R for steel-joist roofs. # References: @@ -122,37 +122,37 @@ class FinishTemplate: framing_path_r_value=0.35, ), "metal_deck": StructuralTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.0015, supports_cavity_insulation=False, cavity_depth_m=None, ), "mass_timber": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "precast_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "poured_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "reinforced_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), "sip": StructuralTemplate( - material_name=SIP_CORE.Name, + material_name=SIP_CORE, thickness_m=0.160, supports_cavity_insulation=False, cavity_depth_m=None, @@ -162,15 +162,15 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[RoofInteriorFinish, FinishTemplate | None] = { "none": None, "gypsum_board": FinishTemplate( - material_name=GYPSUM_BOARD.Name, + material_name=GYPSUM_BOARD, thickness_m=0.0127, ), "acoustic_tile": FinishTemplate( - material_name=ACOUSTIC_TILE.Name, + material_name=ACOUSTIC_TILE, thickness_m=0.019, ), "wood_panel": FinishTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.012, ), } @@ -178,23 +178,23 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[RoofExteriorFinish, FinishTemplate | None] = { "none": None, "epdm_membrane": FinishTemplate( - material_name=ROOF_MEMBRANE.Name, + material_name=ROOF_MEMBRANE, thickness_m=0.005, ), "cool_membrane": FinishTemplate( - material_name=COOL_ROOF_MEMBRANE.Name, + material_name=COOL_ROOF_MEMBRANE, thickness_m=0.005, ), "built_up_roof": FinishTemplate( - material_name=CEMENT_MORTAR.Name, + material_name=CEMENT_MORTAR, thickness_m=0.02, ), "metal_roof": FinishTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.001, ), "tile_roof": FinishTemplate( - material_name=CERAMIC_TILE.Name, + material_name=CERAMIC_TILE, thickness_m=0.02, ), } @@ -313,7 +313,7 @@ def build_roof_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(exterior_finish.material_name), + ConstructionMaterial=exterior_finish.material_name, Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) @@ -323,7 +323,7 @@ def build_roof_assembly( if roof.nominal_exterior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=POLYISO_BOARD.Name, + material=POLYISO_BOARD, nominal_r_value=roof.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -341,7 +341,7 @@ def build_roof_assembly( consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=roof.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material=template.framing_material_name or SOFTWOOD_GENERAL.Name, + framing_material=template.framing_material_name or SOFTWOOD_GENERAL, framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, @@ -359,7 +359,7 @@ def build_roof_assembly( if template.thickness_m > 0: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(template.material_name), + ConstructionMaterial=template.material_name, Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -373,7 +373,7 @@ def build_roof_assembly( ) layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS.Name, + material=FIBERGLASS_BATTS, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -383,7 +383,7 @@ def build_roof_assembly( if roof.nominal_interior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS.Name, + material=FIBERGLASS_BATTS, nominal_r_value=roof.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -394,7 +394,7 @@ def build_roof_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(interior_finish.material_name), + ConstructionMaterial=interior_finish.material_name, Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index 6d265ae..dd15a2c 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -9,9 +9,9 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( layer_from_nominal_r, - resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, @@ -56,7 +56,7 @@ class StructuralTemplate: """Default structural slab assumptions for a structural system.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float supports_under_insulation: bool @@ -65,43 +65,43 @@ class StructuralTemplate: class FinishTemplate: """Default slab finish material and thickness assumptions.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float STRUCTURAL_TEMPLATES: dict[SlabStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=CONCRETE_MC_LIGHT.Name, + material_name=CONCRETE_MC_LIGHT, thickness_m=0.05, supports_under_insulation=False, ), "slab_on_grade": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.15, supports_under_insulation=True, ), "thickened_edge_slab": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.20, supports_under_insulation=True, ), "reinforced_concrete_suspended": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.18, supports_under_insulation=False, ), "precast_hollow_core": StructuralTemplate( - material_name=CONCRETE_MC_LIGHT.Name, + material_name=CONCRETE_MC_LIGHT, thickness_m=0.20, supports_under_insulation=False, ), "mass_timber_deck": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.18, supports_under_insulation=False, ), "sip_floor": StructuralTemplate( - material_name=SIP_CORE.Name, + material_name=SIP_CORE, thickness_m=0.18, supports_under_insulation=False, ), @@ -110,19 +110,19 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[SlabInteriorFinish, FinishTemplate | None] = { "none": None, "polished_concrete": FinishTemplate( - material_name=CEMENT_MORTAR.Name, + material_name=CEMENT_MORTAR, thickness_m=0.015, ), "tile": FinishTemplate( - material_name=CERAMIC_TILE.Name, + material_name=CERAMIC_TILE, thickness_m=0.015, ), "carpet": FinishTemplate( - material_name=URETHANE_CARPET.Name, + material_name=URETHANE_CARPET, thickness_m=0.012, ), "wood_floor": FinishTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.015, ), } @@ -130,11 +130,11 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[SlabExteriorFinish, FinishTemplate | None] = { "none": None, "gypsum_board": FinishTemplate( - material_name=GYPSUM_BOARD.Name, + material_name=GYPSUM_BOARD, thickness_m=0.0127, ), "plaster": FinishTemplate( - material_name=GYPSUM_PLASTER.Name, + material_name=GYPSUM_PLASTER, thickness_m=0.013, ), } @@ -244,7 +244,7 @@ def build_slab_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(interior_finish.material_name), + ConstructionMaterial=interior_finish.material_name, Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) @@ -257,7 +257,7 @@ def build_slab_assembly( ): layers.append( layer_from_nominal_r( - material=XPS_BOARD.Name, + material=XPS_BOARD, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -266,7 +266,7 @@ def build_slab_assembly( layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(template.material_name), + ConstructionMaterial=template.material_name, Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -279,7 +279,7 @@ def build_slab_assembly( ): layers.append( layer_from_nominal_r( - material=XPS_BOARD.Name, + material=XPS_BOARD, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -290,7 +290,7 @@ def build_slab_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(exterior_finish.material_name), + ConstructionMaterial=exterior_finish.material_name, Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index c14e805..69baca4 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -9,10 +9,10 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) +from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( equivalent_framed_cavity_material, layer_from_nominal_r, - resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, @@ -68,11 +68,11 @@ class StructuralTemplate: """Default structural wall assumptions for a structural system.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None - framing_material_name: str | None = None + framing_material_name: ConstructionMaterialComponent | None = None framing_fraction: float | None = None framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 @@ -83,29 +83,29 @@ class StructuralTemplate: class FinishTemplate: """Default finish material and thickness assumptions.""" - material_name: str + material_name: ConstructionMaterialComponent thickness_m: float STRUCTURAL_TEMPLATES: dict[WallStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=GYPSUM_BOARD.Name, + material_name=GYPSUM_BOARD, thickness_m=0.005, supports_cavity_insulation=False, cavity_depth_m=None, ), "sheet_metal": StructuralTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.001, supports_cavity_insulation=False, cavity_depth_m=None, ), "light_gauge_steel": StructuralTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=STEEL_PANEL.Name, + framing_material_name=STEEL_PANEL, framing_fraction=0.12, # Calibrated to reproduce ~55% effective batt R for 3.5in steel-stud walls. # References: @@ -114,95 +114,95 @@ class FinishTemplate: framing_path_r_value=0.26, ), "structural_steel": StructuralTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.006, supports_cavity_insulation=False, cavity_depth_m=None, ), "woodframe": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.23, ), "deep_woodframe": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.23, ), "woodframe_24oc": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.17, ), "deep_woodframe_24oc": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL.Name, + framing_material_name=SOFTWOOD_GENERAL, framing_fraction=0.17, ), "engineered_timber": StructuralTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.160, supports_cavity_insulation=False, cavity_depth_m=None, ), "cmu": StructuralTemplate( - material_name=CONCRETE_BLOCK_H.Name, + material_name=CONCRETE_BLOCK_H, thickness_m=0.190, supports_cavity_insulation=True, cavity_depth_m=0.090, cavity_r_correction_factor=0.90, ), "double_layer_cmu": StructuralTemplate( - material_name=CONCRETE_BLOCK_H.Name, + material_name=CONCRETE_BLOCK_H, thickness_m=0.290, supports_cavity_insulation=True, cavity_depth_m=0.140, cavity_r_correction_factor=0.92, ), "precast_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "poured_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "masonry": StructuralTemplate( - material_name=CLAY_BRICK.Name, + material_name=CLAY_BRICK, thickness_m=0.190, supports_cavity_insulation=False, cavity_depth_m=None, ), "rammed_earth": StructuralTemplate( - material_name=RAMMED_EARTH.Name, + material_name=RAMMED_EARTH, thickness_m=0.350, supports_cavity_insulation=False, cavity_depth_m=None, ), "reinforced_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE.Name, + material_name=CONCRETE_RC_DENSE, thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), "sip": StructuralTemplate( - material_name=SIP_CORE.Name, + material_name=SIP_CORE, thickness_m=0.150, supports_cavity_insulation=False, cavity_depth_m=None, @@ -212,15 +212,15 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { "none": None, "drywall": FinishTemplate( - material_name=GYPSUM_BOARD.Name, + material_name=GYPSUM_BOARD, thickness_m=0.0127, ), "plaster": FinishTemplate( - material_name=GYPSUM_PLASTER.Name, + material_name=GYPSUM_PLASTER, thickness_m=0.013, ), "wood_panel": FinishTemplate( - material_name=SOFTWOOD_GENERAL.Name, + material_name=SOFTWOOD_GENERAL, thickness_m=0.012, ), } @@ -228,19 +228,19 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[WallExteriorFinish, FinishTemplate | None] = { "none": None, "brick_veneer": FinishTemplate( - material_name=CLAY_BRICK.Name, + material_name=CLAY_BRICK, thickness_m=0.090, ), "stucco": FinishTemplate( - material_name=CEMENT_MORTAR.Name, + material_name=CEMENT_MORTAR, thickness_m=0.020, ), "fiber_cement": FinishTemplate( - material_name=FIBER_CEMENT_BOARD.Name, + material_name=FIBER_CEMENT_BOARD, thickness_m=0.012, ), "metal_panel": FinishTemplate( - material_name=STEEL_PANEL.Name, + material_name=STEEL_PANEL, thickness_m=0.001, ), } @@ -360,7 +360,7 @@ def build_facade_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(exterior_finish.material_name), + ConstructionMaterial=exterior_finish.material_name, Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) @@ -370,7 +370,7 @@ def build_facade_assembly( if wall.nominal_exterior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=XPS_BOARD.Name, + material=XPS_BOARD, nominal_r_value=wall.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -388,7 +388,7 @@ def build_facade_assembly( consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=wall.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material=template.framing_material_name or SOFTWOOD_GENERAL.Name, + framing_material=template.framing_material_name or SOFTWOOD_GENERAL, framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, @@ -406,7 +406,7 @@ def build_facade_assembly( if template.thickness_m > 0: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(template.material_name), + ConstructionMaterial=template.material_name, Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -420,7 +420,7 @@ def build_facade_assembly( ) layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS.Name, + material=FIBERGLASS_BATTS, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -430,7 +430,7 @@ def build_facade_assembly( if wall.nominal_interior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS.Name, + material=FIBERGLASS_BATTS, nominal_r_value=wall.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -441,7 +441,7 @@ def build_facade_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(interior_finish.material_name), + ConstructionMaterial=interior_finish.material_name, Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) From a95fbae2c71cd1271baf3213931d95bf21970280 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 14:45:08 +0000 Subject: [PATCH 09/18] Use MaterialName literals in construction templates Co-authored-by: Sam Wolk --- epinterface/sbem/flat_constructions/roofs.py | 76 +++++++--------- epinterface/sbem/flat_constructions/slabs.py | 55 +++++------- epinterface/sbem/flat_constructions/walls.py | 93 +++++++++----------- 3 files changed, 96 insertions(+), 128 deletions(-) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 8afeccb..4505b01 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -9,24 +9,14 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( equivalent_framed_cavity_material, layer_from_nominal_r, + resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( - ACOUSTIC_TILE, - CEMENT_MORTAR, - CERAMIC_TILE, - CONCRETE_RC_DENSE, - COOL_ROOF_MEMBRANE, FIBERGLASS_BATTS, - GYPSUM_BOARD, - POLYISO_BOARD, - ROOF_MEMBRANE, - SIP_CORE, - SOFTWOOD_GENERAL, - STEEL_PANEL, + MaterialName, ) RoofStructuralSystem = Literal[ @@ -66,11 +56,11 @@ class StructuralTemplate: """Default structural roof assumptions for a structural system.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None - framing_material_name: ConstructionMaterialComponent | None = None + framing_material_name: MaterialName | None = None framing_fraction: float | None = None framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 @@ -81,39 +71,39 @@ class StructuralTemplate: class FinishTemplate: """Default roof finish material and thickness assumptions.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float STRUCTURAL_TEMPLATES: dict[RoofStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=GYPSUM_BOARD, + material_name="GypsumBoard", thickness_m=0.005, supports_cavity_insulation=False, cavity_depth_m=None, ), "light_wood_truss": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.14, ), "deep_wood_truss": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.240, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.12, ), "steel_joist": StructuralTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.180, - framing_material_name=STEEL_PANEL, + framing_material_name="SteelPanel", framing_fraction=0.08, # Calibrated to reproduce ~60-65% effective batt R for steel-joist roofs. # References: @@ -122,37 +112,37 @@ class FinishTemplate: framing_path_r_value=0.35, ), "metal_deck": StructuralTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.0015, supports_cavity_insulation=False, cavity_depth_m=None, ), "mass_timber": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "precast_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "poured_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "reinforced_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), "sip": StructuralTemplate( - material_name=SIP_CORE, + material_name="SIPCore", thickness_m=0.160, supports_cavity_insulation=False, cavity_depth_m=None, @@ -162,15 +152,15 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[RoofInteriorFinish, FinishTemplate | None] = { "none": None, "gypsum_board": FinishTemplate( - material_name=GYPSUM_BOARD, + material_name="GypsumBoard", thickness_m=0.0127, ), "acoustic_tile": FinishTemplate( - material_name=ACOUSTIC_TILE, + material_name="AcousticTile", thickness_m=0.019, ), "wood_panel": FinishTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.012, ), } @@ -178,23 +168,23 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[RoofExteriorFinish, FinishTemplate | None] = { "none": None, "epdm_membrane": FinishTemplate( - material_name=ROOF_MEMBRANE, + material_name="RoofMembrane", thickness_m=0.005, ), "cool_membrane": FinishTemplate( - material_name=COOL_ROOF_MEMBRANE, + material_name="CoolRoofMembrane", thickness_m=0.005, ), "built_up_roof": FinishTemplate( - material_name=CEMENT_MORTAR, + material_name="CementMortar", thickness_m=0.02, ), "metal_roof": FinishTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.001, ), "tile_roof": FinishTemplate( - material_name=CERAMIC_TILE, + material_name="CeramicTile", thickness_m=0.02, ), } @@ -313,7 +303,7 @@ def build_roof_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=exterior_finish.material_name, + ConstructionMaterial=resolve_material(exterior_finish.material_name), Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) @@ -323,7 +313,7 @@ def build_roof_assembly( if roof.nominal_exterior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=POLYISO_BOARD, + material="PolyisoBoard", nominal_r_value=roof.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -341,7 +331,7 @@ def build_roof_assembly( consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=roof.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material=template.framing_material_name or SOFTWOOD_GENERAL, + framing_material=template.framing_material_name or "SoftwoodGeneral", framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, @@ -359,7 +349,7 @@ def build_roof_assembly( if template.thickness_m > 0: layers.append( ConstructionLayerComponent( - ConstructionMaterial=template.material_name, + ConstructionMaterial=resolve_material(template.material_name), Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -373,7 +363,7 @@ def build_roof_assembly( ) layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS, + material="FiberglassBatt", nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -383,7 +373,7 @@ def build_roof_assembly( if roof.nominal_interior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS, + material="FiberglassBatt", nominal_r_value=roof.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -394,7 +384,7 @@ def build_roof_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=interior_finish.material_name, + ConstructionMaterial=resolve_material(interior_finish.material_name), Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index dd15a2c..d1544c4 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -9,22 +9,11 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( layer_from_nominal_r, + resolve_material, ) -from epinterface.sbem.flat_constructions.materials import ( - CEMENT_MORTAR, - CERAMIC_TILE, - CONCRETE_MC_LIGHT, - CONCRETE_RC_DENSE, - GYPSUM_BOARD, - GYPSUM_PLASTER, - SIP_CORE, - SOFTWOOD_GENERAL, - URETHANE_CARPET, - XPS_BOARD, -) +from epinterface.sbem.flat_constructions.materials import MaterialName SlabStructuralSystem = Literal[ "none", @@ -56,7 +45,7 @@ class StructuralTemplate: """Default structural slab assumptions for a structural system.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float supports_under_insulation: bool @@ -65,43 +54,43 @@ class StructuralTemplate: class FinishTemplate: """Default slab finish material and thickness assumptions.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float STRUCTURAL_TEMPLATES: dict[SlabStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=CONCRETE_MC_LIGHT, + material_name="ConcreteMC_Light", thickness_m=0.05, supports_under_insulation=False, ), "slab_on_grade": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.15, supports_under_insulation=True, ), "thickened_edge_slab": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.20, supports_under_insulation=True, ), "reinforced_concrete_suspended": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.18, supports_under_insulation=False, ), "precast_hollow_core": StructuralTemplate( - material_name=CONCRETE_MC_LIGHT, + material_name="ConcreteMC_Light", thickness_m=0.20, supports_under_insulation=False, ), "mass_timber_deck": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.18, supports_under_insulation=False, ), "sip_floor": StructuralTemplate( - material_name=SIP_CORE, + material_name="SIPCore", thickness_m=0.18, supports_under_insulation=False, ), @@ -110,19 +99,19 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[SlabInteriorFinish, FinishTemplate | None] = { "none": None, "polished_concrete": FinishTemplate( - material_name=CEMENT_MORTAR, + material_name="CementMortar", thickness_m=0.015, ), "tile": FinishTemplate( - material_name=CERAMIC_TILE, + material_name="CeramicTile", thickness_m=0.015, ), "carpet": FinishTemplate( - material_name=URETHANE_CARPET, + material_name="UrethaneCarpet", thickness_m=0.012, ), "wood_floor": FinishTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.015, ), } @@ -130,11 +119,11 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[SlabExteriorFinish, FinishTemplate | None] = { "none": None, "gypsum_board": FinishTemplate( - material_name=GYPSUM_BOARD, + material_name="GypsumBoard", thickness_m=0.0127, ), "plaster": FinishTemplate( - material_name=GYPSUM_PLASTER, + material_name="GypsumPlaster", thickness_m=0.013, ), } @@ -244,7 +233,7 @@ def build_slab_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=interior_finish.material_name, + ConstructionMaterial=resolve_material(interior_finish.material_name), Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) @@ -257,7 +246,7 @@ def build_slab_assembly( ): layers.append( layer_from_nominal_r( - material=XPS_BOARD, + material="XPSBoard", nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -266,7 +255,7 @@ def build_slab_assembly( layers.append( ConstructionLayerComponent( - ConstructionMaterial=template.material_name, + ConstructionMaterial=resolve_material(template.material_name), Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -279,7 +268,7 @@ def build_slab_assembly( ): layers.append( layer_from_nominal_r( - material=XPS_BOARD, + material="XPSBoard", nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -290,7 +279,7 @@ def build_slab_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=exterior_finish.material_name, + ConstructionMaterial=resolve_material(exterior_finish.material_name), Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 69baca4..45bed39 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -9,25 +9,14 @@ ConstructionAssemblyComponent, ConstructionLayerComponent, ) -from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.layers import ( equivalent_framed_cavity_material, layer_from_nominal_r, + resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( - CEMENT_MORTAR, - CLAY_BRICK, - CONCRETE_BLOCK_H, - CONCRETE_RC_DENSE, - FIBER_CEMENT_BOARD, FIBERGLASS_BATTS, - GYPSUM_BOARD, - GYPSUM_PLASTER, - RAMMED_EARTH, - SIP_CORE, - SOFTWOOD_GENERAL, - STEEL_PANEL, - XPS_BOARD, + MaterialName, ) WallStructuralSystem = Literal[ @@ -68,11 +57,11 @@ class StructuralTemplate: """Default structural wall assumptions for a structural system.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float supports_cavity_insulation: bool cavity_depth_m: float | None - framing_material_name: ConstructionMaterialComponent | None = None + framing_material_name: MaterialName | None = None framing_fraction: float | None = None framing_path_r_value: float | None = None uninsulated_cavity_r_value: float = 0.17 @@ -83,29 +72,29 @@ class StructuralTemplate: class FinishTemplate: """Default finish material and thickness assumptions.""" - material_name: ConstructionMaterialComponent + material_name: MaterialName thickness_m: float STRUCTURAL_TEMPLATES: dict[WallStructuralSystem, StructuralTemplate] = { "none": StructuralTemplate( - material_name=GYPSUM_BOARD, + material_name="GypsumBoard", thickness_m=0.005, supports_cavity_insulation=False, cavity_depth_m=None, ), "sheet_metal": StructuralTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.001, supports_cavity_insulation=False, cavity_depth_m=None, ), "light_gauge_steel": StructuralTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=STEEL_PANEL, + framing_material_name="SteelPanel", framing_fraction=0.12, # Calibrated to reproduce ~55% effective batt R for 3.5in steel-stud walls. # References: @@ -114,95 +103,95 @@ class FinishTemplate: framing_path_r_value=0.26, ), "structural_steel": StructuralTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.006, supports_cavity_insulation=False, cavity_depth_m=None, ), "woodframe": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.23, ), "deep_woodframe": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.23, ), "woodframe_24oc": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.090, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.17, ), "deep_woodframe_24oc": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.0, supports_cavity_insulation=True, cavity_depth_m=0.140, - framing_material_name=SOFTWOOD_GENERAL, + framing_material_name="SoftwoodGeneral", framing_fraction=0.17, ), "engineered_timber": StructuralTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.160, supports_cavity_insulation=False, cavity_depth_m=None, ), "cmu": StructuralTemplate( - material_name=CONCRETE_BLOCK_H, + material_name="ConcreteBlockH", thickness_m=0.190, supports_cavity_insulation=True, cavity_depth_m=0.090, cavity_r_correction_factor=0.90, ), "double_layer_cmu": StructuralTemplate( - material_name=CONCRETE_BLOCK_H, + material_name="ConcreteBlockH", thickness_m=0.290, supports_cavity_insulation=True, cavity_depth_m=0.140, cavity_r_correction_factor=0.92, ), "precast_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "poured_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.180, supports_cavity_insulation=False, cavity_depth_m=None, ), "masonry": StructuralTemplate( - material_name=CLAY_BRICK, + material_name="ClayBrick", thickness_m=0.190, supports_cavity_insulation=False, cavity_depth_m=None, ), "rammed_earth": StructuralTemplate( - material_name=RAMMED_EARTH, + material_name="RammedEarth", thickness_m=0.350, supports_cavity_insulation=False, cavity_depth_m=None, ), "reinforced_concrete": StructuralTemplate( - material_name=CONCRETE_RC_DENSE, + material_name="ConcreteRC_Dense", thickness_m=0.200, supports_cavity_insulation=False, cavity_depth_m=None, ), "sip": StructuralTemplate( - material_name=SIP_CORE, + material_name="SIPCore", thickness_m=0.150, supports_cavity_insulation=False, cavity_depth_m=None, @@ -212,15 +201,15 @@ class FinishTemplate: INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { "none": None, "drywall": FinishTemplate( - material_name=GYPSUM_BOARD, + material_name="GypsumBoard", thickness_m=0.0127, ), "plaster": FinishTemplate( - material_name=GYPSUM_PLASTER, + material_name="GypsumPlaster", thickness_m=0.013, ), "wood_panel": FinishTemplate( - material_name=SOFTWOOD_GENERAL, + material_name="SoftwoodGeneral", thickness_m=0.012, ), } @@ -228,19 +217,19 @@ class FinishTemplate: EXTERIOR_FINISH_TEMPLATES: dict[WallExteriorFinish, FinishTemplate | None] = { "none": None, "brick_veneer": FinishTemplate( - material_name=CLAY_BRICK, + material_name="ClayBrick", thickness_m=0.090, ), "stucco": FinishTemplate( - material_name=CEMENT_MORTAR, + material_name="CementMortar", thickness_m=0.020, ), "fiber_cement": FinishTemplate( - material_name=FIBER_CEMENT_BOARD, + material_name="FiberCementBoard", thickness_m=0.012, ), "metal_panel": FinishTemplate( - material_name=STEEL_PANEL, + material_name="SteelPanel", thickness_m=0.001, ), } @@ -360,7 +349,7 @@ def build_facade_assembly( if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=exterior_finish.material_name, + ConstructionMaterial=resolve_material(exterior_finish.material_name), Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) @@ -370,7 +359,7 @@ def build_facade_assembly( if wall.nominal_exterior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=XPS_BOARD, + material="XPSBoard", nominal_r_value=wall.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -388,7 +377,7 @@ def build_facade_assembly( consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=wall.structural_system, cavity_depth_m=template.cavity_depth_m or 0.0, - framing_material=template.framing_material_name or SOFTWOOD_GENERAL, + framing_material=template.framing_material_name or "SoftwoodGeneral", framing_fraction=template.framing_fraction or 0.0, framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, @@ -406,7 +395,7 @@ def build_facade_assembly( if template.thickness_m > 0: layers.append( ConstructionLayerComponent( - ConstructionMaterial=template.material_name, + ConstructionMaterial=resolve_material(template.material_name), Thickness=template.thickness_m, LayerOrder=layer_order, ) @@ -420,7 +409,7 @@ def build_facade_assembly( ) layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS, + material="FiberglassBatt", nominal_r_value=effective_cavity_r, layer_order=layer_order, ) @@ -430,7 +419,7 @@ def build_facade_assembly( if wall.nominal_interior_insulation_r > 0: layers.append( layer_from_nominal_r( - material=FIBERGLASS_BATTS, + material="FiberglassBatt", nominal_r_value=wall.nominal_interior_insulation_r, layer_order=layer_order, ) @@ -441,7 +430,7 @@ def build_facade_assembly( if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=interior_finish.material_name, + ConstructionMaterial=resolve_material(interior_finish.material_name), Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) From 9704909e0ae8cd02702a7432b8aafbadb322d2f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 14:54:22 +0000 Subject: [PATCH 10/18] Allow per-material absorptance overrides in material registry Co-authored-by: Sam Wolk --- .../sbem/flat_constructions/materials.py | 39 +++++++++++++++++-- .../test_layers_utils.py | 22 ++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index e93f413..a8c0cf3 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -29,6 +29,10 @@ MATERIAL_NAME_VALUES: tuple[MaterialName, ...] = get_args(MaterialName) +DEFAULT_THERMAL_ABSORPTANCE = 0.9 +DEFAULT_SOLAR_ABSORPTANCE = 0.6 +DEFAULT_VISIBLE_ABSORPTANCE = 0.6 + def _material( *, @@ -37,16 +41,31 @@ def _material( density: float, specific_heat: float, mat_type: str, + thermal_absorptance: float | None = None, + solar_absorptance: float | None = None, + visible_absorptance: float | None = None, ) -> ConstructionMaterialComponent: - """Create a construction material component with common optical defaults.""" + """Create a construction material component with optional optical overrides.""" return ConstructionMaterialComponent( Name=name, Conductivity=conductivity, Density=density, SpecificHeat=specific_heat, - ThermalAbsorptance=0.9, - SolarAbsorptance=0.6, - VisibleAbsorptance=0.6, + ThermalAbsorptance=( + DEFAULT_THERMAL_ABSORPTANCE + if thermal_absorptance is None + else thermal_absorptance + ), + SolarAbsorptance=( + DEFAULT_SOLAR_ABSORPTANCE + if solar_absorptance is None + else solar_absorptance + ), + VisibleAbsorptance=( + DEFAULT_VISIBLE_ABSORPTANCE + if visible_absorptance is None + else visible_absorptance + ), TemperatureCoefficientThermalConductivity=0.0, Roughness="MediumRough", Type=mat_type, # pyright: ignore[reportArgumentType] @@ -115,6 +134,8 @@ def _material( density=1700, specific_heat=840, mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, ) CONCRETE_BLOCK_H = _material( @@ -139,6 +160,8 @@ def _material( density=1850, specific_heat=840, mat_type="Other", + solar_absorptance=0.65, + visible_absorptance=0.65, ) CERAMIC_TILE = _material( @@ -163,6 +186,8 @@ def _material( density=7850, specific_heat=500, mat_type="Metal", + solar_absorptance=0.55, + visible_absorptance=0.55, ) RAMMED_EARTH = _material( @@ -187,6 +212,8 @@ def _material( density=1350, specific_heat=840, mat_type="Siding", + solar_absorptance=0.65, + visible_absorptance=0.65, ) ROOF_MEMBRANE = _material( @@ -195,6 +222,8 @@ def _material( density=1200, specific_heat=900, mat_type="Sealing", + solar_absorptance=0.88, + visible_absorptance=0.88, ) COOL_ROOF_MEMBRANE = _material( @@ -203,6 +232,8 @@ def _material( density=1200, specific_heat=900, mat_type="Sealing", + solar_absorptance=0.30, + visible_absorptance=0.30, ) ACOUSTIC_TILE = _material( diff --git a/tests/test_flat_constructions/test_layers_utils.py b/tests/test_flat_constructions/test_layers_utils.py index 1ce6203..a067cb9 100644 --- a/tests/test_flat_constructions/test_layers_utils.py +++ b/tests/test_flat_constructions/test_layers_utils.py @@ -6,7 +6,11 @@ layer_from_nominal_r, resolve_material, ) -from epinterface.sbem.flat_constructions.materials import XPS_BOARD +from epinterface.sbem.flat_constructions.materials import ( + COOL_ROOF_MEMBRANE, + ROOF_MEMBRANE, + XPS_BOARD, +) def test_layer_from_nominal_r_accepts_material_name_literal() -> None: @@ -34,3 +38,19 @@ def test_layer_from_nominal_r_accepts_material_component() -> None: def test_resolve_material_returns_same_component_for_objects() -> None: """Material resolver should pass through component inputs unchanged.""" assert resolve_material(XPS_BOARD) is XPS_BOARD + + +def test_cool_roof_membrane_uses_lower_absorptance_than_dark_membrane() -> None: + """Cool roof optical properties should differ from generic dark membrane.""" + assert ROOF_MEMBRANE.SolarAbsorptance == pytest.approx(0.88) + assert ROOF_MEMBRANE.VisibleAbsorptance == pytest.approx(0.88) + assert COOL_ROOF_MEMBRANE.SolarAbsorptance == pytest.approx(0.30) + assert COOL_ROOF_MEMBRANE.VisibleAbsorptance == pytest.approx(0.30) + assert COOL_ROOF_MEMBRANE.SolarAbsorptance < ROOF_MEMBRANE.SolarAbsorptance + + +def test_xps_board_retains_default_optical_properties() -> None: + """Materials without overrides should continue using helper defaults.""" + assert XPS_BOARD.ThermalAbsorptance == pytest.approx(0.9) + assert XPS_BOARD.SolarAbsorptance == pytest.approx(0.6) + assert XPS_BOARD.VisibleAbsorptance == pytest.approx(0.6) From 5138bf49f8de0fd1b44f4e65a03a4c0ba34c5836 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:19:02 -0500 Subject: [PATCH 11/18] add some more materials --- epinterface/sbem/flat_constructions/audit.py | 4 +- .../sbem/flat_constructions/materials.py | 49 ++++++++++++++- epinterface/sbem/flat_constructions/roofs.py | 15 +++-- epinterface/sbem/flat_constructions/walls.py | 27 ++++++-- .../test_layers_utils.py | 41 +++++++++++++ .../test_roofs_slabs.py | 34 +++++++++++ tests/test_flat_constructions/test_walls.py | 61 +++++++++++++++++++ 7 files changed, 220 insertions(+), 11 deletions(-) diff --git a/epinterface/sbem/flat_constructions/audit.py b/epinterface/sbem/flat_constructions/audit.py index 5d63ab8..b8d7e4a 100644 --- a/epinterface/sbem/flat_constructions/audit.py +++ b/epinterface/sbem/flat_constructions/audit.py @@ -42,7 +42,7 @@ class AuditIssue: "Insulation": (0.02, 0.08), "Concrete": (0.4, 2.5), "Timber": (0.08, 0.25), - "Masonry": (0.3, 1.6), + "Masonry": (0.3, 3.5), "Metal": (10.0, 70.0), "Boards": (0.04, 0.25), "Other": (0.2, 1.5), @@ -56,7 +56,7 @@ class AuditIssue: "Insulation": (8, 100), "Concrete": (800, 2600), "Timber": (300, 900), - "Masonry": (900, 2400), + "Masonry": (900, 2800), "Metal": (6500, 8500), "Boards": (100, 1200), "Other": (500, 2600), diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index a8c0cf3..2532711 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -25,6 +25,9 @@ "RoofMembrane", "CoolRoofMembrane", "AcousticTile", + "VinylSiding", + "AsphaltShingle", + "NaturalStone", ] MATERIAL_NAME_VALUES: tuple[MaterialName, ...] = get_args(MaterialName) @@ -44,6 +47,7 @@ def _material( thermal_absorptance: float | None = None, solar_absorptance: float | None = None, visible_absorptance: float | None = None, + roughness: str = "MediumRough", ) -> ConstructionMaterialComponent: """Create a construction material component with optional optical overrides.""" return ConstructionMaterialComponent( @@ -67,7 +71,7 @@ def _material( else visible_absorptance ), TemperatureCoefficientThermalConductivity=0.0, - Roughness="MediumRough", + Roughness=roughness, # pyright: ignore[reportArgumentType] Type=mat_type, # pyright: ignore[reportArgumentType] ) @@ -144,6 +148,8 @@ def _material( density=1100, specific_heat=840, mat_type="Concrete", + solar_absorptance=0.65, + visible_absorptance=0.65, ) FIBERGLASS_BATTS = _material( @@ -170,6 +176,7 @@ def _material( density=2000, specific_heat=840, mat_type="Finishes", + roughness="MediumSmooth", ) URETHANE_CARPET = _material( @@ -188,6 +195,7 @@ def _material( mat_type="Metal", solar_absorptance=0.55, visible_absorptance=0.55, + roughness="Smooth", ) RAMMED_EARTH = _material( @@ -196,6 +204,8 @@ def _material( density=1900, specific_heat=1000, mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, ) SIP_CORE = _material( @@ -224,6 +234,7 @@ def _material( mat_type="Sealing", solar_absorptance=0.88, visible_absorptance=0.88, + roughness="Smooth", ) COOL_ROOF_MEMBRANE = _material( @@ -234,6 +245,7 @@ def _material( mat_type="Sealing", solar_absorptance=0.30, visible_absorptance=0.30, + roughness="Smooth", ) ACOUSTIC_TILE = _material( @@ -244,6 +256,38 @@ def _material( mat_type="Boards", ) +VINYL_SIDING = _material( + name="VinylSiding", + conductivity=0.17, + density=1380, + specific_heat=1000, + mat_type="Siding", + solar_absorptance=0.55, + visible_absorptance=0.55, + roughness="Smooth", +) + +ASPHALT_SHINGLE = _material( + name="AsphaltShingle", + conductivity=0.06, + density=1120, + specific_heat=920, + mat_type="Finishes", + solar_absorptance=0.85, + visible_absorptance=0.85, + roughness="Rough", +) + +NATURAL_STONE = _material( + name="NaturalStone", + conductivity=2.90, + density=2500, + specific_heat=840, + mat_type="Masonry", + solar_absorptance=0.55, + visible_absorptance=0.55, +) + _ALL_MATERIALS = ( XPS_BOARD, POLYISO_BOARD, @@ -265,6 +309,9 @@ def _material( ROOF_MEMBRANE, COOL_ROOF_MEMBRANE, ACOUSTIC_TILE, + VINYL_SIDING, + ASPHALT_SHINGLE, + NATURAL_STONE, ) MATERIALS_BY_NAME: dict[MaterialName, ConstructionMaterialComponent] = { diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 4505b01..75e80f2 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -14,10 +14,7 @@ layer_from_nominal_r, resolve_material, ) -from epinterface.sbem.flat_constructions.materials import ( - FIBERGLASS_BATTS, - MaterialName, -) +from epinterface.sbem.flat_constructions.materials import FIBERGLASS_BATTS, MaterialName RoofStructuralSystem = Literal[ "none", @@ -45,6 +42,8 @@ "built_up_roof", "metal_roof", "tile_roof", + "asphalt_shingle", + "wood_shake", ] ALL_ROOF_STRUCTURAL_SYSTEMS = get_args(RoofStructuralSystem) @@ -187,6 +186,14 @@ class FinishTemplate: material_name="CeramicTile", thickness_m=0.02, ), + "asphalt_shingle": FinishTemplate( + material_name="AsphaltShingle", + thickness_m=0.006, + ), + "wood_shake": FinishTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.012, + ), } diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 45bed39..0caadd2 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -14,10 +14,7 @@ layer_from_nominal_r, resolve_material, ) -from epinterface.sbem.flat_constructions.materials import ( - FIBERGLASS_BATTS, - MaterialName, -) +from epinterface.sbem.flat_constructions.materials import FIBERGLASS_BATTS, MaterialName WallStructuralSystem = Literal[ "none", @@ -37,6 +34,7 @@ "rammed_earth", "reinforced_concrete", "sip", + "icf", ] WallInteriorFinish = Literal["none", "drywall", "plaster", "wood_panel"] @@ -46,6 +44,9 @@ "stucco", "fiber_cement", "metal_panel", + "vinyl_siding", + "wood_siding", + "stone_veneer", ] ALL_WALL_STRUCTURAL_SYSTEMS = get_args(WallStructuralSystem) @@ -196,6 +197,12 @@ class FinishTemplate: supports_cavity_insulation=False, cavity_depth_m=None, ), + "icf": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), } INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { @@ -232,6 +239,18 @@ class FinishTemplate: material_name="SteelPanel", thickness_m=0.001, ), + "vinyl_siding": FinishTemplate( + material_name="VinylSiding", + thickness_m=0.0015, + ), + "wood_siding": FinishTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.018, + ), + "stone_veneer": FinishTemplate( + material_name="NaturalStone", + thickness_m=0.025, + ), } diff --git a/tests/test_flat_constructions/test_layers_utils.py b/tests/test_flat_constructions/test_layers_utils.py index a067cb9..eca91fb 100644 --- a/tests/test_flat_constructions/test_layers_utils.py +++ b/tests/test_flat_constructions/test_layers_utils.py @@ -7,8 +7,14 @@ resolve_material, ) from epinterface.sbem.flat_constructions.materials import ( + ASPHALT_SHINGLE, + CONCRETE_BLOCK_H, COOL_ROOF_MEMBRANE, + NATURAL_STONE, + RAMMED_EARTH, ROOF_MEMBRANE, + STEEL_PANEL, + VINYL_SIDING, XPS_BOARD, ) @@ -54,3 +60,38 @@ def test_xps_board_retains_default_optical_properties() -> None: assert XPS_BOARD.ThermalAbsorptance == pytest.approx(0.9) assert XPS_BOARD.SolarAbsorptance == pytest.approx(0.6) assert XPS_BOARD.VisibleAbsorptance == pytest.approx(0.6) + + +def test_rammed_earth_has_explicit_absorptances() -> None: + """Rammed earth should have explicit solar/visible absorptance for exterior exposure.""" + assert RAMMED_EARTH.SolarAbsorptance == pytest.approx(0.70) + assert RAMMED_EARTH.VisibleAbsorptance == pytest.approx(0.70) + + +def test_concrete_block_has_explicit_absorptances() -> None: + """Concrete block should have explicit solar/visible absorptance for exterior exposure.""" + assert CONCRETE_BLOCK_H.SolarAbsorptance == pytest.approx(0.65) + assert CONCRETE_BLOCK_H.VisibleAbsorptance == pytest.approx(0.65) + + +def test_roughness_overrides_applied_correctly() -> None: + """Materials with non-default roughness should have the correct value.""" + assert STEEL_PANEL.Roughness == "Smooth" + assert ROOF_MEMBRANE.Roughness == "Smooth" + assert COOL_ROOF_MEMBRANE.Roughness == "Smooth" + assert XPS_BOARD.Roughness == "MediumRough" + + +def test_new_materials_have_expected_properties() -> None: + """Newly added materials should have correct key properties.""" + assert VINYL_SIDING.Conductivity == pytest.approx(0.17) + assert VINYL_SIDING.SolarAbsorptance == pytest.approx(0.55) + assert VINYL_SIDING.Roughness == "Smooth" + + assert ASPHALT_SHINGLE.Conductivity == pytest.approx(0.06) + assert ASPHALT_SHINGLE.SolarAbsorptance == pytest.approx(0.85) + assert ASPHALT_SHINGLE.Roughness == "Rough" + + assert NATURAL_STONE.Conductivity == pytest.approx(2.90) + assert NATURAL_STONE.SolarAbsorptance == pytest.approx(0.55) + assert NATURAL_STONE.Density == pytest.approx(2500) diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index e1c591d..d936145 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -276,3 +276,37 @@ def test_build_envelope_assemblies_with_surface_specific_specs() -> None: assert envelope_assemblies.FacadeAssembly.Type == "Facade" assert envelope_assemblies.FlatRoofAssembly.Type == "FlatRoof" assert envelope_assemblies.GroundSlabAssembly.Type == "GroundSlab" + + +def test_asphalt_shingle_exterior_finish_round_trip() -> None: + """Asphalt shingle finish should produce a valid roof assembly.""" + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + nominal_cavity_insulation_r=3.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="gypsum_board", + exterior_finish="asphalt_shingle", + ) + assembly = build_roof_assembly(roof) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == "AsphaltShingle" + assert outer_layer.Thickness == pytest.approx(0.006) + assert assembly.r_value > 0 + + +def test_wood_shake_exterior_finish_round_trip() -> None: + """Wood shake finish should produce a valid roof assembly.""" + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + nominal_cavity_insulation_r=3.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="wood_shake", + ) + assembly = build_roof_assembly(roof) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == "SoftwoodGeneral" + assert outer_layer.Thickness == pytest.approx(0.012) + assert assembly.r_value > 0 diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index 284f118..cc876fa 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -5,8 +5,10 @@ from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CONCRETE_BLOCK_H, + CONCRETE_RC_DENSE, GYPSUM_BOARD, SOFTWOOD_GENERAL, + MaterialName, ) from epinterface.sbem.flat_constructions.walls import ( ALL_WALL_EXTERIOR_FINISHES, @@ -14,6 +16,7 @@ ALL_WALL_STRUCTURAL_SYSTEMS, STRUCTURAL_TEMPLATES, SemiFlatWallConstruction, + WallExteriorFinish, build_facade_assembly, ) @@ -150,3 +153,61 @@ def test_wall_feature_dict_has_fixed_length() -> None: assert features["FacadeStructuralSystem__deep_woodframe_24oc"] == 1.0 assert features["FacadeInteriorFinish__plaster"] == 1.0 assert features["FacadeExteriorFinish__fiber_cement"] == 1.0 + + +def test_vinyl_siding_exterior_finish_round_trip() -> None: + """Vinyl siding finish should produce a valid assembly with the correct outer layer.""" + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.5, + nominal_interior_insulation_r=0.0, + interior_finish="drywall", + exterior_finish="vinyl_siding", + ) + assembly = build_facade_assembly(wall) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == "VinylSiding" + assert outer_layer.Thickness == pytest.approx(0.0015) + assert assembly.r_value > 0 + + +def test_icf_structural_system_round_trip() -> None: + """ICF wall should produce a concrete-core assembly with continuous insulation layers.""" + wall = SemiFlatWallConstruction( + structural_system="icf", + nominal_cavity_insulation_r=0.0, + nominal_exterior_insulation_r=2.0, + nominal_interior_insulation_r=1.5, + interior_finish="drywall", + exterior_finish="none", + ) + assembly = build_facade_assembly(wall) + icf_template = STRUCTURAL_TEMPLATES["icf"] + + expected_r = ( + 2.0 + + (icf_template.thickness_m / CONCRETE_RC_DENSE.Conductivity) + + 1.5 + + (0.0127 / GYPSUM_BOARD.Conductivity) + ) + assert assembly.Type == "Facade" + assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) + + +def test_wood_siding_and_stone_veneer_produce_valid_assemblies() -> None: + """New exterior finishes should each produce valid assemblies.""" + finishes: list[tuple[WallExteriorFinish, MaterialName]] = [ + ("wood_siding", "SoftwoodGeneral"), + ("stone_veneer", "NaturalStone"), + ] + for finish, mat_name in finishes: + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=2.0, + exterior_finish=finish, + ) + assembly = build_facade_assembly(wall) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == mat_name + assert assembly.r_value > 0 From 59a48031b5e36bdd6257e5ef86ad977ad8d711ab Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:40:28 -0500 Subject: [PATCH 12/18] add insulation materials to walls and add ventilated cavity option. --- epinterface/sbem/components/materials.py | 1 + epinterface/sbem/flat_constructions/audit.py | 22 +- epinterface/sbem/flat_constructions/layers.py | 45 ++++ .../sbem/flat_constructions/materials.py | 93 ++++++++ epinterface/sbem/flat_constructions/roofs.py | 75 ++++++- epinterface/sbem/flat_constructions/slabs.py | 29 ++- epinterface/sbem/flat_constructions/walls.py | 152 ++++++++++++- .../test_roofs_slabs.py | 200 +++++++++++++++++- tests/test_flat_constructions/test_walls.py | 159 ++++++++++++++ 9 files changed, 757 insertions(+), 19 deletions(-) diff --git a/epinterface/sbem/components/materials.py b/epinterface/sbem/components/materials.py index 1e65ea7..4b2880a 100644 --- a/epinterface/sbem/components/materials.py +++ b/epinterface/sbem/components/materials.py @@ -78,6 +78,7 @@ class CommonMaterialPropertiesMixin(BaseModel): "Finishes", "Siding", "Sealing", + "Bio", ] MaterialRoughness = Literal[ diff --git a/epinterface/sbem/flat_constructions/audit.py b/epinterface/sbem/flat_constructions/audit.py index b8d7e4a..ba3eb29 100644 --- a/epinterface/sbem/flat_constructions/audit.py +++ b/epinterface/sbem/flat_constructions/audit.py @@ -3,7 +3,10 @@ from dataclasses import dataclass from typing import Literal -from epinterface.sbem.flat_constructions.materials import MATERIALS_BY_NAME +from epinterface.sbem.flat_constructions.materials import ( + FIBERGLASS_BATTS, + MATERIALS_BY_NAME, +) from epinterface.sbem.flat_constructions.roofs import ( STRUCTURAL_TEMPLATES as ROOF_STRUCTURAL_TEMPLATES, ) @@ -42,7 +45,7 @@ class AuditIssue: "Insulation": (0.02, 0.08), "Concrete": (0.4, 2.5), "Timber": (0.08, 0.25), - "Masonry": (0.3, 3.5), + "Masonry": (0.05, 3.5), "Metal": (10.0, 70.0), "Boards": (0.04, 0.25), "Other": (0.2, 1.5), @@ -50,13 +53,14 @@ class AuditIssue: "Finishes": (0.04, 1.5), "Siding": (0.1, 0.8), "Sealing": (0.1, 0.3), + "Bio": (0.04, 1.2), } _DENSITY_RANGES = { - "Insulation": (8, 100), + "Insulation": (8, 200), "Concrete": (800, 2600), "Timber": (300, 900), - "Masonry": (900, 2800), + "Masonry": (400, 2800), "Metal": (6500, 8500), "Boards": (100, 1200), "Other": (500, 2600), @@ -64,6 +68,7 @@ class AuditIssue: "Finishes": (80, 2600), "Siding": (600, 2000), "Sealing": (700, 1800), + "Bio": (100, 2000), } @@ -145,11 +150,14 @@ def audit_layups() -> list[AuditIssue]: issues: list[AuditIssue] = [] for structural_system, template in WALL_STRUCTURAL_TEMPLATES.items(): + if template.supports_cavity_insulation and template.cavity_depth_m is not None: + max_cavity_r = template.cavity_depth_m / FIBERGLASS_BATTS.Conductivity + audit_cavity_r = min(2.0, max_cavity_r * 0.9) + else: + audit_cavity_r = 0.0 wall = SemiFlatWallConstruction( structural_system=structural_system, - nominal_cavity_insulation_r=( - 2.0 if template.supports_cavity_insulation else 0.0 - ), + nominal_cavity_insulation_r=audit_cavity_r, nominal_exterior_insulation_r=1.0, nominal_interior_insulation_r=0.2, interior_finish="drywall", diff --git a/epinterface/sbem/flat_constructions/layers.py b/epinterface/sbem/flat_constructions/layers.py index 3f1ef47..35afa6d 100644 --- a/epinterface/sbem/flat_constructions/layers.py +++ b/epinterface/sbem/flat_constructions/layers.py @@ -1,5 +1,7 @@ """Shared layer and equivalent-material helpers for flat constructions.""" +from typing import Literal, get_args + from epinterface.sbem.components.envelope import ConstructionLayerComponent from epinterface.sbem.components.materials import ConstructionMaterialComponent from epinterface.sbem.flat_constructions.materials import ( @@ -10,6 +12,49 @@ type MaterialRef = ConstructionMaterialComponent | MaterialName +ContinuousInsulationMaterial = Literal["xps", "polyiso", "eps", "mineral_wool"] +ALL_CONTINUOUS_INSULATION_MATERIALS = get_args(ContinuousInsulationMaterial) + +# TODO: do we really need this map? +CONTINUOUS_INSULATION_MATERIAL_MAP: dict[ContinuousInsulationMaterial, MaterialName] = { + "xps": "XPSBoard", + "polyiso": "PolyisoBoard", + "eps": "EPSBoard", + "mineral_wool": "MineralWoolBoard", +} + +ExteriorCavityType = Literal["none", "unventilated", "well_ventilated"] +ALL_EXTERIOR_CAVITY_TYPES = get_args(ExteriorCavityType) + +# ISO 6946:2017 Table 2 -- thermal resistance of unventilated air layers. +# Vertical (walls): ~0.18 m2K/W for 25mm gap. +# Horizontal heat-flow-up (roofs): ~0.16 m2K/W for 25mm gap. +UNVENTILATED_AIR_R_WALL = 0.18 +UNVENTILATED_AIR_R_ROOF = 0.16 +_AIR_GAP_THICKNESS_M = 0.025 + + +# TODO: should this be a NoMass or AirGap Material instead? Also, make sure it is not on the outside! +def _make_air_gap_material(r_value: float) -> ConstructionMaterialComponent: + """Create a virtual material representing an unventilated air gap.""" + effective_conductivity = _AIR_GAP_THICKNESS_M / r_value + return ConstructionMaterialComponent( + Name=f"AirGap_R{r_value:.2f}", + Conductivity=effective_conductivity, + Density=1.2, + SpecificHeat=1005, + ThermalAbsorptance=0.9, + SolarAbsorptance=0.0, + VisibleAbsorptance=0.0, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="Smooth", + Type="Other", + ) + + +AIR_GAP_WALL = _make_air_gap_material(UNVENTILATED_AIR_R_WALL) +AIR_GAP_ROOF = _make_air_gap_material(UNVENTILATED_AIR_R_ROOF) + def resolve_material(material: MaterialRef) -> ConstructionMaterialComponent: """Resolve a material name or component into a material component.""" diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index 2532711..1b0f816 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -7,6 +7,8 @@ MaterialName = Literal[ "XPSBoard", "PolyisoBoard", + "EPSBoard", + "MineralWoolBoard", "ConcreteMC_Light", "ConcreteRC_Dense", "GypsumBoard", @@ -28,6 +30,12 @@ "VinylSiding", "AsphaltShingle", "NaturalStone", + "AACBlock", + "SandcreteBlock", + "HollowClayBlock", + "StabilizedSoilBlock", + "WattleDaub", + "ThatchReed", ] MATERIAL_NAME_VALUES: tuple[MaterialName, ...] = get_args(MaterialName) @@ -288,6 +296,83 @@ def _material( visible_absorptance=0.55, ) +EPS_BOARD = _material( + name="EPSBoard", + conductivity=0.037, + density=25, + specific_heat=1400, + mat_type="Insulation", +) + +MINERAL_WOOL_BOARD = _material( + name="MineralWoolBoard", + conductivity=0.035, + density=140, + specific_heat=840, + mat_type="Insulation", +) + +AAC_BLOCK = _material( + name="AACBlock", + conductivity=0.11, + density=500, + specific_heat=1000, + mat_type="Masonry", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +SANDCRETE_BLOCK = _material( + name="SandcreteBlock", + conductivity=0.72, + density=1700, + specific_heat=840, + mat_type="Masonry", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +HOLLOW_CLAY_BLOCK = _material( + name="HollowClayBlock", + conductivity=0.35, + density=900, + specific_heat=840, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +STABILIZED_SOIL_BLOCK = _material( + name="StabilizedSoilBlock", + conductivity=0.65, + density=1600, + specific_heat=900, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +WATTLE_DAUB = _material( + name="WattleDaub", + conductivity=0.07, + density=800, + specific_heat=1000, + mat_type="Bio", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +THATCH_REED = _material( + name="ThatchReed", + conductivity=0.09, + density=150, + specific_heat=1000, + mat_type="Bio", + solar_absorptance=0.75, + visible_absorptance=0.75, + roughness="Rough", +) + _ALL_MATERIALS = ( XPS_BOARD, POLYISO_BOARD, @@ -312,6 +397,14 @@ def _material( VINYL_SIDING, ASPHALT_SHINGLE, NATURAL_STONE, + EPS_BOARD, + MINERAL_WOOL_BOARD, + AAC_BLOCK, + SANDCRETE_BLOCK, + HOLLOW_CLAY_BLOCK, + STABILIZED_SOIL_BLOCK, + WATTLE_DAUB, + THATCH_REED, ) MATERIALS_BY_NAME: dict[MaterialName, ConstructionMaterialComponent] = { diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 75e80f2..aa24901 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -10,6 +10,13 @@ ConstructionLayerComponent, ) from epinterface.sbem.flat_constructions.layers import ( + _AIR_GAP_THICKNESS_M, + AIR_GAP_ROOF, + ALL_CONTINUOUS_INSULATION_MATERIALS, + ALL_EXTERIOR_CAVITY_TYPES, + CONTINUOUS_INSULATION_MATERIAL_MAP, + ContinuousInsulationMaterial, + ExteriorCavityType, equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, @@ -44,6 +51,8 @@ "tile_roof", "asphalt_shingle", "wood_shake", + "thatch", + "fiber_cement_sheet", ] ALL_ROOF_STRUCTURAL_SYSTEMS = get_args(RoofStructuralSystem) @@ -194,6 +203,14 @@ class FinishTemplate: material_name="SoftwoodGeneral", thickness_m=0.012, ), + "thatch": FinishTemplate( + material_name="ThatchReed", + thickness_m=0.200, + ), + "fiber_cement_sheet": FinishTemplate( + material_name="FiberCementBoard", + thickness_m=0.006, + ), } @@ -219,6 +236,14 @@ class SemiFlatRoofConstruction(BaseModel): ge=0, title="Nominal interior continuous roof insulation R-value [m²K/W]", ) + exterior_insulation_material: ContinuousInsulationMaterial = Field( + default="polyiso", + title="Exterior continuous roof insulation material", + ) + interior_insulation_material: ContinuousInsulationMaterial = Field( + default="polyiso", + title="Interior continuous roof insulation material", + ) interior_finish: RoofInteriorFinish = Field( default="gypsum_board", title="Interior roof finish selection", @@ -227,6 +252,10 @@ class SemiFlatRoofConstruction(BaseModel): default="epdm_membrane", title="Exterior roof finish selection", ) + exterior_cavity_type: ExteriorCavityType = Field( + default="none", + title="Exterior ventilation cavity type per ISO 6946:2017 Section 6.9", + ) @property def effective_nominal_cavity_insulation_r(self) -> float: @@ -293,6 +322,17 @@ def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( self.exterior_finish == exterior_finish ) + for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: + features[f"{prefix}ExteriorInsulationMaterial__{ins_mat}"] = float( + self.exterior_insulation_material == ins_mat + ) + features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( + self.interior_insulation_material == ins_mat + ) + for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: + features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( + self.exterior_cavity_type == cavity_type + ) return features @@ -307,7 +347,11 @@ def build_roof_assembly( layer_order = 0 exterior_finish = EXTERIOR_FINISH_TEMPLATES[roof.exterior_finish] - if exterior_finish is not None: + + # ISO 6946:2017 Section 6.9 -- well-ventilated cavities: disregard cladding + # and air layer R. Omit the finish layer; EnergyPlus applies its own + # exterior surface coefficient to the next layer inward. + if exterior_finish is not None and roof.exterior_cavity_type != "well_ventilated": layers.append( ConstructionLayerComponent( ConstructionMaterial=resolve_material(exterior_finish.material_name), @@ -317,10 +361,25 @@ def build_roof_assembly( ) layer_order += 1 + # ISO 6946:2017 Section 6.9 -- unventilated cavities: add equivalent + # still-air thermal resistance (~0.16 m2K/W for 25mm horizontal gap). + if roof.exterior_cavity_type == "unventilated" and exterior_finish is not None: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=AIR_GAP_ROOF, + Thickness=_AIR_GAP_THICKNESS_M, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + if roof.nominal_exterior_insulation_r > 0: + ext_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[ + roof.exterior_insulation_material + ] layers.append( layer_from_nominal_r( - material="PolyisoBoard", + material=ext_ins_material, nominal_r_value=roof.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -363,13 +422,18 @@ def build_roof_assembly( ) layer_order += 1 - if roof.effective_nominal_cavity_insulation_r > 0: + if ( + roof.effective_nominal_cavity_insulation_r > 0 + and template.supports_cavity_insulation + ): effective_cavity_r = ( roof.effective_nominal_cavity_insulation_r * template.cavity_r_correction_factor ) layers.append( layer_from_nominal_r( + # TODO: make this configurable with a CavityInsulationMaterial field + # e.g. blown cellulose, fiberglass, etc. material="FiberglassBatt", nominal_r_value=effective_cavity_r, layer_order=layer_order, @@ -378,9 +442,12 @@ def build_roof_assembly( layer_order += 1 if roof.nominal_interior_insulation_r > 0: + int_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[ + roof.interior_insulation_material + ] layers.append( layer_from_nominal_r( - material="FiberglassBatt", + material=int_ins_material, nominal_r_value=roof.nominal_interior_insulation_r, layer_order=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index d1544c4..5d9c747 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -10,6 +10,9 @@ ConstructionLayerComponent, ) from epinterface.sbem.flat_constructions.layers import ( + ALL_CONTINUOUS_INSULATION_MATERIALS, + CONTINUOUS_INSULATION_MATERIAL_MAP, + ContinuousInsulationMaterial, layer_from_nominal_r, resolve_material, ) @@ -23,6 +26,7 @@ "precast_hollow_core", "mass_timber_deck", "sip_floor", + "compacted_earth_floor", ] SlabInsulationPlacement = Literal["auto", "under_slab", "above_slab"] @@ -32,6 +36,7 @@ "tile", "carpet", "wood_floor", + "cement_screed", ] SlabExteriorFinish = Literal["none", "gypsum_board", "plaster"] @@ -94,6 +99,11 @@ class FinishTemplate: thickness_m=0.18, supports_under_insulation=False, ), + "compacted_earth_floor": StructuralTemplate( + material_name="RammedEarth", + thickness_m=0.10, + supports_under_insulation=False, + ), } INTERIOR_FINISH_TEMPLATES: dict[SlabInteriorFinish, FinishTemplate | None] = { @@ -114,6 +124,10 @@ class FinishTemplate: material_name="SoftwoodGeneral", thickness_m=0.015, ), + "cement_screed": FinishTemplate( + material_name="CementMortar", + thickness_m=0.02, + ), } EXTERIOR_FINISH_TEMPLATES: dict[SlabExteriorFinish, FinishTemplate | None] = { @@ -141,6 +155,10 @@ class SemiFlatSlabConstruction(BaseModel): ge=0, title="Nominal slab insulation R-value [m²K/W]", ) + insulation_material: ContinuousInsulationMaterial = Field( + default="xps", + title="Slab insulation material", + ) insulation_placement: SlabInsulationPlacement = Field( default="auto", title="Slab insulation placement", @@ -216,6 +234,10 @@ def to_feature_dict(self, prefix: str = "Slab") -> dict[str, float]: features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( self.exterior_finish == exterior_finish ) + for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: + features[f"{prefix}InsulationMaterial__{ins_mat}"] = float( + self.insulation_material == ins_mat + ) return features @@ -225,6 +247,7 @@ def build_slab_assembly( name: str = "GroundSlabAssembly", ) -> ConstructionAssemblyComponent: """Translate semi-flat slab inputs into a concrete slab assembly.""" + # TODO: check the order of layers to make sure external vs internal is in correct order template = STRUCTURAL_TEMPLATES[slab.structural_system] layers: list[ConstructionLayerComponent] = [] layer_order = 0 @@ -240,13 +263,15 @@ def build_slab_assembly( ) layer_order += 1 + slab_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[slab.insulation_material] + if ( slab.effective_insulation_placement == "above_slab" and slab.effective_nominal_insulation_r > 0 ): layers.append( layer_from_nominal_r( - material="XPSBoard", + material=slab_ins_material, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) @@ -268,7 +293,7 @@ def build_slab_assembly( ): layers.append( layer_from_nominal_r( - material="XPSBoard", + material=slab_ins_material, nominal_r_value=slab.effective_nominal_insulation_r, layer_order=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 0caadd2..46637e9 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -10,6 +10,13 @@ ConstructionLayerComponent, ) from epinterface.sbem.flat_constructions.layers import ( + _AIR_GAP_THICKNESS_M, + AIR_GAP_WALL, + ALL_CONTINUOUS_INSULATION_MATERIALS, + ALL_EXTERIOR_CAVITY_TYPES, + CONTINUOUS_INSULATION_MATERIAL_MAP, + ContinuousInsulationMaterial, + ExteriorCavityType, equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, @@ -26,18 +33,31 @@ "woodframe_24oc", "deep_woodframe_24oc", "engineered_timber", + "timber_panel", "cmu", "double_layer_cmu", "precast_concrete", "poured_concrete", "masonry", + "cavity_masonry", "rammed_earth", + "thick_rammed_earth", "reinforced_concrete", "sip", "icf", + "aac", + "thick_aac", + "hollow_clay_block", + "thick_hollow_clay_block", + "sandcrete_block", + "thick_sandcrete_block", + "stabilized_soil_block", + "wattle_and_daub", ] -WallInteriorFinish = Literal["none", "drywall", "plaster", "wood_panel"] +WallInteriorFinish = Literal[ + "none", "drywall", "plaster", "cement_plaster", "wood_panel" +] WallExteriorFinish = Literal[ "none", "brick_veneer", @@ -203,6 +223,73 @@ class FinishTemplate: supports_cavity_insulation=False, cavity_depth_m=None, ), + "timber_panel": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.018, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "cavity_masonry": StructuralTemplate( + material_name="ConcreteBlockH", + thickness_m=0.100, + supports_cavity_insulation=True, + cavity_depth_m=0.075, + cavity_r_correction_factor=0.90, + ), + "thick_rammed_earth": StructuralTemplate( + material_name="RammedEarth", + thickness_m=0.500, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "aac": StructuralTemplate( + material_name="AACBlock", + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "thick_aac": StructuralTemplate( + material_name="AACBlock", + thickness_m=0.300, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "hollow_clay_block": StructuralTemplate( + material_name="HollowClayBlock", + thickness_m=0.250, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "thick_hollow_clay_block": StructuralTemplate( + material_name="HollowClayBlock", + thickness_m=0.365, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sandcrete_block": StructuralTemplate( + material_name="SandcreteBlock", + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "thick_sandcrete_block": StructuralTemplate( + material_name="SandcreteBlock", + thickness_m=0.225, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "stabilized_soil_block": StructuralTemplate( + material_name="StabilizedSoilBlock", + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "wattle_and_daub": StructuralTemplate( + material_name="WattleDaub", + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), } INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { @@ -215,6 +302,10 @@ class FinishTemplate: material_name="GypsumPlaster", thickness_m=0.013, ), + "cement_plaster": FinishTemplate( + material_name="CementMortar", + thickness_m=0.015, + ), "wood_panel": FinishTemplate( material_name="SoftwoodGeneral", thickness_m=0.012, @@ -276,6 +367,14 @@ class SemiFlatWallConstruction(BaseModel): ge=0, title="Nominal interior continuous insulation R-value [m²K/W]", ) + exterior_insulation_material: ContinuousInsulationMaterial = Field( + default="xps", + title="Exterior continuous insulation material", + ) + interior_insulation_material: ContinuousInsulationMaterial = Field( + default="xps", + title="Interior continuous insulation material", + ) interior_finish: WallInteriorFinish = Field( default="drywall", title="Interior finish selection", @@ -284,6 +383,10 @@ class SemiFlatWallConstruction(BaseModel): default="none", title="Exterior finish selection", ) + exterior_cavity_type: ExteriorCavityType = Field( + default="none", + title="Exterior ventilation cavity type per ISO 6946:2017 Section 6.9", + ) @property def effective_nominal_cavity_insulation_r(self) -> float: @@ -351,6 +454,17 @@ def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( self.exterior_finish == exterior_finish ) + for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: + features[f"{prefix}ExteriorInsulationMaterial__{ins_mat}"] = float( + self.exterior_insulation_material == ins_mat + ) + features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( + self.interior_insulation_material == ins_mat + ) + for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: + features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( + self.exterior_cavity_type == cavity_type + ) return features @@ -365,7 +479,11 @@ def build_facade_assembly( layer_order = 0 exterior_finish = EXTERIOR_FINISH_TEMPLATES[wall.exterior_finish] - if exterior_finish is not None: + + # ISO 6946:2017 Section 6.9 -- well-ventilated cavities: disregard cladding + # and air layer R. We omit the finish layer entirely; EnergyPlus applies its + # own exterior surface coefficient to the next layer inward. + if exterior_finish is not None and wall.exterior_cavity_type != "well_ventilated": layers.append( ConstructionLayerComponent( ConstructionMaterial=resolve_material(exterior_finish.material_name), @@ -375,10 +493,26 @@ def build_facade_assembly( ) layer_order += 1 + # ISO 6946:2017 Section 6.9 -- unventilated cavities: add equivalent + # still-air thermal resistance (~0.18 m2K/W for 25mm vertical gap). + # TODO: use a proper air gap material here + if wall.exterior_cavity_type == "unventilated" and exterior_finish is not None: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=AIR_GAP_WALL, + Thickness=_AIR_GAP_THICKNESS_M, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + if wall.nominal_exterior_insulation_r > 0: + ext_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[ + wall.exterior_insulation_material + ] layers.append( layer_from_nominal_r( - material="XPSBoard", + material=ext_ins_material, nominal_r_value=wall.nominal_exterior_insulation_r, layer_order=layer_order, ) @@ -421,13 +555,18 @@ def build_facade_assembly( ) layer_order += 1 - if wall.effective_nominal_cavity_insulation_r > 0: + if ( + wall.effective_nominal_cavity_insulation_r > 0 + and template.supports_cavity_insulation + ): effective_cavity_r = ( wall.effective_nominal_cavity_insulation_r * template.cavity_r_correction_factor ) layers.append( layer_from_nominal_r( + # TODO: make this configurable with a CavityInsulationMaterial field + # e.g. blown cellulose, fiberglass, etc. material="FiberglassBatt", nominal_r_value=effective_cavity_r, layer_order=layer_order, @@ -436,9 +575,12 @@ def build_facade_assembly( layer_order += 1 if wall.nominal_interior_insulation_r > 0: + int_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[ + wall.interior_insulation_material + ] layers.append( layer_from_nominal_r( - material="FiberglassBatt", + material=int_ins_material, nominal_r_value=wall.nominal_interior_insulation_r, layer_order=layer_order, ) diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index d936145..f0c3376 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -3,6 +3,10 @@ import pytest from epinterface.sbem.flat_constructions import build_envelope_assemblies +from epinterface.sbem.flat_constructions.layers import ( + ALL_CONTINUOUS_INSULATION_MATERIALS, + ALL_EXTERIOR_CAVITY_TYPES, +) from epinterface.sbem.flat_constructions.materials import ( CERAMIC_TILE, CONCRETE_RC_DENSE, @@ -31,7 +35,10 @@ from epinterface.sbem.flat_constructions.slabs import ( STRUCTURAL_TEMPLATES as SLAB_STRUCTURAL_TEMPLATES, ) -from epinterface.sbem.flat_constructions.walls import SemiFlatWallConstruction +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + build_facade_assembly, +) def test_build_roof_assembly_from_nominal_r_values() -> None: @@ -108,6 +115,8 @@ def test_roof_feature_dict_has_fixed_length() -> None: + len(ALL_ROOF_STRUCTURAL_SYSTEMS) + len(ALL_ROOF_INTERIOR_FINISHES) + len(ALL_ROOF_EXTERIOR_FINISHES) + + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 + + len(ALL_EXTERIOR_CAVITY_TYPES) ) assert len(features) == expected_length assert features["RoofStructuralSystem__steel_joist"] == 1.0 @@ -238,6 +247,7 @@ def test_slab_feature_dict_has_fixed_length() -> None: + len(ALL_SLAB_INSULATION_PLACEMENTS) * 2 + len(ALL_SLAB_INTERIOR_FINISHES) + len(ALL_SLAB_EXTERIOR_FINISHES) + + len(ALL_CONTINUOUS_INSULATION_MATERIALS) ) assert len(features) == expected_length assert features["SlabStructuralSystem__precast_hollow_core"] == 1.0 @@ -310,3 +320,191 @@ def test_wood_shake_exterior_finish_round_trip() -> None: assert outer_layer.ConstructionMaterial.Name == "SoftwoodGeneral" assert outer_layer.Thickness == pytest.approx(0.012) assert assembly.r_value > 0 + + +# --- Phase 4: new roof finishes --- + + +def test_thatch_roof_finish_round_trip() -> None: + """Thatch finish should produce a valid roof assembly with ThatchReed.""" + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + nominal_cavity_insulation_r=0.0, + exterior_finish="thatch", + interior_finish="none", + ) + assembly = build_roof_assembly(roof) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == "ThatchReed" + assert outer_layer.Thickness == pytest.approx(0.200) + assert assembly.r_value > 0 + + +def test_fiber_cement_sheet_roof_finish_round_trip() -> None: + """Fiber cement sheet finish should produce a thin corrugated layer.""" + roof = SemiFlatRoofConstruction( + structural_system="poured_concrete", + exterior_finish="fiber_cement_sheet", + ) + assembly = build_roof_assembly(roof) + outer_layer = assembly.sorted_layers[0] + assert outer_layer.ConstructionMaterial.Name == "FiberCementBoard" + assert outer_layer.Thickness == pytest.approx(0.006) + + +# --- Phase 2: roof insulation material selection --- + + +def test_roof_exterior_insulation_material_affects_assembly() -> None: + """Switching roof insulation material should change the layer material.""" + for mat_key, expected_name in [ + ("polyiso", "PolyisoBoard"), + ("eps", "EPSBoard"), + ("mineral_wool", "MineralWoolBoard"), + ]: + roof = SemiFlatRoofConstruction( + structural_system="poured_concrete", + nominal_exterior_insulation_r=2.0, + exterior_insulation_material=mat_key, # pyright: ignore[reportArgumentType] + ) + assembly = build_roof_assembly(roof) + ins_layers = [ + layer + for layer in assembly.sorted_layers + if layer.ConstructionMaterial.Name == expected_name + ] + assert len(ins_layers) == 1 + + +# --- Phase 6: roof ventilated cavity --- + + +def test_roof_well_ventilated_cavity_omits_exterior_finish() -> None: + """Well-ventilated roof cavity should omit the exterior finish per ISO 6946.""" + roof_vent = SemiFlatRoofConstruction( + structural_system="poured_concrete", + exterior_finish="tile_roof", + exterior_cavity_type="well_ventilated", + ) + roof_none = SemiFlatRoofConstruction( + structural_system="poured_concrete", + exterior_finish="tile_roof", + exterior_cavity_type="none", + ) + a_vent = build_roof_assembly(roof_vent) + a_none = build_roof_assembly(roof_none) + names_vent = [layer.ConstructionMaterial.Name for layer in a_vent.sorted_layers] + assert "CeramicTile" not in names_vent + assert a_vent.r_value < a_none.r_value + + +def test_roof_unventilated_cavity_adds_air_gap() -> None: + """Unventilated roof cavity should add an air gap layer.""" + roof = SemiFlatRoofConstruction( + structural_system="poured_concrete", + exterior_finish="metal_roof", + exterior_cavity_type="unventilated", + ) + assembly = build_roof_assembly(roof) + layer_names = [layer.ConstructionMaterial.Name for layer in assembly.sorted_layers] + assert any("AirGap" in n for n in layer_names) + + +# --- Phase 5: slab additions --- + + +def test_compacted_earth_floor_round_trip() -> None: + """Compacted earth floor should produce a valid assembly with RammedEarth.""" + slab = SemiFlatSlabConstruction( + structural_system="compacted_earth_floor", + interior_finish="none", + exterior_finish="none", + ) + assembly = build_slab_assembly(slab) + struct_layer = assembly.sorted_layers[0] + assert struct_layer.ConstructionMaterial.Name == "RammedEarth" + assert struct_layer.Thickness == pytest.approx(0.10) + + +def test_cement_screed_slab_finish_round_trip() -> None: + """Cement screed interior finish should map to CementMortar.""" + slab = SemiFlatSlabConstruction( + structural_system="slab_on_grade", + interior_finish="cement_screed", + ) + assembly = build_slab_assembly(slab) + inner_layer = assembly.sorted_layers[0] + assert inner_layer.ConstructionMaterial.Name == "CementMortar" + assert inner_layer.Thickness == pytest.approx(0.02) + + +def test_slab_insulation_material_affects_assembly() -> None: + """Switching slab insulation material should change the insulation layer.""" + slab_xps = SemiFlatSlabConstruction( + structural_system="slab_on_grade", + nominal_insulation_r=2.0, + insulation_material="xps", + ) + slab_eps = SemiFlatSlabConstruction( + structural_system="slab_on_grade", + nominal_insulation_r=2.0, + insulation_material="eps", + ) + a_xps = build_slab_assembly(slab_xps) + a_eps = build_slab_assembly(slab_eps) + xps_names = [layer.ConstructionMaterial.Name for layer in a_xps.sorted_layers] + eps_names = [layer.ConstructionMaterial.Name for layer in a_eps.sorted_layers] + assert "XPSBoard" in xps_names + assert "EPSBoard" in eps_names + + +# --- Informal settlement archetype integration tests --- + + +def test_corrugated_iron_shack_archetype() -> None: + """A corrugated iron shack should be expressible with existing + new systems.""" + wall = SemiFlatWallConstruction( + structural_system="sheet_metal", + interior_finish="none", + exterior_finish="none", + ) + roof = SemiFlatRoofConstruction( + structural_system="none", + exterior_finish="metal_roof", + interior_finish="none", + ) + slab = SemiFlatSlabConstruction( + structural_system="compacted_earth_floor", + interior_finish="none", + exterior_finish="none", + ) + wall_a = build_facade_assembly(wall) + roof_a = build_roof_assembly(roof) + slab_a = build_slab_assembly(slab) + assert wall_a.r_value > 0 + assert roof_a.r_value > 0 + assert slab_a.r_value > 0 + + +def test_mud_and_pole_with_cgi_roof_archetype() -> None: + """A mud-and-pole wall + corrugated iron roof should be expressible.""" + wall = SemiFlatWallConstruction( + structural_system="wattle_and_daub", + interior_finish="cement_plaster", + exterior_finish="none", + ) + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + exterior_finish="metal_roof", + interior_finish="none", + ) + slab = SemiFlatSlabConstruction( + structural_system="compacted_earth_floor", + interior_finish="cement_screed", + ) + wall_a = build_facade_assembly(wall) + roof_a = build_roof_assembly(roof) + slab_a = build_slab_assembly(slab) + assert wall_a.r_value > 0 + assert roof_a.r_value > 0 + assert slab_a.r_value > 0 diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index cc876fa..8e5ed7c 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -2,6 +2,10 @@ import pytest +from epinterface.sbem.flat_constructions.layers import ( + ALL_CONTINUOUS_INSULATION_MATERIALS, + ALL_EXTERIOR_CAVITY_TYPES, +) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CONCRETE_BLOCK_H, @@ -148,6 +152,8 @@ def test_wall_feature_dict_has_fixed_length() -> None: + len(ALL_WALL_STRUCTURAL_SYSTEMS) + len(ALL_WALL_INTERIOR_FINISHES) + len(ALL_WALL_EXTERIOR_FINISHES) + + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 + + len(ALL_EXTERIOR_CAVITY_TYPES) ) assert len(features) == expected_length assert features["FacadeStructuralSystem__deep_woodframe_24oc"] == 1.0 @@ -211,3 +217,156 @@ def test_wood_siding_and_stone_veneer_produce_valid_assemblies() -> None: outer_layer = assembly.sorted_layers[0] assert outer_layer.ConstructionMaterial.Name == mat_name assert assembly.r_value > 0 + + +# --- Phase 3: new structural systems --- + + +@pytest.mark.parametrize( + "system,expected_material,expected_thickness", + [ + ("aac", "AACBlock", 0.200), + ("thick_aac", "AACBlock", 0.300), + ("hollow_clay_block", "HollowClayBlock", 0.250), + ("thick_hollow_clay_block", "HollowClayBlock", 0.365), + ("sandcrete_block", "SandcreteBlock", 0.150), + ("thick_sandcrete_block", "SandcreteBlock", 0.225), + ("stabilized_soil_block", "StabilizedSoilBlock", 0.150), + ("wattle_and_daub", "WattleDaub", 0.150), + ("timber_panel", "SoftwoodGeneral", 0.018), + ("thick_rammed_earth", "RammedEarth", 0.500), + ], +) +def test_new_structural_systems_produce_valid_assemblies( + system: str, expected_material: str, expected_thickness: float +) -> None: + """Each new structural system should produce a valid assembly with the correct core.""" + wall = SemiFlatWallConstruction( + structural_system=system, # pyright: ignore[reportArgumentType] + interior_finish="none", + exterior_finish="none", + ) + assembly = build_facade_assembly(wall) + assert assembly.r_value > 0 + struct_layer = assembly.sorted_layers[0] + assert struct_layer.ConstructionMaterial.Name == expected_material + assert struct_layer.Thickness == pytest.approx(expected_thickness) + + +def test_thick_variants_have_higher_r_than_thin() -> None: + """Thicker variants of the same material should have higher structural R.""" + pairs = [ + ("aac", "thick_aac"), + ("hollow_clay_block", "thick_hollow_clay_block"), + ("sandcrete_block", "thick_sandcrete_block"), + ("rammed_earth", "thick_rammed_earth"), + ] + for thin, thick in pairs: + thin_wall = SemiFlatWallConstruction( + structural_system=thin, # pyright: ignore[reportArgumentType] + interior_finish="none", + exterior_finish="none", + ) + thick_wall = SemiFlatWallConstruction( + structural_system=thick, # pyright: ignore[reportArgumentType] + interior_finish="none", + exterior_finish="none", + ) + thin_r = build_facade_assembly(thin_wall).r_value + thick_r = build_facade_assembly(thick_wall).r_value + assert thick_r > thin_r, ( + f"{thick} R ({thick_r:.3f}) should exceed {thin} R ({thin_r:.3f})" + ) + + +def test_cavity_masonry_supports_cavity_insulation() -> None: + """Cavity masonry should accept cavity insulation like CMU does.""" + wall = SemiFlatWallConstruction( + structural_system="cavity_masonry", + nominal_cavity_insulation_r=1.0, + exterior_finish="brick_veneer", + interior_finish="cement_plaster", + ) + assembly = build_facade_assembly(wall) + layer_names = [layer.ConstructionMaterial.Name for layer in assembly.sorted_layers] + assert "CementMortar" in layer_names + assert assembly.r_value > 1.0 + + +def test_cement_plaster_interior_finish() -> None: + """Cement plaster interior finish should map to CementMortar.""" + wall = SemiFlatWallConstruction( + structural_system="masonry", + interior_finish="cement_plaster", + ) + assembly = build_facade_assembly(wall) + inner_layer = assembly.sorted_layers[-1] + assert inner_layer.ConstructionMaterial.Name == "CementMortar" + assert inner_layer.Thickness == pytest.approx(0.015) + + +# --- Phase 2: insulation material selection --- + + +def test_exterior_insulation_material_affects_assembly() -> None: + """Switching exterior insulation material should change the layer material.""" + for mat_key, expected_name in [ + ("xps", "XPSBoard"), + ("eps", "EPSBoard"), + ("mineral_wool", "MineralWoolBoard"), + ("polyiso", "PolyisoBoard"), + ]: + wall = SemiFlatWallConstruction( + structural_system="masonry", + nominal_exterior_insulation_r=2.0, + exterior_insulation_material=mat_key, # pyright: ignore[reportArgumentType] + exterior_finish="stucco", + ) + assembly = build_facade_assembly(wall) + ins_layer = assembly.sorted_layers[1] + assert ins_layer.ConstructionMaterial.Name == expected_name + + +# --- Phase 6: ventilated cavity --- + + +def test_well_ventilated_cavity_omits_exterior_finish() -> None: + """Well-ventilated cavity should omit the exterior finish layer per ISO 6946.""" + wall_none = SemiFlatWallConstruction( + structural_system="poured_concrete", + exterior_finish="stucco", + exterior_cavity_type="none", + ) + wall_vent = SemiFlatWallConstruction( + structural_system="poured_concrete", + exterior_finish="stucco", + exterior_cavity_type="well_ventilated", + ) + a_none = build_facade_assembly(wall_none) + a_vent = build_facade_assembly(wall_vent) + names_none = [layer.ConstructionMaterial.Name for layer in a_none.sorted_layers] + names_vent = [layer.ConstructionMaterial.Name for layer in a_vent.sorted_layers] + assert names_none[0] == "CementMortar" + assert names_vent[0] != "CementMortar" + assert a_vent.r_value < a_none.r_value + + +def test_unventilated_cavity_adds_air_gap() -> None: + """Unventilated cavity should add an air gap layer between finish and insulation.""" + wall = SemiFlatWallConstruction( + structural_system="masonry", + exterior_finish="brick_veneer", + nominal_exterior_insulation_r=1.0, + exterior_cavity_type="unventilated", + ) + assembly = build_facade_assembly(wall) + layer_names = [layer.ConstructionMaterial.Name for layer in assembly.sorted_layers] + assert any("AirGap" in n for n in layer_names) + wall_no_cavity = SemiFlatWallConstruction( + structural_system="masonry", + exterior_finish="brick_veneer", + nominal_exterior_insulation_r=1.0, + exterior_cavity_type="none", + ) + r_no_cavity = build_facade_assembly(wall_no_cavity).r_value + assert assembly.r_value > r_no_cavity From 3c96ef89db13c1e019d12b2e34c3536bb310da80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 18:53:13 +0000 Subject: [PATCH 13/18] Ensure assembly layers are ordered outermost to innermost Co-authored-by: Sam Wolk --- epinterface/sbem/flat_constructions/roofs.py | 1 + epinterface/sbem/flat_constructions/slabs.py | 22 +++++++++---------- epinterface/sbem/flat_constructions/walls.py | 1 + .../test_roofs_slabs.py | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index aa24901..8017b81 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -342,6 +342,7 @@ def build_roof_assembly( name: str = "Roof", ) -> ConstructionAssemblyComponent: """Translate semi-flat roof inputs into a concrete roof assembly.""" + # EnergyPlus convention: layer 0 is outermost (outside -> inside). template = STRUCTURAL_TEMPLATES[roof.structural_system] layers: list[ConstructionLayerComponent] = [] layer_order = 0 diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index 5d9c747..ccb024e 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -247,17 +247,17 @@ def build_slab_assembly( name: str = "GroundSlabAssembly", ) -> ConstructionAssemblyComponent: """Translate semi-flat slab inputs into a concrete slab assembly.""" - # TODO: check the order of layers to make sure external vs internal is in correct order + # EnergyPlus convention: layer 0 is outermost (outside -> inside). template = STRUCTURAL_TEMPLATES[slab.structural_system] layers: list[ConstructionLayerComponent] = [] layer_order = 0 - interior_finish = INTERIOR_FINISH_TEMPLATES[slab.interior_finish] - if interior_finish is not None: + exterior_finish = EXTERIOR_FINISH_TEMPLATES[slab.exterior_finish] + if exterior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(interior_finish.material_name), - Thickness=interior_finish.thickness_m, + ConstructionMaterial=resolve_material(exterior_finish.material_name), + Thickness=exterior_finish.thickness_m, LayerOrder=layer_order, ) ) @@ -266,7 +266,7 @@ def build_slab_assembly( slab_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[slab.insulation_material] if ( - slab.effective_insulation_placement == "above_slab" + slab.effective_insulation_placement == "under_slab" and slab.effective_nominal_insulation_r > 0 ): layers.append( @@ -288,7 +288,7 @@ def build_slab_assembly( layer_order += 1 if ( - slab.effective_insulation_placement == "under_slab" + slab.effective_insulation_placement == "above_slab" and slab.effective_nominal_insulation_r > 0 ): layers.append( @@ -300,12 +300,12 @@ def build_slab_assembly( ) layer_order += 1 - exterior_finish = EXTERIOR_FINISH_TEMPLATES[slab.exterior_finish] - if exterior_finish is not None: + interior_finish = INTERIOR_FINISH_TEMPLATES[slab.interior_finish] + if interior_finish is not None: layers.append( ConstructionLayerComponent( - ConstructionMaterial=resolve_material(exterior_finish.material_name), - Thickness=exterior_finish.thickness_m, + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, LayerOrder=layer_order, ) ) diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 46637e9..2078fb7 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -474,6 +474,7 @@ def build_facade_assembly( name: str = "Facade", ) -> ConstructionAssemblyComponent: """Translate semi-flat wall inputs into a concrete facade assembly.""" + # EnergyPlus convention: layer 0 is outermost (outside -> inside). template = STRUCTURAL_TEMPLATES[wall.structural_system] layers: list[ConstructionLayerComponent] = [] layer_order = 0 diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index f0c3376..5366af5 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -433,7 +433,7 @@ def test_cement_screed_slab_finish_round_trip() -> None: interior_finish="cement_screed", ) assembly = build_slab_assembly(slab) - inner_layer = assembly.sorted_layers[0] + inner_layer = assembly.sorted_layers[-1] assert inner_layer.ConstructionMaterial.Name == "CementMortar" assert inner_layer.Thickness == pytest.approx(0.02) From a136df7399bfdc4aa89c98abcb39467c55110aae Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:11:27 -0500 Subject: [PATCH 14/18] add more types and blended materials --- epinterface/sbem/components/envelope.py | 27 +++- .../sbem/flat_constructions/__init__.py | 6 + epinterface/sbem/flat_constructions/layers.py | 30 ++-- .../sbem/flat_constructions/materials.py | 120 ++++++++++++++++ epinterface/sbem/flat_constructions/roofs.py | 40 +++++- epinterface/sbem/flat_constructions/slabs.py | 6 + epinterface/sbem/flat_constructions/walls.py | 135 ++++++++++++++++-- epinterface/sbem/flat_model.py | 39 +++-- .../test_roofs_slabs.py | 2 + tests/test_flat_constructions/test_walls.py | 2 + 10 files changed, 371 insertions(+), 36 deletions(-) diff --git a/epinterface/sbem/components/envelope.py b/epinterface/sbem/components/envelope.py index be2853c..afc8f1f 100644 --- a/epinterface/sbem/components/envelope.py +++ b/epinterface/sbem/components/envelope.py @@ -1,5 +1,6 @@ """Envelope components for the SBEM library.""" +import warnings from typing import Literal from archetypal.idfclass import IDF @@ -177,14 +178,34 @@ def name(self): # return f"{self.LayerOrder}_{self.ConstructionMaterial.Name}_{self.Thickness}m" return f"{self.ConstructionMaterial.Name}_{self.Thickness}m" + _MIN_EP_THICKNESS = 0.003 + @property def ep_material(self): - """Return the EP material for the layer.""" + """Return the EP material for the layer. + + EnergyPlus Material objects require a minimum thickness of 3mm. + For thinner layers, thickness is clamped to 3mm and density is + adjusted to preserve thermal mass per unit area. + """ + thickness = self.Thickness + density = self.ConstructionMaterial.Density + + if thickness < self._MIN_EP_THICKNESS and thickness > 0: + warnings.warn( + f"Layer '{self.ConstructionMaterial.Name}' thickness " + f"{thickness * 1000:.1f}mm is below the EnergyPlus 3mm minimum. " + f"Clamping to 3mm with adjusted density to preserve thermal mass.", + stacklevel=2, + ) + density = density * thickness / self._MIN_EP_THICKNESS + thickness = self._MIN_EP_THICKNESS + return Material( Name=self.name, - Thickness=self.Thickness, + Thickness=thickness, Conductivity=self.ConstructionMaterial.Conductivity, - Density=self.ConstructionMaterial.Density, + Density=density, Specific_Heat=self.ConstructionMaterial.SpecificHeat, Thermal_Absorptance=self.ConstructionMaterial.ThermalAbsorptance, Solar_Absorptance=self.ConstructionMaterial.SolarAbsorptance, diff --git a/epinterface/sbem/flat_constructions/__init__.py b/epinterface/sbem/flat_constructions/__init__.py index ddeebe4..997d64d 100644 --- a/epinterface/sbem/flat_constructions/__init__.py +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -6,6 +6,9 @@ run_physical_sanity_audit, ) from epinterface.sbem.flat_constructions.layers import ( + CavityInsulationMaterial, + ContinuousInsulationMaterial, + ExteriorCavityType, MaterialRef, equivalent_framed_cavity_material, layer_from_nominal_r, @@ -41,6 +44,9 @@ __all__ = [ "MATERIAL_NAME_VALUES", "AuditIssue", + "CavityInsulationMaterial", + "ContinuousInsulationMaterial", + "ExteriorCavityType", "MaterialName", "MaterialRef", "RoofExteriorFinish", diff --git a/epinterface/sbem/flat_constructions/layers.py b/epinterface/sbem/flat_constructions/layers.py index 35afa6d..d4c1507 100644 --- a/epinterface/sbem/flat_constructions/layers.py +++ b/epinterface/sbem/flat_constructions/layers.py @@ -15,7 +15,6 @@ ContinuousInsulationMaterial = Literal["xps", "polyiso", "eps", "mineral_wool"] ALL_CONTINUOUS_INSULATION_MATERIALS = get_args(ContinuousInsulationMaterial) -# TODO: do we really need this map? CONTINUOUS_INSULATION_MATERIAL_MAP: dict[ContinuousInsulationMaterial, MaterialName] = { "xps": "XPSBoard", "polyiso": "PolyisoBoard", @@ -23,18 +22,28 @@ "mineral_wool": "MineralWoolBoard", } +CavityInsulationMaterial = Literal["fiberglass", "mineral_wool", "cellulose"] +ALL_CAVITY_INSULATION_MATERIALS = get_args(CavityInsulationMaterial) + +CAVITY_INSULATION_MATERIAL_MAP: dict[CavityInsulationMaterial, MaterialName] = { + "fiberglass": "FiberglassBatt", + "mineral_wool": "MineralWoolBatt", + "cellulose": "CelluloseBatt", +} + ExteriorCavityType = Literal["none", "unventilated", "well_ventilated"] ALL_EXTERIOR_CAVITY_TYPES = get_args(ExteriorCavityType) -# ISO 6946:2017 Table 2 -- thermal resistance of unventilated air layers. -# Vertical (walls): ~0.18 m2K/W for 25mm gap. -# Horizontal heat-flow-up (roofs): ~0.16 m2K/W for 25mm gap. -UNVENTILATED_AIR_R_WALL = 0.18 -UNVENTILATED_AIR_R_ROOF = 0.16 +# ISO 6946:2017 Section 6.2, Table 2 -- thermal resistance of unventilated +# air layers. Values are for sealed cavities with high-emissivity surfaces. +# For well-ventilated cavities, ISO 6946:2017 Section 6.9 specifies that +# the cladding and air-layer R are disregarded entirely (handled by the +# builder omitting the finish layer in that case). +UNVENTILATED_AIR_R_WALL = 0.18 # vertical, 25mm gap +UNVENTILATED_AIR_R_ROOF = 0.16 # horizontal (heat-flow-up), 25mm gap _AIR_GAP_THICKNESS_M = 0.025 -# TODO: should this be a NoMass or AirGap Material instead? Also, make sure it is not on the outside! def _make_air_gap_material(r_value: float) -> ConstructionMaterialComponent: """Create a virtual material representing an unventilated air gap.""" effective_conductivity = _AIR_GAP_THICKNESS_M / r_value @@ -86,6 +95,7 @@ def equivalent_framed_cavity_material( nominal_cavity_insulation_r: float, uninsulated_cavity_r_value: float, framing_path_r_value: float | None = None, + cavity_insulation_material: MaterialRef = FIBERGLASS_BATTS, ) -> ConstructionMaterialComponent: """Create an equivalent material for a framed cavity layer. @@ -94,6 +104,7 @@ def equivalent_framed_cavity_material( where R_fill is nominal cavity insulation R (or an uninsulated fallback). """ resolved_framing_material = resolve_material(framing_material) + resolved_cavity_insulation = resolve_material(cavity_insulation_material) fill_r = ( nominal_cavity_insulation_r if nominal_cavity_insulation_r > 0 @@ -104,17 +115,18 @@ def equivalent_framed_cavity_material( if framing_path_r_value is not None else cavity_depth_m / resolved_framing_material.Conductivity ) + # TODO: Not currently dealing with AirGap when thicknesses are implicitly unequal. u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r r_eq = 1 / u_eq conductivity_eq = cavity_depth_m / r_eq density_eq = ( framing_fraction * resolved_framing_material.Density - + (1 - framing_fraction) * FIBERGLASS_BATTS.Density + + (1 - framing_fraction) * resolved_cavity_insulation.Density ) specific_heat_eq = ( framing_fraction * resolved_framing_material.SpecificHeat - + (1 - framing_fraction) * FIBERGLASS_BATTS.SpecificHeat + + (1 - framing_fraction) * resolved_cavity_insulation.SpecificHeat ) return ConstructionMaterialComponent( diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py index 1b0f816..a416d34 100644 --- a/epinterface/sbem/flat_constructions/materials.py +++ b/epinterface/sbem/flat_constructions/materials.py @@ -36,6 +36,14 @@ "StabilizedSoilBlock", "WattleDaub", "ThatchReed", + "AdobeBlock", + "CompressedEarthBlock", + "CobEarth", + "BambooComposite", + "CelluloseBatt", + "MineralWoolBatt", + "ConfinedMasonryEffective", + "RCFrameInfillEffective", ] MATERIAL_NAME_VALUES: tuple[MaterialName, ...] = get_args(MaterialName) @@ -373,6 +381,110 @@ def _material( roughness="Rough", ) +ADOBE_BLOCK = _material( + name="AdobeBlock", + conductivity=0.60, + density=1500, + specific_heat=900, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +COMPRESSED_EARTH_BLOCK = _material( + name="CompressedEarthBlock", + conductivity=0.80, + density=1800, + specific_heat=900, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +COB_EARTH = _material( + name="CobEarth", + conductivity=0.80, + density=1550, + specific_heat=1000, + mat_type="Bio", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +BAMBOO_COMPOSITE = _material( + name="BambooComposite", + conductivity=0.17, + density=700, + specific_heat=1200, + mat_type="Bio", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +CELLULOSE_BATT = _material( + name="CelluloseBatt", + conductivity=0.040, + density=50, + specific_heat=1380, + mat_type="Insulation", +) + +MINERAL_WOOL_BATT = _material( + name="MineralWoolBatt", + conductivity=0.038, + density=30, + specific_heat=840, + mat_type="Insulation", +) + + +def _blended_material( + *, + name: str, + mat_a: ConstructionMaterialComponent, + mat_b: ConstructionMaterialComponent, + fraction_a: float, + mat_type: str, +) -> ConstructionMaterialComponent: + """Create an area-weighted blended material from two side-by-side constituents. + + Uses parallel-path (arithmetic mean) for conductivity, matching the + physics of `equivalent_framed_cavity_material` where heat flows through + whichever material it encounters at a given point on the wall face. + """ + fb = 1.0 - fraction_a + k_eq = fraction_a * mat_a.Conductivity + fb * mat_b.Conductivity + return _material( + name=name, + conductivity=k_eq, + density=fraction_a * mat_a.Density + fb * mat_b.Density, + specific_heat=fraction_a * mat_a.SpecificHeat + fb * mat_b.SpecificHeat, + mat_type=mat_type, + solar_absorptance=( + fraction_a * mat_a.SolarAbsorptance + fb * mat_b.SolarAbsorptance + ), + visible_absorptance=( + fraction_a * mat_a.VisibleAbsorptance + fb * mat_b.VisibleAbsorptance + ), + ) + + +CONFINED_MASONRY_EFFECTIVE = _blended_material( + name="ConfinedMasonryEffective", + mat_a=CLAY_BRICK, + mat_b=CONCRETE_RC_DENSE, + fraction_a=0.85, + mat_type="Masonry", +) + +RC_FRAME_INFILL_EFFECTIVE = _blended_material( + name="RCFrameInfillEffective", + mat_a=CLAY_BRICK, + mat_b=CONCRETE_RC_DENSE, + fraction_a=0.75, + mat_type="Masonry", +) + _ALL_MATERIALS = ( XPS_BOARD, POLYISO_BOARD, @@ -405,6 +517,14 @@ def _material( STABILIZED_SOIL_BLOCK, WATTLE_DAUB, THATCH_REED, + ADOBE_BLOCK, + COMPRESSED_EARTH_BLOCK, + COB_EARTH, + BAMBOO_COMPOSITE, + CELLULOSE_BATT, + MINERAL_WOOL_BATT, + CONFINED_MASONRY_EFFECTIVE, + RC_FRAME_INFILL_EFFECTIVE, ) MATERIALS_BY_NAME: dict[MaterialName, ConstructionMaterialComponent] = { diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 8017b81..0768039 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -12,16 +12,19 @@ from epinterface.sbem.flat_constructions.layers import ( _AIR_GAP_THICKNESS_M, AIR_GAP_ROOF, + ALL_CAVITY_INSULATION_MATERIALS, ALL_CONTINUOUS_INSULATION_MATERIALS, ALL_EXTERIOR_CAVITY_TYPES, + CAVITY_INSULATION_MATERIAL_MAP, CONTINUOUS_INSULATION_MATERIAL_MAP, + CavityInsulationMaterial, ContinuousInsulationMaterial, ExteriorCavityType, equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, ) -from epinterface.sbem.flat_constructions.materials import FIBERGLASS_BATTS, MaterialName +from epinterface.sbem.flat_constructions.materials import MaterialName RoofStructuralSystem = Literal[ "none", @@ -34,6 +37,7 @@ "poured_concrete", "reinforced_concrete", "sip", + "corrugated_metal", ] RoofInteriorFinish = Literal[ @@ -113,7 +117,9 @@ class FinishTemplate: cavity_depth_m=0.180, framing_material_name="SteelPanel", framing_fraction=0.08, - # Calibrated to reproduce ~60-65% effective batt R for steel-joist roofs. + # Calibrated to reproduce ~60-65% effective batt R for steel-joist roofs + # with 7in (180mm) joist depth at typical spacing. Not directly applicable + # to EU lightweight steel roof framing conventions. # References: # - ASHRAE Standard 90.1 Appendix A (metal-framing correction methodology) # - COMcheck steel-framed roof U-factor datasets (effective-R behavior) @@ -155,6 +161,12 @@ class FinishTemplate: supports_cavity_insulation=False, cavity_depth_m=None, ), + "corrugated_metal": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.0005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), } INTERIOR_FINISH_TEMPLATES: dict[RoofInteriorFinish, FinishTemplate | None] = { @@ -244,6 +256,10 @@ class SemiFlatRoofConstruction(BaseModel): default="polyiso", title="Interior continuous roof insulation material", ) + cavity_insulation_material: CavityInsulationMaterial = Field( + default="fiberglass", + title="Cavity insulation material for framed roof systems", + ) interior_finish: RoofInteriorFinish = Field( default="gypsum_board", title="Interior roof finish selection", @@ -288,8 +304,11 @@ def validate_cavity_r_against_assumed_depth(self): ): return self - assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity - max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity + cavity_mat_name = CAVITY_INSULATION_MATERIAL_MAP[ + self.cavity_insulation_material + ] + cavity_mat = resolve_material(cavity_mat_name) + max_nominal_r = template.cavity_depth_m / cavity_mat.Conductivity tolerance_r = 0.2 if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: msg = ( @@ -329,6 +348,10 @@ def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( self.interior_insulation_material == ins_mat ) + for cav_ins_mat in ALL_CAVITY_INSULATION_MATERIALS: + features[f"{prefix}CavityInsulationMaterial__{cav_ins_mat}"] = float( + self.cavity_insulation_material == cav_ins_mat + ) for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( self.exterior_cavity_type == cavity_type @@ -394,6 +417,10 @@ def build_roof_assembly( and template.framing_fraction is not None ) + cavity_ins_mat_name = CAVITY_INSULATION_MATERIAL_MAP[ + roof.cavity_insulation_material + ] + if uses_framed_cavity_consolidation: consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=roof.structural_system, @@ -403,6 +430,7 @@ def build_roof_assembly( framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=roof.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, + cavity_insulation_material=cavity_ins_mat_name, ) layers.append( ConstructionLayerComponent( @@ -433,9 +461,7 @@ def build_roof_assembly( ) layers.append( layer_from_nominal_r( - # TODO: make this configurable with a CavityInsulationMaterial field - # e.g. blown cellulose, fiberglass, etc. - material="FiberglassBatt", + material=cavity_ins_mat_name, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index ccb024e..d2dc572 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -27,6 +27,7 @@ "mass_timber_deck", "sip_floor", "compacted_earth_floor", + "suspended_timber_floor", ] SlabInsulationPlacement = Literal["auto", "under_slab", "above_slab"] @@ -104,6 +105,11 @@ class FinishTemplate: thickness_m=0.10, supports_under_insulation=False, ), + "suspended_timber_floor": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.022, + supports_under_insulation=True, + ), } INTERIOR_FINISH_TEMPLATES: dict[SlabInteriorFinish, FinishTemplate | None] = { diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 2078fb7..626eb7c 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -12,16 +12,19 @@ from epinterface.sbem.flat_constructions.layers import ( _AIR_GAP_THICKNESS_M, AIR_GAP_WALL, + ALL_CAVITY_INSULATION_MATERIALS, ALL_CONTINUOUS_INSULATION_MATERIALS, ALL_EXTERIOR_CAVITY_TYPES, + CAVITY_INSULATION_MATERIAL_MAP, CONTINUOUS_INSULATION_MATERIAL_MAP, + CavityInsulationMaterial, ContinuousInsulationMaterial, ExteriorCavityType, equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, ) -from epinterface.sbem.flat_constructions.materials import FIBERGLASS_BATTS, MaterialName +from epinterface.sbem.flat_constructions.materials import MaterialName WallStructuralSystem = Literal[ "none", @@ -53,6 +56,16 @@ "thick_sandcrete_block", "stabilized_soil_block", "wattle_and_daub", + "adobe_block", + "compressed_earth_block", + "cob", + "laterite_stone", + "stone_wall", + "confined_masonry", + "rc_frame_masonry_infill", + "corrugated_metal_sheet", + "insulated_metal_panel", + "bamboo_frame", ] WallInteriorFinish = Literal[ @@ -117,7 +130,9 @@ class FinishTemplate: cavity_depth_m=0.090, framing_material_name="SteelPanel", framing_fraction=0.12, - # Calibrated to reproduce ~55% effective batt R for 3.5in steel-stud walls. + # Calibrated to reproduce ~55% effective batt R for 3.5in (89mm) steel-stud + # walls at 16" o.c. spacing. Not directly applicable to EU lightweight steel + # framing, which uses different stud profiles and spacing conventions. # References: # - ASHRAE Standard 90.1 Appendix A (metal-framing correction methodology) # - COMcheck steel-framed wall U-factor datasets (effective-R behavior) @@ -229,6 +244,10 @@ class FinishTemplate: supports_cavity_insulation=False, cavity_depth_m=None, ), + # UK/EU two-leaf cavity wall: inner leaf (block) + cavity + outer leaf (brick + # veneer via exterior finish). The template represents the inner leaf; the + # cavity supports injected or partial-fill insulation. Outer leaf is applied + # via the exterior_finish field (e.g. "brick_veneer"). "cavity_masonry": StructuralTemplate( material_name="ConcreteBlockH", thickness_m=0.100, @@ -290,6 +309,70 @@ class FinishTemplate: supports_cavity_insulation=False, cavity_depth_m=None, ), + "adobe_block": StructuralTemplate( + material_name="AdobeBlock", + thickness_m=0.300, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "compressed_earth_block": StructuralTemplate( + material_name="CompressedEarthBlock", + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "cob": StructuralTemplate( + material_name="CobEarth", + thickness_m=0.400, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "laterite_stone": StructuralTemplate( + material_name="NaturalStone", + thickness_m=0.300, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "stone_wall": StructuralTemplate( + material_name="NaturalStone", + thickness_m=0.450, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + # Confined masonry: masonry panels confined by RC tie-columns/beams. + # Area-weighted blend of ~85% clay brick + ~15% RC concrete. + "confined_masonry": StructuralTemplate( + material_name="ConfinedMasonryEffective", + thickness_m=0.150, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + # RC frame with masonry infill: ~75% masonry + ~25% RC frame members. + "rc_frame_masonry_infill": StructuralTemplate( + material_name="RCFrameInfillEffective", + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "corrugated_metal_sheet": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.0005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + # IMP: foam core only; builder emits outer/inner steel skins separately. + "insulated_metal_panel": StructuralTemplate( + material_name="SIPCore", + thickness_m=0.075, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "bamboo_frame": StructuralTemplate( + material_name="BambooComposite", + thickness_m=0.050, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), } INTERIOR_FINISH_TEMPLATES: dict[WallInteriorFinish, FinishTemplate | None] = { @@ -375,6 +458,10 @@ class SemiFlatWallConstruction(BaseModel): default="xps", title="Interior continuous insulation material", ) + cavity_insulation_material: CavityInsulationMaterial = Field( + default="fiberglass", + title="Cavity insulation material for framed/cavity systems", + ) interior_finish: WallInteriorFinish = Field( default="drywall", title="Interior finish selection", @@ -419,8 +506,11 @@ def validate_cavity_r_against_assumed_depth(self): ): return self - assumed_cavity_insulation_conductivity = FIBERGLASS_BATTS.Conductivity - max_nominal_r = template.cavity_depth_m / assumed_cavity_insulation_conductivity + cavity_mat_name = CAVITY_INSULATION_MATERIAL_MAP[ + self.cavity_insulation_material + ] + cavity_mat = resolve_material(cavity_mat_name) + max_nominal_r = template.cavity_depth_m / cavity_mat.Conductivity tolerance_r = 0.2 if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: msg = ( @@ -461,6 +551,10 @@ def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( self.interior_insulation_material == ins_mat ) + for cav_ins_mat in ALL_CAVITY_INSULATION_MATERIALS: + features[f"{prefix}CavityInsulationMaterial__{cav_ins_mat}"] = float( + self.cavity_insulation_material == cav_ins_mat + ) for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( self.exterior_cavity_type == cavity_type @@ -468,7 +562,7 @@ def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: return features -def build_facade_assembly( +def build_facade_assembly( # noqa: C901 wall: SemiFlatWallConstruction, *, name: str = "Facade", @@ -527,6 +621,10 @@ def build_facade_assembly( and template.framing_fraction is not None ) + cavity_ins_mat_name = CAVITY_INSULATION_MATERIAL_MAP[ + wall.cavity_insulation_material + ] + if uses_framed_cavity_consolidation: consolidated_cavity_material = equivalent_framed_cavity_material( structural_system=wall.structural_system, @@ -536,6 +634,7 @@ def build_facade_assembly( framing_path_r_value=template.framing_path_r_value, nominal_cavity_insulation_r=wall.effective_nominal_cavity_insulation_r, uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, + cavity_insulation_material=cavity_ins_mat_name, ) layers.append( ConstructionLayerComponent( @@ -546,6 +645,18 @@ def build_facade_assembly( ) layer_order += 1 else: + is_imp = wall.structural_system == "insulated_metal_panel" + _IMP_SKIN_THICKNESS = 0.0005 + if is_imp: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material("SteelPanel"), + Thickness=_IMP_SKIN_THICKNESS, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + if template.thickness_m > 0: layers.append( ConstructionLayerComponent( @@ -556,6 +667,16 @@ def build_facade_assembly( ) layer_order += 1 + if is_imp: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material("SteelPanel"), + Thickness=_IMP_SKIN_THICKNESS, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + if ( wall.effective_nominal_cavity_insulation_r > 0 and template.supports_cavity_insulation @@ -566,9 +687,7 @@ def build_facade_assembly( ) layers.append( layer_from_nominal_r( - # TODO: make this configurable with a CavityInsulationMaterial field - # e.g. blown cellulose, fiberglass, etc. - material="FiberglassBatt", + material=cavity_ins_mat_name, nominal_r_value=effective_cavity_r, layer_order=layer_order, ) diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index 36a0f0f..d52b99e 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -44,15 +44,9 @@ ) from epinterface.sbem.components.zones import ZoneComponent from epinterface.sbem.flat_constructions import ( - RoofExteriorFinish as RoofExteriorFinishType, -) -from epinterface.sbem.flat_constructions import ( - RoofInteriorFinish as RoofInteriorFinishType, -) -from epinterface.sbem.flat_constructions import ( - RoofStructuralSystem as RoofStructuralSystemType, -) -from epinterface.sbem.flat_constructions import ( + CavityInsulationMaterial, + ContinuousInsulationMaterial, + ExteriorCavityType, SemiFlatRoofConstruction, SemiFlatSlabConstruction, SemiFlatWallConstruction, @@ -61,6 +55,15 @@ WallStructuralSystem, build_envelope_assemblies, ) +from epinterface.sbem.flat_constructions import ( + RoofExteriorFinish as RoofExteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + RoofInteriorFinish as RoofInteriorFinishType, +) +from epinterface.sbem.flat_constructions import ( + RoofStructuralSystem as RoofStructuralSystemType, +) from epinterface.sbem.flat_constructions import ( SlabExteriorFinish as SlabExteriorFinishType, ) @@ -789,6 +792,10 @@ class FlatModel(BaseModel): FacadeCavityInsulationRValue: float = Field(default=0, ge=0) FacadeExteriorInsulationRValue: float = Field(default=0, ge=0) FacadeInteriorInsulationRValue: float = Field(default=0, ge=0) + FacadeExteriorInsulationMaterial: ContinuousInsulationMaterial = "xps" + FacadeInteriorInsulationMaterial: ContinuousInsulationMaterial = "xps" + FacadeCavityInsulationMaterial: CavityInsulationMaterial = "fiberglass" + FacadeExteriorCavityType: ExteriorCavityType = "none" FacadeInteriorFinish: WallInteriorFinish = "drywall" FacadeExteriorFinish: WallExteriorFinish = "none" @@ -796,11 +803,16 @@ class FlatModel(BaseModel): RoofCavityInsulationRValue: float = Field(default=0, ge=0) RoofExteriorInsulationRValue: float = Field(default=2.5, ge=0) RoofInteriorInsulationRValue: float = Field(default=0, ge=0) + RoofExteriorInsulationMaterial: ContinuousInsulationMaterial = "polyiso" + RoofInteriorInsulationMaterial: ContinuousInsulationMaterial = "polyiso" + RoofCavityInsulationMaterial: CavityInsulationMaterial = "fiberglass" + RoofExteriorCavityType: ExteriorCavityType = "none" RoofInteriorFinish: RoofInteriorFinishType = "gypsum_board" RoofExteriorFinish: RoofExteriorFinishType = "epdm_membrane" SlabStructuralSystem: SlabStructuralSystemType = "slab_on_grade" SlabInsulationRValue: float = Field(default=1.5, ge=0) + SlabInsulationMaterial: ContinuousInsulationMaterial = "xps" SlabInsulationPlacement: SlabInsulationPlacementType = "auto" SlabInteriorFinish: SlabInteriorFinishType = "tile" SlabExteriorFinish: SlabExteriorFinishType = "none" @@ -822,6 +834,10 @@ def facade_wall(self) -> SemiFlatWallConstruction: nominal_cavity_insulation_r=self.FacadeCavityInsulationRValue, nominal_exterior_insulation_r=self.FacadeExteriorInsulationRValue, nominal_interior_insulation_r=self.FacadeInteriorInsulationRValue, + exterior_insulation_material=self.FacadeExteriorInsulationMaterial, + interior_insulation_material=self.FacadeInteriorInsulationMaterial, + cavity_insulation_material=self.FacadeCavityInsulationMaterial, + exterior_cavity_type=self.FacadeExteriorCavityType, interior_finish=self.FacadeInteriorFinish, exterior_finish=self.FacadeExteriorFinish, ) @@ -834,6 +850,10 @@ def roof_construction(self) -> SemiFlatRoofConstruction: nominal_cavity_insulation_r=self.RoofCavityInsulationRValue, nominal_exterior_insulation_r=self.RoofExteriorInsulationRValue, nominal_interior_insulation_r=self.RoofInteriorInsulationRValue, + exterior_insulation_material=self.RoofExteriorInsulationMaterial, + interior_insulation_material=self.RoofInteriorInsulationMaterial, + cavity_insulation_material=self.RoofCavityInsulationMaterial, + exterior_cavity_type=self.RoofExteriorCavityType, interior_finish=self.RoofInteriorFinish, exterior_finish=self.RoofExteriorFinish, ) @@ -844,6 +864,7 @@ def slab_construction(self) -> SemiFlatSlabConstruction: return SemiFlatSlabConstruction( structural_system=self.SlabStructuralSystem, nominal_insulation_r=self.SlabInsulationRValue, + insulation_material=self.SlabInsulationMaterial, insulation_placement=self.SlabInsulationPlacement, interior_finish=self.SlabInteriorFinish, exterior_finish=self.SlabExteriorFinish, diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 5366af5..82655b2 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -4,6 +4,7 @@ from epinterface.sbem.flat_constructions import build_envelope_assemblies from epinterface.sbem.flat_constructions.layers import ( + ALL_CAVITY_INSULATION_MATERIALS, ALL_CONTINUOUS_INSULATION_MATERIALS, ALL_EXTERIOR_CAVITY_TYPES, ) @@ -116,6 +117,7 @@ def test_roof_feature_dict_has_fixed_length() -> None: + len(ALL_ROOF_INTERIOR_FINISHES) + len(ALL_ROOF_EXTERIOR_FINISHES) + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 + + len(ALL_CAVITY_INSULATION_MATERIALS) + len(ALL_EXTERIOR_CAVITY_TYPES) ) assert len(features) == expected_length diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index 8e5ed7c..77135c3 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -3,6 +3,7 @@ import pytest from epinterface.sbem.flat_constructions.layers import ( + ALL_CAVITY_INSULATION_MATERIALS, ALL_CONTINUOUS_INSULATION_MATERIALS, ALL_EXTERIOR_CAVITY_TYPES, ) @@ -153,6 +154,7 @@ def test_wall_feature_dict_has_fixed_length() -> None: + len(ALL_WALL_INTERIOR_FINISHES) + len(ALL_WALL_EXTERIOR_FINISHES) + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 + + len(ALL_CAVITY_INSULATION_MATERIALS) + len(ALL_EXTERIOR_CAVITY_TYPES) ) assert len(features) == expected_length From 1edae5eaec7512ba9cf68e277c3466c943b45156 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:06:54 -0500 Subject: [PATCH 15/18] add some air gap R to framed cavities --- epinterface/sbem/flat_constructions/layers.py | 34 ++++++-- .../test_layers_utils.py | 84 +++++++++++++++++++ .../test_roofs_slabs.py | 6 +- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/epinterface/sbem/flat_constructions/layers.py b/epinterface/sbem/flat_constructions/layers.py index d4c1507..14fdcd1 100644 --- a/epinterface/sbem/flat_constructions/layers.py +++ b/epinterface/sbem/flat_constructions/layers.py @@ -43,6 +43,11 @@ UNVENTILATED_AIR_R_ROOF = 0.16 # horizontal (heat-flow-up), 25mm gap _AIR_GAP_THICKNESS_M = 0.025 +# Minimum residual gap (between insulation face and sheathing) before we add +# a sealed-air-layer R to the fill path. Gaps narrower than this are treated +# as negligible (batt compression / manufacturing tolerance). +_MIN_RESIDUAL_GAP_M = 0.010 + def _make_air_gap_material(r_value: float) -> ConstructionMaterialComponent: """Create a virtual material representing an unventilated air gap.""" @@ -102,20 +107,36 @@ def equivalent_framed_cavity_material( Uses a parallel-path estimate: U_eq = f_frame / R_frame + (1-f_frame) / R_fill where R_fill is nominal cavity insulation R (or an uninsulated fallback). + + When the insulation batt is thinner than the cavity depth, a residual + sealed air gap exists between the batt face and the adjacent layer. Its + thermal resistance (per ISO 6946:2017 Table 2) is added in series on the + fill path so that the parallel-path calculation remains accurate for + partial-fill scenarios. """ resolved_framing_material = resolve_material(framing_material) resolved_cavity_insulation = resolve_material(cavity_insulation_material) - fill_r = ( - nominal_cavity_insulation_r - if nominal_cavity_insulation_r > 0 - else uninsulated_cavity_r_value - ) + if nominal_cavity_insulation_r > 0: + insulation_thickness_m = ( + nominal_cavity_insulation_r * resolved_cavity_insulation.Conductivity + ) + gap_m = cavity_depth_m - insulation_thickness_m + # ISO 6946:2017 Table 2 -- sealed air-layer R is roughly constant for + # gaps >= 25 mm; using `uninsulated_cavity_r_value` (template-supplied, + # orientation-aware) is a reasonable approximation for any gap above the + # minimum threshold. + residual_air_r = ( + uninsulated_cavity_r_value if gap_m >= _MIN_RESIDUAL_GAP_M else 0.0 + ) + fill_r = nominal_cavity_insulation_r + residual_air_r + else: + fill_r = uninsulated_cavity_r_value + framing_r = ( framing_path_r_value if framing_path_r_value is not None else cavity_depth_m / resolved_framing_material.Conductivity ) - # TODO: Not currently dealing with AirGap when thicknesses are implicitly unequal. u_eq = framing_fraction / framing_r + (1 - framing_fraction) / fill_r r_eq = 1 / u_eq conductivity_eq = cavity_depth_m / r_eq @@ -137,6 +158,7 @@ def equivalent_framed_cavity_material( Conductivity=conductivity_eq, Density=density_eq, SpecificHeat=specific_heat_eq, + # Absorptance values are generally irrelevant since this is an interior layer. ThermalAbsorptance=0.9, SolarAbsorptance=0.6, VisibleAbsorptance=0.6, diff --git a/tests/test_flat_constructions/test_layers_utils.py b/tests/test_flat_constructions/test_layers_utils.py index eca91fb..222681b 100644 --- a/tests/test_flat_constructions/test_layers_utils.py +++ b/tests/test_flat_constructions/test_layers_utils.py @@ -3,6 +3,8 @@ import pytest from epinterface.sbem.flat_constructions.layers import ( + _MIN_RESIDUAL_GAP_M, + equivalent_framed_cavity_material, layer_from_nominal_r, resolve_material, ) @@ -10,9 +12,11 @@ ASPHALT_SHINGLE, CONCRETE_BLOCK_H, COOL_ROOF_MEMBRANE, + FIBERGLASS_BATTS, NATURAL_STONE, RAMMED_EARTH, ROOF_MEMBRANE, + SOFTWOOD_GENERAL, STEEL_PANEL, VINYL_SIDING, XPS_BOARD, @@ -95,3 +99,83 @@ def test_new_materials_have_expected_properties() -> None: assert NATURAL_STONE.Conductivity == pytest.approx(2.90) assert NATURAL_STONE.SolarAbsorptance == pytest.approx(0.55) assert NATURAL_STONE.Density == pytest.approx(2500) + + +# --------------------------------------------------------------------------- +# equivalent_framed_cavity_material -- residual air-gap correction +# --------------------------------------------------------------------------- + +_CAVITY_DEPTH = 0.090 # 90mm (typical 2x4 stud bay) +_FRAMING_FRACTION = 0.23 +_UNINSULATED_R = 0.17 +_K_FIBERGLASS = FIBERGLASS_BATTS.Conductivity # 0.043 +_K_SOFTWOOD = SOFTWOOD_GENERAL.Conductivity # 0.12 +_FRAMING_R = _CAVITY_DEPTH / _K_SOFTWOOD + + +def _expected_r_eq(fill_r: float) -> float: + """Parallel-path R_eq for the standard test fixture.""" + u = _FRAMING_FRACTION / _FRAMING_R + (1 - _FRAMING_FRACTION) / fill_r + return 1.0 / u + + +def test_full_fill_cavity_has_no_air_gap_correction() -> None: + """When insulation fills the cavity, fill_r equals the nominal R.""" + nominal_r = _CAVITY_DEPTH / _K_FIBERGLASS # exactly fills cavity + mat = equivalent_framed_cavity_material( + structural_system="woodframe", + cavity_depth_m=_CAVITY_DEPTH, + framing_material=SOFTWOOD_GENERAL, + framing_fraction=_FRAMING_FRACTION, + nominal_cavity_insulation_r=nominal_r, + uninsulated_cavity_r_value=_UNINSULATED_R, + ) + r_eq = _CAVITY_DEPTH / mat.Conductivity + assert r_eq == pytest.approx(_expected_r_eq(nominal_r), rel=1e-6) + + +def test_partial_fill_cavity_adds_air_gap_r() -> None: + """When a significant residual gap exists, the air-layer R is added.""" + nominal_r = 1.0 # implied thickness ~43mm in 90mm cavity → ~47mm gap + mat = equivalent_framed_cavity_material( + structural_system="woodframe", + cavity_depth_m=_CAVITY_DEPTH, + framing_material=SOFTWOOD_GENERAL, + framing_fraction=_FRAMING_FRACTION, + nominal_cavity_insulation_r=nominal_r, + uninsulated_cavity_r_value=_UNINSULATED_R, + ) + corrected_fill_r = nominal_r + _UNINSULATED_R + r_eq = _CAVITY_DEPTH / mat.Conductivity + assert r_eq == pytest.approx(_expected_r_eq(corrected_fill_r), rel=1e-6) + + +def test_gap_below_threshold_gets_no_correction() -> None: + """A residual gap smaller than the threshold is ignored.""" + gap_just_below = _MIN_RESIDUAL_GAP_M - 0.001 + insulation_thickness = _CAVITY_DEPTH - gap_just_below + nominal_r = insulation_thickness / _K_FIBERGLASS + mat = equivalent_framed_cavity_material( + structural_system="woodframe", + cavity_depth_m=_CAVITY_DEPTH, + framing_material=SOFTWOOD_GENERAL, + framing_fraction=_FRAMING_FRACTION, + nominal_cavity_insulation_r=nominal_r, + uninsulated_cavity_r_value=_UNINSULATED_R, + ) + r_eq = _CAVITY_DEPTH / mat.Conductivity + assert r_eq == pytest.approx(_expected_r_eq(nominal_r), rel=1e-6) + + +def test_uninsulated_cavity_uses_fallback_r() -> None: + """With zero cavity insulation, fill_r falls back to the air-cavity R.""" + mat = equivalent_framed_cavity_material( + structural_system="woodframe", + cavity_depth_m=_CAVITY_DEPTH, + framing_material=SOFTWOOD_GENERAL, + framing_fraction=_FRAMING_FRACTION, + nominal_cavity_insulation_r=0.0, + uninsulated_cavity_r_value=_UNINSULATED_R, + ) + r_eq = _CAVITY_DEPTH / mat.Conductivity + assert r_eq == pytest.approx(_expected_r_eq(_UNINSULATED_R), rel=1e-6) diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 82655b2..b5361ca 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -147,9 +147,10 @@ def test_light_wood_truss_uses_consolidated_cavity_layer() -> None: assert truss_template.framing_fraction is not None framing_r = truss_template.cavity_depth_m / SOFTWOOD_GENERAL.Conductivity + fill_r = 3.0 + truss_template.uninsulated_cavity_r_value r_eq_expected = 1 / ( truss_template.framing_fraction / framing_r - + (1 - truss_template.framing_fraction) / 3.0 + + (1 - truss_template.framing_fraction) / fill_r ) assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) @@ -174,9 +175,10 @@ def test_steel_joist_uses_effective_framing_path() -> None: assert joist_template.framing_fraction is not None assert joist_template.framing_path_r_value is not None + fill_r = 3.0 + joist_template.uninsulated_cavity_r_value r_eq_expected = 1 / ( joist_template.framing_fraction / joist_template.framing_path_r_value - + (1 - joist_template.framing_fraction) / 3.0 + + (1 - joist_template.framing_fraction) / fill_r ) assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) From 7fa1dd5846d084c45e5a513d67d1577bf92ad726 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:38:54 -0500 Subject: [PATCH 16/18] add fallback behavior for excessive cavity r values --- epinterface/sbem/flat_constructions/roofs.py | 9 ++- epinterface/sbem/flat_constructions/walls.py | 9 ++- .../test_physical_sanity_audit.py | 35 +++++++++++ .../test_roofs_slabs.py | 42 ++++++++++--- tests/test_flat_constructions/test_walls.py | 60 ++++++++++++++++--- 5 files changed, 135 insertions(+), 20 deletions(-) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 0768039..4106a86 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -1,5 +1,6 @@ """Semi-flat roof schema and translators for SBEM assemblies.""" +import warnings from dataclasses import dataclass from typing import Literal, get_args @@ -311,12 +312,14 @@ def validate_cavity_r_against_assumed_depth(self): max_nominal_r = template.cavity_depth_m / cavity_mat.Conductivity tolerance_r = 0.2 if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: - msg = ( + warnings.warn( f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " - f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." + f"{self.structural_system} ({max_nominal_r:.2f} m²K/W). " + f"Overriding with the maximum possible value ({max_nominal_r:.2f} m²K/W).", + stacklevel=2, ) - raise ValueError(msg) + self.nominal_cavity_insulation_r = max_nominal_r return self def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 626eb7c..818c1ef 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -1,5 +1,6 @@ """Semi-flat wall schema and translators for SBEM assemblies.""" +import warnings from dataclasses import dataclass from typing import Literal, get_args @@ -513,12 +514,14 @@ def validate_cavity_r_against_assumed_depth(self): max_nominal_r = template.cavity_depth_m / cavity_mat.Conductivity tolerance_r = 0.2 if self.nominal_cavity_insulation_r > max_nominal_r + tolerance_r: - msg = ( + warnings.warn( f"Nominal cavity insulation R-value ({self.nominal_cavity_insulation_r:.2f} " f"m²K/W) exceeds the assumed cavity-depth-compatible limit for " - f"{self.structural_system} ({max_nominal_r:.2f} m²K/W)." + f"{self.structural_system} ({max_nominal_r:.2f} m²K/W). " + f"Overriding with the maximum possible value ({max_nominal_r:.2f} m²K/W).", + stacklevel=2, ) - raise ValueError(msg) + self.nominal_cavity_insulation_r = max_nominal_r return self def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: diff --git a/tests/test_flat_constructions/test_physical_sanity_audit.py b/tests/test_flat_constructions/test_physical_sanity_audit.py index 0354c42..8bf908e 100644 --- a/tests/test_flat_constructions/test_physical_sanity_audit.py +++ b/tests/test_flat_constructions/test_physical_sanity_audit.py @@ -1,6 +1,16 @@ """Physical-sanity audit tests for semi-flat construction defaults.""" +import pytest + from epinterface.sbem.flat_constructions.audit import run_physical_sanity_audit +from epinterface.sbem.flat_constructions.roofs import ( + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + build_facade_assembly, +) def test_physical_sanity_audit_has_no_errors() -> None: @@ -10,3 +20,28 @@ def test_physical_sanity_audit_has_no_errors() -> None: assert not errors, "\n".join([ f"[{issue.scope}] {issue.message}" for issue in errors ]) + + +def test_audit_with_excessive_cavity_r_override_produces_valid_assemblies() -> None: + """When cavity R is overridden to max, assemblies should still pass audit bounds.""" + # Excessive cavity R triggers override; audit bounds should still be satisfied + with pytest.warns(UserWarning, match="cavity-depth-compatible limit"): + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=10.0, + ) + roof = SemiFlatRoofConstruction( + structural_system="deep_wood_truss", + nominal_cavity_insulation_r=10.0, + ) + + wall_assembly = build_facade_assembly(wall) + roof_assembly = build_roof_assembly(roof) + + wall_thickness = sum(layer.Thickness for layer in wall_assembly.sorted_layers) + roof_thickness = sum(layer.Thickness for layer in roof_assembly.sorted_layers) + + assert 0.04 <= wall_thickness <= 0.80 + assert 0.20 <= wall_assembly.r_value <= 12.0 + assert 0.04 <= roof_thickness <= 1.00 + assert 0.20 <= roof_assembly.r_value <= 14.0 diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index b5361ca..5e1eabc 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -11,6 +11,7 @@ from epinterface.sbem.flat_constructions.materials import ( CERAMIC_TILE, CONCRETE_RC_DENSE, + FIBERGLASS_BATTS, GYPSUM_BOARD, ROOF_MEMBRANE, SOFTWOOD_GENERAL, @@ -67,17 +68,44 @@ def test_build_roof_assembly_from_nominal_r_values() -> None: assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) -def test_roof_validator_rejects_unrealistic_cavity_r_for_depth() -> None: - """Roof cavity insulation R should be limited by assumed cavity depth.""" - with pytest.raises( - ValueError, - match="cavity-depth-compatible limit", - ): - SemiFlatRoofConstruction( +def test_roof_validator_overrides_excessive_cavity_r_to_max() -> None: + """Roof cavity insulation R exceeding depth limit should be capped with a warning.""" + with pytest.warns(UserWarning, match="cavity-depth-compatible limit"): + roof = SemiFlatRoofConstruction( structural_system="deep_wood_truss", nominal_cavity_insulation_r=6.0, ) + truss_template = ROOF_STRUCTURAL_TEMPLATES["deep_wood_truss"] + max_nominal_r = ( + truss_template.cavity_depth_m or 0.1 + ) / FIBERGLASS_BATTS.Conductivity + assert roof.nominal_cavity_insulation_r == pytest.approx(max_nominal_r) + assert roof.effective_nominal_cavity_insulation_r == pytest.approx(max_nominal_r) + + # Assembly should use the capped value, not the original excessive input + assembly = build_roof_assembly(roof) + assert assembly.r_value > 0 + assert assembly.Type == "FlatRoof" + + +def test_roof_cavity_r_within_limit_not_overridden() -> None: + """Cavity R within depth limit should pass through unchanged.""" + roof = SemiFlatRoofConstruction( + structural_system="light_wood_truss", + nominal_cavity_insulation_r=3.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + truss_template = ROOF_STRUCTURAL_TEMPLATES["light_wood_truss"] + max_nominal_r = ( + truss_template.cavity_depth_m or 0.1 + ) / FIBERGLASS_BATTS.Conductivity + assert roof.nominal_cavity_insulation_r == 3.0 + assert roof.nominal_cavity_insulation_r < max_nominal_r + 0.2 + def test_non_cavity_roof_treats_cavity_r_as_dead_feature() -> None: """Roof cavity R should become a no-op for non-cavity systems.""" diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index 77135c3..e397443 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -11,7 +11,9 @@ CEMENT_MORTAR, CONCRETE_BLOCK_H, CONCRETE_RC_DENSE, + FIBERGLASS_BATTS, GYPSUM_BOARD, + MINERAL_WOOL_BATT, SOFTWOOD_GENERAL, MaterialName, ) @@ -53,17 +55,61 @@ def test_build_facade_assembly_from_nominal_r_values() -> None: assert assembly.r_value == pytest.approx(expected_r, rel=1e-6) -def test_validator_rejects_unrealistic_cavity_r_for_depth() -> None: - """Cavity insulation R should be limited by assumed cavity depth.""" - with pytest.raises( - ValueError, - match="cavity-depth-compatible limit", - ): - SemiFlatWallConstruction( +def test_validator_overrides_excessive_cavity_r_to_max() -> None: + """Cavity insulation R exceeding depth limit should be capped with a warning.""" + with pytest.warns(UserWarning, match="cavity-depth-compatible limit"): + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=3.0, + ) + + woodframe_template = STRUCTURAL_TEMPLATES["woodframe"] + max_nominal_r = ( + woodframe_template.cavity_depth_m or 0.1 + ) / FIBERGLASS_BATTS.Conductivity + assert wall.nominal_cavity_insulation_r == pytest.approx(max_nominal_r) + assert wall.effective_nominal_cavity_insulation_r == pytest.approx(max_nominal_r) + + # Assembly should use the capped value, not the original excessive input + assembly = build_facade_assembly(wall) + assert assembly.r_value > 0 + assert assembly.Type == "Facade" + + +def test_wall_cavity_r_within_limit_not_overridden() -> None: + """Cavity R within depth limit should pass through unchanged.""" + wall = SemiFlatWallConstruction( + structural_system="woodframe", + nominal_cavity_insulation_r=2.0, + nominal_exterior_insulation_r=0.0, + nominal_interior_insulation_r=0.0, + interior_finish="none", + exterior_finish="none", + ) + woodframe_template = STRUCTURAL_TEMPLATES["woodframe"] + max_nominal_r = ( + woodframe_template.cavity_depth_m or 0.1 + ) / FIBERGLASS_BATTS.Conductivity + assert wall.nominal_cavity_insulation_r == 2.0 + assert wall.nominal_cavity_insulation_r <= max_nominal_r + 0.2 + + +def test_wall_cavity_r_override_uses_material_specific_max() -> None: + """Override max should depend on cavity insulation material conductivity.""" + # Mineral wool has lower conductivity than fiberglass, so higher max R + with pytest.warns(UserWarning, match="cavity-depth-compatible limit"): + wall = SemiFlatWallConstruction( structural_system="woodframe", nominal_cavity_insulation_r=3.0, + cavity_insulation_material="mineral_wool", ) + woodframe_template = STRUCTURAL_TEMPLATES["woodframe"] + max_nominal_r = ( + woodframe_template.cavity_depth_m or 0.1 + ) / MINERAL_WOOL_BATT.Conductivity + assert wall.nominal_cavity_insulation_r == pytest.approx(max_nominal_r) + def test_non_cavity_structural_system_treats_cavity_r_as_dead_feature() -> None: """Cavity R should become a no-op when structural system has no cavity.""" From 42f4297793bd7ad32464a525219e6b129bcb29d2 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:46:54 -0500 Subject: [PATCH 17/18] add todo note --- epinterface/sbem/flat_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index d52b99e..454f101 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -1872,6 +1872,7 @@ def post_geometry_callback(idf: IDF) -> IDF: Model( geometry=geometry, Zone=zone, + # TODO: make attic/basement configurable Attic=AtticAssumptions( UseFraction=None, Conditioned=False, From 07ca72b3b66509ab2c62da771fa2e1e13c2cddf2 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:49:17 -0500 Subject: [PATCH 18/18] get rid of feature dicts --- epinterface/sbem/flat_constructions/roofs.py | 42 ----------- epinterface/sbem/flat_constructions/slabs.py | 48 ------------ epinterface/sbem/flat_constructions/walls.py | 43 ----------- .../test_roofs_slabs.py | 75 ++----------------- tests/test_flat_constructions/test_walls.py | 35 --------- 5 files changed, 5 insertions(+), 238 deletions(-) diff --git a/epinterface/sbem/flat_constructions/roofs.py b/epinterface/sbem/flat_constructions/roofs.py index 4106a86..38ac470 100644 --- a/epinterface/sbem/flat_constructions/roofs.py +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -13,9 +13,6 @@ from epinterface.sbem.flat_constructions.layers import ( _AIR_GAP_THICKNESS_M, AIR_GAP_ROOF, - ALL_CAVITY_INSULATION_MATERIALS, - ALL_CONTINUOUS_INSULATION_MATERIALS, - ALL_EXTERIOR_CAVITY_TYPES, CAVITY_INSULATION_MATERIAL_MAP, CONTINUOUS_INSULATION_MATERIAL_MAP, CavityInsulationMaterial, @@ -322,45 +319,6 @@ def validate_cavity_r_against_assumed_depth(self): self.nominal_cavity_insulation_r = max_nominal_r return self - def to_feature_dict(self, prefix: str = "Roof") -> dict[str, float]: - """Return a fixed-length numeric feature dictionary for ML workflows.""" - features: dict[str, float] = { - f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, - f"{prefix}NominalExteriorInsulationRValue": self.nominal_exterior_insulation_r, - f"{prefix}NominalInteriorInsulationRValue": self.nominal_interior_insulation_r, - f"{prefix}EffectiveNominalCavityInsulationRValue": ( - self.effective_nominal_cavity_insulation_r - ), - } - for structural_system in ALL_ROOF_STRUCTURAL_SYSTEMS: - features[f"{prefix}StructuralSystem__{structural_system}"] = float( - self.structural_system == structural_system - ) - for interior_finish in ALL_ROOF_INTERIOR_FINISHES: - features[f"{prefix}InteriorFinish__{interior_finish}"] = float( - self.interior_finish == interior_finish - ) - for exterior_finish in ALL_ROOF_EXTERIOR_FINISHES: - features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( - self.exterior_finish == exterior_finish - ) - for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: - features[f"{prefix}ExteriorInsulationMaterial__{ins_mat}"] = float( - self.exterior_insulation_material == ins_mat - ) - features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( - self.interior_insulation_material == ins_mat - ) - for cav_ins_mat in ALL_CAVITY_INSULATION_MATERIALS: - features[f"{prefix}CavityInsulationMaterial__{cav_ins_mat}"] = float( - self.cavity_insulation_material == cav_ins_mat - ) - for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: - features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( - self.exterior_cavity_type == cavity_type - ) - return features - def build_roof_assembly( roof: SemiFlatRoofConstruction, diff --git a/epinterface/sbem/flat_constructions/slabs.py b/epinterface/sbem/flat_constructions/slabs.py index d2dc572..f586fff 100644 --- a/epinterface/sbem/flat_constructions/slabs.py +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -10,7 +10,6 @@ ConstructionLayerComponent, ) from epinterface.sbem.flat_constructions.layers import ( - ALL_CONTINUOUS_INSULATION_MATERIALS, CONTINUOUS_INSULATION_MATERIAL_MAP, ContinuousInsulationMaterial, layer_from_nominal_r, @@ -199,53 +198,6 @@ def effective_nominal_insulation_r(self) -> float: return 0.0 return self.nominal_insulation_r - @property - def ignored_feature_names(self) -> tuple[str, ...]: - """Return feature names that are semantic no-ops for this slab.""" - ignored: list[str] = [] - template = STRUCTURAL_TEMPLATES[self.structural_system] - if ( - self.insulation_placement == "under_slab" - and not template.supports_under_insulation - and self.nominal_insulation_r > 0 - ): - ignored.append("nominal_insulation_r") - ignored.append("insulation_placement") - return tuple(ignored) - - def to_feature_dict(self, prefix: str = "Slab") -> dict[str, float]: - """Return a fixed-length numeric feature dictionary for ML workflows.""" - features: dict[str, float] = { - f"{prefix}NominalInsulationRValue": self.nominal_insulation_r, - f"{prefix}EffectiveNominalInsulationRValue": ( - self.effective_nominal_insulation_r - ), - } - for structural_system in ALL_SLAB_STRUCTURAL_SYSTEMS: - features[f"{prefix}StructuralSystem__{structural_system}"] = float( - self.structural_system == structural_system - ) - for placement in ALL_SLAB_INSULATION_PLACEMENTS: - features[f"{prefix}InsulationPlacement__{placement}"] = float( - self.insulation_placement == placement - ) - features[f"{prefix}EffectiveInsulationPlacement__{placement}"] = float( - self.effective_insulation_placement == placement - ) - for interior_finish in ALL_SLAB_INTERIOR_FINISHES: - features[f"{prefix}InteriorFinish__{interior_finish}"] = float( - self.interior_finish == interior_finish - ) - for exterior_finish in ALL_SLAB_EXTERIOR_FINISHES: - features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( - self.exterior_finish == exterior_finish - ) - for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: - features[f"{prefix}InsulationMaterial__{ins_mat}"] = float( - self.insulation_material == ins_mat - ) - return features - def build_slab_assembly( slab: SemiFlatSlabConstruction, diff --git a/epinterface/sbem/flat_constructions/walls.py b/epinterface/sbem/flat_constructions/walls.py index 818c1ef..9cd0f36 100644 --- a/epinterface/sbem/flat_constructions/walls.py +++ b/epinterface/sbem/flat_constructions/walls.py @@ -13,9 +13,6 @@ from epinterface.sbem.flat_constructions.layers import ( _AIR_GAP_THICKNESS_M, AIR_GAP_WALL, - ALL_CAVITY_INSULATION_MATERIALS, - ALL_CONTINUOUS_INSULATION_MATERIALS, - ALL_EXTERIOR_CAVITY_TYPES, CAVITY_INSULATION_MATERIAL_MAP, CONTINUOUS_INSULATION_MATERIAL_MAP, CavityInsulationMaterial, @@ -524,46 +521,6 @@ def validate_cavity_r_against_assumed_depth(self): self.nominal_cavity_insulation_r = max_nominal_r return self - def to_feature_dict(self, prefix: str = "Facade") -> dict[str, float]: - """Return a fixed-length numeric feature dictionary for ML workflows.""" - features: dict[str, float] = { - f"{prefix}NominalCavityInsulationRValue": self.nominal_cavity_insulation_r, - f"{prefix}NominalExteriorInsulationRValue": self.nominal_exterior_insulation_r, - f"{prefix}NominalInteriorInsulationRValue": self.nominal_interior_insulation_r, - f"{prefix}EffectiveNominalCavityInsulationRValue": ( - self.effective_nominal_cavity_insulation_r - ), - } - - for structural_system in ALL_WALL_STRUCTURAL_SYSTEMS: - features[f"{prefix}StructuralSystem__{structural_system}"] = float( - self.structural_system == structural_system - ) - for interior_finish in ALL_WALL_INTERIOR_FINISHES: - features[f"{prefix}InteriorFinish__{interior_finish}"] = float( - self.interior_finish == interior_finish - ) - for exterior_finish in ALL_WALL_EXTERIOR_FINISHES: - features[f"{prefix}ExteriorFinish__{exterior_finish}"] = float( - self.exterior_finish == exterior_finish - ) - for ins_mat in ALL_CONTINUOUS_INSULATION_MATERIALS: - features[f"{prefix}ExteriorInsulationMaterial__{ins_mat}"] = float( - self.exterior_insulation_material == ins_mat - ) - features[f"{prefix}InteriorInsulationMaterial__{ins_mat}"] = float( - self.interior_insulation_material == ins_mat - ) - for cav_ins_mat in ALL_CAVITY_INSULATION_MATERIALS: - features[f"{prefix}CavityInsulationMaterial__{cav_ins_mat}"] = float( - self.cavity_insulation_material == cav_ins_mat - ) - for cavity_type in ALL_EXTERIOR_CAVITY_TYPES: - features[f"{prefix}ExteriorCavityType__{cavity_type}"] = float( - self.exterior_cavity_type == cavity_type - ) - return features - def build_facade_assembly( # noqa: C901 wall: SemiFlatWallConstruction, diff --git a/tests/test_flat_constructions/test_roofs_slabs.py b/tests/test_flat_constructions/test_roofs_slabs.py index 5e1eabc..5fdbbb1 100644 --- a/tests/test_flat_constructions/test_roofs_slabs.py +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -3,11 +3,6 @@ import pytest from epinterface.sbem.flat_constructions import build_envelope_assemblies -from epinterface.sbem.flat_constructions.layers import ( - ALL_CAVITY_INSULATION_MATERIALS, - ALL_CONTINUOUS_INSULATION_MATERIALS, - ALL_EXTERIOR_CAVITY_TYPES, -) from epinterface.sbem.flat_constructions.materials import ( CERAMIC_TILE, CONCRETE_RC_DENSE, @@ -17,26 +12,19 @@ SOFTWOOD_GENERAL, ) from epinterface.sbem.flat_constructions.roofs import ( - ALL_ROOF_EXTERIOR_FINISHES, - ALL_ROOF_INTERIOR_FINISHES, - ALL_ROOF_STRUCTURAL_SYSTEMS, + STRUCTURAL_TEMPLATES as ROOF_STRUCTURAL_TEMPLATES, +) +from epinterface.sbem.flat_constructions.roofs import ( SemiFlatRoofConstruction, build_roof_assembly, ) -from epinterface.sbem.flat_constructions.roofs import ( - STRUCTURAL_TEMPLATES as ROOF_STRUCTURAL_TEMPLATES, +from epinterface.sbem.flat_constructions.slabs import ( + STRUCTURAL_TEMPLATES as SLAB_STRUCTURAL_TEMPLATES, ) from epinterface.sbem.flat_constructions.slabs import ( - ALL_SLAB_EXTERIOR_FINISHES, - ALL_SLAB_INSULATION_PLACEMENTS, - ALL_SLAB_INTERIOR_FINISHES, - ALL_SLAB_STRUCTURAL_SYSTEMS, SemiFlatSlabConstruction, build_slab_assembly, ) -from epinterface.sbem.flat_constructions.slabs import ( - STRUCTURAL_TEMPLATES as SLAB_STRUCTURAL_TEMPLATES, -) from epinterface.sbem.flat_constructions.walls import ( SemiFlatWallConstruction, build_facade_assembly, @@ -127,33 +115,6 @@ def test_non_cavity_roof_treats_cavity_r_as_dead_feature() -> None: assert "FiberglassBatt" not in layer_material_names -def test_roof_feature_dict_has_fixed_length() -> None: - """Roof feature dictionary should remain fixed-length across variants.""" - roof = SemiFlatRoofConstruction( - structural_system="steel_joist", - nominal_cavity_insulation_r=1.5, - nominal_exterior_insulation_r=0.5, - nominal_interior_insulation_r=0.2, - interior_finish="acoustic_tile", - exterior_finish="cool_membrane", - ) - features = roof.to_feature_dict(prefix="Roof") - - expected_length = ( - 4 - + len(ALL_ROOF_STRUCTURAL_SYSTEMS) - + len(ALL_ROOF_INTERIOR_FINISHES) - + len(ALL_ROOF_EXTERIOR_FINISHES) - + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 - + len(ALL_CAVITY_INSULATION_MATERIALS) - + len(ALL_EXTERIOR_CAVITY_TYPES) - ) - assert len(features) == expected_length - assert features["RoofStructuralSystem__steel_joist"] == 1.0 - assert features["RoofInteriorFinish__acoustic_tile"] == 1.0 - assert features["RoofExteriorFinish__cool_membrane"] == 1.0 - - def test_light_wood_truss_uses_consolidated_cavity_layer() -> None: """Wood truss roofs should use a consolidated parallel-path cavity layer.""" roof = SemiFlatRoofConstruction( @@ -248,7 +209,6 @@ def test_non_ground_slab_treats_under_slab_r_as_dead_feature() -> None: ] assert slab.effective_nominal_insulation_r == 0.0 - assert "nominal_insulation_r" in slab.ignored_feature_names assert "XPSBoard" not in layer_material_names @@ -262,31 +222,6 @@ def test_slab_auto_placement_uses_under_slab_for_ground_supported_system() -> No assert slab.effective_insulation_placement == "under_slab" -def test_slab_feature_dict_has_fixed_length() -> None: - """Slab feature dictionary should remain fixed-length across variants.""" - slab = SemiFlatSlabConstruction( - structural_system="precast_hollow_core", - nominal_insulation_r=0.8, - insulation_placement="above_slab", - interior_finish="carpet", - exterior_finish="gypsum_board", - ) - features = slab.to_feature_dict(prefix="Slab") - - expected_length = ( - 2 - + len(ALL_SLAB_STRUCTURAL_SYSTEMS) - + len(ALL_SLAB_INSULATION_PLACEMENTS) * 2 - + len(ALL_SLAB_INTERIOR_FINISHES) - + len(ALL_SLAB_EXTERIOR_FINISHES) - + len(ALL_CONTINUOUS_INSULATION_MATERIALS) - ) - assert len(features) == expected_length - assert features["SlabStructuralSystem__precast_hollow_core"] == 1.0 - assert features["SlabInteriorFinish__carpet"] == 1.0 - assert features["SlabExteriorFinish__gypsum_board"] == 1.0 - - def test_build_envelope_assemblies_with_surface_specific_specs() -> None: """Envelope assemblies should use dedicated wall/roof/slab constructors.""" envelope_assemblies = build_envelope_assemblies( diff --git a/tests/test_flat_constructions/test_walls.py b/tests/test_flat_constructions/test_walls.py index e397443..87a6d2a 100644 --- a/tests/test_flat_constructions/test_walls.py +++ b/tests/test_flat_constructions/test_walls.py @@ -2,11 +2,6 @@ import pytest -from epinterface.sbem.flat_constructions.layers import ( - ALL_CAVITY_INSULATION_MATERIALS, - ALL_CONTINUOUS_INSULATION_MATERIALS, - ALL_EXTERIOR_CAVITY_TYPES, -) from epinterface.sbem.flat_constructions.materials import ( CEMENT_MORTAR, CONCRETE_BLOCK_H, @@ -18,9 +13,6 @@ MaterialName, ) from epinterface.sbem.flat_constructions.walls import ( - ALL_WALL_EXTERIOR_FINISHES, - ALL_WALL_INTERIOR_FINISHES, - ALL_WALL_STRUCTURAL_SYSTEMS, STRUCTURAL_TEMPLATES, SemiFlatWallConstruction, WallExteriorFinish, @@ -182,33 +174,6 @@ def test_light_gauge_steel_uses_effective_framing_path() -> None: assert assembly.r_value == pytest.approx(r_eq_expected, rel=1e-6) -def test_wall_feature_dict_has_fixed_length() -> None: - """Feature dictionary should remain fixed-length across wall variants.""" - wall = SemiFlatWallConstruction( - structural_system="deep_woodframe_24oc", - nominal_cavity_insulation_r=2.8, - nominal_exterior_insulation_r=0.5, - nominal_interior_insulation_r=0.0, - interior_finish="plaster", - exterior_finish="fiber_cement", - ) - features = wall.to_feature_dict(prefix="Facade") - - expected_length = ( - 4 - + len(ALL_WALL_STRUCTURAL_SYSTEMS) - + len(ALL_WALL_INTERIOR_FINISHES) - + len(ALL_WALL_EXTERIOR_FINISHES) - + len(ALL_CONTINUOUS_INSULATION_MATERIALS) * 2 - + len(ALL_CAVITY_INSULATION_MATERIALS) - + len(ALL_EXTERIOR_CAVITY_TYPES) - ) - assert len(features) == expected_length - assert features["FacadeStructuralSystem__deep_woodframe_24oc"] == 1.0 - assert features["FacadeInteriorFinish__plaster"] == 1.0 - assert features["FacadeExteriorFinish__fiber_cement"] == 1.0 - - def test_vinyl_siding_exterior_finish_round_trip() -> None: """Vinyl siding finish should produce a valid assembly with the correct outer layer.""" wall = SemiFlatWallConstruction(