Skip to main content

runmat_analysis_fea/adaptation/
structural_recovery.rs

1//! Volume-weighted stress-recovery error estimation for canonical solid meshes.
2
3use std::collections::BTreeMap;
4
5use runmat_analysis_core::{AnalysisField, AnalysisFieldValues};
6use runmat_meshing_core::{FieldTopologyLocation, SolverMeshArtifact, StableDigest};
7use serde::{Deserialize, Serialize};
8
9use crate::contracts::FEA_FIELD_STRUCTURAL_STRESS;
10use crate::progress::is_cancelled;
11
12const STRESS_COMPONENT_COUNT: usize = 6;
13
14#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct StructuralRecoveryEstimatorOptions {
17    pub marking_fraction: f64,
18    pub maximum_marked_elements: u64,
19    pub cancellation_check_interval: u64,
20}
21
22impl Default for StructuralRecoveryEstimatorOptions {
23    fn default() -> Self {
24        Self {
25            marking_fraction: 0.5,
26            maximum_marked_elements: 1_000_000_000,
27            cancellation_check_interval: 1_024,
28        }
29    }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct StructuralRecoveryIndicator {
35    pub element_stable_identity: StableDigest,
36    pub error: f64,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct StructuralRecoveryStatistics {
42    pub element_count: u64,
43    pub minimum_error: f64,
44    pub maximum_error: f64,
45    pub mean_error: f64,
46    pub root_mean_square_error: f64,
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct StructuralRecoveryEstimate {
52    pub solver_artifact_digest: StableDigest,
53    pub stress_topology_id: String,
54    pub total_error: f64,
55    pub indicators: Vec<StructuralRecoveryIndicator>,
56    pub marked_element_identities: Vec<StableDigest>,
57    pub statistics: StructuralRecoveryStatistics,
58}
59
60impl StructuralRecoveryEstimate {
61    pub fn validate(&self) -> Result<(), StructuralRecoveryEstimatorError> {
62        if self.solver_artifact_digest == StableDigest::ZERO
63            || self.stress_topology_id.is_empty()
64            || self.stress_topology_id.len() > 256
65            || !self.stress_topology_id.is_ascii()
66            || self.stress_topology_id.chars().any(char::is_control)
67            || self.indicators.is_empty()
68            || self.statistics.element_count != self.indicators.len() as u64
69            || !self.total_error.is_finite()
70            || self.total_error < 0.0
71            || [
72                self.statistics.minimum_error,
73                self.statistics.maximum_error,
74                self.statistics.mean_error,
75                self.statistics.root_mean_square_error,
76            ]
77            .iter()
78            .any(|value| !value.is_finite() || *value < 0.0)
79        {
80            return Err(StructuralRecoveryEstimatorError::InvalidEstimate);
81        }
82        let mut previous = None;
83        let mut sum = 0.0;
84        let mut sum_squared = 0.0;
85        for indicator in &self.indicators {
86            if indicator.element_stable_identity == StableDigest::ZERO
87                || !indicator.error.is_finite()
88                || indicator.error < 0.0
89                || previous.is_some_and(|identity| identity >= indicator.element_stable_identity)
90            {
91                return Err(StructuralRecoveryEstimatorError::InvalidEstimate);
92            }
93            sum += indicator.error;
94            sum_squared += indicator.error * indicator.error;
95            previous = Some(indicator.element_stable_identity);
96        }
97        let count = self.indicators.len() as f64;
98        let expected = StructuralRecoveryStatistics {
99            element_count: self.indicators.len() as u64,
100            minimum_error: self
101                .indicators
102                .iter()
103                .map(|indicator| indicator.error)
104                .reduce(f64::min)
105                .unwrap_or(0.0),
106            maximum_error: self
107                .indicators
108                .iter()
109                .map(|indicator| indicator.error)
110                .reduce(f64::max)
111                .unwrap_or(0.0),
112            mean_error: sum / count,
113            root_mean_square_error: (sum_squared / count).sqrt(),
114        };
115        let admitted = self
116            .indicators
117            .iter()
118            .filter(|indicator| indicator.error > 0.0)
119            .map(|indicator| indicator.element_stable_identity)
120            .collect::<std::collections::BTreeSet<_>>();
121        if self.total_error != sum_squared.sqrt()
122            || self.statistics != expected
123            || (self.total_error == 0.0) != self.marked_element_identities.is_empty()
124            || !self
125                .marked_element_identities
126                .windows(2)
127                .all(|pair| pair[0] < pair[1])
128            || self
129                .marked_element_identities
130                .iter()
131                .any(|identity| !admitted.contains(identity))
132        {
133            return Err(StructuralRecoveryEstimatorError::InvalidEstimate);
134        }
135        Ok(())
136    }
137}
138
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum StructuralRecoveryEstimatorError {
141    InvalidOptions,
142    InvalidArtifact(String),
143    MissingElementTopology,
144    InvalidStressField,
145    DeviceFieldRequiresHostTransfer,
146    InvalidElementGeometry,
147    InvalidEstimate,
148    ResourceLimit,
149    Cancelled,
150}
151
152pub fn estimate_structural_recovery_error(
153    artifact: &SolverMeshArtifact,
154    stress_topology_id: &str,
155    stress: &AnalysisField,
156    options: StructuralRecoveryEstimatorOptions,
157) -> Result<StructuralRecoveryEstimate, StructuralRecoveryEstimatorError> {
158    validate_options(options)?;
159    artifact.validate().map_err(|failure| {
160        StructuralRecoveryEstimatorError::InvalidArtifact(failure.to_string())
161    })?;
162    let topology = artifact
163        .topology
164        .field_topologies
165        .iter()
166        .find(|topology| topology.topology_id == stress_topology_id)
167        .filter(|topology| topology.location == FieldTopologyLocation::VolumeElement)
168        .ok_or(StructuralRecoveryEstimatorError::MissingElementTopology)?;
169    let values = validate_stress(stress, topology.ordered_entity_ids.len())?;
170    let stress_by_element = topology
171        .ordered_entity_ids
172        .iter()
173        .enumerate()
174        .map(|(index, element_id)| {
175            let offset = index * STRESS_COMPONENT_COUNT;
176            (
177                *element_id,
178                &values[offset..offset + STRESS_COMPONENT_COUNT],
179            )
180        })
181        .collect::<BTreeMap<_, _>>();
182    let coordinates = artifact
183        .topology
184        .nodes
185        .iter()
186        .map(|node| (node.node_id, node.coordinates_m))
187        .collect::<BTreeMap<_, _>>();
188    let mut nodal_stress = BTreeMap::<u64, ([f64; STRESS_COMPONENT_COUNT], f64)>::new();
189    let mut element_volumes = BTreeMap::new();
190    let mut work = 0_u64;
191    for element in &artifact.topology.volume_elements {
192        checkpoint(&mut work, options)?;
193        let volume = tetrahedron_volume(element, &coordinates)?;
194        element_volumes.insert(element.element_id, volume);
195        let element_stress = stress_by_element[&element.element_id];
196        for node_id in &element.node_ids {
197            let (sum, weight) = nodal_stress
198                .entry(*node_id)
199                .or_insert(([0.0; STRESS_COMPONENT_COUNT], 0.0));
200            for (sum, value) in sum.iter_mut().zip(element_stress) {
201                *sum += volume * value;
202            }
203            *weight += volume;
204        }
205    }
206
207    let mut indicators = Vec::with_capacity(artifact.topology.volume_elements.len());
208    for element in &artifact.topology.volume_elements {
209        checkpoint(&mut work, options)?;
210        let mut recovered = [0.0; STRESS_COMPONENT_COUNT];
211        for node_id in &element.node_ids {
212            let (sum, weight) = &nodal_stress[node_id];
213            for (recovered, sum) in recovered.iter_mut().zip(sum) {
214                *recovered += sum / weight / element.node_ids.len() as f64;
215            }
216        }
217        let error_squared = stress_by_element[&element.element_id]
218            .iter()
219            .zip(recovered)
220            .map(|(value, recovered)| (value - recovered).powi(2))
221            .sum::<f64>()
222            * element_volumes[&element.element_id];
223        let error = error_squared.sqrt();
224        if !error.is_finite() {
225            return Err(StructuralRecoveryEstimatorError::InvalidStressField);
226        }
227        indicators.push(StructuralRecoveryIndicator {
228            element_stable_identity: element.stable_identity,
229            error,
230        });
231    }
232    indicators.sort_by_key(|indicator| indicator.element_stable_identity);
233    let total_squared = indicators
234        .iter()
235        .map(|indicator| indicator.error * indicator.error)
236        .sum::<f64>();
237    let marked_element_identities = mark_elements(&indicators, total_squared, options)?;
238    let count = indicators.len() as f64;
239    let sum = indicators
240        .iter()
241        .map(|indicator| indicator.error)
242        .sum::<f64>();
243    let statistics = StructuralRecoveryStatistics {
244        element_count: indicators.len() as u64,
245        minimum_error: indicators
246            .iter()
247            .map(|indicator| indicator.error)
248            .reduce(f64::min)
249            .unwrap_or(0.0),
250        maximum_error: indicators
251            .iter()
252            .map(|indicator| indicator.error)
253            .reduce(f64::max)
254            .unwrap_or(0.0),
255        mean_error: sum / count,
256        root_mean_square_error: (total_squared / count).sqrt(),
257    };
258    let estimate = StructuralRecoveryEstimate {
259        solver_artifact_digest: artifact.canonical_digest,
260        stress_topology_id: stress_topology_id.to_owned(),
261        total_error: total_squared.sqrt(),
262        indicators,
263        marked_element_identities,
264        statistics,
265    };
266    estimate.validate()?;
267    Ok(estimate)
268}
269
270fn validate_options(
271    options: StructuralRecoveryEstimatorOptions,
272) -> Result<(), StructuralRecoveryEstimatorError> {
273    if !options.marking_fraction.is_finite()
274        || !(0.0..=1.0).contains(&options.marking_fraction)
275        || options.marking_fraction == 0.0
276        || options.maximum_marked_elements == 0
277        || options.cancellation_check_interval == 0
278    {
279        return Err(StructuralRecoveryEstimatorError::InvalidOptions);
280    }
281    Ok(())
282}
283
284fn validate_stress(
285    stress: &AnalysisField,
286    element_count: usize,
287) -> Result<&[f64], StructuralRecoveryEstimatorError> {
288    if stress.field_id != FEA_FIELD_STRUCTURAL_STRESS
289        || stress.shape != [element_count, STRESS_COMPONENT_COUNT]
290    {
291        return Err(StructuralRecoveryEstimatorError::InvalidStressField);
292    }
293    match &stress.values {
294        AnalysisFieldValues::HostF64(values)
295            if values.len() == element_count * STRESS_COMPONENT_COUNT
296                && values.iter().all(|value| value.is_finite()) =>
297        {
298            Ok(values)
299        }
300        AnalysisFieldValues::HostF64(_) => {
301            Err(StructuralRecoveryEstimatorError::InvalidStressField)
302        }
303        AnalysisFieldValues::DeviceRef(_) => {
304            Err(StructuralRecoveryEstimatorError::DeviceFieldRequiresHostTransfer)
305        }
306    }
307}
308
309fn tetrahedron_volume(
310    element: &runmat_meshing_core::SolverVolumeElement,
311    coordinates: &BTreeMap<u64, [f64; 3]>,
312) -> Result<f64, StructuralRecoveryEstimatorError> {
313    let [a, b, c, d] = std::array::from_fn(|index| coordinates[&element.node_ids[index]]);
314    let ab = subtract(b, a);
315    let ac = subtract(c, a);
316    let ad = subtract(d, a);
317    let determinant = dot(ab, cross(ac, ad));
318    if !determinant.is_finite() || determinant <= 0.0 {
319        return Err(StructuralRecoveryEstimatorError::InvalidElementGeometry);
320    }
321    Ok(determinant / 6.0)
322}
323
324fn mark_elements(
325    indicators: &[StructuralRecoveryIndicator],
326    total_squared: f64,
327    options: StructuralRecoveryEstimatorOptions,
328) -> Result<Vec<StableDigest>, StructuralRecoveryEstimatorError> {
329    if total_squared == 0.0 {
330        return Ok(Vec::new());
331    }
332    let mut ranked = indicators.to_vec();
333    ranked.sort_by(|left, right| {
334        right.error.total_cmp(&left.error).then_with(|| {
335            left.element_stable_identity
336                .cmp(&right.element_stable_identity)
337        })
338    });
339    let target = options.marking_fraction * total_squared;
340    let mut admitted = 0.0;
341    let mut marked = Vec::new();
342    for indicator in ranked {
343        if marked.len() as u64 >= options.maximum_marked_elements {
344            return Err(StructuralRecoveryEstimatorError::ResourceLimit);
345        }
346        admitted += indicator.error * indicator.error;
347        marked.push(indicator.element_stable_identity);
348        if admitted >= target {
349            break;
350        }
351    }
352    marked.sort_unstable();
353    Ok(marked)
354}
355
356fn checkpoint(
357    work: &mut u64,
358    options: StructuralRecoveryEstimatorOptions,
359) -> Result<(), StructuralRecoveryEstimatorError> {
360    if work.is_multiple_of(options.cancellation_check_interval) && is_cancelled() {
361        return Err(StructuralRecoveryEstimatorError::Cancelled);
362    }
363    *work = work.saturating_add(1);
364    Ok(())
365}
366
367fn subtract(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
368    std::array::from_fn(|axis| left[axis] - right[axis])
369}
370
371fn cross(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
372    [
373        left[1] * right[2] - left[2] * right[1],
374        left[2] * right[0] - left[0] * right[2],
375        left[0] * right[1] - left[1] * right[0],
376    ]
377}
378
379fn dot(left: [f64; 3], right: [f64; 3]) -> f64 {
380    left.into_iter().zip(right).map(|(a, b)| a * b).sum()
381}
382
383#[cfg(test)]
384mod tests;