Functional Weave
Code in Rust

agri.fertiliser-application

Fertiliser product rate (kg/ha and total) to meet N, P2O5 and K2O targets from its analysis, limiting nutrient first.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 16 tests, run in TypeScript, Python and Rust.

What it does

How much of a fertiliser product to spread to meet nitrogen (N), phosphate (P2O5) and potash (K2O) targets, given the product's analysis, and what that rate actually supplies. A 20-10-10 compound for 100 kg N, 60 kg P2O5 and 60 kg K2O per hectare is 500 kg/ha, limited by nitrogen, and leaves P2O5 and K2O each 10 kg/ha short.

**This is arithmetic, not agronomic advice.** It does not work out what a crop needs (that is RB209 or a FACTS-qualified adviser's job, from soil indices, previous crop and yield expectation), check NVZ limits or closed periods, or choose the product.

For example

  • fertiliser_application(nitrogen 34.5, phosphate 0, potash 0, nitrogen 150, phosphate 0, potash 0, 10) → limiting nutrient nitrogen, product kg per hectare 434.8, total product kg 4,348, supplied …, balance … ammonium nitrate 34.5-0-0 for 150 kg N/ha on 10 ha
  • fertiliser_application(nitrogen 20, phosphate 10, potash 10, nitrogen 100, phosphate 60, potash 60, 1) → limiting nutrient nitrogen, product kg per hectare 500, total product kg 500, supplied …, balance … a 20-10-10 compound: nitrogen limits at 500 kg/ha and P and K fall 10 kg short
  • fertiliser_application(nitrogen 0, phosphate 20, potash 30, nitrogen 0, phosphate 50, potash 90, 1) → limiting nutrient phosphate, product kg per hectare 250, total product kg 250, supplied …, balance … a 0-20-30 PK: phosphate limits, potash falls 15 kg short

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn fertiliser_application(analysis: &NutrientAnalysis, targets: &NutrientTargets, area_hectares: f64) -> FertiliserPlan
analysisNutrientAnalysisthe product's declared N-P2O5-K2O, e.g. 20-10-10
targetsNutrientTargetskg/ha of each nutrient wanted; 0 for a nutrient not being applied
area_hectaresfloatarea to be spread, taken to 0.0001 ha
returnsFertiliserPlan

The types it declares, generated into your project

/// A product's analysis, percent by weight, each taken to 0.1%.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NutrientAnalysis {
    /// N %
    pub nitrogen: f64,
    /// P2O5 % (phosphate, not elemental P)
    pub phosphate: f64,
    /// K2O % (potash, not elemental K)
    pub potash: f64,
}

/// Nutrients wanted, whole kg/ha, 0 to 1000.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NutrientTargets {
    /// N
    pub nitrogen: i64,
    /// P2O5
    pub phosphate: i64,
    /// K2O
    pub potash: i64,
}

/// Nutrients in kg/ha, to 0.1 kg.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NutrientSupply {
    pub nitrogen: f64,
    pub phosphate: f64,
    pub potash: f64,
}

// Nutrient is a string in Rust, one of: "nitrogen", "phosphate", "potash".
// Parameters take it as &str and results hold it as String.

/// The rate that meets the limiting nutrient's target, and what it supplies.
#[derive(Debug, Clone, PartialEq)]
pub struct FertiliserPlan {
    /// the targeted nutrient needing the least product
    pub limiting_nutrient: String,
    /// to 0.1 kg/ha, half up
    pub product_kg_per_hectare: f64,
    /// the rounded rate x the area, to 0.1 kg, half up
    pub total_product_kg: f64,
    /// at the rounded rate
    pub supplied: NutrientSupply,
    /// supplied less target: negative is a shortfall to make up elsewhere
    pub balance: NutrientSupply,
}

Your code names it in one line, in the file that uses it

fune!(agri.fertiliser-application@^1);  // then call fertiliser_application(…)
impl/rust.rs · 123 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

const NUTRIENTS: [&str; 3] = ["nitrogen", "phosphate", "potash"];

fn tenths_of_percent(name: &str, value: f64) -> i64 {
    if !value.is_finite() || value < 0.0 || value > 100.0 {
        panic!("analysis {} must be a finite percentage from 0 to 100, received {}", name, value);
    }
    (round_float(value, 1) * 10.0).round() as i64
}

/// Product rate for a fertiliser, set by the limiting nutrient.
///
/// For each targeted nutrient the product needed is target / analysis; the
/// rate is the smallest of those, so no nutrient is applied beyond its target,
/// and the others show as a shortfall. The arithmetic is exact in tenths (of a
/// percent and of a kilogram) and the rate is rounded once, half up.
///
/// # Panics
/// Panics on an analysis, target or area out of range, no target, or a
/// product with none of the targeted nutrients.
pub fn fertiliser_application(analysis: &NutrientAnalysis, targets: &NutrientTargets, area_hectares: f64) -> FertiliserPlan {
    let a = [
        tenths_of_percent("nitrogen", analysis.nitrogen),
        tenths_of_percent("phosphate", analysis.phosphate),
        tenths_of_percent("potash", analysis.potash),
    ];
    if a[0] + a[1] + a[2] > 1000 {
        panic!("analysis must not total more than 100%");
    }
    let t = [targets.nitrogen, targets.phosphate, targets.potash];
    for (i, v) in t.iter().enumerate() {
        if *v < 0 || *v > 1000 {
            panic!("target {} must be a whole number of kg/ha from 0 to 1000, received {}", NUTRIENTS[i], v);
        }
    }
    if !area_hectares.is_finite() || area_hectares <= 0.0 || area_hectares > 10000.0 {
        panic!("areaHectares must be a finite number greater than 0 and at most 10000, received {}", area_hectares);
    }
    let area = (round_float(area_hectares, 4) * 10000.0).round() as i64;
    if area < 1 {
        panic!("areaHectares must be at least 0.0001 hectares, received {}", area_hectares);
    }
    if t[0] + t[1] + t[2] == 0 {
        panic!("at least one target must be above zero");
    }

    // Least product: target_i / a_i smallest, compared by cross-multiplying.
    let mut limit: Option<usize> = None;
    for i in 0..3 {
        if t[i] == 0 || a[i] == 0 {
            continue;
        }
        match limit {
            Some(l) if t[i] * a[l] >= t[l] * a[i] => {}
            _ => limit = Some(i),
        }
    }
    let limit = match limit {
        Some(l) => l,
        None => panic!("the product contains none of the targeted nutrients"),
    };

    // kg/ha in tenths = target x 100 / (a / 10) x 10.
    let rate = round_div(t[limit] * 10000, a[limit], "half-up");
    let s: Vec<i64> = a.iter().map(|ai| round_div(rate * ai, 1000, "half-up")).collect();
    FertiliserPlan {
        limiting_nutrient: NUTRIENTS[limit].to_string(),
        product_kg_per_hectare: rate as f64 / 10.0,
        total_product_kg: round_div(rate * area, 10000, "half-up") as f64 / 10.0,
        supplied: NutrientSupply {
            nitrogen: s[0] as f64 / 10.0,
            phosphate: s[1] as f64 / 10.0,
            potash: s[2] as f64 / 10.0,
        },
        balance: NutrientSupply {
            nitrogen: (s[0] - t[0] * 10) as f64 / 10.0,
            phosphate: (s[1] - t[1] * 10) as f64 / 10.0,
            potash: (s[2] - t[2] * 10) as f64 / 10.0,
        },
    }
}

pub fn nutrient_supply_to_value(s: &NutrientSupply) -> Value {
    Value::obj(vec![
        ("nitrogen", Value::Float(s.nitrogen)),
        ("phosphate", Value::Float(s.phosphate)),
        ("potash", Value::Float(s.potash)),
    ])
}

pub fn fertiliser_plan_to_value(p: &FertiliserPlan) -> Value {
    Value::obj(vec![
        ("limitingNutrient", Value::str(&p.limiting_nutrient)),
        ("productKgPerHectare", Value::Float(p.product_kg_per_hectare)),
        ("totalProductKg", Value::Float(p.total_product_kg)),
        ("supplied", nutrient_supply_to_value(&p.supplied)),
        ("balance", nutrient_supply_to_value(&p.balance)),
    ])
}

fn target(v: &Value, name: &str) -> i64 {
    match v {
        Value::Int(i) => *i,
        other => panic!("target {} must be a whole number of kg/ha from 0 to 1000, received {:?}", name, other),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let analysis = NutrientAnalysis {
        nitrogen: args[0].get("nitrogen").as_f64(),
        phosphate: args[0].get("phosphate").as_f64(),
        potash: args[0].get("potash").as_f64(),
    };
    let targets = NutrientTargets {
        nitrogen: target(args[1].get("nitrogen"), "nitrogen"),
        phosphate: target(args[1].get("phosphate"), "phosphate"),
        potash: target(args[1].get("potash"), "potash"),
    };
    fertiliser_plan_to_value(&fertiliser_application(&analysis, &targets, args[2].as_f64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add agri.fertiliser-application
Download for Rust agri.fertiliser-application-1.0.0-rust.fune · 20,385 bytes sha256 9ee2c5cd2f8fbf375b05dfa6d9cb366a6a26ad8f4dd27709e532b561bacc35e4

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./agri.fertiliser-application-1.0.0-rust.fune, or fetch it from a terminal with fune pull agri.fertiliser-application@1.0.0:rust.

The whole function, every language, is one file too: agri.fertiliser-application-1.0.0.fune, 26,867 bytes, sha256 3ce76b62cdc963f22dbd95631bbdc5b4569cb9f3ad4cb1e2ea7328c16063f7a3. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before agri.fertiliser-application

after — your function gets the result and the arguments, and returns the final result.

// fune: after agri.fertiliser-application

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace math.round-div in agri.fertiliser-application
// fune: replace math.round-float in agri.fertiliser-application

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show agri.fertiliser-application --steps.

// fune: step agri.fertiliser-application after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
ammonium nitrate 34.5-0-0 for 150 kg N/ha on 10 ha nitrogen 34.5, phosphate 0, potash 0, nitrogen 150, phosphate 0, potash 0, 10 → limiting nutrient nitrogen, product kg per hectare 434.8, total product kg 4,348, supplied …, balance …
a 20-10-10 compound: nitrogen limits at 500 kg/ha and P and K fall 10 kg short nitrogen 20, phosphate 10, potash 10, nitrogen 100, phosphate 60, potash 60, 1 → limiting nutrient nitrogen, product kg per hectare 500, total product kg 500, supplied …, balance …
a 0-20-30 PK: phosphate limits, potash falls 15 kg short nitrogen 0, phosphate 20, potash 30, nitrogen 0, phosphate 50, potash 90, 1 → limiting nutrient phosphate, product kg per hectare 250, total product kg 250, supplied …, balance …
a tie goes to the first nutrient in N, P, K order nitrogen 0, phosphate 24, potash 24, nitrogen 0, phosphate 60, potash 60, 1 → limiting nutrient phosphate, product kg per hectare 250, total product kg 250, supplied …, balance …
a targeted nutrient the product lacks does not limit; it is a shortfall nitrogen 34.5, phosphate 0, potash 0, nitrogen 100, phosphate 40, potash 0, 1 → limiting nutrient nitrogen, product kg per hectare 289.9, total product kg 289.9, supplied …, balance …
urea 46-0-0 for 40 kg N: 86.96 kg rounds to 87.0 and supplies 40.0 nitrogen 46, phosphate 0, potash 0, nitrogen 40, phosphate 0, potash 0, 1 → limiting nutrient nitrogen, product kg per hectare 87, total product kg 87, supplied …, balance …
a rate on the half tenth rounds up: 1 kg N from a 16% product is 6.25, set to 6.3 nitrogen 16, phosphate 0, potash 0, nitrogen 1, phosphate 0, potash 0, 1 → limiting nutrient nitrogen, product kg per hectare 6.3, total product kg 6.3, supplied …, balance …
the total is the rounded rate over a part-hectare field nitrogen 20, phosphate 10, potash 10, nitrogen 100, phosphate 60, potash 60, 2.5 → limiting nutrient nitrogen, product kg per hectare 500, total product kg 1,250, supplied …, balance …
potash limits a 15-15-15 when K is the smallest need per unit nitrogen 15, phosphate 15, potash 15, nitrogen 120, phosphate 60, potash 30, 4 → limiting nutrient potash, product kg per hectare 200, total product kg 800, supplied …, balance …
the analysis is taken to 0.1%: 34.54 is 34.5 nitrogen 34.54, phosphate 0, potash 0, nitrogen 150, phosphate 0, potash 0, 10 → limiting nutrient nitrogen, product kg per hectare 434.8, total product kg 4,348, supplied …, balance …
Show the other 6 tests
CaseArgumentsExpected
no targets is an error nitrogen 20, phosphate 10, potash 10, nitrogen 0, phosphate 0, potash 0, 1 → error: at least one target must be above zero
a product with none of the targeted nutrients is an error nitrogen 0, phosphate 20, potash 30, nitrogen 100, phosphate 0, potash 0, 1 → error: the product contains none of the targeted nutrients
an analysis over 100% is an error nitrogen 50, phosphate 30, potash 21, nitrogen 100, phosphate 0, potash 0, 1 → error: analysis must not total more than 100%
a negative analysis is an error nitrogen -1, phosphate 0, potash 0, nitrogen 100, phosphate 0, potash 0, 1 → error: analysis nitrogen must be a finite percentage from 0 to 100
a fractional target is an error nitrogen 34.5, phosphate 0, potash 0, nitrogen 100.5, phosphate 0, potash 0, 1 → error: target nitrogen must be a whole number of kg/ha from 0 to 1000
zero area is an error nitrogen 34.5, phosphate 0, potash 0, nitrogen 100, phosphate 0, potash 0, 0 → error: areaHectares must be a finite number greater than 0

Lint allowances

Before anything is published, Functional Weave reads every implementation and refuses code that reaches outside the function. A few of its rules can be waived on one line, with a reason. This version is published despite 2 such findings. A registry reviewer read each one its publisher annotated (fune-allow) before it went out; one marked reviewed was accepted by the registry’s maintainers. Read the lines before you build it in.

WhereRuleReasonAllowed by
impl/python.py:25 dynamic-attribute reads the dataclass fields named in the constant NUTRIENTS ("nitrogen", "phosphate", "potash"); no name comes from input
getattr() with a computed attribute name can reach any attribute (__class__, __globals__, ...); name the attribute (obj.field), or annotate it with fune-allow dynamic-attribute and the reason
reviewed by the registry
impl/python.py:30 dynamic-attribute reads the dataclass fields named in the constant NUTRIENTS ("nitrogen", "phosphate", "potash"); no name comes from input
getattr() with a computed attribute name can reach any attribute (__class__, __globals__, ...); name the attribute (obj.field), or annotate it with fune-allow dynamic-attribute and the reason
reviewed by the registry

More from the author

## Limiting nutrient first

For each nutrient with a target above zero and a non-zero analysis, the product needed is target / (analysis %) x 100 kg/ha. The rate chosen is the **smallest** of those, so no nutrient goes on beyond its target; that nutrient is `limitingNutrient`. The others show in `balance` as a shortfall (negative) to make up with a straight product. A targeted nutrient the product does not contain cannot limit the rate and is simply a shortfall. A tie goes to the first in N, P, K order.

## Units and rounding

- Analysis is the declared percentage by weight of N, P2O5 and K2O (the oxide forms UK labels and RB209 use, not elemental P and K), each taken to 0.1%. - Targets are whole kg/ha. - The rate is target x 1000 / (analysis in tenths of a percent), exact, then rounded half up to 0.1 kg/ha: 1 kg N from a 16% product is 6.25, set to 6.3. - `supplied` is computed from that rounded rate (what the spreader delivers), to 0.1 kg/ha, and `balance` is supplied minus target. The limiting nutrient's balance can therefore be a tenth or so either side of zero. - `totalProductKg` is the rounded rate x the area (taken to 0.0001 ha), rounded half up to 0.1 kg. No allowance for headlands, overlaps or bag sizes.

## Limits

Each analysis figure 0 to 100%, together at most 100%; targets 0 to 1000 kg/ha, at least one above zero; area more than 0 and at most 10,000 ha.

Files

PathBytes
README.md2,014
impl/python.py3,204
impl/rust.rs4,857
impl/typescript.ts3,050
vectors.json8,049