diff --git a/setup.py b/setup.py index 60fea021e..e65b58be9 100644 --- a/setup.py +++ b/setup.py @@ -81,5 +81,5 @@ "Topic :: Scientific/Engineering", "Topic :: Utilities", ], - install_requires=["mako", "pytest", "pandas", "numpy"], + install_requires=["mako", "pytest", "pandas", "numpy", "plotly"], ) diff --git a/teaser/data/output/reports/__init__.py b/teaser/data/output/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/teaser/data/output/reports/model_report.py b/teaser/data/output/reports/model_report.py new file mode 100644 index 000000000..21def29f8 --- /dev/null +++ b/teaser/data/output/reports/model_report.py @@ -0,0 +1,764 @@ +"""holds functions to create a report for a TEASER project model""" + +import html +import os +import csv +from collections import OrderedDict + +import plotly.graph_objects as go + + +def localize_floats(row): + return [str(el).replace(".", ",") if isinstance(el, float) else el for el in row] + + +def create_model_report(prj, path): + """Creates model report for the project. + + This creates a html and .csv model report for each building of the project + for easier analysis of the created buildings. Currently only the basic + values for areas and U-values and an abstracted 3D visualization are part of + the report. Wall constructions and similar things might come in the future. + + Parameters + ---------- + + prj : Project + project that the report should be created for + path : string + path of the base project export + + """ + + prj_data = OrderedDict() + for bldg in prj.buildings: + bldg_name = bldg.name + prj_data[bldg_name] = OrderedDict() + # create keys + if bldg.type_of_building: + prj_data[bldg_name]["Type of Building"] = bldg.type_of_building + prj_data[bldg_name]["Net Ground Area"] = bldg.net_leased_area + prj_data[bldg_name]["Ground Floor Area"] = 0 + prj_data[bldg_name]["Roof Area"] = 0 + prj_data[bldg_name]["Floor Height"] = bldg.height_of_floors + prj_data[bldg_name]["Number of Floors"] = bldg.number_of_floors + prj_data[bldg_name]["Total Air Volume"] = bldg.volume + prj_data[bldg_name]["Number of Zones"] = len(bldg.thermal_zones) + prj_data[bldg_name]["Year of Construction"] = bldg.year_of_construction + prj_data[bldg_name]["Calculated Heat Load"] = bldg.sum_heat_load + prj_data[bldg_name]["Calculated Cooling Load"] = bldg.sum_cooling_load + + # todo use bldg.*_names if existing + + prj_data[bldg_name]["Outerwall Area"] = {} + outer_wall_area_total = 0 + + outer_areas = bldg.outer_area + # make sure that lowest values of orient come first + sorted_keys = sorted(outer_areas.keys()) + sorted_outer_areas = {key: outer_areas[key] for key in sorted_keys} + for orient in outer_areas: + # some archetypes use floats, some integers for orientation in + # TEASER + orient = float(orient) + if orient == -1: + prj_data[bldg_name]["Roof Area"] += sorted_outer_areas[orient] + elif orient == -2: + prj_data[bldg_name]["Ground Floor Area"] += sorted_outer_areas[orient] + else: + if orient not in prj_data[bldg_name]["Outerwall Area"]: + prj_data[bldg_name]["Outerwall Area"][orient] = 0 + prj_data[bldg_name]["Outerwall Area"][orient] += sorted_outer_areas[ + orient + ] + outer_wall_area_total += sorted_outer_areas[orient] + window_area_total = 0 + prj_data[bldg_name]["Outerwall Area Total"] = outer_wall_area_total + prj_data[bldg_name]["Window Area"] = {} + + window_areas = bldg.window_area + # make sure that lowest values of orient come first + sorted_keys = sorted(window_areas.keys()) + sorted_window_areas = {key: window_areas[key] for key in sorted_keys} + + for orient in sorted_window_areas: + orient = float(orient) + if orient not in prj_data[bldg_name]["Window Area"]: + prj_data[bldg_name]["Window Area"][orient] = 0 + prj_data[bldg_name]["Window Area"][orient] += sorted_window_areas[orient] + window_area_total += sorted_window_areas[orient] + + prj_data[bldg_name]["Window Area Total"] = window_area_total + prj_data[bldg_name]["Window-Wall-Ratio"] = ( + window_area_total / outer_wall_area_total + ) + prj_data[bldg_name]["Inner Wall Area"] = bldg.get_inner_wall_area() + + u_values_win = [] + g_values_windows = [] + u_values_ground_floor = [] + u_values_inner_wall = [] + u_values_outer_wall = [] + u_values_door = [] + u_values_roof = [] + u_values_ceiling = [] + for tz in bldg.thermal_zones: + for window in tz.windows: + u_values_win.append(1 / (window.r_conduc * window.area)) + g_values_windows.append(window.g_value) + for inner_wall in tz.inner_walls: + u_values_inner_wall.append(1 / (inner_wall.r_conduc * inner_wall.area)) + for outer_wall in tz.outer_walls: + u_values_outer_wall.append(1 / (outer_wall.r_conduc * outer_wall.area)) + for rooftop in tz.rooftops: + u_values_roof.append(1 / (rooftop.r_conduc * rooftop.area)) + for ground_floor in tz.ground_floors: + u_values_ground_floor.append( + 1 / (ground_floor.r_conduc * ground_floor.area) + ) + for ceiling in tz.ceilings: + u_values_ceiling.append(1 / (ceiling.r_conduc * ceiling.area)) + for floor in tz.floors: + u_values_ceiling.append(1 / (floor.r_conduc * floor.area)) + for door in tz.doors: + u_values_door.append(1 / (door.r_conduc * door.area)) + if len(u_values_outer_wall) > 0: + prj_data[bldg_name]["UValue Outerwall"] = sum(u_values_outer_wall) / len( + u_values_outer_wall + ) + else: + prj_data[bldg_name]["UValue Outerwall"] = 0 + if len(u_values_inner_wall) > 0: + prj_data[bldg_name]["UValue Innerwall"] = sum(u_values_inner_wall) / len( + u_values_inner_wall + ) + else: + prj_data[bldg_name]["UValue Innerwall"] = 0 + + if len(u_values_win) > 0: + prj_data[bldg_name]["UValue Window"] = sum(u_values_win) / len(u_values_win) + else: + prj_data[bldg_name]["UValue Window"] = 0 + + if len(u_values_door) > 0: + prj_data[bldg_name]["UValue Door"] = sum(u_values_door) / len(u_values_door) + else: + prj_data[bldg_name]["UValue Door"] = 0 + + if len(u_values_roof) > 0: + prj_data[bldg_name]["UValue Roof"] = sum(u_values_roof) / len(u_values_roof) + else: + prj_data[bldg_name]["UValue Roof"] = 0 + + if len(u_values_ceiling) > 0: + prj_data[bldg_name]["UValue Ceiling"] = sum(u_values_ceiling) / len( + u_values_ceiling + ) + else: + prj_data[bldg_name]["UValue Ceiling"] = 0 + + if len(u_values_ground_floor) > 0: + prj_data[bldg_name]["UValue Groundfloor"] = sum( + u_values_ground_floor + ) / len(u_values_ground_floor) + else: + prj_data[bldg_name]["UValue Groundfloor"] = 0 + if len(g_values_windows) > 0: + prj_data[bldg_name]["gValue Window"] = sum(g_values_windows) / len( + g_values_windows + ) + else: + prj_data[bldg_name]["gValue Window"] = 0 + + bldg_data = prj_data[bldg_name] + + export_reports(bldg_data, bldg_name, path, prj) + + +def export_reports(bldg_data, bldg_name, path, prj): + if not os.path.exists(path): + os.mkdir(path) + os.mkdir(os.path.join(path, "plots")) + base_name = f"{prj.name}_{bldg_name}" + output_path_base = os.path.join(path, base_name) + plotly_file_name = os.path.join(path, "plots", base_name + "_plotly.html") + # Draw an abstract image of the building and save it with plotly to HTML + interactive_fig = create_simple_3d_visualization(bldg_data, roof_angle=30) + if interactive_fig: + interactive_fig.write_html(plotly_file_name) + else: + plotly_file_name = None + html_file_name = os.path.join(output_path_base + ".html") + create_html_page(bldg_data, prj.name, bldg_name, html_file_name, plotly_file_name) + create_csv_report(bldg_data, output_path_base) + + +def create_csv_report(bldg_data, output_path_base): + # flat the keys + + prj_data_flat = {} + for key, val in bldg_data.items(): + if isinstance(bldg_data[key], dict): + for subkey in bldg_data[key].keys(): + prj_data_flat[str(key) + "_" + f"{subkey:03}"] = bldg_data[key][subkey] + else: + prj_data_flat[key] = bldg_data[key] + + bldg_add_list = {"OuterWall": [], "Window": []} + for key in prj_data_flat.keys(): + if key.startswith("Outerwall Area_"): + bldg_add_list["OuterWall"].append(key) + if key.startswith("Window Area_"): + bldg_add_list["Window"].append(key) + bldg_add_list["OuterWall"].sort() + bldg_add_list["Window"].sort() + + bldg_sorted_list = [ + "Net Ground Area", + "Number of Zones" "Ground Floor Area", + "Roof Area", + "Floor Height", + "Number of Floors", + "Total Air Volume", + *bldg_add_list["OuterWall"], + *bldg_add_list["Window"], + "Window-Wall-Ratio", + "Inner Wall Area", + "UValue Outerwall", + "UValue Innerwall", + "UValue Window", + "UValue Door", + "UValue Roof", + "UValue Ceiling", + "UValue Groundfloor", + "gValue Window", + ] + # round values + for key, value in prj_data_flat.items(): + if not isinstance(value, str): + prj_data_flat[key] = round(value, 2) + else: + prj_data_flat[key] = value + bldg_data_flat_sorted = [ + (k, prj_data_flat[k]) for k in bldg_sorted_list if k in prj_data_flat.keys() + ] + + keys = [""] + keys.extend([x[0] for x in bldg_data_flat_sorted]) + + values = ["TEASER"] + values.extend([x[1] for x in bldg_data_flat_sorted]) + + csv_file_name = os.path.join(output_path_base + ".csv") + with open(csv_file_name, "w", newline="", encoding="utf-8") as f: + csvwriter = csv.writer(f, delimiter=";") + csvwriter.writerow(keys) + csvwriter.writerow(localize_floats(values)) + + +def add_compass_to_3d_plot(fig, x_y_axis_sizing): + lines = [ + ((0, x_y_axis_sizing - 1, 0), (0, x_y_axis_sizing, 0), "N"), + ((x_y_axis_sizing - 1, 0, 0), (x_y_axis_sizing, 0, 0), "E"), + ((0, -x_y_axis_sizing + 1, 0), (0, -x_y_axis_sizing, 0), "S"), + ((-x_y_axis_sizing + 1, 0, 0), (-x_y_axis_sizing, 0, 0), "W"), + ] + + for start, end, label in lines: + fig.add_trace( + go.Scatter3d( + x=[start[0], end[0]], + y=[start[1], end[1]], + z=[start[2], end[2]], + mode="lines+text", + line=dict(color="black"), + hoverinfo="none", + showlegend=False, + ) + ) + fig.add_trace( + go.Scatter3d( + x=[end[0]], + y=[end[1]], + z=[end[2]], + mode="text", + text=[label], + textposition="top center", + hoverinfo="none", + showlegend=False, + ) + ) + + arrow_length = 1 + arrow_color = "black" + + arrow = go.Cone( + x=[end[0]], + y=[end[1]], + z=[end[2]], + u=[end[0] - start[0]], + v=[end[1] - start[1]], + w=[end[2] - start[2]], + sizemode="absolute", + sizeref=arrow_length, + showscale=False, + colorscale=[[0, arrow_color], [1, arrow_color]], + hoverinfo="none", + ) + fig.add_trace(arrow) + + # Set layout + fig.update_layout(scene=dict(aspectmode="manual", aspectratio=dict(x=1, y=1, z=1))) + return fig + + +def create_html_page(bldg_data, prj_name, bldg_name, html_file_name, iframe_src): + html_content = f""" + + + + {html.escape(prj_name)} - {html.escape(bldg_name)} + + + + +

{ + html.escape(prj_name)} - {html.escape(bldg_name)}

+
+
+
+ + """ + + current_category = None + for key, value in bldg_data.items(): + unit = "-" + category = None + list_item = False + # Handle category names + if ( + "window" in key.lower() or "wall" in key.lower() + ) and "uvalue" not in key.lower(): + category = "Wall and Window Areas" + unit = "m²" + elif key.startswith("UValue") or key.startswith("Gvalue"): + category = "U-Values (mean)" + unit = ["kW", "kg K"] + elif key in [ + "Net Ground Area", + "Roof Area", + "Floor Height", + "Number of Floors", + "Total Air Volume", + "Number of Zones", + "Year of Construction", + "Type of Building", + ]: + category = "Base Values" + unit = "m²" + elif key.startswith("Calculated"): + category = "Calculated Values" + unit = "W" + + if key.lower() in [ + "number of floors", + "number of zones", + "year of construction", + "window-wall-ratio", + "gvalue window", + "type of building", + ]: + unit = "-" + if key.lower() == "total air volume": + unit = "m³" + if key.lower() == "floor height": + unit = "m" + if category and category != current_category: + html_content += f""" + + + + """ + if category == "Wall and Window Areas": + html_content += """ + + + + """ + current_category = category + + # handle subdict for outerwall and window area with directions + if key == "Outerwall Area" or key == "Window Area": + list_item = True + for orient, area in bldg_data[key].items(): + value = area + html_content += f""" + + + + + + """ + else: + key_human_readable = " ".join( + [word.capitalize() for word in key.split("_")] + ) + html_content += f""" + + + """ + if not isinstance(value, str): + value = str(round(value, 2)) + if not list_item: + html_content += f""" + + + + """ + else: + html_content += f""" + {html.escape(unit)} + + """ + if iframe_src: + html_content += f""" +
{html.escape(category)}
(0° := North, 90° := East, + 180° := South, 270° := West)
{html.escape(str(key))} + {html.escape(str(orient))}{html.escape( + str(round(value, 2)))} + {html.escape(unit)}
{html.escape(key_human_readable)}{html.escape(value)} + """ + if isinstance(unit, list): + html_content += f""" + {html.escape(unit[0])} {html.escape(unit[1])}
+
+
+
+ +
+        + Walls +          +        + Windows
""" + else: + html_content += f""" + +
+
+
+

Error: No graphic + available. + Error during image creation.

""" + html_content += f""" + Assumptions:
+
  • All windows of a storey and with the same + orientation are put together into one big window + which is placed in the middle of the storey
  • +
  • Only works for buildings with 4 directions currently, + while the smallest will be interpreted as + north, the next bigger one as east and so on.
  • +
  • The roof is not displayed correctly yet
  • +
    +
    +
    +
    + + +""" + + with open(html_file_name, "w") as html_file: + html_file.write(html_content) + + +def create_simple_3d_visualization(bldg_data, roof_angle=30): + """Creates a simplified 3d plot of the building. + + This is for a rough first visual analysis of the building and is mostly + relevant for buildings that are created "manual" and not for archetypes. + The simplified visualization has multiple assumptions/simplifications: + * All windows of a storey and with the same orientation are put together + into one big window which is placed in the middle of the storey + * Only works for buildings with 4 directions currently, while the smallest + will be interpreted as north, the next bigger one as east and so on. + * Orientations are + Positive y: North + Positive x: East + Negative y: South + Negative x: West + * The roof is not displayed correctly yet # TODO + """ + + def get_value_with_default(lst, index, default_value): + try: + return lst[index] + except IndexError: + return default_value + + try: + area_values = list(bldg_data["Outerwall Area"].values()) + window_values = list(bldg_data["Window Area"].values()) + # TODO: use orientations as well and "turn" the vertices based on this. + # Currently the first value (which is the smallest) will be taken as + # north, the next one as east and so on. Only the first 4 values are + # taken into account. + area_north = get_value_with_default(area_values, 0, 0) + area_east = get_value_with_default(area_values, 1, 0) + area_south = get_value_with_default(area_values, 2, 0) + area_west = get_value_with_default(area_values, 3, 0) + window_area_north = get_value_with_default(window_values, 0, 0) + window_area_east = get_value_with_default(window_values, 1, 0) + window_area_south = get_value_with_default(window_values, 2, 0) + window_area_west = get_value_with_default(window_values, 3, 0) + height = bldg_data["Floor Height"] + num_floors = bldg_data["Number of Floors"] + + length_north = area_north / (num_floors * height) + length_east = area_east / (num_floors * height) + length_south = area_south / (num_floors * height) + length_west = area_west / (num_floors * height) + + fig = go.Figure() + + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + margin=dict(l=5, r=5, b=5, t=0), + scene=dict( + xaxis=dict( + gridcolor="white", + showbackground=False, + zerolinecolor="white", + ), + yaxis=dict( + gridcolor="white", showbackground=False, zerolinecolor="white" + ), + zaxis=dict( + gridcolor="white", showbackground=False, zerolinecolor="white" + ), + aspectmode="cube", + xaxis_showgrid=False, + yaxis_showgrid=False, + zaxis_showgrid=False, + xaxis_title="", + yaxis_title="", + zaxis_title="", + ), + ) + + max_length = max(length_north, length_south, length_west, length_east) + x_y_axis_sizing = (max_length / 2) * 1.1 + fig.update_layout( + scene=dict( + xaxis=dict(range=[-x_y_axis_sizing, x_y_axis_sizing]), + yaxis=dict(range=[-x_y_axis_sizing, x_y_axis_sizing]), + zaxis=dict(range=[0, max_length]), + ) + ) + fig = add_compass_to_3d_plot(fig, x_y_axis_sizing) + for floor in range(num_floors): + # Ecken des aktuellen Stockwerks + floor_height = height * floor + vertices = [ + (-length_south / 2, -length_east / 2, floor_height), + (-length_south / 2 + length_north, -length_east / 2, floor_height), + ( + -length_south / 2 + length_north, + -length_east / 2 + length_west, + floor_height, + ), + (-length_south / 2, -length_east / 2 + length_west, floor_height), + (-length_south / 2, -length_east / 2, floor_height + height), + ( + -length_south / 2 + length_north, + -length_east / 2, + floor_height + height, + ), + ( + -length_south / 2 + length_north, + -length_east / 2 + length_west, + floor_height + height, + ), + ( + -length_south / 2, + -length_east / 2 + length_west, + floor_height + height, + ), + ] + + edges = [ + # 0: bottom + [vertices[0], vertices[1], vertices[2], vertices[3], vertices[0]], + # 1: top + [vertices[4], vertices[5], vertices[6], vertices[7], vertices[4]], + # 2: south + [vertices[0], vertices[1], vertices[5], vertices[4], vertices[0]], + # 3: north + [vertices[2], vertices[3], vertices[7], vertices[6], vertices[2]], + # 4: east + [vertices[1], vertices[2], vertices[6], vertices[5], vertices[1]], + # 5: west + [vertices[4], vertices[7], vertices[3], vertices[0], vertices[4]], + ] + + # Add walls as 3D polygons with color fill + for edge in edges: + xs, ys, zs = zip(*edge) + fig.add_trace( + go.Mesh3d( + x=xs, + y=ys, + z=zs, + i=[0, 0, 1, 0], + j=[1, 2, 2, 3], + k=[2, 3, 3, 1], + opacity=0.25, + color="gray", + hoverinfo="none", + ) + ) + + # Fenster hinzufügen + window_gap_top_bottom = 0.5 + for i, (window_area, wall_vertices) in enumerate( + zip( + [ + window_area_north, + window_area_east, + window_area_south, + window_area_west, + ], + [edges[3], edges[4], edges[2], edges[5]], + ) + ): + window_height = height - window_gap_top_bottom + window_width = window_area / (num_floors * window_height) + window_x_center = ( + wall_vertices[0][0] + + (wall_vertices[1][0] - wall_vertices[0][0]) / 2 + ) + window_y_center = ( + wall_vertices[0][1] + + (wall_vertices[2][1] - wall_vertices[0][1]) / 2 + ) + window_z_center = ( + floor_height + window_gap_top_bottom / 2 + window_height / 2 + ) + + if i == 0 or i == 2: + fig.add_trace( + go.Mesh3d( + x=[ + window_x_center - window_width / 2, + window_x_center + window_width / 2, + window_x_center + window_width / 2, + window_x_center - window_width / 2, + ], + y=[ + window_y_center, + window_y_center, + window_y_center, + window_y_center, + ], + z=[ + window_z_center - window_height / 2, + window_z_center - window_height / 2, + window_z_center + window_height / 2, + window_z_center + window_height / 2, + ], + i=[0, 0, 1, 0], + j=[1, 2, 2, 3], + k=[2, 3, 3, 1], + opacity=0.7, + color="blue", + hoverinfo="none", + ) + ) + else: + fig.add_trace( + go.Mesh3d( + x=[ + window_x_center, + window_x_center, + window_x_center, + window_x_center, + ], + y=[ + window_y_center - window_width / 2, + window_y_center + window_width / 2, + window_y_center + window_width / 2, + window_y_center - window_width / 2, + ], + z=[ + window_z_center - window_height / 2, + window_z_center - window_height / 2, + window_z_center + window_height / 2, + window_z_center + window_height / 2, + ], + i=[0, 0, 1, 0], + j=[1, 2, 2, 3], + k=[2, 3, 3, 1], + opacity=0.7, + color="blue", + hoverinfo="none", + ) + ) + + return fig + except Exception as e: + message = type(e).__name__ + str(e.args) + print( + f"An error occured during creating the simplified plot for model " + f"report. Will continue without plot. Error: {message}: " + ) + return None diff --git a/teaser/examples/e2_export_aixlib_models.py b/teaser/examples/e2_export_aixlib_models.py index cc8315f6d..a0b66f77a 100644 --- a/teaser/examples/e2_export_aixlib_models.py +++ b/teaser/examples/e2_export_aixlib_models.py @@ -69,7 +69,8 @@ def example_export_aixlib(): path = prj.export_aixlib( internal_id=None, - path=None) + path=None, + report=True) return path diff --git a/teaser/project.py b/teaser/project.py index 3820bce9b..e03fda416 100644 --- a/teaser/project.py +++ b/teaser/project.py @@ -9,6 +9,7 @@ import teaser.data.output.aixlib_output as aixlib_output import teaser.data.output.ibpsa_output as ibpsa_output from teaser.data.dataclass import DataClass +from teaser.data.output.reports import model_report from teaser.logic.archetypebuildings.bmvbs.office import Office from teaser.logic.archetypebuildings.bmvbs.custom.institute import Institute from teaser.logic.archetypebuildings.bmvbs.custom.institute4 import Institute4 @@ -1032,7 +1033,8 @@ def export_aixlib( corG=None, internal_id=None, path=None, - use_postprocessing_calc=False + use_postprocessing_calc=False, + report=False ): """Exports values to a record file for Modelica simulation @@ -1054,6 +1056,9 @@ def export_aixlib( path : string if the Files should not be stored in default output path of TEASER, an alternative path can be specified as a full path + report: boolean + if True a model report in form of a html and csv file will be + created for the exported project. """ if building_model is not None or zone_model is not None or corG is not None: @@ -1084,6 +1089,10 @@ def export_aixlib( buildings=[bldg], prj=self, path=path, use_postprocessing_calc=use_postprocessing_calc ) + + if report: + report_path = os.path.join(path, "Resources", "ModelReport") + model_report.create_model_report(prj=self, path=report_path) return path def export_ibpsa(self, library="AixLib", internal_id=None, path=None):