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