"""Functions for EN 17037 post-processing."""
from typing import Union
from pathlib import Path
import json
from ladybug.color import Colorset
from ladybug.datatype.fraction import Fraction
from ladybug.legend import LegendParameters
from honeybee.model import Model
from . import np
from .results.annual_daylight import AnnualDaylight
from .dynamic import DynamicSchedule
from .metrics import da_array2d
from .util import filter_array
EN17037_RECOMMENDATIONS = {
'Minimum Illuminance': {
'minimum': 100,
'medium': 300,
'high': 500,
},
'Target Illuminance': {
'minimum': 300,
'medium': 500,
'high': 750,
},
}
EN17037_COMPLIANCE_VALUE = {
'minimum': 1,
'medium': 2,
'high': 3,
}
EN17037_SPACE_TARGET = {
'Minimum Illuminance': 95,
'Target Illuminance': 50,
}
EN17037_CRITERION_LABELS = {
('Minimum Illuminance', 'minimum'): 'Minimum Illuminance 100',
('Minimum Illuminance', 'medium'): 'Minimum Illuminance 300',
('Minimum Illuminance', 'high'): 'Minimum Illuminance 500',
('Target Illuminance', 'minimum'): 'Target Illuminance 300',
('Target Illuminance', 'medium'): 'Target Illuminance 500',
('Target Illuminance', 'high'): 'Target Illuminance 750',
}
[docs]
def en17037_compute(array: np.ndarray, grid_info: dict, area_array: np.ndarray = None) -> dict:
"""Compute EN 17037 metrics for a 2D NumPy array.
Args:
array: A 2D NumPy array (sensors x occupied hours).
grid_info: A grid information dictionary that must contain at least
full_id and count.
area_array: An optional 1D NumPy array containing the face areas
corresponding to each sensor point.
Returns:
dict -- Nested result dictionary with the following structure::
{
'grid_id': str,
'grid_count': int,
'target_types': {
'<target_type>': {
'compliance_level': np.ndarray,
'levels': {
'<level>': {
'threshold': int,
'da': np.ndarray,
'sda': float,
'passes': bool,
},
},
},
},
}
"""
grid_id = grid_info['full_id']
grid_count = grid_info['count']
result = {
'grid_id': grid_id,
'grid_count': grid_count,
'target_types': {},
}
for target_type, thresholds in EN17037_RECOMMENDATIONS.items():
space_target = EN17037_SPACE_TARGET[target_type]
compliance_level = None
levels = {}
for level, threshold in thresholds.items():
da = da_array2d(array, total_occ=4380, threshold=threshold)
pass_mask = da >= 50
if area_array is not None and len(area_array) == grid_count:
total_area = area_array.sum()
sda = float((area_array[pass_mask].sum() / total_area) * 100) if total_area > 0 else 0.0
else:
sda = float(pass_mask.mean() * 100)
passes = sda >= space_target
if passes:
compliance_level = np.full(
(grid_count,), EN17037_COMPLIANCE_VALUE[level], dtype=int)
levels[level] = {
'threshold': threshold,
'da': da,
'sda': sda,
'passes': passes,
}
if compliance_level is None:
compliance_level = np.zeros(grid_count, dtype=int)
result['target_types'][target_type] = {
'compliance_level': compliance_level,
'levels': levels,
}
return result
[docs]
def en17037_to_files(
array: np.ndarray, metrics_folder: Path, grid_info: dict,
area_array: np.ndarray = None) -> dict:
"""Compute EN 17037 metrics and write the results to metrics_folder.
Args:
array: A 2D NumPy array.
metrics_folder: Output folder. Created if it does not exist.
grid_info: A grid information dictionary.
area_array: A 2D NumPy array representing the area of each sensor.
Returns:
dict -- The result dictionary.
"""
metrics_folder = Path(metrics_folder)
results = en17037_compute(array, grid_info, area_array=area_array)
grid_id = results['grid_id']
da_folder = metrics_folder / 'da'
sda_folder = metrics_folder / 'sda'
compliance_folder = metrics_folder / 'compliance_level'
for target_type, target_data in results['target_types'].items():
for level, level_data in target_data['levels'].items():
threshold = level_data['threshold']
folder_name = f'{target_type} {threshold}'
da_level_folder = da_folder / folder_name
da_level_folder.mkdir(parents=True, exist_ok=True)
da_file = da_level_folder / f'{grid_id}.da'
np.savetxt(da_file, level_data['da'], fmt='%.2f')
sda_level_folder = sda_folder / folder_name
sda_level_folder.mkdir(parents=True, exist_ok=True)
sda_file = sda_level_folder / f'{grid_id}.sda'
sda_file.write_text(str(round(level_data['sda'], 2)))
compliance_level_folder = compliance_folder / target_type
compliance_level_folder.mkdir(parents=True, exist_ok=True)
compliance_level_file = compliance_level_folder / f'{grid_id}.pf'
np.savetxt(compliance_level_file, target_data['compliance_level'], fmt='%i')
return results
[docs]
def en17037_to_folder(
results: Union[str, AnnualDaylight], schedule: list,
states: DynamicSchedule = None, grids_filter: str = '*',
sub_folder: str = 'en17037') -> Path:
"""Compute annual EN 17037 metrics in a folder and write them in a subfolder.
The results is an output folder of annual daylight recipe.
Args:
results: Results folder.
schedule: An annual schedule for 8760 hours of the year as a list of
values. This should be a daylight hours schedule.
states: A dictionary of states. Defaults to None.
grids_filter: A pattern to filter the grids. By default all the grids
will be processed.
sub_folder: An optional relative path for the subfolder where results
are written. Default: en17037.
Returns:
Path -- Path to the results folder.
"""
if not isinstance(results, AnnualDaylight):
results = AnnualDaylight(results, schedule=schedule)
else:
results.schedule = schedule
total_occ = results.total_occ
occ_mask = results.occ_mask
grids_info = results._filter_grids(grids_filter=grids_filter)
sub_folder = Path(sub_folder)
if total_occ != 4380:
raise ValueError(
f'There are {total_occ} occupied hours in the schedule. According '
'to EN 17037 the schedule must consist of the daylight hours '
'which is defined as the half of the year with the largest '
'quantity of daylight'
)
grid_mesh_dict = {}
for base_file in Path(results.folder).parent.iterdir():
if base_file.suffix in ('.hbjson', '.hbpkl'):
hb_model = Model.from_file(base_file)
for s_grid in hb_model.properties.radiance.sensor_grids:
if s_grid.mesh is not None:
grid_mesh_dict[s_grid.identifier] = np.array(s_grid.mesh.face_areas)
break
all_grid_results = []
all_output_folders: list[Path] = []
for grid_info in grids_info:
area_array = grid_mesh_dict.get(grid_info['full_id'], None)
array = results._array_from_states(
grid_info, states=states, res_type='total', zero_array=True)
if np.any(array):
array = np.apply_along_axis(filter_array, 1, array, occ_mask)
grid_results = en17037_to_files(array, sub_folder, grid_info, area_array=area_array)
all_grid_results.append((grid_info, grid_results, area_array))
for target_type, target_data in grid_results['target_types'].items():
for level, level_data in target_data['levels'].items():
threshold = level_data['threshold']
folder_name = f'{target_type} {threshold}'
all_output_folders.append(sub_folder / 'da' / folder_name)
all_output_folders.append(sub_folder / 'sda' / folder_name)
all_output_folders.append(
sub_folder / 'compliance_level' / target_type)
seen = set()
for folder in all_output_folders:
if folder in seen:
continue
seen.add(folder)
grids_info_file = folder / 'grids_info.json'
with open(grids_info_file, 'w') as outf:
json.dump(grids_info, outf, indent=2)
metric_info_dict = _annual_daylight_en17037_vis_metadata()
da_folder = sub_folder / 'da'
for metric, data in metric_info_dict.items():
file_path = da_folder / metric / 'vis_metadata.json'
with open(file_path, 'w') as fp:
json.dump(data, fp, indent=4)
# Writes individual grid summary
_write_en17037_summary_grid(all_grid_results, sub_folder)
# Writes the combined weighted summary
_write_en17037_summary(all_grid_results, sub_folder)
return sub_folder
def _build_grid_summary(
grid_info: dict, grid_results: dict, area_array: np.ndarray = None) -> dict:
"""Build the summary dict for a single grid.
Args:
grid_info: The grid information dictionary for this grid.
grid_results: The result dict returned by en17037_compute for
the same grid.
area_array: A 2D NumPy array representing the area of each sensor.
Returns:
dict -- Summary with the grid's display_name and a boolean
passes entry for every EN 17037 criterion.
"""
if area_array is not None:
total_weight = float(area_array.sum())
weight_label = 'Total Floor Area'
else:
total_weight = grid_info['count']
weight_label = 'Total Sensors'
data = {
'Sensor Grid': grid_info.get('display_name', grid_results['grid_id']),
weight_label: round(total_weight, 2) if area_array is not None else total_weight
}
for target_type, target_data in grid_results['target_types'].items():
for level, level_data in target_data['levels'].items():
label = EN17037_CRITERION_LABELS[(target_type, level)]
data[label] = {
'sDA': round(level_data['sda'], 2),
'passes': level_data['passes'],
}
return data
def _write_en17037_summary_grid(
all_grid_results: list, sub_folder: Path) -> Path:
"""Write summary_grid.json to sub_folder.
The file contains one object per grid with the grid's display_name
and a pass/fail boolean for each EN 17037 criterion.
Args:
all_grid_results: A list of (grid_info, grid_results, area_array) tuples as
collected by en17037_to_folder.
sub_folder: The root output folder.
Returns:
Path -- Path to the written summary_grid.json file.
"""
summary = [
_build_grid_summary(grid_info, grid_results, area_array)
for grid_info, grid_results, area_array in all_grid_results
]
summary_file = sub_folder / 'summary_grid.json'
sub_folder.mkdir(parents=True, exist_ok=True)
with open(summary_file, 'w') as fp:
json.dump(summary, fp, indent=2)
return summary_file
def _write_en17037_summary(
all_grid_results: list, sub_folder: Path) -> Path:
"""Write summary.json to sub_folder with weighted sDA and area breakdowns."""
# check if all grids contain valid mesh face area arrays
has_mesh = all(area_array is not None for _, _, area_array in all_grid_results)
if has_mesh:
total_weight = sum(float(area_array.sum()) for _, _, area_array in all_grid_results)
weight_label = 'Total Floor Area'
else:
total_weight = sum(grid_info['count'] for grid_info, _, _ in all_grid_results)
weight_label = 'Total Sensors'
summary = {
weight_label: round(total_weight, 2) if has_mesh else total_weight
}
# extract structural categories from the first available grid result
_, first_grid_results, _ = all_grid_results[0]
for target_type, target_data in first_grid_results['target_types'].items():
space_target = EN17037_SPACE_TARGET[target_type]
# compute overall weighted sDA criteria
for level in target_data['levels'].keys():
label = EN17037_CRITERION_LABELS[(target_type, level)]
total_weighted_sda = 0.0
for grid_info, grid_results, area_array in all_grid_results:
weight = float(area_array.sum()) if has_mesh else grid_info['count']
sda = grid_results['target_types'][target_type]['levels'][level]['sda']
total_weighted_sda += sda * weight
weighted_sda = total_weighted_sda / total_weight if total_weight > 0 else 0.0
passes = weighted_sda >= space_target
summary[label] = {
'sDA': round(weighted_sda, 2),
'passes': passes
}
# calculate exclusive area breakdown based on individual sensor point DA
high_weight = 0.0
medium_weight = 0.0
minimum_weight = 0.0
fail_weight = 0.0
for grid_info, grid_results, area_array in all_grid_results:
levels = grid_results['target_types'][target_type]['levels']
da_high = levels['high']['da']
da_medium = levels['medium']['da']
da_minimum = levels['minimum']['da']
# a sensor satisfies the threshold tier if its DA is >= 50%
pass_high = da_high >= 50
pass_medium = da_medium >= 50
pass_minimum = da_minimum >= 50
# group into exclusive, non-overlapping bins
mask_high = pass_high
mask_medium = pass_medium & ~pass_high
mask_minimum = pass_minimum & ~pass_medium
mask_fail = ~pass_minimum
if has_mesh:
high_weight += float(area_array[mask_high].sum())
medium_weight += float(area_array[mask_medium].sum())
minimum_weight += float(area_array[mask_minimum].sum())
fail_weight += float(area_array[mask_fail].sum())
else:
high_weight += int(np.sum(mask_high))
medium_weight += int(np.sum(mask_medium))
minimum_weight += int(np.sum(mask_minimum))
fail_weight += int(np.sum(mask_fail))
# extract threshold lux integers for dynamic labeling
thresh_high = EN17037_RECOMMENDATIONS[target_type]['high']
thresh_med = EN17037_RECOMMENDATIONS[target_type]['medium']
thresh_min = EN17037_RECOMMENDATIONS[target_type]['minimum']
breakdown_suffix = "Area Percentage" if has_mesh else "Sensor Percentage"
breakdown_key = f"{target_type} {breakdown_suffix}"
if total_weight > 0:
summary[breakdown_key] = {
f'High (>= {thresh_high} lux)': round((high_weight / total_weight) * 100, 2),
f'Medium ({thresh_med} - {thresh_high} lux)': round((medium_weight / total_weight) * 100, 2),
f'Minimum ({thresh_min} - {thresh_med} lux)': round((minimum_weight / total_weight) * 100, 2),
f'Fail (< {thresh_min} lux)': round((fail_weight / total_weight) * 100, 2)
}
else:
summary[breakdown_key] = {
'High': 0.0, 'Medium': 0.0, 'Minimum': 0.0, 'Fail': 0.0
}
summary_file = sub_folder / 'summary.json'
sub_folder.mkdir(parents=True, exist_ok=True)
with open(summary_file, 'w') as fp:
json.dump(summary, fp, indent=2)
return summary_file
def _annual_daylight_en17037_vis_metadata():
"""Return visualization metadata for annual daylight."""
da_lpar = LegendParameters(min=0, max=100, colors=Colorset.annual_comfort())
metric_info_dict = {
'Minimum Illuminance 100': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - minimum 100 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
},
'Minimum Illuminance 300': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - minimum 300 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
},
'Minimum Illuminance 500': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - minimum 500 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
},
'Target Illuminance 300': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - target 300 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
},
'Target Illuminance 500': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - target 500 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
},
'Target Illuminance 750': {
'type': 'VisualizationMetaData',
'data_type': Fraction('Daylight Autonomy - target 750 lux').to_dict(),
'unit': '%',
'legend_parameters': da_lpar.to_dict()
}
}
return metric_info_dict
def _annual_daylight_en17037_config():
"""Return vtk-config for annual daylight EN 17037."""
cfg = {
"data": [
{
"identifier": "Daylight Autonomy - target 300 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "target_illuminance/minimum/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
{
"identifier": "Daylight Autonomy - target 500 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "target_illuminance/medium/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
{
"identifier": "Daylight Autonomy - target 750 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "target_illuminance/high/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
{
"identifier": "Daylight Autonomy - minimum 100 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "minimum_illuminance/minimum/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
{
"identifier": "Daylight Autonomy - minimum 300 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "minimum_illuminance/medium/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
{
"identifier": "Daylight Autonomy - minimum 500 lux",
"object_type": "grid",
"unit": "Percentage",
"path": "minimum_illuminance/high/da",
"hide": False,
"legend_parameters": {
"hide_legend": False,
"min": 0,
"max": 100,
"color_set": "nuanced",
},
},
]
}
return cfg