runmat_analysis_fea/adaptation/
structural_recovery.rs1use 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;