Add per-property/quantity Unit-override support to edit_pset/edit_qto, plus unit-scale and candidate-unit helpers

edit_pset()'s unpack_unit_value() couldn't distinguish "no Unit dict was
passed" from "{"Unit": None, ...} passed to explicitly clear an existing
override" -- both collapsed to a bare None, and every consuming call site
checked truthiness, so there was no way to actually clear a previously-set
property Unit override once one existed. Fixed with a private _NO_UNIT
sentinel; bare (unwrapped) values still leave Unit untouched exactly as
before.

edit_qto() had no Unit-handling capability at all: neither
update_existing_property() nor add_new_properties() ever read or wrote a
quantity's Unit attribute. Added the same {"Unit": ..., "NominalValue": ...}
wrapped-dict convention edit_pset() already supports, disambiguated from
the pre-existing IfcPhysicalComplexQuantity dict convention
({"Discrimination": ..., "HasQuantities": ...}) by checking for a "Unit"
key -- a complex-quantity spec never contains one.

ifcopenshell.util.unit gains two small helpers:
- get_unit_scale(unit): dispatches to get_derived_unit_scale/
  get_named_unit_scale depending on unit type, also used to de-duplicate
  calculate_unit_scale()'s own inline dispatch of the same logic.
- get_candidate_units(ifc_file, unit_type): all units in a file matching a
  given unit type, unlike get_project_unit()'s single-default lookup.

Adds regression tests for all of the above, including explicit-clear,
bare-value-preserves-override, and complex-quantity-routing-unaffected
cases.
This commit is contained in:
Richard Brice
2026-08-10 10:51:59 -07:00
parent d067cfd1b5
commit d19c86c72a
6 changed files with 265 additions and 21 deletions
@@ -188,6 +188,36 @@ class TestEditPsetIFC2X3(test.bootstrap.IFC2X3):
assert unit.Prefix == "GIGA"
assert unit.Name == "PASCAL"
def test_explicitly_clearing_a_propertys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 40.0, "Unit": None}}
)
assert prop.Unit is None
assert prop.NominalValue.wrappedValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"MyCustom": 42.0})
assert prop.Unit == custom_unit
assert prop.NominalValue.wrappedValue == 42.0
def test_editing_properties_of_non_rooted_elements(self):
element = self.file.createIfcMaterial()
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
@@ -18,6 +18,7 @@
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import test.bootstrap
@@ -150,3 +151,87 @@ class TestEditQto(test.bootstrap.IFC4):
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 34
def test_adding_a_new_quantity_with_a_custom_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 30.0
assert qto.Quantities[0].Unit == custom_unit
def test_editing_an_existing_quantitys_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 12.0})
quantity = qto.Quantities[0]
assert quantity.Unit is None
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 30.0
def test_explicitly_clearing_a_quantitys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 40.0, "Unit": None}}
)
assert quantity.Unit is None
assert quantity.LengthValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 42.0})
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 42.0
def test_complex_quantity_editing_is_unaffected_by_the_unit_wrapper_convention(self):
# Regression guard: dict values are already overloaded to mean "this is an
# IfcPhysicalComplexQuantity spec" ({"Discrimination": ..., "HasQuantities": ...}).
# The new {"Unit": ..., "NominalValue": ...} convention must not be confused with it.
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer", "HasQuantities": {"Width": 5.0}}},
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
complex_qty = qto.Quantities[0]
assert complex_qty.is_a("IfcPhysicalComplexQuantity")
assert complex_qty.Name == "Layers"
assert complex_qty.Discrimination == "layer"
assert complex_qty.HasQuantities[0].Name == "Width"
assert complex_qty.HasQuantities[0].LengthValue == 5.0
# Editing it again (update_existing_property's complex-quantity branch) still works too.
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer2", "HasQuantities": {"Width": 6.0}}},
)
assert complex_qty.Discrimination == "layer2"
assert complex_qty.HasQuantities[0].LengthValue == 6.0
@@ -95,6 +95,49 @@ class TestGetProjectUnit(test.bootstrap.IFC4):
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
class TestGetCandidateUnits(test.bootstrap.IFC4):
def test_returns_only_units_matching_the_unit_type(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
candidates = subject.get_candidate_units(self.file, "LENGTHUNIT")
assert set(candidates) == {mm, m}
assert area not in candidates
def test_returns_all_matching_units_not_just_the_assigned_default(self):
# Unlike get_project_unit, which only returns the one assigned default.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[mm])
assert subject.get_project_unit(self.file, "LENGTHUNIT") == mm
assert set(subject.get_candidate_units(self.file, "LENGTHUNIT")) == {mm, m}
def test_derived_unit_matched_by_literal_unit_type(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_candidate_units(self.file, "MODULUSOFELASTICITYUNIT") == [modulus]
def test_userdefined_derived_unit_matched_by_dimensional_fallback(self):
# No literal UnitType match (USERDEFINED), but dimensionally it's a pressure unit,
# and PRESSUREUNIT is one of the core families covered by named_dimensions.
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")
weird_pressure = ifcopenshell.api.unit.add_derived_unit(
self.file, "USERDEFINED", "pressure-ish", {force: 1, area: -1}
)
assert subject.get_candidate_units(self.file, "PRESSUREUNIT") == [weird_pressure]
def test_empty_when_nothing_matches(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
assert subject.get_candidate_units(self.file, "MASSUNIT") == []
class TestGetPropertyUnit(test.bootstrap.IFC4):
def test_no_unit(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
@@ -302,6 +345,22 @@ class TestGetDerivedUnitScale(test.bootstrap.IFC4):
assert subject.get_derived_unit_scale(weird) == pytest.approx(1 / 0.001)
class TestGetUnitScale(test.bootstrap.IFC4):
def test_dispatches_to_named_unit_scale_for_si_and_conversion_based_units(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ft = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot")
assert subject.get_unit_scale(mm) == subject.get_named_unit_scale(mm)
assert subject.get_unit_scale(ft) == subject.get_named_unit_scale(ft)
def test_dispatches_to_derived_unit_scale_for_derived_units(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_unit_scale(density) == subject.get_derived_unit_scale(density)
class TestGetUnitSymbol(test.bootstrap.IFC4):
def test_derived_unit_composes_a_symbol(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")