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
fertiliserApplication(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 hafertiliserApplication(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 shortfertiliserApplication(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.
export function fertiliserApplication(analysis: NutrientAnalysis, targets: NutrientTargets, areaHectares: number): FertiliserPlan
| analysis | NutrientAnalysis | the product's declared N-P2O5-K2O, e.g. 20-10-10 |
| targets | NutrientTargets | kg/ha of each nutrient wanted; 0 for a nutrient not being applied |
| areaHectares | float | area to be spread, taken to 0.0001 ha |
| returns | FertiliserPlan |
The types it declares, generated into your project
/** A product's analysis, percent by weight, each taken to 0.1%. */
export interface NutrientAnalysis {
/** N % */
readonly nitrogen: number;
/** P2O5 % (phosphate, not elemental P) */
readonly phosphate: number;
/** K2O % (potash, not elemental K) */
readonly potash: number;
}
/** Nutrients wanted, whole kg/ha, 0 to 1000. */
export interface NutrientTargets {
/** N */
readonly nitrogen: number;
/** P2O5 */
readonly phosphate: number;
/** K2O */
readonly potash: number;
}
/** Nutrients in kg/ha, to 0.1 kg. */
export interface NutrientSupply {
readonly nitrogen: number;
readonly phosphate: number;
readonly potash: number;
}
export type Nutrient = "nitrogen" | "phosphate" | "potash";
/** The rate that meets the limiting nutrient's target, and what it supplies. */
export interface FertiliserPlan {
/** the targeted nutrient needing the least product */
readonly limitingNutrient: Nutrient;
/** to 0.1 kg/ha, half up */
readonly productKgPerHectare: number;
/** the rounded rate x the area, to 0.1 kg, half up */
readonly totalProductKg: number;
/** at the rounded rate */
readonly supplied: NutrientSupply;
/** supplied less target: negative is a shortfall to make up elsewhere */
readonly balance: NutrientSupply;
}
Your code names it in one line, in the file that uses it
import { fertiliserApplication } from "#fune/agri.fertiliser-application@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
import { type FertiliserPlan, type Nutrient, type NutrientAnalysis, type NutrientTargets } from "./agri_fertiliser_application_types.ts";
const NUTRIENTS: readonly Nutrient[] = ["nitrogen", "phosphate", "potash"];
function tenthsOfPercent(name: string, value: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
throw new RangeError(`analysis ${name} must be a finite percentage from 0 to 100, received ${value}`);
}
return Math.round(roundFloat(value, 1) * 10);
}
/**
* 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.
*/
export function fertiliserApplication(analysis: NutrientAnalysis, targets: NutrientTargets, areaHectares: number): FertiliserPlan {
const a = NUTRIENTS.map((n) => tenthsOfPercent(n, analysis[n]));
if (a[0] + a[1] + a[2] > 1000) throw new RangeError("analysis must not total more than 100%");
const t = NUTRIENTS.map((n) => {
const v = targets[n];
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 1000) {
throw new RangeError(`target ${n} must be a whole number of kg/ha from 0 to 1000, received ${v}`);
}
return v;
});
if (typeof areaHectares !== "number" || !Number.isFinite(areaHectares) || areaHectares <= 0 || areaHectares > 10000) {
throw new RangeError(`areaHectares must be a finite number greater than 0 and at most 10000, received ${areaHectares}`);
}
const area = Math.round(roundFloat(areaHectares, 4) * 10000);
if (area < 1) throw new RangeError(`areaHectares must be at least 0.0001 hectares, received ${areaHectares}`);
if (t[0] + t[1] + t[2] === 0) throw new RangeError("at least one target must be above zero");
// Least product: target_i / a_i smallest, compared by cross-multiplying.
let limit = -1;
for (let i = 0; i < 3; i++) {
if (t[i] === 0 || a[i] === 0) continue;
if (limit < 0 || t[i] * a[limit] < t[limit] * a[i]) limit = i;
}
if (limit < 0) throw new RangeError("the product contains none of the targeted nutrients");
// kg/ha in tenths = target x 100 / (a / 10) x 10.
const rate = roundDiv(t[limit] * 10000, a[limit], "half-up");
const supplied = a.map((ai) => roundDiv(rate * ai, 1000, "half-up"));
return {
limitingNutrient: NUTRIENTS[limit],
productKgPerHectare: rate / 10,
totalProductKg: roundDiv(rate * area, 10000, "half-up") / 10,
supplied: { nitrogen: supplied[0] / 10, phosphate: supplied[1] / 10, potash: supplied[2] / 10 },
balance: {
nitrogen: (supplied[0] - t[0] * 10) / 10,
phosphate: (supplied[1] - t[1] * 10) / 10,
potash: (supplied[2] - t[2] * 10) / 10,
},
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:
fune add agri.fertiliser-application
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./agri.fertiliser-application-1.0.0-typescript.fune, or fetch it from a terminal with fune pull agri.fertiliser-application@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.
| Where | Rule | Reason | Allowed 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
| Path | Bytes |
|---|---|
| README.md | 2,014 |
| impl/python.py | 3,204 |
| impl/rust.rs | 4,857 |
| impl/typescript.ts | 3,050 |
| vectors.json | 8,049 |