Coverage for bim2sim/tasks/common/create_elements.py: 59%

293 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-16 08:28 +0000

1from __future__ import annotations 

2 

3import collections 

4import logging 

5from typing import Tuple, List, Any, Generator, Dict, Type, Set 

6import copy 

7 

8from bim2sim.elements import bps_elements as bps 

9from bim2sim.elements.base_elements import (Factory, ProductBased, Material, 

10 Element) 

11from bim2sim.elements.mapping import ifc2python 

12from bim2sim.elements.mapping.filter import (TypeFilter, TextFilter, 

13 StoreyFilter) 

14from bim2sim.kernel import IFCDomainError 

15from bim2sim.kernel.decision import DecisionBunch, ListDecision, Decision 

16from bim2sim.kernel.ifc_file import IfcFileClass 

17from bim2sim.sim_settings import BaseSimSettings 

18from bim2sim.tasks.base import ITask 

19from bim2sim.utilities.common_functions import group_by_levenshtein 

20from bim2sim.utilities.types import LOD 

21from bim2sim.tasks.base import Playground 

22from ifcopenshell import file, entity_instance 

23 

24 

25class CreateElementsOnIfcTypes(ITask): 

26 """Create bim2sim elements based on information of IFC types.""" 

27 

28 reads = ('ifc_files',) 

29 touches = ('elements', '_initial_elements', 'ifc_files') 

30 

31 def __init__(self, playground: Playground): 

32 super().__init__(playground) 

33 self.factory = None 

34 self.source_tools: list = [] 

35 self.layersets_all: list = [] 

36 self.materials_all: list = [] 

37 self.layers_all: list = [] 

38 

39 def run(self, ifc_files: [IfcFileClass]) -> ( 

40 Tuple)[Dict[Any, Element], Dict[Any, Element], List[IfcFileClass]]: 

41 """This task creates the bim2sim elements based on the ifc data. 

42 

43 For each ifc file a factory instance is created. The factory instance 

44 allows the easy creation of bim2sim elements based on ifc elements. 

45 As we might not want to create bim2sim elements for every existing ifc 

46 element, we use the concept of relevant_elements which are taken from 

47 the sim_setting relevant_elements. This way the user can describe which 

48 bim2sim elements are relevant for the respective simulation and only 

49 the fitting ifc elements are taken into account. 

50 During the creation of the bim2sim elements validations are performed, 

51 to make sure that the resulting bim2sim elements hold valid 

52 information. 

53 

54 The element creation process follows a three-stage classification 

55 approach: 

56 

57 1. **Validation-Based Classification**: 

58 * Elements are initially processed based on relevant element 

59 types and IFC classifications 

60 * Each element undergoes validation against predefined criteria 

61 * Valid elements are immediately added to the "valids" collection 

62 * Elements failing validation are placed in "unknown_entities" 

63 for further processing 

64 

65 2. **Pattern Matching Classification**: 

66 * For unidentified elements in "unknown_entities", a text 

67 analysis is performed 

68 * The system examines IFC descriptions and compares them against 

69 regular expression patterns defined in element classes 

70 * Results are handled based on match confidence: 

71 - Single match: Element is automatically moved to "valids" 

72 - Multiple matches: User decision is requested to determine the 

73 correct classification 

74 - No matches: Element remains in "unknown_entities" for final 

75 classification stage 

76 

77 3. **User-Assisted Classification**: 

78 * Remaining unidentified elements are processed through the 

79 set_class_by_user function 

80 * To optimize user experience, similar elements are intelligently 

81 grouped using one of these strategies: 

82 - Exact name matching: Groups elements with identical names 

83 - Name and description matching: Groups elements with identical 

84 names and descriptions 

85 - Fuzzy matching: Groups elements with similar names based on a 

86 configurable similarity threshold 

87 * This grouping significantly reduces the number of required user 

88 decisions 

89 * User decisions determine the final classification of each 

90 element or group 

91 

92 Args: 

93 ifc_files: list of ifc files in bim2sim structured format 

94 Returns: 

95 elements: bim2sim elements created based on ifc data 

96 ifc_files: list of ifc files in bim2sim structured format 

97 """ 

98 self.logger.info("Creates elements of relevant ifc types") 

99 default_ifc_types = {'IfcBuildingElementProxy', 'IfcUnitaryEquipment'} 

100 # Todo maybe move this into IfcFileClass instead simulation settings 

101 relevant_elements = self.playground.sim_settings.relevant_elements 

102 relevant_ifc_types = self.get_ifc_types(relevant_elements) 

103 relevant_ifc_types.update(default_ifc_types) 

104 

105 elements: dict = {} 

106 for ifc_file in ifc_files: 

107 self.factory = Factory( 

108 relevant_elements, 

109 ifc_file.ifc_units, 

110 ifc_file.domain, 

111 ifc_file.finder) 

112 

113 # Filtering: 

114 # filter returns dict of entities: suggested class and list of 

115 # unknown accept_valids returns created elements and lst of 

116 # invalids 

117 

118 element_lst: list = [] 

119 entity_best_guess_dict: dict = {} 

120 # filter by type 

121 type_filter = TypeFilter(relevant_ifc_types) 

122 entity_type_dict, unknown_entities = type_filter.run( 

123 ifc_file.file) 

124 

125 # Extract stories if sim_setting is active 

126 if self.playground.sim_settings.stories_to_load_guids: 

127 storey_filter = StoreyFilter( 

128 self.playground.sim_settings.stories_to_load_guids) 

129 entity_type_dict, unknown_entities = storey_filter.run( 

130 ifc_file.file, entity_type_dict, unknown_entities) 

131 

132 # create valid elements 

133 # First elements are created with validation, by checking 

134 # conditions and number of ports if they fit expectations, those 

135 # who don't fit expectations are added to invalids list 

136 valids, invalids = self.create_with_validation(entity_type_dict) 

137 element_lst.extend(valids) 

138 unknown_entities.extend(invalids) 

139 

140 # filter by text 

141 # invalids from before are checked with text filter on their IFC 

142 # Description to identify them 

143 text_filter = TextFilter( 

144 relevant_elements, 

145 ifc_file.ifc_units, 

146 ['Description']) 

147 entity_class_dict, unknown_entities = yield from ( 

148 self.filter_by_text( 

149 text_filter, unknown_entities)) 

150 entity_best_guess_dict.update(entity_class_dict) 

151 

152 # Now we use the result of previous text filter to create the 

153 # elements that could be identified by text filter 

154 valids, invalids = self.create_with_validation( 

155 entity_class_dict, force=True) 

156 element_lst.extend(valids) 

157 unknown_entities.extend(invalids) 

158 

159 self.logger.info("Found %d relevant elements", len(element_lst)) 

160 self.logger.info("Found %d ifc_entities that could not be " 

161 "identified and therefore not converted into a" 

162 " bim2sim element.", 

163 len(unknown_entities)) 

164 

165 # identification of remaining entities by user 

166 # those are elements that failed initial validation and could not 

167 # be identified by text filter 

168 entity_class_dict, unknown_entities = yield from ( 

169 self.set_class_by_user( 

170 unknown_entities, 

171 self.playground.sim_settings, 

172 entity_best_guess_dict) 

173 ) 

174 entity_best_guess_dict.update(entity_class_dict) 

175 invalids = [] 

176 

177 for element_cls, ifc_entities in entity_class_dict.items(): 

178 for ifc_entity in ifc_entities: 

179 try: 

180 item = self.factory.create(element_cls, ifc_entity) 

181 element_lst.append(item) 

182 except Exception as ex: 

183 invalids.append(ifc_entity) 

184 if invalids: 

185 self.logger.info("Removed %d entities with no class set", 

186 len(invalids)) 

187 

188 self.logger.info(f"Created {len(element_lst)} bim2sim elements " 

189 f"based on IFC file {ifc_file.ifc_file_name}") 

190 elements.update({inst.guid: inst for inst in element_lst}) 

191 if not elements: 

192 self.logger.error("No bim2sim elements could be created based on " 

193 "the IFC files.") 

194 raise AssertionError( 

195 "No bim2sim elements could be created, program" 

196 "will be terminated as no further process is " 

197 "possible.") 

198 self.logger.info(f"Created {len(elements)} bim2sim elements in " 

199 f"total for all IFC files.") 

200 # sort elements for easier handling 

201 elements = dict(sorted(elements.items())) 

202 # store copy of elements to preserve for alter operations 

203 _initial_elements = copy.copy(elements) 

204 return elements, _initial_elements, ifc_files 

205 

206 def create_with_validation(self, entities_dict: dict, warn=True, 

207 force=False) -> \ 

208 Tuple[List[ProductBased], List[Any]]: 

209 """Instantiate ifc_entities using given element class. 

210 

211 The given ifc entities are used to create bim2sim elements via factory 

212 method. After the creation the associated layers and material are 

213 created (see create_layers_and_materials). 

214 All created elements (including material and layers) are checked 

215 against the provided conditions and classified into valid and invalid. 

216 

217 Args: 

218 entities_dict: dict with ifc entities 

219 warn: boolean to warn if something condition fail 

220 force: boolean if conditions should be ignored 

221 

222 Returns: 

223 valid: list of all valid items that fulfill the conditions 

224 invalid: list of all elements that do not fulfill the conditions 

225 

226 """ 

227 valid, invalid = [], [] 

228 blacklist = [ 

229 'IfcMaterialLayerSet', 

230 'IfcMaterialLayer', 

231 'IfcMaterial', 

232 'IfcMaterialConstituentSet', 

233 'IfcMaterialConstituent', 

234 'IfcMaterialProfile', 

235 'IfcMaterialProfileSet', 

236 ] 

237 

238 blacklist_classes = [ 

239 bps.LayerSet, bps.Layer, Material 

240 ] 

241 

242 for entity, ifc_type_or_element_cls in entities_dict.items(): 

243 try: 

244 if isinstance(ifc_type_or_element_cls, str): 

245 if ifc_type_or_element_cls in blacklist: 

246 continue 

247 try: 

248 element = self.factory( 

249 entity, ifc_type=ifc_type_or_element_cls, 

250 use_dummy=False) 

251 except IFCDomainError: 

252 continue 

253 else: 

254 if ifc_type_or_element_cls in blacklist_classes: 

255 continue 

256 element = self.factory.create( 

257 ifc_type_or_element_cls, entity) 

258 except LookupError: 

259 invalid.append(entity) 

260 continue 

261 # TODO #676 

262 plugin_name = self.playground.project.plugin_cls.name 

263 if plugin_name in ['EnergyPlus', 'Comfort', 'Teaser']: 

264 if (self.playground.sim_settings.layers_and_materials 

265 is not LOD.low): 

266 raise NotImplementedError( 

267 "Only layers_and_materials using LOD.low is " 

268 "currently supported.") 

269 self.create_layers_and_materials(element) 

270 valid += ( 

271 self.layersets_all 

272 + self.layers_all + 

273 self.materials_all 

274 ) 

275 

276 if element.validate_creation(): 

277 valid.append(element) 

278 elif force: 

279 valid.append(element) 

280 if warn: 

281 self.logger.warning("Force accept invalid element %s %s", 

282 ifc_type_or_element_cls, element) 

283 else: 

284 if warn: 

285 self.logger.warning("Validation failed for %s %s", 

286 ifc_type_or_element_cls, element) 

287 invalid.append(entity) 

288 

289 return list(set(valid)), list(set(invalid)) 

290 

291 def create_layers_and_materials(self, element: Element): 

292 """Create all layers and materials associated with the given element. 

293 

294 Layers and materials are no IfcProducts and have no GUID. 

295 They are always associated to IfcProducts. To create the association 

296 between Product and layer or material we create layers and materials 

297 directly when creating the corresponding element and not directly based 

298 on their IFC type in the normal creation process. 

299 For more information how materials work in IFC have a look at 

300 

301 `wiki.osarch.org`_. 

302 _wiki.osarch.org: https://wiki.osarch.org/index.php?title=IFC_-_ 

303 Industry_Foundation_Classes/IFC_materials 

304 

305 Args: 

306 element: the already created bim2sim element 

307 """ 

308 quality_logger = logging.getLogger( 

309 'bim2sim.QualityReport') 

310 if hasattr(element.ifc, 'HasAssociations'): 

311 for association in element.ifc.HasAssociations: 

312 if association.is_a("IfcRelAssociatesMaterial"): 

313 ifc_mat_rel_object = association.RelatingMaterial 

314 

315 # Layers 

316 ifc_layerset_entity = None 

317 if ifc_mat_rel_object.is_a('IfcMaterialLayerSetUsage'): 

318 ifc_layerset_entity = ifc_mat_rel_object.ForLayerSet 

319 elif ifc_mat_rel_object.is_a('IfcMaterialLayerSet'): 

320 ifc_layerset_entity = ifc_mat_rel_object 

321 if ifc_layerset_entity: 

322 self.create_layersets(element, ifc_layerset_entity) 

323 

324 # Constituent sets 

325 if ifc_mat_rel_object.is_a( 

326 'IfcMaterialConstituentSet'): 

327 ifc_material_constituents = \ 

328 ifc_mat_rel_object.MaterialConstituents 

329 self.create_constituent( 

330 element, ifc_material_constituents, quality_logger) 

331 

332 # Direct Material 

333 if ifc_mat_rel_object.is_a('IfcMaterial'): 

334 ifc_material_entity = ifc_mat_rel_object 

335 material = self.create_material(ifc_material_entity) 

336 element.material = material 

337 material.parents.append(element) 

338 

339 # TODO maybe use in future 

340 # Profiles 

341 if ifc_mat_rel_object.is_a( 

342 'IfcMaterialProfileSetUsage'): 

343 pass 

344 elif ifc_mat_rel_object.is_a( 

345 'IfcMaterialProfileSet'): 

346 pass 

347 elif ifc_mat_rel_object.is_a( 

348 'IfcMaterialProfile'): 

349 pass 

350 

351 def create_layersets(self, element: Element, 

352 ifc_layerset_entity: entity_instance): 

353 """Instantiate the layerset and its layers and materials and link to 

354 element. 

355 

356 Layersets in IFC are used to describe the layer structure of e.g. 

357 walls. 

358 

359 Args: 

360 element: bim2sim element 

361 ifc_layerset_entity: ifc entity of layerset 

362 """ 

363 for layerset in self.layersets_all: 

364 if ifc_layerset_entity == layerset.ifc: 

365 break 

366 else: 

367 layerset = self.factory( 

368 ifc_layerset_entity, 

369 ifc_type='IfcMaterialLayerSet', 

370 use_dummy=False) 

371 self.layersets_all.append(layerset) 

372 

373 for ifc_layer_entity in ifc_layerset_entity.MaterialLayers: 

374 layer = self.factory( 

375 ifc_layer_entity, 

376 ifc_type='IfcMaterialLayer', 

377 use_dummy=False) 

378 self.layers_all.append(layer) 

379 layer.to_layerset.append(layerset) 

380 layerset.layers.append(layer) 

381 ifc_material_entity = ifc_layer_entity.Material 

382 material = self.create_material(ifc_material_entity) 

383 layer.material = material 

384 material.parents.append(layer) 

385 # add layerset to element and vice versa 

386 element.layerset = layerset 

387 layerset.parents.append(element) 

388 

389 def create_constituent( 

390 self, element: Element, ifc_material_constituents: entity_instance, 

391 quality_logger: Element): # Error durch mypy: Element has no 

392 # attribute Layerset 

393 """Instantiate the constituent set and its materials and link to 

394 element. 

395 

396 Constituent sets in IFC are used to describe the e.g. windows which 

397 consist out of different materials (glass, frame etc.) or mixtures like 

398 concrete (sand, cement etc.). 

399 

400 Args: 

401 element: bim2sim element 

402 ifc_material_constituents: ifc entity of layerset 

403 quality_logger: element of bim2sim quality logger 

404 """ 

405 for ifc_constituent in ifc_material_constituents: 

406 ifc_material_entity = ifc_constituent.Material 

407 

408 material = self.create_material(ifc_material_entity) 

409 fraction = ifc_constituent.Fraction 

410 # todo if every element of the constituent has a geometric 

411 # representation we could use them to get the volume of the 

412 # different sub constituents and create fractions from it 

413 if not fraction: 

414 quality_logger.warning( 

415 f"{element} has a " 

416 f"IfcMaterialConstituentSet but no" 

417 f" information to fraction is provided") 

418 n = len(element.material_set) 

419 fraction = 'unknown_' + str(n) 

420 element.material_set[fraction] = material 

421 material.parents.append(element) 

422 

423 def create_material(self, ifc_material_entity: entity_instance): 

424 """As materials are unique in IFC we only want to have on material 

425 instance per material.""" 

426 for material in self.materials_all: 

427 if ifc_material_entity == material.ifc: 

428 break 

429 else: 

430 material = self.factory( 

431 ifc_material_entity, 

432 ifc_type='IfcMaterial', 

433 use_dummy=False) 

434 self.materials_all.append(material) 

435 return material 

436 

437 def filter_by_text(self, text_filter: TextFilter, 

438 ifc_entities: entity_instance) \ 

439 -> Generator[DecisionBunch, None, Tuple[ 

440 Dict[Any, Type[ProductBased]], List]]: 

441 """Filter IFC elements by analyzing text fragments. 

442 

443 This method applies text-based filtering to identify elements. It 

444 handles: 

445 1. Automatic classification for entities with single matching class 

446 2. User decision for entities with multiple potential matching classes 

447 3. Collection of unidentified entities 

448 

449 Args: 

450 text_filter: The TextFilter instance to use. 

451 ifc_entities: IFC entities to filter. 

452 

453 Yields: 

454 DecisionBunch: User decisions for ambiguous matches. 

455 

456 Returns: 

457 tuple: 

458 - Dictionary mapping entities to their identified element 

459 classes 

460 - List of unidentified entities 

461 """ 

462 # Step 1: Run the text filter to find potential matches 

463 # Now each entity maps to a dictionary of {class: fragments} 

464 entities_match_dict, unknown_entities = text_filter.run(ifc_entities) 

465 

466 # Prepare containers for results 

467 direct_matches = {} # For entities with a single match 

468 decisions = DecisionBunch() # For entities requiring user decision 

469 

470 # Step 2: Process the matches 

471 for entity, class_fragments_dict in entities_match_dict.items(): 

472 # Sort classes by their key 

473 sorted_classes = sorted(class_fragments_dict.keys(), 

474 key=lambda cls: cls.key) 

475 

476 if len(sorted_classes) == 1: 

477 # Single match - direct assignment 

478 direct_matches[entity] = sorted_classes[0] 

479 elif len(sorted_classes) > 1: 

480 # Multiple matches - user decision required 

481 choices = [] 

482 

483 # Create choices with helpful hints using already computed 

484 # fragments 

485 for element_cls in sorted_classes: 

486 fragments = class_fragments_dict[element_cls] 

487 hints = f"Matches: '{', '.join(fragments)}'" 

488 choices.append([element_cls.key, hints]) 

489 

490 choices.append(["Other", "Other"]) 

491 

492 # Create decision 

493 decisions.append(ListDecision( 

494 question=f"Searching for text fragments in '" 

495 f"{entity.Name}' gave the following class hints. " 

496 f"Please select best match.", 

497 console_identifier=f"Name: '{entity.Name}', Descr" 

498 f"iption: '{entity.Description}'", 

499 choices=choices, 

500 key=entity, 

501 related=[entity.GlobalId], 

502 global_key=f"TextFilter:{entity.is_a()}" 

503 f".{entity.GlobalId}.{entity.Name}", 

504 allow_skip=True, 

505 context=[entity.GlobalId] 

506 )) 

507 

508 # Step 3: Yield decisions for user input 

509 yield decisions 

510 

511 # Step 4: Process results 

512 result_entity_dict = {} 

513 

514 # Add direct matches 

515 for entity, element_cls in direct_matches.items(): 

516 result_entity_dict[entity] = element_cls 

517 

518 # Process user decisions 

519 answers = decisions.to_answer_dict() 

520 for entity, element_key in answers.items(): 

521 element_cls = ProductBased.key_map.get(element_key) 

522 if element_cls: 

523 result_entity_dict[entity] = element_cls 

524 else: 

525 unknown_entities.append(entity) 

526 

527 return result_entity_dict, unknown_entities 

528 

529 def set_class_by_user( 

530 self, 

531 unknown_entities: list, 

532 sim_settings: BaseSimSettings, 

533 best_guess_dict: dict): 

534 """Ask user for every given ifc_entity to specify matching element 

535 class. 

536 

537 This function allows to define unknown classes based on user feedback. 

538 To reduce the number of decisions we implemented fuzzy search. If and 

539 how fuzzy search is used can be set the sim_settings 

540 group_unidentified and fuzzy_threshold. See group_similar_entities() 

541 for more information. 

542 

543 Args: 

544 unknown_entities: list of unknown entities 

545 sim_settings: sim_settings used for this project 

546 best_guess_dict: dict that holds the best guesses for every element 

547 """ 

548 

549 def group_similar_entities( 

550 search_type: str = 'fuzzy', 

551 fuzzy_threshold: float = 0.7) -> dict: 

552 """Group unknown entities to reduce number of decisions. 

553 

554 IFC elements are often not correctly specified, or have uncertain 

555 specifications like "USERDEFINED" as predefined type. For some IFC 

556 files this would lead to a very high amount of decisions to 

557 identify 

558 elements. To reduce those decisions, this function groups similar 

559 elements based on: 

560 - same name (exact) 

561 - similar name (fuzzy search) 

562 

563 Args: 

564 search_type: str which is either 'fuzzy' or 'name' 

565 fuzzy_threshold: float that sets the threshold for fuzzy 

566 search. 

567 A low threshold means a small similarity is required for 

568 grouping 

569 

570 Returns: 

571 representatives: A dict with a string of the representing ifc 

572 element type as key (e.g. 'IfcPipeFitting') and a list of all 

573 represented ifc elements. 

574 """ 

575 # Sort unknown entities by GlobalId for determinism 

576 unknown_entities_sorted = sorted(unknown_entities, 

577 key=lambda e: e.GlobalId) 

578 

579 entities_by_type = {} 

580 for entity in unknown_entities_sorted: 

581 entity_type = entity.is_a() 

582 if entity_type not in entities_by_type: 

583 entities_by_type[entity_type] = [entity] 

584 else: 

585 entities_by_type[entity_type].append(entity) 

586 

587 representatives = {} 

588 for entity_type, entities in sorted(entities_by_type.items()): 

589 if len(entities) == 1: 

590 # Use OrderedDict for stable iteration 

591 entity_dict = collections.OrderedDict() 

592 entity_dict[entities[0]] = entities 

593 representatives[entity_type] = entity_dict 

594 continue 

595 

596 # group based on similarity in string of "Name" of IFC element 

597 if search_type == 'fuzzy': 

598 # Modify group_by_levenshtein to ensure stable sorting 

599 grouped = group_by_levenshtein( 

600 entities, similarity_score=fuzzy_threshold) 

601 

602 # Convert result to a stable structure 

603 stable_grouped = collections.OrderedDict() 

604 for key, group in sorted(grouped.items(), 

605 key=lambda x: x[0].GlobalId): 

606 # Sort the group itself 

607 stable_grouped[key] = sorted(group, 

608 key=lambda e: e.GlobalId) 

609 

610 representatives[entity_type] = stable_grouped 

611 

612 self.logger.info( 

613 f"Grouping the unidentified elements with fuzzy " 

614 f"search " 

615 f"based on their Name (Threshold = {fuzzy_threshold})" 

616 f" reduced the number of unknown " 

617 f"entities from {len(entities_by_type[entity_type])} " 

618 f"elements of IFC type {entity_type} " 

619 f"to {len(representatives[entity_type])} elements.") 

620 

621 # just group based on exact same string in "Name" of IFC 

622 # element 

623 elif search_type == 'name': 

624 # Use OrderedDict for stable iteration 

625 name_groups = collections.defaultdict(list) 

626 

627 # Group by name, but keep the sorting 

628 for entity in entities: 

629 name_groups[entity.Name].append(entity) 

630 

631 # Create the stable representatives dictionary 

632 stable_grouped = collections.OrderedDict() 

633 for name, group in sorted(name_groups.items()): 

634 # Sort the group by GlobalId and choose the first 

635 # element as representative 

636 sorted_group = sorted(group, key=lambda e: e.GlobalId) 

637 stable_grouped[sorted_group[0]] = sorted_group 

638 

639 representatives[entity_type] = stable_grouped 

640 

641 self.logger.info( 

642 f"Grouping the unidentified elements by their Name " 

643 f"reduced the number of unknown entities from" 

644 f" {len(entities_by_type[entity_type])} " 

645 f"elements of IFC type {entity_type} " 

646 f"to {len(representatives[entity_type])} elements.") 

647 elif search_type == 'name_and_description': 

648 # Use OrderedDict for stable iteration 

649 name_desc_groups = collections.defaultdict(list) 

650 

651 # Group by name AND description, but keep the sorting 

652 for entity in entities: 

653 # Create a composite key combining Name and 

654 # Description, handling None values 

655 name = entity.Name if ( 

656 hasattr(entity, "Name") and 

657 entity.Name is not None) else "" 

658 desc = entity.Description if ( 

659 hasattr(entity, "Description") and 

660 entity.Description is not None) else "" 

661 composite_key = (name, desc) 

662 name_desc_groups[composite_key].append(entity) 

663 

664 # Create the stable representatives dictionary 

665 stable_grouped = collections.OrderedDict() 

666 

667 # Sort by composite key, handling None values by 

668 # converting them to empty strings 

669 for composite_key, group in sorted( 

670 name_desc_groups.items()): 

671 # Sort the group by GlobalId and choose the first 

672 # element as representative 

673 sorted_group = sorted(group, key=lambda e: e.GlobalId) 

674 stable_grouped[sorted_group[0]] = sorted_group 

675 

676 representatives[entity_type] = stable_grouped 

677 

678 self.logger.info( 

679 f"Grouping the unidentified elements by their Name " 

680 f"and Description " 

681 f"reduced the number of unknown entities from" 

682 f" {len(entities_by_type[entity_type])} " 

683 f"elements of IFC type {entity_type} " 

684 f"to {len(representatives[entity_type])} elements.") 

685 else: 

686 raise NotImplementedError( 

687 'Only fuzzy and name grouping are' 

688 'implemented for now.') 

689 

690 return representatives 

691 

692 possible_elements = sim_settings.relevant_elements 

693 sorted_elements = sorted(possible_elements, key=lambda item: item.key) 

694 

695 result_entity_dict = {} 

696 ignore = [] 

697 

698 representatives = group_similar_entities( 

699 sim_settings.group_unidentified, sim_settings.fuzzy_threshold) 

700 

701 # Sort the outer loop by IFC type 

702 # Create a single decision bunch for all entities 

703 all_decisions = DecisionBunch() 

704 # Store associations between decisions and their representatives 

705 decision_associations = {} # Maps (decision -> (ifc_type, ifc_entity)) 

706 

707 # Sort the outer loop by IFC type 

708 for ifc_type, repr_entities in sorted(representatives.items()): 

709 # Sort the inner loop by GlobalId for determinism 

710 for ifc_entity, represented in sorted(repr_entities.items(), 

711 key=lambda x: x[0].GlobalId): 

712 # assert same list of ifc_files 

713 checksum = Decision.build_checksum( 

714 [pe.key for pe in sorted_elements]) 

715 

716 best_guess_cls = best_guess_dict.get(ifc_entity) 

717 best_guess = best_guess_cls.key if best_guess_cls else None 

718 

719 # Sort ports and related elements 

720 context = [] 

721 for port in sorted(ifc2python.get_ports(ifc_entity), 

722 key=lambda p: p.GlobalId): 

723 connected_ports = sorted( 

724 ifc2python.get_ports_connections(port), 

725 key=lambda p: p.GlobalId) 

726 con_ports_guid = [con.GlobalId for con in connected_ports] 

727 parents = [] 

728 for con_port in sorted(connected_ports, 

729 key=lambda p: p.GlobalId): 

730 port_parents = sorted( 

731 ifc2python.get_ports_parent(con_port), 

732 key=lambda p: p.GlobalId) 

733 parents.extend(port_parents) 

734 parents_guid = [par.GlobalId for par in parents] 

735 context.append(port.GlobalId) 

736 context.extend(sorted(con_ports_guid + parents_guid)) 

737 

738 # Sort the represented elements 

739 representative_global_keys = [] 

740 for represent in sorted(repr_entities[ifc_entity], 

741 key=lambda e: e.GlobalId): 

742 representative_global_keys.append( 

743 "SetClass:%s.%s.%s" % ( 

744 represent.is_a(), represent.GlobalId, 

745 represent.Name 

746 ) 

747 ) 

748 

749 decision = ListDecision( 

750 question="Found unidentified Element of Ifc Type: %s" % ( 

751 ifc_entity.is_a()), 

752 console_identifier="Name: %s, Description: %s, GUID: %s, " 

753 "Predefined Type: %s" 

754 % ( 

755 ifc_entity.Name, 

756 ifc_entity.Description, 

757 ifc_entity.GlobalId, 

758 ifc_entity.PredefinedType), 

759 choices=[ele.key for ele in sorted_elements], 

760 related=[ifc_entity.GlobalId], 

761 context=sorted(context), # Sort the context 

762 default=best_guess, 

763 key=ifc_entity, 

764 global_key="SetClass:%s.%s.%s" % ( 

765 ifc_entity.is_a(), ifc_entity.GlobalId, ifc_entity.Name 

766 ), 

767 representative_global_keys=sorted( 

768 representative_global_keys), # Sort the keys 

769 allow_skip=True, 

770 validate_checksum=checksum) 

771 

772 # Add decision to the bunch 

773 all_decisions.append(decision) 

774 

775 # Store association between decision and its representatives 

776 decision_associations[ifc_entity] = (ifc_type, ifc_entity) 

777 

778 self.logger.info( 

779 f"Found {len(all_decisions)} unidentified Elements to check by " 

780 f"user") 

781 

782 # Yield the single big bunch of decisions 

783 yield all_decisions 

784 

785 # Process all answers at once 

786 answers = all_decisions.to_answer_dict() 

787 

788 for ifc_entity, element_key in sorted(answers.items(), 

789 key=lambda x: x[0].GlobalId): 

790 # Get the associated ifc_type and representative entity 

791 ifc_type, _ = decision_associations[ifc_entity] 

792 represented_entities = representatives[ifc_type][ifc_entity] 

793 

794 if element_key is None: 

795 ignore.extend( 

796 sorted(represented_entities, key=lambda e: e.GlobalId)) 

797 else: 

798 element_cls = ProductBased.key_map[element_key] 

799 lst = result_entity_dict.setdefault(element_cls, []) 

800 lst.extend( 

801 sorted(represented_entities, key=lambda e: e.GlobalId)) 

802 

803 return result_entity_dict, ignore 

804 

805 @staticmethod 

806 def get_ifc_types(relevant_elements: List[Type[ProductBased]]) \ 

807 -> Set[str]: 

808 """Extract used ifc types from list of elements.""" 

809 relevant_ifc_types = [] 

810 for ele in relevant_elements: 

811 relevant_ifc_types.extend(ele.ifc_types.keys()) 

812 return set(relevant_ifc_types)