diff --git a/smart_control/simulator/building_radiation_utils.py b/smart_control/simulator/building_radiation_utils.py index b16bd9fb..21fb63b4 100644 --- a/smart_control/simulator/building_radiation_utils.py +++ b/smart_control/simulator/building_radiation_utils.py @@ -5,13 +5,12 @@ from collections import deque import math -from typing import Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union import numpy as np from smart_control.simulator import constants -from smart_control.simulator.solar_radiation import calculate_poa_irradiance # re-export # pylint: disable=unused-import -from smart_control.simulator.solar_radiation import IrradianceComponents # re-export # pylint: disable=unused-import +from smart_control.simulator import solar_radiation TEMPORARY_MARKED_VALUE = -33 TEMPORARY_BLOCKED_VALUE = -34 @@ -970,3 +969,1145 @@ def mark_directly_seeing_nodes( # Mark the base node with a special value floor_plan_copy[base_row, base_col] = blocked_value + marked_value return floor_plan_copy + + +def _ensure_irradiance_components( + irradiance_components: Union[ + solar_radiation.IrradianceComponents, + Mapping[str, Any], + ], + solar_zenith: Optional[float] = None, + solar_azimuth: Optional[float] = None, +) -> solar_radiation.IrradianceComponents: + """Normalizes irradiance input to an IrradianceComponents instance. + + Args: + irradiance_components: Either an IrradianceComponents dataclass or a + mapping containing irradiance fields. + solar_zenith: Optional fallback solar zenith angle in degrees. Used + when the mapping does not contain 'solar_zenith'. + solar_azimuth: Optional fallback solar azimuth angle in degrees. Used + when the mapping does not contain 'solar_azimuth'. + + Returns: + solar_radiation.IrradianceComponents: Irradiance data with guaranteed + fields and types. + + Raises: + KeyError: If 'ghi', 'dni', or 'dhi' are absent, or if 'solar_zenith'/ + 'solar_azimuth' are absent and no fallback is provided. + """ + if isinstance(irradiance_components, solar_radiation.IrradianceComponents): + return irradiance_components + + required_keys = ('ghi', 'dni', 'dhi') + missing_keys = [ + key for key in required_keys if key not in irradiance_components + ] + if missing_keys: + raise KeyError( + f'Missing irradiance component keys: {", ".join(sorted(missing_keys))}' + ) + + # Use mapping values if available, else fall back to arguments + sz = irradiance_components.get('solar_zenith', solar_zenith) + sa = irradiance_components.get('solar_azimuth', solar_azimuth) + + if sz is None or sa is None: + missing = [] + if sz is None: + missing.append('solar_zenith') + if sa is None: + missing.append('solar_azimuth') + raise KeyError( + f'Missing irradiance component keys: {", ".join(sorted(missing))}' + ) + + timestamp = irradiance_components.get('timestamp') + return solar_radiation.IrradianceComponents( + ghi=float(irradiance_components['ghi']), + dni=float(irradiance_components['dni']), + dhi=float(irradiance_components['dhi']), + solar_zenith=float(sz), + solar_azimuth=float(sa), + timestamp=timestamp, + ) + + +def validate_fenestration_connectivity( + floor_plan: np.ndarray, + fenestration_value: int = constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value: int = constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FILE_INPUT, + interior_wall_value: int = constants.INTERIOR_WALL_VALUE_IN_FILE_INPUT, +) -> bool: + """Validate that fenestration nodes bridge exterior space to interior air.""" + if not np.any(floor_plan == fenestration_value): + return True + + fenestration_groups = _find_connected_groups(floor_plan, fenestration_value) + + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + rows, cols = floor_plan.shape + + for group_name, group_info in fenestration_groups.items(): + group_indices: Sequence[Tuple[int, int]] = group_info['indices'] + + nodes_adjacent_to_air: Set[Tuple[int, int]] = set() + nodes_adjacent_to_exterior: Set[Tuple[int, int]] = set() + nodes_surrounded_by_air: List[Tuple[int, int]] = [] + + for row, col in group_indices: + adjacent_to_air = False + adjacent_to_exterior = False + air_neighbor_count = 0 + + for dr, dc in directions: + nr, nc = row + dr, col + dc + + if nr < 0 or nr >= rows or nc < 0 or nc >= cols: + adjacent_to_exterior = True + continue + + neighbor_val = floor_plan[nr, nc] + + if neighbor_val == air_value: + adjacent_to_air = True + air_neighbor_count += 1 + elif neighbor_val == exterior_space_value: + adjacent_to_exterior = True + elif neighbor_val == interior_wall_value: + # Interior wall does not affect adjacent_to_air/exterior flags + continue + + if adjacent_to_air: + nodes_adjacent_to_air.add((row, col)) + if adjacent_to_exterior: + nodes_adjacent_to_exterior.add((row, col)) + + if air_neighbor_count == 4 and not adjacent_to_exterior: + nodes_surrounded_by_air.append((row, col)) + + if not nodes_adjacent_to_air: + raise ValueError( + f'Fenestration group {group_name} is not connected to indoor air. ' + f'Fenestration must border value {air_value} nodes.' + ) + + if not nodes_adjacent_to_exterior: + raise ValueError( + f'Fenestration group {group_name} is not exposed to exterior ' + f'(value {exterior_space_value}).' + ) + + if nodes_surrounded_by_air: + raise ValueError( + f'Fenestration group {group_name} has fenestration nodes fully ' + f'surrounded by air at positions {nodes_surrounded_by_air}. ' + 'Fenestration must bridge exterior and interior.' + ) + + _validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name=group_name, + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=fenestration_value, + air_value=air_value, + ) + + return True + + +def _validate_fenestration_chain_connectivity( + floor_plan: np.ndarray, + group_name: str, + group_indices: Sequence[Tuple[int, int]], + nodes_adjacent_to_air: Set[Tuple[int, int]], + nodes_adjacent_to_exterior: Set[Tuple[int, int]], + fenestration_value: int, + air_value: int, +) -> None: + """Ensure each fenestration node can reach both exterior and interior.""" + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + direction_names = { + (-1, 0): 'north', + (1, 0): 'south', + (0, -1): 'west', + (0, 1): 'east', + } + rows, cols = floor_plan.shape + group_set = set(group_indices) + + reachable_from_exterior: Set[Tuple[int, int]] = set() + queue = deque(nodes_adjacent_to_exterior) + reachable_from_exterior.update(nodes_adjacent_to_exterior) + + while queue: + row, col = queue.popleft() + for dr, dc in directions: + nr, nc = row + dr, col + dc + if (nr, nc) in group_set and (nr, nc) not in reachable_from_exterior: + reachable_from_exterior.add((nr, nc)) + queue.append((nr, nc)) + + reachable_from_air: Set[Tuple[int, int]] = set() + queue = deque(nodes_adjacent_to_air) + reachable_from_air.update(nodes_adjacent_to_air) + + while queue: + row, col = queue.popleft() + for dr, dc in directions: + nr, nc = row + dr, col + dc + if (nr, nc) in group_set and (nr, nc) not in reachable_from_air: + reachable_from_air.add((nr, nc)) + queue.append((nr, nc)) + + for row, col in group_indices: + if (row, col) not in reachable_from_exterior: + raise ValueError( + f'Fenestration group {group_name} has node {(row, col)} blocked ' + 'from exterior exposure.' + ) + if (row, col) not in reachable_from_air: + raise ValueError( + f'Fenestration group {group_name} has node {(row, col)} blocked ' + 'from interior air.' + ) + + interior_direction = _determine_interior_direction( + nodes_adjacent_to_exterior, rows, cols, floor_plan + ) + + if interior_direction is None: + return + + blocked_by_interior_wall: List[Tuple[int, int]] = [] + dr, dc = interior_direction + for row, col in group_indices: + nr, nc = row + dr, col + dc + if (nr, nc) in group_set: + continue + if 0 <= nr < rows and 0 <= nc < cols: + neighbor_val = floor_plan[nr, nc] + if neighbor_val not in (air_value, fenestration_value): + blocked_by_interior_wall.append((row, col)) + else: + blocked_by_interior_wall.append((row, col)) + + if blocked_by_interior_wall: + direction_name = direction_names.get( + interior_direction, str(interior_direction) + ) + raise ValueError( + f'Fenestration group {group_name} has interior-facing nodes blocked ' + f'by non-air cells in the {direction_name} direction: ' + f'{blocked_by_interior_wall}.' + ) + + +def _determine_interior_direction( + nodes_adjacent_to_exterior: Set[Tuple[int, int]], + rows: int, + cols: int, + floor_plan: np.ndarray, +) -> Optional[Tuple[int, int]]: + """Infer the direction pointing from exterior toward interior air.""" + if not nodes_adjacent_to_exterior: + return None + + for row, col in nodes_adjacent_to_exterior: + if row == 0: + return (1, 0) + if row == rows - 1: + return (-1, 0) + if col == 0: + return (0, 1) + if col == cols - 1: + return (0, -1) + + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + opposite = { + (-1, 0): (1, 0), + (1, 0): (-1, 0), + (0, -1): (0, 1), + (0, 1): (0, -1), + } + exterior_space_value = constants.EXTERIOR_SPACE_VALUE_IN_FILE_INPUT + + for row, col in nodes_adjacent_to_exterior: + for dr, dc in directions: + nr, nc = row + dr, col + dc + if ( + 0 <= nr < rows + and 0 <= nc < cols + and floor_plan[nr, nc] == exterior_space_value + ): + return opposite[(dr, dc)] + return None + + +def _find_connected_groups( + floor_plan: np.ndarray, + target_value: int, +) -> Dict[str, Dict[str, Any]]: + """Find 4-connected groups of `target_value`cells and return their indices.""" + visited = np.zeros_like(floor_plan, dtype=bool) + groups: Dict[str, Dict[str, Any]] = {} + group_count = 0 + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + for row in range(floor_plan.shape[0]): + for col in range(floor_plan.shape[1]): + if floor_plan[row, col] == target_value and not visited[row, col]: + group_count += 1 + group_indices: List[Tuple[int, int]] = [] + queue = deque([(row, col)]) + visited[row, col] = True + + while queue: + r, c = queue.popleft() + group_indices.append((r, c)) + + for dr, dc in directions: + nr, nc = r + dr, c + dc + if ( + 0 <= nr < floor_plan.shape[0] + and 0 <= nc < floor_plan.shape[1] + and floor_plan[nr, nc] == target_value + and not visited[nr, nc] + ): + visited[nr, nc] = True + queue.append((nr, nc)) + + groups[f'group_{group_count}'] = { + 'count': len(group_indices), + 'indices': group_indices, + } + + return groups + + +def mark_fenestration_positions( + floor_plan: np.ndarray, + fenestration_value: int = constants.FENESTRATION_VALUE_IN_FUNCTION, + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + air_value: int = constants.INTERIOR_SPACE_VALUE_IN_FUNCTION, + exterior_fenestration: int = constants.EXTERIOR_FENESTRATION_VALUE, + interior_fenestration: int = constants.INTERIOR_FENESTRATION_VALUE, + inbetween_fenestration: int = constants.INBETWEEN_FENESTRATION_VALUE, +) -> np.ndarray: + """Classify fenestration nodes into exterior, interior, or in-between.""" + result = floor_plan.copy() + rows, cols = floor_plan.shape + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + fen_rows, fen_cols = np.where(floor_plan == fenestration_value) + for row, col in zip(fen_rows, fen_cols): + adjacent_to_exterior = False + adjacent_to_air = False + at_boundary = row == 0 or row == rows - 1 or col == 0 or col == cols - 1 + + for dr, dc in directions: + nr, nc = row + dr, col + dc + if 0 <= nr < rows and 0 <= nc < cols: + neighbor_val = floor_plan[nr, nc] + if neighbor_val == exterior_space_value: + adjacent_to_exterior = True + elif neighbor_val == air_value: + adjacent_to_air = True + + if at_boundary or adjacent_to_exterior and not adjacent_to_air: + result[row, col] = exterior_fenestration + elif adjacent_to_air and not adjacent_to_exterior: + result[row, col] = interior_fenestration + elif adjacent_to_air and adjacent_to_exterior: + result[row, col] = interior_fenestration + else: + result[row, col] = inbetween_fenestration + + return result + + +def group_fenestrations( + floor_plan: np.ndarray, + exterior_fenestration: int = constants.EXTERIOR_FENESTRATION_VALUE, + interior_fenestration: int = constants.INTERIOR_FENESTRATION_VALUE, + inbetween_fenestration: int = constants.INBETWEEN_FENESTRATION_VALUE, + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + floor_plan_orientation: float = 0.0, +) -> Dict[str, Dict[str, Any]]: + """Group adjacent fenestration nodes and compute surface properties.""" + fenestration_mask = ( + (floor_plan == exterior_fenestration) + | (floor_plan == interior_fenestration) + | (floor_plan == inbetween_fenestration) + ) + + visited = np.zeros_like(floor_plan, dtype=bool) + groups: Dict[str, Dict[str, Any]] = {} + group_count = 0 + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + for row in range(floor_plan.shape[0]): + for col in range(floor_plan.shape[1]): + if fenestration_mask[row, col] and not visited[row, col]: + group_count += 1 + group_indices: List[Tuple[int, int]] = [] + exterior_count = 0 + queue = deque([(row, col)]) + visited[row, col] = True + + while queue: + r, c = queue.popleft() + group_indices.append((r, c)) + + if floor_plan[r, c] == exterior_fenestration: + exterior_count += 1 + + for dr, dc in directions: + nr, nc = r + dr, c + dc + if ( + 0 <= nr < floor_plan.shape[0] + and 0 <= nc < floor_plan.shape[1] + and fenestration_mask[nr, nc] + and not visited[nr, nc] + ): + visited[nr, nc] = True + queue.append((nr, nc)) + + indices_array = np.zeros_like(floor_plan, dtype=bool) + for r, c in group_indices: + indices_array[r, c] = True + + exterior_indices_array = np.zeros_like(floor_plan, dtype=bool) + for r, c in group_indices: + if floor_plan[r, c] == exterior_fenestration: + exterior_indices_array[r, c] = True + + azimuth = _determine_fenestration_azimuth( + floor_plan, + group_indices, + exterior_space_value, + exterior_fenestration, + floor_plan_orientation, + ) + + phi = float(constants.FENESTRATION_TILT_ANGLE) + phi_rad = math.radians(phi) + cos_phi = math.cos(phi_rad) + + factor = 0.5 * (1 + cos_phi) + f_gnd = 0.5 * (1 - cos_phi) + f_sky = factor * math.sqrt(factor) + f_air = factor * (1 - math.sqrt(factor)) + beta = math.sqrt(factor) + + groups[f'fenestration_{group_count}'] = { + 'count': len(group_indices), + 'exterior_count': exterior_count, + 'indices': group_indices, + 'indices_array': indices_array, + 'exterior_indices_array': exterior_indices_array, + 'phi': phi, + 'azimuth': float(azimuth), + 'F_gnd': f_gnd, + 'F_sky': f_sky, + 'F_air': f_air, + 'beta': beta, + } + + return groups + + +def _determine_fenestration_azimuth( + floor_plan: np.ndarray, + group_indices: Sequence[Tuple[int, int]], + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + exterior_fenestration_value: int = constants.EXTERIOR_FENESTRATION_VALUE, + floor_plan_orientation: float = 0.0, +) -> float: + """Determine fenestration azimuth from exterior adjacency using atan2. + + The azimuth is inferred from the floor_plan grid using the Cartesian + coordinate system. The direction vector is computed from exterior + fenestration nodes toward adjacent exterior space nodes, then the azimuth + is calculated using atan2. + + The floor_plan grid uses (row, col) coordinates where: + - row increases downward (South in default orientation) + - col increases rightward (East in default orientation) + + In the Cartesian mapping: + - x = col direction (East) + - y = -row direction (North, since row increases downward) + + Azimuth is measured clockwise from North: + - 0° = North (up/decreasing row) + - 90° = East (right/increasing col) + - 180° = South (down/increasing row) + - 270° = West (left/decreasing col) + + The computed azimuth is then offset by floor_plan_orientation. + + Args: + floor_plan: 2D array of the indexed floor plan. + group_indices: List of (row, col) indices for the fenestration group. + exterior_space_value: Value representing exterior space in the floor plan. + exterior_fenestration_value: Value representing exterior fenestration. + floor_plan_orientation: Compass angle (degrees) of the floor-plan's + "up" direction. 0/360 = North, 90 = East, 180 = South, 270 = West. + + Returns: + Azimuth angle in degrees [0, 360). + """ + rows, cols = floor_plan.shape + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + # Accumulate direction vectors from exterior fenestration toward exterior + # space in Cartesian coordinates (dx = col direction, dy = -row direction) + total_dx = 0.0 + total_dy = 0.0 + count = 0 + + for row, col in group_indices: + if floor_plan[row, col] != exterior_fenestration_value: + continue + for dr, dc in directions: + nr, nc = row + dr, col + dc + # Check adjacency to exterior space or array boundary + if nr < 0 or nr >= rows or nc < 0 or nc >= cols: + # Array boundary counts as exterior + total_dx += float(dc) + total_dy += float(-dr) + count += 1 + elif floor_plan[nr, nc] == exterior_space_value: + total_dx += float(dc) + total_dy += float(-dr) + count += 1 + + if count == 0: + # Fallback: check all nodes (not just exterior fenestration) + for row, col in group_indices: + for dr, dc in directions: + nr, nc = row + dr, col + dc + if nr < 0 or nr >= rows or nc < 0 or nc >= cols: + total_dx += float(dc) + total_dy += float(-dr) + count += 1 + elif floor_plan[nr, nc] == exterior_space_value: + total_dx += float(dc) + total_dy += float(-dr) + count += 1 + + if count == 0: + # Default to North if no direction can be determined + return float(floor_plan_orientation) % 360.0 + + # Compute azimuth using atan2 + # atan2(x, y) gives angle clockwise from North (y-axis) + azimuth_rad = math.atan2(total_dx, total_dy) + azimuth_deg = math.degrees(azimuth_rad) + + # Normalize to [0, 360) + azimuth_deg = azimuth_deg % 360.0 + + # Apply floor_plan_orientation offset + return (azimuth_deg + float(floor_plan_orientation)) % 360.0 + + +def group_air_nodes( + floor_plan: np.ndarray, + air_value: int = constants.INTERIOR_SPACE_VALUE_IN_FUNCTION, + exterior_fenestration: int = constants.EXTERIOR_FENESTRATION_VALUE, + interior_fenestration: int = constants.INTERIOR_FENESTRATION_VALUE, + inbetween_fenestration: int = constants.INBETWEEN_FENESTRATION_VALUE, + fenestration_groups: Optional[Mapping[str, Dict[str, Any]]] = None, +) -> Dict[str, Dict[str, Any]]: + """Group connected interior air nodes and annotate adjacent fenestrations. + + Finds all connected components of air nodes (using 4-connectivity) and + records which fenestration groups are adjacent to each air group. + + Args: + floor_plan: 2D indexed floor plan array. + air_value: Value representing interior air spaces. + exterior_fenestration: Value for exterior fenestration nodes. + interior_fenestration: Value for interior fenestration nodes. + inbetween_fenestration: Value for in-between fenestration nodes. + fenestration_groups: Pre-computed fenestration groups (from + :func:`group_fenestrations`). If provided, each air group will list + which fenestration groups are adjacent. + + Returns: + Dictionary mapping group names to group info dicts with keys: + 'count', 'indices', 'indices_array', 'fenestration_groups'. + """ + visited = np.zeros_like(floor_plan, dtype=bool) + groups: Dict[str, Dict[str, Any]] = {} + group_count = 0 + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + fenestration_index_to_group: Dict[Tuple[int, int], str] = {} + if fenestration_groups: + for group_name, group_info in fenestration_groups.items(): + for idx in group_info['indices']: + fenestration_index_to_group[idx] = group_name + + fenestration_values = { + exterior_fenestration, + interior_fenestration, + inbetween_fenestration, + } + + for row in range(floor_plan.shape[0]): + for col in range(floor_plan.shape[1]): + if floor_plan[row, col] == air_value and not visited[row, col]: + group_count += 1 + group_indices: List[Tuple[int, int]] = [] + adjacent_fenestrations: Set[str] = set() + queue = deque([(row, col)]) + visited[row, col] = True + + while queue: + r, c = queue.popleft() + group_indices.append((r, c)) + + for dr, dc in directions: + nr, nc = r + dr, c + dc + if 0 <= nr < floor_plan.shape[0] and 0 <= nc < floor_plan.shape[1]: + neighbor_val = floor_plan[nr, nc] + + if neighbor_val in fenestration_values: + group_name = fenestration_index_to_group.get((nr, nc)) + if group_name: + adjacent_fenestrations.add(group_name) + + if neighbor_val == air_value and not visited[nr, nc]: + visited[nr, nc] = True + queue.append((nr, nc)) + + indices_array = np.zeros_like(floor_plan, dtype=bool) + for r, c in group_indices: + indices_array[r, c] = True + + groups[f'air_{group_count}'] = { + 'count': len(group_indices), + 'indices': group_indices, + 'indices_array': indices_array, + 'fenestration_groups': sorted(adjacent_fenestrations), + } + + return groups + + +def calculate_solar_absorbed_for_fenestration_group( + fenestration_group: Mapping[str, Any], + irradiance_components: Union[ + solar_radiation.IrradianceComponents, + Mapping[str, Any], + ], + solar_zenith: float, + solar_azimuth: float, + alpha: float = constants.FENESTRATION_SOLAR_ABSORPTANCE, +) -> float: + """Compute absorbed solar flux per node for a fenestration group.""" + exterior_count = int(fenestration_group.get('exterior_count', 0)) + if exterior_count == 0: + return 0.0 + + irr = _ensure_irradiance_components( + irradiance_components, solar_zenith, solar_azimuth + ) + + surface_tilt = float(fenestration_group['phi']) + surface_azimuth = float(fenestration_group['azimuth']) + + g_ts = solar_radiation.calculate_poa_irradiance( + irr, + surface_tilt, + surface_azimuth, + solar_zenith, + solar_azimuth, + ) + + total_absorbed = g_ts * alpha * exterior_count + total_count = int(fenestration_group.get('count', 0)) + return total_absorbed / total_count if total_count > 0 else 0.0 + + +def net_solar_absorbed_heatflux_fenestration( + floor_plan: np.ndarray, + fenestration_groups: Optional[Mapping[str, Mapping[str, Any]]], + irradiance_components: Union[ + solar_radiation.IrradianceComponents, + Mapping[str, Any], + ], + solar_zenith: float, + solar_azimuth: float, + alpha: float = constants.FENESTRATION_SOLAR_ABSORPTANCE, +) -> np.ndarray: + """Return array of absorbed solar heat flux at each fenestration node.""" + q_sol_alpha_array = np.zeros_like(floor_plan, dtype=float) + + if not fenestration_groups: + return q_sol_alpha_array + + for group_info in fenestration_groups.values(): + q_sol_alpha_per_node = calculate_solar_absorbed_for_fenestration_group( + group_info, + irradiance_components, + solar_zenith, + solar_azimuth, + alpha, + ) + indices_array = group_info['indices_array'] + q_sol_alpha_array[indices_array] += q_sol_alpha_per_node + + return q_sol_alpha_array + + +def calculate_solar_transmitted_for_fenestration_group( + fenestration_group: Mapping[str, Any], + irradiance_components: Union[ + solar_radiation.IrradianceComponents, + Mapping[str, Any], + ], + solar_zenith: float, + solar_azimuth: float, + tau: float = constants.FENESTRATION_SOLAR_TRANSMITTANCE, +) -> float: + """Compute total transmitted solar radiation for a fenestration group.""" + exterior_count = int(fenestration_group.get('exterior_count', 0)) + if exterior_count == 0: + return 0.0 + + irr = _ensure_irradiance_components( + irradiance_components, solar_zenith, solar_azimuth + ) + + surface_tilt = float(fenestration_group['phi']) + surface_azimuth = float(fenestration_group['azimuth']) + + g_ts = solar_radiation.calculate_poa_irradiance( + irr, + surface_tilt, + surface_azimuth, + solar_zenith, + solar_azimuth, + ) + + return g_ts * tau * exterior_count + + +def net_solar_transmitted_heatflux_fenestration( + floor_plan: np.ndarray, + fenestration_groups: Optional[Mapping[str, Mapping[str, Any]]], + air_groups: Optional[Mapping[str, Mapping[str, Any]]], + irradiance_components: Union[ + solar_radiation.IrradianceComponents, + Mapping[str, Any], + ], + solar_zenith: float, + solar_azimuth: float, + tau: float = constants.FENESTRATION_SOLAR_TRANSMITTANCE, +) -> np.ndarray: + """Distribute transmitted solar heat flux to air nodes connected to windows""" + q_sol_tau_array = np.zeros_like(floor_plan, dtype=float) + + if not fenestration_groups or not air_groups: + return q_sol_tau_array + + fenestration_q_sol_tau: Dict[str, float] = {} + for group_name, group_info in fenestration_groups.items(): + fenestration_q_sol_tau[group_name] = ( + calculate_solar_transmitted_for_fenestration_group( + group_info, + irradiance_components, + solar_zenith, + solar_azimuth, + tau, + ) + ) + + for air_group_info in air_groups.values(): + connected_fenestrations = air_group_info.get('fenestration_groups', []) + total_q_sol_tau = sum( + fenestration_q_sol_tau.get(group, 0.0) + for group in connected_fenestrations + ) + + if total_q_sol_tau == 0.0: + continue + + air_count = int(air_group_info['count']) + q_sol_tau_per_node = total_q_sol_tau / air_count if air_count > 0 else 0.0 + + indices_array = air_group_info['indices_array'] + q_sol_tau_array[indices_array] += q_sol_tau_per_node + + return q_sol_tau_array + + +# --------------------------------------------------------------------------- +# Interior surface adjacency (walls + fenestration) +# --------------------------------------------------------------------------- + + +def mark_interior_surface_adjacent_to_air( + floor_plan: np.ndarray, + interior_wall_value: int = constants.INTERIOR_WALL_VALUE_IN_FUNCTION, + interior_fenestration_value: int = constants.INTERIOR_FENESTRATION_VALUE, + air_value: int = constants.INTERIOR_SPACE_VALUE_IN_FUNCTION, +) -> np.ndarray: + """Mark interior surfaces (walls and fenestration) adjacent to air. + + Creates a boolean mask identifying interior walls and interior fenestration + nodes that share an edge with an air space (value 0) in the floor plan. + Checks adjacency in 4 directions (up, down, left, right). + + Args: + floor_plan: 2D array representing the indexed floor plan. + interior_wall_value: Value used to represent interior walls. + interior_fenestration_value: Value for interior fenestration nodes. + air_value: Value for interior air spaces. + + Returns: + Boolean mask where True indicates an interior surface (wall or + fenestration) that is adjacent to at least one air space. + """ + mask_surface = (floor_plan == interior_wall_value) | ( + floor_plan == interior_fenestration_value + ) + mask_air = floor_plan == air_value + + contact = np.zeros_like(floor_plan, dtype=bool) + # up + contact[1:, :] |= mask_air[:-1, :] & mask_surface[1:, :] + # down + contact[:-1, :] |= mask_air[1:, :] & mask_surface[:-1, :] + # left + contact[:, 1:] |= mask_air[:, :-1] & mask_surface[:, 1:] + # right + contact[:, :-1] |= mask_air[:, 1:] & mask_surface[:, :-1] + + return mask_surface & contact + + +# --------------------------------------------------------------------------- +# Exterior longwave radiation (LWR) for fenestration +# --------------------------------------------------------------------------- + + +def calculate_exterior_lwr_for_fenestration_group( + fenestration_group: Mapping[str, Any], + surface_temperatures: np.ndarray, + emissivity_array: np.ndarray, + ambient_temperature: float, + sky_temperature: float, +) -> float: + """Compute net exterior longwave radiative heat flux for a fenestration group. + + Calculates the net longwave radiation exchange between the fenestration + surface and the sky, ground, and surrounding air using: + + q_lwr = epsilon * sigma * (F_sky*(T_sky^4 - T_s^4) + + F_gnd*(T_air^4 - T_s^4) + + F_air*(T_air^4 - T_s^4)) + + The result is the net heat flux INTO the surface (positive means surface + gains heat). + + Args: + fenestration_group: Dictionary containing fenestration group properties + including 'F_sky', 'F_gnd', 'F_air', 'exterior_indices_array'. + surface_temperatures: 2D array of surface temperatures in K. + emissivity_array: 2D array of surface emissivities. + ambient_temperature: Outdoor dry-bulb temperature in K. + sky_temperature: Sky temperature in K. + + Returns: + Net LWR heat flux in W/m² (positive = surface gains heat). + """ + sigma = constants.STEFAN_BOLTZMANN_CONSTANT + f_sky = float(fenestration_group['F_sky']) + f_gnd = float(fenestration_group['F_gnd']) + f_air = float(fenestration_group['F_air']) + + exterior_mask = fenestration_group['exterior_indices_array'] + + # Average surface temperature and emissivity over exterior fenestration nodes + if np.any(exterior_mask): + t_surf = np.mean(surface_temperatures[exterior_mask]) + epsilon = np.mean(emissivity_array[exterior_mask]) + else: + return 0.0 + + t_sky4 = sky_temperature**4 + t_air4 = ambient_temperature**4 + t_surf4 = t_surf**4 + + q_lwr = ( + epsilon + * sigma + * ( + f_sky * (t_sky4 - t_surf4) + + f_gnd * (t_air4 - t_surf4) + + f_air * (t_air4 - t_surf4) + ) + ) + + return float(q_lwr) + + +def net_exterior_radiative_heatflux( + floor_plan: np.ndarray, + fenestration_groups: Optional[Mapping[str, Mapping[str, Any]]], + surface_temperatures: np.ndarray, + emissivity_array: np.ndarray, + ambient_temperature: float, + sky_temperature: float, +) -> np.ndarray: + """Compute net exterior LWR heat flux array for all fenestration nodes. + + For each fenestration group, calculates the net longwave radiation exchange + and distributes the result uniformly to all nodes in the group. + + Args: + floor_plan: 2D indexed floor plan array. + fenestration_groups: Dictionary of fenestration group properties. + surface_temperatures: 2D array of surface temperatures in K. + emissivity_array: 2D array of surface emissivities. + ambient_temperature: Outdoor dry-bulb temperature in K. + sky_temperature: Sky temperature in K. + + Returns: + 2D array of net LWR heat flux at each grid position. + """ + q_lwr_array = np.zeros_like(floor_plan, dtype=float) + + if not fenestration_groups: + return q_lwr_array + + for group_info in fenestration_groups.values(): + q_lwr = calculate_exterior_lwr_for_fenestration_group( + group_info, + surface_temperatures, + emissivity_array, + ambient_temperature, + sky_temperature, + ) + indices_array = group_info['indices_array'] + q_lwr_array[indices_array] = q_lwr + + return q_lwr_array + + +# --------------------------------------------------------------------------- +# Exterior wall boundary mask +# --------------------------------------------------------------------------- + + +def get_exterior_wall_boundary_mask( + floor_plan: np.ndarray, + wall_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FILE_INPUT, + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + air_value: int = constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, +) -> np.ndarray: + """Identify exterior wall nodes that form the building boundary. + + A wall node is considered an exterior wall boundary if: + 1. It has the specified wall_value. + 2. It is NOT adjacent to an enclosed interior air space. + + Walls adjacent to enclosed interior air (courtyard, rooms) are excluded + because they face inward. Walls at the array boundary or adjacent to + exterior_space_value are included. + + Interior walls (value 1) are treated as solid material and do NOT cause + exclusion of adjacent exterior walls. + + Args: + floor_plan: 2D array of the floor plan (can use FILE_INPUT or FUNCTION + values). + wall_value: The value representing exterior walls. + exterior_space_value: The value representing exterior space. + air_value: The value representing interior air spaces. + + Returns: + Boolean mask where True indicates an exterior wall boundary node. + """ + rows, cols = floor_plan.shape + wall_mask = floor_plan == wall_value + + if not np.any(wall_mask): + return np.zeros_like(floor_plan, dtype=bool) + + # Find all enclosed interior air regions using flood-fill from edges + # Air connected to edges or exterior space is NOT enclosed interior air + air_mask = floor_plan == air_value + if not np.any(air_mask): + # No interior air, all walls are exterior boundaries + return wall_mask + + # Find enclosed air: air NOT connected to exterior + # Use flood fill from exterior space or from array boundary + visited_air = np.zeros_like(floor_plan, dtype=bool) + exterior_mask = floor_plan == exterior_space_value + + # BFS from all exterior space nodes and boundary nodes + queue = deque() + + # Add all exterior space nodes to start BFS + for r in range(rows): + for c in range(cols): + if exterior_mask[r, c]: + queue.append((r, c)) + visited_air[r, c] = True + + # Also check air at array boundaries (connected to outside) + for r in range(rows): + for c in range(cols): + if ( + air_mask[r, c] + and not visited_air[r, c] + and (r == 0 or r == rows - 1 or c == 0 or c == cols - 1) + ): + queue.append((r, c)) + visited_air[r, c] = True + + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + while queue: + r, c = queue.popleft() + for dr, dc in directions: + nr, nc = r + dr, c + dc + if 0 <= nr < rows and 0 <= nc < cols and not visited_air[nr, nc]: + if air_mask[nr, nc] or exterior_mask[nr, nc]: + visited_air[nr, nc] = True + queue.append((nr, nc)) + + # Enclosed interior air = air nodes NOT visited (not connected to exterior) + enclosed_air = air_mask & ~visited_air + + # Walls adjacent to enclosed air should be excluded UNLESS they are also + # at the array boundary or adjacent to exterior space + adjacent_to_enclosed = np.zeros_like(floor_plan, dtype=bool) + for dr, dc in directions: + shifted = np.zeros_like(floor_plan, dtype=bool) + if dr == -1: + shifted[:-1, :] = enclosed_air[1:, :] + elif dr == 1: + shifted[1:, :] = enclosed_air[:-1, :] + elif dc == -1: + shifted[:, :-1] = enclosed_air[:, 1:] + elif dc == 1: + shifted[:, 1:] = enclosed_air[:, :-1] + adjacent_to_enclosed |= shifted + + # Determine which walls are at the array boundary + at_boundary = np.zeros_like(floor_plan, dtype=bool) + at_boundary[0, :] = True + at_boundary[-1, :] = True + at_boundary[:, 0] = True + at_boundary[:, -1] = True + + # Determine which walls are adjacent to exterior space + adjacent_to_exterior = np.zeros_like(floor_plan, dtype=bool) + for dr, dc in directions: + shifted = np.zeros_like(floor_plan, dtype=bool) + if dr == -1: + shifted[:-1, :] = exterior_mask[1:, :] + elif dr == 1: + shifted[1:, :] = exterior_mask[:-1, :] + elif dc == -1: + shifted[:, :-1] = exterior_mask[:, 1:] + elif dc == 1: + shifted[:, 1:] = exterior_mask[:, :-1] + adjacent_to_exterior |= shifted + + # A wall faces outward if it's at boundary or adjacent to exterior space + faces_outward = at_boundary | adjacent_to_exterior + + # Exterior wall boundary = wall AND(faces outward OR NOT adjacent to enclosed) + # i.e., exclude walls ONLY adjacent to enclosed air that don't face outward + return wall_mask & (faces_outward | ~adjacent_to_enclosed) + + +# --------------------------------------------------------------------------- +# Exterior wall azimuth determination +# --------------------------------------------------------------------------- + + +def determine_exterior_wall_azimuth_array( + exterior_wall_boundary_mask: np.ndarray, + floor_plan: np.ndarray, + exterior_space_value: int = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + floor_plan_orientation: float = 0.0, +) -> np.ndarray: + """Determine azimuth for each exterior wall boundary node. + + For each exterior wall boundary node, computes the outward-facing direction + (toward exterior space) and converts it to an azimuth angle using atan2 + in the Cartesian coordinate system. + + The grid coordinate system: + - row increases downward (South in default orientation) + - col increases rightward (East in default orientation) + + Cartesian mapping: + - x = col direction (East) + - y = -row direction (North) + + Azimuth (clockwise from North): + - 0° = North (decreasing row) + - 90° = East (increasing col) + - 180° = South (increasing row) + - 270° = West (decreasing col) + + For corner nodes with multiple adjacent exterior space directions, the + resulting azimuth is the average direction (e.g. top-right = 45°). + + Args: + exterior_wall_boundary_mask: Boolean mask of exterior wall nodes. + floor_plan: 2D indexed floor plan array. + exterior_space_value: Value representing exterior space. + floor_plan_orientation: Compass angle (degrees) of the floor-plan's + "up" direction. 0/360 = North. + + Returns: + 2D array of azimuth angles in degrees. Non-wall positions are 0.0. + """ + rows, cols = floor_plan.shape + azimuth_array = np.zeros((rows, cols), dtype=float) + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + for r in range(rows): + for c in range(cols): + if not exterior_wall_boundary_mask[r, c]: + continue + + total_dx = 0.0 + total_dy = 0.0 + + for dr, dc in directions: + nr, nc = r + dr, c + dc + if nr < 0 or nr >= rows or nc < 0 or nc >= cols: + # Array boundary counts as exterior + total_dx += float(dc) + total_dy += float(-dr) + elif floor_plan[nr, nc] == exterior_space_value: + total_dx += float(dc) + total_dy += float(-dr) + + if total_dx == 0.0 and total_dy == 0.0: + continue + + # atan2(x, y) gives clockwise angle from North (y-axis) + azimuth_rad = math.atan2(total_dx, total_dy) + azimuth_deg = math.degrees(azimuth_rad) % 360.0 + + # Apply floor_plan_orientation offset + azimuth_array[r, c] = (azimuth_deg + floor_plan_orientation) % 360.0 + + return azimuth_array diff --git a/smart_control/simulator/building_radiation_utils_test.py b/smart_control/simulator/building_radiation_utils_test.py index c32dc3f8..ac03649a 100644 --- a/smart_control/simulator/building_radiation_utils_test.py +++ b/smart_control/simulator/building_radiation_utils_test.py @@ -1,11 +1,14 @@ """Tests for radiation utility functions.""" +import math + from absl.testing import absltest import numpy as np from numpy.testing import assert_array_almost_equal from smart_control.simulator import building_radiation_utils as utils from smart_control.simulator import constants +from smart_control.simulator import solar_radiation # Import the constant for air in line of sight AIR_IN_LINE_OF_SIGHT = utils.AIR_IN_LINE_OF_SIGHT @@ -179,6 +182,60 @@ def test_mark_air_connected_interior_walls(self): with self.subTest("air-connected interior walls correctly marked"): assert_array_almost_equal(result, expected_result) + def test_mark_interior_surface_adjacent_to_air_with_fenestration(self): + """Test that interior surfaces (walls and fenestration) are marked correctly + + This test verifies that both interior walls (-3) and interior fenestration + (-43) that are adjacent to air spaces (0) are correctly identified. + """ + # Floor plan with interior walls and interior fenestration + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1], + [-1, -42, -42, -42, -42, -42, -1], # Exterior fenestration (not marked) + [ + -1, + -43, + -43, + -43, + -43, + -43, + -1, + ], # Interior fenestration (adj. to air) + [-1, 0, 0, 0, 0, 0, -1], # Air + [-1, -3, -3, -3, -3, -3, -1], # Interior wall (adjacent to air) + [-1, -1, -1, -1, -1, -1, -1], + ]) + + result = utils.mark_interior_surface_adjacent_to_air(floor_plan) + + # Interior fenestration at row 2 should be marked (adjacent to air at row 3) + for col in range(1, 6): + self.assertTrue( + result[2, col], + f"Interior fenestration at (2, {col}) should be marked", + ) + + # Interior wall at row 4 should be marked (adjacent to air at row 3) + for col in range(1, 6): + self.assertTrue( + result[4, col], + f"Interior wall at (4, {col}) should be marked", + ) + + # Exterior fenestration at row 1 should NOT be marked + for col in range(1, 6): + self.assertFalse( + result[1, col], + f"Exterior fenestration at (1, {col}) should NOT be marked", + ) + + # Air nodes should NOT be marked + for col in range(1, 6): + self.assertFalse( + result[3, col], + f"Air node at (3, {col}) should NOT be marked", + ) + def test_mark_directly_seeing_nodes(self): """Test line-of-sight calculations for radiative heat transfer. @@ -537,6 +594,1689 @@ def test_mark_directly_seeing_nodes(self): air_in_line = np.sum(result128 == AIR_IN_LINE_OF_SIGHT) self.assertEqual(air_in_line, 0, "No air in line of sight.") + def test_fenestration_validation_passes_with_connected_fenestration(self): + """Test that fenestration validation passes when connected to air. + + Outermost layer is all 2 (exterior space); fenestration (4) connects + exterior to interior air (0) in the wall layer. + """ + # Create a floor plan with fenestration connected to air + # Most exterior cells are 2; fenestration sits inside, adjacent to 2 and 0 + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 4, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # Should not raise + result = utils.validate_fenestration_connectivity( + floor_plan, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + self.assertTrue(result) + + def test_fenestration_validation_fails_without_air_connection(self): + """Test that fenestration validation fails when not connected to air.""" + # Create a floor plan with fenestration not connected to air + floor_plan = np.array([ + [2, 2, 2, 2, 2], + [2, 4, 4, 4, 2], + [2, 4, 1, 4, 2], # Fenestration only connected to wall (1) + [2, 4, 4, 4, 2], + [2, 2, 2, 2, 2], + ]) + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + floor_plan, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + self.assertIn("not connected to indoor air", str(context.exception)) + + def test_fenestration_position_marking(self): + """Test that fenestration nodes are correctly marked by position.""" + # Create indexed floor plan with fenestration at value -4 + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1], # Exterior space + [-1, -4, -4, -4, -4, -4, -1], # Fenestration row + [-1, 0, 0, 0, 0, 0, -1], # Air row + [-1, 0, 0, 0, 0, 0, -1], # Air row + [-1, -1, -1, -1, -1, -1, -1], # Exterior space + ]) + + result = utils.mark_fenestration_positions( + floor_plan, + fenestration_value=constants.FENESTRATION_VALUE_IN_FUNCTION, + exterior_space_value=constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FUNCTION, + ) + + # Fenestration at row 1 should be: + # - Adjacent to exterior (row 0) => exterior fenestration (-42) + # - Adjacent to air (row 2) => interior fenestration (-43) + # Since both are adjacent, they should be interior (-43) per logic + for col in range(1, 6): + self.assertEqual( + result[1, col], + constants.INTERIOR_FENESTRATION_VALUE, + f"Position (1, {col}) should be interior fenestration", + ) + + def test_fenestration_grouping_with_view_factors(self): + """Test that fenestration groups are created with correct view factors.""" + + # Create indexed floor plan with marked fenestration positions + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1], + [-1, -42, -42, -42, -42, -42, -1], # Exterior fenestration + [-1, -43, -43, -43, -43, -43, -1], # Interior fenestration + [-1, 0, 0, 0, 0, 0, -1], + [-1, -1, -1, -1, -1, -1, -1], + ]) + + groups = utils.group_fenestrations(floor_plan) + + # Should have one fenestration group + self.assertEqual(len(groups), 1) + + group_name = list(groups.keys())[0] + group = groups[group_name] + + # Check phi is 90 degrees + self.assertEqual(group["phi"], 90.0) + + # Check azimuth is 0 (top/north) since adjacent to exterior at top + self.assertEqual(group["azimuth"], 0.0) + + # Check view factors using the formulas + phi_rad = math.radians(90) + cos_phi = math.cos(phi_rad) # ~0 + + expected_F_gnd = 0.5 * (1 - cos_phi) # ~0.5 + expected_factor = 0.5 * (1 + cos_phi) # ~0.5 + expected_F_sky = expected_factor * math.sqrt(expected_factor) # ~0.354 + expected_F_air = expected_factor * ( + 1 - math.sqrt(expected_factor) + ) # ~0.146 + + self.assertAlmostEqual(group["F_gnd"], expected_F_gnd, places=6) + self.assertAlmostEqual(group["F_sky"], expected_F_sky, places=6) + self.assertAlmostEqual(group["F_air"], expected_F_air, places=6) + + # Check that count and indices are correct + self.assertEqual(group["count"], 10) # 5 exterior + 5 interior + self.assertEqual(len(group["indices"]), 10) + + # Check exterior_count (5 exterior fenestration nodes at row 1) + self.assertEqual(group["exterior_count"], 5) + + # Check indices_array + self.assertEqual(group["indices_array"].shape, floor_plan.shape) + self.assertEqual(group["indices_array"].dtype, bool) + # All fenestration positions should be True + for r, c in group["indices"]: + self.assertTrue(group["indices_array"][r, c]) + # Total True values should equal count + self.assertEqual(np.sum(group["indices_array"]), group["count"]) + + def test_air_node_grouping(self): + """Test that air nodes are correctly grouped.""" + # Create indexed floor plan with two separate air spaces + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1], + [-1, 0, 0, -3, 0, 0, -1], # Two air groups separated by wall + [-1, 0, 0, -3, 0, 0, -1], + [-1, -1, -1, -1, -1, -1, -1], + ]) + + groups = utils.group_air_nodes(floor_plan) + + # Should have two air groups + self.assertEqual(len(groups), 2) + + # Check total air nodes + total_nodes = sum(g["count"] for g in groups.values()) + self.assertEqual(total_nodes, 8) + + # Check indices_array for each group + for group in groups.values(): + self.assertEqual(group["indices_array"].shape, floor_plan.shape) + self.assertEqual(group["indices_array"].dtype, bool) + # All air positions should be True + for r, c in group["indices"]: + self.assertTrue(group["indices_array"][r, c]) + # Total True values should equal count + self.assertEqual(np.sum(group["indices_array"]), group["count"]) + + def test_air_node_grouping_with_fenestration_links(self): + """Test that air groups are linked to adjacent fenestration groups.""" + # Create indexed floor plan with fenestration adjacent to air + floor_plan = np.array([ + [-1, -1, -1, -1, -1], + [-1, -42, -42, -42, -1], # Exterior fenestration + [-1, -43, -43, -43, -1], # Interior fenestration + [-1, 0, 0, 0, -1], # Air + [-1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + air_groups = utils.group_air_nodes( + floor_plan, fenestration_groups=fenestration_groups + ) + + # Should have one air group with one fenestration group adjacent + self.assertEqual(len(air_groups), 1) + + air_group = list(air_groups.values())[0] + self.assertGreater(len(air_group["fenestration_groups"]), 0) + + def test_large_dummy_floor_plan_fenestration_groups(self): + """Test that a floor plan with fenestration creates multiple groups.""" + floor_plan = np.array([ + [2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [4, 4, 4, 4, 0, 0, 1, 0, 0, 4, 4], + [4, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 1, 2], + [4, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 4, 2, 2, 2, 4, 2, 2], + ]) + expected_indexed_floor_plan = np.array([ + [-1, -1, -1, -1, -1, -42, -1, -1, -1, -1, -1], + [-1, -3, -3, -3, -3, -425, -3, -3, -3, -3, -1], + [-1, -3, -3, -3, -3, -43, -3, -3, -3, -3, -1], + [-42, -425, -425, -43, 0, 0, -3, 0, 0, -43, -42], + [-42, -425, -425, -43, 0, 0, -3, 0, 0, -3, -1], + [-1, -3, -3, -3, -3, -3, -3, -3, -3, -3, -1], + [-1, -3, -3, -3, 0, 0, -3, 0, 0, -3, -1], + [-42, -425, -425, -43, 0, 0, -3, 0, 0, -3, -1], + [-1, -3, -3, -3, -43, -3, -3, -3, -43, -3, -1], + [-1, -1, -1, -1, -42, -1, -1, -1, -42, -1, -1], + ]) + # First convert to indexed format + # please see FloorPlanBaseBuilding's + # _assign_radiative_heat_transfer_properties method for this logic. + indexed_floor_plan = floor_plan.copy() + indexed_floor_plan[ + indexed_floor_plan == constants.EXTERIOR_SPACE_VALUE_IN_FILE_INPUT + ] = constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION + indexed_floor_plan[ + indexed_floor_plan == constants.INTERIOR_WALL_VALUE_IN_FILE_INPUT + ] = constants.INTERIOR_WALL_VALUE_IN_FUNCTION + indexed_floor_plan[ + indexed_floor_plan == constants.FENESTRATION_VALUE_IN_FILE_INPUT + ] = constants.FENESTRATION_VALUE_IN_FUNCTION + + # Mark fenestration positions + indexed_floor_plan = utils.mark_fenestration_positions(indexed_floor_plan) + + # Group fenestrations + fenestration_groups = utils.group_fenestrations(indexed_floor_plan) + + # The large floor plan has fenestration on multiple sides + # Should have multiple fenestration groups + self.assertGreater( + len(fenestration_groups), + 0, + "Should have at least one fenestration group", + ) + + # test validation based on manual calculations. + np.testing.assert_array_equal( + indexed_floor_plan, + expected_indexed_floor_plan, + err_msg=( + "indexed_floor_plan after mark_fenestration_positions does not" + " match expected" + ), + ) + + # Check each group has required properties + for group_name, group in fenestration_groups.items(): + with self.subTest(group=group_name): + self.assertIn("count", group) + self.assertIn("indices", group) + self.assertIn("phi", group) + self.assertIn("azimuth", group) + self.assertIn("F_gnd", group) + self.assertIn("F_sky", group) + self.assertIn("F_air", group) + + # Check view factors sum to ~1 (they should for vertical surfaces) + vf_sum = group["F_gnd"] + group["F_sky"] + group["F_air"] + self.assertAlmostEqual( + vf_sum, + 1.0, + places=5, + msg=f"View factors should sum to 1, got {vf_sum}", + ) + + def test_fenestration_azimuth_detection(self): + # pylint: disable=line-too-long + """Test that fenestration azimuth is correctly detected based on exterior. + + Floor plan values (FILE_INPUT format): + 0 = air, 1 = interior wall, 2 = exterior space, 4 = fenestration + + Pipeline: + 1. validate_fenestration_connectivity() on the raw floor plan + 2. Convert to indexed (FUNCTION) format + 3. mark_fenestration_positions() to classify fenestration nodes + 4. group_fenestrations() and assert the azimuth + + Cardinal directions: + Left: 270°, Right: 90°, Bottom: 180°, Top: 0° + Diagonal (L-shaped): + Top-left NW: 315°, Top-right NE: 45° + """ + # pylint: enable=line-too-long + + def _to_indexed(fp): + """Convert FILE_INPUT floor plan to indexed FUNCTION format.""" + indexed = fp.copy() + indexed[indexed == constants.EXTERIOR_SPACE_VALUE_IN_FILE_INPUT] = ( + constants.EXTERIOR_SPACE_VALUE_IN_FUNCTION + ) + indexed[indexed == constants.INTERIOR_WALL_VALUE_IN_FILE_INPUT] = ( + constants.INTERIOR_WALL_VALUE_IN_FUNCTION + ) + indexed[indexed == constants.FENESTRATION_VALUE_IN_FILE_INPUT] = ( + constants.FENESTRATION_VALUE_IN_FUNCTION + ) + return indexed + + # fmt: off + # FILE_INPUT format: 0=air, 1=wall, 2=exterior, 4=fenestration + test_cases = [ + # (name, raw_floor_plan, expected_azimuth) + ("LEFT 270°", np.array([ + [2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 2], + [2, 4, 4, 0, 1, 2], + [2, 4, 4, 0, 1, 2], + [2, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2], + ]), 270.0), + ("RIGHT 90°", np.array([ + [2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 2], + [2, 1, 0, 4, 4, 2], + [2, 1, 0, 4, 4, 2], + [2, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2], + ]), 90.0), + ("BOTTOM 180°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 4, 4, 4, 1, 2], + [2, 2, 4, 4, 4, 2, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 180.0), + ("TOP 0°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 4, 4, 4, 2, 2], + [2, 1, 4, 4, 4, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 0.0), + ("NE 45°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 1, 4, 2, 2, 2], + [2, 1, 1, 4, 4, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 45.0), + ("SE 135°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 1, 4, 4, 1, 2], + [2, 2, 1, 4, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 135.0), + ("SW 225°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 4, 4, 1, 1, 2], + [2, 2, 2, 4, 1, 2, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 225.0), + ("NW 315°", np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 4, 1, 2, 2], + [2, 1, 4, 4, 1, 1, 2], + [2, 1, 0, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]), 315.0), + ] + # fmt: on + + for name, raw_fp, expected_azimuth in test_cases: + with self.subTest(direction=name): + # 1. Validate fenestration connectivity on the raw floor plan + utils.validate_fenestration_connectivity(raw_fp) + + # 2. Convert to indexed (FUNCTION) format + indexed_fp = _to_indexed(raw_fp) + + # 3. Mark fenestration positions (-42/-43) + indexed_fp = utils.mark_fenestration_positions(indexed_fp) + + # 4. Group and check azimuth + groups = utils.group_fenestrations(indexed_fp) + self.assertEqual(len(groups), 1, f"{name}: expected 1 group") + self.assertAlmostEqual( + list(groups.values())[0]["azimuth"], + expected_azimuth, + places=5, + msg=f"{name}: azimuth mismatch", + ) + + def test_fenestration_azimuth_with_orientation_offset(self): + """Test that floor_plan_orientation correctly offsets the azimuth. + + When floor_plan_orientation is non-zero, the computed azimuth should + be offset by that amount. For example, if the floor plan's "up" direction + is actually East (orientation=90), then a fenestration facing "up" in the + grid should have azimuth = 0 + 90 = 90. + """ + # Fenestration on top side (grid azimuth = 0) + floor_plan_top = np.array([ + [-1, -1, -42, -42, -42, -1, -1], + [-1, -3, -43, -43, -43, -3, -1], + [-1, -3, 0, 0, 0, -3, -1], + [-1, -3, -3, -3, -3, -3, -1], + [-1, -1, -1, -1, -1, -1, -1], + ]) + + # With orientation=90 (up=East), grid azimuth 0 should become 90 + groups = utils.group_fenestrations( + floor_plan_top, floor_plan_orientation=90.0 + ) + self.assertAlmostEqual(list(groups.values())[0]["azimuth"], 90.0, places=5) + + # With orientation=180 (up=South), grid azimuth 0 should become 180 + groups = utils.group_fenestrations( + floor_plan_top, floor_plan_orientation=180.0 + ) + self.assertAlmostEqual(list(groups.values())[0]["azimuth"], 180.0, places=5) + + # With orientation=270 (up=West), grid azimuth 0 should become 270 + groups = utils.group_fenestrations( + floor_plan_top, floor_plan_orientation=270.0 + ) + self.assertAlmostEqual(list(groups.values())[0]["azimuth"], 270.0, places=5) + + # Fenestration on left side (grid azimuth = 270) with orientation=90 + # should give 270 + 90 = 360 -> 0 + floor_plan_left = np.array([ + [-1, -1, -1, -1, -1, -1], + [-1, -3, -3, -3, -3, -1], + [-42, -43, 0, 0, -3, -1], + [-42, -43, 0, 0, -3, -1], + [-1, -3, -3, -3, -3, -1], + [-1, -1, -1, -1, -1, -1], + ]) + groups = utils.group_fenestrations( + floor_plan_left, floor_plan_orientation=90.0 + ) + self.assertAlmostEqual(list(groups.values())[0]["azimuth"], 0.0, places=5) + + def test_fenestration_azimuth_diagonal(self): + """Test that fenestration azimuth handles diagonal directions via atan2. + + When exterior fenestration nodes have multiple adjacent exterior space + directions, the atan2-based computation produces intermediate angles. + """ + # Fenestration in a corner: has exterior space both above and to the left + # This creates a diagonal direction (NW = 315 degrees) + floor_plan_corner = np.array([ + [-1, -1, -1, -1, -1], + [-1, -42, -3, -3, -1], + [-1, -3, 0, 0, -1], + [-1, -3, 0, 0, -1], + [-1, -1, -1, -1, -1], + ]) + groups = utils.group_fenestrations(floor_plan_corner) + # The single exterior fenestration node at (1,1) is adjacent to + # exterior at (0,1) [up] and (1,0) [left] + # Direction: up contributes (dx=0, dy=1), left contributes (dx=-1, dy=0) + # Average: (dx=-1, dy=1) => atan2(-1, 1) = -45° => 315° + self.assertAlmostEqual(list(groups.values())[0]["azimuth"], 315.0, places=5) + + def test_calculate_poa_irradiance(self): + """Test POA irradiance calculation from horizontal irradiance components.""" + # Test case: South-facing surface at 30 degree tilt, sun at 30 degree zenith + irradiance_components = solar_radiation.IrradianceComponents( + ghi=800.0, + dni=700.0, + dhi=100.0, + solar_zenith=30.0, + solar_azimuth=180.0, + ) + surface_tilt = 30.0 + surface_azimuth = 180.0 # South-facing + solar_zenith = 30.0 + solar_azimuth = 180.0 # Sun due south + + poa = solar_radiation.calculate_poa_irradiance( + irradiance_components=irradiance_components, + surface_tilt=surface_tilt, + surface_azimuth=surface_azimuth, + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth, + ) + + # POA should be positive + self.assertGreater(poa, 0) + # POA should be reasonable (not exceed theoretical max) + self.assertLess(poa, 1500) + + # Test with sun below horizon (zenith > 90) + irradiance_night = solar_radiation.IrradianceComponents( + ghi=0.0, + dni=0.0, + dhi=0.0, + solar_zenith=100.0, + solar_azimuth=180.0, + ) + poa_night = solar_radiation.calculate_poa_irradiance( + irradiance_components=irradiance_night, + surface_tilt=surface_tilt, + surface_azimuth=surface_azimuth, + solar_zenith=100.0, # Sun below horizon + solar_azimuth=180.0, + ) + self.assertEqual(poa_night, 0.0) + + def test_calculate_poa_irradiance_different_orientations(self): + """Test POA irradiance for different surface orientations.""" + irradiance_components = solar_radiation.IrradianceComponents( + ghi=800.0, + dni=700.0, + dhi=100.0, + solar_zenith=30.0, + solar_azimuth=180.0, + ) + solar_zenith = 30.0 + solar_azimuth = 180.0 # Sun due south + + # South-facing surface (should receive most direct radiation) + poa_south = solar_radiation.calculate_poa_irradiance( + irradiance_components=irradiance_components, + surface_tilt=30.0, + surface_azimuth=180.0, # South + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth, + ) + + # North-facing surface (should receive less direct radiation) + poa_north = solar_radiation.calculate_poa_irradiance( + irradiance_components=irradiance_components, + surface_tilt=30.0, + surface_azimuth=0.0, # North + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth, + ) + + # South-facing should receive more radiation than north-facing + # when sun is in the south + self.assertGreater(poa_south, poa_north) + + def test_calculate_exterior_lwr_for_fenestration_group(self): + """Test exterior LWR calculation for a fenestration group.""" + # Create a simple fenestration group + floor_plan = np.array([ + [-1, -1, -42, -42, -1, -1], # Exterior fenestration at boundary + [-1, -3, -43, -43, -3, -1], # Interior fenestration + [-1, -3, 0, 0, -3, -1], + [-1, -1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + self.assertEqual(len(fenestration_groups), 1) + + group = list(fenestration_groups.values())[0] + + # Verify beta and exterior_indices_array are present + self.assertIn("beta", group) + self.assertIn("exterior_indices_array", group) + self.assertEqual(group["exterior_count"], 2) + + # Create temperature and emissivity arrays + surface_temps = np.full_like(floor_plan, 300.0, dtype=float) + emissivity = np.full_like(floor_plan, 0.9, dtype=float) + + # Test with T_air > T_surf (surface should gain heat) + q_lwr = utils.calculate_exterior_lwr_for_fenestration_group( + fenestration_group=group, + surface_temperatures=surface_temps, + emissivity_array=emissivity, + ambient_temperature=310.0, # Warmer ambient + sky_temperature=280.0, # Cold sky + ) + + # With warmer ambient and cold sky, net heat flux depends on balance + # The result should be a float + self.assertIsInstance(q_lwr, float) + + def test_net_exterior_radiative_heatflux(self): + """Test net exterior radiative heat flux for all fenestrations.""" + # Create floor plan with fenestrations + floor_plan = np.array([ + [-1, -1, -42, -42, -1, -1], + [-1, -3, -43, -43, -3, -1], + [-1, -3, 0, 0, -3, -1], + [-1, -1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + + # Create temperature and emissivity arrays + surface_temps = np.full_like(floor_plan, 300.0, dtype=float) + emissivity = np.full_like(floor_plan, 0.9, dtype=float) + + # Calculate q_lwr + q_lwr = utils.net_exterior_radiative_heatflux( + floor_plan=floor_plan, + fenestration_groups=fenestration_groups, + surface_temperatures=surface_temps, + emissivity_array=emissivity, + ambient_temperature=310.0, + sky_temperature=280.0, + ) + + # Output should have same shape as floor_plan + self.assertEqual(q_lwr.shape, floor_plan.shape) + + # q_lwr should be zero at non-fenestration positions + non_fenestration_mask = (floor_plan != -42) & (floor_plan != -43) + self.assertTrue(np.all(q_lwr[non_fenestration_mask] == 0.0)) + + # q_lwr should be non-zero at fenestration positions + fenestration_mask = (floor_plan == -42) | (floor_plan == -43) + # All fenestration nodes in a group should have the same q_lwr + q_lwr_fenestrations = q_lwr[fenestration_mask] + self.assertTrue(np.all(q_lwr_fenestrations == q_lwr_fenestrations[0])) + + def test_net_exterior_radiative_heatflux_no_fenestrations(self): + """Test that q_lwr is zero when there are no fenestrations.""" + floor_plan = np.array([ + [-1, -1, -1, -1], + [-1, -3, -3, -1], + [-1, -3, 0, -1], + [-1, -1, -1, -1], + ]) + + # No fenestrations + fenestration_groups = utils.group_fenestrations(floor_plan) + self.assertEqual(len(fenestration_groups), 0) + + surface_temps = np.full_like(floor_plan, 300.0, dtype=float) + emissivity = np.full_like(floor_plan, 0.9, dtype=float) + + q_lwr = utils.net_exterior_radiative_heatflux( + floor_plan=floor_plan, + fenestration_groups=fenestration_groups, + surface_temperatures=surface_temps, + emissivity_array=emissivity, + ambient_temperature=310.0, + sky_temperature=280.0, + ) + + # All zeros when no fenestrations + self.assertTrue(np.all(q_lwr == 0.0)) + + def test_calculate_solar_absorbed_for_fenestration_group(self): + """Test absorbed solar radiation calculation for a fenestration group.""" + # Create a floor plan with fenestration + floor_plan = np.array([ + [-1, -1, -42, -42, -1], + [-1, -3, -43, -43, -1], + [-1, -3, 0, 0, -1], + [-1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + self.assertEqual(len(fenestration_groups), 1) + + group = list(fenestration_groups.values())[0] + + # Test irradiance + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + solar_zenith = 30.0 + solar_azimuth = 180.0 + alpha = 0.1 # absorptance + + q_sol_alpha_per_node = ( + utils.calculate_solar_absorbed_for_fenestration_group( + group, + irradiance_components, + solar_zenith, + solar_azimuth, + alpha, + ) + ) + + # Should be positive + self.assertGreater(q_sol_alpha_per_node, 0.0) + + # Calculate expected: G_Ts * alpha * exterior_count / total_count + irr = solar_radiation.IrradianceComponents( + ghi=800.0, + dni=700.0, + dhi=100.0, + solar_zenith=solar_zenith, + solar_azimuth=solar_azimuth, + ) + g_ts = solar_radiation.calculate_poa_irradiance( + irr, + group["phi"], + group["azimuth"], + solar_zenith, + solar_azimuth, + ) + expected = g_ts * alpha * group["exterior_count"] / group["count"] + self.assertAlmostEqual(q_sol_alpha_per_node, expected, places=5) + + def test_net_solar_absorbed_heatflux_fenestration(self): + """Test absorbed solar radiation array calculation for fenestrations.""" + floor_plan = np.array([ + [-1, -1, -42, -42, -1], + [-1, -3, -43, -43, -1], + [-1, -3, 0, 0, -1], + [-1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + + q_sol_alpha_array = utils.net_solar_absorbed_heatflux_fenestration( + floor_plan, + fenestration_groups, + irradiance_components, + solar_zenith=30.0, + solar_azimuth=180.0, + alpha=0.1, + ) + + # Check shape + self.assertEqual(q_sol_alpha_array.shape, floor_plan.shape) + + # Fenestration positions should have non-zero values + self.assertGreater(q_sol_alpha_array[0, 2], 0.0) # exterior fenestration + self.assertGreater(q_sol_alpha_array[0, 3], 0.0) # exterior fenestration + self.assertGreater(q_sol_alpha_array[1, 2], 0.0) # interior fenestration + self.assertGreater(q_sol_alpha_array[1, 3], 0.0) # interior fenestration + + # Non-fenestration positions should be zero + self.assertEqual(q_sol_alpha_array[2, 2], 0.0) # air + self.assertEqual(q_sol_alpha_array[1, 1], 0.0) # wall + + # All fenestration nodes in same group should have same value + self.assertAlmostEqual( + q_sol_alpha_array[0, 2], q_sol_alpha_array[1, 3], places=5 + ) + + def test_calculate_solar_transmitted_for_fenestration_group(self): + """Test transmitted solar radiation calculation for a fenestration group.""" + floor_plan = np.array([ + [-1, -1, -42, -42, -1], + [-1, -3, -43, -43, -1], + [-1, -3, 0, 0, -1], + [-1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + group = list(fenestration_groups.values())[0] + + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + tau = 0.8 # transmittance + + total_q_sol_tau = utils.calculate_solar_transmitted_for_fenestration_group( + group, + irradiance_components, + solar_zenith=30.0, + solar_azimuth=180.0, + tau=tau, + ) + + # Should be positive + self.assertGreater(total_q_sol_tau, 0.0) + + # Calculate expected: G_Ts * tau * exterior_count + irr = solar_radiation.IrradianceComponents( + ghi=800.0, + dni=700.0, + dhi=100.0, + solar_zenith=30.0, + solar_azimuth=180.0, + ) + g_ts = solar_radiation.calculate_poa_irradiance( + irr, + group["phi"], + group["azimuth"], + 30.0, + 180.0, + ) + expected = g_ts * tau * group["exterior_count"] + self.assertAlmostEqual(total_q_sol_tau, expected, places=5) + + def test_net_solar_transmitted_heatflux_fenestration(self): + """Test transmitted solar radiation through fenestrations to air nodes.""" + floor_plan = np.array([ + [-1, -1, -42, -42, -1], + [-1, -3, -43, -43, -1], + [-1, -3, 0, 0, -1], + [-1, -1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + air_groups = utils.group_air_nodes( + floor_plan, fenestration_groups=fenestration_groups + ) + + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + + q_sol_tau_array = utils.net_solar_transmitted_heatflux_fenestration( + floor_plan, + fenestration_groups, + air_groups, + irradiance_components, + solar_zenith=30.0, + solar_azimuth=180.0, + tau=0.8, + ) + + # Check shape + self.assertEqual(q_sol_tau_array.shape, floor_plan.shape) + + # Air positions should have non-zero values + self.assertGreater(q_sol_tau_array[2, 2], 0.0) + self.assertGreater(q_sol_tau_array[2, 3], 0.0) + + # All air nodes in same group should have same value + self.assertAlmostEqual( + q_sol_tau_array[2, 2], q_sol_tau_array[2, 3], places=5 + ) + + # Non-air positions should be zero + self.assertEqual(q_sol_tau_array[0, 2], 0.0) # fenestration + self.assertEqual(q_sol_tau_array[1, 1], 0.0) # wall + + def test_net_solar_absorbed_heatflux_fenestration_no_fenestrations(self): + """Test that q_sol_alpha is zero when there are no fenestrations.""" + floor_plan = np.array([ + [-1, -1, -1, -1], + [-1, -3, -3, -1], + [-1, -3, 0, -1], + [-1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + self.assertEqual(len(fenestration_groups), 0) + + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + + q_sol_alpha_array = utils.net_solar_absorbed_heatflux_fenestration( + floor_plan, + fenestration_groups, + irradiance_components, + solar_zenith=30.0, + solar_azimuth=180.0, + ) + + # All zeros when no fenestrations + self.assertTrue(np.all(q_sol_alpha_array == 0.0)) + + def test_net_solar_transmitted_heatflux_fenestration_no_fenestrations(self): + """Test that q_sol_tau is zero when there are no fenestrations.""" + floor_plan = np.array([ + [-1, -1, -1, -1], + [-1, -3, -3, -1], + [-1, -3, 0, -1], + [-1, -1, -1, -1], + ]) + + fenestration_groups = utils.group_fenestrations(floor_plan) + air_groups = utils.group_air_nodes(floor_plan) + + irradiance_components = { + "ghi": 800.0, + "dni": 700.0, + "dhi": 100.0, + } + + q_sol_tau_array = utils.net_solar_transmitted_heatflux_fenestration( + floor_plan, + fenestration_groups, + air_groups, + irradiance_components, + solar_zenith=30.0, + solar_azimuth=180.0, + ) + + # All zeros when no fenestrations + self.assertTrue(np.all(q_sol_tau_array == 0.0)) + + def test_wrong_fenestration_floor_plan_1_interior_wall_connection(self): + """Test detection: fenestration connected to interior wall instead of air. + + In this floor plan, the left-side fenestration group at row 5 is connected + to interior wall (1) instead of air (0), breaking the proper connection + from exterior to indoor air. + """ + # fmt: off + wrong_fenestration_floor_plan_1 = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 4, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 4, 4, 4, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # fmt: on + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + wrong_fenestration_floor_plan_1, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + # Should detect that fenestration at row 5 is blocked by interior wall + self.assertIn("blocked", str(context.exception).lower()) + + def test_wrong_fenestration_floor_plan_2_blocked_by_interior_wall(self): + """Test detection: fenestration blocked by interior wall. + + In this floor plan, the fenestration at row 7 is blocked by an interior + wall at position (7,3), preventing proper connection to air. + """ + # fmt: off + wrong_fenestration_floor_plan_2 = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 4, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 1, 2], + [2, 4, 4, 1, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # fmt: on + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + wrong_fenestration_floor_plan_2, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + # Should detect blocked fenestration (blocked from air or not connected) + error_msg = str(context.exception).lower() + self.assertTrue( + "blocked" in error_msg or "not connected" in error_msg, + "Expected 'blocked' or 'not connected' in error message, got:" + f" {error_msg}", + ) + + def test_wrong_fenestration_floor_plan_3_not_exposed_to_outdoor(self): + """Test detection: fenestration not exposed to outdoor. + + In this floor plan, the fenestration at row 5 (cols 3-5) is completely + inside the building, surrounded by interior walls (1) and air (0), + with no exposure to exterior space (2) or array boundary. + """ + # fmt: off + wrong_fenestration_floor_plan_3 = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2], + [2, 1, 1, 0, 0, 0, 0, 0, 1, 0, 2], + [2, 1, 1, 0, 0, 0, 0, 0, 1, 0, 2], + [2, 1, 4, 4, 4, 4, 0, 0, 1, 0, 2], # fenestration inside building + [2, 1, 1, 0, 0, 0, 0, 0, 1, 0, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2], + [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # fmt: on + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + wrong_fenestration_floor_plan_3, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + # Should detect fenestration not exposed to exterior + self.assertIn("exterior", str(context.exception).lower()) + + def test_wrong_fenestration_floor_plan_4_partial_blockage(self): + """Test detection: part of fenestration blocked by interior wall. + + In this floor plan, the top fenestration at column 4-5 has a partial + blockage where part of the fenestration is connected to air while + another part is blocked by interior wall at (2,4). + """ + # fmt: off + wrong_fenestration_floor_plan_4 = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 4, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 4, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 1, 2], + [2, 4, 4, 4, 0, 0, 1, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # fmt: on + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + wrong_fenestration_floor_plan_4, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + # Should detect blocked or disconnected fenestration node + error_msg = str(context.exception).lower() + self.assertTrue( + "blocked" in error_msg or "disconnect" in error_msg, + "Expected 'blocked' or 'disconnect' in error message, got:" + f" {error_msg}", + ) + + def test_wrong_fenestration_floor_plan_5_fenestration_within_air(self): + """Test detection: fenestration node within air (surrounded by air). + + In this floor plan, the fenestration at position (6,6) is surrounded + by air nodes, meaning it's not properly connecting exterior to interior + (it's floating in the middle of the air space). + """ + # fmt: off + wrong_fenestration_floor_plan_5 = np.array([ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 4, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 0, 0, 1, 0, 0, 4, 2], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], + [2, 1, 1, 1, 1, 0, 0, 0, 0, 1, 2], + [2, 1, 1, 1, 0, 0, 4, 0, 0, 1, 2], + [2, 4, 4, 4, 0, 0, 0, 0, 0, 1, 2], + [2, 1, 1, 1, 4, 1, 1, 1, 4, 1, 2], + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + ]) + # fmt: on + + with self.assertRaises(ValueError) as context: + utils.validate_fenestration_connectivity( + wrong_fenestration_floor_plan_5, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + # Should detect fenestration surrounded by air + error_msg = str(context.exception).lower() + self.assertTrue( + "surrounded by air" in error_msg + or "not exposed to exterior" in error_msg, + "Expected 'surrounded by air' or 'not exposed to exterior' in error" + f" message, got: {error_msg}", + ) + + def test_get_exterior_wall_boundary_mask_with_ambient_air(self): + """Test exterior wall boundary mask with ambient air nodes. + + The algorithm identifies exterior walls NOT adjacent to enclosed interior + AIR spaces. Interior walls (value 1) are treated as solid material. + """ + # Test case: building with ambient air on the right, interior walls inside + # fmt: off + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2, -1], + [2, 1, 1, 1, 1, 1, 1, -1], + [2, 1, 1, 1, 1, 1, 2, -1], + [2, 1, 1, 1, 1, 1, 2, -1], + [2, 2, 2, 2, 2, 2, 2, -1], + ]) + # fmt: on + + # Expected: all exterior walls are marked (no interior AIR to exclude) + # Interior walls (1) are solid, not air; adjacent exterior walls marked + # fmt: off + expected = np.array([ + [True, True, True, True, True, True, True, False], + [True, False, False, False, False, False, False, False], + [True, False, False, False, False, False, True, False], + [True, False, False, False, False, False, True, False], + [True, True, True, True, True, True, True, False], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal( + result, + expected, + "Exterior wall boundary mask does not match expected with ambient air", + ) + + def test_get_exterior_wall_boundary_mask_thick_walls(self): + """Test exterior wall boundary mask with thick walls. + + When walls are multiple cells thick with interior walls (value 1) inside, + all exterior wall layers are marked since there's no interior AIR (0). + """ + # Test case: thick exterior walls with interior walls (1), no interior air + # fmt: off + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2, -1], + [2, 2, 2, 2, 2, 2, 2, -1], + [2, 2, 1, 1, 1, 2, 2, -1], + [2, 2, 2, 2, 2, 2, 2, -1], + [2, 2, 2, 2, 2, 2, 2, -1], + ]) + # fmt: on + + # Expected: all exterior walls marked (no interior air to exclude) + # Interior walls (1) don't block the exterior wall marking + # fmt: off + expected = np.array([ + [True, True, True, True, True, True, True, False], + [True, True, True, True, True, True, True, False], + [True, True, False, False, False, True, True, False], + [True, True, True, True, True, True, True, False], + [True, True, True, True, True, True, True, False], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal( + result, + expected, + "Exterior wall boundary mask does not match expected for thick walls", + ) + + def test_get_exterior_wall_boundary_mask_with_fenestration(self): + """Test exterior wall boundary mask with fenestration in walls. + + Fenestration (value 4) is not an exterior wall, so it's not marked. + Exterior walls around fenestration are still marked normally. + """ + # Test case: building with fenestration (4) in wall + # fmt: off + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2, -1], + [2, 1, 1, 1, 1, 1, 4, -1], + [2, 1, 1, 1, 1, 1, 2, -1], + [2, 1, 1, 1, 1, 1, 2, -1], + [2, 2, 2, 2, 2, 2, 2, -1], + ]) + # fmt: on + + # Expected: all exterior walls marked, fenestration (4) not marked + # fmt: off + expected = np.array([ + [True, True, True, True, True, True, True, False], + [True, False, False, False, False, False, False, False], + [True, False, False, False, False, False, True, False], + [True, False, False, False, False, False, True, False], + [True, True, True, True, True, True, True, False], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal( + result, + expected, + "Exterior wall boundary mask does not match with fenestration", + ) + + def test_get_exterior_wall_boundary_mask_enclosed_interior_air(self): + """Test exterior wall boundary mask with enclosed interior AIR space. + + When interior AIR (value 0) is enclosed by walls, exterior walls + adjacent to the enclosed air are NOT marked (they face inside). + """ + # Test case: building with enclosed interior AIR (courtyard, value 0) + # fmt: off + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 0, 0, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, -1, -1, -1, -1, -1, -1, -1], + ]) + # fmt: on + + # Expected: walls adjacent to enclosed air (0) are NOT marked + # fmt: off + expected = np.array([ + [False, False, False, False, False, False, False, False], + [False, True, True, True, True, True, True, False], + [False, True, True, False, False, True, True, False], + [False, True, False, False, False, False, True, False], + [False, True, True, False, False, True, True, False], + [False, True, True, True, True, True, True, False], + [False, False, False, False, False, False, False, False], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal( + result, + expected, + "Exterior wall boundary mask does not match for enclosed interior air", + ) + + def test_get_exterior_wall_boundary_mask_enclosed_interior_wall(self): + """Test exterior wall boundary mask with enclosed interior WALL space. + + When interior WALLS (value 1) are enclosed, they're treated as solid + material. All surrounding exterior walls are marked. + """ + # Test case: building with enclosed interior WALLS (value 1) + # fmt: off + floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1, -1, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 1, 1, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, 2, 2, 2, 2, 2, 2, -1], + [-1, -1, -1, -1, -1, -1, -1, -1], + ]) + # fmt: on + + # Expected: all exterior walls marked (interior walls are solid, not air) + # fmt: off + expected = np.array([ + [False, False, False, False, False, False, False, False], + [False, True, True, True, True, True, True, False], + [False, True, True, True, True, True, True, False], + [False, True, True, False, False, True, True, False], + [False, True, True, True, True, True, True, False], + [False, True, True, True, True, True, True, False], + [False, False, False, False, False, False, False, False], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal( + result, + expected, + "Exterior wall boundary mask does not match for enclosed interior" + " walls", + ) + + def test_get_exterior_wall_boundary_mask_no_ambient_air(self): + """Test when no ambient air nodes exist. + + When there are no ambient air nodes, exterior walls at array boundary + are marked, and walls adjacent to enclosed interior air are excluded. + """ + # Test case: no ambient air, exterior walls with interior walls + # fmt: off + floor_plan = np.array([ + [2, 2, 2, 2, 2], + [2, 1, 1, 1, 2], + [2, 1, 1, 1, 2], + [2, 2, 2, 2, 2], + ]) + # fmt: on + + # Expected: all exterior walls marked (no interior AIR to exclude) + # Interior walls (1) don't cause exclusion + # fmt: off + expected = np.array([ + [True, True, True, True, True], + [True, False, False, False, True], + [True, False, False, False, True], + [True, True, True, True, True], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal(result, expected) + + def test_get_exterior_wall_boundary_mask_no_ambient_with_interior_air(self): + """Test when no ambient air but has interior air. + + When there's no ambient air but interior air doesn't touch array edges, + exterior walls at array boundary are still marked. + """ + # Test case: no ambient air, exterior walls with interior air (0) inside + # Interior air doesn't touch array boundary + # fmt: off + floor_plan = np.array([ + [2, 2, 2, 2, 2], + [2, 0, 0, 0, 2], + [2, 0, 0, 0, 2], + [2, 2, 2, 2, 2], + ]) + # fmt: on + + # Expected: exterior walls at boundary are marked + # Interior walls adjacent to enclosed interior air are NOT marked + # But all these exterior walls are at array boundary, so all are marked + # fmt: off + expected = np.array([ + [True, True, True, True, True], + [True, False, False, False, True], + [True, False, False, False, True], + [True, True, True, True, True], + ]) + # fmt: on + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + np.testing.assert_array_equal(result, expected) + + def test_get_exterior_wall_boundary_mask_no_exterior_walls(self): + """Test with floor plan containing no exterior walls.""" + floor_plan = np.array([ + [-1, -1, -1], + [-1, 1, -1], + [-1, -1, -1], + ]) + + result = utils.get_exterior_wall_boundary_mask( + floor_plan, wall_value=2, exterior_space_value=-1 + ) + + # Should return all False + self.assertFalse(np.any(result)) + + def test_determine_exterior_wall_azimuth_array(self): + """Test azimuth determination for exterior wall boundaries. + + Walls are adjacent to exterior space (-1), not at array boundary. + Azimuth is based on which direction the exterior space is. + """ + # Create floor plan: -1 = exterior space, -3 = wall, 0 = interior air + # fmt: off + indexed_floor_plan = np.array([ + [-1, -1, -1, -1, -1], + [-1, -3, -3, -3, -1], + [-1, -3, 0, -3, -1], + [-1, -3, -3, -3, -1], + [-1, -1, -1, -1, -1], + ]) + # fmt: on + + # Walls at (1,1), (1,2), (1,3), (2,1), (2,3), (3,1), (3,2), (3,3) + # are adjacent to exterior space + exterior_wall_boundary_mask = indexed_floor_plan == -3 + + result = utils.determine_exterior_wall_azimuth_array( + exterior_wall_boundary_mask, + indexed_floor_plan, + exterior_space_value=-1, + ) + + # Check corners (intermediate angles) + self.assertEqual(result[1, 1], 315.0, "Top-left corner should be 315°") + self.assertEqual(result[1, 3], 45.0, "Top-right corner should be 45°") + self.assertEqual(result[3, 1], 225.0, "Bottom-left corner should be 225°") + self.assertEqual(result[3, 3], 135.0, "Bottom-right corner should be 135°") + + # Check edges (cardinal directions) + self.assertEqual(result[1, 2], 0.0, "Top edge should be 0° (North)") + self.assertEqual(result[3, 2], 180.0, "Bottom edge should be 180° (South)") + self.assertEqual(result[2, 1], 270.0, "Left edge should be 270° (West)") + self.assertEqual(result[2, 3], 90.0, "Right edge should be 90° (East)") + + # Check interior (should be 0, not a wall) + self.assertEqual(result[2, 2], 0.0, "Interior air should be 0") + + def test_determine_exterior_wall_azimuth_array_l_shape(self): + """Test azimuth determination for L-shaped building.""" + # L-shaped building: -1 = exterior space, -3 = wall, 0 = interior air + # fmt: off + indexed_floor_plan = np.array([ + [-1, -1, -1, -1, -1, -1], + [-1, -3, -3, -3, -3, -1], + [-1, -3, 0, 0, -3, -1], + [-1, -3, 0, 0, -3, -3], + [-1, -3, -3, -3, -3, -3], + [-1, -1, -1, -1, -1, -1], + ]) + # fmt: on + + exterior_wall_boundary_mask = indexed_floor_plan == -3 + + result = utils.determine_exterior_wall_azimuth_array( + exterior_wall_boundary_mask, + indexed_floor_plan, + exterior_space_value=-1, + ) + + # Check key positions + self.assertEqual(result[1, 1], 315.0, "Top-left corner") + self.assertEqual(result[1, 2], 0.0, "Top edge (not corner)") + self.assertEqual(result[1, 4], 45.0, "Top-right corner (before notch)") + self.assertEqual(result[2, 4], 90.0, "Right edge") + self.assertEqual(result[4, 5], 135.0, "Bottom-right corner") + self.assertEqual(result[4, 1], 225.0, "Bottom-left corner") + + def test_determine_exterior_wall_azimuth_with_orientation(self): + """Test azimuth determination with floor_plan_orientation offset.""" + # fmt: off + indexed_floor_plan = np.array([ + [-1, -1, -1, -1, -1], + [-1, -3, -3, -3, -1], + [-1, -3, 0, -3, -1], + [-1, -3, -3, -3, -1], + [-1, -1, -1, -1, -1], + ]) + # fmt: on + + exterior_wall_boundary_mask = indexed_floor_plan == -3 + + # With 90 degree orientation, all azimuths should be offset by 90 + result = utils.determine_exterior_wall_azimuth_array( + exterior_wall_boundary_mask, + indexed_floor_plan, + exterior_space_value=-1, + floor_plan_orientation=90.0, + ) + + # Top edge was 0, now should be 90 + self.assertEqual(result[1, 2], 90.0, "Top edge with 90° offset") + # Right edge was 90, now should be 180 + self.assertEqual(result[2, 3], 180.0, "Right edge with 90° offset") + # Bottom edge was 180, now should be 270 + self.assertEqual(result[3, 2], 270.0, "Bottom edge with 90° offset") + # Left edge was 270, now should be 360 -> 0 + self.assertEqual(result[2, 1], 0.0, "Left edge with 90° offset") + + # pylint: disable=protected-access + def test_validate_fenestration_chain_connectivity_all_reachable(self): + """Test chain connectivity passes for valid chain. + + A simple fenestration group where all nodes can reach both exterior + and interior air through the chain should not raise any errors. + """ + # Floor plan with fenestration bridging exterior to interior + # FILE_INPUT format: 0=air, 1=wall, 2=exterior, 4=fenestration + # All fenestration nodes at row 1 have air at row 2 (interior direction) + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 4, 4, 4, 4, 4, 2], + [2, 0, 0, 0, 0, 0, 2], + [2, 0, 0, 0, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + group_indices = [(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)] + nodes_adjacent_to_air = {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)} + nodes_adjacent_to_exterior = {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)} + + # Should not raise + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_group", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + + def test_validate_fenestration_chain_connectivity_node_blocked_from_exterior( + self, + ): + """Test chain connectivity detects node blocked from exterior. + + If a fenestration node cannot reach any exterior-adjacent node through + the fenestration chain, it should raise a ValueError. + """ + # Node (3, 1) is in the group but disconnected from exterior nodes + # (1, 1)-(1, 3) at row 1, since there's no 4-connected path within + # group_indices from (3, 1) to any of the row 1 nodes. + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 4, 4, 4, 0, 0, 2], + [2, 0, 0, 0, 0, 0, 2], + [2, 4, 0, 0, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + # Group includes nodes from row 1 AND the disconnected node at (3,1) + group_indices = [(1, 1), (1, 2), (1, 3), (3, 1)] + nodes_adjacent_to_air = {(1, 1), (1, 2), (1, 3), (3, 1)} + nodes_adjacent_to_exterior = {(1, 1), (1, 2), (1, 3)} # Row 1 nodes + + with self.assertRaises(ValueError) as context: + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_blocked_group", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + error_msg = str(context.exception).lower() + self.assertIn("blocked", error_msg) + self.assertIn("exterior", error_msg) + + def test_validate_fenestration_chain_connectivity_node_blocked_from_air(self): + """Test chain connectivity detects node blocked from air. + + If a fenestration node cannot reach any air-adjacent node through + the fenestration chain, it should raise a ValueError. + """ + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 4, 1, 4, 4, 4, 2], + [2, 1, 1, 1, 0, 0, 2], + [2, 1, 1, 1, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + # (1,1) is disconnected from the air-adjacent nodes (1,3), (1,4), (1,5) + # because (1,2) is a wall (value 1), breaking the chain + group_indices = [(1, 1), (1, 3), (1, 4), (1, 5)] + nodes_adjacent_to_air = {(1, 3), (1, 4), (1, 5)} # Only right side + nodes_adjacent_to_exterior = {(1, 1), (1, 3), (1, 4), (1, 5)} # All at top + + with self.assertRaises(ValueError) as context: + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_air_blocked_group", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + self.assertIn("blocked", str(context.exception).lower()) + self.assertIn("interior air", str(context.exception).lower()) + + def test_validate_fenestration_chain_connectivity_interior_direction_blocked( + self, + ): + """Test chain connectivity detects interior-facing blockage. + + When a fenestration node's interior-facing neighbor is not air or + fenestration (e.g., it's a wall), it should raise a ValueError about + interior-facing nodes being blocked. + """ + # Fenestration at top edge; interior direction is south (1, 0) + # Node (1, 3) has interior-facing neighbor (2, 3) = wall (1) + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 4, 4, 4, 4, 4, 2], + [2, 0, 0, 1, 0, 0, 2], + [2, 0, 0, 0, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + group_indices = [(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)] + nodes_adjacent_to_air = {(1, 1), (1, 2), (1, 4), (1, 5)} + nodes_adjacent_to_exterior = {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)} + + with self.assertRaises(ValueError) as context: + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_interior_blocked", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + error_msg = str(context.exception).lower() + self.assertIn("interior-facing", error_msg) + self.assertIn("blocked", error_msg) + + def test_validate_fenestration_chain_connectivity_thick_fenestration(self): + """Test chain connectivity with multi-layer fenestration. + + A fenestration group that is 2+ cells thick should pass if all nodes + can reach both exterior and interior through the chain. + """ + # 2-cell thick fenestration: row 1 is exterior-facing, row 2 is air-facing + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 4, 4, 4, 4, 4, 2], + [2, 4, 4, 4, 4, 4, 2], + [2, 0, 0, 0, 0, 0, 2], + [2, 1, 1, 1, 1, 1, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + group_indices = [ + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (2, 1), + (2, 2), + (2, 3), + (2, 4), + (2, 5), + ] + nodes_adjacent_to_air = {(2, 1), (2, 2), (2, 3), (2, 4), (2, 5)} + nodes_adjacent_to_exterior = {(1, 1), (1, 2), (1, 3), (1, 4), (1, 5)} + + # Should not raise - all nodes reachable through chain + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_thick_group", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + + def test_validate_fenestration_chain_connectivity_l_shaped(self): + """Test chain connectivity with L-shaped fenestration. + + An L-shaped fenestration group should pass if all nodes can reach + both exterior and interior through the connected chain. + """ + # L-shaped fenestration: top row exterior-facing, left column air-facing + # Interior direction is south (from top exterior edge) + # (1,3) south -> (2,3) = fenestration (in group) ✓ + # (1,4) south -> (2,4) = air (0) ✓ + # (1,5) south -> (2,5) = air (0) ✓ + # (2,3) south -> (3,3) = fenestration (in group) ✓ + # (3,3) south -> (4,3) = air (0) ✓ + floor_plan = np.array([ + [2, 2, 2, 2, 2, 2, 2], + [2, 1, 1, 4, 4, 4, 2], + [2, 1, 1, 4, 0, 0, 2], + [2, 1, 1, 4, 0, 0, 2], + [2, 1, 1, 0, 0, 0, 2], + [2, 2, 2, 2, 2, 2, 2], + ]) + + group_indices = [(1, 3), (1, 4), (1, 5), (2, 3), (3, 3)] + nodes_adjacent_to_air = {(2, 3), (3, 3)} + nodes_adjacent_to_exterior = {(1, 3), (1, 4), (1, 5)} + + # Should not raise - L-shaped chain connects all nodes + utils._validate_fenestration_chain_connectivity( + floor_plan=floor_plan, + group_name="test_l_shaped", + group_indices=group_indices, + nodes_adjacent_to_air=nodes_adjacent_to_air, + nodes_adjacent_to_exterior=nodes_adjacent_to_exterior, + fenestration_value=constants.FENESTRATION_VALUE_IN_FILE_INPUT, + air_value=constants.INTERIOR_SPACE_VALUE_IN_FILE_INPUT, + ) + if __name__ == "__main__": absltest.main() diff --git a/smart_control/simulator/solar_radiation_test.py b/smart_control/simulator/solar_radiation_test.py index 3bd5bef0..b6bf55c7 100644 --- a/smart_control/simulator/solar_radiation_test.py +++ b/smart_control/simulator/solar_radiation_test.py @@ -19,7 +19,6 @@ from pvlib import location from smart_control.proto import smart_control_building_pb2 -from smart_control.simulator import building_radiation_utils from smart_control.simulator import constants as sim_constants from smart_control.simulator import solar_radiation from smart_control.simulator import weather_controller @@ -483,31 +482,6 @@ def test_calculate_poa_irradiance(self): ) self.assertAlmostEqual(poa, float(poa_pvlib['poa_global']), places=4) - def test_calculate_poa_irradiance_building_radiation_utils(self): - """Backward-compat: building_radiation_utils.calculate_poa_irradiance.""" - irrad_components = solar_radiation.IrradianceComponents( - ghi=800.0, - dni=700.0, - dhi=100.0, - solar_zenith=30.0, - solar_azimuth=180.0, - ) - poa_sr = solar_radiation.calculate_poa_irradiance( - irradiance_components=irrad_components, - surface_tilt=30.0, - surface_azimuth=180.0, - solar_zenith=30.0, - solar_azimuth=180.0, - ) - poa_utils = building_radiation_utils.calculate_poa_irradiance( - irradiance_components=irrad_components, - surface_tilt=30.0, - surface_azimuth=180.0, - solar_zenith=30.0, - solar_azimuth=180.0, - ) - self.assertAlmostEqual(poa_sr, poa_utils, places=4) - def test_poa_with_clearsky_irradiance(self): """POA from clearsky SolarRadiation output matches pvlib.""" irrad = self.solar_radiation.get_current_irradiance(