Spaces:
Sleeping
Sleeping
| """ | |
| Building component data models for HVAC Load Calculator. | |
| This module defines the data structures for walls, roofs, floors, windows, doors, and other building components. | |
| Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.2. | |
| """ | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from typing import List, Dict, Optional, Union | |
| import numpy as np | |
| from data.drapery import Drapery | |
| class Orientation(Enum): | |
| """Enumeration for building component orientations.""" | |
| NORTH = "NORTH" | |
| NORTHEAST = "NORTHEAST" | |
| EAST = "EAST" | |
| SOUTHEAST = "SOUTHEAST" | |
| SOUTH = "SOUTH" | |
| SOUTHWEST = "SOUTHWEST" | |
| WEST = "WEST" | |
| NORTHWEST = "NORTHWEST" | |
| HORIZONTAL = "HORIZONTAL" # For roofs and floors | |
| NOT_APPLICABLE = "N/A" # For components without orientation | |
| class ComponentType(Enum): | |
| """Enumeration for building component types.""" | |
| WALL = "WALL" | |
| ROOF = "ROOF" | |
| FLOOR = "FLOOR" | |
| WINDOW = "WINDOW" | |
| DOOR = "DOOR" | |
| SKYLIGHT = "SKYLIGHT" | |
| class MaterialLayer: | |
| """Class representing a single material layer in a building component.""" | |
| def __init__(self, name: str, thickness: float, conductivity: float, | |
| density: float = None, specific_heat: float = None): | |
| """ | |
| Initialize a material layer. | |
| Args: | |
| name: Name of the material | |
| thickness: Thickness of the layer in meters | |
| conductivity: Thermal conductivity in W/(m·K) | |
| density: Density in kg/m³ (optional) | |
| specific_heat: Specific heat capacity in J/(kg·K) (optional) | |
| """ | |
| self.name = name | |
| self.thickness = thickness # m | |
| self.conductivity = conductivity # W/(m·K) | |
| self.density = density # kg/m³ | |
| self.specific_heat = specific_heat # J/(kg·K) | |
| def r_value(self) -> float: | |
| """Calculate the thermal resistance (R-value) of the layer in m²·K/W.""" | |
| if self.conductivity == 0: | |
| return float('inf') # Avoid division by zero | |
| return self.thickness / self.conductivity | |
| def thermal_mass(self) -> Optional[float]: | |
| """Calculate the thermal mass of the layer in J/(m²·K).""" | |
| if self.density is None or self.specific_heat is None: | |
| return None | |
| return self.thickness * self.density * self.specific_heat | |
| def to_dict(self) -> Dict: | |
| """Convert the material layer to a dictionary.""" | |
| return { | |
| "name": self.name, | |
| "thickness": self.thickness, | |
| "conductivity": self.conductivity, | |
| "density": self.density, | |
| "specific_heat": self.specific_heat, | |
| "r_value": self.r_value, | |
| "thermal_mass": self.thermal_mass | |
| } | |
| class BuildingComponent: | |
| """Base class for all building components.""" | |
| id: str | |
| name: str | |
| component_type: ComponentType | |
| u_value: float # W/(m²·K) | |
| area: float # m² | |
| orientation: Orientation = Orientation.NOT_APPLICABLE | |
| solar_absorptivity: float = 0.6 # Solar absorptivity (0-1), default Medium | |
| material_layers: List[MaterialLayer] = field(default_factory=list) | |
| def __post_init__(self): | |
| """Validate component data after initialization.""" | |
| if self.area <= 0: | |
| raise ValueError("Area must be greater than zero") | |
| if self.u_value < 0: | |
| raise ValueError("U-value cannot be negative") | |
| # Enforce solar_absorptivity to be one of the five allowed values | |
| valid_absorptivities = [0.3, 0.45, 0.6, 0.75, 0.9] | |
| if not 0 <= self.solar_absorptivity <= 1: | |
| raise ValueError("Solar absorptivity must be between 0 and 1") | |
| if self.solar_absorptivity not in valid_absorptivities: | |
| # Find the closest valid value | |
| self.solar_absorptivity = min(valid_absorptivities, key=lambda x: abs(x - self.solar_absorptivity)) | |
| def r_value(self) -> float: | |
| """Calculate the total thermal resistance (R-value) in m²·K/W.""" | |
| return 1 / self.u_value if self.u_value > 0 else float('inf') | |
| def total_r_value_from_layers(self) -> Optional[float]: | |
| """Calculate the total R-value from material layers if available.""" | |
| if not self.material_layers: | |
| return None | |
| # Add surface resistances (interior and exterior) | |
| r_si = 0.13 # m²·K/W (interior surface resistance) | |
| r_se = 0.04 # m²·K/W (exterior surface resistance) | |
| # Sum the R-values of all layers | |
| r_layers = sum(layer.r_value for layer in self.material_layers) | |
| return r_si + r_layers + r_se | |
| def calculated_u_value(self) -> Optional[float]: | |
| """Calculate U-value from material layers if available.""" | |
| total_r = self.total_r_value_from_layers | |
| if total_r is None or total_r == 0: | |
| return None | |
| return 1 / total_r | |
| def heat_transfer_rate(self, delta_t: float) -> float: | |
| """ | |
| Calculate heat transfer rate through the component. | |
| Args: | |
| delta_t: Temperature difference across the component in K or °C | |
| Returns: | |
| Heat transfer rate in Watts | |
| """ | |
| return self.u_value * self.area * delta_t | |
| def to_dict(self) -> Dict: | |
| """Convert the building component to a dictionary.""" | |
| return { | |
| "id": self.id, | |
| "name": self.name, | |
| "component_type": self.component_type.value, | |
| "u_value": self.u_value, | |
| "area": self.area, | |
| "orientation": self.orientation.value, | |
| "solar_absorptivity": self.solar_absorptivity, | |
| "r_value": self.r_value, | |
| "material_layers": [layer.to_dict() for layer in self.material_layers], | |
| "calculated_u_value": self.calculated_u_value, | |
| "total_r_value_from_layers": self.total_r_value_from_layers | |
| } | |
| class Wall(BuildingComponent): | |
| """Class representing a wall component.""" | |
| VALID_WALL_GROUPS = {"A", "B", "C", "D", "E", "F", "G", "H"} # ASHRAE wall groups for CLTD | |
| has_sun_exposure: bool = True | |
| wall_type: str = "Custom" # Brick, Concrete, Wood Frame, etc. | |
| wall_group: str = "A" # ASHRAE wall group (A, B, C, D, E, F, G, H) | |
| gross_area: float = None # m² (before subtracting windows/doors) | |
| net_area: float = None # m² (after subtracting windows/doors) | |
| windows: List[str] = field(default_factory=list) # List of window IDs | |
| doors: List[str] = field(default_factory=list) # List of door IDs | |
| def __post_init__(self): | |
| """Initialize wall-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.WALL | |
| # Validate wall_group | |
| if self.wall_group not in self.VALID_WALL_GROUPS: | |
| raise ValueError(f"Invalid wall_group: {self.wall_group}. Must be one of {self.VALID_WALL_GROUPS}") | |
| # Set net area equal to area if not specified | |
| if self.net_area is None: | |
| self.net_area = self.area | |
| # Set gross area equal to net area if not specified | |
| if self.gross_area is None: | |
| self.gross_area = self.net_area | |
| def update_net_area(self, window_areas: Dict[str, float], door_areas: Dict[str, float]): | |
| """ | |
| Update the net wall area by subtracting windows and doors. | |
| Args: | |
| window_areas: Dictionary mapping window IDs to areas | |
| door_areas: Dictionary mapping door IDs to areas | |
| """ | |
| total_window_area = sum(window_areas.get(window_id, 0) for window_id in self.windows) | |
| total_door_area = sum(door_areas.get(door_id, 0) for door_id in self.doors) | |
| self.net_area = self.gross_area - total_window_area - total_door_area | |
| self.area = self.net_area # Update the main area property | |
| if self.net_area <= 0: | |
| raise ValueError("Net wall area cannot be negative or zero") | |
| def to_dict(self) -> Dict: | |
| """Convert the wall to a dictionary.""" | |
| wall_dict = super().to_dict() | |
| wall_dict.update({ | |
| "has_sun_exposure": self.has_sun_exposure, | |
| "wall_type": self.wall_type, | |
| "wall_group": self.wall_group, | |
| "gross_area": self.gross_area, | |
| "net_area": self.net_area, | |
| "windows": self.windows, | |
| "doors": self.doors | |
| }) | |
| return wall_dict | |
| class Roof(BuildingComponent): | |
| """Class representing a roof component.""" | |
| VALID_ROOF_GROUPS = {"A", "B", "C", "D", "E", "F", "G"} # ASHRAE roof groups for CLTD | |
| roof_type: str = "Custom" # Flat, Pitched, etc. | |
| roof_group: str = "A" # ASHRAE roof group | |
| pitch: float = 0.0 # Roof pitch in degrees | |
| has_suspended_ceiling: bool = False | |
| ceiling_plenum_height: float = 0.0 # m | |
| def __post_init__(self): | |
| """Initialize roof-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.ROOF | |
| self.orientation = Orientation.HORIZONTAL | |
| # Validate roof_group | |
| if self.roof_group not in self.VALID_ROOF_GROUPS: | |
| raise ValueError(f"Invalid roof_group: {self.roof_group}. Must be one of {self.VALID_ROOF_GROUPS}") | |
| def to_dict(self) -> Dict: | |
| """Convert the roof to a dictionary.""" | |
| roof_dict = super().to_dict() | |
| roof_dict.update({ | |
| "roof_type": self.roof_type, | |
| "roof_group": self.roof_group, | |
| "pitch": self.pitch, | |
| "has_suspended_ceiling": self.has_suspended_ceiling, | |
| "ceiling_plenum_height": self.ceiling_plenum_height | |
| }) | |
| return roof_dict | |
| class Floor(BuildingComponent): | |
| """Class representing a floor component.""" | |
| floor_type: str = "Custom" # Slab-on-grade, Raised, etc. | |
| is_ground_contact: bool = False | |
| perimeter_length: float = 0.0 # m (for slab-on-grade floors) | |
| insulated: bool = False # Added to indicate insulation status | |
| ground_temperature_c: float = None # Added for ground temperature in °C | |
| def __post_init__(self): | |
| """Initialize floor-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.FLOOR | |
| self.orientation = Orientation.HORIZONTAL | |
| def to_dict(self) -> Dict: | |
| """Convert the floor to a dictionary.""" | |
| floor_dict = super().to_dict() | |
| floor_dict.update({ | |
| "floor_type": self.floor_type, | |
| "is_ground_contact": self.is_ground_contact, | |
| "perimeter_length": self.perimeter_length, | |
| "insulated": self.insulated, | |
| "ground_temperature_c": self.ground_temperature_c | |
| }) | |
| return floor_dict | |
| class Fenestration(BuildingComponent): | |
| """Base class for fenestration components (windows, doors, skylights).""" | |
| shgc: float = 0.7 # Solar Heat Gain Coefficient | |
| vt: float = 0.7 # Visible Transmittance | |
| frame_type: str = "Aluminum" # Aluminum, Wood, Vinyl, etc. | |
| frame_width: float = 0.05 # m | |
| has_shading: bool = False | |
| shading_type: str = None # Internal, External, Between-glass | |
| shading_coefficient: float = 1.0 # 0-1 (1 = no shading) | |
| def __post_init__(self): | |
| """Initialize fenestration-specific attributes.""" | |
| super().__post_init__() | |
| if self.shgc < 0 or self.shgc > 1: | |
| raise ValueError("SHGC must be between 0 and 1") | |
| if self.vt < 0 or self.vt > 1: | |
| raise ValueError("VT must be between 0 and 1") | |
| if self.shading_coefficient < 0 or self.shading_coefficient > 1: | |
| raise ValueError("Shading coefficient must be between 0 and 1") | |
| def effective_shgc(self) -> float: | |
| """Calculate the effective SHGC considering shading.""" | |
| return self.shgc * self.shading_coefficient | |
| def to_dict(self) -> Dict: | |
| """Convert the fenestration to a dictionary.""" | |
| fenestration_dict = super().to_dict() | |
| fenestration_dict.update({ | |
| "shgc": self.shgc, | |
| "vt": self.vt, | |
| "frame_type": self.frame_type, | |
| "frame_width": self.frame_width, | |
| "has_shading": self.has_shading, | |
| "shading_type": self.shading_type, | |
| "shading_coefficient": self.shading_coefficient, | |
| "effective_shgc": self.effective_shgc | |
| }) | |
| return fenestration_dict | |
| class Window(Fenestration): | |
| """Class representing a window component.""" | |
| window_type: str = "Custom" # Single, Double, Triple glazed, etc. | |
| glazing_layers: int = 2 # Number of glazing layers | |
| gas_fill: str = "Air" # Air, Argon, Krypton, etc. | |
| low_e_coating: bool = False | |
| width: float = 1.0 # m | |
| height: float = 1.0 # m | |
| wall_id: str = None # ID of the wall containing this window | |
| drapery: Optional[Drapery] = None # Drapery object | |
| def __post_init__(self): | |
| """Initialize window-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.WINDOW | |
| # Calculate area from width and height if not provided | |
| if self.area <= 0 and self.width > 0 and self.height > 0: | |
| self.area = self.width * self.height | |
| # Initialize drapery if not provided | |
| if self.drapery is None: | |
| self.drapery = Drapery(enabled=False) | |
| def from_classification(cls, id: str, name: str, u_value: float, area: float, | |
| shgc: float, orientation: Orientation, wall_id: str, | |
| drapery_classification: str, fullness: float = 1.0, **kwargs) -> 'Window': | |
| """ | |
| Create window object with drapery from ASHRAE classification. | |
| Args: | |
| id: Unique identifier | |
| name: Window name | |
| u_value: Window U-value in W/m²K | |
| area: Window area in m² | |
| shgc: Solar Heat Gain Coefficient (0-1) | |
| orientation: Window orientation | |
| wall_id: ID of the wall containing this window | |
| drapery_classification: ASHRAE drapery classification (e.g., ID, IM, IIL) | |
| fullness: Fullness factor (0-2) | |
| **kwargs: Additional arguments for Window attributes | |
| Returns: | |
| Window object | |
| """ | |
| drapery = Drapery.from_classification(drapery_classification, fullness) | |
| return cls( | |
| id=id, | |
| name=name, | |
| component_type=ComponentType.WINDOW, | |
| u_value=u_value, | |
| area=area, | |
| shgc=shgc, | |
| orientation=orientation, | |
| drapery=drapery, | |
| wall_id=wall_id, | |
| **kwargs | |
| ) | |
| def get_effective_u_value(self) -> float: | |
| """Get effective U-value with drapery adjustment.""" | |
| if self.drapery and self.drapery.enabled: | |
| return self.drapery.calculate_u_value_adjustment(self.u_value) | |
| return self.u_value | |
| def get_shading_coefficient(self) -> float: | |
| """Get shading coefficient with drapery.""" | |
| if self.drapery and self.drapery.enabled: | |
| return self.drapery.calculate_shading_coefficient(self.shgc) | |
| return self.shading_coefficient | |
| def get_iac(self) -> float: | |
| """Get Interior Attenuation Coefficient with drapery.""" | |
| if self.drapery and self.drapery.enabled: | |
| return self.drapery.calculate_iac(self.shgc) | |
| return 1.0 # No attenuation | |
| def to_dict(self) -> Dict: | |
| """Convert the window to a dictionary.""" | |
| window_dict = super().to_dict() | |
| window_dict.update({ | |
| "window_type": self.window_type, | |
| "glazing_layers": self.glazing_layers, | |
| "gas_fill": self.gas_fill, | |
| "low_e_coating": self.low_e_coating, | |
| "width": self.width, | |
| "height": self.height, | |
| "wall_id": self.wall_id, | |
| "drapery": self.drapery.to_dict() if self.drapery else None, | |
| "drapery_classification": self.drapery.get_classification() if self.drapery and self.drapery.enabled else None | |
| }) | |
| return window_dict | |
| class Door(Fenestration): | |
| """Class representing a door component.""" | |
| door_type: str = "Custom" # Solid, Partially glazed, etc. | |
| glazing_percentage: float = 0.0 # Percentage of door area that is glazed (0-100) | |
| width: float = 0.9 # m | |
| height: float = 2.1 # m | |
| wall_id: str = None # ID of the wall containing this door | |
| def __post_init__(self): | |
| """Initialize door-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.DOOR | |
| # Calculate area from width and height if not provided | |
| if self.area <= 0 and self.width > 0 and self.height > 0: | |
| self.area = self.width * self.height | |
| if self.glazing_percentage < 0 or self.glazing_percentage > 100: | |
| raise ValueError("Glazing percentage must be between 0 and 100") | |
| def glazing_area(self) -> float: | |
| """Calculate the glazed area of the door in m².""" | |
| return self.area * (self.glazing_percentage / 100) | |
| def opaque_area(self) -> float: | |
| """Calculate the opaque area of the door in m².""" | |
| return self.area - self.glazing_area | |
| def to_dict(self) -> Dict: | |
| """Convert the door to a dictionary.""" | |
| door_dict = super().to_dict() | |
| door_dict.update({ | |
| "door_type": self.door_type, | |
| "glazing_percentage": self.glazing_percentage, | |
| "width": self.width, | |
| "height": self.height, | |
| "wall_id": self.wall_id, | |
| "glazing_area": self.glazing_area, | |
| "opaque_area": self.opaque_area | |
| }) | |
| return door_dict | |
| class Skylight(Fenestration): | |
| """Class representing a skylight component.""" | |
| skylight_type: str = "Custom" # Flat, Domed, etc. | |
| glazing_layers: int = 2 # Number of glazing layers | |
| gas_fill: str = "Air" # Air, Argon, Krypton, etc. | |
| low_e_coating: bool = False | |
| width: float = 1.0 # m | |
| length: float = 1.0 # m | |
| roof_id: str = None # ID of the roof containing this skylight | |
| def __post_init__(self): | |
| """Initialize skylight-specific attributes.""" | |
| super().__post_init__() | |
| self.component_type = ComponentType.SKYLIGHT | |
| self.orientation = Orientation.HORIZONTAL | |
| # Calculate area from width and length if not provided | |
| if self.area <= 0 and self.width > 0 and self.length > 0: | |
| self.area = self.width * self.length | |
| def to_dict(self) -> Dict: | |
| """Convert the skylight to a dictionary.""" | |
| skylight_dict = super().to_dict() | |
| skylight_dict.update({ | |
| "skylight_type": self.skylight_type, | |
| "glazing_layers": self.glazing_layers, | |
| "gas_fill": self.gas_fill, | |
| "low_e_coating": self.low_e_coating, | |
| "width": self.width, | |
| "length": self.length, | |
| "roof_id": self.roof_id | |
| }) | |
| return skylight_dict | |
| class BuildingComponentFactory: | |
| """Factory class for creating building components.""" | |
| def create_component(component_data: Dict) -> BuildingComponent: | |
| """ | |
| Create a building component from a dictionary of data. | |
| Args: | |
| component_data: Dictionary containing component data | |
| Returns: | |
| A BuildingComponent object of the appropriate type | |
| """ | |
| component_type = component_data.get("component_type") | |
| # Convert string component_type to ComponentType enum | |
| if isinstance(component_type, str): | |
| component_type = ComponentType[component_type] | |
| # Handle legacy 'color' field for backward compatibility | |
| if "color" in component_data and "solar_absorptivity" not in component_data: | |
| color_map = { | |
| "Light": 0.3, # Maps to Light | |
| "Light to Medium": 0.45, # Maps to Light to Medium | |
| "Light-Medium": 0.45, # Alternative spelling for legacy data | |
| "Medium": 0.6, # Maps to Medium | |
| "Medium to Dark": 0.75, # Maps to Medium to Dark | |
| "Medium-Dark": 0.75, # Alternative spelling for legacy data | |
| "Dark": 0.9 # Maps to Dark | |
| } | |
| # Use the mapped value or default to 0.6 (Medium) for unrecognized colors | |
| color = component_data["color"] | |
| component_data["solar_absorptivity"] = color_map.get(color, 0.6) | |
| if color not in color_map: | |
| print(f"Warning: Unrecognized legacy color '{color}' in component data. Defaulting to solar_absorptivity = 0.6 (Medium).") | |
| # Handle drapery for Window components | |
| if component_type == ComponentType.WINDOW: | |
| drapery_data = component_data.pop("drapery", None) | |
| drapery_classification = component_data.pop("drapery_classification", None) | |
| if drapery_classification: | |
| fullness = drapery_data.get("fullness", 1.0) if drapery_data else 1.0 | |
| component_data["drapery"] = Drapery.from_classification(drapery_classification, fullness) | |
| elif drapery_data: | |
| component_data["drapery"] = Drapery.from_dict(drapery_data) | |
| # Convert orientation to Orientation enum | |
| if "orientation" in component_data and isinstance(component_data["orientation"], str): | |
| component_data["orientation"] = Orientation[component_data["orientation"]] | |
| # Convert material_layers to MaterialLayer objects | |
| if "material_layers" in component_data: | |
| component_data["material_layers"] = [ | |
| MaterialLayer(**layer) for layer in component_data["material_layers"] | |
| ] | |
| if component_type == ComponentType.WALL: | |
| return Wall(**component_data) | |
| elif component_type == ComponentType.ROOF: | |
| return Roof(**component_data) | |
| elif component_type == ComponentType.FLOOR: | |
| return Floor(**component_data) | |
| elif component_type == ComponentType.WINDOW: | |
| return Window(**component_data) | |
| elif component_type == ComponentType.DOOR: | |
| return Door(**component_data) | |
| elif component_type == ComponentType.SKYLIGHT: | |
| return Skylight(**component_data) | |
| else: | |
| raise ValueError(f"Unknown component type: {component_type}") |