mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 22:50:21 +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))
|
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]:
|
def get_unit_assignment(ifc_file: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]:
|
||||||
return ifc_file.by_type("IfcProject")[0].UnitsInContext
|
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:
|
def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
|
||||||
|
if unit.is_a("IfcDerivedUnit"):
|
||||||
|
return get_derived_unit_symbol(unit)
|
||||||
symbol: str = ""
|
symbol: str = ""
|
||||||
if unit.is_a("IfcSIUnit"):
|
if unit.is_a("IfcSIUnit"):
|
||||||
symbol += prefix_symbols.get(unit.Prefix, "")
|
symbol += prefix_symbols.get(unit.Prefix, "")
|
||||||
@@ -631,6 +678,28 @@ def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
|
|||||||
return symbol
|
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:
|
def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ifcopenshell.entity_instance) -> float:
|
||||||
"""Convert from one unit to another unit
|
"""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
|
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:
|
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.
|
"""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
|
si_meters / unit_scale = ifc_project_length
|
||||||
|
|
||||||
:param ifc_file: The IFC file.
|
: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
|
:returns: The scale factor
|
||||||
"""
|
"""
|
||||||
if (
|
if type(ifc_file) is ifcopenshell.file and unit_type:
|
||||||
type(ifc_file) is ifcopenshell.file
|
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
|
||||||
and unit_type
|
valid_types = set(schema.declaration_by_name("IfcUnitEnum").enumeration_items())
|
||||||
not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
|
valid_types |= set(schema.declaration_by_name("IfcDerivedUnitEnum").enumeration_items())
|
||||||
.declaration_by_name("IfcUnitEnum")
|
if unit_type not in valid_types:
|
||||||
.enumeration_items()
|
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
|
||||||
):
|
|
||||||
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
|
|
||||||
|
|
||||||
# Currently we assume that all ifc projects must have IfcProject.
|
# Currently we assume that all ifc projects must have IfcProject.
|
||||||
if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext):
|
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:
|
for unit in units.Units:
|
||||||
if getattr(unit, "UnitType", ...) != unit_type:
|
if getattr(unit, "UnitType", ...) != unit_type:
|
||||||
continue
|
continue
|
||||||
while unit.is_a("IfcConversionBasedUnit"):
|
if unit.is_a("IfcDerivedUnit"):
|
||||||
conversion_factor = unit.ConversionFactor
|
unit_scale *= get_derived_unit_scale(unit)
|
||||||
unit_scale *= conversion_factor.ValueComponent.wrappedValue
|
else:
|
||||||
unit = conversion_factor.UnitComponent
|
unit_scale *= get_named_unit_scale(unit)
|
||||||
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
|
|
||||||
return unit_scale
|
return unit_scale
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,17 @@ class TestCalculateUnitScale(test.bootstrap.IFC4):
|
|||||||
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
|
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
|
||||||
assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001
|
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):
|
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
|
||||||
# A prefixed square/cubic metre is (prefix-metre) squared/cubed:
|
# 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.
|
# 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)
|
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):
|
class TestFormatLength(test.bootstrap.IFC4):
|
||||||
def test_run(self):
|
def test_run(self):
|
||||||
assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1"
|
assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1"
|
||||||
|
|||||||
Reference in New Issue
Block a user