agri.fertiliser-application@1.0.0
impl/typescript.ts
3,050 bytes · the TypeScript implementation · view raw
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,
},
};
}