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#[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 #[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 fn load_cf_standards(&mut self) {
42 self.0.load_cf_standards();
43 }
44
45 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 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 fn load_knowledge(&mut self) {
110 self.0.load_knowledge();
111 }
112
113 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 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 if let Ok(s) = ob.extract::<String>() {
140 return Ok(KnowledgeValues::String(s));
141 }
142
143 if let Ok(list) = ob.extract::<Vec<String>>() {
145 return Ok(KnowledgeValues::List(list));
146 }
147
148 if let Ok(dict) = ob.extract::<BTreeMap<String, String>>() {
150 return Ok(KnowledgeValues::Dict(dict));
151 }
152
153 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 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 let tests_value = value_dict
180 .get_item("tests")?
181 .ok_or_else(|| PyKeyError::new_err("StaticQc missing 'tests' field"))?;
182
183 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 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 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 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 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 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}