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}