From 3764bb00a7e1191d6fdec1f4ea2789fb353f8d4c Mon Sep 17 00:00:00 2001 From: Tesshub Date: Wed, 3 Jun 2026 15:27:55 +0200 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=E2=9C=85=20set=20night=20ventil?= =?UTF-8?q?ation=20schedule=20(with=20ACH=20and=20optional=20schedule=20cr?= =?UTF-8?q?eation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 118 +++++++++++++++++++++++++++++++++++++++++ tests/test_modifier.py | 63 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/energytool/modifier.py b/energytool/modifier.py index 1204b6c..22b17aa 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -694,3 +694,121 @@ 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: 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 ( + name_filter is not None + and name_filter not in obj.Name + ): + 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"] + ) \ No newline at end of file diff --git a/tests/test_modifier.py b/tests/test_modifier.py index 31e8c31..34e9b57 100644 --- a/tests/test_modifier.py +++ b/tests/test_modifier.py @@ -24,6 +24,7 @@ set_blinds_schedule, set_schedule_constant, set_system, + set_ahu_night_ventilation, update_idf_objects, reverse_kwargs, ) @@ -101,6 +102,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", @@ -794,6 +807,56 @@ 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_envelope_shades_modifier(self, toy_building): # loc_toy = deepcopy(toy_building) # From c9358c1b6ab4d4b7117bf02e01f68e99c6351d58 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 11:49:31 +0200 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8vizualise=20geometrical=20model?= =?UTF-8?q?=20in=20python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/tools.py | 505 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) diff --git a/energytool/tools.py b/energytool/tools.py index fc08e14..79c112a 100644 --- a/energytool/tools.py +++ b/energytool/tools.py @@ -1,5 +1,8 @@ import pandas as pd import datetime as dt +import numpy as np +import plotly.graph_objects as go +import plotly.colors as pc def to_list(f_input): @@ -100,3 +103,505 @@ 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 ) + + +ZONE_PALETTE = ( + pc.qualitative.Safe + + pc.qualitative.Set3 +) +ADIABATIC_COLOR = "coral" +UNCONDITIONED_COLOR = "#9ECAE1" +WINDOW_COLOR = "cyan" +SHADING_COLOR = "mediumpurple" +CONDITIONED_ZONE_PALETTE = [ + "#67000d", + "#a50f15", + "#cb181d", + "#ef3b2c", + "#fb6a4a", + "#fc9272", + "#fcbba1", +] + +def plot_idf_geometry( + building, + show_building_surfaces=True, + show_fenestration_surfaces=True, + show_shading_surfaces=True, + show_names=False, + opacity=0.7, + color_mode="surface_type", +): + """ + Interactive 3D visualization of an EnergyPlus building geometry. + + This function displays the geometry contained in an IDF model using + Plotly. Building surfaces, fenestration surfaces and shading surfaces + can be visualized independently. The resulting figure can be explored + interactively (rotation, zoom, pan). + + Parameters + ---------- + building : Building + EnergyTool Building object containing an IDF model. + + show_building_surfaces : bool, default=True + Display BuildingSurface:Detailed objects. + + show_fenestration_surfaces : bool, default=True + Display FenestrationSurface:Detailed objects. + + show_shading_surfaces : bool, default=True + Display Shading:Zone:Detailed objects. + + show_names : bool, default=False + Display labels on the geometry. + + - In ``surface_type`` mode, surface names are displayed. + - In ``zone`` mode, thermal zone names are displayed at the + centroid of each zone. + + opacity : float, default=0.7 + Surface opacity between 0 and 1. + + color_mode : {"surface_type", "zone"}, default="surface_type" + Controls how surfaces are colored. + + ``surface_type``: + - External walls: light grey + - Internal walls: khaki + - Roofs: dark grey + - Floors: grey + - Windows: cyan + - Shading surfaces: purple + + ``zone``: + Colors are assigned according to thermal zone type. + + - Conditioned zones: red color palette + - Adiabatic zones: orange/coral + - Unconditioned zones: blue + + Returns + ------- + plotly.graph_objects.Figure + Interactive Plotly figure. + + Notes + ----- + This function is intended for model inspection and debugging. + + Typical use cases include: + + - Checking generated geometry + - Verifying window locations + - Validating shading modifiers + (overhangs, side fins, vegetation, PV systems, etc.) + - Visualizing thermal zoning + - Inspecting adiabatic and conditioned zones + + Examples + -------- + Display the complete building geometry: + + >>> plot_idf_geometry(building).show() + + Display thermal zones: + + >>> plot_idf_geometry( + ... building, + ... color_mode="zone", + ... show_names=True, + ... ).show() + + Display only windows and shading devices: + + >>> plot_idf_geometry( + ... building, + ... show_building_surfaces=False, + ... ).show() + + Visualize the effect of a shading modifier: + + >>> set_shading_geometry( + ... building, + ... shading_type="overhang", + ... description={"Depth": 1.0}, + ... ) + >>> + >>> plot_idf_geometry(building).show() + """ + + fig = go.Figure() + + def get_vertices(surface): + n_vertices = int(surface.Number_of_Vertices) + + return np.array( + [ + [ + float(getattr(surface, f"Vertex_{i}_Xcoordinate")), + float(getattr(surface, f"Vertex_{i}_Ycoordinate")), + float(getattr(surface, f"Vertex_{i}_Zcoordinate")), + ] + for i in range(1, n_vertices + 1) + ] + ) + + def add_surface(vertices, color, name=None): + if len(vertices) < 3: + return + + x = vertices[:, 0] + y = vertices[:, 1] + z = vertices[:, 2] + + i = [] + j = [] + k = [] + + for idx in range(1, len(vertices) - 1): + i.append(0) + j.append(idx) + k.append(idx + 1) + + fig.add_trace( + go.Mesh3d( + x=x, + y=y, + z=z, + i=i, + j=j, + k=k, + color=color, + opacity=opacity, + hovertext=name, + hoverinfo="text", + showscale=False, + ) + ) + + vertices_closed = np.vstack([vertices, vertices[0]]) + + fig.add_trace( + go.Scatter3d( + x=vertices_closed[:, 0], + y=vertices_closed[:, 1], + z=vertices_closed[:, 2], + mode="lines", + line=dict( + color="black", + width=2, + ), + showlegend=False, + hoverinfo="skip", + ) + ) + + if show_names and color_mode != "zone": + centroid = vertices.mean(axis=0) + + fig.add_trace( + go.Scatter3d( + x=[centroid[0]], + y=[centroid[1]], + z=[centroid[2]], + mode="text", + text=[name], + showlegend=False, + ) + ) + + def add_zone_labels(building): + + zone_vertices = {} + + for surface in building.idf.idfobjects[ + "BUILDINGSURFACE:DETAILED" + ]: + + zone = getattr(surface, "Zone_Name", None) + + if not zone: + continue + + vertices = get_vertices(surface) + + zone_vertices.setdefault(zone, []).append( + vertices + ) + + for zone, surfaces in zone_vertices.items(): + all_vertices = np.vstack(surfaces) + + centroid = all_vertices.mean(axis=0) + centroid[2] += 0.5 + fig.add_trace( + go.Scatter3d( + x=[centroid[0]], + y=[centroid[1]], + z=[centroid[2]], + mode="text", + text=[zone], + showlegend=False, + ) + ) + + def get_zone_types(building): + + zone_types = {} + + for zone in building.idf.idfobjects["ZONE"]: + zone_types[zone.Name] = "unconditioned" + + for thermostat in building.idf.idfobjects.get( + "ZONECONTROL:THERMOSTAT", + [] + ): + zone_types[ + thermostat.Zone_or_ZoneList_Name + ] = "conditioned" + + for surface in building.idf.idfobjects[ + "BUILDINGSURFACE:DETAILED" + ]: + + zone = getattr(surface, "Zone_Name", None) + + if not zone: + continue + + boundary = getattr( + surface, + "Outside_Boundary_Condition", + "", + ).upper() + + if boundary == "ADIABATIC": + zone_types[zone] = "adiabatic" + + return zone_types + + def get_zone_colors(building): + + zone_types = get_zone_types(building) + + zones = sorted( + { + surface.Zone_Name + for surface in building.idf.idfobjects[ + "BUILDINGSURFACE:DETAILED" + ] + if getattr(surface, "Zone_Name", None) + } + ) + + colors = {} + + idx = 0 + + for zone in zones: + + if zone_types.get(zone) == "adiabatic": + colors[zone] = ADIABATIC_COLOR + + else: + zone_type = zone_types.get( + zone, + "conditioned" + ) + + if zone_type == "adiabatic": + + colors[zone] = ADIABATIC_COLOR + + elif zone_type == "unconditioned": + + colors[zone] = UNCONDITIONED_COLOR + + else: + + colors[zone] = ( + CONDITIONED_ZONE_PALETTE[ + idx % len( + CONDITIONED_ZONE_PALETTE + ) + ] + ) + + idx += 1 + + return colors + + zone_colors = get_zone_colors(building) + + def get_surface_color(surface): + if color_mode == "zone": + + zone_name = getattr( + surface, + "Zone_Name", + None, + ) + + if zone_name in zone_colors: + return zone_colors[zone_name] + + return "lightgray" + + surface_type = surface.Surface_Type.upper() + + boundary = getattr( + surface, + "Outside_Boundary_Condition", + "", + ).upper() + + if surface_type == "ROOF": + return "dimgray" + + if surface_type == "FLOOR": + return "gray" + + if surface_type == "CEILING": + return "silver" + + if surface_type == "WALL": + + if boundary == "OUTDOORS": + return "lightgray" + + return "khaki" + + return "lightgray" + + if show_building_surfaces: + for surface in building.idf.idfobjects[ + "BUILDINGSURFACE:DETAILED" + ]: + add_surface( + get_vertices(surface), + get_surface_color(surface), + surface.Name, + ) + + if show_fenestration_surfaces: + for surface in building.idf.idfobjects[ + "FENESTRATIONSURFACE:DETAILED" + ]: + add_surface( + get_vertices(surface), + "cyan", + surface.Name, + ) + + if show_shading_surfaces: + for surface in building.idf.idfobjects[ + "SHADING:ZONE:DETAILED" + ]: + add_surface( + get_vertices(surface), + "#B784F7", + surface.Name, + ) + if show_names and color_mode == "zone": + add_zone_labels(building) + + fig.update_layout( + scene=dict( + aspectmode="data", + xaxis_title="X", + yaxis_title="Y", + zaxis_title="Z", + ), + height=900, + margin=dict( + l=0, + r=0, + b=0, + t=20, + ), + ) + + if color_mode == "zone": + + zone_types = get_zone_types(building) + + for zone, color in zone_colors.items(): + + label = zone + + if zone_types.get(zone) == "adiabatic": + label += " (adiabatic)" + + fig.add_trace( + go.Scatter3d( + x=[None], + y=[None], + z=[None], + mode="markers", + marker=dict( + size=10, + color=color, + ), + name=label, + ) + ) + + def add_legend_item(name, color): + + fig.add_trace( + go.Scatter3d( + x=[None], + y=[None], + z=[None], + mode="markers", + marker=dict( + size=10, + color=color, + ), + name=name, + ) + ) + + if color_mode == "surface_type": + add_legend_item( + "External walls", + "lightgray", + ) + + add_legend_item( + "Internal walls", + "khaki", + ) + + add_legend_item( + "Roofs", + "dimgray", + ) + + add_legend_item( + "Floors", + "gray", + ) + + add_legend_item( + "Windows", + WINDOW_COLOR, + ) + + add_legend_item( + "Shading", + SHADING_COLOR, + ) + + fig.update_layout( + legend=dict( + yanchor="top", + y=1, + xanchor="left", + x=1.02, + ) + ) + + return fig From 10ce79fb420d4f510e40c6bbdfe4663689202b05 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 14:21:57 +0200 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=E2=9C=85=20set=5Fshading=5Fgeom?= =?UTF-8?q?etry=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 166 ++++++++++++++++++++++++++++++++++++++++- tests/test_modifier.py | 25 +++++++ 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/energytool/modifier.py b/energytool/modifier.py index 22b17aa..8f8b210 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -1,4 +1,5 @@ from typing import Any +import numpy as np from energytool.base.idf_utils import get_objects_name_list from energytool.base.idfobject_utils import ( @@ -699,7 +700,7 @@ def set_system(model, description, **kwargs): def set_ahu_night_ventilation( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, + name_filter: str = None, ##m list of string ): """ Modify DesignSpecification:OutdoorAir objects to represent a @@ -811,4 +812,165 @@ def set_ahu_night_ventilation( if "Outdoor_Air_Schedule_Name" in params: obj.Outdoor_Air_Schedule_Name = ( params["Outdoor_Air_Schedule_Name"] - ) \ No newline at end of file + ) + + +def set_shading_geometry( + model: Building, + shading_type: str, + description: dict = None, + name_filter: str = None, +): + default_parameters = { + "overhang": { + "Depth": 0.5, + "Offset": 0.0, + }, + "sidefins": { + "Depth": 0.5, + "Left": True, + "Right": True, + }, + } + + 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 (name_filter is None or name_filter in window.Name) + ] + + 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"] + + 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, + ) diff --git a/tests/test_modifier.py b/tests/test_modifier.py index 34e9b57..7405b7a 100644 --- a/tests/test_modifier.py +++ b/tests/test_modifier.py @@ -25,6 +25,7 @@ set_schedule_constant, set_system, set_ahu_night_ventilation, + set_shading_geometry, update_idf_objects, reverse_kwargs, ) @@ -856,6 +857,30 @@ def test_set_ahu_night_ventilation(self, toy_building): for obj in oa_objects ) + def test_set_shading_geometry_overhang(self, toy_building): + loc_toy = deepcopy(toy_building) + + set_shading_geometry( + model=loc_toy, + shading_type="overhang", + description={ + "Depth": 0.8, + }, + name_filter="_0", + ) + + shading_surfaces = [ + obj + for obj in loc_toy.idf.idfobjects["Shading:Zone:Detailed"] + if "Window_0_overhang" in obj.Name + ] + + assert len(shading_surfaces) == 1 + + overhang = shading_surfaces[0] + + assert overhang.Name == "Window_0_overhang" + assert overhang.Number_of_Vertices == 4 # def test_envelope_shades_modifier(self, toy_building): # loc_toy = deepcopy(toy_building) From 6a20cd5e96e7466ca36540c3adadd8569a745bef Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 14:28:45 +0200 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8set=5Fshading=5Fproperties=20and?= =?UTF-8?q?=20wrapper=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 105 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/energytool/modifier.py b/energytool/modifier.py index 8f8b210..3d9d431 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -974,3 +974,108 @@ def create_shading_surface( ], window.Building_Surface_Name, ) + + +def set_shading_properties( + model: Building, + description: dict = None, + name_filter: str = None, +): + 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() + + if description is not None: + params.update(description) + + 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 name_filter is None + or name_filter in obj.Name + ] + + for shading in shading_objects: + + 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: str = None, +): + 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, + ) \ No newline at end of file From 20ffcb0893e08bccce23448eec7616dc03f222e9 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 14:54:55 +0200 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8louvers=20(horizontal=20and=20ve?= =?UTF-8?q?rtical)=20added=20to=20set=5Fshading=5Fgeometry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 201 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/energytool/modifier.py b/energytool/modifier.py index 3d9d431..6b89a2f 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -831,6 +831,17 @@ def set_shading_geometry( "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: @@ -851,6 +862,49 @@ def set_shading_geometry( and (name_filter is None or name_filter in window.Name) ] + 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( @@ -905,6 +959,18 @@ def create_shading_surface( 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"] @@ -975,6 +1041,141 @@ def create_shading_surface( 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, From b712c524d9a5ff521dc90bf6c489e855b79348c4 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 15:18:44 +0200 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=A8transmittance=20in=20set=5Fshadi?= =?UTF-8?q?ng=20properties=20can=20be=20modified,=20Transmittance=20schedu?= =?UTF-8?q?le=20as=20well.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 48 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/energytool/modifier.py b/energytool/modifier.py index 6b89a2f..f151533 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -1191,8 +1191,27 @@ def set_shading_properties( params = DEFAULT_SHADING_PROPERTIES.copy() + transmittance = None + schedule = None + if description is not None: - params.update(description) + transmittance = description.get( + "Transmittance" + ) + + schedule = description.get( + "Transmittance_Schedule" + ) + + params.pop( + "Transmittance", + None, + ) + + params.pop( + "Transmittance_Schedule", + None, + ) existing = { obj.Shading_Surface_Name: obj @@ -1212,6 +1231,33 @@ def set_shading_properties( ] 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] From 06e73b9eafeb489099ca8e111d32a93bf28428dc Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 15:31:26 +0200 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=A8set=20blind=20and=20=E2=9C=A8set?= =?UTF-8?q?=20shade=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 341 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 1 deletion(-) diff --git a/energytool/modifier.py b/energytool/modifier.py index f151533..dfe5de2 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -314,6 +314,8 @@ 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]], @@ -1325,4 +1327,341 @@ def set_shading_object( model=model, description=properties, name_filter=name_filter, - ) \ No newline at end of file + ) + +def set_shade( + model: Building, + description: dict = None, + name_filter: str = None, +): + 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 ( + name_filter is None + or name_filter in window.Name + ) + ) + ] + + 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: str = None, +): + 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 ( + name_filter is None + or name_filter in window.Name + ) + ) + ] + + 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 From e90ffc5197319861e0a02aa36cf3065219f86b00 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 16:45:44 +0200 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=93=9D=F0=9F=8E=A8=20Documentation?= =?UTF-8?q?=20of=20new=20functions=20and=20name=5Ffilters=20can=20be=20lis?= =?UTF-8?q?ts=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 366 ++++++++++++++++++++++++++++++++++------- 1 file changed, 302 insertions(+), 64 deletions(-) diff --git a/energytool/modifier.py b/energytool/modifier.py index dfe5de2..8995bc0 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Union import numpy as np from energytool.base.idf_utils import get_objects_name_list @@ -10,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"] @@ -30,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, ): @@ -67,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"] @@ -89,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 " @@ -125,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: @@ -134,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, @@ -167,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, ): """ @@ -210,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} @@ -275,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 @@ -301,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] @@ -319,8 +322,8 @@ def set_afn_surface_opening_factor( 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 @@ -350,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 @@ -450,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. @@ -507,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 @@ -589,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. @@ -621,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 @@ -640,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 @@ -702,7 +692,7 @@ def set_system(model, description, **kwargs): def set_ahu_night_ventilation( model: Building, description: dict[str, dict[str, Any]], - name_filter: str = None, ##m list of string + name_filter: Union[str, list[str]] = None, ): """ Modify DesignSpecification:OutdoorAir objects to represent a @@ -798,10 +788,7 @@ def set_ahu_night_ventilation( for obj in model.idf.idfobjects["DESIGNSPECIFICATION:OUTDOORAIR"]: - if ( - name_filter is not None - and name_filter not in obj.Name - ): + if not _matches_filter(obj.Name, name_filter): continue obj.Outdoor_Air_Method = "AirChanges/Hour" @@ -821,8 +808,63 @@ def set_shading_geometry( model: Building, shading_type: str, description: dict = None, - name_filter: str = 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, @@ -861,7 +903,7 @@ def set_shading_geometry( window for window in model.idf.idfobjects["FenestrationSurface:Detailed"] if (not window.Surface_Type or window.Surface_Type.upper() == "WINDOW") - and (name_filter is None or name_filter in window.Name) + and _matches_filter(window.Name, name_filter) ] def get_top_edge(vertices): @@ -1182,8 +1224,42 @@ def create_shading_surface( def set_shading_properties( model: Building, description: dict = None, - name_filter: str = 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, @@ -1228,8 +1304,7 @@ def set_shading_properties( list(model.idf.idfobjects["SHADING:ZONE:DETAILED"]) + list(model.idf.idfobjects["SHADING:BUILDING:DETAILED"]) ) - if name_filter is None - or name_filter in obj.Name + if _matches_filter(obj.Name, name_filter) ] for shading in shading_objects: @@ -1277,8 +1352,59 @@ def set_shading_object( model: Building, geometry: dict = None, properties: dict = None, - name_filter: str = 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, @@ -1332,8 +1458,54 @@ def set_shading_object( def set_shade( model: Building, description: dict = None, - name_filter: str = 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, @@ -1399,10 +1571,7 @@ def set_shade( not window.Surface_Type or window.Surface_Type.upper() == "WINDOW" ) - and ( - name_filter is None - or name_filter in window.Name - ) + and _matches_filter(window.Name, name_filter) ) ] @@ -1464,8 +1633,80 @@ def set_shade( def set_blind( model: Building, description: dict = None, - name_filter: str = 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", @@ -1608,10 +1849,7 @@ def set_blind( not window.Surface_Type or window.Surface_Type.upper() == "WINDOW" ) - and ( - name_filter is None - or name_filter in window.Name - ) + and _matches_filter(window.Name, name_filter) ) ] From 2577f5b22f6efcd2731729258d84b6cf12c4c21b Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 16:46:29 +0200 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=93=9D=20a=20bit=20of=20help=20on?= =?UTF-8?q?=20shading=20modifiers=20(tutorial=20update)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tutorials/CH3_Building_Modifier.ipynb | 355 ++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) diff --git a/tutorials/CH3_Building_Modifier.ipynb b/tutorials/CH3_Building_Modifier.ipynb index f4d3c5f..d8e56ea 100644 --- a/tutorials/CH3_Building_Modifier.ipynb +++ b/tutorials/CH3_Building_Modifier.ipynb @@ -734,6 +734,361 @@ "outputs": [], "execution_count": null }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "# 1.3 Shading modifiers\n", + "\n", + "EnergyTool provides several modifiers to create and manipulate solar shading systems.\n", + "\n", + "These modifiers can be grouped into three categories:\n", + "\n", + "### Geometric shadings\n", + "\n", + "Physical geometries added around windows:\n", + "\n", + "- `overhang`\n", + "- `sidefins`\n", + "- `horizontal_louvers`\n", + "- `vertical_louvers`\n", + "\n", + "These objects create actual `Shading:Zone:Detailed` surfaces in EnergyPlus and can be visualized directly using `plot_idf_geometry()`.\n", + "\n", + "Functions:\n", + "\n", + "```python\n", + "set_shading_geometry(...)\n", + "set_shading_properties(...)\n", + "set_shading_object(...)\n", + "```\n", + "\n", + "### Optical properties\n", + "\n", + "Optical properties can be assigned to shading surfaces:\n", + "\n", + "- solar reflectance\n", + "- visible reflectance\n", + "- glazing fraction\n", + "- transmittance schedules\n", + "\n", + "Function:\n", + "\n", + "```python\n", + "set_shading_properties(...)\n", + "```\n", + "\n", + "### Shades and blinds\n", + "\n", + "EnergyPlus also supports shading systems attached directly to glazing:\n", + "\n", + "- `Shade` (textile screens, roller shades)\n", + "- `Blind` (venetian blinds, BSO)\n", + "\n", + "Functions:\n", + "\n", + "```python\n", + "set_shade(...)\n", + "set_blind(...)\n", + "```\n" + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "The reference model can first be inspected using `plot_idf_geometry()`. This visualization displays the building envelope, glazing surfaces and any existing shading surfaces present in the EnergyPlus model. It provides a convenient way to verify the geometry before applying any modifier." + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from copy import deepcopy\n", + "from energytool.tools import plot_idf_geometry\n", + "\n", + "base_building = deepcopy(building)\n", + "\n", + "plot_idf_geometry(\n", + " base_building,\n", + " color_mode=\"surface_type\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "One of the simplest geometric shading systems is an overhang.\n", + "\n", + "The `set_shading_geometry()` modifier automatically generates horizontal shading surfaces above each selected window.\n", + "\n", + "Because these objects are created as explicit `Shading:Zone:Detailed` surfaces, they are directly considered by EnergyPlus during solar calculations and can be visualized with `plot_idf_geometry()`." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from energytool.modifier import (\n", + " set_shading_geometry,\n", + " set_shading_object,\n", + " set_shading_properties,\n", + " set_shade,\n", + " set_blind,\n", + ")\n", + "\n", + "overhang_building = deepcopy(building)\n", + "\n", + "set_shading_geometry(\n", + " model=overhang_building,\n", + " shading_type=\"overhang\",\n", + " description={\n", + " \"Depth\": 0.4,\n", + " },\n", + ")\n", + "\n", + "plot_idf_geometry(\n", + " overhang_building,\n", + " color_mode=\"surface_type\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "More advanced shading systems can be generated using `set_shading_object()`, which combines geometry creation and optical property assignment in a single call.\n", + "\n", + "In the following example, vertical louvers are generated in front of each window and assigned custom optical properties, including solar reflectance and transmittance. This approach is particularly useful for representing façade-integrated shading systems such as metal louvers, photovoltaic panels or vegetation screens." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "shaded_building = deepcopy(building)\n", + "\n", + "set_shading_object(\n", + " model=shaded_building,\n", + " geometry={\n", + " \"Type\": \"vertical_louvers\",\n", + " \"Depth\": 0.15,\n", + " \"Spacing\": 0.25,\n", + " \"Tilt\" : 30\n", + " },\n", + " properties={\n", + " \"Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface\": 0.15,\n", + " \"Transmittance\": 0.35,\n", + " },\n", + ")\n", + "\n", + "plot_idf_geometry(\n", + " shaded_building,\n", + " color_mode=\"surface_type\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "EnergyPlus also supports shading devices attached directly to glazing systems.\n", + "\n", + "\n", + "Unlike geometric shadings, these systems do not create explicit geometry. Instead, they modify the optical and thermal behaviour of the window itself.\n", + "\n", + "A `Shade` represents a continuous layer such as a textile screen, roller shade or blackout curtain." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "shade_building = deepcopy(building)\n", + "\n", + "set_shade(\n", + " model=shade_building,\n", + " description={\n", + " \"Name\": \"INTERIOR_SHADE\",\n", + " \"Solar_Transmittance\": 0.1,\n", + " },\n", + ")\n", + "\n", + "shade_building.idf.idfobjects[\"WINDOWMATERIAL:SHADE\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "A `Blind` represents a slatted shading device such as a venetian blind or an exterior brise-soleil orientable (BSO).\n", + "\n", + "In addition to optical properties, blinds include geometric parameters such as slat width, spacing and angle. Although these devices are not represented as explicit geometry in the EnergyPlus model, their optical behaviour is calculated using detailed blind models." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "blind_building = deepcopy(building)\n", + "\n", + "set_blind(\n", + " model=blind_building,\n", + " description={\n", + " \"Preset\": \"bso_exterior\",\n", + " },\n", + ")\n", + "\n", + "blind_building.idf.idfobjects[\"WINDOWMATERIAL:BLIND\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Unlike geometric shading surfaces, shades and blinds are not visible in `plot_idf_geometry()`.\n", + "\n", + "Their presence can be verified by inspecting the generated `WindowMaterial:Shade`, `WindowMaterial:Blind` and `WindowShadingControl` objects in the IDF model." + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Time control\n", + "\n", + "Shading devices can also be controlled dynamically using EnergyPlus schedules. This allows users to represent occupant behaviour, automated façade controls or seasonal operation strategies. In the following example, a shade is linked to a schedule named `SUMMER_SHADE`." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from energytool.modifier import update_idf_objects\n", + "\n", + "update_idf_objects(\n", + " building,\n", + " {\n", + " \"SUMMER_SHADE\": {\n", + " \"Name\": \"SUMMER_SHADE\",\n", + " \"Schedule_Type_Limits_Name\": \"Fraction\",\n", + " \"Hourly_Value\": 1,\n", + " },\n", + " },\n", + " \"Schedule:Constant\",\n", + ")\n", + "\n", + "set_shade(\n", + " model=building,\n", + " description={\n", + " \"Name\": \"INTERIOR_SHADE\",\n", + " \"Schedule\": \"SUMMER_SHADE\",\n", + " },\n", + ")\n", + "\n", + "for obj in building.idf.idfobjects[\n", + " \"WINDOWSHADINGCONTROL\"\n", + "]:\n", + " print(obj.Name)\n", + " print(obj.Schedule_Name)" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "The shade is now controlled through an EnergyPlus schedule. This allows users to represent occupant behaviour or automated solar control strategies." + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Shading transmittance schedule\n", + "\n", + "Geometric shading surfaces can also be assigned a transmittance schedule. This is particularly useful for representing vegetation systems whose density varies throughout the year.\n", + "\n", + "In the example below, a simplified deciduous tree model is created: the shading system is highly transmissive during winter and significantly more opaque during summer." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "set_shading_properties(\n", + " model=shaded_building,\n", + " description={\n", + " \"Transmittance_Schedule\":\n", + " \"DECIDUOUS_TREE\"\n", + " }\n", + ")\n", + "\n", + "update_idf_objects(\n", + " shaded_building,\n", + " {\n", + " \"DECIDUOUS_TREE\": {\n", + " \"Name\": \"DECIDUOUS_TREE\",\n", + " \"Schedule_Type_Limits_Name\": \"Fraction\",\n", + "\n", + " \"Field_1\": \"Through: 03/31\",\n", + " \"Field_2\": \"For: AllDays\",\n", + " \"Field_3\": \"Until: 24:00\",\n", + " \"Field_4\": 0.8,\n", + "\n", + " \"Field_5\": \"Through: 10/31\",\n", + " \"Field_6\": \"For: AllDays\",\n", + " \"Field_7\": \"Until: 24:00\",\n", + " \"Field_8\": 0.3,\n", + "\n", + " \"Field_9\": \"Through: 12/31\",\n", + " \"Field_10\": \"For: AllDays\",\n", + " \"Field_11\": \"Until: 24:00\",\n", + " \"Field_12\": 0.8,\n", + " }\n", + " },\n", + " \"Schedule:Compact\",\n", + ")\n", + "\n", + "set_shading_properties(\n", + " model=shaded_building,\n", + " description={\n", + " \"Transmittance_Schedule\":\n", + " \"DECIDUOUS_TREE\"\n", + " }\n", + ")\n", + "\n", + "[\n", + " (\n", + " obj.Name,\n", + " obj.Transmittance_Schedule_Name\n", + " )\n", + " for obj in shaded_building.idf.idfobjects[\n", + " \"SHADING:ZONE:DETAILED\"\n", + " ]\n", + " if obj.Transmittance_Schedule_Name\n", + "][:5]" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Unlike geometric shading systems, shades and blinds are not represented by explicit surfaces and therefore cannot be visualized using `plot_idf_geometry()`. Their presence can be verified through the generated `WindowMaterial:Shade`, `WindowMaterial:Blind` and `WindowShadingControl` objects.\n", + "\n", + "In contrast, geometric shading systems (`overhang`, `sidefins`, `horizontal_louvers`, `vertical_louvers`) generate explicit EnergyPlus surfaces and can therefore be inspected directly using the geometry viewer." + ] + }, { "metadata": {}, "cell_type": "code", From 05a8bf9ec58b2b5331e79499babf1b349ba824e0 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Thu, 4 Jun 2026 17:06:25 +0200 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=90=9B=E2=9C=85=20tests=20and=20fix?= =?UTF-8?q?=20of=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/modifier.py | 12 +- tests/test_modifier.py | 344 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 327 insertions(+), 29 deletions(-) diff --git a/energytool/modifier.py b/energytool/modifier.py index 8995bc0..d2be7fc 100644 --- a/energytool/modifier.py +++ b/energytool/modifier.py @@ -1281,15 +1281,9 @@ def set_shading_properties( "Transmittance_Schedule" ) - params.pop( - "Transmittance", - None, - ) - - params.pop( - "Transmittance_Schedule", - None, - ) + for key, value in description.items(): + if key not in ("Transmittance", "Transmittance_Schedule"): + params[key] = value existing = { obj.Shading_Surface_Name: obj diff --git a/tests/test_modifier.py b/tests/test_modifier.py index 7405b7a..816db7b 100644 --- a/tests/test_modifier.py +++ b/tests/test_modifier.py @@ -26,6 +26,10 @@ set_system, set_ahu_night_ventilation, set_shading_geometry, + set_shading_properties, + set_shading_object, + set_shade, + set_blind, update_idf_objects, reverse_kwargs, ) @@ -149,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", @@ -857,30 +879,312 @@ def test_set_ahu_night_ventilation(self, toy_building): for obj in oa_objects ) - def test_set_shading_geometry_overhang(self, toy_building): - loc_toy = deepcopy(toy_building) - - set_shading_geometry( - model=loc_toy, - shading_type="overhang", + 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={ - "Depth": 0.8, + "Name": "MY_SHADE", + "Solar_Transmittance": 0.05, + "Shading_Type": "ExteriorShade", + "Schedule": "Shading_control_bis", }, name_filter="_0", ) - - shading_surfaces = [ - obj - for obj in loc_toy.idf.idfobjects["Shading:Zone:Detailed"] - if "Window_0_overhang" in obj.Name - ] - - assert len(shading_surfaces) == 1 - - overhang = shading_surfaces[0] - - assert overhang.Name == "Window_0_overhang" - assert overhang.Number_of_Vertices == 4 + 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) From 58092f2ee82ddf68cab7da23c4bfd615b23ba673 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Fri, 5 Jun 2026 10:17:25 +0200 Subject: [PATCH 11/11] =?UTF-8?q?=E2=8F=AA=EF=B8=8F=20back=20to=20normal,?= =?UTF-8?q?=20we'll=20change=20that=20in=20a=20future=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/tools.py | 507 +------------------------- tutorials/CH3_Building_Modifier.ipynb | 355 ------------------ 2 files changed, 1 insertion(+), 861 deletions(-) diff --git a/energytool/tools.py b/energytool/tools.py index 79c112a..61b5a44 100644 --- a/energytool/tools.py +++ b/energytool/tools.py @@ -1,8 +1,5 @@ import pandas as pd import datetime as dt -import numpy as np -import plotly.graph_objects as go -import plotly.colors as pc def to_list(f_input): @@ -102,506 +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 - ) - - -ZONE_PALETTE = ( - pc.qualitative.Safe - + pc.qualitative.Set3 -) -ADIABATIC_COLOR = "coral" -UNCONDITIONED_COLOR = "#9ECAE1" -WINDOW_COLOR = "cyan" -SHADING_COLOR = "mediumpurple" -CONDITIONED_ZONE_PALETTE = [ - "#67000d", - "#a50f15", - "#cb181d", - "#ef3b2c", - "#fb6a4a", - "#fc9272", - "#fcbba1", -] - -def plot_idf_geometry( - building, - show_building_surfaces=True, - show_fenestration_surfaces=True, - show_shading_surfaces=True, - show_names=False, - opacity=0.7, - color_mode="surface_type", -): - """ - Interactive 3D visualization of an EnergyPlus building geometry. - - This function displays the geometry contained in an IDF model using - Plotly. Building surfaces, fenestration surfaces and shading surfaces - can be visualized independently. The resulting figure can be explored - interactively (rotation, zoom, pan). - - Parameters - ---------- - building : Building - EnergyTool Building object containing an IDF model. - - show_building_surfaces : bool, default=True - Display BuildingSurface:Detailed objects. - - show_fenestration_surfaces : bool, default=True - Display FenestrationSurface:Detailed objects. - - show_shading_surfaces : bool, default=True - Display Shading:Zone:Detailed objects. - - show_names : bool, default=False - Display labels on the geometry. - - - In ``surface_type`` mode, surface names are displayed. - - In ``zone`` mode, thermal zone names are displayed at the - centroid of each zone. - - opacity : float, default=0.7 - Surface opacity between 0 and 1. - - color_mode : {"surface_type", "zone"}, default="surface_type" - Controls how surfaces are colored. - - ``surface_type``: - - External walls: light grey - - Internal walls: khaki - - Roofs: dark grey - - Floors: grey - - Windows: cyan - - Shading surfaces: purple - - ``zone``: - Colors are assigned according to thermal zone type. - - - Conditioned zones: red color palette - - Adiabatic zones: orange/coral - - Unconditioned zones: blue - - Returns - ------- - plotly.graph_objects.Figure - Interactive Plotly figure. - - Notes - ----- - This function is intended for model inspection and debugging. - - Typical use cases include: - - - Checking generated geometry - - Verifying window locations - - Validating shading modifiers - (overhangs, side fins, vegetation, PV systems, etc.) - - Visualizing thermal zoning - - Inspecting adiabatic and conditioned zones - - Examples - -------- - Display the complete building geometry: - - >>> plot_idf_geometry(building).show() - - Display thermal zones: - - >>> plot_idf_geometry( - ... building, - ... color_mode="zone", - ... show_names=True, - ... ).show() - - Display only windows and shading devices: - - >>> plot_idf_geometry( - ... building, - ... show_building_surfaces=False, - ... ).show() - - Visualize the effect of a shading modifier: - - >>> set_shading_geometry( - ... building, - ... shading_type="overhang", - ... description={"Depth": 1.0}, - ... ) - >>> - >>> plot_idf_geometry(building).show() - """ - - fig = go.Figure() - - def get_vertices(surface): - n_vertices = int(surface.Number_of_Vertices) - - return np.array( - [ - [ - float(getattr(surface, f"Vertex_{i}_Xcoordinate")), - float(getattr(surface, f"Vertex_{i}_Ycoordinate")), - float(getattr(surface, f"Vertex_{i}_Zcoordinate")), - ] - for i in range(1, n_vertices + 1) - ] - ) - - def add_surface(vertices, color, name=None): - if len(vertices) < 3: - return - - x = vertices[:, 0] - y = vertices[:, 1] - z = vertices[:, 2] - - i = [] - j = [] - k = [] - - for idx in range(1, len(vertices) - 1): - i.append(0) - j.append(idx) - k.append(idx + 1) - - fig.add_trace( - go.Mesh3d( - x=x, - y=y, - z=z, - i=i, - j=j, - k=k, - color=color, - opacity=opacity, - hovertext=name, - hoverinfo="text", - showscale=False, - ) - ) - - vertices_closed = np.vstack([vertices, vertices[0]]) - - fig.add_trace( - go.Scatter3d( - x=vertices_closed[:, 0], - y=vertices_closed[:, 1], - z=vertices_closed[:, 2], - mode="lines", - line=dict( - color="black", - width=2, - ), - showlegend=False, - hoverinfo="skip", - ) - ) - - if show_names and color_mode != "zone": - centroid = vertices.mean(axis=0) - - fig.add_trace( - go.Scatter3d( - x=[centroid[0]], - y=[centroid[1]], - z=[centroid[2]], - mode="text", - text=[name], - showlegend=False, - ) - ) - - def add_zone_labels(building): - - zone_vertices = {} - - for surface in building.idf.idfobjects[ - "BUILDINGSURFACE:DETAILED" - ]: - - zone = getattr(surface, "Zone_Name", None) - - if not zone: - continue - - vertices = get_vertices(surface) - - zone_vertices.setdefault(zone, []).append( - vertices - ) - - for zone, surfaces in zone_vertices.items(): - all_vertices = np.vstack(surfaces) - - centroid = all_vertices.mean(axis=0) - centroid[2] += 0.5 - fig.add_trace( - go.Scatter3d( - x=[centroid[0]], - y=[centroid[1]], - z=[centroid[2]], - mode="text", - text=[zone], - showlegend=False, - ) - ) - - def get_zone_types(building): - - zone_types = {} - - for zone in building.idf.idfobjects["ZONE"]: - zone_types[zone.Name] = "unconditioned" - - for thermostat in building.idf.idfobjects.get( - "ZONECONTROL:THERMOSTAT", - [] - ): - zone_types[ - thermostat.Zone_or_ZoneList_Name - ] = "conditioned" - - for surface in building.idf.idfobjects[ - "BUILDINGSURFACE:DETAILED" - ]: - - zone = getattr(surface, "Zone_Name", None) - - if not zone: - continue - - boundary = getattr( - surface, - "Outside_Boundary_Condition", - "", - ).upper() - - if boundary == "ADIABATIC": - zone_types[zone] = "adiabatic" - - return zone_types - - def get_zone_colors(building): - - zone_types = get_zone_types(building) - - zones = sorted( - { - surface.Zone_Name - for surface in building.idf.idfobjects[ - "BUILDINGSURFACE:DETAILED" - ] - if getattr(surface, "Zone_Name", None) - } - ) - - colors = {} - - idx = 0 - - for zone in zones: - - if zone_types.get(zone) == "adiabatic": - colors[zone] = ADIABATIC_COLOR - - else: - zone_type = zone_types.get( - zone, - "conditioned" - ) - - if zone_type == "adiabatic": - - colors[zone] = ADIABATIC_COLOR - - elif zone_type == "unconditioned": - - colors[zone] = UNCONDITIONED_COLOR - - else: - - colors[zone] = ( - CONDITIONED_ZONE_PALETTE[ - idx % len( - CONDITIONED_ZONE_PALETTE - ) - ] - ) - - idx += 1 - - return colors - - zone_colors = get_zone_colors(building) - - def get_surface_color(surface): - if color_mode == "zone": - - zone_name = getattr( - surface, - "Zone_Name", - None, - ) - - if zone_name in zone_colors: - return zone_colors[zone_name] - - return "lightgray" - - surface_type = surface.Surface_Type.upper() - - boundary = getattr( - surface, - "Outside_Boundary_Condition", - "", - ).upper() - - if surface_type == "ROOF": - return "dimgray" - - if surface_type == "FLOOR": - return "gray" - - if surface_type == "CEILING": - return "silver" - - if surface_type == "WALL": - - if boundary == "OUTDOORS": - return "lightgray" - - return "khaki" - - return "lightgray" - - if show_building_surfaces: - for surface in building.idf.idfobjects[ - "BUILDINGSURFACE:DETAILED" - ]: - add_surface( - get_vertices(surface), - get_surface_color(surface), - surface.Name, - ) - - if show_fenestration_surfaces: - for surface in building.idf.idfobjects[ - "FENESTRATIONSURFACE:DETAILED" - ]: - add_surface( - get_vertices(surface), - "cyan", - surface.Name, - ) - - if show_shading_surfaces: - for surface in building.idf.idfobjects[ - "SHADING:ZONE:DETAILED" - ]: - add_surface( - get_vertices(surface), - "#B784F7", - surface.Name, - ) - if show_names and color_mode == "zone": - add_zone_labels(building) - - fig.update_layout( - scene=dict( - aspectmode="data", - xaxis_title="X", - yaxis_title="Y", - zaxis_title="Z", - ), - height=900, - margin=dict( - l=0, - r=0, - b=0, - t=20, - ), - ) - - if color_mode == "zone": - - zone_types = get_zone_types(building) - - for zone, color in zone_colors.items(): - - label = zone - - if zone_types.get(zone) == "adiabatic": - label += " (adiabatic)" - - fig.add_trace( - go.Scatter3d( - x=[None], - y=[None], - z=[None], - mode="markers", - marker=dict( - size=10, - color=color, - ), - name=label, - ) - ) - - def add_legend_item(name, color): - - fig.add_trace( - go.Scatter3d( - x=[None], - y=[None], - z=[None], - mode="markers", - marker=dict( - size=10, - color=color, - ), - name=name, - ) - ) - - if color_mode == "surface_type": - add_legend_item( - "External walls", - "lightgray", - ) - - add_legend_item( - "Internal walls", - "khaki", - ) - - add_legend_item( - "Roofs", - "dimgray", - ) - - add_legend_item( - "Floors", - "gray", - ) - - add_legend_item( - "Windows", - WINDOW_COLOR, - ) - - add_legend_item( - "Shading", - SHADING_COLOR, - ) - - fig.update_layout( - legend=dict( - yanchor="top", - y=1, - xanchor="left", - x=1.02, - ) - ) - - return fig + ) \ No newline at end of file diff --git a/tutorials/CH3_Building_Modifier.ipynb b/tutorials/CH3_Building_Modifier.ipynb index d8e56ea..f4d3c5f 100644 --- a/tutorials/CH3_Building_Modifier.ipynb +++ b/tutorials/CH3_Building_Modifier.ipynb @@ -734,361 +734,6 @@ "outputs": [], "execution_count": null }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "# 1.3 Shading modifiers\n", - "\n", - "EnergyTool provides several modifiers to create and manipulate solar shading systems.\n", - "\n", - "These modifiers can be grouped into three categories:\n", - "\n", - "### Geometric shadings\n", - "\n", - "Physical geometries added around windows:\n", - "\n", - "- `overhang`\n", - "- `sidefins`\n", - "- `horizontal_louvers`\n", - "- `vertical_louvers`\n", - "\n", - "These objects create actual `Shading:Zone:Detailed` surfaces in EnergyPlus and can be visualized directly using `plot_idf_geometry()`.\n", - "\n", - "Functions:\n", - "\n", - "```python\n", - "set_shading_geometry(...)\n", - "set_shading_properties(...)\n", - "set_shading_object(...)\n", - "```\n", - "\n", - "### Optical properties\n", - "\n", - "Optical properties can be assigned to shading surfaces:\n", - "\n", - "- solar reflectance\n", - "- visible reflectance\n", - "- glazing fraction\n", - "- transmittance schedules\n", - "\n", - "Function:\n", - "\n", - "```python\n", - "set_shading_properties(...)\n", - "```\n", - "\n", - "### Shades and blinds\n", - "\n", - "EnergyPlus also supports shading systems attached directly to glazing:\n", - "\n", - "- `Shade` (textile screens, roller shades)\n", - "- `Blind` (venetian blinds, BSO)\n", - "\n", - "Functions:\n", - "\n", - "```python\n", - "set_shade(...)\n", - "set_blind(...)\n", - "```\n" - ] - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": "The reference model can first be inspected using `plot_idf_geometry()`. This visualization displays the building envelope, glazing surfaces and any existing shading surfaces present in the EnergyPlus model. It provides a convenient way to verify the geometry before applying any modifier." - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "from copy import deepcopy\n", - "from energytool.tools import plot_idf_geometry\n", - "\n", - "base_building = deepcopy(building)\n", - "\n", - "plot_idf_geometry(\n", - " base_building,\n", - " color_mode=\"surface_type\",\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "One of the simplest geometric shading systems is an overhang.\n", - "\n", - "The `set_shading_geometry()` modifier automatically generates horizontal shading surfaces above each selected window.\n", - "\n", - "Because these objects are created as explicit `Shading:Zone:Detailed` surfaces, they are directly considered by EnergyPlus during solar calculations and can be visualized with `plot_idf_geometry()`." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "from energytool.modifier import (\n", - " set_shading_geometry,\n", - " set_shading_object,\n", - " set_shading_properties,\n", - " set_shade,\n", - " set_blind,\n", - ")\n", - "\n", - "overhang_building = deepcopy(building)\n", - "\n", - "set_shading_geometry(\n", - " model=overhang_building,\n", - " shading_type=\"overhang\",\n", - " description={\n", - " \"Depth\": 0.4,\n", - " },\n", - ")\n", - "\n", - "plot_idf_geometry(\n", - " overhang_building,\n", - " color_mode=\"surface_type\",\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "More advanced shading systems can be generated using `set_shading_object()`, which combines geometry creation and optical property assignment in a single call.\n", - "\n", - "In the following example, vertical louvers are generated in front of each window and assigned custom optical properties, including solar reflectance and transmittance. This approach is particularly useful for representing façade-integrated shading systems such as metal louvers, photovoltaic panels or vegetation screens." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "shaded_building = deepcopy(building)\n", - "\n", - "set_shading_object(\n", - " model=shaded_building,\n", - " geometry={\n", - " \"Type\": \"vertical_louvers\",\n", - " \"Depth\": 0.15,\n", - " \"Spacing\": 0.25,\n", - " \"Tilt\" : 30\n", - " },\n", - " properties={\n", - " \"Diffuse_Solar_Reflectance_of_Unglazed_Part_of_Shading_Surface\": 0.15,\n", - " \"Transmittance\": 0.35,\n", - " },\n", - ")\n", - "\n", - "plot_idf_geometry(\n", - " shaded_building,\n", - " color_mode=\"surface_type\",\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "EnergyPlus also supports shading devices attached directly to glazing systems.\n", - "\n", - "\n", - "Unlike geometric shadings, these systems do not create explicit geometry. Instead, they modify the optical and thermal behaviour of the window itself.\n", - "\n", - "A `Shade` represents a continuous layer such as a textile screen, roller shade or blackout curtain." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "shade_building = deepcopy(building)\n", - "\n", - "set_shade(\n", - " model=shade_building,\n", - " description={\n", - " \"Name\": \"INTERIOR_SHADE\",\n", - " \"Solar_Transmittance\": 0.1,\n", - " },\n", - ")\n", - "\n", - "shade_building.idf.idfobjects[\"WINDOWMATERIAL:SHADE\"]" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "A `Blind` represents a slatted shading device such as a venetian blind or an exterior brise-soleil orientable (BSO).\n", - "\n", - "In addition to optical properties, blinds include geometric parameters such as slat width, spacing and angle. Although these devices are not represented as explicit geometry in the EnergyPlus model, their optical behaviour is calculated using detailed blind models." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "blind_building = deepcopy(building)\n", - "\n", - "set_blind(\n", - " model=blind_building,\n", - " description={\n", - " \"Preset\": \"bso_exterior\",\n", - " },\n", - ")\n", - "\n", - "blind_building.idf.idfobjects[\"WINDOWMATERIAL:BLIND\"]" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "Unlike geometric shading surfaces, shades and blinds are not visible in `plot_idf_geometry()`.\n", - "\n", - "Their presence can be verified by inspecting the generated `WindowMaterial:Shade`, `WindowMaterial:Blind` and `WindowShadingControl` objects in the IDF model." - ] - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "## Time control\n", - "\n", - "Shading devices can also be controlled dynamically using EnergyPlus schedules. This allows users to represent occupant behaviour, automated façade controls or seasonal operation strategies. In the following example, a shade is linked to a schedule named `SUMMER_SHADE`." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "from energytool.modifier import update_idf_objects\n", - "\n", - "update_idf_objects(\n", - " building,\n", - " {\n", - " \"SUMMER_SHADE\": {\n", - " \"Name\": \"SUMMER_SHADE\",\n", - " \"Schedule_Type_Limits_Name\": \"Fraction\",\n", - " \"Hourly_Value\": 1,\n", - " },\n", - " },\n", - " \"Schedule:Constant\",\n", - ")\n", - "\n", - "set_shade(\n", - " model=building,\n", - " description={\n", - " \"Name\": \"INTERIOR_SHADE\",\n", - " \"Schedule\": \"SUMMER_SHADE\",\n", - " },\n", - ")\n", - "\n", - "for obj in building.idf.idfobjects[\n", - " \"WINDOWSHADINGCONTROL\"\n", - "]:\n", - " print(obj.Name)\n", - " print(obj.Schedule_Name)" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": "The shade is now controlled through an EnergyPlus schedule. This allows users to represent occupant behaviour or automated solar control strategies." - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "## Shading transmittance schedule\n", - "\n", - "Geometric shading surfaces can also be assigned a transmittance schedule. This is particularly useful for representing vegetation systems whose density varies throughout the year.\n", - "\n", - "In the example below, a simplified deciduous tree model is created: the shading system is highly transmissive during winter and significantly more opaque during summer." - ] - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "set_shading_properties(\n", - " model=shaded_building,\n", - " description={\n", - " \"Transmittance_Schedule\":\n", - " \"DECIDUOUS_TREE\"\n", - " }\n", - ")\n", - "\n", - "update_idf_objects(\n", - " shaded_building,\n", - " {\n", - " \"DECIDUOUS_TREE\": {\n", - " \"Name\": \"DECIDUOUS_TREE\",\n", - " \"Schedule_Type_Limits_Name\": \"Fraction\",\n", - "\n", - " \"Field_1\": \"Through: 03/31\",\n", - " \"Field_2\": \"For: AllDays\",\n", - " \"Field_3\": \"Until: 24:00\",\n", - " \"Field_4\": 0.8,\n", - "\n", - " \"Field_5\": \"Through: 10/31\",\n", - " \"Field_6\": \"For: AllDays\",\n", - " \"Field_7\": \"Until: 24:00\",\n", - " \"Field_8\": 0.3,\n", - "\n", - " \"Field_9\": \"Through: 12/31\",\n", - " \"Field_10\": \"For: AllDays\",\n", - " \"Field_11\": \"Until: 24:00\",\n", - " \"Field_12\": 0.8,\n", - " }\n", - " },\n", - " \"Schedule:Compact\",\n", - ")\n", - "\n", - "set_shading_properties(\n", - " model=shaded_building,\n", - " description={\n", - " \"Transmittance_Schedule\":\n", - " \"DECIDUOUS_TREE\"\n", - " }\n", - ")\n", - "\n", - "[\n", - " (\n", - " obj.Name,\n", - " obj.Transmittance_Schedule_Name\n", - " )\n", - " for obj in shaded_building.idf.idfobjects[\n", - " \"SHADING:ZONE:DETAILED\"\n", - " ]\n", - " if obj.Transmittance_Schedule_Name\n", - "][:5]" - ], - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "markdown", - "source": [ - "Unlike geometric shading systems, shades and blinds are not represented by explicit surfaces and therefore cannot be visualized using `plot_idf_geometry()`. Their presence can be verified through the generated `WindowMaterial:Shade`, `WindowMaterial:Blind` and `WindowShadingControl` objects.\n", - "\n", - "In contrast, geometric shading systems (`overhang`, `sidefins`, `horizontal_louvers`, `vertical_louvers`) generate explicit EnergyPlus surfaces and can therefore be inspected directly using the geometry viewer." - ] - }, { "metadata": {}, "cell_type": "code",