From b5824ffab10a23983e63110270be502e06726992 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 20:03:47 +0300 Subject: [PATCH] validate.py: flag instantiated measures with no corresponding unit An instantiated measure (e.g. IfcLengthMeasure occupying an IfcValue-typed attribute such as IfcPropertySingleValue.NominalValue) is only meaningful if there is a project unit, an explicit per-instance unit override, or an IfcMeasureWithUnit wrapper to interpret it against. validate() previously never checked this. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/validate.py | 106 ++++++++++++++++++ .../fail-instantiated-measure-no-unit.ifc | 10 ++ ...ass-instantiated-measure-dimensionless.ifc | 12 ++ ...ass-instantiated-measure-explicit-unit.ifc | 11 ++ ...pass-instantiated-measure-project-unit.ifc | 16 +++ ...instantiated-measure-with-unit-wrapper.ifc | 11 ++ 6 files changed, 166 insertions(+) create mode 100644 src/ifcopenshell-python/test/fixtures/validate/fail-instantiated-measure-no-unit.ifc create mode 100644 src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-dimensionless.ifc create mode 100644 src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-explicit-unit.ifc create mode 100644 src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-project-unit.ifc create mode 100644 src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-with-unit-wrapper.ifc diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 46269fe33b..3b4dac15fe 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -61,6 +61,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union import ifcopenshell import ifcopenshell.express.rule_executor import ifcopenshell.ifcopenshell_wrapper +import ifcopenshell.util.unit if TYPE_CHECKING: import ifcopenshell.simple_spf @@ -231,6 +232,88 @@ def get_select_members(schema: schema_definition, ty: select_type) -> set[str]: return v +measure_unit_type_cache: dict[str, dict[str, str]] = {} + + +def get_measure_unit_types(schema: schema_definition) -> dict[str, str]: + """Map instantiable measure type names to the unit type a project unit needs to be defined for. + + Some measure types (e.g. ratios, counts, descriptive or monetary measures) have no + corresponding entry in IfcUnitEnum / IfcDerivedUnitEnum, so they are excluded: their + value is meaningful without any project unit being defined. + + :return: A mapping of measure type name (e.g. "IfcLengthMeasure") to unit type + (e.g. "LENGTHUNIT"). + """ + cache_key = schema.name() + if (from_cache := measure_unit_type_cache.get(cache_key)) is not None: + return from_cache + + valid_unit_types: set[str] = set() + for enum_name in ("IfcUnitEnum", "IfcDerivedUnitEnum"): + try: + decl = schema.declaration_by_name(enum_name) + except RuntimeError: + continue + valid_unit_types.update(decl.enumeration_items()) + valid_unit_types.discard("USERDEFINED") + + mapping: dict[str, str] = {} + for decl in schema.declarations(): + if isinstance(decl, type_declaration) and decl.name().endswith("Measure"): + unit_type = ifcopenshell.util.unit.get_measure_unit_type(decl.name()) + if unit_type in valid_unit_types: + mapping[decl.name()] = unit_type + + measure_unit_type_cache[cache_key] = mapping + return mapping + + +def get_project_units_by_type(f: ifcopenshell.file) -> dict[str, ifcopenshell.entity_instance]: + """Get the project's units, keyed by their unit type (e.g. "LENGTHUNIT").""" + projects = f.by_type("IfcProject") + if not projects or not (assignment := projects[0].UnitsInContext): + return {} + return {unit_type: u for u in assignment.Units or [] if (unit_type := getattr(u, "UnitType", None))} + + +# Attributes that, of a measure occurrence's containing instance, may carry an explicit unit +# that supersedes the need for a project unit. +measure_unit_override_attr = { + ("IfcPropertySingleValue", "NominalValue"): "Unit", + ("IfcPropertyListValue", "ListValues"): "Unit", + ("IfcPropertyBoundedValue", "UpperBoundValue"): "Unit", + ("IfcPropertyBoundedValue", "LowerBoundValue"): "Unit", + ("IfcPropertyBoundedValue", "SetPointValue"): "Unit", + ("IfcPropertyEnumeration", "EnumerationValues"): "Unit", + ("IfcPropertyTableValue", "DefiningValues"): "DefiningUnit", + ("IfcPropertyTableValue", "DefinedValues"): "DefinedUnit", + ("IfcAppliedValue", "AppliedValue"): "UnitBasis", +} + + +def has_explicit_measure_unit(inst: ifcopenshell.entity_instance, entity_name: str, attr_name: str) -> bool: + """Check whether an instantiated measure occurrence is exempt from needing a project unit.""" + if entity_name == "IfcMeasureWithUnit" and attr_name == "ValueComponent": + # The sibling UnitComponent attribute is the unit for this exact value. + return True + if entity_name == "IfcPropertyEnumeratedValue" and attr_name == "EnumerationValues": + ref = inst.EnumerationReference + return bool(ref is not None and ref.Unit is not None) + if override_attr := measure_unit_override_attr.get((entity_name, attr_name)): + return getattr(inst, override_attr, None) is not None + return False + + +def iter_measure_instances(val: Any) -> Iterator[ifcopenshell.entity_instance]: + """Recursively yield the instantiated measure values (e.g. IfcLengthMeasure) within `val`.""" + if isinstance(val, tuple): + for v in val: + yield from iter_measure_instances(v) + elif isinstance(val, ifcopenshell.entity_instance): + yield val + + def assert_valid( attr_type: attribute_types, val: Any, @@ -471,6 +554,8 @@ def validate(f: Union[ifcopenshell.file, str], logger: Union[Logger, json_logger schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(f.schema_identifier) used_guids: dict[str, ifcopenshell.entity_instance] = dict() + measure_unit_types = get_measure_unit_types(schema) + project_units = get_project_units_by_type(f) for inst in f: if isinstance(logger, json_logger): @@ -575,6 +660,27 @@ def validate(f: Union[ifcopenshell.file, str], logger: Union[Logger, json_logger annotate_inst_attr_pos(inst, i), e, ) + else: + for measure_inst in iter_measure_instances(val): + unit_type = measure_unit_types.get(measure_inst.is_a()) + if not unit_type or unit_type in project_units: + continue + if has_explicit_measure_unit(inst, entity.name(), attr.name()): + continue + if isinstance(logger, json_logger): + logger.set_state("attribute", f"{entity.name()}.{attr.name()}") + logger.error("Instantiated measure %s has no corresponding unit", measure_inst) + else: + logger.error( + "For instance:\n %s\n %s\nWith attribute:\n %s\n" + "Instantiated measure:\n %s\nHas no project unit (%s), is not " + "wrapped in IfcMeasureWithUnit, and has no explicit property unit\n", + inst, + annotate_inst_attr_pos(inst, i), + attr, + measure_inst, + unit_type, + ) for attr in entity.all_inverse_attributes(): try: diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-instantiated-measure-no-unit.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-instantiated-measure-no-unit.ifc new file mode 100644 index 0000000000..3e602b924c --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-instantiated-measure-no-unit.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROPERTYSINGLEVALUE('Length',$,IFCLENGTHMEASURE(5.),$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-dimensionless.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-dimensionless.ifc new file mode 100644 index 0000000000..c0da72948f --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-dimensionless.ifc @@ -0,0 +1,12 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROPERTYSINGLEVALUE('Ratio',$,IFCRATIOMEASURE(0.5),$); +#2=IFCPROPERTYSINGLEVALUE('Count',$,IFCCOUNTMEASURE(3.),$); +#3=IFCPROPERTYSINGLEVALUE('Money',$,IFCMONETARYMEASURE(3.),$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-explicit-unit.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-explicit-unit.ifc new file mode 100644 index 0000000000..93ea0daff7 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-explicit-unit.ifc @@ -0,0 +1,11 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#2=IFCPROPERTYSINGLEVALUE('Length',$,IFCLENGTHMEASURE(5.),#1); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-project-unit.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-project-unit.ifc new file mode 100644 index 0000000000..b058ba4109 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-project-unit.ifc @@ -0,0 +1,16 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('1iBd2lkrX61vZxF9AnXLa0',$,'My Project',$,$,$,$,(#3),#2); +#2=IFCUNITASSIGNMENT((#4)); +#3=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#5,$); +#4=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#5=IFCAXIS2PLACEMENT3D(#6,$,$); +#6=IFCCARTESIANPOINT((0.,0.,0.)); +#7=IFCPROPERTYSINGLEVALUE('Length',$,IFCLENGTHMEASURE(5.),$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-with-unit-wrapper.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-with-unit-wrapper.ifc new file mode 100644 index 0000000000..dda83f093b --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-instantiated-measure-with-unit-wrapper.ifc @@ -0,0 +1,11 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#2=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(5.),#1); +ENDSEC; +END-ISO-10303-21;