From 6afb9d780aa1a8c143753e87deef90deaea50b51 Mon Sep 17 00:00:00 2001 From: Tesshub Date: Fri, 24 Jul 2026 14:58:06 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=E2=9C=85=20fix=20vertex=20count=20?= =?UTF-8?q?fallback=20and=20support=20all=20shading=20surface=20types=20in?= =?UTF-8?q?=20plot=5Fidf=5Fgeometry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- energytool/tools.py | 34 ++++++++++--- tests/test_tools.py | 113 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/energytool/tools.py b/energytool/tools.py index f77bf37..c234b9c 100644 --- a/energytool/tools.py +++ b/energytool/tools.py @@ -152,7 +152,8 @@ def plot_idf_geometry( Display FenestrationSurface:Detailed objects. show_shading_surfaces : bool, default=True - Display Shading:Zone:Detailed objects. + Display Shading:Zone:Detailed, Shading:Building:Detailed and + Shading:Site:Detailed objects. show_names : bool, default=False Display labels on the geometry. @@ -238,7 +239,19 @@ def plot_idf_geometry( _label_traces = [] def get_vertices(surface): - n_vertices = int(surface.Number_of_Vertices) + # "Number of Vertices" defaults to "autocalculate" in the E+ IDD and is + # frequently left blank by IDF generators (e.g. Honeybee/OpenStudio + # exports), so it can't always be relied upon: fall back to counting + # the actually populated Vertex_i_Xcoordinate fields in that case. + try: + n_vertices = int(surface.Number_of_Vertices) + except (TypeError, ValueError): + n_vertices = 0 + while getattr(surface, f"Vertex_{n_vertices + 1}_Xcoordinate", "") not in ( + "", + None, + ): + n_vertices += 1 return np.array( [ @@ -462,10 +475,19 @@ def get_building_surface_group(surface): ) if show_shading_surfaces: - for surface in building.idf.idfobjects["SHADING:ZONE:DETAILED"]: - add_surface( - get_vertices(surface), "shading", "Shading", SHADING_COLOR, surface.Name, - ) + # Zone-attached (e.g. overhangs, fins), building-attached (e.g. PV + # panels, balcony railings) and site-attached (e.g. neighbouring + # building masks) shading surfaces all share the same vertex-list + # schema. + for shading_key in ( + "SHADING:ZONE:DETAILED", + "SHADING:BUILDING:DETAILED", + "SHADING:SITE:DETAILED", + ): + for surface in building.idf.idfobjects[shading_key]: + add_surface( + get_vertices(surface), "shading", "Shading", SHADING_COLOR, surface.Name, + ) for key, group in groups.items(): fig.add_trace( diff --git a/tests/test_tools.py b/tests/test_tools.py index 43657b5..85966e4 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -306,3 +306,116 @@ def test_opacity(self, geo_building): fig = tl.plot_idf_geometry(geo_building, opacity=0.3) mesh_traces = [t for t in fig.data if isinstance(t, go.Mesh3d)] assert all(t.opacity == 0.3 for t in mesh_traces) + + +@pytest.fixture(scope="module") +def geo_building_blank_num_vertices(): + """Mimics IDF generators (e.g. Honeybee/OpenStudio) that commonly leave + "Number of Vertices" unset, relying on EnergyPlus's own autocalculation + rather than eppy's.""" + try: + IDF.setiddname(RESOURCES_PATH / "Energy+.idd") + except eppy.modeleditor.IDDAlreadySetError: + pass + + idf = IDF(StringIO("")) + idf.idfname = None + + idf.newidfobject(key="Zone", Name="ConditionedZone") + + def add_surface(obj_type, name, vertices, num_vertices_value, **attrs): + s = idf.newidfobject(key=obj_type, Name=name) + for k, v in attrs.items(): + setattr(s, k, v) + if num_vertices_value is not None: + s.Number_of_Vertices = num_vertices_value + for idx, (x, y, z) in enumerate(vertices, start=1): + setattr(s, f"Vertex_{idx}_Xcoordinate", x) + setattr(s, f"Vertex_{idx}_Ycoordinate", y) + setattr(s, f"Vertex_{idx}_Zcoordinate", z) + return s + + # left at the IDD default ("autocalculate"), as when the field is never touched + add_surface( + "BuildingSurface:Detailed", "ExtWall", + [(0, 0, 0), (3, 0, 0), (3, 0, 3), (0, 0, 3)], + None, + Surface_Type="Wall", + Outside_Boundary_Condition="Outdoors", + Zone_Name="ConditionedZone", + ) + # explicitly blank, as found in some Honeybee-exported IDF text files + add_surface( + "Shading:Zone:Detailed", "Overhang1", + [(-0.5, 0, 2.5), (2.5, 0, 2.5), (2.5, -1, 2.5), (-0.5, -1, 2.5)], + "", + ) + + return FakeBuilding(idf) + + +class TestPlotIdfGeometryBlankNumVertices: + def test_does_not_raise_and_infers_vertex_count(self, geo_building_blank_num_vertices): + fig = tl.plot_idf_geometry(geo_building_blank_num_vertices) + assert isinstance(fig, go.Figure) + + mesh_traces = [t for t in fig.data if isinstance(t, go.Mesh3d)] + # 4 vertices per surface (wall + shading), correctly inferred despite + # a missing/blank "Number of Vertices" field + assert len(mesh_traces) == 2 + assert all(len(t.x) == 4 for t in mesh_traces) + + +@pytest.fixture(scope="module") +def geo_building_mixed_shading(): + """Honeybee/OpenStudio exports commonly represent context shading (PV + panels, balcony railings, neighbouring building masks) as + Shading:Building:Detailed / Shading:Site:Detailed rather than + Shading:Zone:Detailed.""" + try: + IDF.setiddname(RESOURCES_PATH / "Energy+.idd") + except eppy.modeleditor.IDDAlreadySetError: + pass + + idf = IDF(StringIO("")) + idf.idfname = None + + def add_surface(obj_type, name, vertices): + s = idf.newidfobject(key=obj_type, Name=name) + s.Number_of_Vertices = len(vertices) + for idx, (x, y, z) in enumerate(vertices, start=1): + setattr(s, f"Vertex_{idx}_Xcoordinate", x) + setattr(s, f"Vertex_{idx}_Ycoordinate", y) + setattr(s, f"Vertex_{idx}_Zcoordinate", z) + return s + + add_surface( + "Shading:Zone:Detailed", "Overhang1", + [(-0.5, 0, 2.5), (2.5, 0, 2.5), (2.5, -1, 2.5), (-0.5, -1, 2.5)], + ) + add_surface( + "Shading:Building:Detailed", "PVPanel1", + [(0, -0.5, 1), (3, -0.5, 1), (3, -0.5, 2), (0, -0.5, 2)], + ) + add_surface( + "Shading:Site:Detailed", "NeighbourMask1", + [(10, 0, 0), (10, 10, 0), (10, 10, 8), (10, 0, 8)], + ) + + return FakeBuilding(idf) + + +class TestPlotIdfGeometryMixedShading: + def test_all_shading_types_are_displayed(self, geo_building_mixed_shading): + fig = tl.plot_idf_geometry(geo_building_mixed_shading) + + shading_traces = [t for t in fig.data if isinstance(t, go.Mesh3d) and t.name == "Shading"] + assert len(shading_traces) == 1 + + names = set(shading_traces[0].text) + assert names == {"Overhang1", "PVPanel1", "NeighbourMask1"} + assert len(shading_traces[0].x) == 12 # 3 surfaces x 4 vertices + + def test_hide_shading_hides_all_types(self, geo_building_mixed_shading): + fig = tl.plot_idf_geometry(geo_building_mixed_shading, show_shading_surfaces=False) + assert not any(isinstance(t, go.Mesh3d) and t.name == "Shading" for t in fig.data)