soilrust/models/
cpt.rs

1use crate::{
2    enums::SelectionMethod,
3    validation::{validate_field, ValidationError},
4};
5use ordered_float::OrderedFloat;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeSet;
8
9/// Represents a single CPT (Cone Penetration Test) data point.
10///
11/// Each `CPTLayer` instance holds a `depth` value (in meters) and a `cone_resistance` value (in MPa).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CPTLayer {
14    pub depth: Option<f64>,           // Depth in meters
15    pub cone_resistance: Option<f64>, // Cone resistance (qc) in MPa
16    pub sleeve_friction: Option<f64>, // Sleeve friction (fs) in MPa
17    pub pore_pressure: Option<f64>,   // Pore pressure (u2) in MPa
18    pub friction_ratio: Option<f64>,  // Friction ratio (Rf) in percentage
19}
20
21impl Default for CPTLayer {
22    fn default() -> Self {
23        Self {
24            depth: Some(0.0),
25            cone_resistance: Some(0.0),
26            sleeve_friction: Some(0.0),
27            pore_pressure: None,
28            friction_ratio: None,
29        }
30    }
31}
32impl CPTLayer {
33    /// Creates a new `CPTLayer` instance.
34    ///
35    /// # Arguments
36    /// * `depth` - The depth of the CPT data point in meters.
37    /// * `cone_resistance` - The cone resistance of the CPT data point in MPa.
38    pub fn new(depth: f64, qc: f64, fs: f64, u2: Option<f64>) -> Self {
39        Self {
40            depth: Some(depth),
41            cone_resistance: Some(qc),
42            sleeve_friction: Some(fs),
43            pore_pressure: u2,
44            friction_ratio: None,
45        }
46    }
47
48    /// Calculates the friction ratio (Rf) for the CPT data point.
49    /// The friction ratio is calculated as the ratio of sleeve friction to cone resistance.
50    /// If the sleeve friction is not available, the function returns `None`.
51    /// If the cone resistance is zero, the function returns `None`.
52    /// If the friction ratio is calculated, it is stored in the `friction_ratio` field of the `CPTLayer` instance.
53    /// The friction ratio is expressed as a percentage.
54    /// The formula for calculating the friction ratio is:
55    /// ```text
56    /// Rf = (fs / qc) * 100
57    /// ```
58    /// where:
59    /// - `Rf` is the friction ratio in percentage.
60    /// - `fs` is the sleeve friction in MPa.
61    /// - `qc` is the cone resistance in MPa.
62    pub fn calc_friction_ratio(&mut self) {
63        if self.cone_resistance.unwrap() != 0.0 {
64            self.friction_ratio =
65                Some((self.sleeve_friction.unwrap() / self.cone_resistance.unwrap()) * 100.0);
66        }
67    }
68
69    /// Validates specific fields of the CPTLayer using field names.
70    ///
71    /// # Arguments
72    /// * `fields` - A slice of field names to validate.
73    ///
74    /// # Returns
75    /// Ok(()) if all fields are valid, or an error if any field is invalid.
76    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
77        for &field in fields {
78            let result = match field {
79                "depth" => validate_field("depth", self.depth, Some(0.0), None, "cpt"),
80                "cone_resistance" => validate_field(
81                    "cone_resistance",
82                    self.cone_resistance,
83                    Some(0.0),
84                    None,
85                    "cpt",
86                ),
87                "sleeve_friction" => validate_field(
88                    "sleeve_friction",
89                    self.sleeve_friction,
90                    Some(0.0),
91                    None,
92                    "cpt",
93                ),
94                "pore_pressure" => {
95                    validate_field("pore_pressure", self.pore_pressure, Some(0.0), None, "cpt")
96                }
97                "friction_ratio" => validate_field(
98                    "friction_ratio",
99                    self.friction_ratio,
100                    Some(0.0),
101                    None,
102                    "cpt",
103                ),
104                unknown => Err(ValidationError {
105                    code: "cpt.invalid_field".into(),
106                    message: format!("Field '{}' is not valid for CPT.", unknown),
107                }),
108            };
109
110            result?; // propagate error if any field fails
111        }
112
113        Ok(())
114    }
115}
116// ------------------------------------------------------------------------------------------------
117
118/// Represents a collection of CPT data points.
119///
120/// A `CPTExp` struct contains multiple `CPTLayer` instances, forming a complete CPT profile.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CPTExp {
123    pub layers: Vec<CPTLayer>,
124    pub name: String,
125}
126
127impl CPTExp {
128    /// Creates a new `CPT` instance.
129    ///
130    /// # Arguments
131    /// * `layers` - A vector of `CPTLayer` instances.
132    /// * `name` - The name of the CPT profile.
133    pub fn new(layers: Vec<CPTLayer>, name: String) -> Self {
134        Self { layers, name }
135    }
136
137    /// Adds a new `CPTLayer` instance to the `CPTExp` collection.
138    ///
139    /// # Arguments
140    /// * `layer` - The `CPTLayer` instance to add to the collection.
141    pub fn add_layer(&mut self, layer: CPTLayer) {
142        self.layers.push(layer);
143    }
144
145    /// Retrieves the CPT layer corresponding to a given depth.
146    ///
147    /// This function finds the first layer whose depth is greater than or equal to the given `depth`.
148    /// If no such layer is found, it returns the last layer in the list.
149    ///
150    /// # Arguments
151    /// * `depth` - The depth at which to search for a CPT layer.
152    ///
153    /// # Returns
154    /// A reference to the matching `CPTLayer`.
155    pub fn get_layer_at_depth(&self, depth: f64) -> &CPTLayer {
156        self.layers
157            .iter()
158            .find(|exp| exp.depth.unwrap() >= depth)
159            .unwrap_or_else(|| self.layers.last().unwrap())
160    }
161
162    /// Validates specific fields of the CPTExp using field names.
163    ///
164    /// # Arguments
165    /// * `fields` - A slice of field names to validate.
166    ///
167    /// # Returns
168    /// Ok(()) if all fields are valid, or an error if any field is invalid.
169    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
170        if self.layers.is_empty() {
171            return Err(ValidationError {
172                code: "cpt.empty_layers".into(),
173                message: "No layers provided for CPTExp.".into(),
174            });
175        }
176        for layer in &self.layers {
177            layer.validate(fields)?;
178        }
179
180        Ok(())
181    }
182}
183// ------------------------------------------------------------------------------------------------
184
185/// Represents a collection of CPT tests.
186///
187/// A `CPT` struct contains multiple `CPTExp` instances, each representing a single CPT profile.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct CPT {
190    pub exps: Vec<CPTExp>,
191    pub idealization_method: SelectionMethod,
192}
193
194impl CPT {
195    /// Creates a new `CPT` instance.
196    ///
197    /// # Arguments
198    /// * `exps` - A vector of `CPTExp` instances.
199    /// * `idealization_method` - The method used for idealization.
200    pub fn new(exps: Vec<CPTExp>, idealization_method: SelectionMethod) -> Self {
201        Self {
202            exps,
203            idealization_method,
204        }
205    }
206
207    /// Adds a new `CPTExp` instance to the `CPT` collection.
208    ///
209    /// # Arguments
210    /// * `exp` - The `CPTExp` instance to add to the collection.
211    pub fn add_exp(&mut self, exp: CPTExp) {
212        self.exps.push(exp);
213    }
214
215    /// Creates an idealized CPT experiment based on the given mode.
216    /// The idealized experiment is created by combining the corresponding layers from each individual experiment in the model.
217    ///
218    /// # Arguments
219    /// * `name` - The name of the idealized experiment.
220    ///
221    /// # Returns
222    /// A new `CPTExp` instance representing the idealized experiment.
223    pub fn get_idealized_exp(&self, name: String) -> CPTExp {
224        if self.exps.is_empty() {
225            return CPTExp::new(vec![], name);
226        }
227
228        let mode = self.idealization_method;
229
230        // 1. Collect unique depths across all experiments
231        let mut unique_depths = BTreeSet::new();
232        for exp in &self.exps {
233            for layer in &exp.layers {
234                unique_depths.insert(OrderedFloat(layer.depth.unwrap()));
235            }
236        }
237
238        let sorted_depths: Vec<f64> = unique_depths.into_iter().map(|d| d.into_inner()).collect();
239
240        let mut layers = Vec::new();
241
242        let get_mode_value = |mode: SelectionMethod, values: Vec<f64>| -> f64 {
243            match mode {
244                SelectionMethod::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
245                SelectionMethod::Avg => values.iter().sum::<f64>() / values.len() as f64,
246                SelectionMethod::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
247            }
248        };
249        for depth in sorted_depths {
250            let mut qc_at_depth = Vec::new();
251            let mut fs_at_depth = Vec::new();
252            let mut u2_at_depth = Vec::new();
253
254            for exp in &self.exps {
255                let layer = exp.get_layer_at_depth(depth);
256                qc_at_depth.push(layer.cone_resistance.unwrap());
257                fs_at_depth.push(layer.sleeve_friction.unwrap());
258                u2_at_depth.push(layer.pore_pressure.unwrap_or(0.0));
259            }
260
261            let qc = get_mode_value(mode, qc_at_depth);
262            let fs = get_mode_value(mode, fs_at_depth);
263            let u2 = get_mode_value(mode, u2_at_depth);
264
265            layers.push(CPTLayer::new(depth, qc, fs, Some(u2)));
266        }
267
268        CPTExp::new(layers, name)
269    }
270
271    /// Validates specific fields of the CPT using field names.
272    ///
273    /// # Arguments
274    /// * `fields` - A slice of field names to validate.
275    ///
276    /// # Returns
277    /// Ok(()) if all fields are valid, or an error if any field is invalid.
278    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
279        if self.exps.is_empty() {
280            return Err(ValidationError {
281                code: "cpt.empty_exps".into(),
282                message: "No experiments found in CPT.".into(),
283            });
284        }
285        for exp in &self.exps {
286            exp.validate(fields)?;
287        }
288
289        Ok(())
290    }
291}