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
« 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.
3Currently, we implemented only one Finder, the TemplateFinder.
4"""
5from __future__ import annotations
7import contextlib
8import json
9import logging
10import os
11from pathlib import Path
12from typing import Generator, TYPE_CHECKING, Union
14from ifcopenshell import file, entity_instance
16import bim2sim
17from bim2sim.kernel.decision import ListDecision, Decision, DecisionBunch
18from bim2sim.elements.mapping import ifc2python
19from bim2sim.utilities.common_functions import validateJSON
21if TYPE_CHECKING:
22 from bim2sim.elements.base_elements import IFCBased
24logger = logging.getLogger(__name__)
25DEFAULT_PATH = Path(bim2sim.__file__).parent / 'assets/finder'
28class Finder:
30 def find(self, element, property_name):
31 raise NotImplementedError()
33 def reset(self):
34 """Reset finder instance"""
35 raise NotImplementedError()
38class TemplateFinder(Finder):
39 """TemplateFinder works like a multi key dictionary.
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 """
49 prefix = "template_"
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
62 def load(self, path: Union[str, Path]):
63 """Loads jsontemplates from given path.
65 Each *.json file is loaded into the templates dictionary with tool
66 name as key and converted json dictionary as value.
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
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}")
88 def save(self, path):
89 """Save templates to path, one file for each tool in templates.
91 Allows to save the created templates to path.
93 Args:
94 path: str or Path where the templates are stored.
95 """
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)
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.
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
113 def find(self, element: IFCBased, property_name: str):
114 """Tries to find the required property.
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")
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)))
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.")
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.
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.
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
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)
199 pot_names = list(map(lambda tool: tool.lower(), tool_names))
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
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))
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
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
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}')
235 def initialize(self, ifc: file):
236 """Find fitting templates for given IFC and set default source tool.
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.
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
288 def _get_elements_source_tool(self, element: IFCBased):
289 """Get source_tool for specific element
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.
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
309 @staticmethod
310 def remove_duplicate_source_tools(source_tools: list) -> list:
311 """Removes duplicates from source_tools list.
313 Filters a list of SourceTool objects to retain only those with unique
314 combinations of 'version', 'full_name', and 'ident' attributes.
316 Args:
317 source_tools (list): A list of SourceTool objects to be filtered.
319 Returns:
320 list: A new list containing SourceTool objects with unique
321 combinations of 'version', 'full_name', and 'ident'.
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()
330 for tool in source_tools:
331 tool_info = (tool.version, tool.full_name, tool.ident)
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)
339 return unique_tools
341 def reset(self):
342 self.blacklist.clear()
343 self.templates.clear()
344 self.load(DEFAULT_PATH)
346 @contextlib.contextmanager
347 def disable(self):
348 temp = self.enabled
349 self.enabled = False
350 yield
351 self.enabled = temp
354class SourceTool:
355 def __init__(self, app_ifc: entity_instance):
356 """Represents the author software that created the IFC file.
358 Gets the basic information about the tool needed to identify which
359 template should be searched. One IFC might have multiple SourceTools.
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
371 def __repr__(self):
372 return "<%s (Name: %s)>" % (self.__class__.__name__, self.full_name)