Source code for hvacpy.loads._cltd_tables

"""CLTD and CLF lookup tables — ASHRAE 1997 HOF Chapter 28.

Data only — dicts and lookup functions. No imports from other hvacpy modules.
All values are exact transcriptions from the specification.
"""

from __future__ import annotations

from hvacpy.exceptions import DesignConditionsNotFoundError

# ── Wall Group Descriptions ─────────────────────────────────────────

WALL_GROUP_DESCRIPTIONS: dict[str, str] = {
    'A': 'Curtain wall or spandrel — very lightweight. Mass < 50 kg/m². e.g. metal panels.',
    'B': 'Light frame — wood or metal stud with insulation board. Mass 50–100 kg/m².',
    'C': 'Light masonry — 100mm brick or concrete block, insulated. Mass 100–200 kg/m².',
    'D': 'Medium masonry — 200mm brick, cavity wall, or concrete. Mass 200–400 kg/m². Default.',
    'E': 'Heavy masonry — 300mm brick or 200mm concrete. Mass 400–600 kg/m².',
    'F': 'Very heavy — 300mm+ concrete. Mass > 600 kg/m².',
    'G': 'Roofs — flat or low-slope roofs with insulation. Use for is_roof=True surfaces.',
}


# ── CLTD Table — Group D Walls and Roofs ────────────────────────────
# Values are °C at base conditions: T_outdoor=35°C, T_indoor=24°C,
# latitude 32°N, July 21.
# Keys: (hour_24, orientation_str)
# Hour 1 = 01:00, Hour 24 = 24:00 (midnight)

CLTD_GROUP_D: dict[tuple[int, str], float] = {
    # Hour 01
    (1, 'N'): 1, (1, 'NE'): 1, (1, 'E'): 1, (1, 'SE'): 1,
    (1, 'S'): 1, (1, 'SW'): 2, (1, 'W'): 2, (1, 'NW'): 1, (1, 'H'): 3,
    # Hour 02
    (2, 'N'): 0, (2, 'NE'): 0, (2, 'E'): 1, (2, 'SE'): 0,
    (2, 'S'): 0, (2, 'SW'): 1, (2, 'W'): 1, (2, 'NW'): 1, (2, 'H'): 2,
    # Hour 03
    (3, 'N'): 0, (3, 'NE'): 0, (3, 'E'): 0, (3, 'SE'): 0,
    (3, 'S'): 0, (3, 'SW'): 1, (3, 'W'): 1, (3, 'NW'): 0, (3, 'H'): 1,
    # Hour 04
    (4, 'N'): -1, (4, 'NE'): -1, (4, 'E'): 0, (4, 'SE'): -1,
    (4, 'S'): -1, (4, 'SW'): 0, (4, 'W'): 0, (4, 'NW'): 0, (4, 'H'): 1,
    # Hour 05
    (5, 'N'): -1, (5, 'NE'): -1, (5, 'E'): -1, (5, 'SE'): -1,
    (5, 'S'): -1, (5, 'SW'): 0, (5, 'W'): 0, (5, 'NW'): -1, (5, 'H'): 0,
    # Hour 06
    (6, 'N'): -1, (6, 'NE'): -1, (6, 'E'): -1, (6, 'SE'): -1,
    (6, 'S'): -1, (6, 'SW'): 0, (6, 'W'): 0, (6, 'NW'): -1, (6, 'H'): 0,
    # Hour 07
    (7, 'N'): 0, (7, 'NE'): 1, (7, 'E'): 2, (7, 'SE'): 1,
    (7, 'S'): 0, (7, 'SW'): 0, (7, 'W'): 0, (7, 'NW'): 0, (7, 'H'): 1,
    # Hour 08
    (8, 'N'): 1, (8, 'NE'): 3, (8, 'E'): 6, (8, 'SE'): 4,
    (8, 'S'): 1, (8, 'SW'): 1, (8, 'W'): 1, (8, 'NW'): 1, (8, 'H'): 4,
    # Hour 09
    (9, 'N'): 2, (9, 'NE'): 5, (9, 'E'): 10, (9, 'SE'): 7,
    (9, 'S'): 2, (9, 'SW'): 1, (9, 'W'): 1, (9, 'NW'): 1, (9, 'H'): 9,
    # Hour 10
    (10, 'N'): 3, (10, 'NE'): 6, (10, 'E'): 13, (10, 'SE'): 10,
    (10, 'S'): 4, (10, 'SW'): 2, (10, 'W'): 2, (10, 'NW'): 2, (10, 'H'): 15,
    # Hour 11
    (11, 'N'): 5, (11, 'NE'): 7, (11, 'E'): 14, (11, 'SE'): 12,
    (11, 'S'): 6, (11, 'SW'): 3, (11, 'W'): 3, (11, 'NW'): 2, (11, 'H'): 21,
    # Hour 12
    (12, 'N'): 6, (12, 'NE'): 7, (12, 'E'): 13, (12, 'SE'): 12,
    (12, 'S'): 8, (12, 'SW'): 4, (12, 'W'): 3, (12, 'NW'): 3, (12, 'H'): 27,
    # Hour 13
    (13, 'N'): 8, (13, 'NE'): 8, (13, 'E'): 12, (13, 'SE'): 12,
    (13, 'S'): 9, (13, 'SW'): 6, (13, 'W'): 4, (13, 'NW'): 4, (13, 'H'): 32,
    # Hour 14
    (14, 'N'): 10, (14, 'NE'): 8, (14, 'E'): 11, (14, 'SE'): 11,
    (14, 'S'): 10, (14, 'SW'): 8, (14, 'W'): 6, (14, 'NW'): 5, (14, 'H'): 35,
    # Hour 15
    (15, 'N'): 11, (15, 'NE'): 9, (15, 'E'): 10, (15, 'SE'): 10,
    (15, 'S'): 10, (15, 'SW'): 10, (15, 'W'): 8, (15, 'NW'): 6, (15, 'H'): 37,
    # Hour 16
    (16, 'N'): 12, (16, 'NE'): 9, (16, 'E'): 9, (16, 'SE'): 9,
    (16, 'S'): 10, (16, 'SW'): 12, (16, 'W'): 11, (16, 'NW'): 8, (16, 'H'): 36,
    # Hour 17
    (17, 'N'): 13, (17, 'NE'): 10, (17, 'E'): 9, (17, 'SE'): 9,
    (17, 'S'): 9, (17, 'SW'): 13, (17, 'W'): 13, (17, 'NW'): 10, (17, 'H'): 33,
    # Hour 18
    (18, 'N'): 13, (18, 'NE'): 10, (18, 'E'): 8, (18, 'SE'): 8,
    (18, 'S'): 8, (18, 'SW'): 13, (18, 'W'): 15, (18, 'NW'): 12, (18, 'H'): 28,
    # Hour 19
    (19, 'N'): 12, (19, 'NE'): 10, (19, 'E'): 8, (19, 'SE'): 8,
    (19, 'S'): 7, (19, 'SW'): 13, (19, 'W'): 15, (19, 'NW'): 13, (19, 'H'): 22,
    # Hour 20
    (20, 'N'): 11, (20, 'NE'): 9, (20, 'E'): 8, (20, 'SE'): 7,
    (20, 'S'): 6, (20, 'SW'): 12, (20, 'W'): 14, (20, 'NW'): 13, (20, 'H'): 16,
    # Hour 21
    (21, 'N'): 9, (21, 'NE'): 8, (21, 'E'): 7, (21, 'SE'): 6,
    (21, 'S'): 5, (21, 'SW'): 10, (21, 'W'): 13, (21, 'NW'): 12, (21, 'H'): 11,
    # Hour 22
    (22, 'N'): 7, (22, 'NE'): 6, (22, 'E'): 6, (22, 'SE'): 5,
    (22, 'S'): 4, (22, 'SW'): 8, (22, 'W'): 10, (22, 'NW'): 10, (22, 'H'): 8,
    # Hour 23
    (23, 'N'): 5, (23, 'NE'): 5, (23, 'E'): 5, (23, 'SE'): 4,
    (23, 'S'): 3, (23, 'SW'): 6, (23, 'W'): 8, (23, 'NW'): 8, (23, 'H'): 5,
    # Hour 24
    (24, 'N'): 3, (24, 'NE'): 3, (24, 'E'): 3, (24, 'SE'): 3,
    (24, 'S'): 2, (24, 'SW'): 4, (24, 'W'): 5, (24, 'NW'): 5, (24, 'H'): 4,
}


# ── Wall Group Multipliers ──────────────────────────────────────────
# Apply as: CLTD_group = CLTD_D * multiplier
# Group G uses the 'H' (Horiz Roof) column directly — no multiplier.

WALL_GROUP_MULTIPLIERS: dict[str, float] = {
    'A': 1.5,
    'B': 1.3,
    'C': 1.1,
    'D': 1.0,
    'E': 0.85,
    'F': 0.70,
}


def get_cltd(hour: int, orientation: str, wall_group: str = 'D') -> float:
    """Look up CLTD value (°C) for given hour, orientation, and wall group.

    Args:
        hour: Hour 1–24 (24-hour clock).
        orientation: Orientation string — one of N,NE,E,SE,S,SW,W,NW,H.
        wall_group: Wall group 'A' through 'G'. Default 'D'.

    Returns:
        CLTD value in °C (base conditions, before correction).
    """
    # Group G (roofs) — use the H column directly
    if wall_group == 'G':
        key = (hour, 'H')
        return float(CLTD_GROUP_D.get(key, 0.0))

    # All other groups — look up Group D value and apply multiplier
    key = (hour, orientation)
    base = CLTD_GROUP_D.get(key, 0.0)
    multiplier = WALL_GROUP_MULTIPLIERS.get(wall_group, 1.0)
    return float(base * multiplier)


# ── CLF Table — Solar through Glass ─────────────────────────────────
# Cooling Load Factors for no interior shading, latitude 32°N, July 21.
# Keys: (hour_24, orientation_str). Values: dimensionless 0.0–1.0.

CLF_SOLAR_TABLE: dict[tuple[int, str], float] = {
    # 08:00
    (8, 'N'): 0.09, (8, 'NE'): 0.54, (8, 'E'): 0.73, (8, 'SE'): 0.56,
    (8, 'S'): 0.12, (8, 'SW'): 0.06, (8, 'W'): 0.06, (8, 'NW'): 0.06, (8, 'H'): 0.37,
    # 10:00
    (10, 'N'): 0.10, (10, 'NE'): 0.30, (10, 'E'): 0.61, (10, 'SE'): 0.64,
    (10, 'S'): 0.30, (10, 'SW'): 0.08, (10, 'W'): 0.07, (10, 'NW'): 0.07, (10, 'H'): 0.64,
    # 12:00
    (12, 'N'): 0.10, (12, 'NE'): 0.10, (12, 'E'): 0.21, (12, 'SE'): 0.50,
    (12, 'S'): 0.51, (12, 'SW'): 0.21, (12, 'W'): 0.10, (12, 'NW'): 0.10, (12, 'H'): 0.84,
    # 14:00
    (14, 'N'): 0.09, (14, 'NE'): 0.09, (14, 'E'): 0.09, (14, 'SE'): 0.21,
    (14, 'S'): 0.51, (14, 'SW'): 0.50, (14, 'W'): 0.39, (14, 'NW'): 0.16, (14, 'H'): 0.84,
    # 16:00
    (16, 'N'): 0.09, (16, 'NE'): 0.09, (16, 'E'): 0.09, (16, 'SE'): 0.09,
    (16, 'S'): 0.22, (16, 'SW'): 0.58, (16, 'W'): 0.73, (16, 'NW'): 0.44, (16, 'H'): 0.64,
    # 18:00
    (18, 'N'): 0.09, (18, 'NE'): 0.09, (18, 'E'): 0.09, (18, 'SE'): 0.09,
    (18, 'S'): 0.09, (18, 'SW'): 0.28, (18, 'W'): 0.64, (18, 'NW'): 0.55, (18, 'H'): 0.27,
}

# Tabulated hours for interpolation
_CLF_HOURS = [8, 10, 12, 14, 16, 18]

# Night/early morning minimum CLF for all orientations
_CLF_NIGHT_MIN = 0.09

# All orientations
_ORIENTATIONS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'H']


def get_clf_solar(hour: int, orientation: str, has_interior_shading: bool = False) -> float:
    """Look up or interpolate CLF for solar gain through glass.

    For hours 19:00–07:00, returns 0.09 (night minimum).
    For tabulated hours (8,10,12,14,16,18), returns exact value.
    For intermediate hours, linearly interpolates between adjacent values.

    Interior shading reduces CLF by factor of 0.5 (simplified).

    Args:
        hour: Hour 1–24.
        orientation: Orientation string.
        has_interior_shading: True if blinds/curtains present.

    Returns:
        CLF value (dimensionless).
    """
    # Night/early morning — use minimum
    if hour < 8 or hour > 18:
        clf = _CLF_NIGHT_MIN
    elif hour in _CLF_HOURS:
        # Exact table lookup
        clf = CLF_SOLAR_TABLE.get((hour, orientation), _CLF_NIGHT_MIN)
    else:
        # Linear interpolation between adjacent tabulated hours
        # Find bounding hours
        lower_hour = max(h for h in _CLF_HOURS if h <= hour)
        upper_hour = min(h for h in _CLF_HOURS if h >= hour)
        if lower_hour == upper_hour:
            clf = CLF_SOLAR_TABLE.get((lower_hour, orientation), _CLF_NIGHT_MIN)
        else:
            clf_lower = CLF_SOLAR_TABLE.get((lower_hour, orientation), _CLF_NIGHT_MIN)
            clf_upper = CLF_SOLAR_TABLE.get((upper_hour, orientation), _CLF_NIGHT_MIN)
            frac = (hour - lower_hour) / (upper_hour - lower_hour)
            clf = clf_lower + frac * (clf_upper - clf_lower)

    # Interior shading reduces CLF by approximately 0.5
    # This is a simplification of the full ASHRAE shading calculation.
    if has_interior_shading:
        clf *= 0.5

    return clf


# ── Orientation I_max Factors ───────────────────────────────────────
# Maximum solar intensity = 630 W/m² * factor
# Simplified approach for 32°N latitude, July — will be replaced
# with full solar geometry in v0.5.

I_MAX_BASE = 630.0  # W/m²

I_MAX_FACTORS: dict[str, float] = {
    'N':  0.17,
    'NE': 0.57,
    'E':  0.97,
    'SE': 0.83,
    'S':  0.62,
    'SW': 0.83,
    'W':  0.97,
    'NW': 0.57,
    'H':  1.22,
}


def get_i_max(orientation: str) -> float:
    """Return maximum solar intensity (W/m²) for an orientation.

    Uses simplified approach: 630 W/m² * orientation factor.
    This approximation is for 32°N latitude, July.
    Full solar geometry calculation will replace this in v0.5.
    """
    return I_MAX_BASE * I_MAX_FACTORS.get(orientation, 0.0)


# ── Design Conditions Database ──────────────────────────────────────
# ASHRAE 2021 HOF Appendix — Climatic Design Conditions.
# All temperatures in °C, latitude in degrees.

_DESIGN_CONDITIONS: dict[str, dict] = {
    'miami': {
        't_outdoor_db': 33.9, 't_outdoor_wb': 26.8,
        't_winter_db': 8.3, 'lat': 25.8,
    },
    'phoenix': {
        't_outdoor_db': 43.3, 't_outdoor_wb': 24.4,
        't_winter_db': 3.3, 'lat': 33.4,
    },
    'los_angeles': {
        't_outdoor_db': 32.2, 't_outdoor_wb': 21.1,
        't_winter_db': 7.2, 'lat': 33.9,
    },
    'chicago': {
        't_outdoor_db': 33.3, 't_outdoor_wb': 23.9,
        't_winter_db': -16.7, 'lat': 41.9,
    },
    'new_york': {
        't_outdoor_db': 32.8, 't_outdoor_wb': 23.9,
        't_winter_db': -8.9, 'lat': 40.6,
    },
    'london': {
        't_outdoor_db': 28.3, 't_outdoor_wb': 20.6,
        't_winter_db': -3.2, 'lat': 51.5,
    },
    'dubai': {
        't_outdoor_db': 45.0, 't_outdoor_wb': 28.0,
        't_winter_db': 12.0, 'lat': 25.2,
    },
    'singapore': {
        't_outdoor_db': 32.7, 't_outdoor_wb': 26.8,
        't_winter_db': 23.0, 'lat': 1.3,
    },
    'sydney': {
        't_outdoor_db': 33.3, 't_outdoor_wb': 22.8,
        't_winter_db': 5.0, 'lat': -33.9,
    },
    'toronto': {
        't_outdoor_db': 31.7, 't_outdoor_wb': 23.3,
        't_winter_db': -18.3, 'lat': 43.7,
    },
}


[docs] def get_design_conditions(city: str) -> dict: """Get ASHRAE design conditions for a city. Args: city: City name (case-insensitive). Returns: Dict with keys: 'city', 't_outdoor_db', 't_outdoor_wb', 't_winter_db', 'lat'. Raises: DesignConditionsNotFoundError: If city not found. """ key = city.lower().strip() if key not in _DESIGN_CONDITIONS: available = list_design_cities() raise DesignConditionsNotFoundError( f"City '{city}' not found in design conditions database. " f"Available cities: {available}. " f"Check list_design_cities() for the full list." ) result = dict(_DESIGN_CONDITIONS[key]) result['city'] = key return result
[docs] def list_design_cities() -> list[str]: """Return list of available city names in the design conditions database.""" return sorted(_DESIGN_CONDITIONS.keys())