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}