diff --git a/.changeset/grumpy-mirrors-see.md b/.changeset/grumpy-mirrors-see.md
new file mode 100644
index 000000000..ceac34306
--- /dev/null
+++ b/.changeset/grumpy-mirrors-see.md
@@ -0,0 +1,5 @@
+---
+"@nmi-agro/fdm-app": minor
+---
+
+Added calculation for Nitrogen Supplying Capacity (NLV / `d_n_supply_base`) across soil cards, the field overview dashboard, fields table, and the soil analysis atlas map layer.
diff --git a/.changeset/rich-masks-occur.md b/.changeset/rich-masks-occur.md
new file mode 100644
index 000000000..ea1bb056e
--- /dev/null
+++ b/.changeset/rich-masks-occur.md
@@ -0,0 +1,5 @@
+---
+"@nmi-agro/fdm-core": minor
+---
+
+Add `d_n_supply_base` as soil parameter (NLV, mineralisation by soil organic matter from soil) that can have a 'calculated' source
diff --git a/.changeset/swift-ears-argue.md b/.changeset/swift-ears-argue.md
new file mode 100644
index 000000000..09a52f82f
--- /dev/null
+++ b/.changeset/swift-ears-argue.md
@@ -0,0 +1,5 @@
+---
+"@nmi-agro/fdm-calculator": minor
+---
+
+Add function `calculateNlv` to calculate the nitrogen mineralization from soil organic matter. Rename `calculateNlvSupplyBySom` to `calculateNlvSupplyIncreaseBySomPotential`
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index a0df9c13d..3c23df8f9 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -85,6 +85,7 @@ export const GRADIENT_SHADED_SOIL_PARAMETERS = {
a_com_fr: "carbon_ratio",
a_cu_cc: "copper",
a_density_sa: "earth_heavy",
+ d_n_supply_base: "nitrogen",
a_fe_ox: "iron",
a_k_cc: "potassium",
a_k_co: "potassium",
diff --git a/fdm-app/app/components/blocks/field-dashboard/tiles.tsx b/fdm-app/app/components/blocks/field-dashboard/tiles.tsx
index ed90a4194..420014c15 100644
--- a/fdm-app/app/components/blocks/field-dashboard/tiles.tsx
+++ b/fdm-app/app/components/blocks/field-dashboard/tiles.tsx
@@ -1,6 +1,7 @@
import { format } from "date-fns"
import { nl } from "date-fns/locale"
import {
+ Calculator,
CheckCircle2,
ClipboardCheck,
Leaf,
@@ -57,6 +58,7 @@ function formatNumberLabel(value: number | null | undefined, unit?: string) {
}
function soilSourceIcon(source: string | null) {
+ if (source === "calculated") return Calculator
if (source === "nl-other-nmi") return Sparkles
if (source && source !== "other") return Microscope
return User
@@ -743,11 +745,13 @@ export function FieldDashboardSoilParametersTile({ dashboard, tile }: FieldDashb
- {card.source === "nl-other-nmi"
- ? "Geschat met NMI BodemSchat"
- : card.source && card.source !== "other"
- ? `Gemeten door ${card.sourceLabel}`
- : "Onbekende bron"}
+ {card.source === "calculated"
+ ? "Berekend"
+ : card.source === "nl-other-nmi"
+ ? "Geschat met NMI BodemSchat"
+ : card.source && card.source !== "other"
+ ? `Gemeten door ${card.sourceLabel}`
+ : "Onbekende bron"}
diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx
index 93c788732..33629fb89 100644
--- a/fdm-app/app/components/blocks/fields/columns.tsx
+++ b/fdm-app/app/components/blocks/fields/columns.tsx
@@ -35,6 +35,7 @@ export type FieldExtended = {
cultivations: Cultivation[]
cultivationSuggestion?: CultivationSuggestion
fertilizers: Fertilizer[]
+ d_n_supply_base: number | null
a_som_loi: number | null
b_soiltype_agr: string | null
b_area: number
@@ -178,6 +179,25 @@ export function buildColumns(b_id_farm: string, calendar: string): ColumnDef ,
enableHiding: true, // Enable hiding for mobile
},
+ {
+ accessorKey: "d_n_supply_base",
+ enableSorting: true,
+ sortingFn: "alphanumeric",
+ header: ({ column }) => {
+ return
+ },
+ enableHiding: true, // Enable hiding for mobile
+ cell: ({ row }) => {
+ const field = row.original
+ return (
+
+ {field.d_n_supply_base !== null
+ ? `${Math.round(field.d_n_supply_base)} kg N/ha`
+ : "-"}
+
+ )
+ },
+ },
{
accessorKey: "a_som_loi",
enableSorting: true,
diff --git a/fdm-app/app/components/blocks/soil/cards.tsx b/fdm-app/app/components/blocks/soil/cards.tsx
index 68b6645a2..dd68d8d60 100644
--- a/fdm-app/app/components/blocks/soil/cards.tsx
+++ b/fdm-app/app/components/blocks/soil/cards.tsx
@@ -1,7 +1,7 @@
import type { CurrentSoilData, SoilParameterDescription } from "@nmi-agro/fdm-core"
import { format } from "date-fns/format"
import { nl } from "date-fns/locale/nl"
-import { Calendar, ExternalLink, Microscope, Pencil, Sparkles, User } from "lucide-react"
+import { Calculator, Calendar, ExternalLink, Microscope, Pencil, Sparkles, User } from "lucide-react"
import { NavLink } from "react-router"
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import { Separator } from "~/components/ui/separator"
@@ -11,7 +11,7 @@ import { cn } from "~/lib/utils"
const SOIL_GROUPS = [
{
title: "Basis",
- parameters: ["b_soiltype_agr", "a_som_loi", "a_ph_cc", "b_gwl_class"],
+ parameters: ["b_soiltype_agr", "a_som_loi", "d_n_supply_base", "a_ph_cc", "b_gwl_class"],
},
{
title: "Nutriënten",
@@ -88,7 +88,8 @@ function SoilDataCard({
sourceLabel: string
canModify: boolean
}) {
- const EditIcon = canModify ? Pencil : ExternalLink
+ const isEditable = canModify && source !== "calculated"
+ const EditIcon = isEditable ? Pencil : ExternalLink
return (
@@ -112,7 +113,7 @@ function SoilDataCard({
- {canModify ? "Bewerken" : "Bekijken"}
+ {isEditable ? "Bewerken" : "Bekijken"}
) : null}
@@ -145,7 +146,9 @@ function SoilDataCard({
!source ? "invisible" : "",
)}
>
- {source === "nl-other-nmi" ? (
+ {source === "calculated" ? (
+
+ ) : source === "nl-other-nmi" ? (
) : source === "other" || !source ? (
@@ -156,11 +159,13 @@ function SoilDataCard({
- {source === "nl-other-nmi"
- ? `Geschat met ${sourceLabel}`
- : source === "other" || !source
- ? "Onbekende bron"
- : `Gemeten door ${sourceLabel}`}
+ {source === "calculated"
+ ? "Berekend"
+ : source === "nl-other-nmi"
+ ? `Geschat met ${sourceLabel}`
+ : source === "other" || !source
+ ? "Onbekende bron"
+ : `Gemeten door ${sourceLabel}`}
@@ -213,7 +218,10 @@ export function SoilDataCards({
{groupCards.map((card) => {
const sourceParam = soilParameterDescription.find((x) => x.parameter === "a_source")
const sourceOption = sourceParam?.options?.find((x) => x.value === card.source)
- const sourceLabel = sourceOption?.label || card.source || "Onbekend"
+ const sourceLabel =
+ card.source === "calculated"
+ ? "Berekend"
+ : sourceOption?.label || card.source || "Onbekend"
return (
item.parameter !== "d_n_supply_base" || item.value != null,
+ )
+ const hasNlv = data.some(
+ (item) => item.parameter === "d_n_supply_base" && item.value != null,
+ )
+ if (!hasNlv) {
+ const somItem = data.find((item) => item.parameter === "a_som_loi")
+ const clayItem = data.find((item) => item.parameter === "a_clay_mi")
+ const cnItem = data.find((item) => item.parameter === "a_cn_fr")
+ if (
+ somItem &&
+ typeof somItem.value === "number" &&
+ clayItem &&
+ typeof clayItem.value === "number" &&
+ cnItem &&
+ typeof cnItem.value === "number"
+ ) {
+ const nlvVal =
+ Math.round(
+ calculateNlv({
+ a_clay_mi: clayItem.value,
+ a_cn_fr: cnItem.value,
+ a_som_loi: somItem.value,
+ }) * 10,
+ ) / 10
+ data.push({
+ parameter: "d_n_supply_base",
+ value: nlvVal,
+ a_id: somItem.a_id,
+ b_sampling_date: somItem.b_sampling_date,
+ a_depth_upper: somItem.a_depth_upper,
+ a_depth_lower: somItem.a_depth_lower,
+ a_source: "calculated",
+ })
+ }
+ }
+ return data as CurrentSoilData
+}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx
index 1034aed96..ec22e9c6d 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx
@@ -1,5 +1,5 @@
import {
- calculateNlvSupplyBySom,
+ calculateNlvSupplyIncreaseBySomPotential,
calculateWaterSupplyBySom,
getRegion,
getSoilParameterEstimates,
@@ -234,7 +234,7 @@ async function loadAsyncData(_calendar: string, latitude: number, longitude: num
}),
),
extraNMineralization: Math.round(
- calculateNlvSupplyBySom({
+ calculateNlvSupplyIncreaseBySomPotential({
a_clay_mi: estimates.a_clay_mi,
a_cn_fr: estimates.a_cn_fr,
a_som_loi: estimates.a_som_loi,
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.indicators.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.indicators.tsx
index 307a0a493..fc5245f5d 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.indicators.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.indicators.tsx
@@ -125,12 +125,17 @@ export default function AtlasIndicatorsMap() {
const basePath = `/farm/${b_id_farm}/${calendar}/indicators`
const { capture } = useAnalytics()
- useEffect(() => {
- capture("atlas_viewed", { b_id_farm, calendar, layer: "indicators" })
- }, [])
-
const [selectedProperty, setSelectedProperty] = useState("S_BLN")
+ useEffect(() => {
+ capture("atlas_viewed", {
+ b_id_farm,
+ calendar,
+ layer: "indicators",
+ property: selectedProperty,
+ })
+ }, [b_id_farm, calendar, selectedProperty, capture])
+
const selectedLabel =
selectedProperty === "avgScore"
? "Gemiddelde score"
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index cc96fe2b4..464e2a21f 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -1,7 +1,6 @@
import type { FeatureCollection, Geometry } from "geojson"
import type { MetaFunction } from "react-router"
import {
- type CurrentSoilData,
getCurrentSoilDataForFarm,
getFields,
getSoilParametersDescription,
@@ -42,6 +41,7 @@ import { getCalendar, getTimeframe } from "~/lib/calendar"
import { clientConfig } from "~/lib/config"
import { handleLoaderError } from "~/lib/error"
import { fdm } from "~/lib/fdm.server"
+import { enrichCurrentSoilDataWithNlv } from "~/lib/soil.server"
import { useSelectedAtlasSoilParameterStore } from "~/store/selected-soil-parameter"
export const meta: MetaFunction = () => {
@@ -95,16 +95,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const features = fields.map((field) => {
const fieldCurrentSoilData = currentSoilDataForFarm.get(field.b_id) ?? []
+ const fieldEnrichedSoilData = enrichCurrentSoilDataWithNlv(fieldCurrentSoilData)
+ const soilProps = fieldEnrichedSoilData.reduce(
+ (acc, data) => {
+ if (data.value !== null) acc[data.parameter] = data.value
+ return acc
+ },
+ {} as Record,
+ )
+
const feature = {
type: "Feature" as const,
properties: {
- ...fieldCurrentSoilData.reduce(
- (acc, data) => {
- if (data.value !== null) acc[data.parameter] = data.value
- return acc
- },
- {} as Record,
- ),
+ ...soilProps,
b_id: field.b_id,
b_name: field.b_name,
b_area: Math.round((field.b_area ?? 0) * 10) / 10,
@@ -155,10 +158,6 @@ export default function FarmAtlasFieldSoilAnalysisBlock() {
const navigate = useNavigate()
const { capture } = useAnalytics()
- useEffect(() => {
- capture("atlas_viewed", { b_id_farm, calendar, layer: "soil_analysis" })
- }, [])
-
const heatmapLayerId = "fieldsSavedHeatmap"
const heatmapOutlineLayerId = "fieldsSavedHeatmapOutline"
const selectedParameter = useSelectedAtlasSoilParameterStore((store) => store.selectedParameter)
@@ -166,6 +165,15 @@ export default function FarmAtlasFieldSoilAnalysisBlock() {
(store) => store.setSelectedParameter,
)
+ useEffect(() => {
+ capture("atlas_viewed", {
+ b_id_farm,
+ calendar,
+ layer: "soil_analysis",
+ parameter: selectedParameter,
+ })
+ }, [b_id_farm, calendar, selectedParameter, capture])
+
const [min, max] = useMemo(() => {
if (!fieldsData || fieldsData?.features.length === 0) {
return [0, 1]
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
index b0a8a9565..380481e8f 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
@@ -12,6 +12,7 @@ import { getSession } from "~/lib/auth.server"
import { getTimeframe } from "~/lib/calendar"
import { handleLoaderError } from "~/lib/error"
import { fdm } from "~/lib/fdm.server"
+import { enrichCurrentSoilDataWithNlv } from "~/lib/soil.server"
/**
* Loader function for the soil data page of a specific farm field.
@@ -70,7 +71,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
start: null,
end: timeframe.end,
}),
- getCurrentSoilData(fdm, session.principal_id, b_id, timeframe),
+ getCurrentSoilData(fdm, session.principal_id, b_id, timeframe).then(
+ enrichCurrentSoilDataWithNlv,
+ ),
])
// Get soil parameter descriptions
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx
index 755f4f81c..5db0febe2 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx
@@ -84,9 +84,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
// Get soil parameter descriptions and filter on the available soil parameters
const soilParameterDescription = getSoilParametersDescription().filter(
- (item) =>
- typeof soilAnalysis[item.parameter] !== "undefined" &&
- soilAnalysis[item.parameter] !== null,
+ (item: { parameter: string }) =>
+ typeof (soilAnalysis as unknown as Record)[item.parameter] !==
+ "undefined" &&
+ (soilAnalysis as unknown as Record)[item.parameter] !== null,
)
// Return user information from loader
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id._index.tsx
index 4b2b5787b..642585130 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id._index.tsx
@@ -74,6 +74,7 @@ import { handleLoaderError, reportError } from "~/lib/error"
import { fdm } from "~/lib/fdm.server"
import { getMainCultivation } from "~/lib/hoofdteelt.server"
import { getScoreTier, getScoreVerdict, scoreToDisplay } from "~/lib/indicators"
+import { enrichCurrentSoilDataWithNlv } from "~/lib/soil.server"
import { cn } from "~/lib/utils"
export const meta: MetaFunction = ({ loaderData }) => {
@@ -237,7 +238,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
allCultivations,
fertilizerApplications,
fertilizers,
- currentSoilData,
+ currentSoilDataRaw,
soilAnalyses,
measures,
] = await Promise.all([
@@ -256,6 +257,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
getMeasures(fdm, session.principal_id, b_id, timeframe),
])
+ const currentSoilData = enrichCurrentSoilDataWithNlv(currentSoilDataRaw)
+
if (!field) {
throw data("Unable to find field", { status: 404, statusText: "Unable to find field" })
}
@@ -401,7 +404,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const sourceParam = getSoilParametersDescription().find((item) => item.parameter === "a_source")
const basisCards = constructSoilDataCards(currentSoilData, getSoilParametersDescription())
.filter((card) =>
- ["b_soiltype_agr", "a_som_loi", "a_p_al", "a_p_cc", "a_clay_mi"].includes(card.parameter),
+ ["b_soiltype_agr", "a_som_loi", "a_p_al", "a_p_cc", "a_clay_mi", "d_n_supply_base"].includes(card.parameter),
)
.map((card) => ({
...card,
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
index 4e655305e..a302a70a5 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
@@ -27,6 +27,7 @@ import { isBcsAnalysis } from "~/lib/bcs"
import { getTimeframe } from "~/lib/calendar"
import { handleActionError, handleLoaderError } from "~/lib/error"
import { fdm } from "~/lib/fdm.server"
+import { enrichCurrentSoilDataWithNlv } from "~/lib/soil.server"
import { cn } from "~/lib/utils"
/**
@@ -89,7 +90,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const filteredSoilAnalyses = soilAnalyses.filter((analysis) => !isBcsAnalysis(analysis))
// Get current soil data
- const currentSoilData = await getCurrentSoilData(fdm, session.principal_id, b_id, timeframe)
+ const currentSoilData = enrichCurrentSoilDataWithNlv(
+ await getCurrentSoilData(fdm, session.principal_id, b_id, timeframe),
+ )
// Get soil parameter descriptions
const soilParameterDescription = getSoilParametersDescription()
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx
index bb25c7b07..ce49c9148 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx
@@ -1,3 +1,4 @@
+import { calculateNlv } from "@nmi-agro/fdm-calculator"
import {
checkPermission,
getCultivations,
@@ -59,7 +60,7 @@ function chunk(items: T[], size: number): T[][] {
export const meta: MetaFunction = () => {
return [
- { title: `Perceel | ${clientConfig.name}` },
+ { title: `Percelen | ${clientConfig.name}` },
{
name: "description",
content:
@@ -188,6 +189,26 @@ export async function loader({ request, params, url }: LoaderFunctionArgs) {
const a_som_loi = Number(
currentSoilData.find((x) => x.parameter === "a_som_loi")?.value ?? 0,
)
+ const existingNlv = currentSoilData.find(
+ (x) => x.parameter === "d_n_supply_base" && x.value != null,
+ )?.value
+ const clayVal = currentSoilData.find((x) => x.parameter === "a_clay_mi")?.value
+ const cnVal = currentSoilData.find((x) => x.parameter === "a_cn_fr")?.value
+ const somVal = currentSoilData.find((x) => x.parameter === "a_som_loi")?.value
+ const d_n_supply_base =
+ typeof existingNlv === "number"
+ ? existingNlv
+ : typeof somVal === "number" &&
+ typeof clayVal === "number" &&
+ typeof cnVal === "number"
+ ? Math.round(
+ calculateNlv({
+ a_clay_mi: clayVal,
+ a_cn_fr: cnVal,
+ a_som_loi: somVal,
+ }) * 10,
+ ) / 10
+ : null
const b_soiltype_agr = String(
currentSoilData.find((x) => x.parameter === "b_soiltype_agr")?.value ?? "",
)
@@ -239,6 +260,7 @@ export async function loader({ request, params, url }: LoaderFunctionArgs) {
cultivations: cultivations,
cultivationSuggestion: undefined as Awaited>,
fertilizers: fertilizersFiltered,
+ d_n_supply_base: d_n_supply_base,
a_som_loi: a_som_loi,
b_soiltype_agr: b_soiltype_agr,
b_area: Math.round((field.b_area ?? 0) * 10) / 10,
diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts
index f0e3a1c78..14f74276c 100644
--- a/fdm-calculator/src/index.ts
+++ b/fdm-calculator/src/index.ts
@@ -139,8 +139,8 @@ export type {
export { collectInputForSoilParameterEstimates } from "./estimates/input"
export { getSoilParameterEstimates, requestSoilParameterEstimates } from "./estimates/index"
export type { SoilParameterEstimatesInput, SoilParameterEstimatesResponse } from "./estimates/types"
-export type { NlvSupplyBySomParams } from "./other/nlv-supply-by-som"
-export { calculateNlvSupplyBySom } from "./other/nlv-supply-by-som"
+export type { NlvParams, NlvSupplyBySomParams } from "./other/nlv-supply"
+export { calculateNlv, calculateNlvSupplyIncreaseBySomPotential } from "./other/nlv-supply"
export type { WaterSupplyBySomParams } from "./other/water-supply-by-som"
export { calculateWaterSupplyBySom } from "./other/water-supply-by-som"
export type { CultivationForHoofdteelt } from "./shared/hoofdteelt"
diff --git a/fdm-calculator/src/other/nlv-supply-by-som.test.ts b/fdm-calculator/src/other/nlv-supply-by-som.test.ts
deleted file mode 100644
index 3d3c66235..000000000
--- a/fdm-calculator/src/other/nlv-supply-by-som.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, expect, it } from "vitest"
-import { calculateNlvSupplyBySom } from "./nlv-supply-by-som"
-
-describe("calculateNlvSupplyBySom", () => {
- it("should return a positive value when SOM increases", () => {
- const result = calculateNlvSupplyBySom({
- a_clay_mi: 20,
- a_cn_fr: 12,
- a_som_loi: 3,
- b_som_potential: 4,
- })
- expect(result).toBeGreaterThan(0)
- })
-
- it("should return a negative value when SOM decreases", () => {
- const result = calculateNlvSupplyBySom({
- a_clay_mi: 20,
- a_cn_fr: 12,
- a_som_loi: 4,
- b_som_potential: 3,
- })
- expect(result).toBeLessThan(0)
- })
-
- it("should return 0 change when SOM remains the same", () => {
- const result = calculateNlvSupplyBySom({
- a_clay_mi: 20,
- a_cn_fr: 12,
- a_som_loi: 3,
- b_som_potential: 3,
- })
- expect(result).toBe(0)
- })
-
- it("should handle typical soil values and return plausible NLV increase", () => {
- const result = calculateNlvSupplyBySom({
- a_clay_mi: 25,
- a_cn_fr: 10,
- a_som_loi: 4.0,
- b_som_potential: 5.0,
- })
-
- // The increase for 1% SOM is usually around 10-30 kg N/ha in these models
- expect(result).toBeGreaterThan(5)
- expect(result).toBeLessThan(100)
- })
-})
diff --git a/fdm-calculator/src/other/nlv-supply-by-som.ts b/fdm-calculator/src/other/nlv-supply-by-som.ts
deleted file mode 100644
index 3ce00359e..000000000
--- a/fdm-calculator/src/other/nlv-supply-by-som.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import Decimal from "decimal.js"
-
-/**
- * Parameters for calculating the change in Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV) based on change in Soil Organic Matter (SOM).
- */
-export interface NlvSupplyBySomParams {
- /** The clay content of the soil (%) */
- a_clay_mi: number
- /** The carbon to nitrogen ratio of the soil organic matter */
- a_cn_fr: number
- /** The current organic matter content of the soil (%) */
- a_som_loi: number
- /** The maximum achievable organic matter content of the soil (%) */
- b_som_potential: number
-}
-
-/**
- * Calculates the change in NLV by a change in SOM.
- *
- * This function calculates the change in Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV)
- * resulting from an increase in Soil Organic Matter (SOM) using the MINIP model logic.
- * It estimates the nitrogen mineralization potential for a 30 cm soil layer and assumes that the C-to-N ratio remains constant with increasing SOM.
- *
- * @param params The soil texture, CN ratio, and SOM parameters.
- * @returns The change in NLV (in kg N / ha).
- */
-export function calculateNlvSupplyBySom({
- a_clay_mi,
- a_cn_fr,
- a_som_loi,
- b_som_potential,
-}: NlvSupplyBySomParams): number {
- // Settings (from MINIP model)
- const paramA = new Decimal(20) // Age of organic matter
- const paramB = new Decimal(2).pow(new Decimal(14.1).sub(9).div(9)) // Temperature correction
- const paramCnMicro = new Decimal(10) // CN ratio of micro organisms
- const paramT = new Decimal(5).div(12) // 5 months a year
- const paramDissMicro = new Decimal(2) // Dissimilation : assimilation ratio of micro organisms
-
- const clayPct = new Decimal(a_clay_mi)
- const cnFr = new Decimal(a_cn_fr)
-
- /**
- * Internal function to calculate NLV for a given SOM content.
- */
- const calculateNlv = (a_som_loi: number): Decimal => {
- const som = new Decimal(a_som_loi)
-
- // Step 1: Estimate density (g/cm3)
- const densitySand = new Decimal(1).div(som.times(0.02525).add(0.6541))
-
- const densityClay = new Decimal(0.00000067)
- .times(som.pow(4))
- .sub(new Decimal(0.00007792).times(som.pow(3)))
- .add(new Decimal(0.00314712).times(som.pow(2)))
- .sub(new Decimal(0.06039523).times(som))
- .add(1.33932206)
-
- const pClayFr = Decimal.min(1, clayPct.div(25))
- const density = pClayFr.times(densityClay).add(new Decimal(1).sub(pClayFr).times(densitySand))
-
- // Step 2: Estimate Soil Organic Carbon (SOC) stock for 30 cm soil (kg / ha)
- // B_C_ST03 = 0.5 * (SOM / 100) * (100 * 100) * 0.3 * density * 1000
- const socStock = new Decimal(0.5)
- .times(som.div(100))
- .times(10000)
- .times(0.3)
- .times(density)
- .times(1000)
-
- // Step 3: Estimate NLV
- // c.diss = SOC * (1 - exp(4.7 * ((param.a + param.b * param.t)^-0.6 - param.a^-0.6)))
- const exponent = new Decimal(4.7).times(
- paramA.add(paramB.times(paramT)).pow(-0.6).sub(paramA.pow(-0.6)),
- )
- const cDiss = socStock.times(new Decimal(1).sub(exponent.exp()))
- const cAss = cDiss.div(paramDissMicro)
-
- // nlv = ((c.diss + c.ass) / A_CN_FR) - (c.ass / param.cn.micro)
- return cDiss.add(cAss).div(cnFr).sub(cAss.div(paramCnMicro))
- }
-
- const nlvStart = calculateNlv(a_som_loi)
- const nlvEnd = calculateNlv(b_som_potential)
-
- return nlvEnd.sub(nlvStart).toNumber()
-}
diff --git a/fdm-calculator/src/other/nlv-supply.test.ts b/fdm-calculator/src/other/nlv-supply.test.ts
new file mode 100644
index 000000000..f086383a8
--- /dev/null
+++ b/fdm-calculator/src/other/nlv-supply.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest"
+import { calculateNlv, calculateNlvSupplyIncreaseBySomPotential } from "./nlv-supply"
+
+describe("calculateNlv", () => {
+ it("should calculate plausible absolute NLV for typical arable soil", () => {
+ const result = calculateNlv({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 3,
+ })
+ // Typical topsoil NLV values range between 30 and 150 kg N/ha/yr
+ expect(result).toBeGreaterThan(20)
+ expect(result).toBeLessThan(200)
+ })
+
+ it("should return higher NLV for higher SOM content", () => {
+ const nlvLow = calculateNlv({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 2.5,
+ })
+ const nlvHigh = calculateNlv({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 4.5,
+ })
+ expect(nlvHigh).toBeGreaterThan(nlvLow)
+ })
+})
+
+describe("calculateNlvSupplyIncreaseBySomPotential", () => {
+ it("should return a positive value when SOM increases", () => {
+ const result = calculateNlvSupplyIncreaseBySomPotential({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 3,
+ b_som_potential: 4,
+ })
+ expect(result).toBeGreaterThan(0)
+ })
+
+ it("should return a negative value when SOM decreases", () => {
+ const result = calculateNlvSupplyIncreaseBySomPotential({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 4,
+ b_som_potential: 3,
+ })
+ expect(result).toBeLessThan(0)
+ })
+
+ it("should return 0 change when SOM remains the same", () => {
+ const result = calculateNlvSupplyIncreaseBySomPotential({
+ a_clay_mi: 20,
+ a_cn_fr: 12,
+ a_som_loi: 3,
+ b_som_potential: 3,
+ })
+ expect(result).toBe(0)
+ })
+
+ it("should handle typical soil values and return plausible NLV increase", () => {
+ const result = calculateNlvSupplyIncreaseBySomPotential({
+ a_clay_mi: 25,
+ a_cn_fr: 10,
+ a_som_loi: 4.0,
+ b_som_potential: 5.0,
+ })
+
+ // The increase for 1% SOM is usually around 10-30 kg N/ha in these models
+ expect(result).toBeGreaterThan(5)
+ expect(result).toBeLessThan(100)
+ })
+})
diff --git a/fdm-calculator/src/other/nlv-supply.ts b/fdm-calculator/src/other/nlv-supply.ts
new file mode 100644
index 000000000..5d7f98c5a
--- /dev/null
+++ b/fdm-calculator/src/other/nlv-supply.ts
@@ -0,0 +1,105 @@
+import Decimal from "decimal.js"
+
+/**
+ * Parameters for calculating the Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV / d_n_supply_base) of a soil.
+ */
+export interface NlvParams {
+ /** The clay content of the soil (%) */
+ a_clay_mi: number
+ /** The carbon to nitrogen ratio of the soil organic matter */
+ a_cn_fr: number
+ /** The organic matter content of the soil (%) */
+ a_som_loi: number
+}
+
+/**
+ * Parameters for calculating the change in Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV) based on change in Soil Organic Matter (SOM).
+ */
+export interface NlvSupplyBySomParams {
+ /** The clay content of the soil (%) */
+ a_clay_mi: number
+ /** The carbon to nitrogen ratio of the soil organic matter */
+ a_cn_fr: number
+ /** The current organic matter content of the soil (%) */
+ a_som_loi: number
+ /** The maximum achievable organic matter content of the soil (%) */
+ b_som_potential: number
+}
+
+/**
+ * Calculates the Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV / d_n_supply_base) for a soil sample or field.
+ *
+ * This function calculates the base Nitrogen Supplying Capacity using the MINIP model logic.
+ * It estimates the nitrogen mineralization potential for a 30 cm soil layer based on clay content,
+ * C-to-N ratio, and soil organic matter.
+ *
+ * @param params The soil texture, CN ratio, and SOM parameters.
+ * @returns The absolute NLV (in kg N / ha / year).
+ */
+export function calculateNlv({ a_clay_mi, a_cn_fr, a_som_loi }: NlvParams): number {
+ // Settings (from MINIP model)
+ const paramA = new Decimal(20) // Age of organic matter
+ const paramB = new Decimal(2).pow(new Decimal(14.1).sub(9).div(9)) // Temperature correction
+ const paramCnMicro = new Decimal(10) // CN ratio of micro organisms
+ const paramT = new Decimal(5).div(12) // 5 months a year
+ const paramDissMicro = new Decimal(2) // Dissimilation : assimilation ratio of micro organisms
+
+ const clayPct = new Decimal(a_clay_mi)
+ const cnFr = new Decimal(a_cn_fr)
+ const som = new Decimal(a_som_loi)
+
+ // Step 1: Estimate density (g/cm3)
+ const densitySand = new Decimal(1).div(som.times(0.02525).add(0.6541))
+
+ const densityClay = new Decimal(0.00000067)
+ .times(som.pow(4))
+ .sub(new Decimal(0.00007792).times(som.pow(3)))
+ .add(new Decimal(0.00314712).times(som.pow(2)))
+ .sub(new Decimal(0.06039523).times(som))
+ .add(1.33932206)
+
+ const pClayFr = Decimal.min(1, clayPct.div(25))
+ const density = pClayFr.times(densityClay).add(new Decimal(1).sub(pClayFr).times(densitySand))
+
+ // Step 2: Estimate Soil Organic Carbon (SOC) stock for 30 cm soil (kg / ha)
+ // B_C_ST03 = 0.5 * (SOM / 100) * (100 * 100) * 0.3 * density * 1000
+ const socStock = new Decimal(0.5)
+ .times(som.div(100))
+ .times(10000)
+ .times(0.3)
+ .times(density)
+ .times(1000)
+
+ // Step 3: Estimate NLV
+ // c.diss = SOC * (1 - exp(4.7 * ((param.a + param.b * param.t)^-0.6 - param.a^-0.6)))
+ const exponent = new Decimal(4.7).times(
+ paramA.add(paramB.times(paramT)).pow(-0.6).sub(paramA.pow(-0.6)),
+ )
+ const cDiss = socStock.times(new Decimal(1).sub(exponent.exp()))
+ const cAss = cDiss.div(paramDissMicro)
+
+ // nlv = ((c.diss + c.ass) / A_CN_FR) - (c.ass / param.cn.micro)
+ return cDiss.add(cAss).div(cnFr).sub(cAss.div(paramCnMicro)).toNumber()
+}
+
+/**
+ * Calculates the change in NLV by a change in SOM.
+ *
+ * This function calculates the change in Nitrogen Supplying Capacity (Stikstof Leverend Vermogen, NLV)
+ * resulting from an increase in Soil Organic Matter (SOM) using the MINIP model logic.
+ * It estimates the nitrogen mineralization potential for a 30 cm soil layer and assumes that the C-to-N ratio remains constant with increasing SOM.
+ *
+ * @param params The soil texture, CN ratio, and SOM parameters.
+ * @returns The change in NLV (in kg N / ha).
+ */
+export function calculateNlvSupplyIncreaseBySomPotential({
+ a_clay_mi,
+ a_cn_fr,
+ a_som_loi,
+ b_som_potential,
+}: NlvSupplyBySomParams): number {
+ const nlvStart = new Decimal(calculateNlv({ a_clay_mi, a_cn_fr, a_som_loi }))
+ const nlvEnd = new Decimal(calculateNlv({ a_clay_mi, a_cn_fr, a_som_loi: b_som_potential }))
+
+ return nlvEnd.sub(nlvStart).toNumber()
+}
diff --git a/fdm-core/src/soil.test.ts b/fdm-core/src/soil.test.ts
index 34fbc0860..641757b10 100644
--- a/fdm-core/src/soil.test.ts
+++ b/fdm-core/src/soil.test.ts
@@ -768,7 +768,7 @@ describe("Soil Analysis Functions", () => {
describe("getSoilParametersDescription", () => {
it("should return the correct soil parameter descriptions for NL-nl locale", () => {
const descriptions = getSoilParametersDescription("NL-nl")
- expect(descriptions).toHaveLength(42)
+ expect(descriptions).toHaveLength(43)
for (const description of descriptions) {
expect(description).toHaveProperty("parameter")
expect(description).toHaveProperty("unit")
@@ -788,7 +788,7 @@ describe("getSoilParametersDescription", () => {
it("should return the correct soil parameter descriptions for default locale", () => {
const descriptions = getSoilParametersDescription()
- expect(descriptions).toHaveLength(42)
+ expect(descriptions).toHaveLength(43)
for (const description of descriptions) {
expect(description).toHaveProperty("parameter")
expect(description).toHaveProperty("unit")
diff --git a/fdm-core/src/soil.ts b/fdm-core/src/soil.ts
index 857151641..a29f8a07f 100644
--- a/fdm-core/src/soil.ts
+++ b/fdm-core/src/soil.ts
@@ -675,7 +675,11 @@ export async function getCurrentSoilData(
const currentSoilData = parameters
.map((parameter) => {
- const analysis = soilAnalyses.find((a) => a[parameter as keyof typeof a] !== null)
+ const analysis = soilAnalyses.find(
+ (a) =>
+ a[parameter as keyof typeof a] !== null &&
+ a[parameter as keyof typeof a] !== undefined,
+ )
if (!analysis) return null
return {
@@ -800,7 +804,9 @@ export async function getCurrentSoilDataForFarm(
const currentSoilData = parameters
.map((parameter) => {
const analysis = fieldRows.find(
- (a: Record) => a[parameter as keyof typeof a] !== null,
+ (a: Record) =>
+ a[parameter as keyof typeof a] !== null &&
+ a[parameter as keyof typeof a] !== undefined,
)
if (!analysis) return null
return {
@@ -1121,6 +1127,13 @@ export function getSoilParametersDescription(locale = "NL-nl"): SoilParameterDes
type: "numeric",
description: "Zink, plantbeschikbaar",
},
+ {
+ parameter: "d_n_supply_base",
+ unit: "kg N/ha",
+ name: "NLV",
+ type: "numeric",
+ description: "Jaarlijkse stikstofmineralisatie uit organische stof in de bouwvoor (0-30 cm)",
+ },
{
parameter: "b_gwl_class",
unit: "",
@@ -1178,6 +1191,7 @@ const SOIL_PARAMETERS: SoilParameters[] = [
"a_silt_mi",
"a_som_loi",
"a_zn_cc",
+ "d_n_supply_base",
"b_gwl_class",
"b_soiltype_agr",
]
diff --git a/fdm-core/src/soil.types.d.ts b/fdm-core/src/soil.types.d.ts
index a8c79b86d..d069ac44e 100644
--- a/fdm-core/src/soil.types.d.ts
+++ b/fdm-core/src/soil.types.d.ts
@@ -100,6 +100,7 @@ export type SoilParameters =
| "a_silt_mi"
| "a_som_loi"
| "a_zn_cc"
+ | "d_n_supply_base"
| "b_gwl_class"
| "b_soiltype_agr"
| "a_ss_bcs"
diff --git a/fdm-docs/blog/2026-03-04-release-notes.mdx b/fdm-docs/blog/2026-03-04-release-notes.mdx
index e654edfdf..14c1213fb 100644
--- a/fdm-docs/blog/2026-03-04-release-notes.mdx
+++ b/fdm-docs/blog/2026-03-04-release-notes.mdx
@@ -68,7 +68,7 @@ This release introduces a formal invitation system for granting access to farms,
Two new functions have been added to quantify the agronomic value of changes in soil organic matter (SOM):
-- **`calculateNlvSupplyBySom`** — calculates the change in nitrogen-leverend vermogen (NLV) resulting from a given change in SOM content.
+- **`calculateNlvSupplyIncreaseBySomPotential`** — calculates the change in nitrogen-leverend vermogen (NLV) resulting from a given change in SOM content.
- **`calculateWaterSupplyBySom`** — calculates the change in topsoil water holding capacity resulting from a given change in SOM content.
These functions complement the existing organic matter balance capabilities and can be used to evaluate the practical impact of SOM management decisions on nitrogen supply and water retention.