Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f9a6e48
Add option to select fertilizer catalogue application amount unit
BoraIneviNMI Apr 8, 2026
a30916f
Adapt fdm-app to use the new fertilizer application amount display va…
BoraIneviNMI Apr 8, 2026
3ce3f81
Add changeset
BoraIneviNMI Apr 8, 2026
a49a207
Merge branch 'development' into FDM520
BoraIneviNMI Apr 8, 2026
c3e20c9
Address nitpicks
BoraIneviNMI Apr 10, 2026
ae03041
Add fertilizer application units to the bemestingsplan PDF
BoraIneviNMI Apr 10, 2026
9990a93
Use hardcoded table for RVO code unit suggestions
BoraIneviNMI Apr 13, 2026
b6724d3
Add extendCatalogueFertilizer and improve coverage
BoraIneviNMI Apr 13, 2026
e012719
Merge branch 'development' into FDM520
BoraIneviNMI Apr 13, 2026
bdb0b20
Address nitpicks
BoraIneviNMI Apr 14, 2026
57f9802
Remove unused import
BoraIneviNMI Apr 14, 2026
81c97fc
Move unit conversions in Gerrit
BoraIneviNMI Apr 14, 2026
2cd07c9
Fix type errors in fdm-calculator
BoraIneviNMI Apr 14, 2026
708bfe9
Do not use path alias in fdm-calculator
BoraIneviNMI Apr 14, 2026
4097f6b
Improve things
BoraIneviNMI Apr 14, 2026
53d774a
Add fertilizer RVO code 120
BoraIneviNMI Apr 14, 2026
e367153
Reject non-positive density values in fromKgPerHa
BoraIneviNMI Apr 14, 2026
945e92c
Add a way to specify fertilizer amounts directly in kg/ha
BoraIneviNMI Apr 14, 2026
2f53d99
Fix type errors
BoraIneviNMI Apr 14, 2026
0b4107b
Combine CalculatorFertilizerApplication and IncompleteFertilizerAppli…
BoraIneviNMI Apr 14, 2026
68f884f
Make stuff consistent
BoraIneviNMI Apr 15, 2026
6b0251d
Address nitpicks
BoraIneviNMI Apr 16, 2026
62d302a
Remove the way to specify fertilizer application amount unit
BoraIneviNMI Apr 17, 2026
2d1e863
Rename unit-conversion module and add more tests
BoraIneviNMI Apr 17, 2026
67c5d6e
Merge branch 'development' into FDM520
SvenVw Apr 17, 2026
91ffed3
fix: use lowercase for liters
SvenVw Apr 17, 2026
1ded75c
refactor: at the unit conversion return number instead of decimal
SvenVw Apr 17, 2026
8ae1387
docs: improve typedocs for fertilizer application unit conversion fun…
SvenVw Apr 17, 2026
45ba4c6
refactor: remove functio to guess unit of fertilizer
SvenVw Apr 17, 2026
a922790
feat: Add default values for p_app_amount_unit to baat catalogue
SvenVw Apr 17, 2026
30dfb1b
refactor: remove some left over code
SvenVw Apr 17, 2026
3cbec2c
refactor: use lowecase for liter
SvenVw Apr 17, 2026
e9c797d
refactor: some type improvements
SvenVw Apr 17, 2026
f8502b1
refactor: improve text
SvenVw Apr 17, 2026
deb98d2
refactor: make sure gerrit uses display units
SvenVw Apr 17, 2026
f70fff3
fix: enable to update the unit of fertilzier
SvenVw Apr 17, 2026
5fae90e
fix: zod for p_amount_app_display
SvenVw Apr 17, 2026
fe7a0ec
tests: remove not used tests
SvenVw Apr 17, 2026
2ba6be3
tests: fix
SvenVw Apr 17, 2026
60eac40
fix: type issue
SvenVw Apr 17, 2026
16e9f40
tests: fixes
SvenVw Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-candies-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-data": minor
---

Add default values for p_app_amount_unit to baat catalogue
5 changes: 5 additions & 0 deletions .changeset/late-cycles-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-app": minor
---

Fertilizer application amounts are now shown in units relevant to the fertilizer type used, possibly saving the user from converting application amounts to kg/ha all the time.
5 changes: 5 additions & 0 deletions .changeset/ninety-sloths-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-core": minor
---

addFertilizerApplication and updateFertilizerApplication functions now expect an application value in the unit defined for the fertilizer, instead of kg/ha for every fertilizer. The unit is included in the return values of `getFertilizer`, `getFertilizers` etc. as `p_app_amount_unit`. `getFertilizerApplication`, `getFertilizerApplications` etc. now include `p_app_amount_display` and `p_app_amount_unit` which are to be shown to the user instead of p_app_amount and `kg/ha`.
34 changes: 27 additions & 7 deletions fdm-agents/src/agents/gerrit/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ IMPORTANT CONSTRAINTS:
6. APPLICATION METHOD: For each application, you must propose a valid "p_app_method". Choose ONLY from the "p_app_method_options" returned by the search tool for that specific fertilizer.
7. REALISTIC DATES: Ensure all "p_app_date" values are realistic for the crop type, cultivation season, and Dutch climate. Use the provided "b_lu_start" (sowing/start date) as a critical reference point for each crop.
8. REALISTIC APPLICATION AMOUNTS: Ensure the proposed "p_app_amount" per application matches the technical capabilities of common farming equipment. If the total advice requires more, you MUST split it into multiple applications on different dates.
- slurry (drijfmest): 15,000 - 30,000 kg/ha per application (15-30 m³/ha).
- Solid manure / compost (vaste mest): 10,000 - 30,000 kg/ha per application (10-30 t/ha).
- slurry (drijfmest): 15-30 m³/ha per application.
- Solid manure / compost (vaste mest): 10-30 t/ha per application.
- Mineral fertilizers: 50 - 450 kg/ha per application.
- Liquid mineral fertilizers (oplossing): 10 - 1000 l/ha per application.
9. PRIORITIZATION: If legal norms (especially Nitrogen or Phosphate) limit the total nutrient space on the farm, prioritize fulfilling the nutrient advice for high-value crops (e.g., potatoes, onions, sugar beets, vegetables) over lower-value crops or grasslands. Strategy should focus on maximizing the economic return of the limited nutrient space.
10. ORGANIC FARMING: If "Organic Farming" is YES, you MUST NOT use any mineral fertilizers ("p_type": "mineral") in the plan.
11. MANURE FILLING STRATEGY:
Expand Down Expand Up @@ -83,7 +84,14 @@ Your final response MUST be a JSON object with exactly this structure (all field
{
"b_id": "string",
"applications": [
{ "p_id_catalogue": "string", "p_app_amount": number, "p_app_date": "YYYY-MM-DD", "p_app_method": "string" }
{
"p_id_catalogue": "string",
"p_app_amount": number,
"p_app_amount_display": number,
"p_app_amount_unit": "kg/ha" | "l/ha" | "t/ha" | "m3/ha",
"p_app_date": "YYYY-MM-DD",
"p_app_method": "string"
}
],
"fieldMetrics": {
"advice": {
Expand Down Expand Up @@ -137,15 +145,27 @@ CALCULATOR REFERENCE (units and semantics for the simulation tool):
- "omBalance" (organische stofbalans): net organic matter balance, kg EOM/ha. Positive = good. Aim for ≥ 0.
- "nBalance": nitrogen balance structured exactly as fdm-calculator outputs. "nBalance.balance" and "nBalance.target" are in kg N/ha. "nBalance.emission.ammonia.total" and "nBalance.emission.nitrate.total" are also in kg N/ha. The farm-level averages are automatically area-weighted by the simulation tool. nBalance.balance must be ≤ nBalance.target if keepNitrogenBalanceBelowTarget is YES.
- "p_app_amount": application amount — **always in kg/ha, regardless of fertilizer type**.
- Liquid manure / digestate / slurry: convert m³/ha → kg/ha using 1 m³ = 1000 kg. Round to nearest 1000. Example: 18 m³/ha = 18000 kg/ha.
- Solid manure / compost: convert t/ha → kg/ha using 1 t = 1000 kg. Round to nearest 1000. Example: 20 t/ha = 20000 kg/ha.
- Mineral fertilizers: already in kg/ha, round to nearest 5 or 10. Example: 200 kg/ha KAS.
- Propose a round number for the native display unit (e.g., 25 m³/ha, 20 t/ha, 300 l/ha, 200 kg/ha) and then convert to kg/ha for the tools.
- Liquid manure / digestate / slurry: convert m³/ha → kg/ha using: 1 m³/ha = 1000 * density (kg/l).
Example: 25 m³/ha with density 1.005 kg/l = 25 * 1000 * 1.005 = 25125 kg/ha.
- Solid manure / compost: convert t/ha → kg/ha using: 1 t/ha = 1000 kg/ha.
Example: 20 t/ha = 20000 kg/ha.
- Liquid mineral fertilizers (e.g. Ammoniumnitraatureanoplossing): convert l/ha → kg/ha using: 1 l/ha = density (kg/l).
Example: 300 l/ha with density 1.2 kg/l = 300 * 1.2 = 360 kg/ha.
- Solid mineral fertilizers: already in kg/ha, round to nearest 5 or 10.
Example: 200 kg/ha KAS.
- "p_app_amount_display": application amount formatted with its native unit — **use this for the user-facing plan and summary**.
- Use the "p_app_amount_unit" and "p_density" (if needed for volume-to-mass conversion) from the search tool results to determine the correct unit.
- Liquid manure / slurry: e.g., "18 m³/ha".
- Solid manure / compost: e.g., "20 t/ha".
- Mineral fertilizers: e.g., "200 kg/ha".
- Liquid mineral fertilizers: e.g., "300 l/ha".
- "p_ef_nh3": ammonia emission factor (fraction of N applied lost as NH3). Lower = less emission.
TOOL RETURN SHAPES:
- "getFarmFields" returns { fields: [...] } — access the array via result.fields. Each field includes main cultivation details (b_lu_catalogue, b_lu_name, b_lu_start).
- "getFarmNutrientAdvice" returns { advicePerField: [...] } — access via result.advicePerField
- "getFarmLegalNorms" returns { normsPerField: [...] } — access via result.normsPerField
- "searchFertilizers" returns { fertilizers: [...] } — access via result.fertilizers
- "searchFertilizers" returns { fertilizers: [...] } — access via result.fertilizers. Each fertilizer includes "p_app_amount_unit" and "p_density".
- "simulateFarmPlan" returns { fieldResults: [...], farmTotals: {...}, isValid: bool, complianceIssues: [...], agronomicWarnings: [...] }.
Each entry in "fieldResults" has: { b_id, b_area, isValid, fieldMetrics: { normsFilling: { manure, nitrogen, phosphate }, norms: { manure, nitrogen, phosphate }, proposedDose: { p_dose_n, p_dose_nw, p_dose_p, p_dose_k, p_dose_s, p_dose_mg, p_dose_ca, p_dose_na, p_dose_cu, p_dose_zn, p_dose_b, p_dose_mn, p_dose_mo, p_dose_co }, omBalance, nBalance, advice } }.
Use "proposedDose.p_dose_nw" (werkzame stikstof, kg/ha) to compare against "advice.d_n_req" — this is the agronomically correct workable-N value. "proposedDose.p_dose_n" is total N and is provided for reference only.
Expand Down
14 changes: 14 additions & 0 deletions fdm-agents/src/tools/fertilizer-planner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ export function createFertilizerPlannerTools(fdm: FdmType) {
p_eom: f.p_eom,
p_ef_nh3: f.p_ef_nh3,
p_source: f.p_source,
p_app_amount_unit: f.p_app_amount_unit,
p_density: f.p_density,
})),
}
},
Expand Down Expand Up @@ -371,6 +373,18 @@ export function createFertilizerPlannerTools(fdm: FdmType) {
p_app_amount: z
.number()
.describe("Application amount in kg/ha"),
p_app_amount_unit: z
.string()
.optional()
.describe(
"The unit of the application amount (e.g., m3/ha, kg/ha, l/ha, t/ha)",
),
p_app_amount_display: z
.number()
.optional()
.describe(
"The numeric application amount (unit is carried separately in p_app_amount_unit)",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
p_app_date: z
.string()
.describe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,16 @@ function formatDateRange(dates: Date[]) {
* @param numbers array of numbers. Nulls and undefined items are not allowed.
* @returns the formatted string.
*/
function formatNumberRange(numbers: number[], unit = "") {
function formatNumberRange(numbers: number[], unit = "", precision = 2) {
if (numbers.length === 0) return ""
const firstNumber = numbers[0]
const lastNumber = numbers[numbers.length - 1]
const pow = 10 ** precision
const round = (x: number) => Math.round(pow * x) / pow
return firstNumber === lastNumber ||
Math.abs(lastNumber - firstNumber) < Math.abs(firstNumber) / 100
? `${firstNumber} ${unit}`
: `${firstNumber} - ${lastNumber} ${unit}`
? `${round(firstNumber)} ${unit}`
: `${round(firstNumber)} - ${round(lastNumber)} ${unit}`
}

/**
Expand Down Expand Up @@ -163,10 +165,10 @@ export const columns: ColumnDef<FertAppRecordItem>[] = [
cell: ({ row }) =>
formatNumberRange(
row.original.applications
.map((application) => application.p_app_amount)
.filter((amount) => amount !== null)
.map((application) => application.p_app_amount_display)
.filter((amount) => amount !== null && amount !== undefined)
.sort((a, b) => a - b),
"kg / ha",
row.original.applications[0]?.p_app_amount_unit ?? "kg/ha",
),
},
{
Expand Down
52 changes: 35 additions & 17 deletions fdm-app/app/components/blocks/fertilizer-applications/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Navigation } from "react-router"
import { Form, useNavigate, useSearchParams } from "react-router"
import { RemixFormProvider, useRemixForm } from "remix-hook-form"
import { useFieldFertilizerFormStore } from "@/app/store/field-fertilizer-form"
import { getApplicationAmountUnitLabel } from "~/components/blocks/fertilizer-applications/utils"
import { Combobox } from "~/components/custom/combobox"
import { DatePicker } from "~/components/custom/date-picker-v2"
import { Button } from "~/components/ui/button"
Expand Down Expand Up @@ -36,12 +37,7 @@ import {
FormSchemaModify,
type FormSchemaPartial,
} from "./formschema"

export type FertilizerOption = {
value: string
label: string
applicationMethodOptions?: { value: string; label: string }[]
}
import type { FertilizerOption } from "./types.d"

/**
* Renders a fertilizer application creation or modification form.
Expand Down Expand Up @@ -92,7 +88,7 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({
: fertilizerApplication?.p_app_id,
p_id: fertilizerApplication?.p_id,
p_app_method: fertilizerApplication?.p_app_method,
p_app_amount: undefined, // Handled through an effect due to blank behavior
p_app_amount_display: undefined, // Handled through an effect due to blank behavior
p_app_date: fertilizerApplication?.p_app_date
? fertilizerApplication.p_app_date
: exampleFertilizerApplication
Expand All @@ -107,12 +103,17 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({
const selectedFertilizer = options.find((option) => option.value === p_id)
const isSubmitting = navigation.state !== "idle"

// If the user switched the fertilizer, clear the application method and amount
useEffect(() => {
if (
p_id &&
(!fertilizerApplication || fertilizerApplication.p_id !== p_id)
) {
form.setValue("p_app_method", "")
form.setValue(
"p_app_amount_display",
undefined as unknown as number,
)
}
}, [p_id, fertilizerApplication, form.setValue])

Expand All @@ -138,6 +139,7 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({

const fieldFertilizerFormStore = useFieldFertilizerFormStore()

// If the user had a saved fertilizer form and was creating a new fertilizer, fill the form back in
useEffect(() => {
if (b_id_farm && b_id_or_b_lu_catalogue) {
const savedFormValues = fieldFertilizerFormStore.load(
Expand Down Expand Up @@ -165,11 +167,17 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({
])

useEffect(() => {
const p_app_amount = fertilizerApplication?.p_app_amount
if (p_app_amount !== null && typeof p_app_amount !== "undefined") {
form.setValue("p_app_amount", p_app_amount)
const p_app_amount_display = fertilizerApplication?.p_app_amount_display
if (
p_app_amount_display !== null &&
typeof p_app_amount_display !== "undefined"
) {
form.setValue(
"p_app_amount_display",
Math.round(100 * p_app_amount_display) / 100,
)
}
}, [fertilizerApplication?.p_app_amount, form.setValue])
}, [fertilizerApplication?.p_app_amount_display, form.setValue])

// Change fertilizer selection if the user has added a new fertilizer
const new_p_id = searchParams.get("p_id")
Expand Down Expand Up @@ -209,6 +217,12 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({
)
}

const currentApplicationUnit =
options.find((opt) => opt.value === p_id)?.p_app_amount_unit ?? "kg/ha"
const currentApplicationUnitLabel = getApplicationAmountUnitLabel(
currentApplicationUnit,
)

return (
<RemixFormProvider {...form}>
<Form
Expand Down Expand Up @@ -293,21 +307,25 @@ export function FertilizerApplicationForm<T extends typeof FormSchemaPartial>({
)}
/>
<Controller
name="p_app_amount"
name="p_app_amount_display"
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="gap-1"
>
<FieldLabel>Hoeveelheid</FieldLabel>
<FieldLabel>
Hoeveelheid (
{currentApplicationUnitLabel})
</FieldLabel>
<Input
{...field}
placeholder={
Number.isFinite(
exampleFertilizerApplication?.p_app_amount,
)
? `Er zijn verschillende waarden ingevuld, bv: ${exampleFertilizerApplication?.p_app_amount} kg / ha`
: "Bv. 37500 kg / ha"
exampleFertilizerApplication?.p_app_amount_display,
) &&
fertilizerApplication?.p_id === p_id
? `Er zijn verschillende waarden ingevuld, bv: ${exampleFertilizerApplication?.p_app_amount_display} ${currentApplicationUnitLabel}`
: `Bv. ${({ "kg/ha": "3700", "ton/ha": "3.7", "l/ha": "3700", "m3/ha": "3.7" } as const)[currentApplicationUnit]} ${currentApplicationUnitLabel}`
}
aria-required="true"
aria-invalid={fieldState.invalid}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod"

const fields = {
p_app_amount: z.preprocess(
p_app_amount_display: z.preprocess(
(val) => (typeof val === "string" && val !== "" ? Number(val) : val),
z
.number({
Expand Down
13 changes: 11 additions & 2 deletions fdm-app/app/components/blocks/fertilizer-applications/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,17 @@ export function FertilizerApplicationsList({
</ItemTitle>
<ItemDescription>
<p>
{application.p_app_amount} kg /
ha
{application.p_app_amount_display !==
null &&
application.p_app_amount_display !==
undefined
? `${application.p_app_amount_display} ${application.p_app_amount_unit}`
: application.p_app_amount !==
null &&
application.p_app_amount !==
undefined
? `${application.p_app_amount} kg/ha`
: "Onbekend hoeveelheid"}
</p>
<p className="text-xs text-muted-foreground">
{application.p_app_method
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AppAmountUnit } from "@nmi-agro/fdm-core"
import type { ApplicationMethods } from "@nmi-agro/fdm-data"

export interface FertilizerApplication {
Expand All @@ -16,6 +17,7 @@ export interface FertilizerOption {
value: ApplicationMethods
label: string
}[]
p_app_amount_unit: AppAmountUnit
}

export interface FertilizerApplicationsFormProps {
Expand Down
26 changes: 26 additions & 0 deletions fdm-app/app/components/blocks/fertilizer-applications/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { AppAmountUnit } from "@nmi-agro/fdm-core"

export const applicationUnitOptions = {
"kg/ha": { label: "kg/ha", totalLabel: "kg" },
"ton/ha": { label: "ton/ha", totalLabel: "ton" },
"l/ha": { label: "l/ha", totalLabel: "L" },
"m3/ha": { label: "m³/ha", totalLabel: "m³" },
} as const

/**
* Get the pretty-printed mass or volume per area unit for fertilizer applications
* @param unit unit to get the label for
* @returns the pretty-printed unit
*/
export function getApplicationAmountUnitLabel(unit: AppAmountUnit) {
return applicationUnitOptions[unit].label
}

/**
* Gets the pretty-printed mass or volume unit for fertilizer applications (for when taking sum of areas times application amounts)
* @param unit unit to get the corresponding unit for
* @returns the pretty-printed corresponding mass or volume unit
*/
export function getApplicationAmountTotalUnitLabel(unit: AppAmountUnit) {
return applicationUnitOptions[unit].totalLabel
}
5 changes: 4 additions & 1 deletion fdm-app/app/components/blocks/fertilizer/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ export function FertilizerForm({
key={option.value}
value={option.value}
>
{`${option.label} (${option.value})`}
{param.parameter ===
"p_app_amount_unit"
? option.label
: `${option.label} (${option.value})`}
</SelectItem>
))}
</SelectContent>
Expand Down
Loading
Loading