Skip to main content

teamcraft/domain/
simulations.rs

1//! Simulations: bases and result values.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::AppError;
6
7/// What a simulation takes from the revision it is based on.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum BasisRole {
11    /// The analysed geometry (part, assembly).
12    Geometry,
13    /// A mesh / FE model kept as its own item.
14    Mesh,
15    /// Material data (specification, data sheet).
16    Material,
17    /// Where the loads come from (load specification, test report).
18    LoadCase,
19    Other,
20}
21
22impl BasisRole {
23    pub const ALL: [BasisRole; 5] = [
24        BasisRole::Geometry,
25        BasisRole::Mesh,
26        BasisRole::Material,
27        BasisRole::LoadCase,
28        BasisRole::Other,
29    ];
30
31    pub fn as_str(self) -> &'static str {
32        match self {
33            BasisRole::Geometry => "geometry",
34            BasisRole::Mesh => "mesh",
35            BasisRole::Material => "material",
36            BasisRole::LoadCase => "load_case",
37            BasisRole::Other => "other",
38        }
39    }
40
41    pub fn label(self) -> &'static str {
42        match self {
43            BasisRole::Geometry => "Geometry",
44            BasisRole::Mesh => "Mesh",
45            BasisRole::Material => "Material",
46            BasisRole::LoadCase => "Load case",
47            BasisRole::Other => "Other",
48        }
49    }
50
51    /// Also accepts `load-case` and `load case`.
52    pub fn parse(s: &str) -> Option<BasisRole> {
53        match s
54            .trim()
55            .to_ascii_lowercase()
56            .replace(['-', ' '], "_")
57            .as_str()
58        {
59            "geometry" => Some(BasisRole::Geometry),
60            "mesh" => Some(BasisRole::Mesh),
61            "material" => Some(BasisRole::Material),
62            "load_case" => Some(BasisRole::LoadCase),
63            "other" => Some(BasisRole::Other),
64            _ => None,
65        }
66    }
67}
68
69/// Whether a result value is within its limits.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ResultStatus {
73    Pass,
74    Fail,
75    /// No limit given: the value is informative only (`"none"`).
76    #[serde(rename = "none")]
77    NoLimits,
78}
79
80impl ResultStatus {
81    pub fn as_str(self) -> &'static str {
82        match self {
83            ResultStatus::Pass => "pass",
84            ResultStatus::Fail => "fail",
85            ResultStatus::NoLimits => "none",
86        }
87    }
88}
89
90/// Which result values the item search looks for (latest revision of a
91/// simulation): within or outside their limits.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ResultFilter {
94    /// At least one value outside its limits.
95    Failed,
96    /// At least one value with limits, and within them.
97    Passed,
98    /// At least one value above its upper limit.
99    AboveLimit,
100    /// At least one value below its lower limit.
101    BelowLimit,
102}
103
104impl ResultFilter {
105    pub const ALL: [ResultFilter; 4] = [
106        ResultFilter::Failed,
107        ResultFilter::Passed,
108        ResultFilter::AboveLimit,
109        ResultFilter::BelowLimit,
110    ];
111
112    pub fn as_str(self) -> &'static str {
113        match self {
114            ResultFilter::Failed => "failed",
115            ResultFilter::Passed => "passed",
116            ResultFilter::AboveLimit => "above_limit",
117            ResultFilter::BelowLimit => "below_limit",
118        }
119    }
120
121    pub fn label(self) -> &'static str {
122        match self {
123            ResultFilter::Failed => "A value fails its limits",
124            ResultFilter::Passed => "A value passes its limits",
125            ResultFilter::AboveLimit => "A value is above its limit",
126            ResultFilter::BelowLimit => "A value is below its limit",
127        }
128    }
129
130    pub fn parse(s: &str) -> Option<ResultFilter> {
131        ResultFilter::ALL
132            .into_iter()
133            .find(|f| f.as_str() == s.trim())
134    }
135
136    /// Parses an optional request field: empty = no filter, unknown = 400.
137    pub fn parse_field(s: Option<&str>) -> Result<Option<ResultFilter>, AppError> {
138        match s.map(str::trim).filter(|s| !s.is_empty()) {
139            None => Ok(None),
140            Some(s) => ResultFilter::parse(s).map(Some).ok_or_else(|| {
141                AppError::BadRequest(format!(
142                    "Unknown result filter '{s}' (failed, passed, above_limit, below_limit)."
143                ))
144            }),
145        }
146    }
147}
148
149/// Pass when `lower <= value <= upper` (limits inclusive, either may be
150/// missing), fail otherwise; [`ResultStatus::NoLimits`] without limits.
151pub fn result_status(value: f64, lower: Option<f64>, upper: Option<f64>) -> ResultStatus {
152    if lower.is_none() && upper.is_none() {
153        return ResultStatus::NoLimits;
154    }
155    let above_lower = lower.is_none_or(|l| value >= l);
156    let below_upper = upper.is_none_or(|u| value <= u);
157    if value.is_finite() && above_lower && below_upper {
158        ResultStatus::Pass
159    } else {
160        ResultStatus::Fail
161    }
162}
163
164/// Validates a result value and its limits (finite numbers, lower ≤ upper).
165pub fn validate_result_numbers(
166    name: &str,
167    value: f64,
168    lower: Option<f64>,
169    upper: Option<f64>,
170) -> Result<(), AppError> {
171    if !value.is_finite() {
172        return Err(AppError::BadRequest(format!(
173            "{name}: the value must be a finite number."
174        )));
175    }
176    if lower.is_some_and(|v| !v.is_finite()) || upper.is_some_and(|v| !v.is_finite()) {
177        return Err(AppError::BadRequest(format!(
178            "{name}: the limits must be finite numbers."
179        )));
180    }
181    if let (Some(l), Some(u)) = (lower, upper) {
182        if l > u {
183            return Err(AppError::BadRequest(format!(
184                "{name}: the lower limit {l} is above the upper limit {u}."
185            )));
186        }
187    }
188    Ok(())
189}
190
191/// Found by cargo mutants: stored values, labels and the edges of the pass /
192/// fail rule.
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn basis_roles() {
199        let roles: Vec<(&str, &str)> = BasisRole::ALL
200            .iter()
201            .map(|r| (r.as_str(), r.label()))
202            .collect();
203        assert_eq!(
204            roles,
205            [
206                ("geometry", "Geometry"),
207                ("mesh", "Mesh"),
208                ("material", "Material"),
209                ("load_case", "Load case"),
210                ("other", "Other")
211            ]
212        );
213    }
214
215    #[test]
216    fn result_filters() {
217        let filters: Vec<&str> = ResultFilter::ALL.iter().map(|f| f.as_str()).collect();
218        assert_eq!(filters, ["failed", "passed", "above_limit", "below_limit"]);
219        let mut labels: Vec<&str> = ResultFilter::ALL.iter().map(|f| f.label()).collect();
220        assert_eq!(labels[0], "A value fails its limits");
221        labels.sort_unstable();
222        labels.dedup();
223        assert_eq!(labels.len(), ResultFilter::ALL.len());
224        for f in ResultFilter::ALL {
225            assert_eq!(ResultFilter::parse(&format!(" {} ", f.as_str())), Some(f));
226            assert_eq!(
227                ResultFilter::parse_field(Some(f.as_str())).unwrap(),
228                Some(f)
229            );
230        }
231        assert_eq!(ResultFilter::parse_field(None).unwrap(), None);
232        assert_eq!(ResultFilter::parse_field(Some("  ")).unwrap(), None);
233        assert!(ResultFilter::parse_field(Some("broken")).is_err());
234    }
235
236    #[test]
237    fn pass_and_fail() {
238        use ResultStatus::*;
239        assert_eq!(
240            (Pass.as_str(), Fail.as_str(), NoLimits.as_str()),
241            ("pass", "fail", "none")
242        );
243        assert_eq!(result_status(5.0, None, None), NoLimits);
244        // limits are inclusive, either may be missing
245        assert_eq!(result_status(1.0, Some(1.0), Some(2.0)), Pass);
246        assert_eq!(result_status(2.0, Some(1.0), Some(2.0)), Pass);
247        assert_eq!(result_status(0.99, Some(1.0), Some(2.0)), Fail);
248        assert_eq!(result_status(2.01, Some(1.0), Some(2.0)), Fail);
249        assert_eq!(result_status(-1e9, None, Some(2.0)), Pass);
250        assert_eq!(result_status(1e9, Some(1.0), None), Pass);
251        assert_eq!(result_status(f64::NAN, Some(1.0), None), Fail);
252        assert_eq!(result_status(f64::INFINITY, None, Some(1e300)), Fail);
253    }
254}