diff --git a/honeybee_radiance_postprocess/ies/__init__.py b/honeybee_radiance_postprocess/ies/__init__.py new file mode 100644 index 00000000..48637f4d --- /dev/null +++ b/honeybee_radiance_postprocess/ies/__init__.py @@ -0,0 +1 @@ +"""honeybee-radiance-postprocess library.""" diff --git a/honeybee_radiance_postprocess/ies/lm.py b/honeybee_radiance_postprocess/ies/lm.py new file mode 100644 index 00000000..b411d34e --- /dev/null +++ b/honeybee_radiance_postprocess/ies/lm.py @@ -0,0 +1,214 @@ +"""Functions for IES LM post-processing.""" +from typing import Tuple, Union +from pathlib import Path +from collections import defaultdict +import json +import itertools +import numpy as np + +from ladybug.analysisperiod import AnalysisPeriod +from ladybug.datatype.generic import GenericType +from ladybug.color import Colorset +from ladybug.datacollection import HourlyContinuousCollection +from ladybug.datatype.fraction import Fraction +from ladybug.datatype.time import Time +from ladybug.legend import LegendParameters +from ladybug.header import Header +from honeybee.model import Model +from honeybee.units import conversion_factor_to_meters +from honeybee_radiance.writer import _filter_by_pattern +from honeybee_radiance.postprocess.annual import filter_schedule_by_hours + +from ..metrics import da_array2d, ase_array2d +from ..annual import schedule_to_hoys, occupancy_schedule_8_to_6 +from ..results.annual_daylight import AnnualDaylight +from ..util import filter_array, recursive_dict_merge +from ..dynamic import DynamicSchedule, ApertureGroupSchedule +from .lm_schedule import shd_trans_schedule_descending, states_schedule_descending + + +def shade_transmittance_per_light_path( + light_paths: list, shade_transmittance: Union[float, dict], + shd_trans_dict: dict) -> dict: + """Filter shade_transmittance by light paths and add default multiplier. + + Args: + light_paths: A list of light paths. + shade_transmittance: A value to use as a multiplier in place of solar + shading. This input can be either a single value that will be used + for all aperture groups, or a dictionary where aperture groups are + keys, and the value for each key is the shade transmittance. Values + for shade transmittance must be 1 > value > 0. + shd_trans_dict: A dictionary used to store shade transmittance value + for each aperture group. + + Returns: + A dictionary with filtered light paths. + """ + shade_transmittances = {} + if isinstance(shade_transmittance, dict): + for light_path in light_paths: + # default multiplier + shade_transmittances[light_path] = [1] + # add custom shade transmittance + if light_path in shade_transmittance: + shade_transmittances[light_path].append( + shade_transmittance[light_path]) + shd_trans_dict[light_path] = shade_transmittance[light_path] + # add default shade transmittance (0.02) + elif light_path != '__static_apertures__': + shade_transmittances[light_path].append(0.02) + shd_trans_dict[light_path] = 0.02 + else: + shade_transmittances[light_path].append(1) + shd_trans_dict[light_path] = 1 + else: + shd_trans = float(shade_transmittance) + for light_path in light_paths: + # default multiplier + shade_transmittances[light_path] = [1] + # add custom shade transmittance + if light_path != '__static_apertures__': + shade_transmittances[light_path].append(shd_trans) + shd_trans_dict[light_path] = shd_trans + else: + shade_transmittances[light_path].append(1) + shd_trans_dict[light_path] = 1 + + return shade_transmittances, shd_trans_dict + + +def dynamic_schedule_direct_illuminance( + results: Union[str, AnnualDaylight], grids_filter: str = '*', + shade_transmittance: Union[float, dict] = 0.02, + use_states: bool = False + ) -> Tuple[dict, dict]: + """Calculate a schedule of each aperture group. + + This function calculates an annual shading schedule of each aperture + group. Hour by hour it will select the least shaded aperture group + configuration, so that no more than 2% of the sensors points receive + direct illuminance of 1000 lux or more. + + Args: + results: Path to results folder or a Results class object. + grids_filter: The name of a grid or a pattern to filter the grids. + Defaults to '*'. + shade_transmittance: A value to use as a multiplier in place of solar + shading. This input can be either a single value that will be used + for all aperture groups, or a dictionary where aperture groups are + keys, and the value for each key is the shade transmittance. Values + for shade transmittance must be 1 > value > 0. + Defaults to 0.02. + use_states: A boolean to note whether to use the simulated states. Set + to True to use the simulated states. The default is False which will + use the shade transmittance instead. + + Returns: + Tuple: A tuple with a dictionary of the annual schedule and a + dictionary of hours where no shading configuration comply with the + 2% rule. + """ + if not isinstance(results, AnnualDaylight): + results = AnnualDaylight(results) + + grids_info = results._filter_grids(grids_filter=grids_filter) + schedule = occupancy_schedule_8_to_6(as_list=True) + occ_pattern = \ + filter_schedule_by_hours(results.sun_up_hours, schedule=schedule)[0] + occ_mask = np.array(occ_pattern) + + states_schedule = defaultdict(list) + fail_to_comply = {} + shd_trans_dict = {} + + for grid_info in grids_info: + grid_count = grid_info['count'] + light_paths = [lp[0] for lp in grid_info['light_path']] + + shade_transmittances, shd_trans_dict = ( + shade_transmittance_per_light_path( + light_paths, shade_transmittance, shd_trans_dict + ) + ) + + if len(light_paths) > 6: + if use_states: + states_schedule, fail_to_comply = states_schedule_descending( + results, grid_info, light_paths, occ_mask, + states_schedule, fail_to_comply) + else: + states_schedule, fail_to_comply = shd_trans_schedule_descending( + results, grid_info, light_paths, shade_transmittances, occ_mask, + states_schedule, fail_to_comply) + else: + if use_states: + combinations = results._get_state_combinations(grid_info) + else: + shade_transmittances, shd_trans_dict = shade_transmittance_per_light_path( + light_paths, shade_transmittance, shd_trans_dict) + keys, values = zip(*shade_transmittances.items()) + combinations = [dict(zip(keys, v)) for v in itertools.product(*values)] + + array_list_combinations = [] + for combination in combinations: + combination_arrays = [] + for light_path, value in combination.items(): + if use_states: + combination_arrays.append( + results._get_array(grid_info, light_path, state=value, + res_type='direct') + ) + else: + array = results._get_array( + grid_info, light_path, res_type='direct') + if value == 1: + combination_arrays.append(array) + else: + combination_arrays.append(array * value) + combination_array = sum(combination_arrays) + + combination_percentage = \ + (combination_array >= 1000).sum(axis=0) / grid_count + array_list_combinations.append(combination_percentage) + array_combinations = np.array(array_list_combinations) + array_combinations[array_combinations > 0.02] = -np.inf + + grid_comply = np.where(np.all(array_combinations==-np.inf, axis=0))[0] + if grid_comply.size != 0: + grid_comply = np.array(results.sun_up_hours)[grid_comply] + fail_to_comply[grid_info['name']] = \ + [int(hoy) for hoy in grid_comply] + + array_combinations_filter = np.apply_along_axis( + filter_array, 1, array_combinations, occ_mask + ) + max_indices = array_combinations_filter.argmax(axis=0) + # select the combination for each hour + combinations = [combinations[idx] for idx in max_indices] + # merge the combinations of dicts + for combination in combinations: + for light_path, value in combination.items(): + if light_path != '__static_apertures__': + states_schedule[light_path].append(value) + + occupancy_hoys = schedule_to_hoys(schedule, results.sun_up_hours) + + # map states to 8760 values + if use_states: + aperture_group_schedules = [] + for identifier, values in states_schedule.items(): + mapped_states = results.values_to_annual( + occupancy_hoys, values, results.timestep, dtype=np.int32) + aperture_group_schedules.append( + ApertureGroupSchedule(identifier, mapped_states.tolist()) + ) + states_schedule = \ + DynamicSchedule.from_group_schedules(aperture_group_schedules) + else: + for light_path, shd_trans in states_schedule.items(): + mapped_states = results.values_to_annual( + occupancy_hoys, shd_trans, results.timestep) + states_schedule[light_path] = mapped_states + + return states_schedule, fail_to_comply, shd_trans_dict diff --git a/honeybee_radiance_postprocess/ies/lm_schedule.py b/honeybee_radiance_postprocess/ies/lm_schedule.py new file mode 100644 index 00000000..963b7852 --- /dev/null +++ b/honeybee_radiance_postprocess/ies/lm_schedule.py @@ -0,0 +1,243 @@ +"""Module for dynamic LM schedules.""" +from typing import Tuple +import numpy as np + +from ..results.annual_daylight import AnnualDaylight +from ..util import filter_array + + +def shd_trans_schedule_descending( + results: AnnualDaylight, grid_info, light_paths, shade_transmittances, occ_mask, + states_schedule, fail_to_comply + ) -> Tuple[dict, dict]: + grid_count = grid_info['count'] + full_direct = [] + full_thresh = [] + full_shd_trans_array = [] + for light_path in light_paths: + array = results._get_array(grid_info, light_path, res_type="direct") + array = np.apply_along_axis(filter_array, 1, array, occ_mask) + full_direct.append(array) + full_thresh.append((array >= 1000).sum(axis=0)) + full_shd_trans_array.append(shade_transmittances[light_path][1]) + + # Sum the array element-wise. + # This array is the sum of all direct illuminance without shade + # transmittance. + full_direct_sum = sum(full_direct) + + # Create base list of shading combinations (all set to 1). + # We will replace the 1s later. + combinations = [ + {light_path: 1 for light_path in light_paths} + for i in range(full_direct_sum.shape[1]) + ] + + # Find the percentage of floor area >= 1000 lux. + # This array is the percentage for each hour (axis=0). + direct_pct_above = (full_direct_sum >= 1000).sum(axis=0) / grid_count + + # Find the indices where the percentage of floor area is > 2%. + # This array is the problematic hours. + above_2_indices = np.where(direct_pct_above > 0.02)[0] + + # Use the indices to get the relevant hours. + direct_sum = np.take(full_direct_sum, above_2_indices, axis=1) + + # Use the indices to get the relevant hours. + direct = np.take(full_direct, above_2_indices, axis=2) + + # Use the indices to get the relevant hours. + thresh = np.take(full_thresh, above_2_indices, axis=1) + + # Sort and get indices. Negate the array to get descending order. + # Descending order puts the "highest offender" light path first. + sort_thresh = np.argsort(-thresh, axis=0).transpose() + + _combinations = [] + _combinations.insert( + 0, (np.arange(full_direct_sum.shape[1]), combinations) + ) + + if np.any(above_2_indices): + # There are hours where the percentage of floor area is > 2%. + for idx, lp in enumerate(light_paths): + # Take column. For each iteration it will take the next column + # in descending order, i.e., the "highest offender" is the first + # column. + sort_indices = np.take(sort_thresh, idx, axis=1) + + # Map light path identifiers to indices. + light_path_ids = np.take(light_paths, sort_indices) + + # Map shade transmittance to indices. + shd_trans_array = np.take(full_shd_trans_array, sort_indices) + + # Create combination for the subset. + _subset_combination = [ + {light_path: _shd_trans} for light_path, _shd_trans in + zip(light_path_ids, shd_trans_array) + ] + _combinations.insert(0, (above_2_indices, _subset_combination)) + + # Take the values from each array by indexing. + direct_array = \ + direct[sort_indices, :, range(len(sort_indices))].transpose() + + # Subtract the illuminance values. + direct_sum = direct_sum - (direct_array * (1 - shd_trans_array)) + + # Find the percentage of floor area >= 1000 lux. + direct_pct_above = (direct_sum >= 1000).sum(axis=0) / grid_count + + # Find the indices where the percentage of floor area is > 2%. + above_2_indices = np.where(direct_pct_above > 0.02)[0] + + # Break if there are no hours above 2%. + if not np.any(above_2_indices): + break + + # Update variables for the next iteration. + direct_sum = np.take(direct_sum, above_2_indices, axis=1) + direct = np.take(direct, above_2_indices, axis=2) + thresh = np.take(thresh, above_2_indices, axis=1) + sort_thresh = np.take(sort_thresh, above_2_indices, axis=0) + + if np.any(above_2_indices): + # There are hours not complying with the 2% rule. + previous_indices = [] + previous_combination = [] + grid_comply = [] + # Merge the combinations from the iterations of the subsets. + for i, subset in enumerate(_combinations): + if i == 0: + previous_indices = subset[0] + else: + _indices = subset[0] + grid_comply = [] + for _pr_idx in previous_indices: + grid_comply.append(_indices[_pr_idx]) + previous_indices = grid_comply + # Convert indices to sun up hours indices. + filter_indices = np.where(occ_mask.astype(bool))[0] + grid_comply = [filter_indices[_gc] for _gc in grid_comply] + grid_comply = np.array(results.sun_up_hours)[grid_comply] + fail_to_comply[grid_info['name']] = \ + [int(hoy) for hoy in grid_comply] + + previous_indices = None + previous_combination = None + # Merge the combinations from the iterations of the subsets. + for i, subset in enumerate(_combinations): + if i == 0: + previous_indices, previous_combination = subset + else: + _indices, _combination = subset + for _pr_idx, _pr_comb in \ + zip(previous_indices, previous_combination): + for light_path, _shd_trans in _pr_comb.items(): + _combination[_pr_idx][light_path] = _shd_trans + previous_indices = _indices + previous_combination = _combination + + combinations = _combination + + # Merge the combinations of dicts. + for combination in combinations: + for light_path, shd_trans in combination.items(): + if light_path != "__static_apertures__": + states_schedule[light_path].append(shd_trans) + + return states_schedule, fail_to_comply + + +def states_schedule_descending( + results: AnnualDaylight, grid_info, light_paths, occ_mask, + states_schedule, fail_to_comply + ) -> Tuple[dict, dict]: + grid_count = grid_info['count'] + full_direct = [] + full_thresh = [] + full_direct_blinds = [] + for light_path in light_paths: + array = results._get_array( + grid_info, light_path, state=0, res_type="direct") + array = np.apply_along_axis(filter_array, 1, array, occ_mask) + full_direct.append(array) + full_thresh.append((array >= 1000).sum(axis=0)) + + array = results._get_array( + grid_info, light_path, state=1, res_type="direct") + array = np.apply_along_axis(filter_array, 1, array, occ_mask) + full_direct_blinds.append(array) + + full_direct = np.array(full_direct) + full_direct_blinds = np.array(full_direct_blinds) + full_direct_sum = full_direct.sum(axis=0) + + new_array = full_direct.copy() + + percentage_sensors = (full_direct_sum >= 1000).sum(axis=0) / grid_count + if not np.any(percentage_sensors > 0.02): + combinations = [ + {light_path: 0 for light_path in light_paths} + for i in range(full_direct_sum.shape[1])] + else: + tracking_array = np.zeros( + (new_array.shape[0], new_array.shape[2]), dtype=int) + + percentage_sensors = (full_direct >= 1000).sum(axis=1) / grid_count + + ranking_indices = np.argsort(-percentage_sensors, axis=0) + + for rank in range(ranking_indices.shape[0]): + # Calculate the percentage of sensors with values >= 1000 for the current new_array + summed_array = np.sum(new_array, axis=0) + percentage_sensors_summed = np.sum( + summed_array >= 1000, axis=0) / grid_count + indices_above_2_percent = np.where( + percentage_sensors_summed > 0.02)[0] + + # Exit if there are no more hours exceeding the threshold + if len(indices_above_2_percent) == 0: + break + + # Array indices to use for replacement for these hours + replace_indices = indices_above_2_percent + array_indices = ranking_indices[rank, replace_indices] + + # Use advanced indexing to replace values in new_array for these hours + for hour_idx, array_idx in zip(replace_indices, array_indices): + new_array[array_idx, :, hour_idx] = full_direct_blinds[ + array_idx, :, hour_idx + ] + + # Update the tracking array + tracking_array[array_indices, replace_indices] = 1 + + combinations = [] + for hour in range(new_array.shape[2]): + hour_dict = { + light_paths[i]: tracking_array[i, hour] + for i in range(tracking_array.shape[0])} + combinations.append(hour_dict) + + final_summed_array = np.sum(new_array, axis=0) + final_percentage_sensors_summed = ( + final_summed_array >= 1000).sum( + axis=0) / grid_count + final_indices_above_2_percent = np.where( + final_percentage_sensors_summed > 0.02)[0] + if np.any(final_indices_above_2_percent): + sun_up_hours_indices = np.where(occ_mask == 1)[0][ + final_indices_above_2_percent] + grid_comply = np.array(results.sun_up_hours)[sun_up_hours_indices] + fail_to_comply[grid_info['name']] = [ + int(hoy) for hoy in grid_comply] + + for combination in combinations: + for light_path, value in combination.items(): + if light_path != '__static_apertures__': + states_schedule[light_path].append(value) + + return states_schedule, fail_to_comply diff --git a/honeybee_radiance_postprocess/well/__init__.py b/honeybee_radiance_postprocess/well/__init__.py new file mode 100644 index 00000000..48637f4d --- /dev/null +++ b/honeybee_radiance_postprocess/well/__init__.py @@ -0,0 +1 @@ +"""honeybee-radiance-postprocess library.""" diff --git a/honeybee_radiance_postprocess/well/well.py b/honeybee_radiance_postprocess/well/well.py new file mode 100644 index 00000000..08fc8acf --- /dev/null +++ b/honeybee_radiance_postprocess/well/well.py @@ -0,0 +1,393 @@ +"""Functions for WELL post-processing.""" +from typing import Tuple, Union +from pathlib import Path +from collections import defaultdict +import json +import itertools +import numpy as np + +from ladybug.analysisperiod import AnalysisPeriod +from ladybug.datatype.generic import GenericType +from ladybug.color import Colorset +from ladybug.datacollection import HourlyContinuousCollection +from ladybug.datatype.fraction import Fraction +from ladybug.datatype.time import Time +from ladybug.legend import LegendParameters +from ladybug.header import Header +from honeybee.model import Model +from honeybee.units import conversion_factor_to_meters +from honeybee_radiance.writer import _filter_by_pattern +from honeybee_radiance.postprocess.annual import filter_schedule_by_hours + +from ..metrics import da_array2d +from ..annual import schedule_to_hoys, occupancy_schedule_8_to_6 +from ..results.annual_daylight import AnnualDaylight +from ..util import filter_array, recursive_dict_merge +from ..dynamic import DynamicSchedule, ApertureGroupSchedule +from ..ies.lm import dynamic_schedule_direct_illuminance + + +def _create_grid_summary( + grid_info, sda_grid, sda_blinds_up_grid, sda_blinds_down_grid, pass_sda, + total_floor, area_weighted=True): + """Create a WELL summary for a single grid. + + Args: + grid_info: Grid information. + sda_grid: Spatial Daylight Autonomy. + pass_sda: The percentage of the sensor points or floor area that + passes sDA. + total_floor: The number of sensor points or floor area. + area_weighted: Boolean to determine if the results are area + weighted. Defaults to True. + + Returns: + Tuple: + - summary_grid: Summary of each grid individually. + """ + grid_id = grid_info['full_id'] + grid_name = grid_info['name'] + grid_summary = { + grid_id: {} + } + + if area_weighted: + _grid_summary = { + grid_id: { + 'name': grid_name, + 'full_id': grid_id, + 'sda': round(sda_grid, 2), + 'sda_blinds_up': round(sda_blinds_up_grid, 2), + 'sda_blinds_down': round(sda_blinds_down_grid, 2), + 'floor_area_passing_sda': round(pass_sda, 2), + 'total_floor_area': round(total_floor, 2) + } + } + else: + _grid_summary = { + grid_id: { + 'name': grid_name, + 'full_id': grid_id, + 'sda': round(sda_grid, 2), + 'sda_blinds_up': round(sda_blinds_up_grid, 2), + 'sda_blinds_down': round(sda_blinds_down_grid, 2), + 'sensor_count_passing_sda': int(round(pass_sda, 2)), + 'total_sensor_count': total_floor + } + } + + recursive_dict_merge(grid_summary, _grid_summary) + + return grid_summary + + +def _well_summary( + pass_sda_grids: list, grids_info: list, + grid_areas: list, pass_sda_blinds_up_grids: list, + pass_sda_blinds_down_grids: list) -> Tuple[dict, dict]: + """Create combined summary and summary for each grid individually. + + Args: + pass_sda_grids: A list where each sublist is a list of True/False that + tells if each sensor point passes sDA. + grids_info: A list of grid information. + grid_areas: A list where each sublist is the area of each sensor point. + The alternative is a list of None values for each grid information. + + Returns: + Tuple: + - summary: Summary of of all grids combined. + - summary_grid: Summary of each grid individually. + """ + summary = {} + summary_grid = {} + + if all(grid_area is not None for grid_area in grid_areas): + # weighted by mesh face area + total_area = 0 + total_area_pass_sda = 0 + for (pass_sda, grid_area, grid_info, pass_sda_blinds_up, + pass_sda_blinds_down) in \ + zip(pass_sda_grids, grid_areas, grids_info, + pass_sda_blinds_up_grids, pass_sda_blinds_down_grids): + total_grid_area = grid_area.sum() + + area_pass_sda = grid_area[pass_sda].sum() + area_pass_sda_blind_up = grid_area[pass_sda_blinds_up].sum() + area_pass_sda_blinds_down = grid_area[pass_sda_blinds_down].sum() + sda_grid = area_pass_sda / total_grid_area * 100 + sda_blinds_up_grid = area_pass_sda_blind_up / total_grid_area * 100 + sda_blinds_down_grid = area_pass_sda_blinds_down / total_grid_area * 100 + + # grid summary + grid_summary = \ + _create_grid_summary( + grid_info, sda_grid, sda_blinds_up_grid, sda_blinds_down_grid, + area_pass_sda, total_grid_area, area_weighted=True + ) + + recursive_dict_merge(summary_grid, grid_summary) + + total_area += total_grid_area + total_area_pass_sda += area_pass_sda + + summary['sda'] = round(total_area_pass_sda / total_area * 100, 2) + summary['floor_area_passing_sda'] = total_area_pass_sda + summary['total_floor_area'] = total_area + else: + # assume all sensor points cover the same area + total_sensor_count = 0 + total_sensor_count_pass_sda = 0 + for (pass_sda, grid_info, pass_sda_blinds_up, pass_sda_blinds_down) in \ + zip(pass_sda_grids, grids_info, pass_sda_blinds_up_grids, + pass_sda_blinds_down_grids): + grid_count = grid_info['count'] + + sensor_count_pass_sda = pass_sda.sum() + sensor_count_pass_sda_blinds_up = pass_sda_blinds_up.sum() + sensor_count_pass_sda_blinds_down = pass_sda_blinds_down.sum() + sda_grid = sensor_count_pass_sda / grid_count * 100 + sda_blinds_up_grid = sensor_count_pass_sda_blinds_up / grid_count * 100 + sda_blinds_down_grid = sensor_count_pass_sda_blinds_down / grid_count * 100 + + # grid summary + grid_summary = \ + _create_grid_summary( + grid_info, sda_grid, sda_blinds_up_grid, sda_blinds_down_grid, + sensor_count_pass_sda, grid_count, area_weighted=False + ) + + recursive_dict_merge(summary_grid, grid_summary) + + total_sensor_count += grid_count + total_sensor_count_pass_sda += sensor_count_pass_sda + + summary['sda'] = round(total_sensor_count_pass_sda / total_sensor_count * 100, 2) + summary['sensor_count_passing_sda'] = int(total_sensor_count_pass_sda) + summary['total_sensor_count'] = total_sensor_count + + return summary, summary_grid + + +def well_annual_daylight( + results: Union[str, AnnualDaylight], grids_filter: str = '*', + shade_transmittance: Union[float, dict] = 0.05, + use_states: bool = False, states_schedule: dict = None, + threshold: float = 300, target_time: float = 50, sub_folder: str = None): + """Calculate credits for WELL L06. + + Args: + results: Path to results folder or a Results class object. + grids_filter: The name of a grid or a pattern to filter the grids. + Defaults to '*'. + shade_transmittance: A value to use as a multiplier in place of solar + shading. This input can be either a single value that will be used + for all aperture groups, or a dictionary where aperture groups are + keys, and the value for each key is the shade transmittance. Values + for shade transmittance must be 1 > value > 0. + Defaults to 0.05. + use_states: A boolean to note whether to use the simulated states. Set + to True to use the simulated states. The default is False which will + use the shade transmittance instead. + states_schedule: A custom dictionary of shading states. In case this is + left empty, the function will calculate a shading schedule by using + the shade_transmittance input. If a states schedule is provided it + will check that it is complying with the 2% rule. Defaults to None. + threshold: Threshold value for daylight autonomy. Default: 300. + target_time: A minimum threshold of occupied time (eg. 50% of the + time), above which a given sensor passes and contributes to the + spatial daylight autonomy. Defaults to 50. + sub_folder: Relative path for a subfolder to write the output. If None, + the files will not be written. Defaults to None. + + Returns: + Tuple: + - summary: Summary of all grids combined. + - summary_grid: Summary of each grid individually. + - da_grids: List of daylight autonomy values for each grid. Each item + in the list is a NumPy array of DA values. + - states_schedule: A dictionary of annual shading schedules for each + aperture group. + - grids_info: Grid information. + """ + schedule = occupancy_schedule_8_to_6(as_list=True) + + if not isinstance(results, AnnualDaylight): + results = AnnualDaylight(results, schedule=schedule) + else: + # set schedule to default leed schedule + results.schedule = schedule + + occ_mask = results.occ_mask + total_occ = results.total_occ + + grids_info = results._filter_grids(grids_filter=grids_filter) + + if not states_schedule: + states_schedule, fail_to_comply, shd_trans_dict = dynamic_schedule_direct_illuminance( + results, grids_filter=grids_filter, shade_transmittance=shade_transmittance, use_states=use_states) + else: + raise NotImplementedError( + 'Custom input for argument states_schedule is not yet implemented.' + ) + + # check to see if there is a HBJSON with sensor grid meshes for areas + grid_areas, units_conversion = [], 1 + for base_file in Path(results.folder).parent.iterdir(): + if base_file.suffix in ('.hbjson', '.hbpkl'): + hb_model = Model.from_file(base_file) + units_conversion = conversion_factor_to_meters(hb_model.units) + filt_grids = _filter_by_pattern( + hb_model.properties.radiance.sensor_grids, filter=grids_filter) + for s_grid in filt_grids: + if s_grid.mesh is not None: + grid_areas.append(s_grid.mesh.face_areas) + grid_areas = [np.array(grid) for grid in grid_areas] + if not grid_areas: + grid_areas = [None] * len(grids_info) + + # spatial daylight autonomy + da_grids = [] + pass_sda_grids = [] + pass_sda_blinds_up_grids = [] + pass_sda_blinds_down_grids = [] + for grid_info in grids_info: + light_paths = [lp[0] for lp in grid_info['light_path']] + base_zero_array = np.apply_along_axis(filter_array, 1, np.zeros( + (grid_info['count'], len(results.sun_up_hours))), occ_mask) + arrays = [base_zero_array.copy()] + arrays_blinds_up = [base_zero_array.copy()] + arrays_blinds_down = [base_zero_array.copy()] + # combine total array for all light paths + if use_states: + array = results._array_from_states(grid_info, states=states_schedule, zero_array=True) + array = np.apply_along_axis(filter_array, 1, array, occ_mask) + + for light_path in light_paths: + # do an extra pass to calculate with blinds always up or down + if light_path != '__static_apertures__': + array_blinds_up = results._get_array( + grid_info, light_path, state=0, res_type='total') + array_filter = np.apply_along_axis( + filter_array, 1, array_blinds_up, occ_mask) + arrays_blinds_up.append(array_filter) + array_blinds_down = results._get_array( + grid_info, light_path, state=1, res_type='total') + array_filter = np.apply_along_axis( + filter_array, 1, array_blinds_down, occ_mask) + arrays_blinds_down.append(array_filter) + else: + static_array = results._get_array( + grid_info, light_path, state=0, res_type='total') + array_filter = np.apply_along_axis( + filter_array, 1, static_array, occ_mask) + arrays_blinds_up.append(array_filter) + arrays_blinds_down.append(array_filter) + else: + for light_path in light_paths: + array = results._get_array( + grid_info, light_path, res_type='total') + array_filter = np.apply_along_axis( + filter_array, 1, array, occ_mask) + if light_path != '__static_apertures__': + sun_up_hours = np.array(results.sun_up_hours).astype(int) + shd_trans_array = states_schedule[light_path][sun_up_hours] + shd_trans_array = shd_trans_array[occ_mask.astype(bool)] + arrays.append(array_filter * shd_trans_array) + arrays_blinds_up.append(array_filter) + arrays_blinds_down.append( + array_filter * shd_trans_dict[light_path]) + else: + arrays.append(array_filter) + arrays_blinds_up.append(array_filter) + arrays_blinds_down.append(array_filter) + array = sum(arrays) + + array_blinds_up = sum(arrays_blinds_up) + array_blinds_down = sum(arrays_blinds_down) + # calculate da per grid + da_grid = da_array2d(array, total_occ=total_occ, threshold=threshold) + da_grids.append(da_grid) + da_blinds_up_grid = da_array2d( + array_blinds_up, total_occ=total_occ, threshold=threshold) + da_blinds_down_grid = da_array2d( + array_blinds_down, total_occ=total_occ, threshold=threshold) + # calculate sda per grid + pass_sda_grids.append(da_grid >= target_time) + pass_sda_blinds_up_grids.append(da_blinds_up_grid >= target_time) + pass_sda_blinds_down_grids.append(da_blinds_down_grid >= target_time) + + # create summaries for all grids and each grid individually + summary, summary_grid = _well_summary( + pass_sda_grids, grids_info, grid_areas, + pass_sda_blinds_up_grids, pass_sda_blinds_down_grids) + + # credits + if not fail_to_comply: + if summary['sda'] >= 75: + summary['credits'] = 2 + elif summary['sda'] >= 55: + summary['credits'] = 1 + else: + summary['credits'] = 0 + else: + summary['credits'] = 0 + fail_to_comply_rooms = ', '.join(list(fail_to_comply.keys())) + note = ( + '0 credits have been awarded. The following sensor grids have at ' + 'least one hour where 2% of the floor area receives direct ' + f'illuminance of 1000 lux or more: {fail_to_comply_rooms}.' + ) + summary['note'] = note + + # convert to datacollection + def to_datacollection(aperture_group: str, values: np.ndarray): + # convert values to 0 and 1 (0 = no shading, 1 = shading) + if use_states: + header = Header(data_type=GenericType(aperture_group, ''), unit='', + analysis_period=AnalysisPeriod()) + hourly_data = HourlyContinuousCollection(header=header, values=values) + else: + values[values == 1] = 0 + values[values == shd_trans_dict[aperture_group]] = 1 + header = Header(data_type=GenericType(aperture_group, ''), unit='', + analysis_period=AnalysisPeriod(), + metadata={'Shade Transmittance': shd_trans_dict[aperture_group]}) + hourly_data = HourlyContinuousCollection(header=header, values=values.tolist()) + return hourly_data.to_dict() + + if use_states: + states_schedule = {k:to_datacollection(k, v['schedule']) for k, v in states_schedule.to_dict().items()} + else: + states_schedule = {k:to_datacollection(k, v) for k, v in states_schedule.items()} + + if sub_folder: + folder = Path(sub_folder) + folder.mkdir(parents=True, exist_ok=True) + + summary_file = folder.joinpath('summary.json') + summary_file.write_text(json.dumps(summary, indent=2)) + summary_grid_file = folder.joinpath('summary_grid.json') + summary_grid_file.write_text(json.dumps(summary_grid, indent=2)) + states_schedule_file = folder.joinpath('states_schedule.json') + states_schedule_file.write_text(json.dumps(states_schedule)) + grids_info_file = folder.joinpath('grids_info.json') + grids_info_file.write_text(json.dumps(grids_info, indent=2)) + + for (da, grid_info) in \ + zip(da_grids, grids_info): + grid_id = grid_info['full_id'] + da_file = folder.joinpath('results', 'da', f'{grid_id}.da') + da_file.parent.mkdir(parents=True, exist_ok=True) + np.savetxt(da_file, da, fmt='%.2f') + + da_grids_info_file = folder.joinpath( + 'results', 'da', 'grids_info.json') + da_grids_info_file.write_text(json.dumps(grids_info, indent=2)) + + states_schedule_err_file = \ + folder.joinpath('states_schedule_err.json') + states_schedule_err_file.write_text(json.dumps(fail_to_comply)) + + return (summary, summary_grid, da_grids, states_schedule, + fail_to_comply, grids_info)