mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 07:49:59 +00:00
Make quantity take-off respect manual Unit overrides; add target-unit support
This commit is contained in:
+86
-4
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user