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 hafertiliser_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 shortfertiliser_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.
def fertiliser_application(analysis: NutrientAnalysis, targets: NutrientTargets, area_hectares: float) -> 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 |
| area_hectares | float | area to be spread, taken to 0.0001 ha |
| returns | FertiliserPlan |
The types it declares, generated into your project
@dataclass(frozen=True)
class NutrientAnalysis:
"""A product's analysis, percent by weight, each taken to 0.1%."""
#: N %
nitrogen: float
#: P2O5 % (phosphate, not elemental P)
phosphate: float
#: K2O % (potash, not elemental K)
potash: float
@dataclass(frozen=True)
class NutrientTargets:
"""Nutrients wanted, whole kg/ha, 0 to 1000."""
#: N
nitrogen: int
#: P2O5
phosphate: int
#: K2O
potash: int
@dataclass(frozen=True)
class NutrientSupply:
"""Nutrients in kg/ha, to 0.1 kg."""
nitrogen: float
phosphate: float
potash: float
Nutrient = Literal["nitrogen", "phosphate", "potash"]
@dataclass(frozen=True)
class FertiliserPlan:
"""The rate that meets the limiting nutrient's target, and what it supplies."""
#: the targeted nutrient needing the least product
limiting_nutrient: Nutrient
#: to 0.1 kg/ha, half up
product_kg_per_hectare: float
#: the rounded rate x the area, to 0.1 kg, half up
total_product_kg: float
#: at the rounded rate
supplied: NutrientSupply
#: supplied less target: negative is a shortfall to make up elsewhere
balance: NutrientSupply
Your code names it in one line, in the file that uses it
from fune.agri.fertiliser_application import fertiliser_application # 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 math
from typing import List
from .agri_fertiliser_application_types import FertiliserPlan, NutrientAnalysis, NutrientSupply, NutrientTargets
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .math_round_float import round_float ← from math.round-float ^1.0.0 · built alongside by fune
NUTRIENTS = ("nitrogen", "phosphate", "potash")
def _tenths_of_percent(name: str, value: float) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0 or value > 100:
raise ValueError("analysis %s must be a finite percentage from 0 to 100, received %r" % (name, value))
return int(round(round_float(float(value), 1) * 10))
def fertiliser_application(analysis: NutrientAnalysis, targets: NutrientTargets, area_hectares: float) -> FertiliserPlan:
"""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.
"""
a = [_tenths_of_percent(n, getattr(analysis, n)) for n in NUTRIENTS]
if sum(a) > 1000:
raise ValueError("analysis must not total more than 100%")
t: List[int] = []
for n in NUTRIENTS:
v = getattr(targets, n)
if isinstance(v, bool) or not isinstance(v, int) or v < 0 or v > 1000:
raise ValueError("target %s must be a whole number of kg/ha from 0 to 1000, received %r" % (n, v))
t.append(v)
if (
isinstance(area_hectares, bool)
or not isinstance(area_hectares, (int, float))
or not math.isfinite(area_hectares)
or area_hectares <= 0
or area_hectares > 10000
):
raise ValueError("areaHectares must be a finite number greater than 0 and at most 10000, received %r" % (area_hectares,))
area = int(round(round_float(float(area_hectares), 4) * 10000))
if area < 1:
raise ValueError("areaHectares must be at least 0.0001 hectares, received %r" % (area_hectares,))
if sum(t) == 0:
raise ValueError("at least one target must be above zero")
# Least product: target_i / a_i smallest, compared by cross-multiplying.
limit = -1
for i in range(3):
if t[i] == 0 or a[i] == 0:
continue
if limit < 0 or t[i] * a[limit] < t[limit] * a[i]:
limit = i
if limit < 0:
raise ValueError("the product contains none of the targeted nutrients")
# kg/ha in tenths = target x 100 / (a / 10) x 10.
rate = round_div(t[limit] * 10000, a[limit], "half-up")
s = [round_div(rate * ai, 1000, "half-up") for ai in a]
return FertiliserPlan(
limiting_nutrient=NUTRIENTS[limit],
product_kg_per_hectare=rate / 10,
total_product_kg=round_div(rate * area, 10000, "half-up") / 10,
supplied=NutrientSupply(nitrogen=s[0] / 10, phosphate=s[1] / 10, potash=s[2] / 10),
balance=NutrientSupply(
nitrogen=(s[0] - t[0] * 10) / 10,
phosphate=(s[1] - t[1] * 10) / 10,
potash=(s[2] - t[2] * 10) / 10,
),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./agri.fertiliser-application-1.0.0-python.fune, or fetch it from a terminal with fune pull agri.fertiliser-application@1.0.0:python.
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 |