From 01d7174bef42f2fc8e71b4bb25eee045687e8c56 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:44:56 +0100 Subject: [PATCH 1/6] feat: Implement Dutch nitrogen reduction (korting) logic for grassland renewal and destruction for 2025 and 2026 --- .changeset/nitrogen-korting-grassland.md | 5 + .../2025/value/stikstofgebruiksnorm.test.ts | 231 ++++++++++++++++++ .../nl/2025/value/stikstofgebruiksnorm.ts | 167 ++++++++++++- .../2026/value/stikstofgebruiksnorm.test.ts | 216 ++++++++++++++++ .../nl/2026/value/stikstofgebruiksnorm.ts | 101 +++++++- 5 files changed, 718 insertions(+), 2 deletions(-) create mode 100644 .changeset/nitrogen-korting-grassland.md diff --git a/.changeset/nitrogen-korting-grassland.md b/.changeset/nitrogen-korting-grassland.md new file mode 100644 index 000000000..06b09f83e --- /dev/null +++ b/.changeset/nitrogen-korting-grassland.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-calculator": minor +--- + +Implement Dutch nitrogen reduction (korting) logic for grassland renewal and destruction for 2025 and 2026. Includes localized Dutch error messages for invalid operation dates. diff --git a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts index 90aefb5c1..d335004b9 100644 --- a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts +++ b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts @@ -589,3 +589,234 @@ describe(" calculateNL2025StikstofGebruiksNorm", () => { ) }) }) + +const sandCentroid: [number, number] = [5.656346970245633, 51.987872886419524] // zand_nwc +const clayCentroid: [number, number] = [5.64188724, 51.977587] // klei + +describe("calculateNL2025StikstofGebruiksNorm - Korting Logic", () => { + describe("Grassland Renewal (Gras-na-Gras) - 50 kg N/ha", () => { + it("should apply 50 discount on Sand (June 1 - Aug 31)", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 5, 15), // June 15 + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 5, 16), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + // Expect korting of 50. Base might be 320 (zand_nwc, maaien). + // 320 - 50 = 270. + expect(result.normSource).toContain( + "Korting: 50kg N/ha: graslandvernieuwing", + ) + }) + + it("should apply 50 discount on Clay (Derogation + NV: June 1 - Aug 31)", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: true, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: clayCentroid, // Assume Non-NV + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 8, 1), // Sep 1 + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 8, 2), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + // Derogation + Non-NV allows up to Sep 15. Sep 1 is valid. + expect(result.normSource).toContain( + "Korting: 50kg N/ha: graslandvernieuwing", + ) + }) + + it("should throw error for invalid renewal date on Sand", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 4, 15), // May 15 (Too early) + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 4, 16), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2025StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernieuwing op zand- en lössgrond is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + }) + }) + + describe("Grassland Destruction (Gras-naar-Bouwland) - 65 kg N/ha", () => { + it("should apply 65 discount on Sand (Maize, Feb 1 - May 10)", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 1, 15), // Feb 15 + }, + { + b_lu_catalogue: "nl_259", // Maize (Snijmais) + b_lu_start: new Date(2025, 1, 16), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 65kg N/ha: graslandvernietiging", + ) + }) + + it("should apply 65 discount on Clay (Consumption Potato, Feb 1 - May 31)", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: clayCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 3, 15), // April 15 + }, + { + b_lu_catalogue: "nl_2014", // Consumption Potato + b_lu_variety: "Agria", // Low norm + b_lu_start: new Date(2025, 3, 16), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 65kg N/ha: graslandvernietiging", + ) + }) + + it("should NOT apply discount for Seed Potatoes", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 1, 15), // Feb 15 + }, + { + b_lu_catalogue: "nl_2015", // Seed Potato + b_lu_variety: "Adora", + b_lu_start: new Date(2025, 1, 16), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).not.toContain("graslandvernietiging") + }) + + it("should throw error for invalid destruction date on Sand", async () => { + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 5, 1), // June 1 (Too late) + }, + { + b_lu_catalogue: "nl_259", // Maize + b_lu_start: new Date(2025, 5, 2), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2025StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernietiging op zand- en lössgrond is alleen toegestaan tussen 1 februari en 10 mei.", + ) + }) + }) +}) diff --git a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts index f6e3ca91f..e38d2d1c4 100644 --- a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts +++ b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts @@ -352,11 +352,176 @@ function determineSubTypeOmschrijving( function calculateKorting( cultivations: NL2025NormsInputForCultivation[], region: RegionKey, + is_derogatie_bedrijf: boolean | undefined, + is_nv_area: boolean, ): { amount: Decimal; description: string } { const currentYear = 2025 const previousYear = currentYear - 1 const sandyOrLoessRegions: RegionKey[] = ["zand_nwc", "zand_zuid", "loess"] + const clayOrPeatRegions: RegionKey[] = ["klei", "veen"] + + // Sort cultivations by start date + const sortedCultivations = [...cultivations].sort((a, b) => { + if (!a.b_lu_start || !b.b_lu_start) return 0 + return a.b_lu_start.getTime() - b.b_lu_start.getTime() + }) + + // Find the transition from Grassland to Next Crop in 2025 + for (let i = 0; i < sortedCultivations.length - 1; i++) { + const prevCult = sortedCultivations[i] + const currCult = sortedCultivations[i + 1] + + if (!prevCult.b_lu_end || !currCult.b_lu_start) continue + + // Check if transition happens in 2025 + if (prevCult.b_lu_end.getFullYear() !== currentYear) continue + + const prevStandard = nitrogenStandardsData.find((ns) => + ns.b_lu_catalogue_match.includes(prevCult.b_lu_catalogue), + ) + const currStandard = nitrogenStandardsData.find((ns) => + ns.b_lu_catalogue_match.includes(currCult.b_lu_catalogue), + ) + + // 1. Grassland Renewal (Gras-na-Gras) -> 50 kg N/ha korting + if ( + (prevStandard?.type === "grasland" || + prevStandard?.type === "grasland_tijdelijk") && + (currStandard?.type === "grasland" || + currStandard?.type === "grasland_tijdelijk") + ) { + const renewalDate = prevCult.b_lu_end + let isValidRenewal = false + + if (sandyOrLoessRegions.includes(region)) { + // Sand/Loess: June 1 - August 31 + if ( + renewalDate >= new Date(currentYear, 5, 1) && // June 1 + renewalDate <= new Date(currentYear, 7, 31) // Aug 31 + ) { + isValidRenewal = true + } else { + throw new Error( + "Graslandvernieuwing op zand- en lössgrond is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + } + } else if (clayOrPeatRegions.includes(region)) { + if (is_derogatie_bedrijf) { + if (is_nv_area) { + // Derogation + NV: June 1 - August 31 + if ( + renewalDate >= new Date(currentYear, 5, 1) && + renewalDate <= new Date(currentYear, 7, 31) + ) { + isValidRenewal = true + } else { + throw new Error( + "Graslandvernieuwing op klei- en veengrond (derogatie + NV-gebied) is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + } + } else { + // Derogation + Non-NV: June 1 - September 15 + if ( + renewalDate >= new Date(currentYear, 5, 1) && + renewalDate <= new Date(currentYear, 8, 15) + ) { + isValidRenewal = true + } else { + throw new Error( + "Graslandvernieuwing op klei- en veengrond (derogatie + niet NV-gebied) is alleen toegestaan tussen 1 juni en 15 september.", + ) + } + } + } else { + // Non-Derogation: February 1 - September 15 + if ( + renewalDate >= new Date(currentYear, 1, 1) && + renewalDate <= new Date(currentYear, 8, 15) + ) { + isValidRenewal = true + } else { + throw new Error( + "Graslandvernieuwing op klei- en veengrond (geen derogatie) is alleen toegestaan tussen 1 februari en 15 september.", + ) + } + } + } + + if (isValidRenewal) { + return { + amount: new Decimal(50), + description: ". Korting: 50kg N/ha: graslandvernieuwing", + } + } + } + + // 2. Grassland Destruction (Gras-naar-Bouwland) -> 65 kg N/ha korting + // Applies if New Crop is Maize OR Consumption/Factory/Starch Potatoes (NOT Seed Potatoes) + const isMaize = currStandard?.cultivation_rvo_table2.includes("mais") + const isPotato = currStandard?.type === "aardappel" + const isSeedPotato = + currStandard?.cultivation_rvo_table2.includes("pootaardappelen") || + currCult.b_lu_catalogue === "nl_2015" || + currCult.b_lu_catalogue === "nl_2016" || + currStandard?.cultivation_rvo_table2.includes("uitgroeiteelt") + + if ( + (prevStandard?.type === "grasland" || + prevStandard?.type === "grasland_tijdelijk") && + (isMaize || (isPotato && !isSeedPotato)) + ) { + const destructionDate = prevCult.b_lu_end + let isValidDestruction = false + + if (sandyOrLoessRegions.includes(region)) { + // Sand/Loess: Feb 1 - May 10 + if ( + destructionDate >= new Date(currentYear, 1, 1) && // Feb 1 + destructionDate <= new Date(currentYear, 4, 10) // May 10 + ) { + isValidDestruction = true + } else { + throw new Error( + "Graslandvernietiging op zand- en lössgrond is alleen toegestaan tussen 1 februari en 10 mei.", + ) + } + } else if (clayOrPeatRegions.includes(region)) { + if (is_nv_area) { + // Clay/Peat NV: Feb 1 - Mar 15 + if ( + destructionDate >= new Date(currentYear, 1, 1) && + destructionDate <= new Date(currentYear, 2, 15) + ) { + isValidDestruction = true + } else { + throw new Error( + "Graslandvernietiging op klei- en veengrond (NV-gebied) is alleen toegestaan tussen 1 februari en 15 maart.", + ) + } + } else { + // Clay/Peat Non-NV: Feb 1 - May 31 + if ( + destructionDate >= new Date(currentYear, 1, 1) && + destructionDate <= new Date(currentYear, 4, 31) + ) { + isValidDestruction = true + } else { + throw new Error( + "Graslandvernietiging op klei- en veengrond (niet NV-gebied) is alleen toegestaan tussen 1 februari en 31 mei.", + ) + } + } + } + + if (isValidDestruction) { + return { + amount: new Decimal(65), + description: ". Korting: 65kg N/ha: graslandvernietiging", + } + } + } + } // Check if field is outside regions with korting if (!sandyOrLoessRegions.includes(region)) { @@ -657,7 +822,7 @@ export async function calculateNL2025StikstofGebruiksNorm( // Apply korting const { amount: kortingAmount, description: kortingDescription } = - calculateKorting(cultivations, region) + calculateKorting(cultivations, region, is_derogatie_bedrijf, is_nv_area) normValue = new Decimal(normValue).minus(kortingAmount) // If normvalue is negative, e.g. Geen plaatsingsruimte plus korting, set it to 0 diff --git a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts index c132e3b48..a6e322357 100644 --- a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts +++ b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts @@ -556,3 +556,219 @@ describe("calculateNL2026StikstofGebruiksNorm", () => { ) }) }) + +const sandCentroid: [number, number] = [5.656346970245633, 51.987872886419524] // zand_nwc +const clayCentroid: [number, number] = [5.64188724, 51.977587] // klei + +describe("calculateNL2026StikstofGebruiksNorm - Korting Logic", () => { + describe("Grassland Renewal (Gras-na-Gras) - 50 kg N/ha", () => { + it("should apply 50 discount on Sand (June 1 - Aug 31)", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 5, 15), // June 15 + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 5, 16), + b_lu_end: new Date(2026, 11, 31), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 50kg N/ha: graslandvernieuwing", + ) + }) + + it("should NOT apply discount on Clay", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: clayCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 5, 15), + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 5, 16), + b_lu_end: new Date(2026, 11, 31), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).not.toContain("graslandvernieuwing") + }) + + it("should throw error for invalid renewal date on Sand", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 4, 15), // May 15 (Too early) + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 4, 16), + b_lu_end: new Date(2026, 11, 31), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2026StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernieuwing op zand- en lössgrond is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + }) + }) + + describe("Grassland Destruction (Gras-naar-Bouwland) - 65 kg N/ha", () => { + it("should apply 65 discount on Sand (Maize, Feb 1 - May 10)", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 1, 15), // Feb 15 + }, + { + b_lu_catalogue: "nl_259", // Maize (Snijmais) + b_lu_start: new Date(2026, 1, 16), + b_lu_end: new Date(2026, 9, 1), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 65kg N/ha: graslandvernietiging", + ) + }) + + it("should NOT apply discount if previous crop was a Catch Crop (sown in Autumn)", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_266", // Tijdelijk Grass + b_lu_start: new Date(2025, 9, 1), // Oct 1, 2025 (Autumn) -> Catch Crop + b_lu_end: new Date(2026, 1, 15), // Feb 15 + }, + { + b_lu_catalogue: "nl_259", // Maize + b_lu_start: new Date(2026, 1, 16), + b_lu_end: new Date(2026, 9, 1), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).not.toContain("graslandvernietiging") + }) + + it("should NOT apply discount for Seed Potatoes", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 1, 15), // Feb 15 + }, + { + b_lu_catalogue: "nl_2015", // Seed Potato + b_lu_variety: "Adora", + b_lu_start: new Date(2026, 1, 16), + b_lu_end: new Date(2026, 9, 1), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).not.toContain("graslandvernietiging") + }) + + it("should throw error for invalid destruction date on Sand", async () => { + const mockInput: NL2026NormsInput = { + farm: { + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 5, 1), // June 1 (Too late) + }, + { + b_lu_catalogue: "nl_259", // Maize + b_lu_start: new Date(2026, 5, 2), + b_lu_end: new Date(2026, 9, 1), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2026StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernietiging op zand- en lössgrond is alleen toegestaan tussen 1 februari en 10 mei.", + ) + }) + }) +}) diff --git a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts index 9e4189e40..39f1d4602 100644 --- a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts +++ b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts @@ -138,6 +138,12 @@ function determineSubTypeOmschrijving( ?.omschrijving } + // Maize logic based on derogation status + if (standard.cultivation_rvo_table2 === "Akkerbouwgewassen, mais") { + // In 2026 derogation is no longer possible, so we always return "non-derogatie" + return "non-derogatie" + } + // Luzerne logic based on cultivation history if (standard.cultivation_rvo_table2 === "Akkerbouwgewassen, Luzerne") { const lucerneCultivationCodes = standard.b_lu_catalogue_match @@ -259,7 +265,7 @@ function calculateKorting( const sandyOrLoessRegions: RegionKey[] = ["zand_nwc", "zand_zuid", "loess"] - // Check if field is outside regions with korting + // Check if field is outside regions with korting (2026: ONLY Sand/Loess) if (!sandyOrLoessRegions.includes(region)) { return { amount: new Decimal(0), @@ -267,6 +273,99 @@ function calculateKorting( } } + // Sort cultivations by start date + const sortedCultivations = [...cultivations].sort((a, b) => { + if (!a.b_lu_start || !b.b_lu_start) return 0 + return a.b_lu_start.getTime() - b.b_lu_start.getTime() + }) + + // Find the transition from Grassland to Next Crop in 2026 + for (let i = 0; i < sortedCultivations.length - 1; i++) { + const prevCult = sortedCultivations[i] + const currCult = sortedCultivations[i + 1] + + if (!prevCult.b_lu_end || !currCult.b_lu_start) continue + + // Check if transition happens in 2026 + if (prevCult.b_lu_end.getFullYear() !== currentYear) continue + + const prevStandard = nitrogenStandardsData.find((ns) => + ns.b_lu_catalogue_match.includes(prevCult.b_lu_catalogue), + ) + const currStandard = nitrogenStandardsData.find((ns) => + ns.b_lu_catalogue_match.includes(currCult.b_lu_catalogue), + ) + + const isPrevGrass = + prevStandard?.type === "grasland" || + prevStandard?.type === "grasland_tijdelijk" + const isCurrGrass = + currStandard?.type === "grasland" || + currStandard?.type === "grasland_tijdelijk" + + // 1. Grassland Renewal (Gras-na-Gras) -> 50 kg N/ha korting + // Only applies on Sand/Loess (already checked above) + if (isPrevGrass && isCurrGrass) { + const renewalDate = prevCult.b_lu_end + // Sand/Loess: June 1 - August 31 + if ( + renewalDate >= new Date(currentYear, 5, 1) && // June 1 + renewalDate <= new Date(currentYear, 7, 31) // Aug 31 + ) { + return { + amount: new Decimal(50), + description: ". Korting: 50kg N/ha: graslandvernieuwing", + } + } + // Error if date is invalid for renewal + throw new Error( + "Graslandvernieuwing op zand- en lössgrond is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + } + + // 2. Grassland Destruction (Gras-naar-Bouwland) -> 65 kg N/ha korting + const isMaize = currStandard?.cultivation_rvo_table2.includes("mais") + const isPotato = currStandard?.type === "aardappel" + const isSeedPotato = + currStandard?.cultivation_rvo_table2.includes("pootaardappelen") || + currCult.b_lu_catalogue === "nl_2015" || + currCult.b_lu_catalogue === "nl_2016" || + currStandard?.cultivation_rvo_table2.includes("uitgroeiteelt") + + if (isPrevGrass && (isMaize || (isPotato && !isSeedPotato))) { + // Check Exclusion: Was previous grass a Catch Crop? + // "Infer from sowing date (late previous year)" + // If sown in autumn of previous year (e.g. >= Aug 1), it's a catch crop. + // Also check is_vanggewas property if true. + const isCatchCrop = + prevStandard?.is_vanggewas || + (prevCult.b_lu_start && + prevCult.b_lu_start.getFullYear() === previousYear && + prevCult.b_lu_start.getMonth() >= 7) // August or later + + if (isCatchCrop) { + // No korting allowed if it was a catch crop + continue + } + + const destructionDate = prevCult.b_lu_end + // Sand/Loess: Feb 1 - May 10 + if ( + destructionDate >= new Date(currentYear, 1, 1) && // Feb 1 + destructionDate <= new Date(currentYear, 4, 10) // May 10 + ) { + return { + amount: new Decimal(65), + description: ". Korting: 65kg N/ha: graslandvernietiging", + } + } + // Error if date is invalid for destruction + throw new Error( + "Graslandvernietiging op zand- en lössgrond is alleen toegestaan tussen 1 februari en 10 mei.", + ) + } + } + // Determine hoofdteelt for the current year (2026) const hoofdteelt2026 = determineNLHoofdteelt( cultivations.filter( From 36757922dd4e71d4767defd906d2a986757d09f5 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:09:04 +0100 Subject: [PATCH 2/6] docs: Updated 2025 nitrogen usage norm documentation to include grassland renewal and destruction reduction rules. --- .changeset/nitrogen-korting-docs.md | 5 +++ .../nl/2025/stikstofgebruiksnorm.md | 34 ++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/nitrogen-korting-docs.md diff --git a/.changeset/nitrogen-korting-docs.md b/.changeset/nitrogen-korting-docs.md new file mode 100644 index 000000000..b3c848a81 --- /dev/null +++ b/.changeset/nitrogen-korting-docs.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-docs": patch +--- + +Updated 2025 nitrogen usage norm documentation to include grassland renewal and destruction reduction rules. diff --git a/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md b/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md index bf71c4c3b..229f629f6 100644 --- a/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md +++ b/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md @@ -57,13 +57,39 @@ If there is no winter crop, a **catch crop (`vanggewas`)** must be sown. The sow * **10 kg N/ha Reduction**: Sown between **October 15th and October 31st**. * **20 kg N/ha Reduction**: No valid catch crop, sown on or after **November 1st**, or destroyed before **February 1st**. +--- + +## Grassland Renewal and Destruction Reductions + +In 2025, specific nitrogen usage norm reductions (`kortingen`) apply when grassland is renewed or destroyed (scheuren). These reductions account for the nitrogen released during the decomposition of the sod. + +### 1. Grassland Renewal (Gras-na-Gras) + +When grassland is directly followed by new grassland, a reduction of **50 kg N/ha** applies. This is only allowed within specific periods: + +* **Sand and Loess Soils**: June 1st – August 31st. +* **Clay and Peat Soils**: + * **Derogation Farm + NV-Area**: June 1st – August 31st. + * **Derogation Farm + Non-NV-Area**: June 1st – September 15th. + * **Non-Derogation Farm**: February 1st – September 15th. + +### 2. Grassland Destruction (Gras-naar-Bouwland) + +When grassland is replaced by Maize or specific Potato types, a reduction of **65 kg N/ha** applies. + +* **Eligible Crops**: Maize, Consumption Potatoes, and Factory Potatoes. +* **Excluded Crops**: **Seed Potatoes (`Pootaardappelen`)** do not trigger this reduction. +* **Allowed Periods**: + * **Sand and Loess Soils**: February 1st – May 10th. + * **Clay and Peat Soils**: + * **NV-Area**: February 1st – March 15th. + * **Non-NV-Area**: February 1st – May 31st. + ### How the FDM Calculator Implements These Rules -This logic is part of the `calculateNitrogenUsageNorm` function: +The `fdm-calculator` automatically detects grassland renewal and destruction events by analyzing the sequence of cultivations. It verifies the soil type, location (NV-gebied), and farm derogation status to apply the correct reduction. -1. **Check for Winter Crop**: The calculator checks if the current year's main crop is a winter crop. -2. **Check for Catch Crop**: If not, it checks the previous year's cultivation data for a catch crop. -3. **Apply Reduction**: The sowing date determines the reduction, which is applied to the current year's norm. +If a renewal or destruction action is performed **outside** the legally allowed periods, the calculator will provide a descriptive error message to ensure compliance. --- From 8ade88ec712d5a4fa4ad9a3d04b0e1ae04a3c328 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:32:24 +0100 Subject: [PATCH 3/6] docs: improve formatting --- .../nl/2025/stikstofgebruiksnorm.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md b/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md index 229f629f6..5bde7dcfb 100644 --- a/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md +++ b/fdm-docs/docs/insights/fertilizer-application-norms/nl/2025/stikstofgebruiksnorm.md @@ -69,9 +69,9 @@ When grassland is directly followed by new grassland, a reduction of **50 kg N/h * **Sand and Loess Soils**: June 1st – August 31st. * **Clay and Peat Soils**: - * **Derogation Farm + NV-Area**: June 1st – August 31st. - * **Derogation Farm + Non-NV-Area**: June 1st – September 15th. - * **Non-Derogation Farm**: February 1st – September 15th. + * **Derogation Farm + NV-Area**: June 1st – August 31st. + * **Derogation Farm + Non-NV-Area**: June 1st – September 15th. + * **Non-Derogation Farm**: February 1st – September 15th. ### 2. Grassland Destruction (Gras-naar-Bouwland) @@ -80,10 +80,10 @@ When grassland is replaced by Maize or specific Potato types, a reduction of **6 * **Eligible Crops**: Maize, Consumption Potatoes, and Factory Potatoes. * **Excluded Crops**: **Seed Potatoes (`Pootaardappelen`)** do not trigger this reduction. * **Allowed Periods**: - * **Sand and Loess Soils**: February 1st – May 10th. - * **Clay and Peat Soils**: - * **NV-Area**: February 1st – March 15th. - * **Non-NV-Area**: February 1st – May 31st. + * **Sand and Loess Soils**: February 1st – May 10th. + * **Clay and Peat Soils**: + * **NV-Area**: February 1st – March 15th. + * **Non-NV-Area**: February 1st – May 31st. ### How the FDM Calculator Implements These Rules From 9e22db72a684c2be7b9c9ee419d75eb7473fe23f Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:33:05 +0100 Subject: [PATCH 4/6] test: add test case for potatoes --- .../2026/value/stikstofgebruiksnorm.test.ts | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts index 19e6f914b..a659a5e37 100644 --- a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts +++ b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.test.ts @@ -94,9 +94,7 @@ describe("calculateNL2026StikstofGebruiksNorm", () => { // The base norm for Grasland in zand_nwc is 200 in nv-gebied. expect(result.normValue).toBe(200) - expect(result.normSource).toEqual( - "Grasland (beweiden).", - ) + expect(result.normSource).toEqual("Grasland (beweiden).") }) it("should apply 0 korting if Tijdelijk grasland is present in zand_nwc region", async () => { @@ -604,8 +602,8 @@ describe("calculateNL2026StikstofGebruiksNorm", () => { }) it("should select the correct norm for a period ending in May (tot minstens 15 mei)", async () => { - // Matches "van 1 jan tot minstens 15 mei" -> 110 (Klei) - // Should NOT match "tot minstens 15 augustus" + // Matches "van 1 jan tot minstens 15 mei" -> 110 (Klei) + // Should NOT match "tot minstens 15 augustus" const mockInput: NL2026NormsInput = { farm: { has_grazing_intention: false }, field: { b_id: "1", b_centroid: kleiCentroid } as Field, @@ -623,7 +621,7 @@ describe("calculateNL2026StikstofGebruiksNorm", () => { expect(result.normValue).toBe(110) // Klei standard for "van 1 jan tot minstens 15 mei" }) - it("should select the correct norm for a late sown crop (vanaf 15 oktober)", async () => { + it("should select the correct norm for a late sown crop (vanaf 15 oktober)", async () => { // Matches "vanaf 15 oktober" -> 0 (Klei) const mockInput: NL2026NormsInput = { farm: { has_grazing_intention: false }, @@ -641,11 +639,11 @@ describe("calculateNL2026StikstofGebruiksNorm", () => { const result = await calculateNL2026StikstofGebruiksNorm(mockInput) expect(result.normValue).toBe(0) // Klei standard for "vanaf 15 oktober" }) - + it("should handle start dates from previous year correctly (van 1 januari)", async () => { - // Started in 2025, still present in 2026 until Aug 20. - // Matches "van 1 jan tot minstens 15 aug" -> 250 (Klei) - const mockInput: NL2026NormsInput = { + // Started in 2025, still present in 2026 until Aug 20. + // Matches "van 1 jan tot minstens 15 aug" -> 250 (Klei) + const mockInput: NL2026NormsInput = { farm: { has_grazing_intention: false }, field: { b_id: "1", b_centroid: kleiCentroid } as Field, cultivations: [ @@ -896,5 +894,34 @@ describe("calculateNL2026StikstofGebruiksNorm - Korting Logic", () => { "Graslandvernietiging op zand- en lössgrond is alleen toegestaan tussen 1 februari en 10 mei.", ) }) + + it("should apply 65 discount on Sand (Consumption Potato, Feb 1 - May 10)", async () => { + const mockInput: NL2026NormsInput = { + farm: { has_grazing_intention: false }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2026, 0, 1), + b_lu_end: new Date(2026, 2, 15), // March 15 + }, + { + b_lu_catalogue: "nl_2014", // Consumption Potato + b_lu_variety: "Agria", + b_lu_start: new Date(2026, 2, 16), + b_lu_end: new Date(2026, 9, 1), + }, + ] as NL2026NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2026StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 65kg N/ha: graslandvernietiging", + ) + }) }) }) From 70207e97cca7c22dc21d82d239536e86caf3826a Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:06:43 +0100 Subject: [PATCH 5/6] tests: increase coverage for calculateKorting --- .../2025/value/stikstofgebruiksnorm.test.ts | 285 +++++++++++++++++- 1 file changed, 284 insertions(+), 1 deletion(-) diff --git a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts index 7f8e4a38b..f9332cb23 100644 --- a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts +++ b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.test.ts @@ -1,5 +1,6 @@ import type { Field } from "@svenvw/fdm-core" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi, afterEach } from "vitest" +import * as GeoTiff from "../../../../shared/geotiff" import { calculateNL2025StikstofGebruiksNorm, getRegion, @@ -7,6 +8,14 @@ import { } from "./stikstofgebruiksnorm" import type { NL2025NormsInput, NL2025NormsInputForCultivation } from "./types" +vi.mock("../../../../shared/geotiff", async (importActual) => { + const actual = await importActual() + return { + ...actual, + getGeoTiffValue: vi.fn(actual.getGeoTiffValue), + } +}) + describe("stikstofgebruiksnorm helpers", () => { it("should correctly identify a field in an NV Gebied", async () => { const centroidInNV: [number, number] = [5.654709, 51.987605] @@ -959,3 +968,277 @@ describe("calculateNL2025StikstofGebruiksNorm - Korting Logic", () => { }) }) }) + +describe("calculateNL2025StikstofGebruiksNorm - Additional Korting Edge Cases", () => { + afterEach(() => { + vi.mocked(GeoTiff.getGeoTiffValue).mockClear() + }) + + // Helper to mock Region and NV status + const setupMock = (regionCode: number, nvCode: number) => { + vi.mocked(GeoTiff.getGeoTiffValue).mockImplementation( + async (url: string) => { + if (url.includes("grondsoorten")) return regionCode // 1=Klei, 4=Zand + if (url.includes("nv.tiff")) return nvCode // 1=NV, 0=Non-NV + return 0 + }, + ) + } + + const sandCentroid: [number, number] = [ + 5.656346970245633, 51.987872886419524, + ] + + it("should apply 20 korting if vanggewas is removed before Feb 1st", async () => { + setupMock(4, 0) // Sand, Non-NV + const mockInput: NL2025NormsInput = { + farm: { is_derogatie_bedrijf: false, has_grazing_intention: false }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_2751", // Vruchtgewassen + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 5, 1), + } as Partial, + { + b_lu_catalogue: "nl_428", // Gele mosterd (vanggewas) + b_lu_start: new Date(2024, 9, 1), // Oct 1 + b_lu_end: new Date(2025, 0, 15), // Jan 15 (Removed before Feb 1) + } as Partial, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 20kg N/ha: vanggewas staat niet tot 1 februari", + ) + }) + + it("should apply 20 korting if vanggewas is sown too early (before July 15)", async () => { + setupMock(4, 0) // Sand, Non-NV + const mockInput: NL2025NormsInput = { + farm: { is_derogatie_bedrijf: false, has_grazing_intention: false }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_2751", // Vruchtgewassen + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 5, 1), + } as Partial, + { + b_lu_catalogue: "nl_428", // Gele mosterd + b_lu_start: new Date(2024, 6, 10), // July 10 (Too early) + b_lu_end: new Date(2025, 1, 15), + } as Partial, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 20kg N/ha: geen vanggewas of winterteelt", + ) + }) + + it("should apply 50 discount for Graslandvernieuwing on Clay (No Derogation) - Valid Date (Feb 10)", async () => { + setupMock(1, 0) // Clay, Non-NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 1, 10), // Feb 10 + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 1, 11), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 50kg N/ha: graslandvernieuwing", + ) + }) + + it("should throw error for Graslandvernieuwing on Clay (No Derogation) - Invalid Date (Jan 20)", async () => { + setupMock(1, 0) // Clay, Non-NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 0, 20), // Jan 20 (Too early, starts Feb 1) + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 21), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2025StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernieuwing op klei- en veengrond (geen derogatie) is alleen toegestaan tussen 1 februari en 15 september.", + ) + }) + + it("should apply 50 discount for Graslandvernieuwing on Clay (Derogation + NV) - Valid Date (Aug 15)", async () => { + setupMock(1, 1) // Clay, NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: true, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 7, 15), // Aug 15 + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 7, 16), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 50kg N/ha: graslandvernieuwing", + ) + }) + + it("should throw error for Graslandvernieuwing on Clay (Derogation + NV) - Invalid Date (Sep 10)", async () => { + setupMock(1, 1) // Clay, NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: true, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 8, 10), // Sep 10 (Too late, ends Aug 31) + }, + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 8, 11), + b_lu_end: new Date(2025, 11, 31), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2025StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernieuwing op klei- en veengrond (derogatie + NV-gebied) is alleen toegestaan tussen 1 juni en 31 augustus.", + ) + }) + + it("should apply 65 discount for Graslandvernietiging on Clay (NV) - Valid Date (Mar 10)", async () => { + setupMock(1, 1) // Clay, NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 2, 10), // Mar 10 + }, + { + b_lu_catalogue: "nl_259", // Maize (as example of relevant crop) + b_lu_start: new Date(2025, 2, 11), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + const result = await calculateNL2025StikstofGebruiksNorm(mockInput) + expect(result.normSource).toContain( + "Korting: 65kg N/ha: graslandvernietiging", + ) + }) + + it("should throw error for Graslandvernietiging on Clay (NV) - Invalid Date (Mar 20)", async () => { + setupMock(1, 1) // Clay, NV + const mockInput: NL2025NormsInput = { + farm: { + is_derogatie_bedrijf: false, + has_grazing_intention: false, + }, + field: { + b_id: "1", + b_centroid: sandCentroid, + } as Field, + cultivations: [ + { + b_lu_catalogue: "nl_265", // Grass + b_lu_start: new Date(2025, 0, 1), + b_lu_end: new Date(2025, 2, 20), // Mar 20 (Too late, ends Mar 15) + }, + { + b_lu_catalogue: "nl_259", // Maize + b_lu_start: new Date(2025, 2, 21), + b_lu_end: new Date(2025, 9, 1), + }, + ] as NL2025NormsInputForCultivation[], + soilAnalysis: { a_p_al: 20, a_p_cc: 0.9 }, + } + + await expect( + calculateNL2025StikstofGebruiksNorm(mockInput), + ).rejects.toThrow( + "Graslandvernietiging op klei- en veengrond (NV-gebied) is alleen toegestaan tussen 1 februari en 15 maart.", + ) + }) +}) From 9bf92653664e2f6ef5b119d497dc2be3ceeebd11 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:35:54 +0100 Subject: [PATCH 6/6] refacotr: use consisten grassland checking --- .../src/norms/nl/2025/value/stikstofgebruiksnorm.ts | 12 +++--------- .../src/norms/nl/2026/value/stikstofgebruiksnorm.ts | 8 ++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts index fc82e60b5..ee12f7ce1 100644 --- a/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts +++ b/fdm-calculator/src/norms/nl/2025/value/stikstofgebruiksnorm.ts @@ -468,19 +468,14 @@ function calculateKorting( // Check if transition happens in 2025 if (prevCult.b_lu_end.getFullYear() !== currentYear) continue - const prevStandard = nitrogenStandardsData.find((ns) => - ns.b_lu_catalogue_match.includes(prevCult.b_lu_catalogue), - ) const currStandard = nitrogenStandardsData.find((ns) => ns.b_lu_catalogue_match.includes(currCult.b_lu_catalogue), ) // 1. Grassland Renewal (Gras-na-Gras) -> 50 kg N/ha korting if ( - (prevStandard?.type === "grasland" || - prevStandard?.type === "grasland_tijdelijk") && - (currStandard?.type === "grasland" || - currStandard?.type === "grasland_tijdelijk") + nonBouwlandCodes.includes(prevCult.b_lu_catalogue) && + nonBouwlandCodes.includes(currCult.b_lu_catalogue) ) { const renewalDate = prevCult.b_lu_end let isValidRenewal = false @@ -558,8 +553,7 @@ function calculateKorting( currStandard?.cultivation_rvo_table2.includes("uitgroeiteelt") if ( - (prevStandard?.type === "grasland" || - prevStandard?.type === "grasland_tijdelijk") && + nonBouwlandCodes.includes(prevCult.b_lu_catalogue) && (isMaize || (isPotato && !isSeedPotato)) ) { const destructionDate = prevCult.b_lu_end diff --git a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts index 1b44a8e84..27e420cf3 100644 --- a/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts +++ b/fdm-calculator/src/norms/nl/2026/value/stikstofgebruiksnorm.ts @@ -387,12 +387,8 @@ function calculateKorting( ns.b_lu_catalogue_match.includes(currCult.b_lu_catalogue), ) - const isPrevGrass = - prevStandard?.type === "grasland" || - prevStandard?.type === "grasland_tijdelijk" - const isCurrGrass = - currStandard?.type === "grasland" || - currStandard?.type === "grasland_tijdelijk" + const isPrevGrass = nonBouwlandCodes.includes(prevCult.b_lu_catalogue) + const isCurrGrass = nonBouwlandCodes.includes(currCult.b_lu_catalogue) // 1. Grassland Renewal (Gras-na-Gras) -> 50 kg N/ha korting // Only applies on Sand/Loess (already checked above)