Source code for hvacpy.assembly

"""Assembly module — layered building envelope elements.

An Assembly represents a wall, roof, floor, or ceiling made of
stacked material layers. It calculates total thermal resistance
(R-value) and thermal transmittance (U-value) using the series
resistance method from ISO 6946:2017.

Example:
    >>> from hvacpy import Q_, db, Assembly
    >>> wall = Assembly('My Wall')
    >>> wall.add_layer('brick_common', Q_(110, 'mm'))
    >>> wall.add_layer('mineral_wool_batt', Q_(100, 'mm'))
    >>> wall.add_layer('plasterboard_std', Q_(12.5, 'mm'))
    >>> print(wall.u_value)
    0.298... W / K / m²
"""

from __future__ import annotations

from pint import Quantity

from hvacpy.exceptions import UnitError
from hvacpy.materials import Material, _DB
from hvacpy.units import Q_, validate_unit

# Surface resistance values — ISO 6946:2017 Table 1
# Named constants per spec Section 9.2, no magic numbers.
R_SI_WALL = 0.13  # m²K/W — horizontal heat flow
R_SI_ROOF = 0.10  # m²K/W — upward heat flow
R_SI_FLOOR = 0.17  # m²K/W — downward heat flow
R_SE = 0.04  # m²K/W — all orientations

_R_SI_MAP: dict[str, float] = {
    "wall": R_SI_WALL,
    "roof": R_SI_ROOF,
    "floor": R_SI_FLOOR,
}

_VALID_ORIENTATIONS = frozenset(_R_SI_MAP.keys())


[docs] class Assembly: """A layered building envelope element. Layers are ordered outside-to-inside. Surface resistances are added automatically based on orientation. Args: name: Human-readable description e.g. 'Brick Cavity Wall'. orientation: 'wall' | 'roof' | 'floor'. Defaults to 'wall'. Raises: ValueError: If orientation is not 'wall', 'roof', or 'floor'. """ def __init__(self, name: str, orientation: str = "wall") -> None: if orientation not in _VALID_ORIENTATIONS: raise ValueError( f"orientation must be 'wall', 'roof', or 'floor', " f"got '{orientation}'" ) self._name = name self._orientation = orientation self._r_si: float = _R_SI_MAP[orientation] self._r_se: float = R_SE # Internal layer storage — plain floats in SI units. self._layers: list[dict] = []
[docs] def add_layer( self, material: str | Material, thickness: Quantity, ) -> "Assembly": """Add a material layer to this assembly. Layers are ordered as added, outside to inside. Args: material: Material key string (e.g. 'brick_common') or a Material instance. String keys are case-insensitive. thickness: Layer thickness as a pint Quantity with length units. E.g. Q_(110, 'mm') or Q_(0.11, 'm'). Returns: self, enabling method chaining. Raises: TypeError: If thickness is not a pint Quantity. UnitError: If thickness does not have length dimensions. ValueError: If thickness is not positive. MaterialNotFoundError: If material is a string key not in DB. """ # Validate thickness type. if not isinstance(thickness, Quantity): raise TypeError( f"thickness must be a pint Quantity, got " f"{type(thickness).__name__}. " f'Use Q_(value, unit) e.g. Q_(110, "mm")' ) # Validate thickness dimensionality. validate_unit(thickness, "[length]", "thickness") # Convert to metres (SI). thickness_m: float = thickness.to("m").magnitude # Validate positive. if thickness_m <= 0: raise ValueError( f"thickness must be positive, got {thickness}" ) # Resolve material. db_key: str | None = None if isinstance(material, str): db_key = material.lower() mat = _DB.get(material) elif isinstance(material, Material): mat = material else: raise TypeError( f"material must be a str or Material, got " f"{type(material).__name__}" ) conductivity: float = mat.conductivity.to("W/(m*K)").magnitude r_layer: float = thickness_m / conductivity self._layers.append({ "name": mat.name, "key": db_key, "thickness_m": thickness_m, "conductivity": conductivity, "r_layer": r_layer, }) return self
@property def r_value(self) -> Quantity: """Total thermal resistance including surface resistances. Returns: R-value as a pint Quantity in m²·K/W. """ r_total = self._r_si + self._r_se for layer in self._layers: r_total += layer["r_layer"] return Q_(r_total, "m²*K/W") @property def u_value(self) -> Quantity: """Thermal transmittance (reciprocal of R-value). Returns: U-value as a pint Quantity in W/(m²·K). """ r_total = self.r_value.magnitude return Q_(1.0 / r_total, "W/(m²*K)") @property def layers(self) -> list[dict]: """Layer information as list of dicts. Each dict contains: - name (str): Material name. - key (str | None): DB key if looked up, None if passed as Material instance. - thickness_m (float): Thickness in metres. - conductivity (float): W/(m·K). - r_layer (float): Layer resistance in m²K/W. - r_fraction (float): Layer R as fraction of total layer R (excludes surface resistances), 0.0–1.0. Returns: List of layer dicts ordered outside-to-inside. """ r_layers_total = sum( layer["r_layer"] for layer in self._layers ) result: list[dict] = [] for layer in self._layers: fraction = ( layer["r_layer"] / r_layers_total if r_layers_total > 0 else 0.0 ) result.append({ "name": layer["name"], "key": layer["key"], "thickness_m": layer["thickness_m"], "conductivity": layer["conductivity"], "r_layer": layer["r_layer"], "r_fraction": fraction, }) return result
[docs] def breakdown(self) -> str: """Human-readable breakdown of the assembly. Returns: Formatted string showing all layers, their resistances, surface resistances, and total R/U values. """ r_total = self.r_value.magnitude u_val = self.u_value.magnitude lines: list[str] = [] lines.append( f"Wall: {self._name} " f"(orientation: {self._orientation})" ) lines.append("━" * 47) lines.append(" [outside]") lines.append( f" Surface resistance (R_se)" f" {self._r_se:.3f} m²K/W" ) lines.append(" " + "─" * 45) for layer in self._layers: thickness_mm = layer["thickness_m"] * 1000 # Format thickness — show decimal only if needed. if thickness_mm == int(thickness_mm): t_str = f"{int(thickness_mm)} mm" else: t_str = f"{thickness_mm:.1f} mm" # Use key if available, otherwise name. label = layer["key"] if layer["key"] else layer["name"] lines.append( f" {label:<16s}{t_str:>8s}" f" {layer['r_layer']:.3f} m²K/W" ) lines.append(" " + "─" * 45) lines.append( f" Surface resistance (R_si)" f" {self._r_si:.3f} m²K/W" ) lines.append(" [inside]") lines.append("━" * 47) lines.append(f" R_total = {r_total:.3f} m²K/W") lines.append(f" U_value = {u_val:.3f} W/(m²K)") return "\n".join(lines)