diff --git a/benchmarking/benchmark.py b/benchmarking/benchmark.py index 53ac38c..1c559a1 100644 --- a/benchmarking/benchmark.py +++ b/benchmarking/benchmark.py @@ -27,9 +27,23 @@ def benchmark() -> None: Rotation=45, WWR=0.3, NFloors=2, - FacadeRValue=3.0, - RoofRValue=3.0, - SlabRValue=3.0, + FacadeStructuralSystem="cmu", + FacadeCavityInsulationRValue=1.2, + FacadeExteriorInsulationRValue=1.0, + FacadeInteriorInsulationRValue=0.0, + FacadeInteriorFinish="drywall", + FacadeExteriorFinish="brick_veneer", + RoofStructuralSystem="poured_concrete", + RoofCavityInsulationRValue=0.0, + RoofExteriorInsulationRValue=2.5, + RoofInteriorInsulationRValue=0.2, + RoofInteriorFinish="gypsum_board", + RoofExteriorFinish="epdm_membrane", + SlabStructuralSystem="slab_on_grade", + SlabInsulationRValue=2.2, + SlabInsulationPlacement="auto", + SlabInteriorFinish="tile", + SlabExteriorFinish="none", WindowUValue=3.0, WindowSHGF=0.7, WindowTVis=0.5, 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/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/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/__init__.py b/epinterface/sbem/flat_constructions/__init__.py new file mode 100644 index 0000000..997d64d --- /dev/null +++ b/epinterface/sbem/flat_constructions/__init__.py @@ -0,0 +1,73 @@ +"""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.layers import ( + CavityInsulationMaterial, + ContinuousInsulationMaterial, + ExteriorCavityType, + 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, + RoofStructuralSystem, + SemiFlatRoofConstruction, + build_roof_assembly, +) +from epinterface.sbem.flat_constructions.slabs import ( + SemiFlatSlabConstruction, + SlabExteriorFinish, + SlabInsulationPlacement, + SlabInteriorFinish, + SlabStructuralSystem, + build_slab_assembly, +) +from epinterface.sbem.flat_constructions.walls import ( + SemiFlatWallConstruction, + WallExteriorFinish, + WallInteriorFinish, + WallStructuralSystem, + build_facade_assembly, +) + +__all__ = [ + "MATERIAL_NAME_VALUES", + "AuditIssue", + "CavityInsulationMaterial", + "ContinuousInsulationMaterial", + "ExteriorCavityType", + "MaterialName", + "MaterialRef", + "RoofExteriorFinish", + "RoofInteriorFinish", + "RoofStructuralSystem", + "SemiFlatRoofConstruction", + "SemiFlatSlabConstruction", + "SemiFlatWallConstruction", + "SlabExteriorFinish", + "SlabInsulationPlacement", + "SlabInteriorFinish", + "SlabStructuralSystem", + "WallExteriorFinish", + "WallInteriorFinish", + "WallStructuralSystem", + "build_envelope_assemblies", + "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/assemblies.py b/epinterface/sbem/flat_constructions/assemblies.py new file mode 100644 index 0000000..a332291 --- /dev/null +++ b/epinterface/sbem/flat_constructions/assemblies.py @@ -0,0 +1,118 @@ +"""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, + CONCRETE_RC_DENSE, + GYPSUM_BOARD, + GYPSUM_PLASTER, + SOFTWOOD_GENERAL, + URETHANE_CARPET, +) +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, +) + + +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_envelope_assemblies( + *, + facade_wall: SemiFlatWallConstruction, + roof: SemiFlatRoofConstruction, + slab: SemiFlatSlabConstruction, +) -> EnvelopeAssemblyComponent: + """Build envelope assemblies from the flat model construction semantics.""" + facade = build_facade_assembly(facade_wall, name="Facade") + 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_slab_assembly( + slab, + name="GroundSlabAssembly", + ) + + return EnvelopeAssemblyComponent( + Name="EnvelopeAssemblies", + FacadeAssembly=facade, + FlatRoofAssembly=roof_assembly, + AtticRoofAssembly=roof_assembly, + 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/audit.py b/epinterface/sbem/flat_constructions/audit.py new file mode 100644 index 0000000..ba3eb29 --- /dev/null +++ b/epinterface/sbem/flat_constructions/audit.py @@ -0,0 +1,268 @@ +"""Physical-sanity audit checks for semi-flat constructions.""" + +from dataclasses import dataclass +from typing import Literal + +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, +) +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.05, 3.5), + "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), + "Bio": (0.04, 1.2), +} + +_DENSITY_RANGES = { + "Insulation": (8, 200), + "Concrete": (800, 2600), + "Timber": (300, 900), + "Masonry": (400, 2800), + "Metal": (6500, 8500), + "Boards": (100, 1200), + "Other": (500, 2600), + "Plaster": (600, 1800), + "Finishes": (80, 2600), + "Siding": (600, 2000), + "Sealing": (700, 1800), + "Bio": (100, 2000), +} + + +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(): + 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=audit_cavity_r, + 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, + ) + ) + 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( + 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, + ) + ) + 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( + 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/layers.py b/epinterface/sbem/flat_constructions/layers.py new file mode 100644 index 0000000..14fdcd1 --- /dev/null +++ b/epinterface/sbem/flat_constructions/layers.py @@ -0,0 +1,168 @@ +"""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 ( + FIBERGLASS_BATTS, + MATERIALS_BY_NAME, + MaterialName, +) + +type MaterialRef = ConstructionMaterialComponent | MaterialName + +ContinuousInsulationMaterial = Literal["xps", "polyiso", "eps", "mineral_wool"] +ALL_CONTINUOUS_INSULATION_MATERIALS = get_args(ContinuousInsulationMaterial) + +CONTINUOUS_INSULATION_MATERIAL_MAP: dict[ContinuousInsulationMaterial, MaterialName] = { + "xps": "XPSBoard", + "polyiso": "PolyisoBoard", + "eps": "EPSBoard", + "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 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 + +# 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.""" + 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.""" + 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, + cavity_insulation_material: MaterialRef = FIBERGLASS_BATTS, +) -> 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). + + 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) + 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 + ) + 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) * resolved_cavity_insulation.Density + ) + specific_heat_eq = ( + framing_fraction * resolved_framing_material.SpecificHeat + + (1 - framing_fraction) * resolved_cavity_insulation.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, + # Absorptance values are generally irrelevant since this is an interior layer. + ThermalAbsorptance=0.9, + SolarAbsorptance=0.6, + VisibleAbsorptance=0.6, + TemperatureCoefficientThermalConductivity=0.0, + Roughness="MediumRough", + Type="Other", + ) diff --git a/epinterface/sbem/flat_constructions/materials.py b/epinterface/sbem/flat_constructions/materials.py new file mode 100644 index 0000000..a416d34 --- /dev/null +++ b/epinterface/sbem/flat_constructions/materials.py @@ -0,0 +1,542 @@ +"""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", + "EPSBoard", + "MineralWoolBoard", + "ConcreteMC_Light", + "ConcreteRC_Dense", + "GypsumBoard", + "GypsumPlaster", + "SoftwoodGeneral", + "ClayBrick", + "ConcreteBlockH", + "FiberglassBatt", + "CementMortar", + "CeramicTile", + "UrethaneCarpet", + "SteelPanel", + "RammedEarth", + "SIPCore", + "FiberCementBoard", + "RoofMembrane", + "CoolRoofMembrane", + "AcousticTile", + "VinylSiding", + "AsphaltShingle", + "NaturalStone", + "AACBlock", + "SandcreteBlock", + "HollowClayBlock", + "StabilizedSoilBlock", + "WattleDaub", + "ThatchReed", + "AdobeBlock", + "CompressedEarthBlock", + "CobEarth", + "BambooComposite", + "CelluloseBatt", + "MineralWoolBatt", + "ConfinedMasonryEffective", + "RCFrameInfillEffective", +] + +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( + *, + name: str, + conductivity: float, + density: float, + specific_heat: float, + mat_type: str, + 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( + Name=name, + Conductivity=conductivity, + Density=density, + SpecificHeat=specific_heat, + 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=roughness, # pyright: ignore[reportArgumentType] + Type=mat_type, # pyright: ignore[reportArgumentType] + ) + + +XPS_BOARD = _material( + name="XPSBoard", + conductivity=0.037, + density=40, + specific_heat=1200, + 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.20, + density=1700, + specific_heat=900, + mat_type="Concrete", +) + +CONCRETE_RC_DENSE = _material( + name="ConcreteRC_Dense", + conductivity=1.95, + density=2300, + specific_heat=900, + 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.12, + density=500, + specific_heat=1630, + mat_type="Timber", +) + +CLAY_BRICK = _material( + name="ClayBrick", + conductivity=0.69, + density=1700, + specific_heat=840, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +CONCRETE_BLOCK_H = _material( + name="ConcreteBlockH", + conductivity=0.51, + density=1100, + specific_heat=840, + mat_type="Concrete", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +FIBERGLASS_BATTS = _material( + name="FiberglassBatt", + conductivity=0.043, + density=12, + specific_heat=840, + mat_type="Insulation", +) + +CEMENT_MORTAR = _material( + name="CementMortar", + conductivity=0.72, + density=1850, + specific_heat=840, + mat_type="Other", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +CERAMIC_TILE = _material( + name="CeramicTile", + conductivity=1.05, + density=2000, + specific_heat=840, + mat_type="Finishes", + roughness="MediumSmooth", +) + +URETHANE_CARPET = _material( + name="UrethaneCarpet", + conductivity=0.06, + density=160, + specific_heat=840, + mat_type="Finishes", +) + +STEEL_PANEL = _material( + name="SteelPanel", + conductivity=45.0, + density=7850, + specific_heat=500, + mat_type="Metal", + solar_absorptance=0.55, + visible_absorptance=0.55, + roughness="Smooth", +) + +RAMMED_EARTH = _material( + name="RammedEarth", + conductivity=1.10, + density=1900, + specific_heat=1000, + mat_type="Masonry", + solar_absorptance=0.70, + visible_absorptance=0.70, +) + +SIP_CORE = _material( + name="SIPCore", + conductivity=0.026, + density=35, + specific_heat=1400, + mat_type="Insulation", +) + +FIBER_CEMENT_BOARD = _material( + name="FiberCementBoard", + conductivity=0.35, + density=1350, + specific_heat=840, + mat_type="Siding", + solar_absorptance=0.65, + visible_absorptance=0.65, +) + +ROOF_MEMBRANE = _material( + name="RoofMembrane", + conductivity=0.17, + density=1200, + specific_heat=900, + mat_type="Sealing", + solar_absorptance=0.88, + visible_absorptance=0.88, + roughness="Smooth", +) + +COOL_ROOF_MEMBRANE = _material( + name="CoolRoofMembrane", + conductivity=0.17, + density=1200, + specific_heat=900, + mat_type="Sealing", + solar_absorptance=0.30, + visible_absorptance=0.30, + roughness="Smooth", +) + +ACOUSTIC_TILE = _material( + name="AcousticTile", + conductivity=0.065, + density=280, + specific_heat=840, + 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, +) + +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", +) + +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, + 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, + 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, + 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] = { + 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 new file mode 100644 index 0000000..38ac470 --- /dev/null +++ b/epinterface/sbem/flat_constructions/roofs.py @@ -0,0 +1,459 @@ +"""Semi-flat roof schema and translators for SBEM assemblies.""" + +import warnings +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.layers import ( + _AIR_GAP_THICKNESS_M, + AIR_GAP_ROOF, + 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 MaterialName + +RoofStructuralSystem = Literal[ + "none", + "light_wood_truss", + "deep_wood_truss", + "steel_joist", + "metal_deck", + "mass_timber", + "precast_concrete", + "poured_concrete", + "reinforced_concrete", + "sip", + "corrugated_metal", +] + +RoofInteriorFinish = Literal[ + "none", + "gypsum_board", + "acoustic_tile", + "wood_panel", +] +RoofExteriorFinish = Literal[ + "none", + "epdm_membrane", + "cool_membrane", + "built_up_roof", + "metal_roof", + "tile_roof", + "asphalt_shingle", + "wood_shake", + "thatch", + "fiber_cement_sheet", +] + +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: MaterialName + thickness_m: float + supports_cavity_insulation: bool + cavity_depth_m: float | 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 + cavity_r_correction_factor: float = 1.0 + + +@dataclass(frozen=True) +class FinishTemplate: + """Default roof finish material and thickness assumptions.""" + + material_name: MaterialName + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[RoofStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name="GypsumBoard", + thickness_m=0.005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "light_wood_truss": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.14, + ), + "deep_wood_truss": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.240, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.12, + ), + "steel_joist": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.0, + supports_cavity_insulation=True, + 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 + # 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) + framing_path_r_value=0.35, + ), + "metal_deck": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.0015, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "mass_timber": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "precast_concrete": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "poured_concrete": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "reinforced_concrete": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sip": StructuralTemplate( + material_name="SIPCore", + thickness_m=0.160, + 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] = { + "none": None, + "gypsum_board": FinishTemplate( + material_name="GypsumBoard", + thickness_m=0.0127, + ), + "acoustic_tile": FinishTemplate( + material_name="AcousticTile", + thickness_m=0.019, + ), + "wood_panel": FinishTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.012, + ), +} + +EXTERIOR_FINISH_TEMPLATES: dict[RoofExteriorFinish, FinishTemplate | None] = { + "none": None, + "epdm_membrane": FinishTemplate( + material_name="RoofMembrane", + thickness_m=0.005, + ), + "cool_membrane": FinishTemplate( + material_name="CoolRoofMembrane", + thickness_m=0.005, + ), + "built_up_roof": FinishTemplate( + material_name="CementMortar", + thickness_m=0.02, + ), + "metal_roof": FinishTemplate( + material_name="SteelPanel", + thickness_m=0.001, + ), + "tile_roof": 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, + ), + "thatch": FinishTemplate( + material_name="ThatchReed", + thickness_m=0.200, + ), + "fiber_cement_sheet": FinishTemplate( + material_name="FiberCementBoard", + thickness_m=0.006, + ), +} + + +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]", + ) + 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", + ) + 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", + ) + exterior_finish: RoofExteriorFinish = Field( + 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: + """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 + + 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: + 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"Overriding with the maximum possible value ({max_nominal_r:.2f} m²K/W).", + stacklevel=2, + ) + self.nominal_cavity_insulation_r = max_nominal_r + return self + + +def build_roof_assembly( + roof: SemiFlatRoofConstruction, + *, + 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 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[roof.exterior_finish] + + # 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), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, + ) + ) + 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=ext_ins_material, + nominal_r_value=roof.nominal_exterior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + 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 + ) + + 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, + cavity_depth_m=template.cavity_depth_m or 0.0, + 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, + uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, + cavity_insulation_material=cavity_ins_mat_name, + ) + layers.append( + 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( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + + 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( + material=cavity_ins_mat_name, + nominal_r_value=effective_cavity_r, + layer_order=layer_order, + ) + ) + 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=int_ins_material, + 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( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=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..f586fff --- /dev/null +++ b/epinterface/sbem/flat_constructions/slabs.py @@ -0,0 +1,275 @@ +"""Semi-flat slab schema and translators for SBEM assemblies.""" + +from dataclasses import dataclass +from typing import Literal, get_args + +from pydantic import BaseModel, Field + +from epinterface.sbem.components.envelope import ( + ConstructionAssemblyComponent, + ConstructionLayerComponent, +) +from epinterface.sbem.flat_constructions.layers import ( + CONTINUOUS_INSULATION_MATERIAL_MAP, + ContinuousInsulationMaterial, + layer_from_nominal_r, + resolve_material, +) +from epinterface.sbem.flat_constructions.materials import MaterialName + +SlabStructuralSystem = Literal[ + "none", + "slab_on_grade", + "thickened_edge_slab", + "reinforced_concrete_suspended", + "precast_hollow_core", + "mass_timber_deck", + "sip_floor", + "compacted_earth_floor", + "suspended_timber_floor", +] +SlabInsulationPlacement = Literal["auto", "under_slab", "above_slab"] + +SlabInteriorFinish = Literal[ + "none", + "polished_concrete", + "tile", + "carpet", + "wood_floor", + "cement_screed", +] +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) + + +@dataclass(frozen=True) +class StructuralTemplate: + """Default structural slab assumptions for a structural system.""" + + material_name: MaterialName + thickness_m: float + supports_under_insulation: bool + + +@dataclass(frozen=True) +class FinishTemplate: + """Default slab finish material and thickness assumptions.""" + + material_name: MaterialName + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[SlabStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name="ConcreteMC_Light", + thickness_m=0.05, + supports_under_insulation=False, + ), + "slab_on_grade": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.15, + supports_under_insulation=True, + ), + "thickened_edge_slab": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.20, + supports_under_insulation=True, + ), + "reinforced_concrete_suspended": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.18, + supports_under_insulation=False, + ), + "precast_hollow_core": StructuralTemplate( + material_name="ConcreteMC_Light", + thickness_m=0.20, + supports_under_insulation=False, + ), + "mass_timber_deck": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.18, + supports_under_insulation=False, + ), + "sip_floor": StructuralTemplate( + material_name="SIPCore", + thickness_m=0.18, + supports_under_insulation=False, + ), + "compacted_earth_floor": StructuralTemplate( + material_name="RammedEarth", + 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] = { + "none": None, + "polished_concrete": FinishTemplate( + material_name="CementMortar", + thickness_m=0.015, + ), + "tile": FinishTemplate( + material_name="CeramicTile", + thickness_m=0.015, + ), + "carpet": FinishTemplate( + material_name="UrethaneCarpet", + thickness_m=0.012, + ), + "wood_floor": 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] = { + "none": None, + "gypsum_board": FinishTemplate( + material_name="GypsumBoard", + thickness_m=0.0127, + ), + "plaster": FinishTemplate( + material_name="GypsumPlaster", + 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_insulation_r: float = Field( + default=0.0, + 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", + ) + 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_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] + return "under_slab" if template.supports_under_insulation else "above_slab" + + @property + 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 ( + self.effective_insulation_placement == "under_slab" + and not template.supports_under_insulation + ): + return 0.0 + return self.nominal_insulation_r + + +def build_slab_assembly( + slab: SemiFlatSlabConstruction, + *, + name: str = "GroundSlabAssembly", +) -> ConstructionAssemblyComponent: + """Translate semi-flat slab inputs into a concrete slab assembly.""" + # EnergyPlus convention: layer 0 is outermost (outside -> inside). + template = STRUCTURAL_TEMPLATES[slab.structural_system] + layers: list[ConstructionLayerComponent] = [] + layer_order = 0 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[slab.exterior_finish] + if exterior_finish is not None: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(exterior_finish.material_name), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + + slab_ins_material = CONTINUOUS_INSULATION_MATERIAL_MAP[slab.insulation_material] + + if ( + slab.effective_insulation_placement == "under_slab" + and slab.effective_nominal_insulation_r > 0 + ): + layers.append( + layer_from_nominal_r( + material=slab_ins_material, + nominal_r_value=slab.effective_nominal_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, + ) + ) + layer_order += 1 + + if ( + slab.effective_insulation_placement == "above_slab" + and slab.effective_nominal_insulation_r > 0 + ): + layers.append( + layer_from_nominal_r( + material=slab_ins_material, + nominal_r_value=slab.effective_nominal_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + interior_finish = INTERIOR_FINISH_TEMPLATES[slab.interior_finish] + if interior_finish is not None: + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=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 new file mode 100644 index 0000000..9cd0f36 --- /dev/null +++ b/epinterface/sbem/flat_constructions/walls.py @@ -0,0 +1,684 @@ +"""Semi-flat wall schema and translators for SBEM assemblies.""" + +import warnings +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.layers import ( + _AIR_GAP_THICKNESS_M, + AIR_GAP_WALL, + 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 MaterialName + +WallStructuralSystem = Literal[ + "none", + "sheet_metal", + "light_gauge_steel", + "structural_steel", + "woodframe", + "deep_woodframe", + "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", + "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[ + "none", "drywall", "plaster", "cement_plaster", "wood_panel" +] +WallExteriorFinish = Literal[ + "none", + "brick_veneer", + "stucco", + "fiber_cement", + "metal_panel", + "vinyl_siding", + "wood_siding", + "stone_veneer", +] + +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: MaterialName + thickness_m: float + supports_cavity_insulation: bool + cavity_depth_m: float | 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 + cavity_r_correction_factor: float = 1.0 + + +@dataclass(frozen=True) +class FinishTemplate: + """Default finish material and thickness assumptions.""" + + material_name: MaterialName + thickness_m: float + + +STRUCTURAL_TEMPLATES: dict[WallStructuralSystem, StructuralTemplate] = { + "none": StructuralTemplate( + material_name="GypsumBoard", + thickness_m=0.005, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sheet_metal": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.001, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "light_gauge_steel": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + framing_material_name="SteelPanel", + framing_fraction=0.12, + # 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) + framing_path_r_value=0.26, + ), + "structural_steel": StructuralTemplate( + material_name="SteelPanel", + thickness_m=0.006, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "woodframe": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.23, + ), + "deep_woodframe": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.23, + ), + "woodframe_24oc": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.090, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.17, + ), + "deep_woodframe_24oc": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.0, + supports_cavity_insulation=True, + cavity_depth_m=0.140, + framing_material_name="SoftwoodGeneral", + framing_fraction=0.17, + ), + "engineered_timber": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.160, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "cmu": StructuralTemplate( + 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="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="ConcreteRC_Dense", + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "poured_concrete": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.180, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "masonry": StructuralTemplate( + material_name="ClayBrick", + thickness_m=0.190, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "rammed_earth": StructuralTemplate( + material_name="RammedEarth", + thickness_m=0.350, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "reinforced_concrete": StructuralTemplate( + material_name="ConcreteRC_Dense", + thickness_m=0.200, + supports_cavity_insulation=False, + cavity_depth_m=None, + ), + "sip": StructuralTemplate( + material_name="SIPCore", + thickness_m=0.150, + 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, + ), + "timber_panel": StructuralTemplate( + material_name="SoftwoodGeneral", + thickness_m=0.018, + 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, + 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, + ), + "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] = { + "none": None, + "drywall": FinishTemplate( + material_name="GypsumBoard", + thickness_m=0.0127, + ), + "plaster": 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, + ), +} + +EXTERIOR_FINISH_TEMPLATES: dict[WallExteriorFinish, FinishTemplate | None] = { + "none": None, + "brick_veneer": FinishTemplate( + material_name="ClayBrick", + thickness_m=0.090, + ), + "stucco": FinishTemplate( + material_name="CementMortar", + thickness_m=0.020, + ), + "fiber_cement": FinishTemplate( + material_name="FiberCementBoard", + thickness_m=0.012, + ), + "metal_panel": 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, + ), +} + + +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]", + ) + exterior_insulation_material: ContinuousInsulationMaterial = Field( + default="xps", + title="Exterior continuous insulation material", + ) + interior_insulation_material: ContinuousInsulationMaterial = Field( + 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", + ) + exterior_finish: WallExteriorFinish = Field( + 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: + """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 + + 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: + 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"Overriding with the maximum possible value ({max_nominal_r:.2f} m²K/W).", + stacklevel=2, + ) + self.nominal_cavity_insulation_r = max_nominal_r + return self + + +def build_facade_assembly( # noqa: C901 + wall: SemiFlatWallConstruction, + *, + 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 + + exterior_finish = EXTERIOR_FINISH_TEMPLATES[wall.exterior_finish] + + # 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), + Thickness=exterior_finish.thickness_m, + LayerOrder=layer_order, + ) + ) + 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=ext_ins_material, + nominal_r_value=wall.nominal_exterior_insulation_r, + layer_order=layer_order, + ) + ) + layer_order += 1 + + 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 + ) + + 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, + cavity_depth_m=template.cavity_depth_m or 0.0, + 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, + uninsulated_cavity_r_value=template.uninsulated_cavity_r_value, + cavity_insulation_material=cavity_ins_mat_name, + ) + layers.append( + ConstructionLayerComponent( + ConstructionMaterial=consolidated_cavity_material, + Thickness=template.cavity_depth_m or 0.0, + LayerOrder=layer_order, + ) + ) + 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( + ConstructionMaterial=resolve_material(template.material_name), + Thickness=template.thickness_m, + LayerOrder=layer_order, + ) + ) + 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 + ): + effective_cavity_r = ( + wall.effective_nominal_cavity_insulation_r + * template.cavity_r_correction_factor + ) + layers.append( + layer_from_nominal_r( + material=cavity_ins_mat_name, + nominal_r_value=effective_cavity_r, + layer_order=layer_order, + ) + ) + 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=int_ins_material, + 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( + ConstructionLayerComponent( + ConstructionMaterial=resolve_material(interior_finish.material_name), + Thickness=interior_finish.thickness_m, + LayerOrder=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..454f101 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,40 @@ 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", +from epinterface.sbem.flat_constructions import ( + CavityInsulationMaterial, + ContinuousInsulationMaterial, + ExteriorCavityType, + SemiFlatRoofConstruction, + SemiFlatSlabConstruction, + SemiFlatWallConstruction, + WallExteriorFinish, + WallInteriorFinish, + WallStructuralSystem, + build_envelope_assemblies, ) - -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", +from epinterface.sbem.flat_constructions import ( + RoofExteriorFinish as RoofExteriorFinishType, ) - -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", +from epinterface.sbem.flat_constructions import ( + RoofInteriorFinish as RoofInteriorFinishType, ) - -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", +from epinterface.sbem.flat_constructions import ( + RoofStructuralSystem as RoofStructuralSystemType, ) - -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", +from epinterface.sbem.flat_constructions import ( + SlabExteriorFinish as SlabExteriorFinishType, ) - -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", +from epinterface.sbem.flat_constructions import ( + SlabInsulationPlacement as SlabInsulationPlacementType, ) - -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", +from epinterface.sbem.flat_constructions import ( + SlabInteriorFinish as SlabInteriorFinishType, ) - -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 ( + SlabStructuralSystem as SlabStructuralSystemType, ) +from epinterface.weather import WeatherUrl class ParametericYear(BaseModel): @@ -915,9 +788,34 @@ class FlatModel(BaseModel): WindowSHGF: float WindowTVis: float - FacadeRValue: float - RoofRValue: float - SlabRValue: float + FacadeStructuralSystem: WallStructuralSystem = "cmu" + 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" + + 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) + 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" WWR: float F2FHeight: float @@ -928,6 +826,50 @@ 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, + 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, + ) + + @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, + 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, + ) + + @property + def slab_construction(self) -> SemiFlatSlabConstruction: + """Return the semantic slab specification.""" + 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, + ) + def to_zone(self) -> ZoneComponent: """Convert the flat model to a full zone.""" # occ_regular_workday = DayComponent( @@ -1880,190 +1822,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=self.roof_construction, + slab=self.slab_construction, ) basement_infiltration = infiltration.model_copy(deep=True) @@ -2110,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, @@ -2148,9 +1911,23 @@ def simulate( Rotation=45, WWR=0.3, NFloors=2, - FacadeRValue=3.0, - RoofRValue=3.0, - SlabRValue=3.0, + FacadeStructuralSystem="cmu", + FacadeCavityInsulationRValue=1.2, + FacadeExteriorInsulationRValue=1.0, + FacadeInteriorInsulationRValue=0.0, + FacadeInteriorFinish="drywall", + FacadeExteriorFinish="brick_veneer", + RoofStructuralSystem="poured_concrete", + RoofCavityInsulationRValue=0.0, + RoofExteriorInsulationRValue=2.5, + RoofInteriorInsulationRValue=0.2, + RoofInteriorFinish="gypsum_board", + RoofExteriorFinish="epdm_membrane", + SlabStructuralSystem="slab_on_grade", + SlabInsulationRValue=2.2, + SlabInsulationPlacement="auto", + SlabInteriorFinish="tile", + SlabExteriorFinish="none", WindowUValue=3.0, WindowSHGF=0.7, WindowTVis=0.5, 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..222681b --- /dev/null +++ b/tests/test_flat_constructions/test_layers_utils.py @@ -0,0 +1,181 @@ +"""Tests for shared flat-construction layer helpers.""" + +import pytest + +from epinterface.sbem.flat_constructions.layers import ( + _MIN_RESIDUAL_GAP_M, + equivalent_framed_cavity_material, + layer_from_nominal_r, + resolve_material, +) +from epinterface.sbem.flat_constructions.materials import ( + ASPHALT_SHINGLE, + CONCRETE_BLOCK_H, + COOL_ROOF_MEMBRANE, + FIBERGLASS_BATTS, + NATURAL_STONE, + RAMMED_EARTH, + ROOF_MEMBRANE, + SOFTWOOD_GENERAL, + STEEL_PANEL, + VINYL_SIDING, + 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 + + +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) + + +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) + + +# --------------------------------------------------------------------------- +# 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_physical_sanity_audit.py b/tests/test_flat_constructions/test_physical_sanity_audit.py new file mode 100644 index 0000000..8bf908e --- /dev/null +++ b/tests/test_flat_constructions/test_physical_sanity_audit.py @@ -0,0 +1,47 @@ +"""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: + """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 + ]) + + +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 new file mode 100644 index 0000000..5fdbbb1 --- /dev/null +++ b/tests/test_flat_constructions/test_roofs_slabs.py @@ -0,0 +1,477 @@ +"""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.materials import ( + CERAMIC_TILE, + CONCRETE_RC_DENSE, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + ROOF_MEMBRANE, + SOFTWOOD_GENERAL, +) +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 ( + SemiFlatWallConstruction, + build_facade_assembly, +) + + +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 + 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) + + +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.""" + 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_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 + 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) / fill_r + ) + 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 + + 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) / fill_r + ) + 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( + structural_system="slab_on_grade", + nominal_insulation_r=1.5, + insulation_placement="auto", + interior_finish="tile", + exterior_finish="none", + ) + assembly = build_slab_assembly(slab) + + 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) + + +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_insulation_r=2.0, + insulation_placement="under_slab", + 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_insulation_r == 0.0 + assert "XPSBoard" not in layer_material_names + + +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_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_insulation_r=1.4, + insulation_placement="auto", + interior_finish="tile", + exterior_finish="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 + + +# --- 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[-1] + 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 new file mode 100644 index 0000000..87a6d2a --- /dev/null +++ b/tests/test_flat_constructions/test_walls.py @@ -0,0 +1,385 @@ +"""Tests for semi-flat wall construction translation.""" + +import pytest + +from epinterface.sbem.flat_constructions.materials import ( + CEMENT_MORTAR, + CONCRETE_BLOCK_H, + CONCRETE_RC_DENSE, + FIBERGLASS_BATTS, + GYPSUM_BOARD, + MINERAL_WOOL_BATT, + SOFTWOOD_GENERAL, + MaterialName, +) +from epinterface.sbem.flat_constructions.walls import ( + STRUCTURAL_TEMPLATES, + SemiFlatWallConstruction, + WallExteriorFinish, + 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 + 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) + + +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.""" + 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_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_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_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 + + +# --- 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