material: report exact missing condition fields (#1334)

Material-card validation now names the exact condition fields missing for each property (missing_condition_fields), alongside the existing unspecified_conditions.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
This commit is contained in:
seluneand0xlillith authored and GitHub committed 2026-10-15 13:10:49 -04:00
1 parent fd422a3fc1
commit 8b7fa2602e
4 files changed
+85 -6

No files matched your search

+20 -6
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use crate::{DocError, Result};
@@ -116,6 +116,10 @@ pub struct Validation {
pub property_kinds: Vec<String>,
pub isotropic_moduli: Option<IsotropicModuli>,
pub unspecified_conditions: Vec<String>,
/// Property kind -> absent condition fields under the existing family policy.
/// Empty when reading an older report that did not include this detail.
#[serde(default)]
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
pub source_review_required: Vec<String>,
pub analysis_solver_available: bool,
pub rendering_properties_available: bool,
@@ -256,6 +260,7 @@ impl MaterialCard {
let mut kinds = BTreeSet::new();
let mut used_sources = BTreeSet::new();
let mut unspecified = Vec::new();
let mut missing_condition_fields = BTreeMap::new();
let mut isotropic_moduli = None;
for property in &self.properties {
let kind = property.data.kind();
@@ -279,12 +284,20 @@ impl MaterialCard {
if let Some(state) = &conditions.state {
text(state, 1024, "conditions.state")?;
}
if conditions.temperature_k.is_none()
|| conditions.state.is_none()
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
{
let mut missing = Vec::new();
for (absent, field) in [
(conditions.temperature_k.is_none(), "temperature_k"),
(conditions.state.is_none(), "state"),
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
] {
if absent {
missing.push(field.to_string());
}
}
if !missing.is_empty() {
unspecified.push(kind.to_string());
missing_condition_fields.insert(kind.to_string(), missing);
}
if let Some(moduli) = property.data.validate()? {
isotropic_moduli = Some(moduli);
@@ -295,6 +308,7 @@ impl MaterialCard {
property_kinds: kinds.into_iter().collect(),
isotropic_moduli,
unspecified_conditions: unspecified,
missing_condition_fields,
source_review_required: used_sources.into_iter().collect(),
analysis_solver_available: false,
rendering_properties_available: false,
+52
View File
@@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
}
#[test]
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
for (family, extra) in
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
{
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["family"] = json!(family);
let mut expected = vec!["temperature_k", "state"];
expected.extend(extra);
let result = validate_card(&value).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
value["properties"][0]["conditions"] =
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
for field in expected {
let mut partial = value.clone();
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
let result = validate_card(&partial).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
}
value["properties"] = json!([]);
let result = validate_card(&value).unwrap();
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
}
}
#[test]
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
let before = value.clone();
let result = validate_card(&value).unwrap();
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
let serialized = serde_json::to_value(&result).unwrap();
assert_eq!(serialized["missing_condition_fields"], expected);
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
assert_eq!(value, before);
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
let mut old = serialized;
old.as_object_mut().unwrap().remove("missing_condition_fields");
let old: Validation = serde_json::from_value(old).unwrap();
assert!(old.missing_condition_fields.is_empty());
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
assert_eq!(old.source_review_required, result.source_review_required);
}
#[test]
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());