Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,8 @@ falk_stuff/
# ignore download libraries
**/test/regression/library/
local/
bim2sim/tasks/.DS_Store
docs/.DS_Store
.gitignore
.gitignore
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ class PluginEnergyPlus(Plugin):
ep_tasks.CreateResultDF,
# ep_tasks.VisualizeResults,
bps.PlotBEPSResults,
ep_tasks.FixEPHtml
]
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from .create_result_df import CreateResultDF
from .load_energyplus_results import LoadEnergyPlusResults
from .ep_visualize_results import VisualizeResults
from .fix_ep_html import FixEPHtml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
from bim2sim.tasks.base import ITask
from bim2sim.elements.mapping.units import ureg
from bim2sim.utilities.common_functions import filter_elements
import re

def _normalize_cols(df):
df = df.copy()
df.columns = (
df.columns
.str.replace(r'\s+', ' ', regex=True) # collapse multiple spaces
.str.strip() # trim leading/trailing spaces
)
return df

bim2sim_energyplus_mapping_base = {
"NOT_AVAILABLE": "heat_demand_total",
Expand All @@ -22,7 +32,22 @@
"SPACEGUID IDEAL LOADS AIR SYSTEM:Zone Ideal Loads Supply Air Total "
"Cooling Rate [W](Hourly)": "cool_demand_rooms",
"DistrictHeating:HVAC [J](Hourly)": "heat_energy_total",
"DistrictCooling:HVAC [J](Hourly) ": "cool_energy_total",
"DistrictCooling:HVAC [J](Hourly)": "cool_energy_total",
"Electricity:Facility [J](Hourly)": "electricity_total",
"Electricity:Building [J](Hourly)": "electricity_building",
"InteriorLights:Electricity [J](Hourly)": "electricity_lighting",
"InteriorEquipment:Electricity [J](Hourly)": "electricity_equipment",
"Fans:Electricity [J](Hourly)": "electricity_fans",
"Pumps:Electricity [J](Hourly)": "electricity_pumps",
"ExteriorLighting:Electricity [J](Hourly)": "electricity_exterior_lighting",
"ExteriorEquipment:Electricity [J](Hourly)": "electricity_exterior_equipment",
"DistrictHeating:Facility [J](Hourly)": "dhw_energy_total", # purchased DHW+space heat; filter to WaterSystems if your model splits
"DistrictHeating:WaterSystems [J](Hourly)": "dhw_energy_watersystems", # if present
"PlantLoopHeatingDemand:WaterSystems [J](Hourly)": "dhw_energy_plantloop", # if DHW via plant loop
"SPACEGUID Water Use Equipment Heating Energy [J](Hourly)": "dhw_energy_rooms",
"SPACEGUID Water Use Equipment Hot Water Volume [m3](Hourly)": "dhw_volume_rooms",
"SPACEGUID Water Use Connections Plant Hot Water Energy [J](Hourly)": "dhw_energy_connections_rooms",
"SPACEGUID Water Use Connections Hot Water Volume [m3](Hourly)": "dhw_volume_connections_rooms",
"SPACEGUID IDEAL LOADS AIR SYSTEM:Zone Ideal Loads Supply Air Total "
"Heating Energy [J](Hourly)":
"heat_energy_rooms",
Expand Down Expand Up @@ -108,6 +133,7 @@ def run(self, idf: IDF, sim_results_path: Path, elements: dict) \
"DataFrame ist needed.")
return df_finals,
raw_csv_path = sim_results_path / self.prj_name / 'eplusout.csv'
mtr_csv_path = sim_results_path / self.prj_name / 'eplusmtr.csv'
# TODO @Veronika: the zone_dict.json can be removed and instead the
# elements structure can be used to get the zone guids
zone_dict_path = sim_results_path / self.prj_name / 'zone_dict.json'
Expand Down Expand Up @@ -135,19 +161,41 @@ def run(self, idf: IDF, sim_results_path: Path, elements: dict) \
space.guid)
space_bound_dict[space.guid] = space_guids
with open(sim_results_path / self.prj_name / 'space_bound_dict.json',
'w+') as file1:
json.dump(space_bound_dict, file1, indent=4)
with open(sim_results_path / self.prj_name /
'space_bound_renamed_dict.json',
'w+') as file2:
json.dump(space_bound_renamed_dict, file2, indent=4)

df_original = PostprocessingUtils.read_csv_and_format_datetime(
raw_csv_path)
df_original = (
PostprocessingUtils.shift_dataframe_to_midnight(df_original))
'w+') as file:
json.dump(space_bound_dict, file, indent=4)


df_original = PostprocessingUtils.read_csv_and_format_datetime(raw_csv_path)
df_original = (PostprocessingUtils.shift_dataframe_to_midnight(df_original))
df_original = _normalize_cols(df_original)
if mtr_csv_path.exists():
df_mtr = PostprocessingUtils.read_csv_and_format_datetime(mtr_csv_path)
df_mtr = PostprocessingUtils.shift_dataframe_to_midnight(df_mtr)
df_mtr = _normalize_cols(df_mtr)

# Determine overlaps after normalization
overlap = [c for c in df_mtr.columns if c in df_original.columns]

if overlap:
# Option A (recommended): keep whatever is already in df_original,
# and only add *new* meter columns
new_cols = [c for c in df_mtr.columns if c not in df_original.columns]
df_original = df_original.join(df_mtr[new_cols], how='outer')

# If you *instead* want to prefer mtr values where there’s overlap:
# df_original = df_original.drop(columns=overlap).join(df_mtr[overlap + new_cols], how='outer')
else:
df_original = df_original.join(df_mtr, how='outer')
else:
self.logger.warning(
"eplusmtr.csv not found; meter-based time-series (e.g., Electricity:Facility) unavailable."
)

df_final = self.format_dataframe(df_original, zone_dict,
space_bound_dict)
for col in df_final.columns:
if df_final[col].name.endswith('[J](Hourly)'):
df_final[col.replace('[J](Hourly)', '[kWh](Hourly)')] = df_final[col] / 3_600_000.0
df_finals[self.prj_name] = df_final

return df_finals,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1819,14 +1819,21 @@ def set_output_variables(idf: IDF, sim_settings: EnergyPlusSimSettings):
)
idf.newidfobject(
"OUTPUT:METER",
Key_Name="DistrictHeating:HVAC",
Reporting_Frequency="Hourly",
)
idf.newidfobject(
"OUTPUT:METER",
Key_Name="DistrictCooling:HVAC",
Key_Name="Electricity:Facility",
Reporting_Frequency="Hourly",
)
idf.newidfobject("OUTPUT:METER", Key_Name="Electricity:Building", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="InteriorLights:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="InteriorEquipment:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="Fans:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="Pumps:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="ExteriorLighting:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="ExteriorEquipment:Electricity", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="DistrictHeating:*", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="PlantLoopHeatingDemand:*", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="MainsWater:*", Reporting_Frequency="Hourly")
idf.newidfobject("OUTPUT:METER", Key_Name="Electricity:*", Reporting_Frequency="Hourly")

if 'output_dxf' in sim_settings.output_keys:
idf.newidfobject("OUTPUT:SURFACES:DRAWING",
Report_Type="DXF")
Expand Down Expand Up @@ -2291,6 +2298,32 @@ def map_boundary_conditions(self, inst_obj: Union[SpaceBoundary,
Args:
inst_obj: SpaceBoundary instance
"""
# ─── Centroid-based “buried” test for true exterior walls/windows ─────────
# Only BUILDINGSURFACE or FENESTRATIONSURFACE that have no matching
# interzone partner (i.e.exterior on one side)
if self.key in ("BUILDINGSURFACE:DETAILED", "FENESTRATIONSURFACE:DETAILED") \
and (inst_obj.related_bound is None
or inst_obj.related_bound.ifc.RelatingSpace.is_a("IfcExternalSpatialElement")):
try:
pts = PyOCCTools.get_points_of_face(inst_obj.bound_shape)
# compute centroid Z
zc = sum(p.Coord()[2] for p in pts) / len(pts)
if zc < 0.0:
# fully buried → ground
self.out_bound_cond = "Ground"
self.sun_exposed = "NoSun"
self.wind_exposed = "NoWind"
return
# if centroid >= 0.0, treat as exposed → outdoors, and return
self.out_bound_cond = "Outdoors"
self.sun_exposed = "SunExposed"
self.wind_exposed = "WindExposed"
# for fenestration, parent GUID already set
return
except Exception:
# any geometry error: fall back
pass

if inst_obj.level_description == '2b' \
or inst_obj.related_adb_bound is not None:
self.out_bound_cond = 'Adiabatic'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,71 @@
import json
from pathlib import Path

import pandas as pd
from geomeppy import IDF

from bim2sim.elements.bps_elements import ThermalZone
from bim2sim.tasks.base import ITask
from bim2sim.utilities.common_functions import filter_elements

# — robust GUID→name & TOC fixer for any EP HTML file —
def replace_guids_in_html(report_dir, zone_dict_path):
"""
Finds whichever .htm contains the “People Internal Gains Nominal” table,
moves its TOC to the top under <body>, replaces GUIDs in its “Zone Name”
column (and anywhere they occur) with human-readable labels from zone_dict.json,
and writes out a new file *_with_names.htm.
"""
import json
from bs4 import BeautifulSoup

# load the mapping (normalize keys to uppercase)
raw = json.loads((zone_dict_path).read_text(encoding='utf-8'))
zone_map = {k.upper(): v for k, v in raw.items()}

# scan all .htm files until we find the right one
html_path = None
for f in report_dir.glob("*.htm"):
text = f.read_text(encoding='utf-8')
if "People Internal Gains Nominal" in text:
html_path = f
break
if html_path is None:
raise FileNotFoundError(f"No HTML file in {report_dir} contains the target table")

soup = BeautifulSoup(text, 'html.parser')

# 1) Move TOC: find all <a href="#toc">, remove the 2nd, insert the 1st under <body>
toc_links = soup.find_all('a', href="#toc")
if len(toc_links) >= 2:
first_p = toc_links[0].find_parent('p')
second_p = toc_links[1].find_parent('p')
second_p.decompose()
first_p.extract()
soup.body.insert(1, first_p)

# 2) Replace GUIDs in the “People Internal Gains Nominal” table
header = soup.find('b', string="People Internal Gains Nominal")
if not header:
raise RuntimeError("Found HTML but no ‘People Internal Gains Nominal’ header")
# detect which column is “Zone Name”
idx = None
for i, cell in enumerate(tbl.find('tr').find_all(['td','th'])):
if "Zone Name" in cell.get_text(strip=True):
idx = i
break

if idx is not None:
for tr in tbl.find_all('tr')[1:]:
cols = tr.find_all('td')
if len(cols) > idx:
guid = cols[idx].get_text(strip=True).upper()
if guid in zone_map:
cols[idx].string.replace_with(zone_map[guid])

# write updated HTML
out = report_dir / f"{html_path.stem}_with_names{html_path.suffix}"
out.write_text(str(soup), encoding='utf-8')
return out

class IdfPostprocessing(ITask):
"""Idf Postprocessin task.
Expand Down Expand Up @@ -38,6 +96,7 @@ def run(self, elements: dict, idf: IDF, ifc_files: list,
self._export_boundary_report(elements, idf, ifc_files)
self.write_zone_names(idf, elements,
sim_results_path / self.prj_name)
self._export_combined_html_report()
self.logger.info("IDF Postprocessing finished!")


Expand All @@ -62,16 +121,15 @@ def write_zone_names(idf, elements, exportpath: Path):
ifc_zones = filter_elements(elements, ThermalZone)
zone_dict_ifc_names = {}
for zone in zones:
usage = [z.usage for z in ifc_zones if z.guid == zone.Name]
zone_dict.update({zone.Name: usage[0]})
zone_dict_ifc_names.update({
zone.Name: {
'ZoneUsage': usage[0],
'Name': elements[zone.Name].ifc.Name,
'LongName': elements[zone.Name].ifc.LongName,
'StoreyName': elements[zone.Name].storeys[0].ifc.Name,
'StoreyElevation': elements[zone.Name].storeys[
0].ifc.Elevation}})
# find the matching BIM2SIM ThermalZone element
matches = [z for z in ifc_zones if z.guid == zone.Name]
if matches:
# use the .name property (i.e. IFC Reference)
zone_dict[zone.Name] = matches[0].zone_name
else:
# fallback to GUID
zone_dict[zone.Name] = zone.Name

with open(exportpath / 'zone_dict.json', 'w+') as file:
json.dump(zone_dict, file, indent=4)
with open(exportpath / 'zone_dict_ifc_names.json', 'w+') as file:
Expand Down Expand Up @@ -187,6 +245,59 @@ def _export_space_info(self, elements, idf):
ignore_index=True)
space_df.to_csv(path_or_buf=str(self.paths.export) + "/space.csv")

def _export_combined_html_report(self):
"""Create an HTML report combining area.csv and bound_count.csv data.

This method reads the previously exported CSV files and combines them
into a single HTML report with basic visualization.
The HTML file is saved in the same directory as the CSV files.
"""
export_path = Path(str(self.paths.export))
area_file = export_path / "area.csv"
bound_count_file = export_path / "bound_count.csv"
html_file = export_path / "area_bound_count_energida.htm"

# Read the CSV files
area_df = pd.read_csv(area_file)
bound_count_df = pd.read_csv(bound_count_file)

# Convert DataFrames to HTML tables
area_table = area_df.to_html(index=False)
bound_count_table = bound_count_df.to_html(index=False)

# Create HTML content without complex formatting
html_content = """<!DOCTYPE html>
<html>
<head>
<title>BIM2SIM Export Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333366; }
h2 { color: #336699; margin-top: 30px; }
table { border-collapse: collapse; width: 100%; margin-bottom: 30px; }
th { background-color: #336699; color: white; text-align: left; padding: 8px; }
td { border: 1px solid #ddd; padding: 8px; }
tr:nth-child(even) { background-color: #f2f2f2; }
tr:hover { background-color: #e6e6e6; }
</style>
</head>
<body>
<h1>BIM2SIM Export Report</h1>

<h2>Surface Areas</h2>
""" + area_table + """

<h2>Boundary Counts</h2>
""" + bound_count_table + """
</body>
</html>"""

# Save the HTML file
with open(html_file, 'w') as f:
f.write(html_content)

self.logger.info(f"Combined HTML report saved to {html_file}")

def _export_boundary_report(self, elements, idf, ifc_files):
"""Export a report on the number of space boundaries.
Creates a report as a DataFrame and exports it to csv.
Expand Down
Loading