ifctrano 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ifctrano
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Package for generating building energy simulation model from IFC
5
5
  License: GPL V3
6
6
  Keywords: BIM,IFC,energy simulation,modelica,building energy simulation,buildings,ideas
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Requires-Dist: ifcopenshell (>=0.8.1.post1,<0.9.0)
16
16
  Requires-Dist: open3d (>=0.19.0,<0.20.0)
17
17
  Requires-Dist: shapely (>=2.0.7,<3.0.0)
18
- Requires-Dist: trano (>=0.2.0,<0.3.0)
18
+ Requires-Dist: trano (>=0.6.0,<0.7.0)
19
19
  Requires-Dist: typer (>=0.12.5,<0.13.0)
20
20
  Requires-Dist: vedo (>=2025.5.3,<2026.0.0)
21
21
  Project-URL: Repository, https://github.com/andoludo/ifctrano
@@ -23,33 +23,11 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  # ifctrano - IFC to Energy Simulation Tool
25
25
 
26
+ ---
26
27
  📖 **Full Documentation:** 👉 [ifctrano Docs](https://andoludo.github.io/ifctrano/)
28
+ ---
27
29
 
28
- ```bash
29
- pip install ifctrano
30
- ```
31
-
32
- To check the installation, run the following commands:
33
-
34
- ```bash
35
- ifctrano --help
36
-
37
- ifctrano verify
38
- ```
39
-
40
- # ⚠️ WARNING ⚠️
41
-
42
- **This package is still under construction and is largely a work in progress.**
43
- There are still several aspects that need further development, including:
44
-
45
- - Material and construction extraction
46
- - Slab and roof boundaries
47
- - Systems integration
48
- - Additional validation
49
- - Bug fixes
50
- - ...
51
- -
52
- Help and contribution are more than appreciated! 🚧
30
+ Generate Modelica building models directly from IFC files — with support for simulation, visualization, and multiple libraries.
53
31
 
54
32
  ## Overview
55
33
  ifctrano is yet another **IFC to energy simulation** tool designed to translate **Industry Foundation Classes (IFC)** models into energy simulation models in **Modelica**.
@@ -79,10 +57,104 @@ ifctrano has been tested using open-source IFC files from various repositories:
79
57
  - 🕸️ [Ifc2Graph Test Files](https://github.com/JBjoernskov/Ifc2Graph/tree/main/test_ifc_files)
80
58
  - 🔓 [Open Source BIM](https://github.com/opensourceBIM)
81
59
 
82
- ## Installation & Usage
83
- (Installation and usage instructions will be provided here, depending on the package distribution method.)
60
+ ## 🚀 Installation
61
+
62
+ ### 📦 Install `ifctrano`
63
+
64
+ !!! warning
65
+ Trano requires python 3.9 or higher and docker to be installed on the system.
66
+
67
+
68
+ ifctrano is a Python package that can be installed via pip.
69
+
70
+ ```bash
71
+ pip install ifctrano
72
+ ```
73
+
74
+ ### ✅ Verify Installation
75
+
76
+ Run the following commands to ensure everything is working:
77
+
78
+ ```bash
79
+ ifctrano --help
80
+ ifctrano verify
81
+ ```
82
+
83
+ ---
84
+
85
+ ## 🔧 Optional Dependencies
86
+
87
+ ### 🐳 Docker (for simulation)
88
+
89
+ To enable model simulation using the official OpenModelica Docker image, install Docker Desktop:
90
+
91
+ 👉 [https://docs.docker.com/desktop/](https://docs.docker.com/desktop/)
92
+
93
+ Required for using the `--simulate-model` flag.
94
+
95
+ ---
84
96
 
97
+ ### 🧠 Graphviz (for layout visualization)
98
+
99
+ `ifctrano` leverages Graphviz to optimize component layout in generated Modelica models. It is optional, but **recommended**.
100
+
101
+ #### 📥 Install on Windows
102
+
103
+ - Download and install from: [https://graphviz.org/download/](https://graphviz.org/download/)
104
+ - Add the Graphviz `bin` folder to your **system `PATH`**.
105
+
106
+ #### 🐧 Install on Linux
107
+
108
+ ```bash
109
+ sudo apt update
110
+ sudo apt install graphviz
111
+ ```
112
+
113
+ ---
114
+
115
+ ## ⚙️ Usage
116
+
117
+ ### 📁 Generate Modelica models from IFC
118
+
119
+ #### 🏢 Using the **Buildings** library
120
+
121
+ ```bash
122
+ ifctrano create /path/to/your.ifc
123
+ ```
124
+
125
+ #### 🏫 Using the **IDEAS** library
126
+
127
+ ```bash
128
+ ifctrano create /path/to/your.ifc IDEAS
129
+ ```
130
+
131
+ #### 🧮 Using the **Reduced Order** library
132
+
133
+ ```bash
134
+ ifctrano create /path/to/your.ifc reduced_order
135
+ ```
136
+
137
+ ---
138
+
139
+ ### 🧱 Show Space Boundaries
140
+
141
+ To visualize the computed space boundaries:
142
+
143
+ ```bash
144
+ ifctrano create /path/to/your.ifc --show-space-boundaries
145
+ ```
146
+
147
+ ---
148
+
149
+ ### 🔁 Simulate the Model
150
+
151
+ Run a full simulation after model generation:
152
+
153
+ ```bash
154
+ ifctrano create /path/to/your.ifc --simulate-model
155
+ ```
85
156
 
157
+ Make sure Docker is installed and running before simulating.
86
158
 
87
159
  ---
88
160
  💡 **ifctrano** aims to make energy simulation model generation from IFC files **simpler, more accessible, and less reliant on incomplete IFC attributes**. 🚀
@@ -1,32 +1,10 @@
1
1
  # ifctrano - IFC to Energy Simulation Tool
2
2
 
3
+ ---
3
4
  📖 **Full Documentation:** 👉 [ifctrano Docs](https://andoludo.github.io/ifctrano/)
5
+ ---
4
6
 
5
- ```bash
6
- pip install ifctrano
7
- ```
8
-
9
- To check the installation, run the following commands:
10
-
11
- ```bash
12
- ifctrano --help
13
-
14
- ifctrano verify
15
- ```
16
-
17
- # ⚠️ WARNING ⚠️
18
-
19
- **This package is still under construction and is largely a work in progress.**
20
- There are still several aspects that need further development, including:
21
-
22
- - Material and construction extraction
23
- - Slab and roof boundaries
24
- - Systems integration
25
- - Additional validation
26
- - Bug fixes
27
- - ...
28
- -
29
- Help and contribution are more than appreciated! 🚧
7
+ Generate Modelica building models directly from IFC files — with support for simulation, visualization, and multiple libraries.
30
8
 
31
9
  ## Overview
32
10
  ifctrano is yet another **IFC to energy simulation** tool designed to translate **Industry Foundation Classes (IFC)** models into energy simulation models in **Modelica**.
@@ -56,10 +34,104 @@ ifctrano has been tested using open-source IFC files from various repositories:
56
34
  - 🕸️ [Ifc2Graph Test Files](https://github.com/JBjoernskov/Ifc2Graph/tree/main/test_ifc_files)
57
35
  - 🔓 [Open Source BIM](https://github.com/opensourceBIM)
58
36
 
59
- ## Installation & Usage
60
- (Installation and usage instructions will be provided here, depending on the package distribution method.)
37
+ ## 🚀 Installation
38
+
39
+ ### 📦 Install `ifctrano`
40
+
41
+ !!! warning
42
+ Trano requires python 3.9 or higher and docker to be installed on the system.
43
+
44
+
45
+ ifctrano is a Python package that can be installed via pip.
46
+
47
+ ```bash
48
+ pip install ifctrano
49
+ ```
50
+
51
+ ### ✅ Verify Installation
52
+
53
+ Run the following commands to ensure everything is working:
54
+
55
+ ```bash
56
+ ifctrano --help
57
+ ifctrano verify
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 🔧 Optional Dependencies
63
+
64
+ ### 🐳 Docker (for simulation)
65
+
66
+ To enable model simulation using the official OpenModelica Docker image, install Docker Desktop:
67
+
68
+ 👉 [https://docs.docker.com/desktop/](https://docs.docker.com/desktop/)
69
+
70
+ Required for using the `--simulate-model` flag.
71
+
72
+ ---
61
73
 
74
+ ### 🧠 Graphviz (for layout visualization)
75
+
76
+ `ifctrano` leverages Graphviz to optimize component layout in generated Modelica models. It is optional, but **recommended**.
77
+
78
+ #### 📥 Install on Windows
79
+
80
+ - Download and install from: [https://graphviz.org/download/](https://graphviz.org/download/)
81
+ - Add the Graphviz `bin` folder to your **system `PATH`**.
82
+
83
+ #### 🐧 Install on Linux
84
+
85
+ ```bash
86
+ sudo apt update
87
+ sudo apt install graphviz
88
+ ```
89
+
90
+ ---
91
+
92
+ ## ⚙️ Usage
93
+
94
+ ### 📁 Generate Modelica models from IFC
95
+
96
+ #### 🏢 Using the **Buildings** library
97
+
98
+ ```bash
99
+ ifctrano create /path/to/your.ifc
100
+ ```
101
+
102
+ #### 🏫 Using the **IDEAS** library
103
+
104
+ ```bash
105
+ ifctrano create /path/to/your.ifc IDEAS
106
+ ```
107
+
108
+ #### 🧮 Using the **Reduced Order** library
109
+
110
+ ```bash
111
+ ifctrano create /path/to/your.ifc reduced_order
112
+ ```
113
+
114
+ ---
115
+
116
+ ### 🧱 Show Space Boundaries
117
+
118
+ To visualize the computed space boundaries:
119
+
120
+ ```bash
121
+ ifctrano create /path/to/your.ifc --show-space-boundaries
122
+ ```
123
+
124
+ ---
125
+
126
+ ### 🔁 Simulate the Model
127
+
128
+ Run a full simulation after model generation:
129
+
130
+ ```bash
131
+ ifctrano create /path/to/your.ifc --simulate-model
132
+ ```
62
133
 
134
+ Make sure Docker is installed and running before simulating.
63
135
 
64
136
  ---
65
137
  💡 **ifctrano** aims to make energy simulation model generation from IFC files **simpler, more accessible, and less reliant on incomplete IFC attributes**. 🚀
@@ -0,0 +1,8 @@
1
+ import warnings
2
+
3
+ warnings.filterwarnings("ignore", category=RuntimeWarning)
4
+ warnings.filterwarnings(
5
+ "ignore",
6
+ message=".*Pydantic serializer warnings.*",
7
+ category=UserWarning,
8
+ )
@@ -1,5 +1,8 @@
1
1
  import json
2
2
  import math
3
+ import sys
4
+ from itertools import combinations
5
+ from multiprocessing import Process
3
6
  from pathlib import Path
4
7
  from typing import Tuple, Literal, List, Annotated, Any, Dict, cast
5
8
 
@@ -18,7 +21,6 @@ from shapely.geometry.polygon import Polygon # type: ignore
18
21
  from vedo import Line, Arrow, Mesh, show, write # type: ignore
19
22
 
20
23
  from ifctrano.exceptions import VectorWithNansError
21
- from multiprocessing import Process
22
24
 
23
25
  settings = ifcopenshell.geom.settings() # type: ignore
24
26
  Coordinate = Literal["x", "y", "z"]
@@ -53,6 +55,9 @@ class BaseShow(BaseModel):
53
55
  def description(self) -> Any: ... # noqa: ANN401
54
56
 
55
57
  def show(self, interactive: bool = True) -> None:
58
+ if sys.platform == "win32":
59
+ _show(self.lines(), interactive)
60
+ return
56
61
  p = Process(target=_show, args=(self.lines(), interactive))
57
62
  p.start()
58
63
 
@@ -147,12 +152,15 @@ class Vector(BasePoint):
147
152
  a = self.dot(other) / other.dot(other)
148
153
  return Vector(x=a * other.x, y=a * other.y, z=a * other.z)
149
154
 
150
- def norm(self) -> "Vector":
155
+ def normalize(self) -> "Vector":
151
156
  normalized_vector = self.to_array() / np.linalg.norm(self.to_array())
152
157
  return Vector(
153
158
  x=normalized_vector[0], y=normalized_vector[1], z=normalized_vector[2]
154
159
  )
155
160
 
161
+ def norm(self) -> float:
162
+ return float(np.linalg.norm(self.to_array()))
163
+
156
164
  def to_array(self) -> np.ndarray: # type: ignore
157
165
  return np.array([self.x, self.y, self.z])
158
166
 
@@ -163,7 +171,7 @@ class Vector(BasePoint):
163
171
  normal_index_list = [abs(v) for v in self.to_list()]
164
172
  return normal_index_list.index(max(normal_index_list))
165
173
 
166
- def is_a_zero(self, tolerance: float = 0.1) -> bool:
174
+ def is_null(self, tolerance: float = 0.1) -> bool:
167
175
  return all(abs(value) < tolerance for value in self.to_list())
168
176
 
169
177
  @classmethod
@@ -247,18 +255,23 @@ class Vertices(BaseModel):
247
255
  return FaceVertices(points=self.points)
248
256
 
249
257
  def get_local_coordinate_system(self) -> CoordinateSystem:
250
- origin = self.points[0]
251
- x = self.points[1] - origin
258
+ vectors = [
259
+ (a - b).normalize().to_array() for a, b in combinations(self.points, 2)
260
+ ]
252
261
  found = False
253
- for point in self.points[2:]:
254
- y = point - origin
255
- if abs(x.dot(y)) < 0.00001:
262
+ for v1, v2, v3 in combinations(vectors, 3):
263
+ if (
264
+ np.isclose(abs(np.dot(v1, v2)), 0)
265
+ and np.isclose(abs(np.dot(v1, v3)), 0)
266
+ and np.isclose(abs(np.dot(v2, v3)), 0)
267
+ ):
256
268
  found = True
269
+ x = Vector.from_array(v1)
270
+ y = Vector.from_array(v2)
271
+ z = Vector.from_array(v3)
257
272
  break
258
273
  if not found:
259
- raise ValueError("No orthogonal vectors found.")
260
-
261
- z = x * y
274
+ raise ValueError("Cannot find coordinate system")
262
275
  return CoordinateSystem(x=x, y=y, z=z)
263
276
 
264
277
  def get_bounding_box(self) -> "Vertices":
@@ -14,7 +14,7 @@ from pydantic import (
14
14
  Field,
15
15
  ConfigDict,
16
16
  )
17
- from shapely import Polygon # type: ignore
17
+ from scipy.spatial import ConvexHull, QhullError # type: ignore
18
18
  from vedo import Line # type: ignore
19
19
 
20
20
  from ifctrano.base import (
@@ -98,31 +98,28 @@ class OrientedBoundingBox(BaseShow):
98
98
  lines.append(Line(a, b))
99
99
  return lines
100
100
 
101
- def contained(self, poly_1: Polygon, poly_2: Polygon) -> bool:
102
- include_1_in_2 = poly_1.contains(poly_2)
103
- include_2_in_1 = poly_2.contains(poly_1)
104
- return bool(include_2_in_1 or include_1_in_2)
105
-
106
101
  def intersect_faces(self, other: "OrientedBoundingBox") -> Optional[CommonSurface]:
107
102
  extend_surfaces = []
108
103
  for face in self.faces.faces:
109
104
 
110
105
  for other_face in other.faces.faces:
111
106
  vector = face.normal * other_face.normal
112
- if vector.is_a_zero():
107
+ if vector.is_null():
113
108
  projected_face_1 = face.vertices.project(face.vertices)
114
109
  projected_face_2 = face.vertices.project(other_face.vertices)
115
110
  polygon_1 = projected_face_1.to_polygon()
116
111
  polygon_2 = projected_face_2.to_polygon()
117
112
  intersection = polygon_2.intersection(polygon_1)
118
- if intersection.area > self.area_tolerance or self.contained(
119
- polygon_1, polygon_2
120
- ):
113
+ if intersection.area > self.area_tolerance:
121
114
  distance = projected_face_1.get_distance(projected_face_2)
122
115
  area = intersection.area
123
116
  try:
124
- direction_vector = (other.centroid - self.centroid).norm()
125
- orientation = direction_vector.project(face.normal).norm()
117
+ direction_vector = (
118
+ other.centroid - self.centroid
119
+ ).normalize()
120
+ orientation = direction_vector.project(
121
+ face.normal
122
+ ).normalize()
126
123
  except VectorWithNansError as e:
127
124
  logger.warning(
128
125
  "Orientation vector was not properly computed when computing the intersection between "
@@ -170,7 +167,9 @@ class OrientedBoundingBox(BaseShow):
170
167
  entity: Optional[entity_instance] = None,
171
168
  ) -> "OrientedBoundingBox":
172
169
  points_ = open3d.utility.Vector3dVector(vertices)
173
- mobb = open3d.geometry.OrientedBoundingBox.create_from_points_minimal(points_)
170
+ mobb = open3d.geometry.OrientedBoundingBox.create_from_points_minimal(
171
+ points_, robust=True
172
+ )
174
173
  height = (mobb.get_max_bound() - mobb.get_min_bound())[
175
174
  2
176
175
  ] # assuming that height is the z axis
@@ -191,6 +190,14 @@ class OrientedBoundingBox(BaseShow):
191
190
  entity_shape, entity_shape.geometry # type: ignore
192
191
  )
193
192
  vertices_ = Vertices.from_arrays(np.asarray(vertices))
193
+ try:
194
+ hull = ConvexHull(vertices_.to_array())
195
+ vertices_ = Vertices.from_arrays(vertices_.to_array()[hull.vertices])
194
196
 
195
- vertices_ = vertices_.get_bounding_box()
196
- return cls.from_vertices(vertices_.to_array(), entity)
197
+ except QhullError:
198
+ logger.error(
199
+ f"Convex hull failed for {entity.GlobalId} ({entity.is_a()}).... Continuing without it."
200
+ )
201
+ points_ = open3d.utility.Vector3dVector(vertices_.to_array())
202
+ aab = open3d.geometry.AxisAlignedBoundingBox.create_from_points(points_)
203
+ return cls.from_vertices(aab.get_box_points(), entity)
@@ -102,11 +102,11 @@ def get_internal_elements(space1_boundaries: List[SpaceBoundaries]) -> InternalE
102
102
  and (
103
103
  boundary.common_surface.orientation
104
104
  * common_surface.orientation
105
- ).is_a_zero()
105
+ ).is_null()
106
106
  and (
107
107
  boundary_.common_surface.orientation
108
108
  * common_surface.orientation
109
- ).is_a_zero()
109
+ ).is_null()
110
110
  ) and boundary.common_surface.orientation.dot(
111
111
  boundary_.common_surface.orientation
112
112
  ) < 0:
@@ -200,7 +200,7 @@ class Building(BaseShow):
200
200
  return get_internal_elements(self.space_boundaries)
201
201
 
202
202
  @validate_call
203
- def create_model(
203
+ def create_network(
204
204
  self,
205
205
  library: Libraries = "Buildings",
206
206
  north_axis: Optional[Vector] = None,
@@ -232,6 +232,9 @@ class Building(BaseShow):
232
232
  )
233
233
  return network
234
234
 
235
+ def get_model(self) -> str:
236
+ return str(self.create_network().model())
237
+
235
238
  def save_model(self, library: Libraries = "Buildings") -> None:
236
- model_ = self.create_model(library)
239
+ model_ = self.create_network(library)
237
240
  Path(self.parent_folder.joinpath(f"{self.name}.mo")).write_text(model_.model())
@@ -189,9 +189,9 @@ class Constructions(BaseModel):
189
189
  def get_construction(self, entity: entity_instance) -> Construction:
190
190
  construction_id = self._get_construction_id(entity)
191
191
  if construction_id is None:
192
- logger.error(
193
- f"Construction ID not found for {entity.GlobalId} ({entity.is_a()}) "
194
- f"({entity.Name}). Using default construction."
192
+ logger.warning(
193
+ f"Construction ID not found for {entity.GlobalId} ({entity.is_a()}). "
194
+ f"Using default construction."
195
195
  )
196
196
  return default_construction
197
197
  constructions = [
@@ -210,11 +210,11 @@ class Constructions(BaseModel):
210
210
  if association.is_a() == "IfcRelAssociatesMaterial"
211
211
  ]
212
212
  if not associates_materials:
213
- logger.error(f"Associate materials not found for {entity.GlobalId}.")
213
+ logger.warning(f"Associate materials not found for {entity.GlobalId}.")
214
214
  return None
215
215
  relating_material = associates_materials[0].RelatingMaterial
216
216
  if relating_material.is_a() == "IfcMaterialList":
217
- logger.error(
217
+ logger.warning(
218
218
  f"Material list found for {entity.GlobalId}, but no construction ID available."
219
219
  )
220
220
  return None
@@ -24,3 +24,7 @@ class InvalidLibraryError(Exception):
24
24
 
25
25
  class VectorWithNansError(Exception):
26
26
  pass
27
+
28
+
29
+ class HasWindowsWithoutWallsError(Exception):
30
+ pass
@@ -15,6 +15,7 @@ from trano.utils.utils import is_success # type: ignore
15
15
  from ifctrano.base import Libraries
16
16
  from ifctrano.building import Building
17
17
  from ifctrano.exceptions import InvalidLibraryError
18
+ from rich import print
18
19
 
19
20
  app = typer.Typer()
20
21
  CHECKMARK = "[green]✔[/green]"
@@ -34,11 +35,11 @@ def create(
34
35
  show_space_boundaries: Annotated[
35
36
  bool,
36
37
  typer.Option(help="Show computed space boundaries."),
37
- ] = True,
38
+ ] = False,
38
39
  simulate_model: Annotated[
39
40
  bool,
40
41
  typer.Option(help="Simulate the generated model."),
41
- ] = True,
42
+ ] = False,
42
43
  ) -> None:
43
44
  with Progress(
44
45
  SpinnerColumn(),
@@ -58,7 +59,7 @@ def create(
58
59
  if show_space_boundaries:
59
60
  print(f"{CHECKMARK} Showing space boundaries.")
60
61
  building.show()
61
- modelica_network = building.create_model(library=library) # type: ignore
62
+ modelica_network = building.create_network(library=library) # type: ignore
62
63
  progress.update(task, completed=True)
63
64
  task = progress.add_task(description="Writing model to file...", total=None)
64
65
  modelica_model_path.write_text(modelica_network.model())
@@ -66,12 +67,16 @@ def create(
66
67
  print(f"{CHECKMARK} Model generated at {modelica_model_path}")
67
68
  if simulate_model:
68
69
  print("Simulating...")
69
- results = simulate(
70
- modelica_model_path.parent,
71
- building.create_model(
72
- library=library # type: ignore
73
- ), # TODO: cannot use the network after cretingt he model
74
- )
70
+ try:
71
+ results = simulate(
72
+ modelica_model_path.parent,
73
+ building.create_network(
74
+ library=library # type: ignore
75
+ ), # TODO: cannot use the network after creating he model
76
+ )
77
+ except Exception as e:
78
+ print(f"{CROSS_MARK} Simulation failed: {e}")
79
+ return
75
80
  if not is_success(results):
76
81
  print(f"{CROSS_MARK} Simulation failed. See logs for more information.")
77
82
  return
@@ -79,7 +84,7 @@ def create(
79
84
  result_path = (
80
85
  Path(modelica_model_path.parent)
81
86
  / "results"
82
- / f"{modelica_model_path.stem}.building_res.mat"
87
+ / f"{modelica_model_path.stem.lower()}.building_res.mat"
83
88
  )
84
89
  if not result_path.exists():
85
90
  print(
@@ -87,7 +92,7 @@ def create(
87
92
  )
88
93
  return
89
94
  reporting = ModelDocumentation.from_network(
90
- building.create_model(library=library), # type: ignore
95
+ building.create_network(library=library), # type: ignore
91
96
  result=ResultFile(path=result_path),
92
97
  )
93
98
  html = to_html_reporting(reporting)
@@ -5,7 +5,7 @@ import ifcopenshell
5
5
  import ifcopenshell.geom
6
6
  import ifcopenshell.util.shape
7
7
  from ifcopenshell import entity_instance, file
8
- from pydantic import Field, BeforeValidator
8
+ from pydantic import Field, BeforeValidator, BaseModel
9
9
  from trano.data_models.conversion import SpaceParameter # type: ignore
10
10
  from trano.elements import Space as TranoSpace, ExternalWall, Window, BaseWall, ExternalDoor # type: ignore
11
11
  from trano.elements.system import Occupancy # type: ignore
@@ -23,7 +23,13 @@ from ifctrano.base import (
23
23
  )
24
24
  from ifctrano.bounding_box import OrientedBoundingBox
25
25
  from ifctrano.construction import glass, Constructions
26
- from ifctrano.utils import remove_non_alphanumeric, _round, get_building_elements
26
+ from ifctrano.exceptions import HasWindowsWithoutWallsError
27
+ from ifctrano.utils import (
28
+ remove_non_alphanumeric,
29
+ _round,
30
+ get_building_elements,
31
+ short_uuid,
32
+ )
27
33
 
28
34
  ROOF_VECTOR = Vector(x=0, y=0, z=1)
29
35
 
@@ -83,6 +89,87 @@ class Space(GlobalId):
83
89
  return f"space_{main_name}{remove_non_alphanumeric(self.entity.GlobalId)}"
84
90
 
85
91
 
92
+ class ExternalSpaceBoundaryGroup(BaseModelConfig):
93
+ constructions: List[BaseWall]
94
+ azimuth: float
95
+ tilt: Tilt
96
+
97
+ def __hash__(self) -> int:
98
+ return hash((self.azimuth, self.tilt.value))
99
+
100
+ def has_window(self) -> bool:
101
+ return any(
102
+ isinstance(construction, Window) for construction in self.constructions
103
+ )
104
+
105
+ def has_external_wall(self) -> bool:
106
+ return any(
107
+ isinstance(construction, ExternalWall)
108
+ for construction in self.constructions
109
+ )
110
+
111
+
112
+ class ExternalSpaceBoundaryGroups(BaseModelConfig):
113
+ space_boundary_groups: List[ExternalSpaceBoundaryGroup] = Field(
114
+ default_factory=list
115
+ )
116
+
117
+ @classmethod
118
+ def from_external_boundaries(
119
+ cls, external_boundaries: List[BaseWall]
120
+ ) -> "ExternalSpaceBoundaryGroups":
121
+ boundary_walls = [
122
+ ex
123
+ for ex in external_boundaries
124
+ if isinstance(ex, (ExternalWall, Window)) and ex.tilt == Tilt.wall
125
+ ]
126
+ space_boundary_groups = list(
127
+ {
128
+ ExternalSpaceBoundaryGroup(
129
+ constructions=[
130
+ ex_
131
+ for ex_ in boundary_walls
132
+ if ex_.azimuth == ex.azimuth and ex_.tilt == ex.tilt
133
+ ],
134
+ azimuth=ex.azimuth,
135
+ tilt=ex.tilt,
136
+ )
137
+ for ex in boundary_walls
138
+ }
139
+ )
140
+ return cls(space_boundary_groups=space_boundary_groups)
141
+
142
+ def has_windows_without_wall(self) -> bool:
143
+ return all(
144
+ not (group.has_window() and not group.has_external_wall())
145
+ for group in self.space_boundary_groups
146
+ )
147
+
148
+
149
+ class Azimuths(BaseModel):
150
+ north: List[float] = [0.0, 360]
151
+ east: List[float] = [90.0]
152
+ south: List[float] = [180.0]
153
+ west: List[float] = [270.0]
154
+ northeast: List[float] = [45.0]
155
+ southeast: List[float] = [135.0]
156
+ southwest: List[float] = [225.0]
157
+ northwest: List[float] = [315.0]
158
+ tolerance: float = 22.5
159
+
160
+ def get_azimuth(self, value: float) -> float:
161
+ fields = [field for field in self.model_fields if field not in ["tolerance"]]
162
+ for field in fields:
163
+ possibilities = getattr(self, field)
164
+ for possibility in possibilities:
165
+ if (
166
+ value >= possibility - self.tolerance
167
+ and value <= possibility + self.tolerance
168
+ ):
169
+ return float(possibilities[0])
170
+ raise ValueError(f"Value {value} is not within tolerance of any azimuths.")
171
+
172
+
86
173
  class SpaceBoundary(BaseModelConfig):
87
174
  bounding_box: OrientedBoundingBox
88
175
  entity: entity_instance
@@ -93,7 +180,10 @@ class SpaceBoundary(BaseModelConfig):
93
180
  return hash(self.common_surface)
94
181
 
95
182
  def boundary_name(self) -> str:
96
- return f"{self.entity.is_a()}_{remove_non_alphanumeric(self.entity.GlobalId)}"
183
+ return (
184
+ f"{remove_non_alphanumeric(self.entity.Name) or self.entity.is_a().lower()}_"
185
+ f"__{remove_non_alphanumeric(self.entity.GlobalId)}{short_uuid()}"
186
+ )
97
187
 
98
188
  def model_element( # noqa: PLR0911
99
189
  self,
@@ -108,7 +198,7 @@ class SpaceBoundary(BaseModelConfig):
108
198
  return ExternalWall(
109
199
  name=self.boundary_name(),
110
200
  surface=self.common_surface.area,
111
- azimuth=azimuth,
201
+ azimuth=Azimuths().get_azimuth(azimuth),
112
202
  tilt=Tilt.wall,
113
203
  construction=constructions.get_construction(self.entity),
114
204
  )
@@ -116,7 +206,7 @@ class SpaceBoundary(BaseModelConfig):
116
206
  return ExternalDoor(
117
207
  name=self.boundary_name(),
118
208
  surface=self.common_surface.area,
119
- azimuth=azimuth,
209
+ azimuth=Azimuths().get_azimuth(azimuth),
120
210
  tilt=Tilt.wall,
121
211
  construction=constructions.get_construction(self.entity),
122
212
  )
@@ -124,7 +214,7 @@ class SpaceBoundary(BaseModelConfig):
124
214
  return Window(
125
215
  name=self.boundary_name(),
126
216
  surface=self.common_surface.area,
127
- azimuth=azimuth,
217
+ azimuth=Azimuths().get_azimuth(azimuth),
128
218
  tilt=Tilt.wall,
129
219
  construction=glass,
130
220
  )
@@ -169,25 +259,6 @@ class SpaceBoundary(BaseModelConfig):
169
259
  )
170
260
 
171
261
 
172
- def _reassign_constructions(external_boundaries: List[BaseWall]) -> None:
173
- results = {
174
- tuple(sorted([ex.name, ex_.name])): (ex, ex_)
175
- for ex in external_boundaries
176
- for ex_ in external_boundaries
177
- if ex.construction.name != ex_.construction.name
178
- and ex.azimuth == ex_.azimuth
179
- and isinstance(ex, ExternalWall)
180
- and isinstance(ex_, ExternalWall)
181
- and ex.tilt == Tilt.wall
182
- and ex_.tilt == Tilt.wall
183
- }
184
- if results:
185
- for walls in results.values():
186
- construction = next(w.construction for w in walls)
187
- for w in walls:
188
- w.construction = construction.model_copy(deep=True)
189
-
190
-
191
262
  class SpaceBoundaries(BaseShow):
192
263
  space: Space
193
264
  boundaries: List[SpaceBoundary] = Field(default_factory=list)
@@ -220,8 +291,13 @@ class SpaceBoundaries(BaseShow):
220
291
  if boundary_model:
221
292
  external_boundaries.append(boundary_model)
222
293
 
223
- _reassign_constructions(external_boundaries)
224
-
294
+ external_space_boundaries_group = (
295
+ ExternalSpaceBoundaryGroups.from_external_boundaries(external_boundaries)
296
+ )
297
+ if not external_space_boundaries_group.has_windows_without_wall():
298
+ raise HasWindowsWithoutWallsError(
299
+ f"Space {self.space.global_id} has a boundary that has a windows but without walls."
300
+ )
225
301
  return TranoSpace(
226
302
  name=self.space.space_name(),
227
303
  occupancy=Occupancy(),
@@ -258,7 +334,7 @@ class SpaceBoundaries(BaseShow):
258
334
  if entity.is_a() not in ["IfcSpace"]
259
335
  }
260
336
 
261
- for element in elements_:
337
+ for element in list(elements_):
262
338
  space_boundary = SpaceBoundary.from_space_and_element(
263
339
  space_.bounding_box, element
264
340
  )
@@ -1,4 +1,6 @@
1
+ import random
1
2
  import re
3
+ import string
2
4
  import uuid
3
5
  from typing import get_args
4
6
 
@@ -13,6 +15,12 @@ def remove_non_alphanumeric(text: str) -> str:
13
15
  return re.sub(r"[^a-zA-Z0-9_]", "", text).lower()
14
16
 
15
17
 
18
+ def short_uuid() -> str:
19
+ return "".join(
20
+ random.choices(string.ascii_letters + string.digits, k=3) # noqa: S311
21
+ )
22
+
23
+
16
24
  def generate_alphanumeric_uuid() -> str:
17
25
  return str(uuid.uuid4().hex).lower()
18
26
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ifctrano"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Package for generating building energy simulation model from IFC"
5
5
  authors = ["Ando Andriamamonjy <andoludovic.andriamamonjy@gmail.com>"]
6
6
  license = "GPL V3"
@@ -11,7 +11,7 @@ keywords = ["BIM","IFC","energy simulation", "modelica", "building energy simula
11
11
  [tool.poetry.dependencies]
12
12
  python = ">=3.10,<3.13"
13
13
  ifcopenshell = "^0.8.1.post1"
14
- trano = "^0.2.0"
14
+ trano = "^0.6.0"
15
15
  shapely = "^2.0.7"
16
16
  typer = "^0.12.5"
17
17
  vedo = "^2025.5.3"
@@ -1,3 +0,0 @@
1
- import warnings
2
-
3
- warnings.filterwarnings("ignore", category=RuntimeWarning)
File without changes
File without changes