Skip to main content

ifc_properties/quantity/
validation.rs

1//! Comparing an authored quantity against an externally computed value.
2//!
3//! # This crate never measures anything
4//!
5//! The invariant: an IFC quantity is an authored
6//! assertion, and applications compute shape elsewhere and pass the typed
7//! result in. So this module takes a value the CALLER computed and reports
8//! whether the file agrees. It does not open a geometry crate, and it cannot:
9//! `ifc-properties` has no geometry dependency and the boundary gate enforces
10//! that.
11//!
12//! # Comparison needs a tolerance, and the honest default is relative
13//!
14//! An absolute epsilon that suits a 0.2 m thickness is meaningless for a
15//! 4000 m3 volume. Comparison is therefore relative to the larger magnitude,
16//! with an absolute floor so values near zero do not demand infinite
17//! precision.
18//!
19//! # Units are not converted silently
20//!
21//! If the authored quantity states millimetres and the caller computed
22//! metres, the numbers differ by 1000 and no tolerance makes that agreement.
23//! Rather than guess, [`Comparison::UnitMismatch`] reports it: a silent
24//! conversion is how a 1000x error becomes a passing check.
25
26use ifc_model::{EntityId, Model};
27
28use crate::quantity::{Quantity, QuantityKind};
29use crate::unit::{unit, UnitKind};
30
31/// How an authored quantity compares to an externally computed value.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub enum Comparison {
35    /// The file agrees with the computed value within tolerance.
36    Agrees {
37        /// The authored value.
38        authored: f64,
39        /// The value supplied by the caller.
40        computed: f64,
41        /// Relative difference actually observed.
42        relative_difference: f64,
43    },
44    /// The file states a different value.
45    Disagrees {
46        /// The authored value.
47        authored: f64,
48        /// The value supplied by the caller.
49        computed: f64,
50        /// Relative difference actually observed.
51        relative_difference: f64,
52    },
53    /// The quantity and the computed value are in different units.
54    ///
55    /// Reported rather than converted: see the module note.
56    UnitMismatch {
57        /// The unit the quantity states.
58        authored_unit: String,
59        /// The unit the caller says its value is in.
60        computed_unit: String,
61    },
62    /// The quantity does not measure what the caller computed.
63    ///
64    /// Comparing an authored area to a computed volume is a caller error, and
65    /// silently returning `Disagrees` would hide it.
66    KindMismatch {
67        /// What the quantity measures.
68        authored: QuantityKind,
69        /// What the caller says it computed.
70        computed: QuantityKind,
71    },
72    /// The quantity carries no comparable scalar: complex, unsupported, or
73    /// [`Quantity::Unresolved`] (a missing or non-numeric value is not
74    /// compared as 0).
75    NotComparable,
76}
77
78/// A value computed outside this crate, with its meaning attached.
79///
80/// Constructing one requires stating the kind and unit, so a bare `f64`
81/// cannot cross the boundary -- the crate invariant that units stay explicit.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ComputedQuantity {
84    /// What was measured.
85    pub kind: QuantityKind,
86    /// The magnitude.
87    pub value: f64,
88    /// The `IfcUnitEnum`-style unit name the value is expressed in, e.g.
89    /// `METRE` with no prefix. Compared textually against the authored unit.
90    pub unit: String,
91}
92
93/// Tolerance for comparing two measurements.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct Tolerance {
96    /// Fractional difference allowed, e.g. `1e-6`.
97    pub relative: f64,
98    /// Absolute floor, so near-zero values stay comparable.
99    pub absolute: f64,
100}
101
102impl Default for Tolerance {
103    /// A tolerance suited to authored building data.
104    ///
105    /// 1e-6 relative is far tighter than any exporter's rounding, so a
106    /// disagreement means a real difference rather than float noise.
107    fn default() -> Self {
108        Self {
109            relative: 1e-6,
110            absolute: 1e-9,
111        }
112    }
113}
114
115/// Compare one authored quantity against a computed value.
116///
117/// Only a [`Quantity::Simple`] is compared, and only against a computed value
118/// of the same [`QuantityKind`]. An IFC4X3 `IfcQuantityNumber`
119/// ([`QuantityKind::Number`]) is compared like the others: against a computed
120/// `Number`, with a stated unit matched textually and no unit assumed when
121/// it states none. It has no `>= 0` rule, so a negative number compares as
122/// it is.
123pub fn compare(
124    model: &Model,
125    quantity: &Quantity,
126    computed: &ComputedQuantity,
127    tolerance: Tolerance,
128) -> Comparison {
129    let (kind, value, unit_id) = match quantity {
130        Quantity::Simple {
131            kind, value, unit, ..
132        } => (kind, value, unit),
133        // No value: comparing would have to invent one.
134        Quantity::Unresolved { .. } => return Comparison::NotComparable,
135        _ => return Comparison::NotComparable,
136    };
137
138    if *kind != computed.kind {
139        return Comparison::KindMismatch {
140            authored: *kind,
141            computed: computed.kind,
142        };
143    }
144
145    if let Some(authored_unit) = unit_id.and_then(|id| unit_name(model, id)) {
146        if !authored_unit.eq_ignore_ascii_case(&computed.unit) {
147            return Comparison::UnitMismatch {
148                authored_unit,
149                computed_unit: computed.unit.clone(),
150            };
151        }
152    }
153
154    let difference = (value - computed.value).abs();
155    let magnitude = value.abs().max(computed.value.abs());
156    // Relative to the larger magnitude, with an absolute floor near zero.
157    let relative_difference = if magnitude > 0.0 {
158        difference / magnitude
159    } else {
160        0.0
161    };
162    let agrees = difference <= tolerance.absolute || relative_difference <= tolerance.relative;
163
164    if agrees {
165        Comparison::Agrees {
166            authored: *value,
167            computed: computed.value,
168            relative_difference,
169        }
170    } else {
171        Comparison::Disagrees {
172            authored: *value,
173            computed: computed.value,
174            relative_difference,
175        }
176    }
177}
178
179/// A comparable name for a unit, including its prefix.
180///
181/// `MILLI` + `METRE` becomes `MILLIMETRE`, so a prefixed unit does not
182/// compare equal to its base. That is the point: mm and m are different
183/// units and a check that conflates them is worse than no check.
184fn unit_name(model: &Model, id: EntityId) -> Option<String> {
185    match unit(model, id)? {
186        UnitKind::Si { name, prefix, .. } => Some(match prefix {
187            Some(p) => format!("{p}{name}"),
188            None => name.to_string(),
189        }),
190        UnitKind::Conversion { name, .. } => name.map(|n| n.to_string()),
191        UnitKind::Monetary { currency } => currency.map(|c| c.to_string()),
192        UnitKind::Derived { unit_type, .. } | UnitKind::ContextDependent { unit_type } => {
193            Some(unit_type.to_string())
194        }
195    }
196}