From 486eb4d9a04f3961872a3f1ad163ad72c7b3d439 Mon Sep 17 00:00:00 2001 From: ArturTomczak Date: Tue, 24 Aug 2021 12:57:44 +0200 Subject: [PATCH] Implement new IDS schema 0.4.1 (#1623) --- .../ifcopenshell-python/api-documentation.rst | 3 + src/ifcopenshell-python/ifcopenshell/ids.py | 1203 ++++++++++++----- src/ifcopenshell-python/ifcopenshell/ids.xsd | 232 ++++ .../ifcopenshell/test_ids.py | 253 +++- 4 files changed, 1341 insertions(+), 350 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/ids.xsd diff --git a/src/blenderbim/docs/ifcopenshell-python/api-documentation.rst b/src/blenderbim/docs/ifcopenshell-python/api-documentation.rst index feabcc8345..299250fb90 100644 --- a/src/blenderbim/docs/ifcopenshell-python/api-documentation.rst +++ b/src/blenderbim/docs/ifcopenshell-python/api-documentation.rst @@ -16,3 +16,6 @@ API Documentation .. automodule:: ifcopenshell.validate :members: + +.. automodule:: ifcopenshell.ids + :members: diff --git a/src/ifcopenshell-python/ifcopenshell/ids.py b/src/ifcopenshell-python/ifcopenshell/ids.py index 4b0f9f762c..769016b600 100644 --- a/src/ifcopenshell-python/ifcopenshell/ids.py +++ b/src/ifcopenshell-python/ifcopenshell/ids.py @@ -1,26 +1,407 @@ -import operator -import ifcopenshell.util.element +# IDS - Information Delivery Specification. +# Copyright (C) 2021 Artur Tomczak , Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + import re +import logging +import operator +import os +import csv +import numpy as np +from datetime import date + +import ifcopenshell.util.element +import ifcopenshell.util.placement + +from bcf.v2.bcfxml import BcfXml +from bcf.v2 import data as bcf + +from bcf import bcfxml + from xmlschema import XMLSchema +from xmlschema import XMLSchemaConverter from xmlschema import etree_tostring +from lxml import etree as ElementTree from xmlschema.validators import facets from xmlschema.validators import identities -ids_schema = XMLSchema("http://standards.buildingsmart.org/IDS/ids.xsd") - -class exception(Exception): - pass +cwd = os.path.dirname(os.path.realpath(__file__)) +ids_schema = XMLSchema(os.path.join(cwd, "ids.xsd")) # source: "http://standards.buildingsmart.org/IDS/ids_04.xsd" def error(msg): - raise exception(msg) + raise Exception(msg) + + +class ids: + """Represents the XML root node and its childNodes.""" + + def __init__( + self, + ifcversion=None, + description=None, + author=None, + copyright=None, + version=None, + creation_date=None, + purpose=None, + milestone=None, + ): + """Create an IDS object. + + :param ifcversion: IFC schema version. If None, then schema independent. Options: '2.3.0.1'|'4.0.2.1'|'4.3.0.0'|None, defaults to None + :type ifcversion: str, optional + :param description:, defaults to None + :type description: str, optional + :param author: Email of the IDS author, defaults to None + :type author: str, optional + :param copyright:, defaults to None + :type copyright: str, optional + :param version: IDS file version, defaults to None + :type version: float, optional + :param creation_date: Date in 'yyyy-mm-dd' format, defaults to current date + :type creation_date: str, optional + :param purpose:, defaults to None + :type purpose: str, optional + :param milestone:, defaults to None + :type milestone: str, optional + """ + self.specifications = [] + self.info = {} + if ifcversion: + if ifcversion in ["2.3.0.1", "4.0.2.1", "4.3.0.0"]: + self.info["ifcversion"] = ifcversion + if author: + if "@" in author: + self.info["author"] = author + if description: + self.info["description"] = description + if copyright: + self.info["copyright"] = copyright + if version: + self.info["version"] = version + if creation_date: + if re.match(r"\d\d\d\d-\d\d-\d\d", creation_date): + self.info["date"] = creation_date # date.fromisoformat(creation_date).isoformat() + if "date" not in self.info: + self.info["date"] = date.today().isoformat() + if purpose: + self.info["purpose"] = purpose + if milestone: + self.info["milestone"] = milestone + + def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ + ids_dict = { + "@xmlns": "http://standards.buildingsmart.org/IDS", + "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_04.xsd", + "specification": [], + "info": self.info, + } + for spec in self.specifications: + ids_dict["specification"].append(spec.asdict()) + return ids_dict + + def to_xml(self, filepath="./", ids_schema=ids_schema): + """Save IDS object as .xml file. + + :param filepath: Path for the new file, defaults to "./" + :type filepath: str, optional + :param ids_schema: XML Schema for an IDS file, defaults to ids_schema object from buildingSMART + :type ids_schema: XMLschema, optional + :return: Result of the newly created file validation against the schema. + :rtype: bool + """ + + if filepath.endswith("/"): + filepath = filepath + "IDS" + if not filepath.endswith(".xml"): + filepath = filepath + ".xml" + + ids_dict = self.asdict() + + ids_xml = ids_schema.encode( + ids_dict, + namespaces={ + "": "http://standards.buildingsmart.org/IDS", + "xs": "http://www.w3.org/2001/XMLSchema", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_04.xsd", + }, + ) # validation='skip', + + ids_str = etree_tostring( + ids_xml, + namespaces={ + "": "http://standards.buildingsmart.org/IDS", + # 'xs': 'http://www.w3.org/2001/XMLSchema', + # 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + # 'xsi:schemaLocation': "http://standards.buildingsmart.org/IDS/ids_04.xsd" + }, + ) + + with open(filepath, "w") as f: + f.write('\n') + f.write("\n") + f.write(ids_str) + f.close() + + # ids_schema.validate(filepath) + return ids_schema.is_valid(filepath) + + @staticmethod + def open(filepath, ids_schema=ids_schema): + """Use to open ids.xml files + + :param filepath: ids file path + :type filepath: str + :param ids_schema: XML Schema for an IDS file, defaults to ids_schema object from buildingSMART + :type ids_schema: XMLschema, optional + :return: IDS file as a python object + :rtype: ids object + """ + + ids_schema.validate(filepath) + ids_content = ids_schema.decode( + filepath, strip_namespaces=True, namespaces={"": "http://standards.buildingsmart.org/IDS"} + ) + ids_file = ids() + ids_file.specifications = [specification.parse(s) for s in ids_content["specification"]] + return ids_file + + def validate(self, ifc_file, logger=None): + """Use to validate IFC model against IDS specifications. + + :param ifc_file: path to ifc file + :type ifc_file: str + :param logger: Logging object with handlers, defaults to None + :type logger: logging, optional + """ + if not isinstance(logger, logging.Logger): + logger = logging.getLogger("IDS_Logger") + logging.basicConfig(level=logging.INFO, format="%(message)s") + logger.setLevel(logging.INFO) + + if "ifcversion" in self.info.keys(): + if self.info["ifcversion"] in ["2.3.0.1", "4.0.2.1", "4.3.0.0"]: + if self.info["ifcversion"][0:3] == "2.3": + if not ifc_file.schema.startswith("IFC2x3"): + logger.error("IFC file is of %s not of %s schema." % (ifc_file.schema, self.info["ifcversion"])) + elif self.info["ifcversion"][0:3] == "4.0": + if not ifc_file.schema == "IFC4": + logger.error("IFC file is of %s not of %s schema." % (ifc_file.schema, self.info["ifcversion"])) + elif self.info["ifcversion"][0:3] == "4.3": + if not ifc_file.schema.startswith("IFC4x3"): + logger.error("IFC file is of %s not of %s schema." % (ifc_file.schema, self.info["ifcversion"])) + else: + logger.error("IFC version not recognized") + + # Consider other way around: for elem, for spec so we can see if an element pass all IDSes? + for spec in self.specifications: + self.ifc_applicable = 0 + self.ifc_passed = 0 + for elem in ifc_file.by_type("IfcObject"): + apply, comply = spec(elem, logger) + if apply: + self.ifc_applicable += 1 + if comply: + self.ifc_passed += 1 + if self.ifc_applicable == 0: + if spec.necessity == "required": + logger.error("No applicable elements found. Minimum 1 applicable element required.") + else: + logger.debug("No applicable elements found. None required.") + + logger.debug( + "Out of %s IFC elements, %s were applicable and %s of them passed (%s)." + % ( + len(ifc_file.by_type("IfcProduct")), + self.ifc_applicable, + self.ifc_passed, + str(self.ifc_passed / self.ifc_applicable * 100) + "%", + ) + ) + for h in logger.handlers: + h.flush() + + +class specification: + """Represents the XML node and its two children and """ + + def __init__(self, name="Specification", necessity="required"): + """Create a specification to be added in ids. + + :param name:, defaults to "Specification" + :type name: str, optional + :param necessity: 'required'|'optional', defaults to "required" + :type necessity: str, optional + """ + self.name = name + self.applicability = None + self.requirements = None + self.necessity = necessity + + def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ + # if older python collections.OrderedDict() + spec_dict = { + "@name": self.name, + "@necessity": self.necessity, + "applicability": {}, + "requirements": {}, + } + for x in ["applicability", "requirements"]: + for fac in (getattr(self, x)).terms: + fclass = type(fac).__name__ + if fclass in spec_dict[x]: + spec_dict[x][fclass].append(fac.asdict()) + else: + spec_dict[x][fclass] = [fac.asdict()] + return spec_dict + + @staticmethod + def parse(ids_dict): + """Parse xml specification to python object. + + :param ids_dict: + :type ids_dict: dict + """ + + def parse_rules(dict): + facet_names = list(dict.keys()) + facet_properties = [v[0] if isinstance(v, list) else v for v in list(dict.values())] + classes = [meta_facet.facets.__getitem__(f) for f in facet_names] + facets = [cls(n) for cls, n in zip(classes, facet_properties)] + return facets + + spec = specification() + spec.name = ids_dict["@name"] + spec.necessity = ids_dict["@necessity"] + spec.applicability = boolean_and(parse_rules(ids_dict["applicability"])) + spec.requirements = boolean_and(parse_rules(ids_dict["requirements"])) + return spec + + def add_applicability(self, facet): + """Applicability specifies what conditions must be meet for an IFC object to be used for validation. Note, that at least one entity facet is required. + + :param facet: any of entity|classification|property|material + :type facet: facet + + Example:: + + i = ids.ids() + i.specifications.append(ids.specification(name="Test_Specification")) + e = ids.entity.create(name="Test_Name", predefinedtype="Test_PredefinedType") + i.specifications[0].add_applicability(e) + """ + if self.applicability: + self.applicability = boolean_and(self.applicability.terms + [facet]) + else: + self.applicability = boolean_and([facet]) + + def add_requirement(self, facet): + """Requirement is validated on all applicable IFC elements. Note, that at least one facet of any type is required. + + :param facet: any of entity|classification|property|material + :type facet: facet + """ + if self.requirements: + self.requirements = boolean_and(self.requirements.terms + [facet]) + else: + self.requirements = boolean_and([facet]) + + def __call__(self, inst, logger): + """When specification is called on an ifc instance, it validates against applicability and requirements. + + :param inst: IFC entity element + :type inst: IFC entity + :param logger: Logging object + :type logger: logging + :return: results of validation on applicability and requirements + :rtype: [bool,bool] + """ + if self.applicability(inst, logger): + + valid = self.requirements(inst, logger) + + if valid: + logger.info( + { + "guid": inst.GlobalId, + "result": valid.success, + "sentence": str(self) + + ".\n" + + inst.is_a() + + " '" + + str(inst.Name) + + "' (#" + + str(inst.id()) + + ") has " + + str(valid) + + " so is compliant", + "ifc_element": inst, + } + ) + return True, True + else: + # BUG "has does not have" + logger.error( + { + "guid": inst.GlobalId, + "result": valid.success, + "sentence": str(self) + + ".\n" + + inst.is_a() + + " '" + + str(inst.Name) + + "' (#" + + str(inst.id()) + + ") has " + + str(valid) + + " so is not compliant", + "ifc_element": inst, + } + ) + return True, False + else: + return False, False + + def __str__(self): + """Represent the specification in human readible sentence. + + :return: sentence + :rtype: str + """ + return "Given an instance with %(applicability)s\nWe expect %(requirements)s" % self.__dict__ class facet_evaluation: - """ - The evaluation of a facet with data from IFC. Converts to bool and has a human readable string format. - """ + """The evaluation of a facet with data from IFC. Converts to bool and has a human readable string format.""" def __init__(self, success, str): self.success = success @@ -34,9 +415,7 @@ class facet_evaluation: class meta_facet(type): - """ - A metaclass for automatically registering facets in a map to be instantiated based on XML tagnames. - """ + """A metaclass for automatically registering facets in a map to be instantiated based on XML tagnames.""" facets = {} @@ -51,27 +430,34 @@ class facet(metaclass=meta_facet): The base class for IDS facets. IDS facets are functors constructed from XML nodes that return True or False. A getattr method is provided for conveniently extracting XML child node text content. - """ + Use child classes instead: entity, classification, property and material. + """ def __init__(self, node=None, location=None): if node: self.node = node - if '@location' in self: - self.location = self.node['@location'] + if "@location" in self: + self.location = self.node["@location"] else: - self.location = 'any' + self.location = "any" if location: self.location = location else: - self.location = 'any' + self.location = "any" def __getattr__(self, k): if k in self.node: v = self.node[k] - if isinstance(v, dict): #is restriction? - return restriction(v['xs:restriction'][0]) + # BUG list of dictionaries should not happen + if isinstance(v, list): + v = v[0] + if "simpleValue" in list(v): + return v["simpleValue"] + elif "restriction" in list(v): + return restriction.parse(v["restriction"][0]) + # TODO handle more than one restriction: return [restriction(r) for r in v["restriction"]] else: - return v + raise Exception("Unknown value declaration.") else: return None @@ -88,38 +474,59 @@ class facet(metaclass=meta_facet): class entity(facet): - """ - The IDS entity facet currently *with* inheritance - """ + """The IDS entity facet currently *with* inheritance""" parameters = ["name", "predefinedtype"] - + + @staticmethod def create(name=None, predefinedtype=None): + """Create an entity facet that can be added to applicability or requirements of IDS specification. + + :param name: IFC entity name that is required. e.g. IfcWall, defaults to None + :type name: str, optional + :param predefinedtype: name of the predefined type, defaults to None + :type predefinedtype: str, optional + :return: entity object + :rtype: entity + """ + inst = entity() inst.name = name inst.predefinedtype = predefinedtype return inst def asdict(self): - fac_dict = {'name': self.name} - if 'predefinedtype' in self: - fac_dict['predefinedtype'] = self.predefinedtype + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ + fac_dict = {"name": parameter_asdict(self.name)} + if "predefinedtype" in self: + fac_dict["predefinedtype"] = parameter_asdict(self.predefinedtype) return fac_dict def __call__(self, inst, logger): + """Validate an ifc instance against that entity facet. + + :param inst: IFC entity element + :type inst: IFC entity + :param logger: Logging object + :type logger: logging + :return: result of the validation as bool and message + :rtype: facet_evaluation(bool, str) + """ + # @nb with inheritance if self.predefinedtype and hasattr(inst, "PredefinedType"): self.message = "an entity name '%(name)s' of predefined type '%(predefinedtype)s'" return facet_evaluation( inst.is_a(self.name) and inst.PredefinedType == self.predefinedtype, - self.message % {"name": inst.is_a(), "predefinedtype": inst.PredefinedType} - ) + self.message % {"name": inst.is_a(), "predefinedtype": inst.PredefinedType}, + ) else: self.message = "an entity name '%(name)s'" - return facet_evaluation( - inst.is_a(self.name), - self.message % {"name": inst.is_a()} - ) + return facet_evaluation(inst.is_a(self.name), self.message % {"name": inst.is_a()}) class classification(facet): @@ -130,7 +537,20 @@ class classification(facet): parameters = ["system", "value", "location"] message = "%(location)sclassification reference %(value)s from '%(system)s'" - def create(location='any', value=None, system=None): + @staticmethod + def create(location="any", value=None, system=None): + """Create a classification facet that can be added to applicability or requirements of IDS specification. + + :param location: Define where to check for the parameter. One of "any"|"instance"|"type", defaults to "any" + :type location: str, optional + :param value: Value that is required. Could be alphanumeric or restriction object, defaults to None + :type value: restriction|alphanumeric, optional + :param system: System that is required. Could be alphanumeric or restriction object, defaults to None + :type system: restriction|alphanumeric, optional + :return: classification object + :rtype: classification + """ + inst = classification() inst.location = location inst.value = value @@ -138,26 +558,41 @@ class classification(facet): return inst def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ fac_dict = { - '@location': self.location, - 'value': self.value, - 'system': self.system - } + "value": parameter_asdict(self.value), + "system": parameter_asdict(self.system), + "@location": self.location, + # "instructions": "SAMPLE_INSTRUCTIONS", + } return fac_dict def __call__(self, inst, logger): - + """Validate an ifc instance against that classification facet. + + :param inst: IFC entity element + :type inst: IFC entity + :param logger: Logging object + :type logger: logging + :return: result of the validation as bool and message + :rtype: facet_evaluation(bool, str) + """ + instance_classiciations = inst.HasAssociations if ifcopenshell.util.element.get_type(inst): type_classifications = ifcopenshell.util.element.get_type(inst).HasAssociations else: type_classifications = () - if self.location == 'instance' and instance_classiciations: + if self.location == "instance" and instance_classiciations: associations = instance_classiciations - elif self.location == 'type' and type_classifications: + elif self.location == "type" and type_classifications: associations = type_classifications - elif self.location == 'any' and (instance_classiciations or type_classifications): + elif self.location == "any" and (instance_classiciations or type_classifications): associations = instance_classiciations + type_classifications else: associations = () @@ -166,23 +601,25 @@ class classification(facet): for association in associations: if association.is_a("IfcRelAssociatesClassification"): cref = association.RelatingClassification - if hasattr(cref, 'ItemReference'): #IFC2x3 + if hasattr(cref, "ItemReference"): # IFC2x3 refs.append((cref.ReferencedSource.Name, cref.ItemReference)) - elif hasattr(cref, 'Identification'): # IFC4 - refs.append((cref.ReferencedSource.Name, cref.Identification)) + elif hasattr(cref, "Identification"): # IFC4 + refs.append((cref.ReferencedSource.Name, cref.Identification)) self.location_msg = location[self.location] if refs: return facet_evaluation( (self.system, self.value) in refs, - self.message % {"system": refs[0][0], "value": "'"+refs[0][1]+"'", "location": self.location_msg} # what if not first item of refs? - ) - else: - return facet_evaluation( - False, - "does not have %sclassification reference" % self.location_msg + self.message + % { + "system": refs[0][0], + "value": "'" + refs[0][1] + "'", + "location": self.location_msg, + }, # what if not first item of refs? ) + else: + return facet_evaluation(False, "does not have %sclassification reference" % self.location_msg) class property(facet): @@ -192,8 +629,22 @@ class property(facet): parameters = ["name", "propertyset", "value", "location"] message = "%(location)sproperty '%(name)s' in '%(propertyset)s' with a value %(value)s" - - def create(location='any', propertyset=None, name=None, value=None): + + @staticmethod + def create(location="any", propertyset=None, name=None, value=None): + """Create a property facet that can be added to applicability or requirements of IDS specification. + + :param location: Define where to check for the parameter. One of "any"|"instance"|"type", defaults to "any" + :type location: str, optional + :param propertyset: Propertyset that is required. Could be alphanumeric or restriction object, defaults to None + :type propertyset: restriction|alphanumeric, optional + :param name: Name that is required. Could be alphanumeric or restriction object, defaults to None + :type name: restriction|alphanumeric, optional + :param value: Value that is required. Could be alphanumeric or restriction object, defaults to None + :type value: restriction|alphanumeric, optional + :return: property object + :rtype: property + """ inst = property() inst.location = location inst.propertyset = propertyset @@ -205,46 +656,56 @@ class property(facet): return inst def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ fac_dict = { - '@location': self.location, - 'propertyset': self.propertyset, - 'name': self.name, - 'value': self.value, + "@location": self.location, + "propertyset": parameter_asdict(self.propertyset), + "name": parameter_asdict(self.name), + "value": parameter_asdict(self.value), + # "instructions": "SAMPLE_INSTRUCTIONS", # TODO '@href': 'http://identifier.buildingsmart.org/uri/buildingsmart/ifc-4.3/prop/FireRating', #https://identifier.buildingsmart.org/uri/something - # TODO 'instructions': 'Please add the desired rating.' - } + } return fac_dict - def __call__(self, inst, logger): + """Validate an ifc instance against that property facet. - self.location = self.node['@location'] + :param inst: IFC entity element + :type inst: IFC entity + :param logger: Logging object + :type logger: logging + :return: result of the validation as bool and message + :rtype: facet_evaluation(bool, str) + """ + self.location = self.node["@location"] + + # TODO sometimes AttributeError: 'str' object has no attribute 'wrappedValue' instance_props = ifcopenshell.util.element.get_psets(inst) + if ifcopenshell.util.element.get_type(inst): - type_props = ifcopenshell.util.element.get_psets( ifcopenshell.util.element.get_type(inst) ) + type_props = ifcopenshell.util.element.get_psets(ifcopenshell.util.element.get_type(inst)) else: type_props = {} - if self.location == 'instance': + if self.location == "instance": props = instance_props - elif self.location == 'type' and type_props: + elif self.location == "type" and type_props: props = type_props - elif self.location == 'any' and (instance_props or type_props): - props = {**instance_props , **type_props} + elif self.location == "any" and (instance_props or type_props): + props = {**instance_props, **type_props} else: props = {} - + pset = props.get(self.propertyset) val = pset.get(self.name) if pset else None - + self.location_msg = location[self.location] - di = { - "name": self.name, - "propertyset": self.propertyset, - "value": "'%s'" % val, - "location": self.location_msg - } + di = {"name": self.name, "propertyset": self.propertyset, "value": "'%s'" % val, "location": self.location_msg} if val is not None: msg = self.message % di @@ -254,55 +715,88 @@ class property(facet): else: msg = "does not have %(location)sset '%(propertyset)s'" % di - #TODO implement data type comparison - return facet_evaluation( - val == self.value, - msg - ) + # TODO implement data type comparison + # xs:string + # xs:decimal + # xs:integer + # xs:boolean + # xs:anyURI + # xs:date YYYY-MM-DD + # xs:time hh:mm:ss + # xs:dateTime YYYY-MM-DDThh:mm:ss + # xs:duration PnYnMnDTnHnMnS + + return facet_evaluation(val == self.value, msg) class material(facet): - """ - The IDS material facet by traversing the HasAssociations inverse attribute - """ + """The IDS material facet used to traverse the HasAssociations inverse attribute.""" + parameters = ["value", "location"] message = "%(location)smaterial '%(value)s'" - - def create(location='any', value=None): + + @staticmethod + def create(location="any", value=None): + """Create a material facet that can be added to applicability or requirements of IDS specification. + + :param location: Define where to check for the parameter. One of "any"|"instance"|"type", defaults to "any" + :type location: str, optional + :param value: Value that is required. Could be alphanumeric or restriction object, defaults to None + :type value: restriction|alphanumeric, optional + :return: material object + :rtype: material + """ inst = material() inst.location = location inst.value = value - # self.attributes = {'@location': location} # 'type', 'instance', 'any' - # # BUG '@use': 'optional' - # # BUG '@href': 'https://identifier.buildingsmart.org/uri/something', - # # BUG 'instructions': 'Please add the desired...', + # TODO '@use': 'optional' + # TODO '@href': 'https://identifier.buildingsmart.org/uri/something', + # TODO 'instructions': 'Please add the desired...', return inst def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ fac_dict = { - '@location': self.location, - 'value': self.value, + "value": parameter_asdict(self.value), + "@location": self.location, + # TODO "instructions": "SAMPLE_INSTRUCTIONS", # TODO '@href': 'http://identifier.buildingsmart.org/uri/buildingsmart/ifc-4.3/prop/FireRating', #https://identifier.buildingsmart.org/uri/something - # TODO 'instructions': 'Please add the desired rating.' # TODO '@use': 'optional' - } + } return fac_dict def __call__(self, inst, logger): + """Validate an ifc instance against that material facet. - self.location = self.node['@location'] + :param inst: IFC entity element + :type inst: IFC entity + :param logger: Logging object + :type logger: logging + :return: result of the validation as bool and message + :rtype: facet_evaluation(bool, str) + """ + + self.location = self.node["@location"] instance_material_rel = [rel for rel in inst.HasAssociations if rel.is_a("IfcRelAssociatesMaterial")] if ifcopenshell.util.element.get_type(inst): - type_material_rel = [rel for rel in ifcopenshell.util.element.get_type(inst).HasAssociations if rel.is_a("IfcRelAssociatesMaterial")] + type_material_rel = [ + rel + for rel in ifcopenshell.util.element.get_type(inst).HasAssociations + if rel.is_a("IfcRelAssociatesMaterial") + ] else: type_material_rel = [] - if self.location == 'instance': + if self.location == "instance": material_relations = list(instance_material_rel) - elif self.location == 'type' and type_material_rel: + elif self.location == "type" and type_material_rel: material_relations = list(type_material_rel) - elif self.location == 'any' and (instance_material_rel or type_material_rel): + elif self.location == "any" and (instance_material_rel or type_material_rel): material_relations = instance_material_rel + type_material_rel else: material_relations = [] @@ -311,7 +805,7 @@ class material(facet): for rel in material_relations: if rel.RelatingMaterial.is_a() == "IfcMaterial": materials.append(rel.RelatingMaterial.Name) - elif rel.RelatingMaterial.is_a() == "IfcMaterialMaterialList": #DEPRECATED in IFC4 + elif rel.RelatingMaterial.is_a() == "IfcMaterialMaterialList": # DEPRECATED in IFC4 [materials.append(mat.Name) for mat in rel.RelatingMaterial] elif rel.RelatingMaterial.is_a() == "IfcMaterialConstituentSet": [materials.append(mat.Material.Name) for mat in rel.RelatingMaterial.MaterialConstituents] @@ -326,10 +820,10 @@ class material(facet): profileSets = rel.RelatingMaterial.ForProfileSet.MaterialProfiles [materials.append(pset.Material.Name) for pset in profileSets] else: - logger.error({'guid':inst.GlobalId, 'result':'ERROR', 'sentence':'IfcRelAssociatesMaterial not implemented'}) + raise Exception("IfcRelAssociatesMaterial not implemented") if not materials: - materials.append('UNDEFINED') + materials.append("UNDEFINED") self.location_msg = location[self.location] @@ -339,10 +833,27 @@ class material(facet): ) +def parameter_asdict(parameter): + """Converts parameter to an IDS compliant dictionary, handling both value and restrictions. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ + if isinstance(parameter, str): + parameter_dict = {"simpleValue": parameter} + elif isinstance(parameter, restriction): + parameter_dict = {"xs:restriction": [parameter.asdict()]} + elif isinstance(parameter, list): + restrictions = {"@base": "xs:" + parameter[0].base} + for p in parameter: + x = p.asdict() + restrictions[list(x)[1]] = x[list(x)[1]] + parameter_dict = {"xs:restriction": [restrictions]} + return parameter_dict + + class boolean_logic: - """ - Boolean conjunction over a collection of functions - """ + """Boolean conjunction over a collection of functions""" def __init__(self, terms): self.terms = terms @@ -350,10 +861,7 @@ class boolean_logic: def __call__(self, *args): eval = [t(*args) for t in self.terms] join = [" and ", " or "][self.fold == any] - return facet_evaluation( - self.fold(eval), - join.join(map(str, eval)) - ) + return facet_evaluation(self.fold(eval), join.join(map(str, eval))) def __str__(self): return [" and ", " or "][self.fold == any].join(map(str, self.terms)) @@ -372,267 +880,330 @@ class restriction: The value restriction from XSD implemented as a list of values and a containment test """ - def __init__(self, node): - - self.restriction_on = node['@base'][3:] + def __init__(self): + """Create a restriction that can be used instead of value of a parameter.""" self.type = "" self.options = [] - for n in node: - if n[0:3] == "xs:": - if n[3:] == "enumeration": - self.type = "enumeration" - for x in node[n]: - self.options.append(x["@value"]) - elif n[8:] == "clusive": - self.type = "bounds" - if n[3:6] == 'min': - self.options.insert(0,'>') - else: - self.options.insert(0,'<') - if n[6:9] == 'Inc': - self.options[0] += '=' - self.options[0] += node[n]['@value'] + @staticmethod + def parse(ids_dict): + """Parse xml restriction to python object. + + :param ids_dict: + :type ids_dict: dict + """ + r = restriction() + if ids_dict: + # TODO 'base' missing in some IDS?! + r.base = ids_dict["@base"][3:] + for n in ids_dict: + if n == "enumeration": + r.type = "enumeration" + for x in ids_dict[n]: + r.options.append(x["@value"]) + elif n[-7:] == "clusive": + r.type = "bounds" + r.options.append({n: ids_dict[n]["@value"]}) elif n[-5:] == "ength": - self.type = "length" + r.type = "length" if n[3:6] == "min": - self.options.append('>=') + r.options.append(">=") elif n[3:6] == "max": - self.options.append('<=') + r.options.append("<=") else: - self.options.append('==') - self.options[-1] += str(node[n]['@value']) - elif n[3:] == "pattern": - self.type = "pattern" - self.options.append(node[n]['@value']) - #TODO add fractionDigits - #TODO add totalDigits - #TODO add whiteSpace + self.options.append("==") + r.options[-1] += str(ids_dict[n]["@value"]) + elif n == "pattern": + r.type = "pattern" + r.options.append(ids_dict[n]["@value"]) + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace + elif n == "@base": + pass else: - logger.error({'result':'ERROR', 'sentence':'Restriction not implemented'}) + print("Error! Restriction not implemented") + return r + + def asdict(self): + """Converts object to a dictionary, adding required attributes. + + :return: Xmlschema compliant dictionary. + :rtype: dict + """ + rest_dict = {"@base": "xs:" + self.base} + if self.type == "enumeration": + for option in self.options: + if "xs:enumeration" not in rest_dict: + rest_dict["xs:enumeration"] = [{"@value": option}] + else: + rest_dict["xs:enumeration"].append({"@value": option}) + elif self.type == "bounds": + for option in self.options: + if "xs:option" not in rest_dict: + rest_dict["xs:" + option] = [{"@value": option}] + else: + rest_dict["xs:" + option].append({"@value": self.options[option], "@fixed": False}) + elif self.type == "pattern": + if "xs:pattern" not in rest_dict: + rest_dict["xs:pattern"] = [{"@value": self.options}] + else: + rest_dict["xs:pattern"].append({"@value": self.options}) + return rest_dict + + @staticmethod + def create(options, type="pattern", base="string"): + """Create restriction instead of simpleValue + + :param type: One of "enumeration"|"pattern"|"bounds", defaults to "pattern" + :type type: str, optional + :param options: if enumeration: list of possible values + if pattern: xml regular expression string + if bounds: dictionary with possible keys: 'minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive' + :type options: list|str|dict + :param base: One of "string"|"boolean"|"decimal"|"integer", defaults to "string" + :type base: str, optional + :raises Exception: If not properly defined restriction. + :return: restriction object + :rtype: restriction + """ + rest = restriction() + if type in ["enumeration", "pattern", "bounds"]: + rest.type = type + rest.base = base + rest.options = options + if ( + (type == "enumeration" and isinstance(options, list)) + or (type == "bounds" and isinstance(options, dict)) + or (type == "pattern" and isinstance(options, str)) + ): + rest.options = options + else: + Exception("Options were not properly defined.") + return rest + else: + raise Exception( + "Such restriction not implemented. Try: 'enumeration', 'pattern' or 'min/maxInclusive' or 'min/maxExclusive'." + ) def __eq__(self, other): - result=False - #TODO implement data type comparison - if self and other: - if self.type == "enumeration" and self.restriction_on == 'bool': + """Evaluate the restriction using equality sign. + + :param other: value to compare with the restriction. + :type other: str|float|int + :return: True if 'other' match the restriction, False if not. + :rtype: bool + """ + result = False + # TODO implement data type comparison + if self and (other or other == 0): + if self.type == "enumeration" and self.base == "bool": self.options = [x.lower() for x in self.options] result = str(other).lower() in self.options elif self.type == "enumeration": result = other in self.options elif self.type == "bounds": - for op in self.options: - if eval(str(other)+op): #TODO eval not safe? - result = True + result = True + for sign in self.options.keys(): + if sign == "minInclusive" and other < self.options[sign]: + result = False + elif sign == "maxInclusive" and other > self.options[sign]: + result = False + elif sign == "minExclusive" and other <= self.options[sign]: + result = False + elif sign == "maxExclusive" and other >= self.options[sign]: + result = False elif self.type == "length": for op in self.options: - if eval(str(len(other))+op): #TODO eval not safe? + if eval(str(len(other)) + op): # TODO eval not safe? result = True elif self.type == "pattern": - self.options - translated_pattern = identities.translate_pattern(r'[A-Z]{1,3}') # Between one and three capital letters + translated_pattern = identities.translate_pattern(self.options) regex_pattern = re.compile(translated_pattern) if regex_pattern.fullmatch(other) is not None: result = True - #TODO add fractionDigits - #TODO add totalDigits - #TODO add whiteSpace + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace return result def __repr__(self): + """Represent the restriction in human readible sentence. + + :return: sentence + :rtype: str + """ + msg = "of type '%s', " % (self.base) if self.type == "enumeration": - return "'%s'" % "' or '".join(self.options) + msg = msg + "of value: '%s'" % "' or '".join(self.options) elif self.type == "bounds": - self.options.sort() - return "of type '%s', having a value %s" % (self.restriction_on, ' and '.join(self.options)) + msg = msg + "of value %s" % ", and ".join([bounds[x] + str(self.options[x]) for x in self.options]) elif self.type == "length": - return "of type '%s' with %s letters" % (self.restriction_on, ' and '.join(self.options)) + msg = msg + "with %s letters" % " and ".join(self.options) elif self.type == "pattern": - return "of type '%s' respecting pattern '%s'" % (self.restriction_on, ' and '.join(self.options)) - #TODO add fractionDigits - #TODO add totalDigits - #TODO add whiteSpace + msg = msg + "respecting the pattern '%s'" % self.options + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace + return msg -class specification: - """ - Represents the XML node and its two children and - """ +class SimpleHandler(logging.StreamHandler): + """Logging handler listing all cases in python list.""" - def __init__(self, name='Specification'): - self.name = name - self.applicability = None - self.requirements = None + def __init__(self, report_valid=False): + """Logging handler listing all cases in python list. - def asdict(self): - spec_dict = { - '@name': self.name, - 'applicability': {}, - 'requirements': {} - } - for fac in self.applicability.terms: - fclass = type(fac).__name__ - if fclass in spec_dict['applicability']: - spec_dict['applicability'][fclass].append(fac.asdict()) - else: - spec_dict['applicability'][fclass] = [fac.asdict()] - for fac in self.requirements.terms: - fclass = type(fac).__name__ - if fclass in spec_dict['requirements']: - spec_dict['requirements'][fclass].append(fac.asdict()) - else: - spec_dict['requirements'][fclass] = [fac.asdict()] - return spec_dict - - @staticmethod - def parse(node): - def parse_rules(node): - names = [req for req in node for n in node[req]] - children = [child for req in node for child in node[req]] - classes = map(meta_facet.facets.__getitem__, names) - # return [cls.parse(n) for cls, n in zip(classes, children)] - return [cls(n) for cls, n in zip(classes, children)] # list of facet objects - - spec = specification() - spec.name = node['@name'] - spec.applicability = boolean_and(parse_rules(node['applicability'])) - spec.requirements = boolean_and(parse_rules(node['requirements'])) - return spec - - # TODO adding applicability/requirements to specification. How to avoid repetitions? - def add_applicability(self, facet): + :param report_valid: True if you want to list all the compliant cases as well, defaults to False + :type report_valid: bool, optional """ - Applicability specifies what conditions must be meet for an IFC object to be used for validation. - Takes: entity, classification, property or material objects as an input (at least one entity is required). - """ - if self.applicability: - self.applicability = boolean_and( self.applicability.terms + [facet] ) + logging.StreamHandler.__init__(self) + self.statements = [] + if report_valid: + self.setLevel(logging.INFO) else: - self.applicability = boolean_and([facet]) - - def add_requirement(self, facet): + self.setLevel(logging.ERROR) + + def emit(self, mymsg): + """Triggered on each use of logging with the Simple handler enabled. + + :param log_content: default logger message + :type log_content: string|dict """ - Requirement is validated on all applicable IFC elements. - Takes: entity, classification, property or material objects as an input (at least one of them is required). - """ - if self.requirements: - self.requirements = boolean_and( self.requirements.terms + [facet] ) - else: - self.requirements = boolean_and([facet]) - - def __call__(self, inst, logger): - if self.applicability(inst, logger): - - valid = self.requirements(inst, logger) - - if valid: - logger.info({'guid':inst.GlobalId, 'result':valid.success,'sentence':str(self) + ".\n" + inst.is_a() + " '" + str(inst.Name) + "' (#" + str(inst.id()) + ") has " + str(valid) + " so is compliant"}) - return True, True - else: - # BUG "has does not have" - logger.error({'guid':inst.GlobalId, 'result':valid.success, 'sentence':str(self) + ".\n" + inst.is_a() + " '" + str(inst.Name) + "' (#" + str(inst.id()) + ") has " + str(valid) + " so is not compliant"}) - return True, False - else: - return False, False - - def __str__(self): - return "Given an instance with %(applicability)s\nWe expect %(requirements)s" % self.__dict__ + self.statements.append(mymsg.msg) -class ids: - """ - Represents the XML root node and its childNodes. +class BcfHandler(logging.StreamHandler): + """Logging handler for creation of BCF report files. + + :param project_name: defaults to "IDS Project" + :type project_name: str, optional + :param author: Email of the person creating the BCF report, defaults to "your@email.com" + :type author: str, optional + :param filepath: Path to save the BCF report, defaults to None + :type filepath: str, optional + :param report_valid: True if you want to list all the compliant cases as well, defaults to False + :type report_valid: bool, optional + + Example:: + + bcf_handler = BcfHandler( + project_name="Default IDS Project", + author="your@email.com", + filepath=r".\example.bcfzip", + ) + logger = logging.getLogger("IDS_Logger") + logging.basicConfig(level=logging.INFO, format="%(message)s") + logger.addHandler(bcf_handler) """ - def __init__(self): - self.specifications = [] - self.info = None - #self.attributes = { - # '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', - # '@xmlns': 'http://standards.buildingsmart.org/IDS', - # '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - # '@xsi:schemaLocation': 'http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/ids.xsd', - # } + def __init__(self, project_name="IDS Project", author="your@email.com", filepath=None, report_valid=False): - def asdict(self): - ids_dict = {'@xmlns': 'http://standards.buildingsmart.org/IDS', - '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:schemaLocation': 'http://standards.buildingsmart.org/IDS ' - 'http://standards.buildingsmart.org/IDS/ids.xsd', - 'specification': [], - 'info': self.info, - } - for spec in self.specifications: - ids_dict['specification'].append(spec.asdict()) - return ids_dict + logging.StreamHandler.__init__(self) + if report_valid: + self.setLevel(logging.INFO) + else: + self.setLevel(logging.ERROR) + self.bcf = BcfXml() + self.bcf.author = author + self.bcf.new_project() + self.bcf.project.name = project_name + self.filepath = filepath + self.bcf.edit_project() - def to_xml(self, fn='./', ids_schema=ids_schema): - if fn.endswith('/'): - fn = fn + 'IDS' - if not fn.endswith('.xml'): - fn = fn + '.xml' + def emit(self, log_content): + """Triggered on each use of logging with the BCF handler enabled. - ids_dict = self.asdict() + :param log_content: default logger message + :type log_content: string|dict + """ + topic = bcf.Topic() + topic.title = log_content.msg["sentence"].split(".\n")[1] + topic.description = log_content.msg["sentence"].split(".\n")[0] + self.bcf.add_topic(topic) + try: # Add viewpoint and link to ifc object + viewpoint = bcf.Viewpoint() + viewpoint.perspective_camera = bcf.PerspectiveCamera() + ifc_elem = log_content.msg["ifc_element"] + # ifc_elem = ifc_file.by_guid(log_content.msg["guid"]) + target_position = np.array(ifcopenshell.util.placement.get_local_placement(ifc_elem.ObjectPlacement)) + target_position = target_position[:, 3][0:3] + camera_position = target_position + np.array((5, 5, 5)) + viewpoint.perspective_camera.camera_view_point.x = camera_position[0] + viewpoint.perspective_camera.camera_view_point.y = camera_position[1] + viewpoint.perspective_camera.camera_view_point.z = camera_position[2] + camera_direction = camera_position - target_position + camera_direction = camera_direction / np.linalg.norm(camera_direction) + camera_right = np.cross(np.array([0.0, 0.0, 1.0]), camera_direction) + camera_right = camera_right / np.linalg.norm(camera_right) + camera_up = np.cross(camera_direction, camera_right) + camera_up = camera_up / np.linalg.norm(camera_up) + rotation_transform = np.zeros((4, 4)) + rotation_transform[0, :3] = camera_right + rotation_transform[1, :3] = camera_up + rotation_transform[2, :3] = camera_direction + rotation_transform[-1, -1] = 1 + translation_transform = np.eye(4) + translation_transform[:3, -1] = -camera_position + look_at_transform = np.matmul(rotation_transform, translation_transform) + mat = np.linalg.inv(look_at_transform) + viewpoint.perspective_camera.camera_direction.x = mat[0][2] * -1 + viewpoint.perspective_camera.camera_direction.y = mat[1][2] * -1 + viewpoint.perspective_camera.camera_direction.z = mat[2][2] * -1 + viewpoint.perspective_camera.camera_up_vector.x = mat[0][1] + viewpoint.perspective_camera.camera_up_vector.y = mat[1][1] + viewpoint.perspective_camera.camera_up_vector.z = mat[2][1] + viewpoint.components = bcf.Components() + c = bcf.Component() + c.ifc_guid = log_content.msg["guid"] + viewpoint.components.selection.append(c) + viewpoint.components.visibility = bcf.ComponentVisibility() + viewpoint.components.visibility.default_visibility = True + viewpoint.snapshot = None + self.bcf.add_viewpoint(topic, viewpoint) + except: + pass - ids_xml = ids_schema.encode(ids_dict) #, namespaces='http://standards.buildingsmart.org/IDS') - ids_str = etree_tostring(ids_xml, namespaces={'': 'http://standards.buildingsmart.org/IDS'}) # if restrictions, add also: 'xs': 'http://www.w3.org/2001/XMLSchema' - ids_schema.validate(ids_str) - - with open(fn, 'w') as f: - f.write('\n') - f.write('\n') - f.write(ids_str) - f.close() - - ids_schema.validate(fn) - return ids_schema.is_valid(fn) - - @staticmethod - def parse(fn, ids_schema=ids_schema): - ids_schema.validate(fn) - ids_content = ids_schema.decode(fn) - new_ids = ids() - new_ids.specifications = [specification.parse(s) for s in ids_content['specification']] - return new_ids - - - def validate(self, ifc_file, logger): - self.ifc_checked = 0 - self.ifc_passed = 0 - for spec in self.specifications: - for elem in ifc_file.by_type("IfcObject"): - apply, comply = spec(elem, logger) - if apply: self.ifc_checked += 1 - if comply: self.ifc_passed += 1 + def flush(self): + """Saves the BCF report to file. Triggered at the end of the validation process.""" + if not self.filepath: + self.filepath = os.getcwd() + r"\IDS_report.bcfzip" + if not (self.filepath.endswith(".bcf") or self.filepath.endswith(".bcfzip")): + self.filepath = self.filepath + r"\IDS_report.bcfzip" + self.bcf.save_project(self.filepath) -location = { - 'instance': 'an instance ', - 'type': 'a type ', - 'any': 'a ' +location = {"instance": "an instance ", "type": "a type ", "any": "a "} + +bounds = { + "minInclusive": "larger or equal ", + "maxInclusive": "smaller or equal ", + "minExclusive": "larger than ", + "maxExclusive": "smaller than ", } - if __name__ == "__main__": - import time - start_time = time.time() import sys, os - import logging import ifcopenshell - from datetime import date - - filename = os.path.join(os.getcwd(), str(date.today())+"_ids_result.txt") - - logger = logging.getLogger("IDS") - logging.basicConfig(filename=filename, level=logging.INFO, format="%(message)s") - logging.FileHandler(filename, mode='w') + ids_file = ids.open(sys.argv[1]) ifc_file = ifcopenshell.open(sys.argv[2]) - ids_file = ids.parse(sys.argv[1]) + filepath = sys.argv[3] + + logger = logging.getLogger("IDS_Logger") + logging.basicConfig(filename=filepath, level=logging.INFO, format="%(message)s") + logging.FileHandler(filepath + r"\report.txt", mode="w") + + bcf_handler = BcfHandler( + project_name="Default IDS Project", + author="your@email.com", + filepath=filepath + r"\report.bcfzip", + ) + logger.addHandler(bcf_handler) + + report = SimpleHandler() + logger.addHandler(report) + ids_file.validate(ifc_file, logger) - - print("Out of %s IFC elements, %s were checked against %s requirements in %s specification(s) and %s of them passed (%s).\nRuntime=%ss. Results saved to %s" - % (len(ifc_file.by_type('IfcProduct')), ids_file.ifc_checked, len(ids_file.specifications[0].requirements.terms), len(ids_file.specifications), ids_file.ifc_passed, str(ids_file.ifc_passed/ids_file.ifc_checked*100)+'%', round(time.time() - start_time, 2), filename)) diff --git a/src/ifcopenshell-python/ifcopenshell/ids.xsd b/src/ifcopenshell-python/ifcopenshell/ids.xsd new file mode 100644 index 0000000000..5d8ba0a295 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/ids.xsd @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Make sure 'Name' value of requirements entity is the same as the 'applicability' node, or a wildcard (inclusive pattern). + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ifcopenshell-python/ifcopenshell/test_ids.py b/src/ifcopenshell-python/ifcopenshell/test_ids.py index bec6587600..2684e32937 100644 --- a/src/ifcopenshell-python/ifcopenshell/test_ids.py +++ b/src/ifcopenshell-python/ifcopenshell/test_ids.py @@ -1,8 +1,16 @@ import unittest import ids + +# from ids import ids import requests import os +import logging + # from xmlschema.validators.exceptions import XMLSchemaChildrenValidationError +import ifcopenshell +from bcf import bcfxml + +import tempfile def read_web_file(URL): @@ -11,62 +19,104 @@ def read_web_file(URL): class TestIdsParsing(unittest.TestCase): - def test_basic_ids_parse(self): + """Parsing basic IDS files""" + + def test_parse_basic_ids(self): IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_all_fields.xml" - ids_file = ids.ids.parse(read_web_file(IDS_URL)) + ids_file = ids.ids.open(read_web_file(IDS_URL)) self.assertEqual(type(ids_file).__name__, "ids") - def test_entity_facet(self): + def test_parse_entity_facet(self): IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_entity.xml" - ids_file = ids.ids.parse(read_web_file(IDS_URL)) - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"], "IfcWall") + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]["simpleValue"], "IfcWall") - def test_predefinedtype_facet(self): + def test_parse_predefinedtype_facet(self): IDS_URL = ( "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_predefinedtype.xml" ) - ids_file = ids.ids.parse(read_web_file(IDS_URL)) - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["predefinedtype"], "CLADDING") + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual( + ids_file.specifications[0].requirements.terms[0].node["predefinedtype"]["simpleValue"], "CLADDING" + ) - def test_property_facet(self): + def test_parse_property_facet(self): IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property.xml" - ids_file = ids.ids.parse(read_web_file(IDS_URL)) - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["propertyset"], "Test_PropertySet") - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"], "Test_Parameter") - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"], "Test_Value") + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual( + ids_file.specifications[0].requirements.terms[0].node["propertyset"]["simpleValue"], "Test_PropertySet" + ) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"]["simpleValue"], "Test_Value") - def test_material_facet(self): + def test_parse_material_facet(self): IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_material.xml" - ids_file = ids.ids.parse(read_web_file(IDS_URL)) - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"], "Test_Material") + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"]["simpleValue"], "Test_Material") - def test_classification_facet(self): + def test_parse_classification_facet(self): IDS_URL = ( "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_classification.xml" ) - ids_file = ids.ids.parse(read_web_file(IDS_URL)) - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"], "Test_Classification") - self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["system"], "Test_System") + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual( + ids_file.specifications[0].requirements.terms[0].node["value"]["simpleValue"], "Test_Classification" + ) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["system"]["simpleValue"], "Test_System") """ Parsing invalid IDS.xml """ # TODO # def test_invalid_classification_facet(self): # IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/Invalid_IDS_Wall_needs_classification.xml" - # self.assertRaises( XMLSchemaChildrenValidationError, ids.parse(read_web_file(IDS_URL)) ) + # self.assertRaises( XMLSchemaChildrenValidationError, ids.open(read_web_file(IDS_URL)) ) """ Saving parsed IDS to IDS.xml """ def test_parsed_ids_to_xml(self): IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_all_fields.xml" - ids_file = ids.ids.parse(read_web_file(IDS_URL)) + ids_file = ids.ids.open(read_web_file(IDS_URL)) fn = "TEST_FILE.xml" result = ids_file.to_xml(fn) os.remove(fn) self.assertTrue(result) + """ Parsing IDS files with restrictions """ + + def test_parse_restrictions_enumeration(self): + IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_enumeration.xml" + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + [ + x["@value"] + for x in ids_file.specifications[0].requirements.terms[0].node["value"]["restriction"][0]["enumeration"] + ], + ["testA", "testB"], + ) + + def test_parse_restrictions_bounds(self): + IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_bounds.xml" + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements.terms[0].node["value"]["restriction"][0]["minInclusive"]["@value"], + "0", + ) + + def test_parse_restrictions_pattern_simple(self): + IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_pattern.xml" + ids_file = ids.ids.open(read_web_file(IDS_URL)) + self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements.terms[0].node["value"]["restriction"][0]["pattern"]["@value"], + "[A-Z]{2,4}", + ) + class TestIdsAuthoring(unittest.TestCase): + """Creating basic IDS""" + def test_entity_create(self): e = ids.entity.create(name="Test_Name", predefinedtype="Test_PredefinedType") self.assertEqual(e.name, "Test_Name") @@ -96,11 +146,6 @@ class TestIdsAuthoring(unittest.TestCase): s = ids.specification(name="Test_Specification") self.assertEqual(s.name, "Test_Specification") - def test_ids_create(self): - i = ids.ids() - self.assertEqual(i.specifications, []) - self.assertEqual(i.info, None) - def test_ids_add_content(self): i = ids.ids() i.specifications.append(ids.specification(name="Test_Specification")) @@ -115,6 +160,55 @@ class TestIdsAuthoring(unittest.TestCase): i.specifications[0].add_requirement(m) self.assertEqual(i.specifications[0].requirements.terms[1].value, "Test_Value") + """ Creating IDS with restrictions """ + + def test_create_restrictions_enumeration(self): + i = ids.ids() + i.specifications.append(ids.specification(name="Test_Specification")) + i.specifications[0].add_applicability(ids.entity.create(name="Test_Name")) + r = ids.restriction.create(options=["testA", "testB"], type="enumeration", base="string") + m = ids.material.create(location="any", value=r) + i.specifications[0].add_requirement(m) + self.assertEqual(i.specifications[0].requirements.terms[0].value, "testA") + self.assertEqual(i.specifications[0].requirements.terms[0].value, "testB") + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, "testC") + + def test_create_restrictions_bounds(self): + i = ids.ids() + i.specifications.append(ids.specification(name="Test_Specification")) + i.specifications[0].add_applicability(ids.entity.create(name="Test_Name")) + r = ids.restriction.create(options={"minInclusive": 0, "maxExclusive": 10}, type="bounds", base="integer") + p = ids.property.create(location="any", propertyset="Test", name="Test", value=r) + i.specifications[0].add_requirement(p) + self.assertEqual(i.specifications[0].requirements.terms[0].value, 0) + self.assertEqual(i.specifications[0].requirements.terms[0].value, 5) + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, -1) + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, 10) + + def test_create_restrictions_pattern_simple(self): + i = ids.ids() + i.specifications.append(ids.specification(name="Test_Specification")) + i.specifications[0].add_applicability(ids.entity.create(name="Test_Name")) + r = ids.restriction.create(options="[A-Z]{2,4}", type="pattern", base="string") + p = ids.property.create(location="any", propertyset="Test", name="Test", value=r) + i.specifications[0].add_requirement(p) + self.assertEqual(i.specifications[0].requirements.terms[0].value, "XYZ") + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, "abc") + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, "ABCDE") + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, "A") + + def test_create_restrictions_pattern_advanced(self): + i = ids.ids() + i.specifications.append(ids.specification(name="Test_Specification")) + i.specifications[0].add_applicability(ids.entity.create(name="Test_Name")) + # r = ids.restriction.create(options="^(Wanddurchbruch.*|Deckendurchbruch.*)", type="pattern", base="string") + r = ids.restriction.create(options="(Wanddurchbruch|Deckendurchbruch).*", type="pattern", base="string") + p = ids.property.create(location="any", propertyset="Test", name="Test", value=r) + i.specifications[0].add_requirement(p) + self.assertEqual(i.specifications[0].requirements.terms[0].value, "Wanddurchbruch") + self.assertEqual(i.specifications[0].requirements.terms[0].value, "Deckendurchbruch") + self.assertNotEqual(i.specifications[0].requirements.terms[0].value, "Deeckendurchbruch") + """ Saving created IDS to IDS.xml """ def test_created_ids_to_xml(self): @@ -123,25 +217,116 @@ class TestIdsAuthoring(unittest.TestCase): e = ids.entity.create(name="Test_Name", predefinedtype="Test_PredefinedType") c = ids.classification.create(location="any", value="Test_Value", system="Test_System") m = ids.material.create(location="any", value="Test_Value") - p = ids.property.create(location="any", propertyset="Test_PropertySet", name="Test_Parameter", value="Test_Value") + re = ids.restriction.create(options=["testA", "testB"], type="enumeration", base="string") + rb = ids.restriction.create(options={"minInclusive": 0, "maxExclusive": 10}, type="bounds", base="integer") + rp = ids.restriction.create(options="[A-Z]{2,4}", type="pattern", base="string") + p1 = ids.property.create(location="any", propertyset="Test_PropertySet", name="Test_Parameter", value=re) + p2 = ids.property.create(location="any", propertyset="Test_PropertySet", name="Test_Parameter", value=rb) + p3 = ids.property.create(location="any", propertyset="Test_PropertySet", name="Test_Parameter", value=rp) + p4 = ids.property.create( + location="any", propertyset="Test_PropertySet", name="Test_Parameter", value=[re, rb, rp] + ) i.specifications[0].add_applicability(e) i.specifications[0].add_applicability(m) i.specifications[0].add_requirement(c) - i.specifications[0].add_requirement(p) + i.specifications[0].add_requirement(p1) + i.specifications[0].add_requirement(p2) + i.specifications[0].add_requirement(p3) + i.specifications[0].add_requirement(p4) fn = "TEST_FILE.xml" result = i.to_xml(fn) os.remove(fn) self.assertTrue(result) + """ IDS information """ + + def test_create_full_information(self): + i = ids.ids( + ifcversion="2.3.0.1", + description="test", + author="test@test.com", + copyright="test", + version=1.23, + creation_date="2021-01-01", + purpose="test", + milestone="test", + ) + self.assertEqual(i.info["version"], 1.23) + class TestIfcValidation(unittest.TestCase): - pass - # TODO + def test_validate_simple(self): + # TODO + pass + + def test_validate_all_facets(self): + # TODO + pass + + """ Validating IDS files with restrictions """ + + # def test_validate_restrictions_enumeration(self): + # IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_enumeration.xml" + # ids_file = ids.ids.open(read_web_file(IDS_URL)) + # self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]['simpleValue'], "Test_Parameter") + # self.assertEqual( [x['@value'] for x in ids_file.specifications[0].requirements.terms[0].node["value"]['restriction'][0]['enumeration'] ], ['testA', 'testB']) + # # TODO actual test of validation result + # # self.assertTrue( ) + + # def test_validate_restrictions_boundsInclusive(self): + # IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_bounds.xml" + # ids_file = ids.ids.open(read_web_file(IDS_URL)) + # self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]['simpleValue'], "Test_Parameter") + # self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"]['restriction'][0]['minInclusive']['@value'], '0') + # # TODO actual test of validation result + # # self.assertTrue( ) + + # def test_validate_restrictions_boundsExclusive(self): + # #TODO + # pass + + # def test_validate_restrictions_pattern_simple(self): + # IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_property_with_restriction_pattern.xml" + # ids_file = ids.ids.open(read_web_file(IDS_URL)) + # self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["name"]['simpleValue'], "Test_Parameter") + # self.assertEqual(ids_file.specifications[0].requirements.terms[0].node["value"]['restriction'][0]['pattern']['@value'], '[A-Z]{2,4}') + # # TODO actual test of validation result + # # self.assertTrue( ) -class TestIdsResults(unittest.TestCase): - pass - # TODO +class TestIdsReporting(unittest.TestCase): + + TEST_PATH = os.getcwd() + IFC_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IFC/IFC4_Wall_3_with_properties.ifc" + IDS_URL = "https://raw.githubusercontent.com/atomczak/Sample-BIM-Files/main/IDS/IDS_Wall_needs_all_fields.xml" + + logger = logging.getLogger("IDS_Logger") + # logging.basicConfig(level=logging.INFO, format="%(message)s") + # logging.basicConfig(filename=TEST_PATH+r"\log.txt", level=logging.INFO, format="%(message)s") + + content = read_web_file(IFC_URL) + file = open(TEST_PATH + r"\test.ifc", "w") + file.write(content) + file.close() + ifc_file = ifcopenshell.open(TEST_PATH + r"\test.ifc") + os.remove(TEST_PATH + r"\test.ifc") + + def test_simple_report(self): + ids_file = ids.ids.open(read_web_file(self.IDS_URL)) + report = ids.SimpleHandler() + self.logger.addHandler(report) + ids_file.validate(self.ifc_file, self.logger) + self.assertEqual(len(report.statements), 5) + + def test_bcf_report(self): + ids_file = ids.ids.open(read_web_file(self.IDS_URL)) + fn = tempfile.gettempdir() + r"\bcf_test.bcfzip" + bcf_handler = ids.BcfHandler(project_name="Default IDS Project", author="your@email.com", filepath=fn) + self.logger.addHandler(bcf_handler) + ids_file.validate(self.ifc_file, self.logger) + my_bcfxml = bcfxml.load(fn) + topics = my_bcfxml.get_topics() + self.assertEqual(len(topics), 5) if __name__ == "__main__":