Coverage for bim2sim/elements/mapping/finder.py: 75%

175 statements  

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

1"""Finders are used to get properties from ifc which do not meet IFC standards. 

2 

3Currently, we implemented only one Finder, the TemplateFinder. 

4""" 

5from __future__ import annotations 

6 

7import contextlib 

8import json 

9import logging 

10import os 

11from pathlib import Path 

12from typing import Generator, TYPE_CHECKING, Union 

13 

14from ifcopenshell import file, entity_instance 

15 

16import bim2sim 

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

18from bim2sim.elements.mapping import ifc2python 

19from bim2sim.utilities.common_functions import validateJSON 

20 

21if TYPE_CHECKING: 

22 from bim2sim.elements.base_elements import IFCBased 

23 

24logger = logging.getLogger(__name__) 

25DEFAULT_PATH = Path(bim2sim.__file__).parent / 'assets/finder' 

26 

27 

28class Finder: 

29 

30 def find(self, element, property_name): 

31 raise NotImplementedError() 

32 

33 def reset(self): 

34 """Reset finder instance""" 

35 raise NotImplementedError() 

36 

37 

38class TemplateFinder(Finder): 

39 """TemplateFinder works like a multi key dictionary. 

40 

41 The TemplateFinder allows to find information (properties) which are stored 

42 in the IFC file but not on the position the IFC schema specifies. To find 

43 these information we implemented templates in form of .json files for the 

44 most relevant IFC exporter tools. These templates hold the information where 

45 to look in the IFC. E.g. Revit stores length of IfcPipeSegment in 

46 PropertySet 'Abmessungen' with name 'Länge'. 

47 """ 

48 

49 prefix = "template_" 

50 

51 def __init__(self): 

52 super().__init__() 

53 # {tool: {Element class name: {parameter: (Pset name, property name)}}} 

54 self.templates = {} 

55 self.blacklist = [] 

56 self.path = None 

57 self.load(DEFAULT_PATH) # load default path 

58 self.enabled = True 

59 self.source_tools = [] 

60 self.default_source_tool = None 

61 

62 def load(self, path: Union[str, Path]): 

63 """Loads jsontemplates from given path. 

64 

65 Each *.json file is loaded into the templates dictionary with tool 

66 name as key and converted json dictionary as value. 

67 

68 Args: 

69 path: str or Path where the templates are stored. 

70 Raises: 

71 ValueError: if an invalid json is loaded. 

72 """ 

73 if not isinstance(path, Path): 

74 path = Path(path) 

75 self.path = path 

76 

77 # search in path 

78 json_gen = self.path.rglob('*.json') 

79 for json_file_path in json_gen: 

80 if json_file_path.name.lower().startswith(TemplateFinder.prefix): 

81 tool_name = json_file_path.stem[len(TemplateFinder.prefix):] 

82 if validateJSON(json_file_path): 

83 with open(json_file_path, 'rb') as json_file: 

84 self.templates[tool_name] = json.load(json_file) 

85 else: 

86 raise ValueError(f"Invalid JSON in {json_file_path}") 

87 

88 def save(self, path): 

89 """Save templates to path, one file for each tool in templates. 

90 

91 Allows to save the created templates to path. 

92 

93 Args: 

94 path: str or Path where the templates are stored. 

95 """ 

96 

97 for tool, element_dict in self.templates.items(): 

98 full_path = os.path.join( 

99 path, TemplateFinder.prefix + tool + '.json') 

100 with open(full_path, 'w') as file: 

101 json.dump(element_dict, file, indent=2) 

102 

103 def set( 

104 self, tool, ifc_type: str, parameter, property_set_name, 

105 property_name): 

106 """Internally saves property_set_name as property_name. 

107 

108 This deals as a lookup source for tool, element and parameter""" 

109 value = [property_set_name, property_name] 

110 self.templates.setdefault(tool, {}).setdefault(ifc_type, {}).setdefault( 

111 'default_ps', {})[parameter] = value 

112 

113 def find(self, element: IFCBased, property_name: str): 

114 """Tries to find the required property. 

115 

116 Args: 

117 element: IFCBased bim2sim element 

118 property_name: str with name of the property 

119 Returns: 

120 value of property or None if propertyset or property is not 

121 available. 

122 Raises: 

123 AttributeError if TemplateFinder does not know about given input 

124 """ 

125 if not self.enabled: 

126 raise AttributeError("Finder is disabled") 

127 

128 self._get_elements_source_tool(element) 

129 if not element.source_tool: 

130 return None 

131 key1 = element.source_tool.templ_name 

132 key2 = type(element).__name__ 

133 key3 = 'default_ps' 

134 key4 = property_name 

135 try: 

136 res = self.templates[key1][key2][key3][key4] 

137 except KeyError: 

138 raise AttributeError("%s does not know where to look for %s" % ( 

139 self.__class__.__name__, (key1, key2, key3, key4))) 

140 

141 try: 

142 # get value from templates 

143 for res_ele in ( 

144 res if all(isinstance(r, list) for r in res[:2]) else [ 

145 res]): 

146 pset = ifc2python.get_property_set_by_name(res_ele[0], 

147 element.ifc, 

148 element.ifc_units) 

149 if pset: 

150 val = pset.get(res_ele[1]) 

151 if val is not None: 

152 return val 

153 return None 

154 except AttributeError: 

155 raise AttributeError("Can't find property as defined by template.") 

156 

157 def _set_templates_by_tools(self, source_tool: SourceTool) \ 

158 -> Generator[Decision, None, None]: 

159 """Check the given IFC Creation tool and choose the template. 

160 

161 If no template exists for the given source_tool a decision is triggered 

162 if their another of the existing should be chosen. If yes the selected 

163 template is used for the given source_tool as well. 

164 

165 Args: 

166 source_tool: str of ApplicationFullName of the IfcApplication 

167 Yields: 

168 decision_source_tool: ListDecision which existing tool template 

169 might fit 

170 """ 

171 if source_tool.full_name in self.blacklist: 

172 logger.warning(f'No finder template found for ' 

173 f'{source_tool.full_name}') 

174 return 

175 

176 for templ in self.templates.items(): 

177 templ_name = templ[0] 

178 temp_tool_names = [templ_name] 

179 try: 

180 temp_tool_names += templ[-1]["Identification"]["tool_names"] 

181 except KeyError: 

182 logger.warning( 

183 f'No Identification defined in template for {templ_name}') 

184 # generate all potential fitting names based on content of template 

185 tool_names = [] 

186 for tool_name in temp_tool_names: 

187 # Revit stores version and language, so generate possible names 

188 if any(x in tool_name for x in ["%lang", "%v"]): 

189 if "%v" in tool_name: 

190 tool_name = tool_name.replace( 

191 "%v", source_tool.version) 

192 if "%lang" in tool_name: 

193 langs = templ[-1]["Identification"]["languages"] 

194 for lang in langs: 

195 tool_names.append(tool_name.replace("%lang", lang)) 

196 else: 

197 tool_names.append(tool_name) 

198 

199 pot_names = list(map(lambda tool: tool.lower(), tool_names)) 

200 

201 if any(name in pot_names for name in 

202 [source_tool.full_name.lower(), source_tool.ident.lower()]): 

203 source_tool.templ_name = templ_name 

204 break 

205 

206 if not source_tool.templ_name: 

207 # no matching template 

208 logger.warning('No finder template found for {}.' 

209 .format(source_tool.full_name)) 

210 

211 choices = list(self.templates.keys()) + ['Other'] 

212 choice_checksum = ListDecision.build_checksum(choices) 

213 decision_source_tool = ListDecision( 

214 f"Please select best matching source tool for " 

215 f"{source_tool.full_name} with version: {source_tool.version} ", 

216 choices=choices, 

217 default='Other', 

218 global_key=f'tool_{source_tool.full_name}_{source_tool.ifc}_' 

219 f'{choice_checksum}', 

220 allow_skip=True) 

221 yield decision_source_tool 

222 tool_name = decision_source_tool.value 

223 

224 if not tool_name or tool_name == 'Other': 

225 self.blacklist.append(source_tool.full_name) 

226 logger.warning('No finder template found for %s.', source_tool) 

227 return 

228 else: 

229 source_tool.templ_name = tool_name 

230 

231 logger.info(f'Found matching template for IfcApplication with' 

232 f'full name {source_tool.full_name} in template ' 

233 f'{source_tool.templ_name}') 

234 

235 def initialize(self, ifc: file): 

236 """Find fitting templates for given IFC and set default source tool. 

237 

238 Checks the IfcApplications in the IFC File against the existing 

239 templates of the finder. If multiple IfcApplications with existing 

240 templates are found a decision is triggered which template should be 

241 used by default for template lookup to reduce number of decisions 

242 during process. 

243 This must be called from inside a tasks because it holds decisions. 

244 

245 Args: 

246 ifc: ifcopenshell instance of ifc file 

247 """ 

248 # use finder to get correct export tool 

249 source_tools = [] 

250 for app in ifc.by_type('IfcApplication'): 

251 source_tool = SourceTool(app) 

252 source_tools.append(source_tool) 

253 # Filter source tools as there might be duplications in IFC 

254 unique_source_tools = self.remove_duplicate_source_tools(source_tools) 

255 for unique_source_tool in unique_source_tools: 

256 self.source_tools.append(unique_source_tool) 

257 for decision in self._set_templates_by_tools(unique_source_tool): 

258 yield DecisionBunch([decision]) 

259 # Only take source tools with existing template into account 

260 tools = [tool.templ_name for tool in self.source_tools if 

261 tool.templ_name] 

262 if len(tools) == 1: 

263 self.default_source_tool = self.source_tools[0] 

264 elif len(tools) > 1: 

265 choice_checksum = ListDecision.build_checksum(tools) 

266 decision_source_tool = ListDecision( 

267 "Multiple source tools found, please decide which one to " 

268 "use as fallback for template based searches if no" 

269 " IfcOwnerHistory exists.", 

270 choices=tools, 

271 global_key=f'tool_{choice_checksum}', 

272 allow_skip=True) 

273 yield DecisionBunch([decision_source_tool]) 

274 if decision_source_tool.value: 

275 self.default_source_tool = \ 

276 decision_source_tool.value 

277 else: 

278 logger.info(f"No decision for default source tool, taking " 

279 f"last source tool found: " 

280 f"{self.source_tools[-1]}") 

281 self.default_source_tool = self.source_tools[-1] 

282 else: 

283 logger.info(f"No template could be found for one of the following " 

284 f"tools: " 

285 f"{[tool.full_name for tool in self.source_tools]}") 

286 self.default_source_tool = None 

287 

288 def _get_elements_source_tool(self, element: IFCBased): 

289 """Get source_tool for specific element 

290 

291 As IfcOwnerHistory is only an optional attribute we can't rely on that 

292 ,and it's formation about the used application exists. As fallback, 

293 we use the default source tool which is selected in 

294 set_source_tool_templates. 

295 

296 Args: 

297 element: IFCBased bim2sim element 

298 """ 

299 if element.ifc.OwnerHistory: 

300 full_name = element.ifc.OwnerHistory.OwningApplication. \ 

301 ApplicationFullName 

302 for source_tool in self.source_tools: 

303 if source_tool.full_name == full_name: 

304 element.source_tool = source_tool 

305 return 

306 else: 

307 element.source_tool = self.default_source_tool 

308 

309 @staticmethod 

310 def remove_duplicate_source_tools(source_tools: list) -> list: 

311 """Removes duplicates from source_tools list. 

312 

313 Filters a list of SourceTool objects to retain only those with unique 

314 combinations of 'version', 'full_name', and 'ident' attributes. 

315 

316 Args: 

317 source_tools (list): A list of SourceTool objects to be filtered. 

318 

319 Returns: 

320 list: A new list containing SourceTool objects with unique 

321 combinations of 'version', 'full_name', and 'ident'. 

322 

323 Example: 

324 Assuming source_tools is a list of SourceTool objects, 

325 filtered_tools = filter_source_tools(source_tools) 

326 """ 

327 unique_tools = [] 

328 seen_combinations = set() 

329 

330 for tool in source_tools: 

331 tool_info = (tool.version, tool.full_name, tool.ident) 

332 

333 # Check if the combination of version, full_name, 

334 # and ident is unique 

335 if tool_info not in seen_combinations: 

336 seen_combinations.add(tool_info) 

337 unique_tools.append(tool) 

338 

339 return unique_tools 

340 

341 def reset(self): 

342 self.blacklist.clear() 

343 self.templates.clear() 

344 self.load(DEFAULT_PATH) 

345 

346 @contextlib.contextmanager 

347 def disable(self): 

348 temp = self.enabled 

349 self.enabled = False 

350 yield 

351 self.enabled = temp 

352 

353 

354class SourceTool: 

355 def __init__(self, app_ifc: entity_instance): 

356 """Represents the author software that created the IFC file. 

357 

358 Gets the basic information about the tool needed to identify which 

359 template should be searched. One IFC might have multiple SourceTools. 

360 

361 Args: 

362 app_ifc: entity_instance of IfcApplication 

363 """ 

364 self.ifc = app_ifc 

365 self.version = self.ifc.Version 

366 self.full_name = self.ifc.ApplicationFullName 

367 self.ident = self.ifc.ApplicationIdentifier 

368 self.developer = self.ifc.ApplicationDeveloper 

369 self.templ_name = None 

370 

371 def __repr__(self): 

372 return "<%s (Name: %s)>" % (self.__class__.__name__, self.full_name)