diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 7d6a8588db..1b5660dcb8 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -106,6 +106,14 @@ class Facet: raise Exception(str(parameter) + " was not able to be converted into 'Parameter_dict'") return parameter_dict + def get_usage(self): + if self.minOccurs != 0: + return "required" + elif self.minOccurs == 0 and self.maxOccurs != 0: + return "optional" + elif self.maxOccurs == 0: + return "prohibited" + class Entity(Facet): def __init__(self, name="IFCWALL", predefinedType=None, instructions=None): @@ -165,24 +173,11 @@ class Attribute(Facet): if self.minOccurs == 0 and self.maxOccurs != 0: return AttributeResult(True) - def get_values(element, name): - if isinstance(name, str): - return [getattr(element, name, None)] - return [v for k, v in element.get_info().items() if k == name] - - element_type = ifcopenshell.util.element.get_type(inst) - if isinstance(self.name, str): - type_value = getattr(element_type, self.name, None) if element_type else None - occurrence_value = getattr(inst, self.name, None) names = [self.name] - values = [occurrence_value if occurrence_value is not None else type_value] + values = [getattr(inst, self.name, None)] else: - if element_type: - info = element_type.get_info() - info.update({k: v for k, v in inst.get_info().items() if v is not None}) - else: - info = inst.get_info() + info = inst.get_info() names = [] values = [] for k, v in info.items(): @@ -197,29 +192,31 @@ class Attribute(Facet): reason = {"type": "NOVALUE"} if is_pass: + non_empty_values = [] for i, value in enumerate(values): + is_empty = False if value is None: - is_pass = False - reason = {"type": "FALSEY", "actual": value} + is_empty = True elif value == "": - is_pass = False - reason = {"type": "FALSEY", "actual": value} + is_empty = True elif value == tuple(): - is_pass = False - reason = {"type": "FALSEY", "actual": value} + is_empty = True else: argument_index = inst.wrapped_data.get_argument_index(names[i]) try: attribute_type = inst.attribute_type(argument_index) if attribute_type == "LOGICAL" and value == "UNKNOWN": - is_pass = False - reason = {"type": "FALSEY", "actual": value} + is_empty = True except: if names[i] in inst.wrapped_data.get_inverse_attribute_names(): - is_pass = False - reason = {"type": "INVALID"} - if not is_pass: - break + is_empty = True + if not is_empty: + non_empty_values.append(value) + if non_empty_values: + values = non_empty_values + else: + is_pass = False + reason = {"type": "FALSEY", "actual": values if len(values) > 1 else values[0]} if is_pass and self.value: for value in values: @@ -326,7 +323,7 @@ class PartOf(Facet): aggregate = ifcopenshell.util.element.get_aggregate(inst) is_pass = aggregate is not None if not is_pass: - reason = {"type": "RELATION"} + reason = {"type": "NOVALUE"} if is_pass and self.entity: is_pass = False ancestors = [] @@ -355,7 +352,7 @@ class PartOf(Facet): container = ifcopenshell.util.element.get_container(inst) is_pass = container is not None if not is_pass: - reason = {"type": "RELATION"} + reason = {"type": "NOVALUE"} if is_pass and self.entity: if container.is_a().upper() != self.entity: is_pass = False @@ -871,6 +868,8 @@ class AttributeResult(Result): return f"An invalid attribute name was specified in the IDS" elif self.reason["type"] == "VALUE": return f"The attribute value \"{str(self.reason['actual'])}\" does not match the requirement" + elif self.reason["type"] == "PROHIBITED": + return f"The attribute value should not have met the requirement" class ClassificationResult(Result): @@ -881,11 +880,18 @@ class ClassificationResult(Result): return f"The references \"{str(self.reason['actual'])}\" do not match the requirements" elif self.reason["type"] == "system": return f"The systems \"{str(self.reason['actual'])}\" do not match the requirements" + elif self.reason["type"] == "PROHIBITED": + return f"The classification should not have met the requirement" class PartOfResult(Result): def to_string(self): - return "TODO" + if self.reason["type"] == "NOVALUE": + return "The entity has no relationship" + elif self.reason["type"] == "ENTITY": + return f"The entity has a relationship with incorrect entities: \"{str(self.reason['actual'])}\"" + elif self.reason["type"] == "PROHIBITED": + return f"The relationship should not have met the requirement" class PropertyResult(Result): @@ -900,6 +906,8 @@ class PropertyResult(Result): return f"The property value \"{str(self.reason['actual'][0])}\" does not match the requirements" elif self.reason["type"] == "VALUE": return f"The property values \"{str(self.reason['actual'])}\" do not match the requirements" + elif self.reason["type"] == "PROHIBITED": + return f"The property should not have met the requirement" class MaterialResult(Result): @@ -910,3 +918,5 @@ class MaterialResult(Result): return ( f"The material names and categories of \"{str(self.reason['actual'])}\" does not match the requirement" ) + elif self.reason["type"] == "PROHIBITED": + return f"The material should not have met the requirement" diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index b8a8cbb722..35c1ea18c2 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -32,9 +32,7 @@ def open(filepath, validate=False): if validate: get_schema().validate(filepath) return Ids().parse( - get_schema().decode( - filepath, strip_namespaces=True, namespaces={"": "http://standards.buildingsmart.org/IDS"} - ) + get_schema().decode(filepath, strip_namespaces=True, namespaces={"": "http://standards.buildingsmart.org/IDS"}) ) @@ -114,18 +112,18 @@ class Ids: ET.ElementTree(get_schema().encode(self.asdict())).write(filepath, encoding="utf-8", xml_declaration=True) return get_schema().is_valid(filepath) - def validate(self, ifc_file): + def validate(self, ifc_file, filter_version=False): for specification in self.specifications: specification.reset_status() - specification.validate(ifc_file) + specification.validate(ifc_file, filter_version=filter_version) class Specification: def __init__( self, name="Unnamed", - minOccurs=None, - maxOccurs=None, + minOccurs=0, + maxOccurs="unbounded", ifcVersion=["IFC2X3", "IFC4"], identifier=None, description=None, @@ -153,7 +151,7 @@ class Specification: } for attribute in ["identifier", "description", "instructions", "minOccurs", "maxOccurs"]: value = getattr(self, attribute) - if value: + if value is not None: results[f"@{attribute}"] = value for clause_type in ["applicability", "requirements"]: clause = getattr(self, clause_type) @@ -194,11 +192,12 @@ class Specification: self.applicable_entities.clear() self.failed_entities = set() for facet in self.requirements: + facet.status = None facet.failed_entities.clear() self.status = None - def validate(self, ifc_file): - if ifc_file.schema not in self.ifcVersion: + def validate(self, ifc_file, filter_version=False): + if filter_version and ifc_file.schema not in self.ifcVersion: return elements = [] @@ -218,18 +217,38 @@ class Specification: self.applicable_entities.append(element) for facet in self.requirements: result = facet(element) - facet.status = bool(result) - if not facet.status: + if not bool(result): self.failed_entities.add(element) facet.failed_entities.append(element) facet.failed_reasons.append(str(result)) + for facet in self.requirements: + if facet.minOccurs != 0: + facet.status = not bool(facet.failed_entities) + elif facet.minOccurs == 0 and facet.maxOccurs != 0: + facet.status = True + elif facet.maxOccurs == 0: + facet.status = bool(facet.failed_entities) + self.status = True - if self.failed_entities: - self.status = False - elif self.minOccurs != 0 and not self.applicable_entities: - self.status = False - for facet in self.requirements: - facet.status = False - elif len(self.applicable_entities) > (self.maxOccurs or 1): - self.status = False + if self.minOccurs != 0: + if not self.applicable_entities: + self.status = False + for facet in self.requirements: + facet.status = False + elif self.failed_entities: + self.status = False + elif self.minOccurs == 0 and self.maxOccurs != 0: + if self.failed_entities: + self.status = False + elif self.maxOccurs == 0: + if (len(self.applicable_entities) - len(self.failed_entities)) > 0: + self.status = False + + def get_usage(self): + if self.minOccurs != 0: + return "required" + elif self.minOccurs == 0 and self.maxOccurs != 0: + return "optional" + elif self.maxOccurs == 0: + return "prohibited" diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 4fb16053d6..61bb8e6dc3 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -24,27 +24,21 @@ import functools import ifcopenshell import ifctester import test_facet +import test_ids from xml.dom.minidom import parseString from ifctester import ids from ifcopenshell import validate outdir = "build" -class StableUUIDGenerator: - file = None - - @classmethod - def generate(cls): - ns = uuid.UUID('b59aa156-82a4-4b4c-a6e5-3d04a0236af9') - if cls.file: - return ifcopenshell.guid.compress(uuid.uuid5(ns, str(cls.file.wrapped_data.getMaxId())).hex) - return ifcopenshell.guid.compress(uuid.uuid4().hex) - # Just for aesthetics so we don't keep on getting brand new GlobalIds on each generation -ifcopenshell.guid.new = StableUUIDGenerator.generate +def regenerate_guids(ifc): + ns = uuid.UUID("b59aa156-82a4-4b4c-a6e5-3d04a0236af9") + for element in ifc.by_type("IfcRoot"): + element.GlobalId = ifcopenshell.guid.compress(uuid.uuid5(ns, str(element.id())).hex) -class DocGenerator: +class FacetDocGenerator: def __init__(self): self.facet = None self.testcases = {} @@ -55,12 +49,13 @@ class DocGenerator: result = "pass" if expected is True else "fail" - f = inst.wrapped_data.file - StableUUIDGenerator.file = f + ifc = inst.wrapped_data.file + if "GlobalId" not in name: + regenerate_guids(ifc) # Validate the file created and loop over the issues, fixing them one by one. l = validate.json_logger() - validate.validate(f, l) + validate.validate(ifc, l) for issue in l.statements: if "GlobalId" in issue["message"]: issue["instance"].GlobalId = ifcopenshell.guid.new() @@ -68,17 +63,17 @@ class DocGenerator: ty = re.findall("\\(.+?\\)", issue["message"])[0][1:-1].split(", ")[0] issue["instance"].PredefinedType = ty elif "IfcMaterialList" in issue["message"]: - issue["instance"].Materials = [f.createIfcMaterial("Concrete", None, "CONCRETE")] + issue["instance"].Materials = [ifc.createIfcMaterial("Concrete", None, "CONCRETE")] else: raise Exception("About to emit invalid example data:", issue) # ifc_text = "\n".join([f"{e} /* Testcase */" if e == inst else str(e) for e in f]) - lines = f.wrapped_data.to_string().split("\n")[7:-3] + lines = ifc.wrapped_data.to_string().split("\n")[7:-3] ifc_text = "\n".join([f"{l} /* Testcase */" if f"#{inst.id()}=" in l else l for l in lines]) basename = f"{result}-" + re.sub("[^0-9a-zA-Z]", "_", name.lower()) # Write IFC to disk - f.write(os.path.join(outdir, "testcases", self.facet, f"{basename}.ifc")) + ifc.write(os.path.join(outdir, "testcases", self.facet, f"{basename}.ifc")) # Create an IDS with the applicability selecting exactly # the entity type passed to us in `inst`. @@ -112,8 +107,73 @@ class DocGenerator: self.facet = facet -test_facet.run = DocGenerator() +class IdsDocGenerator: + def __init__(self): + self.testcases = [] + + def __call__(self, name, ids, ifc, expected, applicable_entities=[], failed_entities=[]): + ids.validate(ifc) + all_applicable = set() + all_failures = set() + for spec in ids.specifications: + assert spec.status is expected + all_applicable.update(spec.applicable_entities) + for requirement in spec.requirements: + if requirement.status is False: + all_failures.update(requirement.failed_entities) + assert set(all_applicable) == set(applicable_entities) + assert set(all_failures) == set(failed_entities) + + result = "pass" if expected is True else "fail" + + regenerate_guids(ifc) + + l = validate.json_logger() + validate.validate(ifc, l) + for issue in l.statements: + raise Exception("About to emit invalid example data:", issue) + + lines = ifc.wrapped_data.to_string().split("\n")[7:-3] + ifc_text = "" + for i, line in enumerate(lines): + step_id = int(line[1 : line.index("=")]) + element = ifc.by_id(step_id) + newline = "" if i == 0 else "\n" + if element in applicable_entities: + pass_or_fail = "FAIL" if element in failed_entities else "PASS" + ifc_text += f"{newline}[{pass_or_fail}] {line}" + else: + ifc_text += f"{newline} {line}" + basename = f"{result}-" + re.sub("[^0-9a-zA-Z]", "_", name.lower()) + + # Write IFC to disk + ifc.write(os.path.join(outdir, "testcases", "ids", f"{basename}.ifc")) + + # Write IDS to disk + with open(os.path.join(outdir, "testcases", "ids", f"{basename}.ids"), "w", encoding="utf-8") as ids_file: + ids_file.write(ids.to_string()) + + reports = [] + for spec in ids.specifications: + report = {"applicability": [], "requirements": [], "usage": spec.get_usage(), "status": spec.status} + for facet in spec.applicability: + report["applicability"].append(facet.to_string("applicability")) + for facet in spec.requirements: + report["requirements"].append( + {"status": facet.status, "text": facet.to_string("requirement"), "usage": facet.get_usage()} + ) + reports.append(report) + + xml_text = "\n".join([l[4:] for l in ids.to_string().split("\n")[4:-1]]).replace("\t", " ") + + self.testcases.append( + {"name": name, "ids": xml_text, "ifc": ifc_text, "basename": basename, "result": result, "reports": reports} + ) + + +test_facet.run = FacetDocGenerator() test_facet.set_facet = test_facet.run.set_facet +test_ids.run = IdsDocGenerator() pytest.main(["-p", "no:pytest-blender"]) @@ -142,6 +202,42 @@ for facet, testcases in test_facet.run.testcases.items(): ) write() +with open(os.path.join(outdir, f"testcases-ids.md"), "w") as f: + write = functools.partial(print, file=f) + write(f"# IDS integration testcases") + write() + write( + "These testcases are designed to help describe behaviour in edge cases and ambiguities. All valid IDS implementations must demonstrate identical behaviour to these test cases." + ) + write() + for testcase in test_ids.run.testcases: + write(f"## [{testcase['result'].upper()}] {testcase['name']}") + write() + write("~~~xml") + write(testcase["ids"]) + write("~~~") + write() + write("~~~lua") + write(testcase["ifc"]) + write("~~~") + write() + for report in testcase["reports"]: + write("```") + icon = "✔️" if report["status"] else "❌" + write(f"# {icon} Specification ({report['usage']})") + write("Applies to:") + for facet in report["applicability"]: + write(f" - {facet}") + write("Requirements:") + for facet in report["requirements"]: + icon = "✔️" if facet["status"] else "❌" + write(f" - {icon} {facet['text']} ({facet['usage']})") + write("```") + write() + write( + f"[Sample IDS](testcases/ids/{testcase['basename']}.ids) - [Sample IFC](testcases/ids/{testcase['basename']}.ifc)" + ) + write() specs = ifctester.ids.Ids( title="buildingSMART Sample IDS", @@ -153,15 +249,38 @@ specs = ifctester.ids.Ids( purpose="Contractual requirements", ) -spec = ifctester.ids.Specification(name="Project naming", ifcVersion=["IFC4"], description="Projects shall be named correctly for the purposes of identification, project archival, and model federation.", instructions="Each discipline is responsible for naming their own project.") +spec = ifctester.ids.Specification( + name="Project naming", + ifcVersion=["IFC4"], + description="Projects shall be named correctly for the purposes of identification, project archival, and model federation.", + instructions="Each discipline is responsible for naming their own project.", +) specs.specifications.append(spec) spec.applicability.append(ifctester.ids.Entity(name="IFCPROJECT")) -spec.requirements.append(ifctester.ids.Attribute(name="Name", value="TEST", instructions="The project manager shall confirm the short project code with the client based on their real estate portfolio naming scheme.")) +spec.requirements.append( + ifctester.ids.Attribute( + name="Name", + value="TEST", + instructions="The project manager shall confirm the short project code with the client based on their real estate portfolio naming scheme.", + ) +) -spec = ifctester.ids.Specification(name="Fire rating", ifcVersion=["IFC4"], description="All objects must have a fire rating for building compliance checks and to know the protection strategies needed for any penetrations.", instructions="The architect is responsible for including this data.") +spec = ifctester.ids.Specification( + name="Fire rating", + ifcVersion=["IFC4"], + description="All objects must have a fire rating for building compliance checks and to know the protection strategies needed for any penetrations.", + instructions="The architect is responsible for including this data.", +) specs.specifications.append(spec) spec.applicability.append(ifctester.ids.Entity(name="IFCWALLTYPE")) restriction = ifctester.ids.Restriction(options="(-|[0-9]{2,3})\/(-|[0-9]{2,3})\/(-|[0-9]{2,3})", type="pattern") -spec.requirements.append(ifctester.ids.Property(propertySet="Pset_WallCommon", name="FireRating", value=restriction, instructions="Fire rating is specified using the Fire Resistance Level as defined in the Australian National Construction Code (NCC) 2019. Valid examples include -/-/-, -/120/120, and 60/60/60")) +spec.requirements.append( + ifctester.ids.Property( + propertySet="Pset_WallCommon", + name="FireRating", + value=restriction, + instructions="Fire rating is specified using the Fire Resistance Level as defined in the Australian National Construction Code (NCC) 2019. Valid examples include -/-/-, -/120/120, and 60/60/60", + ) +) specs.to_xml(os.path.join(outdir, "library", "sample.ids")) diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index d8e23a32a1..2cae4dabc4 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -583,14 +583,14 @@ class TestAttribute: run( "Durations are treated as strings 1/2", facet=facet, - inst=ifc.createIfcClassification(Name="Name", EditionDate="PT16H"), - expected=False, + inst=ifc.createIfcTaskTime(Name="Name", ScheduleDuration="PT16H"), + expected=True, ) ifc = ifcopenshell.file() run( "Durations are treated as strings 2/2", facet=facet, - inst=ifc.createIfcClassification(Name="Name", EditionDate="P2D"), + inst=ifc.createIfcTaskTime(Name="Name", ScheduleDuration="P2D"), expected=False, ) @@ -598,30 +598,27 @@ class TestAttribute: facet = Attribute(name=restriction) ifc = ifcopenshell.file() run( - "Name restrictions may be used 1/4", + "Name restrictions will match any result 1/3", facet=facet, inst=ifc.createIfcMaterialLayerSet( MaterialLayers=[ifc.createIfcMaterialLayer(LayerThickness=1)], LayerSetName="Foo" ), expected=True, ) - ifc = ifcopenshell.file() - run( - "Name restrictions may be used 2/4", - facet=facet, - inst=ifc.createIfcMaterialConstituentSet(Name="Foo"), - expected=True, - ) - restriction = Restriction(options=["Name", "Description"], type="enumeration") facet = Attribute(name=restriction) ifc = ifcopenshell.file() - run("Name restrictions may be used 3/4", facet=facet, inst=ifc.createIfcWall(Name="Foo"), expected=False) + run( + "Name restrictions will match any result 2/3", + facet=facet, + inst=ifc.createIfcWall(Name="Foo"), + expected=True, + ) ifc = ifcopenshell.file() run( - "Name restrictions may be used 4/4", + "Name restrictions will match any result 3/3", facet=facet, - inst=ifc.createIfcWall(Name="Foo", Description="Bar"), + inst=ifc.createIfcWall(Description="Bar"), expected=True, ) @@ -639,6 +636,7 @@ class TestAttribute: wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) wall_type.Description = "Foobar" + facet = Attribute(name="Description", value="Foobar") run("Attributes are not inherited by the occurrence", facet=facet, inst=wall, expected=False) restriction = Restriction(options=["42"], type="enumeration", base="string") @@ -857,18 +855,7 @@ class TestProperty: def test_filtering_using_a_property_facet(self): set_facet("property") - ifc = ifcopenshell.file() - ifc.createIfcProject() - # Milli prefix used to check measurement conversions - lengthunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT", name="METRE", prefix="MILLI") - areaunit = ifcopenshell.api.run( - "unit.add_si_unit", ifc, unit_type="AREAUNIT", name="SQUARE_METRE", prefix="MILLI" - ) - volumeunit = ifcopenshell.api.run( - "unit.add_si_unit", ifc, unit_type="VOLUMEUNIT", name="CUBIC_METRE", prefix="MILLI" - ) - timeunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="TIMEUNIT", name="SECOND") - ifcopenshell.api.run("unit.assign_unit", ifc, units=[lengthunit, areaunit, volumeunit, timeunit]) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") @@ -876,11 +863,12 @@ class TestProperty: pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"AnotherProperty": "AnotherValue"}) run("Elements with a matching pset but no property also fail", facet=facet, inst=element, expected=False) - ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": None}) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"AnotherProperty": None}) run("Properties with a null value fail", facet=facet, inst=element, expected=False) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) run("A name check will match any property with any string value", facet=facet, inst=element, expected=True) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -912,6 +900,7 @@ class TestProperty: expected=True, ) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", value="Bar", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -933,6 +922,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcIdentifier(identifier)}) run("IDS does not handle string truncation such as for identifiers", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", value="1", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -1009,6 +999,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcDuration("P2D")}) run("Durations are treated as strings 1/2", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Pset_WallCommon") pset_template = ifcopenshell.util.pset.get_template("IFC4").get_by_name("Pset_WallCommon") @@ -1026,6 +1017,7 @@ class TestProperty: facet = Property(propertySet="Pset_WallCommon", name="Status", value="NEW", measure="IfcLabel") run("Any matching value in an enumerated property will pass 3/3", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") list_property = ifc.createIfcPropertyListValue( @@ -1039,6 +1031,7 @@ class TestProperty: facet = Property(propertySet="Foo_Bar", name="Foo", value="Z", measure="IfcLabel") run("Any matching value in a list property will pass 3/3", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") bounded_property = ifc.createIfcPropertyBoundedValue( @@ -1057,6 +1050,7 @@ class TestProperty: facet = Property(propertySet="Foo_Bar", name="Foo", value="2", measure="IfcLengthMeasure") run("Any matching value in a bounded property will pass 4/4", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") table_property = ifc.createIfcPropertyTableValue( @@ -1070,12 +1064,14 @@ class TestProperty: facet = Property(propertySet="Foo_Bar", name="Foo", value="Y", measure="IfcLabel") run("Any matching value in a table property will pass 3/3", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") pset.HasProperties = [ifc.createIfcPropertyReferenceValue(Name="Foo")] facet = Property(propertySet="Foo_Bar", name="Foo", measure="IfcLabel") run("Reference properties are treated as objects and not supported", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDoor") pset = ifc.create_entity( "IfcDoorPanelProperties", @@ -1099,6 +1095,7 @@ class TestProperty: ) run("Predefined properties are supported but discouraged 2/2", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", measure="IfcLengthMeasure") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") qto = ifcopenshell.api.run("pset.add_qto", ifc, product=element, name="Foo_Bar") @@ -1107,6 +1104,7 @@ class TestProperty: facet = Property(propertySet="Foo_Bar", name="Foo", measure="IfcAreaMeasure") run("Quantities must also match the appropriate measure", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") complex_property = ifc.createIfcComplexProperty(Name="Foo", UsageName="RabbitAgilityTraining") @@ -1117,6 +1115,7 @@ class TestProperty: facet = Property(propertySet="Foo", name="Rabbits", measure="IfcLabel") run("Complex properties are not supported 2/2", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") qto = ifcopenshell.api.run("pset.add_qto", ifc, product=element, name="Foo_Bar") complex_quantity = ifc.createIfcPhysicalComplexQuantity(Name="Foo", Discrimination="FurThickness") @@ -1129,6 +1128,7 @@ class TestProperty: facet = Property(propertySet="Foo", name="MyLength", measure="IfcLengthMeasure") run("Complex properties are not supported 2/2", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() restriction = Restriction(options="Foo_.*", type="pattern") facet = Property(propertySet=restriction, name="Foo", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") @@ -1141,6 +1141,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) run("All matching property sets must satisfy requirements 3/3", facet=facet, inst=element, expected=True) + ifc = self.setup_ifc() restriction = Restriction(options="Foo.*", type="pattern") facet = Property(propertySet="Foo_Bar", name=restriction, value="x", measure="IfcLabel") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") @@ -1152,6 +1153,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "y"}) run("All matching properties must satisfy requirements 3/3", facet=facet, inst=element, expected=False) + ifc = self.setup_ifc() restriction1 = Restriction(options="Foo.*", type="pattern") restriction2 = Restriction(options=["x", "y"], type="enumeration") facet = Property(propertySet="Foo_Bar", name=restriction1, value=restriction2, measure="IfcLabel") @@ -1172,6 +1174,7 @@ class TestProperty: expected=False, ) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", value="2", measure="IfcTimeMeasure") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -1180,6 +1183,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcTimeMeasure(2)}) run("Measures are used to specify an IFC data type 2/2", facet=facet, inst=element, expected=True) + ifc = self.setup_ifc() facet = Property(propertySet="Foo_Bar", name="Foo", value="2", measure="IfcLengthMeasure") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -1198,6 +1202,7 @@ class TestProperty: expected=True, ) + ifc = self.setup_ifc() wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) @@ -1207,6 +1212,7 @@ class TestProperty: run("Properties can be inherited from the type 1/2", facet=facet, inst=wall, expected=True) run("Properties can be inherited from the type 2/2", facet=facet, inst=wall_type, expected=True) + ifc = self.setup_ifc() wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) @@ -1218,6 +1224,21 @@ class TestProperty: run("Properties can be overriden by an occurrence 1/2", facet=facet, inst=wall, expected=True) run("Properties can be overriden by an occurrence 2/2", facet=facet, inst=wall_type, expected=False) + def setup_ifc(self): + ifc = ifcopenshell.file() + ifc.createIfcProject() + # Milli prefix used to check measurement conversions + lengthunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT", name="METRE", prefix="MILLI") + areaunit = ifcopenshell.api.run( + "unit.add_si_unit", ifc, unit_type="AREAUNIT", name="SQUARE_METRE", prefix="MILLI" + ) + volumeunit = ifcopenshell.api.run( + "unit.add_si_unit", ifc, unit_type="VOLUMEUNIT", name="CUBIC_METRE", prefix="MILLI" + ) + timeunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="TIMEUNIT", name="SECOND") + ifcopenshell.api.run("unit.assign_unit", ifc, units=[lengthunit, areaunit, volumeunit, timeunit]) + return ifc + class TestMaterial: def test_creating_a_material_facet(self): @@ -1258,7 +1279,6 @@ class TestMaterial: element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") material = ifcopenshell.api.run("material.add_material", ifc) ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material) - run("Material with no data will fail a value check", facet=facet, inst=element, expected=False) material.Name = "Foo" run("A material name may pass the value check", facet=facet, inst=element, expected=True) material.Name = "Bar" @@ -1270,7 +1290,6 @@ class TestMaterial: element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") material_set = ifcopenshell.api.run("material.add_material_set", ifc, set_type="IfcMaterialList") ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material_set) - run("A material list with no data will fail a value check", facet=facet, inst=element, expected=False) material = ifcopenshell.api.run("material.add_material", ifc) ifcopenshell.api.run("material.add_list_item", ifc, material_list=material_set, material=material) material.Name = "Foo" @@ -1418,6 +1437,7 @@ class TestPartOf: run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) run("An optional facet always passes regardless of outcome 2/2", facet=facet, inst=subelement, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSlab") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcBeam") ifcopenshell.api.run("aggregate.assign_object", ifc, product=subelement, relating_object=element) @@ -1426,6 +1446,7 @@ class TestPartOf: facet = PartOf(entity="IFCWALL", relation="IfcRelAggregates") run("An aggregate may specify the entity of the whole 2/2", facet=facet, inst=subelement, expected=False) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSlab") subsubelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcBeam") @@ -1434,6 +1455,7 @@ class TestPartOf: facet = PartOf(entity="IFCELEMENTASSEMBLY", relation="IfcRelAggregates") run("An aggregate entity may pass any ancestral whole passes", facet=facet, inst=subsubelement, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") group = ifcopenshell.api.run("group.add_group", ifc) facet = PartOf(relation="IfcRelAssignsToGroup") @@ -1441,6 +1463,7 @@ class TestPartOf: ifcopenshell.api.run("group.assign_group", ifc, products=[element], group=group) run("A grouped element passes a group relationship", facet=facet, inst=element, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") group = ifc.createIfcInventory() facet = PartOf(entity="IFCGROUP", relation="IfcRelAssignsToGroup") @@ -1449,6 +1472,7 @@ class TestPartOf: facet = PartOf(entity="IFCINVENTORY", relation="IfcRelAssignsToGroup") run("A group entity must match exactly 2/2", facet=facet, inst=element, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") container = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSpace") facet = PartOf(relation="IfcRelContainedInSpatialStructure") @@ -1457,6 +1481,7 @@ class TestPartOf: run("Any contained element passes a containment relationship 2/2", facet=facet, inst=element, expected=True) run("The container itself always fails", facet=facet, inst=container, expected=False) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") container = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSpace") ifcopenshell.api.run("spatial.assign_container", ifc, product=element, relating_structure=container) @@ -1465,6 +1490,7 @@ class TestPartOf: facet = PartOf(relation="IfcRelContainedInSpatialStructure", entity="IFCSPACE") run("The container entity must match exactly 2/2", facet=facet, inst=element, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSlab") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcBeam") ifcopenshell.api.run("aggregate.assign_object", ifc, product=subelement, relating_object=element) @@ -1473,6 +1499,7 @@ class TestPartOf: facet = PartOf(relation="IfcRelContainedInSpatialStructure", entity="IFCSPACE") run("The container may be indirect", facet=facet, inst=subelement, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") ifcopenshell.api.run("nest.assign_object", ifc, related_object=subelement, relating_object=element) @@ -1480,6 +1507,7 @@ class TestPartOf: run("Any nested part passes a nest relationship", facet=facet, inst=subelement, expected=True) run("Any nested whole fails a nest relationship", facet=facet, inst=element, expected=False) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") ifcopenshell.api.run("nest.assign_object", ifc, related_object=subelement, relating_object=element) @@ -1488,6 +1516,7 @@ class TestPartOf: facet = PartOf(relation="IfcRelNests", entity="IFCFURNITURE") run("The nest entity must match exactly 2/2", facet=facet, inst=subelement, expected=True) + ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") subsubelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcMechanicalFastener") @@ -1516,3 +1545,47 @@ class TestRestriction: assert restriction == "AB01" assert restriction != "AB" assert restriction != "01" + + def test_filtering_using_an_enumeration(self): + set_facet("restriction") + + ifc = ifcopenshell.file() + restriction = Restriction(options=["Foo", "Bar"], type="enumeration") + facet = Attribute(name="Name", value=restriction) + element = ifc.createIfcWall(Name="Foo") + run("An enumeration matches case sensitively 1/3", facet=facet, inst=element, expected=True) + element.Name = "Bar" + run("An enumeration matches case sensitively 1/3", facet=facet, inst=element, expected=True) + element.Name = "Baz" + run("An enumeration matches case sensitively 1/3", facet=facet, inst=element, expected=False) + + ifc = ifcopenshell.file() + restriction = Restriction(options={"minInclusive": 0, "maxInclusive": 10}, type="bounds", base="integer") + element = ifc.createIfcSurfaceStyleRefraction(RefractionIndex=0) + facet = Attribute(name="RefractionIndex", value=restriction) + run("A bound can be inclusive 1/4", facet=facet, inst=element, expected=True) + element.RefractionIndex = 5 + run("A bound can be inclusive 2/4", facet=facet, inst=element, expected=True) + element.RefractionIndex = 10 + run("A bound can be inclusive 3/4", facet=facet, inst=element, expected=True) + element.RefractionIndex = 100 + run("A bound can be inclusive 4/4", facet=facet, inst=element, expected=False) + + restriction = Restriction(options={"minExclusive": 0, "maxExclusive": 10}, type="bounds", base="integer") + facet = Attribute(name="RefractionIndex", value=restriction) + element.RefractionIndex = 0 + run("A bound can be inclusive 1/3", facet=facet, inst=element, expected=False) + element.RefractionIndex = 5 + run("A bound can be inclusive 2/3", facet=facet, inst=element, expected=True) + element.RefractionIndex = 10 + run("A bound can be inclusive 3/3", facet=facet, inst=element, expected=False) + + ifc = ifcopenshell.file() + restriction = Restriction(options="[A-Z]{2}[0-9]{2}", type="pattern") + facet = Attribute(name="Name", value=restriction) + element = ifc.createIfcWall(Name="WT01") + run("Regex patterns can be used 1/3", facet=facet, inst=element, expected=True) + element.Name = "XY99" + run("Regex patterns can be used 2/3", facet=facet, inst=element, expected=True) + element.Name = "A5" + run("Regex patterns can be used 3/3", facet=facet, inst=element, expected=False) diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py index 04c8f3ccdc..310090315f 100644 --- a/src/ifctester/test/test_ids.py +++ b/src/ifctester/test/test_ids.py @@ -23,6 +23,14 @@ import ifcopenshell from ifctester import ids +def run(name, specs, model, expected, applicable_entities, failed_entities): + specs.validate(model) + spec = specs.specifications[0] + assert spec.status is expected + assert set(spec.applicable_entities) == set(applicable_entities) + assert set(spec.requirements[0].failed_entities) == set(failed_entities) + + class TestIds: def test_failing_on_opening_invalid_ids_data(self): with pytest.raises(xmlschema.validators.exceptions.XMLSchemaValidationError): @@ -106,11 +114,73 @@ class TestIds: model = ifcopenshell.file() wall = model.createIfcWall() waldo = model.createIfcWall(Name="Waldo") - specs.validate(model) + run("A minimal IDS can check a minimal IFC 1/2", specs, model, False, [wall, waldo], [wall]) + wall.Name = "Waldo" + run("A minimal IDS can check a minimal IFC 2/2", specs, model, True, [wall, waldo], []) - assert spec.status == False - assert set(spec.applicable_entities) == {wall, waldo} - assert spec.requirements[0].failed_entities == [wall] + spec.ifcVersion = [] + run( + "Specification version is purely metadata and does not impact pass or fail result", + specs, + model, + True, + [wall, waldo], + ) + + spec.minOccurs = 1 + model = ifcopenshell.file() + waldo = model.createIfcWall(Name="Waldo") + run("Required specifications need at least one applicable entity 1/2", specs, model, True, [waldo]) + model = ifcopenshell.file() + waldo = model.createIfcSlab(Name="Waldo") + run("Required specifications need at least one applicable entity 2/2", specs, model, False) + + spec.minOccurs = 0 + model = ifcopenshell.file() + waldo = model.createIfcSlab(Name="Waldo") + run("Optional specifications may still pass if nothing is applicable", specs, model, True) + + spec.minOccurs = 0 + spec.maxOccurs = 0 + model = ifcopenshell.file() + wall = model.createIfcSlab(Name="Waldo") + run("Prohibited specifications fail if at least one entity passes all requirements 1/3", specs, model, True) + model = ifcopenshell.file() + wall = model.createIfcWall(Name="Wally") + run("Prohibited specifications fail if at least one entity passes all requirements 2/3", specs, model, True, [wall], [wall]) + model = ifcopenshell.file() + wall = model.createIfcWall(Name="Waldo") + run("Prohibited specifications fail if at least one entity passes all requirements 3/3", specs, model, False, [wall]) + + spec.minOccurs = 0 + spec.maxOccurs = "unbounded" + model = ifcopenshell.file() + wall = model.createIfcWall(Name="Waldo") + spec.requirements.append(ids.Attribute(name="Description", value="Foobar")) + run("A specification passes only if all requirements pass 1/2", specs, model, False, [wall], [wall]) + wall.Description = "Foobar" + run("A specification passes only if all requirements pass 2/2", specs, model, True, [wall]) + + spec.requirements[1].minOccurs = 0 + wall.Description = None + run("Specification optionality and facet optionality can be combined", specs, model, True, [wall]) + + spec.minOccurs = 0 + spec.maxOccurs = 0 + spec.requirements[0].minOccurs = 0 + spec.requirements[0].maxOccurs = 0 + spec.requirements[1].minOccurs = 0 + spec.requirements[1].maxOccurs = 0 + wall.Name = "Waldo" + wall.Description = "Foobar" + run("A prohibited specification and a prohibited facet results in a double negative", specs, model, True, [wall]) + + spec = ids.Specification(name="Name") + spec.applicability.append(ids.Entity(name="IFCWALL")) + spec.requirements.append(ids.Attribute(name="Name", value="Waldo")) + specs.specifications.append(spec) + wall2 = model.createIfcWall(Name="Waldo") + run("Multiple specifications are independent of one another", specs, model, True, [wall, wall2]) def test_creating_multiple_specifications(self): specs = ids.Ids(title="Title") @@ -138,9 +208,12 @@ class TestIds: class TestSpecification: def test_create_specification_with_minimal_information(self): spec = ids.Specification() + print(spec.asdict()) assert spec.asdict() == { "@name": "Unnamed", "@ifcVersion": ["IFC2X3", "IFC4"], + "@minOccurs": 0, + "@maxOccurs": "unbounded", "applicability": {}, "requirements": {}, } @@ -148,8 +221,8 @@ class TestSpecification: def test_create_specification_with_all_possible_information(self): spec = ids.Specification( name="name", - minOccurs="0", - maxOccurs="unbounded", + minOccurs=1, + maxOccurs=1, ifcVersion="IFC4", identifier="identifier", description="description", @@ -157,8 +230,8 @@ class TestSpecification: ) assert spec.asdict() == { "@name": "name", - "@minOccurs": "0", - "@maxOccurs": "unbounded", + "@minOccurs": 1, + "@maxOccurs": 1, "@ifcVersion": "IFC4", "@identifier": "identifier", "@description": "description",