From 5f8c4b8ed4683056384dd570138d1b2cbabaf07b Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:17:10 +0200 Subject: [PATCH 01/22] feat: implement phase 1 for Mineralisatie app --- .../blocks/header/mineralisatie.tsx | 79 ++ .../mineralisatie/data-completeness.tsx | 215 ++++++ .../blocks/mineralisatie/field-list.tsx | 108 +++ .../blocks/mineralisatie/method-selector.tsx | 71 ++ .../mineralisatie/mineralisatie-chart.tsx | 364 +++++++++ .../blocks/mineralisatie/nsupply-kpi.tsx | 329 ++++++++ .../blocks/mineralisatie/skeletons.tsx | 128 ++++ .../app/components/blocks/sidebar/apps.tsx | 1 + .../app/components/blocks/sidebar/farm.tsx | 31 +- .../integrations/mineralisatie.server.test.ts | 314 ++++++++ .../app/integrations/mineralisatie.server.ts | 725 ++++++++++++++++++ ..._id_farm.$calendar.mineralisatie.$b_id.tsx | 300 ++++++++ ...id_farm.$calendar.mineralisatie._index.tsx | 214 ++++++ ...arm.$b_id_farm.$calendar.mineralisatie.tsx | 135 ++++ fdm-app/package.json | 4 +- fdm-app/vitest.config.ts | 11 + pnpm-lock.yaml | 5 +- 17 files changed, 3025 insertions(+), 9 deletions(-) create mode 100644 fdm-app/app/components/blocks/header/mineralisatie.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/field-list.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/method-selector.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/skeletons.tsx create mode 100644 fdm-app/app/integrations/mineralisatie.server.test.ts create mode 100644 fdm-app/app/integrations/mineralisatie.server.ts create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.tsx create mode 100644 fdm-app/vitest.config.ts diff --git a/fdm-app/app/components/blocks/header/mineralisatie.tsx b/fdm-app/app/components/blocks/header/mineralisatie.tsx new file mode 100644 index 000000000..de5afd3a5 --- /dev/null +++ b/fdm-app/app/components/blocks/header/mineralisatie.tsx @@ -0,0 +1,79 @@ +import { ChevronDown } from "lucide-react" +import { NavLink } from "react-router" +import { useCalendarStore } from "@/app/store/calendar" +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" + +type HeaderFieldOption = { + b_id: string + b_name: string | null | undefined +} + +export function HeaderMineralisatie({ + b_id_farm, + b_id, + fieldOptions, +}: { + b_id_farm: string + b_id: string | undefined + fieldOptions: HeaderFieldOption[] +}) { + const calendar = useCalendarStore((state) => state.calendar) + + const selectedField = b_id + ? fieldOptions.find((f) => f.b_id === b_id) + : undefined + + return ( + <> + + + + Mineralisatie + + + + {b_id && ( + <> + + + + + + {selectedField?.b_name ?? + "Kies een perceel"} + + + + + {fieldOptions.map((option) => ( + + + {option.b_name} + + + ))} + + + + + )} + + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx b/fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx new file mode 100644 index 000000000..571dbf057 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx @@ -0,0 +1,215 @@ +import { CircleAlert, CircleX } from "lucide-react" +import { NavLink } from "react-router" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import type { + DataCompleteness, + NSupplyMethod, +} from "~/integrations/mineralisatie.server" + +const PARAM_LABELS: Record = { + a_som_loi: "Organische stof (LOI)", + a_clay_mi: "Kleigehalte", + a_silt_mi: "Siltgehalte", + a_sand_mi: "Zandgehalte", + a_c_of: "Organisch koolstof (OF)", + a_cn_fr: "C/N-verhouding", + a_n_rt: "Totaal stikstof", + a_n_pmn: "PMN (mineraliseerbaar N)", + b_soiltype_agr: "Bodemtype", + a_depth_lower: "Bemonsteringsdiepte", +} + +const PARAM_UNITS: Record = { + a_som_loi: "%", + a_clay_mi: "%", + a_silt_mi: "%", + a_sand_mi: "%", + a_c_of: "g C/kg", + a_cn_fr: "—", + a_n_rt: "mg N/kg", + a_n_pmn: "mg N/kg", + b_soiltype_agr: "", + a_depth_lower: "cm", +} + +const METHOD_LABELS: Record = { + minip: "MINIP", + pmn: "PMN", + century: "Century", +} + +const NMI_SOURCE = "nl-other-nmi" + +function isNmiEstimate(source: string | undefined): boolean { + return source === NMI_SOURCE +} + +interface DataCompletenessProps { + completeness: DataCompleteness + method: NSupplyMethod + b_id_farm: string + b_id: string + calendar: string +} + +export function DataCompletenessCard({ + completeness, + method, + b_id_farm, + b_id, + calendar, +}: DataCompletenessProps) { + const { available, missing, estimated, score } = completeness + + return ( + + +
+ Bodemgegevens + + + + = 80 + ? "default" + : score >= 60 + ? "secondary" + : "destructive" + } + className="cursor-help" + > + {score}% + + + + Aandeel vereiste parameters gemeten via + labanalyse. NMI BodemSchat-schattingen tellen + niet mee. + {missing.length > 0 && ( + + {" "} + {missing.length} parameter + {missing.length > 1 ? "s" : ""} ontbrek + {missing.length > 1 ? "en" : "t"}. + + )} + + + +
+ + Beschikbare parameters voor {METHOD_LABELS[method]}-methode + +
+ +
    + {available.map((item) => { + const isEstimate = isNmiEstimate(item.source) + const unit = PARAM_UNITS[item.param] ?? "" + return ( +
  • +
    +
    + + {PARAM_LABELS[item.param] ?? + item.param} + + {isEstimate && ( + + NMI BodemSchat + + )} +
    + {!isEstimate && item.date && ( + + {new Date( + item.date, + ).toLocaleDateString("nl-NL")} + + )} +
    + + {typeof item.value === "number" + ? item.value.toFixed(2) + : item.value} + {unit && unit !== "—" ? ` ${unit}` : ""} + +
  • + ) + })} + {missing.map((param) => ( +
  • + + + {PARAM_LABELS[param] ?? param} + + + Ontbreekt + +
  • + ))} + {estimated.map((param) => ( +
  • + + + {PARAM_LABELS[param] ?? param} + + + Geschat + +
  • + ))} +
+ + {missing.length > 0 && ( +
+

+ Voeg een bodemanalyse toe voor een nauwkeurigere + berekening. +

+ +
+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/field-list.tsx b/fdm-app/app/components/blocks/mineralisatie/field-list.tsx new file mode 100644 index 000000000..208cf5524 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/field-list.tsx @@ -0,0 +1,108 @@ +import { CircleX } from "lucide-react" +import { NavLink } from "react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import type { NSupplyResult } from "~/integrations/mineralisatie.server" + +interface FieldListProps { + results: NSupplyResult[] + b_id_farm: string + calendar: string +} + +function getCurrentDoy(): number { + const now = new Date() + const startOfYear = new Date(now.getFullYear(), 0, 1) + return ( + Math.ceil( + (now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24), + ) + 1 + ) +} + +export function FieldList({ results, b_id_farm, calendar }: FieldListProps) { + const sorted = [...results].sort((a, b) => b.totalAnnualN - a.totalAnnualN) + const currentDoy = getCurrentDoy() + + return ( + + + + Perceel + + N Levering (kg N/ha) + + % vandaag + + + + {sorted.map((result) => ( + + + + {result.b_name} + + + + {result.error + ? "—" + : Math.round(result.totalAnnualN)} + + + + + + ))} + +
+ ) +} + +function NMineralizedToday({ + result, + currentDoy, +}: { + result: NSupplyResult + currentDoy: number +}) { + if (result.error) { + return ( + + + + ) + } + if (result.data.length === 0 || result.totalAnnualN === 0) { + return + } + + const todayPoint = result.data.reduce((prev, curr) => + Math.abs(curr.doy - currentDoy) < Math.abs(prev.doy - currentDoy) + ? curr + : prev, + ) + const pct = Math.round( + (todayPoint.d_n_supply_actual / result.totalAnnualN) * 100, + ) + + return ( + + {pct}% + + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/method-selector.tsx b/fdm-app/app/components/blocks/mineralisatie/method-selector.tsx new file mode 100644 index 000000000..c8db83043 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/method-selector.tsx @@ -0,0 +1,71 @@ +import { useSearchParams } from "react-router" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import type { NSupplyMethod } from "~/integrations/mineralisatie.server" + +const METHOD_OPTIONS: { + value: NSupplyMethod + label: string + description: string +}[] = [ + { + value: "minip", + label: "MINIP", + description: "Op basis van organische stof", + }, + { + value: "pmn", + label: "PMN", + description: "Op basis van mineraliseerbaar N", + }, + { + value: "century", + label: "Century", + description: "Op basis van organisch koolstof", + }, +] + +interface MethodSelectorProps { + value?: NSupplyMethod +} + +export function MethodSelector({ value }: MethodSelectorProps) { + const [, setSearchParams] = useSearchParams() + + const currentMethod: NSupplyMethod = value ?? "minip" + + function handleChange(method: string) { + setSearchParams( + (prev) => { + prev.set("method", method) + return prev + }, + { preventScrollReset: true }, + ) + } + + return ( + + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx new file mode 100644 index 000000000..93b9c5e34 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx @@ -0,0 +1,364 @@ +"use client" + +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ReferenceLine, + Tooltip, + XAxis, + YAxis, +} from "recharts" +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "~/components/ui/chart" +import type { + NSupplyDataPoint, + NSupplyMethod, +} from "~/integrations/mineralisatie.server" + +const MONTH_DOYS = [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] +const MONTH_LABELS = [ + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dec", +] + +function doyToMonthLabel(doy: number): string { + const idx = MONTH_DOYS.findIndex((d, i) => { + const next = MONTH_DOYS[i + 1] ?? 366 + return doy >= d && doy < next + }) + return MONTH_LABELS[idx] ?? "" +} + +function doyToDate(doy: number, year: number): string { + const date = new Date(year, 0, 1) + date.setDate(doy) + return date.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) +} + +function getCurrentDoy(): number { + const now = new Date() + const start = new Date(now.getFullYear(), 0, 1) + return Math.ceil( + (now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24), + ) + 1 +} + +// ─── Single-series chart (farm overview) ───────────────────────────────────── + +interface FarmMineralisatieChartProps { + data: NSupplyDataPoint[] + year?: number +} + +const farmChartConfig = { + d_n_supply_actual: { + label: "N Levering", + color: "hsl(142, 71%, 45%)", + }, +} satisfies ChartConfig + +export function FarmMineralisatieChart({ + data, + year = new Date().getFullYear(), +}: FarmMineralisatieChartProps) { + const currentDoy = getCurrentDoy() + + return ( + + + + + + + + + + + `${v}`} + tick={{ fontSize: 12 }} + width={48} + label={{ + value: "kg N/ha", + angle: -90, + position: "insideLeft", + offset: 12, + style: { fontSize: 11 }, + }} + /> + { + const doy = ( + payload?.[0]?.payload as { + doy?: number + } + )?.doy + return doy + ? doyToDate(doy, year) + : _label + }} + formatter={(value) => [ + `${Number(value).toFixed(1)} kg N/ha`, + "Cumulatief", + ]} + /> + } + /> + + + + + ) +} + +// ─── Multi-series chart (field detail, 3 methods overlaid) ─────────────────── + +interface FieldDataSeries { + method: NSupplyMethod + data: NSupplyDataPoint[] + error?: string +} + +interface FieldMineralisatieChartProps { + series: FieldDataSeries[] + year?: number +} + +// Distinct colors + dash patterns so methods are always distinguishable +const METHOD_STYLE: Record< + NSupplyMethod, + { color: string; dashArray: string; fillOpacity: number } +> = { + minip: { + color: "hsl(142, 71%, 45%)", + dashArray: "0", + fillOpacity: 0.15, + }, + pmn: { + color: "hsl(221, 83%, 53%)", + dashArray: "6 3", + fillOpacity: 0, + }, + century: { + color: "hsl(25, 95%, 53%)", + dashArray: "2 5", + fillOpacity: 0, + }, +} + +const fieldChartConfig = { + minip: { label: "MINIP", color: METHOD_STYLE.minip.color }, + pmn: { label: "PMN", color: METHOD_STYLE.pmn.color }, + century: { label: "Century", color: METHOD_STYLE.century.color }, +} satisfies ChartConfig + +// Merge data points from multiple series by DOY +function mergeSeriesData( + series: FieldDataSeries[], +): Record[] { + const map = new Map>() + + for (const s of series) { + if (s.error) continue + for (const point of s.data) { + const existing = map.get(point.doy) ?? { doy: point.doy } + existing[s.method] = point.d_n_supply_actual + map.set(point.doy, existing) + } + } + + return Array.from(map.values()).sort( + (a, b) => (a.doy as number) - (b.doy as number), + ) +} + +export function FieldMineralisatieChart({ + series, + year = new Date().getFullYear(), +}: FieldMineralisatieChartProps) { + const currentDoy = getCurrentDoy() + const mergedData = mergeSeriesData(series) + const activeSeries = series.filter((s) => !s.error) + + return ( + + + + {/* Only MINIP gets a fill gradient */} + + + + + + + + `${v}`} + tick={{ fontSize: 12 }} + width={48} + label={{ + value: "kg N/ha", + angle: -90, + position: "insideLeft", + offset: 12, + style: { fontSize: 11 }, + }} + /> + { + const doy = ( + payload?.[0]?.payload as { + doy?: number + } + )?.doy + return doy + ? doyToDate(doy, year) + : _label + }} + formatter={(value, name) => [ + `${Number(value).toFixed(1)} kg N/ha`, + fieldChartConfig[name as NSupplyMethod] + ?.label ?? name, + ]} + /> + } + /> + + {activeSeries.map((s) => { + const style = METHOD_STYLE[s.method] + return ( + + ) + })} + } /> + + + ) +} + +// Re-export helper for routes +export { getCurrentDoy } + diff --git a/fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx b/fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx new file mode 100644 index 000000000..45e76e6de --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx @@ -0,0 +1,329 @@ +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import type { + NSupplyMethod, + NSupplyResult, +} from "~/integrations/mineralisatie.server" + +const METHOD_LABELS: Record = { + minip: "MINIP", + pmn: "PMN", + century: "Century", +} + +// ─── Farm overview KPIs ─────────────────────────────────────────────────────── + +interface FarmNSupplyKpiProps { + results: NSupplyResult[] +} + +export function FarmNSupplyKpi({ results }: FarmNSupplyKpiProps) { + const validResults = results.filter((r) => !r.error && r.data.length > 0) + + const avgN = + validResults.length > 0 + ? validResults.reduce((sum, r) => sum + r.totalAnnualN, 0) / + validResults.length + : 0 + + const maxResult = validResults.reduce( + (best, r) => (!best || r.totalAnnualN > best.totalAnnualN ? r : best), + undefined, + ) + + const minResult = validResults.reduce( + (low, r) => (!low || r.totalAnnualN < low.totalAnnualN ? r : low), + undefined, + ) + + return ( + <> + + + + N Levering Bedrijf + + + +
+ {Math.round(avgN)} kg N/ha +
+

+ Gemiddeld over {validResults.length} percelen +

+
+
+ + + + + Hoogste N Levering + + + +
+ {maxResult ? Math.round(maxResult.totalAnnualN) : "—"}{" "} + {maxResult ? "kg N/ha" : ""} +
+

+ {maxResult?.b_name ?? "Geen gegevens"} +

+
+
+ + + + + Laagste N Levering + + + +
+ {minResult ? Math.round(minResult.totalAnnualN) : "—"}{" "} + {minResult ? "kg N/ha" : ""} +
+

+ {minResult?.b_name ?? "Geen gegevens"} +

+
+
+ + ) +} + +// ─── Field detail: single model results card ────────────────────────────────── + +function getCurrentDoy(): number { + const now = new Date() + const startOfYear = new Date(now.getFullYear(), 0, 1) + return ( + Math.ceil( + (now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24), + ) + 1 + ) +} + +function getNAtDoy( + data: { doy: number; d_n_supply_actual: number }[], + targetDoy: number, +): number | undefined { + if (data.length === 0) return undefined + return data.reduce((prev, curr) => + Math.abs(curr.doy - targetDoy) < Math.abs(prev.doy - targetDoy) + ? curr + : prev, + ).d_n_supply_actual +} + +interface FieldNSupplyDetailsCardProps { + results: NSupplyResult[] +} + +export function FieldNSupplyDetailsCard({ + results, +}: FieldNSupplyDetailsCardProps) { + const bestResult = + results.find((r) => !r.error && r.method === "minip") ?? + results.find((r) => !r.error) + + const currentDoy = getCurrentDoy() + const todayN = + bestResult && bestResult.data.length > 0 + ? getNAtDoy(bestResult.data, currentDoy) + : undefined + const progress = + todayN != null && bestResult && bestResult.totalAnnualN > 0 + ? (todayN / bestResult.totalAnnualN) * 100 + : undefined + + const validResults = results.filter((r) => !r.error && r.data.length > 0) + const spread = + validResults.length >= 2 + ? Math.max(...validResults.map((r) => r.totalAnnualN)) - + Math.min(...validResults.map((r) => r.totalAnnualN)) + : undefined + + return ( + + + + Berekeningsresultaten + + + + {/* Annual total headline */} +
+
+ + {bestResult && !bestResult.error + ? Math.round(bestResult.totalAnnualN) + : "—"} + + {bestResult && !bestResult.error && ( + + kg N/ha/jaar + + )} +
+

+ {bestResult + ? `${METHOD_LABELS[bestResult.method]}-methode` + : "Geen berekening beschikbaar"} +

+
+ + {/* Season progress */} + {(todayN != null || progress != null) && ( +
+ + {todayN != null && ( +
+ + Tot vandaag + + + {Math.round(todayN)} kg N/ha + +
+ )} + {progress != null && ( +
+ + Voortgang dit jaar + + + {Math.round(progress)}% + +
+ )} +
+ )} + + {/* Per-method breakdown */} +
+ + {results.map((r) => ( +
+ + {METHOD_LABELS[r.method]} + + {r.error ? ( + + Niet beschikbaar + + ) : ( + + {Math.round(r.totalAnnualN)} kg N/ha + + )} +
+ ))} + {spread != null && ( +
+ + Spreiding methoden + + + ±{Math.round(spread / 2)} kg N/ha + +
+ )} +
+
+
+ ) +} + +// ─── Legacy field KPI fragments (kept for farm overview) ───────────────────── + +interface FieldNSupplyKpiProps { + results: NSupplyResult[] + soilType?: string + organicMatter?: number +} + +export function FieldNSupplyKpi({ + results, + soilType, + organicMatter, +}: FieldNSupplyKpiProps) { + const bestResult = + results.find((r) => !r.error && r.method === "minip") ?? + results.find((r) => !r.error) + + const errorCount = results.filter((r) => r.error).length + + return ( + <> + + + + N Levering + + + +
+ {bestResult && !bestResult.error + ? `${Math.round(bestResult.totalAnnualN)} kg N/ha` + : "—"} +
+

+ Jaarlijks cumulatief +

+
+
+ + + + + Methoden + + + +
+ {3 - errorCount} / 3 +
+

+ {results + .filter((r) => !r.error) + .map((r) => METHOD_LABELS[r.method]) + .join(", ") || "Geen"} +

+
+
+ + + + + Bodemtype + + + +
{soilType ?? "—"}
+

+ Bodembeschrijving +

+
+
+ + + + + Organische Stof + + + +
+ {organicMatter != null + ? `${organicMatter.toFixed(1)}%` + : "—"} +
+

a_som_loi

+
+
+ + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx b/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx new file mode 100644 index 000000000..d14383597 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx @@ -0,0 +1,128 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Skeleton } from "~/components/ui/skeleton" + +export function MineralisatieCardSkeleton() { + return ( + + + + + + + + + + + ) +} + +export function MineralisatieChartSkeleton() { + return ( + + + + + + + + + + + + + + ) +} + +export function MineralisatieFieldsSkeleton() { + return ( + + + + + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ ) +} + +/** Fallback for the farm overview page */ +export function MineralisatieFallback() { + return ( +
+
+ + + +
+
+
+ +
+
+ +
+
+
+ ) +} + +/** Fallback for the field detail page */ +export function MineralisatieFieldDetailFallback() { + return ( +
+
+ + + + +
+ +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + ))} + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + ))} + + +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index c7d41c959..179fa4813 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -102,6 +102,7 @@ export function SidebarApps() { } else { omBalanceLink = undefined } + return ( diff --git a/fdm-app/app/components/blocks/sidebar/farm.tsx b/fdm-app/app/components/blocks/sidebar/farm.tsx index 3882d0ab3..a79acb6cf 100644 --- a/fdm-app/app/components/blocks/sidebar/farm.tsx +++ b/fdm-app/app/components/blocks/sidebar/farm.tsx @@ -1,6 +1,7 @@ import type { getFarm } from "@nmi-agro/fdm-core" import { Bot, + Bubbles, Calendar, Check, ChevronRight, @@ -380,8 +381,6 @@ export function SidebarLabs() { const isFarmSelected = farmId && farmId !== "undefined" if (!isFarmSelected) return null - if (!isGerritEnabled) return null - return ( Labs @@ -391,18 +390,36 @@ export function SidebarLabs() { - - Gerrit + + Mineralisatie + {isGerritEnabled && ( + + + + + Gerrit + + + + )} diff --git a/fdm-app/app/integrations/mineralisatie.server.test.ts b/fdm-app/app/integrations/mineralisatie.server.test.ts new file mode 100644 index 000000000..c7282f488 --- /dev/null +++ b/fdm-app/app/integrations/mineralisatie.server.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest" + +// Mock server-only modules so the pure functions can be tested in isolation +vi.mock("~/lib/fdm.server", () => ({ fdm: {} })) +vi.mock("~/integrations/nmi.server", () => ({ getNmiApiKey: () => undefined })) + +import { + assessDataCompleteness, + buildNSupplyRequest, + generateInsights, + NmiApiError, + type DataCompleteness, + type NSupplyResult, +} from "./mineralisatie.server" + +// ─── assessDataCompleteness ─────────────────────────────────────────────────── + +describe("assessDataCompleteness", () => { + describe("MINIP method", () => { + it("returns score 100 when all required and optional params are present", () => { + const soilData = { + a_som_loi: 3.5, + a_clay_mi: 18, + a_silt_mi: 22, + a_sand_mi: 60, + b_soiltype_agr: "zand", + } + const result = assessDataCompleteness(soilData, "minip") + expect(result.score).toBe(100) + expect(result.missing).toHaveLength(0) + expect(result.estimated).toHaveLength(0) + }) + + it("returns score 80 when all required but no optional params present", () => { + const soilData = { + a_som_loi: 3.5, + a_clay_mi: 18, + a_silt_mi: 22, + } + const result = assessDataCompleteness(soilData, "minip") + expect(result.score).toBe(80) + expect(result.missing).toHaveLength(0) + expect(result.estimated).toEqual( + expect.arrayContaining(["a_sand_mi", "b_soiltype_agr"]), + ) + }) + + it("returns low score when required params are missing", () => { + const soilData = { + a_clay_mi: 18, + // a_som_loi and a_silt_mi missing + } + const result = assessDataCompleteness(soilData, "minip") + expect(result.missing).toContain("a_som_loi") + expect(result.missing).toContain("a_silt_mi") + expect(result.score).toBeLessThan(40) + }) + + it("includes available params with their values", () => { + const soilData = { a_som_loi: 4.2, a_clay_mi: 20, a_silt_mi: 15 } + const result = assessDataCompleteness(soilData, "minip") + expect(result.available).toEqual( + expect.arrayContaining([ + expect.objectContaining({ param: "a_som_loi", value: 4.2 }), + expect.objectContaining({ param: "a_clay_mi", value: 20 }), + ]), + ) + }) + }) + + describe("PMN method", () => { + it("requires a_n_pmn and a_clay_mi", () => { + const soilData = { a_clay_mi: 15 } + const result = assessDataCompleteness(soilData, "pmn") + expect(result.missing).toContain("a_n_pmn") + expect(result.score).toBeLessThan(50) + }) + + it("full score with n_pmn and clay plus optionals", () => { + const soilData = { + a_n_pmn: 42, + a_clay_mi: 15, + a_sand_mi: 70, + b_soiltype_agr: "zand", + } + const result = assessDataCompleteness(soilData, "pmn") + expect(result.score).toBe(100) + }) + }) + + describe("Century method", () => { + it("requires a_c_of, a_cn_fr, a_clay_mi, a_silt_mi", () => { + const soilData = {} + const result = assessDataCompleteness(soilData, "century") + expect(result.missing).toEqual( + expect.arrayContaining([ + "a_c_of", + "a_cn_fr", + "a_clay_mi", + "a_silt_mi", + ]), + ) + expect(result.score).toBe(0) + }) + }) +}) + +// ─── buildNSupplyRequest ────────────────────────────────────────────────────── + +describe("buildNSupplyRequest", () => { + const baseField = { + b_centroid: [5.1234, 52.5678] as [number, number], + } + const baseSoilData = { + a_som_loi: 3.5, + a_clay_mi: 18, + a_silt_mi: 22, + } + const baseCultivations = [{ b_lu_catalogue: "nl_233" }] + const baseTimeframe = { + start: new Date("2024-01-01"), + end: new Date("2024-12-31"), + } + + it("maps centroid to a_lat and a_lon", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.a_lon).toBe(5.1234) + expect(req.a_lat).toBe(52.5678) + }) + + it("strips nl_ prefix from b_lu_catalogue and converts to number", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.b_lu_brp).toBe(233) + }) + + it("sets d_n_supply_method from method argument", () => { + for (const method of ["minip", "pmn", "century"] as const) { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + baseCultivations, + method, + baseTimeframe, + ) + expect(req.d_n_supply_method).toBe(method) + } + }) + + it("includes timeframe dates when provided", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.d_start).toBe("2024-01-01") + expect(req.d_end).toBe("2024-12-31") + }) + + it("uses default depth 0.3 when a_depth_lower is absent", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.a_depth).toBe(0.3) + }) + + it("uses a_depth_lower from soilData when present", () => { + const req = buildNSupplyRequest( + baseField, + { ...baseSoilData, a_depth_lower: 25 }, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.a_depth).toBe(25) + }) + + it("omits undefined soil params", () => { + const req = buildNSupplyRequest( + baseField, + { a_som_loi: 3.5 }, // only som_loi + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.a_clay_mi).toBeUndefined() + expect(req.a_n_pmn).toBeUndefined() + }) + + it("handles missing centroid gracefully", () => { + const req = buildNSupplyRequest( + { b_centroid: null }, + baseSoilData, + baseCultivations, + "minip", + baseTimeframe, + ) + expect(req.a_lat).toBeUndefined() + expect(req.a_lon).toBeUndefined() + }) + + it("handles BRP code without nl_ prefix", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + [{ b_lu_catalogue: "233" }], + "minip", + baseTimeframe, + ) + expect(req.b_lu_brp).toBe(233) + }) + + it("skips non-numeric BRP codes", () => { + const req = buildNSupplyRequest( + baseField, + baseSoilData, + [{ b_lu_catalogue: "nl_abc" }], + "minip", + baseTimeframe, + ) + expect(req.b_lu_brp).toBeUndefined() + }) +}) + +// ─── generateInsights ───────────────────────────────────────────────────────── + +describe("generateInsights", () => { + function makeResult( + totalAnnualN: number, + score = 90, + ): NSupplyResult { + const data = Array.from({ length: 365 }, (_, i) => ({ + doy: i + 1, + d_n_supply_actual: (totalAnnualN / 365) * (i + 1), + })) + const completeness: DataCompleteness = { + available: [], + missing: [], + estimated: [], + score, + } + return { + b_id: "field-1", + b_name: "Testperceel", + method: "minip", + data, + totalAnnualN, + completeness, + } + } + + it("generates high-N insight when field is 120%+ above farm average", () => { + const result = makeResult(180) + const insights = generateInsights(result, 120, 100) + expect(insights.some((i) => i.includes("hoger dan het bedrijfsgemiddelde"))).toBe(true) + expect(insights.some((i) => i.includes("kunstmestgift te verlagen"))).toBe(true) + }) + + it("generates low-N insight when field is below 80% of farm average", () => { + const result = makeResult(80) + const insights = generateInsights(result, 120, 100) + expect(insights.some((i) => i.includes("laag N-leverend vermogen"))).toBe(true) + }) + + it("generates completeness warning when score < 70", () => { + const result = makeResult(120, 50) + const insights = generateInsights(result, 120, 100) + expect(insights.some((i) => i.includes("Betrouwbaarheid beperkt"))).toBe(true) + expect(insights.some((i) => i.includes("50%"))).toBe(true) + }) + + it("generates season progress insight with kg values", () => { + const result = makeResult(200) + const insights = generateInsights(result, 200, 180) + const progressInsight = insights.find((i) => i.includes("gemineraliseerd")) + expect(progressInsight).toBeDefined() + expect(progressInsight).toMatch(/kg N\/ha/) + }) + + it("returns no farm-comparison insight when farmAvg is undefined", () => { + const result = makeResult(200) + const insights = generateInsights(result, undefined, 100) + expect(insights.some((i) => i.includes("bedrijfsgemiddelde"))).toBe(false) + }) +}) + +// ─── NmiApiError ───────────────────────────────────────────────────────────── + +describe("NmiApiError", () => { + it("sets status and message correctly", () => { + const err = new NmiApiError(422, "Onvoldoende gegevens") + expect(err.status).toBe(422) + expect(err.message).toBe("Onvoldoende gegevens") + expect(err.name).toBe("NmiApiError") + expect(err).toBeInstanceOf(Error) + }) +}) diff --git a/fdm-app/app/integrations/mineralisatie.server.ts b/fdm-app/app/integrations/mineralisatie.server.ts new file mode 100644 index 000000000..7fec7fc9c --- /dev/null +++ b/fdm-app/app/integrations/mineralisatie.server.ts @@ -0,0 +1,725 @@ +import { + getCultivations, + getCurrentSoilData, + getField, + getFields, + type Timeframe, +} from "@nmi-agro/fdm-core" +import { z } from "zod" +import { getNmiApiKey } from "~/integrations/nmi.server" +import { fdm } from "~/lib/fdm.server" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type NSupplyMethod = "minip" | "pmn" | "century" + +export interface NSupplyDataPoint { + doy: number + d_n_supply_actual: number +} + +export interface DataCompleteness { + available: { + param: string + value: number | string + source?: string + date?: Date + }[] + missing: string[] + estimated: string[] + score: number // 0–100 +} + +export interface NSupplyResult { + b_id: string + b_name: string + method: NSupplyMethod + data: NSupplyDataPoint[] + totalAnnualN: number // last doy value (kg N/ha/yr) + completeness: DataCompleteness + error?: string +} + +export interface DynaDailyPoint { + b_date_calculation: string + b_nw_fr: number + b_nw_fr_recommended: number + b_nw_fr_min: number + b_nw_fr_max: number + b_nw: number + b_nw_recommended: number + b_nw_min: number + b_nw_max: number + b_n_uptake: number + b_n_uptake_recommended: number + b_no3_leach: number + b_no3_leach_recommended: number +} + +export interface DynaNitrogenBalance { + b_n_supply_artificial: number + b_n_supply_organic: number + b_n_supply_green_manure: number + b_n_supply_preceding_crop: number + b_n_supply_total: number +} + +export interface DynaFertilizerAdvice { + b_n_recommended: number + b_date_recommended: string + b_n_remaining: number +} + +export interface DynaResult { + b_id: string + calculationDyna: DynaDailyPoint[] + nitrogenBalance: DynaNitrogenBalance + fertilizingRecommendations: DynaFertilizerAdvice + harvestingRecommendation: { b_date_harvest: string } +} + +// ─── Zod Schemas ───────────────────────────────────────────────────────────── + +const nsupplyDataPointSchema = z.object({ + doy: z.number().int().min(1).max(366), + d_n_supply_actual: z.number(), +}) + +const nsupplyResponseSchema = z.object({ + data: z.array(nsupplyDataPointSchema), +}) + +const dynaDailyPointSchema = z.object({ + b_date_calculation: z.string(), + b_nw_fr: z.number(), + b_nw_fr_recommended: z.number(), + b_nw_fr_min: z.number(), + b_nw_fr_max: z.number(), + b_nw: z.number(), + b_nw_recommended: z.number(), + b_nw_min: z.number(), + b_nw_max: z.number(), + b_n_uptake: z.number(), + b_n_uptake_recommended: z.number(), + b_no3_leach: z.number(), + b_no3_leach_recommended: z.number(), +}) + +const dynaResponseSchema = z.object({ + calculationDyna: z.array(dynaDailyPointSchema), + nitrogenBalance: z.object({ + b_n_supply_artificial: z.number(), + b_n_supply_organic: z.number(), + b_n_supply_green_manure: z.number(), + b_n_supply_preceding_crop: z.number(), + b_n_supply_total: z.number(), + }), + fertilizingRecommendations: z.object({ + b_n_recommended: z.number(), + b_date_recommended: z.string(), + b_n_remaining: z.number(), + }), + harvestingRecommendation: z.object({ + b_date_harvest: z.string(), + }), +}) + +// ─── Cache ──────────────────────────────────────────────────────────────────── + +interface CacheEntry { + data: T + expiresAt: number +} + +const nsupplyCache = new Map>() +const dynaCache = new Map>() + +const NSUPPLY_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours +const DYNA_TTL_MS = 60 * 60 * 1000 // 1 hour + +function getNsupplyCacheKey( + b_id: string, + method: NSupplyMethod, + year: number, +): string { + return `nsupply:${b_id}:${method}:${year}` +} + +function getDynaCacheKey(b_id: string, year: number): string { + return `dyna:${b_id}:${year}` +} + +// ─── Method-specific parameter requirements ─────────────────────────────────── + +const methodRequirements: Record< + NSupplyMethod, + { required: string[]; optional: string[] } +> = { + minip: { + required: ["a_som_loi", "a_clay_mi", "a_silt_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, + pmn: { + required: ["a_n_pmn", "a_clay_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, + century: { + required: ["a_c_of", "a_cn_fr", "a_clay_mi", "a_silt_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, +} + +// ─── Data Completeness ──────────────────────────────────────────────────────── + +/** + * Evaluates which soil parameters are available, missing, or estimated + * for the chosen mineralization method and returns a completeness score. + * Pass `soilMeta` to include per-parameter source and sampling date. + */ +export function assessDataCompleteness( + soilData: Record, + method: NSupplyMethod, + soilMeta?: Record, +): DataCompleteness { + const { required, optional } = methodRequirements[method] + + const available: DataCompleteness["available"] = [] + const missing: string[] = [] + const estimated: string[] = [] + + for (const param of required) { + const value = soilData[param] + if (value !== null && value !== undefined) { + available.push({ + param, + value: value as number | string, + source: soilMeta?.[param]?.source, + date: soilMeta?.[param]?.date, + }) + } else { + missing.push(param) + } + } + + for (const param of optional) { + const value = soilData[param] + if (value !== null && value !== undefined) { + available.push({ + param, + value: value as number | string, + source: soilMeta?.[param]?.source, + date: soilMeta?.[param]?.date, + }) + } else { + estimated.push(param) + } + } + + const NMI_SOURCE = "nl-other-nmi" + + const availableRequired = required.filter( + (p) => + soilData[p] !== null && + soilData[p] !== undefined && + soilMeta?.[p]?.source !== NMI_SOURCE, + ).length + const availableOptional = optional.filter( + (p) => + soilData[p] !== null && + soilData[p] !== undefined && + soilMeta?.[p]?.source !== NMI_SOURCE, + ).length + + const score = + required.length > 0 + ? (availableRequired / required.length) * 80 + + (optional.length > 0 + ? (availableOptional / optional.length) * 20 + : 20) + : 100 + + return { available, missing, estimated, score: Math.round(score) } +} + +// ─── Insights Generation ────────────────────────────────────────────────────── + +/** + * Generates Dutch-language insights comparing a field's N supply to the farm average + * and reporting season progress. + */ +export function generateInsights( + nsupply: NSupplyResult, + farmAvgN: number | undefined, + currentDoy: number, +): string[] { + const insights: string[] = [] + const totalN = nsupply.totalAnnualN + + if (farmAvgN !== undefined && farmAvgN > 0) { + const ratio = totalN / farmAvgN + if (ratio > 1.2) { + const pct = Math.round((ratio - 1) * 100) + insights.push( + `Het N-leverend vermogen is ${pct}% hoger dan het bedrijfsgemiddelde. Overweeg de kunstmestgift te verlagen.`, + ) + } else if (ratio < 0.8) { + insights.push( + "Relatief laag N-leverend vermogen. Verhogen van het organische stofgehalte kan de mineralisatie verbeteren.", + ) + } + } + + if (nsupply.completeness.score < 70) { + insights.push( + `Betrouwbaarheid beperkt (${nsupply.completeness.score}%). Een uitgebreidere bodemanalyse wordt aanbevolen.`, + ) + } + + const currentPoint = nsupply.data.find((d) => d.doy >= currentDoy) + if (currentPoint) { + const remaining = totalN - currentPoint.d_n_supply_actual + const date = doyToDateString(currentDoy, new Date().getFullYear()) + insights.push( + `Op ${date} is circa ${Math.round(currentPoint.d_n_supply_actual)} kg N/ha gemineraliseerd. Tot einde groeiseizoen wordt nog ~${Math.round(remaining)} kg N/ha verwacht.`, + ) + } + + return insights +} + +function doyToDateString(doy: number, year: number): string { + const date = new Date(year, 0) + date.setDate(doy) + return date.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) +} + +// ─── Request Builders ───────────────────────────────────────────────────────── + +/** + * Maps FDM field, soil, and cultivation data to a NMI nsupply API request body. + */ +export function buildNSupplyRequest( + field: { + b_centroid: [number, number] | null | undefined + b_area?: number | null + }, + soilData: Record, + cultivations: { b_lu_catalogue: string | null | undefined }[], + method: NSupplyMethod, + timeframe: Timeframe, +): Record { + const centroid = field.b_centroid + const a_lon = centroid ? centroid[0] : undefined + const a_lat = centroid ? centroid[1] : undefined + + const b_lu_brp = cultivations + .filter((c) => c.b_lu_catalogue) + .map((c) => { + const code = (c.b_lu_catalogue ?? "").replace(/^nl_/, "") + const parsed = Number.parseInt(code, 10) + return Number.isNaN(parsed) ? undefined : parsed + }) + .find((v) => v !== undefined) + + const body: Record = { + d_n_supply_method: method, + } + + if (timeframe.start) { + body.d_start = timeframe.start.toISOString().split("T")[0] + } + if (timeframe.end) { + body.d_end = timeframe.end.toISOString().split("T")[0] + } + + if (a_lat !== undefined) body.a_lat = a_lat + if (a_lon !== undefined) body.a_lon = a_lon + if (b_lu_brp !== undefined) body.b_lu_brp = b_lu_brp + + const soilParams = [ + "a_som_loi", + "a_clay_mi", + "a_silt_mi", + "a_sand_mi", + "a_c_of", + "a_cn_fr", + "a_n_rt", + "a_n_pmn", + "b_soiltype_agr", + ] as const + + for (const param of soilParams) { + const value = soilData[param] + if (value !== null && value !== undefined) { + body[param] = value + } + } + + const aDepthLower = soilData.a_depth_lower + body.a_depth = + aDepthLower !== null && aDepthLower !== undefined + ? Number(aDepthLower) + : 0.3 + + return body +} + +/** + * Maps FDM data to a NMI dyna API request body. + */ +export function buildDynaRequest( + field: { + b_centroid: [number, number] | null | undefined + b_area?: number | null + }, + soilData: Record, + cultivations: { + b_lu_catalogue: string | null | undefined + b_sowing_date?: Date | null + b_harvesting_date?: Date | null + }[], + fertilizers: { + p_id: string + p_n_rt?: number | null + p_date?: Date | null + p_dose?: number | null + }[], + farmSector: string, + timeframe: Timeframe, +): Record { + const base = buildNSupplyRequest( + field, + soilData, + cultivations, + "minip", + timeframe, + ) + + return { + ...base, + b_farm_sector: farmSector, + fertilizations: fertilizers.map((f) => ({ + p_id: f.p_id, + p_n_rt: f.p_n_rt ?? 0, + p_date: f.p_date?.toISOString().split("T")[0], + p_dose: f.p_dose ?? 0, + })), + cultivations: cultivations.map((c) => ({ + b_lu_brp: c.b_lu_catalogue + ? Number.parseInt(c.b_lu_catalogue.replace(/^nl_/, ""), 10) + : undefined, + b_sowing_date: c.b_sowing_date?.toISOString().split("T")[0], + b_harvesting_date: c.b_harvesting_date?.toISOString().split("T")[0], + })), + } +} + +// ─── Core API Calls ─────────────────────────────────────────────────────────── + +/** + * Fetches the N supply mineralization curve for a single field from the NMI API. + * Results are cached for 24 hours. + */ +export async function getNSupplyForField({ + principal_id, + b_id, + method, + timeframe, +}: { + principal_id: string + b_id: string + method: NSupplyMethod + timeframe: Timeframe +}): Promise { + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + const cacheKey = getNsupplyCacheKey(b_id, method, year) + + const cached = nsupplyCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.data + } + + const nmiApiKey = getNmiApiKey() + if (!nmiApiKey) { + throw new Error("NMI API-sleutel niet geconfigureerd") + } + + const field = await getField(fdm, principal_id, b_id) + if (!field) { + throw new Error(`Perceel niet gevonden: ${b_id}`) + } + + const soilDataArray = await getCurrentSoilData(fdm, principal_id, b_id) + const soilData: Record = {} + if (soilDataArray && soilDataArray.length > 0) { + for (const entry of soilDataArray) { + if (entry.parameter && entry.value !== undefined) { + soilData[entry.parameter] = entry.value as + | number + | string + | null + | undefined + } + } + // Capture sampling depth from first entry + const first = soilDataArray[0] + if (first?.a_depth_lower !== undefined) { + soilData.a_depth_lower = first.a_depth_lower + } + } + + const cultivations = await getCultivations(fdm, principal_id, b_id) + + const completeness = assessDataCompleteness(soilData, method) + const requestBody = buildNSupplyRequest( + field, + soilData, + cultivations, + method, + timeframe, + ) + + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/nsupply", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende bodemgegevens voor mineralisatieberekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + } + if (response.status === 401 || response.status === 403) { + throw new NmiApiError( + response.status, + "NMI API-sleutel niet geconfigureerd of verlopen.", + ) + } + throw new NmiApiError( + response.status, + `Er is een fout opgetreden bij het berekenen van de mineralisatie. ${errorText}`, + ) + } + + const parsed = nsupplyResponseSchema.safeParse(await response.json()) + if (!parsed.success) { + throw new Error( + `Ongeldig antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } + + const result: NSupplyResult = { + b_id, + b_name: field.b_name ?? b_id, + method, + data: parsed.data.data, + totalAnnualN: + parsed.data.data.length > 0 + ? parsed.data.data[parsed.data.data.length - 1] + .d_n_supply_actual + : 0, + completeness, + } + + nsupplyCache.set(cacheKey, { + data: result, + expiresAt: Date.now() + NSUPPLY_TTL_MS, + }) + + return result +} + +/** + * Fetches N supply curves for all non-buffer fields in a farm. + * Per-field errors are caught and returned as NSupplyResult with an error property. + */ +export async function getNSupplyForFarm({ + principal_id, + b_id_farm, + method, + timeframe, +}: { + principal_id: string + b_id_farm: string + method: NSupplyMethod + timeframe: Timeframe +}): Promise { + const fields = await getFields(fdm, principal_id, b_id_farm, timeframe) + const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) + + const results = await Promise.all( + nonBufferFields.map(async (field): Promise => { + try { + return await getNSupplyForField({ + principal_id, + b_id: field.b_id, + method, + timeframe, + }) + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : "Onbekende fout bij ophalen mineralisatiegegevens" + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + method, + data: [], + totalAnnualN: 0, + completeness: { + available: [], + missing: [], + estimated: [], + score: 0, + }, + error: errorMessage, + } + } + }), + ) + + return results +} + +/** + * Fetches the DYNA nitrogen advice simulation for a single field. + * Results are cached for 1 hour. + */ +export async function getDynaForField({ + principal_id, + b_id, + timeframe, + farmSector, + fertilizers, +}: { + principal_id: string + b_id: string + timeframe: Timeframe + farmSector: string + fertilizers?: { + p_id: string + p_n_rt?: number | null + p_date?: Date | null + p_dose?: number | null + }[] +}): Promise { + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + const cacheKey = getDynaCacheKey(b_id, year) + + const cached = dynaCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.data + } + + const nmiApiKey = getNmiApiKey() + if (!nmiApiKey) { + throw new Error("NMI API-sleutel niet geconfigureerd") + } + + const field = await getField(fdm, principal_id, b_id) + if (!field) { + throw new Error(`Perceel niet gevonden: ${b_id}`) + } + + const soilDataArray = await getCurrentSoilData(fdm, principal_id, b_id) + const soilData: Record = {} + if (soilDataArray && soilDataArray.length > 0) { + for (const entry of soilDataArray) { + if (entry.parameter && entry.value !== undefined) { + soilData[entry.parameter] = entry.value as + | number + | string + | null + | undefined + } + } + const first = soilDataArray[0] + if (first?.a_depth_lower !== undefined) { + soilData.a_depth_lower = first.a_depth_lower + } + } + + const cultivations = await getCultivations(fdm, principal_id, b_id) + const requestBody = buildDynaRequest( + field, + soilData, + cultivations, + fertilizers ?? [], + farmSector, + timeframe, + ) + + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/dyna", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + } + throw new NmiApiError( + response.status, + `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, + ) + } + + const parsed = dynaResponseSchema.safeParse(await response.json()) + if (!parsed.success) { + throw new Error( + `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } + + const result: DynaResult = { + b_id, + ...parsed.data, + } + + dynaCache.set(cacheKey, { + data: result, + expiresAt: Date.now() + DYNA_TTL_MS, + }) + + return result +} + +// ─── Error class ────────────────────────────────────────────────────────────── + +export class NmiApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + this.name = "NmiApiError" + } +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx new file mode 100644 index 000000000..ca3273fdc --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx @@ -0,0 +1,300 @@ +import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" +import { ArrowRight, Lightbulb } from "lucide-react" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + useLoaderData, +} from "react-router" +import { DataCompletenessCard } from "~/components/blocks/mineralisatie/data-completeness" +import { FieldMineralisatieChart } from "~/components/blocks/mineralisatie/mineralisatie-chart" +import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralisatie/nsupply-kpi" +import { MineralisatieFieldDetailFallback } from "~/components/blocks/mineralisatie/skeletons" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + assessDataCompleteness, + type DataCompleteness, + generateInsights, + getNSupplyForField, + type NSupplyMethod, + type NSupplyResult, +} from "~/integrations/mineralisatie.server" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +const METHODS: NSupplyMethod[] = ["minip", "pmn", "century"] + +export const meta: MetaFunction = ({ data: loaderData }) => { + const name = loaderData?.field?.b_name ?? "Perceel" + return [ + { + title: `${name} | Mineralisatie | ${clientConfig.name}`, + }, + { + name: "description", + content: `Mineralisatiedetails voor ${name}.`, + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + const b_id = params.b_id + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + if (!b_id) { + throw data("invalid: b_id", { + status: 400, + statusText: "invalid: b_id", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const field = await getField(fdm, session.principal_id, b_id) + if (!field) { + throw data("not found: b_id", { + status: 404, + statusText: "not found: b_id", + }) + } + + // Get soil data + const soilDataArray = await getCurrentSoilData( + fdm, + session.principal_id, + b_id, + ) + const soilData: Record = {} + const soilMeta: Record = {} + + if (soilDataArray && soilDataArray.length > 0) { + for (const entry of soilDataArray) { + if (entry.parameter && entry.value !== undefined) { + soilData[entry.parameter] = entry.value as + | number + | string + | null + | undefined + soilMeta[entry.parameter] = { + source: entry.a_source ?? undefined, + date: entry.b_sampling_date ?? undefined, + } + } + } + } + + const organicMatter = + soilData.a_som_loi != null ? Number(soilData.a_som_loi) : undefined + const soilType = + soilData.b_soiltype_agr != null + ? String(soilData.b_soiltype_agr) + : undefined + const completeness = assessDataCompleteness(soilData, "minip", soilMeta) + + // Fetch all 3 methods in parallel (streamed) + const asyncData = (async () => { + const results = await Promise.all( + METHODS.map(async (method): Promise => { + try { + return await getNSupplyForField({ + principal_id: session.principal_id, + b_id, + method, + timeframe, + }) + } catch (err) { + return { + b_id, + b_name: field.b_name ?? b_id, + method, + data: [], + totalAnnualN: 0, + completeness: { + available: [], + missing: [], + estimated: [], + score: 0, + }, + error: + err instanceof Error + ? err.message + : String(err), + } + } + }), + ) + + const primaryResult = + results.find((r) => r.method === "minip" && !r.error) ?? + results.find((r) => !r.error) + + const now = new Date() + const currentDoy = Math.floor( + (now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / + (1000 * 60 * 60 * 24), + ) + + const insights = primaryResult + ? generateInsights(primaryResult, undefined, currentDoy) + : [] + + return { results, insights } + })() + + return { + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + soilData, + organicMatter, + soilType, + completeness, + asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function MineralisatieFieldDetail() { + const loaderData = useLoaderData() + const { + b_id, + b_id_farm, + calendar, + completeness, + asyncData, + } = loaderData + + return ( +
+ }> + + +
+ ) +} + +function MineralisatieFieldContent({ + asyncData, + b_id, + b_id_farm, + calendar, + completeness, +}: { + asyncData: Promise<{ results: NSupplyResult[]; insights: string[] }> + b_id: string + b_id_farm: string + calendar: string + completeness: DataCompleteness +}) { + const { results, insights } = use(asyncData) + + const series = results.map((r) => ({ + method: r.method, + data: r.data, + error: r.error, + })) + + return ( + <> + {/* Insights — prominent, above the chart */} + {insights.length > 0 && ( + + + + + Inzichten + + + + {insights.map((insight, i) => ( +

+ {insight} +

+ ))} +
+
+ )} + + + + Mineralisatiecurve + + Cumulatieve N-levering (kg N/ha) — vergelijking van + MINIP, PMN en Century methoden + + + + + + + +
+ + +
+ + {/* DYNA Call-to-Action */} + + Dynamisch N-advies beschikbaar (bèta) + + + Bereken gedetailleerd N-opname vs. beschikbaarheid + advies met het DYNA-model. + + + + + + ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx new file mode 100644 index 000000000..783103d32 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx @@ -0,0 +1,214 @@ +import { getFarm, getFields } from "@nmi-agro/fdm-core" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, +} from "react-router" +import { FieldList } from "~/components/blocks/mineralisatie/field-list" +import { MethodSelector } from "~/components/blocks/mineralisatie/method-selector" +import { FarmMineralisatieChart } from "~/components/blocks/mineralisatie/mineralisatie-chart" +import { FarmNSupplyKpi } from "~/components/blocks/mineralisatie/nsupply-kpi" +import { MineralisatieFallback } from "~/components/blocks/mineralisatie/skeletons" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + getNSupplyForFarm, + type NSupplyMethod, + type NSupplyResult, +} from "~/integrations/mineralisatie.server" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError, reportError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +export const meta: MetaFunction = () => { + return [ + { + title: `Mineralisatie | Bedrijfsoverzicht | ${clientConfig.name}`, + }, + { + name: "description", + content: "Bedrijfsoverzicht stikstofmineralisatie.", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (!farm) { + throw data("not found: b_id_farm", { + status: 404, + statusText: "not found: b_id_farm", + }) + } + + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + + // Read method from search params (default: minip) + const url = new URL(request.url) + const method = (url.searchParams.get("method") ?? + "minip") as NSupplyMethod + + const asyncData = (async () => { + try { + const results = await getNSupplyForFarm({ + principal_id: session.principal_id, + b_id_farm, + method, + timeframe, + }) + return { results } + } catch (err) { + reportError( + err instanceof Error ? err.message : String(err), + { + page: "farm/{b_id_farm}/{calendar}/mineralisatie/_index", + scope: "loader/asyncData", + }, + { b_id_farm }, + ) + return { results: [] as NSupplyResult[] } + } + })() + + return { + farm, + fields, + b_id_farm, + method, + calendar: params.calendar ?? "", + asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function MineralisatieFarmOverview() { + const loaderData = useLoaderData() + const { b_id_farm, method, calendar, asyncData } = loaderData + + return ( +
+ }> + + +
+ ) +} + +function MineralisatieFarmContent({ + asyncData, + b_id_farm, + method, + calendar, +}: { + asyncData: Promise<{ results: NSupplyResult[] }> + b_id_farm: string + method: NSupplyMethod + calendar: string +}) { + const { results } = use(asyncData) + + // Compute farm-average curve (simple average per DOY across all valid results) + const validResults = results.filter((r) => !r.error && r.data.length > 0) + const farmAvgData = computeFarmAverageCurve(validResults) + + return ( + <> +
+ +
+
+
+ + + + Mineralisatiecurve — Bedrijfsgemiddelde + + + Cumulatieve N-levering (kg N/ha) over het jaar + + + + + + +
+
+ + +
+ Percelen + + Gesorteerd op N-levering + +
+ +
+ + + +
+
+
+ + ) +} + +function computeFarmAverageCurve( + results: NSupplyResult[], +): { doy: number; d_n_supply_actual: number }[] { + if (results.length === 0) return [] + + const doyMap = new Map() + for (const result of results) { + for (const point of result.data) { + const existing = doyMap.get(point.doy) ?? [] + existing.push(point.d_n_supply_actual) + doyMap.set(point.doy, existing) + } + } + + return Array.from(doyMap.entries()) + .sort(([a], [b]) => a - b) + .map(([doy, values]) => ({ + doy, + d_n_supply_actual: + values.reduce((s, v) => s + v, 0) / values.length, + })) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.tsx new file mode 100644 index 000000000..9263b7bc5 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.tsx @@ -0,0 +1,135 @@ +import { getFarm, getFarms, getFields } from "@nmi-agro/fdm-core" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + Outlet, + useLoaderData, +} from "react-router" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { HeaderMineralisatie } from "~/components/blocks/header/mineralisatie" +import { SidebarInset } from "~/components/ui/sidebar" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +export const meta: MetaFunction = () => { + return [ + { title: `Mineralisatie | ${clientConfig.name}` }, + { + name: "description", + content: + "Bekijk de stikstofmineralisatie voor je percelen en bedrijf.", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + + const b_id = params.b_id + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (!farm) { + throw data("not found: b_id_farm", { + status: 404, + statusText: "not found: b_id_farm", + }) + } + + const farms = await getFarms(fdm, session.principal_id) + if (!farms || farms.length === 0) { + throw data("not found: farms", { + status: 404, + statusText: "not found: farms", + }) + } + + const farmOptions = farms.map((f) => ({ + b_id_farm: f.b_id_farm, + b_name_farm: f.b_name_farm, + })) + + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + const fieldOptions = fields + .filter((field) => !field.b_bufferstrip) + .map((field) => { + if (!field?.b_id) throw new Error("Invalid field data") + return { + b_id: field.b_id, + b_name: field.b_name, + b_area: + field.b_area != null + ? Math.round(field.b_area * 10) / 10 + : 0, + } + }) + + return { + farm, + b_id_farm, + b_id, + farmOptions, + fieldOptions, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function MineralisatieLayout() { + const loaderData = useLoaderData() + + return ( + +
+ + +
+
+
+
+
+

+ Mineralisatie +

+

+ Stikstofmineralisatie per perceel en bedrijf +

+
+
+
+
+ +
+
+
+
+
+ ) +} diff --git a/fdm-app/package.json b/fdm-app/package.json index 678725138..c3461cba7 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -11,6 +11,7 @@ "start": "pnpm db:migrate && NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", "start-dev": "dotenvx run -- pnpm db:migrate && dotenvx run -- NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", "db:migrate": "node ./app/lib/fdm-migrate.server.js", + "test": "vitest run", "typecheck": "react-router typegen && tsc" }, "dependencies": { @@ -107,7 +108,8 @@ "typescript": "catalog:", "vite": "^8.0.3", "vite-node": "^6.0.0", - "vite-tsconfig-paths": "^6.1.1" + "vite-tsconfig-paths": "^6.1.1", + "vitest": "catalog:" }, "engines": { "node": ">=24.0.0" diff --git a/fdm-app/vitest.config.ts b/fdm-app/vitest.config.ts new file mode 100644 index 000000000..a859e9904 --- /dev/null +++ b/fdm-app/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" +import tsconfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["app/**/*.{test,spec}.{ts,tsx}"], + exclude: ["node_modules", "build"], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b0e17e17..2fc52ace0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: vite-tsconfig-paths: specifier: ^6.1.1 version: 6.1.1(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: + specifier: 'catalog:' + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) fdm-calculator: dependencies: @@ -16285,7 +16288,7 @@ snapshots: '@opentelemetry/api-logs@0.205.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs@0.207.0': dependencies: From f3531aaa6cae2334c0d1d627e54dfaae0b72c0d2 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:20:17 +0200 Subject: [PATCH 02/22] feat: implement phase 2 of mineralisatie --- .../blocks/header/mineralisatie.tsx | 17 +- .../blocks/mineralisatie/dyna-advice.tsx | 118 +++++ .../blocks/mineralisatie/dyna-balance.tsx | 73 +++ .../blocks/mineralisatie/dyna-chart.tsx | 311 ++++++++++++ .../blocks/mineralisatie/leaching-chart.tsx | 140 ++++++ .../mineralisatie/mineralisatie-chart.tsx | 2 - .../blocks/mineralisatie/skeletons.tsx | 47 ++ .../app/integrations/mineralisatie.server.ts | 458 +++++++++++++----- ...m.$calendar.mineralisatie.$b_id._index.tsx | 330 +++++++++++++ ...arm.$calendar.mineralisatie.$b_id.dyna.tsx | 365 ++++++++++++++ ..._id_farm.$calendar.mineralisatie.$b_id.tsx | 301 +----------- ...id_farm.$calendar.mineralisatie._index.tsx | 3 +- 12 files changed, 1743 insertions(+), 422 deletions(-) create mode 100644 fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx create mode 100644 fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx diff --git a/fdm-app/app/components/blocks/header/mineralisatie.tsx b/fdm-app/app/components/blocks/header/mineralisatie.tsx index de5afd3a5..54e8e67e1 100644 --- a/fdm-app/app/components/blocks/header/mineralisatie.tsx +++ b/fdm-app/app/components/blocks/header/mineralisatie.tsx @@ -1,5 +1,5 @@ import { ChevronDown } from "lucide-react" -import { NavLink } from "react-router" +import { NavLink, useLocation } from "react-router" import { useCalendarStore } from "@/app/store/calendar" import { BreadcrumbItem, @@ -28,6 +28,8 @@ export function HeaderMineralisatie({ fieldOptions: HeaderFieldOption[] }) { const calendar = useCalendarStore((state) => state.calendar) + const location = useLocation() + const isDyna = location.pathname.endsWith("/dyna") const selectedField = b_id ? fieldOptions.find((f) => f.b_id === b_id) @@ -74,6 +76,19 @@ export function HeaderMineralisatie({ )} + + {isDyna && ( + <> + + + + DYNA + + + + )} ) } diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx b/fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx new file mode 100644 index 000000000..853f0bc15 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx @@ -0,0 +1,118 @@ +import { CalendarCheck, Leaf } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import type { DynaFertilizerAdvice } from "~/integrations/mineralisatie.server" + +interface DynaAdviceCardProps { + fertilizingRecommendations: DynaFertilizerAdvice | null + harvestingRecommendation: { b_date_harvest: string } | null +} + +function formatDate(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return d.toLocaleDateString("nl-NL", { + day: "numeric", + month: "long", + year: "numeric", + }) +} + +export function DynaAdviceCard({ + fertilizingRecommendations, + harvestingRecommendation, +}: DynaAdviceCardProps) { + return ( + + + Advies + + Bemesting- en oogstadvies op basis van DYNA + + + + {/* Fertilizer advice */} +
+
+ + Bemestingsadvies +
+ {fertilizingRecommendations ? ( +
+
+
+ Aanbevolen gift +
+
+ {fertilizingRecommendations.b_n_recommended.toFixed( + 1, + )}{" "} + kg N/ha +
+
+
+
+ Aanbevolen datum +
+
+ {formatDate( + fertilizingRecommendations.b_date_recommended, + )} +
+
+
+
+ Resterende ruimte +
+
+ {fertilizingRecommendations.b_n_remaining.toFixed( + 1, + )}{" "} + kg N/ha +
+
+
+ ) : ( +

+ Geen bemestingsadvies beschikbaar. +

+ )} +
+ + + + {/* Harvest advice */} +
+
+ + Oogstadvies +
+ {harvestingRecommendation ? ( +
+
+
+ Aanbevolen oogstdatum +
+
+ {formatDate( + harvestingRecommendation.b_date_harvest, + )} +
+
+
+ ) : ( +

+ Geen oogstadvies beschikbaar. +

+ )} +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx b/fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx new file mode 100644 index 000000000..06ecc4daa --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx @@ -0,0 +1,73 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import type { DynaNitrogenBalance } from "~/integrations/mineralisatie.server" + +interface DynaBalanceCardProps { + nitrogenBalance: DynaNitrogenBalance +} + +interface BalanceRow { + label: string + value: number + isTotal?: boolean +} + +export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { + const rows: BalanceRow[] = [ + { + label: "Kunstmest", + value: nitrogenBalance.b_n_fertilizer_artificial, + }, + { + label: "Organische mest", + value: nitrogenBalance.b_n_fertilizer_organic, + }, + { + label: "Groenbemester", + value: nitrogenBalance.b_n_greenmanure, + }, + { + label: "Voorvrucht", + value: nitrogenBalance.b_n_fertilizer_preceeding, + }, + ] + + return ( + + + N-balans + + Stikstofaanbod naar bron (kg N/ha/jaar) + + + +
+ {rows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value.toFixed(1)} kg N/ha +
+
+ ))} + +
+
Totaal
+
+ {nitrogenBalance.b_nw.toFixed(1)} kg + N/ha +
+
+
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx new file mode 100644 index 000000000..5a8bfdae6 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx @@ -0,0 +1,311 @@ +"use client" + +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ReferenceLine, + XAxis, + YAxis, +} from "recharts" +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "~/components/ui/chart" +import type { + DynaDailyPoint, + DynaFertilizerAdvice, +} from "~/integrations/mineralisatie.server" + +const MONTH_LABELS_NL = [ + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dec", +] + +function formatDateTick(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return MONTH_LABELS_NL[d.getMonth()] ?? dateStr +} + +function formatDateLabel(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return d.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) +} + +function getMonthTicks(data: DynaDailyPoint[]): string[] { + const seen = new Set() + return data + .filter((d) => { + const month = d.b_date_calculation.slice(0, 7) + if (seen.has(month)) return false + seen.add(month) + return true + }) + .map((d) => d.b_date_calculation) +} + +const dynaChartConfig = { + band: { + label: "Bandbreedte", + color: "hsl(var(--chart-1))", + }, + b_nw: { + label: "N beschikbaar", + color: "hsl(var(--chart-1))", + }, + b_n_uptake: { + label: "N opname", + color: "hsl(var(--chart-2))", + }, + b_nw_recommended: { + label: "N advies", + color: "hsl(var(--chart-3))", + }, +} satisfies ChartConfig + +export interface DynaChartEvent { + date: string + type: "sowing" | "harvest" | "fertilizer" + label: string +} + +const EVENT_COLORS: Record = { + sowing: "hsl(142, 71%, 45%)", + harvest: "hsl(38, 92%, 50%)", + fertilizer: "hsl(217, 91%, 60%)", +} + +const EVENT_ABBR: Record = { + sowing: "Z", + harvest: "O", + fertilizer: "M", +} + +interface EventLabelProps { + viewBox?: { x?: number; y?: number; height?: number } + event: DynaChartEvent + stackIndex: number +} + +function EventLabel({ viewBox, event, stackIndex }: EventLabelProps) { + if (!viewBox?.x || viewBox.y === undefined) return null + const x = viewBox.x + // Stack labels vertically: 4px from top + 14px per stacked slot + const y = (viewBox.y ?? 0) + 10 + stackIndex * 14 + return ( + + {EVENT_ABBR[event.type]} + + ) +} + +interface DynaChartProps { + data: DynaDailyPoint[] + fertilizingRecommendations: DynaFertilizerAdvice | null + events?: DynaChartEvent[] +} + +export function DynaChart({ + data, + fertilizingRecommendations, + events = [], +}: DynaChartProps) { + const monthTicks = getMonthTicks(data) + const today = new Date().toISOString().split("T")[0] ?? "" + + const recDate = fertilizingRecommendations?.b_date_recommended + + // Count how many events share the same date, for stacking labels + const dateCounts = new Map() + const eventsWithStack = events.map((ev) => { + const count = dateCounts.get(ev.date) ?? 0 + dateCounts.set(ev.date, count + 1) + return { ...ev, stackIndex: count } + }) + + const chartData = data.map((d) => ({ + date: d.b_date_calculation, + b_nw: d.b_nw, + b_nw_min: d.b_nw_min, + b_nw_max: d.b_nw_max, + b_nw_recommended: d.b_nw_recommended, + b_n_uptake: d.b_n_uptake, + })) + + return ( + + + + + + + + + + + + { + const raw = payload?.[0]?.payload?.date ?? label + return formatDateLabel(raw) + }} + /> + } + /> + } /> + + {/* Min-max band */} + + + + {/* N availability line */} + + + {/* N uptake line */} + + + {/* Recommended N line */} + + + {/* Today reference line */} + + + {/* Fertilizer recommendation date */} + {recDate && ( + + )} + + {/* Field events: sowing (Z), harvest (O), fertilizer (M) + Labels are stacked vertically per date to avoid overlap */} + {eventsWithStack.map((ev, idx) => ( + ( + + )} + /> + ))} + + + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx new file mode 100644 index 000000000..bd9e44f27 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx @@ -0,0 +1,140 @@ +"use client" + +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts" +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "~/components/ui/chart" +import type { DynaDailyPoint } from "~/integrations/mineralisatie.server" + +const MONTH_LABELS_NL = [ + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dec", +] + +function formatDateTick(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return MONTH_LABELS_NL[d.getMonth()] ?? dateStr +} + +function formatDateLabel(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return d.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) +} + +function getMonthTicks(data: DynaDailyPoint[]): string[] { + const seen = new Set() + return data + .filter((d) => { + const month = d.b_date_calculation.slice(0, 7) + if (seen.has(month)) return false + seen.add(month) + return true + }) + .map((d) => d.b_date_calculation) +} + +const leachingChartConfig = { + b_no3_leach: { + label: "NO₃ uitspoeling", + color: "hsl(0, 72%, 51%)", + }, +} satisfies ChartConfig + +interface LeachingChartProps { + data: DynaDailyPoint[] +} + +export function LeachingChart({ data }: LeachingChartProps) { + const monthTicks = getMonthTicks(data) + + const chartData = data.map((d) => ({ + date: d.b_date_calculation, + b_no3_leach: d.b_no3_leach, + })) + + return ( + + + + + + + + + + + + { + const raw = payload?.[0]?.payload?.date ?? label + return formatDateLabel(raw) + }} + formatter={(value) => [ + `${Number(value).toFixed(1)} kg NO₃/ha`, + "Uitspoeling", + ]} + /> + } + /> + + + + ) +} diff --git a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx index 93b9c5e34..db38cacb0 100644 --- a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx +++ b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx @@ -4,9 +4,7 @@ import { Area, AreaChart, CartesianGrid, - Legend, ReferenceLine, - Tooltip, XAxis, YAxis, } from "recharts" diff --git a/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx b/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx index d14383597..b0599567f 100644 --- a/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx +++ b/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx @@ -88,6 +88,53 @@ export function MineralisatieFallback() { ) } +/** Fallback for the DYNA page */ +export function DynaFallback() { + return ( +
+
+ + + + +
+ +
+ + + + + + {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + ))} + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + ))} + + +
+ + + + + + + + +
+ ) +} + /** Fallback for the field detail page */ export function MineralisatieFieldDetailFallback() { return ( diff --git a/fdm-app/app/integrations/mineralisatie.server.ts b/fdm-app/app/integrations/mineralisatie.server.ts index 7fec7fc9c..e01ebcc10 100644 --- a/fdm-app/app/integrations/mineralisatie.server.ts +++ b/fdm-app/app/integrations/mineralisatie.server.ts @@ -1,18 +1,19 @@ import { getCultivations, + getCultivationsFromCatalogue, getCurrentSoilData, getField, getFields, type Timeframe, + withCalculationCache, } from "@nmi-agro/fdm-core" import { z } from "zod" import { getNmiApiKey } from "~/integrations/nmi.server" +import { getDefaultCultivation } from "~/lib/cultivation-helpers" import { fdm } from "~/lib/fdm.server" // ─── Types ──────────────────────────────────────────────────────────────────── -export type NSupplyMethod = "minip" | "pmn" | "century" - export interface NSupplyDataPoint { doy: number d_n_supply_actual: number @@ -42,26 +43,27 @@ export interface NSupplyResult { export interface DynaDailyPoint { b_date_calculation: string - b_nw_fr: number - b_nw_fr_recommended: number - b_nw_fr_min: number - b_nw_fr_max: number b_nw: number - b_nw_recommended: number b_nw_min: number b_nw_max: number + b_nw_recommended: number b_n_uptake: number + b_n_uptake_min: number + b_n_uptake_max: number b_n_uptake_recommended: number b_no3_leach: number + b_no3_leach_min: number + b_no3_leach_max: number b_no3_leach_recommended: number } export interface DynaNitrogenBalance { - b_n_supply_artificial: number - b_n_supply_organic: number - b_n_supply_green_manure: number - b_n_supply_preceding_crop: number - b_n_supply_total: number + b_nw: number + b_n_uptake: number + b_n_greenmanure: number + b_n_fertilizer_organic: number + b_n_fertilizer_artificial: number + b_n_fertilizer_preceeding: number } export interface DynaFertilizerAdvice { @@ -74,8 +76,8 @@ export interface DynaResult { b_id: string calculationDyna: DynaDailyPoint[] nitrogenBalance: DynaNitrogenBalance - fertilizingRecommendations: DynaFertilizerAdvice - harvestingRecommendation: { b_date_harvest: string } + fertilizingRecommendations: DynaFertilizerAdvice | null + harvestingRecommendation: { b_date_harvest: string } | null } // ─── Zod Schemas ───────────────────────────────────────────────────────────── @@ -91,37 +93,53 @@ const nsupplyResponseSchema = z.object({ const dynaDailyPointSchema = z.object({ b_date_calculation: z.string(), - b_nw_fr: z.number(), - b_nw_fr_recommended: z.number(), - b_nw_fr_min: z.number(), - b_nw_fr_max: z.number(), b_nw: z.number(), - b_nw_recommended: z.number(), b_nw_min: z.number(), b_nw_max: z.number(), + b_nw_recommended: z.number(), b_n_uptake: z.number(), + b_n_uptake_min: z.number(), + b_n_uptake_max: z.number(), b_n_uptake_recommended: z.number(), b_no3_leach: z.number(), + b_no3_leach_min: z.number(), + b_no3_leach_max: z.number(), b_no3_leach_recommended: z.number(), }) +const dynaResponseDataSchema = z + .object({ + calculation_dyna: z.array(dynaDailyPointSchema), + nitrogen_balance: z.object({ + b_nw: z.number(), + b_n_uptake: z.number(), + b_n_greenmanure: z.number(), + b_n_fertilizer_organic: z.number(), + b_n_fertilizer_artificial: z.number(), + b_n_fertilizer_preceeding: z.number(), + }), + fertilizing_recommendations: z + .object({ + b_n_recommended: z.number(), + b_date_recommended: z.string(), + b_n_remaining: z.number(), + }) + .nullable(), + harvesting_recommendations: z + .object({ + b_date_harvest: z.string(), + }) + .nullable(), + }) + .transform((d) => ({ + calculationDyna: d.calculation_dyna, + nitrogenBalance: d.nitrogen_balance, + fertilizingRecommendations: d.fertilizing_recommendations, + harvestingRecommendation: d.harvesting_recommendations, + })) + const dynaResponseSchema = z.object({ - calculationDyna: z.array(dynaDailyPointSchema), - nitrogenBalance: z.object({ - b_n_supply_artificial: z.number(), - b_n_supply_organic: z.number(), - b_n_supply_green_manure: z.number(), - b_n_supply_preceding_crop: z.number(), - b_n_supply_total: z.number(), - }), - fertilizingRecommendations: z.object({ - b_n_recommended: z.number(), - b_date_recommended: z.string(), - b_n_remaining: z.number(), - }), - harvestingRecommendation: z.object({ - b_date_harvest: z.string(), - }), + data: dynaResponseDataSchema, }) // ─── Cache ──────────────────────────────────────────────────────────────────── @@ -132,10 +150,8 @@ interface CacheEntry { } const nsupplyCache = new Map>() -const dynaCache = new Map>() const NSUPPLY_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours -const DYNA_TTL_MS = 60 * 60 * 1000 // 1 hour function getNsupplyCacheKey( b_id: string, @@ -145,10 +161,66 @@ function getNsupplyCacheKey( return `nsupply:${b_id}:${method}:${year}` } -function getDynaCacheKey(b_id: string, year: number): string { - return `dyna:${b_id}:${year}` +// ─── DYNA DB-backed cache ───────────────────────────────────────────────────── + +interface DynaComputeInput { + b_id: string + nmiApiKey: string + requestBody: unknown +} + +async function _callDynaApi({ + b_id, + nmiApiKey, + requestBody, +}: DynaComputeInput): Promise { + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/dyna", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + } + throw new NmiApiError( + response.status, + `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, + ) + } + + const rawJson = await response.json() + const parsed = dynaResponseSchema.safeParse(rawJson) + if (!parsed.success) { + throw new Error( + `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } + + return { b_id, ...parsed.data.data } } +const _cachedCallDynaApi = withCalculationCache( + _callDynaApi, + "callDynaApi", + "v1.0.0", + ["nmiApiKey"], +) + // ─── Method-specific parameter requirements ─────────────────────────────────── const methodRequirements: Record< @@ -366,51 +438,223 @@ export function buildNSupplyRequest( /** * Maps FDM data to a NMI dyna API request body. + * Follows the same nested structure as the /bemestingsplan/nutrient_balance endpoint. */ export function buildDynaRequest( field: { + b_id?: string | null b_centroid: [number, number] | null | undefined b_area?: number | null }, soilData: Record, cultivations: { b_lu_catalogue: string | null | undefined - b_sowing_date?: Date | null - b_harvesting_date?: Date | null + b_lu_start?: Date | null + b_lu_end?: Date | null + b_lu_croprotation?: string | null + m_cropresidue?: boolean | null }[], fertilizers: { p_id: string p_n_rt?: number | null + p_n_if?: number | null + p_n_of?: number | null + p_n_wc?: number | null + p_p_rt?: number | null + p_k_rt?: number | null + p_dm?: number | null + p_om?: number | null p_date?: Date | null p_dose?: number | null + p_app_method?: string | null }[], farmSector: string, timeframe: Timeframe, + cropProperties?: { + b_lu_catalogue: string + b_lu_yield?: number | null + b_lu_n_harvestable?: number | null + b_lu_n_residue?: number | null + }[], ): Record { - const base = buildNSupplyRequest( - field, - soilData, - cultivations, - "minip", - timeframe, - ) + const centroid = field.b_centroid + const a_lon = centroid ? centroid[0] : undefined + const a_lat = centroid ? centroid[1] : undefined + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() - return { - ...base, - b_farm_sector: farmSector, - fertilizations: fertilizers.map((f) => ({ + // Build field object with coordinates and soil params + const fieldObj: Record = {} + if (field.b_id) fieldObj.b_id = field.b_id + if (a_lat !== undefined) fieldObj.a_lat = a_lat + if (a_lon !== undefined) fieldObj.a_lon = a_lon + + const soilParams = [ + "a_som_loi", + "a_clay_mi", + "a_silt_mi", + "a_sand_mi", + "a_c_of", + "a_cn_fr", + "a_n_rt", + "a_n_pmn", + "b_soiltype_agr", + ] as const + + for (const param of soilParams) { + const value = soilData[param] + if (value !== null && value !== undefined) { + fieldObj[param] = value + } + } + + const aDepthLower = soilData.a_depth_lower + fieldObj.a_depth = + aDepthLower !== null && aDepthLower !== undefined + ? Number(aDepthLower) + : 0.3 + + // Build amendments list for the rotation entry + const amendments = fertilizers + .filter((f) => f.p_date !== null && f.p_date !== undefined) + .map((f) => ({ p_id: f.p_id, - p_n_rt: f.p_n_rt ?? 0, - p_date: f.p_date?.toISOString().split("T")[0], p_dose: f.p_dose ?? 0, - })), - cultivations: cultivations.map((c) => ({ - b_lu_brp: c.b_lu_catalogue - ? Number.parseInt(c.b_lu_catalogue.replace(/^nl_/, ""), 10) - : undefined, - b_sowing_date: c.b_sowing_date?.toISOString().split("T")[0], - b_harvesting_date: c.b_harvesting_date?.toISOString().split("T")[0], - })), + p_app_method: f.p_app_method ?? "broadcasting", + p_date_fertilization: f.p_date?.toISOString().split("T")[0], + })) + + // Build one rotation entry per calendar year using the May 15th rule to + // identify the main cultivation. If no crop is active on May 15th, fall back + // to the first (earliest-starting) non-catchcrop in that year. + // Catchcrops become green-manure fields. Amendments only apply to the requested year. + const allYears = [ + ...new Set( + cultivations + .filter((c) => c.b_lu_catalogue && c.b_lu_start) + .map((c) => c.b_lu_start!.getFullYear()), + ), + ].sort((a, b) => a - b) + + const rotation = allYears + .map((rotationYear) => { + const yearCultivations = cultivations.filter( + (c) => + c.b_lu_catalogue && + c.b_lu_start?.getFullYear() === rotationYear, + ) + + // Use May 15th rule; fall back to first non-catchcrop in the year + const mainCrop = + getDefaultCultivation( + cultivations as Parameters[0], + rotationYear.toString(), + ) ?? + yearCultivations.find( + (c) => c.b_lu_croprotation !== "catchcrop", + ) ?? + yearCultivations[0] + + // Skip years where no main crop could be determined + if (!mainCrop?.b_lu_catalogue) return null + + // Look up yield from crop_properties for the harvests array + const cropProp = cropProperties?.find( + (cp) => cp.b_lu_catalogue === mainCrop.b_lu_catalogue, + ) + const harvests = mainCrop.b_lu_end + ? [ + { + b_date_harvest: mainCrop.b_lu_end + .toISOString() + .split("T")[0], + ...(cropProp?.b_lu_yield != null + ? { b_lu_yield: cropProp.b_lu_yield } + : {}), + }, + ] + : [] + + const greenManure = yearCultivations.find( + (c) => c.b_lu_croprotation === "catchcrop" && c !== mainCrop, + ) + return { + year: rotationYear, + b_lu: mainCrop.b_lu_catalogue, + b_lu_start: mainCrop.b_lu_start?.toISOString().split("T")[0], + harvests, + ...(mainCrop.m_cropresidue != null + ? { m_cropresidue: mainCrop.m_cropresidue } + : {}), + ...(greenManure?.b_lu_catalogue + ? { + b_lu_green: greenManure.b_lu_catalogue, + b_date_green_incorporation: greenManure.b_lu_end + ?.toISOString() + .split("T")[0], + } + : {}), + irrigation: [], + amendments: rotationYear === year ? amendments : [], + } + }) + .filter((entry) => entry !== null) + + // If no cultivations found, create a minimal rotation entry for the requested year + if (rotation.length === 0) { + rotation.push({ + year, + b_lu: undefined, + b_lu_start: timeframe.start?.toISOString().split("T")[0], + harvests: [], + irrigation: [], + amendments, + }) + } + + fieldObj.rotation = rotation + + // Build fertilizer_properties (unique p_ids with all available properties) + const seenIds = new Set() + const fertilizer_properties = fertilizers + .filter((f) => { + if (seenIds.has(f.p_id)) return false + seenIds.add(f.p_id) + return true + }) + .map((f) => { + const props: Record = { + p_id: f.p_id, + p_n_rt: f.p_n_rt ?? 0, + } + if (f.p_n_if != null) props.p_n_if = f.p_n_if + if (f.p_n_of != null) props.p_n_of = f.p_n_of + if (f.p_n_wc != null) props.p_n_wc = f.p_n_wc + if (f.p_p_rt != null) props.p_p_rt = f.p_p_rt + if (f.p_k_rt != null) props.p_k_rt = f.p_k_rt + if (f.p_dm != null) props.p_dm = f.p_dm + if (f.p_om != null) props.p_om = f.p_om + return props + }) + + // Build crop_properties from catalogue data + const builtCropProperties = + cropProperties && cropProperties.length > 0 + ? cropProperties + .filter((cp) => cp.b_lu_catalogue) + .map((cp) => ({ + b_lu: cp.b_lu_catalogue, + b_lu_yield: cp.b_lu_yield ?? null, + b_lu_n_harvestable: cp.b_lu_n_harvestable ?? null, + b_lu_n_residue: cp.b_lu_n_residue ?? null, + })) + : null + + return { + field: fieldObj, + farm: { sector: farmSector || "arable" }, + crop_properties: builtCropProperties, + fertilizer_properties: + fertilizer_properties.length > 0 ? fertilizer_properties : null, } } @@ -597,34 +841,36 @@ export async function getNSupplyForFarm({ /** * Fetches the DYNA nitrogen advice simulation for a single field. - * Results are cached for 1 hour. + * Results are cached in the fdm database via withCalculationCache. */ export async function getDynaForField({ principal_id, b_id, + b_id_farm, timeframe, farmSector, fertilizers, }: { principal_id: string b_id: string + b_id_farm: string timeframe: Timeframe farmSector: string fertilizers?: { p_id: string p_n_rt?: number | null + p_n_if?: number | null + p_n_of?: number | null + p_n_wc?: number | null + p_p_rt?: number | null + p_k_rt?: number | null + p_dm?: number | null + p_om?: number | null p_date?: Date | null p_dose?: number | null + p_app_method?: string | null }[] }): Promise { - const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() - const cacheKey = getDynaCacheKey(b_id, year) - - const cached = dynaCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return cached.data - } - const nmiApiKey = getNmiApiKey() if (!nmiApiKey) { throw new Error("NMI API-sleutel niet geconfigureerd") @@ -635,7 +881,12 @@ export async function getDynaForField({ throw new Error(`Perceel niet gevonden: ${b_id}`) } - const soilDataArray = await getCurrentSoilData(fdm, principal_id, b_id) + const [soilDataArray, cultivations, catalogueEntries] = await Promise.all([ + getCurrentSoilData(fdm, principal_id, b_id), + getCultivations(fdm, principal_id, b_id), + getCultivationsFromCatalogue(fdm, principal_id, b_id_farm), + ]) + const soilData: Record = {} if (soilDataArray && soilDataArray.length > 0) { for (const entry of soilDataArray) { @@ -653,7 +904,19 @@ export async function getDynaForField({ } } - const cultivations = await getCultivations(fdm, principal_id, b_id) + // Build crop_properties from catalogue entries for the field's cultivations + const cultivationCodes = new Set( + cultivations.map((c) => c.b_lu_catalogue).filter(Boolean), + ) + const cropProperties = catalogueEntries + .filter((e) => cultivationCodes.has(e.b_lu_catalogue)) + .map((e) => ({ + b_lu_catalogue: e.b_lu_catalogue, + b_lu_yield: e.b_lu_yield ?? null, + b_lu_n_harvestable: e.b_lu_n_harvestable ?? null, + b_lu_n_residue: e.b_lu_n_residue ?? null, + })) + const requestBody = buildDynaRequest( field, soilData, @@ -661,55 +924,10 @@ export async function getDynaForField({ fertilizers ?? [], farmSector, timeframe, + cropProperties.length > 0 ? cropProperties : undefined, ) - const response = await fetch( - "https://api.nmi-agro.nl/bemestingsplan/dyna", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${nmiApiKey}`, - }, - body: JSON.stringify(requestBody), - }, - ) - - if (!response.ok) { - const errorText = await response.text() - if (response.status === 422) { - throw new NmiApiError( - 422, - `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, - ) - } - if (response.status === 503) { - throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") - } - throw new NmiApiError( - response.status, - `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, - ) - } - - const parsed = dynaResponseSchema.safeParse(await response.json()) - if (!parsed.success) { - throw new Error( - `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, - ) - } - - const result: DynaResult = { - b_id, - ...parsed.data, - } - - dynaCache.set(cacheKey, { - data: result, - expiresAt: Date.now() + DYNA_TTL_MS, - }) - - return result + return _cachedCallDynaApi(fdm, { b_id, nmiApiKey, requestBody }) } // ─── Error class ────────────────────────────────────────────────────────────── diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx new file mode 100644 index 000000000..fb0c73f17 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx @@ -0,0 +1,330 @@ +import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" +import { ArrowRight, Lightbulb, Slash } from "lucide-react" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + useLoaderData, +} from "react-router" +import { DataCompletenessCard } from "~/components/blocks/mineralisatie/data-completeness" +import { FieldMineralisatieChart } from "~/components/blocks/mineralisatie/mineralisatie-chart" +import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralisatie/nsupply-kpi" +import { MineralisatieFieldDetailFallback } from "~/components/blocks/mineralisatie/skeletons" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/components/ui/empty" +import { + assessDataCompleteness, + type DataCompleteness, + generateInsights, + getNSupplyForField, + type NSupplyMethod, + type NSupplyResult, +} from "~/integrations/mineralisatie.server" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +const METHODS: NSupplyMethod[] = ["minip", "pmn", "century"] + +export const meta: MetaFunction = ({ data: loaderData }) => { + const name = loaderData?.field?.b_name ?? "Perceel" + return [ + { + title: `${name} | Mineralisatie | ${clientConfig.name}`, + }, + { + name: "description", + content: `Mineralisatiedetails voor ${name}.`, + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + const b_id = params.b_id + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + if (!b_id) { + throw data("invalid: b_id", { + status: 400, + statusText: "invalid: b_id", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const field = await getField(fdm, session.principal_id, b_id) + if (!field) { + throw data("not found: b_id", { + status: 404, + statusText: "not found: b_id", + }) + } + if (field.b_bufferstrip) { + return { + isBufferStrip: true as const, + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + } + } + + // Get soil data + const soilDataArray = await getCurrentSoilData( + fdm, + session.principal_id, + b_id, + ) + const soilData: Record = {} + const soilMeta: Record = {} + + if (soilDataArray && soilDataArray.length > 0) { + for (const entry of soilDataArray) { + if (entry.parameter && entry.value !== undefined) { + soilData[entry.parameter] = entry.value as + | number + | string + | null + | undefined + soilMeta[entry.parameter] = { + source: entry.a_source ?? undefined, + date: entry.b_sampling_date ?? undefined, + } + } + } + } + + const organicMatter = + soilData.a_som_loi != null ? Number(soilData.a_som_loi) : undefined + const soilType = + soilData.b_soiltype_agr != null + ? String(soilData.b_soiltype_agr) + : undefined + const completeness = assessDataCompleteness(soilData, "minip", soilMeta) + + // Fetch all 3 methods in parallel (streamed) + const asyncData = (async () => { + const results = await Promise.all( + METHODS.map(async (method): Promise => { + try { + return await getNSupplyForField({ + principal_id: session.principal_id, + b_id, + method, + timeframe, + }) + } catch (err) { + return { + b_id, + b_name: field.b_name ?? b_id, + method, + data: [], + totalAnnualN: 0, + completeness: { + available: [], + missing: [], + estimated: [], + score: 0, + }, + error: + err instanceof Error + ? err.message + : String(err), + } + } + }), + ) + + const primaryResult = + results.find((r) => r.method === "minip" && !r.error) ?? + results.find((r) => !r.error) + + const now = new Date() + const currentDoy = Math.floor( + (now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / + (1000 * 60 * 60 * 24), + ) + + const insights = primaryResult + ? generateInsights(primaryResult, undefined, currentDoy) + : [] + + return { results, insights } + })() + + return { + isBufferStrip: false as const, + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + soilData, + organicMatter, + soilType, + completeness, + asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function MineralisatieFieldDetail() { + const loaderData = useLoaderData() + + if (loaderData.isBufferStrip) { + return ( + + + + + + Niet beschikbaar voor bufferstroken + + Mineralisatieberekeningen zijn niet beschikbaar voor + bufferstroken. + + + + ) + } + + const { b_id, b_id_farm, calendar, completeness, asyncData } = loaderData + + return ( +
+ }> + + +
+ ) +} + +function MineralisatieFieldContent({ + asyncData, + b_id, + b_id_farm, + calendar, + completeness, +}: { + asyncData: Promise<{ results: NSupplyResult[]; insights: string[] }> + b_id: string + b_id_farm: string + calendar: string + completeness: DataCompleteness +}) { + const { results, insights } = use(asyncData) + + const series = results.map((r) => ({ + method: r.method, + data: r.data, + error: r.error, + })) + + return ( + <> + {/* Insights — prominent, above the chart */} + {insights.length > 0 && ( + + + + + Inzichten + + + + {insights.map((insight, i) => ( +

+ {insight} +

+ ))} +
+
+ )} + + + + Mineralisatiecurve + + Cumulatieve N-levering (kg N/ha) — vergelijking van + MINIP, PMN en Century methoden + + + + + + + +
+ + +
+ + {/* DYNA Call-to-Action */} + + + Dynamisch N-advies beschikbaar (bèta) + + + Bereken gedetailleerd N-opname vs. beschikbaarheid + advies met het DYNA-model. + + + + + + + ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx new file mode 100644 index 000000000..267097b41 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx @@ -0,0 +1,365 @@ +import { + getFertilizerApplications, + getFertilizers, + getField, + getGrazingIntention, + getCultivations, + type FertilizerApplication, +} from "@nmi-agro/fdm-core" +import { Slash } from "lucide-react" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, +} from "react-router" +import { DynaAdviceCard } from "~/components/blocks/mineralisatie/dyna-advice" +import { DynaBalanceCard } from "~/components/blocks/mineralisatie/dyna-balance" +import { DynaChart } from "~/components/blocks/mineralisatie/dyna-chart" +import { LeachingChart } from "~/components/blocks/mineralisatie/leaching-chart" +import { DynaFallback } from "~/components/blocks/mineralisatie/skeletons" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/components/ui/empty" +import { + type DynaResult, + getDynaForField, +} from "~/integrations/mineralisatie.server" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +export const meta: MetaFunction = ({ data: loaderData }) => { + const name = loaderData?.field?.b_name ?? "Perceel" + return [ + { + title: `${name} — DYNA | Mineralisatie | ${clientConfig.name}`, + }, + { + name: "description", + content: `DYNA dynamisch N-advies voor ${name}.`, + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + const b_id = params.b_id + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + if (!b_id) { + throw data("invalid: b_id", { + status: 400, + statusText: "invalid: b_id", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + + const field = await getField(fdm, session.principal_id, b_id) + if (!field) { + throw data("not found: b_id", { + status: 404, + statusText: "not found: b_id", + }) + } + if (field.b_bufferstrip) { + return { + isBufferStrip: true as const, + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + } + } + + // Determine farm sector from grazing intention + const isGrazing = await getGrazingIntention( + fdm, + session.principal_id, + b_id_farm, + year, + ) + const farmSector = isGrazing ? "dairy" : "arable" + + // Get fertilizer applications, fertilizer properties, and cultivations in parallel + const [applications, fertilizers, cultivations] = await Promise.all([ + getFertilizerApplications( + fdm, + session.principal_id, + b_id, + timeframe, + ), + getFertilizers(fdm, session.principal_id, b_id_farm), + getCultivations(fdm, session.principal_id, b_id, timeframe), + ]) + + // Build a map from p_id → full fertilizer properties for quick lookup + const fertilizerMap = new Map(fertilizers.map((f) => [f.p_id, f])) + + // Map fdm-core FertilizerApplication → DYNA fertilizer input + const dynaFertilizers = applications.map((app: FertilizerApplication) => { + const props = fertilizerMap.get(app.p_id) + return { + p_id: app.p_id, + p_n_rt: props?.p_n_rt ?? null, + p_n_if: props?.p_n_if ?? null, + p_n_of: props?.p_n_of ?? null, + p_n_wc: props?.p_n_wc ?? null, + p_p_rt: props?.p_p_rt ?? null, + p_k_rt: props?.p_k_rt ?? null, + p_dm: props?.p_dm ?? null, + p_om: props?.p_om ?? null, + p_date: app.p_app_date, + p_dose: app.p_app_amount, + p_app_method: app.p_app_method ?? null, + } + }) + + // Stream DYNA calculation + const asyncData = getDynaForField({ + principal_id: session.principal_id, + b_id, + b_id_farm, + timeframe, + farmSector, + fertilizers: dynaFertilizers, + }) + + // Build chart events: sowing, harvest, and fertilizer applications + const fertilizerNameMap = new Map( + fertilizers.map((f) => [f.p_id, f.p_name_nl ?? f.p_id]), + ) + type ChartEvent = { + date: string + type: "sowing" | "harvest" | "fertilizer" + label: string + } + const chartEvents: ChartEvent[] = [] + for (const c of cultivations) { + if (c.b_lu_start) { + chartEvents.push({ + date: c.b_lu_start.toISOString().split("T")[0] ?? "", + type: "sowing", + label: c.b_lu_name ?? "Zaai", + }) + } + if (c.b_lu_end) { + chartEvents.push({ + date: c.b_lu_end.toISOString().split("T")[0] ?? "", + type: "harvest", + label: c.b_lu_name ?? "Oogst", + }) + } + } + for (const app of applications) { + if (app.p_app_date) { + const name = + fertilizerNameMap.get(app.p_id) ?? + app.p_name_nl ?? + "Mest" + chartEvents.push({ + date: app.p_app_date.toISOString().split("T")[0] ?? "", + type: "fertilizer", + label: `${app.p_app_amount ?? "?"} kg — ${name}`, + }) + } + } + + return { + isBufferStrip: false as const, + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + chartEvents, + asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function DynaPage() { + const loaderData = useLoaderData() + + if (loaderData.isBufferStrip) { + return ( + + + + + + Niet beschikbaar voor bufferstroken + + Mineralisatieberekeningen zijn niet beschikbaar voor + bufferstroken. + + + + ) + } + + const { asyncData, chartEvents } = loaderData + + return ( +
+ }> + + +
+ ) +} + +function DynaContent({ + asyncData, + year, + chartEvents, +}: { + asyncData: Promise + year: number + chartEvents: { date: string; type: "sowing" | "harvest" | "fertilizer"; label: string }[] +}) { + const result = use(asyncData) + const { + calculationDyna, + nitrogenBalance, + fertilizingRecommendations, + harvestingRecommendation, + } = result + + // Filter to the selected year only + const yearData = calculationDyna.filter( + (d) => new Date(d.b_date_calculation).getFullYear() === year, + ) + + // KPI values — use year-filtered data + const lastPoint = yearData[yearData.length - 1] + const today = new Date().toISOString().split("T")[0] ?? "" + const todayPoint = yearData.find((d) => d.b_date_calculation >= today) + + const totalLeaching = lastPoint?.b_no3_leach ?? 0 + const currentNAvailability = todayPoint?.b_nw ?? lastPoint?.b_nw ?? 0 + const currentUptake = todayPoint?.b_n_uptake ?? lastPoint?.b_n_uptake ?? 0 + + return ( + <> + {/* KPI cards */} +
+ + + N aanbod totaal + + +

+ {nitrogenBalance.b_nw.toFixed(1)} +

+

+ kg N/ha/jaar +

+
+
+ + + N beschikbaar nu + + +

+ {currentNAvailability.toFixed(1)} +

+

kg N/ha

+
+
+ + + N opname nu + + +

+ {currentUptake.toFixed(1)} +

+

kg N/ha

+
+
+ + + NO₃ uitspoeling + + +

+ {totalLeaching.toFixed(1)} +

+

+ kg NO₃/ha (cumulatief) +

+
+
+
+ + {/* DYNA chart */} + + + N-dynamiek + + N-beschikbaarheid (bandbreedte en verwachting) versus + N-opname door het gewas + + + + + + + + {/* Balance and advice side by side */} +
+ + +
+ + {/* Leaching chart */} + + + NO₃ uitspoeling + + Cumulatieve nitraatuitspoeling (kg NO₃/ha) + + + + + + + + ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx index ca3273fdc..60b335216 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx @@ -1,300 +1,5 @@ -import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" -import { ArrowRight, Lightbulb } from "lucide-react" -import { Suspense, use } from "react" -import { - data, - type LoaderFunctionArgs, - type MetaFunction, - NavLink, - useLoaderData, -} from "react-router" -import { DataCompletenessCard } from "~/components/blocks/mineralisatie/data-completeness" -import { FieldMineralisatieChart } from "~/components/blocks/mineralisatie/mineralisatie-chart" -import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralisatie/nsupply-kpi" -import { MineralisatieFieldDetailFallback } from "~/components/blocks/mineralisatie/skeletons" -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" -import { Button } from "~/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card" -import { - assessDataCompleteness, - type DataCompleteness, - generateInsights, - getNSupplyForField, - type NSupplyMethod, - type NSupplyResult, -} from "~/integrations/mineralisatie.server" -import { getSession } from "~/lib/auth.server" -import { getTimeframe } from "~/lib/calendar" -import { clientConfig } from "~/lib/config" -import { handleLoaderError } from "~/lib/error" -import { fdm } from "~/lib/fdm.server" +import { Outlet } from "react-router" -const METHODS: NSupplyMethod[] = ["minip", "pmn", "century"] - -export const meta: MetaFunction = ({ data: loaderData }) => { - const name = loaderData?.field?.b_name ?? "Perceel" - return [ - { - title: `${name} | Mineralisatie | ${clientConfig.name}`, - }, - { - name: "description", - content: `Mineralisatiedetails voor ${name}.`, - }, - ] -} - -export async function loader({ request, params }: LoaderFunctionArgs) { - try { - const b_id_farm = params.b_id_farm - const b_id = params.b_id - if (!b_id_farm) { - throw data("invalid: b_id_farm", { - status: 400, - statusText: "invalid: b_id_farm", - }) - } - if (!b_id) { - throw data("invalid: b_id", { - status: 400, - statusText: "invalid: b_id", - }) - } - - const session = await getSession(request) - const timeframe = getTimeframe(params) - - const field = await getField(fdm, session.principal_id, b_id) - if (!field) { - throw data("not found: b_id", { - status: 404, - statusText: "not found: b_id", - }) - } - - // Get soil data - const soilDataArray = await getCurrentSoilData( - fdm, - session.principal_id, - b_id, - ) - const soilData: Record = {} - const soilMeta: Record = {} - - if (soilDataArray && soilDataArray.length > 0) { - for (const entry of soilDataArray) { - if (entry.parameter && entry.value !== undefined) { - soilData[entry.parameter] = entry.value as - | number - | string - | null - | undefined - soilMeta[entry.parameter] = { - source: entry.a_source ?? undefined, - date: entry.b_sampling_date ?? undefined, - } - } - } - } - - const organicMatter = - soilData.a_som_loi != null ? Number(soilData.a_som_loi) : undefined - const soilType = - soilData.b_soiltype_agr != null - ? String(soilData.b_soiltype_agr) - : undefined - const completeness = assessDataCompleteness(soilData, "minip", soilMeta) - - // Fetch all 3 methods in parallel (streamed) - const asyncData = (async () => { - const results = await Promise.all( - METHODS.map(async (method): Promise => { - try { - return await getNSupplyForField({ - principal_id: session.principal_id, - b_id, - method, - timeframe, - }) - } catch (err) { - return { - b_id, - b_name: field.b_name ?? b_id, - method, - data: [], - totalAnnualN: 0, - completeness: { - available: [], - missing: [], - estimated: [], - score: 0, - }, - error: - err instanceof Error - ? err.message - : String(err), - } - } - }), - ) - - const primaryResult = - results.find((r) => r.method === "minip" && !r.error) ?? - results.find((r) => !r.error) - - const now = new Date() - const currentDoy = Math.floor( - (now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / - (1000 * 60 * 60 * 24), - ) - - const insights = primaryResult - ? generateInsights(primaryResult, undefined, currentDoy) - : [] - - return { results, insights } - })() - - return { - field, - b_id, - b_id_farm, - calendar: params.calendar ?? "", - soilData, - organicMatter, - soilType, - completeness, - asyncData, - } - } catch (error) { - throw handleLoaderError(error) - } -} - -export default function MineralisatieFieldDetail() { - const loaderData = useLoaderData() - const { - b_id, - b_id_farm, - calendar, - completeness, - asyncData, - } = loaderData - - return ( -
- }> - - -
- ) -} - -function MineralisatieFieldContent({ - asyncData, - b_id, - b_id_farm, - calendar, - completeness, -}: { - asyncData: Promise<{ results: NSupplyResult[]; insights: string[] }> - b_id: string - b_id_farm: string - calendar: string - completeness: DataCompleteness -}) { - const { results, insights } = use(asyncData) - - const series = results.map((r) => ({ - method: r.method, - data: r.data, - error: r.error, - })) - - return ( - <> - {/* Insights — prominent, above the chart */} - {insights.length > 0 && ( - - - - - Inzichten - - - - {insights.map((insight, i) => ( -

- {insight} -

- ))} -
-
- )} - - - - Mineralisatiecurve - - Cumulatieve N-levering (kg N/ha) — vergelijking van - MINIP, PMN en Century methoden - - - - - - - -
- - -
- - {/* DYNA Call-to-Action */} - - Dynamisch N-advies beschikbaar (bèta) - - - Bereken gedetailleerd N-opname vs. beschikbaarheid - advies met het DYNA-model. - - - - - - ) +export default function MineralisatieFieldLayout() { + return } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx index 783103d32..9b0f50dc5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx @@ -62,12 +62,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - const fields = await getFields( + const allFields = await getFields( fdm, session.principal_id, b_id_farm, timeframe, ) + const fields = allFields.filter((f) => !f.b_bufferstrip) // Read method from search params (default: minip) const url = new URL(request.url) From 300f7f69cd779006d901730695a69e6bd48a3f52 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:49:36 +0200 Subject: [PATCH 03/22] refactor: migrate mineralisation logic to fdm-calculator --- .../integrations/mineralisatie.server.test.ts | 314 ----- .../app/integrations/mineralisatie.server.ts | 1013 ++++------------- ...arm.$calendar.mineralisatie.$b_id.dyna.tsx | 9 +- fdm-app/package.json | 3 +- fdm-app/vitest.config.ts | 11 - fdm-calculator/package.json | 3 +- fdm-calculator/src/index.ts | 23 + .../src/mineralisatie/assessment.ts | 149 +++ fdm-calculator/src/mineralisatie/builders.ts | 455 ++++++++ fdm-calculator/src/mineralisatie/dyna.ts | 156 +++ fdm-calculator/src/mineralisatie/errors.ts | 44 + fdm-calculator/src/mineralisatie/index.ts | 54 + fdm-calculator/src/mineralisatie/nsupply.ts | 155 +++ fdm-calculator/src/mineralisatie/schemas.ts | 137 +++ fdm-calculator/src/mineralisatie/types.d.ts | 220 ++++ pnpm-lock.yaml | 6 +- 16 files changed, 1639 insertions(+), 1113 deletions(-) delete mode 100644 fdm-app/app/integrations/mineralisatie.server.test.ts delete mode 100644 fdm-app/vitest.config.ts create mode 100644 fdm-calculator/src/mineralisatie/assessment.ts create mode 100644 fdm-calculator/src/mineralisatie/builders.ts create mode 100644 fdm-calculator/src/mineralisatie/dyna.ts create mode 100644 fdm-calculator/src/mineralisatie/errors.ts create mode 100644 fdm-calculator/src/mineralisatie/index.ts create mode 100644 fdm-calculator/src/mineralisatie/nsupply.ts create mode 100644 fdm-calculator/src/mineralisatie/schemas.ts create mode 100644 fdm-calculator/src/mineralisatie/types.d.ts diff --git a/fdm-app/app/integrations/mineralisatie.server.test.ts b/fdm-app/app/integrations/mineralisatie.server.test.ts deleted file mode 100644 index c7282f488..000000000 --- a/fdm-app/app/integrations/mineralisatie.server.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -// Mock server-only modules so the pure functions can be tested in isolation -vi.mock("~/lib/fdm.server", () => ({ fdm: {} })) -vi.mock("~/integrations/nmi.server", () => ({ getNmiApiKey: () => undefined })) - -import { - assessDataCompleteness, - buildNSupplyRequest, - generateInsights, - NmiApiError, - type DataCompleteness, - type NSupplyResult, -} from "./mineralisatie.server" - -// ─── assessDataCompleteness ─────────────────────────────────────────────────── - -describe("assessDataCompleteness", () => { - describe("MINIP method", () => { - it("returns score 100 when all required and optional params are present", () => { - const soilData = { - a_som_loi: 3.5, - a_clay_mi: 18, - a_silt_mi: 22, - a_sand_mi: 60, - b_soiltype_agr: "zand", - } - const result = assessDataCompleteness(soilData, "minip") - expect(result.score).toBe(100) - expect(result.missing).toHaveLength(0) - expect(result.estimated).toHaveLength(0) - }) - - it("returns score 80 when all required but no optional params present", () => { - const soilData = { - a_som_loi: 3.5, - a_clay_mi: 18, - a_silt_mi: 22, - } - const result = assessDataCompleteness(soilData, "minip") - expect(result.score).toBe(80) - expect(result.missing).toHaveLength(0) - expect(result.estimated).toEqual( - expect.arrayContaining(["a_sand_mi", "b_soiltype_agr"]), - ) - }) - - it("returns low score when required params are missing", () => { - const soilData = { - a_clay_mi: 18, - // a_som_loi and a_silt_mi missing - } - const result = assessDataCompleteness(soilData, "minip") - expect(result.missing).toContain("a_som_loi") - expect(result.missing).toContain("a_silt_mi") - expect(result.score).toBeLessThan(40) - }) - - it("includes available params with their values", () => { - const soilData = { a_som_loi: 4.2, a_clay_mi: 20, a_silt_mi: 15 } - const result = assessDataCompleteness(soilData, "minip") - expect(result.available).toEqual( - expect.arrayContaining([ - expect.objectContaining({ param: "a_som_loi", value: 4.2 }), - expect.objectContaining({ param: "a_clay_mi", value: 20 }), - ]), - ) - }) - }) - - describe("PMN method", () => { - it("requires a_n_pmn and a_clay_mi", () => { - const soilData = { a_clay_mi: 15 } - const result = assessDataCompleteness(soilData, "pmn") - expect(result.missing).toContain("a_n_pmn") - expect(result.score).toBeLessThan(50) - }) - - it("full score with n_pmn and clay plus optionals", () => { - const soilData = { - a_n_pmn: 42, - a_clay_mi: 15, - a_sand_mi: 70, - b_soiltype_agr: "zand", - } - const result = assessDataCompleteness(soilData, "pmn") - expect(result.score).toBe(100) - }) - }) - - describe("Century method", () => { - it("requires a_c_of, a_cn_fr, a_clay_mi, a_silt_mi", () => { - const soilData = {} - const result = assessDataCompleteness(soilData, "century") - expect(result.missing).toEqual( - expect.arrayContaining([ - "a_c_of", - "a_cn_fr", - "a_clay_mi", - "a_silt_mi", - ]), - ) - expect(result.score).toBe(0) - }) - }) -}) - -// ─── buildNSupplyRequest ────────────────────────────────────────────────────── - -describe("buildNSupplyRequest", () => { - const baseField = { - b_centroid: [5.1234, 52.5678] as [number, number], - } - const baseSoilData = { - a_som_loi: 3.5, - a_clay_mi: 18, - a_silt_mi: 22, - } - const baseCultivations = [{ b_lu_catalogue: "nl_233" }] - const baseTimeframe = { - start: new Date("2024-01-01"), - end: new Date("2024-12-31"), - } - - it("maps centroid to a_lat and a_lon", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.a_lon).toBe(5.1234) - expect(req.a_lat).toBe(52.5678) - }) - - it("strips nl_ prefix from b_lu_catalogue and converts to number", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.b_lu_brp).toBe(233) - }) - - it("sets d_n_supply_method from method argument", () => { - for (const method of ["minip", "pmn", "century"] as const) { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - baseCultivations, - method, - baseTimeframe, - ) - expect(req.d_n_supply_method).toBe(method) - } - }) - - it("includes timeframe dates when provided", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.d_start).toBe("2024-01-01") - expect(req.d_end).toBe("2024-12-31") - }) - - it("uses default depth 0.3 when a_depth_lower is absent", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.a_depth).toBe(0.3) - }) - - it("uses a_depth_lower from soilData when present", () => { - const req = buildNSupplyRequest( - baseField, - { ...baseSoilData, a_depth_lower: 25 }, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.a_depth).toBe(25) - }) - - it("omits undefined soil params", () => { - const req = buildNSupplyRequest( - baseField, - { a_som_loi: 3.5 }, // only som_loi - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.a_clay_mi).toBeUndefined() - expect(req.a_n_pmn).toBeUndefined() - }) - - it("handles missing centroid gracefully", () => { - const req = buildNSupplyRequest( - { b_centroid: null }, - baseSoilData, - baseCultivations, - "minip", - baseTimeframe, - ) - expect(req.a_lat).toBeUndefined() - expect(req.a_lon).toBeUndefined() - }) - - it("handles BRP code without nl_ prefix", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - [{ b_lu_catalogue: "233" }], - "minip", - baseTimeframe, - ) - expect(req.b_lu_brp).toBe(233) - }) - - it("skips non-numeric BRP codes", () => { - const req = buildNSupplyRequest( - baseField, - baseSoilData, - [{ b_lu_catalogue: "nl_abc" }], - "minip", - baseTimeframe, - ) - expect(req.b_lu_brp).toBeUndefined() - }) -}) - -// ─── generateInsights ───────────────────────────────────────────────────────── - -describe("generateInsights", () => { - function makeResult( - totalAnnualN: number, - score = 90, - ): NSupplyResult { - const data = Array.from({ length: 365 }, (_, i) => ({ - doy: i + 1, - d_n_supply_actual: (totalAnnualN / 365) * (i + 1), - })) - const completeness: DataCompleteness = { - available: [], - missing: [], - estimated: [], - score, - } - return { - b_id: "field-1", - b_name: "Testperceel", - method: "minip", - data, - totalAnnualN, - completeness, - } - } - - it("generates high-N insight when field is 120%+ above farm average", () => { - const result = makeResult(180) - const insights = generateInsights(result, 120, 100) - expect(insights.some((i) => i.includes("hoger dan het bedrijfsgemiddelde"))).toBe(true) - expect(insights.some((i) => i.includes("kunstmestgift te verlagen"))).toBe(true) - }) - - it("generates low-N insight when field is below 80% of farm average", () => { - const result = makeResult(80) - const insights = generateInsights(result, 120, 100) - expect(insights.some((i) => i.includes("laag N-leverend vermogen"))).toBe(true) - }) - - it("generates completeness warning when score < 70", () => { - const result = makeResult(120, 50) - const insights = generateInsights(result, 120, 100) - expect(insights.some((i) => i.includes("Betrouwbaarheid beperkt"))).toBe(true) - expect(insights.some((i) => i.includes("50%"))).toBe(true) - }) - - it("generates season progress insight with kg values", () => { - const result = makeResult(200) - const insights = generateInsights(result, 200, 180) - const progressInsight = insights.find((i) => i.includes("gemineraliseerd")) - expect(progressInsight).toBeDefined() - expect(progressInsight).toMatch(/kg N\/ha/) - }) - - it("returns no farm-comparison insight when farmAvg is undefined", () => { - const result = makeResult(200) - const insights = generateInsights(result, undefined, 100) - expect(insights.some((i) => i.includes("bedrijfsgemiddelde"))).toBe(false) - }) -}) - -// ─── NmiApiError ───────────────────────────────────────────────────────────── - -describe("NmiApiError", () => { - it("sets status and message correctly", () => { - const err = new NmiApiError(422, "Onvoldoende gegevens") - expect(err.status).toBe(422) - expect(err.message).toBe("Onvoldoende gegevens") - expect(err.name).toBe("NmiApiError") - expect(err).toBeInstanceOf(Error) - }) -}) diff --git a/fdm-app/app/integrations/mineralisatie.server.ts b/fdm-app/app/integrations/mineralisatie.server.ts index e01ebcc10..dd15704e4 100644 --- a/fdm-app/app/integrations/mineralisatie.server.ts +++ b/fdm-app/app/integrations/mineralisatie.server.ts @@ -1,3 +1,29 @@ +/** + * @file mineralisatie.server.ts + * + * Server-side orchestration layer for the Mineralisatie (Nitrogen Mineralization) feature. + * + * This module acts as a thin bridge between the FDM database and the calculation + * functions in `@nmi-agro/fdm-calculator`. It is responsible for: + * 1. Fetching the required FDM domain objects (field, soil data, cultivations, fertilizers). + * 2. Building the NMI API request bodies via the calculator builders. + * 3. Calling the cached calculator functions (`getNSupply`, `getDyna`). + * 4. Farm-level aggregation (running per-field calculations in parallel). + * 5. Generating Dutch-language user-facing insights from the results. + * + * **All core calculation logic, types, schemas, and error classes live in + * `@nmi-agro/fdm-calculator/mineralisatie`.** This file re-exports the types + * that are needed by the app's route files and components. + */ + +import { + type NSupplyComputeInput, + assessDataCompleteness, + buildDynaRequest, + buildNSupplyRequest, + getDyna, + getNSupply, +} from "@nmi-agro/fdm-calculator" import { getCultivations, getCultivationsFromCatalogue, @@ -5,664 +31,93 @@ import { getField, getFields, type Timeframe, - withCalculationCache, } from "@nmi-agro/fdm-core" -import { z } from "zod" import { getNmiApiKey } from "~/integrations/nmi.server" -import { getDefaultCultivation } from "~/lib/cultivation-helpers" import { fdm } from "~/lib/fdm.server" -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface NSupplyDataPoint { - doy: number - d_n_supply_actual: number -} - -export interface DataCompleteness { - available: { - param: string - value: number | string - source?: string - date?: Date - }[] - missing: string[] - estimated: string[] - score: number // 0–100 -} - -export interface NSupplyResult { - b_id: string - b_name: string - method: NSupplyMethod - data: NSupplyDataPoint[] - totalAnnualN: number // last doy value (kg N/ha/yr) - completeness: DataCompleteness - error?: string -} - -export interface DynaDailyPoint { - b_date_calculation: string - b_nw: number - b_nw_min: number - b_nw_max: number - b_nw_recommended: number - b_n_uptake: number - b_n_uptake_min: number - b_n_uptake_max: number - b_n_uptake_recommended: number - b_no3_leach: number - b_no3_leach_min: number - b_no3_leach_max: number - b_no3_leach_recommended: number -} - -export interface DynaNitrogenBalance { - b_nw: number - b_n_uptake: number - b_n_greenmanure: number - b_n_fertilizer_organic: number - b_n_fertilizer_artificial: number - b_n_fertilizer_preceeding: number -} - -export interface DynaFertilizerAdvice { - b_n_recommended: number - b_date_recommended: string - b_n_remaining: number -} - -export interface DynaResult { - b_id: string - calculationDyna: DynaDailyPoint[] - nitrogenBalance: DynaNitrogenBalance - fertilizingRecommendations: DynaFertilizerAdvice | null - harvestingRecommendation: { b_date_harvest: string } | null -} - -// ─── Zod Schemas ───────────────────────────────────────────────────────────── - -const nsupplyDataPointSchema = z.object({ - doy: z.number().int().min(1).max(366), - d_n_supply_actual: z.number(), -}) - -const nsupplyResponseSchema = z.object({ - data: z.array(nsupplyDataPointSchema), -}) - -const dynaDailyPointSchema = z.object({ - b_date_calculation: z.string(), - b_nw: z.number(), - b_nw_min: z.number(), - b_nw_max: z.number(), - b_nw_recommended: z.number(), - b_n_uptake: z.number(), - b_n_uptake_min: z.number(), - b_n_uptake_max: z.number(), - b_n_uptake_recommended: z.number(), - b_no3_leach: z.number(), - b_no3_leach_min: z.number(), - b_no3_leach_max: z.number(), - b_no3_leach_recommended: z.number(), -}) - -const dynaResponseDataSchema = z - .object({ - calculation_dyna: z.array(dynaDailyPointSchema), - nitrogen_balance: z.object({ - b_nw: z.number(), - b_n_uptake: z.number(), - b_n_greenmanure: z.number(), - b_n_fertilizer_organic: z.number(), - b_n_fertilizer_artificial: z.number(), - b_n_fertilizer_preceeding: z.number(), - }), - fertilizing_recommendations: z - .object({ - b_n_recommended: z.number(), - b_date_recommended: z.string(), - b_n_remaining: z.number(), - }) - .nullable(), - harvesting_recommendations: z - .object({ - b_date_harvest: z.string(), - }) - .nullable(), - }) - .transform((d) => ({ - calculationDyna: d.calculation_dyna, - nitrogenBalance: d.nitrogen_balance, - fertilizingRecommendations: d.fertilizing_recommendations, - harvestingRecommendation: d.harvesting_recommendations, - })) - -const dynaResponseSchema = z.object({ - data: dynaResponseDataSchema, -}) - -// ─── Cache ──────────────────────────────────────────────────────────────────── - -interface CacheEntry { - data: T - expiresAt: number -} - -const nsupplyCache = new Map>() - -const NSUPPLY_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours - -function getNsupplyCacheKey( - b_id: string, - method: NSupplyMethod, - year: number, -): string { - return `nsupply:${b_id}:${method}:${year}` -} - -// ─── DYNA DB-backed cache ───────────────────────────────────────────────────── - -interface DynaComputeInput { - b_id: string - nmiApiKey: string - requestBody: unknown -} - -async function _callDynaApi({ - b_id, - nmiApiKey, - requestBody, -}: DynaComputeInput): Promise { - const response = await fetch( - "https://api.nmi-agro.nl/bemestingsplan/dyna", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${nmiApiKey}`, - }, - body: JSON.stringify(requestBody), - }, - ) - - if (!response.ok) { - const errorText = await response.text() - if (response.status === 422) { - throw new NmiApiError( - 422, - `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, - ) - } - if (response.status === 503) { - throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") - } - throw new NmiApiError( - response.status, - `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, - ) - } - - const rawJson = await response.json() - const parsed = dynaResponseSchema.safeParse(rawJson) - if (!parsed.success) { - throw new Error( - `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, - ) - } - - return { b_id, ...parsed.data.data } -} - -const _cachedCallDynaApi = withCalculationCache( - _callDynaApi, - "callDynaApi", - "v1.0.0", - ["nmiApiKey"], -) - -// ─── Method-specific parameter requirements ─────────────────────────────────── - -const methodRequirements: Record< +// Re-export types consumed by route files and UI components +export type { + DataCompleteness, + DynaFertilizerAdvice, + DynaNitrogenBalance, + DynaResult, + DynaDailyPoint, + NSupplyDataPoint, NSupplyMethod, - { required: string[]; optional: string[] } -> = { - minip: { - required: ["a_som_loi", "a_clay_mi", "a_silt_mi"], - optional: ["a_sand_mi", "b_soiltype_agr"], - }, - pmn: { - required: ["a_n_pmn", "a_clay_mi"], - optional: ["a_sand_mi", "b_soiltype_agr"], - }, - century: { - required: ["a_c_of", "a_cn_fr", "a_clay_mi", "a_silt_mi"], - optional: ["a_sand_mi", "b_soiltype_agr"], - }, -} - -// ─── Data Completeness ──────────────────────────────────────────────────────── - -/** - * Evaluates which soil parameters are available, missing, or estimated - * for the chosen mineralization method and returns a completeness score. - * Pass `soilMeta` to include per-parameter source and sampling date. - */ -export function assessDataCompleteness( - soilData: Record, - method: NSupplyMethod, - soilMeta?: Record, -): DataCompleteness { - const { required, optional } = methodRequirements[method] - - const available: DataCompleteness["available"] = [] - const missing: string[] = [] - const estimated: string[] = [] - - for (const param of required) { - const value = soilData[param] - if (value !== null && value !== undefined) { - available.push({ - param, - value: value as number | string, - source: soilMeta?.[param]?.source, - date: soilMeta?.[param]?.date, - }) - } else { - missing.push(param) - } - } + NSupplyResult, +} from "@nmi-agro/fdm-calculator" +export { + NmiApiError, + assessDataCompleteness, + buildNSupplyRequest, +} from "@nmi-agro/fdm-calculator" - for (const param of optional) { - const value = soilData[param] - if (value !== null && value !== undefined) { - available.push({ - param, - value: value as number | string, - source: soilMeta?.[param]?.source, - date: soilMeta?.[param]?.date, - }) - } else { - estimated.push(param) - } - } - - const NMI_SOURCE = "nl-other-nmi" - - const availableRequired = required.filter( - (p) => - soilData[p] !== null && - soilData[p] !== undefined && - soilMeta?.[p]?.source !== NMI_SOURCE, - ).length - const availableOptional = optional.filter( - (p) => - soilData[p] !== null && - soilData[p] !== undefined && - soilMeta?.[p]?.source !== NMI_SOURCE, - ).length - - const score = - required.length > 0 - ? (availableRequired / required.length) * 80 + - (optional.length > 0 - ? (availableOptional / optional.length) * 20 - : 20) - : 100 - - return { available, missing, estimated, score: Math.round(score) } -} - -// ─── Insights Generation ────────────────────────────────────────────────────── +// ─── Helpers ────────────────────────────────────────────────────────────────── /** - * Generates Dutch-language insights comparing a field's N supply to the farm average - * and reporting season progress. + * Converts a day-of-year (DOY) integer to a Dutch-locale date string. + * + * @param doy - Day of year (1–366). + * @param year - Calendar year. + * @returns Formatted date string, e.g. `"15 april"`. + * + * @internal */ -export function generateInsights( - nsupply: NSupplyResult, - farmAvgN: number | undefined, - currentDoy: number, -): string[] { - const insights: string[] = [] - const totalN = nsupply.totalAnnualN - - if (farmAvgN !== undefined && farmAvgN > 0) { - const ratio = totalN / farmAvgN - if (ratio > 1.2) { - const pct = Math.round((ratio - 1) * 100) - insights.push( - `Het N-leverend vermogen is ${pct}% hoger dan het bedrijfsgemiddelde. Overweeg de kunstmestgift te verlagen.`, - ) - } else if (ratio < 0.8) { - insights.push( - "Relatief laag N-leverend vermogen. Verhogen van het organische stofgehalte kan de mineralisatie verbeteren.", - ) - } - } - - if (nsupply.completeness.score < 70) { - insights.push( - `Betrouwbaarheid beperkt (${nsupply.completeness.score}%). Een uitgebreidere bodemanalyse wordt aanbevolen.`, - ) - } - - const currentPoint = nsupply.data.find((d) => d.doy >= currentDoy) - if (currentPoint) { - const remaining = totalN - currentPoint.d_n_supply_actual - const date = doyToDateString(currentDoy, new Date().getFullYear()) - insights.push( - `Op ${date} is circa ${Math.round(currentPoint.d_n_supply_actual)} kg N/ha gemineraliseerd. Tot einde groeiseizoen wordt nog ~${Math.round(remaining)} kg N/ha verwacht.`, - ) - } - - return insights -} - function doyToDateString(doy: number, year: number): string { const date = new Date(year, 0) date.setDate(doy) return date.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) } -// ─── Request Builders ───────────────────────────────────────────────────────── - /** - * Maps FDM field, soil, and cultivation data to a NMI nsupply API request body. + * Builds a flat `soilData` map from the array returned by `getCurrentSoilData`, + * and captures the sampling depth from the first entry. + * + * @internal */ -export function buildNSupplyRequest( - field: { - b_centroid: [number, number] | null | undefined - b_area?: number | null - }, - soilData: Record, - cultivations: { b_lu_catalogue: string | null | undefined }[], - method: NSupplyMethod, - timeframe: Timeframe, -): Record { - const centroid = field.b_centroid - const a_lon = centroid ? centroid[0] : undefined - const a_lat = centroid ? centroid[1] : undefined - - const b_lu_brp = cultivations - .filter((c) => c.b_lu_catalogue) - .map((c) => { - const code = (c.b_lu_catalogue ?? "").replace(/^nl_/, "") - const parsed = Number.parseInt(code, 10) - return Number.isNaN(parsed) ? undefined : parsed - }) - .find((v) => v !== undefined) - - const body: Record = { - d_n_supply_method: method, - } - - if (timeframe.start) { - body.d_start = timeframe.start.toISOString().split("T")[0] - } - if (timeframe.end) { - body.d_end = timeframe.end.toISOString().split("T")[0] - } - - if (a_lat !== undefined) body.a_lat = a_lat - if (a_lon !== undefined) body.a_lon = a_lon - if (b_lu_brp !== undefined) body.b_lu_brp = b_lu_brp - - const soilParams = [ - "a_som_loi", - "a_clay_mi", - "a_silt_mi", - "a_sand_mi", - "a_c_of", - "a_cn_fr", - "a_n_rt", - "a_n_pmn", - "b_soiltype_agr", - ] as const - - for (const param of soilParams) { - const value = soilData[param] - if (value !== null && value !== undefined) { - body[param] = value +function buildSoilDataMap( + soilDataArray: Awaited>, +): Record { + const soilData: Record = {} + if (!soilDataArray || soilDataArray.length === 0) return soilData + + for (const entry of soilDataArray) { + if (entry.parameter && entry.value !== undefined) { + soilData[entry.parameter] = entry.value as + | number + | string + | null + | undefined } } - - const aDepthLower = soilData.a_depth_lower - body.a_depth = - aDepthLower !== null && aDepthLower !== undefined - ? Number(aDepthLower) - : 0.3 - - return body -} - -/** - * Maps FDM data to a NMI dyna API request body. - * Follows the same nested structure as the /bemestingsplan/nutrient_balance endpoint. - */ -export function buildDynaRequest( - field: { - b_id?: string | null - b_centroid: [number, number] | null | undefined - b_area?: number | null - }, - soilData: Record, - cultivations: { - b_lu_catalogue: string | null | undefined - b_lu_start?: Date | null - b_lu_end?: Date | null - b_lu_croprotation?: string | null - m_cropresidue?: boolean | null - }[], - fertilizers: { - p_id: string - p_n_rt?: number | null - p_n_if?: number | null - p_n_of?: number | null - p_n_wc?: number | null - p_p_rt?: number | null - p_k_rt?: number | null - p_dm?: number | null - p_om?: number | null - p_date?: Date | null - p_dose?: number | null - p_app_method?: string | null - }[], - farmSector: string, - timeframe: Timeframe, - cropProperties?: { - b_lu_catalogue: string - b_lu_yield?: number | null - b_lu_n_harvestable?: number | null - b_lu_n_residue?: number | null - }[], -): Record { - const centroid = field.b_centroid - const a_lon = centroid ? centroid[0] : undefined - const a_lat = centroid ? centroid[1] : undefined - const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() - - // Build field object with coordinates and soil params - const fieldObj: Record = {} - if (field.b_id) fieldObj.b_id = field.b_id - if (a_lat !== undefined) fieldObj.a_lat = a_lat - if (a_lon !== undefined) fieldObj.a_lon = a_lon - - const soilParams = [ - "a_som_loi", - "a_clay_mi", - "a_silt_mi", - "a_sand_mi", - "a_c_of", - "a_cn_fr", - "a_n_rt", - "a_n_pmn", - "b_soiltype_agr", - ] as const - - for (const param of soilParams) { - const value = soilData[param] - if (value !== null && value !== undefined) { - fieldObj[param] = value - } - } - - const aDepthLower = soilData.a_depth_lower - fieldObj.a_depth = - aDepthLower !== null && aDepthLower !== undefined - ? Number(aDepthLower) - : 0.3 - - // Build amendments list for the rotation entry - const amendments = fertilizers - .filter((f) => f.p_date !== null && f.p_date !== undefined) - .map((f) => ({ - p_id: f.p_id, - p_dose: f.p_dose ?? 0, - p_app_method: f.p_app_method ?? "broadcasting", - p_date_fertilization: f.p_date?.toISOString().split("T")[0], - })) - - // Build one rotation entry per calendar year using the May 15th rule to - // identify the main cultivation. If no crop is active on May 15th, fall back - // to the first (earliest-starting) non-catchcrop in that year. - // Catchcrops become green-manure fields. Amendments only apply to the requested year. - const allYears = [ - ...new Set( - cultivations - .filter((c) => c.b_lu_catalogue && c.b_lu_start) - .map((c) => c.b_lu_start!.getFullYear()), - ), - ].sort((a, b) => a - b) - - const rotation = allYears - .map((rotationYear) => { - const yearCultivations = cultivations.filter( - (c) => - c.b_lu_catalogue && - c.b_lu_start?.getFullYear() === rotationYear, - ) - - // Use May 15th rule; fall back to first non-catchcrop in the year - const mainCrop = - getDefaultCultivation( - cultivations as Parameters[0], - rotationYear.toString(), - ) ?? - yearCultivations.find( - (c) => c.b_lu_croprotation !== "catchcrop", - ) ?? - yearCultivations[0] - - // Skip years where no main crop could be determined - if (!mainCrop?.b_lu_catalogue) return null - - // Look up yield from crop_properties for the harvests array - const cropProp = cropProperties?.find( - (cp) => cp.b_lu_catalogue === mainCrop.b_lu_catalogue, - ) - const harvests = mainCrop.b_lu_end - ? [ - { - b_date_harvest: mainCrop.b_lu_end - .toISOString() - .split("T")[0], - ...(cropProp?.b_lu_yield != null - ? { b_lu_yield: cropProp.b_lu_yield } - : {}), - }, - ] - : [] - - const greenManure = yearCultivations.find( - (c) => c.b_lu_croprotation === "catchcrop" && c !== mainCrop, - ) - return { - year: rotationYear, - b_lu: mainCrop.b_lu_catalogue, - b_lu_start: mainCrop.b_lu_start?.toISOString().split("T")[0], - harvests, - ...(mainCrop.m_cropresidue != null - ? { m_cropresidue: mainCrop.m_cropresidue } - : {}), - ...(greenManure?.b_lu_catalogue - ? { - b_lu_green: greenManure.b_lu_catalogue, - b_date_green_incorporation: greenManure.b_lu_end - ?.toISOString() - .split("T")[0], - } - : {}), - irrigation: [], - amendments: rotationYear === year ? amendments : [], - } - }) - .filter((entry) => entry !== null) - - // If no cultivations found, create a minimal rotation entry for the requested year - if (rotation.length === 0) { - rotation.push({ - year, - b_lu: undefined, - b_lu_start: timeframe.start?.toISOString().split("T")[0], - harvests: [], - irrigation: [], - amendments, - }) - } - - fieldObj.rotation = rotation - - // Build fertilizer_properties (unique p_ids with all available properties) - const seenIds = new Set() - const fertilizer_properties = fertilizers - .filter((f) => { - if (seenIds.has(f.p_id)) return false - seenIds.add(f.p_id) - return true - }) - .map((f) => { - const props: Record = { - p_id: f.p_id, - p_n_rt: f.p_n_rt ?? 0, - } - if (f.p_n_if != null) props.p_n_if = f.p_n_if - if (f.p_n_of != null) props.p_n_of = f.p_n_of - if (f.p_n_wc != null) props.p_n_wc = f.p_n_wc - if (f.p_p_rt != null) props.p_p_rt = f.p_p_rt - if (f.p_k_rt != null) props.p_k_rt = f.p_k_rt - if (f.p_dm != null) props.p_dm = f.p_dm - if (f.p_om != null) props.p_om = f.p_om - return props - }) - - // Build crop_properties from catalogue data - const builtCropProperties = - cropProperties && cropProperties.length > 0 - ? cropProperties - .filter((cp) => cp.b_lu_catalogue) - .map((cp) => ({ - b_lu: cp.b_lu_catalogue, - b_lu_yield: cp.b_lu_yield ?? null, - b_lu_n_harvestable: cp.b_lu_n_harvestable ?? null, - b_lu_n_residue: cp.b_lu_n_residue ?? null, - })) - : null - - return { - field: fieldObj, - farm: { sector: farmSector || "arable" }, - crop_properties: builtCropProperties, - fertilizer_properties: - fertilizer_properties.length > 0 ? fertilizer_properties : null, + const first = soilDataArray[0] + if (first?.a_depth_lower !== undefined) { + soilData.a_depth_lower = first.a_depth_lower } + return soilData } -// ─── Core API Calls ─────────────────────────────────────────────────────────── +// ─── Public API ─────────────────────────────────────────────────────────────── /** - * Fetches the N supply mineralization curve for a single field from the NMI API. - * Results are cached for 24 hours. + * Fetches the N supply mineralization curve for a single field. + * + * Orchestrates the full pipeline: + * 1. Retrieves field geometry, soil data, and cultivations from FDM. + * 2. Assesses data completeness for the chosen method. + * 3. Builds the nsupply API request body. + * 4. Delegates to {@link getNSupply} (DB-cached) from `@nmi-agro/fdm-calculator`. + * + * Results are automatically cached in the FDM database. The cache is + * invalidated whenever the underlying soil data, cultivations, or method changes. + * + * @param params.principal_id - Authenticated user / principal identifier. + * @param params.b_id - FDM field identifier. + * @param params.method - Mineralization model (`"minip"`, `"pmn"`, or `"century"`). + * @param params.timeframe - Calendar year window; used to set `d_start`/`d_end` in the request. + * @returns A fully populated {@link NSupplyResult} with 365/366 daily data points. + * @throws `Error` if the field is not found or the NMI API key is not configured. + * @throws {@link NmiApiError} on NMI API errors (422, 503, etc.). */ export async function getNSupplyForField({ principal_id, @@ -672,17 +127,9 @@ export async function getNSupplyForField({ }: { principal_id: string b_id: string - method: NSupplyMethod + method: import("@nmi-agro/fdm-calculator").NSupplyMethod timeframe: Timeframe -}): Promise { - const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() - const cacheKey = getNsupplyCacheKey(b_id, method, year) - - const cached = nsupplyCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return cached.data - } - +}): Promise { const nmiApiKey = getNmiApiKey() if (!nmiApiKey) { throw new Error("NMI API-sleutel niet geconfigureerd") @@ -693,27 +140,12 @@ export async function getNSupplyForField({ throw new Error(`Perceel niet gevonden: ${b_id}`) } - const soilDataArray = await getCurrentSoilData(fdm, principal_id, b_id) - const soilData: Record = {} - if (soilDataArray && soilDataArray.length > 0) { - for (const entry of soilDataArray) { - if (entry.parameter && entry.value !== undefined) { - soilData[entry.parameter] = entry.value as - | number - | string - | null - | undefined - } - } - // Capture sampling depth from first entry - const first = soilDataArray[0] - if (first?.a_depth_lower !== undefined) { - soilData.a_depth_lower = first.a_depth_lower - } - } - - const cultivations = await getCultivations(fdm, principal_id, b_id) + const [soilDataArray, cultivations] = await Promise.all([ + getCurrentSoilData(fdm, principal_id, b_id), + getCultivations(fdm, principal_id, b_id), + ]) + const soilData = buildSoilDataMap(soilDataArray) const completeness = assessDataCompleteness(soilData, method) const requestBody = buildNSupplyRequest( field, @@ -723,72 +155,31 @@ export async function getNSupplyForField({ timeframe, ) - const response = await fetch( - "https://api.nmi-agro.nl/bemestingsplan/nsupply", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${nmiApiKey}`, - }, - body: JSON.stringify(requestBody), - }, - ) - - if (!response.ok) { - const errorText = await response.text() - if (response.status === 422) { - throw new NmiApiError( - 422, - `Onvoldoende bodemgegevens voor mineralisatieberekening. ${errorText}`, - ) - } - if (response.status === 503) { - throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") - } - if (response.status === 401 || response.status === 403) { - throw new NmiApiError( - response.status, - "NMI API-sleutel niet geconfigureerd of verlopen.", - ) - } - throw new NmiApiError( - response.status, - `Er is een fout opgetreden bij het berekenen van de mineralisatie. ${errorText}`, - ) - } - - const parsed = nsupplyResponseSchema.safeParse(await response.json()) - if (!parsed.success) { - throw new Error( - `Ongeldig antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, - ) - } - - const result: NSupplyResult = { + const input: NSupplyComputeInput = { b_id, b_name: field.b_name ?? b_id, + nmiApiKey, + requestBody, method, - data: parsed.data.data, - totalAnnualN: - parsed.data.data.length > 0 - ? parsed.data.data[parsed.data.data.length - 1] - .d_n_supply_actual - : 0, completeness, } - nsupplyCache.set(cacheKey, { - data: result, - expiresAt: Date.now() + NSUPPLY_TTL_MS, - }) - - return result + return getNSupply(fdm, input) } /** - * Fetches N supply curves for all non-buffer fields in a farm. - * Per-field errors are caught and returned as NSupplyResult with an error property. + * Fetches N supply curves for all non-buffer fields in a farm, in parallel. + * + * Per-field errors are caught and returned as an {@link NSupplyResult} with an + * `error` property set to a Dutch-language message. This ensures that a single + * field with missing data does not prevent other fields from rendering. + * + * @param params.principal_id - Authenticated user / principal identifier. + * @param params.b_id_farm - FDM farm identifier. + * @param params.method - Mineralization model to use for all fields. + * @param params.timeframe - Calendar year window. + * @returns Array of {@link NSupplyResult} — one per non-buffer field. + * Entries with `error` set should be displayed with a warning indicator. */ export async function getNSupplyForFarm({ principal_id, @@ -798,42 +189,46 @@ export async function getNSupplyForFarm({ }: { principal_id: string b_id_farm: string - method: NSupplyMethod + method: import("@nmi-agro/fdm-calculator").NSupplyMethod timeframe: Timeframe -}): Promise { +}): Promise { const fields = await getFields(fdm, principal_id, b_id_farm, timeframe) const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) const results = await Promise.all( - nonBufferFields.map(async (field): Promise => { - try { - return await getNSupplyForField({ - principal_id, - b_id: field.b_id, - method, - timeframe, - }) - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : "Onbekende fout bij ophalen mineralisatiegegevens" - return { - b_id: field.b_id, - b_name: field.b_name ?? field.b_id, - method, - data: [], - totalAnnualN: 0, - completeness: { - available: [], - missing: [], - estimated: [], - score: 0, - }, - error: errorMessage, + nonBufferFields.map( + async ( + field, + ): Promise => { + try { + return await getNSupplyForField({ + principal_id, + b_id: field.b_id, + method, + timeframe, + }) + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : "Onbekende fout bij ophalen mineralisatiegegevens" + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + method, + data: [], + totalAnnualN: 0, + completeness: { + available: [], + missing: [], + estimated: [], + score: 0, + }, + error: errorMessage, + } } - } - }), + }, + ), ) return results @@ -841,7 +236,31 @@ export async function getNSupplyForFarm({ /** * Fetches the DYNA nitrogen advice simulation for a single field. - * Results are cached in the fdm database via withCalculationCache. + * + * Orchestrates the full pipeline: + * 1. Retrieves field geometry, soil data, all cultivations (across all years), + * and crop catalogue data from FDM. + * 2. Builds the DYNA API request body via {@link buildDynaRequest}. + * 3. Delegates to {@link getDyna} (DB-cached) from `@nmi-agro/fdm-calculator`. + * + * **Important:** All cultivations are fetched without a timeframe filter so that + * preceding-year entries appear in the rotation array. The DYNA model requires + * historical rotation data to compute carry-over N from previous seasons. + * + * Results are automatically cached in the FDM database. The cache is + * invalidated whenever cultivations, soil data, or fertilizer applications change. + * + * @param params.principal_id - Authenticated user / principal identifier. + * @param params.b_id - FDM field identifier. + * @param params.b_id_farm - FDM farm identifier (used to look up crop catalogue). + * @param params.timeframe - Calendar year window; determines the requested calculation year. + * @param params.farmSector - Farm sector string (e.g. `"arable"`, `"dairy"`). + * @param params.fertilizers - Fertilizer applications for the current year, + * enriched with nutrient content from the fertilizer catalogue. + * @returns A {@link DynaResult} with daily simulation data, nitrogen balance, + * and optional fertilizer / harvest recommendations. + * @throws `Error` if the field is not found or the NMI API key is not configured. + * @throws {@link NmiApiError} on NMI API errors. */ export async function getDynaForField({ principal_id, @@ -870,7 +289,7 @@ export async function getDynaForField({ p_dose?: number | null p_app_method?: string | null }[] -}): Promise { +}): Promise { const nmiApiKey = getNmiApiKey() if (!nmiApiKey) { throw new Error("NMI API-sleutel niet geconfigureerd") @@ -883,28 +302,15 @@ export async function getDynaForField({ const [soilDataArray, cultivations, catalogueEntries] = await Promise.all([ getCurrentSoilData(fdm, principal_id, b_id), + // Fetch ALL cultivations (no timeframe) so preceding-year entries + // appear in the DYNA rotation history getCultivations(fdm, principal_id, b_id), getCultivationsFromCatalogue(fdm, principal_id, b_id_farm), ]) - const soilData: Record = {} - if (soilDataArray && soilDataArray.length > 0) { - for (const entry of soilDataArray) { - if (entry.parameter && entry.value !== undefined) { - soilData[entry.parameter] = entry.value as - | number - | string - | null - | undefined - } - } - const first = soilDataArray[0] - if (first?.a_depth_lower !== undefined) { - soilData.a_depth_lower = first.a_depth_lower - } - } + const soilData = buildSoilDataMap(soilDataArray) - // Build crop_properties from catalogue entries for the field's cultivations + // Build crop_properties from catalogue entries for this field's cultivations const cultivationCodes = new Set( cultivations.map((c) => c.b_lu_catalogue).filter(Boolean), ) @@ -927,17 +333,66 @@ export async function getDynaForField({ cropProperties.length > 0 ? cropProperties : undefined, ) - return _cachedCallDynaApi(fdm, { b_id, nmiApiKey, requestBody }) + return getDyna(fdm, { b_id, nmiApiKey, requestBody }) } -// ─── Error class ────────────────────────────────────────────────────────────── +// ─── Insights ───────────────────────────────────────────────────────────────── + +/** + * Generates Dutch-language insights comparing a field's N supply to the farm + * average and reporting season progress. + * + * This is a presentation-layer helper — it produces user-facing text strings + * suitable for display in an "Inzicht" card on the field detail page. + * + * **Rules:** + * - If `totalAnnualN` is >20% above `farmAvgN`: warn about potential over-supply. + * - If `totalAnnualN` is >20% below `farmAvgN`: suggest improving organic matter. + * - If `completeness.score < 70`: advise a more comprehensive soil analysis. + * - Always: report current-season progress (N mineralised to date vs. remaining). + * + * @param nsupply - The N supply result for the field. + * @param farmAvgN - Weighted farm-average annual N mineralisation (kg N/ha). + * Pass `undefined` when not yet available (e.g. farm-level call still pending). + * @param currentDoy - Day of year to use as "today" (1–366). + * @returns Array of Dutch insight strings. May be empty if no noteworthy conditions. + */ +export function generateInsights( + nsupply: import("@nmi-agro/fdm-calculator").NSupplyResult, + farmAvgN: number | undefined, + currentDoy: number, +): string[] { + const insights: string[] = [] + const totalN = nsupply.totalAnnualN + + if (farmAvgN !== undefined && farmAvgN > 0) { + const ratio = totalN / farmAvgN + if (ratio > 1.2) { + const pct = Math.round((ratio - 1) * 100) + insights.push( + `Het N-leverend vermogen is ${pct}% hoger dan het bedrijfsgemiddelde. Overweeg de kunstmestgift te verlagen.`, + ) + } else if (ratio < 0.8) { + insights.push( + "Relatief laag N-leverend vermogen. Verhogen van het organische stofgehalte kan de mineralisatie verbeteren.", + ) + } + } -export class NmiApiError extends Error { - constructor( - public readonly status: number, - message: string, - ) { - super(message) - this.name = "NmiApiError" + if (nsupply.completeness.score < 70) { + insights.push( + `Betrouwbaarheid beperkt (${nsupply.completeness.score}%). Een uitgebreidere bodemanalyse wordt aanbevolen.`, + ) + } + + const currentPoint = nsupply.data.find((d) => d.doy >= currentDoy) + if (currentPoint) { + const remaining = totalN - currentPoint.d_n_supply_actual + const date = doyToDateString(currentDoy, new Date().getFullYear()) + insights.push( + `Op ${date} is circa ${Math.round(currentPoint.d_n_supply_actual)} kg N/ha gemineraliseerd. Tot einde groeiseizoen wordt nog ~${Math.round(remaining)} kg N/ha verwacht.`, + ) } + + return insights } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx index 267097b41..f45a88de9 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx @@ -5,6 +5,7 @@ import { getGrazingIntention, getCultivations, type FertilizerApplication, + type Fertilizer, } from "@nmi-agro/fdm-core" import { Slash } from "lucide-react" import { Suspense, use } from "react" @@ -116,7 +117,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ]) // Build a map from p_id → full fertilizer properties for quick lookup - const fertilizerMap = new Map(fertilizers.map((f) => [f.p_id, f])) + const fertilizerMap = new Map( + fertilizers.map((f: Fertilizer) => [f.p_id, f]), + ) // Map fdm-core FertilizerApplication → DYNA fertilizer input const dynaFertilizers = applications.map((app: FertilizerApplication) => { @@ -148,8 +151,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) // Build chart events: sowing, harvest, and fertilizer applications - const fertilizerNameMap = new Map( - fertilizers.map((f) => [f.p_id, f.p_name_nl ?? f.p_id]), + const fertilizerNameMap = new Map( + fertilizers.map((f: Fertilizer) => [f.p_id, f.p_name_nl ?? f.p_id]), ) type ChartEvent = { date: string diff --git a/fdm-app/package.json b/fdm-app/package.json index c3461cba7..d45dc5e46 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -108,8 +108,7 @@ "typescript": "catalog:", "vite": "^8.0.3", "vite-node": "^6.0.0", - "vite-tsconfig-paths": "^6.1.1", - "vitest": "catalog:" + "vite-tsconfig-paths": "^6.1.1" }, "engines": { "node": ">=24.0.0" diff --git a/fdm-app/vitest.config.ts b/fdm-app/vitest.config.ts deleted file mode 100644 index a859e9904..000000000 --- a/fdm-app/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config" -import tsconfigPaths from "vite-tsconfig-paths" - -export default defineConfig({ - plugins: [tsconfigPaths()], - test: { - environment: "node", - include: ["app/**/*.{test,spec}.{ts,tsx}"], - exclude: ["node_modules", "build"], - }, -}) diff --git a/fdm-calculator/package.json b/fdm-calculator/package.json index 910a86727..6254a3d9d 100644 --- a/fdm-calculator/package.json +++ b/fdm-calculator/package.json @@ -43,7 +43,8 @@ "@nmi-agro/fdm-core": "workspace:^", "date-fns": "^4.1.0", "decimal.js": "^10.6.0", - "geotiff": "^3.0.5" + "geotiff": "^3.0.5", + "zod": "^4.3.6" }, "devDependencies": { "@dotenvx/dotenvx": "catalog:", diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 2106537cf..fa16b1f39 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -92,6 +92,29 @@ export type { NutrientAdviceInputs, NutrientAdviceResponse, } from "./nutrient-advice/types" +export { + NmiApiError, + assessDataCompleteness, + buildDynaRequest, + buildNSupplyRequest, + getDyna, + getNSupply, + methodRequirements, + requestDyna, + requestNSupply, +} from "./mineralisatie" +export type { + DataCompleteness, + DynaComputeInput, + DynaDailyPoint, + DynaFertilizerAdvice, + DynaNitrogenBalance, + DynaResult, + NSupplyComputeInput, + NSupplyDataPoint, + NSupplyMethod, + NSupplyResult, +} from "./mineralisatie" export type { NlvSupplyBySomParams } from "./other/nlv-supply-by-som" export { calculateNlvSupplyBySom } from "./other/nlv-supply-by-som" export type { WaterSupplyBySomParams } from "./other/water-supply-by-som" diff --git a/fdm-calculator/src/mineralisatie/assessment.ts b/fdm-calculator/src/mineralisatie/assessment.ts new file mode 100644 index 000000000..1588746c7 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/assessment.ts @@ -0,0 +1,149 @@ +/** + * @packageDocumentation + * @module mineralisatie/assessment + * + * Soil data completeness assessment for the Mineralisatie module. + * + * Before calling the NMI nsupply API, the completeness of available soil data + * is assessed for the chosen mineralization method. This lets the UI communicate + * data quality and guide users towards improving their soil data. + */ + +import type { DataCompleteness, NSupplyMethod } from "./types" + +// ─── Method requirements ────────────────────────────────────────────────────── + +/** + * Defines the required and optional soil parameters for each N supply method. + * + * **Required** parameters must be present for the API to compute a result. + * **Optional** parameters improve the estimate but are estimated by the API + * when absent. + * + * @see {@link NSupplyMethod} + */ +export const methodRequirements: Record< + NSupplyMethod, + { required: string[]; optional: string[] } +> = { + /** + * MINIP — organic matter decomposition model. + * Requires organic matter (`a_som_loi`), clay fraction (`a_clay_mi`), + * and silt fraction (`a_silt_mi`). + */ + minip: { + required: ["a_som_loi", "a_clay_mi", "a_silt_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, + /** + * PMN — Potentially Mineralizable Nitrogen incubation method. + * Requires measured PMN (`a_n_pmn`) and clay fraction (`a_clay_mi`). + */ + pmn: { + required: ["a_n_pmn", "a_clay_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, + /** + * CENTURY — carbon cycling model. + * Requires organic carbon (`a_c_of`), C:N ratio (`a_cn_fr`), + * clay fraction (`a_clay_mi`), and silt fraction (`a_silt_mi`). + */ + century: { + required: ["a_c_of", "a_cn_fr", "a_clay_mi", "a_silt_mi"], + optional: ["a_sand_mi", "b_soiltype_agr"], + }, +} + +// ─── Assessment ─────────────────────────────────────────────────────────────── + +/** + * Evaluates which soil parameters are available, missing, or will be estimated + * for the chosen mineralization method, and returns a 0–100 completeness score. + * + * **Score calculation:** + * - Required parameters contribute up to **80 points** (proportional to fraction present). + * - Optional parameters contribute up to **20 points** (proportional to fraction present). + * - Parameters measured by NMI (`source === "nl-other-nmi"`) are treated as absent + * for scoring purposes, since they represent API estimates rather than field data. + * + * @example + * ```typescript + * const completeness = assessDataCompleteness( + * { a_som_loi: 3.5, a_clay_mi: 12, a_silt_mi: 18, a_sand_mi: 70 }, + * "minip", + * ) + * // completeness.score === 100 (all required + all optional present) + * ``` + * + * @param soilData - Flat key-value map of soil parameters (from `getCurrentSoilData`). + * Keys are FDM parameter names (e.g. `"a_som_loi"`). + * @param method - The mineralization method determining which parameters are needed. + * @param soilMeta - Optional metadata per parameter: measurement source identifier + * and sampling date. Used to distinguish lab measurements from NMI estimates. + * @returns A {@link DataCompleteness} object with categorized parameters and score. + */ +export function assessDataCompleteness( + soilData: Record, + method: NSupplyMethod, + soilMeta?: Record, +): DataCompleteness { + const { required, optional } = methodRequirements[method] + + const available: DataCompleteness["available"] = [] + const missing: string[] = [] + const estimated: string[] = [] + + for (const param of required) { + const value = soilData[param] + if (value !== null && value !== undefined) { + available.push({ + param, + value: value as number | string, + source: soilMeta?.[param]?.source, + date: soilMeta?.[param]?.date, + }) + } else { + missing.push(param) + } + } + + for (const param of optional) { + const value = soilData[param] + if (value !== null && value !== undefined) { + available.push({ + param, + value: value as number | string, + source: soilMeta?.[param]?.source, + date: soilMeta?.[param]?.date, + }) + } else { + estimated.push(param) + } + } + + // Only count parameters measured in-field (not NMI-estimated) for scoring + const NMI_SOURCE = "nl-other-nmi" + + const availableRequired = required.filter( + (p) => + soilData[p] !== null && + soilData[p] !== undefined && + soilMeta?.[p]?.source !== NMI_SOURCE, + ).length + const availableOptional = optional.filter( + (p) => + soilData[p] !== null && + soilData[p] !== undefined && + soilMeta?.[p]?.source !== NMI_SOURCE, + ).length + + const score = + required.length > 0 + ? (availableRequired / required.length) * 80 + + (optional.length > 0 + ? (availableOptional / optional.length) * 20 + : 20) + : 100 + + return { available, missing, estimated, score: Math.round(score) } +} diff --git a/fdm-calculator/src/mineralisatie/builders.ts b/fdm-calculator/src/mineralisatie/builders.ts new file mode 100644 index 000000000..cfd9548f9 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/builders.ts @@ -0,0 +1,455 @@ +/** + * @packageDocumentation + * @module mineralisatie/builders + * + * Request body builders for the NMI Mineralisatie API endpoints. + * + * These pure functions transform FDM domain objects (already fetched from the + * database by the caller) into the JSON request bodies expected by: + * - `POST /bemestingsplan/nsupply` — {@link buildNSupplyRequest} + * - `POST /bemestingsplan/dyna` — {@link buildDynaRequest} + * + * No database access is performed here. All required FDM data must be passed + * in by the caller. + */ + +import type { Timeframe } from "@nmi-agro/fdm-core" +import type { NSupplyMethod } from "./types" + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Determines the main cultivation for a given year using the "May 15th" rule. + * + * The NMI models expect one principal cultivation per rotation year. This rule + * identifies the crop that is active on May 15th of the specified year. If no + * cultivation spans that date (e.g. a late-starting crop), the function falls + * back to the first non-catchcrop in the year, then to any cultivation. + * + * When multiple cultivations overlap May 15th, the most recently started one + * takes precedence (sorted descending by `b_lu_start`). + * + * @param cultivations - All cultivations for the given year. + * @param year - Calendar year to evaluate (e.g. `2026`). + * @returns The main cultivation for the year, or `undefined` if the list is empty. + * + * @internal + */ +function getMainCultivation< + T extends { + b_lu_catalogue?: string | null + b_lu_start?: Date | null + b_lu_end?: Date | null + b_lu_croprotation?: string | null + }, +>(cultivations: T[], year: number): T | undefined { + // Use 12:00 noon to avoid timezone edge cases at midnight + const targetDate = new Date(`${year}-05-15T12:00:00`) + + const activeOnMay15 = [...cultivations] + .sort((a, b) => { + const aTime = a.b_lu_start?.getTime() ?? 0 + const bTime = b.b_lu_start?.getTime() ?? 0 + return bTime - aTime + }) + .find((c) => { + if (!c.b_lu_start) return false + const start = c.b_lu_start + const end = c.b_lu_end ?? null + if (end) return start <= targetDate && end >= targetDate + return start <= targetDate + }) + + if (activeOnMay15) return activeOnMay15 + + // Fallback: first non-catchcrop in the year, then any crop + return ( + cultivations.find((c) => c.b_lu_croprotation !== "catchcrop") ?? + cultivations[0] + ) +} + +// ─── N-Supply request builder ───────────────────────────────────────────────── + +/** + * Builds the JSON request body for `POST /bemestingsplan/nsupply`. + * + * Maps FDM field, soil, and cultivation data to the flat parameter structure + * expected by the NMI nsupply endpoint. + * + * **Soil parameter mapping:** + * | FDM parameter | API field | Unit | + * |---------------|-----------|------| + * | `a_som_loi` | `a_som_loi` | % | + * | `a_clay_mi` | `a_clay_mi` | % | + * | `a_silt_mi` | `a_silt_mi` | % | + * | `a_sand_mi` | `a_sand_mi` | % | + * | `a_c_of` | `a_c_of` | g C/kg | + * | `a_cn_fr` | `a_cn_fr` | — | + * | `a_n_rt` | `a_n_rt` | mg N/kg | + * | `a_n_pmn` | `a_n_pmn` | mg N/kg | + * | `b_soiltype_agr` | `b_soiltype_agr` | — | + * | `a_depth_lower` | `a_depth` | m (default `0.3`) | + * + * The BRP crop code is extracted from `b_lu_catalogue` by stripping the `"nl_"` prefix + * and converting the remainder to an integer (e.g. `"nl_256"` → `256`). + * Only the first matched crop code is sent. + * + * @example + * ```typescript + * const body = buildNSupplyRequest( + * { b_centroid: [5.585, 53.288] }, + * { a_som_loi: 3.5, a_clay_mi: 10, a_silt_mi: 20, a_depth_lower: 0.3 }, + * [{ b_lu_catalogue: "nl_256" }], + * "minip", + * { start: new Date("2026-01-01"), end: new Date("2026-12-31") }, + * ) + * ``` + * + * @param field - Basic field geometry (centroid required for lat/lon). + * @param soilData - Flat map of soil parameter values from `getCurrentSoilData`. + * @param cultivations - Cultivations on the field (used to derive BRP code). + * @param method - Mineralization model to request. + * @param timeframe - Calendar year timeframe; `start` and `end` set `d_start`/`d_end`. + * @returns A plain object suitable for `JSON.stringify` and sending to the API. + */ +export function buildNSupplyRequest( + field: { + /** [longitude, latitude] in WGS84 */ + b_centroid?: [number, number] | null + b_area?: number | null + }, + soilData: Record, + cultivations: { b_lu_catalogue?: string | null }[], + method: NSupplyMethod, + timeframe: Timeframe, +): Record { + const centroid = field.b_centroid + const a_lon = centroid ? centroid[0] : undefined + const a_lat = centroid ? centroid[1] : undefined + + const b_lu_brp = cultivations + .filter((c) => c.b_lu_catalogue) + .map((c) => { + const code = (c.b_lu_catalogue ?? "").replace(/^nl_/, "") + const parsed = Number.parseInt(code, 10) + return Number.isNaN(parsed) ? undefined : parsed + }) + .find((v) => v !== undefined) + + const body: Record = { + d_n_supply_method: method, + } + + if (timeframe.start) { + body.d_start = timeframe.start.toISOString().split("T")[0] + } + if (timeframe.end) { + body.d_end = timeframe.end.toISOString().split("T")[0] + } + + if (a_lat !== undefined) body.a_lat = a_lat + if (a_lon !== undefined) body.a_lon = a_lon + if (b_lu_brp !== undefined) body.b_lu_brp = b_lu_brp + + const soilParams = [ + "a_som_loi", + "a_clay_mi", + "a_silt_mi", + "a_sand_mi", + "a_c_of", + "a_cn_fr", + "a_n_rt", + "a_n_pmn", + "b_soiltype_agr", + ] as const + + for (const param of soilParams) { + const value = soilData[param] + if (value !== null && value !== undefined) { + body[param] = value + } + } + + const aDepthLower = soilData.a_depth_lower + body.a_depth = + aDepthLower !== null && aDepthLower !== undefined + ? Number(aDepthLower) + : 0.3 + + return body +} + +// ─── DYNA request builder ───────────────────────────────────────────────────── + +/** + * Builds the JSON request body for `POST /bemestingsplan/dyna`. + * + * Constructs the nested `field / farm / crop_properties / fertilizer_properties` + * structure required by the DYNA endpoint. + * + * **Rotation building rules:** + * 1. Cultivations are grouped by `b_lu_start` calendar year. + * 2. The main crop per year is selected using the May 15th rule + * (see {@link getMainCultivation}). + * 3. Catchcrops (`b_lu_croprotation === "catchcrop"`) are converted to + * `b_lu_green` + `b_date_green_incorporation` on the same year's entry — + * they do **not** become a separate rotation entry. + * 4. Fertilizer amendments are only attached to the current calendar year + * (i.e. `timeframe.start.getFullYear()`). Preceding years have empty + * amendment arrays. + * 5. If no cultivations are found at all, a minimal placeholder entry is + * generated for the requested year so the API call can still proceed. + * + * **Harvests array:** + * Each rotation entry includes a `harvests` array with one element containing + * the harvest date and optionally the yield (looked up from `cropProperties`). + * + * **Fertilizer properties deduplication:** + * If the same `p_id` appears in multiple applications, only the first occurrence + * is included in `fertilizer_properties`. + * + * @example + * ```typescript + * const body = buildDynaRequest( + * { b_id: "field_1", b_centroid: [5.585, 53.288] }, + * { a_som_loi: 1.5, a_clay_mi: 10, a_silt_mi: 25, a_depth_lower: 0.3 }, + * cultivations, + * fertilizerApplications, + * "arable", + * { start: new Date("2026-01-01"), end: new Date("2026-12-31") }, + * cropProperties, + * ) + * ``` + * + * @param field - Field geometry and identifier. + * @param soilData - Flat map of soil parameter values from `getCurrentSoilData`. + * @param cultivations - **All** cultivations for the field across all years + * (no timeframe filter). Preceding-year entries are required for the rotation history. + * @param fertilizers - Fertilizer applications for the field (current year only, with + * application date, dose, method, and nutrient content). + * @param farmSector - Farm sector string sent to the API (e.g. `"arable"`, `"dairy"`). + * Defaults to `"arable"` if empty. + * @param timeframe - Calendar year timeframe; the `start` year determines the + * requested calculation year. + * @param cropProperties - Optional catalogue entries for the cultivations on the field, + * used to populate `b_lu_yield` in harvests and `crop_properties`. + * @returns A plain object suitable for `JSON.stringify` and sending to the API. + */ +export function buildDynaRequest( + field: { + b_id?: string | null + b_centroid?: [number, number] | null + b_area?: number | null + }, + soilData: Record, + cultivations: { + b_lu_catalogue?: string | null + b_lu_start?: Date | null + b_lu_end?: Date | null + b_lu_croprotation?: string | null + m_cropresidue?: boolean | null + }[], + fertilizers: { + p_id: string + /** Total N content (g N/kg) */ + p_n_rt?: number | null + /** Inorganic N fraction */ + p_n_if?: number | null + /** Organic N fraction */ + p_n_of?: number | null + /** Water content (kg/kg) */ + p_n_wc?: number | null + /** Total P content (g P2O5/kg) */ + p_p_rt?: number | null + /** Total K content (g K2O/kg) */ + p_k_rt?: number | null + /** Dry matter content (%) */ + p_dm?: number | null + /** Organic matter content (%) */ + p_om?: number | null + /** Application date */ + p_date?: Date | null + /** Applied dose (kg/ha or m³/ha) */ + p_dose?: number | null + /** Application method (e.g. `"broadcasting"`, `"injection"`) */ + p_app_method?: string | null + }[], + farmSector: string, + timeframe: Timeframe, + cropProperties?: { + b_lu_catalogue: string + b_lu_yield?: number | null + b_lu_n_harvestable?: number | null + b_lu_n_residue?: number | null + }[], +): Record { + const centroid = field.b_centroid + const a_lon = centroid ? centroid[0] : undefined + const a_lat = centroid ? centroid[1] : undefined + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + + const fieldObj: Record = {} + if (field.b_id) fieldObj.b_id = field.b_id + if (a_lat !== undefined) fieldObj.a_lat = a_lat + if (a_lon !== undefined) fieldObj.a_lon = a_lon + + const soilParams = [ + "a_som_loi", + "a_clay_mi", + "a_silt_mi", + "a_sand_mi", + "a_c_of", + "a_cn_fr", + "a_n_rt", + "a_n_pmn", + "b_soiltype_agr", + ] as const + + for (const param of soilParams) { + const value = soilData[param] + if (value !== null && value !== undefined) { + fieldObj[param] = value + } + } + + const aDepthLower = soilData.a_depth_lower + fieldObj.a_depth = + aDepthLower !== null && aDepthLower !== undefined + ? Number(aDepthLower) + : 0.3 + + // Build amendments list — only applications with a date are included + const amendments = fertilizers + .filter((f) => f.p_date !== null && f.p_date !== undefined) + .map((f) => ({ + p_id: f.p_id, + p_dose: f.p_dose ?? 0, + p_app_method: f.p_app_method ?? "broadcasting", + p_date_fertilization: f.p_date?.toISOString().split("T")[0], + })) + + // Collect distinct start years across all cultivations + const allYears = [ + ...new Set( + cultivations + .filter((c) => c.b_lu_catalogue && c.b_lu_start) + .map((c) => c.b_lu_start!.getFullYear()), + ), + ].sort((a, b) => a - b) + + const rotation: Record[] = allYears + .map((rotationYear) => { + const yearCultivations = cultivations.filter( + (c) => + c.b_lu_catalogue && + c.b_lu_start?.getFullYear() === rotationYear, + ) + + // Select main crop using May 15th rule + const mainCrop = getMainCultivation(yearCultivations, rotationYear) + + if (!mainCrop?.b_lu_catalogue) return null + + // Look up yield from crop_properties for the harvests array + const cropProp = cropProperties?.find( + (cp) => cp.b_lu_catalogue === mainCrop.b_lu_catalogue, + ) + const harvests = mainCrop.b_lu_end + ? [ + { + b_date_harvest: mainCrop.b_lu_end + .toISOString() + .split("T")[0], + ...(cropProp?.b_lu_yield != null + ? { b_lu_yield: cropProp.b_lu_yield } + : {}), + }, + ] + : [] + + // Catchcrop becomes green manure on the same rotation entry + const greenManure = yearCultivations.find( + (c) => c.b_lu_croprotation === "catchcrop" && c !== mainCrop, + ) + + return { + year: rotationYear, + b_lu: mainCrop.b_lu_catalogue, + b_lu_start: mainCrop.b_lu_start?.toISOString().split("T")[0], + harvests, + ...(mainCrop.m_cropresidue != null + ? { m_cropresidue: mainCrop.m_cropresidue } + : {}), + ...(greenManure?.b_lu_catalogue + ? { + b_lu_green: greenManure.b_lu_catalogue, + b_date_green_incorporation: greenManure.b_lu_end + ?.toISOString() + .split("T")[0], + } + : {}), + irrigation: [], + // Amendments only on the current calendar year + amendments: rotationYear === year ? amendments : [], + } + }) + .filter((entry) => entry !== null) + + // Fallback: ensure at least one rotation entry so the API call can proceed + if (rotation.length === 0) { + rotation.push({ + year, + b_lu: undefined, + b_lu_start: timeframe.start?.toISOString().split("T")[0], + harvests: [], + irrigation: [], + amendments, + }) + } + + fieldObj.rotation = rotation + + // Deduplicate fertilizers by p_id; include all available nutrient properties + const seenIds = new Set() + const fertilizer_properties = fertilizers + .filter((f) => { + if (seenIds.has(f.p_id)) return false + seenIds.add(f.p_id) + return true + }) + .map((f) => { + const props: Record = { + p_id: f.p_id, + p_n_rt: f.p_n_rt ?? 0, + } + if (f.p_n_if != null) props.p_n_if = f.p_n_if + if (f.p_n_of != null) props.p_n_of = f.p_n_of + if (f.p_n_wc != null) props.p_n_wc = f.p_n_wc + if (f.p_p_rt != null) props.p_p_rt = f.p_p_rt + if (f.p_k_rt != null) props.p_k_rt = f.p_k_rt + if (f.p_dm != null) props.p_dm = f.p_dm + if (f.p_om != null) props.p_om = f.p_om + return props + }) + + const builtCropProperties = + cropProperties && cropProperties.length > 0 + ? cropProperties + .filter((cp) => cp.b_lu_catalogue) + .map((cp) => ({ + b_lu: cp.b_lu_catalogue, + b_lu_yield: cp.b_lu_yield ?? null, + b_lu_n_harvestable: cp.b_lu_n_harvestable ?? null, + b_lu_n_residue: cp.b_lu_n_residue ?? null, + })) + : null + + return { + field: fieldObj, + farm: { sector: farmSector || "arable" }, + crop_properties: builtCropProperties, + fertilizer_properties: + fertilizer_properties.length > 0 ? fertilizer_properties : null, + } +} diff --git a/fdm-calculator/src/mineralisatie/dyna.ts b/fdm-calculator/src/mineralisatie/dyna.ts new file mode 100644 index 000000000..4c7cca112 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/dyna.ts @@ -0,0 +1,156 @@ +/** + * @packageDocumentation + * @module mineralisatie/dyna + * + * DYNA dynamic nitrogen advice calculation via the NMI API. + * + * Provides two exports: + * - {@link requestDyna} — the raw, uncached API call function + * - {@link getDyna} — the DB-backed cached version (use this in production) + * + * The DYNA model simulates daily nitrogen dynamics through the growing season, + * combining soil N supply, crop uptake, fertilizer releases, and leaching into + * a continuous curve. It returns: + * - A daily time series ({@link DynaDailyPoint}[]) covering the entire rotation period + * - A season-total nitrogen balance ({@link DynaNitrogenBalance}) + * - Optional fertilizer dose + timing advice ({@link DynaFertilizerAdvice}) + * - Optional optimal harvest date + * + * @example + * ```typescript + * import { getDyna } from "@nmi-agro/fdm-calculator" + * + * const result = await getDyna(fdm, { + * b_id: "field_abc", + * nmiApiKey: process.env.NMI_API_KEY, + * requestBody: buildDynaRequest(field, soilData, cultivations, fertilizers, "arable", timeframe, cropProperties), + * }) + * ``` + */ + +import { withCalculationCache } from "@nmi-agro/fdm-core" +import { z } from "zod" +import { NmiApiError } from "./errors" +import { dynaResponseSchema } from "./schemas" +import type { DynaComputeInput, DynaResult } from "./types" +import pkg from "../package" + +// ─── API call ───────────────────────────────────────────────────────────────── + +/** + * Calls `POST /bemestingsplan/dyna` with the pre-built request body and + * returns the parsed DYNA simulation result as a {@link DynaResult}. + * + * This is the **uncached** version. In most cases you should use {@link getDyna} + * which adds DB-backed caching via `withCalculationCache`. + * + * The response body uses `snake_case` field names which are transformed to + * `camelCase` by {@link dynaResponseDataSchema} during validation. + * + * **Error handling:** + * | HTTP status | Thrown error | + * |-------------|--------------| + * | 400 | `NmiApiError(400, "Er is een fout opgetreden bij de DYNA-berekening. ...")` — e.g. duplicate year or missing `b_lu` | + * | 422 | `NmiApiError(422, "Onvoldoende gegevens voor DYNA-berekening.")` | + * | 503 | `NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.")` | + * | other 4xx/5xx | `NmiApiError(status, "Er is een fout opgetreden...")` | + * | invalid JSON | `Error("Ongeldig DYNA-antwoord van NMI API: ...")` | + * + * @param input - Pre-assembled input bundle: field id, API key, and the + * fully-formed DYNA request body (built by {@link buildDynaRequest}). + * @returns A {@link DynaResult} with daily simulation data, nitrogen balance, + * and optional recommendations. + * @throws {@link NmiApiError} on API or HTTP errors. + * @throws `Error` if the response body fails Zod validation. + */ +export async function requestDyna( + input: DynaComputeInput, +): Promise { + const { b_id, nmiApiKey, requestBody } = input + + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/dyna", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + } + throw new NmiApiError( + response.status, + `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, + ) + } + + const rawJson = await response.json() + const parsed = dynaResponseSchema.safeParse(rawJson) + if (!parsed.success) { + throw new Error( + `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } + + return { b_id, ...parsed.data.data } +} + +// ─── Cached version ─────────────────────────────────────────────────────────── + +/** + * DB-backed cached version of {@link requestDyna}. + * + * Uses `withCalculationCache` from `@nmi-agro/fdm-core` to persist results in + * the FDM database. The cache key is derived from a hash of the input (excluding + * `nmiApiKey` which is redacted). The cache is automatically invalidated when + * the request body changes — e.g. when soil data, cultivations, or fertilizer + * applications are updated. + * + * Because the DYNA model simulates the full rotation, a single cached result + * covers all years in the rotation. The caller should filter the returned + * `calculationDyna` array to the target calendar year before displaying. + * + * **Signature:** `(fdm: FdmType, input: DynaComputeInput) => Promise` + * + * Cache parameters: + * - Function name: `"requestDyna"` + * - Version: `pkg.calculatorVersion` (invalidates on package upgrades) + * - Sensitive keys: `["nmiApiKey"]` (redacted from cache key hash) + * + * @example + * ```typescript + * import { getDyna } from "@nmi-agro/fdm-calculator" + * + * const result = await getDyna(fdm, { + * b_id: "field_abc", + * nmiApiKey: env.NMI_API_KEY, + * requestBody: buildDynaRequest( + * field, soilData, cultivations, fertilizers, + * "arable", timeframe, cropProperties + * ), + * }) + * // Filter to current year before rendering: + * const yearData = result.calculationDyna.filter( + * d => new Date(d.b_date_calculation).getFullYear() === 2026 + * ) + * ``` + */ +export const getDyna = withCalculationCache( + requestDyna, + "requestDyna", + pkg.calculatorVersion, + ["nmiApiKey"], +) diff --git a/fdm-calculator/src/mineralisatie/errors.ts b/fdm-calculator/src/mineralisatie/errors.ts new file mode 100644 index 000000000..bc8c49524 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/errors.ts @@ -0,0 +1,44 @@ +/** + * @packageDocumentation + * @module mineralisatie/errors + * + * Custom error types for the Mineralisatie module. + */ + +/** + * Thrown when the NMI API returns a non-2xx HTTP response during a + * mineralization (nsupply) or DYNA calculation request. + * + * @example + * ```typescript + * try { + * await getNSupply(fdm, input) + * } catch (err) { + * if (err instanceof NmiApiError && err.status === 422) { + * // Handle missing soil data + * } + * } + * ``` + * + * Common status codes: + * - `400` — Invalid request (e.g. duplicate year in rotation, missing `b_lu`) + * - `401` / `403` — API key missing or expired + * - `422` — Insufficient soil/field data to run the model + * - `503` — NMI API temporarily unavailable + */ +export class NmiApiError extends Error { + /** + * The HTTP status code returned by the NMI API. + */ + public readonly status: number + + /** + * @param status - HTTP status code from the NMI API response + * @param message - Dutch-language user-facing error description + */ + constructor(status: number, message: string) { + super(message) + this.status = status + this.name = "NmiApiError" + } +} diff --git a/fdm-calculator/src/mineralisatie/index.ts b/fdm-calculator/src/mineralisatie/index.ts new file mode 100644 index 000000000..1aa0d79e5 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/index.ts @@ -0,0 +1,54 @@ +/** + * @packageDocumentation + * @module mineralisatie + * + * Nitrogen mineralization calculations for the FDM platform. + * + * This module provides two NMI API integrations: + * + * ## N-Supply (static mineralization curve) + * Computes a daily cumulative nitrogen mineralization curve for a field using + * one of three soil models (MINIP, PMN, CENTURY). + * + * ```typescript + * import { getNSupply, buildNSupplyRequest, assessDataCompleteness } from "@nmi-agro/fdm-calculator" + * ``` + * + * ## DYNA (dynamic nitrogen advice) + * Simulates daily nitrogen dynamics through the growing season, combining soil N + * supply, crop uptake, fertilizer releases, and NO₃ leaching. Returns a fertilizer + * dose/timing recommendation and an optional optimal harvest date. + * + * ```typescript + * import { getDyna, buildDynaRequest } from "@nmi-agro/fdm-calculator" + * ``` + * + * ## Typical usage pattern + * 1. Fetch field, soil, cultivation, and fertilizer data from FDM (app layer). + * 2. Build the request body using {@link buildNSupplyRequest} or {@link buildDynaRequest}. + * 3. Call {@link getNSupply} or {@link getDyna} with the FDM instance and input bundle. + * Results are automatically cached in the FDM database. + * + * @see {@link module:mineralisatie/nsupply} + * @see {@link module:mineralisatie/dyna} + * @see {@link module:mineralisatie/builders} + * @see {@link module:mineralisatie/assessment} + */ + +export { NmiApiError } from "./errors" +export { assessDataCompleteness, methodRequirements } from "./assessment" +export { buildDynaRequest, buildNSupplyRequest } from "./builders" +export { getDyna, requestDyna } from "./dyna" +export { getNSupply, requestNSupply } from "./nsupply" +export type { + DataCompleteness, + DynaComputeInput, + DynaDailyPoint, + DynaFertilizerAdvice, + DynaNitrogenBalance, + DynaResult, + NSupplyComputeInput, + NSupplyDataPoint, + NSupplyMethod, + NSupplyResult, +} from "./types" diff --git a/fdm-calculator/src/mineralisatie/nsupply.ts b/fdm-calculator/src/mineralisatie/nsupply.ts new file mode 100644 index 000000000..e019ff741 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/nsupply.ts @@ -0,0 +1,155 @@ +/** + * @packageDocumentation + * @module mineralisatie/nsupply + * + * N supply (nitrogen mineralization) curve calculation via the NMI API. + * + * Provides two exports: + * - {@link requestNSupply} — the raw, uncached API call function + * - {@link getNSupply} — the DB-backed cached version (use this in production) + * + * @example + * ```typescript + * import { getNSupply } from "@nmi-agro/fdm-calculator" + * + * const result = await getNSupply(fdm, { + * b_id: "field_abc", + * b_name: "Perceel Noord", + * nmiApiKey: process.env.NMI_API_KEY, + * requestBody: buildNSupplyRequest(field, soilData, cultivations, "minip", timeframe), + * method: "minip", + * completeness, + * }) + * ``` + */ + +import { withCalculationCache } from "@nmi-agro/fdm-core" +import { z } from "zod" +import { NmiApiError } from "./errors" +import { nsupplyResponseSchema } from "./schemas" +import type { NSupplyComputeInput, NSupplyResult } from "./types" +import pkg from "../package" + +// ─── API call ───────────────────────────────────────────────────────────────── + +/** + * Calls `POST /bemestingsplan/nsupply` with the pre-built request body and + * returns the parsed N supply curve as an {@link NSupplyResult}. + * + * This is the **uncached** version. In most cases you should use {@link getNSupply} + * which adds DB-backed caching via `withCalculationCache`. + * + * **Error handling:** + * | HTTP status | Thrown error | + * |-------------|--------------| + * | 401 / 403 | `NmiApiError(status, "API-sleutel niet geconfigureerd of verlopen.")` | + * | 422 | `NmiApiError(422, "Onvoldoende bodemgegevens...")` | + * | 503 | `NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.")` | + * | other 4xx/5xx | `NmiApiError(status, "Er is een fout opgetreden...")` | + * | invalid JSON | `Error("Ongeldig antwoord van NMI API: ...")` | + * + * @param input - Pre-assembled input bundle including the request body, + * field metadata, chosen method, and data completeness assessment. + * @returns A fully populated {@link NSupplyResult}. + * @throws {@link NmiApiError} on API or HTTP errors. + * @throws `Error` if the response body fails Zod validation. + */ +export async function requestNSupply( + input: NSupplyComputeInput, +): Promise { + const { b_id, b_name, nmiApiKey, requestBody, method, completeness } = + input + + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/nsupply", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende bodemgegevens voor mineralisatieberekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + } + if (response.status === 401 || response.status === 403) { + throw new NmiApiError( + response.status, + "NMI API-sleutel niet geconfigureerd of verlopen.", + ) + } + throw new NmiApiError( + response.status, + `Er is een fout opgetreden bij het berekenen van de mineralisatie. ${errorText}`, + ) + } + + const parsed = nsupplyResponseSchema.safeParse(await response.json()) + if (!parsed.success) { + throw new Error( + `Ongeldig antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } + + return { + b_id, + b_name, + method, + data: parsed.data.data, + totalAnnualN: + parsed.data.data.length > 0 + ? (parsed.data.data[parsed.data.data.length - 1] + ?.d_n_supply_actual ?? 0) + : 0, + completeness, + } +} + +// ─── Cached version ─────────────────────────────────────────────────────────── + +/** + * DB-backed cached version of {@link requestNSupply}. + * + * Uses `withCalculationCache` from `@nmi-agro/fdm-core` to persist results in + * the FDM database. The cache key is derived from a hash of the input (excluding + * `nmiApiKey` which is redacted). The cache is automatically invalidated when + * the request body changes (e.g. soil data updated, different method selected). + * + * **Signature:** `(fdm: FdmType, input: NSupplyComputeInput) => Promise` + * + * Cache parameters: + * - Function name: `"requestNSupply"` + * - Version: `pkg.calculatorVersion` (invalidates on package upgrades) + * - Sensitive keys: `["nmiApiKey"]` (redacted from cache key hash) + * + * @example + * ```typescript + * import { getNSupply } from "@nmi-agro/fdm-calculator" + * + * const result = await getNSupply(fdm, { + * b_id: "field_abc", + * b_name: "Perceel Noord", + * nmiApiKey: env.NMI_API_KEY, + * requestBody: buildNSupplyRequest(field, soilData, cultivations, "minip", timeframe), + * method: "minip", + * completeness, + * }) + * ``` + */ +export const getNSupply = withCalculationCache( + requestNSupply, + "requestNSupply", + pkg.calculatorVersion, + ["nmiApiKey"], +) diff --git a/fdm-calculator/src/mineralisatie/schemas.ts b/fdm-calculator/src/mineralisatie/schemas.ts new file mode 100644 index 000000000..a14871b6e --- /dev/null +++ b/fdm-calculator/src/mineralisatie/schemas.ts @@ -0,0 +1,137 @@ +/** + * @packageDocumentation + * @module mineralisatie/schemas + * + * Zod validation schemas for NMI API responses used by the Mineralisatie module. + * + * The NMI API uses `snake_case` field names in its JSON responses. Where noted, + * schemas include a `.transform()` step to map them to the `camelCase` TypeScript + * interfaces defined in {@link module:mineralisatie/types}. + */ + +import { z } from "zod" + +// ─── N-Supply schemas ───────────────────────────────────────────────────────── + +/** + * Validates a single daily data point from the `/bemestingsplan/nsupply` response. + * + * @see {@link NSupplyDataPoint} + */ +export const nsupplyDataPointSchema = z.object({ + /** Day of year (1–366) */ + doy: z.number().int().min(1).max(366), + /** Cumulative N mineralised to this DOY (kg N/ha) */ + d_n_supply_actual: z.number(), +}) + +/** + * Validates the top-level response body from `POST /bemestingsplan/nsupply`. + * + * The API returns an array of 365 or 366 daily data points under a `data` key. + */ +export const nsupplyResponseSchema = z.object({ + data: z.array(nsupplyDataPointSchema), +}) + +// ─── DYNA schemas ───────────────────────────────────────────────────────────── + +/** + * Validates a single daily simulation point from the DYNA model + * (within `data.calculation_dyna[]` of the `/bemestingsplan/dyna` response). + * + * All numeric fields represent kg N/ha (or kg NO₃/ha for leaching), cumulative. + * + * @see {@link DynaDailyPoint} + */ +export const dynaDailyPointSchema = z.object({ + /** Calendar date of this simulation step (ISO 8601) */ + b_date_calculation: z.string(), + /** N availability — central estimate */ + b_nw: z.number(), + /** N availability — lower bound */ + b_nw_min: z.number(), + /** N availability — upper bound */ + b_nw_max: z.number(), + /** N availability — recommended target */ + b_nw_recommended: z.number(), + /** N uptake by crop — central estimate */ + b_n_uptake: z.number(), + /** N uptake — lower bound */ + b_n_uptake_min: z.number(), + /** N uptake — upper bound */ + b_n_uptake_max: z.number(), + /** N uptake — recommended target */ + b_n_uptake_recommended: z.number(), + /** NO₃ leaching — central estimate */ + b_no3_leach: z.number(), + /** NO₃ leaching — lower bound */ + b_no3_leach_min: z.number(), + /** NO₃ leaching — upper bound */ + b_no3_leach_max: z.number(), + /** NO₃ leaching — recommended target */ + b_no3_leach_recommended: z.number(), +}) + +/** + * Validates the `data` object within the `/bemestingsplan/dyna` response body + * and **transforms** the snake_case API field names to camelCase TypeScript names. + * + * Transformation mapping: + * - `calculation_dyna` → `calculationDyna` + * - `nitrogen_balance` → `nitrogenBalance` + * - `fertilizing_recommendations` → `fertilizingRecommendations` + * - `harvesting_recommendations` → `harvestingRecommendation` + * + * Both recommendation fields are nullable — the API returns `null` when no + * recommendation can be generated. + * + * @see {@link DynaResult} + */ +export const dynaResponseDataSchema = z + .object({ + calculation_dyna: z.array(dynaDailyPointSchema), + nitrogen_balance: z.object({ + /** Total N supply (kg N/ha) */ + b_nw: z.number(), + /** Total N uptake by crop (kg N/ha) */ + b_n_uptake: z.number(), + /** N from green manure incorporation (kg N/ha) */ + b_n_greenmanure: z.number(), + /** N from organic fertilizers (kg N/ha) */ + b_n_fertilizer_organic: z.number(), + /** N from mineral fertilizers (kg N/ha) */ + b_n_fertilizer_artificial: z.number(), + /** N carried over from preceding rotation (kg N/ha) */ + b_n_fertilizer_preceeding: z.number(), + }), + /** Nullable: `null` when no additional fertilization is required */ + fertilizing_recommendations: z + .object({ + b_n_recommended: z.number(), + b_date_recommended: z.string(), + b_n_remaining: z.number(), + }) + .nullable(), + /** Nullable: `null` when no harvest recommendation is available */ + harvesting_recommendations: z + .object({ + b_date_harvest: z.string(), + }) + .nullable(), + }) + .transform((d) => ({ + calculationDyna: d.calculation_dyna, + nitrogenBalance: d.nitrogen_balance, + fertilizingRecommendations: d.fertilizing_recommendations, + harvestingRecommendation: d.harvesting_recommendations, + })) + +/** + * Validates the full top-level response body from `POST /bemestingsplan/dyna`. + * + * The API wraps all calculation results under a single `data` key. + */ +export const dynaResponseSchema = z.object({ + data: dynaResponseDataSchema, +}) diff --git a/fdm-calculator/src/mineralisatie/types.d.ts b/fdm-calculator/src/mineralisatie/types.d.ts new file mode 100644 index 000000000..7b0e37be5 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/types.d.ts @@ -0,0 +1,220 @@ +/** + * @packageDocumentation + * @module mineralisatie/types + * + * TypeScript type definitions for the Mineralisatie (Nitrogen Mineralization) module. + * These types model the inputs, outputs, and intermediate data structures used by the + * NMI API endpoints `/bemestingsplan/nsupply` and `/bemestingsplan/dyna`. + */ + +// ─── N-Supply ───────────────────────────────────────────────────────────────── + +/** + * Identifies which soil–model combination is used to compute the N mineralization curve. + * + * | Value | Model | Key soil inputs | + * |-------|-------|-----------------| + * | `"minip"` | MINIP (organic matter decomposition) | `a_som_loi`, `a_clay_mi`, `a_silt_mi` | + * | `"pmn"` | Potentially Mineralizable Nitrogen | `a_n_pmn`, `a_clay_mi` | + * | `"century"` | CENTURY (carbon cycling) | `a_c_of`, `a_cn_fr`, `a_clay_mi`, `a_silt_mi` | + */ +export type NSupplyMethod = "minip" | "pmn" | "century" + +/** + * A single day in the N supply mineralization curve returned by the NMI + * `/bemestingsplan/nsupply` endpoint. + */ +export interface NSupplyDataPoint { + /** Day of year (1–366) */ + doy: number + /** Cumulative mineralised N to this DOY (kg N/ha) */ + d_n_supply_actual: number +} + +/** + * Describes the quality and completeness of soil data for a given method. + * Used to inform the user how reliable the mineralization estimate is. + */ +export interface DataCompleteness { + /** + * Parameters that were found in the soil data, including optional metadata + * about the measurement source and date. + */ + available: { + /** Parameter key (e.g. `"a_som_loi"`) */ + param: string + /** The measured value */ + value: number | string + /** Data source identifier (e.g. lab code or `"nl-other-nmi"` for estimated values) */ + source?: string + /** Date the sample was taken */ + date?: Date + }[] + /** Required parameters that were **not** found in the soil data */ + missing: string[] + /** Optional parameters that were absent and will be estimated by the API */ + estimated: string[] + /** + * Overall completeness score from 0–100. + * - Required params account for 80 points (proportional). + * - Optional params account for 20 points (proportional). + * - Parameters sourced from NMI estimates (`"nl-other-nmi"`) are not counted. + */ + score: number +} + +/** + * The complete result for a single field's N supply calculation. + * Returned by {@link getNSupply} and consumed by the farm overview and field detail pages. + */ +export interface NSupplyResult { + /** FDM field identifier */ + b_id: string + /** Human-readable field name */ + b_name: string + /** The mineralization model used for this calculation */ + method: NSupplyMethod + /** Daily cumulative N supply curve (365 or 366 data points) */ + data: NSupplyDataPoint[] + /** + * Total annual N mineralised (kg N/ha/yr). + * Taken from the last point in `data` (`data[data.length - 1].d_n_supply_actual`). + */ + totalAnnualN: number + /** Soil data completeness assessment for the chosen method */ + completeness: DataCompleteness + /** + * If present, indicates the calculation failed. + * The value is a user-facing Dutch-language error message. + * Used by `getNSupplyForFarm` to isolate per-field failures. + */ + error?: string +} + +/** + * Input bundle passed to the cached `getNSupply` function. + * The caller (app server layer) is responsible for fetching field/soil/cultivation + * data and building the `requestBody` via {@link buildNSupplyRequest}. + */ +export interface NSupplyComputeInput { + /** FDM field identifier — included in the result and used as part of the cache key */ + b_id: string + /** Human-readable field name — passed through to {@link NSupplyResult} */ + b_name: string + /** NMI API bearer token — redacted from the cache key hash */ + nmiApiKey: string + /** Fully-formed request body for `POST /bemestingsplan/nsupply` */ + requestBody: Record + /** Model used — stored in the result */ + method: NSupplyMethod + /** Pre-computed completeness assessment — stored in the result */ + completeness: DataCompleteness +} + +// ─── DYNA ───────────────────────────────────────────────────────────────────── + +/** + * A single day's simulated nitrogen state from the DYNA model, + * as returned by `POST /bemestingsplan/dyna`. + */ +export interface DynaDailyPoint { + /** Calendar date of this simulation step (ISO 8601, e.g. `"2026-04-15"`) */ + b_date_calculation: string + /** Simulated N availability — central estimate (kg N/ha, cumulative) */ + b_nw: number + /** Lower bound of the N availability uncertainty band (kg N/ha) */ + b_nw_min: number + /** Upper bound of the N availability uncertainty band (kg N/ha) */ + b_nw_max: number + /** Recommended N availability target for this date (kg N/ha) */ + b_nw_recommended: number + /** Cumulative N uptake by the crop — central estimate (kg N/ha) */ + b_n_uptake: number + /** Lower bound of N uptake (kg N/ha) */ + b_n_uptake_min: number + /** Upper bound of N uptake (kg N/ha) */ + b_n_uptake_max: number + /** Recommended N uptake target (kg N/ha) */ + b_n_uptake_recommended: number + /** Cumulative NO₃ leaching — central estimate (kg NO₃/ha) */ + b_no3_leach: number + /** Lower bound of NO₃ leaching (kg NO₃/ha) */ + b_no3_leach_min: number + /** Upper bound of NO₃ leaching (kg NO₃/ha) */ + b_no3_leach_max: number + /** Recommended NO₃ leaching target (kg NO₃/ha) */ + b_no3_leach_recommended: number +} + +/** + * The season-total nitrogen balance components returned by the DYNA model. + * All values are in kg N/ha for the full growing season. + */ +export interface DynaNitrogenBalance { + /** Total N supply from all sources (kg N/ha) */ + b_nw: number + /** Total N uptake by the crop (kg N/ha) */ + b_n_uptake: number + /** N contribution from green manure / catch crop incorporation (kg N/ha) */ + b_n_greenmanure: number + /** N supplied by organic fertilizers (kg N/ha) */ + b_n_fertilizer_organic: number + /** N supplied by mineral (artificial) fertilizers (kg N/ha) */ + b_n_fertilizer_artificial: number + /** N carried over from the preceding crop rotation entry (kg N/ha) */ + b_n_fertilizer_preceeding: number +} + +/** + * Fertilizer application advice generated by the DYNA model. + * `null` when no additional application is required or when the model + * could not generate a recommendation. + */ +export interface DynaFertilizerAdvice { + /** Recommended N dose to apply (kg N/ha) */ + b_n_recommended: number + /** Recommended application date (ISO 8601) */ + b_date_recommended: string + /** N still available to the crop after applying the recommended dose (kg N/ha) */ + b_n_remaining: number +} + +/** + * The complete result of a DYNA nitrogen advice simulation for a single field. + * Returned by {@link getDyna}. + */ +export interface DynaResult { + /** FDM field identifier */ + b_id: string + /** + * Daily simulation points for the entire rotation period. + * Filtered to the target calendar year before display. + */ + calculationDyna: DynaDailyPoint[] + /** Season-total nitrogen balance */ + nitrogenBalance: DynaNitrogenBalance + /** + * Fertilizer dose and timing recommendation. + * `null` if no additional fertilization is needed or not computable. + */ + fertilizingRecommendations: DynaFertilizerAdvice | null + /** + * Optimal harvest date recommendation. + * `null` if not applicable or not computable. + */ + harvestingRecommendation: { b_date_harvest: string } | null +} + +/** + * Input bundle passed to the cached `getDyna` function. + * The caller (app server layer) is responsible for fetching all required FDM data + * and building the `requestBody` via {@link buildDynaRequest}. + */ +export interface DynaComputeInput { + /** FDM field identifier — included in the result and used as part of the cache key */ + b_id: string + /** NMI API bearer token — redacted from the cache key hash */ + nmiApiKey: string + /** Fully-formed request body for `POST /bemestingsplan/dyna` */ + requestBody: Record +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fc52ace0..d25fa5419 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,9 +396,6 @@ importers: vite-tsconfig-paths: specifier: ^6.1.1 version: 6.1.1(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vitest: - specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) fdm-calculator: dependencies: @@ -414,6 +411,9 @@ importers: geotiff: specifier: ^3.0.5 version: 3.0.5 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@dotenvx/dotenvx': specifier: 'catalog:' From c0ef0318b26771a033a13dc0e602ea93e6bcac1e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:25:14 +0200 Subject: [PATCH 04/22] fix: include the harvests --- .../app/integrations/mineralisatie.server.ts | 30 +++ .../src/mineralisatie/builders.test.ts | 233 ++++++++++++++++++ fdm-calculator/src/mineralisatie/builders.ts | 67 +++-- 3 files changed, 307 insertions(+), 23 deletions(-) create mode 100644 fdm-calculator/src/mineralisatie/builders.test.ts diff --git a/fdm-app/app/integrations/mineralisatie.server.ts b/fdm-app/app/integrations/mineralisatie.server.ts index dd15704e4..e64abe09f 100644 --- a/fdm-app/app/integrations/mineralisatie.server.ts +++ b/fdm-app/app/integrations/mineralisatie.server.ts @@ -30,6 +30,7 @@ import { getCurrentSoilData, getField, getFields, + getHarvests, type Timeframe, } from "@nmi-agro/fdm-core" import { getNmiApiKey } from "~/integrations/nmi.server" @@ -308,6 +309,34 @@ export async function getDynaForField({ getCultivationsFromCatalogue(fdm, principal_id, b_id_farm), ]) + // Fetch actual harvest records for each cultivation so the rotation entry + // reflects real cut dates rather than only the cultivation end date. + const harvestsByBlu = new Map< + string, + { b_lu_harvest_date?: Date | null; b_lu_yield?: number | null }[] + >() + await Promise.all( + cultivations + .filter((c) => c.b_lu) + .map(async (c) => { + const harvests = await getHarvests( + fdm, + principal_id, + c.b_lu, + timeframe, + ) + harvestsByBlu.set( + c.b_lu, + harvests.map((h) => ({ + b_lu_harvest_date: h.b_lu_harvest_date, + b_lu_yield: + h.harvestable?.harvestable_analyses?.[0] + ?.b_lu_yield ?? null, + })), + ) + }), + ) + const soilData = buildSoilDataMap(soilDataArray) // Build crop_properties from catalogue entries for this field's cultivations @@ -331,6 +360,7 @@ export async function getDynaForField({ farmSector, timeframe, cropProperties.length > 0 ? cropProperties : undefined, + harvestsByBlu, ) return getDyna(fdm, { b_id, nmiApiKey, requestBody }) diff --git a/fdm-calculator/src/mineralisatie/builders.test.ts b/fdm-calculator/src/mineralisatie/builders.test.ts new file mode 100644 index 000000000..8d9ccf144 --- /dev/null +++ b/fdm-calculator/src/mineralisatie/builders.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest" +import { buildDynaRequest } from "./builders" +import type { Timeframe } from "@nmi-agro/fdm-core" + +const baseField = { b_id: "field-1", b_centroid: [5.0, 52.0] as [number, number], b_area: 5 } +const soilData = { a_som_loi: 3.5, b_soiltype_agr: "clay" } +const timeframe2025: Timeframe = { + start: new Date("2025-01-01"), + end: new Date("2025-12-31"), +} + +describe("buildDynaRequest – rotation building", () => { + it("includes a cultivation that started in the requested year", () => { + const cultivations = [ + { + b_lu: "cult-1", + b_lu_catalogue: "bwt", + b_lu_start: new Date("2025-03-01"), + b_lu_end: new Date("2025-10-01"), + b_lu_croprotation: "main", + m_cropresidue: false, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + const rotation = result.rotation as { year: number; b_lu: string }[] + expect(rotation).toHaveLength(1) + expect(rotation[0].year).toBe(2025) + expect(rotation[0].b_lu).toBe("bwt") + }) + + it("includes a cultivation that started in a prior year but is still active in the requested year", () => { + const cultivations = [ + { + b_lu_catalogue: "grs", + b_lu_start: new Date("2024-04-01"), + b_lu_end: new Date("2025-11-30"), + b_lu_croprotation: "main", + m_cropresidue: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) + const rotation = result.rotation as { year: number; b_lu: string }[] + expect(rotation).toHaveLength(1) + expect(rotation[0].year).toBe(2025) + expect(rotation[0].b_lu).toBe("grs") + }) + + it("includes a cultivation with no end date started before the requested year", () => { + const cultivations = [ + { + b_lu_catalogue: "grs", + b_lu_start: new Date("2023-01-01"), + b_lu_end: null, + b_lu_croprotation: "main", + m_cropresidue: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) + const rotation = result.rotation as { year: number; b_lu: string }[] + expect(rotation).toHaveLength(1) + expect(rotation[0].b_lu).toBe("grs") + }) + + it("excludes a cultivation that ended before the requested year", () => { + const cultivations = [ + { + b_lu_catalogue: "bwt", + b_lu_start: new Date("2023-03-01"), + b_lu_end: new Date("2023-10-01"), + b_lu_croprotation: "main", + m_cropresidue: false, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + const rotation = result.rotation as unknown[] + // Falls back to the empty fallback entry (no b_lu) + expect(rotation).toHaveLength(1) + expect((rotation[0] as Record).b_lu).toBeUndefined() + }) + + it("only produces a single rotation entry for the requested year even when cultivations span multiple years", () => { + const cultivations = [ + { + b_lu_catalogue: "bwt", + b_lu_start: new Date("2024-03-01"), + b_lu_end: new Date("2024-10-01"), + b_lu_croprotation: "main", + m_cropresidue: false, + }, + { + b_lu_catalogue: "uib", + b_lu_start: new Date("2025-04-01"), + b_lu_end: new Date("2025-09-15"), + b_lu_croprotation: "main", + m_cropresidue: false, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + const rotation = result.rotation as { year: number; b_lu: string }[] + // Only 2025 entry is produced — 2024 cultivation is excluded (ended before 2025) + expect(rotation).toHaveLength(1) + expect(rotation[0].year).toBe(2025) + expect(rotation[0].b_lu).toBe("uib") + }) + + it("uses May 15th rule to pick main crop for a multi-year cultivation overlapping with a short crop in the same year", () => { + // Grass started 2024, still active May 15 2025 → chosen as main crop + // Catch crop starts after May 15 and becomes green manure + const cultivations = [ + { + b_lu: "cult-grs", + b_lu_catalogue: "grs", + b_lu_start: new Date("2024-04-01"), + b_lu_end: new Date("2025-09-30"), + b_lu_croprotation: "main", + m_cropresidue: null, + }, + { + b_lu: "cult-cc", + b_lu_catalogue: "phc", + b_lu_start: new Date("2025-10-01"), + b_lu_end: new Date("2025-12-01"), + b_lu_croprotation: "catchcrop", + m_cropresidue: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) + const rotation = result.rotation as { year: number; b_lu: string; b_lu_green?: string }[] + expect(rotation).toHaveLength(1) + expect(rotation[0].b_lu).toBe("grs") + expect(rotation[0].b_lu_green).toBe("phc") + }) +}) + +describe("buildDynaRequest – harvests", () => { + it("uses actual harvest records from harvestsByBlu when provided", () => { + const cultivations = [ + { + b_lu: "cult-grass", + b_lu_catalogue: "grs", + b_lu_start: new Date("2024-10-15"), + b_lu_end: null, + b_lu_croprotation: "main", + m_cropresidue: null, + }, + ] + const harvestsByBlu = new Map([ + [ + "cult-grass", + [ + { b_lu_harvest_date: new Date("2025-05-10"), b_lu_yield: 3200 }, + { b_lu_harvest_date: new Date("2025-07-01"), b_lu_yield: 2800 }, + { b_lu_harvest_date: new Date("2025-09-15"), b_lu_yield: 2600 }, + ], + ], + ]) + const result = buildDynaRequest( + baseField, soilData, cultivations, [], "dairy", timeframe2025, + undefined, harvestsByBlu, + ) + const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield: number }[] }[] + expect(rotation).toHaveLength(1) + expect(rotation[0].harvests).toHaveLength(3) + expect(rotation[0].harvests[0].b_date_harvest).toBe("2025-05-10") + expect(rotation[0].harvests[0].b_lu_yield).toBe(3200) + expect(rotation[0].harvests[2].b_date_harvest).toBe("2025-09-15") + }) + + it("falls back to b_lu_end harvest when no harvest records are in the map", () => { + const cultivations = [ + { + b_lu: "cult-wheat", + b_lu_catalogue: "bwt", + b_lu_start: new Date("2025-03-01"), + b_lu_end: new Date("2025-08-15"), + b_lu_croprotation: "main", + m_cropresidue: false, + }, + ] + const result = buildDynaRequest( + baseField, soilData, cultivations, [], "arable", timeframe2025, + [{ b_lu_catalogue: "bwt", b_lu_yield: 1800 }], + new Map(), + ) + const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] + expect(rotation[0].harvests).toHaveLength(1) + expect(rotation[0].harvests[0].b_date_harvest).toBe("2025-08-15") + expect(rotation[0].harvests[0].b_lu_yield).toBe(1800) + }) + + it("uses catalogue yield as fallback when harvest record has no yield", () => { + const cultivations = [ + { + b_lu: "cult-grass", + b_lu_catalogue: "grs", + b_lu_start: new Date("2024-10-15"), + b_lu_end: null, + b_lu_croprotation: "main", + m_cropresidue: null, + }, + ] + const harvestsByBlu = new Map([ + [ + "cult-grass", + [{ b_lu_harvest_date: new Date("2025-05-10"), b_lu_yield: null }], + ], + ]) + const result = buildDynaRequest( + baseField, soilData, cultivations, [], "dairy", timeframe2025, + [{ b_lu_catalogue: "grs", b_lu_yield: 1838 }], + harvestsByBlu, + ) + const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] + expect(rotation[0].harvests[0].b_lu_yield).toBe(1838) + }) + + it("produces empty harvests when no records and no end date (ongoing cultivation)", () => { + const cultivations = [ + { + b_lu: "cult-grass", + b_lu_catalogue: "grs", + b_lu_start: new Date("2024-10-15"), + b_lu_end: null, + b_lu_croprotation: "main", + m_cropresidue: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) + const rotation = result.rotation as { harvests: unknown[] }[] + expect(rotation[0].harvests).toHaveLength(0) + }) +}) + diff --git a/fdm-calculator/src/mineralisatie/builders.ts b/fdm-calculator/src/mineralisatie/builders.ts index cfd9548f9..7e190644f 100644 --- a/fdm-calculator/src/mineralisatie/builders.ts +++ b/fdm-calculator/src/mineralisatie/builders.ts @@ -244,6 +244,7 @@ export function buildDynaRequest( }, soilData: Record, cultivations: { + b_lu?: string | null b_lu_catalogue?: string | null b_lu_start?: Date | null b_lu_end?: Date | null @@ -283,6 +284,10 @@ export function buildDynaRequest( b_lu_n_harvestable?: number | null b_lu_n_residue?: number | null }[], + harvestsByBlu?: Map< + string, + { b_lu_harvest_date?: Date | null; b_lu_yield?: number | null }[] + >, ): Record { const centroid = field.b_centroid const a_lon = centroid ? centroid[0] : undefined @@ -329,21 +334,16 @@ export function buildDynaRequest( p_date_fertilization: f.p_date?.toISOString().split("T")[0], })) - // Collect distinct start years across all cultivations - const allYears = [ - ...new Set( - cultivations - .filter((c) => c.b_lu_catalogue && c.b_lu_start) - .map((c) => c.b_lu_start!.getFullYear()), - ), - ].sort((a, b) => a - b) - - const rotation: Record[] = allYears + const rotation: Record[] = [year] .map((rotationYear) => { + const yearStart = new Date(rotationYear, 0, 1) + const yearEnd = new Date(rotationYear, 11, 31, 23, 59, 59, 999) const yearCultivations = cultivations.filter( (c) => c.b_lu_catalogue && - c.b_lu_start?.getFullYear() === rotationYear, + c.b_lu_start != null && + c.b_lu_start <= yearEnd && + (c.b_lu_end == null || c.b_lu_end >= yearStart), ) // Select main crop using May 15th rule @@ -355,18 +355,39 @@ export function buildDynaRequest( const cropProp = cropProperties?.find( (cp) => cp.b_lu_catalogue === mainCrop.b_lu_catalogue, ) - const harvests = mainCrop.b_lu_end - ? [ - { - b_date_harvest: mainCrop.b_lu_end - .toISOString() - .split("T")[0], - ...(cropProp?.b_lu_yield != null - ? { b_lu_yield: cropProp.b_lu_yield } - : {}), - }, - ] - : [] + + // Prefer actual harvest records; fall back to inferring a single + // harvest from b_lu_end when no records are available. + const actualHarvestRecords = + mainCrop.b_lu && harvestsByBlu + ? (harvestsByBlu.get(mainCrop.b_lu) ?? []) + : [] + const harvests = + actualHarvestRecords.length > 0 + ? actualHarvestRecords + .filter((h) => h.b_lu_harvest_date != null) + .map((h) => ({ + b_date_harvest: h.b_lu_harvest_date! + .toISOString() + .split("T")[0], + ...(h.b_lu_yield != null + ? { b_lu_yield: h.b_lu_yield } + : cropProp?.b_lu_yield != null + ? { b_lu_yield: cropProp.b_lu_yield } + : {}), + })) + : mainCrop.b_lu_end + ? [ + { + b_date_harvest: mainCrop.b_lu_end + .toISOString() + .split("T")[0], + ...(cropProp?.b_lu_yield != null + ? { b_lu_yield: cropProp.b_lu_yield } + : {}), + }, + ] + : [] // Catchcrop becomes green manure on the same rotation entry const greenManure = yearCultivations.find( From f59af915aac5ebe86901a5601ea52bb8ca102db2 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:16:45 +0200 Subject: [PATCH 05/22] feat: improvements --- .../blocks/mineralisatie/dyna-chart.tsx | 228 +++++++++++++----- .../mineralisatie/mineralisatie-chart.tsx | 54 +++-- ...m.$calendar.mineralisatie.$b_id._index.tsx | 64 ++--- ...arm.$calendar.mineralisatie.$b_id.dyna.tsx | 50 +++- ...id_farm.$calendar.mineralisatie._index.tsx | 5 +- .../src/mineralisatie/builders.test.ts | 20 +- 6 files changed, 292 insertions(+), 129 deletions(-) diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx index 5a8bfdae6..93f6f2c8d 100644 --- a/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx +++ b/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx @@ -15,7 +15,6 @@ import { ChartLegend, ChartLegendContent, ChartTooltip, - ChartTooltipContent, } from "~/components/ui/chart" import type { DynaDailyPoint, @@ -92,34 +91,119 @@ const EVENT_COLORS: Record = { fertilizer: "hsl(217, 91%, 60%)", } -const EVENT_ABBR: Record = { - sowing: "Z", - harvest: "O", - fertilizer: "M", +function groupEventsByDate( + events: DynaChartEvent[], +): Map { + const map = new Map() + for (const ev of events) { + const arr = map.get(ev.date) ?? [] + arr.push(ev) + map.set(ev.date, arr) + } + return map } -interface EventLabelProps { - viewBox?: { x?: number; y?: number; height?: number } - event: DynaChartEvent - stackIndex: number +interface EventDotProps { + viewBox?: { x?: number; y?: number } + events: DynaChartEvent[] } -function EventLabel({ viewBox, event, stackIndex }: EventLabelProps) { +function EventDot({ viewBox, events }: EventDotProps) { if (!viewBox?.x || viewBox.y === undefined) return null const x = viewBox.x - // Stack labels vertically: 4px from top + 14px per stacked slot - const y = (viewBox.y ?? 0) + 10 + stackIndex * 14 + const y = (viewBox.y ?? 0) + 10 + const color = EVENT_COLORS[events[0].type] return ( - - {EVENT_ABBR[event.type]} - + + ) +} + +type DynaChartPoint = { + date: string + b_nw: number + b_nw_min: number + b_nw_max: number + b_nw_recommended: number | null + b_n_uptake: number | null + _events?: DynaChartEvent[] +} + +const SERIES_TO_SHOW = ["b_nw", "b_n_uptake", "b_nw_recommended"] as const + +function DynaTooltipContent({ + active, + payload, +}: { + active?: boolean + payload?: Array<{ + dataKey: string + value: number + color?: string + payload: DynaChartPoint + }> +}) { + if (!active || !payload?.length) return null + const point = payload[0]?.payload + if (!point) return null + + const visibleEntries = payload.filter((p) => + SERIES_TO_SHOW.includes(p.dataKey as (typeof SERIES_TO_SHOW)[number]), + ) + + return ( +
+
{formatDateLabel(point.date)}
+
+ {visibleEntries.map((entry) => ( +
+
+ + {dynaChartConfig[ + entry.dataKey as keyof typeof dynaChartConfig + ]?.label ?? entry.dataKey} + + + {Number(entry.value).toFixed(1)}{" "} + + kg N/ha + + +
+ ))} +
+ {point._events && point._events.length > 0 && ( +
+ {point._events.map((ev, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: stable ordered list +
+
+ + {ev.label} + +
+ ))} +
+ )} +
) } @@ -127,33 +211,36 @@ interface DynaChartProps { data: DynaDailyPoint[] fertilizingRecommendations: DynaFertilizerAdvice | null events?: DynaChartEvent[] + year?: number } export function DynaChart({ data, fertilizingRecommendations, events = [], + year = new Date().getFullYear(), }: DynaChartProps) { const monthTicks = getMonthTicks(data) const today = new Date().toISOString().split("T")[0] ?? "" + const isCurrentYear = year === new Date().getFullYear() const recDate = fertilizingRecommendations?.b_date_recommended - // Count how many events share the same date, for stacking labels - const dateCounts = new Map() - const eventsWithStack = events.map((ev) => { - const count = dateCounts.get(ev.date) ?? 0 - dateCounts.set(ev.date, count + 1) - return { ...ev, stackIndex: count } - }) + // Group events by date and only include dates present in the data + const eventsByDate = groupEventsByDate(events) + const chartDates = new Set(data.map((d) => d.b_date_calculation)) + const uniqueEventDates = Array.from(eventsByDate.entries()).filter( + ([date]) => chartDates.has(date), + ) - const chartData = data.map((d) => ({ + const chartData: DynaChartPoint[] = data.map((d) => ({ date: d.b_date_calculation, b_nw: d.b_nw, b_nw_min: d.b_nw_min, b_nw_max: d.b_nw_max, b_nw_recommended: d.b_nw_recommended, b_n_uptake: d.b_n_uptake, + _events: eventsByDate.get(d.b_date_calculation), })) return ( @@ -181,6 +268,24 @@ export function DynaChart({ stopOpacity={0.05} /> + + + + - { - const raw = payload?.[0]?.payload?.date ?? label - return formatDateLabel(raw) - }} - /> - } - /> + } /> } /> - {/* Min-max band */} + {/* Min-max band (render first so it's behind everything) */} - {/* N availability line */} - - {/* N uptake line */} + {/* N uptake — dashed line for distinction */} - {/* Today reference line */} - + {/* Today reference line — only for current year */} + {isCurrentYear && ( + + )} {/* Fertilizer recommendation date */} {recDate && ( @@ -287,20 +387,18 @@ export function DynaChart({ /> )} - {/* Field events: sowing (Z), harvest (O), fertilizer (M) - Labels are stacked vertically per date to avoid overlap */} - {eventsWithStack.map((ev, idx) => ( + {/* Field events — info dot at top of each vertical line. + Events grouped by date; tooltip shows all events for that day. */} + {uniqueEventDates.map(([date, evs]) => ( ( - )} /> diff --git a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx index db38cacb0..22b16fa28 100644 --- a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx +++ b/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx @@ -78,6 +78,7 @@ export function FarmMineralisatieChart({ year = new Date().getFullYear(), }: FarmMineralisatieChartProps) { const currentDoy = getCurrentDoy() + const isCurrentYear = year === new Date().getFullYear() return ( @@ -146,18 +147,20 @@ export function FarmMineralisatieChart({ /> } /> - + {isCurrentYear && ( + + )} !s.error) @@ -313,18 +317,20 @@ export function FieldMineralisatieChart({ /> } /> - + {isCurrentYear && ( + + )} {activeSeries.map((s) => { const style = METHOD_STYLE[s.method] return ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx index fb0c73f17..8c431c72a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx @@ -1,5 +1,5 @@ import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" -import { ArrowRight, Lightbulb, Slash } from "lucide-react" +import { ArrowRight, FlaskConical, Lightbulb, Slash } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -276,6 +276,37 @@ function MineralisatieFieldContent({ )} + {/* DYNA Call-to-Action — prominent banner */} +
+
+
+ +
+
+
+ + Dynamisch N-advies met DYNA + + + bèta + +
+

+ Gedetailleerd N-opname vs. beschikbaarheid advies op + dagbasis — inclusief uitspoelingsrisico. +

+
+
+ +
+ Mineralisatiecurve @@ -285,7 +316,10 @@ function MineralisatieFieldContent({ - + @@ -299,32 +333,6 @@ function MineralisatieFieldContent({ calendar={calendar} />
- - {/* DYNA Call-to-Action */} - - - Dynamisch N-advies beschikbaar (bèta) - - - Bereken gedetailleerd N-opname vs. beschikbaarheid - advies met het DYNA-model. - - - - - ) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx index f45a88de9..de6816592 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx @@ -4,15 +4,17 @@ import { getField, getGrazingIntention, getCultivations, + getHarvests, type FertilizerApplication, type Fertilizer, } from "@nmi-agro/fdm-core" -import { Slash } from "lucide-react" +import { CalendarOff, Slash } from "lucide-react" import { Suspense, use } from "react" import { data, type LoaderFunctionArgs, type MetaFunction, + NavLink, useLoaderData, } from "react-router" import { DynaAdviceCard } from "~/components/blocks/mineralisatie/dyna-advice" @@ -20,6 +22,7 @@ import { DynaBalanceCard } from "~/components/blocks/mineralisatie/dyna-balance" import { DynaChart } from "~/components/blocks/mineralisatie/dyna-chart" import { LeachingChart } from "~/components/blocks/mineralisatie/leaching-chart" import { DynaFallback } from "~/components/blocks/mineralisatie/skeletons" +import { Button } from "~/components/ui/button" import { Card, CardContent, @@ -116,6 +119,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) { getCultivations(fdm, session.principal_id, b_id, timeframe), ]) + // Pre-flight check: any main crop without a harvest date will cause + // the DYNA API to return 400 "b_date_harvest is missing". + const ongoingMainCrops = cultivations.filter( + (c) => + c.b_lu_end == null && + c.b_lu_croprotation !== "catchcrop", + ) + for (const crop of ongoingMainCrops) { + if (!crop.b_lu) continue + const harvests = await getHarvests(fdm, session.principal_id, crop.b_lu, timeframe) + if (harvests.length === 0) { + return { + missingHarvestDate: true as const, + field, + b_id, + b_id_farm, + calendar: params.calendar ?? "", + } + } + } + // Build a map from p_id → full fertilizer properties for quick lookup const fertilizerMap = new Map( fertilizers.map((f: Fertilizer) => [f.p_id, f]), @@ -224,6 +248,29 @@ export default function DynaPage() { ) } + if (loaderData.missingHarvestDate) { + return ( + + + + + + Oogstdatum ontbreekt + + DYNA heeft een oogstdatum nodig om de berekening uit te + voeren. Voeg een oogstdatum toe aan het gewas op dit + perceel. + + + + + + + ) + } + const { asyncData, chartEvents } = loaderData return ( @@ -338,6 +385,7 @@ function DynaContent({ data={yearData} fertilizingRecommendations={fertilizingRecommendations} events={chartEvents} + year={year} /> diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx index 9b0f50dc5..b13c164c5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie._index.tsx @@ -162,7 +162,10 @@ function MineralisatieFarmContent({ - +
diff --git a/fdm-calculator/src/mineralisatie/builders.test.ts b/fdm-calculator/src/mineralisatie/builders.test.ts index 8d9ccf144..5b407d62e 100644 --- a/fdm-calculator/src/mineralisatie/builders.test.ts +++ b/fdm-calculator/src/mineralisatie/builders.test.ts @@ -22,7 +22,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) - const rotation = result.rotation as { year: number; b_lu: string }[] + const rotation = (result.field as Record).rotation as { year: number; b_lu: string }[] expect(rotation).toHaveLength(1) expect(rotation[0].year).toBe(2025) expect(rotation[0].b_lu).toBe("bwt") @@ -39,7 +39,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) - const rotation = result.rotation as { year: number; b_lu: string }[] + const rotation = (result.field as Record).rotation as { year: number; b_lu: string }[] expect(rotation).toHaveLength(1) expect(rotation[0].year).toBe(2025) expect(rotation[0].b_lu).toBe("grs") @@ -56,7 +56,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) - const rotation = result.rotation as { year: number; b_lu: string }[] + const rotation = (result.field as Record).rotation as { year: number; b_lu: string }[] expect(rotation).toHaveLength(1) expect(rotation[0].b_lu).toBe("grs") }) @@ -72,7 +72,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) - const rotation = result.rotation as unknown[] + const rotation = (result.field as Record).rotation as unknown[] // Falls back to the empty fallback entry (no b_lu) expect(rotation).toHaveLength(1) expect((rotation[0] as Record).b_lu).toBeUndefined() @@ -96,7 +96,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) - const rotation = result.rotation as { year: number; b_lu: string }[] + const rotation = (result.field as Record).rotation as { year: number; b_lu: string }[] // Only 2025 entry is produced — 2024 cultivation is excluded (ended before 2025) expect(rotation).toHaveLength(1) expect(rotation[0].year).toBe(2025) @@ -125,7 +125,7 @@ describe("buildDynaRequest – rotation building", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) - const rotation = result.rotation as { year: number; b_lu: string; b_lu_green?: string }[] + const rotation = (result.field as Record).rotation as { year: number; b_lu: string; b_lu_green?: string }[] expect(rotation).toHaveLength(1) expect(rotation[0].b_lu).toBe("grs") expect(rotation[0].b_lu_green).toBe("phc") @@ -158,7 +158,7 @@ describe("buildDynaRequest – harvests", () => { baseField, soilData, cultivations, [], "dairy", timeframe2025, undefined, harvestsByBlu, ) - const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield: number }[] }[] + const rotation = (result.field as Record).rotation as { harvests: { b_date_harvest: string; b_lu_yield: number }[] }[] expect(rotation).toHaveLength(1) expect(rotation[0].harvests).toHaveLength(3) expect(rotation[0].harvests[0].b_date_harvest).toBe("2025-05-10") @@ -182,7 +182,7 @@ describe("buildDynaRequest – harvests", () => { [{ b_lu_catalogue: "bwt", b_lu_yield: 1800 }], new Map(), ) - const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] + const rotation = (result.field as Record).rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] expect(rotation[0].harvests).toHaveLength(1) expect(rotation[0].harvests[0].b_date_harvest).toBe("2025-08-15") expect(rotation[0].harvests[0].b_lu_yield).toBe(1800) @@ -210,7 +210,7 @@ describe("buildDynaRequest – harvests", () => { [{ b_lu_catalogue: "grs", b_lu_yield: 1838 }], harvestsByBlu, ) - const rotation = result.rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] + const rotation = (result.field as Record).rotation as { harvests: { b_date_harvest: string; b_lu_yield?: number }[] }[] expect(rotation[0].harvests[0].b_lu_yield).toBe(1838) }) @@ -226,7 +226,7 @@ describe("buildDynaRequest – harvests", () => { }, ] const result = buildDynaRequest(baseField, soilData, cultivations, [], "dairy", timeframe2025) - const rotation = result.rotation as { harvests: unknown[] }[] + const rotation = (result.field as Record).rotation as { harvests: unknown[] }[] expect(rotation[0].harvests).toHaveLength(0) }) }) From 23b7b63d807488f2227da39f49a9b737cbdc4904 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:23:14 +0200 Subject: [PATCH 06/22] refactor: renaming for consistency --- .../{mineralisatie.tsx => mineralization.tsx} | 8 ++-- .../data-completeness.tsx | 2 +- .../dyna-advice.tsx | 2 +- .../dyna-balance.tsx | 4 +- .../dyna-chart.tsx | 2 +- .../field-list.tsx | 4 +- .../leaching-chart.tsx | 2 +- .../method-selector.tsx | 2 +- .../mineralization-chart.tsx} | 14 +++---- .../nsupply-kpi.tsx | 2 +- .../skeletons.tsx | 40 +++++++++---------- .../app/components/blocks/sidebar/farm.tsx | 4 +- ...tie.server.ts => mineralization.server.ts} | 2 +- ...$calendar.mineralization.$b_id._index.tsx} | 22 +++++----- ...m.$calendar.mineralization.$b_id.dyna.tsx} | 12 +++--- ...d_farm.$calendar.mineralization.$b_id.tsx} | 2 +- ..._farm.$calendar.mineralization._index.tsx} | 26 ++++++------ ...m.$b_id_farm.$calendar.mineralization.tsx} | 6 +-- 18 files changed, 78 insertions(+), 78 deletions(-) rename fdm-app/app/components/blocks/header/{mineralisatie.tsx => mineralization.tsx} (96%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/data-completeness.tsx (99%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/dyna-advice.tsx (99%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/dyna-balance.tsx (95%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/dyna-chart.tsx (99%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/field-list.tsx (96%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/leaching-chart.tsx (98%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/method-selector.tsx (96%) rename fdm-app/app/components/blocks/{mineralisatie/mineralisatie-chart.tsx => mineralization/mineralization-chart.tsx} (97%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/nsupply-kpi.tsx (99%) rename fdm-app/app/components/blocks/{mineralisatie => mineralization}/skeletons.tsx (85%) rename fdm-app/app/integrations/{mineralisatie.server.ts => mineralization.server.ts} (99%) rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx => farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx} (95%) rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.mineralisatie.$b_id.dyna.tsx => farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx} (96%) rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.mineralisatie.$b_id.tsx => farm.$b_id_farm.$calendar.mineralization.$b_id.tsx} (53%) rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.mineralisatie._index.tsx => farm.$b_id_farm.$calendar.mineralization._index.tsx} (89%) rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.mineralisatie.tsx => farm.$b_id_farm.$calendar.mineralization.tsx} (96%) diff --git a/fdm-app/app/components/blocks/header/mineralisatie.tsx b/fdm-app/app/components/blocks/header/mineralization.tsx similarity index 96% rename from fdm-app/app/components/blocks/header/mineralisatie.tsx rename to fdm-app/app/components/blocks/header/mineralization.tsx index 54e8e67e1..b3831cacd 100644 --- a/fdm-app/app/components/blocks/header/mineralisatie.tsx +++ b/fdm-app/app/components/blocks/header/mineralization.tsx @@ -18,7 +18,7 @@ type HeaderFieldOption = { b_name: string | null | undefined } -export function HeaderMineralisatie({ +export function HeaderMineralization({ b_id_farm, b_id, fieldOptions, @@ -40,7 +40,7 @@ export function HeaderMineralisatie({ Mineralisatie @@ -65,7 +65,7 @@ export function HeaderMineralisatie({ key={option.b_id} > {option.b_name} @@ -82,7 +82,7 @@ export function HeaderMineralisatie({ DYNA diff --git a/fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx b/fdm-app/app/components/blocks/mineralization/data-completeness.tsx similarity index 99% rename from fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx rename to fdm-app/app/components/blocks/mineralization/data-completeness.tsx index 571dbf057..aea5f4176 100644 --- a/fdm-app/app/components/blocks/mineralisatie/data-completeness.tsx +++ b/fdm-app/app/components/blocks/mineralization/data-completeness.tsx @@ -18,7 +18,7 @@ import { import type { DataCompleteness, NSupplyMethod, -} from "~/integrations/mineralisatie.server" +} from "~/integrations/mineralization.server" const PARAM_LABELS: Record = { a_som_loi: "Organische stof (LOI)", diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx similarity index 99% rename from fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx rename to fdm-app/app/components/blocks/mineralization/dyna-advice.tsx index 853f0bc15..12c3b6d03 100644 --- a/fdm-app/app/components/blocks/mineralisatie/dyna-advice.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx @@ -7,7 +7,7 @@ import { CardTitle, } from "~/components/ui/card" import { Separator } from "~/components/ui/separator" -import type { DynaFertilizerAdvice } from "~/integrations/mineralisatie.server" +import type { DynaFertilizerAdvice } from "~/integrations/mineralization.server" interface DynaAdviceCardProps { fertilizingRecommendations: DynaFertilizerAdvice | null diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx similarity index 95% rename from fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx rename to fdm-app/app/components/blocks/mineralization/dyna-balance.tsx index 06ecc4daa..1946aac80 100644 --- a/fdm-app/app/components/blocks/mineralisatie/dyna-balance.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx @@ -6,7 +6,7 @@ import { CardTitle, } from "~/components/ui/card" import { Separator } from "~/components/ui/separator" -import type { DynaNitrogenBalance } from "~/integrations/mineralisatie.server" +import type { DynaNitrogenBalance } from "~/integrations/mineralization.server" interface DynaBalanceCardProps { nitrogenBalance: DynaNitrogenBalance @@ -41,7 +41,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { return ( - N-balans + Werkzame N-balans Stikstofaanbod naar bron (kg N/ha/jaar) diff --git a/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx similarity index 99% rename from fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx rename to fdm-app/app/components/blocks/mineralization/dyna-chart.tsx index 93f6f2c8d..5c67895e1 100644 --- a/fdm-app/app/components/blocks/mineralisatie/dyna-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx @@ -19,7 +19,7 @@ import { import type { DynaDailyPoint, DynaFertilizerAdvice, -} from "~/integrations/mineralisatie.server" +} from "~/integrations/mineralization.server" const MONTH_LABELS_NL = [ "Jan", diff --git a/fdm-app/app/components/blocks/mineralisatie/field-list.tsx b/fdm-app/app/components/blocks/mineralization/field-list.tsx similarity index 96% rename from fdm-app/app/components/blocks/mineralisatie/field-list.tsx rename to fdm-app/app/components/blocks/mineralization/field-list.tsx index 208cf5524..3e09d8459 100644 --- a/fdm-app/app/components/blocks/mineralisatie/field-list.tsx +++ b/fdm-app/app/components/blocks/mineralization/field-list.tsx @@ -8,7 +8,7 @@ import { TableHeader, TableRow, } from "~/components/ui/table" -import type { NSupplyResult } from "~/integrations/mineralisatie.server" +import type { NSupplyResult } from "~/integrations/mineralization.server" interface FieldListProps { results: NSupplyResult[] @@ -46,7 +46,7 @@ export function FieldList({ results, b_id_farm, calendar }: FieldListProps) { {result.b_name} diff --git a/fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx b/fdm-app/app/components/blocks/mineralization/leaching-chart.tsx similarity index 98% rename from fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx rename to fdm-app/app/components/blocks/mineralization/leaching-chart.tsx index bd9e44f27..0648ccc8d 100644 --- a/fdm-app/app/components/blocks/mineralisatie/leaching-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/leaching-chart.tsx @@ -7,7 +7,7 @@ import { ChartTooltip, ChartTooltipContent, } from "~/components/ui/chart" -import type { DynaDailyPoint } from "~/integrations/mineralisatie.server" +import type { DynaDailyPoint } from "~/integrations/mineralization.server" const MONTH_LABELS_NL = [ "Jan", diff --git a/fdm-app/app/components/blocks/mineralisatie/method-selector.tsx b/fdm-app/app/components/blocks/mineralization/method-selector.tsx similarity index 96% rename from fdm-app/app/components/blocks/mineralisatie/method-selector.tsx rename to fdm-app/app/components/blocks/mineralization/method-selector.tsx index c8db83043..589d831b2 100644 --- a/fdm-app/app/components/blocks/mineralisatie/method-selector.tsx +++ b/fdm-app/app/components/blocks/mineralization/method-selector.tsx @@ -6,7 +6,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select" -import type { NSupplyMethod } from "~/integrations/mineralisatie.server" +import type { NSupplyMethod } from "~/integrations/mineralization.server" const METHOD_OPTIONS: { value: NSupplyMethod diff --git a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx b/fdm-app/app/components/blocks/mineralization/mineralization-chart.tsx similarity index 97% rename from fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx rename to fdm-app/app/components/blocks/mineralization/mineralization-chart.tsx index 22b16fa28..5e7aab739 100644 --- a/fdm-app/app/components/blocks/mineralisatie/mineralisatie-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/mineralization-chart.tsx @@ -19,7 +19,7 @@ import { import type { NSupplyDataPoint, NSupplyMethod, -} from "~/integrations/mineralisatie.server" +} from "~/integrations/mineralization.server" const MONTH_DOYS = [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] const MONTH_LABELS = [ @@ -61,7 +61,7 @@ function getCurrentDoy(): number { // ─── Single-series chart (farm overview) ───────────────────────────────────── -interface FarmMineralisatieChartProps { +interface FarmMineralizationChartProps { data: NSupplyDataPoint[] year?: number } @@ -73,10 +73,10 @@ const farmChartConfig = { }, } satisfies ChartConfig -export function FarmMineralisatieChart({ +export function FarmMineralizationChart({ data, year = new Date().getFullYear(), -}: FarmMineralisatieChartProps) { +}: FarmMineralizationChartProps) { const currentDoy = getCurrentDoy() const isCurrentYear = year === new Date().getFullYear() @@ -183,7 +183,7 @@ interface FieldDataSeries { error?: string } -interface FieldMineralisatieChartProps { +interface FieldMineralizationChartProps { series: FieldDataSeries[] year?: number } @@ -236,10 +236,10 @@ function mergeSeriesData( ) } -export function FieldMineralisatieChart({ +export function FieldMineralizationChart({ series, year = new Date().getFullYear(), -}: FieldMineralisatieChartProps) { +}: FieldMineralizationChartProps) { const currentDoy = getCurrentDoy() const isCurrentYear = year === new Date().getFullYear() const mergedData = mergeSeriesData(series) diff --git a/fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx similarity index 99% rename from fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx rename to fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx index 45e76e6de..43b7e10ba 100644 --- a/fdm-app/app/components/blocks/mineralisatie/nsupply-kpi.tsx +++ b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx @@ -3,7 +3,7 @@ import { Separator } from "~/components/ui/separator" import type { NSupplyMethod, NSupplyResult, -} from "~/integrations/mineralisatie.server" +} from "~/integrations/mineralization.server" const METHOD_LABELS: Record = { minip: "MINIP", diff --git a/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx b/fdm-app/app/components/blocks/mineralization/skeletons.tsx similarity index 85% rename from fdm-app/app/components/blocks/mineralisatie/skeletons.tsx rename to fdm-app/app/components/blocks/mineralization/skeletons.tsx index b0599567f..bf7609598 100644 --- a/fdm-app/app/components/blocks/mineralisatie/skeletons.tsx +++ b/fdm-app/app/components/blocks/mineralization/skeletons.tsx @@ -7,7 +7,7 @@ import { } from "~/components/ui/card" import { Skeleton } from "~/components/ui/skeleton" -export function MineralisatieCardSkeleton() { +export function MineralizationCardSkeleton() { return ( @@ -22,7 +22,7 @@ export function MineralisatieCardSkeleton() { ) } -export function MineralisatieChartSkeleton() { +export function MineralizationChartSkeleton() { return ( @@ -40,7 +40,7 @@ export function MineralisatieChartSkeleton() { ) } -export function MineralisatieFieldsSkeleton() { +export function MineralizationFieldsSkeleton() { return ( @@ -68,20 +68,20 @@ export function MineralisatieFieldsSkeleton() { } /** Fallback for the farm overview page */ -export function MineralisatieFallback() { +export function MineralizationFallback() { return (
- - - + + +
- +
- +
@@ -93,12 +93,12 @@ export function DynaFallback() { return (
- - - - + + + +
- +
@@ -136,16 +136,16 @@ export function DynaFallback() { } /** Fallback for the field detail page */ -export function MineralisatieFieldDetailFallback() { +export function MineralizationFieldDetailFallback() { return (
- - - - + + + +
- +
diff --git a/fdm-app/app/components/blocks/sidebar/farm.tsx b/fdm-app/app/components/blocks/sidebar/farm.tsx index a79acb6cf..e2f7e0dee 100644 --- a/fdm-app/app/components/blocks/sidebar/farm.tsx +++ b/fdm-app/app/components/blocks/sidebar/farm.tsx @@ -390,12 +390,12 @@ export function SidebarLabs() { Mineralisatie diff --git a/fdm-app/app/integrations/mineralisatie.server.ts b/fdm-app/app/integrations/mineralization.server.ts similarity index 99% rename from fdm-app/app/integrations/mineralisatie.server.ts rename to fdm-app/app/integrations/mineralization.server.ts index e64abe09f..c98a6ffc5 100644 --- a/fdm-app/app/integrations/mineralisatie.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -1,5 +1,5 @@ /** - * @file mineralisatie.server.ts + * @file mineralization.server.ts * * Server-side orchestration layer for the Mineralisatie (Nitrogen Mineralization) feature. * diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx similarity index 95% rename from fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx rename to fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx index 8c431c72a..7869fccd3 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralisatie.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx @@ -8,10 +8,10 @@ import { NavLink, useLoaderData, } from "react-router" -import { DataCompletenessCard } from "~/components/blocks/mineralisatie/data-completeness" -import { FieldMineralisatieChart } from "~/components/blocks/mineralisatie/mineralisatie-chart" -import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralisatie/nsupply-kpi" -import { MineralisatieFieldDetailFallback } from "~/components/blocks/mineralisatie/skeletons" +import { DataCompletenessCard } from "~/components/blocks/mineralization/data-completeness" +import { FieldMineralizationChart } from "~/components/blocks/mineralization/mineralization-chart" +import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralization/nsupply-kpi" +import { MineralizationFieldDetailFallback } from "~/components/blocks/mineralization/skeletons" import { Button } from "~/components/ui/button" import { Card, @@ -34,7 +34,7 @@ import { getNSupplyForField, type NSupplyMethod, type NSupplyResult, -} from "~/integrations/mineralisatie.server" +} from "~/integrations/mineralization.server" import { getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" @@ -193,7 +193,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } -export default function MineralisatieFieldDetail() { +export default function MineralizationFieldDetail() { const loaderData = useLoaderData() if (loaderData.isBufferStrip) { @@ -217,8 +217,8 @@ export default function MineralisatieFieldDetail() { return (
- }> - }> +
-
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index 771c85766..33101ff45 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -8,7 +8,7 @@ import { type FertilizerApplication, type Fertilizer, } from "@nmi-agro/fdm-core" -import { CalendarOff, Slash } from "lucide-react" +import { CalendarOff, Layers, Slash } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -275,6 +275,21 @@ export default function DynaPage() { return (
+ {/* Method context banner */} +
+
+ + + DYNA berekent de N-beschikbaarheid op basis van bodem, gewas én bemesting — nauwkeuriger dan bodem-alleen methoden. + +
+ + Bodem N-levering + +
}> Date: Mon, 20 Apr 2026 17:24:51 +0200 Subject: [PATCH 08/22] feat: improve charts and balances --- .../blocks/mineralization/dyna-balance.tsx | 127 +++-- .../blocks/mineralization/dyna-chart.tsx | 449 +++++++++++------- ....$calendar.mineralization.$b_id._index.tsx | 6 +- ...rm.$calendar.mineralization.$b_id.dyna.tsx | 36 +- 4 files changed, 392 insertions(+), 226 deletions(-) diff --git a/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx index 1946aac80..e2f65466a 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx @@ -12,58 +12,109 @@ interface DynaBalanceCardProps { nitrogenBalance: DynaNitrogenBalance } -interface BalanceRow { - label: string - value: number - isTotal?: boolean -} - export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { - const rows: BalanceRow[] = [ - { - label: "Kunstmest", - value: nitrogenBalance.b_n_fertilizer_artificial, - }, - { - label: "Organische mest", - value: nitrogenBalance.b_n_fertilizer_organic, - }, - { - label: "Groenbemester", - value: nitrogenBalance.b_n_greenmanure, - }, - { - label: "Voorvrucht", - value: nitrogenBalance.b_n_fertilizer_preceeding, - }, - ] + // Mineralisatie = b_nw - b_n_greenmanure - (artificial + organic fertilizer) + const mineralisatie = + nitrogenBalance.b_nw - + nitrogenBalance.b_n_greenmanure - + nitrogenBalance.b_n_fertilizer_artificial - + nitrogenBalance.b_n_fertilizer_organic + + // Total balance = b_nw - b_n_uptake + const totalBalance = nitrogenBalance.b_nw - nitrogenBalance.b_n_uptake return ( Werkzame N-balans - Stikstofaanbod naar bron (kg N/ha/jaar) + Stikstof uit mineralisatie en toevoegingen (kg N/ha/jaar)
- {rows.map((row) => ( -
-
- {row.label} -
-
- {row.value.toFixed(1)} kg N/ha -
-
- ))} + {/* Mineralisatie (bodem) */} +
+
+ Bodem mineralisatie +
+
+ {mineralisatie.toFixed(1)} kg N/ha +
+
+ + {/* Artificial fertilizer */} +
+
+ Kunstmest +
+
+ +{nitrogenBalance.b_n_fertilizer_artificial.toFixed(1)} kg N/ha +
+
+ + {/* Organic fertilizer */} +
+
+ Organische mest +
+
+ +{nitrogenBalance.b_n_fertilizer_organic.toFixed(1)} kg N/ha +
+
+ + {/* Preceding crop */} +
+
+ Voorvrucht +
+
+ +{nitrogenBalance.b_n_fertilizer_preceeding.toFixed(1)} kg N/ha +
+
+ + {/* Green manure */} +
+
+ Groenbemesting +
+
+ +{nitrogenBalance.b_n_greenmanure.toFixed(1)} kg N/ha +
+
+ + + {/* Total N aanbod */}
-
Totaal
+
Totaal aanbod
- {nitrogenBalance.b_nw.toFixed(1)} kg - N/ha + {nitrogenBalance.b_nw.toFixed(1)} kg N/ha +
+
+ + {/* Uptake */} +
+
N-opname gewas
+
+ -{nitrogenBalance.b_n_uptake.toFixed(1)} kg N/ha +
+
+ + + + {/* Balance (surplus/deficit) */} +
+
N-balans
+
0 + ? "text-orange-600" + : "text-green-600" + }`} + > + {totalBalance > 0 ? "+" : ""} + {totalBalance.toFixed(1)} kg N/ha
diff --git a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx index 5c67895e1..a82f0146b 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx @@ -9,6 +9,7 @@ import { XAxis, YAxis, } from "recharts" +import { useState } from "react" import { type ChartConfig, ChartContainer, @@ -16,6 +17,7 @@ import { ChartLegendContent, ChartTooltip, } from "~/components/ui/chart" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" import type { DynaDailyPoint, DynaFertilizerAdvice, @@ -66,16 +68,16 @@ const dynaChartConfig = { color: "hsl(var(--chart-1))", }, b_nw: { - label: "N beschikbaar", + label: "N aanbod", color: "hsl(var(--chart-1))", }, b_n_uptake: { label: "N opname", color: "hsl(var(--chart-2))", }, - b_nw_recommended: { - label: "N advies", - color: "hsl(var(--chart-3))", + b_nw_difference: { + label: "N surplus/deficit", + color: "hsl(var(--chart-1))", }, } satisfies ChartConfig @@ -133,14 +135,16 @@ type DynaChartPoint = { b_nw_max: number b_nw_recommended: number | null b_n_uptake: number | null + b_nw_difference?: number _events?: DynaChartEvent[] } -const SERIES_TO_SHOW = ["b_nw", "b_n_uptake", "b_nw_recommended"] as const +const SERIES_TO_SHOW = ["b_nw", "b_n_uptake"] as const function DynaTooltipContent({ active, payload, + isBalance = false, }: { active?: boolean payload?: Array<{ @@ -149,14 +153,18 @@ function DynaTooltipContent({ color?: string payload: DynaChartPoint }> + isBalance?: boolean }) { if (!active || !payload?.length) return null const point = payload[0]?.payload if (!point) return null - const visibleEntries = payload.filter((p) => - SERIES_TO_SHOW.includes(p.dataKey as (typeof SERIES_TO_SHOW)[number]), - ) + // For balance tab, only show difference; for normal tab, show both supply and uptake + const visibleEntries = isBalance + ? payload.filter((p) => p.dataKey === "b_nw_difference") + : payload.filter((p) => + SERIES_TO_SHOW.includes(p.dataKey as (typeof SERIES_TO_SHOW)[number]), + ) return (
@@ -209,23 +217,21 @@ function DynaTooltipContent({ interface DynaChartProps { data: DynaDailyPoint[] - fertilizingRecommendations: DynaFertilizerAdvice | null + fertilizingRecommendations?: DynaFertilizerAdvice | null events?: DynaChartEvent[] year?: number } export function DynaChart({ data, - fertilizingRecommendations, events = [], year = new Date().getFullYear(), }: DynaChartProps) { + const [activeTab, setActiveTab] = useState("dynamics") const monthTicks = getMonthTicks(data) const today = new Date().toISOString().split("T")[0] ?? "" const isCurrentYear = year === new Date().getFullYear() - const recDate = fertilizingRecommendations?.b_date_recommended - // Group events by date and only include dates present in the data const eventsByDate = groupEventsByDate(events) const chartDates = new Set(data.map((d) => d.b_date_calculation)) @@ -243,167 +249,272 @@ export function DynaChart({ _events: eventsByDate.get(d.b_date_calculation), })) + // Calculate surplus/deficit (N aanbod - N opname) + const chartDataWithDifference = chartData.map((d) => ({ + ...d, + b_nw_difference: + d.b_n_uptake !== null && d.b_n_uptake !== undefined + ? d.b_nw - d.b_n_uptake + : null, + })) + return ( - - + - - + N-dynamiek + N beschikbaar + + + {/* Tab 1: N-dynamiek — N aanbod and N opname lines */} + + - - - - + + + + + + + + + + } + /> + } + /> + + {/* Min-max band — only for current year */} + {isCurrentYear && ( + <> + + + + )} + + {/* N aanbod — line only (no area) */} + + + {/* N opname — dashed line for distinction */} + + + {/* Today reference line — only for current year */} + {isCurrentYear && ( + + )} + + {/* Field events — info dot at top of each vertical line. + Events grouped by date; tooltip shows all events for that day. */} + {uniqueEventDates.map(([date, evs]) => ( + ( + + )} + /> + ))} + + + + + {/* Tab 2: N beschikbaar — area chart showing difference */} + + - - - - - - - - } /> - } /> - - {/* Min-max band (render first so it's behind everything) */} - - - - {/* N availability — area with gradient fill */} - - - {/* N uptake — dashed line for distinction */} - - - {/* Recommended N line */} - - - {/* Today reference line — only for current year */} - {isCurrentYear && ( - - )} - - {/* Fertilizer recommendation date */} - {recDate && ( - - )} - - {/* Field events — info dot at top of each vertical line. - Events grouped by date; tooltip shows all events for that day. */} - {uniqueEventDates.map(([date, evs]) => ( - ( - + + + + + + + - )} - /> - ))} - - + + + } + /> + } + /> + + {/* Surplus/deficit as area */} + + + {/* Today reference line */} + {isCurrentYear && ( + + )} + + {/* Zero line for reference */} + + + {/* Field events */} + {uniqueEventDates.map(([date, evs]) => ( + ( + + )} + /> + ))} + + + + +
) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx index 39a803ed6..e9ef2c197 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx @@ -1,5 +1,5 @@ import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" -import { Activity, ArrowRight, CheckCircle2, Layers, Lightbulb, Slash } from "lucide-react" +import { ArrowRight, CheckCircle2, Component, Lightbulb, Slash, Zap } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -282,7 +282,7 @@ function MineralizationFieldContent({
- +
@@ -305,7 +305,7 @@ function MineralizationFieldContent({
- +
Meest volledig diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index 33101ff45..34a878fb7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -327,31 +327,31 @@ function DynaContent({ const lastPoint = yearData[yearData.length - 1] const today = new Date().toISOString().split("T")[0] ?? "" const todayPoint = yearData.find((d) => d.b_date_calculation >= today) + const isCurrentYear = year === new Date().getFullYear() const totalLeaching = lastPoint?.b_no3_leach ?? 0 const currentNAvailability = todayPoint?.b_nw ?? lastPoint?.b_nw ?? 0 const currentUptake = todayPoint?.b_n_uptake ?? lastPoint?.b_n_uptake ?? 0 + // Format date label for non-current years + const lastPointDateLabel = lastPoint + ? new Date(lastPoint.b_date_calculation).toLocaleDateString("nl-NL", { + day: "2-digit", + month: "short", + }) + : "" + return ( <> {/* KPI cards */} -
- - - N aanbod totaal - - -

- {nitrogenBalance.b_nw.toFixed(1)} -

-

- kg N/ha/jaar -

-
-
+
- N beschikbaar nu + + {isCurrentYear + ? "N aanbod nu" + : `N aanbod op ${lastPointDateLabel}`} +

@@ -362,7 +362,11 @@ function DynaContent({ - N opname nu + + {isCurrentYear + ? "N opname nu" + : `N opname op ${lastPointDateLabel}`} +

From 02fed73e99ad2b1ebeeb639b41a3666b336946f6 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:48:55 +0200 Subject: [PATCH 09/22] feat: show harvest events --- .../blocks/mineralization/dyna-chart.tsx | 28 ++++++------- .../blocks/mineralization/leaching-chart.tsx | 42 +++++++++++++++++-- ...rm.$calendar.mineralization.$b_id.dyna.tsx | 33 ++++++++++++--- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx index a82f0146b..063ad2b38 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx @@ -65,19 +65,19 @@ function getMonthTicks(data: DynaDailyPoint[]): string[] { const dynaChartConfig = { band: { label: "Bandbreedte", - color: "hsl(var(--chart-1))", + color: "hsl(var(--chart-2))", }, b_nw: { label: "N aanbod", - color: "hsl(var(--chart-1))", + color: "hsl(var(--chart-2))", }, b_n_uptake: { label: "N opname", - color: "hsl(var(--chart-2))", + color: "hsl(var(--chart-1))", }, b_nw_difference: { - label: "N surplus/deficit", - color: "hsl(var(--chart-1))", + label: "N beschikbaar", + color: "hsl(var(--chart-2))", }, } satisfies ChartConfig @@ -87,13 +87,13 @@ export interface DynaChartEvent { label: string } -const EVENT_COLORS: Record = { +export const EVENT_COLORS: Record = { sowing: "hsl(142, 71%, 45%)", harvest: "hsl(38, 92%, 50%)", fertilizer: "hsl(217, 91%, 60%)", } -function groupEventsByDate( +export function groupEventsByDate( events: DynaChartEvent[], ): Map { const map = new Map() @@ -110,7 +110,7 @@ interface EventDotProps { events: DynaChartEvent[] } -function EventDot({ viewBox, events }: EventDotProps) { +export function EventDot({ viewBox, events }: EventDotProps) { if (!viewBox?.x || viewBox.y === undefined) return null const x = viewBox.x const y = (viewBox.y ?? 0) + 10 @@ -249,7 +249,7 @@ export function DynaChart({ _events: eventsByDate.get(d.b_date_calculation), })) - // Calculate surplus/deficit (N aanbod - N opname) + // Calculate available N (N aanbod - N opname) const chartDataWithDifference = chartData.map((d) => ({ ...d, b_nw_difference: @@ -259,22 +259,22 @@ export function DynaChart({ })) return ( -

+
- - N-dynamiek + + N aanbod & opname N beschikbaar - {/* Tab 1: N-dynamiek — N aanbod and N opname lines */} + {/* Tab 1: N aanbod en opname — N aanbod and N opname lines */} d.b_date_calculation)) + const uniqueEventDates = Array.from(eventsByDate.entries()).filter( + ([date]) => chartDates.has(date), + ) + const chartData = data.map((d) => ({ date: d.b_date_calculation, b_no3_leach: d.b_no3_leach, @@ -72,7 +93,7 @@ export function LeachingChart({ data }: LeachingChartProps) { config={leachingChartConfig} className="h-[220px] w-full" > - @@ -134,7 +155,20 @@ export function LeachingChart({ data }: LeachingChartProps) { fill="url(#leachGradient)" isAnimationActive={false} /> - + + {/* Field events */} + {uniqueEventDates.map(([date, evs]) => ( + ( + + )} + /> + ))} + ) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index 34a878fb7..27107b808 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -5,8 +5,10 @@ import { getGrazingIntention, getCultivations, getHarvests, + getHarvestsForFarm, type FertilizerApplication, type Fertilizer, + type Harvest, } from "@nmi-agro/fdm-core" import { CalendarOff, Layers, Slash } from "lucide-react" import { Suspense, use } from "react" @@ -107,8 +109,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) const farmSector = isGrazing ? "dairy" : "arable" - // Get fertilizer applications, fertilizer properties, and cultivations in parallel - const [applications, fertilizers, cultivations] = await Promise.all([ + // Get fertilizer applications, fertilizer properties, cultivations, and harvests in parallel + const [applications, fertilizers, cultivations, harvestsMap] = await Promise.all([ getFertilizerApplications( fdm, session.principal_id, @@ -117,6 +119,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ), getFertilizers(fdm, session.principal_id, b_id_farm), getCultivations(fdm, session.principal_id, b_id, timeframe), + getHarvestsForFarm(fdm, session.principal_id, b_id_farm, timeframe), ]) // Pre-flight check: any main crop without a harvest date will cause @@ -128,7 +131,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) for (const crop of ongoingMainCrops) { if (!crop.b_lu) continue - const harvests = await getHarvests(fdm, session.principal_id, crop.b_lu, timeframe) + const harvests = harvestsMap.get(crop.b_lu) ?? [] if (harvests.length === 0) { return { missingHarvestDate: true as const, @@ -199,6 +202,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) { label: c.b_lu_name ?? "Oogst", }) } + + // Add actual harvests + const cultivationHarvests = harvestsMap.get(c.b_lu) ?? [] + for (const h of cultivationHarvests) { + if (h.b_lu_harvest_date) { + const analysis = h.harvestable.harvestable_analyses[0] + const yieldVal = analysis?.b_lu_yield + const label = yieldVal + ? `${yieldVal.toFixed(0)} kg DS/ha — ${c.b_lu_name ?? "Oogst"}` + : (c.b_lu_name ?? "Oogst") + + chartEvents.push({ + date: h.b_lu_harvest_date.toISOString().split("T")[0] ?? "", + type: "harvest", + label, + }) + } + } } for (const app of applications) { if (app.p_app_date) { @@ -206,10 +227,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { fertilizerNameMap.get(app.p_id) ?? app.p_name_nl ?? "Mest" + const amount = app.p_app_amount_display ?? app.p_app_amount ?? "?" + const unit = app.p_app_amount_unit ?? "kg" chartEvents.push({ date: app.p_app_date.toISOString().split("T")[0] ?? "", type: "fertilizer", - label: `${app.p_app_amount ?? "?"} kg — ${name}`, + label: `${amount} ${unit} — ${name}`, }) } } @@ -427,7 +450,7 @@ function DynaContent({ - + From 7940f233f9fbcae9830858a3be086549eba42b0f Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:25:20 +0200 Subject: [PATCH 10/22] feat: show dyna results on farm page --- .../blocks/header/mineralization.tsx | 15 +- .../blocks/mineralization/dyna-field-list.tsx | 133 +++++++++++++ .../blocks/mineralization/nsupply-kpi.tsx | 6 +- .../app/integrations/mineralization.server.ts | 180 ++++++++++++++++++ ....$calendar.mineralization.$b_id._index.tsx | 88 +++------ ...rm.$calendar.mineralization.$b_id.dyna.tsx | 47 +++-- ...d_farm.$calendar.mineralization._index.tsx | 161 +++++++++++----- ...rm.$b_id_farm.$calendar.mineralization.tsx | 21 +- 8 files changed, 522 insertions(+), 129 deletions(-) create mode 100644 fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx diff --git a/fdm-app/app/components/blocks/header/mineralization.tsx b/fdm-app/app/components/blocks/header/mineralization.tsx index b3831cacd..5b36b9b1f 100644 --- a/fdm-app/app/components/blocks/header/mineralization.tsx +++ b/fdm-app/app/components/blocks/header/mineralization.tsx @@ -77,7 +77,7 @@ export function HeaderMineralization({ )} - {isDyna && ( + {isDyna ? ( <> @@ -88,6 +88,19 @@ export function HeaderMineralization({ + ) : ( + b_id && ( + <> + + + + Bodem N-levering + + + + ) )} ) diff --git a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx new file mode 100644 index 000000000..fb148059d --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx @@ -0,0 +1,133 @@ +import { CircleCheck, CircleX, Loader2 } from "lucide-react" +import { Suspense, use } from "react" +import { NavLink } from "react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import type { FarmDynaResult } from "~/integrations/mineralization.server" + +interface DynaFieldListProps { + fields: { b_id: string; b_name: string | null }[] + promises: Promise[] + b_id_farm: string + calendar: string +} + +export function DynaFieldList({ + fields, + promises, + b_id_farm, + calendar, +}: DynaFieldListProps) { + // Map promises to their b_id for easier lookup + // Since we know the promises array matches the non-buffer fields order from the loader + return ( + + + + Perceel + N Aanbod (kg/ha) + N Opname (kg/ha) + Uitspoeling (kg/ha) + Status + + + + {fields.map((field, i) => ( + + ))} + +
+ ) +} + +function DynaTableRow({ + field, + promise, + b_id_farm, + calendar, +}: { + field: { b_id: string; b_name: string | null } + promise: Promise + b_id_farm: string + calendar: string +}) { + return ( + + + + {field.b_name} + + + }> + + + + ) +} + +function DynaCells({ promise }: { promise: Promise }) { + const { result, error } = use(promise) + + const lastPoint = result?.calculationDyna?.[result.calculationDyna.length - 1] + const nAvailability = lastPoint?.b_nw ?? 0 + const nUptake = lastPoint?.b_n_uptake ?? 0 + const leaching = lastPoint?.b_no3_leach ?? 0 + + return ( + <> + + {error ? "—" : nAvailability.toFixed(1)} + + + {error ? "—" : nUptake.toFixed(1)} + + + {error ? "—" : leaching.toFixed(1)} + + + {error ? ( + + ) : ( + + )} + + + ) +} + +function DynaCellsSkeleton() { + return ( + <> + + + + + + + + + + + + + + ) +} diff --git a/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx index 43b7e10ba..36efda02d 100644 --- a/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx +++ b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx @@ -41,7 +41,7 @@ export function FarmNSupplyKpi({ results }: FarmNSupplyKpiProps) { - N Levering Bedrijf + N Levering bedrijf @@ -57,7 +57,7 @@ export function FarmNSupplyKpi({ results }: FarmNSupplyKpiProps) { - Hoogste N Levering + Hoogste N levering @@ -74,7 +74,7 @@ export function FarmNSupplyKpi({ results }: FarmNSupplyKpiProps) { - Laagste N Levering + Laagste N levering diff --git a/fdm-app/app/integrations/mineralization.server.ts b/fdm-app/app/integrations/mineralization.server.ts index c98a6ffc5..b5b63c66c 100644 --- a/fdm-app/app/integrations/mineralization.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -26,11 +26,17 @@ import { } from "@nmi-agro/fdm-calculator" import { getCultivations, + getCultivationsForFarm, getCultivationsFromCatalogue, getCurrentSoilData, + getCurrentSoilDataForFarm, + getFertilizerApplicationsForFarm, + getFertilizers, getField, getFields, + getGrazingIntention, getHarvests, + getHarvestsForFarm, type Timeframe, } from "@nmi-agro/fdm-core" import { getNmiApiKey } from "~/integrations/nmi.server" @@ -53,6 +59,16 @@ export { buildNSupplyRequest, } from "@nmi-agro/fdm-calculator" +/** + * Result wrapper for DYNA at farm level. + */ +export type FarmDynaResult = { + b_id: string + b_name: string | null + result?: import("@nmi-agro/fdm-calculator").DynaResult + error?: string +} + // ─── Helpers ────────────────────────────────────────────────────────────────── /** @@ -366,6 +382,170 @@ export async function getDynaForField({ return getDyna(fdm, { b_id, nmiApiKey, requestBody }) } +/** + * Fetches the DYNA nitrogen advice simulation for all non-buffer fields in a farm. + * + * This is an optimized batch operation that fetches all required data (soil, + * cultivations, fertilizers, harvests) for the entire farm in a few queries, + * then maps them per-field and returns an array of independent promises. + * + * **Architecture:** Because DYNA calculations can take 30-60 seconds per field, + * this function returns an array of promises. The UI can then resolve each field + * independently (streaming) rather than waiting for the entire farm to finish. + */ +export async function getDynaForFarm({ + principal_id, + b_id_farm, + timeframe, +}: { + principal_id: string + b_id_farm: string + timeframe: Timeframe +}): Promise[]> { + const nmiApiKey = getNmiApiKey() + if (!nmiApiKey) { + throw new Error("NMI API-sleutel niet geconfigureerd") + } + + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + + // 1. Optimized batch fetching for all fields in the farm + const [ + fields, + isGrazing, + applications, + fertilizers, + cultivations, + soilDataArray, + catalogueEntries, + harvestsMap, + ] = await Promise.all([ + getFields(fdm, principal_id, b_id_farm, timeframe), + getGrazingIntention(fdm, principal_id, b_id_farm, year), + getFertilizerApplicationsForFarm( + fdm, + principal_id, + b_id_farm, + timeframe, + ), + getFertilizers(fdm, principal_id, b_id_farm), + getCultivationsForFarm(fdm, principal_id, b_id_farm), + getCurrentSoilDataForFarm(fdm, principal_id, b_id_farm), + getCultivationsFromCatalogue(fdm, principal_id, b_id_farm), + getHarvestsForFarm(fdm, principal_id, b_id_farm, timeframe), + ]) + + const farmSector = isGrazing ? "dairy" : "arable" + const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) + const fertilizerMap = new Map(fertilizers.map((f) => [f.p_id, f])) + + // 3. Map each field to an independent calculation promise + return nonBufferFields.map(async (field): Promise => { + try { + const fieldSoilDataRaw = soilDataArray.get(field.b_id) ?? [] + const fieldSoilData = buildSoilDataMap(fieldSoilDataRaw) + + const fieldCultivations = cultivations.get(field.b_id) ?? [] + const fieldApps = applications.get(field.b_id) ?? [] + + // Pre-flight check: any main crop without a harvest date will cause + // the DYNA API to return 400 "b_date_harvest is missing". + const ongoingMainCrops = fieldCultivations.filter( + (c) => + c.b_lu_end == null && c.b_lu_croprotation !== "catchcrop", + ) + for (const crop of ongoingMainCrops) { + if (!crop.b_lu) continue + const harvests = harvestsMap.get(crop.b_lu) ?? [] + if (harvests.length === 0) { + throw new Error("Oogstdatum ontbreekt voor lopend gewas") + } + } + + const dynaFertilizers = fieldApps.map((app) => { + const props = fertilizerMap.get(app.p_id) + return { + p_id: app.p_id, + p_n_rt: props?.p_n_rt ?? null, + p_n_if: props?.p_n_if ?? null, + p_n_of: props?.p_n_of ?? null, + p_n_wc: props?.p_n_wc ?? null, + p_p_rt: props?.p_p_rt ?? null, + p_k_rt: props?.p_k_rt ?? null, + p_dm: props?.p_dm ?? null, + p_om: props?.p_om ?? null, + p_date: app.p_app_date, + p_dose: app.p_app_amount, + p_app_method: app.p_app_method ?? null, + } + }) + + const cultivationCodes = new Set( + fieldCultivations.map((c) => c.b_lu_catalogue).filter(Boolean), + ) + const cropProperties = catalogueEntries + .filter((e) => cultivationCodes.has(e.b_lu_catalogue)) + .map((e) => ({ + b_lu_catalogue: e.b_lu_catalogue, + b_lu_yield: e.b_lu_yield ?? null, + b_lu_n_harvestable: e.b_lu_n_harvestable ?? null, + b_lu_n_residue: e.b_lu_n_residue ?? null, + })) + + // Build harvestsByBlu for this specific field's cultivations + const fieldHarvestsByBlu = new Map< + string, + { + b_lu_harvest_date?: Date | null + b_lu_yield?: number | null + }[] + >() + for (const cult of fieldCultivations) { + if (cult.b_lu) { + const harvests = harvestsMap.get(cult.b_lu) ?? [] + fieldHarvestsByBlu.set( + cult.b_lu, + harvests.map((h) => ({ + b_lu_harvest_date: h.b_lu_harvest_date, + b_lu_yield: + h.harvestable?.harvestable_analyses?.[0] + ?.b_lu_yield ?? null, + })), + ) + } + } + + const requestBody = buildDynaRequest( + field, + fieldSoilData, + fieldCultivations, + dynaFertilizers, + farmSector, + timeframe, + cropProperties.length > 0 ? cropProperties : undefined, + fieldHarvestsByBlu, + ) + + const result = await getDyna(fdm, { + b_id: field.b_id, + nmiApiKey, + requestBody, + }) + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + result, + } + } catch (err) { + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + error: err instanceof Error ? err.message : String(err), + } + } + }) +} + // ─── Insights ───────────────────────────────────────────────────────────────── /** diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx index e9ef2c197..52265d6f2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx @@ -216,7 +216,37 @@ export default function MineralizationFieldDetail() { const { b_id, b_id_farm, calendar, completeness, asyncData } = loaderData return ( -
+
+
+ + +
+ }> )} - {/* Method comparison — two peers */} -
- {/* Card A: Bodem N-levering — current view */} -
-
-
- -
- - - Huidige weergave - -
-
-

Bodem N-levering

-

MINIP · PMN · Century

-
-

- Berekent de N-levering uit bodemorganische stof. Geschikt voor een snelle inschatting zonder gewas- of bemestingsgegevens. -

-
- Vereist: bodemgegevens (organische stof, grondsoort) -
-
- - {/* Card B: DYNA — more advanced */} -
-
-
- -
- - Meest volledig - -
-
-

DYNA

-

Dynamisch N-advies

-
-

- Berekent dag-voor-dag N-beschikbaarheid én gewasopname op basis van bodem, gewas én bemesting. Nauwkeuriger, maar vereist meer gegevens. -

-
- - Vereist: bodem + gewas + bemesting (incl. oogstdatum) - - -
-
-
- Mineralisatiecurve diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index 27107b808..ee85278f6 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -10,7 +10,7 @@ import { type Fertilizer, type Harvest, } from "@nmi-agro/fdm-core" -import { CalendarOff, Layers, Slash } from "lucide-react" +import { CalendarOff, Component, Layers, Slash, Zap } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -294,25 +294,40 @@ export default function DynaPage() { ) } - const { asyncData, chartEvents } = loaderData + const { asyncData, chartEvents, b_id, b_id_farm, calendar } = loaderData return ( -
- {/* Method context banner */} -
-
- - - DYNA berekent de N-beschikbaarheid op basis van bodem, gewas én bemesting — nauwkeuriger dan bodem-alleen methoden. - -
- +
+ +
+ }> { + const asyncNSupply = (async () => { try { const results = await getNSupplyForFarm({ principal_id: session.principal_id, @@ -89,7 +94,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { err instanceof Error ? err.message : String(err), { page: "farm/{b_id_farm}/{calendar}/mineralization/_index", - scope: "loader/asyncData", + scope: "loader/asyncNSupply", }, { b_id_farm }, ) @@ -97,13 +102,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } })() + const asyncDynaPromises = getDynaForFarm({ + principal_id: session.principal_id, + b_id_farm, + timeframe, + }) + return { farm, fields, b_id_farm, method, calendar: params.calendar ?? "", - asyncData, + asyncNSupply, + asyncDynaPromises, } } catch (error) { throw handleLoaderError(error) @@ -112,16 +124,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export default function MineralizationFarmOverview() { const loaderData = useLoaderData() - const { b_id_farm, method, calendar, asyncData } = loaderData + const { b_id_farm, method, calendar, asyncNSupply, asyncDynaPromises, fields } = + loaderData return ( -
+
}>
@@ -129,68 +144,114 @@ export default function MineralizationFarmOverview() { } function MineralizationFarmContent({ - asyncData, + asyncNSupply, + asyncDynaPromises, b_id_farm, method, calendar, + fields, }: { - asyncData: Promise<{ results: NSupplyResult[] }> + asyncNSupply: Promise<{ results: NSupplyResult[] }> + asyncDynaPromises: Promise[]> b_id_farm: string method: NSupplyMethod calendar: string + fields: { b_id: string; b_name: string | null }[] }) { - const { results } = use(asyncData) + const { results } = use(asyncNSupply) + const dynaPromises = use(asyncDynaPromises) // Compute farm-average curve (simple average per DOY across all valid results) const validResults = results.filter((r) => !r.error && r.data.length > 0) const farmAvgData = computeFarmAverageCurve(validResults) return ( - <> -
- -
-
-
- - - - Mineralisatiecurve — Bedrijfsgemiddelde - - - Cumulatieve N-levering (kg N/ha) over het jaar - - - - - - +
+ {/* 1. N-Supply Section */} +
+
+ +

+ Bodem N-levering +

+
+ +
+
-
- - -
- Percelen + +
+
+ + + + Mineralisatiecurve — Bedrijfsgemiddelde + - Gesorteerd op N-levering + Cumulatieve N-levering (kg N/ha) over het + jaar. Schatting op basis van bodemgegevens. -
- - - - - - + + + + + +
+
+ + +
+ Percelen + + Gesorteerd op N-levering + +
+ +
+ + + +
+
+
+
+ + {/* 2. DYNA Section */} +
+
+ +

+ DYNA +

-
- + + + + Resultaten per perceel + + Dag-voor-dag berekening van N-beschikbaarheid en + gewasopname op basis van bodem, gewas én + bemesting. Resultaten laden per perceel. + + + + + + + +
) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx index 1a1e23c66..a9da80665 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx @@ -5,6 +5,7 @@ import { type MetaFunction, Outlet, useLoaderData, + useLocation, } from "react-router" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -97,6 +98,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export default function MineralizationLayout() { const loaderData = useLoaderData() + const location = useLocation() + + const isField = !!loaderData.b_id + const isDyna = location.pathname.endsWith("/dyna") + + const title = isField + ? isDyna + ? "DYNA Dynamisch N-advies" + : "Bodem N-levering" + : "Mineralisatie" + + const description = isField + ? isDyna + ? "Gedetailleerde N-beschikbaarheid op basis van bodem, gewas en bemesting." + : "Schatting van N-levering uit bodemorganische stof." + : "Stikstofmineralisatie per perceel en bedrijf." return ( @@ -116,10 +133,10 @@ export default function MineralizationLayout() {

- Mineralisatie + {title}

- Stikstofmineralisatie per perceel en bedrijf + {description}

From c90b82a9e880db3bce997f41fe2876bd28b4d9fb Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:27:08 +0200 Subject: [PATCH 11/22] refactor: implement review feedback --- .../blocks/header/mineralization.tsx | 25 ++-- .../blocks/mineralization/dyna-advice.tsx | 17 +-- .../blocks/mineralization/dyna-balance.tsx | 38 ++++-- .../blocks/mineralization/dyna-chart.tsx | 35 ++++-- .../blocks/mineralization/dyna-field-list.tsx | 59 ++++++--- .../blocks/mineralization/field-list.tsx | 23 ++-- .../blocks/mineralization/leaching-chart.tsx | 18 ++- .../mineralization/mineralization-chart.tsx | 11 +- .../blocks/mineralization/nsupply-kpi.tsx | 13 +- .../blocks/mineralization/skeletons.tsx | 4 +- .../app/integrations/mineralization.server.ts | 27 +++- ....$calendar.mineralization.$b_id._index.tsx | 42 ++++--- ...rm.$calendar.mineralization.$b_id.dyna.tsx | 31 ++--- ...d_farm.$calendar.mineralization._index.tsx | 56 ++++++--- ...rm.$b_id_farm.$calendar.mineralization.tsx | 14 +-- fdm-app/package.json | 1 - fdm-calculator/src/mineralisatie/builders.ts | 13 +- fdm-calculator/src/mineralisatie/dyna.ts | 88 ++++++++----- fdm-calculator/src/mineralisatie/nsupply.ts | 118 +++++++++++------- fdm-calculator/src/mineralisatie/schemas.ts | 2 +- fdm-calculator/src/mineralisatie/types.d.ts | 4 + 21 files changed, 405 insertions(+), 234 deletions(-) diff --git a/fdm-app/app/components/blocks/header/mineralization.tsx b/fdm-app/app/components/blocks/header/mineralization.tsx index 5b36b9b1f..0bdc3bd72 100644 --- a/fdm-app/app/components/blocks/header/mineralization.tsx +++ b/fdm-app/app/components/blocks/header/mineralization.tsx @@ -1,6 +1,6 @@ -import { ChevronDown } from "lucide-react" +import { Check, ChevronDown } from "lucide-react" import { NavLink, useLocation } from "react-router" -import { useCalendarStore } from "@/app/store/calendar" +import { useCalendarStore } from "~/store/calendar" import { BreadcrumbItem, BreadcrumbLink, @@ -8,10 +8,11 @@ import { } from "~/components/ui/breadcrumb" import { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu" +import { cn } from "~/lib/utils" type HeaderFieldOption = { b_id: string @@ -60,16 +61,26 @@ export function HeaderMineralization({ {fieldOptions.map((option) => ( - - {option.b_name} + + {option.b_name} + + {b_id === option.b_id && ( + + )} - + ))} diff --git a/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx index 12c3b6d03..3bb3af185 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx @@ -15,7 +15,14 @@ interface DynaAdviceCardProps { } function formatDate(dateStr: string): string { - const d = new Date(dateStr) + let d: Date + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const [year, month, day] = dateStr.split("-").map(Number) + d = new Date(year, month - 1, day) + } else { + d = new Date(dateStr) + } + if (Number.isNaN(d.getTime())) return dateStr return d.toLocaleDateString("nl-NL", { day: "numeric", @@ -50,9 +57,7 @@ export function DynaAdviceCard({ Aanbevolen gift
- {fertilizingRecommendations.b_n_recommended.toFixed( - 1, - )}{" "} + {Math.round(fertilizingRecommendations.b_n_recommended)}{" "} kg N/ha
@@ -71,9 +76,7 @@ export function DynaAdviceCard({ Resterende ruimte
- {fertilizingRecommendations.b_n_remaining.toFixed( - 1, - )}{" "} + {Math.round(fertilizingRecommendations.b_n_remaining)}{" "} kg N/ha
diff --git a/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx index e2f65466a..7750090ea 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx @@ -10,18 +10,20 @@ import type { DynaNitrogenBalance } from "~/integrations/mineralization.server" interface DynaBalanceCardProps { nitrogenBalance: DynaNitrogenBalance + leaching: number } -export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { - // Mineralisatie = b_nw - b_n_greenmanure - (artificial + organic fertilizer) +export function DynaBalanceCard({ nitrogenBalance, leaching }: DynaBalanceCardProps) { + // Mineralisatie = b_nw - b_n_greenmanure - (artificial + organic fertilizer) - preceeding const mineralisatie = nitrogenBalance.b_nw - nitrogenBalance.b_n_greenmanure - nitrogenBalance.b_n_fertilizer_artificial - - nitrogenBalance.b_n_fertilizer_organic + nitrogenBalance.b_n_fertilizer_organic - + nitrogenBalance.b_n_fertilizer_preceeding - // Total balance = b_nw - b_n_uptake - const totalBalance = nitrogenBalance.b_nw - nitrogenBalance.b_n_uptake + // Total balance = b_nw - b_n_uptake - leaching + const totalBalance = nitrogenBalance.b_nw - nitrogenBalance.b_n_uptake - leaching return ( @@ -39,7 +41,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { Bodem mineralisatie
- {mineralisatie.toFixed(1)} kg N/ha + {Math.round(mineralisatie)} kg N/ha
@@ -49,7 +51,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { Kunstmest
- +{nitrogenBalance.b_n_fertilizer_artificial.toFixed(1)} kg N/ha + +{Math.round(nitrogenBalance.b_n_fertilizer_artificial)} kg N/ha
@@ -59,7 +61,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { Organische mest
- +{nitrogenBalance.b_n_fertilizer_organic.toFixed(1)} kg N/ha + +{Math.round(nitrogenBalance.b_n_fertilizer_organic)} kg N/ha
@@ -69,7 +71,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { Voorvrucht
- +{nitrogenBalance.b_n_fertilizer_preceeding.toFixed(1)} kg N/ha + +{Math.round(nitrogenBalance.b_n_fertilizer_preceeding)} kg N/ha
@@ -79,7 +81,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { Groenbemesting
- +{nitrogenBalance.b_n_greenmanure.toFixed(1)} kg N/ha + +{Math.round(nitrogenBalance.b_n_greenmanure)} kg N/ha
@@ -89,7 +91,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) {
Totaal aanbod
- {nitrogenBalance.b_nw.toFixed(1)} kg N/ha + {Math.round(nitrogenBalance.b_nw)} kg N/ha
@@ -97,7 +99,17 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) {
N-opname gewas
- -{nitrogenBalance.b_n_uptake.toFixed(1)} kg N/ha + -{Math.round(nitrogenBalance.b_n_uptake)} kg N/ha +
+
+ + {/* Leaching */} +
+
+ N-NO₃ uitspoeling +
+
+ -{Math.round(leaching)} kg N/ha
@@ -114,7 +126,7 @@ export function DynaBalanceCard({ nitrogenBalance }: DynaBalanceCardProps) { }`} > {totalBalance > 0 ? "+" : ""} - {totalBalance.toFixed(1)} kg N/ha + {Math.round(totalBalance)} kg N/ha
diff --git a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx index 063ad2b38..3be537886 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx @@ -111,10 +111,12 @@ interface EventDotProps { } export function EventDot({ viewBox, events }: EventDotProps) { - if (!viewBox?.x || viewBox.y === undefined) return null + if (viewBox?.x === undefined || viewBox.y === undefined) return null + if (!events || events.length === 0) return null + const x = viewBox.x const y = (viewBox.y ?? 0) + 10 - const color = EVENT_COLORS[events[0].type] + const color = EVENT_COLORS[events[0].type] ?? "hsl(var(--chart-1))" return ( - {Number(entry.value).toFixed(1)}{" "} + {Math.round(entry.value)}{" "} kg N/ha @@ -224,12 +226,13 @@ interface DynaChartProps { export function DynaChart({ data, + fertilizingRecommendations, events = [], year = new Date().getFullYear(), }: DynaChartProps) { const [activeTab, setActiveTab] = useState("dynamics") const monthTicks = getMonthTicks(data) - const today = new Date().toISOString().split("T")[0] ?? "" + const today = new Date().toLocaleDateString("en-CA") const isCurrentYear = year === new Date().getFullYear() // Group events by date and only include dates present in the data @@ -246,15 +249,16 @@ export function DynaChart({ b_nw_max: d.b_nw_max, b_nw_recommended: d.b_nw_recommended, b_n_uptake: d.b_n_uptake, + b_no3_leach: d.b_no3_leach, _events: eventsByDate.get(d.b_date_calculation), })) - // Calculate available N (N aanbod - N opname) + // Calculate available N (N aanbod - N opname - N uitspoeling) const chartDataWithDifference = chartData.map((d) => ({ ...d, b_nw_difference: d.b_n_uptake !== null && d.b_n_uptake !== undefined - ? d.b_nw - d.b_n_uptake + ? d.b_nw - d.b_n_uptake - d.b_no3_leach : null, })) @@ -274,7 +278,7 @@ export function DynaChart({ )} + {/* Fertilizing Recommendation */} + {fertilizingRecommendations?.b_date_recommended && ( + + )} + {/* Field events — info dot at top of each vertical line. Events grouped by date; tooltip shows all events for that day. */} {uniqueEventDates.map(([date, evs]) => ( diff --git a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx index fb148059d..f98378a74 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx @@ -24,8 +24,15 @@ export function DynaFieldList({ b_id_farm, calendar, }: DynaFieldListProps) { - // Map promises to their b_id for easier lookup - // Since we know the promises array matches the non-buffer fields order from the loader + // Map promises to their b_id for easier lookup to avoid index-based alignment issues + const promisesById = new Map>() + for (let i = 0; i < fields.length; i++) { + const fieldId = fields[i].b_id + if (i < promises.length) { + promisesById.set(fieldId, promises[i]) + } + } + return ( @@ -38,15 +45,20 @@ export function DynaFieldList({ - {fields.map((field, i) => ( - - ))} + {fields.map((field) => { + const promise = promisesById.get(field.b_id) + if (!promise) return null + + return ( + + ) + })}
) @@ -91,22 +103,31 @@ function DynaCells({ promise }: { promise: Promise }) { return ( <> - {error ? "—" : nAvailability.toFixed(1)} + {error ? "—" : Math.round(nAvailability)} - {error ? "—" : nUptake.toFixed(1)} + {error ? "—" : Math.round(nUptake)} - {error ? "—" : leaching.toFixed(1)} + {error ? "—" : Math.round(leaching)} {error ? ( - + <> + Fout: {error} + diff --git a/fdm-app/app/components/blocks/mineralization/field-list.tsx b/fdm-app/app/components/blocks/mineralization/field-list.tsx index 3e09d8459..9c62b40b1 100644 --- a/fdm-app/app/components/blocks/mineralization/field-list.tsx +++ b/fdm-app/app/components/blocks/mineralization/field-list.tsx @@ -9,6 +9,7 @@ import { TableRow, } from "~/components/ui/table" import type { NSupplyResult } from "~/integrations/mineralization.server" +import { getCurrentDoy } from "./mineralization-chart" interface FieldListProps { results: NSupplyResult[] @@ -16,16 +17,6 @@ interface FieldListProps { calendar: string } -function getCurrentDoy(): number { - const now = new Date() - const startOfYear = new Date(now.getFullYear(), 0, 1) - return ( - Math.ceil( - (now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24), - ) + 1 - ) -} - export function FieldList({ results, b_id_farm, calendar }: FieldListProps) { const sorted = [...results].sort((a, b) => b.totalAnnualN - a.totalAnnualN) const currentDoy = getCurrentDoy() @@ -79,9 +70,13 @@ function NMineralizedToday({ }) { if (result.error) { return ( - - - + <> + Fout: {result.error} +
-
+
-
+
diff --git a/fdm-app/app/integrations/mineralization.server.ts b/fdm-app/app/integrations/mineralization.server.ts index b5b63c66c..4b50bc182 100644 --- a/fdm-app/app/integrations/mineralization.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -141,25 +141,31 @@ export async function getNSupplyForField({ b_id, method, timeframe, + field: preFetchedField, + soilDataArray: preFetchedSoilData, + cultivations: preFetchedCultivations, }: { principal_id: string b_id: string method: import("@nmi-agro/fdm-calculator").NSupplyMethod timeframe: Timeframe + field?: Awaited> + soilDataArray?: Awaited> + cultivations?: Awaited> }): Promise { const nmiApiKey = getNmiApiKey() if (!nmiApiKey) { throw new Error("NMI API-sleutel niet geconfigureerd") } - const field = await getField(fdm, principal_id, b_id) + const field = preFetchedField ?? (await getField(fdm, principal_id, b_id)) if (!field) { throw new Error(`Perceel niet gevonden: ${b_id}`) } const [soilDataArray, cultivations] = await Promise.all([ - getCurrentSoilData(fdm, principal_id, b_id), - getCultivations(fdm, principal_id, b_id), + preFetchedSoilData ?? getCurrentSoilData(fdm, principal_id, b_id), + preFetchedCultivations ?? getCultivations(fdm, principal_id, b_id), ]) const soilData = buildSoilDataMap(soilDataArray) @@ -175,6 +181,7 @@ export async function getNSupplyForField({ const input: NSupplyComputeInput = { b_id, b_name: field.b_name ?? b_id, + area: field.b_area ?? 0, nmiApiKey, requestBody, method, @@ -209,7 +216,12 @@ export async function getNSupplyForFarm({ method: import("@nmi-agro/fdm-calculator").NSupplyMethod timeframe: Timeframe }): Promise { - const fields = await getFields(fdm, principal_id, b_id_farm, timeframe) + const [fields, cultivationsMap, soilDataMap] = await Promise.all([ + getFields(fdm, principal_id, b_id_farm, timeframe), + getCultivationsForFarm(fdm, principal_id, b_id_farm), + getCurrentSoilDataForFarm(fdm, principal_id, b_id_farm), + ]) + const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) const results = await Promise.all( @@ -223,6 +235,9 @@ export async function getNSupplyForFarm({ b_id: field.b_id, method, timeframe, + field, + soilDataArray: soilDataMap.get(field.b_id) ?? [], + cultivations: cultivationsMap.get(field.b_id) ?? [], }) } catch (err) { const errorMessage = @@ -232,6 +247,7 @@ export async function getNSupplyForFarm({ return { b_id: field.b_id, b_name: field.b_name ?? field.b_id, + area: field.b_area ?? 0, method, data: [], totalAnnualN: 0, @@ -571,6 +587,7 @@ export function generateInsights( nsupply: import("@nmi-agro/fdm-calculator").NSupplyResult, farmAvgN: number | undefined, currentDoy: number, + year: number, ): string[] { const insights: string[] = [] const totalN = nsupply.totalAnnualN @@ -598,7 +615,7 @@ export function generateInsights( const currentPoint = nsupply.data.find((d) => d.doy >= currentDoy) if (currentPoint) { const remaining = totalN - currentPoint.d_n_supply_actual - const date = doyToDateString(currentDoy, new Date().getFullYear()) + const date = doyToDateString(currentDoy, year) insights.push( `Op ${date} is circa ${Math.round(currentPoint.d_n_supply_actual)} kg N/ha gemineraliseerd. Tot einde groeiseizoen wordt nog ~${Math.round(remaining)} kg N/ha verwacht.`, ) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx index 52265d6f2..f7f0f7a52 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx @@ -1,5 +1,5 @@ -import { getCurrentSoilData, getField } from "@nmi-agro/fdm-core" -import { ArrowRight, CheckCircle2, Component, Lightbulb, Slash, Zap } from "lucide-react" +import { getCurrentSoilData, getField, getCultivations } from "@nmi-agro/fdm-core" +import { Component, Lightbulb, Slash, Zap } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -9,7 +9,7 @@ import { useLoaderData, } from "react-router" import { DataCompletenessCard } from "~/components/blocks/mineralization/data-completeness" -import { FieldMineralizationChart } from "~/components/blocks/mineralization/mineralization-chart" +import { FieldMineralizationChart, getCurrentDoy } from "~/components/blocks/mineralization/mineralization-chart" import { FieldNSupplyDetailsCard } from "~/components/blocks/mineralization/nsupply-kpi" import { MineralizationFieldDetailFallback } from "~/components/blocks/mineralization/skeletons" import { Button } from "~/components/ui/button" @@ -60,6 +60,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { try { const b_id_farm = params.b_id_farm const b_id = params.b_id + const calendar = params.calendar if (!b_id_farm) { throw data("invalid: b_id_farm", { status: 400, @@ -76,7 +77,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const session = await getSession(request) const timeframe = getTimeframe(params) - const field = await getField(fdm, session.principal_id, b_id) + const [field, soilDataArray, cultivations] = await Promise.all([ + getField(fdm, session.principal_id, b_id), + getCurrentSoilData(fdm, session.principal_id, b_id), + getCultivations(fdm, session.principal_id, b_id), + ]) + if (!field) { throw data("not found: b_id", { status: 404, @@ -89,16 +95,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { field, b_id, b_id_farm, - calendar: params.calendar ?? "", + calendar: calendar ?? "", } } - // Get soil data - const soilDataArray = await getCurrentSoilData( - fdm, - session.principal_id, - b_id, - ) + // Get soil data map for completeness check const soilData: Record = {} const soilMeta: Record = {} @@ -136,12 +137,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_id, method, timeframe, + field, + soilDataArray, + cultivations, }) } catch (err) { return { b_id, b_name: field.b_name ?? b_id, method, + area: field.b_area ?? 0, data: [], totalAnnualN: 0, completeness: { @@ -163,14 +168,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { results.find((r) => r.method === "minip" && !r.error) ?? results.find((r) => !r.error) - const now = new Date() - const currentDoy = Math.floor( - (now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / - (1000 * 60 * 60 * 24), - ) + const currentDoy = getCurrentDoy() const insights = primaryResult - ? generateInsights(primaryResult, undefined, currentDoy) + ? generateInsights( + primaryResult, + undefined, + currentDoy, + Number(calendar), + ) : [] return { results, insights } @@ -181,7 +187,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { field, b_id, b_id_farm, - calendar: params.calendar ?? "", + calendar: calendar ?? "", soilData, organicMatter, soilType, diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index ee85278f6..9ff566d30 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -4,13 +4,11 @@ import { getField, getGrazingIntention, getCultivations, - getHarvests, getHarvestsForFarm, type FertilizerApplication, type Fertilizer, - type Harvest, } from "@nmi-agro/fdm-core" -import { CalendarOff, Component, Layers, Slash, Zap } from "lucide-react" +import { CalendarOff, Component, Slash, Zap } from "lucide-react" import { Suspense, use } from "react" import { data, @@ -57,7 +55,7 @@ export const meta: MetaFunction = ({ data: loaderData }) => { }, { name: "description", - content: `DYNA dynamisch N-advies voor ${name}.`, + content: `DYNA voor ${name}.`, }, ] } @@ -207,8 +205,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const cultivationHarvests = harvestsMap.get(c.b_lu) ?? [] for (const h of cultivationHarvests) { if (h.b_lu_harvest_date) { - const analysis = h.harvestable.harvestable_analyses[0] - const yieldVal = analysis?.b_lu_yield + const analysis = h.harvestable?.harvestable_analyses?.[0] + const yieldVal = analysis?.b_lu_yield ?? null const label = yieldVal ? `${yieldVal.toFixed(0)} kg DS/ha — ${c.b_lu_name ?? "Oogst"}` : (c.b_lu_name ?? "Oogst") @@ -363,7 +361,7 @@ function DynaContent({ // KPI values — use year-filtered data const lastPoint = yearData[yearData.length - 1] - const today = new Date().toISOString().split("T")[0] ?? "" + const today = new Date().toLocaleDateString("en-CA") const todayPoint = yearData.find((d) => d.b_date_calculation >= today) const isCurrentYear = year === new Date().getFullYear() @@ -393,7 +391,7 @@ function DynaContent({

- {currentNAvailability.toFixed(1)} + {Math.round(currentNAvailability)}

kg N/ha

@@ -408,21 +406,21 @@ function DynaContent({

- {currentUptake.toFixed(1)} + {Math.round(currentUptake)}

kg N/ha

- NO₃ uitspoeling + N-NO₃ uitspoeling

- {totalLeaching.toFixed(1)} + {Math.round(totalLeaching)}

- kg NO₃/ha (cumulatief) + kg N-NO₃/ha (cumulatief)

@@ -449,7 +447,10 @@ function DynaContent({ {/* Balance and advice side by side */}
- + - NO₃ uitspoeling + N-NO₃ uitspoeling - Cumulatieve nitraatuitspoeling (kg NO₃/ha) + Cumulatieve nitraatuitspoeling (kg N-NO₃/ha) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx index 618393745..346c1c94a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx @@ -13,7 +13,6 @@ import { MethodSelector } from "~/components/blocks/mineralization/method-select import { FarmMineralizationChart } from "~/components/blocks/mineralization/mineralization-chart" import { FarmNSupplyKpi } from "~/components/blocks/mineralization/nsupply-kpi" import { MineralizationFallback } from "~/components/blocks/mineralization/skeletons" -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" import { Card, CardContent, @@ -77,8 +76,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Read method from search params (default: minip) const url = new URL(request.url) - const method = (url.searchParams.get("method") ?? - "minip") as NSupplyMethod + const methodParam = url.searchParams.get("method") + const method: NSupplyMethod = + methodParam === "minip" || + methodParam === "pmn" || + methodParam === "century" + ? methodParam + : "minip" const asyncNSupply = (async () => { try { @@ -102,11 +106,25 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } })() - const asyncDynaPromises = getDynaForFarm({ - principal_id: session.principal_id, - b_id_farm, - timeframe, - }) + const asyncDynaPromises = (async () => { + try { + return await getDynaForFarm({ + principal_id: session.principal_id, + b_id_farm, + timeframe, + }) + } catch (err) { + reportError( + err instanceof Error ? err.message : String(err), + { + page: "farm/{b_id_farm}/{calendar}/mineralization/_index", + scope: "loader/asyncDynaPromises", + }, + { b_id_farm }, + ) + return [] as Promise[] + } + })() return { farm, @@ -118,7 +136,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { asyncDynaPromises, } } catch (error) { - throw handleLoaderError(error) + const normalized = handleLoaderError(error) + throw normalized ?? error } } @@ -260,20 +279,27 @@ function computeFarmAverageCurve( ): { doy: number; d_n_supply_actual: number }[] { if (results.length === 0) return [] - const doyMap = new Map() + const doyMap = new Map() + for (const result of results) { + if (result.error || result.data.length === 0) continue + const area = result.area || 0 + for (const point of result.data) { - const existing = doyMap.get(point.doy) ?? [] - existing.push(point.d_n_supply_actual) + const existing = doyMap.get(point.doy) ?? { + sumWeighted: 0, + sumArea: 0, + } + existing.sumWeighted += area * point.d_n_supply_actual + existing.sumArea += area doyMap.set(point.doy, existing) } } return Array.from(doyMap.entries()) .sort(([a], [b]) => a - b) - .map(([doy, values]) => ({ + .map(([doy, { sumWeighted, sumArea }]) => ({ doy, - d_n_supply_actual: - values.reduce((s, v) => s + v, 0) / values.length, + d_n_supply_actual: sumArea > 0 ? sumWeighted / sumArea : 0, })) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx index a9da80665..937272506 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx @@ -6,6 +6,7 @@ import { Outlet, useLoaderData, useLocation, + useParams, } from "react-router" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -38,8 +39,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - const b_id = params.b_id - const session = await getSession(request) const timeframe = getTimeframe(params) @@ -87,25 +86,26 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return { farm, b_id_farm, - b_id, farmOptions, fieldOptions, } } catch (error) { - throw handleLoaderError(error) + const normalized = handleLoaderError(error) + throw normalized ?? error } } export default function MineralizationLayout() { const loaderData = useLoaderData() const location = useLocation() + const { b_id } = useParams() // Get the field ID from child routes - const isField = !!loaderData.b_id + const isField = !!b_id const isDyna = location.pathname.endsWith("/dyna") const title = isField ? isDyna - ? "DYNA Dynamisch N-advies" + ? "DYNA" : "Bodem N-levering" : "Mineralisatie" @@ -124,7 +124,7 @@ export default function MineralizationLayout() { /> diff --git a/fdm-app/package.json b/fdm-app/package.json index 69bf71f51..d847f2a97 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -11,7 +11,6 @@ "start": "pnpm db:migrate && NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", "start-dev": "dotenvx run -- pnpm db:migrate && dotenvx run -- NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", "db:migrate": "node ./app/lib/fdm-migrate.server.js", - "test": "vitest run", "typecheck": "react-router typegen && tsc" }, "dependencies": { diff --git a/fdm-calculator/src/mineralisatie/builders.ts b/fdm-calculator/src/mineralisatie/builders.ts index 7e190644f..8bf8a83b7 100644 --- a/fdm-calculator/src/mineralisatie/builders.ts +++ b/fdm-calculator/src/mineralisatie/builders.ts @@ -324,9 +324,14 @@ export function buildDynaRequest( ? Number(aDepthLower) : 0.3 - // Build amendments list — only applications with a date are included + // Build amendments list — only applications from the current calculation year const amendments = fertilizers - .filter((f) => f.p_date !== null && f.p_date !== undefined) + .filter( + (f) => + f.p_date !== null && + f.p_date !== undefined && + f.p_date.getFullYear() === year, + ) .map((f) => ({ p_id: f.p_id, p_dose: f.p_dose ?? 0, @@ -334,6 +339,8 @@ export function buildDynaRequest( p_date_fertilization: f.p_date?.toISOString().split("T")[0], })) + // Build rotation array — only include the current calculation year + // This ensures the simulation starts at 0 on January 1st of this year. const rotation: Record[] = [year] .map((rotationYear) => { const yearStart = new Date(rotationYear, 0, 1) @@ -411,7 +418,7 @@ export function buildDynaRequest( } : {}), irrigation: [], - // Amendments only on the current calendar year + // Amendments only on the matching calendar year amendments: rotationYear === year ? amendments : [], } }) diff --git a/fdm-calculator/src/mineralisatie/dyna.ts b/fdm-calculator/src/mineralisatie/dyna.ts index 4c7cca112..09da8de9a 100644 --- a/fdm-calculator/src/mineralisatie/dyna.ts +++ b/fdm-calculator/src/mineralisatie/dyna.ts @@ -2,7 +2,7 @@ * @packageDocumentation * @module mineralisatie/dyna * - * DYNA dynamic nitrogen advice calculation via the NMI API. + * DYNA calculation via the NMI API. * * Provides two exports: * - {@link requestDyna} — the raw, uncached API call function @@ -68,44 +68,70 @@ export async function requestDyna( ): Promise { const { b_id, nmiApiKey, requestBody } = input - const response = await fetch( - "https://api.nmi-agro.nl/bemestingsplan/dyna", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${nmiApiKey}`, + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 60000) // 60s timeout for DYNA + + try { + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/dyna", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + "NMI-API-Version": "v1", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, }, - body: JSON.stringify(requestBody), - }, - ) + ) - if (!response.ok) { - const errorText = await response.text() - if (response.status === 422) { + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError( + 503, + "NMI API is tijdelijk niet beschikbaar.", + ) + } throw new NmiApiError( - 422, - `Onvoldoende gegevens voor DYNA-berekening. ${errorText}`, + response.status, + `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, ) } - if (response.status === 503) { - throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + + let json: unknown + try { + json = await response.json() + } catch (err) { + throw new Error("Ongeldig DYNA-antwoord van NMI API: Geen geldige JSON") } - throw new NmiApiError( - response.status, - `Er is een fout opgetreden bij de DYNA-berekening. ${errorText}`, - ) - } - const rawJson = await response.json() - const parsed = dynaResponseSchema.safeParse(rawJson) - if (!parsed.success) { - throw new Error( - `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, - ) - } + const parsed = dynaResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error( + `Ongeldig DYNA-antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, + ) + } - return { b_id, ...parsed.data.data } + return { b_id, ...parsed.data.data } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new NmiApiError( + 408, + "De aanvraag naar de NMI API is verlopen (timeout).", + ) + } + throw err + } finally { + clearTimeout(timeout) + } } // ─── Cached version ─────────────────────────────────────────────────────────── diff --git a/fdm-calculator/src/mineralisatie/nsupply.ts b/fdm-calculator/src/mineralisatie/nsupply.ts index e019ff741..9c9ce66a3 100644 --- a/fdm-calculator/src/mineralisatie/nsupply.ts +++ b/fdm-calculator/src/mineralisatie/nsupply.ts @@ -57,62 +57,90 @@ import pkg from "../package" export async function requestNSupply( input: NSupplyComputeInput, ): Promise { - const { b_id, b_name, nmiApiKey, requestBody, method, completeness } = + const { b_id, b_name, area, nmiApiKey, requestBody, method, completeness } = input - const response = await fetch( - "https://api.nmi-agro.nl/bemestingsplan/nsupply", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${nmiApiKey}`, + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout + + try { + const response = await fetch( + "https://api.nmi-agro.nl/bemestingsplan/nsupply", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${nmiApiKey}`, + "NMI-API-Version": "v1", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, }, - body: JSON.stringify(requestBody), - }, - ) + ) - if (!response.ok) { - const errorText = await response.text() - if (response.status === 422) { + if (!response.ok) { + const errorText = await response.text() + if (response.status === 422) { + throw new NmiApiError( + 422, + `Onvoldoende bodemgegevens voor mineralisatieberekening. ${errorText}`, + ) + } + if (response.status === 503) { + throw new NmiApiError( + 503, + "NMI API is tijdelijk niet beschikbaar.", + ) + } + if (response.status === 401 || response.status === 403) { + throw new NmiApiError( + response.status, + "NMI API-sleutel niet geconfigureerd of verlopen.", + ) + } throw new NmiApiError( - 422, - `Onvoldoende bodemgegevens voor mineralisatieberekening. ${errorText}`, + response.status, + `Er is een fout opgetreden bij het berekenen van de mineralisatie. ${errorText}`, ) } - if (response.status === 503) { - throw new NmiApiError(503, "NMI API is tijdelijk niet beschikbaar.") + + let json: unknown + try { + json = await response.json() + } catch (err) { + throw new Error("Ongeldig antwoord van NMI API: Geen geldige JSON") } - if (response.status === 401 || response.status === 403) { - throw new NmiApiError( - response.status, - "NMI API-sleutel niet geconfigureerd of verlopen.", + + const parsed = nsupplyResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error( + `Ongeldig antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, ) } - throw new NmiApiError( - response.status, - `Er is een fout opgetreden bij het berekenen van de mineralisatie. ${errorText}`, - ) - } - - const parsed = nsupplyResponseSchema.safeParse(await response.json()) - if (!parsed.success) { - throw new Error( - `Ongeldig antwoord van NMI API: ${JSON.stringify(z.treeifyError(parsed.error))}`, - ) - } - return { - b_id, - b_name, - method, - data: parsed.data.data, - totalAnnualN: - parsed.data.data.length > 0 - ? (parsed.data.data[parsed.data.data.length - 1] - ?.d_n_supply_actual ?? 0) - : 0, - completeness, + return { + b_id, + b_name, + area, + method, + data: parsed.data.data, + totalAnnualN: + parsed.data.data.length > 0 + ? (parsed.data.data[parsed.data.data.length - 1] + ?.d_n_supply_actual ?? 0) + : 0, + completeness, + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new NmiApiError( + 408, + "De aanvraag naar de NMI API is verlopen (timeout).", + ) + } + throw err + } finally { + clearTimeout(timeout) } } diff --git a/fdm-calculator/src/mineralisatie/schemas.ts b/fdm-calculator/src/mineralisatie/schemas.ts index a14871b6e..2674874ff 100644 --- a/fdm-calculator/src/mineralisatie/schemas.ts +++ b/fdm-calculator/src/mineralisatie/schemas.ts @@ -31,7 +31,7 @@ export const nsupplyDataPointSchema = z.object({ * The API returns an array of 365 or 366 daily data points under a `data` key. */ export const nsupplyResponseSchema = z.object({ - data: z.array(nsupplyDataPointSchema), + data: z.array(nsupplyDataPointSchema).min(1), }) // ─── DYNA schemas ───────────────────────────────────────────────────────────── diff --git a/fdm-calculator/src/mineralisatie/types.d.ts b/fdm-calculator/src/mineralisatie/types.d.ts index 7b0e37be5..78b4a90fd 100644 --- a/fdm-calculator/src/mineralisatie/types.d.ts +++ b/fdm-calculator/src/mineralisatie/types.d.ts @@ -72,6 +72,8 @@ export interface NSupplyResult { b_id: string /** Human-readable field name */ b_name: string + /** Field area in hectares (used for farm-level weighting) */ + area: number /** The mineralization model used for this calculation */ method: NSupplyMethod /** Daily cumulative N supply curve (365 or 366 data points) */ @@ -101,6 +103,8 @@ export interface NSupplyComputeInput { b_id: string /** Human-readable field name — passed through to {@link NSupplyResult} */ b_name: string + /** Field area in hectares — passed through to {@link NSupplyResult} */ + area: number /** NMI API bearer token — redacted from the cache key hash */ nmiApiKey: string /** Fully-formed request body for `POST /bemestingsplan/nsupply` */ From cb310b8c5d907625c0a96ef0bf38d81121eedce8 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:54:07 +0200 Subject: [PATCH 12/22] refactor: implement review feedback --- .../blocks/mineralization/dyna-field-list.tsx | 13 +- .../blocks/mineralization/nsupply-kpi.tsx | 14 +- .../app/integrations/mineralization.server.ts | 289 ++++++++++-------- ....$calendar.mineralization.$b_id._index.tsx | 3 +- ...rm.$calendar.mineralization.$b_id.dyna.tsx | 12 +- ...d_farm.$calendar.mineralization._index.tsx | 36 ++- fdm-calculator/src/index.ts | 4 +- .../assessment.ts | 2 +- .../builders.test.ts | 0 .../builders.ts | 75 +++-- .../{mineralisatie => mineralization}/dyna.ts | 10 +- .../errors.ts | 2 +- .../index.ts | 2 +- .../nsupply.ts | 5 +- .../schemas.ts | 2 +- .../types.d.ts | 2 +- 16 files changed, 265 insertions(+), 206 deletions(-) rename fdm-calculator/src/{mineralisatie => mineralization}/assessment.ts (99%) rename fdm-calculator/src/{mineralisatie => mineralization}/builders.test.ts (100%) rename fdm-calculator/src/{mineralisatie => mineralization}/builders.ts (89%) rename fdm-calculator/src/{mineralisatie => mineralization}/dyna.ts (95%) rename fdm-calculator/src/{mineralisatie => mineralization}/errors.ts (97%) rename fdm-calculator/src/{mineralisatie => mineralization}/index.ts (98%) rename fdm-calculator/src/{mineralisatie => mineralization}/nsupply.ts (98%) rename fdm-calculator/src/{mineralisatie => mineralization}/schemas.ts (99%) rename fdm-calculator/src/{mineralisatie => mineralization}/types.d.ts (99%) diff --git a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx index f98378a74..579e44750 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx @@ -13,7 +13,7 @@ import type { FarmDynaResult } from "~/integrations/mineralization.server" interface DynaFieldListProps { fields: { b_id: string; b_name: string | null }[] - promises: Promise[] + promises: Record> b_id_farm: string calendar: string } @@ -24,15 +24,6 @@ export function DynaFieldList({ b_id_farm, calendar, }: DynaFieldListProps) { - // Map promises to their b_id for easier lookup to avoid index-based alignment issues - const promisesById = new Map>() - for (let i = 0; i < fields.length; i++) { - const fieldId = fields[i].b_id - if (i < promises.length) { - promisesById.set(fieldId, promises[i]) - } - } - return ( @@ -46,7 +37,7 @@ export function DynaFieldList({ {fields.map((field) => { - const promise = promisesById.get(field.b_id) + const promise = promises[field.b_id] if (!promise) return null return ( diff --git a/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx index 5c2282ed1..9b1515506 100644 --- a/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx +++ b/fdm-app/app/components/blocks/mineralization/nsupply-kpi.tsx @@ -21,10 +21,16 @@ interface FarmNSupplyKpiProps { export function FarmNSupplyKpi({ results }: FarmNSupplyKpiProps) { const validResults = results.filter((r) => !r.error && r.data.length > 0) + const totalArea = validResults.reduce((sum, r) => sum + (r.area || 0), 0) const avgN = validResults.length > 0 - ? validResults.reduce((sum, r) => sum + r.totalAnnualN, 0) / - validResults.length + ? totalArea > 0 + ? validResults.reduce( + (sum, r) => sum + r.totalAnnualN * (r.area || 0), + 0, + ) / totalArea + : validResults.reduce((sum, r) => sum + r.totalAnnualN, 0) / + validResults.length : 0 const maxResult = validResults.reduce( @@ -245,7 +251,7 @@ export function FieldNSupplyKpi({ results.find((r) => !r.error && r.method === "minip") ?? results.find((r) => !r.error) - const errorCount = results.filter((r) => r.error).length + const successfulCount = results.filter((r) => !r.error).length return ( <> @@ -275,7 +281,7 @@ export function FieldNSupplyKpi({
- {3 - errorCount} / 3 + {successfulCount} / {results.length}

{results diff --git a/fdm-app/app/integrations/mineralization.server.ts b/fdm-app/app/integrations/mineralization.server.ts index 4b50bc182..9a259ce3d 100644 --- a/fdm-app/app/integrations/mineralization.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -224,45 +224,53 @@ export async function getNSupplyForFarm({ const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) - const results = await Promise.all( - nonBufferFields.map( - async ( - field, - ): Promise => { - try { - return await getNSupplyForField({ - principal_id, - b_id: field.b_id, - method, - timeframe, - field, - soilDataArray: soilDataMap.get(field.b_id) ?? [], - cultivations: cultivationsMap.get(field.b_id) ?? [], - }) - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : "Onbekende fout bij ophalen mineralisatiegegevens" - return { - b_id: field.b_id, - b_name: field.b_name ?? field.b_id, - area: field.b_area ?? 0, - method, - data: [], - totalAnnualN: 0, - completeness: { - available: [], - missing: [], - estimated: [], - score: 0, - }, - error: errorMessage, + // Simple concurrency limiting: process in chunks of 10 + const CONCURRENCY = 10 + const results: import("@nmi-agro/fdm-calculator").NSupplyResult[] = [] + + for (let i = 0; i < nonBufferFields.length; i += CONCURRENCY) { + const chunk = nonBufferFields.slice(i, i + CONCURRENCY) + const chunkResults = await Promise.all( + chunk.map( + async ( + field, + ): Promise => { + try { + return await getNSupplyForField({ + principal_id, + b_id: field.b_id, + method, + timeframe, + field, + soilDataArray: soilDataMap.get(field.b_id) ?? [], + cultivations: cultivationsMap.get(field.b_id) ?? [], + }) + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : "Onbekende fout bij ophalen mineralisatiegegevens" + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + area: field.b_area ?? 0, + method, + data: [], + totalAnnualN: 0, + completeness: { + available: [], + missing: [], + estimated: [], + score: 0, + }, + error: errorMessage, + } } - } - }, - ), - ) + }, + ), + ) + results.push(...chunkResults) + } return results } @@ -455,110 +463,123 @@ export async function getDynaForFarm({ const nonBufferFields = fields.filter((f) => !f.b_bufferstrip) const fertilizerMap = new Map(fertilizers.map((f) => [f.p_id, f])) - // 3. Map each field to an independent calculation promise - return nonBufferFields.map(async (field): Promise => { - try { - const fieldSoilDataRaw = soilDataArray.get(field.b_id) ?? [] - const fieldSoilData = buildSoilDataMap(fieldSoilDataRaw) + // 3. Map each field to an independent calculation promise with concurrency limiting + const CONCURRENCY = 5 + const activeTasks = new Array(CONCURRENCY).fill(Promise.resolve()) + let taskIndex = 0 + + return nonBufferFields.map((field) => { + const currentSlot = taskIndex % CONCURRENCY + const task = activeTasks[currentSlot].then(async (): Promise => { + try { + const fieldSoilDataRaw = soilDataArray.get(field.b_id) ?? [] + const fieldSoilData = buildSoilDataMap(fieldSoilDataRaw) + + const fieldCultivations = cultivations.get(field.b_id) ?? [] + const fieldApps = applications.get(field.b_id) ?? [] + + // Pre-flight check: any main crop without a harvest date will cause + // the DYNA API to return 400 "b_date_harvest is missing". + const ongoingMainCrops = fieldCultivations.filter( + (c) => + c.b_lu_end == null && c.b_lu_croprotation !== "catchcrop", + ) + for (const crop of ongoingMainCrops) { + if (!crop.b_lu) continue + const harvests = harvestsMap.get(crop.b_lu) ?? [] + const hasDatedHarvest = harvests.some( + (h) => h.b_lu_harvest_date != null, + ) + if (!hasDatedHarvest) { + throw new Error("Oogstdatum ontbreekt voor lopend gewas") + } + } - const fieldCultivations = cultivations.get(field.b_id) ?? [] - const fieldApps = applications.get(field.b_id) ?? [] + const dynaFertilizers = fieldApps.map((app) => { + const props = fertilizerMap.get(app.p_id) + return { + p_id: app.p_id, + p_n_rt: props?.p_n_rt ?? null, + p_n_if: props?.p_n_if ?? null, + p_n_of: props?.p_n_of ?? null, + p_n_wc: props?.p_n_wc ?? null, + p_p_rt: props?.p_p_rt ?? null, + p_k_rt: props?.p_k_rt ?? null, + p_dm: props?.p_dm ?? null, + p_om: props?.p_om ?? null, + p_date: app.p_app_date, + p_dose: app.p_app_amount, + p_app_method: app.p_app_method ?? null, + } + }) - // Pre-flight check: any main crop without a harvest date will cause - // the DYNA API to return 400 "b_date_harvest is missing". - const ongoingMainCrops = fieldCultivations.filter( - (c) => - c.b_lu_end == null && c.b_lu_croprotation !== "catchcrop", - ) - for (const crop of ongoingMainCrops) { - if (!crop.b_lu) continue - const harvests = harvestsMap.get(crop.b_lu) ?? [] - if (harvests.length === 0) { - throw new Error("Oogstdatum ontbreekt voor lopend gewas") + const cultivationCodes = new Set( + fieldCultivations.map((c) => c.b_lu_catalogue).filter(Boolean), + ) + const cropProperties = catalogueEntries + .filter((e) => cultivationCodes.has(e.b_lu_catalogue)) + .map((e) => ({ + b_lu_catalogue: e.b_lu_catalogue, + b_lu_yield: e.b_lu_yield ?? null, + b_lu_n_harvestable: e.b_lu_n_harvestable ?? null, + b_lu_n_residue: e.b_lu_n_residue ?? null, + })) + + // Build harvestsByBlu for this specific field's cultivations + const fieldHarvestsByBlu = new Map< + string, + { + b_lu_harvest_date?: Date | null + b_lu_yield?: number | null + }[] + >() + for (const cult of fieldCultivations) { + if (cult.b_lu) { + const harvests = harvestsMap.get(cult.b_lu) ?? [] + fieldHarvestsByBlu.set( + cult.b_lu, + harvests.map((h) => ({ + b_lu_harvest_date: h.b_lu_harvest_date, + b_lu_yield: + h.harvestable?.harvestable_analyses?.[0] + ?.b_lu_yield ?? null, + })), + ) + } } - } - const dynaFertilizers = fieldApps.map((app) => { - const props = fertilizerMap.get(app.p_id) + const requestBody = buildDynaRequest( + field, + fieldSoilData, + fieldCultivations, + dynaFertilizers, + farmSector, + timeframe, + cropProperties.length > 0 ? cropProperties : undefined, + fieldHarvestsByBlu, + ) + + const result = await getDyna(fdm, { + b_id: field.b_id, + nmiApiKey, + requestBody, + }) return { - p_id: app.p_id, - p_n_rt: props?.p_n_rt ?? null, - p_n_if: props?.p_n_if ?? null, - p_n_of: props?.p_n_of ?? null, - p_n_wc: props?.p_n_wc ?? null, - p_p_rt: props?.p_p_rt ?? null, - p_k_rt: props?.p_k_rt ?? null, - p_dm: props?.p_dm ?? null, - p_om: props?.p_om ?? null, - p_date: app.p_app_date, - p_dose: app.p_app_amount, - p_app_method: app.p_app_method ?? null, + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + result, } - }) - - const cultivationCodes = new Set( - fieldCultivations.map((c) => c.b_lu_catalogue).filter(Boolean), - ) - const cropProperties = catalogueEntries - .filter((e) => cultivationCodes.has(e.b_lu_catalogue)) - .map((e) => ({ - b_lu_catalogue: e.b_lu_catalogue, - b_lu_yield: e.b_lu_yield ?? null, - b_lu_n_harvestable: e.b_lu_n_harvestable ?? null, - b_lu_n_residue: e.b_lu_n_residue ?? null, - })) - - // Build harvestsByBlu for this specific field's cultivations - const fieldHarvestsByBlu = new Map< - string, - { - b_lu_harvest_date?: Date | null - b_lu_yield?: number | null - }[] - >() - for (const cult of fieldCultivations) { - if (cult.b_lu) { - const harvests = harvestsMap.get(cult.b_lu) ?? [] - fieldHarvestsByBlu.set( - cult.b_lu, - harvests.map((h) => ({ - b_lu_harvest_date: h.b_lu_harvest_date, - b_lu_yield: - h.harvestable?.harvestable_analyses?.[0] - ?.b_lu_yield ?? null, - })), - ) + } catch (err) { + return { + b_id: field.b_id, + b_name: field.b_name ?? field.b_id, + error: err instanceof Error ? err.message : String(err), } } - - const requestBody = buildDynaRequest( - field, - fieldSoilData, - fieldCultivations, - dynaFertilizers, - farmSector, - timeframe, - cropProperties.length > 0 ? cropProperties : undefined, - fieldHarvestsByBlu, - ) - - const result = await getDyna(fdm, { - b_id: field.b_id, - nmiApiKey, - requestBody, - }) - return { - b_id: field.b_id, - b_name: field.b_name ?? field.b_id, - result, - } - } catch (err) { - return { - b_id: field.b_id, - b_name: field.b_name ?? field.b_id, - error: err instanceof Error ? err.message : String(err), - } - } + }) + activeTasks[currentSlot] = task + taskIndex++ + return task }) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx index f7f0f7a52..654cb2765 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id._index.tsx @@ -195,7 +195,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { asyncData, } } catch (error) { - throw handleLoaderError(error) + const normalized = handleLoaderError(error) + throw normalized ?? error } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx index 9ff566d30..badb8dfbb 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.$b_id.dyna.tsx @@ -130,7 +130,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { for (const crop of ongoingMainCrops) { if (!crop.b_lu) continue const harvests = harvestsMap.get(crop.b_lu) ?? [] - if (harvests.length === 0) { + const hasDatedHarvest = harvests.some((h) => h.b_lu_harvest_date != null) + if (!hasDatedHarvest) { return { missingHarvestDate: true as const, field, @@ -245,7 +246,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { asyncData, } } catch (error) { - throw handleLoaderError(error) + const normalized = handleLoaderError(error) + throw normalized ?? error } } @@ -360,10 +362,12 @@ function DynaContent({ ) // KPI values — use year-filtered data + const isCurrentYear = year === new Date().getFullYear() const lastPoint = yearData[yearData.length - 1] const today = new Date().toLocaleDateString("en-CA") - const todayPoint = yearData.find((d) => d.b_date_calculation >= today) - const isCurrentYear = year === new Date().getFullYear() + const todayPoint = isCurrentYear + ? yearData.find((d) => d.b_date_calculation >= today) + : undefined const totalLeaching = lastPoint?.b_no3_leach ?? 0 const currentNAvailability = todayPoint?.b_nw ?? lastPoint?.b_nw ?? 0 diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx index 346c1c94a..c39a80a15 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization._index.tsx @@ -178,7 +178,12 @@ function MineralizationFarmContent({ fields: { b_id: string; b_name: string | null }[] }) { const { results } = use(asyncNSupply) - const dynaPromises = use(asyncDynaPromises) + const dynaPromisesArray = use(asyncDynaPromises) + + // Map the array of promises to a keyed object for the DynaFieldList + const dynaPromises = Object.fromEntries( + fields.map((field, i) => [field.b_id, dynaPromisesArray[i]]), + ) // Compute farm-average curve (simple average per DOY across all valid results) const validResults = results.filter((r) => !r.error && r.data.length > 0) @@ -279,7 +284,15 @@ function computeFarmAverageCurve( ): { doy: number; d_n_supply_actual: number }[] { if (results.length === 0) return [] - const doyMap = new Map() + const doyMap = new Map< + number, + { + sumWeighted: number + sumArea: number + sumUnweighted: number + count: number + } + >() for (const result of results) { if (result.error || result.data.length === 0) continue @@ -289,17 +302,28 @@ function computeFarmAverageCurve( const existing = doyMap.get(point.doy) ?? { sumWeighted: 0, sumArea: 0, + sumUnweighted: 0, + count: 0, + } + if (area > 0) { + existing.sumWeighted += area * point.d_n_supply_actual + existing.sumArea += area } - existing.sumWeighted += area * point.d_n_supply_actual - existing.sumArea += area + existing.sumUnweighted += point.d_n_supply_actual + existing.count += 1 doyMap.set(point.doy, existing) } } return Array.from(doyMap.entries()) .sort(([a], [b]) => a - b) - .map(([doy, { sumWeighted, sumArea }]) => ({ + .map(([doy, { sumWeighted, sumArea, sumUnweighted, count }]) => ({ doy, - d_n_supply_actual: sumArea > 0 ? sumWeighted / sumArea : 0, + d_n_supply_actual: + sumArea > 0 + ? sumWeighted / sumArea + : count > 0 + ? sumUnweighted / count + : 0, })) } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index fa16b1f39..ba6566c1f 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -102,7 +102,7 @@ export { methodRequirements, requestDyna, requestNSupply, -} from "./mineralisatie" +} from "./mineralization" export type { DataCompleteness, DynaComputeInput, @@ -114,7 +114,7 @@ export type { NSupplyDataPoint, NSupplyMethod, NSupplyResult, -} from "./mineralisatie" +} from "./mineralization" export type { NlvSupplyBySomParams } from "./other/nlv-supply-by-som" export { calculateNlvSupplyBySom } from "./other/nlv-supply-by-som" export type { WaterSupplyBySomParams } from "./other/water-supply-by-som" diff --git a/fdm-calculator/src/mineralisatie/assessment.ts b/fdm-calculator/src/mineralization/assessment.ts similarity index 99% rename from fdm-calculator/src/mineralisatie/assessment.ts rename to fdm-calculator/src/mineralization/assessment.ts index 1588746c7..467ffc121 100644 --- a/fdm-calculator/src/mineralisatie/assessment.ts +++ b/fdm-calculator/src/mineralization/assessment.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/assessment + * @module mineralization/assessment * * Soil data completeness assessment for the Mineralisatie module. * diff --git a/fdm-calculator/src/mineralisatie/builders.test.ts b/fdm-calculator/src/mineralization/builders.test.ts similarity index 100% rename from fdm-calculator/src/mineralisatie/builders.test.ts rename to fdm-calculator/src/mineralization/builders.test.ts diff --git a/fdm-calculator/src/mineralisatie/builders.ts b/fdm-calculator/src/mineralization/builders.ts similarity index 89% rename from fdm-calculator/src/mineralisatie/builders.ts rename to fdm-calculator/src/mineralization/builders.ts index 8bf8a83b7..f3139aa97 100644 --- a/fdm-calculator/src/mineralisatie/builders.ts +++ b/fdm-calculator/src/mineralization/builders.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/builders + * @module mineralization/builders * * Request body builders for the NMI Mineralisatie API endpoints. * @@ -46,23 +46,27 @@ function getMainCultivation< // Use 12:00 noon to avoid timezone edge cases at midnight const targetDate = new Date(`${year}-05-15T12:00:00`) - const activeOnMay15 = [...cultivations] - .sort((a, b) => { - const aTime = a.b_lu_start?.getTime() ?? 0 - const bTime = b.b_lu_start?.getTime() ?? 0 - return bTime - aTime - }) - .find((c) => { - if (!c.b_lu_start) return false - const start = c.b_lu_start - const end = c.b_lu_end ?? null - if (end) return start <= targetDate && end >= targetDate - return start <= targetDate - }) - - if (activeOnMay15) return activeOnMay15 - - // Fallback: first non-catchcrop in the year, then any crop + // 1. First priority: a non-catchcrop that spans May 15th + const activeMainOnMay15 = cultivations.find((c) => { + if (!c.b_lu_start || c.b_lu_croprotation === "catchcrop") return false + const start = c.b_lu_start + const end = c.b_lu_end ?? null + if (end) return start <= targetDate && end >= targetDate + return start <= targetDate + }) + if (activeMainOnMay15) return activeMainOnMay15 + + // 2. Second priority: any crop that spans May 15th (including catchcrops) + const activeAnyOnMay15 = cultivations.find((c) => { + if (!c.b_lu_start) return false + const start = c.b_lu_start + const end = c.b_lu_end ?? null + if (end) return start <= targetDate && end >= targetDate + return start <= targetDate + }) + if (activeAnyOnMay15) return activeAnyOnMay15 + + // 3. Fallback: first non-catchcrop in the year, then any crop return ( cultivations.find((c) => c.b_lu_croprotation !== "catchcrop") ?? cultivations[0] @@ -91,16 +95,15 @@ function getMainCultivation< * | `b_soiltype_agr` | `b_soiltype_agr` | — | * | `a_depth_lower` | `a_depth` | m (default `0.3`) | * - * The BRP crop code is extracted from `b_lu_catalogue` by stripping the `"nl_"` prefix - * and converting the remainder to an integer (e.g. `"nl_256"` → `256`). - * Only the first matched crop code is sent. + * The BRP crop code is extracted from `b_lu_catalogue` of the main crop in the + * requested timeframe. We strip the `"nl_"` prefix and convert to an integer. * * @example * ```typescript * const body = buildNSupplyRequest( * { b_centroid: [5.585, 53.288] }, * { a_som_loi: 3.5, a_clay_mi: 10, a_silt_mi: 20, a_depth_lower: 0.3 }, - * [{ b_lu_catalogue: "nl_256" }], + * [{ b_lu_catalogue: "nl_256", b_lu_start: new Date("2026-04-01") }], * "minip", * { start: new Date("2026-01-01"), end: new Date("2026-12-31") }, * ) @@ -120,7 +123,12 @@ export function buildNSupplyRequest( b_area?: number | null }, soilData: Record, - cultivations: { b_lu_catalogue?: string | null }[], + cultivations: { + b_lu_catalogue?: string | null + b_lu_start?: Date | null + b_lu_end?: Date | null + b_lu_croprotation?: string | null + }[], method: NSupplyMethod, timeframe: Timeframe, ): Record { @@ -128,14 +136,15 @@ export function buildNSupplyRequest( const a_lon = centroid ? centroid[0] : undefined const a_lat = centroid ? centroid[1] : undefined - const b_lu_brp = cultivations - .filter((c) => c.b_lu_catalogue) - .map((c) => { - const code = (c.b_lu_catalogue ?? "").replace(/^nl_/, "") - const parsed = Number.parseInt(code, 10) - return Number.isNaN(parsed) ? undefined : parsed - }) - .find((v) => v !== undefined) + // Determine the main crop for the requested year to extract the BRP code. + const year = timeframe.start?.getFullYear() ?? new Date().getFullYear() + const mainCrop = getMainCultivation(cultivations, year) + + const b_lu_brp = (() => { + const code = (mainCrop?.b_lu_catalogue ?? "").replace(/^nl_/, "") + const parsed = Number.parseInt(code, 10) + return Number.isNaN(parsed) ? undefined : parsed + })() const body: Record = { d_n_supply_method: method, @@ -374,8 +383,8 @@ export function buildDynaRequest( ? actualHarvestRecords .filter((h) => h.b_lu_harvest_date != null) .map((h) => ({ - b_date_harvest: h.b_lu_harvest_date! - .toISOString() + b_date_harvest: h + .b_lu_harvest_date!.toISOString() .split("T")[0], ...(h.b_lu_yield != null ? { b_lu_yield: h.b_lu_yield } diff --git a/fdm-calculator/src/mineralisatie/dyna.ts b/fdm-calculator/src/mineralization/dyna.ts similarity index 95% rename from fdm-calculator/src/mineralisatie/dyna.ts rename to fdm-calculator/src/mineralization/dyna.ts index 09da8de9a..d1f9e9356 100644 --- a/fdm-calculator/src/mineralisatie/dyna.ts +++ b/fdm-calculator/src/mineralization/dyna.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/dyna + * @module mineralization/dyna * * DYNA calculation via the NMI API. * @@ -110,7 +110,9 @@ export async function requestDyna( try { json = await response.json() } catch (err) { - throw new Error("Ongeldig DYNA-antwoord van NMI API: Geen geldige JSON") + throw new Error( + "Ongeldig DYNA-antwoord van NMI API: Geen geldige JSON", + ) } const parsed = dynaResponseSchema.safeParse(json) @@ -142,8 +144,8 @@ export async function requestDyna( * Uses `withCalculationCache` from `@nmi-agro/fdm-core` to persist results in * the FDM database. The cache key is derived from a hash of the input (excluding * `nmiApiKey` which is redacted). The cache is automatically invalidated when - * the request body changes — e.g. when soil data, cultivations, or fertilizer - * applications are updated. + * the request body changes (e.g. when soil data, cultivations, or fertilizer + * applications are updated) or when the package version changes. * * Because the DYNA model simulates the full rotation, a single cached result * covers all years in the rotation. The caller should filter the returned diff --git a/fdm-calculator/src/mineralisatie/errors.ts b/fdm-calculator/src/mineralization/errors.ts similarity index 97% rename from fdm-calculator/src/mineralisatie/errors.ts rename to fdm-calculator/src/mineralization/errors.ts index bc8c49524..7ea115879 100644 --- a/fdm-calculator/src/mineralisatie/errors.ts +++ b/fdm-calculator/src/mineralization/errors.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/errors + * @module mineralization/errors * * Custom error types for the Mineralisatie module. */ diff --git a/fdm-calculator/src/mineralisatie/index.ts b/fdm-calculator/src/mineralization/index.ts similarity index 98% rename from fdm-calculator/src/mineralisatie/index.ts rename to fdm-calculator/src/mineralization/index.ts index 1aa0d79e5..e2cd95495 100644 --- a/fdm-calculator/src/mineralisatie/index.ts +++ b/fdm-calculator/src/mineralization/index.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie + * @module mineralization * * Nitrogen mineralization calculations for the FDM platform. * diff --git a/fdm-calculator/src/mineralisatie/nsupply.ts b/fdm-calculator/src/mineralization/nsupply.ts similarity index 98% rename from fdm-calculator/src/mineralisatie/nsupply.ts rename to fdm-calculator/src/mineralization/nsupply.ts index 9c9ce66a3..192fb9461 100644 --- a/fdm-calculator/src/mineralisatie/nsupply.ts +++ b/fdm-calculator/src/mineralization/nsupply.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/nsupply + * @module mineralization/nsupply * * N supply (nitrogen mineralization) curve calculation via the NMI API. * @@ -152,7 +152,8 @@ export async function requestNSupply( * Uses `withCalculationCache` from `@nmi-agro/fdm-core` to persist results in * the FDM database. The cache key is derived from a hash of the input (excluding * `nmiApiKey` which is redacted). The cache is automatically invalidated when - * the request body changes (e.g. soil data updated, different method selected). + * the request body changes (e.g. soil data updated, different method selected) + * or when the package version changes. * * **Signature:** `(fdm: FdmType, input: NSupplyComputeInput) => Promise` * diff --git a/fdm-calculator/src/mineralisatie/schemas.ts b/fdm-calculator/src/mineralization/schemas.ts similarity index 99% rename from fdm-calculator/src/mineralisatie/schemas.ts rename to fdm-calculator/src/mineralization/schemas.ts index 2674874ff..196ec1cb9 100644 --- a/fdm-calculator/src/mineralisatie/schemas.ts +++ b/fdm-calculator/src/mineralization/schemas.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/schemas + * @module mineralization/schemas * * Zod validation schemas for NMI API responses used by the Mineralisatie module. * diff --git a/fdm-calculator/src/mineralisatie/types.d.ts b/fdm-calculator/src/mineralization/types.d.ts similarity index 99% rename from fdm-calculator/src/mineralisatie/types.d.ts rename to fdm-calculator/src/mineralization/types.d.ts index 78b4a90fd..47812887c 100644 --- a/fdm-calculator/src/mineralisatie/types.d.ts +++ b/fdm-calculator/src/mineralization/types.d.ts @@ -1,6 +1,6 @@ /** * @packageDocumentation - * @module mineralisatie/types + * @module mineralization/types * * TypeScript type definitions for the Mineralisatie (Nitrogen Mineralization) module. * These types model the inputs, outputs, and intermediate data structures used by the From 172e2f8d1fa82776acfc32bd450b0adf52aee935 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:12:32 +0200 Subject: [PATCH 13/22] refactor: implement review feedback --- .../blocks/mineralization/dyna-field-list.tsx | 24 +- .../app/integrations/mineralization.server.ts | 411 ++++++++++-------- ...d_farm.$calendar.mineralization._index.tsx | 14 +- fdm-calculator/src/mineralization/builders.ts | 46 +- fdm-calculator/src/mineralization/schemas.ts | 22 +- fdm-calculator/src/mineralization/types.d.ts | 15 +- 6 files changed, 313 insertions(+), 219 deletions(-) diff --git a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx index 579e44750..ce06b6da3 100644 --- a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx +++ b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx @@ -87,6 +87,8 @@ function DynaCells({ promise }: { promise: Promise }) { const { result, error } = use(promise) const lastPoint = result?.calculationDyna?.[result.calculationDyna.length - 1] + const hasValue = + !error && !!result?.calculationDyna?.length && lastPoint !== undefined const nAvailability = lastPoint?.b_nw ?? 0 const nUptake = lastPoint?.b_n_uptake ?? 0 const leaching = lastPoint?.b_no3_leach ?? 0 @@ -94,28 +96,30 @@ function DynaCells({ promise }: { promise: Promise }) { return ( <> - {error ? "—" : Math.round(nAvailability)} + {hasValue ? Math.round(nAvailability) : "—"} - {error ? "—" : Math.round(nUptake)} + {hasValue ? Math.round(nUptake) : "—"} - {error ? "—" : Math.round(leaching)} + {hasValue ? Math.round(leaching) : "—"} - {error ? ( + {hasValue ? ( <> - Fout: {error} - Succes +

+ +
+
+ + + + + + + Mineralisatie is nog niet beschikbaar voor je. + + + Mineralisatie is momenteel in ontwikkeling en is + nog niet voor iedereen geactiveerd. Als je meer + wilt weten, neem dan contact op met + Ondersteuning. + + + +
+ + ) + } + return (
From 16692f1c368e4ff24497ae1a3cbb61f4a0d1a04e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:24:24 +0200 Subject: [PATCH 15/22] chore: add changesets --- .changeset/bright-pans-exist.md | 5 +++++ .changeset/funky-doors-sort.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/bright-pans-exist.md create mode 100644 .changeset/funky-doors-sort.md diff --git a/.changeset/bright-pans-exist.md b/.changeset/bright-pans-exist.md new file mode 100644 index 000000000..b1e1a8546 --- /dev/null +++ b/.changeset/bright-pans-exist.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-calculator": minor +--- + +Add Mineralization module to request the nsupply and dyna endpoint at NMI API diff --git a/.changeset/funky-doors-sort.md b/.changeset/funky-doors-sort.md new file mode 100644 index 000000000..1410553b1 --- /dev/null +++ b/.changeset/funky-doors-sort.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Add Mineralisatie app to Labs (now behind posthog feature flag) to learn more about mineralization at farm and field level with MINIP and DYNA From f9214f835dc706d2b17aeb3180acf51171e25907 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:44:17 +0200 Subject: [PATCH 16/22] refactor: remove unused import --- fdm-app/app/integrations/mineralization.server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/fdm-app/app/integrations/mineralization.server.ts b/fdm-app/app/integrations/mineralization.server.ts index 3edd58cd8..0cea6c6b8 100644 --- a/fdm-app/app/integrations/mineralization.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -35,7 +35,6 @@ import { getField, getFields, getGrazingIntention, - getHarvests, getHarvestsForFarm, type Timeframe, } from "@nmi-agro/fdm-core" From d5d8a8f5b24a83b4b91d50c0fc0358384ec7ba55 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:47:14 +0200 Subject: [PATCH 17/22] fix: types --- fdm-calculator/src/mineralization/schemas.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/fdm-calculator/src/mineralization/schemas.ts b/fdm-calculator/src/mineralization/schemas.ts index 60ca96046..114197db3 100644 --- a/fdm-calculator/src/mineralization/schemas.ts +++ b/fdm-calculator/src/mineralization/schemas.ts @@ -47,18 +47,30 @@ export const nsupplyResponseSchema = z.object({ export const dynaDailyPointSchema = z.object({ /** Calendar date of this simulation step (ISO 8601) */ b_date_calculation: z.string(), - /** N availability — central estimate */ + /** N availability */ b_nw: z.number(), - /** N availability — lower bound */ + /** N availability — minimal scenario */ b_nw_min: z.number(), - /** N availability — upper bound */ + /** N availability — maximal scenario */ b_nw_max: z.number(), - /** N availability — recommended target */ + /** N availability — recommended scenario */ b_nw_recommended: z.number().nullable(), /** Crop N uptake */ b_n_uptake: z.number().nullable(), + /** Crop N uptake — minimal scenario */ + b_n_uptake_min: z.number(), + /** Crop N uptake — maximal scenario */ + b_n_uptake_max: z.number(), + /** Crop N uptake — recommended scenario */ + b_n_uptake_recommended: z.number(), /** Cumulative NO3 leaching */ b_no3_leach: z.number().nullable(), + /** NO3 leaching — minimal scenario */ + b_no3_leach_min: z.number(), + /** NO3 leaching — maximal scenario */ + b_no3_leach_max: z.number(), + /** NO3 leaching — recommended scenario */ + b_no3_leach_recommended: z.number(), }) /** From f9cabf8d8fb4b1a343b5ec8ee7d87806bc204030 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:58:29 +0200 Subject: [PATCH 18/22] tests: expand coverage for mineralization --- .../src/mineralization/builders.test.ts | 236 +++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/fdm-calculator/src/mineralization/builders.test.ts b/fdm-calculator/src/mineralization/builders.test.ts index 5b407d62e..5aa47f0bf 100644 --- a/fdm-calculator/src/mineralization/builders.test.ts +++ b/fdm-calculator/src/mineralization/builders.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { buildDynaRequest } from "./builders" +import { buildDynaRequest, buildNSupplyRequest } from "./builders" import type { Timeframe } from "@nmi-agro/fdm-core" const baseField = { b_id: "field-1", b_centroid: [5.0, 52.0] as [number, number], b_area: 5 } @@ -231,3 +231,237 @@ describe("buildDynaRequest – harvests", () => { }) }) +describe("buildDynaRequest – fertilizers", () => { + const cultivations = [ + { + b_lu: "cult-1", + b_lu_catalogue: "bwt", + b_lu_start: new Date("2025-03-01"), + b_lu_end: new Date("2025-08-15"), + b_lu_croprotation: "main", + m_cropresidue: true, + }, + ] + + it("includes amendments only for the requested year", () => { + const fertilizers = [ + { + p_id: "fert-1", + p_n_rt: 120, + p_n_if: 0.5, + p_n_of: 0.3, + p_n_wc: 0.1, + p_p_rt: 30, + p_k_rt: 50, + p_dm: 25, + p_om: 10, + p_date: new Date("2025-04-01"), + p_dose: 20, + p_app_method: "injection", + }, + { + p_id: "fert-2", + p_n_rt: 80, + p_n_if: null, + p_n_of: null, + p_n_wc: null, + p_p_rt: null, + p_k_rt: null, + p_dm: null, + p_om: null, + p_date: new Date("2024-04-01"), // prior year – excluded from amendments + p_dose: 15, + p_app_method: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, fertilizers, "arable", timeframe2025) + const rotation = (result.field as Record).rotation as { amendments: { p_id: string; p_app_method: string }[] }[] + expect(rotation[0].amendments).toHaveLength(1) + expect(rotation[0].amendments[0].p_id).toBe("fert-1") + expect(rotation[0].amendments[0].p_app_method).toBe("injection") + }) + + it("defaults p_app_method to broadcasting when null", () => { + const fertilizers = [ + { + p_id: "fert-3", + p_n_rt: 50, + p_date: new Date("2025-05-01"), + p_dose: 10, + p_app_method: null, + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, fertilizers, "arable", timeframe2025) + const rotation = (result.field as Record).rotation as { amendments: { p_app_method: string }[] }[] + expect(rotation[0].amendments[0].p_app_method).toBe("broadcasting") + }) + + it("deduplicates fertilizer_properties by p_id", () => { + const fertilizers = [ + { p_id: "fert-A", p_n_rt: 100, p_date: new Date("2025-04-01"), p_dose: 10, p_app_method: "broadcasting" }, + { p_id: "fert-A", p_n_rt: 100, p_date: new Date("2025-05-01"), p_dose: 15, p_app_method: "broadcasting" }, + { p_id: "fert-B", p_n_rt: 60, p_date: new Date("2025-06-01"), p_dose: 12, p_app_method: "broadcasting" }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, fertilizers, "arable", timeframe2025) + const props = result.fertilizer_properties as { p_id: string }[] + expect(props).toHaveLength(2) + expect(props.map((p) => p.p_id)).toEqual(["fert-A", "fert-B"]) + }) + + it("includes all optional nutrient properties in fertilizer_properties", () => { + const fertilizers = [ + { + p_id: "fert-full", + p_n_rt: 120, + p_n_if: 0.5, + p_n_of: 0.3, + p_n_wc: 0.1, + p_p_rt: 30, + p_k_rt: 50, + p_dm: 25, + p_om: 10, + p_date: new Date("2025-04-01"), + p_dose: 20, + p_app_method: "injection", + }, + ] + const result = buildDynaRequest(baseField, soilData, cultivations, fertilizers, "arable", timeframe2025) + const props = result.fertilizer_properties as Record[] + expect(props[0]).toMatchObject({ p_n_if: 0.5, p_n_of: 0.3, p_n_wc: 0.1, p_p_rt: 30, p_k_rt: 50, p_dm: 25, p_om: 10 }) + }) + + it("returns null for fertilizer_properties when no fertilizers provided", () => { + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + expect(result.fertilizer_properties).toBeNull() + }) +}) + +describe("buildDynaRequest – crop_properties and misc", () => { + const cultivations = [ + { + b_lu: "cult-1", + b_lu_catalogue: "bwt", + b_lu_start: new Date("2025-03-01"), + b_lu_end: new Date("2025-08-15"), + b_lu_croprotation: "main", + m_cropresidue: true, + }, + ] + + it("includes crop_properties when provided", () => { + const cropProperties = [{ b_lu_catalogue: "bwt", b_lu_yield: 1800, b_lu_n_harvestable: 20, b_lu_n_residue: 5 }] + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025, cropProperties) + const props = result.crop_properties as { b_lu: string; b_lu_yield: number }[] + expect(props).toHaveLength(1) + expect(props[0].b_lu).toBe("bwt") + expect(props[0].b_lu_yield).toBe(1800) + }) + + it("returns null for crop_properties when not provided", () => { + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + expect(result.crop_properties).toBeNull() + }) + + it("defaults sector to arable when farmSector is empty string", () => { + const result = buildDynaRequest(baseField, soilData, cultivations, [], "", timeframe2025) + expect((result.farm as { sector: string }).sector).toBe("arable") + }) + + it("omits b_id from field when not provided", () => { + const fieldNoId = { b_centroid: [5.0, 52.0] as [number, number], b_area: 5 } + const result = buildDynaRequest(fieldNoId, soilData, cultivations, [], "arable", timeframe2025) + expect((result.field as Record).b_id).toBeUndefined() + }) + + it("includes m_cropresidue on rotation entry when set", () => { + const result = buildDynaRequest(baseField, soilData, cultivations, [], "arable", timeframe2025) + const rotation = (result.field as Record).rotation as { m_cropresidue: boolean }[] + expect(rotation[0].m_cropresidue).toBe(true) + }) + + it("omits a_lat/a_lon when no centroid provided", () => { + const fieldNoCentroid = { b_id: "f1", b_area: 5 } + const result = buildDynaRequest(fieldNoCentroid, soilData, cultivations, [], "arable", timeframe2025) + const fieldObj = result.field as Record + expect(fieldObj.a_lat).toBeUndefined() + expect(fieldObj.a_lon).toBeUndefined() + }) +}) + +describe("buildNSupplyRequest", () => { + const timeframe2025: Timeframe = { + start: new Date("2025-01-01"), + end: new Date("2025-12-31"), + } + + it("builds a basic nsupply request with all fields", () => { + const field = { b_centroid: [5.585, 53.288] as [number, number], b_area: 10 } + const soilData = { + a_som_loi: 3.5, + a_clay_mi: 10, + a_silt_mi: 20, + a_sand_mi: 70, + a_c_of: 18, + a_cn_fr: 12, + a_n_rt: 2000, + a_n_pmn: 30, + b_soiltype_agr: "sand", + a_depth_lower: 0.3, + } + const cultivations = [{ b_lu_catalogue: "nl_256", b_lu_start: new Date("2025-04-01"), b_lu_end: new Date("2025-09-01"), b_lu_croprotation: "main" }] + + const result = buildNSupplyRequest(field, soilData, cultivations, "minip", timeframe2025) + + expect(result.d_n_supply_method).toBe("minip") + expect(result.d_start).toBe("2025-01-01") + expect(result.d_end).toBe("2025-12-31") + expect(result.a_lat).toBe(53.288) + expect(result.a_lon).toBe(5.585) + expect(result.b_lu_brp).toBe(256) + expect(result.a_som_loi).toBe(3.5) + expect(result.a_clay_mi).toBe(10) + expect(result.b_soiltype_agr).toBe("sand") + expect(result.a_depth).toBe(0.3) + }) + + it("omits a_lat/a_lon when no centroid", () => { + const result = buildNSupplyRequest({}, {}, [], "minip", timeframe2025) + expect(result.a_lat).toBeUndefined() + expect(result.a_lon).toBeUndefined() + }) + + it("omits b_lu_brp when no cultivations", () => { + const result = buildNSupplyRequest({}, {}, [], "minip", timeframe2025) + expect(result.b_lu_brp).toBeUndefined() + }) + + it("omits b_lu_brp when catalogue code cannot be parsed as integer", () => { + const cultivations = [{ b_lu_catalogue: "grs", b_lu_start: new Date("2025-05-01"), b_lu_end: null, b_lu_croprotation: "main" }] + const result = buildNSupplyRequest({}, {}, cultivations, "minip", timeframe2025) + expect(result.b_lu_brp).toBeUndefined() + }) + + it("defaults a_depth to 0.3 when a_depth_lower is not in soilData", () => { + const result = buildNSupplyRequest({}, {}, [], "minip", timeframe2025) + expect(result.a_depth).toBe(0.3) + }) + + it("uses a_depth_lower value from soilData when provided", () => { + const result = buildNSupplyRequest({}, { a_depth_lower: 0.25 }, [], "minip", timeframe2025) + expect(result.a_depth).toBe(0.25) + }) + + it("omits d_start and d_end when timeframe has no dates", () => { + const result = buildNSupplyRequest({}, {}, [], "minip", { start: undefined, end: undefined }) + expect(result.d_start).toBeUndefined() + expect(result.d_end).toBeUndefined() + }) + + it("skips null/undefined soil params", () => { + const result = buildNSupplyRequest({}, { a_som_loi: null, a_clay_mi: undefined, a_n_rt: 1000 }, [], "minip", timeframe2025) + expect(result.a_som_loi).toBeUndefined() + expect(result.a_clay_mi).toBeUndefined() + expect(result.a_n_rt).toBe(1000) + }) +}) + From c68f5623011ac282c03630462bbd1710aaf80699 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:09:28 +0200 Subject: [PATCH 19/22] nitpicks --- .../app/integrations/mineralization.server.ts | 19 ++++++++++++++++--- ...rm.$b_id_farm.$calendar.mineralization.tsx | 6 +++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/fdm-app/app/integrations/mineralization.server.ts b/fdm-app/app/integrations/mineralization.server.ts index 0cea6c6b8..74f6d684b 100644 --- a/fdm-app/app/integrations/mineralization.server.ts +++ b/fdm-app/app/integrations/mineralization.server.ts @@ -326,6 +326,19 @@ export async function getNSupplyForFarm({ * * @internal */ +/** Subset of fertilizer properties needed by the DYNA request builder. */ +type FertilizerNutrientProps = { + p_id: string + p_n_rt?: number | null + p_n_if?: number | null + p_n_of?: number | null + p_n_wc?: number | null + p_p_rt?: number | null + p_k_rt?: number | null + p_dm?: number | null + p_om?: number | null +} + async function runDynaForPrefetchedField({ field, soilDataArray, @@ -342,7 +355,7 @@ async function runDynaForPrefetchedField({ soilDataArray: Awaited> cultivations: Awaited> applications: Awaited> extends Map ? T : any - fertilizerMap: Map>[number]> + fertilizerMap: Map catalogueEntries: Awaited> harvestsMap: Awaited> farmSector: string @@ -517,7 +530,7 @@ export async function getDynaForField({ p_app_amount: f.p_dose ?? 0, p_app_method: f.p_app_method ?? null, })) - const fertilizerMap = new Map( + const fertilizerMap = new Map( (fertilizers ?? []).map((f) => [ f.p_id, { @@ -539,7 +552,7 @@ export async function getDynaForField({ soilDataArray, cultivations, applications, - fertilizerMap: fertilizerMap as any, + fertilizerMap, catalogueEntries, harvestsMap, farmSector, diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx index 3b4d83621..64fc0f5d2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.mineralization.tsx @@ -120,8 +120,12 @@ export default function MineralizationLayout() { const location = useLocation() const { b_id } = useParams() // Get the field ID from child routes + // Prefer the server-evaluated flag (avoids flicker on initial render and + // eliminates a redundant PostHog call). Fall back to the client hook only + // when the server value is absent (e.g. PostHog unavailable server-side). + const clientFlagEnabled = useFeatureFlagEnabled("mineralization") const isMineralizationEnabled = - useFeatureFlagEnabled("mineralization") ?? true + loaderData.isMineralizationEnabled ?? clientFlagEnabled ?? true const isField = !!b_id const isDyna = location.pathname.endsWith("/dyna") From aa215e425a37f61e2a0657f24f3993c7998c6f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 28 Apr 2026 10:04:01 +0200 Subject: [PATCH 20/22] Fix truncation issue with the nitrogen supply method selector --- .../app/components/blocks/mineralization/method-selector.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fdm-app/app/components/blocks/mineralization/method-selector.tsx b/fdm-app/app/components/blocks/mineralization/method-selector.tsx index 589d831b2..5fa35c491 100644 --- a/fdm-app/app/components/blocks/mineralization/method-selector.tsx +++ b/fdm-app/app/components/blocks/mineralization/method-selector.tsx @@ -4,7 +4,6 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, } from "~/components/ui/select" import type { NSupplyMethod } from "~/integrations/mineralization.server" @@ -52,7 +51,7 @@ export function MethodSelector({ value }: MethodSelectorProps) { return (