diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index adbece1254..c458951a84 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -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: diff --git a/src/ifc5d/test/test_qto.py b/src/ifc5d/test/test_qto.py index 8723a29836..ec2b87fe8d 100644 --- a/src/ifc5d/test/test_qto.py +++ b/src/ifc5d/test/test_qto.py @@ -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)