Source code for hvacpy.materials

"""Materials module — Material dataclass and MaterialsDB registry.

Provides a database of building materials with thermal properties for
use in assembly thermal resistance calculations.

Example:
    >>> from hvacpy import db
    >>> brick = db.get('brick_common')
    >>> print(brick.conductivity)
    0.72 W / K / m
"""

from __future__ import annotations

import threading
from dataclasses import dataclass
from typing import Any

from pint import Quantity

from hvacpy.exceptions import MaterialNotFoundError
from hvacpy.materials._database import MATERIALS
from hvacpy.units import Q_

# Allowed material categories — enforced on Material creation.
_ALLOWED_CATEGORIES = frozenset({
    "masonry",
    "insulation",
    "wood",
    "metal",
    "glazing",
    "membrane",
    "air",
    "finish",
    "concrete",
})


@dataclass(frozen=True)
class Material:
    """A building material with thermal properties.

    Attributes:
        name: Human-readable material name.
        conductivity: Thermal conductivity in W/(m·K).
        density: Material density in kg/m³.
        specific_heat: Specific heat capacity in J/(kg·K).
        category: Material category. Must be one of: masonry,
            insulation, wood, metal, glazing, membrane, air,
            finish, concrete.
        source: Reference source, e.g. 'ASHRAE HOF 2021 Table 1'.
    """

    name: str
    conductivity: Quantity  # W/(m·K)
    density: Quantity  # kg/m³
    specific_heat: Quantity  # J/(kg·K)
    category: str
    source: str

    def __post_init__(self) -> None:
        """Validate category on creation."""
        if self.category not in _ALLOWED_CATEGORIES:
            raise ValueError(
                f"category must be one of {sorted(_ALLOWED_CATEGORIES)}, "
                f"got '{self.category}'"
            )


[docs] class MaterialsDB: """Registry of building materials. The database contains built-in materials from ASHRAE and ISO standards, and supports adding custom materials at runtime. This class should not be instantiated directly by users. Use the module-level ``_DB`` singleton instead, exposed as ``db`` in the top-level ``hvacpy`` package. """ def __init__(self) -> None: """Initialize the database with built-in materials.""" self._builtin_keys: frozenset[str] = frozenset(MATERIALS.keys()) self._builtin_materials: dict[str, Material] = {} self._custom_materials: dict[str, Material] = {} self._lock = threading.Lock() # Convert raw dicts to Material instances. for key, data in MATERIALS.items(): self._builtin_materials[key] = _dict_to_material(data)
[docs] def get(self, key: str) -> Material: """Return the Material for the given key. Args: key: Material identifier, case-insensitive. E.g. 'brick_common' or 'BRICK_COMMON'. Returns: The matching Material instance. Raises: MaterialNotFoundError: If no material matches the key. The error message includes the key and a hint to use list_keys(). """ normalized = key.lower() # Check built-ins first, then custom. material = self._builtin_materials.get(normalized) if material is not None: return material material = self._custom_materials.get(normalized) if material is not None: return material raise MaterialNotFoundError( f"Material '{key}' not found. " f"Use list_keys() to see available materials." )
[docs] def list_keys(self) -> list[str]: """Return a sorted list of all available material keys. Returns: Sorted list of material key strings. """ all_keys = set(self._builtin_materials.keys()) all_keys.update(self._custom_materials.keys()) return sorted(all_keys)
[docs] def list_by_category(self, category: str) -> list[Material]: """Return materials filtered by category, sorted by name. Args: category: Category to filter by. Must be one of the allowed categories (masonry, insulation, wood, etc.). Returns: List of Material instances sorted by name. Raises: ValueError: If category is not in the allowed list. """ if category not in _ALLOWED_CATEGORIES: raise ValueError( f"category must be one of {sorted(_ALLOWED_CATEGORIES)}, " f"got '{category}'" ) results: list[Material] = [] for mat in self._builtin_materials.values(): if mat.category == category: results.append(mat) for mat in self._custom_materials.values(): if mat.category == category: results.append(mat) return sorted(results, key=lambda m: m.name)
[docs] def add_custom(self, key: str, material: Material) -> None: """Add a custom material to the in-memory database. Custom materials persist only for the lifetime of the process. Args: key: Unique identifier for the material. Must be a non-empty string. material: A Material instance to register. Raises: TypeError: If material is not a Material instance. ValueError: If key is empty or already exists as a built-in material. """ if not isinstance(material, Material): raise TypeError( f"material must be a Material instance, " f"got {type(material).__name__}" ) if not key or not isinstance(key, str): raise ValueError("key must be a non-empty string") normalized = key.lower() if normalized in self._builtin_keys: raise ValueError( f"Cannot overwrite built-in material '{key}'. " f"Built-in materials are read-only." ) with self._lock: self._custom_materials[normalized] = material
def _dict_to_material(data: dict[str, Any]) -> Material: """Convert a raw data dict to a Material instance. Args: data: Dict with keys: name, conductivity, density, specific_heat, category, source. Returns: A frozen Material dataclass instance. """ return Material( name=data["name"], conductivity=Q_(data["conductivity"], "W/(m*K)"), density=Q_(data["density"], "kg/m³"), specific_heat=Q_(data["specific_heat"], "J/(kg*K)"), category=data["category"], source=data["source"], ) # Module-level singleton — the one and only materials database. _DB = MaterialsDB()