#![deny(missing_docs)]
pub mod insert;
pub mod radius;
pub use insert::*;
pub use radius::*;
use std::f64::consts::PI;
use grass_app::prelude::*;
use grass_scheduler::prelude::*;
use serde::Deserialize;
use soil_derive::AtomData;
use soil_core::{register_atom_data, Atom, AtomData, AtomPlugin, Config, ScheduleSetupSet};
pub const SQRT_5_6: f64 = 0.9128709291752768;
fn tsuji_alpha(e: f64) -> f64 {
1.2728 - 4.2783 * e + 11.087 * e.powi(2) - 22.348 * e.powi(3) + 27.467 * e.powi(4)
- 18.022 * e.powi(5)
+ 4.8218 * e.powi(6)
}
#[cfg(test)]
fn hertz_cor_of_beta(beta: f64) -> f64 {
if beta <= 0.0 {
return 1.0;
}
let c = 2.0 * beta * SQRT_5_6 * std::f64::consts::SQRT_2; let acc = |d: f64, v: f64| -> f64 {
if d <= 0.0 {
0.0
} else {
-(4.0 / 3.0) * d.powf(1.5) - c * d.powf(0.25) * v
}
};
let dt = 1.0e-4;
let (mut d, mut v) = (0.0_f64, 1.0_f64);
for _ in 0..2_000_000 {
let (k1d, k1v) = (v, acc(d, v));
let (k2d, k2v) = (
v + 0.5 * dt * k1v,
acc(d + 0.5 * dt * k1d, v + 0.5 * dt * k1v),
);
let (k3d, k3v) = (
v + 0.5 * dt * k2v,
acc(d + 0.5 * dt * k2d, v + 0.5 * dt * k2v),
);
let (k4d, k4v) = (v + dt * k3v, acc(d + dt * k3d, v + dt * k3v));
d += dt / 6.0 * (k1d + 2.0 * k2d + 2.0 * k3d + k4d);
v += dt / 6.0 * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
if d <= 0.0 && v < 0.0 {
return v.abs(); }
}
v.abs()
}
pub fn hertz_beta_for_cor(e_target: f64) -> f64 {
if e_target >= 0.9999 {
return 0.0;
}
let e = e_target.clamp(1.0e-3, 0.9999);
tsuji_alpha(e) / 5.0_f64.sqrt()
}
fn default_friction() -> f64 {
0.4
}
fn default_contact_model() -> String {
"hertz".to_string()
}
#[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct MaterialConfig {
pub name: String,
pub youngs_mod: f64,
pub poisson_ratio: f64,
pub restitution: f64,
#[serde(default = "default_friction")]
pub friction: f64,
#[serde(default)]
pub rolling_friction: f64,
#[serde(default)]
pub cohesion_energy: f64,
#[serde(default)]
pub surface_energy: f64,
#[serde(default)]
pub twisting_friction: f64,
#[serde(default)]
pub kn: f64,
#[serde(default)]
pub kt: f64,
#[serde(default)]
pub rolling_stiffness: f64,
#[serde(default)]
pub rolling_damping: f64,
#[serde(default)]
pub twisting_stiffness: f64,
#[serde(default)]
pub twisting_damping: f64,
#[serde(default)]
pub mdr_yield_stress: f64,
#[serde(default)]
pub mdr_psi_b: f64,
#[serde(default)]
pub mdr_damping: f64,
#[serde(default)]
pub liquid_bridge_volume: f64,
#[serde(default)]
pub liquid_surface_tension: f64,
#[serde(default)]
pub liquid_contact_angle: f64,
#[serde(default)]
pub liquid_rupture_distance: f64,
}
fn default_adhesion_model() -> String {
"jkr".to_string()
}
fn default_rolling_model() -> String {
"constant".to_string()
}
fn default_tangential_model() -> String {
"history".to_string()
}
fn default_twisting_model() -> String {
"constant".to_string()
}
fn default_limit_damping() -> bool {
true
}
fn default_liquid_bridge_model() -> String {
"off".to_string()
}
#[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct DemConfig {
pub materials: Option<Vec<MaterialConfig>>,
#[serde(default = "default_contact_model")]
pub contact_model: String,
#[serde(default = "default_adhesion_model")]
pub adhesion_model: String,
#[serde(default = "default_rolling_model")]
pub rolling_model: String,
#[serde(default = "default_twisting_model")]
pub twisting_model: String,
#[serde(default = "default_tangential_model")]
pub tangential_model: String,
#[serde(default)]
pub track_orientation: bool,
#[serde(default = "default_limit_damping")]
pub limit_damping: bool,
#[serde(default = "default_liquid_bridge_model")]
pub liquid_bridge_model: String,
}
impl Default for DemConfig {
fn default() -> Self {
DemConfig {
materials: None,
contact_model: default_contact_model(),
adhesion_model: default_adhesion_model(),
rolling_model: default_rolling_model(),
twisting_model: default_twisting_model(),
tangential_model: default_tangential_model(),
track_orientation: false,
limit_damping: default_limit_damping(),
liquid_bridge_model: default_liquid_bridge_model(),
}
}
}
pub fn hooke_surface_energy_warning(config: &DemConfig) -> Option<String> {
if config.contact_model != "hooke" {
return None;
}
let offenders: Vec<&str> = match config.materials {
Some(ref mats) => mats
.iter()
.filter(|m| m.surface_energy > 0.0)
.map(|m| m.name.as_str())
.collect(),
None => Vec::new(),
};
if offenders.is_empty() {
return None;
}
Some(format!(
"WARNING: contact_model = \"hooke\" ignores `surface_energy` \
(JKR/DMT adhesion is only implemented on the Hertz contact path). \
surface_energy > 0 on material(s) [{}] will be SILENTLY DROPPED — no \
adhesion/pull-off force will be applied. To get JKR/DMT adhesion set \
contact_model = \"hertz\" (the default); otherwise set surface_energy = 0 \
on these material(s) to silence this warning. For linear-spring cohesion \
under Hooke, use `cohesion_energy` (SJKR) instead.",
offenders.join(", ")
))
}
fn liquid_bridge_model_error(config: &DemConfig) -> Option<String> {
match config.liquid_bridge_model.as_str() {
"off" | "willett2000" => None,
other => Some(format!(
"ERROR: invalid [dem].liquid_bridge_model = {:?}. Supported values are \
\"off\" and \"willett2000\".",
other
)),
}
}
pub struct MaterialTable {
pub names: Vec<String>,
pub youngs_mod: Vec<f64>,
pub poisson_ratio: Vec<f64>,
pub friction: Vec<f64>,
pub restitution: Vec<f64>,
pub rolling_friction: Vec<f64>,
pub twisting_friction: Vec<f64>,
pub cohesion_energy: Vec<f64>,
pub surface_energy: Vec<f64>,
pub beta_ij: Vec<Vec<f64>>,
pub friction_ij: Vec<Vec<f64>>,
pub rolling_friction_ij: Vec<Vec<f64>>,
pub cohesion_energy_ij: Vec<Vec<f64>>,
pub surface_energy_ij: Vec<Vec<f64>>,
pub e_eff_ij: Vec<Vec<f64>>,
pub g_eff_ij: Vec<Vec<f64>>,
pub twisting_friction_ij: Vec<Vec<f64>>,
pub kn: Vec<f64>,
pub kt: Vec<f64>,
pub kn_ij: Vec<Vec<f64>>,
pub kt_ij: Vec<Vec<f64>>,
pub contact_model: String,
pub adhesion_model: String,
pub rolling_model: String,
pub twisting_model: String,
pub tangential_model: String,
pub track_orientation: bool,
pub limit_damping: bool,
pub rolling_stiffness: Vec<f64>,
pub rolling_damping: Vec<f64>,
pub twisting_stiffness: Vec<f64>,
pub twisting_damping: Vec<f64>,
pub mdr_yield_stress: Vec<f64>,
pub mdr_psi_b: Vec<f64>,
pub mdr_damping: Vec<f64>,
pub liquid_bridge_volume: Vec<f64>,
pub liquid_surface_tension: Vec<f64>,
pub liquid_contact_angle: Vec<f64>,
pub liquid_rupture_distance: Vec<f64>,
pub rolling_stiffness_ij: Vec<Vec<f64>>,
pub rolling_damping_ij: Vec<Vec<f64>>,
pub twisting_stiffness_ij: Vec<Vec<f64>>,
pub twisting_damping_ij: Vec<Vec<f64>>,
pub mdr_yield_stress_ij: Vec<Vec<f64>>,
pub mdr_psi_b_ij: Vec<Vec<f64>>,
pub mdr_damping_ij: Vec<Vec<f64>>,
pub liquid_bridge_volume_ij: Vec<Vec<f64>>,
pub liquid_surface_tension_ij: Vec<Vec<f64>>,
pub liquid_contact_angle_ij: Vec<Vec<f64>>,
pub liquid_rupture_distance_ij: Vec<Vec<f64>>,
pub liquid_bridge_model: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MaterialError {
ConflictingCohesion {
name: String,
},
InvalidProperty {
name: String,
property: &'static str,
requirement: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MaterialId(u32);
impl MaterialId {
pub const fn index(self) -> usize {
self.0 as usize
}
pub const fn raw(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Elastic {
pub youngs_mod: f64,
pub poisson_ratio: f64,
pub restitution: f64,
pub normal_stiffness: f64,
pub tangential_stiffness: f64,
}
impl Elastic {
pub const fn new(youngs_mod: f64, poisson_ratio: f64, restitution: f64) -> Self {
Self {
youngs_mod,
poisson_ratio,
restitution,
normal_stiffness: 0.0,
tangential_stiffness: 0.0,
}
}
pub const fn with_hooke_stiffness(mut self, normal: f64, tangential: f64) -> Self {
self.normal_stiffness = normal;
self.tangential_stiffness = tangential;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Friction {
pub sliding: f64,
pub rolling: f64,
pub twisting: f64,
}
impl Default for Friction {
fn default() -> Self {
Self {
sliding: default_friction(),
rolling: 0.0,
twisting: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Adhesion {
None,
Sjkr {
energy: f64,
},
SurfaceEnergy {
energy: f64,
},
}
impl Default for Adhesion {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Rolling {
Constant,
Sds {
stiffness: f64,
damping: f64,
},
}
impl Default for Rolling {
fn default() -> Self {
Self::Constant
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Twisting {
Constant,
Sds {
stiffness: f64,
damping: f64,
},
}
impl Default for Twisting {
fn default() -> Self {
Self::Constant
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Mdr {
pub yield_stress: f64,
pub psi_b: f64,
pub damping: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LiquidBridge {
pub volume: f64,
pub surface_tension: f64,
pub contact_angle: f64,
pub rupture_distance: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Material {
pub name: String,
pub elastic: Elastic,
pub friction: Friction,
pub adhesion: Adhesion,
pub rolling: Rolling,
pub twisting: Twisting,
pub mdr: Mdr,
pub liquid_bridge: LiquidBridge,
}
impl Material {
pub fn new(name: impl Into<String>, elastic: Elastic) -> Self {
Self {
name: name.into(),
elastic,
friction: Friction::default(),
adhesion: Adhesion::None,
rolling: Rolling::Constant,
twisting: Twisting::Constant,
mdr: Mdr::default(),
liquid_bridge: LiquidBridge::default(),
}
}
pub const fn with_friction(mut self, friction: Friction) -> Self {
self.friction = friction;
self
}
pub const fn with_adhesion(mut self, adhesion: Adhesion) -> Self {
self.adhesion = adhesion;
self
}
pub const fn with_rolling(mut self, rolling: Rolling) -> Self {
self.rolling = rolling;
self
}
pub const fn with_twisting(mut self, twisting: Twisting) -> Self {
self.twisting = twisting;
self
}
pub const fn with_mdr(mut self, mdr: Mdr) -> Self {
self.mdr = mdr;
self
}
pub const fn with_liquid_bridge(mut self, liquid_bridge: LiquidBridge) -> Self {
self.liquid_bridge = liquid_bridge;
self
}
}
impl Material {
fn from_config(config: &MaterialConfig) -> Result<Self, MaterialError> {
if config.cohesion_energy > 0.0 && config.surface_energy > 0.0 {
return Err(MaterialError::ConflictingCohesion {
name: config.name.clone(),
});
}
Ok(Material::new(
&config.name,
Elastic::new(config.youngs_mod, config.poisson_ratio, config.restitution)
.with_hooke_stiffness(config.kn, config.kt),
)
.with_friction(Friction {
sliding: config.friction,
rolling: config.rolling_friction,
twisting: config.twisting_friction,
})
.with_adhesion(if config.cohesion_energy > 0.0 {
Adhesion::Sjkr {
energy: config.cohesion_energy,
}
} else if config.surface_energy > 0.0 {
Adhesion::SurfaceEnergy {
energy: config.surface_energy,
}
} else {
Adhesion::None
})
.with_rolling(Rolling::Sds {
stiffness: config.rolling_stiffness,
damping: config.rolling_damping,
})
.with_twisting(Twisting::Sds {
stiffness: config.twisting_stiffness,
damping: config.twisting_damping,
})
.with_mdr(Mdr {
yield_stress: config.mdr_yield_stress,
psi_b: config.mdr_psi_b,
damping: config.mdr_damping,
})
.with_liquid_bridge(LiquidBridge {
volume: config.liquid_bridge_volume,
surface_tension: config.liquid_surface_tension,
contact_angle: config.liquid_contact_angle,
rupture_distance: config.liquid_rupture_distance,
}))
}
}
fn validate_material(material: &Material) -> Result<(), MaterialError> {
let invalid = |property, requirement| MaterialError::InvalidProperty {
name: material.name.clone(),
property,
requirement,
};
let finite = |value: f64| value.is_finite();
let nonnegative = |value: f64| finite(value) && value >= 0.0;
if !finite(material.elastic.youngs_mod) || material.elastic.youngs_mod <= 0.0 {
return Err(invalid("elastic.youngs_mod", "finite and > 0"));
}
if !finite(material.elastic.poisson_ratio)
|| !(0.0..0.5).contains(&material.elastic.poisson_ratio)
{
return Err(invalid("elastic.poisson_ratio", "finite and in [0, 0.5)"));
}
if !finite(material.elastic.restitution) || !(0.0..=1.0).contains(&material.elastic.restitution)
{
return Err(invalid("elastic.restitution", "finite and in [0, 1]"));
}
for (property, value) in [
(
"elastic.normal_stiffness",
material.elastic.normal_stiffness,
),
(
"elastic.tangential_stiffness",
material.elastic.tangential_stiffness,
),
("friction.sliding", material.friction.sliding),
("friction.rolling", material.friction.rolling),
("friction.twisting", material.friction.twisting),
("mdr.yield_stress", material.mdr.yield_stress),
("mdr.damping", material.mdr.damping),
("liquid_bridge.volume", material.liquid_bridge.volume),
(
"liquid_bridge.surface_tension",
material.liquid_bridge.surface_tension,
),
(
"liquid_bridge.rupture_distance",
material.liquid_bridge.rupture_distance,
),
] {
if !nonnegative(value) {
return Err(invalid(property, "finite and >= 0"));
}
}
if !finite(material.mdr.psi_b) || !(0.0..=1.0).contains(&material.mdr.psi_b) {
return Err(invalid("mdr.psi_b", "finite and in [0, 1]"));
}
if !finite(material.liquid_bridge.contact_angle)
|| !(0.0..=std::f64::consts::PI).contains(&material.liquid_bridge.contact_angle)
{
return Err(invalid(
"liquid_bridge.contact_angle",
"finite and in [0, pi]",
));
}
match material.adhesion {
Adhesion::None => {}
Adhesion::Sjkr { energy } if !nonnegative(energy) => {
return Err(invalid("adhesion.sjkr.energy", "finite and >= 0"));
}
Adhesion::SurfaceEnergy { energy } if !nonnegative(energy) => {
return Err(invalid("adhesion.surface_energy", "finite and >= 0"));
}
_ => {}
}
for (property, value) in match material.rolling {
Rolling::Constant => Vec::new(),
Rolling::Sds { stiffness, damping } => vec![
("rolling.sds.stiffness", stiffness),
("rolling.sds.damping", damping),
],
}
.into_iter()
.chain(match material.twisting {
Twisting::Constant => Vec::new(),
Twisting::Sds { stiffness, damping } => vec![
("twisting.sds.stiffness", stiffness),
("twisting.sds.damping", damping),
],
}) {
if !nonnegative(value) {
return Err(invalid(property, "finite and >= 0"));
}
}
Ok(())
}
impl std::fmt::Display for MaterialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConflictingCohesion { name } => write!(
f,
"material '{}' has both cohesion_energy and surface_energy > 0; use only one",
name
),
Self::InvalidProperty {
name,
property,
requirement,
} => write!(
f,
"material '{}' property '{}' must be {}",
name, property, requirement
),
}
}
}
impl std::error::Error for MaterialError {}
impl Default for MaterialTable {
fn default() -> Self {
Self::new()
}
}
#[allow(deprecated)] impl MaterialTable {
pub fn new() -> Self {
MaterialTable {
names: Vec::new(),
youngs_mod: Vec::new(),
poisson_ratio: Vec::new(),
friction: Vec::new(),
restitution: Vec::new(),
rolling_friction: Vec::new(),
twisting_friction: Vec::new(),
cohesion_energy: Vec::new(),
surface_energy: Vec::new(),
beta_ij: Vec::new(),
friction_ij: Vec::new(),
rolling_friction_ij: Vec::new(),
cohesion_energy_ij: Vec::new(),
surface_energy_ij: Vec::new(),
e_eff_ij: Vec::new(),
g_eff_ij: Vec::new(),
twisting_friction_ij: Vec::new(),
kn: Vec::new(),
kt: Vec::new(),
kn_ij: Vec::new(),
kt_ij: Vec::new(),
contact_model: "hertz".to_string(),
adhesion_model: "jkr".to_string(),
rolling_model: "constant".to_string(),
twisting_model: "constant".to_string(),
tangential_model: "history".to_string(),
track_orientation: false,
limit_damping: true,
rolling_stiffness: Vec::new(),
rolling_damping: Vec::new(),
twisting_stiffness: Vec::new(),
twisting_damping: Vec::new(),
mdr_yield_stress: Vec::new(),
mdr_psi_b: Vec::new(),
mdr_damping: Vec::new(),
liquid_bridge_volume: Vec::new(),
liquid_surface_tension: Vec::new(),
liquid_contact_angle: Vec::new(),
liquid_rupture_distance: Vec::new(),
rolling_stiffness_ij: Vec::new(),
rolling_damping_ij: Vec::new(),
twisting_stiffness_ij: Vec::new(),
twisting_damping_ij: Vec::new(),
mdr_yield_stress_ij: Vec::new(),
mdr_psi_b_ij: Vec::new(),
mdr_damping_ij: Vec::new(),
liquid_bridge_volume_ij: Vec::new(),
liquid_surface_tension_ij: Vec::new(),
liquid_contact_angle_ij: Vec::new(),
liquid_rupture_distance_ij: Vec::new(),
liquid_bridge_model: "off".to_string(),
}
}
pub fn add(&mut self, material: Material) -> Result<MaterialId, MaterialError> {
validate_material(&material)?;
let (cohesion_energy, surface_energy) = match material.adhesion {
Adhesion::None => (0.0, 0.0),
Adhesion::Sjkr { energy } => (energy, 0.0),
Adhesion::SurfaceEnergy { energy } => (0.0, energy),
};
let (rolling_stiffness, rolling_damping) = match material.rolling {
Rolling::Constant => (0.0, 0.0),
Rolling::Sds { stiffness, damping } => (stiffness, damping),
};
let (twisting_stiffness, twisting_damping) = match material.twisting {
Twisting::Constant => (0.0, 0.0),
Twisting::Sds { stiffness, damping } => (stiffness, damping),
};
let id = MaterialId(self.names.len() as u32);
self.names.push(material.name);
self.youngs_mod.push(material.elastic.youngs_mod);
self.poisson_ratio.push(material.elastic.poisson_ratio);
self.restitution.push(material.elastic.restitution);
self.friction.push(material.friction.sliding);
self.rolling_friction.push(material.friction.rolling);
self.twisting_friction.push(material.friction.twisting);
self.cohesion_energy.push(cohesion_energy);
self.surface_energy.push(surface_energy);
self.kn.push(material.elastic.normal_stiffness);
self.kt.push(material.elastic.tangential_stiffness);
self.rolling_stiffness.push(rolling_stiffness);
self.rolling_damping.push(rolling_damping);
self.twisting_stiffness.push(twisting_stiffness);
self.twisting_damping.push(twisting_damping);
self.mdr_yield_stress.push(material.mdr.yield_stress);
self.mdr_psi_b.push(material.mdr.psi_b);
self.mdr_damping.push(material.mdr.damping);
self.liquid_bridge_volume
.push(material.liquid_bridge.volume);
self.liquid_surface_tension
.push(material.liquid_bridge.surface_tension);
self.liquid_contact_angle
.push(material.liquid_bridge.contact_angle);
self.liquid_rupture_distance
.push(material.liquid_bridge.rupture_distance);
Ok(id)
}
pub fn find_material(&self, name: &str) -> Option<u32> {
self.names.iter().position(|n| n == name).map(|i| i as u32)
}
pub fn liquid_bridge_cutoff_padding(&self, material_idx: u32) -> f64 {
if self.liquid_bridge_model != "willett2000" {
return 0.0;
}
let i = material_idx as usize;
if i >= self.names.len()
|| i >= self.liquid_bridge_volume_ij.len()
|| i >= self.liquid_surface_tension_ij.len()
|| i >= self.liquid_contact_angle_ij.len()
|| i >= self.liquid_rupture_distance_ij.len()
{
return 0.0;
}
let mut padding = 0.0_f64;
for j in 0..self.names.len() {
let volume = self.liquid_bridge_volume_ij[i][j];
let gamma = self.liquid_surface_tension_ij[i][j];
if volume <= 0.0 || gamma <= 0.0 {
continue;
}
let theta = self.liquid_contact_angle_ij[i][j];
let rupture = if self.liquid_rupture_distance_ij[i][j] > 0.0 {
self.liquid_rupture_distance_ij[i][j]
} else {
(1.0 + 0.5 * theta) * volume.cbrt()
};
padding = padding.max(rupture.max(0.0));
}
padding
}
pub fn build_pair_tables(&mut self) {
let n = self.names.len();
self.beta_ij = vec![vec![0.0; n]; n];
self.friction_ij = vec![vec![0.0; n]; n];
self.rolling_friction_ij = vec![vec![0.0; n]; n];
self.cohesion_energy_ij = vec![vec![0.0; n]; n];
self.surface_energy_ij = vec![vec![0.0; n]; n];
self.e_eff_ij = vec![vec![0.0; n]; n];
self.g_eff_ij = vec![vec![0.0; n]; n];
self.twisting_friction_ij = vec![vec![0.0; n]; n];
self.kn_ij = vec![vec![0.0; n]; n];
self.kt_ij = vec![vec![0.0; n]; n];
self.rolling_stiffness_ij = vec![vec![0.0; n]; n];
self.rolling_damping_ij = vec![vec![0.0; n]; n];
self.twisting_stiffness_ij = vec![vec![0.0; n]; n];
self.twisting_damping_ij = vec![vec![0.0; n]; n];
self.mdr_yield_stress_ij = vec![vec![0.0; n]; n];
self.mdr_psi_b_ij = vec![vec![0.0; n]; n];
self.mdr_damping_ij = vec![vec![0.0; n]; n];
self.liquid_bridge_volume_ij = vec![vec![0.0; n]; n];
self.liquid_surface_tension_ij = vec![vec![0.0; n]; n];
self.liquid_contact_angle_ij = vec![vec![0.0; n]; n];
self.liquid_rupture_distance_ij = vec![vec![0.0; n]; n];
while self.surface_energy.len() < n {
self.surface_energy.push(0.0);
}
while self.twisting_friction.len() < n {
self.twisting_friction.push(0.0);
}
while self.kn.len() < n {
self.kn.push(0.0);
}
while self.kt.len() < n {
self.kt.push(0.0);
}
while self.rolling_stiffness.len() < n {
self.rolling_stiffness.push(0.0);
}
while self.rolling_damping.len() < n {
self.rolling_damping.push(0.0);
}
while self.twisting_stiffness.len() < n {
self.twisting_stiffness.push(0.0);
}
while self.twisting_damping.len() < n {
self.twisting_damping.push(0.0);
}
while self.mdr_yield_stress.len() < n {
self.mdr_yield_stress.push(0.0);
}
while self.mdr_psi_b.len() < n {
self.mdr_psi_b.push(0.0);
}
while self.mdr_damping.len() < n {
self.mdr_damping.push(0.0);
}
while self.liquid_bridge_volume.len() < n {
self.liquid_bridge_volume.push(0.0);
}
while self.liquid_surface_tension.len() < n {
self.liquid_surface_tension.push(0.0);
}
while self.liquid_contact_angle.len() < n {
self.liquid_contact_angle.push(0.0);
}
while self.liquid_rupture_distance.len() < n {
self.liquid_rupture_distance.push(0.0);
}
for i in 0..n {
for j in 0..n {
let e_ij = (self.restitution[i] * self.restitution[j]).sqrt();
let log_e = e_ij.ln();
self.beta_ij[i][j] = if self.contact_model == "hooke" {
-log_e / (PI * PI + log_e * log_e).sqrt()
} else {
hertz_beta_for_cor(e_ij)
};
self.friction_ij[i][j] = (self.friction[i] * self.friction[j]).sqrt();
self.rolling_friction_ij[i][j] =
(self.rolling_friction[i] * self.rolling_friction[j]).sqrt();
self.cohesion_energy_ij[i][j] =
(self.cohesion_energy[i] * self.cohesion_energy[j]).sqrt();
self.surface_energy_ij[i][j] =
(self.surface_energy[i] * self.surface_energy[j]).sqrt();
self.twisting_friction_ij[i][j] = (self.twisting_friction[i].max(0.0)
* self.twisting_friction[j].max(0.0))
.sqrt();
let nu_i = self.poisson_ratio[i];
let nu_j = self.poisson_ratio[j];
self.e_eff_ij[i][j] = 1.0
/ ((1.0 - nu_i * nu_i) / self.youngs_mod[i]
+ (1.0 - nu_j * nu_j) / self.youngs_mod[j]);
self.g_eff_ij[i][j] = 1.0
/ (2.0 * (2.0 - nu_i) * (1.0 + nu_i) / self.youngs_mod[i]
+ 2.0 * (2.0 - nu_j) * (1.0 + nu_j) / self.youngs_mod[j]);
let ki = self.kn[i];
let kj = self.kn[j];
self.kn_ij[i][j] = if ki > 0.0 && kj > 0.0 {
2.0 * ki * kj / (ki + kj)
} else {
0.0
};
let kti = self.kt[i];
let ktj = self.kt[j];
self.kt_ij[i][j] = if kti > 0.0 && ktj > 0.0 {
2.0 * kti * ktj / (kti + ktj)
} else {
0.0
};
let kri = self.rolling_stiffness[i];
let krj = self.rolling_stiffness[j];
self.rolling_stiffness_ij[i][j] = if kri > 0.0 && krj > 0.0 {
2.0 * kri * krj / (kri + krj)
} else if kri > 0.0 {
kri
} else {
krj
};
self.rolling_damping_ij[i][j] =
(self.rolling_damping[i].max(0.0) * self.rolling_damping[j].max(0.0)).sqrt();
let kwi = self.twisting_stiffness[i];
let kwj = self.twisting_stiffness[j];
self.twisting_stiffness_ij[i][j] = if kwi > 0.0 && kwj > 0.0 {
2.0 * kwi * kwj / (kwi + kwj)
} else if kwi > 0.0 {
kwi
} else {
kwj
};
self.twisting_damping_ij[i][j] =
(self.twisting_damping[i].max(0.0) * self.twisting_damping[j].max(0.0)).sqrt();
self.mdr_yield_stress_ij[i][j] =
(self.mdr_yield_stress[i].max(0.0) * self.mdr_yield_stress[j].max(0.0)).sqrt();
self.mdr_psi_b_ij[i][j] = 0.5 * (self.mdr_psi_b[i] + self.mdr_psi_b[j]);
self.mdr_damping_ij[i][j] =
(self.mdr_damping[i].max(0.0) * self.mdr_damping[j].max(0.0)).sqrt();
self.liquid_bridge_volume_ij[i][j] = (self.liquid_bridge_volume[i].max(0.0)
* self.liquid_bridge_volume[j].max(0.0))
.sqrt();
self.liquid_surface_tension_ij[i][j] = (self.liquid_surface_tension[i].max(0.0)
* self.liquid_surface_tension[j].max(0.0))
.sqrt();
self.liquid_contact_angle_ij[i][j] =
0.5 * (self.liquid_contact_angle[i] + self.liquid_contact_angle[j]);
self.liquid_rupture_distance_ij[i][j] = (self.liquid_rupture_distance[i].max(0.0)
* self.liquid_rupture_distance[j].max(0.0))
.sqrt();
}
}
}
}
#[derive(AtomData)]
pub struct DemAtom {
pub radius: Vec<f64>,
pub density: Vec<f64>,
pub inv_inertia: Vec<f64>,
pub quaternion: Vec<[f64; 4]>,
#[forward]
pub omega: Vec<[f64; 3]>,
pub ang_mom: Vec<[f64; 3]>,
#[reverse]
#[zero]
pub torque: Vec<[f64; 3]>,
#[forward]
pub body_id: Vec<f64>,
}
impl Default for DemAtom {
fn default() -> Self {
Self::new()
}
}
impl DemAtom {
pub fn new() -> Self {
DemAtom {
radius: Vec::new(),
density: Vec::new(),
inv_inertia: Vec::new(),
quaternion: Vec::new(),
omega: Vec::new(),
ang_mom: Vec::new(),
torque: Vec::new(),
body_id: Vec::new(),
}
}
}
#[inline]
pub fn same_body(dem: &DemAtom, i: usize, j: usize) -> bool {
let bi = dem.body_id[i];
let bj = dem.body_id[j];
bi > 0.0 && bj > 0.0 && (bi - bj).abs() < 0.5
}
pub struct DemAtomPlugin;
impl Plugin for DemAtomPlugin {
fn provides(&self) -> Vec<&str> {
vec!["dem_particles"]
}
fn default_config(&self) -> Option<&str> {
Some(
r#"# Material definitions for DEM particles
[[dem.materials]]
name = "glass"
youngs_mod = 8.7e9
poisson_ratio = 0.3
restitution = 0.95
friction = 0.4
# rolling_friction = 0.1 # rolling resistance coefficient (default 0.0 = disabled)
# cohesion_energy = 0.05 # SJKR cohesion energy density J/m² (default 0.0 = disabled)
# surface_energy = 0.05 # JKR/DMT surface energy J/m² (default 0.0 = disabled)
# liquid_bridge_volume = 1e-12 # m^3/contact, with liquid_bridge_model = "willett2000"
# liquid_surface_tension = 0.072 # N/m
# liquid_contact_angle = 0.0 # radians
# liquid_rupture_distance = 0.0 # m; 0 = volume-scaled estimate
# mdr_yield_stress = 1.0e5 # Pa, for contact_model = "mdr"
# mdr_psi_b = 0.5 # parsed for LAMMPS MDR parity; bulk branch not yet active
# mdr_damping = 0.0 # MDR normal damping prefactor
# adhesion_model = "jkr" # "jkr" (default) or "dmt" when surface_energy > 0
# Additional materials can be added:
# [[dem.materials]]
# name = "steel"
# youngs_mod = 200e9
# poisson_ratio = 0.28
# restitution = 0.8
# friction = 0.3"#,
)
}
fn build(&self, app: &mut App) {
configure_dem_atom(app)
.unwrap_or_else(|error| panic!("DemAtomPlugin failed to build: {error}"));
}
fn try_build(&self, app: &mut App) -> Result<(), AppError> {
configure_dem_atom(app)
}
}
fn configure_dem_atom(app: &mut App) -> Result<(), AppError> {
app.add_plugins(AtomPlugin);
register_atom_data!(app, DemAtom::new());
let dem_config = Config::try_load::<DemConfig>(app, "dem")
.map_err(|error| AppError::message(error.to_string()))?;
if let Some(msg) = hooke_surface_energy_warning(&dem_config) {
eprintln!("{}", msg);
}
if let Some(msg) = liquid_bridge_model_error(&dem_config) {
return Err(AppError::message(msg));
}
let mut material_table = MaterialTable::new();
material_table.contact_model = dem_config.contact_model.clone();
material_table.adhesion_model = dem_config.adhesion_model.clone();
material_table.rolling_model = dem_config.rolling_model.clone();
material_table.twisting_model = dem_config.twisting_model.clone();
material_table.tangential_model = dem_config.tangential_model.clone();
material_table.track_orientation = dem_config.track_orientation;
material_table.limit_damping = dem_config.limit_damping;
material_table.liquid_bridge_model = dem_config.liquid_bridge_model.clone();
if let Some(ref materials) = dem_config.materials {
for mat in materials {
material_table
.add(
Material::from_config(mat)
.map_err(|error| AppError::message(error.to_string()))?,
)
.map_err(|error| AppError::message(error.to_string()))?;
}
material_table.build_pair_tables();
}
app.add_resource(material_table);
app.add_setup_system(set_dem_ntypes, ScheduleSetupSet::Setup);
Ok(())
}
fn set_dem_ntypes(mut atoms: ResMut<Atom>, material_table: Res<MaterialTable>) {
if !material_table.names.is_empty() {
atoms.ntypes = material_table.names.len();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_material_beta_and_friction() {
let mut mt = MaterialTable::new();
mt.add(
Material::new("glass", Elastic::new(8.7e9, 0.3, 0.95)).with_friction(Friction {
sliding: 0.4,
..Friction::default()
}),
)
.expect("valid glass material");
mt.build_pair_tables();
let e = 0.95_f64;
let expected_beta = hertz_beta_for_cor(e);
assert!(
(mt.beta_ij[0][0] - expected_beta).abs() < 1e-12,
"beta should be {}, got {}",
expected_beta,
mt.beta_ij[0][0]
);
assert!(
(mt.beta_ij[0][0] - tsuji_alpha(e) / 5.0_f64.sqrt()).abs() < 1e-12,
"beta should be α(e)/√5, got {}",
mt.beta_ij[0][0]
);
let realized = hertz_cor_of_beta(mt.beta_ij[0][0]);
assert!(
(0.960..=0.970).contains(&realized),
"realized COR should be ~0.965 (LAMMPS Tsuji), got {}",
realized
);
assert!(
(mt.friction_ij[0][0] - 0.4).abs() < 1e-12,
"friction should be 0.4, got {}",
mt.friction_ij[0][0]
);
}
#[test]
fn multi_material_mixing_symmetry() {
let mut mt = MaterialTable::new();
mt.add(
Material::new("glass", Elastic::new(8.7e9, 0.3, 0.95)).with_friction(Friction {
sliding: 0.4,
..Friction::default()
}),
)
.expect("valid glass material");
mt.add(
Material::new("steel", Elastic::new(200e9, 0.28, 0.8)).with_friction(Friction {
sliding: 0.3,
..Friction::default()
}),
)
.expect("valid steel material");
mt.build_pair_tables();
assert!(
(mt.beta_ij[0][1] - mt.beta_ij[1][0]).abs() < 1e-15,
"beta_ij should be symmetric"
);
assert!(
(mt.friction_ij[0][1] - mt.friction_ij[1][0]).abs() < 1e-15,
"friction_ij should be symmetric"
);
let expected_friction = (0.4_f64 * 0.3).sqrt();
assert!(
(mt.friction_ij[0][1] - expected_friction).abs() < 1e-12,
"friction_ij should be geometric mean {}, got {}",
expected_friction,
mt.friction_ij[0][1]
);
let e_mix = (0.95_f64 * 0.8).sqrt();
let expected_beta = hertz_beta_for_cor(e_mix);
assert!(
(mt.beta_ij[0][1] - expected_beta).abs() < 1e-12,
"beta_ij should use geometric mean restitution"
);
assert!(
(mt.e_eff_ij[0][1] - mt.e_eff_ij[1][0]).abs() < 1e-6,
"e_eff_ij should be symmetric"
);
assert!(
(mt.g_eff_ij[0][1] - mt.g_eff_ij[1][0]).abs() < 1e-6,
"g_eff_ij should be symmetric"
);
assert!(mt.e_eff_ij[0][0] > 0.0, "e_eff should be positive");
assert!(mt.g_eff_ij[0][0] > 0.0, "g_eff should be positive");
}
#[test]
fn typed_materials_match_independent_legacy_mixing_rules() {
let mut typed = MaterialTable::new();
let soft = typed
.add(
Material::new(
"soft",
Elastic::new(8.7e9, 0.30, 0.95).with_hooke_stiffness(1.0e6, 5.0e5),
)
.with_friction(Friction {
sliding: 0.4,
rolling: 0.1,
twisting: 0.05,
})
.with_adhesion(Adhesion::SurfaceEnergy { energy: 0.2 })
.with_rolling(Rolling::Sds {
stiffness: 2.0,
damping: 0.3,
})
.with_twisting(Twisting::Sds {
stiffness: 3.0,
damping: 0.4,
})
.with_mdr(Mdr {
yield_stress: 1.0e6,
psi_b: 0.1,
damping: 0.02,
})
.with_liquid_bridge(LiquidBridge {
volume: 1.0e-11,
surface_tension: 0.072,
contact_angle: 0.2,
rupture_distance: 1.0e-4,
}),
)
.unwrap();
let stiff = typed
.add(
Material::new(
"stiff",
Elastic::new(70e9, 0.22, 0.80).with_hooke_stiffness(2.0e6, 7.0e5),
)
.with_friction(Friction {
sliding: 0.3,
rolling: 0.2,
twisting: 0.07,
})
.with_adhesion(Adhesion::SurfaceEnergy { energy: 0.5 })
.with_rolling(Rolling::Sds {
stiffness: 4.0,
damping: 0.6,
})
.with_twisting(Twisting::Sds {
stiffness: 5.0,
damping: 0.8,
})
.with_mdr(Mdr {
yield_stress: 2.0e6,
psi_b: 0.2,
damping: 0.04,
})
.with_liquid_bridge(LiquidBridge {
volume: 8.0e-12,
surface_tension: 0.060,
contact_angle: 0.4,
rupture_distance: 2.0e-4,
}),
)
.unwrap();
assert_eq!((soft.raw(), stiff.raw()), (0, 1));
typed.build_pair_tables();
let geo = |a: f64, b: f64| (a * b).sqrt();
let harmonic_or_nonzero = |a: f64, b: f64| {
if a > 0.0 && b > 0.0 {
2.0 * a * b / (a + b)
} else if a > 0.0 {
a
} else {
b
}
};
let hertz_beta = |e: f64| {
let e = e.clamp(1.0e-3, 0.9999);
(1.2728 - 4.2783 * e + 11.087 * e.powi(2) - 22.348 * e.powi(3) + 27.467 * e.powi(4)
- 18.022 * e.powi(5)
+ 4.8218 * e.powi(6))
/ 5.0_f64.sqrt()
};
let expected = [
("beta", typed.beta_ij[0][1], hertz_beta(geo(0.95, 0.80))),
("friction", typed.friction_ij[0][1], geo(0.4, 0.3)),
(
"rolling_friction",
typed.rolling_friction_ij[0][1],
geo(0.1, 0.2),
),
("cohesion_energy", typed.cohesion_energy_ij[0][1], 0.0),
(
"surface_energy",
typed.surface_energy_ij[0][1],
geo(0.2, 0.5),
),
(
"twisting_friction",
typed.twisting_friction_ij[0][1],
geo(0.05, 0.07),
),
(
"e_eff",
typed.e_eff_ij[0][1],
1.0 / ((1.0 - 0.30_f64.powi(2)) / 8.7e9 + (1.0 - 0.22_f64.powi(2)) / 70e9),
),
(
"g_eff",
typed.g_eff_ij[0][1],
1.0 / (2.0 * (2.0 - 0.30) * 1.30 / 8.7e9 + 2.0 * (2.0 - 0.22) * 1.22 / 70e9),
),
("kn", typed.kn_ij[0][1], harmonic_or_nonzero(1.0e6, 2.0e6)),
("kt", typed.kt_ij[0][1], harmonic_or_nonzero(5.0e5, 7.0e5)),
(
"rolling_stiffness",
typed.rolling_stiffness_ij[0][1],
harmonic_or_nonzero(2.0, 4.0),
),
(
"rolling_damping",
typed.rolling_damping_ij[0][1],
geo(0.3, 0.6),
),
(
"twisting_stiffness",
typed.twisting_stiffness_ij[0][1],
harmonic_or_nonzero(3.0, 5.0),
),
(
"twisting_damping",
typed.twisting_damping_ij[0][1],
geo(0.4, 0.8),
),
(
"mdr_yield_stress",
typed.mdr_yield_stress_ij[0][1],
geo(1.0e6, 2.0e6),
),
("mdr_psi_b", typed.mdr_psi_b_ij[0][1], 0.15),
("mdr_damping", typed.mdr_damping_ij[0][1], geo(0.02, 0.04)),
(
"liquid_volume",
typed.liquid_bridge_volume_ij[0][1],
geo(1.0e-11, 8.0e-12),
),
(
"liquid_surface_tension",
typed.liquid_surface_tension_ij[0][1],
geo(0.072, 0.060),
),
(
"liquid_contact_angle",
typed.liquid_contact_angle_ij[0][1],
0.3,
),
(
"liquid_rupture_distance",
typed.liquid_rupture_distance_ij[0][1],
geo(1.0e-4, 2.0e-4),
),
];
println!("PAIR_TABLE,property,value");
for (name, actual, expected) in expected {
assert!(
(actual - expected).abs() <= 1.0e-12 * expected.abs().max(1.0),
"{name}: expected {expected:e}, got {actual:e}"
);
println!("PAIR_TABLE,{name},{actual:.17e}");
}
}
#[test]
fn e_eff_matches_manual_computation() {
let mut mt = MaterialTable::new();
mt.add(
Material::new("glass", Elastic::new(8.7e9, 0.3, 0.95)).with_friction(Friction {
sliding: 0.4,
..Friction::default()
}),
)
.expect("valid glass material");
mt.build_pair_tables();
let nu = 0.3_f64;
let e = 8.7e9_f64;
let expected = 1.0 / (2.0 * (1.0 - nu * nu) / e);
assert!(
(mt.e_eff_ij[0][0] - expected).abs() < 1.0,
"e_eff_ij[0][0] should be {}, got {}",
expected,
mt.e_eff_ij[0][0]
);
}
#[test]
fn liquid_bridge_cutoff_padding_tracks_active_rupture_range() {
let mut mt = MaterialTable::new();
mt.liquid_bridge_model = "willett2000".to_string();
let glass = mt
.add(
Material::new("glass", Elastic::new(1.0e6, 0.25, 1.0))
.with_friction(Friction {
sliding: 0.0,
rolling: 0.0,
twisting: 0.0,
})
.with_liquid_bridge(LiquidBridge {
volume: 1.0e-11,
surface_tension: 0.072,
contact_angle: 0.0,
rupture_distance: 1.5e-4,
}),
)
.expect("valid liquid-bridge material");
mt.add(
Material::new("dry", Elastic::new(1.0e6, 0.25, 1.0)).with_friction(Friction {
sliding: 0.0,
rolling: 0.0,
twisting: 0.0,
}),
)
.expect("valid dry material");
mt.build_pair_tables();
assert!((mt.liquid_bridge_cutoff_padding(glass.raw()) - 1.5e-4).abs() < 1.0e-15);
assert_eq!(mt.liquid_bridge_cutoff_padding(1), 0.0);
mt.liquid_bridge_model = "off".to_string();
assert_eq!(mt.liquid_bridge_cutoff_padding(glass.raw()), 0.0);
}
#[test]
fn config_material_rejects_conflicting_cohesion() {
let config: MaterialConfig = soil_core::toml::from_str(
r#"
name = "bad"
youngs_mod = 1.0
poisson_ratio = 0.25
restitution = 0.9
cohesion_energy = 1.0
surface_energy = 1.0
"#,
)
.unwrap();
let error = Material::from_config(&config).unwrap_err();
assert_eq!(
error,
MaterialError::ConflictingCohesion {
name: "bad".to_string()
}
);
}
#[test]
fn typed_material_validation_rejects_invalid_domains_without_mutation() {
let valid = || Material::new("bad", Elastic::new(1.0e6, 0.25, 0.9));
let cases = [
(
valid().with_friction(Friction {
sliding: f64::NAN,
..Friction::default()
}),
"friction.sliding",
),
(
Material::new("bad", Elastic::new(-1.0, 0.25, 0.9)),
"elastic.youngs_mod",
),
(
Material::new("bad", Elastic::new(1.0e6, -0.01, 0.9)),
"elastic.poisson_ratio",
),
(
Material::new("bad", Elastic::new(1.0e6, 0.25, 1.01)),
"elastic.restitution",
),
(
valid().with_rolling(Rolling::Sds {
stiffness: -1.0,
damping: 0.0,
}),
"rolling.sds.stiffness",
),
(
valid().with_liquid_bridge(LiquidBridge {
contact_angle: std::f64::consts::PI + 0.01,
..LiquidBridge::default()
}),
"liquid_bridge.contact_angle",
),
];
for (material, property) in cases {
let mut table = MaterialTable::new();
let error = table.add(material).unwrap_err();
assert!(
matches!(error, MaterialError::InvalidProperty { property: actual, .. } if actual == property)
);
assert!(
table.names.is_empty(),
"invalid input must not mutate any column"
);
assert!(table.youngs_mod.is_empty());
assert!(table.liquid_bridge_volume.is_empty());
}
}
fn mat(name: &str, surface_energy: f64) -> MaterialConfig {
MaterialConfig {
name: name.to_string(),
youngs_mod: 8.7e9,
poisson_ratio: 0.3,
restitution: 0.9,
friction: 0.4,
rolling_friction: 0.0,
cohesion_energy: 0.0,
surface_energy,
twisting_friction: 0.0,
kn: 0.0,
kt: 0.0,
rolling_stiffness: 0.0,
rolling_damping: 0.0,
twisting_stiffness: 0.0,
twisting_damping: 0.0,
mdr_yield_stress: 0.0,
mdr_psi_b: 0.0,
mdr_damping: 0.0,
liquid_bridge_volume: 0.0,
liquid_surface_tension: 0.0,
liquid_contact_angle: 0.0,
liquid_rupture_distance: 0.0,
}
}
fn cfg(contact_model: &str, materials: Vec<MaterialConfig>) -> DemConfig {
DemConfig {
contact_model: contact_model.to_string(),
materials: Some(materials),
..DemConfig::default()
}
}
#[test]
fn warns_for_hooke_with_surface_energy() {
let config = cfg("hooke", vec![mat("glass", 0.05)]);
let msg = hooke_surface_energy_warning(&config)
.expect("hooke + surface_energy>0 must produce a diagnostic");
assert!(
msg.contains("surface_energy"),
"must name the ignored field: {msg}"
);
assert!(
msg.contains("glass"),
"must name the offending material: {msg}"
);
assert!(msg.contains("hertz"), "must point at the hertz path: {msg}");
assert!(
msg.contains("hooke"),
"must name the offending contact_model: {msg}"
);
}
#[test]
fn warns_lists_all_offending_materials() {
let config = cfg(
"hooke",
vec![mat("glass", 0.05), mat("dry", 0.0), mat("wet", 0.02)],
);
let msg = hooke_surface_energy_warning(&config).expect("diagnostic must fire");
assert!(
msg.contains("glass") && msg.contains("wet"),
"names all offenders: {msg}"
);
assert!(
!msg.contains("dry"),
"must not name a zero-surface_energy material: {msg}"
);
}
#[test]
fn silent_for_hertz_with_surface_energy() {
let config = cfg("hertz", vec![mat("glass", 0.05)]);
assert!(
hooke_surface_energy_warning(&config).is_none(),
"hertz + surface_energy>0 is valid and must NOT warn"
);
let default_model = cfg(&default_contact_model(), vec![mat("glass", 0.05)]);
assert!(
hooke_surface_energy_warning(&default_model).is_none(),
"default (hertz) + surface_energy>0 must NOT warn"
);
}
#[test]
fn silent_for_hooke_without_surface_energy() {
let config = cfg("hooke", vec![mat("glass", 0.0), mat("steel", 0.0)]);
assert!(
hooke_surface_energy_warning(&config).is_none(),
"hooke + surface_energy=0 drops nothing and must NOT warn"
);
}
#[test]
fn silent_for_hooke_with_no_materials() {
let config = cfg("hooke", vec![]);
assert!(
hooke_surface_energy_warning(&config).is_none(),
"hooke with no materials must NOT warn"
);
}
#[test]
fn accepts_supported_liquid_bridge_model_selectors() {
let mut config = DemConfig::default();
for model in ["off", "willett2000"] {
config.liquid_bridge_model = model.to_string();
assert!(
liquid_bridge_model_error(&config).is_none(),
"{model} must be accepted"
);
}
}
#[test]
fn rejects_invalid_liquid_bridge_model_selector() {
let mut config = DemConfig {
liquid_bridge_model: "willet2000".to_string(),
..DemConfig::default()
};
let msg = liquid_bridge_model_error(&config).expect("typo must be rejected");
assert!(
msg.contains("liquid_bridge_model"),
"must name the bad selector field: {msg}"
);
assert!(
msg.contains("willet2000"),
"must echo the invalid value: {msg}"
);
assert!(
msg.contains("willett2000") && msg.contains("off"),
"must list supported values: {msg}"
);
config.liquid_bridge_model = "Willett2000".to_string();
assert!(
liquid_bridge_model_error(&config).is_some(),
"case-mismatched selectors must not silently disable bridge physics"
);
}
}