diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 89e4466b6c..910efcd186 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -720,7 +720,29 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN unit_scale *= conversion_factor.ValueComponent.wrappedValue unit = conversion_factor.UnitComponent if unit.is_a("IfcSIUnit"): - unit_scale *= get_prefix_multiplier(unit.Prefix) + 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 = unit.Dimensions + length_exponent = dimensions.LengthExponent + if length_exponent > 0 and not any( + ( + dimensions.MassExponent, + dimensions.TimeExponent, + dimensions.ElectricCurrentExponent, + dimensions.ThermodynamicTemperatureExponent, + dimensions.AmountOfSubstanceExponent, + dimensions.LuminousIntensityExponent, + ) + ): + prefix_multiplier **= length_exponent + unit_scale *= prefix_multiplier return unit_scale diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index 38d78ba024..0e3af2fc07 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -200,6 +200,31 @@ 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_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. + # https://github.com/IfcOpenShell/IfcOpenShell/issues/9278 + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT") + area.Prefix = "DECI" + volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT") + volume.Prefix = "DECI" + ifcopenshell.api.unit.assign_unit(self.file, units=[area, volume]) + assert subject.calculate_unit_scale(self.file, "AREAUNIT") == pytest.approx(0.1**2) + assert subject.calculate_unit_scale(self.file, "VOLUMEUNIT") == pytest.approx(0.1**3) + + def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self): + # For derived and non-length SI units the prefix scales the unit itself: + # KILO PASCAL = 1e3 Pa, KILO GRAM = 1e3 g. + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT") + pressure.Prefix = "KILO" + mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT") + mass.Prefix = "KILO" + ifcopenshell.api.unit.assign_unit(self.file, units=[pressure, mass]) + assert subject.calculate_unit_scale(self.file, "PRESSUREUNIT") == pytest.approx(1000) + assert subject.calculate_unit_scale(self.file, "MASSUNIT") == pytest.approx(1000) + class TestFormatLength(test.bootstrap.IFC4): def test_run(self):