Skip to main content

carapace_wasm/
material_probe.rs

1//! Uniaxial material probe: one persistent unit zero-length spring driven by
2//! prescribed displacement (`Analysis::step_prescribed`), the equivalent of
3//! OpenSees' `sp`-constrained zeroLength model. Strain is the relative
4//! displacement `node2.x - node1.x` and stress is the material's tensile
5//! resisting force, `Domain::reaction` at node 2 (OpenSees' `-nodeReaction(1, 1)`).
6//! Unit area and gauge length are implicit.
7
8use carapace_core::analysis::{
9    Algorithm, Analysis, AnalysisBuilder, AnalysisError, ConstraintHandler, ConvergenceTest,
10    Integrator, TangentStrategy,
11};
12use carapace_core::model::{Domain, Element, Material, Node, NodeId, ZeroLength};
13use serde::{Deserialize, Serialize};
14
15use crate::input_v1::materials::{resolve_materials, MaterialSpec};
16
17#[derive(Debug, Clone, Deserialize, tsify::Tsify)]
18#[serde(rename_all = "camelCase")]
19pub struct MaterialProbeConfig {
20    pub material: MaterialSpec,
21    /// Materials that composite variants of `material` (`Parallel`, `Series`,
22    /// `MinMax`) refer to by index. Empty for a self-contained material.
23    #[serde(default)]
24    pub arena: Vec<MaterialSpec>,
25    #[serde(default)]
26    pub initial_strain: Option<f64>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, tsify::Tsify)]
30#[serde(rename_all = "camelCase")]
31pub struct MaterialProbeResponse {
32    pub strain: f64,
33    pub stress: f64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, tsify::Tsify)]
37#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
38pub enum MaterialProbeError {
39    /// The material kind isn't in the probe's supported set.
40    UnsupportedMaterial { material: &'static str },
41    /// The target strain isn't finite.
42    InvalidTarget { target: f64 },
43    InvalidProbe { reason: String },
44    /// The nonlinear solve failed; the last committed state is intact.
45    SolverFailure { target: f64, detail: String },
46}
47
48pub struct MaterialProbe {
49    material: Material,
50    initial_strain: f64,
51    analysis: Analysis,
52    node: NodeId,
53    strain: f64,
54}
55
56fn kind_name(spec: &MaterialSpec) -> Option<&'static str> {
57    match spec {
58        MaterialSpec::Gap { .. } => Some("gap"),
59        _ => None,
60    }
61}
62
63fn build(material: &Material) -> (Analysis, NodeId) {
64    let mut domain = Domain::new();
65    let ground = domain.add_node(Node::new([0.0, 0.0]).fix(0).fix(1).fix(2));
66    let node = domain.add_node(Node::new([0.0, 0.0]).fix(0).fix(1).fix(2));
67    domain.add_element(Element::ZeroLength(
68        ZeroLength::new(ground, node).with_material(0, material.clone()),
69    ));
70    // No free DOFs: the solve is a trivial equilibrium check that still commits
71    // material state, and a zero-tangent branch has nothing to divide by.
72    let analysis = AnalysisBuilder::new()
73        .constraint_handler(ConstraintHandler::Plain)
74        .integrator(Integrator::LoadControl { increment: 0.0 })
75        .algorithm(Algorithm::Newton {
76            tangent: TangentStrategy::Current,
77            line_search: None,
78        })
79        .test(ConvergenceTest::NormUnbalance {
80            tol: 1e-9,
81            max_iter: 10,
82        })
83        .build(domain);
84    (analysis, node)
85}
86
87impl MaterialProbe {
88    pub fn new(config: &MaterialProbeConfig) -> Result<Self, MaterialProbeError> {
89        if let Some(material) = config
90            .arena
91            .iter()
92            .chain(std::iter::once(&config.material))
93            .find_map(kind_name)
94        {
95            return Err(MaterialProbeError::UnsupportedMaterial { material });
96        }
97        let initial_strain = config.initial_strain.unwrap_or(0.0);
98        if !initial_strain.is_finite() {
99            return Err(MaterialProbeError::InvalidTarget {
100                target: initial_strain,
101            });
102        }
103        // The probed material goes last so the arena indices it refers to stay valid.
104        let mut arena = config.arena.clone();
105        arena.push(config.material.clone());
106        let material = resolve_materials(&arena)
107            .map_err(|error| MaterialProbeError::InvalidProbe {
108                reason: format!("{error:?}"),
109            })?
110            .pop()
111            .expect("arena is non-empty");
112        let mut probe = Self::fresh(material, initial_strain);
113        probe.apply_strain(initial_strain)?;
114        Ok(probe)
115    }
116
117    fn fresh(material: Material, initial_strain: f64) -> Self {
118        let (analysis, node) = build(&material);
119        MaterialProbe {
120            material,
121            initial_strain,
122            analysis,
123            node,
124            strain: 0.0,
125        }
126    }
127
128    /// Imposes `target` and returns the response. A repeat of the current
129    /// strain reports the committed response without stepping, so it never
130    /// advances material state. A failed target leaves the last committed
131    /// state intact.
132    pub fn apply_strain(&mut self, target: f64) -> Result<MaterialProbeResponse, MaterialProbeError> {
133        if !target.is_finite() {
134            return Err(MaterialProbeError::InvalidTarget { target });
135        }
136        if target != self.strain {
137            self.analysis
138                .step_prescribed(&[(self.node, 0, target)])
139                .map_err(|error: AnalysisError| MaterialProbeError::SolverFailure {
140                    target,
141                    detail: format!("{error:?}"),
142                })?;
143            self.strain = target;
144        }
145        Ok(MaterialProbeResponse {
146            strain: self.strain,
147            stress: self.analysis.domain().reaction(self.node, 0, 0.0),
148        })
149    }
150
151    /// Back to the freshly constructed state: committed history discarded.
152    pub fn reset(&mut self) -> Result<(), MaterialProbeError> {
153        *self = Self::fresh(self.material.clone(), self.initial_strain);
154        self.apply_strain(self.initial_strain).map(|_| ())
155    }
156}