diff --git a/energytool/modifier.py b/energytool/modifier.py index 1204b6c..d2be7fc 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -1,4 +1,5 @@ -from typing import Any +from typing import Any, Union +import numpy as np from energytool.base.idf_utils import get_objects_name_list from energytool.base.idfobject_utils import ( @@ -9,6 +10,14 @@ from energytool.tools import is_items_in_list +def _matches_filter(name: str, name_filter: Union[str, list, None]) -> bool: + if name_filter is None: + return True + if isinstance(name_filter, list): + return any(f in name for f in name_filter) + return name_filter in name + + def reverse_kwargs(construction_kwargs): construction_name = construction_kwargs["Name"] @@ -29,7 +38,7 @@ def reverse_kwargs(construction_kwargs): def set_opaque_surface_construction( model: Building, description: dict[str, list[dict[str, Any]]], - name_filter: str = None, + name_filter: Union[str, list[str]] = None, surface_type: str = "Wall", outside_boundary_condition: str = None, ): @@ -66,9 +75,6 @@ def set_opaque_surface_construction( These kwargs are then reversed to ensure consistency for any surfaces that require the inversion of their construction. """ - if name_filter is None: - name_filter = "" - new_construction_name = list(description.keys())[0] new_composition = description[new_construction_name] surface_list = model.idf.idfobjects["BuildingSurface:Detailed"] @@ -88,7 +94,7 @@ def set_opaque_surface_construction( outside_boundary_condition not in obj.getfieldidd("Outside_Boundary_Condition")["key"] for obj in surface_list - if name_filter in obj.Name + if _matches_filter(obj.Name, name_filter) ): raise ValueError( f"outside_boundary_condition must be one of " @@ -124,7 +130,7 @@ def set_opaque_surface_construction( if outside_boundary_condition is not None else True ) - and name_filter in obj.Name + and _matches_filter(obj.Name, name_filter) ] for surf in surf_to_modify: @@ -133,7 +139,7 @@ def set_opaque_surface_construction( construction_to_reverse = [ obj.Construction_Name for obj in surface_list - if name_filter in obj.Outside_Boundary_Condition_Object + if _matches_filter(obj.Outside_Boundary_Condition_Object, name_filter) and ( getattr( obj.Outside_Boundary_Condition_Object, @@ -166,8 +172,8 @@ def set_opaque_surface_construction( def set_external_windows( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, - surface_name_filter: str = None, + name_filter: Union[str, list[str]] = None, + surface_name_filter: Union[str, list[str]] = None, boundary_conditions: str = None, ): """ @@ -209,10 +215,8 @@ def set_external_windows( windows = [ win for win in windows - if ( - name_filter is None and surface_name_filter in win.Building_Surface_Name - ) - or (surface_name_filter is None and name_filter in win.Name) + if _matches_filter(win.Name, name_filter) + and _matches_filter(win.Building_Surface_Name, surface_name_filter) ] windows_names = [win.Name for win in windows] win_cons_names = {win.Construction_Name for win in windows} @@ -274,8 +278,8 @@ def set_external_windows( def set_afn_surface_opening_factor( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, - surface_name_filter: str = None, + name_filter: Union[str, list[str]] = None, + surface_name_filter: Union[str, list[str]] = None, ): """ Modify AirFlowNetwork:Multizone:Surface WindowDoor_Opening_Factor_or_Crack_Factor @@ -300,8 +304,8 @@ def set_afn_surface_opening_factor( openings = [ op for op in openings - if (surface_name_filter is None and name_filter in op.Surface_Name) - or (name_filter is None and surface_name_filter in op.Surface_Name) + if _matches_filter(op.Surface_Name, name_filter) + and _matches_filter(op.Surface_Name, surface_name_filter) ] new_opening_ratio_name = list(description.keys())[0] @@ -313,11 +317,13 @@ def set_afn_surface_opening_factor( opening["WindowDoor_Opening_Factor_or_Crack_Factor"] = new_opening_ratio + + def set_blinds_solar_transmittance( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, - surface_name_filter: str = None, + name_filter: Union[str, list[str]] = None, + surface_name_filter: Union[str, list[str]] = None, ): """ Modify WindowMaterial:Shade Solar_Transmittance (or/and Reflectance) based @@ -347,21 +353,11 @@ def set_blinds_solar_transmittance( selected_shades = [] - if name_filter is None: - name_filter = "" - if surface_name_filter is None: - surface_name_filter = "" - filtered_windows = [ window for window in idf.idfobjects["FenestrationSurface:Detailed"] - if (surface_name_filter == "" and name_filter in window.Name) - or (name_filter == "" and surface_name_filter in window.Building_Surface_Name) - or (surface_name_filter == "" and name_filter == "") - or ( - surface_name_filter in window.Building_Surface_Name - and name_filter in window.Name - ) + if _matches_filter(window.Name, name_filter) + and _matches_filter(window.Building_Surface_Name, surface_name_filter) ] construction_names_dict = { window.Name: window.Construction_Name for window in filtered_windows @@ -447,8 +443,8 @@ def set_schedule_constant( def set_blinds_schedule( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, - surface_name_filter: str = None, + name_filter: Union[str, list[str]] = None, + surface_name_filter: Union[str, list[str]] = None, ): """ Create/update Schedule based on the given description. @@ -504,11 +500,8 @@ def set_blinds_schedule( filtered_windows = [ window for window in idf.idfobjects["FenestrationSurface:Detailed"] - if (surface_name_filter is None and name_filter in window.Name) - or ( - name_filter is None - and surface_name_filter in window.Building_Surface_Name - ) + if _matches_filter(window.Name, name_filter) + and _matches_filter(window.Building_Surface_Name, surface_name_filter) ] construction_names_dict = { window.Name: window.Construction_Name for window in filtered_windows @@ -586,7 +579,7 @@ def update_idf_objects( model: Building, description: dict[str, dict[str, Any]], idfobject_type: str, - name_filter: str = None, + name_filter: Union[str, list[str]] = None, ): """ Updates or creates objects in an IDF based on the provided description. @@ -618,7 +611,7 @@ def update_idf_objects( obj_exists = False for obj in idf_objects: - if name_filter is not None and name_filter in obj["Name"]: + if name_filter is not None and _matches_filter(obj["Name"], name_filter): for field, value in obj_fields.items(): if field != "Name": obj[field] = value @@ -637,8 +630,8 @@ def update_idf_objects( def set_blinds_st_and_schedule( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, - surface_name_filter: str = None, + name_filter: Union[str, list[str]] = None, + surface_name_filter: Union[str, list[str]] = None, ): """ Modify WindowMaterial:Shade Solar_Transmittance and create/update @@ -694,3 +687,1213 @@ def set_system(model, description, **kwargs): # Add new system model.add_system(system) + + +def set_ahu_night_ventilation( + model: Building, + description: dict[str, dict[str, Any]], + name_filter: Union[str, list[str]] = None, +): + """ + Modify DesignSpecification:OutdoorAir objects to represent a + night ventilation strategy. + + This modifier updates all DesignSpecification:OutdoorAir objects + in the model and forces the outdoor air method to be specified + as air changes per hour (ACH). + + Parameters + ---------- + model : Building + EnergyTool Building object. + + description : dict[str, dict[str, Any]] + Dictionary describing the night ventilation strategy. + + The expected dictionary must be of the following form: + + { + "NightVentilation": { + "Outdoor_Air_Flow_Air_Changes_per_Hour": 4.0, + "Outdoor_Air_Schedule_Name": "NIGHT_VENTILATION", + } + } + + name_filter : str, optional + Partial name filter used to select + DesignSpecification:OutdoorAir objects. + + If provided, only objects whose Name + contains the specified string will be + modified. + + If None, all DesignSpecaouaification:OutdoorAir + objects are modified. + + Optionally, a Schedule:Compact description can be provided + using the "Scenario" key. If present, the schedule will be + created or updated before being assigned to the outdoor air + specification. + + Example: + + { + "NightVentilation": { + "Outdoor_Air_Flow_Air_Changes_per_Hour": 4.0, + "Outdoor_Air_Schedule_Name": "NIGHT_VENTILATION", + "Scenario": { + "Name": "NIGHT_VENTILATION", + "Schedule_Type_Limits_Name": "Fraction", + "Field_1": "Through: 12/31", + "Field_2": "For: AllDays", + "Field_3": "Until: 07:00", + "Field_4": 1, + "Field_5": "Until: 22:00", + "Field_6": 0.2, + "Field_7": "Until: 24:00", + "Field_8": 1, + } + } + } + + Notes + ----- + The modifier updates all DesignSpecification:OutdoorAir objects + found in the model. + + The following fields are modified: + + - Outdoor_Air_Method + - Outdoor_Air_Flow_Air_Changes_per_Hour + - Outdoor_Air_Schedule_Name + + Outdoor_Air_Method is automatically set to: + + "AirChanges/Hour" + + This modifier is intended to represent free cooling or night + ventilation strategies through scheduled outdoor air supply. + """ + + params = list(description.values())[0] + + if "Scenario" in params: + schedule = params["Scenario"] + + update_idf_objects( + model, + {schedule["Name"]: schedule}, + "Schedule:Compact", + ) + + for obj in model.idf.idfobjects["DESIGNSPECIFICATION:OUTDOORAIR"]: + + if not _matches_filter(obj.Name, name_filter): + continue + + obj.Outdoor_Air_Method = "AirChanges/Hour" + + if "Outdoor_Air_Flow_Air_Changes_per_Hour" in params: + obj.Outdoor_Air_Flow_Air_Changes_per_Hour = ( + params["Outdoor_Air_Flow_Air_Changes_per_Hour"] + ) + + if "Outdoor_Air_Schedule_Name" in params: + obj.Outdoor_Air_Schedule_Name = ( + params["Outdoor_Air_Schedule_Name"] + ) + + +def set_shading_geometry( + model: Building, + shading_type: str, + description: dict = None, + name_filter: Union[str, list[str]] = None, +): + """ + Create or replace shading geometry attached to fenestration surfaces (windows). + + Existing shading objects of the same type are removed before new ones are created. + + Supported shading types and their default parameters + ---------------------------------------------------- + ``"overhang"`` + Horizontal projection above the window. + + - ``Depth`` (m, default 0.5): how far the overhang extends from the wall. + - ``Offset`` (m, default 0.0): vertical offset above the top edge of the window. + + ``"sidefins"`` + Vertical fins on the sides of the window. + + - ``Depth`` (m, default 0.5): how far each fin extends from the wall. + - ``Left`` (bool, default True): add a fin on the left side. + - ``Right`` (bool, default True): add a fin on the right side. + + ``"horizontal_louvers"`` + Horizontal slats distributed over the window height. + + - ``Depth`` (m, default 0.5): depth of each louver. + - ``Spacing`` (m, default 0.25): vertical distance between louvers. + - ``Tilt`` (°, default 0): tilt of the louvers (0 = horizontal plane). + - ``Offset`` (m, default 0): horizontal gap between the louver and the wall. + + ``"vertical_louvers"`` + Vertical slats distributed over the window width. + + - ``Depth`` (m, default 0.5): depth of each louver. + - ``Spacing`` (m, default 0.30): horizontal distance between louvers. + - ``Tilt`` (°, default 0): tilt of the louvers (0 = perpendicular to wall). + + Parameters + ---------- + model : Building + EnergyTool Building object. + shading_type : str + Type of shading geometry to create. Must be one of + ``"overhang"``, ``"sidefins"``, ``"horizontal_louvers"``, + ``"vertical_louvers"``. + description : dict, optional + Parameter overrides for the chosen shading type. + Only keys that exist in the default parameters are meaningful. + Example for an overhang:: + + {"Depth": 1.2, "Offset": 0.1} + + name_filter : str or list[str], optional + If provided, only windows whose name contains the filter string + (or any string in the list) are processed. + If None, all windows are processed. + """ + default_parameters = { + "overhang": { + "Depth": 0.5, + "Offset": 0.0, + }, + "sidefins": { + "Depth": 0.5, + "Left": True, + "Right": True, + }, + "horizontal_louvers": { + "Depth": 0.5, + "Spacing": 0.25, + "Tilt": 0, + "Offset": 0, + }, + "vertical_louvers": { + "Depth": 0.5, + "Spacing": 0.30, + "Tilt": 0, + }, + } + + if shading_type not in default_parameters: + raise ValueError( + f"shading_type must be one of " + f"{list(default_parameters.keys())}" + ) + + params = default_parameters[shading_type].copy() + + if description is not None: + params.update(description) + + windows = [ + window + for window in model.idf.idfobjects["FenestrationSurface:Detailed"] + if (not window.Surface_Type or window.Surface_Type.upper() == "WINDOW") + and _matches_filter(window.Name, name_filter) + ] + + def get_top_edge(vertices): + top_vertices = sorted( + vertices, + key=lambda p: p[2], + reverse=True, + )[:2] + + edge = top_vertices[1] - top_vertices[0] + + if abs(edge[0]) >= abs(edge[1]): + top_vertices = sorted( + top_vertices, + key=lambda p: p[0], + ) + else: + top_vertices = sorted( + top_vertices, + key=lambda p: p[1], + ) + + return top_vertices + + def get_bottom_edge(vertices): + bottom_vertices = sorted( + vertices, + key=lambda p: p[2], + )[:2] + + edge = bottom_vertices[1] - bottom_vertices[0] + + if abs(edge[0]) >= abs(edge[1]): + bottom_vertices = sorted( + bottom_vertices, + key=lambda p: p[0], + ) + else: + bottom_vertices = sorted( + bottom_vertices, + key=lambda p: p[1], + ) + + return bottom_vertices + + def get_vertices(window): + return [ + np.array( + [ + float(getattr(window, f"Vertex_{i}_Xcoordinate") or 0), + float(getattr(window, f"Vertex_{i}_Ycoordinate") or 0), + float(getattr(window, f"Vertex_{i}_Zcoordinate") or 0), + ] + ) + for i in range(1, 5) + ] + + def get_outward_normal(vertices): + p1, p2 = vertices[:2] + horizontal = p2 - p1 + normal = np.cross(horizontal, np.array([0.0, 0.0, 1.0])) + norm = np.linalg.norm(normal) + if norm < 1e-10: + return np.array([1.0, 0.0, 0.0]) + return normal / norm + + def delete_existing_shading(window_name): + objects = model.idf.idfobjects["Shading:Zone:Detailed"] + for obj in list(objects): + if obj.Name.startswith(f"{window_name}_{shading_type}"): + model.idf.removeidfobject(obj) + + def create_shading_surface( + name, + vertices, + base_surface_name, + ): + kwargs = { + "Name": name, + 'Base_Surface_Name': base_surface_name, + "Number_of_Vertices": 4, + } + for i, vertex in enumerate(vertices, start=1): + kwargs[f"Vertex_{i}_Xcoordinate"] = float(vertex[0]) + kwargs[f"Vertex_{i}_Ycoordinate"] = float(vertex[1]) + kwargs[f"Vertex_{i}_Zcoordinate"] = float(vertex[2]) + model.idf.newidfobject( + "Shading:Zone:Detailed", + **kwargs, + ) + + for window in windows: + delete_existing_shading(window.Name) + vertices = get_vertices(window) + p1, p2, p3, p4 = vertices + + normal = get_outward_normal(vertices) + depth = params["Depth"] + + top_1, top_2 = get_top_edge(vertices) + bottom_1, bottom_2 = get_bottom_edge(vertices) + + height = ( + max(v[2] for v in vertices) + - min(v[2] for v in vertices) + ) + + width = np.linalg.norm( + top_2 - top_1 + ) + + if shading_type == "overhang": + offset = params["Offset"] + + top_vertices = sorted( + vertices, + key=lambda p: p[2], + reverse=True, + )[:2] + + edge = top_vertices[1] - top_vertices[0] + + if np.linalg.norm(edge) > 1e-10: + edge = edge / np.linalg.norm(edge) + + if abs(edge[0]) >= abs(edge[1]): + top_vertices = sorted(top_vertices, key=lambda p: p[0]) + else: + top_vertices = sorted(top_vertices, key=lambda p: p[1]) + + top_1, top_2 = top_vertices + + top_1 = top_1 + np.array([0.0, 0.0, offset]) + top_2 = top_2 + np.array([0.0, 0.0, offset]) + + q1 = top_1 + depth * normal + q2 = top_2 + depth * normal + + create_shading_surface( + f"{window.Name}_overhang", + [ + top_1, + top_2, + q2, + q1, + ], + window.Building_Surface_Name, + ) + + elif shading_type == "sidefins": + + if params["Left"]: + q1 = p1 + depth * normal + q4 = p4 + depth * normal + + create_shading_surface( + f"{window.Name}_left_fin", + [ + p1, + q1, + q4, + p4, + ], + window.Building_Surface_Name, + ) + + if params["Right"]: + q2 = p2 + depth * normal + q3 = p3 + depth * normal + + create_shading_surface( + f"{window.Name}_right_fin", + [ + p2, + p3, + q3, + q2, + ], + window.Building_Surface_Name, + ) + + elif shading_type == "horizontal_louvers": + + spacing = params["Spacing"] + offset = params["Offset"] + tilt = np.deg2rad(params["Tilt"]) + + vertical = np.array([0.0, 0.0, 1.0]) + + louver_direction = ( + np.cos(tilt) * normal + - np.sin(tilt) * vertical + ) + + z_positions = np.arange( + 0, + height + 1e-6, + spacing, + ) + + for i, z_offset in enumerate(z_positions): + p1_louver = ( + top_1 + - np.array([0, 0, z_offset]) + + offset * normal + ) + + p2_louver = ( + top_2 + - np.array([0, 0, z_offset]) + + offset * normal + ) + + q1 = ( + p1_louver + + depth * louver_direction + ) + + q2 = ( + p2_louver + + depth * louver_direction + ) + + create_shading_surface( + f"{window.Name}_horizontal_louver_{i}", + [ + p1_louver, + p2_louver, + q2, + q1, + ], + window.Building_Surface_Name, + ) + + elif shading_type == "vertical_louvers": + + spacing = params["Spacing"] + tilt = np.deg2rad(params["Tilt"]) + + edge_vector = top_2 - top_1 + edge_vector /= np.linalg.norm(edge_vector) + + horizontal_normal = normal.copy() + horizontal_normal[2] = 0.0 + horizontal_normal /= np.linalg.norm(horizontal_normal) + + vertical = np.array( + [0.0, 0.0, 1.0] + ) + + local_right = np.cross( + vertical, + horizontal_normal, + ) + local_right /= np.linalg.norm(local_right) + + louver_direction = ( + np.cos(tilt) * horizontal_normal + + np.sin(tilt) * local_right + ) + + n_louvers = int( + np.floor(width / spacing) + ) + + occupied_width = ( + n_louvers * spacing + ) + + margin = ( + width - occupied_width + ) / 2 + + x_positions = np.arange( + margin, + width - margin + 1e-6, + spacing, + ) + + for i, x_offset in enumerate(x_positions): + offset_vector = ( + x_offset + * edge_vector + ) + + p_bottom = ( + bottom_1 + + offset_vector + ) + + p_top = ( + top_1 + + offset_vector + ) + + q_bottom = ( + p_bottom + + depth * louver_direction + ) + + q_top = ( + p_top + + depth * louver_direction + ) + + create_shading_surface( + f"{window.Name}_vertical_louver_{i}", + [ + p_bottom, + q_bottom, + q_top, + p_top, + ], + window.Building_Surface_Name, + ) + + +def set_shading_properties( + model: Building, + description: dict = None, + name_filter: Union[str, list[str]] = None, +): + """ + Assign reflectance and transmittance properties to shading surfaces. + + For each ``Shading:Zone:Detailed`` and ``Shading:Building:Detailed`` surface, + a ``ShadingProperty:Reflectance`` object is created or updated. + A transmittance schedule can also be attached. + + Default values (applied when ``description`` is None or a key is absent) + ------------------------------------------------------------------------- + - ``Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface``: 0.2 + - ``Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface``: 0.2 + - ``Fraction_of_Shading_Surface_That_Is_Glazed``: 0.0 + - ``Glazing_Construction_Name``: "" (none) + + Parameters + ---------- + model : Building + EnergyTool Building object. + description : dict, optional + Property overrides. Accepted special keys: + + - ``"Transmittance"`` (float): constant transmittance value. + A ``Schedule:Constant`` is automatically created and assigned + to ``Transmittance_Schedule_Name`` for each surface. + - ``"Transmittance_Schedule"`` (str): name of an existing EnergyPlus + schedule to use directly. Takes precedence over ``"Transmittance"``. + + Any other key must be a valid ``ShadingProperty:Reflectance`` field name, + e.g. ``"Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface"``. + + name_filter : str or list[str], optional + If provided, only shading surfaces whose name contains the filter string + (or any string in the list) are affected. If None, all surfaces are updated. + """ + DEFAULT_SHADING_PROPERTIES = { + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.2, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.2, + "Fraction_of_Shading_Surface_That_Is_Glazed": 0.0, + "Glazing_Construction_Name": "", + } + + params = DEFAULT_SHADING_PROPERTIES.copy() + + transmittance = None + schedule = None + + if description is not None: + transmittance = description.get( + "Transmittance" + ) + + schedule = description.get( + "Transmittance_Schedule" + ) + + for key, value in description.items(): + if key not in ("Transmittance", "Transmittance_Schedule"): + params[key] = value + + existing = { + obj.Shading_Surface_Name: obj + for obj in model.idf.idfobjects[ + "SHADINGPROPERTY:REFLECTANCE" + ] + } + + shading_objects = [ + obj + for obj in ( + list(model.idf.idfobjects["SHADING:ZONE:DETAILED"]) + + list(model.idf.idfobjects["SHADING:BUILDING:DETAILED"]) + ) + if _matches_filter(obj.Name, name_filter) + ] + + for shading in shading_objects: + if schedule is not None: + + shading.Transmittance_Schedule_Name = ( + schedule + ) + + elif transmittance is not None: + + schedule_name = ( + f"{shading.Name}_transmittance" + ) + + update_idf_objects( + model, + { + schedule_name: { + "Name": schedule_name, + "Schedule_Type_Limits_Name": "Fraction", + "Hourly_Value": transmittance, + } + }, + "Schedule:Constant", + ) + + shading.Transmittance_Schedule_Name = ( + schedule_name + ) + + if shading.Name in existing: + refl_obj = existing[shading.Name] + + else: + refl_obj = model.idf.newidfobject( + "SHADINGPROPERTY:REFLECTANCE", + Shading_Surface_Name=shading.Name, + ) + + for field, value in params.items(): + setattr(refl_obj, field, value) + +def set_shading_object( + model: Building, + geometry: dict = None, + properties: dict = None, + name_filter: Union[str, list[str]] = None, +): + """ + Create shading geometry and/or assign shading properties in a single call. + + Convenience wrapper around :func:`set_shading_geometry` and + :func:`set_shading_properties`. + + Property presets (use ``properties={"Preset": "", ...}``) + --------------------------------------------------------------- + +--------------------+----------------------------+-----------------------------+ + | Preset | Solar reflectance | Visible reflectance | + +====================+============================+=============================+ + | ``vegetation`` | 0.25 | 0.15 | + +--------------------+----------------------------+-----------------------------+ + | ``light_concrete`` | 0.60 | 0.60 | + +--------------------+----------------------------+-----------------------------+ + | ``dark_metal`` | 0.15 | 0.15 | + +--------------------+----------------------------+-----------------------------+ + | ``pv_panel`` | 0.05 | 0.05 | + +--------------------+----------------------------+-----------------------------+ + + Preset values are used as defaults and can be overridden by other keys in + ``properties``. + + Parameters + ---------- + model : Building + EnergyTool Building object. + geometry : dict, optional + Geometry configuration. Must contain a ``"Type"`` key set to one of + ``"overhang"``, ``"sidefins"``, ``"horizontal_louvers"``, or + ``"vertical_louvers"``. All other keys are forwarded as parameter + overrides to :func:`set_shading_geometry`. Example:: + + {"Type": "overhang", "Depth": 1.0, "Offset": 0.05} + + properties : dict, optional + Properties configuration. May include: + + - ``"Preset"`` (str): one of the preset names listed above. + - ``"Transmittance"`` (float): constant transmittance (0–1). + - ``"Transmittance_Schedule"`` (str): name of an existing schedule. + - Any ``ShadingProperty:Reflectance`` EnergyPlus field. + + Example:: + + {"Preset": "light_concrete", "Transmittance": 0.0} + + name_filter : str or list[str], optional + Forwarded to both :func:`set_shading_geometry` and + :func:`set_shading_properties`. + """ + SHADING_PROPERTY_PRESETS = { + "vegetation": { + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.25, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.15, + }, + "light_concrete": { + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.60, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.60, + }, + "dark_metal": { + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.15, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.15, + }, + "pv_panel": { + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.05, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.05, + }, + } + + if geometry is not None: + shading_type = geometry.pop("Type") + + set_shading_geometry( + model=model, + shading_type=shading_type, + description=geometry, + name_filter=name_filter, + ) + + if properties is not None: + + properties = properties.copy() + + preset = properties.pop("Preset", None) + + if preset is not None: + preset_values = SHADING_PROPERTY_PRESETS[ + preset + ].copy() + + preset_values.update(properties) + + properties = preset_values + + set_shading_properties( + model=model, + description=properties, + name_filter=name_filter, + ) + +def set_shade( + model: Building, + description: dict = None, + name_filter: Union[str, list[str]] = None, +): + """ + Attach a shade material to windows via a ``WindowShadingControl``. + + Creates a ``WindowMaterial:Shade`` and an associated construction, then + assigns a ``WindowShadingControl`` (type ``OnIfScheduleAllows``) to each + matching window. Existing shade material and construction objects are reused + if their names already exist in the IDF. + + Default parameters + ------------------ + - ``Name``: ``"DEFAULT_SHADE"`` + - ``Solar_Transmittance``: 0.10 + - ``Solar_Reflectance``: 0.70 + - ``Visible_Transmittance``: 0.10 + - ``Visible_Reflectance``: 0.70 + - ``Infrared_Hemispherical_Emissivity``: 0.90 + - ``Thickness`` (m): 0.005 + - ``Conductivity`` (W/m·K): 0.10 + - ``Shading_Type``: ``"InteriorShade"`` — also accepts ``"ExteriorShade"`` + - ``Schedule``: None (no schedule assigned, control is always considered active) + + Parameters + ---------- + model : Building + EnergyTool Building object. + description : dict, optional + Parameter overrides. Any key from the default list above can be set. + + - ``"Shading_Type"``: ``"InteriorShade"`` or ``"ExteriorShade"``. + - ``"Schedule"`` (str): name of an existing EnergyPlus schedule used to + drive the shading control (value 1 = active, 0 = inactive). + + Example:: + + { + "Name": "MY_SHADE", + "Solar_Transmittance": 0.05, + "Shading_Type": "ExteriorShade", + "Schedule": "SummerOnlySchedule", + } + + name_filter : str or list[str], optional + If provided, only windows whose name contains the filter string + (or any string in the list) receive the shade control. + If None, all windows are processed. + """ + DEFAULT_SHADE = { + "Name": "DEFAULT_SHADE", + "Solar_Transmittance": 0.10, + "Solar_Reflectance": 0.70, + "Visible_Transmittance": 0.10, + "Visible_Reflectance": 0.70, + "Infrared_Hemispherical_Emissivity": 0.90, + "Thickness": 0.005, + "Conductivity": 0.10, + "Schedule": None, + "Shading_Type": "InteriorShade", + } + + params = DEFAULT_SHADE.copy() + + if description is not None: + params.update(description) + + shade_name = params["Name"] + construction_name = f"{shade_name}_CONSTRUCTION" + + existing_shades = { + obj.Name + for obj in model.idf.idfobjects["WINDOWMATERIAL:SHADE"] + } + + if shade_name not in existing_shades: + + model.idf.newidfobject( + "WINDOWMATERIAL:SHADE", + Name=shade_name, + Solar_Transmittance=params["Solar_Transmittance"], + Solar_Reflectance=params["Solar_Reflectance"], + Visible_Transmittance=params["Visible_Transmittance"], + Visible_Reflectance=params["Visible_Reflectance"], + Infrared_Hemispherical_Emissivity=params[ + "Infrared_Hemispherical_Emissivity" + ], + Thickness=params["Thickness"], + Conductivity=params["Conductivity"], + ) + + existing_constructions = { + obj.Name + for obj in model.idf.idfobjects["CONSTRUCTION"] + } + + if construction_name not in existing_constructions: + + model.idf.newidfobject( + "CONSTRUCTION", + Name=construction_name, + Outside_Layer=shade_name, + ) + + windows = [ + window + for window in model.idf.idfobjects[ + "FENESTRATIONSURFACE:DETAILED" + ] + if ( + ( + not window.Surface_Type + or window.Surface_Type.upper() == "WINDOW" + ) + and _matches_filter(window.Name, name_filter) + ) + ] + + existing_controls = { + obj.Name: obj + for obj in model.idf.idfobjects[ + "WINDOWSHADINGCONTROL" + ] + } + + for window in windows: + + control_name = ( + f"{window.Name}_{shade_name}_control" + ) + + if control_name in existing_controls: + + control = existing_controls[ + control_name + ] + + else: + + control = model.idf.newidfobject( + "WINDOWSHADINGCONTROL", + Name=control_name, + ) + + control.Zone_Name = ( + getattr(window, "Zone_Name", "") + ) + + control.Shading_Type = ( + params["Shading_Type"] + ) + + control.Construction_with_Shading_Name = ( + construction_name + ) + + control.Shading_Control_Type = ( + "OnIfScheduleAllows" + ) + + if params["Schedule"] is not None: + + control.Schedule_Name = ( + params["Schedule"] + ) + + try: + control.Fenestration_Surface_1_Name = ( + window.Name + ) + except Exception: + pass + +def set_blind( + model: Building, + description: dict = None, + name_filter: Union[str, list[str]] = None, +): + """ + Attach a venetian blind material to windows via a ``WindowShadingControl``. + + Creates a ``WindowMaterial:Blind`` and an associated construction, then + assigns a ``WindowShadingControl`` (type ``OnIfScheduleAllows``) to each + matching window. Existing blind material and construction objects are reused + if their names already exist in the IDF. + + Available presets (use ``description={"Preset": "", ...}``) + ----------------------------------------------------------------- + +----------------------+-------------------+------------+------------------------------------+ + | Preset | Shading_Type | Slat_Angle | Slat_Beam_Solar_Reflectance | + +======================+===================+============+====================================+ + | ``venetian_indoor`` | InteriorBlind | 45° | 0.70 | + +----------------------+-------------------+------------+------------------------------------+ + | ``bso_exterior`` | ExteriorBlind | 60° | 0.80 | + +----------------------+-------------------+------------+------------------------------------+ + | ``micro_louver`` | BetweenGlassBlind | 75° | — (uses default 0.70) | + +----------------------+-------------------+------------+------------------------------------+ + + ``micro_louver`` also sets ``Slat_Separation`` to 0.01 m. + Preset values are applied first; any other key in ``description`` overrides them. + + Default parameters + ------------------ + - ``Name``: ``"DEFAULT_BLIND"`` + - ``Slat_Orientation``: ``"Horizontal"`` + - ``Slat_Width`` (m): 0.08 + - ``Slat_Separation`` (m): 0.07 + - ``Slat_Thickness`` (m): 0.002 + - ``Slat_Angle`` (°): 45 + - ``Slat_Conductivity`` (W/m·K): 160 + - ``Slat_Beam_Solar_Transmittance``: 0.0 + - ``Slat_Beam_Solar_Reflectance``: 0.7 + - ``Slat_Diffuse_Solar_Transmittance``: 0.0 + - ``Slat_Diffuse_Solar_Reflectance``: 0.7 + - ``Slat_Beam_Visible_Transmittance``: 0.0 + - ``Slat_Beam_Visible_Reflectance``: 0.7 + - ``Slat_Diffuse_Visible_Transmittance``: 0.0 + - ``Slat_Diffuse_Visible_Reflectance``: 0.7 + - ``Slat_Infrared_Hemispherical_Transmittance``: 0.0 + - ``Slat_Infrared_Hemispherical_Emissivity``: 0.9 + - ``Blind_to_Glass_Distance`` (m): 0.05 + - ``Minimum_Slat_Angle`` (°): 0 + - ``Maximum_Slat_Angle`` (°): 180 + - ``Shading_Type``: ``"ExteriorBlind"`` + - ``Schedule``: None (no schedule assigned) + + Parameters + ---------- + model : Building + EnergyTool Building object. + description : dict, optional + Parameter overrides. Any key from the default list above can be set. + + - ``"Preset"`` (str): one of the preset names above. + - ``"Schedule"`` (str): name of an existing EnergyPlus schedule (1 = active, + 0 = inactive). + + Example:: + + { + "Preset": "venetian_indoor", + "Name": "MY_BLIND", + "Schedule": "SummerBlindSchedule", + } + + name_filter : str or list[str], optional + If provided, only windows whose name contains the filter string + (or any string in the list) receive the blind control. + If None, all windows are processed. + """ + BLIND_PRESETS = { + "venetian_indoor": { + "Shading_Type": "InteriorBlind", + "Slat_Angle": 45, + "Slat_Beam_Solar_Reflectance": 0.7, + }, + "bso_exterior": { + "Shading_Type": "ExteriorBlind", + "Slat_Angle": 60, + "Slat_Beam_Solar_Reflectance": 0.8, + }, + "micro_louver": { + "Shading_Type": "BetweenGlassBlind", + "Slat_Angle": 75, + "Slat_Separation": 0.01, + }, + } + + DEFAULT_BLIND = { + "Name": "DEFAULT_BLIND", + "Slat_Orientation": "Horizontal", + "Slat_Width": 0.08, + "Slat_Separation": 0.07, + "Slat_Thickness": 0.002, + "Slat_Angle": 45, + "Slat_Conductivity": 160, + "Slat_Beam_Solar_Transmittance": 0.0, + "Slat_Beam_Solar_Reflectance": 0.7, + "Slat_Diffuse_Solar_Transmittance": 0.0, + "Slat_Diffuse_Solar_Reflectance": 0.7, + "Slat_Beam_Visible_Transmittance": 0.0, + "Slat_Beam_Visible_Reflectance": 0.7, + "Slat_Diffuse_Visible_Transmittance": 0.0, + "Slat_Diffuse_Visible_Reflectance": 0.7, + "Slat_Infrared_Hemispherical_Transmittance": 0.0, + "Slat_Infrared_Hemispherical_Emissivity": 0.9, + "Blind_to_Glass_Distance": 0.05, + "Minimum_Slat_Angle": 0, + "Maximum_Slat_Angle": 180, + "Schedule": None, + "Shading_Type": "ExteriorBlind", + } + + params = DEFAULT_BLIND.copy() + if description is not None: + preset = description.pop( + "Preset", + None, + ) + if preset is not None: + params.update( + BLIND_PRESETS[preset] + ) + params.update(description) + + blind_name = params["Name"] + construction_name = f"{blind_name}_CONSTRUCTION" + + existing_blinds = { + obj.Name + for obj in model.idf.idfobjects[ + "WINDOWMATERIAL:BLIND" + ] + } + + if blind_name not in existing_blinds: + model.idf.newidfobject( + "WINDOWMATERIAL:BLIND", + Name=blind_name, + Slat_Orientation=params["Slat_Orientation"], + Slat_Width=params["Slat_Width"], + Slat_Separation=params["Slat_Separation"], + Slat_Thickness=params["Slat_Thickness"], + Slat_Angle=params["Slat_Angle"], + Slat_Conductivity=params["Slat_Conductivity"], + Slat_Beam_Solar_Transmittance= + params["Slat_Beam_Solar_Transmittance"], + Front_Side_Slat_Beam_Solar_Reflectance= + params["Slat_Beam_Solar_Reflectance"], + Back_Side_Slat_Beam_Solar_Reflectance= + params["Slat_Beam_Solar_Reflectance"], + Slat_Diffuse_Solar_Transmittance= + params["Slat_Diffuse_Solar_Transmittance"], + Front_Side_Slat_Diffuse_Solar_Reflectance= + params["Slat_Diffuse_Solar_Reflectance"], + Back_Side_Slat_Diffuse_Solar_Reflectance= + params["Slat_Diffuse_Solar_Reflectance"], + Slat_Beam_Visible_Transmittance= + params["Slat_Beam_Visible_Transmittance"], + Front_Side_Slat_Beam_Visible_Reflectance= + params["Slat_Beam_Visible_Reflectance"], + Back_Side_Slat_Beam_Visible_Reflectance= + params["Slat_Beam_Visible_Reflectance"], + Slat_Diffuse_Visible_Transmittance= + params["Slat_Diffuse_Visible_Transmittance"], + Front_Side_Slat_Diffuse_Visible_Reflectance= + params["Slat_Diffuse_Visible_Reflectance"], + Back_Side_Slat_Diffuse_Visible_Reflectance= + params["Slat_Diffuse_Visible_Reflectance"], + Slat_Infrared_Hemispherical_Transmittance= + params["Slat_Infrared_Hemispherical_Transmittance"], + Front_Side_Slat_Infrared_Hemispherical_Emissivity= + params["Slat_Infrared_Hemispherical_Emissivity"], + Back_Side_Slat_Infrared_Hemispherical_Emissivity= + params["Slat_Infrared_Hemispherical_Emissivity"], + Blind_to_Glass_Distance= + params["Blind_to_Glass_Distance"], + Blind_Top_Opening_Multiplier=1, + Blind_Bottom_Opening_Multiplier=1, + Blind_Left_Side_Opening_Multiplier=1, + Blind_Right_Side_Opening_Multiplier=1, + Minimum_Slat_Angle= + params["Minimum_Slat_Angle"], + Maximum_Slat_Angle= + params["Maximum_Slat_Angle"], + ) + + existing_constructions = { + obj.Name + for obj in model.idf.idfobjects[ + "CONSTRUCTION" + ] + } + + if construction_name not in existing_constructions: + + model.idf.newidfobject( + "CONSTRUCTION", + Name=construction_name, + Outside_Layer=blind_name, + ) + + windows = [ + window + for window in model.idf.idfobjects[ + "FENESTRATIONSURFACE:DETAILED" + ] + if ( + ( + not window.Surface_Type + or window.Surface_Type.upper() == "WINDOW" + ) + and _matches_filter(window.Name, name_filter) + ) + ] + + existing_controls = { + obj.Name: obj + for obj in model.idf.idfobjects[ + "WINDOWSHADINGCONTROL" + ] + } + + for window in windows: + + control_name = ( + f"{window.Name}_{blind_name}_control" + ) + + if control_name in existing_controls: + + control = existing_controls[ + control_name + ] + + else: + + control = model.idf.newidfobject( + "WINDOWSHADINGCONTROL", + Name=control_name, + ) + + control.Shading_Type = ( + params["Shading_Type"] + ) + + control.Construction_with_Shading_Name = ( + construction_name + ) + + control.Shading_Control_Type = ( + "OnIfScheduleAllows" + ) + + if params["Schedule"] is not None: + + control.Schedule_Name = ( + params["Schedule"] + ) + + try: + control.Fenestration_Surface_1_Name = ( + window.Name + ) + except Exception: + pass \ No newline at end of file diff --git a/energytool/tools.py b/energytool/tools.py index fc08e14..61b5a44 100644 --- a/energytool/tools.py +++ b/energytool/tools.py @@ -99,4 +99,4 @@ def add_day_in_period(self, start, end, days, hourly_dict): self.series.loc[selected_timestamp] = hourly_lst_from_dict(hourly_dict) * int( len(selected_timestamp) / 24 - ) + ) \ No newline at end of file diff --git a/tests/test_modifier.py b/tests/test_modifier.py index 31e8c31..816db7b 100644 --- a/tests/test_modifier.py +++ b/tests/test_modifier.py @@ -24,6 +24,12 @@ set_blinds_schedule, set_schedule_constant, set_system, + set_ahu_night_ventilation, + set_shading_geometry, + set_shading_properties, + set_shading_object, + set_shade, + set_blind, update_idf_objects, reverse_kwargs, ) @@ -101,6 +107,18 @@ def toy_building(tmp_path_factory): Outside_Layer="Int_win", ) + toy_idf.newidfobject( + key="DESIGNSPECIFICATION:OUTDOORAIR", + Name="zone_0_oa", + Outdoor_Air_Method="Flow/Person", + ) + + toy_idf.newidfobject( + key="DESIGNSPECIFICATION:OUTDOORAIR", + Name="zone_1_oa", + Outdoor_Air_Method="Flow/Person", + ) + for toy_surf in range(6): toy_idf.newidfobject( "BuildingSurface:Detailed", @@ -135,6 +153,24 @@ def toy_building(tmp_path_factory): Building_Surface_Name=sur, ) + # 1 m × 1 m south-facing windows (y=5 plane), required by shading geometry tests. + # Vertices in EnergyPlus counter-clockwise order (viewed from outside): + # Vertex 1 lower-left, 2 lower-right, 3 upper-right, 4 upper-left. + # This gives: height = 1 m, width = 1 m, outward normal = (0, -1, 0). + for window in toy_idf.idfobjects["FenestrationSurface:Detailed"]: + window.Vertex_1_Xcoordinate = 0.0 + window.Vertex_1_Ycoordinate = 5.0 + window.Vertex_1_Zcoordinate = 0.5 + window.Vertex_2_Xcoordinate = 1.0 + window.Vertex_2_Ycoordinate = 5.0 + window.Vertex_2_Zcoordinate = 0.5 + window.Vertex_3_Xcoordinate = 1.0 + window.Vertex_3_Ycoordinate = 5.0 + window.Vertex_3_Zcoordinate = 1.5 + window.Vertex_4_Xcoordinate = 0.0 + window.Vertex_4_Ycoordinate = 5.0 + window.Vertex_4_Zcoordinate = 1.5 + energytool.base.idf_utils.set_named_objects_field_values( idf=toy_idf, idf_object="FenestrationSurface:Detailed", @@ -794,6 +830,362 @@ def test_set_system(self, toy_building): 4.0, ] + def test_set_ahu_night_ventilation(self, toy_building): + set_ahu_night_ventilation( + model=toy_building, + description={ + "night_ventilation": { + "Outdoor_Air_Flow_Air_Changes_per_Hour": 4.0, + "Outdoor_Air_Schedule_Name": "NIGHT_VENTILATION", + } + }, + name_filter="zone_0", + ) + + oa_objects = toy_building.idf.idfobjects[ + "DESIGNSPECIFICATION:OUTDOORAIR" + ] + + zone_0 = next( + obj for obj in oa_objects + if obj.Name == "zone_0_oa" + ) + + zone_1 = next( + obj for obj in oa_objects + if obj.Name == "zone_1_oa" + ) + + assert zone_0.Outdoor_Air_Method == "AirChanges/Hour" + assert zone_0.Outdoor_Air_Flow_Air_Changes_per_Hour == 4.0 + assert zone_0.Outdoor_Air_Schedule_Name == "NIGHT_VENTILATION" + assert zone_1.Outdoor_Air_Method == "Flow/Person" + + set_ahu_night_ventilation( + model=toy_building, + description={ + "night_ventilation": { + "Outdoor_Air_Flow_Air_Changes_per_Hour": 4.0, + } + }, + ) + + oa_objects = toy_building.idf.idfobjects[ + "DESIGNSPECIFICATION:OUTDOORAIR" + ] + + assert all( + obj.Outdoor_Air_Method == "AirChanges/Hour" + for obj in oa_objects + ) + + def test_set_shading_geometry(self, toy_building): + # --- overhang: one surface per window --- + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", {"Depth": 0.8}, name_filter="_0") + shading = loc.idf.idfobjects["Shading:Zone:Detailed"] + overhang_surfaces = [s for s in shading if s.Name == "Window_0_overhang"] + assert len(overhang_surfaces) == 1 + assert overhang_surfaces[0].Number_of_Vertices == 4 + # name_filter must exclude other windows + assert not any(s.Name == "Window_1_overhang" for s in shading) + + # second call replaces existing surface (idempotent) + set_shading_geometry(loc, "overhang", {"Depth": 1.0}, name_filter="_0") + assert sum( + 1 for s in loc.idf.idfobjects["Shading:Zone:Detailed"] + if s.Name == "Window_0_overhang" + ) == 1 + + #sidefins > left fin + right fin + loc = deepcopy(toy_building) + set_shading_geometry(loc, "sidefins", name_filter="_0") + names = {s.Name for s in loc.idf.idfobjects["Shading:Zone:Detailed"]} + assert "Window_0_left_fin" in names + assert "Window_0_right_fin" in names + + # only right fin when Left=False + loc = deepcopy(toy_building) + set_shading_geometry(loc, "sidefins", {"Left": False}, name_filter="_0") + names = {s.Name for s in loc.idf.idfobjects["Shading:Zone:Detailed"]} + assert "Window_0_left_fin" not in names + assert "Window_0_right_fin" in names + + # --- horizontal_louvers --- + loc = deepcopy(toy_building) + set_shading_geometry(loc, "horizontal_louvers", name_filter="_0") + louvers = [ + s for s in loc.idf.idfobjects["Shading:Zone:Detailed"] + if "Window_0_horizontal_louver" in s.Name + ] + assert len(louvers) == 5 + assert all(s.Number_of_Vertices == 4 for s in louvers) + + # --- vertical_louvers --- + loc = deepcopy(toy_building) + set_shading_geometry(loc, "vertical_louvers", name_filter="_0") + louvers = [ + s for s in loc.idf.idfobjects["Shading:Zone:Detailed"] + if "Window_0_vertical_louver" in s.Name + ] + assert len(louvers) == 4 + assert all(s.Number_of_Vertices == 4 for s in louvers) + + # --- list name_filter: Window_0 and Window_1 --- + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", name_filter=["_0", "_1"]) + overhangs = [s for s in loc.idf.idfobjects["Shading:Zone:Detailed"] if "overhang" in s.Name] + assert {s.Name for s in overhangs} == {"Window_0_overhang", "Window_1_overhang"} + + # --- invalid type raises ValueError --- + with pytest.raises(ValueError): + set_shading_geometry(deepcopy(toy_building), "invalid_type") + + def test_set_shading_properties(self, toy_building): + # setup: one overhang on Window_0 + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", name_filter="_0") + + # default properties + set_shading_properties(loc) + refl_objs = loc.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"] + assert len(refl_objs) == 1 + refl = refl_objs[0] + assert refl.Shading_Surface_Name == "Window_0_overhang" + assert refl.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.2) + assert refl.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.2) + assert refl.Fraction_of_Shading_Surface_That_Is_Glazed == pytest.approx(0.0) + + # custom reflectances + loc2 = deepcopy(toy_building) + set_shading_geometry(loc2, "overhang", name_filter="_0") + set_shading_properties(loc2, description={ + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.6, + "Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.55, + }) + refl = loc2.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"][0] + assert refl.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.6) + assert refl.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.55) + + # Transmittance: creates a Schedule:Constant and assigns it + loc3 = deepcopy(toy_building) + set_shading_geometry(loc3, "overhang", name_filter="_0") + set_shading_properties(loc3, description={"Transmittance": 0.3}) + shading_obj = loc3.idf.idfobjects["SHADING:ZONE:DETAILED"][0] + sched_name = shading_obj.Transmittance_Schedule_Name + assert sched_name != "" + consts = loc3.idf.idfobjects["SCHEDULE:CONSTANT"] + assert any(s.Name == sched_name and s.Hourly_Value == pytest.approx(0.3) for s in consts) + + # Transmittance_Schedule: assign existing schedule name directly + loc4 = deepcopy(toy_building) + set_shading_geometry(loc4, "overhang", name_filter="_0") + set_shading_properties(loc4, description={"Transmittance_Schedule": "Shading_control_bis"}) + shading_obj = loc4.idf.idfobjects["SHADING:ZONE:DETAILED"][0] + assert shading_obj.Transmittance_Schedule_Name == "Shading_control_bis" + + # name_filter: 4 overhangs created, properties applied only to Window_0 + loc5 = deepcopy(toy_building) + set_shading_geometry(loc5, "overhang") # all windows + set_shading_properties(loc5, name_filter="Window_0") + refl_objs = loc5.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"] + assert len(refl_objs) == 1 + assert refl_objs[0].Shading_Surface_Name == "Window_0_overhang" + + # list name_filter + loc6 = deepcopy(toy_building) + set_shading_geometry(loc6, "overhang") + set_shading_properties(loc6, name_filter=["Window_0", "Window_1"]) + refl_names = {r.Shading_Surface_Name for r in loc6.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"]} + assert refl_names == {"Window_0_overhang", "Window_1_overhang"} + + def test_set_shading_object(self, toy_building): + # geometry only + loc = deepcopy(toy_building) + set_shading_object(loc, geometry={"Type": "overhang", "Depth": 0.6}, name_filter="_0") + shading = loc.idf.idfobjects["Shading:Zone:Detailed"] + assert any(s.Name == "Window_0_overhang" for s in shading) + assert not any(s.Name == "Window_1_overhang" for s in shading) + + # properties only with preset "light_concrete" + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", name_filter="_0") + set_shading_object(loc, properties={"Preset": "light_concrete"}, name_filter="_0") + refl = loc.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"][0] + assert refl.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.60) + assert refl.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.60) + + # preset "dark_metal" with solar reflectance override + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", name_filter="_0") + set_shading_object( + loc, + properties={ + "Preset": "dark_metal", + "Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface": 0.25, + }, + name_filter="_0", + ) + refl = loc.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"][0] + # override takes precedence + assert refl.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.25) + # visible from preset (dark_metal = 0.15) + assert refl.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.15) + + # combined geometry + properties ("vegetation" preset) + loc = deepcopy(toy_building) + set_shading_object( + loc, + geometry={"Type": "sidefins"}, + properties={"Preset": "vegetation"}, + name_filter="_0", + ) + shading = loc.idf.idfobjects["Shading:Zone:Detailed"] + assert any(s.Name == "Window_0_left_fin" for s in shading) + refl_objs = loc.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"] + assert len(refl_objs) >= 1 + for r in refl_objs: + assert r.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.25) + assert r.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.15) + + # preset "pv_panel" + loc = deepcopy(toy_building) + set_shading_geometry(loc, "overhang", name_filter="_0") + set_shading_object(loc, properties={"Preset": "pv_panel"}, name_filter="_0") + refl = loc.idf.idfobjects["SHADINGPROPERTY:REFLECTANCE"][0] + assert refl.Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.05) + assert refl.Diffuse_Visible_Reflectance_of_Unglazed_Part_of_Shading_Surface == pytest.approx(0.05) + + def test_set_shade(self, toy_building): + # default shade applied to Window_0 only + loc = deepcopy(toy_building) + set_shade(loc, name_filter="_0") + + shades = loc.idf.idfobjects["WINDOWMATERIAL:SHADE"] + default_shade = next((s for s in shades if s.Name == "DEFAULT_SHADE"), None) + assert default_shade is not None + assert default_shade.Solar_Transmittance == pytest.approx(0.10) + assert default_shade.Solar_Reflectance == pytest.approx(0.70) + assert default_shade.Visible_Transmittance == pytest.approx(0.10) + + assert "DEFAULT_SHADE_CONSTRUCTION" in {c.Name for c in loc.idf.idfobjects["CONSTRUCTION"]} + + controls = loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + ctrl = next((c for c in controls if c.Name == "Window_0_DEFAULT_SHADE_control"), None) + assert ctrl is not None + assert ctrl.Shading_Type == "InteriorShade" + assert ctrl.Construction_with_Shading_Name == "DEFAULT_SHADE_CONSTRUCTION" + assert ctrl.Shading_Control_Type == "OnIfScheduleAllows" + # Window_1 excluded by name_filter + assert not any(c.Name == "Window_1_DEFAULT_SHADE_control" for c in controls) + + # custom: ExteriorShade, lower transmittance, with schedule + loc = deepcopy(toy_building) + set_shade( + loc, + description={ + "Name": "MY_SHADE", + "Solar_Transmittance": 0.05, + "Shading_Type": "ExteriorShade", + "Schedule": "Shading_control_bis", + }, + name_filter="_0", + ) + shade = next(s for s in loc.idf.idfobjects["WINDOWMATERIAL:SHADE"] if s.Name == "MY_SHADE") + assert shade.Solar_Transmittance == pytest.approx(0.05) + ctrl = next( + c for c in loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + if c.Name == "Window_0_MY_SHADE_control" + ) + assert ctrl.Shading_Type == "ExteriorShade" + assert ctrl.Schedule_Name == "Shading_control_bis" + + # second call with same name reuses material, does not duplicate it + loc = deepcopy(toy_building) + set_shade(loc) + set_shade(loc) + assert sum(1 for s in loc.idf.idfobjects["WINDOWMATERIAL:SHADE"] if s.Name == "DEFAULT_SHADE") == 1 + + # list name_filter: Window_0 and Window_1 + loc = deepcopy(toy_building) + set_shade(loc, name_filter=["_0", "_1"]) + controls = loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + assert any(c.Name == "Window_0_DEFAULT_SHADE_control" for c in controls) + assert any(c.Name == "Window_1_DEFAULT_SHADE_control" for c in controls) + assert not any(c.Name == "Window_2_DEFAULT_SHADE_control" for c in controls) + + def test_set_blind(self, toy_building): + # default blind on Window_0 only + loc = deepcopy(toy_building) + set_blind(loc, name_filter="_0") + + blinds = loc.idf.idfobjects["WINDOWMATERIAL:BLIND"] + default_blind = next((b for b in blinds if b.Name == "DEFAULT_BLIND"), None) + assert default_blind is not None + assert default_blind.Slat_Width == pytest.approx(0.08) + assert default_blind.Slat_Angle == pytest.approx(45) + assert default_blind.Slat_Separation == pytest.approx(0.07) + + assert "DEFAULT_BLIND_CONSTRUCTION" in {c.Name for c in loc.idf.idfobjects["CONSTRUCTION"]} + + controls = loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + ctrl = next((c for c in controls if c.Name == "Window_0_DEFAULT_BLIND_control"), None) + assert ctrl is not None + assert ctrl.Shading_Type == "ExteriorBlind" # default Shading_Type + assert ctrl.Construction_with_Shading_Name == "DEFAULT_BLIND_CONSTRUCTION" + assert ctrl.Shading_Control_Type == "OnIfScheduleAllows" + assert not any(c.Name == "Window_1_DEFAULT_BLIND_control" for c in controls) + + # preset "venetian_indoor": InteriorBlind, Slat_Angle=45, reflectance=0.7 + loc = deepcopy(toy_building) + set_blind(loc, description={"Preset": "venetian_indoor", "Name": "VENETIAN"}, name_filter="_0") + blind = next(b for b in loc.idf.idfobjects["WINDOWMATERIAL:BLIND"] if b.Name == "VENETIAN") + assert blind.Slat_Angle == pytest.approx(45) + assert blind.Front_Side_Slat_Beam_Solar_Reflectance == pytest.approx(0.7) + ctrl = next( + c for c in loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + if c.Name == "Window_0_VENETIAN_control" + ) + assert ctrl.Shading_Type == "InteriorBlind" + + # preset "bso_exterior": ExteriorBlind, Slat_Angle=60, reflectance=0.8 + loc = deepcopy(toy_building) + set_blind(loc, description={"Preset": "bso_exterior", "Name": "BSO"}, name_filter="_0") + blind = next(b for b in loc.idf.idfobjects["WINDOWMATERIAL:BLIND"] if b.Name == "BSO") + assert blind.Slat_Angle == pytest.approx(60) + assert blind.Front_Side_Slat_Beam_Solar_Reflectance == pytest.approx(0.8) + ctrl = next( + c for c in loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + if c.Name == "Window_0_BSO_control" + ) + assert ctrl.Shading_Type == "ExteriorBlind" + + # preset "micro_louver": BetweenGlassBlind, Slat_Angle=75, Slat_Separation=0.01 + loc = deepcopy(toy_building) + set_blind(loc, description={"Preset": "micro_louver", "Name": "MICRO"}, name_filter="_0") + blind = next(b for b in loc.idf.idfobjects["WINDOWMATERIAL:BLIND"] if b.Name == "MICRO") + assert blind.Slat_Angle == pytest.approx(75) + assert blind.Slat_Separation == pytest.approx(0.01) + ctrl = next( + c for c in loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + if c.Name == "Window_0_MICRO_control" + ) + assert ctrl.Shading_Type == "BetweenGlassBlind" + + # second call with same name reuses material, does not duplicate it + loc = deepcopy(toy_building) + set_blind(loc, name_filter="_0") + set_blind(loc, name_filter="_0") + assert sum(1 for b in loc.idf.idfobjects["WINDOWMATERIAL:BLIND"] if b.Name == "DEFAULT_BLIND") == 1 + + # list name_filter: Window_0 and Window_1 only + loc = deepcopy(toy_building) + set_blind(loc, name_filter=["_0", "_1"]) + controls = loc.idf.idfobjects["WINDOWSHADINGCONTROL"] + assert any(c.Name == "Window_0_DEFAULT_BLIND_control" for c in controls) + assert any(c.Name == "Window_1_DEFAULT_BLIND_control" for c in controls) + assert not any(c.Name == "Window_2_DEFAULT_BLIND_control" for c in controls) + # def test_envelope_shades_modifier(self, toy_building): # loc_toy = deepcopy(toy_building) #