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}