soil_sensor_toolbox/
lib.rs

1/*
2 * VWC (Volumetric Water Content) Calculation Library
3 *
4 * This implementation is based on the myClim R package algorithms and coefficients.
5 * Original myClim package: https://github.com/ibot-geoecology/myClim
6 *
7 * Copyright notice for myClim-derived components:
8 * The VWC calculation algorithm, soil type coefficients, and temperature correction
9 * constants are derived from the myClim R package, which is licensed under GPL v2.
10 *
11 * myClim package authors and contributors:
12 * - Institute of Botany of the Czech Academy of Sciences
13 * - See: https://github.com/ibot-geoecology/myClim
14 *
15 * This program is free software; you can redistribute it and/or modify
16 * it under the terms of the GNU General Public License as published by
17 * the Free Software Foundation; either version 2 of the License, or
18 * (at your option) any later version.
19 *
20 * This program is distributed in the hope that it will be useful,
21 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 * GNU General Public License for more details.
24 */
25
26use anyhow::Result;
27use chrono::NaiveDateTime;
28use csv::ReaderBuilder;
29use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
32pub enum SoilType {
33    Sand,
34    LoamySandA,
35    LoamySandB,
36    SandyLoamA,
37    SandyLoamB,
38    Loam,
39    SiltLoam,
40    Peat,
41    Water,
42    Universal,
43    SandTMS1,
44    LoamySandTMS1,
45    SiltLoamTMS1,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct SoilTypeModel {
50    pub id: SoilType,
51    pub name: String,
52    pub machine_name: String,
53}
54
55impl SoilType {
56    /// Soil type coefficients (a, b, c) for VWC = a·count² + b·count + c
57    /// Source: myClim R package (<https://github.com/ibot-geoecology/myClim>)
58    /// References:
59    /// - Wild et al. (2019), 10.1016/j.agrformet.2018.12.018 (soil types 1-9)
60    /// - Kopecký et al. (2021), 10.1016/j.scitotenv.2020.143785 (universal)
61    /// - Vlček (2010) Kalibrace vlhkostního čidla TST1 (TMS1 variants)
62    fn coeffs(self) -> (f64, f64, f64) {
63        match self {
64            SoilType::Sand => (-3.00e-09, 0.000_161_192, -0.109_956_5),
65            SoilType::LoamySandA => (-1.90e-08, 0.000_265_610, -0.154_089_3),
66            SoilType::LoamySandB => (-2.30e-08, 0.000_282_473, -0.167_211_2),
67            SoilType::SandyLoamA => (-3.80e-08, 0.000_339_449, -0.214_921_8),
68            SoilType::SandyLoamB => (-9.00e-10, 0.000_261_847, -0.158_618_3),
69            SoilType::Loam => (-5.10e-08, 0.000_397_984, -0.291_046_4),
70            SoilType::SiltLoam => (1.70e-08, 0.000_118_119, -0.101_168_5),
71            SoilType::Peat => (1.23e-07, -0.000_144_644, 0.202_927_9),
72            SoilType::Water => (0.00e+00, 0.000_306_700, -0.134_927_9),
73            SoilType::Universal => (-1.34e-08, 0.000_249_622, -0.157_888_8),
74            SoilType::SandTMS1 => (0.00e+00, 0.000_260_000, -0.133_040_0),
75            SoilType::LoamySandTMS1 => (0.00e+00, 0.000_330_000, -0.193_890_0),
76            SoilType::SiltLoamTMS1 => (0.00e+00, 0.000_380_000, -0.294_270_0),
77        }
78    }
79
80    #[must_use]
81    pub fn as_str(&self) -> &'static str {
82        match self {
83            SoilType::Sand => "sand",
84            SoilType::LoamySandA => "loamysanda",
85            SoilType::LoamySandB => "loamysandb",
86            SoilType::SandyLoamA => "sandyloama",
87            SoilType::SandyLoamB => "sandyloamb",
88            SoilType::Loam => "loam",
89            SoilType::SiltLoam => "siltloam",
90            SoilType::Peat => "peat",
91            SoilType::Water => "water",
92            SoilType::Universal => "universal",
93            SoilType::SandTMS1 => "sandtms1",
94            SoilType::LoamySandTMS1 => "loamysandtms1",
95            SoilType::SiltLoamTMS1 => "siltloamtms1",
96        }
97    }
98
99    pub const ALL: [SoilType; 13] = [
100        SoilType::Sand,
101        SoilType::LoamySandA,
102        SoilType::LoamySandB,
103        SoilType::SandyLoamA,
104        SoilType::SandyLoamB,
105        SoilType::Loam,
106        SoilType::SiltLoam,
107        SoilType::Peat,
108        SoilType::Water,
109        SoilType::Universal,
110        SoilType::SandTMS1,
111        SoilType::LoamySandTMS1,
112        SoilType::SiltLoamTMS1,
113    ];
114}
115
116impl From<SoilType> for SoilTypeModel {
117    fn from(soil: SoilType) -> Self {
118        match soil {
119            SoilType::Sand => SoilTypeModel {
120                id: SoilType::Sand,
121                name: "Sand".to_string(),
122                machine_name: "sand".to_string(),
123            },
124            SoilType::LoamySandA => SoilTypeModel {
125                id: SoilType::LoamySandA,
126                name: "Loamy Sand A".to_string(),
127                machine_name: "loamysanda".to_string(),
128            },
129            SoilType::LoamySandB => SoilTypeModel {
130                id: SoilType::LoamySandB,
131                name: "Loamy Sand B".to_string(),
132                machine_name: "loamysandb".to_string(),
133            },
134            SoilType::SandyLoamA => SoilTypeModel {
135                id: SoilType::SandyLoamA,
136                name: "Sandy Loam A".to_string(),
137                machine_name: "sandyloama".to_string(),
138            },
139            SoilType::SandyLoamB => SoilTypeModel {
140                id: SoilType::SandyLoamB,
141                name: "Sandy Loam B".to_string(),
142                machine_name: "sandyloamb".to_string(),
143            },
144            SoilType::Loam => SoilTypeModel {
145                id: SoilType::Loam,
146                name: "Loam".to_string(),
147                machine_name: "loam".to_string(),
148            },
149            SoilType::SiltLoam => SoilTypeModel {
150                id: SoilType::SiltLoam,
151                name: "Silt Loam".to_string(),
152                machine_name: "siltloam".to_string(),
153            },
154            SoilType::Peat => SoilTypeModel {
155                id: SoilType::Peat,
156                name: "Peat".to_string(),
157                machine_name: "peat".to_string(),
158            },
159            SoilType::Water => SoilTypeModel {
160                id: SoilType::Water,
161                name: "Water".to_string(),
162                machine_name: "water".to_string(),
163            },
164            SoilType::Universal => SoilTypeModel {
165                id: SoilType::Universal,
166                name: "Universal".to_string(),
167                machine_name: "universal".to_string(),
168            },
169            SoilType::SandTMS1 => SoilTypeModel {
170                id: SoilType::SandTMS1,
171                name: "Sand TMS1".to_string(),
172                machine_name: "sandtms1".to_string(),
173            },
174            SoilType::LoamySandTMS1 => SoilTypeModel {
175                id: SoilType::LoamySandTMS1,
176                name: "Loamy Sand TMS1".to_string(),
177                machine_name: "loamysandtms1".to_string(),
178            },
179            SoilType::SiltLoamTMS1 => SoilTypeModel {
180                id: SoilType::SiltLoamTMS1,
181                name: "Silt Loam TMS1".to_string(),
182                machine_name: "siltloamtms1".to_string(),
183            },
184        }
185    }
186}
187
188impl TryFrom<&str> for SoilTypeModel {
189    type Error = String;
190
191    fn try_from(s: &str) -> Result<Self, Self::Error> {
192        match s.to_lowercase().as_str() {
193            "sand" => Ok(Self::from(SoilType::Sand)),
194            "loamysanda" => Ok(Self::from(SoilType::LoamySandA)),
195            "loamysandb" => Ok(Self::from(SoilType::LoamySandB)),
196            "sandyloama" => Ok(Self::from(SoilType::SandyLoamA)),
197            "sandyloamb" => Ok(Self::from(SoilType::SandyLoamB)),
198            "loam" => Ok(Self::from(SoilType::Loam)),
199            "siltloam" => Ok(Self::from(SoilType::SiltLoam)),
200            "peat" => Ok(Self::from(SoilType::Peat)),
201            "water" => Ok(Self::from(SoilType::Water)),
202            "universal" => Ok(Self::from(SoilType::Universal)),
203            "sandtms1" => Ok(Self::from(SoilType::SandTMS1)),
204            "loamysandtms1" => Ok(Self::from(SoilType::LoamySandTMS1)),
205            "siltloamtms1" => Ok(Self::from(SoilType::SiltLoamTMS1)),
206            _ => Err(format!("Unknown soil type: {s}")),
207        }
208    }
209}
210
211// myClim temperature correction constants
212// Source: myClim R package constants
213const REF_T: f64 = 24.0; // Reference temperature (°C)
214const ACOR_T: f64 = 1.911_327; // Temperature correction coefficient A
215const WCOR_T: f64 = 0.64108; // Temperature correction coefficient W
216
217/// Calculate VWC using the myClim algorithm
218///
219/// This function implements the exact algorithm from the myClim R package:
220/// 1. Calculate initial VWC from raw sensor values
221/// 2. Apply temperature correction to raw values  
222/// 3. Recalculate VWC with temperature-corrected values
223/// 4. Apply calibration corrections (if any)
224/// 5. Clamp result between 0 and 1
225///
226/// # Arguments
227/// * `raw_value` - Raw moisture sensor reading
228/// * `temp_value` - Temperature reading (°C)
229/// * `soil` - Soil type for coefficient selection
230///
231/// # Returns
232/// Volumetric Water Content (VWC) as a fraction (0.0 to 1.0)
233fn mc_calc_vwc(raw_value: f64, temp_value: f64, soil: SoilType) -> f64 {
234    let (a, b, c) = soil.coeffs();
235
236    // Step 1: Initial VWC calculation
237    let vwc = a * raw_value * raw_value + b * raw_value + c;
238
239    // Step 2: Temperature correction (from myClim source)
240    let dcor_t = WCOR_T - ACOR_T;
241    let tcor = if temp_value.is_nan() {
242        raw_value
243    } else {
244        raw_value + (REF_T - temp_value) * (ACOR_T + dcor_t * vwc)
245    };
246
247    // Step 3: Temperature-corrected VWC calculation
248    // Note: cal_cor_factor and cal_cor_slope are 0 for uncalibrated data
249    let cal_cor_factor = 0.0;
250    let cal_cor_slope = 0.0;
251    let corrected_raw = tcor + cal_cor_factor + cal_cor_slope * vwc;
252    let vwc_cor = a * corrected_raw * corrected_raw + b * corrected_raw + c;
253
254    // Step 4: Clamp result between 0 and 1 (pmin(pmax(vwc_cor, 0), 1))
255    vwc_cor.clamp(0.0, 1.0)
256}
257
258#[derive(Debug, Deserialize)]
259struct RawRecord {
260    _field0: String,  // index 0
261    datetime: String, // index 1
262    _field2: String,  // index 2
263    temp: f64,        // index 3 - temperature field
264    _field4: String,  // index 4
265    _field5: String,  // index 5
266    raw: f64,         // index 6 - raw count for VWC calculation
267    _field7: String,  // index 7
268    _field8: String,  // index 8
269}
270
271/// Read `<path>`, compute VWC for `soil`, return (datetime, raw, temp, vwc).
272///
273/// # Errors
274///
275/// This function returns an error if:
276/// - The file at `path` cannot be opened or read
277/// - CSV parsing fails due to invalid format
278/// - `DateTime` parsing fails (expects format: "%Y.%m.%d %H:%M")
279/// - Any field deserialization fails
280pub fn process_file(path: String, soil: SoilType) -> Result<Vec<(NaiveDateTime, f64, f64, f64)>> {
281    let mut rdr = ReaderBuilder::new()
282        .delimiter(b';')
283        .has_headers(false)
284        .from_path(path)?;
285    let mut out = Vec::new();
286    for result in rdr.deserialize() {
287        let rec: RawRecord = result?;
288        let dt = NaiveDateTime::parse_from_str(&rec.datetime, "%Y.%m.%d %H:%M")?;
289        let vwc = mc_calc_vwc(rec.raw, rec.temp, soil);
290        out.push((dt, rec.raw, rec.temp, vwc));
291    }
292    Ok(out)
293}