Skip to main content

_standard_knowledge_py/
standards_library.rs

1use std::collections::{BTreeMap, HashMap};
2
3use pyo3::{exceptions::PyKeyError, prelude::*, types::PyDict};
4
5use crate::standard::PyStandard;
6use standard_knowledge::qartod::static_qc::StaticQc;
7use standard_knowledge::{Knowledge, StandardsLibrary};
8
9#[pyclass(name = "StandardsLibrary")]
10#[derive(Clone)]
11pub struct PyStandardsLibrary(pub StandardsLibrary);
12
13/// A collection of CF compatible standards with methods for searching through them
14#[pymethods]
15impl PyStandardsLibrary {
16    #[new]
17    fn new() -> Self {
18        Self(StandardsLibrary {
19            standards: HashMap::new(),
20        })
21    }
22
23    fn __repr__(&self) -> PyResult<String> {
24        Ok(format!(
25            "<StandardsLibrary: {} standards>",
26            self.0.standards.len()
27        ))
28    }
29
30    /// Get the standards dictionary
31    #[getter]
32    fn standards(&self) -> PyResult<HashMap<String, crate::standard::PyStandard>> {
33        let mut py_standards = HashMap::new();
34        for (key, standard) in &self.0.standards {
35            py_standards.insert(key.clone(), crate::standard::PyStandard(standard.clone()));
36        }
37        Ok(py_standards)
38    }
39
40    /// Load CF standards into library
41    fn load_cf_standards(&mut self) {
42        self.0.load_cf_standards();
43    }
44
45    /// Get a standard by standard name or aliases
46    fn get(&self, py: Python, name_or_alias: &str) -> PyResult<Py<PyStandard>> {
47        match self.0.get(name_or_alias) {
48            Ok(standard) => {
49                let py_standard = PyStandard(standard);
50                Py::new(py, py_standard)
51            }
52            Err(e) => Err(PyKeyError::new_err(e.to_string())),
53        }
54    }
55
56    /// Apply knowledge to loaded standards
57    fn apply_knowledge(
58        &mut self,
59        knowledge: Vec<HashMap<String, KnowledgeValues>>,
60    ) -> PyResult<()> {
61        let mut cleaned_knowledge = Vec::new();
62
63        for know in knowledge {
64            let name;
65            if let Some(value) = know.get("name") {
66                if let KnowledgeValues::String(str_value) = value {
67                    name = str_value.clone();
68                } else {
69                    return Err(PyKeyError::new_err("`name` is not a string in knowledge"));
70                }
71            } else {
72                return Err(PyKeyError::new_err(
73                    "The knowledge needs a name of the standard to be applied to",
74                ));
75            }
76
77            let long_name = get_string_field(&know, "long_name")?;
78            let ioos_category = get_string_field(&know, "ioos_category")?;
79            let comments = get_string_field(&know, "comments")?;
80
81            let common_variable_names = get_list_field(&know, "common_variable_names")?;
82            let related_standards = get_list_field(&know, "related_standards")?;
83            let sibling_standards = get_list_field(&know, "sibling_standards")?;
84            let extra_attrs = get_dict_field(&know, "extra_attrs")?;
85            let other_units = get_list_field(&know, "other_units")?;
86            let qc = get_static_qc_field(&know, "qc")?;
87
88            let cleaned = Knowledge {
89                name,
90                long_name,
91                ioos_category,
92                common_variable_names,
93                related_standards,
94                sibling_standards,
95                extra_attrs,
96                other_units,
97                comments,
98                qc: Some(qc),
99            };
100            cleaned_knowledge.push(cleaned);
101        }
102
103        self.0.apply_knowledge(cleaned_knowledge);
104
105        Ok(())
106    }
107
108    /// Load community knowledge baked into the library
109    fn load_knowledge(&mut self) {
110        self.0.load_knowledge();
111    }
112
113    /// Return a standards filter for chaining operations
114    fn filter(&self, py: Python) -> PyResult<Py<crate::PyStandardsFilter>> {
115        let filter = self.0.filter();
116        let py_filter = crate::PyStandardsFilter::from(filter);
117        Py::new(py, py_filter)
118    }
119
120    /// Return known IOOS Categories
121    fn known_ioos_categories(&self) -> Vec<String> {
122        self.0.known_ioos_categories().into_iter().collect()
123    }
124}
125
126#[derive(Debug)]
127enum KnowledgeValues {
128    String(String),
129    List(Vec<String>),
130    Dict(BTreeMap<String, String>),
131    QC(BTreeMap<String, StaticQc>),
132}
133
134impl<'a, 'py> FromPyObject<'a, 'py> for KnowledgeValues {
135    type Error = PyErr;
136
137    fn extract(ob: pyo3::Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
138        // Try to extract as string first
139        if let Ok(s) = ob.extract::<String>() {
140            return Ok(KnowledgeValues::String(s));
141        }
142
143        // Try to extract as list of strings
144        if let Ok(list) = ob.extract::<Vec<String>>() {
145            return Ok(KnowledgeValues::List(list));
146        }
147
148        // Try to extract as dict of strings
149        if let Ok(dict) = ob.extract::<BTreeMap<String, String>>() {
150            return Ok(KnowledgeValues::Dict(dict));
151        }
152
153        // Try to extract as QC dict (BTreeMap<String, StaticQc>)
154        if let Ok(dict) = ob.cast::<PyDict>() {
155            let mut qc_map = BTreeMap::new();
156
157            for (key, value) in dict.iter() {
158                let key_str: String = key.extract()?;
159
160                // Extract StaticQc fields from the Python dict
161                let value_dict = value.cast::<PyDict>()?;
162
163                let name: String = value_dict
164                    .get_item("name")?
165                    .ok_or_else(|| PyKeyError::new_err("StaticQc missing 'name' field"))?
166                    .extract()?;
167
168                let summary: String = value_dict
169                    .get_item("summary")?
170                    .ok_or_else(|| PyKeyError::new_err("StaticQc missing 'summary' field"))?
171                    .extract()?;
172
173                let description: String = value_dict
174                    .get_item("description")?
175                    .ok_or_else(|| PyKeyError::new_err("StaticQc missing 'description' field"))?
176                    .extract()?;
177
178                // For tests, we need to handle ConfigStream
179                let tests_value = value_dict
180                    .get_item("tests")?
181                    .ok_or_else(|| PyKeyError::new_err("StaticQc missing 'tests' field"))?;
182
183                // Convert Python tests dict to ConfigStream
184                let tests = convert_tests_to_config_stream(&tests_value)?;
185
186                let static_qc = StaticQc {
187                    name,
188                    summary,
189                    description,
190                    tests,
191                };
192
193                qc_map.insert(key_str, static_qc);
194            }
195
196            return Ok(KnowledgeValues::QC(qc_map));
197        }
198
199        Err(PyKeyError::new_err(
200            "Could not extract KnowledgeValues from Python object",
201        ))
202    }
203}
204
205fn get_static_qc_field(
206    knowledge: &HashMap<String, KnowledgeValues>,
207    key: &str,
208) -> PyResult<BTreeMap<String, StaticQc>> {
209    match knowledge.get(key) {
210        Some(KnowledgeValues::QC(qc_value)) => Ok(qc_value.clone()),
211        Some(_) => Err(PyKeyError::new_err(format!(
212            "`{key}` must be a QARTOD static QC field"
213        ))),
214        None => Ok(BTreeMap::new()),
215    }
216}
217
218fn get_string_field(
219    knowledge: &HashMap<String, KnowledgeValues>,
220    key: &str,
221) -> PyResult<Option<String>> {
222    match knowledge.get(key) {
223        Some(KnowledgeValues::String(str_value)) => Ok(Some(str_value.clone())),
224        Some(_) => Err(PyKeyError::new_err(format!(
225            "`{key}` must be a string field"
226        ))),
227        None => Ok(None),
228    }
229}
230
231fn get_list_field(
232    knowledge: &HashMap<String, KnowledgeValues>,
233    key: &str,
234) -> PyResult<Vec<String>> {
235    match knowledge.get(key) {
236        Some(KnowledgeValues::List(list_value)) => Ok(list_value.clone()),
237        Some(_) => Err(PyKeyError::new_err(format!(
238            "`{key}` must be a list of strings"
239        ))),
240        None => Ok(Vec::new()),
241    }
242}
243
244fn get_dict_field(
245    knowledge: &HashMap<String, KnowledgeValues>,
246    key: &str,
247) -> PyResult<BTreeMap<String, String>> {
248    match knowledge.get(key) {
249        Some(KnowledgeValues::Dict(dict_value)) => Ok(dict_value.clone()),
250        Some(_) => Err(PyKeyError::new_err(format!(
251            "`{key}` must be a dictionary of strings"
252        ))),
253        None => Ok(BTreeMap::new()),
254    }
255}
256
257fn convert_tests_to_config_stream(
258    tests_value: &Bound<'_, PyAny>,
259) -> PyResult<standard_knowledge::qartod::config::ConfigStream> {
260    use standard_knowledge::qartod::config::*;
261
262    // Extract the "qartod" field from tests
263    let tests_dict = tests_value.cast::<PyDict>()?;
264    let qartod_value = tests_dict
265        .get_item("qartod")?
266        .ok_or_else(|| PyKeyError::new_err("tests missing 'qartod' field"))?;
267    let qartod_dict = qartod_value.cast::<PyDict>()?;
268
269    let mut config_qartod = ConfigStreamQartod::default();
270
271    // Convert flat_line_test
272    if let Some(flat_line_item) = qartod_dict.get_item("flat_line_test")? {
273        let flat_line_dict = flat_line_item.cast::<PyDict>()?;
274        let tolerance: f64 = flat_line_dict
275            .get_item("tolerance")?
276            .ok_or_else(|| PyKeyError::new_err("flat_line_test missing 'tolerance'"))?
277            .extract()?;
278        let suspect_threshold: isize = flat_line_dict
279            .get_item("suspect_threshold")?
280            .ok_or_else(|| PyKeyError::new_err("flat_line_test missing 'suspect_threshold'"))?
281            .extract()?;
282        let fail_threshold: isize = flat_line_dict
283            .get_item("fail_threshold")?
284            .ok_or_else(|| PyKeyError::new_err("flat_line_test missing 'fail_threshold'"))?
285            .extract()?;
286
287        config_qartod.flat_line_test = Some(FlatLine {
288            tolerance,
289            suspect_threshold,
290            fail_threshold,
291        });
292    }
293
294    // Convert gross_range_test
295    if let Some(gross_range_item) = qartod_dict.get_item("gross_range_test")? {
296        let gross_range_dict = gross_range_item.cast::<PyDict>()?;
297        let fail_span: Vec<f64> = gross_range_dict
298            .get_item("fail_span")?
299            .ok_or_else(|| PyKeyError::new_err("gross_range_test missing 'fail_span'"))?
300            .extract()?;
301        let suspect_span: Vec<f64> = gross_range_dict
302            .get_item("suspect_span")?
303            .ok_or_else(|| PyKeyError::new_err("gross_range_test missing 'suspect_span'"))?
304            .extract()?;
305
306        if fail_span.len() != 2 || suspect_span.len() != 2 {
307            return Err(PyKeyError::new_err(
308                "fail_span and suspect_span must be arrays of length 2",
309            ));
310        }
311
312        config_qartod.gross_range_test = Some(GrossRangeTest {
313            fail_span: (fail_span[0], fail_span[1]),
314            suspect_span: (suspect_span[0], suspect_span[1]),
315        });
316    }
317
318    // Convert spike_test
319    if let Some(spike_item) = qartod_dict.get_item("spike_test")? {
320        let spike_dict = spike_item.cast::<PyDict>()?;
321        let suspect_threshold: f64 = spike_dict
322            .get_item("suspect_threshold")?
323            .ok_or_else(|| PyKeyError::new_err("spike_test missing 'suspect_threshold'"))?
324            .extract()?;
325        let fail_threshold: f64 = spike_dict
326            .get_item("fail_threshold")?
327            .ok_or_else(|| PyKeyError::new_err("spike_test missing 'fail_threshold'"))?
328            .extract()?;
329
330        config_qartod.spike_test = Some(Spike {
331            suspect_threshold,
332            fail_threshold,
333        });
334    }
335
336    // Convert rate_of_change_test
337    if let Some(rate_of_change_item) = qartod_dict.get_item("rate_of_change_test")? {
338        let rate_of_change_dict = rate_of_change_item.cast::<PyDict>()?;
339        let threshold: f64 = rate_of_change_dict
340            .get_item("threshold")?
341            .ok_or_else(|| PyKeyError::new_err("rate_of_change_test missing 'threshold'"))?
342            .extract()?;
343
344        config_qartod.rate_of_change_test = Some(RateOfChange { threshold });
345    }
346
347    Ok(ConfigStream {
348        qartod: config_qartod,
349    })
350}