From 363b6c49b2ecb362a0353c9207fccabcb12d58e4 Mon Sep 17 00:00:00 2001 From: "K.Tolstygin" Date: Fri, 21 Aug 2026 16:46:20 +0300 Subject: [PATCH] feat: Added USDA converter --- .agents/skills/ptf-extract/SKILL.md | 44 ++- .../references/extraction-quality-gate.md | 30 +- .../ptf-extract/references/spec-template.yaml | 30 +- codegen/src/documentation.rs | 6 +- codegen/src/main.rs | 8 +- codegen/src/model.rs | 53 +++ codegen/src/specs.rs | 92 +++++ codegen/src/targets/catalog.rs | 22 ++ codegen/src/targets/mod.rs | 16 +- codegen/src/targets/python/mod.rs | 12 +- codegen/src/targets/python/wrapper.rs | 1 + codegen/src/targets/reference/python.rs | 14 +- codegen/src/usda_texture.rs | 321 ++++++++++++++++++ codegen/src/validate.rs | 57 +++- docs/src/.nav.yml | 1 + docs/src/reference/python/index.md | 1 + docs/src/reference/python/usda_texture.md | 8 + docs/src/utilities/.nav.yml | 2 + docs/src/utilities/usda-texture.md | 48 +++ specs/schema/ptf-spec.schema.json | 44 +++ specs/schema/usda-texture.schema.json | 56 +++ specs/utilities/usda_texture.yaml | 28 ++ targets/ptfkit-py/src/ptfkit/usda_texture.py | 125 +++++++ targets/ptfkit-py/tests/test_usda_texture.py | 85 +++++ 24 files changed, 1084 insertions(+), 20 deletions(-) create mode 100644 codegen/src/usda_texture.rs create mode 100644 docs/src/reference/python/usda_texture.md create mode 100644 docs/src/utilities/.nav.yml create mode 100644 docs/src/utilities/usda-texture.md create mode 100644 specs/schema/usda-texture.schema.json create mode 100644 specs/utilities/usda_texture.yaml create mode 100644 targets/ptfkit-py/src/ptfkit/usda_texture.py create mode 100644 targets/ptfkit-py/tests/test_usda_texture.py diff --git a/.agents/skills/ptf-extract/SKILL.md b/.agents/skills/ptf-extract/SKILL.md index f7d3cee..9f3d602 100644 --- a/.agents/skills/ptf-extract/SKILL.md +++ b/.agents/skills/ptf-extract/SKILL.md @@ -22,10 +22,34 @@ input error. 2. Extract only facts explicitly supported by the paper. Write its standalone YAML directly to `specs/functions/.yaml`, following `references/spec-template.yaml`. -3. Record missing or ambiguous required scientific information as explicit - blockers and set affected functions to `blocked`; otherwise set reviewed, - complete functions to `ready-for-implementation`. -4. Run `cargo run --manifest-path codegen/Cargo.toml -- validate` and fix +3. For particle-size or texture-related inputs, examine whether sand, silt, and + clay are mass or volume fractions; whether percentages use the fine-earth + fraction; the particle-size boundaries and their units; any named + particle-size or texture-classification system; whether the fractions should + sum to 100; and whether the source directly uses a categorical texture-class + predictor. +4. For functions with numeric particle-size inputs that could potentially be + supplied through the common USDA texture adapter, record + `input_adapters.usda_texture` as: + - `supported` only when the source provides explicit compatibility evidence; + - `unsupported` only when the source provides explicit incompatibility + evidence; + - `unknown` when the relevant definitions are absent or ambiguous. + + Require non-empty evidence for every recorded status. Do not add this adapter + metadata merely because input parameters are named `sand`, `silt`, or + `clay`. If the source directly uses a categorical texture-class predictor, + preserve that predictor as part of the published model rather than replacing + it with representative numeric fractions. + + An `unknown` adapter status blocks only a USDA compatibility claim, not an + otherwise complete PTF specification. +5. Record missing or ambiguous scientific information required to interpret or + implement the published PTF as explicit blockers and set affected functions + to `blocked`; otherwise set reviewed, complete functions to + `ready-for-implementation`. Do not treat information needed only to establish + USDA adapter compatibility as a PTF implementation blocker. +6. Run `cargo run --manifest-path codegen/Cargo.toml -- validate` and fix validation errors before finishing. Validation never justifies inferred science. @@ -45,3 +69,15 @@ exact YAML path and explicit blockers. - Use `generation.public_python: manual` only when the public wrapper cannot follow the standard generated API; it never opts the native NumPy ufunc out of generation. +- Never infer USDA compatibility from parameter names alone. +- Never replace measured or source-defined particle-size fractions with + representative USDA fractions during extraction. +- Never create a golden test by converting a texture-class label to + representative sand, silt, and clay values. +- If the publication directly defines a categorical texture-class predictor, + preserve it rather than rewriting the published model as a numeric + sand-silt-clay model. +- Never copy the common USDA representative-value table into an individual PTF + specification. +- `ptf-extract` records compatibility evidence; it does not perform user-data + conversion. diff --git a/.agents/skills/ptf-extract/references/extraction-quality-gate.md b/.agents/skills/ptf-extract/references/extraction-quality-gate.md index 4066b50..dde30c8 100644 --- a/.agents/skills/ptf-extract/references/extraction-quality-gate.md +++ b/.agents/skills/ptf-extract/references/extraction-quality-gate.md @@ -27,13 +27,41 @@ APA-style slug and identifies the generated public module, for example `generation.public_python: manual` only for an intentional manual public wrapper; native ufunc generation remains required. +## Texture-input adapters + +For each function with particle-size or texture-related inputs, review the +source evidence for fraction basis, fine-earth basis, particle-size boundaries, +named classification system, sum-to-100 expectations, and direct categorical +texture predictors. Record the result in `input_adapters.usda_texture` when +relevant. + +Verify that: + +- every mapped `sand`, `silt`, or `clay` adapter input exists in the function's + declared `inputs`; +- roles are supported by source definitions and are not assigned from variable + names alone; +- `supported` includes explicit evidence of USDA compatibility; +- `unsupported` explains the explicit incompatibility; +- `unknown` identifies the missing or ambiguous definitions; +- adapter metadata does not alter the published PTF formula; +- representative USDA values are not embedded in the PTF source specification; + and +- missing compatibility evidence does not set an otherwise complete PTF to + `blocked`. + +Compatibility evidence belongs in the structured adapter metadata. It may also +be explained in `scientific_notes` when additional context helps scientific +review. The extractor records evidence only; it does not convert user data. + ## Blockers Set affected functions to `blocked` and name the missing evidence when a formula, constant, unit, output mapping, semantic expression, golden value, numeric policy, or applicability fact is missing or ambiguous. Do not use `TODO` as a substitute for a structured required value; write it only in an -explicit blocker note. Schema-valid YAML may still be blocked. +explicit blocker note. Schema-valid YAML may still be blocked. An `unknown` +USDA adapter status blocks only a compatibility claim. ## Statuses diff --git a/.agents/skills/ptf-extract/references/spec-template.yaml b/.agents/skills/ptf-extract/references/spec-template.yaml index 1fe3a39..90209f5 100644 --- a/.agents/skills/ptf-extract/references/spec-template.yaml +++ b/.agents/skills/ptf-extract/references/spec-template.yaml @@ -17,15 +17,37 @@ functions: models: h_theta: null k_h: null - inputs: [] - outputs: [] + input_adapters: + usda_texture: + status: unknown + inputs: + sand: sand + silt: null + clay: clay + evidence: Particle-size boundaries are not yet established from the source. + inputs: + - name: sand + symbol: null + unit: '%' + domain: '[0, 100]' + description: Sand content defined by the source. + - name: clay + symbol: null + unit: '%' + domain: '[0, 100]' + description: Clay content defined by the source. + outputs: + type: scalar + name: result + symbol: null + unit: '1' + domain: null + description: Target quantity predicted by the source. golden_tests: [] edge_cases: [] documentation: notes: [] warnings: [] - implementation: - variables: [] scientific_notes: | ## Supported models diff --git a/codegen/src/documentation.rs b/codegen/src/documentation.rs index e912483..df1187b 100644 --- a/codegen/src/documentation.rs +++ b/codegen/src/documentation.rs @@ -3,7 +3,7 @@ //! Targets choose their own section ordering and markup. This module only //! describes the information they have available to render. -use crate::model::{Function, Outputs, Parameter, Scope, Source}; +use crate::model::{Function, InputAdapters, Outputs, Parameter, Scope, Source}; #[derive(Clone, Copy, Debug)] pub(crate) struct SourceDocument<'a> { @@ -35,6 +35,7 @@ pub(crate) struct FunctionDocument<'a> { pub(crate) remarks: Remarks<'a>, pub(crate) notes: &'a [String], pub(crate) warnings: &'a [String], + pub(crate) input_adapters: Option<&'a InputAdapters>, } #[derive(Clone, Copy, Debug)] @@ -90,6 +91,7 @@ pub(crate) fn for_function(function: &Function) -> FunctionDocument<'_> { }, notes: &function.documentation.notes, warnings: &function.documentation.warnings, + input_adapters: function.input_adapters.as_ref(), } } @@ -133,6 +135,7 @@ mod tests { prediction_target: "Test property.".into(), models: Models::default(), }, + input_adapters: None, inputs: Vec::new(), outputs: Outputs::Scalar { field: parameter("result"), @@ -169,6 +172,7 @@ mod tests { k_h: Some("Conductivity model.".into()), }, }, + input_adapters: None, inputs: vec![parameter("sand")], outputs: Outputs::Record { name: "TestResult".into(), diff --git a/codegen/src/main.rs b/codegen/src/main.rs index 304aeef..3967518 100644 --- a/codegen/src/main.rs +++ b/codegen/src/main.rs @@ -14,6 +14,7 @@ mod render; mod semantic; mod specs; mod targets; +mod usda_texture; mod validate; mod version; @@ -41,6 +42,7 @@ impl Cli { match self.command { Command::Validate => { let entries = load_validated_specifications(root)?; + usda_texture::load(root)?; println!( "validated {} PTF specification files containing {} functions", entries.len(), @@ -53,11 +55,13 @@ impl Cli { } Command::Generate => { let entries = load_validated_specifications(root)?; - targets::run(root, entries) + let usda_texture = usda_texture::load(root)?; + targets::run(root, entries, &usda_texture) } Command::CheckGenerated => { let entries = load_validated_specifications(root)?; - targets::check_generated(root, entries) + let usda_texture = usda_texture::load(root)?; + targets::check_generated(root, entries, &usda_texture) } Command::Version { version } => version::run(root, &version), } diff --git a/codegen/src/model.rs b/codegen/src/model.rs index b3f7628..fd7b9c0 100644 --- a/codegen/src/model.rs +++ b/codegen/src/model.rs @@ -37,6 +37,8 @@ struct FunctionReference { status: String, public_api: PublicApi, scope: FunctionScope, + #[serde(default)] + input_adapters: Option, inputs: Vec, outputs: OutputReference, implementation: Option, @@ -119,6 +121,7 @@ impl<'de> Deserialize<'de> for Spec { status: function.status, public_api: function.public_api, scope: function.scope, + input_adapters: function.input_adapters, inputs, outputs, implementation: function.implementation, @@ -176,6 +179,8 @@ pub(crate) struct Function { pub(crate) status: String, pub(crate) public_api: PublicApi, pub(crate) scope: FunctionScope, + #[serde(default)] + pub(crate) input_adapters: Option, pub(crate) inputs: Vec, pub(crate) outputs: Outputs, pub(crate) implementation: Option, @@ -185,6 +190,54 @@ pub(crate) struct Function { pub(crate) documentation: Documentation, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct InputAdapters { + pub(crate) usda_texture: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct UsdaTextureAdapter { + pub(crate) status: AdapterStatus, + #[serde(default)] + pub(crate) inputs: Option, + pub(crate) evidence: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum AdapterStatus { + Supported, + Unsupported, + Unknown, +} + +impl AdapterStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Unsupported => "unsupported", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct TextureInputMapping { + pub(crate) sand: Option, + pub(crate) silt: Option, + pub(crate) clay: Option, +} + +impl TextureInputMapping { + pub(crate) fn roles(&self) -> [(&'static str, Option<&str>); 3] { + [ + ("sand", self.sand.as_deref()), + ("silt", self.silt.as_deref()), + ("clay", self.clay.as_deref()), + ] + } +} + impl Function { pub(crate) fn result_class(&self) -> Option<&str> { match &self.outputs { diff --git a/codegen/src/specs.rs b/codegen/src/specs.rs index 56c629e..6287b9e 100644 --- a/codegen/src/specs.rs +++ b/codegen/src/specs.rs @@ -271,6 +271,15 @@ mod tests { .unwrap(); } + fn specification_with_adapter(adapter: &str) -> String { + specification( + "adapter", + " implementation:\n variables: [{name: value, expr: x}]\n", + "", + ) + .replace(" inputs:\n", &format!("{adapter} inputs:\n")) + } + #[test] fn rejects_a_code_generating_function_without_implementation() { let root = fixture_root("mixed"); @@ -455,4 +464,87 @@ mod tests { assert!(error.contains("filename stem must be an APA-style slug")); } + + #[test] + fn accepts_supported_unsupported_unknown_and_absent_usda_adapters() { + for (label, adapter) in [ + ( + "supported", + " input_adapters:\n usda_texture:\n status: supported\n inputs: {sand: x}\n evidence: The source defines x as USDA total sand by mass of fine earth.\n", + ), + ( + "unsupported", + " input_adapters:\n usda_texture:\n status: unsupported\n evidence: The source uses incompatible particle-size boundaries.\n", + ), + ( + "unknown", + " input_adapters:\n usda_texture:\n status: unknown\n inputs: {sand: x}\n evidence: The source does not report the sand particle-size boundary.\n", + ), + ("absent", ""), + ] { + let root = fixture_root(label); + fs::write( + root.join("specs/functions/adapter.yaml"), + specification_with_adapter(adapter), + ) + .unwrap(); + let entries = load(&root).expect("adapter fixture must satisfy the schema"); + assert!(crate::validate::specifications(&entries).is_empty()); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn schema_rejects_supported_adapter_without_evidence_or_mapped_input() { + for (label, adapter, expected) in [ + ( + "no-evidence", + " input_adapters:\n usda_texture:\n status: supported\n inputs: {sand: x}\n", + "evidence", + ), + ( + "no-mapping", + " input_adapters:\n usda_texture:\n status: supported\n inputs: {}\n evidence: Explicit USDA definitions are reported.\n", + "not valid under any", + ), + ] { + let root = fixture_root(label); + fs::write( + root.join("specs/functions/adapter.yaml"), + specification_with_adapter(adapter), + ) + .unwrap(); + let error = load(&root) + .expect_err("invalid adapter must fail") + .to_string(); + assert!(error.contains(expected), "{error}"); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn semantic_validation_rejects_unknown_and_duplicate_mapped_inputs() { + for (label, mapping, expected) in [ + ("missing", "{sand: missing}", "is not declared"), + ( + "duplicate", + "{sand: x, silt: x}", + "mapped to multiple texture roles", + ), + ] { + let root = fixture_root(label); + let adapter = format!( + " input_adapters:\n usda_texture:\n status: unknown\n inputs: {mapping}\n evidence: Particle-size compatibility is not reported.\n" + ); + fs::write( + root.join("specs/functions/adapter.yaml"), + specification_with_adapter(&adapter), + ) + .unwrap(); + let entries = load(&root).expect("fixture must satisfy structural schema"); + let errors = crate::validate::specifications(&entries).join("\n"); + assert!(errors.contains(expected), "{errors}"); + fs::remove_dir_all(root).unwrap(); + } + } } diff --git a/codegen/src/targets/catalog.rs b/codegen/src/targets/catalog.rs index 5bf3284..76cf6ab 100644 --- a/codegen/src/targets/catalog.rs +++ b/codegen/src/targets/catalog.rs @@ -121,6 +121,28 @@ impl Render for FunctionSection<'_> { parameters: self.document.parameters, } .render(writer); + if let Some(adapter) = self + .document + .input_adapters + .and_then(|adapters| adapters.usda_texture.as_ref()) + { + writer.write("#### USDA texture adapter\n\n"); + writer.line(format_args!("**Status:** `{}`", adapter.status.as_str())); + writer.blank_line(); + if let Some(inputs) = &adapter.inputs { + let mappings = inputs + .roles() + .into_iter() + .filter_map(|(role, input)| input.map(|input| format!("{role} → `{input}`"))) + .collect::>(); + if !mappings.is_empty() { + writer.line(format_args!("**Inputs:** {}", mappings.join(", "))); + writer.blank_line(); + } + } + writer.line(format_args!("**Evidence:** {}", adapter.evidence)); + writer.blank_line(); + } ParameterTable { title: "Outputs", parameters: match self.document.returns { diff --git a/codegen/src/targets/mod.rs b/codegen/src/targets/mod.rs index b0d1afc..62fac16 100644 --- a/codegen/src/targets/mod.rs +++ b/codegen/src/targets/mod.rs @@ -29,14 +29,18 @@ pub(super) fn group_by_source( sources } -pub(crate) fn run(root: &Path, entries: Vec) -> Result<()> { +pub(crate) fn run( + root: &Path, + entries: Vec, + usda_texture: &crate::usda_texture::Specification, +) -> Result<()> { let catalog = catalog::render(&entries); let reference_python = reference::python::render(&entries); let compiled = compile::functions(entries)?; let reference_c = reference::c::render(&compiled)?; let reference_cpp = reference::cpp::render(&compiled)?; let rust = rust::render(&compiled)?; - let python = python::render(&compiled)?; + let python = python::render(&compiled, usda_texture)?; let native = native::render(&compiled)?; output::commit( @@ -60,8 +64,12 @@ pub(crate) fn run(root: &Path, entries: Vec) -> Result<()> { } /// Regenerate every target and fail when that changes a codegen-owned file. -pub(crate) fn check_generated(root: &Path, entries: Vec) -> Result<()> { +pub(crate) fn check_generated( + root: &Path, + entries: Vec, + usda_texture: &crate::usda_texture::Specification, +) -> Result<()> { let before = output::snapshot_generated(root)?; - run(root, entries)?; + run(root, entries, usda_texture)?; output::assert_unchanged(root, before) } diff --git a/codegen/src/targets/python/mod.rs b/codegen/src/targets/python/mod.rs index 31637c0..23222fb 100644 --- a/codegen/src/targets/python/mod.rs +++ b/codegen/src/targets/python/mod.rs @@ -17,7 +17,10 @@ pub(super) struct Output { pub(super) tests: Vec, } -pub(super) fn render(functions: &[CompiledFunction]) -> anyhow::Result { +pub(super) fn render( + functions: &[CompiledFunction], + usda_texture: &crate::usda_texture::Specification, +) -> anyhow::Result { Ok(Output { extension: extension::render(functions)?, wrappers: { @@ -26,9 +29,14 @@ pub(super) fn render(functions: &[CompiledFunction]) -> anyhow::Result { "ptfkit/_ptfkit.pyi".into(), stub::render(functions), )); + wrappers.push(crate::usda_texture::render_module(usda_texture)); wrappers }, - tests: test::render(functions), + tests: { + let mut tests = test::render(functions); + tests.push(crate::usda_texture::render_tests(usda_texture)); + tests + }, }) } diff --git a/codegen/src/targets/python/wrapper.rs b/codegen/src/targets/python/wrapper.rs index 3a08d34..5708efe 100644 --- a/codegen/src/targets/python/wrapper.rs +++ b/codegen/src/targets/python/wrapper.rs @@ -506,6 +506,7 @@ mod tests { prediction_target: "Test property.".into(), models: Models::default(), }, + input_adapters: None, inputs: Vec::new(), outputs: crate::model::Outputs::Record { name: "TestResult".into(), diff --git a/codegen/src/targets/reference/python.rs b/codegen/src/targets/reference/python.rs index 7119ff2..3f1e0e9 100644 --- a/codegen/src/targets/reference/python.rs +++ b/codegen/src/targets/reference/python.rs @@ -12,6 +12,13 @@ pub(crate) fn render(entries: &[Entry]) -> Vec { let mut files = vec![markdown::markdown_file("index.md", |writer| { IndexPage { entries: &entries }.render(writer); })]; + files.push(markdown::markdown_file("usda_texture.md", |writer| { + markdown::generated_frontmatter(writer, |writer| { + writer.line("title: Python module ptfkit.usda_texture"); + writer.line("nav-title: ptfkit.usda_texture"); + }); + writer.line("::: ptfkit.usda_texture"); + })); for entry in entries { files.push(markdown::markdown_file( format!("{}.md", entry.slug), @@ -33,6 +40,9 @@ impl Render for IndexPage<'_> { writer.write( "# Python API reference\n\nptfkit's Python API is organized around public source modules.\n\n## Modules\n\n", ); + writer.line( + "- [`ptfkit.usda_texture`](usda_texture.md) — representative USDA texture fractions", + ); for entry in self.entries { ModuleReference { entry }.render(writer); } @@ -103,7 +113,9 @@ mod tests { assert!(index.starts_with( "---\n# @generated by ptfkit-codegen; DO NOT EDIT.\n\ntitle: Python API reference\n---\n" )); - assert_eq!(files.len(), entries.len() + 1); + assert_eq!(files.len(), entries.len() + 2); + assert!(index.contains("[`ptfkit.usda_texture`](usda_texture.md)")); + assert!(contents(&files, "usda_texture.md").contains("::: ptfkit.usda_texture")); for entry in &entries { let module = format!("ptfkit.{}", entry.slug); diff --git a/codegen/src/usda_texture.rs b/codegen/src/usda_texture.rs new file mode 100644 index 0000000..a35ff5b --- /dev/null +++ b/codegen/src/usda_texture.rs @@ -0,0 +1,321 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use anyhow::{Result, bail}; +use jsonschema::Draft; +use serde::Deserialize; +use serde_json::Value; + +use crate::output::GeneratedFile; + +const CANONICAL_CLASSES: [&str; 12] = [ + "sand", + "loamy sand", + "sandy loam", + "loam", + "silt loam", + "silt", + "sandy clay loam", + "clay loam", + "silty clay loam", + "sandy clay", + "silty clay", + "clay", +]; + +#[derive(Clone, Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct Specification { + pub(crate) classification_system: String, + pub(crate) source: Source, + pub(crate) units: String, + pub(crate) sum_tolerance: f64, + pub(crate) classes: Vec, + pub(crate) notes: Vec, + pub(crate) warnings: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct Source { + pub(crate) organization: String, + pub(crate) title: String, + pub(crate) artifact: String, + pub(crate) retrieved: String, + pub(crate) url: String, + pub(crate) sha256: String, + pub(crate) value_rule: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct TextureClass { + pub(crate) name: String, + pub(crate) sand: f64, + pub(crate) silt: f64, + pub(crate) clay: f64, + #[serde(default)] + pub(crate) aliases: Vec, +} + +pub(crate) fn load(root: &Path) -> Result { + let path = root.join("specs/utilities/usda_texture.yaml"); + let schema: Value = serde_json::from_slice(&fs::read( + root.join("specs/schema/usda-texture.schema.json"), + )?)?; + let validator = jsonschema::options() + .with_draft(Draft::Draft202012) + .build(&schema)?; + let yaml: serde_yaml::Value = serde_yaml::from_str(&fs::read_to_string(&path)?)?; + let value = serde_json::to_value(yaml)?; + let mut errors = validator + .iter_errors(&value) + .map(|error| format!("{}: {error}", error.instance_path())) + .collect::>(); + let specification: Specification = serde_json::from_value(value)?; + validate(&specification, &mut errors); + if !errors.is_empty() { + bail!( + "{}: USDA texture validation failed:\n{}", + path.display(), + errors.join("\n") + ); + } + Ok(specification) +} + +fn validate(specification: &Specification, errors: &mut Vec) { + let actual = specification + .classes + .iter() + .map(|class| class.name.as_str()) + .collect::>(); + let expected = CANONICAL_CLASSES.into_iter().collect::>(); + if actual != expected || specification.classes.len() != CANONICAL_CLASSES.len() { + errors.push("classes must contain each canonical basic USDA class exactly once".into()); + } + + let mut normalized_names = BTreeSet::new(); + for class in &specification.classes { + for (component, value) in [ + ("sand", class.sand), + ("silt", class.silt), + ("clay", class.clay), + ] { + if !value.is_finite() || !(0.0..=100.0).contains(&value) { + errors.push(format!( + "class `{}` has invalid {component} value {value}", + class.name + )); + } + } + let sum = class.sand + class.silt + class.clay; + if (sum - 100.0).abs() > specification.sum_tolerance { + errors.push(format!( + "class `{}` sums to {sum}, outside tolerance {}", + class.name, specification.sum_tolerance + )); + } + for accepted in std::iter::once(&class.name).chain(&class.aliases) { + let normalized = normalize(accepted); + if !normalized_names.insert(normalized.clone()) { + errors.push(format!( + "normalized accepted name `{normalized}` is ambiguous" + )); + } + } + } +} + +fn normalize(value: &str) -> String { + value + .trim() + .to_lowercase() + .replace(['_', '-'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +pub(crate) fn render_module(specification: &Specification) -> GeneratedFile { + let mut output = format!( + "# @generated by ptfkit-codegen; DO NOT EDIT.\n\n\ + \"\"\"Estimate representative particle-size fractions from a USDA texture class.\n\n\ + Classification: {}.\n\ + Units: {}.\n\ + Source organization: {}.\n\ + Source title: {}.\n\ + Artifact: {}, retrieved {}.\n\ + Source URL: {}\n\ + SHA-256: {}\n\ + The committed values are the source workbook's representative values.\n\ + \"\"\"\n\n\ + from enum import StrEnum\n\ + from typing import Final, NamedTuple\n\n\n\ + class USDATextureClass(StrEnum):\n\ + \x20 \"\"\"One of the 12 basic USDA-NRCS fine-earth texture classes.\"\"\"\n", + specification.classification_system, + specification.units, + specification.source.organization, + specification.source.title, + specification.source.artifact, + specification.source.retrieved, + specification.source.url, + specification.source.sha256, + ); + for class in &specification.classes { + output.push_str(&format!( + "\n {} = {:?}\n", + class.name.to_uppercase().replace(' ', "_"), + class.name + )); + } + output.push_str( + "\n\nclass USDATextureFractions(NamedTuple):\n\ + \x20 \"\"\"Estimated representative fractions in percent by mass of fine earth.\"\"\"\n\n\ + \x20 sand: float\n\ + \x20 silt: float\n\ + \x20 clay: float\n\n\n\ + _FRACTIONS: Final[dict[USDATextureClass, USDATextureFractions]] = {\n", + ); + for class in &specification.classes { + let variant = class.name.to_uppercase().replace(' ', "_"); + output.push_str(&format!( + " USDATextureClass.{variant}: USDATextureFractions({}, {}, {}),\n", + python_number(class.sand), + python_number(class.silt), + python_number(class.clay) + )); + } + output.push_str("}\n\n_NORMALIZED_CLASSES: Final[dict[str, USDATextureClass]] = {\n"); + for class in &specification.classes { + let variant = class.name.to_uppercase().replace(' ', "_"); + for accepted in std::iter::once(&class.name).chain(&class.aliases) { + output.push_str(&format!( + " {:?}: USDATextureClass.{variant},\n", + normalize(accepted) + )); + } + } + output.push_str( + "}\n\n_VALID_CLASS_NAMES = ', '.join(member.value for member in USDATextureClass)\n\n\n\ + def _normalize(value: str) -> str:\n\ + \x20 return ' '.join(value.strip().lower().replace('_', ' ').replace('-', ' ').split())\n\n\n\ + def estimate_usda_texture_fractions(\n\ + \x20 texture_class: USDATextureClass | str,\n\ + ) -> USDATextureFractions:\n\ + \x20 \"\"\"Return the official representative composition for a basic USDA class.\n\n\ + \x20 Args:\n\ + \x20 texture_class: A complete canonical class name or enum value. String\n\ + \x20 comparisons ignore case, surrounding/repeated whitespace, hyphens,\n\ + \x20 and underscores.\n\n\ + \x20 Returns:\n\ + \x20 Estimated sand, silt, and clay percentages, in that order.\n\n\ + \x20 Raises:\n\ + \x20 ValueError: If the value is not one of the 12 canonical classes.\n\n\ + \x20 Note:\n\ + \x20 The result is representative, not a measurement. Verify particle-size\n\ + \x20 compatibility before passing it to a PTF.\n\n\ + \x20 \"\"\"\n\ + \x20 if isinstance(texture_class, str):\n\ + \x20 member = _NORMALIZED_CLASSES.get(_normalize(texture_class))\n\ + \x20 if member is not None:\n\ + \x20 return _FRACTIONS[member]\n\ + \x20 msg = f'Unknown USDA texture class {texture_class!r}. Valid classes: {_VALID_CLASS_NAMES}'\n\ + \x20 raise ValueError(msg)\n\n\n\ + __all__ = [\n\ + \x20 'USDATextureClass',\n\ + \x20 'USDATextureFractions',\n\ + \x20 'estimate_usda_texture_fractions',\n\ + ]\n", + ); + GeneratedFile::new("ptfkit/usda_texture.py".into(), output) +} + +pub(crate) fn render_tests(specification: &Specification) -> GeneratedFile { + let mut output = String::from( + "# @generated by ptfkit-codegen; DO NOT EDIT.\n\n\ + import importlib\n\ + import math\n\ + import sys\n\ + from unittest.mock import patch\n\n\ + import pytest\n\n\ + from ptfkit.usda_texture import (\n\ + \x20 USDATextureClass,\n\ + \x20 USDATextureFractions,\n\ + \x20 estimate_usda_texture_fractions,\n\ + )\n\n\n\ + EXPECTED = {\n", + ); + for class in &specification.classes { + output.push_str(&format!( + " {:?}: USDATextureFractions({}, {}, {}),\n", + class.name, + python_number(class.sand), + python_number(class.silt), + python_number(class.clay) + )); + } + output.push_str(&format!( + "}}\n\nSUM_TOLERANCE = {}\n\n\n", + specification.sum_tolerance + )); + output.push_str( + "def test_canonical_classes_and_authoritative_values() -> None:\n\ + \x20 assert [member.value for member in USDATextureClass] == list(EXPECTED)\n\ + \x20 assert {member.value for member in USDATextureClass} == set(EXPECTED)\n\ + \x20 for name, expected in EXPECTED.items():\n\ + \x20 actual = estimate_usda_texture_fractions(name)\n\ + \x20 assert actual == expected\n\ + \x20 assert all(math.isfinite(value) for value in actual)\n\ + \x20 assert all(0.0 <= value <= 100.0 for value in actual)\n\ + \x20 assert abs(sum(actual) - 100.0) <= SUM_TOLERANCE\n\n\n\ + def test_loam_has_the_official_representative_triplet() -> None:\n\ + \x20 assert estimate_usda_texture_fractions('loam') == USDATextureFractions(41.0, 42.0, 17.0)\n\n\n\ + def test_enum_and_normalized_string_inputs() -> None:\n\ + \x20 expected = USDATextureFractions(61.0, 12.0, 27.0)\n\ + \x20 assert estimate_usda_texture_fractions(USDATextureClass.SANDY_CLAY_LOAM) == expected\n\ + \x20 for value in ('Sandy Clay Loam', ' sandy clay loam ', 'sandy-clay-loam', 'sandy_clay_loam'):\n\ + \x20 assert estimate_usda_texture_fractions(value) == expected\n\n\n\ + @pytest.mark.parametrize(\n\ + \x20 'value',\n\ + \x20 ['unknown', 'fine sandy loam', 'very fine sandy loam', 'gravelly loam', 'very gravelly clay', 'L', 'SL', 'SCL', 'loa'],\n + )\n\ + def test_invalid_subclasses_modifiers_abbreviations_and_fuzzy_values(value: str) -> None:\n\ + \x20 with pytest.raises(ValueError, match='Unknown USDA texture class') as error:\n\ + \x20 estimate_usda_texture_fractions(value)\n\ + \x20 message = str(error.value)\n\ + \x20 assert repr(value) in message\n\ + \x20 assert all(name in message for name in EXPECTED)\n\n\n\ + def test_module_import_reads_no_external_files_or_network() -> None:\n\ + \x20 sys.modules.pop('ptfkit.usda_texture', None)\n\ + \x20 with (\n\ + \x20 patch('builtins.open', side_effect=AssertionError('unexpected file read')),\n\ + \x20 patch('socket.socket', side_effect=AssertionError('unexpected network access')),\n\ + \x20 ):\n\ + \x20 importlib.import_module('ptfkit.usda_texture')\n", + ); + GeneratedFile::new("tests/test_usda_texture.py".into(), output) +} + +fn python_number(value: f64) -> String { + format!("{value:.1}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repository_source_is_valid_and_complete() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let specification = load(root).expect("USDA texture source must validate"); + assert_eq!(specification.classes.len(), 12); + assert_eq!( + specification + .classes + .iter() + .find(|class| class.name == "loam") + .map(|class| (class.sand, class.silt, class.clay)), + Some((41.0, 42.0, 17.0)) + ); + } +} diff --git a/codegen/src/validate.rs b/codegen/src/validate.rs index 47bee07..05d53c1 100644 --- a/codegen/src/validate.rs +++ b/codegen/src/validate.rs @@ -3,7 +3,7 @@ use std::{ path::PathBuf, }; -use crate::model::{Entry, Function, Parameter}; +use crate::model::{AdapterStatus, Entry, Function, Parameter}; pub(crate) fn specifications(entries: &[Entry]) -> Vec { let mut errors = Vec::new(); @@ -34,6 +34,7 @@ pub(crate) fn specifications(entries: &[Entry]) -> Vec { "outputs.fields", &mut errors, ); + input_adapters(entry, function, &mut errors); match ( function.outputs.fields().len(), function.result_class().is_some(), @@ -63,6 +64,60 @@ pub(crate) fn specifications(entries: &[Entry]) -> Vec { errors } +fn input_adapters(entry: &Entry, function: &Function, errors: &mut Vec) { + let Some(adapter) = function + .input_adapters + .as_ref() + .and_then(|adapters| adapters.usda_texture.as_ref()) + else { + return; + }; + if adapter.evidence.trim().is_empty() { + errors.push(diag( + entry, + "input_adapters.usda_texture.evidence", + Some(&function.name), + "must contain scientific compatibility evidence", + )); + } + + let declared = function + .inputs + .iter() + .map(|input| input.name.as_str()) + .collect::>(); + let mut mapped = BTreeSet::new(); + if let Some(mapping) = &adapter.inputs { + for (role, name) in mapping.roles() { + let Some(name) = name else { continue }; + if !declared.contains(name) { + errors.push(diag( + entry, + &format!("input_adapters.usda_texture.inputs.{role}"), + Some(&function.name), + &format!("mapped input `{name}` is not declared by the function"), + )); + } + if !mapped.insert(name) { + errors.push(diag( + entry, + "input_adapters.usda_texture.inputs", + Some(&function.name), + &format!("input `{name}` is mapped to multiple texture roles"), + )); + } + } + } + if matches!(adapter.status, AdapterStatus::Supported) && mapped.is_empty() { + errors.push(diag( + entry, + "input_adapters.usda_texture.inputs", + Some(&function.name), + "supported compatibility must map at least one texture role", + )); + } +} + fn duplicate( map: &mut BTreeMap, key: &K, diff --git a/docs/src/.nav.yml b/docs/src/.nav.yml index 3cc455c..4d0ebaf 100644 --- a/docs/src/.nav.yml +++ b/docs/src/.nav.yml @@ -6,6 +6,7 @@ nav: - Python: targets/python.md - Rust: targets/rust.md - PTF Catalog: ptf-catalog + - Utilities: utilities - Contributing: contributing - API Reference: - C: reference/c diff --git a/docs/src/reference/python/index.md b/docs/src/reference/python/index.md index 14af279..2f179e1 100644 --- a/docs/src/reference/python/index.md +++ b/docs/src/reference/python/index.md @@ -10,6 +10,7 @@ ptfkit's Python API is organized around public source modules. ## Modules +- [`ptfkit.usda_texture`](usda_texture.md) — representative USDA texture fractions - [`ptfkit.ahuja1984`](ahuja1984.md) — Ahuja et al. (1984), effective-porosity relations for saturated conductivity. - [`ptfkit.aimrun2009`](aimrun2009.md) — Aimrun & Amin (2009), Tanjung Karang Rice Irrigation Project, Malaysia. - [`ptfkit.beniaich2023`](beniaich2023.md) — Beniaich et al. (2023), soil-water PTFs for four Moroccan regions. diff --git a/docs/src/reference/python/usda_texture.md b/docs/src/reference/python/usda_texture.md new file mode 100644 index 0000000..013c54f --- /dev/null +++ b/docs/src/reference/python/usda_texture.md @@ -0,0 +1,8 @@ +--- +# @generated by ptfkit-codegen; DO NOT EDIT. + +title: Python module ptfkit.usda_texture +nav-title: ptfkit.usda_texture +--- + +::: ptfkit.usda_texture diff --git a/docs/src/utilities/.nav.yml b/docs/src/utilities/.nav.yml new file mode 100644 index 0000000..76a996a --- /dev/null +++ b/docs/src/utilities/.nav.yml @@ -0,0 +1,2 @@ +nav: + - USDA texture adapter: usda-texture.md diff --git a/docs/src/utilities/usda-texture.md b/docs/src/utilities/usda-texture.md new file mode 100644 index 0000000..bd2a7bf --- /dev/null +++ b/docs/src/utilities/usda-texture.md @@ -0,0 +1,48 @@ +# USDA texture adapter + +`ptfkit.usda_texture` converts one of the 12 basic USDA-NRCS fine-earth texture +classes into one deterministic, representative estimate of sand, silt, and clay +percentages. It is an input-data adapter, not a published pedotransfer function, +and its output is not a laboratory measurement. + +The values come from the representative outputs in the USDA Natural Resources +Conservation Service [Soil Texture Calculator](https://www.nrcs.usda.gov/resources/education-and-teaching-materials/soil-texture-calculator), +artifact `USDA_Soil_Texture_Calculator.xlsm`. "Representative" means the single +composition selected by that workbook for a class. ptfkit does not calculate +independent range midpoints, normalize the result, or substitute a polygon +centroid. + +## Supported classes + +The exact canonical names are `sand`, `loamy sand`, `sandy loam`, `loam`, +`silt loam`, `silt`, `sandy clay loam`, `clay loam`, `silty clay loam`, +`sandy clay`, `silty clay`, and `clay`. + +## Python usage + +```python +from ptfkit.usda_texture import estimate_usda_texture_fractions + +fractions = estimate_usda_texture_fractions('loam') +print(fractions.sand, fractions.silt, fractions.clay) +``` + +Strings are stripped, compared case-insensitively, and may use repeated +whitespace, hyphens, or underscores between words. For example, +`sandy_clay_loam`, `sandy-clay-loam`, and `Sandy Clay Loam` are equivalent. +The function does not use abbreviations or fuzzy matching. Subclasses and +fragment modifiers such as `fine sandy loam` and `gravelly loam` raise +`ValueError`; the error lists every accepted canonical class. + +## Applicability and uncertainty + +All uncertainty introduced by replacing an unknown measured composition with a +representative point propagates into any later calculation. A PTF may also use +particle-size boundaries, bases, or conventions incompatible with the USDA +fine-earth mass fractions. Never assume compatibility merely because a PTF has +arguments named `sand`, `silt`, or `clay`; verify its specification and source +evidence first. + +No existing PTF is used in this example because this change does not add +compatibility claims to existing scientific specifications without explicit +source evidence. diff --git a/specs/schema/ptf-spec.schema.json b/specs/schema/ptf-spec.schema.json index 494f6ff..952cadd 100644 --- a/specs/schema/ptf-spec.schema.json +++ b/specs/schema/ptf-spec.schema.json @@ -239,6 +239,7 @@ } }, "scope": { "$ref": "#/$defs/functionScope" }, + "input_adapters": { "$ref": "#/$defs/inputAdapters" }, "inputs": { "type": "array", "minItems": 1, @@ -273,6 +274,49 @@ }, "implementation": { "$ref": "#/$defs/implementation" } } + }, + "textureInputMapping": { + "type": "object", + "additionalProperties": false, + "properties": { + "sand": { "$ref": "#/$defs/stringOrNull" }, + "silt": { "$ref": "#/$defs/stringOrNull" }, + "clay": { "$ref": "#/$defs/stringOrNull" } + } + }, + "usdaTextureAdapter": { + "type": "object", + "additionalProperties": false, + "required": ["status", "evidence"], + "properties": { + "status": { "enum": ["supported", "unsupported", "unknown"] }, + "inputs": { "$ref": "#/$defs/textureInputMapping" }, + "evidence": { "type": "string", "minLength": 1 } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "supported" } }, "required": ["status"] }, + "then": { + "required": ["inputs"], + "properties": { + "inputs": { + "anyOf": [ + { "required": ["sand"], "properties": { "sand": { "type": "string", "minLength": 1 } } }, + { "required": ["silt"], "properties": { "silt": { "type": "string", "minLength": 1 } } }, + { "required": ["clay"], "properties": { "clay": { "type": "string", "minLength": 1 } } } + ] + } + } + } + } + ] + }, + "inputAdapters": { + "type": "object", + "additionalProperties": false, + "properties": { + "usda_texture": { "$ref": "#/$defs/usdaTextureAdapter" } + } } } } diff --git a/specs/schema/usda-texture.schema.json b/specs/schema/usda-texture.schema.json new file mode 100644 index 0000000..d4a436a --- /dev/null +++ b/specs/schema/usda-texture.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/AgroDT/ptfkit/blob/main/specs/schema/usda-texture.schema.json", + "title": "USDA texture representative compositions", + "type": "object", + "additionalProperties": false, + "required": ["classification_system", "source", "units", "sum_tolerance", "classes", "notes", "warnings"], + "properties": { + "classification_system": { "const": "USDA-NRCS fine-earth texture classes" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["organization", "title", "artifact", "retrieved", "url", "sha256", "value_rule"], + "properties": { + "organization": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "artifact": { "type": "string", "minLength": 1 }, + "retrieved": { "type": "string", "format": "date" }, + "url": { "type": "string", "format": "uri" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "value_rule": { "type": "string", "minLength": 1 } + } + }, + "units": { "const": "percent by mass of fine earth" }, + "sum_tolerance": { "type": "number", "exclusiveMinimum": 0, "maximum": 1e-9 }, + "classes": { + "type": "array", + "minItems": 12, + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "sand", "silt", "clay"], + "properties": { + "name": { + "enum": [ + "sand", "loamy sand", "sandy loam", "loam", "silt loam", "silt", + "sandy clay loam", "clay loam", "silty clay loam", "sandy clay", + "silty clay", "clay" + ] + }, + "sand": { "type": "number", "minimum": 0, "maximum": 100 }, + "silt": { "type": "number", "minimum": 0, "maximum": 100 }, + "clay": { "type": "number", "minimum": 0, "maximum": 100 }, + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "notes": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "warnings": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } +} diff --git a/specs/utilities/usda_texture.yaml b/specs/utilities/usda_texture.yaml new file mode 100644 index 0000000..2f73d62 --- /dev/null +++ b/specs/utilities/usda_texture.yaml @@ -0,0 +1,28 @@ +classification_system: USDA-NRCS fine-earth texture classes +source: + organization: USDA Natural Resources Conservation Service + title: Soil Texture Calculator + artifact: USDA_Soil_Texture_Calculator.xlsm + retrieved: 2026-08-21 + url: https://www.nrcs.usda.gov/sites/default/files/2022-11/USDA_Soil_Texture_Calculator.xlsm + sha256: f81aedb7525709be04b3dbb4989a2d5204dd5a56e53d6c4f5f68e4ba9a9ce8ab + value_rule: Values are the workbook's representative values for the 12 basic texture classes, without normalization or substitution. +units: percent by mass of fine earth +sum_tolerance: 1.0e-12 +classes: + - {name: sand, sand: 90, silt: 5, clay: 5} + - {name: loamy sand, sand: 79, silt: 14, clay: 7} + - {name: sandy loam, sand: 65, silt: 25, clay: 10} + - {name: loam, sand: 41, silt: 42, clay: 17} + - {name: silt loam, sand: 22, silt: 65, clay: 13} + - {name: silt, sand: 7, silt: 87, clay: 6} + - {name: sandy clay loam, sand: 61, silt: 12, clay: 27} + - {name: clay loam, sand: 33, silt: 34, clay: 33} + - {name: silty clay loam, sand: 11, silt: 56, clay: 33} + - {name: sandy clay, sand: 50, silt: 5, clay: 45} + - {name: silty clay, sand: 5, silt: 45, clay: 50} + - {name: clay, sand: 17, silt: 13, clay: 70} +notes: + - Representative values are deterministic estimates for a class, not measured particle-size fractions. +warnings: + - Compatibility with a PTF depends on its particle-size definitions and must not be inferred from input names. diff --git a/targets/ptfkit-py/src/ptfkit/usda_texture.py b/targets/ptfkit-py/src/ptfkit/usda_texture.py new file mode 100644 index 0000000..8447591 --- /dev/null +++ b/targets/ptfkit-py/src/ptfkit/usda_texture.py @@ -0,0 +1,125 @@ +# @generated by ptfkit-codegen; DO NOT EDIT. + +"""Estimate representative particle-size fractions from a USDA texture class. + +Classification: USDA-NRCS fine-earth texture classes. +Units: percent by mass of fine earth. +Source organization: USDA Natural Resources Conservation Service. +Source title: Soil Texture Calculator. +Artifact: USDA_Soil_Texture_Calculator.xlsm, retrieved 2026-08-21. +Source URL: https://www.nrcs.usda.gov/sites/default/files/2022-11/USDA_Soil_Texture_Calculator.xlsm +SHA-256: f81aedb7525709be04b3dbb4989a2d5204dd5a56e53d6c4f5f68e4ba9a9ce8ab +The committed values are the source workbook's representative values. +""" + +from enum import StrEnum +from typing import Final, NamedTuple + + +class USDATextureClass(StrEnum): + """One of the 12 basic USDA-NRCS fine-earth texture classes.""" + + SAND = 'sand' + + LOAMY_SAND = 'loamy sand' + + SANDY_LOAM = 'sandy loam' + + LOAM = 'loam' + + SILT_LOAM = 'silt loam' + + SILT = 'silt' + + SANDY_CLAY_LOAM = 'sandy clay loam' + + CLAY_LOAM = 'clay loam' + + SILTY_CLAY_LOAM = 'silty clay loam' + + SANDY_CLAY = 'sandy clay' + + SILTY_CLAY = 'silty clay' + + CLAY = 'clay' + + +class USDATextureFractions(NamedTuple): + """Estimated representative fractions in percent by mass of fine earth.""" + + sand: float + silt: float + clay: float + + +_FRACTIONS: Final[dict[USDATextureClass, USDATextureFractions]] = { + USDATextureClass.SAND: USDATextureFractions(90.0, 5.0, 5.0), + USDATextureClass.LOAMY_SAND: USDATextureFractions(79.0, 14.0, 7.0), + USDATextureClass.SANDY_LOAM: USDATextureFractions(65.0, 25.0, 10.0), + USDATextureClass.LOAM: USDATextureFractions(41.0, 42.0, 17.0), + USDATextureClass.SILT_LOAM: USDATextureFractions(22.0, 65.0, 13.0), + USDATextureClass.SILT: USDATextureFractions(7.0, 87.0, 6.0), + USDATextureClass.SANDY_CLAY_LOAM: USDATextureFractions(61.0, 12.0, 27.0), + USDATextureClass.CLAY_LOAM: USDATextureFractions(33.0, 34.0, 33.0), + USDATextureClass.SILTY_CLAY_LOAM: USDATextureFractions(11.0, 56.0, 33.0), + USDATextureClass.SANDY_CLAY: USDATextureFractions(50.0, 5.0, 45.0), + USDATextureClass.SILTY_CLAY: USDATextureFractions(5.0, 45.0, 50.0), + USDATextureClass.CLAY: USDATextureFractions(17.0, 13.0, 70.0), +} + +_NORMALIZED_CLASSES: Final[dict[str, USDATextureClass]] = { + 'sand': USDATextureClass.SAND, + 'loamy sand': USDATextureClass.LOAMY_SAND, + 'sandy loam': USDATextureClass.SANDY_LOAM, + 'loam': USDATextureClass.LOAM, + 'silt loam': USDATextureClass.SILT_LOAM, + 'silt': USDATextureClass.SILT, + 'sandy clay loam': USDATextureClass.SANDY_CLAY_LOAM, + 'clay loam': USDATextureClass.CLAY_LOAM, + 'silty clay loam': USDATextureClass.SILTY_CLAY_LOAM, + 'sandy clay': USDATextureClass.SANDY_CLAY, + 'silty clay': USDATextureClass.SILTY_CLAY, + 'clay': USDATextureClass.CLAY, +} + +_VALID_CLASS_NAMES = ', '.join(member.value for member in USDATextureClass) + + +def _normalize(value: str) -> str: + return ' '.join(value.strip().lower().replace('_', ' ').replace('-', ' ').split()) + + +def estimate_usda_texture_fractions( + texture_class: USDATextureClass | str, +) -> USDATextureFractions: + """Return the official representative composition for a basic USDA class. + + Args: + texture_class: A complete canonical class name or enum value. String + comparisons ignore case, surrounding/repeated whitespace, hyphens, + and underscores. + + Returns: + Estimated sand, silt, and clay percentages, in that order. + + Raises: + ValueError: If the value is not one of the 12 canonical classes. + + Note: + The result is representative, not a measurement. Verify particle-size + compatibility before passing it to a PTF. + + """ + if isinstance(texture_class, str): + member = _NORMALIZED_CLASSES.get(_normalize(texture_class)) + if member is not None: + return _FRACTIONS[member] + msg = f'Unknown USDA texture class {texture_class!r}. Valid classes: {_VALID_CLASS_NAMES}' + raise ValueError(msg) + + +__all__ = [ + 'USDATextureClass', + 'USDATextureFractions', + 'estimate_usda_texture_fractions', +] diff --git a/targets/ptfkit-py/tests/test_usda_texture.py b/targets/ptfkit-py/tests/test_usda_texture.py new file mode 100644 index 0000000..ccf6251 --- /dev/null +++ b/targets/ptfkit-py/tests/test_usda_texture.py @@ -0,0 +1,85 @@ +# @generated by ptfkit-codegen; DO NOT EDIT. + +import importlib +import math +import sys +from unittest.mock import patch + +import pytest + +from ptfkit.usda_texture import ( + USDATextureClass, + USDATextureFractions, + estimate_usda_texture_fractions, +) + + +EXPECTED = { + 'sand': USDATextureFractions(90.0, 5.0, 5.0), + 'loamy sand': USDATextureFractions(79.0, 14.0, 7.0), + 'sandy loam': USDATextureFractions(65.0, 25.0, 10.0), + 'loam': USDATextureFractions(41.0, 42.0, 17.0), + 'silt loam': USDATextureFractions(22.0, 65.0, 13.0), + 'silt': USDATextureFractions(7.0, 87.0, 6.0), + 'sandy clay loam': USDATextureFractions(61.0, 12.0, 27.0), + 'clay loam': USDATextureFractions(33.0, 34.0, 33.0), + 'silty clay loam': USDATextureFractions(11.0, 56.0, 33.0), + 'sandy clay': USDATextureFractions(50.0, 5.0, 45.0), + 'silty clay': USDATextureFractions(5.0, 45.0, 50.0), + 'clay': USDATextureFractions(17.0, 13.0, 70.0), +} + +SUM_TOLERANCE = 0.000000000001 + + +def test_canonical_classes_and_authoritative_values() -> None: + assert [member.value for member in USDATextureClass] == list(EXPECTED) + assert {member.value for member in USDATextureClass} == set(EXPECTED) + for name, expected in EXPECTED.items(): + actual = estimate_usda_texture_fractions(name) + assert actual == expected + assert all(math.isfinite(value) for value in actual) + assert all(0.0 <= value <= 100.0 for value in actual) + assert abs(sum(actual) - 100.0) <= SUM_TOLERANCE + + +def test_loam_has_the_official_representative_triplet() -> None: + assert estimate_usda_texture_fractions('loam') == USDATextureFractions(41.0, 42.0, 17.0) + + +def test_enum_and_normalized_string_inputs() -> None: + expected = USDATextureFractions(61.0, 12.0, 27.0) + assert estimate_usda_texture_fractions(USDATextureClass.SANDY_CLAY_LOAM) == expected + for value in ('Sandy Clay Loam', ' sandy clay loam ', 'sandy-clay-loam', 'sandy_clay_loam'): + assert estimate_usda_texture_fractions(value) == expected + + +@pytest.mark.parametrize( + 'value', + [ + 'unknown', + 'fine sandy loam', + 'very fine sandy loam', + 'gravelly loam', + 'very gravelly clay', + 'L', + 'SL', + 'SCL', + 'loa', + ], +) +def test_invalid_subclasses_modifiers_abbreviations_and_fuzzy_values(value: str) -> None: + with pytest.raises(ValueError, match='Unknown USDA texture class') as error: + estimate_usda_texture_fractions(value) + message = str(error.value) + assert repr(value) in message + assert all(name in message for name in EXPECTED) + + +def test_module_import_reads_no_external_files_or_network() -> None: + sys.modules.pop('ptfkit.usda_texture', None) + with ( + patch('builtins.open', side_effect=AssertionError('unexpected file read')), + patch('socket.socket', side_effect=AssertionError('unexpected network access')), + ): + importlib.import_module('ptfkit.usda_texture')