Add dimensional-analysis fallback to get_project_unit() for IfcDerivedUnit

This commit is contained in:
Richard Brice
2026-08-10 14:18:43 -07:00
parent 7727a4fb4d
commit ebb498ddd0
2 changed files with 49 additions and 1 deletions
@@ -466,7 +466,16 @@ def cache_units(ifc_file: ifcopenshell.file) -> None:
"""
ifc_file.units = {}
if assignment := get_unit_assignment(ifc_file):
ifc_file.units = {u.UnitType: u for u in assignment.Units if getattr(u, "UnitType", None)}
all_units = assignment.Units or []
units = {u.UnitType: u for u in all_units if getattr(u, "UnitType", None)}
# As in get_project_unit(): a literal match always wins; an IfcDerivedUnit with no
# literal UnitType match is matched dimensionally instead, only to fill a gap.
for unit in all_units:
if unit.is_a("IfcDerivedUnit"):
dimension = identify_unit_dimensions(unit)
if dimension and dimension not in units:
units[dimension] = unit
ifc_file.units = units
def clear_unit_cache(ifc_file: ifcopenshell.file) -> None:
@@ -482,6 +491,10 @@ def get_project_unit(
) -> Union[ifcopenshell.entity_instance, None]:
"""Get the default project unit of a particular unit type
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a fallback, by
dimensional analysis (:func:`identify_unit_dimensions`), mirroring
:func:`get_candidate_units`.
:param ifc_file: The IFC file.
:param unit_type: The type of unit, taken from the list of IFC unit types,
such as "LENGTHUNIT".
@@ -493,9 +506,13 @@ def get_project_unit(
if units := ifc_file.units:
return units.get(unit_type, None)
if unit_assignment := get_unit_assignment(ifc_file):
dimensional_match = None
for unit in unit_assignment.Units or []:
if getattr(unit, "UnitType", None) == unit_type:
return unit
if dimensional_match is None and unit.is_a("IfcDerivedUnit") and identify_unit_dimensions(unit) == unit_type:
dimensional_match = unit
return dimensional_match
def get_candidate_units(ifc_file: ifcopenshell.file, unit_type: str) -> list[ifcopenshell.entity_instance]:
@@ -94,6 +94,37 @@ class TestGetProjectUnit(test.bootstrap.IFC4):
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
def test_area_and_volume_derived_from_length_are_matched_dimensionally(self):
# AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project whose area/volume
# default is an IfcDerivedUnit has no literal UnitType match for either -- get_project_unit
# must still resolve them by dimensional analysis, like get_candidate_units already does.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
volume = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "volume-ish", {length: 3})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area, volume])
assert subject.get_project_unit(self.file, "AREAUNIT") == area
assert subject.get_project_unit(self.file, "VOLUMEUNIT") == volume
def test_literal_unit_type_match_takes_priority_over_dimensional(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
literal_area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
derived_area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, literal_area, derived_area])
assert subject.get_project_unit(self.file, "AREAUNIT") == literal_area
def test_dimensional_fallback_also_applies_when_using_a_cache(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert subject.get_project_unit(self.file, "AREAUNIT", use_cache=True) == area
assert self.file.units["AREAUNIT"] == area
class TestGetCandidateUnits(test.bootstrap.IFC4):
def test_returns_only_units_matching_the_unit_type(self):