///|
/// A unit label resolved to a dimensional kind and an SI scale.
pub(all) struct UnitSemantic {
label : String
normalized : String
kind : UnitKind
to_si : Double
recognized : Bool
} derive(Debug)
///|
/// One explicit conversion between two labels.
pub(all) struct UnitConversion {
kind : UnitKind
value : Double
converted : Double
from_unit : String
to_unit : String
factor : Double
recognized : Bool
} derive(Debug)
///|
/// A warning emitted while checking a model's unit declarations.
pub(all) enum UnitAuditStatus {
Consistent
Missing
Duplicate
Unknown
Incompatible
} derive(Debug, Eq)
///|
/// A normalized unit declaration from the model.
pub(all) struct UnitAuditRow {
entity_id : Int
kind : UnitKind
name : String
prefix : String?
to_si : Double
status : UnitAuditStatus
message : String
} derive(Debug)
///|
/// Summary of the unit declarations and quantity conversions in a model.
pub(all) struct UnitAuditReport {
rows : Array[UnitAuditRow]
recognized_count : Int
unknown_count : Int
duplicate_kind_count : Int
length_scale : Double
area_scale : Double
volume_scale : Double
} derive(Debug)
///|
/// A quantity normalized to the requested presentation unit.
pub(all) struct NormalizedQuantity {
product_id : Int
kind : QuantityKind
value : Double
unit : String
source : String
estimated : Bool
recognized : Bool
} derive(Debug)
///|
fn semantic_trimmed(value : String) -> String {
value.trim().to_owned().to_upper()
}
///|
fn semantic_compact(value : String) -> String {
value
.replace(old="_", new="")
.replace(old=" ", new="")
.replace(old="-", new="")
}
///|
/// Resolve the common labels used by IFC exports and quantity schedules.
pub fn unit_semantic(label : String) -> UnitSemantic {
let normalized = semantic_compact(semantic_trimmed(label))
let item : (UnitKind, Double)? = match normalized {
"M" | "METRE" | "METER" | "METRES" | "METERS" => Some((Length, 1.0))
"MM" | "MILLIMETRE" | "MILLIMETER" | "MILLIMETRES" | "MILLIMETERS" =>
Some((Length, 0.001))
"CM" | "CENTIMETRE" | "CENTIMETER" | "CENTIMETRES" | "CENTIMETERS" =>
Some((Length, 0.01))
"KM" | "KILOMETRE" | "KILOMETER" | "KILOMETRES" | "KILOMETERS" =>
Some((Length, 1000.0))
"IN" | "INCH" | "INCHES" => Some((Length, 0.0254))
"FT" | "FOOT" | "FEET" => Some((Length, 0.3048))
"YD" | "YARD" | "YARDS" => Some((Length, 0.9144))
"M2" | "M²" | "SQUAREMETRE" | "SQUAREMETER" | "SQM" => Some((Area, 1.0))
"MM2" | "MM²" | "SQUAREMILLIMETRE" | "SQUAREMILLIMETER" =>
Some((Area, 0.000001))
"CM2" | "CM²" | "SQUARECENTIMETRE" | "SQUARECENTIMETER" =>
Some((Area, 0.0001))
"FT2" | "FT²" | "SQUAREFOOT" | "SQUAREFEET" | "SQFT" =>
Some((Area, 0.09290304))
"M3" | "M³" | "CUBICMETRE" | "CUBICMETER" | "CUM" => Some((Volume, 1.0))
"MM3" | "MM³" | "CUBICMILLIMETRE" | "CUBICMILLIMETER" =>
Some((Volume, 0.000000001))
"CM3" | "CM³" | "CUBICCENTIMETRE" | "CUBICCENTIMETER" =>
Some((Volume, 0.000001))
"FT3" | "FT³" | "CUBICFOOT" | "CUBICFEET" | "CUFT" =>
Some((Volume, 0.028316846592))
"KG" | "KILOGRAM" | "KILOGRAMS" => Some((Mass, 1.0))
"G" | "GRAM" | "GRAMS" => Some((Mass, 0.001))
"T" | "TONNE" | "TONNES" | "METRICTON" => Some((Mass, 1000.0))
"S" | "SEC" | "SECOND" | "SECONDS" => Some((Time, 1.0))
"MIN" | "MINUTE" | "MINUTES" => Some((Time, 60.0))
"H" | "HR" | "HOUR" | "HOURS" => Some((Time, 3600.0))
"RAD" | "RADIAN" | "RADIANS" => Some((PlaneAngle, 1.0))
"DEG" | "DEGREE" | "DEGREES" => Some((PlaneAngle, 0.017453292519943295))
_ => None
}
match item {
Some((kind, to_si)) => { label, normalized, kind, to_si, recognized: true }
None =>
{ label, normalized, kind: UnknownUnit, to_si: 1.0, recognized: false }
}
}
///|
pub fn unit_semantic_kind(label : String) -> UnitKind {
unit_semantic(label).kind
}
///|
pub fn unit_semantic_scale(label : String) -> Double? {
let semantic = unit_semantic(label)
if semantic.recognized {
Some(semantic.to_si)
} else {
None
}
}
///|
pub fn unit_semantic_is_recognized(label : String) -> Bool {
unit_semantic(label).recognized
}
///|
pub fn convert_unit_value(
value : Double,
from_unit : String,
to_unit : String,
) -> UnitConversion {
let from = unit_semantic(from_unit)
let to = unit_semantic(to_unit)
let compatible = from.recognized && to.recognized && from.kind == to.kind
if compatible {
let converted = value * from.to_si / to.to_si
{
kind: from.kind,
value,
converted,
from_unit: from.label,
to_unit: to.label,
factor: from.to_si / to.to_si,
recognized: true,
}
} else {
{
kind: UnknownUnit,
value,
converted: value,
from_unit: from.label,
to_unit: to.label,
factor: 1.0,
recognized: false,
}
}
}
///|
pub fn UnitConversion::is_compatible(self : UnitConversion) -> Bool {
self.recognized
}
///|
pub fn UnitConversion::headline(self : UnitConversion) -> String {
self.value.to_string() +
" " +
self.from_unit +
" -> " +
self.converted.to_string() +
" " +
self.to_unit
}
///|
pub fn UnitConversion::to_json(self : UnitConversion) -> String {
"{\"kind\":\"" +
semantic_kind_name(self.kind) +
"\",\"value\":" +
self.value.to_string() +
",\"converted\":" +
self.converted.to_string() +
",\"from\":\"" +
self.from_unit +
"\",\"to\":\"" +
self.to_unit +
"\",\"factor\":" +
self.factor.to_string() +
",\"recognized\":" +
self.recognized.to_string() +
"}"
}
///|
fn semantic_kind_name(kind : UnitKind) -> String {
match kind {
Length => "length"
Area => "area"
Volume => "volume"
Mass => "mass"
Time => "time"
PlaneAngle => "plane-angle"
UnknownUnit => "unknown"
}
}
///|
fn semantic_row_message(status : UnitAuditStatus, name : String) -> String {
match status {
Consistent => "recognized IFC unit " + name
Missing => "no declaration for " + name
Duplicate => "multiple declarations for " + name
Unknown => "unit name is not in the semantic registry: " + name
Incompatible => "unit declaration has an incompatible dimension: " + name
}
}
///|
fn semantic_unit_status(
kind : UnitKind,
known : Map[String, Int],
) -> UnitAuditStatus {
let key = semantic_kind_name(kind)
if kind == UnknownUnit {
Unknown
} else if known.get(key).unwrap_or(0) > 1 {
Duplicate
} else {
Consistent
}
}
///|
fn semantic_unit_rows(index : ModelIndex) -> Array[UnitAuditRow] {
let known : Map[String, Int] = Map([])
let decoded : Array[(Int, UnitScale)] = []
for id in index.ids_named("IFCSIUNIT") {
match index.entity(id) {
Some(entity) => {
let kind = unit_semantic_kind(
optional_string_argument(entity, 3).unwrap_or(""),
)
known.update_or_default(semantic_kind_name(kind), 1, count => count + 1)
let name = optional_string_argument(entity, 3).unwrap_or("UNKNOWN")
let prefix = match entity_value(entity, 2) {
Some(Enumeration(value)) => Some(value)
_ => None
}
let scale = match unit_semantic_scale(name) {
Some(value) => value
None => 1.0
}
decoded.push((id, { kind, name, prefix, to_si: scale }))
}
None => ()
}
}
let rows : Array[UnitAuditRow] = []
for item in decoded {
let (id, scale) = item
let status = semantic_unit_status(scale.kind, known)
rows.push({
entity_id: id,
kind: scale.kind,
name: scale.name,
prefix: scale.prefix,
to_si: scale.to_si,
status,
message: semantic_row_message(status, scale.name),
})
}
rows
}
///|
fn semantic_count_status(
rows : Array[UnitAuditRow],
status : UnitAuditStatus,
) -> Int {
rows.fold(init=0, (count, row) => {
if row.status == status {
count + 1
} else {
count
}
})
}
///|
fn semantic_first_scale(rows : Array[UnitAuditRow], kind : UnitKind) -> Double {
for row in rows {
if row.kind == kind && row.status != Unknown {
return row.to_si
}
}
1.0
}
///|
/// Audit the IFC unit assignment while retaining unknown declarations.
pub fn unit_audit(model : Model) -> UnitAuditReport {
let rows = semantic_unit_rows(model.index())
{
rows,
recognized_count: semantic_count_status(rows, Consistent),
unknown_count: semantic_count_status(rows, Unknown),
duplicate_kind_count: semantic_count_status(rows, Duplicate),
length_scale: semantic_first_scale(rows, Length),
area_scale: semantic_first_scale(rows, Area),
volume_scale: semantic_first_scale(rows, Volume),
}
}
///|
pub fn UnitAuditReport::is_consistent(self : UnitAuditReport) -> Bool {
self.unknown_count == 0 && self.duplicate_kind_count == 0
}
///|
pub fn UnitAuditReport::row_count(self : UnitAuditReport) -> Int {
self.rows.length()
}
///|
pub fn UnitAuditReport::rows_for(
self : UnitAuditReport,
kind : UnitKind,
) -> Array[UnitAuditRow] {
self.rows.filter(row => row.kind == kind)
}
///|
pub fn UnitAuditReport::scale(
self : UnitAuditReport,
kind : UnitKind,
) -> Double {
match kind {
Length => self.length_scale
Area => self.area_scale
Volume => self.volume_scale
_ => 1.0
}
}
///|
pub fn UnitAuditReport::headline(self : UnitAuditReport) -> String {
"units=" +
self.row_count().to_string() +
" recognized=" +
self.recognized_count.to_string() +
" unknown=" +
self.unknown_count.to_string() +
" duplicate=" +
self.duplicate_kind_count.to_string()
}
///|
pub fn UnitAuditReport::to_json(self : UnitAuditReport) -> String {
let rows = self.rows
.map(row => {
"{\"entity_id\":" +
row.entity_id.to_string() +
",\"kind\":\"" +
semantic_kind_name(row.kind) +
"\",\"name\":\"" +
row.name +
"\",\"to_si\":" +
row.to_si.to_string() +
",\"status\":\"" +
semantic_status_name(row.status) +
"\"}"
})
.join(",")
"{\"recognized\":" +
self.recognized_count.to_string() +
",\"unknown\":" +
self.unknown_count.to_string() +
",\"duplicate\":" +
self.duplicate_kind_count.to_string() +
",\"length_scale\":" +
self.length_scale.to_string() +
",\"area_scale\":" +
self.area_scale.to_string() +
",\"volume_scale\":" +
self.volume_scale.to_string() +
",\"rows\":[" +
rows +
"]}"
}
///|
pub fn UnitAuditReport::to_markdown(self : UnitAuditReport) -> String {
let builder = StringBuilder()
builder <+ "# Unit audit\n\n"
builder <+ "- \{self.headline()}\n"
builder <+ "- Length scale: \{self.length_scale}\n"
builder <+ "- Area scale: \{self.area_scale}\n"
builder <+ "- Volume scale: \{self.volume_scale}\n\n"
builder <+
"| Entity | Kind | Name | SI scale | Status |\n| ---: | --- | --- | ---: | --- |\n"
for row in self.rows {
builder <+
"|\{row.entity_id}| \{semantic_kind_name(row.kind)} | \{row.name} | \{row.to_si} | \{semantic_status_name(row.status)} |\n"
}
builder.to_string()
}
///|
fn normalized_quantity_kind(kind : QuantityKind) -> UnitKind {
match kind {
Count => UnknownUnit
Length | Width | Height | Depth => Length
Area | SurfaceArea => Area
Volume => Volume
}
}
///|
fn normalized_default_unit(kind : QuantityKind) -> String {
match kind {
Count => "count"
Length | Width | Height | Depth => "m"
Area | SurfaceArea => "m2"
Volume => "m3"
}
}
///|
fn normalized_kind_name(kind : QuantityKind) -> String {
match kind {
Count => "count"
Length => "length"
Area => "area"
Volume => "volume"
SurfaceArea => "surface-area"
Height => "height"
Width => "width"
Depth => "depth"
}
}
///|
fn semantic_status_name(status : UnitAuditStatus) -> String {
match status {
Consistent => "consistent"
Missing => "missing"
Duplicate => "duplicate"
Unknown => "unknown"
Incompatible => "incompatible"
}
}
///|
fn normalized_quantity_value(
product_id : Int,
quantity : QuantityValue,
target_unit : String,
) -> NormalizedQuantity {
if quantity.kind == Count {
{
product_id,
kind: quantity.kind,
value: quantity.value,
unit: target_unit,
source: quantity.source,
estimated: quantity.estimated,
recognized: true,
}
} else {
let conversion = convert_unit_value(
quantity.value,
quantity.unit,
target_unit,
)
{
product_id,
kind: quantity.kind,
value: conversion.converted,
unit: target_unit,
source: quantity.source,
estimated: quantity.estimated,
recognized: conversion.recognized,
}
}
}
///|
pub fn normalize_quantity(
product_id : Int,
quantity : QuantityValue,
target_unit~ : String?,
) -> NormalizedQuantity {
let target = target_unit.unwrap_or(normalized_default_unit(quantity.kind))
normalized_quantity_value(product_id, quantity, target)
}
///|
pub fn normalize_takeoff(
report : TakeoffReport,
target_length_unit? : String = "m",
target_area_unit? : String = "m2",
target_volume_unit? : String = "m3",
) -> Array[NormalizedQuantity] {
let result : Array[NormalizedQuantity] = []
for row in report.products {
for quantity in row.quantities {
let target = match normalized_quantity_kind(quantity.kind) {
Length => target_length_unit
Area => target_area_unit
Volume => target_volume_unit
_ => quantity.unit
}
result.push(normalized_quantity_value(row.product.id, quantity, target))
}
}
result
}
///|
pub fn NormalizedQuantity::headline(self : NormalizedQuantity) -> String {
let suffix = if self.estimated { " (estimated)" } else { "" }
"#" +
self.product_id.to_string() +
" " +
normalized_kind_name(self.kind) +
"=" +
self.value.to_string() +
" " +
self.unit +
suffix
}
///|
pub fn NormalizedQuantity::to_json(self : NormalizedQuantity) -> String {
"{\"product_id\":" +
self.product_id.to_string() +
",\"kind\":\"" +
normalized_kind_name(self.kind) +
"\",\"value\":" +
self.value.to_string() +
",\"unit\":\"" +
self.unit +
"\",\"source\":\"" +
self.source +
"\",\"estimated\":" +
self.estimated.to_string() +
",\"recognized\":" +
self.recognized.to_string() +
"}"
}
///|
pub fn normalized_quantities_to_csv(rows : Array[NormalizedQuantity]) -> String {
let builder = StringBuilder()
builder <+ "product_id,kind,value,unit,source,estimated,recognized\n"
for row in rows {
builder <+
"\{row.product_id},\{normalized_kind_name(row.kind)},\{row.value},\{row.unit},\{row.source},\{row.estimated},\{row.recognized}\n"
}
builder.to_string()
}
///|
pub fn normalized_quantities_to_json(
rows : Array[NormalizedQuantity],
) -> String {
"[" + rows.map(row => row.to_json()).join(",") + "]"
}
///|
pub fn normalized_quantities_total(
rows : Array[NormalizedQuantity],
kind : QuantityKind,
) -> Double {
rows.fold(init=0.0, (sum, row) => {
if row.kind == kind {
sum + row.value
} else {
sum
}
})
}
///|
pub fn normalized_quantities_for_product(
rows : Array[NormalizedQuantity],
product_id : Int,
) -> Array[NormalizedQuantity] {
rows.filter(row => row.product_id == product_id)
}
///|
pub fn normalized_quantities_unrecognized(
rows : Array[NormalizedQuantity],
) -> Array[NormalizedQuantity] {
rows.filter(row => !row.recognized)
}
///|
pub fn normalized_quantity_unit_histogram(
rows : Array[NormalizedQuantity],
) -> Map[String, Int] {
let result : Map[String, Int] = Map([])
for row in rows {
result.update_or_default(row.unit, 1, count => count + 1)
}
result
}
///|
pub fn normalized_quantity_summary(
rows : Array[NormalizedQuantity],
) -> Array[String] {
[
"rows=" + rows.length().to_string(),
"length=" + normalized_quantities_total(rows, Length).to_string(),
"area=" + normalized_quantities_total(rows, Area).to_string(),
"volume=" + normalized_quantities_total(rows, Volume).to_string(),
"unrecognized=" +
normalized_quantities_unrecognized(rows).length().to_string(),
]
}
///|
pub fn quantity_kind_unit(kind : QuantityKind) -> String {
normalized_default_unit(kind)
}
///|
pub fn quantity_kind_dimension(kind : QuantityKind) -> UnitKind {
normalized_quantity_kind(kind)
}
///|
pub fn unit_semantic_catalog() -> Array[UnitSemantic] {
[
unit_semantic("m"),
unit_semantic("mm"),
unit_semantic("cm"),
unit_semantic("ft"),
unit_semantic("m2"),
unit_semantic("ft2"),
unit_semantic("m3"),
unit_semantic("ft3"),
unit_semantic("kg"),
unit_semantic("s"),
unit_semantic("rad"),
]
}
///|
pub fn unit_semantic_catalog_json() -> String {
"[" +
unit_semantic_catalog()
.map(item => {
"{\"label\":\"" +
item.label +
"\",\"normalized\":\"" +
item.normalized +
"\",\"kind\":\"" +
semantic_kind_name(item.kind) +
"\",\"to_si\":" +
item.to_si.to_string() +
"}"
})
.join(",") +
"]"
}