Skip to main content

groundmodels_core/
strip_log.rs

1use crate::{GroundModel, SoilLayer, SoilType};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct StripLogEntry {
6    pub top_level: f64,
7    pub bottom_level: f64,
8    pub thickness: f64,
9    pub reference: String,
10    pub typical_description: Option<String>,
11    pub behavior: SoilType,
12    pub unit_weight: Option<f64>,
13    pub phi_prime_deg: Option<f64>,
14    pub c_prime: Option<f64>,
15    pub cu: Option<f64>,
16    pub gw_within_layer: bool,
17    pub gw_above_top: bool,
18    pub sigma_v_total_mid: Option<f64>,
19    pub u_mid: Option<f64>,
20    pub sigma_v_prime_mid: Option<f64>,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
24pub struct BuildStripLogOptions {
25    pub include_stresses: bool,
26    pub stress_dz: f64,
27}
28
29impl Default for BuildStripLogOptions {
30    fn default() -> Self {
31        BuildStripLogOptions {
32            include_stresses: false,
33            stress_dz: 0.05,
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct StripLogRenderOptions {
40    pub column_width_px: u32,
41    pub px_per_meter: Option<f64>,
42    pub left_margin_px: u32,
43    pub right_margin_px: u32,
44    pub top_margin_px: u32,
45    pub bottom_margin_px: u32,
46    pub tick_every_meters: f64,
47    pub show_grid: bool,
48    pub show_labels: bool,
49    pub axis_unit_label: String,
50    pub colors: Option<StripLogColors>,
51    pub font_family: String,
52    pub title: Option<String>,
53}
54
55impl Default for StripLogRenderOptions {
56    fn default() -> Self {
57        StripLogRenderOptions {
58            column_width_px: 220,
59            px_per_meter: None,
60            left_margin_px: 64,
61            right_margin_px: 64,
62            top_margin_px: 28,
63            bottom_margin_px: 28,
64            tick_every_meters: 1.0,
65            show_grid: true,
66            show_labels: true,
67            axis_unit_label: "m".to_string(),
68            colors: None,
69            font_family: "Segoe UI, Arial, sans-serif".to_string(),
70            title: None,
71        }
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StripLogColors {
77    pub cohesive: Option<String>,
78    pub granular: Option<String>,
79    pub rock: Option<String>,
80}
81
82impl Default for StripLogColors {
83    fn default() -> Self {
84        StripLogColors {
85            cohesive: Some("#B8906B".to_string()),
86            granular: Some("#F6D04D".to_string()),
87            rock: Some("#9BA3AD".to_string()),
88        }
89    }
90}
91
92impl GroundModel {
93    pub fn to_strip_log(&self, opts: BuildStripLogOptions) -> Vec<StripLogEntry> {
94        let mut layers = self.soil_layers.clone();
95        layers.sort_by(|a, b| b.top_level.partial_cmp(&a.top_level).unwrap());
96
97        let mut entries = Vec::new();
98        for layer in layers {
99            let reference = layer_reference(&layer);
100            let params = self.get_soil_params(&reference).cloned();
101            let mid = match layer.base_level {
102                Some(base) => (layer.top_level + base) / 2.0,
103                None => layer.top_level,
104            };
105            let gw = self.groundwater;
106            let base = layer.base_level.unwrap_or(layer.top_level);
107            let gw_within =
108                (gw >= base && gw <= layer.top_level) || (gw <= layer.top_level && gw >= base);
109            let gw_above_top = gw > layer.top_level;
110
111            let mut entry = StripLogEntry {
112                top_level: layer.top_level,
113                bottom_level: base,
114                thickness: (layer.top_level - base).abs(),
115                reference: reference.clone(),
116                typical_description: if layer.typical_description.is_empty() {
117                    None
118                } else {
119                    Some(layer.typical_description.clone())
120                },
121                behavior: params
122                    .as_ref()
123                    .map(|p| p.behaviour)
124                    .unwrap_or(SoilType::Granular),
125                unit_weight: params.as_ref().map(|p| p.unit_weight),
126                phi_prime_deg: params
127                    .as_ref()
128                    .and_then(|p| p.phi_prime)
129                    .map(|phi| phi.to_degrees()),
130                c_prime: params.as_ref().and_then(|p| p.c_prime),
131                cu: params.as_ref().and_then(|p| p.cu),
132                gw_within_layer: gw_within,
133                gw_above_top,
134                sigma_v_total_mid: None,
135                u_mid: None,
136                sigma_v_prime_mid: None,
137            };
138
139            if opts.include_stresses {
140                let sigma_v = integrate_sigma_v_total_at(self, mid, opts.stress_dz);
141                let u = self.get_pwp_at_level(mid);
142                entry.sigma_v_total_mid = Some(sigma_v);
143                entry.u_mid = Some(u);
144                entry.sigma_v_prime_mid = Some(sigma_v - u);
145            }
146
147            entries.push(entry);
148        }
149
150        entries
151    }
152
153    pub fn to_strip_log_csv(&self, rows: Option<Vec<StripLogEntry>>) -> String {
154        let rows = rows.unwrap_or_else(|| self.to_strip_log(BuildStripLogOptions::default()));
155        let headers = [
156            "TOP_LEVEL(m)",
157            "BOTTOM_LEVEL(m)",
158            "THICKNESS(m)",
159            "REFERENCE",
160            "DESCRIPTION",
161            "BEHAVIOR",
162            "GAMMA(kN/m3)",
163            "PHI_PRIME(deg)",
164            "C_PRIME(kPa)",
165            "CU(kPa)",
166            "GW_WITHIN_LAYER",
167            "GW_ABOVE_TOP",
168            "SIGMA_V_TOTAL_MID(kPa)",
169            "U_MID(kPa)",
170            "SIGMA_V_PRIME_MID(kPa)",
171        ];
172
173        let mut lines = vec![headers.join(",")];
174        for r in rows {
175            let desc = r.typical_description.unwrap_or_default();
176            lines.push(
177                [
178                    r.top_level.to_string(),
179                    r.bottom_level.to_string(),
180                    r.thickness.to_string(),
181                    csv_quote(&r.reference),
182                    csv_quote(&desc),
183                    format!("{:?}", r.behavior),
184                    r.unit_weight.map_or("".to_string(), |v| v.to_string()),
185                    r.phi_prime_deg.map_or("".to_string(), |v| v.to_string()),
186                    r.c_prime.map_or("".to_string(), |v| v.to_string()),
187                    r.cu.map_or("".to_string(), |v| v.to_string()),
188                    r.gw_within_layer.to_string(),
189                    r.gw_above_top.to_string(),
190                    r.sigma_v_total_mid
191                        .map_or("".to_string(), |v| v.to_string()),
192                    r.u_mid.map_or("".to_string(), |v| v.to_string()),
193                    r.sigma_v_prime_mid
194                        .map_or("".to_string(), |v| v.to_string()),
195                ]
196                .join(","),
197            );
198        }
199
200        lines.join("\n")
201    }
202
203    pub fn to_ags_geol_csv(&self, hole_id: &str, rows: Option<Vec<StripLogEntry>>) -> String {
204        let rows = rows.unwrap_or_else(|| self.to_strip_log(BuildStripLogOptions::default()));
205        let headers = ["HOLE_ID", "GEOL_TOP(m)", "GEOL_BASE(m)", "GEOL_DESC"];
206        let mut lines = vec![headers.join(",")];
207        for r in rows {
208            let desc = r
209                .typical_description
210                .unwrap_or_else(|| format!("{:?}", r.behavior));
211            lines.push(
212                [
213                    csv_quote(hole_id),
214                    r.top_level.to_string(),
215                    r.bottom_level.to_string(),
216                    csv_quote(&desc),
217                ]
218                .join(","),
219            );
220        }
221        lines.join("\n")
222    }
223
224    pub fn render_strip_log_svg(&self, opts: StripLogRenderOptions) -> String {
225        let colors = opts.colors.unwrap_or_default();
226        let top = self.get_top_level();
227        let bottom = self.get_base_level();
228        let model_height_m = (top - bottom).abs().max(0.0001);
229        let col_height = 600.0;
230        let scale = opts.px_per_meter.unwrap_or(col_height / model_height_m);
231
232        let width = opts.left_margin_px + opts.column_width_px + opts.right_margin_px;
233        let height = opts.top_margin_px + (model_height_m * scale) as u32 + opts.bottom_margin_px;
234        let col_x = opts.left_margin_px as f64;
235        let col_y = opts.top_margin_px as f64;
236        let col_h = (model_height_m * scale).round();
237
238        let y_for_level = |level: f64| col_y + (top - level) * scale;
239        let depth_at = |level: f64| top - level;
240
241        let rows = self.to_strip_log(BuildStripLogOptions::default());
242
243        let mut svg = Vec::new();
244        svg.push(format!(
245            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
246            width, height, width, height
247        ));
248        svg.push(format!(
249            "<style>.axis{{font:12px {};fill:#333;}}.title{{font:14px {};font-weight:600;fill:#111;}}.tickLabelDepth{{dominant-baseline:middle;text-anchor:end;}}.tickLabelLevel{{dominant-baseline:middle;text-anchor:start;}}.layerDesc{{font:12px {};fill:#222;}}.layerNotes{{font:10px {};fill:#444;}}.legend{{font:12px {};}}</style>",
250            opts.font_family, opts.font_family, opts.font_family, opts.font_family, opts.font_family
251        ));
252
253        if let Some(title) = &opts.title {
254            svg.push(format!(
255                "<text class=\"title\" x=\"{}\" y=\"{}\">{}</text>",
256                opts.left_margin_px,
257                (opts.top_margin_px as f64 * 0.7).max(16.0),
258                escape_xml(title)
259            ));
260        }
261
262        if opts.show_grid {
263            let mut m = bottom.ceil();
264            while m <= top.floor() {
265                let yy = y_for_level(m).round() + 0.5;
266                svg.push(format!(
267                    "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#eee\" />",
268                    col_x,
269                    yy,
270                    col_x + opts.column_width_px as f64,
271                    yy
272                ));
273                m += opts.tick_every_meters;
274            }
275        }
276
277        svg.push(format!(
278            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#fff\" stroke=\"#222\" />",
279            col_x, col_y, opts.column_width_px, col_h
280        ));
281
282        let mut m = bottom.ceil();
283        while m <= top.floor() {
284            let yy = y_for_level(m).round() + 0.5;
285            let depth = depth_at(m);
286            svg.push(format!(
287                "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#222\" />",
288                col_x - 6.0,
289                yy,
290                col_x,
291                yy
292            ));
293            svg.push(format!(
294                "<text class=\"axis tickLabelDepth\" x=\"{}\" y=\"{}\">{}</text>",
295                col_x - 8.0,
296                yy,
297                format_number(depth)
298            ));
299            m += opts.tick_every_meters;
300        }
301        svg.push(format!(
302            "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"end\">m bGL</text>",
303            col_x - 8.0,
304            col_y - 6.0
305        ));
306
307        let mut m = bottom.ceil();
308        while m <= top.floor() {
309            let yy = y_for_level(m).round() + 0.5;
310            svg.push(format!(
311                "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#222\" />",
312                col_x + opts.column_width_px as f64,
313                yy,
314                col_x + opts.column_width_px as f64 + 6.0,
315                yy
316            ));
317            svg.push(format!(
318                "<text class=\"axis tickLabelLevel\" x=\"{}\" y=\"{}\">{}</text>",
319                col_x + opts.column_width_px as f64 + 8.0,
320                yy,
321                format_number(m)
322            ));
323            m += opts.tick_every_meters;
324        }
325        svg.push(format!(
326            "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"start\">{}</text>",
327            col_x + opts.column_width_px as f64 + 8.0,
328            col_y - 6.0,
329            escape_xml(&opts.axis_unit_label)
330        ));
331
332        for row in rows {
333            let y_top = y_for_level(row.top_level);
334            let y_bot = y_for_level(row.bottom_level);
335            let h = (y_bot - y_top).max(0.0);
336            let fill = match row.behavior {
337                SoilType::Cohesive => colors
338                    .cohesive
339                    .clone()
340                    .unwrap_or_else(|| "#B8906B".to_string()),
341                SoilType::Granular => colors
342                    .granular
343                    .clone()
344                    .unwrap_or_else(|| "#F6D04D".to_string()),
345                SoilType::Rock => colors.rock.clone().unwrap_or_else(|| "#9BA3AD".to_string()),
346            };
347
348            svg.push(format!(
349                "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\" stroke=\"#555\" stroke-width=\"0.5\"/>",
350                col_x,
351                y_top,
352                opts.column_width_px,
353                h,
354                fill
355            ));
356
357            if opts.show_labels && h >= 16.0 {
358                let inner_x = col_x + 6.0;
359                let max_text_width = opts.column_width_px as f64 - 12.0;
360                let desc = row
361                    .typical_description
362                    .clone()
363                    .unwrap_or_else(|| format!("{:?}", row.behavior));
364                let mut cursor_y = y_top + 6.0 + 12.0;
365                for line in wrap_text(&desc, max_text_width, 12.0) {
366                    if cursor_y < y_bot - 2.0 {
367                        svg.push(format!(
368                            "<text class=\"layerDesc\" x=\"{}\" y=\"{}\" dominant-baseline=\"alphabetic\">{}</text>",
369                            inner_x,
370                            cursor_y,
371                            escape_xml(&line)
372                        ));
373                        cursor_y += 14.0;
374                    }
375                }
376            }
377        }
378
379        if self.groundwater <= top && self.groundwater >= bottom {
380            let y_gw = y_for_level(self.groundwater).round() + 0.5;
381            svg.push(format!(
382                "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#1E90FF\" stroke-width=\"2\" stroke-dasharray=\"6,4\"/>",
383                col_x,
384                y_gw,
385                col_x + opts.column_width_px as f64,
386                y_gw
387            ));
388            svg.push(format!(
389                "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"end\" fill=\"#1E90FF\">Groundwater</text>",
390                col_x - 8.0,
391                y_gw - 2.0
392            ));
393            svg.push(format!(
394                "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"start\" fill=\"#1E90FF\">{} {}</text>",
395                col_x + opts.column_width_px as f64 + 8.0,
396                y_gw - 2.0,
397                format_number(self.groundwater),
398                escape_xml(&opts.axis_unit_label)
399            ));
400        }
401
402        svg.push("</svg>".to_string());
403        svg.join("")
404    }
405}
406
407fn layer_reference(layer: &SoilLayer) -> String {
408    if !layer.unit_reference.is_empty() {
409        layer.unit_reference.clone()
410    } else {
411        layer.reference.clone()
412    }
413}
414
415fn integrate_sigma_v_total_at(model: &GroundModel, level: f64, dz: f64) -> f64 {
416    let top = model.get_top_level();
417    if level >= top {
418        return 0.0;
419    }
420    let mut s = 0.0;
421    let mut z = top - dz / 2.0;
422    while z > level {
423        let sp = model.get_params_at_level(z).ok();
424        if let Some(params) = sp {
425            s += params.unit_weight * dz;
426        }
427        z -= dz;
428    }
429    s
430}
431
432fn csv_quote(s: &str) -> String {
433    format!("\"{}\"", s.replace('"', "\"\""))
434}
435
436fn escape_xml(s: &str) -> String {
437    s.replace('&', "&amp;")
438        .replace('<', "&lt;")
439        .replace('>', "&gt;")
440        .replace('"', "&quot;")
441        .replace('\'', "&apos;")
442}
443
444fn wrap_text(text: &str, max_width_px: f64, font_size_px: f64) -> Vec<String> {
445    let approx_char_w = 0.6 * font_size_px;
446    let max_chars = ((max_width_px / approx_char_w).floor() as usize).max(4);
447    let words: Vec<&str> = text.split_whitespace().collect();
448    let mut lines = Vec::new();
449    let mut line = String::new();
450
451    for w in words {
452        if line.is_empty() {
453            line = w.to_string();
454        } else if line.len() + 1 + w.len() <= max_chars {
455            line.push(' ');
456            line.push_str(w);
457        } else {
458            lines.push(line);
459            line = w.to_string();
460        }
461    }
462    if !line.is_empty() {
463        lines.push(line);
464    }
465    lines
466}
467
468fn format_number(n: f64) -> String {
469    if n.abs() < 1e-6 {
470        "0".to_string()
471    } else {
472        format!("{:.3}", n)
473            .trim_end_matches('0')
474            .trim_end_matches('.')
475            .to_string()
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::SoilParams;
483
484    fn sample_model() -> GroundModel {
485        let mut params1 = SoilParams::default();
486        params1.reference = "CL".to_string();
487        params1.unit_weight = 19.0;
488        params1.behaviour = SoilType::Cohesive;
489        params1.phi_prime = Some(22.0_f64.to_radians());
490
491        let mut params2 = SoilParams::default();
492        params2.reference = "SA".to_string();
493        params2.unit_weight = 18.0;
494        params2.behaviour = SoilType::Granular;
495        params2.phi_prime = Some(30.0_f64.to_radians());
496
497        let layer1 = SoilLayer::with_all_fields(
498            "CL".to_string(),
499            1.0,
500            Some(-1.0),
501            None,
502            "Firm clay".to_string(),
503            "CL".to_string(),
504        );
505        let layer2 = SoilLayer::with_all_fields(
506            "SA".to_string(),
507            -1.0,
508            Some(-3.0),
509            None,
510            "Medium dense sand".to_string(),
511            "SA".to_string(),
512        );
513
514        let mut model = GroundModel::new(vec![layer1, layer2], vec![params1, params2]);
515        model.groundwater = -0.5;
516        model
517    }
518
519    #[test]
520    fn strip_log_csv_has_headers() {
521        let model = sample_model();
522        let csv = model.to_strip_log_csv(None);
523        assert!(csv.starts_with("TOP_LEVEL(m),BOTTOM_LEVEL(m),THICKNESS(m),REFERENCE"));
524    }
525
526    #[test]
527    fn strip_log_svg_contains_elements() {
528        let model = sample_model();
529        let svg = model.render_strip_log_svg(StripLogRenderOptions::default());
530        assert!(svg.starts_with("<svg"));
531        assert!(svg.contains("stroke=\"#1E90FF\""));
532    }
533
534    #[test]
535    fn ags_geol_csv_has_hole_id() {
536        let model = sample_model();
537        let csv = model.to_ags_geol_csv("BH101", None);
538        assert!(csv.contains("\"BH101\""));
539    }
540}