Make quantity take-off respect manual Unit overrides; add target-unit support

This commit is contained in:
Richard Brice
2026-08-10 14:36:26 -07:00
parent c0f967e81e
commit 7727a4fb4d
2 changed files with 225 additions and 4 deletions
+86 -4
View File
@@ -24,7 +24,7 @@ import os
import types
from collections import defaultdict
from collections.abc import Iterable
from typing import Any, Literal, NamedTuple, Union, get_args
from typing import Any, Literal, NamedTuple, Optional, Union, get_args
import ifcopenshell
import ifcopenshell.api.pset
@@ -127,8 +127,61 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst
return results
def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
"""Apply quantification results as quantity sets."""
def get_quantity_measures(rules: dict) -> dict[str, dict[str, str]]:
"""Statically derive each quantity's measure class from the rule set that defines it,
reading it straight from the calculator's own Function table (the same source the
calculator itself used to compute the value) -- not guessed from the quantity name.
:param rules: A rule set as accepted by :func:`quantify`, e.g. from `ifc5d.qto.rules`.
:return: `qto_name -> quantity_name -> measure class` (e.g. "IfcLengthMeasure"), matching
the keys used by `SI2ProjectUnitConverter.project_units`.
"""
measures: dict[str, dict[str, str]] = {}
for calculator_name, queries in rules.get("calculators", {}).items():
calculator = calculators[calculator_name]
for _entity_or_query, qtos in queries.items():
for qto_name, quantities in qtos.items():
for quantity_name, formula in quantities.items():
if not formula:
continue
function = calculator.functions.get(formula)
if function is None:
continue
measures.setdefault(qto_name, {})[quantity_name] = function.measure
return measures
def _reconvert(ifc_file: ifcopenshell.file, value: float, to_unit: ifcopenshell.entity_instance) -> float:
"""Re-express `value` (as computed by `SI2ProjectUnitConverter` -- the project's default
unit for its dimension, or, if the project has none, raw SI, mirroring `convert()`'s own
fallback below) in `to_unit`, which shares `to_unit`'s dimension (`UnitType`).
"""
unit_type = getattr(to_unit, "UnitType", None)
from_unit = ifcopenshell.util.unit.get_project_unit(ifc_file, unit_type) if unit_type else None
from_scale = ifcopenshell.util.unit.get_unit_scale(from_unit) if from_unit else 1.0 # already SI
return value * from_scale / ifcopenshell.util.unit.get_unit_scale(to_unit)
def edit_qtos(
ifc_file: ifcopenshell.file,
results: ResultsDict,
target_units: Optional[dict[str, ifcopenshell.entity_instance]] = None,
rules: Optional[dict] = None,
) -> None:
"""Apply quantification results as quantity sets.
:param target_units: Optional map of measure class (e.g. "IfcLengthMeasure", matching
`SI2ProjectUnitConverter.project_units`'s keys) to a unit to express *newly created*
quantities of that measure in, instead of the project default. Ignored unless `rules`
is also given (needed to resolve each quantity's measure class -- see
`get_quantity_measures`). Has no effect on quantities that already exist -- those are
always re-expressed in whatever Unit they already carry (see below), regardless of
`target_units`.
:param rules: The rule set used to produce `results` (the same object passed to
`quantify()`), used only to resolve `target_units` via `get_quantity_measures()`.
"""
quantity_measures = get_quantity_measures(rules) if (target_units and rules) else {}
for element, qtos in results.items():
for name, quantities in qtos.items():
qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False)
@@ -136,7 +189,36 @@ def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
qto = ifc_file.by_id(qto["id"])
else:
qto = ifcopenshell.api.pset.add_qto(ifc_file, element, name)
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=quantities)
existing_by_name = {q.Name: q for q in (qto.Quantities or ())}
wrapped_quantities: dict[str, Any] = {}
for quantity_name, value in quantities.items():
existing_unit = getattr(existing_by_name.get(quantity_name), "Unit", None)
if existing_unit is not None:
# A quantity that already carries its own Unit override must be
# re-expressed in that unit, not overwritten with a value computed in
# the project default while the stale Unit label stays put.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, existing_unit),
"Unit": existing_unit,
}
continue
measure = quantity_measures.get(name, {}).get(quantity_name)
target_unit = target_units.get(measure) if (target_units and measure) else None
if target_unit is not None:
# Brand new quantity, proactively expressed in the chosen target unit.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, target_unit),
"Unit": target_unit,
}
continue
wrapped_quantities[quantity_name] = value # unchanged bare-float path
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=wrapped_quantities)
class SI2ProjectUnitConverter:
+139
View File
@@ -22,6 +22,7 @@ import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.element
import pytest
import ifc5d.qto
@@ -90,3 +91,141 @@ class TestOpeningQuantities:
assert quantities["Depth"] == pytest.approx(0.3)
assert quantities["Area"] == pytest.approx(0.5)
assert quantities["Volume"] == pytest.approx(0.15)
class TestGetQuantityMeasures:
def test_resolves_measures_from_the_calculator_function_table(self):
measures = ifc5d.qto.get_quantity_measures(ifc5d.qto.rules["IFC4X3QtoBaseQuantities"])
assert measures["Qto_WallBaseQuantities"]["Length"] == "IfcLengthMeasure"
assert measures["Qto_WallBaseQuantities"]["NetWeight"] == "IfcMassMeasure"
class TestEditQtos:
def setup_method(self):
self.file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test")
self.wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
def get_quantity(self, name: str) -> ifcopenshell.entity_instance:
pset = ifcopenshell.util.element.get_pset(self.wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = self.file.by_id(pset["id"])
return next(q for q in qto.Quantities if q.Name == name)
def test_new_quantity_with_no_target_unit_is_a_bare_value(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5.0)
assert quantity.Unit is None
def test_existing_manual_unit_override_is_reconverted_not_left_stale(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
# Simulate a user picking a millimetre override via the per-property picker.
quantity.Unit = millimetre
quantity.LengthValue = 5000.0
# Re-running take-off recomputes the value in the project default (metres) again --
# this must not leave the recomputed metres value mislabeled as millimetres.
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 6.0}}})
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(6000.0)
def test_target_unit_applies_only_to_brand_new_quantities(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(5000.0)
def test_target_units_are_ignored_without_rules(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
# `rules` is required to resolve a quantity's measure class -- without it, target_units
# has nothing to key off, so brand new quantities fall back to today's bare-float path.
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
)
quantity = self.get_quantity("Length")
assert quantity.Unit is None
assert quantity.LengthValue == pytest.approx(5.0)
def test_reconvert_treats_a_missing_project_default_as_raw_si(self):
# No LENGTHUNIT is assigned to the project at all, so SI2ProjectUnitConverter.convert()
# would have left the calculated value as raw SI (metres) -- _reconvert must match.
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5000.0)
class TestEditQtosIntegration:
"""A real quantify() + edit_qtos() round trip, guarding against edit_qto's own
class-inference disagreeing with get_quantity_measures()'s notion of measure.
"""
def test_target_unit_produces_the_correct_quantity_class(self):
file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(file, ifc_class="IfcProject", name="Test")
metre = file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
sqm = file.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE")
cum = file.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE")
ifcopenshell.api.unit.assign_unit(file, units=[metre, sqm, cum])
model = ifcopenshell.api.context.add_context(file, context_type="Model")
body = ifcopenshell.api.context.add_context(
file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model
)
wall = ifcopenshell.api.root.create_entity(file, ifc_class="IfcWall")
wall.ObjectPlacement = file.createIfcLocalPlacement(
None, file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
)
profile = file.createIfcRectangleProfileDef("AREA", None, None, 5.0, 0.2)
position = file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
solid = file.createIfcExtrudedAreaSolid(profile, position, file.createIfcDirection((0.0, 0.0, 1.0)), 3.0)
rep = file.createIfcShapeRepresentation(body, "Body", "SweptSolid", [solid])
wall.Representation = file.createIfcProductDefinitionShape(None, None, [rep])
millimetre = file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"]
results = ifc5d.qto.quantify(file, {wall}, rules)
ifc5d.qto.edit_qtos(file, results, target_units={"IfcLengthMeasure": millimetre}, rules=rules)
pset = ifcopenshell.util.element.get_pset(wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = file.by_id(pset["id"])
length = next(q for q in qto.Quantities if q.Name == "Length")
assert length.is_a("IfcQuantityLength")
assert length.Unit == millimetre
assert length.LengthValue == pytest.approx(5000.0)