diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 1fc2b6f946..5f383e7154 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -406,6 +406,51 @@ def get_named_dimensions(name): return named_dimensions.get(name, (0, 0, 0, 0, 0, 0, 0)) +def get_unit_dimensions(unit: ifcopenshell.entity_instance) -> tuple[int, int, int, int, int, int, int]: + """Get the dimensional exponents of a unit, per IfcDimensionalExponents. + + Supports IfcSIUnit, IfcConversionBasedUnit, IfcContextDependentUnit, and + IfcDerivedUnit (composed recursively from its elements). + + :param unit: The unit to inspect. + :return: A 7-tuple of (Length, Mass, Time, ElectricCurrent, + ThermodynamicTemperature, AmountOfSubstance, LuminousIntensity). + """ + if unit.is_a("IfcDerivedUnit"): + dimensions = [0, 0, 0, 0, 0, 0, 0] + for element in unit.Elements: + element_dimensions = get_unit_dimensions(element.Unit) + for i in range(7): + dimensions[i] += element_dimensions[i] * element.Exponent + return tuple(dimensions) + if unit.is_a("IfcSIUnit"): + return get_si_dimensions(unit.Name.replace("METER", "METRE")) + return get_named_dimensions(getattr(unit, "UnitType", None)) + + +def identify_unit_dimensions(unit: ifcopenshell.entity_instance) -> Union[str, None]: + """Identify which named IfcUnitEnum type a unit's dimensions correspond to. + + This is mainly useful for an IfcDerivedUnit that has no named + IfcDerivedUnitEnum match for its measure type, allowing it to still be + recognised as, e.g., a pressure unit by dimensional analysis alone. + + Note that dimensionless quantities (e.g. plane angle, solid angle, or a + genuinely unitless value) are dimensionally indistinguishable, so this + heuristically returns the first zero-dimension match rather than + disambiguating them. + + :param unit: The unit to identify. + :return: An uppercase IfcUnitEnum value, or None if no named type shares + the same dimensions. + """ + dimensions = get_unit_dimensions(unit) + for name, named in named_dimensions.items(): + if named == dimensions: + return name + return None + + def get_unit_assignment(ifc_file: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]: return ifc_file.by_type("IfcProject")[0].UnitsInContext @@ -622,6 +667,8 @@ def get_symbol_quantity_class(symbol: Optional[str] = None) -> QUANTITY_CLASS: def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str: + if unit.is_a("IfcDerivedUnit"): + return get_derived_unit_symbol(unit) symbol: str = "" if unit.is_a("IfcSIUnit"): symbol += prefix_symbols.get(unit.Prefix, "") @@ -631,6 +678,28 @@ def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str: return symbol +def get_derived_unit_symbol(unit: ifcopenshell.entity_instance) -> str: + """Compose a unit symbol for an IfcDerivedUnit from its elements. + + E.g. a derived unit of NEWTON / SQUARE_METRE composes to "N/m2". + + :param unit: The IfcDerivedUnit. + :return: The composed symbol. + """ + numerator = [] + denominator = [] + for element in unit.Elements: + symbol = get_unit_symbol(element.Unit) + exponent = abs(element.Exponent) + if exponent != 1: + symbol += str(exponent) + (numerator if element.Exponent > 0 else denominator).append(symbol) + result = ".".join(numerator) or "1" + if denominator: + result += "/" + ".".join(denominator) + return result + + def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ifcopenshell.entity_instance) -> float: """Convert from one unit to another unit @@ -684,6 +753,55 @@ def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix: return value +def get_named_unit_scale(unit: ifcopenshell.entity_instance) -> float: + """Get the scale factor to convert a value in a named unit to SI units. + + Supports IfcSIUnit and IfcConversionBasedUnit (including chains of + conversion-based units). Does not support IfcDerivedUnit -- see + :func:`get_derived_unit_scale` for that. + + :param unit: The IfcNamedUnit. + :returns: The scale factor. + """ + scale = 1.0 + while unit.is_a("IfcConversionBasedUnit"): + conversion_factor = unit.ConversionFactor + scale *= conversion_factor.ValueComponent.wrappedValue + unit = conversion_factor.UnitComponent + if unit.is_a("IfcSIUnit"): + prefix_multiplier = get_prefix_multiplier(unit.Prefix) + # An SI prefix attaches to the base unit symbol, and the prefixed + # symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3, + # not 0.1 m3. For units whose dimensions are a pure power of length + # (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must + # therefore be raised to the length exponent. Units with mixed or + # non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear + # multiplier, as there the prefix scales the derived unit itself. + # https://github.com/IfcOpenShell/IfcOpenShell/issues/9278 + # + # Dimensions is looked up from si_dimensions by name rather than via + # unit.Dimensions (the schema-derived IfcDimensionalExponents), since + # the derived attribute isn't computed for SQLite-linked files and + # would return None there. + length_exponent, *other_exponents = get_si_dimensions(unit.Name.replace("METER", "METRE")) + if length_exponent > 0 and not any(other_exponents): + prefix_multiplier **= length_exponent + scale *= prefix_multiplier + return scale + + +def get_derived_unit_scale(unit: ifcopenshell.entity_instance) -> float: + """Get the scale factor to convert a value in an IfcDerivedUnit to SI units. + + :param unit: The IfcDerivedUnit. + :returns: The scale factor. + """ + scale = 1.0 + for element in unit.Elements: + scale *= get_named_unit_scale(element.Unit) ** element.Exponent + return scale + + def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUNIT") -> float: """Returns a unit scale factor to convert to and from IFC project units and SI units. @@ -695,17 +813,17 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN si_meters / unit_scale = ifc_project_length :param ifc_file: The IFC file. - :param unit_type: The type of SI unit, defaults to "LENGTHUNIT" + :param unit_type: The type of SI unit, defaults to "LENGTHUNIT". This may + also be an IfcDerivedUnitEnum value (e.g. "MASSDENSITYUNIT") to + support project units defined as an IfcDerivedUnit. :returns: The scale factor """ - if ( - type(ifc_file) is ifcopenshell.file - and unit_type - not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) - .declaration_by_name("IfcUnitEnum") - .enumeration_items() - ): - raise ValueError(f"Unit type {unit_type!r} does not name a valid type") + if type(ifc_file) is ifcopenshell.file and unit_type: + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) + valid_types = set(schema.declaration_by_name("IfcUnitEnum").enumeration_items()) + valid_types |= set(schema.declaration_by_name("IfcDerivedUnitEnum").enumeration_items()) + if unit_type not in valid_types: + raise ValueError(f"Unit type {unit_type!r} does not name a valid type") # Currently we assume that all ifc projects must have IfcProject. if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext): @@ -715,29 +833,10 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN for unit in units.Units: if getattr(unit, "UnitType", ...) != unit_type: continue - while unit.is_a("IfcConversionBasedUnit"): - conversion_factor = unit.ConversionFactor - unit_scale *= conversion_factor.ValueComponent.wrappedValue - unit = conversion_factor.UnitComponent - if unit.is_a("IfcSIUnit"): - prefix_multiplier = get_prefix_multiplier(unit.Prefix) - # An SI prefix attaches to the base unit symbol, and the prefixed - # symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3, - # not 0.1 m3. For units whose dimensions are a pure power of length - # (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must - # therefore be raised to the length exponent. Units with mixed or - # non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear - # multiplier, as there the prefix scales the derived unit itself. - # https://github.com/IfcOpenShell/IfcOpenShell/issues/9278 - # - # Dimensions is looked up from si_dimensions by name rather than via - # unit.Dimensions (the schema-derived IfcDimensionalExponents), since - # the derived attribute isn't computed for SQLite-linked files and - # would return None there. - length_exponent, *other_exponents = get_si_dimensions(unit.Name.replace("METER", "METRE")) - if length_exponent > 0 and not any(other_exponents): - prefix_multiplier **= length_exponent - unit_scale *= prefix_multiplier + if unit.is_a("IfcDerivedUnit"): + unit_scale *= get_derived_unit_scale(unit) + else: + unit_scale *= get_named_unit_scale(unit) return unit_scale diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index 786e26076e..d0ae57ab41 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -204,6 +204,17 @@ class TestCalculateUnitScale(test.bootstrap.IFC4): ifcopenshell.api.unit.assign_unit(self.file, units=[angle]) assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001 + def test_derived_units_are_considered(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="MILLI") + modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1}) + ifcopenshell.api.unit.assign_unit(self.file, units=[modulus]) + # AREAUNIT is a pure power of length, so its MILLI prefix is raised to + # the length exponent (2) per #9278: (1e-3)**2 = 1e-6, inverted by the + # derived unit's -1 exponent to give 1e6. + assert subject.calculate_unit_scale(self.file, "MODULUSOFELASTICITYUNIT") == pytest.approx(1_000_000.0) + def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self): # A prefixed square/cubic metre is (prefix-metre) squared/cubed: # DECI SQUARE_METRE = dm2 = 1e-2 m2, DECI CUBIC_METRE = dm3 (litre) = 1e-3 m3. @@ -258,6 +269,65 @@ class TestCalculateUnitScaleOnLinkedFile(test.bootstrap.IFC4): tmp_file.unlink(missing_ok=True) +class TestGetNamedUnitScale(test.bootstrap.IFC4): + def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self): + area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="DECI") + assert subject.get_named_unit_scale(area) == pytest.approx(0.1**2) + + def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self): + pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT", prefix="KILO") + assert subject.get_named_unit_scale(pressure) == pytest.approx(1000) + + +class TestGetDerivedUnitScale(test.bootstrap.IFC4): + def test_composes_scale_from_elements(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO") + volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT") + density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1}) + assert subject.get_derived_unit_scale(density) == 1000.0 + + def test_unnamed_derived_unit_still_composes(self): + # A made-up "force per unit time" derived unit with no IfcDerivedUnitEnum match. + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT", prefix="MILLI") + weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1}) + assert subject.get_derived_unit_scale(weird) == pytest.approx(1 / 0.001) + + +class TestGetUnitSymbol(test.bootstrap.IFC4): + def test_derived_unit_composes_a_symbol(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT") + modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1}) + assert subject.get_unit_symbol(modulus) == "N/m2" + + def test_unnamed_derived_unit_still_composes_a_symbol_without_crashing(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT") + weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1}) + assert subject.get_unit_symbol(weird) == "N/s" + + +class TestIdentifyUnitDimensions(test.bootstrap.IFC4): + def test_matches_a_named_unit_type_by_dimension(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT") + modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1}) + assert subject.identify_unit_dimensions(modulus) == "PRESSUREUNIT" + + def test_returns_none_for_no_match(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT") + time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT") + weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1}) + assert subject.identify_unit_dimensions(weird) is None + + class TestFormatLength(test.bootstrap.IFC4): def test_run(self): assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1"