Coverage for bim2sim / plugins / PluginOpenFOAM / test / regression / test_openfoam.py: 0%
236 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-18 09:34 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-18 09:34 +0000
1import os
2import sys
3import filecmp
4import difflib
5import unittest
6import logging
7from pathlib import Path
9import difflib
10import multiprocessing as mp
11import queue # für queue.Empty
12from typing import Optional
14import bim2sim
15from bim2sim.tasks import common, bps
16from bim2sim.plugins.PluginComfort.bim2sim_comfort import task as comfort_tasks
17from bim2sim.plugins.PluginEnergyPlus.bim2sim_energyplus import task as ep_tasks
18from bim2sim.plugins.PluginOpenFOAM.bim2sim_openfoam import task as of_tasks
19from bim2sim.utilities.types import LOD, IFCDomain
20from bim2sim.kernel.decision.decisionhandler import DebugDecisionHandler
21from bim2sim.utilities.test import RegressionTestBase
23logger = logging.getLogger(__name__)
24MAX_LINES = 200
27class RegressionTestOpenFOAM(RegressionTestBase):
28 """Class to set up and run CFD regression tests."""
30 def setUp(self):
31 self.old_stderr = sys.stderr
32 self.working_dir = os.getcwd()
33 self.ref_results_src_path = None
34 self.results_src_dir = None
35 self.results_dst_dir = None
36 self.tester = None
37 super().setUp()
39 def tearDown(self):
40 os.chdir(self.working_dir)
41 sys.stderr = self.old_stderr
42 super().tearDown()
44 @staticmethod
45 def _make_table_worker(q: mp.Queue,
46 ref_lines, gen_lines,
47 desc_from: str, desc_to: str,
48 context_lines: int):
49 try:
50 differ = difflib.HtmlDiff(tabsize=4, wrapcolumn=80)
51 table = differ.make_table(
52 ref_lines, gen_lines,
53 fromdesc=desc_from, todesc=desc_to,
54 context=True, numlines=context_lines
55 )
56 q.put(("ok", table))
57 except Exception as e:
58 q.put(("err", f"{type(e).__name__}: {e}"))
60 def make_table_with_timeout(self, ref_lines, gen_lines,
61 desc_from: str, desc_to: str,
62 context_lines: int,
63 timeout_s: float = 10.0) -> Optional[str]:
64 q = mp.Queue(maxsize=1)
65 p = mp.Process(
66 target=self._make_table_worker,
67 args=(q, ref_lines, gen_lines, desc_from, desc_to, context_lines),
68 )
69 p.start()
70 try:
71 status, payload = q.get(timeout=timeout_s)
72 except queue.Empty:
73 p.kill()
74 p.join()
75 logger.warning('Table not finished due to timeout.')
76 return None
77 else:
78 p.join()
79 if status == "ok":
80 return payload
81 else:
82 return None
84 def table_from_truncated_files(self, ref_lines, gen_lines, desc_from: str,
85 desc_to: str, context_lines: int,
86 truncated: bool):
87 if len(ref_lines) > MAX_LINES or len(gen_lines) > MAX_LINES:
88 truncated = True
89 ref_trunc = ref_lines[:MAX_LINES]
90 gen_trunc = gen_lines[:MAX_LINES]
91 else:
92 ref_trunc = ref_lines
93 gen_trunc = gen_lines
94 table_html = self.make_table_with_timeout(ref_trunc, gen_trunc,
95 desc_from, desc_to,
96 context_lines,
97 timeout_s=10)
98 return table_html, truncated
100 @staticmethod
101 def truncate_html_tables(table_html: str, section_html: str):
102 """Truncate individual html tables after
103 MAX_LINES rows."""
104 lower = table_html.lower()
105 tbody_start = lower.find("<tbody>")
106 tbody_end = lower.find("</tbody>")
107 if tbody_start != -1 and tbody_end != -1:
108 prefix = table_html[:tbody_start + len("<tbody>")]
109 tbody = table_html[tbody_start + len("<tbody>"):tbody_end]
110 suffix = table_html[tbody_end:]
111 rows = tbody.split("<tr")
112 if len(rows) - 1 > MAX_LINES:
113 truncated = True
114 kept_rows = rows[0] # text before first <tr>
115 for r in rows[1:MAX_LINES + 1]:
116 kept_rows += "<tr" + r
117 table_html = (prefix + kept_rows + "\n</tbody>" +
118 suffix[suffix.lower().find("</table>"):])
119 else:
120 truncated = False
121 else:
122 truncated = False
123 section_html += f"{table_html}"
124 if truncated:
125 section_html += (
126 f"<p><i>Diff truncated to {MAX_LINES} rows in the HTML table; "
127 f"additional diff rows were omitted.</i></p>")
128 return section_html
130 def generate_html_diff_report(self, new_dir: Path, ref_dir: Path,
131 output_html: str, context_lines: int = 5,
132 truncate: str = None):
133 """
134 Recursively compare reference vs generated directories and produce an HTML report.
135 Returns a tuple (has_diffs: bool, html_path: str).
136 """
137 html_sections = []
138 diffs_found = False
139 final_diffs_found = 0
140 for root, _, files in os.walk(ref_dir):
141 rel_root = os.path.relpath(root, ref_dir)
142 if 'temp' in rel_root.lower():
143 # exclude temp file directories from further diff checks as
144 # they are obsolete
145 break
146 gen_root = new_dir / rel_root
148 if not gen_root.exists():
149 html_sections.append(
150 f"<h3>Missing directory in generated: {rel_root}</h3>")
151 diffs_found = True
152 continue
154 for f in files:
155 ref_file = Path(root) / f
156 gen_file = gen_root / f
157 if not gen_file.exists():
158 diffs_found = True
159 html_sections.append(f"<h4>Missing file in generated: "
160 f"{os.path.join(rel_root, f)}</h4>")
161 final_diffs_found += 1
162 continue
163 if filecmp.cmp(ref_file, gen_file, shallow=False):
164 continue # identical, skip
165 diffs_found = True
166 with open(ref_file, "r", encoding="utf-8",
167 errors="replace") as rf:
168 ref_lines = rf.read().splitlines()
169 with open(gen_file, "r", encoding="utf-8",
170 errors="replace") as gf:
171 gen_lines = gf.read().splitlines()
173 desc_from = f"Reference: {os.path.join(rel_root, f)}"
174 desc_to = f"Generated: {os.path.join(rel_root, f)}"
175 truncated = False
176 if truncate == 'file':
177 table_html, truncated = self.table_from_truncated_files(
178 ref_lines, gen_lines, desc_from, desc_to,
179 context_lines, truncated)
180 else:
181 table_html = self.make_table_with_timeout(
182 ref_lines, gen_lines, desc_from, desc_to, context_lines,
183 timeout_s=10)
184 if table_html is not None:
185 if "No Differences Found".lower() in table_html.lower():
186 continue
187 html_sections.append(
188 f"<h2>{os.path.join(rel_root, f)}</h2>\n{table_html}")
189 final_diffs_found += 1
190 else:
191 section_html = f"<h2>{os.path.join(rel_root, f)}</h2>\n"
192 if truncate == 'table':
193 section_html = self.truncate_scanned_table(
194 table_html, section_html)
195 else:
196 section_html += f"{table_html}"
197 if truncated:
198 section_html += (
199 f"<p><i>Diff truncated to the first {MAX_LINES} lines of each file; "
200 f"additional lines were omitted.</i></p>")
201 html_sections.append(section_html)
202 final_diffs_found += 1
204 # Check for unexpected extra files in generated_dir
205 extra_files = []
206 for root, _, files in os.walk(new_dir):
207 rel_root = os.path.relpath(root, new_dir)
208 if 'temp' in rel_root.lower():
209 # exclude temp file directories from further diff checks as
210 # they are obsolete
211 break
212 for f in files:
213 gen_path = Path(root) / f
214 ref_path = ref_dir / rel_root / f
215 if not ref_path.exists():
216 extra_files.append(os.path.join(rel_root, f))
217 logger.warning(
218 f"Extra file in generated: "
219 f"{os.path.join(rel_root, f)}")
220 final_diffs_found += 1
222 if extra_files:
223 diffs_found = True
224 extras_html = \
225 "<h3>Unexpected extra files in generated:</h3>\n<ul>\n"
226 for ef in extra_files:
227 extras_html += f"<li>{ef}</li>\n"
228 extras_html += "</ul>\n"
229 html_sections.insert(0, extras_html) # show extras near top
231 # Build full HTML page
232 html_body = "\n<hr/>\n".join(
233 html_sections) if html_sections else "<p>No differences found.</p>"
234 html_page = f"""<!doctype html>
235 <html lang="en">
236 <head>
237 <meta charset="utf-8">
238 <title>Regression Test Diff Report</title>
239 <style>
240 body {{ font-family: Arial, sans-serif; padding: 1rem; }}
241 h1 {{ margin-bottom: .5rem; }}
242 table.diff {{ width: 100%; border-collapse: collapse; }}
243 table.diff td, table.diff th {{ padding: 4px; vertical-align: top; font-family: monospace; }}
244 /* difflib default classes: diff_header, diff_next, diff_add, diff_chg, diff_sub */
245 .diff_add {{ background-color: #99ffb6; }} /* added in generated */
246 .diff_chg {{ background-color: #ffe74d; }} /* changed */
247 .diff_sub {{ background-color: #ff808e; }} /* removed from reference */
248 .diff_header {{ background-color: #f0f0f0; font-weight: bold; }}
249 .center {{ text-align:center; }}
250 </style>
251 </head>
252 <body>
253 <h1>Regression Test Diff Report</h1>
254 <p><b>Reference dir:</b> {ref_dir}</p>
255 <p><b>Generated dir:</b> {new_dir}</p>
256 <hr/>
257 {html_body}
258 </body>
259 </html>
260 """
261 # Ensure parent exists
262 out_path = Path(output_html)
263 out_path.parent.mkdir(parents=True, exist_ok=True)
264 out_path.write_text(html_page, encoding="utf-8")
265 logger.warning(f"HTML diff report written to: {out_path}. Differences "
266 f"were found in {final_diffs_found} files.")
267 if final_diffs_found == 0:
268 diffs_found = False
269 return diffs_found, str(out_path)
271 def create_regression_setup(self):
272 passed_regression_test = True
273 ref_results_dir = Path(bim2sim.__file__).parent.parent \
274 / "test/resources/arch/regression_results" \
275 / self.project.name / 'OpenFOAM'
276 sim_output_dir = self.project.paths.export / "OpenFOAM"
277 regression_results_dir = (self.project.paths.root /
278 "regression_results" / "cfd" /
279 self.project.name / "OpenFOAM")
280 regression_results_dir.mkdir(parents=True, exist_ok=True)
281 html_report_path = regression_results_dir / "diff_report.html"
282 logger.warning(f"Generating HTML diff report: {html_report_path}")
283 has_diffs, report_path = self.generate_html_diff_report(sim_output_dir,
284 ref_results_dir,
285 html_report_path,
286 truncate='file')
287 if has_diffs:
288 passed_regression_test = False
289 logger.error(
290 f"Regression test failed. Results are written to {report_path}.")
291 return passed_regression_test
293 def run_regression_test(self):
294 return self.create_regression_setup()
297class TestRegressionOpenFOAMCase(RegressionTestOpenFOAM, unittest.TestCase):
298 """Regression tests for PluginOpenFOAM."""
299 def test_regression_AC20_FZK_Haus(self):
300 """Run PluginOpenFOAM regression test with AC20-FZK-Haus.ifc."""
301 ifc_path = {IFCDomain.arch: 'AC20-FZK-Haus.ifc'}
302 project = self.create_project(ifc_path, "openfoam")
304 project.plugin_cls.default_tasks = [
305 common.LoadIFC,
306 # common.CheckIfc,
307 common.CreateElementsOnIfcTypes,
308 bps.CreateSpaceBoundaries,
309 bps.AddSpaceBoundaries2B,
310 bps.CorrectSpaceBoundaries,
311 common.CreateRelations,
312 bps.DisaggregationCreationAndTypeCheck,
313 bps.EnrichMaterial,
314 bps.EnrichUseConditions,
315 common.Weather,
316 ep_tasks.CreateIdf,
317 comfort_tasks.ComfortSettings,
318 # ep_tasks.ExportIdfForCfd,
319 # common.SerializeElements,
320 ep_tasks.RunEnergyPlusSimulation,
321 of_tasks.InitializeOpenFOAMSetup,
322 of_tasks.CreateOpenFOAMGeometry,
323 of_tasks.AddOpenFOAMComfort,
324 of_tasks.CreateOpenFOAMMeshing,
325 of_tasks.SetOpenFOAMBoundaryConditions,
326 of_tasks.RunOpenFOAMMeshing,
327 of_tasks.RunOpenFOAMSimulation
328 ]
330 project.sim_settings.weather_file_path = \
331 (self.test_resources_path() /
332 'weather_files/DEU_NW_Aachen.105010_TMYx.epw')
333 # project.sim_settings.ep_install_path = 'C://EnergyPlusV9-4-0/'
334 project.sim_settings.cfd_export = True
335 project.sim_settings.select_space_guid = '2RSCzLOBz4FAK$_wE8VckM'
336 project.sim_settings.simulation_time = 12
337 project.sim_settings.simulation_date = "01/14"
338 project.sim_settings.add_heating = True
339 project.sim_settings.heater_radiation = 0.6
340 project.sim_settings.radiation_model = 'P1'
341 project.sim_settings.add_airterminals = True
342 project.sim_settings.inlet_type = 'SimpleStlDiffusor'
343 project.sim_settings.outlet_type = 'SimpleStlDiffusor'
344 project.sim_settings.mesh_size = 0.15
345 project.sim_settings.cluster_max_runtime_simulation = "02:59:00"
346 project.sim_settings.cluster_max_runtime_meshing = "00:20:00"
347 project.sim_settings.cluster_jobname = "RegressionTest"
348 project.sim_settings.cluster_compute_account = "test1234"
349 project.sim_settings.cluster_cpu_per_node = 48
350 project.sim_settings.n_procs = 72
351 project.sim_settings.total_iterations = 5000
353 handler = DebugDecisionHandler(())
354 handler.handle(project.run())
356 reg_test_res = self.run_regression_test()
357 self.assertTrue(reg_test_res,
358 "OpenFOAM Regression test did not finish successfully "
359 "or created deviations.")
361 def test_regression_DigitalHub_SB89(self):
362 """Run PluginOpenFOAM regression test with DigitalHub."""
363 ifc_paths = {
364 IFCDomain.arch:
365 Path(bim2sim.__file__).parent.parent /
366 'test/resources/arch/ifc/FM_ARC_DigitalHub_with_SB89.ifc',
367 IFCDomain.ventilation:
368 Path(bim2sim.__file__).parent.parent /
369 'test/resources/hydraulic/ifc/DigitalHub_Gebaeudetechnik'
370 '-LUEFTUNG_v2.ifc',
371 IFCDomain.hydraulic:
372 Path(bim2sim.__file__).parent.parent /
373 'test/resources/hydraulic/ifc/DigitalHub_Gebaeudetechnik-HEIZUNG_v2'
374 '.ifc',
375 }
376 project = self.create_project(ifc_paths, "openfoam")
377 project.plugin_cls.default_tasks = [
378 common.LoadIFC,
379 # common.CheckIfc,
380 common.CreateElementsOnIfcTypes,
381 bps.CreateSpaceBoundaries,
382 bps.AddSpaceBoundaries2B,
383 bps.CorrectSpaceBoundaries,
384 common.CreateRelations,
385 bps.DisaggregationCreationAndTypeCheck,
386 bps.EnrichMaterial,
387 bps.EnrichUseConditions,
388 common.Weather,
389 ep_tasks.CreateIdf,
390 comfort_tasks.ComfortSettings,
391 # ep_tasks.ExportIdfForCfd,
392 # common.SerializeElements,
393 ep_tasks.RunEnergyPlusSimulation,
394 of_tasks.InitializeOpenFOAMSetup,
395 of_tasks.CreateOpenFOAMGeometry,
396 of_tasks.AddOpenFOAMComfort,
397 of_tasks.CreateOpenFOAMMeshing,
398 of_tasks.SetOpenFOAMBoundaryConditions,
399 of_tasks.RunOpenFOAMMeshing,
400 of_tasks.RunOpenFOAMSimulation
401 ]
403 project.sim_settings.weather_file_path = \
404 (self.test_resources_path() /
405 'weather_files/DEU_NW_Aachen.105010_TMYx.epw')
406 project.sim_settings.prj_custom_usages = (Path(
407 bim2sim.__file__).parent.parent / "test/resources/arch/custom_usages/"
408 "customUsagesFM_ARC_DigitalHub_with_SB89.json")
409 # project.sim_settings.ep_install_path = 'C://EnergyPlusV9-4-0/'
410 project.sim_settings.cfd_export = True
411 project.sim_settings.select_space_guid = '3hiy47ppf5B8MyZqbpTfpc'
412 project.sim_settings.inlet_type = 'Original'
413 project.sim_settings.outlet_type = 'Original'
414 project.sim_settings.add_heating = True
415 project.sim_settings.add_people = True
416 project.sim_settings.add_floorheating = False
417 project.sim_settings.add_airterminals = True
418 project.sim_settings.add_comfort = True
419 project.sim_settings.add_furniture = True
420 project.sim_settings.add_people = True
421 project.sim_settings.add_comfort = True
422 project.sim_settings.furniture_setting = 'Office'
423 project.sim_settings.furniture_amount = 8
424 project.sim_settings.people_amount = 4
425 project.sim_settings.people_setting = 'Seated'
426 project.sim_settings.radiation_precondition_time = 4000
427 project.sim_settings.radiation_model = 'preconditioned_fvDOM'
428 project.sim_settings.output_keys = ['output_outdoor_conditions',
429 'output_zone_temperature',
430 'output_zone',
431 'output_infiltration',
432 'output_meters',
433 'output_internal_gains']
435 answers = ('Autodesk Revit', 'Autodesk Revit', *(None,) * 13,
436 *('HVAC-AirTerminal',) * 3, *(None,) * 2, 2015)
437 handler = DebugDecisionHandler(answers)
438 handler.handle(project.run())
440 reg_test_res = self.run_regression_test()
441 self.assertTrue(reg_test_res,
442 "OpenFOAM Regression test did not finish successfully "
443 "or created deviations.")