soilrust/models/
point_load_test.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use ordered_float::OrderedFloat;
5
6use crate::{
7    enums::SelectionMethod,
8    validation::{validate_field, ValidationError},
9};
10
11/// Represents an individual Point Load Test sample for determining rock strength.
12///
13/// # Fields
14/// * `depth` - Depth of the sample in meters.
15/// * `sample_no` - Optional identifier number for the tested sample.
16/// * `p` - Optional applied load at failure in kiloNewtons (kN).
17/// * `is` - Optional point load strength index in MegaPascals (MPa).
18/// * `f` - Optional size correction factor.
19/// * `is50` - Corrected point load strength index to 50 mm diameter in MegaPascals (MPa).
20/// * `l` - Optional distance between load application points in millimeters (mm).
21/// * `d` - Equivalent core diameter in millimeters (mm).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PointLoadSample {
24    pub depth: Option<f64>,
25    pub sample_no: Option<u32>,
26    pub p: Option<f64>,
27    pub is: Option<f64>,
28    pub f: Option<f64>,
29    pub is50: Option<f64>,
30    pub l: Option<f64>,
31    pub d: Option<f64>,
32}
33
34impl PointLoadSample {
35    pub fn new(depth: f64, is50: f64, d: f64) -> Self {
36        Self {
37            depth: Some(depth),
38            sample_no: None,
39            p: None,
40            is: None,
41            f: None,
42            is50: Some(is50),
43            l: None,
44            d: Some(d),
45        }
46    }
47    /// Validates specific fields of the PointLoadSample using field names.
48    ///
49    /// # Arguments
50    /// * `fields` - A slice of field names to validate.
51    ///
52    /// # Returns
53    /// Ok(()) if all fields are valid, or an error if any field is invalid.
54    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
55        for &field in fields {
56            let result = match field {
57                "depth" => validate_field("depth", self.depth, Some(0.0), None, "point_load_test"),
58                "sample_no" => validate_field(
59                    "sample_no",
60                    self.sample_no,
61                    Some(0),
62                    None,
63                    "point_load_test",
64                ),
65                "p" => validate_field("p", self.p, Some(0.0001), None, "point_load_test"),
66                "is" => validate_field("is", self.is, Some(0.00001), None, "point_load_test"),
67                "f" => validate_field("f", self.f, Some(0.00001), None, "point_load_test"),
68                "is50" => validate_field("is50", self.is50, Some(0.00001), None, "point_load_test"),
69                "l" => validate_field("l", self.l, Some(0.00001), None, "point_load_test"),
70                "d" => validate_field("d", self.d, Some(0.00001), None, "point_load_test"),
71                unknown => Err(ValidationError {
72                    code: "point_load_test.invalid_field".into(),
73                    message: format!("Field '{}' is not valid for Point Load Test.", unknown),
74                }),
75            };
76
77            result?; // propagate error if any field fails
78        }
79
80        Ok(())
81    }
82}
83
84/// Represents a single borehole containing multiple Point Load Test samples.
85///
86/// # Fields
87/// * `borehole_id` - Identifier for the borehole.
88/// * `samples` - Collection of Point Load Test samples taken from the borehole.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct PointLoadExp {
91    pub borehole_id: String,
92    pub samples: Vec<PointLoadSample>,
93}
94
95impl PointLoadExp {
96    pub fn new(borehole_id: String, samples: Vec<PointLoadSample>) -> Self {
97        Self {
98            borehole_id,
99            samples,
100        }
101    }
102
103    pub fn add_sample(&mut self, sample: PointLoadSample) {
104        self.samples.push(sample);
105    }
106
107    /// Retrieves the sample at the specified depth.
108    ///
109    /// This function finds the first sample whose depth is greater than or equal to the given `depth`.
110    /// If no such sample is found, it returns the last sample in the list.
111    ///
112    /// # Arguments
113    ///
114    /// * `depth` - The depth at which to search for an experiment sample.
115    ///
116    /// # Returns
117    ///
118    /// A reference to the matching `PointLoadSample`.
119    pub fn get_sample_at_depth(&self, depth: f64) -> &PointLoadSample {
120        self.samples
121            .iter()
122            .find(|exp| exp.depth.unwrap() >= depth)
123            .unwrap_or_else(|| self.samples.last().unwrap())
124    }
125
126    /// Validates specific fields of the PointLoadExp using field names.
127    ///
128    /// # Arguments
129    /// * `fields` - A slice of field names to validate.
130    ///
131    /// # Returns
132    /// Ok(()) if all fields are valid, or an error if any field is invalid.
133    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
134        if self.samples.is_empty() {
135            return Err(ValidationError {
136                code: "point_load_test.empty_samples".into(),
137                message: "No samples provided for Point Load Experiment.".into(),
138            });
139        }
140        for sample in &self.samples {
141            sample.validate(fields)?;
142        }
143
144        Ok(())
145    }
146}
147
148/// Represents the entire Point Load Test comprising multiple boreholes.
149///
150/// # Fields
151/// * `exps` - Collection of borehole tests included in the overall test campaign.
152/// * `idealization_method` - Method used for idealizing the test results.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct PointLoadTest {
155    pub exps: Vec<PointLoadExp>,
156    pub idealization_method: SelectionMethod,
157}
158
159impl PointLoadTest {
160    pub fn new(exps: Vec<PointLoadExp>, idealization_method: SelectionMethod) -> Self {
161        Self {
162            exps,
163            idealization_method,
164        }
165    }
166
167    pub fn add_borehole(&mut self, exp: PointLoadExp) {
168        self.exps.push(exp);
169    }
170
171    /// Get the idealized experiment
172    ///
173    /// # Arguments
174    /// * `mode` - Idealized mode to use when combining the layers
175    /// * `name` - Name of the idealized experiment
176    ///
177    /// # Returns
178    /// * `PointLoadExp` - Idealized experiment
179    pub fn get_idealized_exp(&self, name: String) -> PointLoadExp {
180        if self.exps.is_empty() {
181            return PointLoadExp::new(name, vec![]);
182        }
183
184        let mode = self.idealization_method;
185
186        let mut depth_map: BTreeMap<
187            OrderedFloat<f64>,
188            Vec<(OrderedFloat<f64>, OrderedFloat<f64>)>,
189        > = BTreeMap::new();
190
191        // Collect all unique depths and corresponding (is50, d) values
192        for exp in &self.exps {
193            for sample in &exp.samples {
194                depth_map
195                    .entry(OrderedFloat(sample.depth.unwrap()))
196                    .or_default()
197                    .push((
198                        OrderedFloat(sample.is50.unwrap()),
199                        OrderedFloat(sample.d.unwrap()),
200                    ));
201            }
202        }
203
204        // Create a new PointLoadExp with selected values
205        let mut idealized_samples = Vec::new();
206
207        for (&depth, is50_d_pairs) in &depth_map {
208            let selected_is50 = match mode {
209                SelectionMethod::Min => is50_d_pairs.iter().min_by_key(|&(is50, _)| is50).unwrap(),
210                SelectionMethod::Max => is50_d_pairs.iter().max_by_key(|&(is50, _)| is50).unwrap(),
211                SelectionMethod::Avg => {
212                    let sum_is50: f64 =
213                        is50_d_pairs.iter().map(|(is50, _)| is50.into_inner()).sum();
214                    let sum_d: f64 = is50_d_pairs.iter().map(|(_, d)| d.into_inner()).sum();
215                    let count = is50_d_pairs.len() as f64;
216                    &(OrderedFloat(sum_is50 / count), OrderedFloat(sum_d / count))
217                }
218            };
219
220            // Add to new PointLoadExp
221            idealized_samples.push(PointLoadSample::new(
222                depth.into_inner(),
223                selected_is50.0.into_inner(),
224                selected_is50.1.into_inner(),
225            ));
226        }
227
228        PointLoadExp::new(name, idealized_samples)
229    }
230    /// Validates specific fields of the PointLoadTest using field names.
231    ///
232    /// # Arguments
233    /// * `fields` - A slice of field names to validate.
234    ///
235    /// # Returns
236    /// Ok(()) if all fields are valid, or an error if any field is invalid.
237    pub fn validate(&self, fields: &[&str]) -> Result<(), ValidationError> {
238        if self.exps.is_empty() {
239            return Err(ValidationError {
240                code: "point_load_test.empty_exps".into(),
241                message: "No experiments provided for Point Load Test.".into(),
242            });
243        }
244        for exp in &self.exps {
245            exp.validate(fields)?;
246        }
247
248        Ok(())
249    }
250}