feat: query directional elastic modulus from validated material cards (#998)

* feat: validate physical material cards with sources and conditions

* feat: query directional elastic modulus from physical material cards

* fix: register physical material validation labels in complete locales

* fix: register directional elasticity labels in complete locales

* Add German and Korean labels for the material property commands

---------

Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
seluneandbherbruck authored and GitHub committed 2026-10-14 08:17:00 -04:00
1 parent 377e12a059
commit 52fb5ac0e6
11 files changed
+228 -7

No files matched your search

+69 -1
View File
@@ -305,6 +305,12 @@ impl MaterialCard {
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
/// bounds are admission limits, not claims about a real material's valid range.
pub fn validate_card(value: &Value) -> Result<Validation> {
admit_card(value)?.validate()
}
/// Bound and deserialize a candidate before cloning it. Semantic checks belong
/// to `validate` or the derived query; this alone does not validate the numbers.
pub fn admit_card(value: &Value) -> Result<MaterialCard> {
let mut pending = vec![(value, 0usize)];
let (mut nodes, mut bytes) = (0usize, 0usize);
while let Some((value, depth)) = pending.pop() {
@@ -345,7 +351,69 @@ pub fn validate_card(value: &Value) -> Result<Validation> {
}
}
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
card.validate()
Ok(card)
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DirectionalModulus {
pub direction: [f64; 3],
pub axes: Option<MaterialAxes>,
pub young_modulus_pa: f64,
pub validation: Validation,
}
impl MaterialCard {
/// Uniaxial small-strain modulus in the supplied material-local direction.
/// This is a constitutive query, not a body load case or structural solver.
pub fn directional_modulus(&self, direction: [f64; 3]) -> Result<DirectionalModulus> {
let validation = self.validate()?;
if !direction.iter().all(|x| x.is_finite()) {
return Err(invalid("direction: expected three finite components"));
}
let scale = direction.iter().fold(0.0_f64, |a, x| a.max(x.abs()));
if scale == 0. {
return Err(invalid("direction: must be nonzero"));
}
let scaled = direction.map(|x| x / scale);
let length = scaled.iter().map(|x| x * x).sum::<f64>().sqrt();
let direction = scaled.map(|x| x / length);
let model = self
.properties
.iter()
.find_map(|p| match &p.data {
PropertyData::Elasticity { model } => Some(model),
_ => None,
})
.ok_or_else(|| invalid("elasticity is unspecified"))?;
let (axes, modulus) = match model {
Elasticity::Isotropic { young_modulus_pa, .. } => (None, *young_modulus_pa),
Elasticity::Orthotropic { axes, young_moduli_pa, shear_moduli_pa, poisson_ratios } => {
let [x, y, z] = direction;
let [e1, e2, e3] = *young_moduli_pa;
let [g12, g23, g31] = *shear_moduli_pa;
let [n12, n23, n31] = *poisson_ratios;
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
let minor = 1. - a * a;
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
// Cholesky of the scaled normal compliance: evaluate the energy
// as sums of squares instead of cancelling positive/negative terms.
let l22 = minor.sqrt();
let l32 = (b - a * c) / l22;
let l33 = (determinant / minor).sqrt();
let [u, v, w] = [x * x / e1.sqrt(), y * y / e2.sqrt(), z * z / e3.sqrt()];
let terms = [u + a * v + c * w, l22 * v + l32 * w, l33 * w];
let compliance = terms.iter().map(|t| t * t).sum::<f64>() + (x * y).powi(2) / g12 + (y * z).powi(2) / g23 + (z * x).powi(2) / g31;
if !compliance.is_finite() || compliance <= 0. {
return Err(invalid("directional compliance is unrepresentable"));
}
(Some(*axes), compliance.recip())
}
};
if !modulus.is_finite() || modulus <= 0. {
return Err(invalid("directional modulus is unrepresentable"));
}
Ok(DirectionalModulus { direction, axes, young_modulus_pa: modulus, validation })
}
}
#[cfg(test)]
+62
View File
@@ -14,6 +14,68 @@ fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
}
#[test]
fn directional_isotropy_and_principal_axes_match_analytic_values() {
let iso = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
let mut equivalent = orthotropic([12e9; 3], [0.25; 3]);
equivalent["model"]["shear_moduli_pa"] = json!([4.8e9, 4.8e9, 4.8e9]);
let equivalent = admit_card(&card(equivalent)).unwrap();
for direction in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 2., 3.], [-1., 2., -3.], [f64::MAX; 3], [1e-320; 3]] {
let a = iso.directional_modulus(direction).unwrap();
let b = equivalent.directional_modulus(direction).unwrap();
assert_eq!(a.young_modulus_pa, 12e9);
assert!((b.young_modulus_pa / 12e9 - 1.).abs() < 1e-14);
assert_eq!(a.axes, None);
assert_eq!(b.axes, Some(MaterialAxes::WoodLrt));
assert!((a.direction.iter().map(|x| x * x).sum::<f64>() - 1.).abs() < 1e-14);
}
let ortho = admit_card(&card(orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]))).unwrap();
for (direction, e) in [([1., 0., 0.], 12e9), ([0., 1., 0.], 3e9), ([0., 0., 1.], 1e9)] {
assert!((ortho.directional_modulus(direction).unwrap().young_modulus_pa / e - 1.).abs() < 1e-14);
}
}
#[test]
fn directional_orthotropy_includes_shear_and_reciprocal_coupling() {
let mut data = orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]);
data["model"]["shear_moduli_pa"] = json!([2e9, 1e9, 0.5e9]);
let material = admit_card(&card(data)).unwrap();
// For n=(1,1,0)/sqrt(2), 1/E(n)=(S11+S22+2S12+1/G12)/4.
let expected = 4. / (1. / 12e9 + 1. / 3e9 - 2. * 0.2 / 12e9 + 1. / 2e9);
for d in [[1., 1., 0.], [-2., 2., 0.], [1e200, 1e200, 0.]] {
let r = material.directional_modulus(d).unwrap();
assert!((r.young_modulus_pa / expected - 1.).abs() < 1e-14);
assert_eq!(r.validation.source_review_required, vec!["test"]);
assert!(!r.validation.analysis_solver_available);
}
// Independent direct tensor contraction in a general non-principal direction.
let [x, y, z] = [2. / 3., -1. / 3., 2. / 3.];
let inverse = x * x * x * x / 12e9
+ y * y * y * y / 3e9
+ z * z * z * z / 1e9
+ x * x * y * y * (1. / 2e9 - 2. * 0.2 / 12e9)
+ y * y * z * z * (1. / 1e9 - 2. * 0.1 / 3e9)
+ z * z * x * x * (1. / 0.5e9 - 2. * 0.02 / 1e9);
let actual = material.directional_modulus([2., -1., 2.]).unwrap();
assert!((actual.young_modulus_pa * inverse - 1.).abs() < 1e-14);
}
#[test]
fn directional_queries_refuse_unknown_unstable_and_nonfinite_inputs() {
let mut material = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
for d in [[0.; 3], [f64::NAN, 0., 1.], [0., f64::INFINITY, 0.]] {
assert!(material.directional_modulus(d).is_err());
}
material.properties.clear();
assert!(material.directional_modulus([1., 0., 0.]).is_err());
material.properties.push(Property {
data: PropertyData::Elasticity { model: Elasticity::Isotropic { young_modulus_pa: 12e9, poisson_ratio: 0.6 } },
source_id: "test".into(),
conditions: Conditions::default(),
});
assert!(material.directional_modulus([1., 0., 0.]).is_err());
}
#[test]
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();