Coverage for bim2sim/elements/bps_elements.py: 50%

804 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 17:09 +0000

1"""Module contains the different classes for all HVAC elements""" 

2import inspect 

3import logging 

4import math 

5import re 

6import sys 

7from datetime import date 

8from typing import Set, List, Union 

9 

10import ifcopenshell 

11import ifcopenshell.geom 

12from OCC.Core.BRepBndLib import brepbndlib_Add 

13from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform 

14from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape 

15from OCC.Core.BRepGProp import brepgprop_SurfaceProperties 

16from OCC.Core.BRepLib import BRepLib_FuseEdges 

17from OCC.Core.Bnd import Bnd_Box 

18from OCC.Core.Extrema import Extrema_ExtFlag_MIN 

19from OCC.Core.GProp import GProp_GProps 

20from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain 

21from OCC.Core.gp import gp_Trsf, gp_Vec, gp_XYZ, gp_Pnt, \ 

22 gp_Mat, gp_Quaternion 

23from ifcopenshell import guid 

24 

25from bim2sim.elements.mapping import condition, attribute 

26from bim2sim.elements.base_elements import ProductBased, RelationBased 

27from bim2sim.elements.mapping.units import ureg 

28from bim2sim.tasks.common.inner_loop_remover import remove_inner_loops 

29from bim2sim.utilities.common_functions import vector_angle, angle_equivalent 

30from bim2sim.utilities.pyocc_tools import PyOCCTools 

31from bim2sim.utilities.types import IFCDomain, BoundaryOrientation 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36class BPSProduct(ProductBased): 

37 domain = 'BPS' 

38 

39 def __init__(self, *args, **kwargs): 

40 super().__init__(*args, **kwargs) 

41 self.thermal_zones = [] 

42 self.space_boundaries = [] 

43 self.storeys = [] 

44 self.material = None 

45 self.disaggregations = [] 

46 self.building = None 

47 self.site = None 

48 

49 def __repr__(self): 

50 return "<%s (guid: %s)>" % ( 

51 self.__class__.__name__, self.guid) 

52 

53 def get_bound_area(self, name) -> ureg.Quantity: 

54 """ get gross bound area (including opening areas) of the element""" 

55 return sum(sb.bound_area for sb in self.sbs_without_corresponding) 

56 

57 def get_net_bound_area(self, name) -> ureg.Quantity: 

58 """get net area (including opening areas) of the element""" 

59 return self.gross_area - self.opening_area 

60 

61 @property 

62 def is_external(self) -> bool or None: 

63 """Checks if the corresponding element has contact with external 

64 environment (e.g. ground, roof, wall)""" 

65 if hasattr(self, 'parent'): 

66 return self.parent.is_external 

67 elif hasattr(self, 'ifc'): 

68 if hasattr(self.ifc, 'ProvidesBoundaries'): 

69 if len(self.ifc.ProvidesBoundaries) > 0: 

70 ext_int = list( 

71 set([boundary.InternalOrExternalBoundary for boundary 

72 in self.ifc.ProvidesBoundaries])) 

73 if len(ext_int) == 1: 

74 if ext_int[0].lower() == 'external': 

75 return True 

76 if ext_int[0].lower() == 'internal': 

77 return False 

78 else: 

79 return ext_int 

80 return None 

81 

82 def calc_cost_group(self) -> int: 

83 """Default cost group for building elements is 300""" 

84 return 300 

85 

86 def _calc_teaser_orientation(self, name) -> Union[int, None]: 

87 """Calculate the orientation of the bps product based on SB direction. 

88 

89 For buildings elements we can use the more reliable space boundaries 

90 normal vector to calculate the orientation if the space boundaries 

91 exists. Otherwise the base calc_orientation of IFCBased will be used. 

92 

93 Returns: 

94 Orientation angle between 0 and 360. 

95 (0 : north, 90: east, 180: south, 270: west) 

96 """ 

97 true_north = self.get_true_north() 

98 if len(self.space_boundaries): 

99 new_orientation = self.group_orientation( 

100 [vector_angle(space_boundary.bound_normal.Coord()) 

101 for space_boundary in self.space_boundaries]) 

102 if new_orientation is not None: 

103 return int(angle_equivalent(new_orientation + true_north)) 

104 # return int(angle_equivalent(super().calc_orientation() + true_north)) 

105 return None 

106 

107 @staticmethod 

108 def group_orientation(orientations: list): 

109 dict_orientations = {} 

110 for orientation in orientations: 

111 rounded_orientation = round(orientation) 

112 if rounded_orientation not in dict_orientations: 

113 dict_orientations[rounded_orientation] = 0 

114 dict_orientations[rounded_orientation] += 1 

115 if len(dict_orientations): 

116 return max(dict_orientations, key=dict_orientations.get) 

117 return None 

118 

119 def _get_sbs_without_corresponding(self, name) -> list: 

120 """get a list with only not duplicated space boundaries""" 

121 sbs_without_corresponding = list(self.space_boundaries) 

122 for sb in self.space_boundaries: 

123 if sb in sbs_without_corresponding: 

124 if sb.related_bound and sb.related_bound in \ 

125 sbs_without_corresponding: 

126 sbs_without_corresponding.remove(sb.related_bound) 

127 return sbs_without_corresponding 

128 

129 def _get_opening_area(self, name): 

130 """get sum of opening areas of the element""" 

131 return sum(sb.opening_area for sb in self.sbs_without_corresponding) 

132 

133 teaser_orientation = attribute.Attribute( 

134 description="Orientation of element in TEASER conventions. 0-360 for " 

135 "orientation of vertical elements and -1 for roofs and " 

136 "ceiling, -2 for groundfloors and floors.", 

137 functions=[_calc_teaser_orientation], 

138 ) 

139 

140 gross_area = attribute.Attribute( 

141 functions=[get_bound_area], 

142 unit=ureg.meter ** 2 

143 ) 

144 

145 net_area = attribute.Attribute( 

146 functions=[get_net_bound_area], 

147 unit=ureg.meter ** 2 

148 ) 

149 

150 sbs_without_corresponding = attribute.Attribute( 

151 description="A list with only not duplicated space boundaries", 

152 functions=[_get_sbs_without_corresponding] 

153 ) 

154 

155 opening_area = attribute.Attribute( 

156 description="Sum of opening areas of the element", 

157 functions=[_get_opening_area] 

158 ) 

159 

160 

161class ThermalZone(BPSProduct): 

162 ifc_types = { 

163 "IfcSpace": 

164 ['*', 'SPACE', 'PARKING', 'GFA', 'INTERNAL', 'EXTERNAL'] 

165 } 

166 

167 pattern_ifc_type = [ 

168 re.compile('Space', flags=re.IGNORECASE), 

169 re.compile('Zone', flags=re.IGNORECASE) 

170 ] 

171 

172 def __init__(self, *args, **kwargs): 

173 self.bound_elements = kwargs.pop('bound_elements', []) 

174 super().__init__(*args, **kwargs) 

175 

176 @property 

177 def outer_walls(self) -> list: 

178 """List of all outer wall elements bounded to the thermal zone""" 

179 return [ 

180 ele for ele in self.bound_elements if isinstance(ele, OuterWall)] 

181 

182 @property 

183 def windows(self) -> list: 

184 """List of all window elements bounded to the thermal zone""" 

185 return [ele for ele in self.bound_elements if isinstance(ele, Window)] 

186 

187 @property 

188 def is_external(self) -> bool: 

189 """determines if a thermal zone is external or internal based on the 

190 presence of outer walls""" 

191 return len(self.outer_walls) > 0 

192 

193 def _get_external_orientation(self, name) -> str or float: 

194 """determines the orientation of the thermal zone based on its elements 

195 it can be a corner (list of 2 angles) or an edge (1 angle)""" 

196 if self.is_external is True: 

197 orientations = [ele.teaser_orientation for ele in self.outer_walls] 

198 calc_temp = list(set(orientations)) 

199 sum_or = sum(calc_temp) 

200 if 0 in calc_temp: 

201 if sum_or > 180: 

202 sum_or += 360 

203 return sum_or / len(calc_temp) 

204 return 'Internal' 

205 

206 def _get_glass_percentage(self, name) -> float or ureg.Quantity: 

207 """determines the glass area/facade area ratio for all the windows in 

208 the space in one of the 4 following ranges 

209 0%-30%: 15 

210 30%-50%: 40 

211 50%-70%: 60 

212 70%-100%: 85""" 

213 glass_area = sum(wi.gross_area for wi in self.windows) 

214 facade_area = sum(wa.gross_area for wa in self.outer_walls) 

215 if facade_area > 0: 

216 return 100 * (glass_area / (facade_area + glass_area)).m 

217 else: 

218 return 'Internal' 

219 

220 def _get_space_neighbors(self, name) -> list: 

221 """determines the neighbors of the thermal zone""" 

222 neighbors = [] 

223 for sb in self.space_boundaries: 

224 if sb.related_bound is not None: 

225 tz = sb.related_bound.bound_thermal_zone 

226 # todo: check if computation of neighbors works as expected 

227 # what if boundary has no related bound but still has a 

228 # neighbor? 

229 # hint: neighbors != related bounds 

230 if (tz is not self) and (tz not in neighbors): 

231 neighbors.append(tz) 

232 return neighbors 

233 

234 def _get_space_shape(self, name): 

235 """returns topods shape of the IfcSpace""" 

236 settings = ifcopenshell.geom.main.settings() 

237 settings.set(settings.USE_PYTHON_OPENCASCADE, True) 

238 settings.set(settings.USE_WORLD_COORDS, True) 

239 settings.set(settings.EXCLUDE_SOLIDS_AND_SURFACES, False) 

240 settings.set(settings.INCLUDE_CURVES, True) 

241 return ifcopenshell.geom.create_shape(settings, self.ifc).geometry 

242 

243 def _get_space_center(self, name) -> float: 

244 """ 

245 This function returns the center of the bounding box of an ifc space 

246 shape 

247 :return: center of space bounding box (gp_Pnt) 

248 """ 

249 bbox = Bnd_Box() 

250 brepbndlib_Add(self.space_shape, bbox) 

251 bbox_center = ifcopenshell.geom.utils.get_bounding_box_center(bbox) 

252 return bbox_center 

253 

254 def _get_footprint_shape(self, name): 

255 """ 

256 This function returns the footprint of a space shape. This can be 

257 used e.g., to visualize floor plans. 

258 """ 

259 footprint = PyOCCTools.get_footprint_of_shape(self.space_shape) 

260 return footprint 

261 

262 def _get_space_shape_volume(self, name): 

263 """ 

264 This function returns the volume of a space shape 

265 """ 

266 return PyOCCTools.get_shape_volume(self.space_shape) 

267 

268 def _get_volume_geometric(self, name): 

269 """ 

270 This function returns the volume of a space geometrically 

271 """ 

272 return self.gross_area * self.height 

273 

274 def _get_usage(self, name): 

275 """ 

276 This function returns the usage of a space 

277 """ 

278 if self.zone_name is not None: 

279 usage = self.zone_name 

280 elif self.ifc.LongName is not None and \ 

281 "oldSpaceGuids_" not in self.ifc.LongName: 

282 # todo oldSpaceGuids_ is hardcode for erics tool 

283 usage = self.ifc.LongName 

284 else: 

285 usage = self.name 

286 return usage 

287 

288 def _get_name(self, name): 

289 """ 

290 This function returns the name of a space 

291 """ 

292 if self.zone_name: 

293 space_name = self.zone_name 

294 else: 

295 space_name = self.ifc.Name 

296 return space_name 

297 

298 def get_bound_floor_area(self, name): 

299 """Get bound floor area of zone. This is currently set by sum of all 

300 horizontal gross area and take half of it due to issues with 

301 TOP BOTTOM""" 

302 leveled_areas = {} 

303 for height, sbs in self.horizontal_sbs.items(): 

304 if height not in leveled_areas: 

305 leveled_areas[height] = 0 

306 leveled_areas[height] += sum([sb.bound_area for sb in sbs]) 

307 

308 return sum(leveled_areas.values()) / 2 

309 

310 def get_net_bound_floor_area(self, name): 

311 """Get net bound floor area of zone. This is currently set by sum of all 

312 horizontal net area and take half of it due to issues with TOP BOTTOM.""" 

313 leveled_areas = {} 

314 for height, sbs in self.horizontal_sbs.items(): 

315 if height not in leveled_areas: 

316 leveled_areas[height] = 0 

317 leveled_areas[height] += sum([sb.net_bound_area for sb in sbs]) 

318 

319 return sum(leveled_areas.values()) / 2 

320 

321 def _get_horizontal_sbs(self, name): 

322 """get all horizonal SBs in a zone and convert them into a dict with 

323 key z-height in room and the SB as value.""" 

324 # todo: use only bottom when TOP bottom is working correctly 

325 valid = [BoundaryOrientation.top, BoundaryOrientation.bottom] 

326 leveled_sbs = {} 

327 for sb in self.sbs_without_corresponding: 

328 if sb.top_bottom in valid: 

329 pos = round(sb.position[2], 1) 

330 if pos not in leveled_sbs: 

331 leveled_sbs[pos] = [] 

332 leveled_sbs[pos].append(sb) 

333 

334 return leveled_sbs 

335 

336 def _area_specific_post_processing(self, value): 

337 return value / self.net_area 

338 

339 def _get_heating_profile(self, name) -> list: 

340 """returns a heating profile using the heat temperature in the IFC""" 

341 # todo make this "dynamic" with a night set back 

342 if self.t_set_heat is not None: 

343 return [self.t_set_heat.to(ureg.kelvin).m] * 24 

344 

345 def _get_cooling_profile(self, name) -> list: 

346 """returns a cooling profile using the cool temperature in the IFC""" 

347 # todo make this "dynamic" with a night set back 

348 if self.t_set_cool is not None: 

349 return [self.t_set_cool.to(ureg.kelvin).m] * 24 

350 

351 def _get_persons(self, name): 

352 if self.area_per_occupant: 

353 return 1 / self.area_per_occupant 

354 

355 external_orientation = attribute.Attribute( 

356 description="Orientation of the thermal zone, either 'Internal' or a " 

357 "list of 2 angles or a single angle as value between 0 and " 

358 "360.", 

359 functions=[_get_external_orientation] 

360 ) 

361 

362 glass_percentage = attribute.Attribute( 

363 description="Determines the glass area/facade area ratio for all the " 

364 "windows in the space in one of the 4 following ranges:" 

365 " 0%-30%: 15, 30%-50%: 40, 50%-70%: 60, 70%-100%: 85.", 

366 functions=[_get_glass_percentage] 

367 ) 

368 

369 space_neighbors = attribute.Attribute( 

370 description="Determines the neighbors of the thermal zone.", 

371 functions=[_get_space_neighbors] 

372 ) 

373 

374 space_shape = attribute.Attribute( 

375 description="Returns topods shape of the IfcSpace.", 

376 functions=[_get_space_shape] 

377 ) 

378 

379 space_center = attribute.Attribute( 

380 description="Returns the center of the bounding box of an ifc space " 

381 "shape.", 

382 functions=[_get_space_center] 

383 ) 

384 

385 footprint_shape = attribute.Attribute( 

386 description="Returns the footprint of a space shape, which can be " 

387 "used e.g., to visualize floor plans.", 

388 functions=[_get_footprint_shape] 

389 ) 

390 

391 horizontal_sbs = attribute.Attribute( 

392 description="All horizontal space boundaries in a zone as dict. Key is" 

393 " the z-zeight in the room and value the SB.", 

394 functions=[_get_horizontal_sbs] 

395 ) 

396 

397 zone_name = attribute.Attribute( 

398 default_ps=("Pset_SpaceCommon", "Reference") 

399 ) 

400 

401 name = attribute.Attribute( 

402 functions=[_get_name] 

403 ) 

404 

405 usage = attribute.Attribute( 

406 default_ps=("Pset_SpaceOccupancyRequirements", "OccupancyType"), 

407 functions=[_get_usage] 

408 ) 

409 

410 t_set_heat = attribute.Attribute( 

411 default_ps=("Pset_SpaceThermalRequirements", "SpaceTemperatureMin"), 

412 unit=ureg.degC, 

413 ) 

414 

415 t_set_cool = attribute.Attribute( 

416 default_ps=("Pset_SpaceThermalRequirements", "SpaceTemperatureMax"), 

417 unit=ureg.degC, 

418 ) 

419 

420 t_ground = attribute.Attribute( 

421 unit=ureg.degC, 

422 default=13, 

423 ) 

424 

425 max_humidity = attribute.Attribute( 

426 default_ps=("Pset_SpaceThermalRequirements", "SpaceHumidityMax"), 

427 unit=ureg.dimensionless, 

428 ) 

429 

430 min_humidity = attribute.Attribute( 

431 default_ps=("Pset_SpaceThermalRequirements", "SpaceHumidityMin"), 

432 unit=ureg.dimensionless, 

433 ) 

434 

435 natural_ventilation = attribute.Attribute( 

436 default_ps=("Pset_SpaceThermalRequirements", "NaturalVentilation"), 

437 ) 

438 

439 natural_ventilation_rate = attribute.Attribute( 

440 default_ps=("Pset_SpaceThermalRequirements", "NaturalVentilationRate"), 

441 unit=1 / ureg.hour, 

442 ) 

443 

444 mechanical_ventilation_rate = attribute.Attribute( 

445 default_ps=("Pset_SpaceThermalRequirements", 

446 "MechanicalVentilationRate"), 

447 unit=1 / ureg.hour, 

448 ) 

449 

450 with_ahu = attribute.Attribute( 

451 default_ps=("Pset_SpaceThermalRequirements", "AirConditioning"), 

452 ) 

453 

454 central_ahu = attribute.Attribute( 

455 default_ps=("Pset_SpaceThermalRequirements", "AirConditioningCentral"), 

456 ) 

457 

458 gross_area = attribute.Attribute( 

459 default_ps=("Qto_SpaceBaseQuantities", "GrossFloorArea"), 

460 functions=[get_bound_floor_area], 

461 unit=ureg.meter ** 2 

462 ) 

463 

464 net_area = attribute.Attribute( 

465 default_ps=("Qto_SpaceBaseQuantities", "NetFloorArea"), 

466 functions=[get_net_bound_floor_area], 

467 unit=ureg.meter ** 2 

468 ) 

469 

470 net_wall_area = attribute.Attribute( 

471 default_ps=("Qto_SpaceBaseQuantities", "NetWallArea"), 

472 unit=ureg.meter ** 2 

473 ) 

474 

475 net_ceiling_area = attribute.Attribute( 

476 default_ps=("Qto_SpaceBaseQuantities", "NetCeilingArea"), 

477 unit=ureg.meter ** 2 

478 ) 

479 

480 net_volume = attribute.Attribute( 

481 default_ps=("Qto_SpaceBaseQuantities", "NetVolume"), 

482 functions=[_get_space_shape_volume, _get_volume_geometric], 

483 unit=ureg.meter ** 3, 

484 ) 

485 gross_volume = attribute.Attribute( 

486 default_ps=("Qto_SpaceBaseQuantities", "GrossVolume"), 

487 functions=[_get_volume_geometric], 

488 unit=ureg.meter ** 3, 

489 ) 

490 

491 height = attribute.Attribute( 

492 default_ps=("Qto_SpaceBaseQuantities", "Height"), 

493 unit=ureg.meter, 

494 ) 

495 

496 length = attribute.Attribute( 

497 default_ps=("Qto_SpaceBaseQuantities", "Length"), 

498 unit=ureg.meter, 

499 ) 

500 

501 width = attribute.Attribute( 

502 default_ps=("Qto_SpaceBaseQuantities", "Width"), 

503 unit=ureg.m 

504 ) 

505 

506 area_per_occupant = attribute.Attribute( 

507 default_ps=("Pset_SpaceOccupancyRequirements", "AreaPerOccupant"), 

508 unit=ureg.meter ** 2 

509 ) 

510 

511 space_shape_volume = attribute.Attribute( 

512 functions=[_get_space_shape_volume], 

513 unit=ureg.meter ** 3, 

514 ) 

515 

516 clothing_persons = attribute.Attribute( 

517 default_ps=("", "") 

518 ) 

519 

520 surround_clo_persons = attribute.Attribute( 

521 default_ps=("", "") 

522 ) 

523 

524 heating_profile = attribute.Attribute( 

525 functions=[_get_heating_profile], 

526 ) 

527 

528 cooling_profile = attribute.Attribute( 

529 functions=[_get_cooling_profile], 

530 ) 

531 

532 persons = attribute.Attribute( 

533 functions=[_get_persons], 

534 ) 

535 

536 # use conditions 

537 with_cooling = attribute.Attribute( 

538 ) 

539 

540 with_heating = attribute.Attribute( 

541 ) 

542 

543 T_threshold_heating = attribute.Attribute( 

544 ) 

545 

546 activity_degree_persons = attribute.Attribute( 

547 ) 

548 

549 fixed_heat_flow_rate_persons = attribute.Attribute( 

550 default_ps=("Pset_SpaceThermalLoad", "People"), 

551 unit=ureg.W, 

552 ) 

553 

554 internal_gains_moisture_no_people = attribute.Attribute( 

555 ) 

556 

557 T_threshold_cooling = attribute.Attribute( 

558 ) 

559 

560 ratio_conv_rad_persons = attribute.Attribute( 

561 default=0.5, 

562 ) 

563 

564 ratio_conv_rad_machines = attribute.Attribute( 

565 default=0.5, 

566 ) 

567 

568 ratio_conv_rad_lighting = attribute.Attribute( 

569 default=0.5, 

570 ) 

571 

572 machines = attribute.Attribute( 

573 description="Specific internal gains through machines, if taken from" 

574 " IFC property set a division by thermal zone area is" 

575 " needed.", 

576 default_ps=("Pset_SpaceThermalLoad", "EquipmentSensible"), 

577 ifc_postprocessing=_area_specific_post_processing, 

578 unit=ureg.W / (ureg.meter ** 2), 

579 ) 

580 

581 def _calc_lighting_power(self, name) -> float: 

582 if self.use_maintained_illuminance: 

583 return self.maintained_illuminance / self.lighting_efficiency_lumen 

584 else: 

585 return self.fixed_lighting_power 

586 

587 lighting_power = attribute.Attribute( 

588 description="Specific lighting power in W/m2. If taken from IFC" 

589 " property set a division by thermal zone area is needed.", 

590 default_ps=("Pset_SpaceThermalLoad", "Lighting"), 

591 ifc_postprocessing=_area_specific_post_processing, 

592 functions=[_calc_lighting_power], 

593 unit=ureg.W / (ureg.meter ** 2), 

594 ) 

595 

596 fixed_lighting_power = attribute.Attribute( 

597 description="Specific fixed electrical power for lighting in W/m2. " 

598 "This value is taken from SIA 2024.", 

599 unit=ureg.W / (ureg.meter ** 2) 

600 ) 

601 

602 maintained_illuminance = attribute.Attribute( 

603 description="Maintained illuminance value for lighting. This value is" 

604 " taken from SIA 2024.", 

605 unit=ureg.lumen / (ureg.meter ** 2) 

606 ) 

607 

608 use_maintained_illuminance = attribute.Attribute( 

609 description="Decision variable to determine if lighting_power will" 

610 " be given by fixed_lighting_power or by calculation " 

611 "using the variables maintained_illuminance and " 

612 "lighting_efficiency_lumen. This is not available in IFC " 

613 "and can be set through the sim_setting with equivalent " 

614 "name. " 

615 ) 

616 

617 lighting_efficiency_lumen = attribute.Attribute( 

618 description="Lighting efficiency in lm/W_el, in german: Lichtausbeute.", 

619 unit=ureg.lumen / ureg.W 

620 ) 

621 

622 use_constant_infiltration = attribute.Attribute( 

623 ) 

624 

625 base_infiltration = attribute.Attribute( 

626 ) 

627 

628 max_user_infiltration = attribute.Attribute( 

629 ) 

630 

631 max_overheating_infiltration = attribute.Attribute( 

632 ) 

633 

634 max_summer_infiltration = attribute.Attribute( 

635 ) 

636 

637 winter_reduction_infiltration = attribute.Attribute( 

638 ) 

639 

640 min_ahu = attribute.Attribute( 

641 ) 

642 

643 max_ahu = attribute.Attribute( 

644 default_ps=("Pset_AirSideSystemInformation", "TotalAirflow"), 

645 unit=ureg.meter ** 3 / ureg.s 

646 ) 

647 

648 with_ideal_thresholds = attribute.Attribute( 

649 ) 

650 

651 persons_profile = attribute.Attribute( 

652 ) 

653 

654 machines_profile = attribute.Attribute( 

655 ) 

656 

657 lighting_profile = attribute.Attribute( 

658 ) 

659 

660 def get__elements_by_type(self, type): 

661 raise NotImplementedError 

662 

663 def __repr__(self): 

664 return "<%s (usage: %s)>" \ 

665 % (self.__class__.__name__, self.usage) 

666 

667class ExternalSpatialElement(ThermalZone): 

668 ifc_types = { 

669 "IfcExternalSpatialElement": 

670 ['*'] 

671 } 

672 

673 

674class SpaceBoundary(RelationBased): 

675 ifc_types = {'IfcRelSpaceBoundary': ['*']} 

676 

677 def __init__(self, *args, elements: dict, **kwargs): 

678 """spaceboundary __init__ function""" 

679 super().__init__(*args, **kwargs) 

680 self.disaggregation = [] 

681 self.bound_element = None 

682 self.disagg_parent = None 

683 self.bound_thermal_zone = None 

684 self._elements = elements 

685 self.parent_bound = None 

686 self.opening_bounds = [] 

687 

688 def _calc_position(self, name): 

689 """ 

690 calculates the position of the spaceboundary, using the relative 

691 position of resultant disaggregation 

692 """ 

693 if hasattr(self.ifc.ConnectionGeometry.SurfaceOnRelatingElement, 

694 'BasisSurface'): 

695 position = self.ifc.ConnectionGeometry.SurfaceOnRelatingElement. \ 

696 BasisSurface.Position.Location.Coordinates 

697 else: 

698 position = self.ifc.ConnectionGeometry.SurfaceOnRelatingElement. \ 

699 Position.Location.Coordinates 

700 

701 return position 

702 

703 @classmethod 

704 def pre_validate(cls, ifc) -> bool: 

705 return True 

706 

707 def validate_creation(self) -> bool: 

708 if self.bound_area and self.bound_area < 1e-2 * ureg.meter ** 2: 

709 return True 

710 return False 

711 

712 def get_bound_area(self, name) -> ureg.Quantity: 

713 """compute area of a space boundary""" 

714 bound_prop = GProp_GProps() 

715 brepgprop_SurfaceProperties(self.bound_shape, bound_prop) 

716 area = bound_prop.Mass() 

717 return area * ureg.meter ** 2 

718 

719 bound_area = attribute.Attribute( 

720 description="The area bound by the space boundary.", 

721 unit=ureg.meter ** 2, 

722 functions=[get_bound_area] 

723 ) 

724 

725 def _get_top_bottom(self, name) -> BoundaryOrientation: 

726 """ 

727 Determines if a boundary is a top (ceiling/roof) or bottom (floor/slab) 

728 element based solely on its normal vector orientation. 

729 

730 Classification is based on the dot product between the boundary's 

731 normal vector and the vertical vector (0, 0, 1): 

732 - TOP: when normal points upward (dot product > cos(89°)) 

733 - BOTTOM: when normal points downward (dot product < cos(91°)) 

734 - VERTICAL: when normal is perpendicular to vertical (dot product ≈ 0) 

735 

736 Returns: 

737 BoundaryOrientation: Enumerated orientation classification 

738 """ 

739 vertical_vector = gp_XYZ(0.0, 0.0, 1.0) 

740 cos_angle_top = math.cos(math.radians(89)) 

741 cos_angle_bottom = math.cos(math.radians(91)) 

742 

743 normal_dot_vertical = vertical_vector.Dot(self.bound_normal) 

744 

745 # Classify based on dot product 

746 if normal_dot_vertical > cos_angle_top: 

747 return BoundaryOrientation.top 

748 elif normal_dot_vertical < cos_angle_bottom: 

749 return BoundaryOrientation.bottom 

750 

751 return BoundaryOrientation.vertical 

752 

753 def _get_bound_center(self, name): 

754 """ compute center of the bounding box of a space boundary""" 

755 p = GProp_GProps() 

756 brepgprop_SurfaceProperties(self.bound_shape, p) 

757 return p.CentreOfMass().XYZ() 

758 

759 def _get_related_bound(self, name): 

760 """ 

761 Get corresponding space boundary in another space, 

762 ensuring that corresponding space boundaries have a matching number of 

763 vertices. 

764 """ 

765 if hasattr(self.ifc, 'CorrespondingBoundary') and \ 

766 self.ifc.CorrespondingBoundary is not None: 

767 corr_bound = self._elements.get( 

768 self.ifc.CorrespondingBoundary.GlobalId) 

769 if corr_bound: 

770 nb_vert_this = PyOCCTools.get_number_of_vertices( 

771 self.bound_shape) 

772 nb_vert_other = PyOCCTools.get_number_of_vertices( 

773 corr_bound.bound_shape) 

774 # if not nb_vert_this == nb_vert_other: 

775 # print("NO VERT MATCH!:", nb_vert_this, nb_vert_other) 

776 if nb_vert_this == nb_vert_other: 

777 return corr_bound 

778 else: 

779 # deal with a mismatch of vertices, due to different 

780 # triangulation or for other reasons. Only applicable for 

781 # small differences in the bound area between the 

782 # corresponding surfaces 

783 if abs(self.bound_area.m - corr_bound.bound_area.m) < 0.01: 

784 # get points of the current space boundary 

785 p = PyOCCTools.get_points_of_face(self.bound_shape) 

786 # reverse the points and create a new face. Points 

787 # have to be reverted, otherwise it would result in an 

788 # incorrectly oriented surface normal 

789 p.reverse() 

790 new_corr_shape = PyOCCTools.make_faces_from_pnts(p) 

791 # move the new shape of the corresponding boundary to 

792 # the original position of the corresponding boundary 

793 new_moved_corr_shape = ( 

794 PyOCCTools.move_bounds_to_vertical_pos([ 

795 new_corr_shape], corr_bound.bound_shape))[0] 

796 # assign the new shape to the original shape and 

797 # return the new corresponding boundary 

798 corr_bound.bound_shape = new_moved_corr_shape 

799 return corr_bound 

800 if self.bound_element is None: 

801 # return None 

802 # check for virtual bounds 

803 if not self.physical: 

804 corr_bound = None 

805 # cover virtual space boundaries without related IfcVirtualElement 

806 if not self.ifc.RelatedBuildingElement: 

807 vbs = [b for b in self._elements.values() if 

808 isinstance(b, SpaceBoundary) and not 

809 b.ifc.RelatedBuildingElement] 

810 for b in vbs: 

811 if b is self: 

812 continue 

813 if b.ifc.RelatingSpace == self.ifc.RelatingSpace: 

814 continue 

815 if not (b.bound_area.m - self.bound_area.m) ** 2 < 1e-2: 

816 continue 

817 center_dist = gp_Pnt(self.bound_center).Distance( 

818 gp_Pnt(b.bound_center)) ** 2 

819 if center_dist > 0.5: 

820 continue 

821 corr_bound = b 

822 return corr_bound 

823 return None 

824 # cover virtual space boundaries related to an IfcVirtualElement 

825 if self.ifc.RelatedBuildingElement.is_a('IfcVirtualElement'): 

826 if len(self.ifc.RelatedBuildingElement.ProvidesBoundaries) == 2: 

827 for bound in self.ifc.RelatedBuildingElement.ProvidesBoundaries: 

828 if bound.GlobalId != self.ifc.GlobalId: 

829 corr_bound = self._elements[bound.GlobalId] 

830 return corr_bound 

831 elif len(self.bound_element.space_boundaries) == 1: 

832 return None 

833 elif len(self.bound_element.space_boundaries) >= 2: 

834 own_space_id = self.bound_thermal_zone.ifc.GlobalId 

835 min_dist = 1000 

836 corr_bound = None 

837 for bound in self.bound_element.space_boundaries: 

838 if bound.level_description != "2a": 

839 continue 

840 if bound is self: 

841 continue 

842 # if bound.bound_normal.Dot(self.bound_normal) != -1: 

843 # continue 

844 other_area = bound.bound_area 

845 if (other_area.m - self.bound_area.m) ** 2 > 1e-1: 

846 continue 

847 center_dist = gp_Pnt(self.bound_center).Distance( 

848 gp_Pnt(bound.bound_center)) ** 2 

849 if abs(center_dist) > 0.5: 

850 continue 

851 distance = BRepExtrema_DistShapeShape( 

852 bound.bound_shape, 

853 self.bound_shape, 

854 Extrema_ExtFlag_MIN 

855 ).Value() 

856 if distance > min_dist: 

857 continue 

858 min_dist = abs(center_dist) 

859 # self.check_for_vertex_duplicates(bound) 

860 nb_vert_this = PyOCCTools.get_number_of_vertices( 

861 self.bound_shape) 

862 nb_vert_other = PyOCCTools.get_number_of_vertices( 

863 bound.bound_shape) 

864 # if not nb_vert_this == nb_vert_other: 

865 # print("NO VERT MATCH!:", nb_vert_this, nb_vert_other) 

866 if nb_vert_this == nb_vert_other: 

867 corr_bound = bound 

868 return corr_bound 

869 else: 

870 return None 

871 

872 def _get_related_adb_bound(self, name): 

873 adb_bound = None 

874 if self.bound_element is None: 

875 return None 

876 # check for visual bounds 

877 if not self.physical: 

878 return None 

879 if self.related_bound: 

880 if self.bound_thermal_zone == self.related_bound.bound_thermal_zone: 

881 adb_bound = self.related_bound 

882 return adb_bound 

883 for bound in self.bound_element.space_boundaries: 

884 if bound == self: 

885 continue 

886 if not bound.bound_thermal_zone == self.bound_thermal_zone: 

887 continue 

888 if abs(bound.bound_area.m - self.bound_area.m) > 1e-3: 

889 continue 

890 if all([abs(i) < 1e-3 for i in 

891 ((self.bound_normal - bound.bound_normal).Coord())]): 

892 continue 

893 if gp_Pnt(bound.bound_center).Distance( 

894 gp_Pnt(self.bound_center)) < 0.4: 

895 adb_bound = bound 

896 return adb_bound 

897 

898 related_adb_bound = attribute.Attribute( 

899 description="Related adiabatic boundary.", 

900 functions=[_get_related_adb_bound] 

901 ) 

902 

903 def _get_is_physical(self, name) -> bool: 

904 """ 

905 This function returns True if the spaceboundary is physical 

906 """ 

907 return self.ifc.PhysicalOrVirtualBoundary.lower() == 'physical' 

908 

909 def _get_bound_shape(self, name): 

910 settings = ifcopenshell.geom.settings() 

911 settings.set(settings.USE_PYTHON_OPENCASCADE, True) 

912 settings.set(settings.USE_WORLD_COORDS, True) 

913 settings.set(settings.EXCLUDE_SOLIDS_AND_SURFACES, False) 

914 settings.set(settings.INCLUDE_CURVES, True) 

915 

916 # check if the space boundary shapes need a unit conversion (i.e., 

917 # an additional transformation to the correct size and position) 

918 length_unit = self.ifc_units.get('IfcLengthMeasure'.lower()) 

919 conv_required = length_unit != ureg.meter 

920 

921 try: 

922 sore = self.ifc.ConnectionGeometry.SurfaceOnRelatingElement 

923 # if sore.get_info()["InnerBoundaries"] is None: 

924 shape = ifcopenshell.geom.create_shape(settings, sore) 

925 

926 if sore.InnerBoundaries: 

927 # shape = remove_inner_loops(shape) # todo: return None if not horizontal shape 

928 # if not shape: 

929 if self.bound_element.ifc.is_a( 

930 'IfcWall'): # todo: remove this hotfix (generalize) 

931 ifc_new = ifcopenshell.file() 

932 temp_sore = ifc_new.create_entity('IfcCurveBoundedPlane', 

933 OuterBoundary=sore.OuterBoundary, 

934 BasisSurface=sore.BasisSurface) 

935 temp_sore.InnerBoundaries = () 

936 shape = ifcopenshell.geom.create_shape(settings, temp_sore) 

937 else: 

938 shape = remove_inner_loops(shape) 

939 if not (sore.InnerBoundaries and not self.bound_element.ifc.is_a( 

940 'IfcWall')): 

941 faces = PyOCCTools.get_faces_from_shape(shape) 

942 if len(faces) > 1: 

943 unify = ShapeUpgrade_UnifySameDomain() 

944 unify.Initialize(shape) 

945 unify.Build() 

946 shape = unify.Shape() 

947 faces = PyOCCTools.get_faces_from_shape(shape) 

948 face = faces[0] 

949 face = PyOCCTools.remove_coincident_and_collinear_points_from_face( 

950 face) 

951 shape = face 

952 except: 

953 try: 

954 sore = self.ifc.ConnectionGeometry.SurfaceOnRelatingElement 

955 ifc_new = ifcopenshell.file() 

956 temp_sore = ifc_new.create_entity('IfcCurveBoundedPlane', 

957 OuterBoundary=sore.OuterBoundary, 

958 BasisSurface=sore.BasisSurface) 

959 temp_sore.InnerBoundaries = () 

960 shape = ifcopenshell.geom.create_shape(settings, temp_sore) 

961 except: 

962 poly = self.ifc.ConnectionGeometry.SurfaceOnRelatingElement.OuterBoundary.Points 

963 pnts = [] 

964 for p in poly: 

965 p.Coordinates = (p.Coordinates[0], p.Coordinates[1], 0.0) 

966 pnts.append((p.Coordinates[:])) 

967 shape = PyOCCTools.make_faces_from_pnts(pnts) 

968 shape = BRepLib_FuseEdges(shape).Shape() 

969 

970 if conv_required: 

971 # scale newly created shape of space boundary to correct size 

972 conv_factor = (1 * length_unit).to( 

973 ureg.metre).m 

974 shape = PyOCCTools.scale_shape(shape, conv_factor, gp_Pnt(0, 0, 0)) 

975 

976 if self.ifc.RelatingSpace.ObjectPlacement: 

977 lp = PyOCCTools.local_placement( 

978 self.ifc.RelatingSpace.ObjectPlacement).tolist() 

979 # transform newly created shape of space boundary to correct 

980 # position if a unit conversion is required. 

981 # todo: check if x-, y-coord of "vec" also need to be transformed. 

982 if conv_required: 

983 z_coord = lp[2][3] * length_unit 

984 lp[2][3] = z_coord.to(ureg.meter).m 

985 mat = gp_Mat(lp[0][0], lp[0][1], lp[0][2], lp[1][0], lp[1][1], 

986 lp[1][2], lp[2][0], lp[2][1], lp[2][2]) 

987 vec = gp_Vec(lp[0][3], lp[1][3], lp[2][3]) 

988 trsf = gp_Trsf() 

989 trsf.SetTransformation(gp_Quaternion(mat), vec) 

990 shape = BRepBuilderAPI_Transform(shape, trsf).Shape() 

991 

992 # shape = shape.Reversed() 

993 unify = ShapeUpgrade_UnifySameDomain() 

994 unify.Initialize(shape) 

995 unify.Build() 

996 shape = unify.Shape() 

997 

998 if self.bound_element is not None: 

999 bi = self.bound_element 

1000 if not hasattr(bi, "related_openings"): 

1001 return shape 

1002 if len(bi.related_openings) == 0: 

1003 return shape 

1004 shape = PyOCCTools.get_face_from_shape(shape) 

1005 return shape 

1006 

1007 def get_level_description(self, name) -> str: 

1008 """ 

1009 This function returns the level description of the spaceboundary 

1010 """ 

1011 return self.ifc.Description 

1012 

1013 def _get_is_external(self, name) -> Union[None, bool]: 

1014 """ 

1015 This function returns True if the spaceboundary is external 

1016 """ 

1017 if self.ifc.InternalOrExternalBoundary is not None: 

1018 ifc_ext_internal = self.ifc.InternalOrExternalBoundary.lower() 

1019 if ifc_ext_internal == 'internal': 

1020 return False 

1021 elif 'external' in ifc_ext_internal: 

1022 return True 

1023 else: 

1024 return None 

1025 # return not self.ifc.InternalOrExternalBoundary.lower() == 'internal' 

1026 

1027 def _get_opening_area(self, name): 

1028 """ 

1029 This function returns the opening area of the spaceboundary 

1030 """ 

1031 if self.opening_bounds: 

1032 return sum(opening_boundary.bound_area for opening_boundary 

1033 in self.opening_bounds) 

1034 return 0 

1035 

1036 def _get_net_bound_area(self, name): 

1037 """ 

1038 This function returns the net bound area of the spaceboundary 

1039 """ 

1040 return self.bound_area - self.opening_area 

1041 

1042 is_external = attribute.Attribute( 

1043 description="True if the Space Boundary is external", 

1044 functions=[_get_is_external] 

1045 ) 

1046 

1047 bound_shape = attribute.Attribute( 

1048 description="Bound shape element of the SB.", 

1049 functions=[_get_bound_shape] 

1050 ) 

1051 

1052 top_bottom = attribute.Attribute( 

1053 description="Info if the SB is top " 

1054 "(ceiling etc.) or bottom (floor etc.).", 

1055 functions=[_get_top_bottom] 

1056 ) 

1057 

1058 bound_center = attribute.Attribute( 

1059 description="The center of the space boundary.", 

1060 functions=[_get_bound_center] 

1061 ) 

1062 

1063 related_bound = attribute.Attribute( 

1064 description="Related space boundary.", 

1065 functions=[_get_related_bound] 

1066 ) 

1067 

1068 physical = attribute.Attribute( 

1069 description="If the Space Boundary is physical or not.", 

1070 functions=[_get_is_physical] 

1071 ) 

1072 

1073 opening_area = attribute.Attribute( 

1074 description="Opening area of the Space Boundary.", 

1075 functions = [_get_opening_area] 

1076 ) 

1077 

1078 net_bound_area = attribute.Attribute( 

1079 description="Net bound area of the Space Boundary", 

1080 functions=[_get_net_bound_area] 

1081 ) 

1082 

1083 def _get_bound_normal(self, name): 

1084 """ 

1085 This function returns the normal vector of the spaceboundary 

1086 """ 

1087 return PyOCCTools.simple_face_normal(self.bound_shape) 

1088 

1089 bound_normal = attribute.Attribute( 

1090 description="Normal vector of the Space Boundary.", 

1091 functions=[_get_bound_normal] 

1092 ) 

1093 

1094 level_description = attribute.Attribute( 

1095 functions=[get_level_description], 

1096 # Todo this should be removed in near future. We should either  

1097 # find # a way to distinguish the level of SB by something 

1098 # different or should check this during the creation of SBs 

1099 # and throw an error if the level is not defined. 

1100 default='2a' 

1101 # HACK: Rou's Model has 2a boundaries but, the description is None, 

1102 # default set to 2a to temporary solve this problem 

1103 ) 

1104 

1105 internal_external_type = attribute.Attribute( 

1106 description="Defines, whether the Space Boundary is internal" 

1107 " (Internal), or external, i.e. adjacent to open space " 

1108 "(that can be an partially enclosed space, such as terrace" 

1109 " (External", 

1110 ifc_attr_name="InternalOrExternalBoundary" 

1111 ) 

1112 

1113 

1114class ExtSpatialSpaceBoundary(SpaceBoundary): 

1115 """describes all space boundaries related to an IfcExternalSpatialElement instead of an IfcSpace""" 

1116 pass 

1117 

1118 

1119class SpaceBoundary2B(SpaceBoundary): 

1120 """describes all newly created space boundaries of type 2b to fill gaps within spaces""" 

1121 

1122 def __init__(self, *args, elements=None, **kwargs): 

1123 super(SpaceBoundary2B, self).__init__(*args, elements=None, **kwargs) 

1124 self.ifc = ifcopenshell.create_entity('IfcRelSpaceBoundary') 

1125 self.guid = None 

1126 self.bound_shape = None 

1127 self.thermal_zones = [] 

1128 self.bound_element = None 

1129 self.physical = True 

1130 self.is_external = False 

1131 self.related_bound = None 

1132 self.related_adb_bound = None 

1133 self.level_description = '2b' 

1134 

1135 

1136class BPSProductWithLayers(BPSProduct): 

1137 ifc_types = {} 

1138 

1139 def __init__(self, *args, **kwargs): 

1140 """BPSProductWithLayers __init__ function. 

1141 

1142 Convention in bim2sim for layerset is layer 0 is inside, 

1143 layer n is outside. 

1144 """ 

1145 super().__init__(*args, **kwargs) 

1146 self.layerset = None 

1147 

1148 def get_u_value(self, name): 

1149 """wall get_u_value function""" 

1150 layers_r = 0 

1151 for layer in self.layerset.layers: 

1152 if layer.thickness: 

1153 if layer.material.thermal_conduc and \ 

1154 layer.material.thermal_conduc > 0: 

1155 layers_r += layer.thickness / layer.material.thermal_conduc 

1156 

1157 if layers_r > 0: 

1158 return 1 / layers_r 

1159 return None 

1160 

1161 def get_thickness_by_layers(self, name): 

1162 """calculate the total thickness of the product based on the thickness 

1163 of each layer.""" 

1164 thickness = 0 

1165 for layer in self.layerset.layers: 

1166 if layer.thickness: 

1167 thickness += layer.thickness 

1168 return thickness 

1169 

1170 

1171class Wall(BPSProductWithLayers): 

1172 """Abstract wall class, only its subclasses Inner- and Outerwalls are used. 

1173 

1174 Every element where self.is_external is not True, is an InnerWall. 

1175 """ 

1176 ifc_types = { 

1177 "IfcWall": 

1178 ['*', 'MOVABLE', 'PARAPET', 'PARTITIONING', 'PLUMBINGWALL', 

1179 'SHEAR', 'SOLIDWALL', 'POLYGONAL', 'DOOR', 'GATE', 'TRAPDOOR'], 

1180 "IfcWallStandardCase": 

1181 ['*', 'MOVABLE', 'PARAPET', 'PARTITIONING', 'PLUMBINGWALL', 

1182 'SHEAR', 'SOLIDWALL', 'POLYGONAL', 'DOOR', 'GATE', 'TRAPDOOR'], 

1183 # "IfcElementedCase": "?" # TODO 

1184 } 

1185 

1186 conditions = [ 

1187 condition.RangeCondition('u_value', 

1188 0 * ureg.W / ureg.K / ureg.meter ** 2, 

1189 5 * ureg.W / ureg.K / ureg.meter ** 2, 

1190 critical_for_creation=False), 

1191 condition.UValueCondition('u_value', 

1192 threshold=0.2, 

1193 critical_for_creation=False), 

1194 ] 

1195 

1196 pattern_ifc_type = [ 

1197 re.compile('Wall', flags=re.IGNORECASE), 

1198 re.compile('Wand', flags=re.IGNORECASE) 

1199 ] 

1200 

1201 def __init__(self, *args, **kwargs): 

1202 """wall __init__ function""" 

1203 super().__init__(*args, **kwargs) 

1204 

1205 def get_better_subclass(self): 

1206 return OuterWall if self.is_external else InnerWall 

1207 

1208 net_area = attribute.Attribute( 

1209 default_ps=("Qto_WallBaseQuantities", "NetSideArea"), 

1210 functions=[BPSProduct.get_net_bound_area], 

1211 unit=ureg.meter ** 2 

1212 ) 

1213 

1214 gross_area = attribute.Attribute( 

1215 default_ps=("Qto_WallBaseQuantities", "GrossSideArea"), 

1216 functions=[BPSProduct.get_bound_area], 

1217 unit=ureg.meter ** 2 

1218 ) 

1219 

1220 tilt = attribute.Attribute( 

1221 default=90 

1222 ) 

1223 

1224 u_value = attribute.Attribute( 

1225 default_ps=("Pset_WallCommon", "ThermalTransmittance"), 

1226 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1227 functions=[BPSProductWithLayers.get_u_value], 

1228 ) 

1229 

1230 width = attribute.Attribute( 

1231 default_ps=("Qto_WallBaseQuantities", "Width"), 

1232 functions=[BPSProductWithLayers.get_thickness_by_layers], 

1233 unit=ureg.m 

1234 ) 

1235 

1236 inner_convection = attribute.Attribute( 

1237 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1238 default=0.6 

1239 ) 

1240 

1241 is_load_bearing = attribute.Attribute( 

1242 default_ps=("Pset_WallCommon", "LoadBearing"), 

1243 ) 

1244 

1245 net_volume = attribute.Attribute( 

1246 default_ps=("Qto_WallBaseQuantities", "NetVolume"), 

1247 unit=ureg.meter ** 3 

1248 ) 

1249 

1250 gross_volume = attribute.Attribute( 

1251 default_ps=("Qto_WallBaseQuantities", "GrossVolume") 

1252 ) 

1253 

1254 

1255class Layer(BPSProduct): 

1256 """Represents the IfcMaterialLayer class.""" 

1257 ifc_types = { 

1258 "IfcMaterialLayer": ["*"], 

1259 } 

1260 guid_prefix = "Layer_" 

1261 

1262 conditions = [ 

1263 condition.RangeCondition('thickness', 

1264 0 * ureg.m, 

1265 10 * ureg.m, 

1266 critical_for_creation=False, incl_edges=False) 

1267 ] 

1268 

1269 def __init__(self, *args, **kwargs): 

1270 """layer __init__ function""" 

1271 super().__init__(*args, **kwargs) 

1272 self.to_layerset: List[LayerSet] = [] 

1273 self.parent = None 

1274 self.material = None 

1275 

1276 @staticmethod 

1277 def get_id(prefix=""): 

1278 prefix_length = len(prefix) 

1279 if prefix_length > 10: 

1280 raise AttributeError("Max prefix length is 10!") 

1281 ifcopenshell_guid = guid.new()[prefix_length + 1:] 

1282 return f"{prefix}{ifcopenshell_guid}" 

1283 

1284 @classmethod 

1285 def pre_validate(cls, ifc) -> bool: 

1286 return True 

1287 

1288 def validate_creation(self) -> bool: 

1289 return True 

1290 

1291 def _get_thickness(self, name): 

1292 """layer thickness function""" 

1293 if hasattr(self.ifc, 'LayerThickness'): 

1294 return self.ifc.LayerThickness * ureg.meter 

1295 else: 

1296 return float('nan') * ureg.meter 

1297 

1298 thickness = attribute.Attribute( 

1299 unit=ureg.m, 

1300 functions=[_get_thickness] 

1301 ) 

1302 

1303 is_ventilated = attribute.Attribute( 

1304 description="Indication of whether the material layer represents an " 

1305 "air layer (or cavity).", 

1306 ifc_attr_name="IsVentilated", 

1307 ) 

1308 

1309 description = attribute.Attribute( 

1310 description="Definition of the material layer in more descriptive " 

1311 "terms than given by attributes Name or Category.", 

1312 ifc_attr_name="Description", 

1313 ) 

1314 

1315 category = attribute.Attribute( 

1316 description="Category of the material layer, e.g. the role it has in" 

1317 " the layer set it belongs to (such as 'load bearing', " 

1318 "'thermal insulation' etc.). The list of keywords might be" 

1319 " extended by model view definitions, however the " 

1320 "following keywords shall apply in general:", 

1321 ifc_attr_name="Category", 

1322 ) 

1323 

1324 def __repr__(self): 

1325 return "<%s (material: %s>" \ 

1326 % (self.__class__.__name__, self.material) 

1327 

1328 

1329class LayerSet(BPSProduct): 

1330 """Represents a Layerset in bim2sim. 

1331 

1332 Convention in bim2sim for layerset is layer 0 is inside, 

1333 layer n is outside. 

1334 

1335 # TODO: when not enriching we currently don't check layer orientation. 

1336 """ 

1337 

1338 ifc_types = { 

1339 "IfcMaterialLayerSet": ["*"], 

1340 } 

1341 

1342 guid_prefix = "LayerSet_" 

1343 conditions = [ 

1344 condition.ListCondition('layers', 

1345 critical_for_creation=False), 

1346 condition.ThicknessCondition('total_thickness', 

1347 threshold=0.2, 

1348 critical_for_creation=False), 

1349 ] 

1350 

1351 def __init__(self, *args, **kwargs): 

1352 """layerset __init__ function""" 

1353 super().__init__(*args, **kwargs) 

1354 self.parents: List[BPSProductWithLayers] = [] 

1355 self.layers: List[Layer] = [] 

1356 

1357 @staticmethod 

1358 def get_id(prefix=""): 

1359 prefix_length = len(prefix) 

1360 if prefix_length > 10: 

1361 raise AttributeError("Max prefix length is 10!") 

1362 ifcopenshell_guid = guid.new()[prefix_length + 1:] 

1363 return f"{prefix}{ifcopenshell_guid}" 

1364 

1365 def get_total_thickness(self, name): 

1366 if hasattr(self.ifc, 'TotalThickness'): 

1367 if self.ifc.TotalThickness: 

1368 return self.ifc.TotalThickness * ureg.m 

1369 return sum(layer.thickness for layer in self.layers) 

1370 

1371 def _get_volume(self, name): 

1372 if hasattr(self, "net_volume"): 

1373 if self.net_volume: 

1374 vol = self.net_volume 

1375 return vol 

1376 # TODO This is not working currently, because with multiple parents 

1377 # we dont know the area or width of the parent 

1378 # elif self.parent.width: 

1379 # vol = self.parent.volume * self.parent.width / self.thickness 

1380 else: 

1381 vol = float('nan') * ureg.meter ** 3 

1382 # TODO see above 

1383 # elif self.parent.width: 

1384 # vol = self.parent.volume * self.parent.width / self.thickness 

1385 else: 

1386 vol = float('nan') * ureg.meter ** 3 

1387 return vol 

1388 

1389 thickness = attribute.Attribute( 

1390 unit=ureg.m, 

1391 functions=[get_total_thickness], 

1392 ) 

1393 

1394 name = attribute.Attribute( 

1395 description="The name by which the IfcMaterialLayerSet is known.", 

1396 ifc_attr_name="LayerSetName", 

1397 ) 

1398 

1399 volume = attribute.Attribute( 

1400 description="Volume of layer set", 

1401 functions=[_get_volume], 

1402 ) 

1403 

1404 def __repr__(self): 

1405 if self.name: 

1406 return "<%s (name: %s, layers: %d)>" \ 

1407 % (self.__class__.__name__, self.name, len(self.layers)) 

1408 else: 

1409 return "<%s (layers: %d)>" % (self.__class__.__name__, len(self.layers)) 

1410 

1411 

1412class OuterWall(Wall): 

1413 ifc_types = {} 

1414 

1415 def calc_cost_group(self) -> int: 

1416 """Calc cost group for OuterWall 

1417 

1418 Load bearing outer walls: 331 

1419 Not load bearing outer walls: 332 

1420 Rest: 330 

1421 """ 

1422 

1423 if self.is_load_bearing: 

1424 return 331 

1425 elif not self.is_load_bearing: 

1426 return 332 

1427 else: 

1428 return 330 

1429 

1430 

1431class InnerWall(Wall): 

1432 """InnerWalls are assumed to be always symmetric.""" 

1433 ifc_types = {} 

1434 

1435 def calc_cost_group(self) -> int: 

1436 """Calc cost group for InnerWall 

1437 

1438 Load bearing inner walls: 341 

1439 Not load bearing inner walls: 342 

1440 Rest: 340 

1441 """ 

1442 

1443 if self.is_load_bearing: 

1444 return 341 

1445 elif not self.is_load_bearing: 

1446 return 342 

1447 else: 

1448 return 340 

1449 

1450 

1451class Window(BPSProductWithLayers): 

1452 ifc_types = {"IfcWindow": ['*', 'WINDOW', 'SKYLIGHT', 'LIGHTDOME']} 

1453 

1454 pattern_ifc_type = [ 

1455 re.compile('Window', flags=re.IGNORECASE), 

1456 re.compile('Fenster', flags=re.IGNORECASE) 

1457 ] 

1458 

1459 def get_glazing_area(self, name): 

1460 """returns only the glazing area of the windows""" 

1461 if self.glazing_ratio: 

1462 return self.gross_area * self.glazing_ratio 

1463 return self.opening_area 

1464 

1465 def calc_cost_group(self) -> int: 

1466 """Calc cost group for Windows 

1467 

1468 Outer door: 334 

1469 """ 

1470 

1471 return 334 

1472 

1473 net_area = attribute.Attribute( 

1474 functions=[get_glazing_area], 

1475 unit=ureg.meter ** 2, 

1476 ) 

1477 

1478 gross_area = attribute.Attribute( 

1479 default_ps=("Qto_WindowBaseQuantities", "Area"), 

1480 functions=[BPSProduct.get_bound_area], 

1481 unit=ureg.meter ** 2 

1482 ) 

1483 

1484 glazing_ratio = attribute.Attribute( 

1485 default_ps=("Pset_WindowCommon", "GlazingAreaFraction"), 

1486 ) 

1487 

1488 width = attribute.Attribute( 

1489 default_ps=("Qto_WindowBaseQuantities", "Depth"), 

1490 functions=[BPSProductWithLayers.get_thickness_by_layers], 

1491 unit=ureg.m 

1492 ) 

1493 u_value = attribute.Attribute( 

1494 default_ps=("Pset_WallCommon", "ThermalTransmittance"), 

1495 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1496 functions=[BPSProductWithLayers.get_u_value], 

1497 ) 

1498 

1499 g_value = attribute.Attribute( # material 

1500 ) 

1501 

1502 a_conv = attribute.Attribute( 

1503 ) 

1504 

1505 shading_g_total = attribute.Attribute( 

1506 ) 

1507 

1508 shading_max_irr = attribute.Attribute( 

1509 ) 

1510 

1511 inner_convection = attribute.Attribute( 

1512 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1513 ) 

1514 

1515 inner_radiation = attribute.Attribute( 

1516 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1517 ) 

1518 

1519 outer_radiation = attribute.Attribute( 

1520 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1521 ) 

1522 

1523 outer_convection = attribute.Attribute( 

1524 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1525 ) 

1526 

1527 

1528class Door(BPSProductWithLayers): 

1529 ifc_types = {"IfcDoor": ['*', 'DOOR', 'GATE', 'TRAPDOOR']} 

1530 

1531 pattern_ifc_type = [ 

1532 re.compile('Door', flags=re.IGNORECASE), 

1533 re.compile('Tuer', flags=re.IGNORECASE) 

1534 ] 

1535 

1536 conditions = [ 

1537 condition.RangeCondition('glazing_ratio', 

1538 0 * ureg.dimensionless, 

1539 1 * ureg.dimensionless, True, 

1540 critical_for_creation=False), 

1541 ] 

1542 

1543 def get_better_subclass(self): 

1544 return OuterDoor if self.is_external else InnerDoor 

1545 

1546 def get_net_area(self, name): 

1547 if self.glazing_ratio: 

1548 return self.gross_area * (1 - self.glazing_ratio) 

1549 return self.gross_area - self.opening_area 

1550 

1551 net_area = attribute.Attribute( 

1552 functions=[get_net_area, ], 

1553 unit=ureg.meter ** 2, 

1554 ) 

1555 

1556 gross_area = attribute.Attribute( 

1557 default_ps=("Qto_DoorBaseQuantities", "Area"), 

1558 functions=[BPSProduct.get_bound_area], 

1559 unit=ureg.meter ** 2 

1560 ) 

1561 

1562 glazing_ratio = attribute.Attribute( 

1563 default_ps=("Pset_DoorCommon", "GlazingAreaFraction"), 

1564 ) 

1565 

1566 width = attribute.Attribute( 

1567 default_ps=("Qto_DoorBaseQuantities", "Width"), 

1568 functions=[BPSProductWithLayers.get_thickness_by_layers], 

1569 unit=ureg.m 

1570 ) 

1571 

1572 u_value = attribute.Attribute( 

1573 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1574 functions=[BPSProductWithLayers.get_u_value], 

1575 ) 

1576 

1577 inner_convection = attribute.Attribute( 

1578 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1579 default=0.6 

1580 ) 

1581 

1582 inner_radiation = attribute.Attribute( 

1583 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1584 ) 

1585 

1586 outer_radiation = attribute.Attribute( 

1587 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1588 ) 

1589 

1590 outer_convection = attribute.Attribute( 

1591 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1592 ) 

1593 

1594 

1595class InnerDoor(Door): 

1596 ifc_types = {} 

1597 

1598 def calc_cost_group(self) -> int: 

1599 """Calc cost group for Innerdoors 

1600 

1601 Inner door: 344 

1602 """ 

1603 

1604 return 344 

1605 

1606 

1607class OuterDoor(Door): 

1608 ifc_types = {} 

1609 

1610 def calc_cost_group(self) -> int: 

1611 """Calc cost group for Outerdoors 

1612 

1613 Outer door: 334 

1614 """ 

1615 

1616 return 334 

1617 

1618 

1619class Slab(BPSProductWithLayers): 

1620 ifc_types = { 

1621 "IfcSlab": ['*', 'LANDING'] 

1622 } 

1623 

1624 def __init__(self, *args, **kwargs): 

1625 """slab __init__ function""" 

1626 super().__init__(*args, **kwargs) 

1627 

1628 def _calc_teaser_orientation(self, name) -> int: 

1629 """Returns the orientation of the slab in TEASER convention.""" 

1630 return -1 

1631 

1632 net_area = attribute.Attribute( 

1633 default_ps=("Qto_SlabBaseQuantities", "NetArea"), 

1634 functions=[BPSProduct.get_net_bound_area], 

1635 unit=ureg.meter ** 2 

1636 ) 

1637 

1638 gross_area = attribute.Attribute( 

1639 default_ps=("Qto_SlabBaseQuantities", "GrossArea"), 

1640 functions=[BPSProduct.get_bound_area], 

1641 unit=ureg.meter ** 2 

1642 ) 

1643 

1644 width = attribute.Attribute( 

1645 default_ps=("Qto_SlabBaseQuantities", "Width"), 

1646 functions=[BPSProductWithLayers.get_thickness_by_layers], 

1647 unit=ureg.m 

1648 ) 

1649 

1650 u_value = attribute.Attribute( 

1651 default_ps=("Pset_SlabCommon", "ThermalTransmittance"), 

1652 unit=ureg.W / ureg.K / ureg.meter ** 2, 

1653 functions=[BPSProductWithLayers.get_u_value], 

1654 ) 

1655 

1656 net_volume = attribute.Attribute( 

1657 default_ps=("Qto_SlabBaseQuantities", "NetVolume"), 

1658 unit=ureg.meter ** 3 

1659 ) 

1660 

1661 is_load_bearing = attribute.Attribute( 

1662 default_ps=("Pset_SlabCommon", "LoadBearing"), 

1663 ) 

1664 

1665 

1666class Roof(Slab): 

1667 # todo decomposed roofs dont have materials, layers etc. because these 

1668 # information are stored in the slab itself and not the decomposition 

1669 # is_external = True 

1670 ifc_types = { 

1671 "IfcRoof": 

1672 ['*', 'FLAT_ROOF', 'SHED_ROOF', 'GABLE_ROOF', 'HIP_ROOF', 

1673 'HIPPED_GABLE_ROOF', 'GAMBREL_ROOF', 'MANSARD_ROOF', 

1674 'BARREL_ROOF', 'RAINBOW_ROOF', 'BUTTERFLY_ROOF', 'PAVILION_ROOF', 

1675 'DOME_ROOF', 'FREEFORM'], 

1676 "IfcSlab": ['ROOF'] 

1677 } 

1678 

1679 def calc_cost_group(self) -> int: 

1680 """Calc cost group for Roofs 

1681 

1682 

1683 Load bearing: 361 

1684 Not load bearing: 363 

1685 """ 

1686 if self.is_load_bearing: 

1687 return 361 

1688 elif not self.is_load_bearing: 

1689 return 363 

1690 else: 

1691 return 300 

1692 

1693 

1694class InnerFloor(Slab): 

1695 """In bim2sim we handle all inner slabs as floors/inner floors. 

1696 

1697 Orientation of layerset is layer 0 is inside (floor surface of this room), 

1698 layer n is outside (ceiling surface of room below). 

1699 """ 

1700 ifc_types = { 

1701 "IfcSlab": ['FLOOR'] 

1702 } 

1703 

1704 def calc_cost_group(self) -> int: 

1705 """Calc cost group for Floors 

1706 

1707 Floor: 351 

1708 """ 

1709 return 351 

1710 

1711 

1712class GroundFloor(Slab): 

1713 # is_external = True # todo to be removed 

1714 ifc_types = { 

1715 "IfcSlab": ['BASESLAB'] 

1716 } 

1717 

1718 def _calc_teaser_orientation(self, name) -> int: 

1719 """Returns the orientation of the groundfloor in TEASER convention.""" 

1720 return -2 

1721 

1722 def calc_cost_group(self) -> int: 

1723 """Calc cost group for groundfloors 

1724 

1725 groundfloors: 322 

1726 """ 

1727 

1728 return 322 

1729 

1730 

1731 # pattern_ifc_type = [ 

1732 # re.compile('Bodenplatte', flags=re.IGNORECASE), 

1733 # re.compile('') 

1734 # ] 

1735 

1736 

1737class Site(BPSProduct): 

1738 def __init__(self, *args, **kwargs): 

1739 super().__init__(*args, **kwargs) 

1740 del self.building 

1741 self.buildings = [] 

1742 

1743 # todo move this to base elements as this relevant for other domains as well 

1744 ifc_types = {"IfcSite": ['*']} 

1745 

1746 gross_area = attribute.Attribute( 

1747 default_ps=("Qto_SiteBaseQuantities", "GrossArea"), 

1748 unit=ureg.meter ** 2 

1749 ) 

1750 

1751 location_latitude = attribute.Attribute( 

1752 ifc_attr_name="RefLatitude", 

1753 ) 

1754 

1755 location_longitude = attribute.Attribute( 

1756 ifc_attr_name="RefLongitude" 

1757 ) 

1758 

1759 

1760class Building(BPSProduct): 

1761 def __init__(self, *args, **kwargs): 

1762 super().__init__(*args, **kwargs) 

1763 self.thermal_zones = [] 

1764 self.storeys = [] 

1765 self.elements = [] 

1766 

1767 ifc_types = {"IfcBuilding": ['*']} 

1768 from_ifc_domains = [IFCDomain.arch] 

1769 

1770 conditions = [ 

1771 condition.RangeCondition('year_of_construction', 

1772 1900 * ureg.year, 

1773 date.today().year * ureg.year, 

1774 critical_for_creation=False), 

1775 ] 

1776 

1777 def _get_building_name(self, name): 

1778 """get building name""" 

1779 bldg_name = self.get_ifc_attribute('Name') 

1780 if bldg_name: 

1781 return bldg_name 

1782 else: 

1783 # todo needs to be adjusted for multiple buildings #165 

1784 bldg_name = 'Building' 

1785 return bldg_name 

1786 

1787 def _get_number_of_storeys(self, name): 

1788 return len(self.storeys) 

1789 

1790 def _get_avg_storey_height(self, name): 

1791 """Calculates the average height of all storeys.""" 

1792 storey_height_sum = 0 

1793 avg_height = None 

1794 if hasattr(self, "storeys"): 

1795 if len(self.storeys) > 0: 

1796 for storey in self.storeys: 

1797 if storey.height: 

1798 height = storey.height 

1799 elif storey.gross_height: 

1800 height = storey.gross_height 

1801 elif storey.net_height: 

1802 height = storey.net_height 

1803 else: 

1804 height = None 

1805 if height: 

1806 storey_height_sum += height 

1807 avg_height = storey_height_sum / len(self.storeys) 

1808 return avg_height 

1809 

1810 def _check_tz_ahu(self, name): 

1811 """Check if any TZs have AHU, then the building has one as well.""" 

1812 with_ahu = False 

1813 for tz in self.thermal_zones: 

1814 if tz.with_ahu: 

1815 with_ahu = True 

1816 break 

1817 return with_ahu 

1818 

1819 bldg_name = attribute.Attribute( 

1820 functions=[_get_building_name], 

1821 ) 

1822 

1823 year_of_construction = attribute.Attribute( 

1824 default_ps=("Pset_BuildingCommon", "YearOfConstruction"), 

1825 unit=ureg.year 

1826 ) 

1827 

1828 gross_area = attribute.Attribute( 

1829 default_ps=("Qto_BuildingBaseQuantities", "GrossFloorArea"), 

1830 unit=ureg.meter ** 2 

1831 ) 

1832 

1833 net_area = attribute.Attribute( 

1834 default_ps=("Qto_BuildingBaseQuantities", "NetFloorArea"), 

1835 unit=ureg.meter ** 2 

1836 ) 

1837 

1838 number_of_storeys = attribute.Attribute( 

1839 unit=ureg.dimensionless, 

1840 functions=[_get_number_of_storeys] 

1841 ) 

1842 

1843 occupancy_type = attribute.Attribute( 

1844 default_ps=("Pset_BuildingCommon", "OccupancyType"), 

1845 ) 

1846 

1847 avg_storey_height = attribute.Attribute( 

1848 unit=ureg.meter, 

1849 functions=[_get_avg_storey_height] 

1850 ) 

1851 

1852 with_ahu = attribute.Attribute( 

1853 functions=[_check_tz_ahu] 

1854 ) 

1855 

1856 ahu_heating = attribute.Attribute( 

1857 attr_type=bool 

1858 ) 

1859 

1860 ahu_cooling = attribute.Attribute( 

1861 attr_type=bool 

1862 ) 

1863 

1864 ahu_dehumidification = attribute.Attribute( 

1865 attr_type=bool 

1866 ) 

1867 

1868 ahu_humidification = attribute.Attribute( 

1869 attr_type=bool 

1870 ) 

1871 

1872 ahu_heat_recovery = attribute.Attribute( 

1873 attr_type=bool 

1874 ) 

1875 

1876 ahu_heat_recovery_efficiency = attribute.Attribute( 

1877 ) 

1878 

1879 

1880class Storey(BPSProduct): 

1881 ifc_types = {'IfcBuildingStorey': ['*']} 

1882 from_ifc_domains = [IFCDomain.arch] 

1883 

1884 def __init__(self, *args, **kwargs): 

1885 """storey __init__ function""" 

1886 super().__init__(*args, **kwargs) 

1887 self.elements = [] 

1888 

1889 spec_machines_internal_load = attribute.Attribute( 

1890 default_ps=("Pset_ThermalLoadDesignCriteria", 

1891 "ReceptacleLoadIntensity"), 

1892 unit=ureg.kilowatt / (ureg.meter ** 2) 

1893 ) 

1894 

1895 spec_lighting_internal_load = attribute.Attribute( 

1896 default_ps=("Pset_ThermalLoadDesignCriteria", "LightingLoadIntensity"), 

1897 unit=ureg.kilowatt / (ureg.meter ** 2) 

1898 ) 

1899 

1900 cooling_load = attribute.Attribute( 

1901 default_ps=("Pset_ThermalLoadAggregate", "TotalCoolingLoad"), 

1902 unit=ureg.kilowatt 

1903 ) 

1904 

1905 heating_load = attribute.Attribute( 

1906 default_ps=("Pset_ThermalLoadAggregate", "TotalHeatingLoad"), 

1907 unit=ureg.kilowatt 

1908 ) 

1909 

1910 air_per_person = attribute.Attribute( 

1911 default_ps=("Pset_ThermalLoadDesignCriteria", "OutsideAirPerPerson"), 

1912 unit=ureg.meter ** 3 / ureg.hour 

1913 ) 

1914 

1915 percent_load_to_radiant = attribute.Attribute( 

1916 default_ps=("Pset_ThermalLoadDesignCriteria", 

1917 "AppliancePercentLoadToRadiant"), 

1918 unit=ureg.percent 

1919 ) 

1920 

1921 gross_floor_area = attribute.Attribute( 

1922 default_ps=("Qto_BuildingStoreyBaseQuantities", "GrossFloorArea"), 

1923 unit=ureg.meter ** 2 

1924 ) 

1925 

1926 # todo make the lookup for height hierarchical 

1927 net_height = attribute.Attribute( 

1928 default_ps=("Qto_BuildingStoreyBaseQuantities", "NetHeight"), 

1929 unit=ureg.meter 

1930 ) 

1931 

1932 gross_height = attribute.Attribute( 

1933 default_ps=("Qto_BuildingStoreyBaseQuantities", "GrossHeight"), 

1934 unit=ureg.meter 

1935 ) 

1936 

1937 height = attribute.Attribute( 

1938 default_ps=("Qto_BuildingStoreyBaseQuantities", "Height"), 

1939 unit=ureg.meter 

1940 ) 

1941 

1942 

1943class SpaceBoundaryRepresentation(BPSProduct): 

1944 """describes the geometric representation of space boundaries which are 

1945 created by the webtool to allow the """ 

1946 ifc_types = { 

1947 "IFCBUILDINGELEMENTPROXY": 

1948 ['USERDEFINED'] 

1949 } 

1950 pattern_ifc_type = [ 

1951 re.compile('ProxyBound', flags=re.IGNORECASE) 

1952 ] 

1953 

1954 

1955# collect all domain classes 

1956items: Set[BPSProduct] = set() 

1957for name, cls in inspect.getmembers( 

1958 sys.modules[__name__], 

1959 lambda member: inspect.isclass(member) # class at all 

1960 and issubclass(member, BPSProduct) # domain subclass 

1961 and member is not BPSProduct # but not base class 

1962 and member.__module__ == __name__): # declared here 

1963 items.add(cls)