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}") |