Skip to main content

energyml_export/
csv.rs

1//! Long CSV - one row per (object, element, property), in metres.
2//!
3//! The tabular export: what a geologist pastes into a
4//! spreadsheet or loads into pandas. **Long format, never wide** - one column
5//! per property would mean aligning the elements of properties that do not
6//! sample in the same places, hence choosing a common grid; refusing to align
7//! and writing long is the honest option, and pivoting is a spreadsheet's job.
8//!
9//! Three decisions, stated here:
10//!
11//! - **A `NaN` value is an empty cell.** CSV has no convention for "no data";
12//!   an empty cell is what every reader understands, where `NaN` becomes a
13//!   string half of them refuse to sum. *Nodes* with no position are removed
14//!   upstream by [`crate::registry::prepare`], as in every format.
15//! - **A cell value has empty coordinates.** A cell has not one position but
16//!   eight; writing its centroid would be a computed value presented as data
17//!   from the file. The `element` column says `cell`, the index says which one,
18//!   and x/y/z stay empty.
19//! - **An object with no property is written all the same**: one row per node,
20//!   with empty property columns. Geometry alone is an answer, and an export
21//!   that yields an empty file for a well with no log looks like a broken
22//!   export.
23//!
24//! A property's header is its title, its kind and its unit in separate columns -
25//! not `title (uom)` run together, which would force every consumer to
26//! re-parse what this writer already knew apart.
27
28use std::io::Write;
29
30use energyml_geometry::{AttributeScope, Placement, SceneObject};
31
32use crate::error::{Error, Result};
33use crate::registry::{Exporter, Options};
34
35/// Writes long-format CSV.
36pub struct CsvExporter;
37
38impl Exporter for CsvExporter {
39    fn name(&self) -> &'static str {
40        "csv"
41    }
42
43    fn extension(&self) -> &'static str {
44        "csv"
45    }
46
47    fn mime(&self) -> &'static str {
48        "text/csv"
49    }
50
51    fn describe(&self) -> &'static str {
52        "long-format CSV: one row per element and attribute, placed and in metres"
53    }
54
55    fn placement(&self) -> Placement {
56        Placement::Metres
57    }
58
59    fn handles(&self, object: &SceneObject) -> bool {
60        !object.geometry.is_empty()
61    }
62
63    fn write(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
64        if object.geometry.is_empty() {
65            return Err(Error::unsuitable(
66                "csv",
67                format!("`{}`, which has no points", object.title),
68            ));
69        }
70        self.write_all(std::slice::from_ref(object), out, options)
71    }
72
73    fn write_all(&self, objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
74        // One header for the whole file - that is what makes a group export
75        // pasteable as it stands: the well is a column, not a block.
76        writeln!(out, "object,uuid,element,index,x,y,z,property,kind,uom,value")?;
77        for object in objects {
78            if !self.handles(object) {
79                continue;
80            }
81            let object = crate::registry::prepare(object, options)?;
82            let object = object.as_ref();
83            let placement = options.placement.unwrap_or_else(|| self.placement());
84            let points = options.points(object, placement)?;
85            let name = field(&object.title);
86            let uuid = field(&object.uuid);
87
88            let of_scope = |scope: AttributeScope| {
89                object
90                    .attributes
91                    .iter()
92                    .filter(move |attribute| attribute.scope == scope)
93            };
94
95            // The nodes: the position always, then one row per value attached
96            // to it - or a bare row when nothing is attached.
97            for (index, point) in points.iter().enumerate() {
98                let mut wrote = false;
99                for attribute in of_scope(AttributeScope::Points) {
100                    for component in 0..attribute.components as usize {
101                        let value = attribute
102                            .values
103                            .get(index * attribute.components as usize + component)
104                            .copied();
105                        writeln!(
106                            out,
107                            "{name},{uuid},node,{index},{},{},{},{},{},{},{}",
108                            point[0],
109                            point[1],
110                            point[2],
111                            field(&component_name(&attribute.name, attribute.components, component)),
112                            field(attribute.kind.as_deref().unwrap_or("")),
113                            field(attribute.uom.as_deref().unwrap_or("")),
114                            cell(value),
115                        )?;
116                        wrote = true;
117                    }
118                }
119                if !wrote {
120                    writeln!(
121                        out,
122                        "{name},{uuid},node,{index},{},{},{},,,,",
123                        point[0], point[1], point[2]
124                    )?;
125                }
126            }
127
128            // The cells: no position - see the module - but every value, with
129            // the index that places it back in the grid.
130            for attribute in of_scope(AttributeScope::Cells) {
131                let stride = attribute.components as usize;
132                for index in 0..attribute.values.len() / stride.max(1) {
133                    for component in 0..stride {
134                        let value = attribute.values.get(index * stride + component).copied();
135                        writeln!(
136                            out,
137                            "{name},{uuid},cell,{index},,,,{},{},{},{}",
138                            field(&component_name(&attribute.name, attribute.components, component)),
139                            field(attribute.kind.as_deref().unwrap_or("")),
140                            field(attribute.uom.as_deref().unwrap_or("")),
141                            cell(value),
142                        )?;
143                    }
144                }
145            }
146        }
147        Ok(())
148    }
149}
150
151/// `Velocity[1]` for a vector's component, the bare name for a scalar.
152fn component_name(name: &str, components: u64, component: usize) -> String {
153    if components > 1 {
154        format!("{name}[{component}]")
155    } else {
156        name.to_owned()
157    }
158}
159
160/// A value: the number, or the empty cell for `NaN` and for the absent.
161fn cell(value: Option<f64>) -> String {
162    match value {
163        Some(v) if v.is_finite() => format!("{v}"),
164        // ±inf is a value the file states (xsd:double has them); losing it to
165        // an empty cell would confuse it with "no data".
166        Some(v) if v.is_infinite() => format!("{v}"),
167        _ => String::new(),
168    }
169}
170
171/// The CSV field: quoted as soon as it holds anything that would break the row.
172fn field(raw: &str) -> String {
173    if raw.contains([',', '"', '\n', '\r']) {
174        format!("\"{}\"", raw.replace('"', "\"\""))
175    } else {
176        raw.to_owned()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn a_field_with_a_comma_is_quoted_and_quotes_are_doubled() {
186        assert_eq!(field("plain"), "plain");
187        assert_eq!(field("a,b"), "\"a,b\"");
188        assert_eq!(field("say \"hi\""), "\"say \"\"hi\"\"\"");
189    }
190
191    #[test]
192    fn nan_is_an_empty_cell_and_infinity_is_not() {
193        assert_eq!(cell(Some(1.5)), "1.5");
194        assert_eq!(cell(Some(f64::NAN)), "");
195        assert_eq!(cell(None), "");
196        assert_eq!(cell(Some(f64::INFINITY)), "inf");
197    }
198
199    #[test]
200    fn a_vector_component_is_named_and_a_scalar_is_not() {
201        assert_eq!(component_name("Velocity", 3, 1), "Velocity[1]");
202        assert_eq!(component_name("Porosity", 1, 0), "Porosity");
203    }
204}