diff --git a/crates/doc/src/physical_material.rs b/crates/doc/src/physical_material.rs index 26aee591..ad3b1644 100644 --- a/crates/doc/src/physical_material.rs +++ b/crates/doc/src/physical_material.rs @@ -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, pub isotropic_moduli: Option, pub unspecified_conditions: Vec, + /// 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>, pub source_review_required: Vec, 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, diff --git a/crates/doc/src/physical_material_tests.rs b/crates/doc/src/physical_material_tests.rs index 70dc3588..8b93a007 100644 --- a/crates/doc/src/physical_material_tests.rs +++ b/crates/doc/src/physical_material_tests.rs @@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() { assert_eq!(serde_json::from_value::(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::(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()); diff --git a/crates/engine/src/cmd/material_properties.rs b/crates/engine/src/cmd/material_properties.rs index 7a34c47d..1fc4e7b5 100644 --- a/crates/engine/src/cmd/material_properties.rs +++ b/crates/engine/src/cmd/material_properties.rs @@ -57,6 +57,7 @@ mod tests { let response = s.execute("material.properties.directional", &json!({"card":card,"direction":[1,2,3]})).unwrap(); assert_eq!(response["young_modulus_pa"], 12e9); assert_eq!(response["validation"]["source_review_required"], json!(["test"])); + assert_eq!(response["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]})); for direction in [json!([0, 0, 0]), json!([1, 2]), json!([1, 2, 3, 4]), json!([true, 0, 0]), json!(["1", 0, 0]), Value::Null] { assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":direction})).is_err()); } @@ -76,6 +77,7 @@ mod tests { assert_eq!(result["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9})); assert_eq!(result["validation"]["source_review_required"], json!(["fixture"])); assert_eq!(result["validation"]["rendering_properties_available"], false); + assert_eq!(result["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]})); card["properties"][0]["data"]["model"]["poisson_ratio"] = json!(0.6); assert!(s.execute("material.properties.validate", &json!({"card":card})).is_err()); } @@ -91,6 +93,7 @@ mod tests { let card = json!({"schema_version":1,"designation":"Synthetic empty card","family":"other","sources":[],"properties":[]}); let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap(); assert_eq!(result["validation"]["analysis_solver_available"], false); + assert_eq!(result["validation"]["missing_condition_fields"], json!({})); for p in [json!({}), json!({"card":card,"assign":true}), json!({"card":{"schema_version":9}})] { assert!(s.execute("material.properties.validate", &p).is_err()); } diff --git a/docs/physical-material-cards.md b/docs/physical-material-cards.md index 1b3dfb3a..b7682866 100644 --- a/docs/physical-material-cards.md +++ b/docs/physical-material-cards.md @@ -58,6 +58,16 @@ isotropic derived response and its reciprocals must also remain finite/positive. Moisture is kg water / kg dry material and can exceed one. It is not a wet-mass fraction. Unspecified temperature/state, wood moisture or polymer strain rate are reported in `unspecified_conditions`; they are not guessed or replaced by zero. +The validation report also returns `missing_condition_fields`, a map from each +incomplete property kind to its exact absent field names. Temperature and state +apply to every family; wood additionally checks dry-basis moisture and polymers +strain rate, following the same reporting policy as `unspecified_conditions`. +For example, a wood density with temperature and state but no moisture reports +`{"density":["moisture_dry_basis_kg_kg"]}`. Complete properties are omitted; +an empty card returns an empty map, without inventing properties or conditions. +This detail is read-only and does not change admission or the saved card schema. +Older serialized validation reports without the map deserialize with an empty +map; regenerate validation from the card to obtain the missing-field detail. The family label does not certify that a chosen constitutive model fits a material. Scalar strengths are individual measurements, not a three-dimensional failure envelope. Typed strength directions and test methods remain a separate extension.