soilrust/models/
soil_profile.rs

1use serde::{Deserialize, Serialize};
2
3use crate::validation::{validate_field, ValidationError};
4
5/// Represents a single soil layer in a geotechnical engineering model.
6///
7/// This struct contains essential soil properties used for analysis, such as
8/// shear strength, stiffness, and classification parameters. The parameters are
9/// divided into **total stress** (undrained) and **effective stress** (drained)
10/// conditions for comprehensive modeling.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct SoilLayer {
13    pub soil_classification: Option<String>, // e.g., "CLAY", "SAND", "SILT"
14    pub thickness: Option<f64>,              // meter
15    pub natural_unit_weight: Option<f64>,    // t/m³
16    pub dry_unit_weight: Option<f64>,        // t/m³
17    pub saturated_unit_weight: Option<f64>,  // t/m³
18    pub depth: Option<f64>,                  // meter
19    pub center: Option<f64>,                 // meter
20    pub damping_ratio: Option<f64>,          // percentage
21    pub fine_content: Option<f64>,           // percentage
22    pub liquid_limit: Option<f64>,           // percentage
23    pub plastic_limit: Option<f64>,          // percentage
24    pub plasticity_index: Option<f64>,       // percentage
25    pub cu: Option<f64>,                     // Undrained shear strength in t/m²
26    pub c_prime: Option<f64>,                // Effective cohesion in t/m²
27    pub phi_u: Option<f64>,                  // Undrained internal friction angle in degrees
28    pub phi_prime: Option<f64>,              // Effective internal friction angle in degrees
29    pub water_content: Option<f64>,          // percentage
30    pub poissons_ratio: Option<f64>,         // Poisson's ratio
31    pub elastic_modulus: Option<f64>,        // t/m²
32    pub void_ratio: Option<f64>,             // Void ratio
33    pub recompression_index: Option<f64>,    // Recompression index
34    pub compression_index: Option<f64>,      // Compression index
35    pub preconsolidation_pressure: Option<f64>, // t/m²
36    pub mv: Option<f64>,                     // volume compressibility coefficient in m²/t
37    pub shear_wave_velocity: Option<f64>,    // m/s
38}
39
40impl SoilLayer {
41    pub fn new(thickness: f64) -> Self {
42        Self {
43            thickness: Some(thickness),
44            ..Default::default()
45        }
46    }
47    /// Validate based on a list of required fields by name.
48    ///
49    /// # Arguments
50    /// * `fields` - A slice of field names to validate.
51    ///
52    /// # Returns
53    /// * `Ok(())` if all required fields are valid.
54    pub fn validate_fields(&self, fields: &[&str]) -> Result<(), ValidationError> {
55        for &field in fields {
56            let result = match field {
57                "thickness" => validate_field(
58                    "thickness",
59                    self.thickness,
60                    Some(0.0001),
61                    None,
62                    "soil_profile",
63                ),
64                "natural_unit_weight" => validate_field(
65                    "natural_unit_weight",
66                    self.natural_unit_weight,
67                    Some(0.1),
68                    Some(10.0),
69                    "soil_profile",
70                ),
71                "dry_unit_weight" => validate_field(
72                    "dry_unit_weight",
73                    self.dry_unit_weight,
74                    Some(0.1),
75                    Some(10.0),
76                    "soil_profile",
77                ),
78                "saturated_unit_weight" => validate_field(
79                    "saturated_unit_weight",
80                    self.saturated_unit_weight,
81                    Some(0.1),
82                    Some(10.0),
83                    "soil_profile",
84                ),
85                "damping_ratio" => validate_field(
86                    "damping_ratio",
87                    self.damping_ratio,
88                    Some(0.1),
89                    Some(100.0),
90                    "soil_profile",
91                ),
92                "fine_content" => validate_field(
93                    "fine_content",
94                    self.fine_content,
95                    Some(0.0),
96                    Some(100.),
97                    "soil_profile",
98                ),
99                "liquid_limit" => validate_field(
100                    "liquid_limit",
101                    self.liquid_limit,
102                    Some(0.0),
103                    Some(100.),
104                    "soil_profile",
105                ),
106                "plastic_limit" => validate_field(
107                    "plastic_limit",
108                    self.plastic_limit,
109                    Some(0.0),
110                    Some(100.),
111                    "soil_profile",
112                ),
113                "plasticity_index" => validate_field(
114                    "plasticity_index",
115                    self.plasticity_index,
116                    Some(0.0),
117                    Some(100.),
118                    "soil_profile",
119                ),
120                "cu" => validate_field("cu", self.cu, Some(0.0), None, "soil_profile"),
121                "c_prime" => {
122                    validate_field("c_prime", self.c_prime, Some(0.0), None, "soil_profile")
123                }
124                "phi_u" => {
125                    validate_field("phi_u", self.phi_u, Some(0.0), Some(90.), "soil_profile")
126                }
127                "phi_prime" => validate_field(
128                    "phi_prime",
129                    self.phi_prime,
130                    Some(0.0),
131                    Some(90.),
132                    "soil_profile",
133                ),
134                "water_content" => validate_field(
135                    "water_content",
136                    self.water_content,
137                    Some(0.),
138                    Some(100.),
139                    "soil_profile",
140                ),
141                "poissons_ratio" => validate_field(
142                    "poissons_ratio",
143                    self.poissons_ratio,
144                    Some(0.0001),
145                    Some(0.5),
146                    "soil_profile",
147                ),
148                "elastic_modulus" => validate_field(
149                    "elastic_modulus",
150                    self.elastic_modulus,
151                    Some(0.0001),
152                    None,
153                    "soil_profile",
154                ),
155                "void_ratio" => validate_field(
156                    "void_ratio",
157                    self.void_ratio,
158                    Some(0.0),
159                    None,
160                    "soil_profile",
161                ),
162                "compression_index" => validate_field(
163                    "compression_index",
164                    self.compression_index,
165                    Some(0.0),
166                    None,
167                    "soil_profile",
168                ),
169                "recompression_index" => validate_field(
170                    "recompression_index",
171                    self.recompression_index,
172                    Some(0.0),
173                    None,
174                    "soil_profile",
175                ),
176                "preconsolidation_pressure" => validate_field(
177                    "preconsolidation_pressure",
178                    self.preconsolidation_pressure,
179                    Some(0.0),
180                    None,
181                    "soil_profile",
182                ),
183                "mv" => validate_field("mv", self.mv, Some(0.0), None, "soil_profile"),
184                "shear_wave_velocity" => validate_field(
185                    "shear_wave_velocity",
186                    self.shear_wave_velocity,
187                    Some(0.0),
188                    None,
189                    "soil_profile",
190                ),
191                other => Err(ValidationError {
192                    code: "soil_profile.invalid_field".to_string(),
193                    message: format!("Field '{}' is not valid for SoilLayer.", other),
194                }),
195            };
196
197            result?;
198        }
199
200        Ok(())
201    }
202}
203
204/// Represents a soil profile consisting of multiple soil layers.
205/// This structure stores soil layers and calculates normal and effective stresses.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SoilProfile {
208    /// A list of soil layers in the profile.
209    pub layers: Vec<SoilLayer>,
210    /// Depth of the groundwater table (meters).
211    pub ground_water_level: Option<f64>, // meters
212}
213
214impl SoilProfile {
215    /// Creates a new soil profile and initializes layer depths.
216    ///
217    /// # Arguments
218    /// * `layers` - A vector of `SoilLayer` objects.
219    /// * `ground_water_level` - Depth of the groundwater table in meters.
220    ///
221    /// # Panics
222    /// * If no layers are provided.
223    pub fn new(layers: Vec<SoilLayer>, ground_water_level: f64) -> Self {
224        if layers.is_empty() {
225            panic!("Soil profile must contain at least one layer.");
226        }
227
228        let mut profile = Self {
229            layers,
230            ground_water_level: Some(ground_water_level),
231        };
232        profile.calc_layer_depths();
233        profile
234    }
235
236    /// Calculates center and bottom depth for each soil layer.
237    pub fn calc_layer_depths(&mut self) {
238        if self.layers.is_empty() {
239            return;
240        }
241
242        let mut bottom = 0.0;
243
244        for layer in &mut self.layers {
245            let thickness = layer.thickness.unwrap();
246            layer.center = Some(bottom + thickness / 2.0);
247            bottom += thickness;
248            layer.depth = Some(bottom);
249        }
250    }
251
252    /// Returns the index of the soil layer at a specified depth.
253    ///
254    /// # Arguments
255    /// * `depth` - The depth at which to find the layer.
256    ///
257    /// # Returns
258    /// * The index of the layer containing the specified depth.
259    pub fn get_layer_index(&self, depth: f64) -> usize {
260        for (i, layer) in self.layers.iter().enumerate() {
261            if let Some(layer_depth) = layer.depth {
262                if layer_depth >= depth {
263                    return i;
264                }
265            }
266        }
267        self.layers.len() - 1
268    }
269
270    /// Returns a reference to the soil layer at a specified depth.
271    ///
272    /// # Arguments
273    /// * `depth` - The depth at which to find the layer.
274    ///
275    /// # Returns
276    /// * A reference to the `SoilLayer` at the specified depth.
277    pub fn get_layer_at_depth(&self, depth: f64) -> &SoilLayer {
278        let index = self.get_layer_index(depth);
279        &self.layers[index]
280    }
281
282    /// Calculates the total (normal) stress at a given depth.
283    ///
284    /// # Arguments
285    /// * `depth` - The depth at which to calculate total stress.
286    ///
287    /// # Returns
288    /// * The total normal stress (t/m²) at the specified depth.
289    pub fn calc_normal_stress(&self, depth: f64) -> f64 {
290        let layer_index = self.get_layer_index(depth);
291
292        let mut total_stress = 0.0;
293        let mut previous_depth = 0.0;
294        let gwt = self.ground_water_level.unwrap();
295
296        for (i, layer) in self.layers.iter().take(layer_index + 1).enumerate() {
297            let layer_thickness = if i == layer_index {
298                depth - previous_depth // Partial thickness for last layer
299            } else {
300                layer.thickness.unwrap() // Full thickness for earlier layers
301            };
302            let dry_unit_weight = layer.dry_unit_weight.unwrap_or(0.0);
303            let saturated_unit_weight = layer.saturated_unit_weight.unwrap_or(0.0);
304            if dry_unit_weight <= 1.0 && saturated_unit_weight <= 1.0 {
305                panic!("Dry or saturated unit weight must be greater then 1 for each layer.");
306            }
307            if gwt >= previous_depth + layer_thickness {
308                // Entirely above groundwater table (dry unit weight applies)
309                total_stress += dry_unit_weight * layer_thickness;
310            } else if gwt <= previous_depth {
311                // Entirely below groundwater table (saturated unit weight applies)
312                total_stress += saturated_unit_weight * layer_thickness;
313            } else {
314                // Partially submerged (both dry and saturated weights apply)
315                let dry_thickness = gwt - previous_depth;
316                let submerged_thickness = layer_thickness - dry_thickness;
317                total_stress +=
318                    dry_unit_weight * dry_thickness + saturated_unit_weight * submerged_thickness;
319            }
320
321            previous_depth += layer_thickness;
322        }
323
324        total_stress
325    }
326
327    /// Calculates the effective stress at a given depth.
328    ///
329    /// # Arguments
330    /// * `depth` - The depth at which to calculate effective stress.
331    ///
332    /// # Returns
333    /// * The effective stress (t/m²) at the specified depth.
334    pub fn calc_effective_stress(&self, depth: f64) -> f64 {
335        let normal_stress = self.calc_normal_stress(depth);
336
337        if self.ground_water_level.unwrap() >= depth {
338            normal_stress // Effective stress equals total stress above water table
339        } else {
340            let pore_pressure = (depth - self.ground_water_level.unwrap()) * 0.981; // t/m³ for water
341            normal_stress - pore_pressure
342        }
343    }
344
345    /// Validates the soil profile and its layers.
346    ///
347    /// # Arguments
348    /// * `fields` - A slice of field names to validate.
349    ///
350    /// # Returns
351    /// * `Ok(())` if the profile is valid.
352    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
353        if self.layers.is_empty() {
354            return Err(ValidationError {
355                code: "soil_profile.empty".to_string(),
356                message: "Soil profile must contain at least one layer.".to_string(),
357            });
358        }
359
360        for layer in &self.layers {
361            layer.validate_fields(fields)?;
362        }
363
364        validate_field(
365            "ground_water_level",
366            self.ground_water_level,
367            Some(0.0),
368            None,
369            "soil_profile",
370        )?;
371
372        Ok(())
373    }
374}