mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 07:49:59 +00:00
Add IfcDerivedUnit support to ifcopenshell.util.unit (scale, symbol, dimension identification)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user