ifctrano 0.1.7__tar.gz → 0.1.9__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.1
2
2
  Name: ifctrano
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Package for generating building energy simulation model from IFC
5
5
  Home-page: https://github.com/andoludo/ifctrano
6
6
  License: GPL V3
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # ifctrano - IFC to Energy Simulation Tool
24
24
 
25
- 📖 **Full Documentation:** 👉 [Trano Docs](https://andoludo.github.io/ifctrano/)
25
+ 📖 **Full Documentation:** 👉 [ifctrano Docs](https://andoludo.github.io/ifctrano/)
26
26
 
27
27
  ```bash
28
28
  pip install ifctrano
@@ -1,6 +1,6 @@
1
1
  # ifctrano - IFC to Energy Simulation Tool
2
2
 
3
- 📖 **Full Documentation:** 👉 [Trano Docs](https://andoludo.github.io/ifctrano/)
3
+ 📖 **Full Documentation:** 👉 [ifctrano Docs](https://andoludo.github.io/ifctrano/)
4
4
 
5
5
  ```bash
6
6
  pip install ifctrano
@@ -3,11 +3,15 @@ from typing import Tuple, Literal, List, Annotated, Sequence
3
3
  import ifcopenshell.geom
4
4
  import numpy as np
5
5
  from numpy import ndarray
6
- from pydantic import BaseModel, BeforeValidator, ConfigDict
6
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, model_validator
7
+
8
+ from ifctrano.exceptions import VectorWithNansError
7
9
 
8
10
  settings = ifcopenshell.geom.settings() # type: ignore
9
11
  Coordinate = Literal["x", "y", "z"]
10
12
  AREA_TOLERANCE = 0.5
13
+ ROUNDING_FACTOR = 5
14
+ CLASH_CLEARANCE = 0.5
11
15
 
12
16
 
13
17
  class BaseModelConfig(BaseModel):
@@ -60,6 +64,13 @@ class Sign(BaseModel):
60
64
 
61
65
 
62
66
  class Vector(BasePoint):
67
+
68
+ @model_validator(mode="after")
69
+ def _validator(self) -> "Vector":
70
+ if any(np.isnan(v) for v in self.to_list()):
71
+ raise VectorWithNansError("Vector cannot have NaN values")
72
+ return self
73
+
63
74
  def __mul__(self, other: "Vector") -> "Vector":
64
75
 
65
76
  array = np.cross(self.to_array(), other.to_array())
@@ -68,6 +79,15 @@ class Vector(BasePoint):
68
79
  def dot(self, other: "Vector") -> float:
69
80
  return np.dot(self.to_array(), other.to_array()) # type: ignore
70
81
 
82
+ def angle(self, other: "Vector") -> int:
83
+ dot_product = np.dot(self.to_xy(), other.to_xy())
84
+ cross_product = np.cross(self.to_xy(), other.to_xy())
85
+ angle_rad = np.arctan2(cross_product, dot_product)
86
+ angle_deg = np.degrees(angle_rad)
87
+ if angle_deg < 0:
88
+ angle_deg += 360
89
+ return int(angle_deg)
90
+
71
91
  def project(self, other: "Vector") -> "Vector":
72
92
  a = self.dot(other) / other.dot(other)
73
93
  return Vector(x=a * other.x, y=a * other.y, z=a * other.z)
@@ -81,6 +101,9 @@ class Vector(BasePoint):
81
101
  def to_array(self) -> np.ndarray: # type: ignore
82
102
  return np.array([self.x, self.y, self.z])
83
103
 
104
+ def to_xy(self) -> np.ndarray: # type: ignore
105
+ return np.array([self.x, self.y])
106
+
84
107
  def get_normal_index(self) -> int:
85
108
  normal_index_list = [abs(v) for v in self.to_list()]
86
109
  return normal_index_list.index(max(normal_index_list))
@@ -1,5 +1,5 @@
1
1
  from logging import getLogger
2
- from typing import List, Optional, Any, Tuple
2
+ from typing import List, Optional, Any, Tuple, cast
3
3
 
4
4
  import ifcopenshell
5
5
  import numpy as np
@@ -23,8 +23,9 @@ from ifctrano.base import (
23
23
  settings,
24
24
  CommonSurface,
25
25
  AREA_TOLERANCE,
26
+ ROUNDING_FACTOR,
26
27
  )
27
- from ifctrano.exceptions import BoundingBoxFaceError
28
+ from ifctrano.exceptions import BoundingBoxFaceError, VectorWithNansError
28
29
 
29
30
  logger = getLogger(__name__)
30
31
 
@@ -77,7 +78,12 @@ class BoundingBoxFace(BaseModelConfig):
77
78
  vertices=vertices, normal=normal, coordinate_system=coordinate_system
78
79
  )
79
80
 
81
+ def get_face_area(self) -> float:
82
+ polygon_2d = self.get_2d_polygon(self.coordinate_system)
83
+ return cast(float, round(polygon_2d.polygon.area, ROUNDING_FACTOR))
84
+
80
85
  def get_2d_polygon(self, coordinate_system: CoordinateSystem) -> Polygon2D:
86
+
81
87
  projected_vertices = coordinate_system.inverse(self.vertices.to_array())
82
88
  projected_normal_index = Vector.from_array(
83
89
  coordinate_system.inverse(self.normal.to_array())
@@ -152,6 +158,8 @@ class OrientedBoundingBox(BaseModel):
152
158
  faces: BoundingBoxFaces
153
159
  centroid: Point
154
160
  area_tolerance: float = Field(default=AREA_TOLERANCE)
161
+ volume: float
162
+ height: float
155
163
 
156
164
  def intersect_faces(self, other: "OrientedBoundingBox") -> Optional[CommonSurface]:
157
165
  extend_surfaces = []
@@ -160,16 +168,21 @@ class OrientedBoundingBox(BaseModel):
160
168
  for other_face in other.faces.faces:
161
169
  vector = face.normal * other_face.normal
162
170
  if vector.is_a_zero():
163
-
164
171
  polygon_1 = other_face.get_2d_polygon(face.coordinate_system)
165
172
  polygon_2 = face.get_2d_polygon(face.coordinate_system)
166
173
  intersection = polygon_2.polygon.intersection(polygon_1.polygon)
167
-
168
174
  if intersection.area > self.area_tolerance:
169
175
  distance = abs(polygon_1.length - polygon_2.length)
170
176
  area = intersection.area
171
177
  direction_vector = (other.centroid - self.centroid).norm()
172
- orientation = direction_vector.project(face.normal).norm()
178
+ try:
179
+ orientation = direction_vector.project(face.normal).norm()
180
+ except VectorWithNansError as e:
181
+ logger.error(
182
+ "Orientation vector was not properly computed when computing the intersection between"
183
+ f"two elements. Error: {e}"
184
+ )
185
+ continue
173
186
  extend_surfaces.append(
174
187
  ExtendCommonSurface(
175
188
  distance=distance, area=area, orientation=orientation
@@ -194,8 +207,8 @@ class OrientedBoundingBox(BaseModel):
194
207
  ) -> "OrientedBoundingBox":
195
208
  vertices_np = np.array(vertices)
196
209
  points = np.asarray(vertices_np)
197
- cov = np.cov(points, y=None, rowvar=0, bias=1) # type: ignore
198
- v, vect = np.linalg.eig(cov)
210
+ cov = np.cov(points, y=None, rowvar=0, bias=0) # type: ignore
211
+ v, vect = np.linalg.eig(np.round(cov, ROUNDING_FACTOR))
199
212
  tvect = np.transpose(vect)
200
213
  points_r = np.dot(points, np.linalg.inv(tvect))
201
214
 
@@ -206,9 +219,12 @@ class OrientedBoundingBox(BaseModel):
206
219
  ymin, ymax = co_min[1], co_max[1]
207
220
  zmin, zmax = co_min[2], co_max[2]
208
221
 
209
- xdif = (xmax - xmin) * 0.5
210
- ydif = (ymax - ymin) * 0.5
211
- zdif = (zmax - zmin) * 0.5
222
+ x_len = xmax - xmin
223
+ y_len = ymax - ymin
224
+ z_len = zmax - zmin
225
+ xdif = x_len * 0.5
226
+ ydif = y_len * 0.5
227
+ zdif = z_len * 0.5
212
228
 
213
229
  cx = xmin + xdif
214
230
  cy = ymin + ydif
@@ -225,7 +241,11 @@ class OrientedBoundingBox(BaseModel):
225
241
  [cx + xdif, cy - ydif, cz - zdif],
226
242
  ]
227
243
  )
228
- corners = np.dot(corners, tvect)
244
+ corners_ = np.dot(corners, tvect)
245
+ dims = np.transpose(corners_)
246
+ x_size = np.max(dims[0]) - np.min(dims[0])
247
+ y_size = np.max(dims[1]) - np.min(dims[1])
248
+ z_size = np.max(dims[2]) - np.min(dims[2])
229
249
  coordinate_system = CoordinateSystem.from_array(tvect)
230
250
  c = P(x=cx, y=cy, z=cz)
231
251
  d = P(x=xdif, y=ydif, z=zdif)
@@ -233,6 +253,8 @@ class OrientedBoundingBox(BaseModel):
233
253
  return cls(
234
254
  faces=faces,
235
255
  centroid=Point.from_array(coordinate_system.project(c.to_array())),
256
+ volume=x_size * y_size * z_size,
257
+ height=z_size,
236
258
  )
237
259
 
238
260
  @classmethod
@@ -0,0 +1,185 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import List, Tuple, Any, Optional
4
+
5
+ import ifcopenshell
6
+ from ifcopenshell import file, entity_instance
7
+ from pydantic import validate_call, Field, model_validator, field_validator
8
+ from trano.elements import InternalElement # type: ignore
9
+ from trano.elements.library.library import Library # type: ignore
10
+ from trano.elements.types import Tilt # type: ignore
11
+ from trano.topology import Network # type: ignore
12
+
13
+ from ifctrano.base import BaseModelConfig, Libraries, Vector
14
+ from ifctrano.exceptions import IfcFileNotFoundError, NoIfcSpaceFoundError
15
+ from ifctrano.space_boundary import (
16
+ SpaceBoundaries,
17
+ initialize_tree,
18
+ Space,
19
+ construction,
20
+ )
21
+
22
+
23
+ def get_spaces(ifcopenshell_file: file) -> List[entity_instance]:
24
+ return ifcopenshell_file.by_type("IfcSpace")
25
+
26
+
27
+ class IfcInternalElement(BaseModelConfig):
28
+ spaces: List[Space]
29
+ element: entity_instance
30
+ area: float
31
+
32
+ def __hash__(self) -> int:
33
+ return hash(
34
+ (
35
+ *sorted([space.global_id for space in self.spaces]),
36
+ self.element.GlobalId,
37
+ self.area,
38
+ )
39
+ )
40
+
41
+ def __eq__(self, other: "IfcInternalElement") -> bool: # type: ignore
42
+ return hash(self) == hash(other)
43
+
44
+ def description(self) -> Tuple[Any, Any, str, float]:
45
+ return (
46
+ *sorted([space.global_id for space in self.spaces]),
47
+ self.element.GlobalId,
48
+ self.element.is_a(),
49
+ self.area,
50
+ )
51
+
52
+
53
+ class InternalElements(BaseModelConfig):
54
+ elements: List[IfcInternalElement] = Field(default_factory=list)
55
+
56
+ def internal_element_ids(self) -> List[str]:
57
+ return list({e.element.GlobalId for e in self.elements})
58
+
59
+ def description(self) -> List[Tuple[Any, Any, str, float]]:
60
+ return sorted([element.description() for element in self.elements])
61
+
62
+
63
+ class Building(BaseModelConfig):
64
+ name: str
65
+ space_boundaries: List[SpaceBoundaries]
66
+ ifc_file: file
67
+ parent_folder: Path
68
+ internal_elements: InternalElements = Field(default_factory=InternalElements)
69
+
70
+ @field_validator("name")
71
+ @classmethod
72
+ def _name_validator(cls, name: str) -> str:
73
+ name = name.replace(" ", "_")
74
+ name = re.sub(r"[^a-zA-Z0-9_]", "", name)
75
+ return name.lower()
76
+
77
+ @classmethod
78
+ def from_ifc(
79
+ cls, ifc_file_path: Path, selected_spaces_global_id: Optional[List[str]] = None
80
+ ) -> "Building":
81
+ selected_spaces_global_id = selected_spaces_global_id or []
82
+ if not ifc_file_path.exists():
83
+ raise IfcFileNotFoundError(
84
+ f"File specified {ifc_file_path} does not exist."
85
+ )
86
+ ifc_file = ifcopenshell.open(str(ifc_file_path))
87
+ tree = initialize_tree(ifc_file)
88
+ spaces = get_spaces(ifc_file)
89
+ if selected_spaces_global_id:
90
+ spaces = [
91
+ space for space in spaces if space.GlobalId in selected_spaces_global_id
92
+ ]
93
+ if not spaces:
94
+ raise NoIfcSpaceFoundError("No IfcSpace found in the file.")
95
+ space_boundaries = [
96
+ SpaceBoundaries.from_space_entity(ifc_file, tree, space) for space in spaces
97
+ ]
98
+ return cls(
99
+ space_boundaries=space_boundaries,
100
+ ifc_file=ifc_file,
101
+ parent_folder=ifc_file_path.parent,
102
+ name=ifc_file_path.stem,
103
+ )
104
+
105
+ @model_validator(mode="after")
106
+ def _validator(self) -> "Building":
107
+ self.internal_elements = self.get_adjacency()
108
+ return self
109
+
110
+ def get_adjacency(self) -> InternalElements:
111
+ elements = []
112
+ for space_boundaries_ in self.space_boundaries:
113
+ for space_boundaries__ in self.space_boundaries:
114
+ space_1 = space_boundaries_.space
115
+ space_2 = space_boundaries__.space
116
+ if space_1.global_id == space_2.global_id:
117
+ continue
118
+ common_surface = space_1.bounding_box.intersect_faces(
119
+ space_2.bounding_box
120
+ )
121
+ for boundary in space_boundaries_.boundaries:
122
+ for boundary_ in space_boundaries__.boundaries:
123
+ if (
124
+ boundary.entity.GlobalId == boundary_.entity.GlobalId
125
+ and boundary.common_surface
126
+ and boundary_.common_surface
127
+ and common_surface
128
+ and (
129
+ boundary.common_surface.orientation
130
+ * common_surface.orientation
131
+ ).is_a_zero()
132
+ and (
133
+ boundary_.common_surface.orientation
134
+ * common_surface.orientation
135
+ ).is_a_zero()
136
+ ) and boundary.common_surface.orientation.dot(
137
+ boundary_.common_surface.orientation
138
+ ) < 0:
139
+ elements.append( # noqa: PERF401
140
+ IfcInternalElement(
141
+ spaces=[space_1, space_2],
142
+ element=boundary_.entity,
143
+ area=min(
144
+ common_surface.area,
145
+ boundary.common_surface.area,
146
+ boundary_.common_surface.area,
147
+ ),
148
+ )
149
+ )
150
+ return InternalElements(elements=list(set(elements)))
151
+
152
+ @validate_call
153
+ def create_model(
154
+ self,
155
+ library: Libraries = "Buildings",
156
+ north_axis: Optional[Vector] = None,
157
+ ) -> str:
158
+ north_axis = north_axis or Vector(x=0, y=1, z=0)
159
+ network = Network(name=self.name, library=Library.from_configuration(library))
160
+ spaces = {
161
+ space_boundary.space.global_id: space_boundary.model(
162
+ self.internal_elements.internal_element_ids(), north_axis
163
+ )
164
+ for space_boundary in self.space_boundaries
165
+ }
166
+ spaces = {k: v for k, v in spaces.items() if v}
167
+ network.add_boiler_plate_spaces(list(spaces.values()), create_internal=False)
168
+ for internal_element in self.internal_elements.elements:
169
+ space_1 = internal_element.spaces[0]
170
+ space_2 = internal_element.spaces[1]
171
+ network.connect_spaces(
172
+ spaces[space_1.global_id],
173
+ spaces[space_2.global_id],
174
+ InternalElement(
175
+ azimuth=10,
176
+ construction=construction,
177
+ surface=internal_element.area,
178
+ tilt=Tilt.wall,
179
+ ),
180
+ )
181
+ return network.model() # type: ignore
182
+
183
+ def save_model(self, library: Libraries = "Buildings") -> None:
184
+ model_ = self.create_model(library)
185
+ Path(self.parent_folder.joinpath(f"{self.name}.mo")).write_text(model_)