From c09b5bf87af13c2b9cb6f1200c7e293492a12a8c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 7 May 2026 11:27:06 +0200 Subject: [PATCH 1/7] feat: Add BLN3 score calculation module. Exports `requestBln3Score` (raw NMI API call to `POST /maatwerk/bln3/score/field`), `getBln3Score` (cached wrapper via `withCalculationCache`), and `collectInputForBln3Score` (assembles field inputs from fdm-core: lat/lon from field centroid, soil analysis parameters, cultivations mapped from BRP catalogue codes, and adopted BLN measures). Types exported: `Bln3Score`, `Bln3ScoreInputs`, `Bln3ScoreCollectedInputs`, `Bln3IndicatorResult`, `Bln3AggregationResult`. --- .changeset/loud-mirrors-kneel.md | 5 + fdm-calculator/src/bln3/collect.test.ts | 454 ++++++++++++++++++++++++ fdm-calculator/src/bln3/collect.ts | 106 ++++++ fdm-calculator/src/bln3/index.test.ts | 134 +++++++ fdm-calculator/src/bln3/index.ts | 69 ++++ fdm-calculator/src/bln3/types.d.ts | 161 +++++++++ fdm-calculator/src/index.ts | 12 + 7 files changed, 941 insertions(+) create mode 100644 .changeset/loud-mirrors-kneel.md create mode 100644 fdm-calculator/src/bln3/collect.test.ts create mode 100644 fdm-calculator/src/bln3/collect.ts create mode 100644 fdm-calculator/src/bln3/index.test.ts create mode 100644 fdm-calculator/src/bln3/index.ts create mode 100644 fdm-calculator/src/bln3/types.d.ts diff --git a/.changeset/loud-mirrors-kneel.md b/.changeset/loud-mirrors-kneel.md new file mode 100644 index 000000000..7ad4256ac --- /dev/null +++ b/.changeset/loud-mirrors-kneel.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-calculator": minor +--- + +Add BLN3 score calculation module. Exports `requestBln3Score` (raw NMI API call to `POST /maatwerk/bln3/score/field`), `getBln3Score` (cached wrapper via `withCalculationCache`), and `collectInputForBln3Score` (assembles field inputs from fdm-core: lat/lon from field centroid, soil analysis parameters, cultivations mapped from BRP catalogue codes, and adopted BLN measures). Types exported: `Bln3Score`, `Bln3ScoreInputs`, `Bln3ScoreCollectedInputs`, `Bln3IndicatorResult`, `Bln3AggregationResult`. diff --git a/fdm-calculator/src/bln3/collect.test.ts b/fdm-calculator/src/bln3/collect.test.ts new file mode 100644 index 000000000..72f7d055a --- /dev/null +++ b/fdm-calculator/src/bln3/collect.test.ts @@ -0,0 +1,454 @@ +import type { + Cultivation, + FdmType, + Field, + Measure, + PrincipalId, + SoilAnalysis, + Timeframe, +} from "@nmi-agro/fdm-core" +import { + getCultivations, + getField, + getMeasures, + getSoilAnalyses, +} from "@nmi-agro/fdm-core" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { collectInputForBln3Score } from "./collect" + +vi.mock("@nmi-agro/fdm-core", async () => { + const actual = await vi.importActual("@nmi-agro/fdm-core") + return { + ...actual, + getField: vi.fn(), + getSoilAnalyses: vi.fn(), + getCultivations: vi.fn(), + getMeasures: vi.fn(), + } +}) + +const mockedGetField = vi.mocked(getField) +const mockedGetSoilAnalyses = vi.mocked(getSoilAnalyses) +const mockedGetCultivations = vi.mocked(getCultivations) +const mockedGetMeasures = vi.mocked(getMeasures) + +// Minimal FdmType mock — collect functions don't use transactions +const mockFdm = {} as FdmType +const principal_id: PrincipalId = "test-principal" +const b_id = "field-1" +const timeframe: Timeframe = { + start: new Date("2024-01-01"), + end: new Date("2024-12-31"), +} + +// Base field: centroid [lon, lat] = [5.2, 51.6] +const mockField: Field = { + b_id, + b_name: "Test field", + b_id_farm: "farm-1", + b_id_source: null, + b_geometry: null, + b_centroid: [5.2, 51.6], + b_area: 10, + b_perimeter: 400, + b_start: new Date("2020-01-01"), + b_end: null, + b_acquiring_method: "unknown", + b_bufferstrip: false, +} + +// Soil analysis with assorted a_* fields and metadata +const mockSoilAnalysis: SoilAnalysis = { + a_id: "sa-1", + a_date: new Date("2023-06-01"), + a_source: "lab", + b_soiltype_agr: "dekzand", + b_gwl_class: "IIb", + a_som_loi: 4.5, + a_clay_mi: 10, + a_p_cc: 1.2, + a_p_al: 42, + a_n_rt: 2500, + // Non-numeric fields that must NOT appear in the API payload + a_depth_upper: 0, + a_depth_lower: 30, +} as unknown as SoilAnalysis + +// Cultivation with a valid nl_ BRP catalogue code +const mockCultivation: Cultivation = { + b_lu: "cult-1", + b_lu_catalogue: "nl_266", + b_lu_start: new Date("2024-03-15"), + b_lu_end: new Date("2024-09-01"), + b_lu_source: "nl", + b_lu_name: "Maize", + b_lu_name_en: "Maize", + b_lu_hcat3: "hcat3", + b_lu_hcat3_name: "Hcat3", + b_lu_croprotation: "maize", + b_lu_eom: 1, + b_lu_eom_residue: 1, + b_lu_harvestcat: "HC010", + b_lu_harvestable: "once", + b_lu_variety: null, + m_cropresidue: false, + b_id, +} as unknown as Cultivation + +// BLN measure +const mockMeasure: Measure = { + b_id_measure: "meas-1", + m_id: "bln_BM3", + b_id, + m_start: new Date("2024-01-01"), + m_end: null, + m_name: "Niet-kerende grondbewerking", + m_summary: null, + m_conflicts: null, +} + +describe("collectInputForBln3Score", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should return lat/lon from field centroid", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + // b_centroid = [lon, lat] + expect(result.a_lon).toBe(5.2) + expect(result.a_lat).toBe(51.6) + }) + + it("should map soil analysis: b_soiltype_agr and b_gwl_class", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([mockSoilAnalysis]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.b_soiltype_agr).toBe("dekzand") + expect(result.b_gwl_class).toBe("IIb") + }) + + it("should include numeric a_* fields from the most recent soil analysis", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([mockSoilAnalysis]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.a_som_loi).toBe(4.5) + expect(result.a_clay_mi).toBe(10) + expect(result.a_p_cc).toBe(1.2) + expect(result.a_n_rt).toBe(2500) + }) + + it("should exclude non-numeric fields from soil analysis (metadata and integers used for depth)", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([mockSoilAnalysis]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result).not.toHaveProperty("a_id") + expect(result).not.toHaveProperty("a_date") + expect(result).not.toHaveProperty("a_source") + }) + + it("should use the first (most recent) soil analysis when multiple exist", async () => { + const olderAnalysis: SoilAnalysis = { + ...mockSoilAnalysis, + a_id: "sa-old", + b_soiltype_agr: "veen", + a_som_loi: 99, + } + mockedGetField.mockResolvedValue(mockField) + // getSoilAnalyses returns most-recent first (DESC by sampling date) + mockedGetSoilAnalyses.mockResolvedValue([ + mockSoilAnalysis, + olderAnalysis, + ]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.b_soiltype_agr).toBe("dekzand") // from first (mockSoilAnalysis) + expect(result.a_som_loi).toBe(4.5) + }) + + it("should omit b_soiltype_agr and b_gwl_class when no soil analyses exist", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.b_soiltype_agr).toBeUndefined() + expect(result.b_gwl_class).toBeUndefined() + }) + + it("should map a cultivation with nl_ catalogue to { b_lu_brp, b_lu_year }", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([mockCultivation]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.cultivations).toEqual([ + { b_lu_brp: 266, b_lu_year: 2024 }, + ]) + }) + + it("should skip cultivations with non-nl_ catalogue codes", async () => { + const foreign: Cultivation = { + ...mockCultivation, + b_lu_catalogue: "de_12345", + } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([foreign]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.cultivations).toBeUndefined() + }) + + it("should skip cultivations where b_lu_start is null and no timeframe is provided", async () => { + const noStart: Cultivation = { ...mockCultivation, b_lu_start: null } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([noStart]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.cultivations).toBeUndefined() + }) + + it("should use timeframe.end year as fallback when cultivation b_lu_start is null", async () => { + const noStart: Cultivation = { ...mockCultivation, b_lu_start: null } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([noStart]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + timeframe, + ) + + expect(result.cultivations).toEqual([ + { b_lu_brp: 266, b_lu_year: 2024 }, + ]) + }) + + it("should omit cultivations key entirely when no valid cultivations exist", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result).not.toHaveProperty("cultivations") + }) + + it("should map a bln_ measure to { measure_id, year }", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([mockMeasure]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.measures).toEqual([{ measure_id: "BM3", year: 2024 }]) + }) + + it("should skip measures without the bln_ prefix", async () => { + const nonBln: Measure = { ...mockMeasure, m_id: "other_BM3" } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([nonBln]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.measures).toBeUndefined() + }) + + it("should skip measures where m_start is null and no timeframe is provided", async () => { + const noStart: Measure = { ...mockMeasure, m_start: null } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([noStart]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result.measures).toBeUndefined() + }) + + it("should use timeframe.end year as fallback when measure m_start is null", async () => { + const noStart: Measure = { ...mockMeasure, m_start: null } + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([noStart]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + timeframe, + ) + + expect(result.measures).toEqual([{ measure_id: "BM3", year: 2024 }]) + }) + + it("should omit measures key entirely when no valid measures exist", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + ) + + expect(result).not.toHaveProperty("measures") + }) + + it("should combine all data sources in a full happy-path call", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([mockSoilAnalysis]) + mockedGetCultivations.mockResolvedValue([mockCultivation]) + mockedGetMeasures.mockResolvedValue([mockMeasure]) + + const result = await collectInputForBln3Score( + mockFdm, + principal_id, + b_id, + timeframe, + ) + + expect(result.a_lat).toBe(51.6) + expect(result.a_lon).toBe(5.2) + expect(result.b_soiltype_agr).toBe("dekzand") + expect(result.b_gwl_class).toBe("IIb") + expect(result.a_som_loi).toBe(4.5) + expect(result.cultivations).toEqual([ + { b_lu_brp: 266, b_lu_year: 2024 }, + ]) + expect(result.measures).toEqual([{ measure_id: "BM3", year: 2024 }]) + }) + + it("should wrap errors from fdm-core with a descriptive message", async () => { + mockedGetField.mockRejectedValue(new Error("DB connection lost")) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + await expect( + collectInputForBln3Score(mockFdm, principal_id, b_id), + ).rejects.toThrow( + `Failed to collect BLN3 score inputs for field ${b_id}`, + ) + }) + + it("should pass the timeframe to all fdm-core calls", async () => { + mockedGetField.mockResolvedValue(mockField) + mockedGetSoilAnalyses.mockResolvedValue([]) + mockedGetCultivations.mockResolvedValue([]) + mockedGetMeasures.mockResolvedValue([]) + + await collectInputForBln3Score(mockFdm, principal_id, b_id, timeframe) + + expect(mockedGetSoilAnalyses).toHaveBeenCalledWith( + mockFdm, + principal_id, + b_id, + timeframe, + ) + expect(mockedGetCultivations).toHaveBeenCalledWith( + mockFdm, + principal_id, + b_id, + timeframe, + ) + expect(mockedGetMeasures).toHaveBeenCalledWith( + mockFdm, + principal_id, + b_id, + timeframe, + ) + }) +}) diff --git a/fdm-calculator/src/bln3/collect.ts b/fdm-calculator/src/bln3/collect.ts new file mode 100644 index 000000000..daa6e7310 --- /dev/null +++ b/fdm-calculator/src/bln3/collect.ts @@ -0,0 +1,106 @@ +import { + getCultivations, + getField, + getMeasures, + getSoilAnalyses, +} from "@nmi-agro/fdm-core" +import type { + FdmType, + fdmSchema, + PrincipalId, + Timeframe, +} from "@nmi-agro/fdm-core" +import type { + Bln3Cultivation, + Bln3Measure, + Bln3ScoreCollectedInputs, +} from "./types" + +/** + * Collects all field data needed for a BLN3 score calculation from the FDM database. + * + * Fetches field geometry (lat/lon), the most recent soil analysis, cultivations, and + * adopted BLN measures for the field, then maps them to the shape expected by the + * NMI BLN3 API. The caller is responsible for adding `nmiApiKey` before calling + * `getBln3Score`. + * + * @param fdm - The FDM instance for database interaction. + * @param principal_id - The principal making the request. + * @param b_id - The field ID for which to collect inputs. + * @param timeframe - Optional timeframe to filter cultivations and measures. + * @returns A promise resolving to the collected BLN3 score inputs (without `nmiApiKey`). + * @throws {Error} If data collection fails. + */ +export async function collectInputForBln3Score( + fdm: FdmType, + principal_id: PrincipalId, + b_id: fdmSchema.fieldsTypeSelect["b_id"], + timeframe?: Timeframe, +): Promise { + try { + const [field, soilAnalyses, cultivations, measures] = await Promise.all( + [ + getField(fdm, principal_id, b_id), + getSoilAnalyses(fdm, principal_id, b_id, timeframe), + getCultivations(fdm, principal_id, b_id, timeframe), + getMeasures(fdm, principal_id, b_id, timeframe), + ], + ) + + // b_centroid = [lon, lat] (ST_X = longitude, ST_Y = latitude) + const [a_lon, a_lat] = field.b_centroid + + // Pick non-null numeric a_* fields from the most recent soil analysis + const latestAnalysis = soilAnalyses[0] + const soilData: Record = {} + if (latestAnalysis) { + for (const [key, value] of Object.entries(latestAnalysis)) { + if (key.startsWith("a_") && typeof value === "number") { + soilData[key] = value + } + } + } + + // Map cultivations: "nl_266" → { b_lu_brp: 266, b_lu_year: 2025 } + const fallbackYear = timeframe?.end?.getFullYear() + const bln3Cultivations: Bln3Cultivation[] = cultivations + .map((c) => { + const match = /^nl_(\d+)$/.exec(c.b_lu_catalogue) + if (!match) return null + const year = c.b_lu_start?.getFullYear() ?? fallbackYear + if (year === undefined) return null + return { b_lu_brp: Number(match[1]), b_lu_year: year } + }) + .filter((c): c is Bln3Cultivation => c !== null) + + // Map measures: "bln_BM3" → { measure_id: "BM3", year: 2025 } + const bln3Measures: Bln3Measure[] = measures + .filter((m) => m.m_id.startsWith("bln_")) + .map((m) => { + const year = m.m_start?.getFullYear() ?? fallbackYear + if (year === undefined) return null + return { + measure_id: m.m_id.replace(/^bln_/, ""), + year, + } + }) + .filter((m): m is Bln3Measure => m !== null) + + return { + a_lat, + a_lon, + b_soiltype_agr: latestAnalysis?.b_soiltype_agr ?? undefined, + b_gwl_class: latestAnalysis?.b_gwl_class ?? undefined, + ...(bln3Cultivations.length > 0 && { + cultivations: bln3Cultivations, + }), + ...(bln3Measures.length > 0 && { measures: bln3Measures }), + ...soilData, + } + } catch (error) { + throw new Error( + `Failed to collect BLN3 score inputs for field ${b_id}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } +} diff --git a/fdm-calculator/src/bln3/index.test.ts b/fdm-calculator/src/bln3/index.test.ts new file mode 100644 index 000000000..888b481c9 --- /dev/null +++ b/fdm-calculator/src/bln3/index.test.ts @@ -0,0 +1,134 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest" +import { getBln3Score, requestBln3Score } from "./index" +import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types" + +const mockBln3ScoreResponse: Bln3ScoreResponse = { + request_id: "test-uuid", + success: true, + status: 200, + message: null, + data: { + indicator: [ + { + indicator_id: "C_P", + status: 4.9398, + target: 6, + index: 0.9752, + impact: 0, + score: 0.9752, + }, + { + indicator_id: "C_K", + status: 0.7559, + target: 30, + index: 0.1748, + impact: 0, + score: 0.1748, + }, + ], + }, +} + +const baseInputs: Bln3ScoreInputs = { + nmiApiKey: "mock-api-key", + a_lat: 51.613, + a_lon: 5.2, +} + +describe("requestBln3Score", () => { + beforeAll(() => { + vi.stubGlobal("fetch", vi.fn()) + }) + + afterEach(() => { + vi.mocked(fetch).mockClear() + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it("should throw if nmiApiKey is not provided", async () => { + const inputs: Bln3ScoreInputs = { ...baseInputs, nmiApiKey: undefined } + await expect(requestBln3Score(inputs)).rejects.toThrow( + "NMI API key not provided", + ) + expect(fetch).not.toHaveBeenCalled() + }) + + it("should call the NMI API with correct URL, headers, and body", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockBln3ScoreResponse, + } as Response) + + const inputs: Bln3ScoreInputs = { + ...baseInputs, + cultivations: [{ b_lu_brp: 266, b_lu_year: 2025 }], + measures: [{ measure_id: "BM3", year: 2025 }], + } + + await requestBln3Score(inputs) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith( + "https://api.nmi-agro.nl/maatwerk/bln3/score/field", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer mock-api-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + a_lat: 51.613, + a_lon: 5.2, + cultivations: [{ b_lu_brp: 266, b_lu_year: 2025 }], + measures: [{ measure_id: "BM3", year: 2025 }], + }), + }), + ) + }) + + it("should return mapped Bln3Score with indicators (plural)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockBln3ScoreResponse, + } as Response) + + const result = await requestBln3Score(baseInputs) + + expect(result).toEqual({ + indicators: mockBln3ScoreResponse.data.indicator, + aggregations: undefined, + }) + }) + + it("should throw if the NMI API returns a non-ok response", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: vi.fn().mockResolvedValue("upstream error"), + } as unknown as Response) + + await expect(requestBln3Score(baseInputs)).rejects.toThrow( + "BLN3 score request failed with status 500", + ) + expect(fetch).toHaveBeenCalledTimes(1) + }) +}) + +// getBln3Score is the cached wrapper around requestBln3Score via withCalculationCache. +// Cache behaviour is tested thoroughly in fdm-core/src/calculator.test.ts. +// We just verify the export exists and has the correct shape. +it("getBln3Score should be a function", () => { + expect(typeof getBln3Score).toBe("function") +}) diff --git a/fdm-calculator/src/bln3/index.ts b/fdm-calculator/src/bln3/index.ts new file mode 100644 index 000000000..e6356d05d --- /dev/null +++ b/fdm-calculator/src/bln3/index.ts @@ -0,0 +1,69 @@ +import { withCalculationCache } from "@nmi-agro/fdm-core" +import pkg from "../package" +import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types" + +export { collectInputForBln3Score } from "./collect" + +/** + * Requests a BLN3 score from the NMI API for a single field. + * + * Calls `POST /maatwerk/bln3/score/field` with the provided field data and + * returns per-indicator status, target, index, impact, and score values. + * + * @param inputs - Field data and NMI API key. Only `a_lat`, `a_lon`, and + * `nmiApiKey` are required; all other fields improve calculation quality. + * @returns A promise resolving to a `Bln3Score` with `indicators` and + * optional `aggregations`. + * @throws If the NMI API key is not provided or the API request fails. + */ +export async function requestBln3Score( + inputs: Bln3ScoreInputs, +): Promise { + const { nmiApiKey, ...fieldData } = inputs + + if (!nmiApiKey) { + throw new Error("NMI API key not provided") + } + + const response = await fetch( + "https://api.nmi-agro.nl/maatwerk/bln3/score/field", + { + method: "POST", + headers: { + Authorization: `Bearer ${nmiApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(fieldData), + }, + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => "") + throw new Error( + `BLN3 score request failed with status ${response.status}: ${response.statusText} - ${errorText}`, + ) + } + + const result: Bln3ScoreResponse = await response.json() + // Map the API's "indicator" (singular) to "indicators" (plural) for ergonomics + return { + indicators: result.data.indicator, + aggregations: result.data.aggregations, + } +} + +/** + * Cached version of `requestBln3Score`. + * + * Uses `withCalculationCache` to store and retrieve results from the + * `fdm-calculator.calculation_cache` table. The cache key is a SHA-256 hash + * of the function name, calculator version, and sanitized inputs (API key + * redacted). Bumping `calculatorVersion` in `package.ts` invalidates all + * existing cache entries. + */ +export const getBln3Score = withCalculationCache( + requestBln3Score, + "requestBln3Score", + pkg.calculatorVersion, + ["nmiApiKey"], +) diff --git a/fdm-calculator/src/bln3/types.d.ts b/fdm-calculator/src/bln3/types.d.ts new file mode 100644 index 000000000..f5b14a844 --- /dev/null +++ b/fdm-calculator/src/bln3/types.d.ts @@ -0,0 +1,161 @@ +/** + * A single cultivation entry for the BLN3 score request. + */ +export type Bln3Cultivation = { + /** Year of the land use / cultivation */ + b_lu_year: number + /** Crop cultivation code according to BRP */ + b_lu_brp: number +} + +/** + * A single measure entry for the BLN3 score request. + * `measure_id` is the raw BLN measure identifier (e.g. "BM3"), not the + * namespaced format used in fdm-core ("bln_BM3"). + */ +export type Bln3Measure = { + /** ID of the measure (e.g. "BM3", "G1") */ + measure_id: string + /** Year in which the measure was taken */ + year: number +} + +/** + * Input parameters for the BLN3 score calculation. + * Maps to the request body of `POST /maatwerk/bln3/score/field`. + * + * Only `a_lat` and `a_lon` are required by the NMI API. + * All other fields are optional and improve calculation quality when provided. + */ +export type Bln3ScoreInputs = { + /** NMI API key for authentication — redacted from cache hash */ + nmiApiKey: string | undefined + + // ── Location (required) ────────────────────────────────────────────────── + /** Latitude of the field centroid (WGS84; EPSG:4326) */ + a_lat: number + /** Longitude of the field centroid (WGS84; EPSG:4326) */ + a_lon: number + + // ── Cultivation history ────────────────────────────────────────────────── + /** Crop cultivations on the field (most recent first) */ + cultivations?: Bln3Cultivation[] + + // ── Field characteristics ──────────────────────────────────────────────── + /** Dutch agricultural soil type */ + b_soiltype_agr?: + | "zeeklei" + | "rivierklei" + | "maasklei" + | "moerige_klei" + | "duinzand" + | "dalgrond" + | "dekzand" + | "loess" + | "veen" + /** HELP soil map unit */ + b_help_wenr?: string + /** Compaction risk (from Van den Akker, 2012) */ + b_sc_wenr?: 1 | 2 | 3 | 4 | 5 | 10 | 11 | 401 | 901 | 902 + /** Groundwater class */ + b_gwl_class?: + | "Ia" + | "Ic" + | "IIa" + | "IIb" + | "IIc" + | "IIIa" + | "IIIb" + | "IVu" + | "IVc" + | "Va" + | "Vao" + | "Vad" + | "Vb" + | "Vbo" + | "Vbd" + | "VIo" + | "VId" + | "VIIo" + | "VIId" + | "VIIIo" + | "VIIId" + /** Whether drains are present */ + b_drain?: boolean + + // ── Soil analysis ──────────────────────────────────────────────────────── + /** Phosphorus plant available (PAE) (mg P / kg) */ + a_p_cc?: number + /** Phosphate in ammonium lactate extraction (PAL) (mg P2O5 / 100g) */ + a_p_al?: number + /** Phosphate extractable with water (Pw) (mg P2O5 / l) */ + a_p_wa?: number + + // ── Measures ───────────────────────────────────────────────────────────── + /** Implemented soil management measures */ + measures?: Bln3Measure[] + + // ── Additional fields accepted by the API (undocumented in schema) ─────── + [key: string]: unknown +} + +/** + * A single indicator result from the BLN3 score calculation. + */ +export type Bln3IndicatorResult = { + /** Indicator identifier (e.g. "B_DI", "C_N", "P_DS") */ + indicator_id: string + /** Measured value in indicator unit */ + status: number + /** Target value in the same unit */ + target: number + /** Normalized score (0–1) comparing status to target */ + index: number + /** Effect of selected measures on this indicator (0–1) */ + impact: number + /** Final score: combination of index and impact (0–1) */ + score: number +} + +/** + * An aggregated score (e.g. OBI, BBWP) combining multiple indicator scores. + * Not yet implemented in the NMI API — the `aggregations` field is optional. + */ +export type Bln3AggregationResult = { + /** Aggregation identifier (e.g. "OBI", "BBWP") */ + aggregation_id: string + /** Aggregated score */ + score: number +} + +/** + * Field data for a BLN3 score request, assembled from the FDM database. + * Passed to `getBln3Score` together with `nmiApiKey`. + */ +export type Bln3ScoreCollectedInputs = Omit + +/** + * The BLN3 score result returned by `requestBln3Score` / `getBln3Score`. + */ +export type Bln3Score = { + indicators: Bln3IndicatorResult[] + /** Aggregation scores — not yet implemented by the NMI API */ + aggregations?: Bln3AggregationResult[] +} + +/** + * Full response envelope from the NMI API for `POST /maatwerk/bln3/score/field`. + * The `data.indicator` field (singular, as named in the API) is mapped to + * `Bln3Score.indicators` (plural) in `requestBln3Score`. + */ +export type Bln3ScoreResponse = { + request_id: string + success: boolean + status: number + message: string | null + data: { + /** The API uses singular "indicator" — mapped to plural "indicators" in Bln3Score */ + indicator: Bln3IndicatorResult[] + aggregations?: Bln3AggregationResult[] + } +} diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 8edfe3c12..eaf3d8672 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -108,6 +108,18 @@ export type { GebruiksnormResult, NormFilling, } from "./norms/nl/types" +export { + collectInputForBln3Score, + getBln3Score, + requestBln3Score, +} from "./bln3" +export type { + Bln3AggregationResult, + Bln3IndicatorResult, + Bln3Score, + Bln3ScoreCollectedInputs, + Bln3ScoreInputs, +} from "./bln3/types" export { getNutrientAdvice, requestNutrientAdvice, From 850b5cdcef8b54f608f3802a2d5d467c2b844170 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 10:57:58 +0200 Subject: [PATCH 2/7] feat: add abortController for bln3 request --- fdm-calculator/src/bln3/index.ts | 57 ++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/fdm-calculator/src/bln3/index.ts b/fdm-calculator/src/bln3/index.ts index e6356d05d..06f3c11cf 100644 --- a/fdm-calculator/src/bln3/index.ts +++ b/fdm-calculator/src/bln3/index.ts @@ -25,30 +25,45 @@ export async function requestBln3Score( throw new Error("NMI API key not provided") } - const response = await fetch( - "https://api.nmi-agro.nl/maatwerk/bln3/score/field", - { - method: "POST", - headers: { - Authorization: `Bearer ${nmiApiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(fieldData), - }, - ) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout - if (!response.ok) { - const errorText = await response.text().catch(() => "") - throw new Error( - `BLN3 score request failed with status ${response.status}: ${response.statusText} - ${errorText}`, + try { + const response = await fetch( + "https://api.nmi-agro.nl/maatwerk/bln3/score/field", + { + method: "POST", + headers: { + Authorization: `Bearer ${nmiApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(fieldData), + signal: controller.signal, + }, ) - } - const result: Bln3ScoreResponse = await response.json() - // Map the API's "indicator" (singular) to "indicators" (plural) for ergonomics - return { - indicators: result.data.indicator, - aggregations: result.data.aggregations, + if (!response.ok) { + const errorText = await response.text().catch(() => "") + throw new Error( + `BLN3 score request failed with status ${response.status}: ${response.statusText} - ${errorText}`, + ) + } + + const result: Bln3ScoreResponse = await response.json() + // Map the API's "indicator" (singular) to "indicators" (plural) for ergonomics + return { + indicators: result.data.indicator, + aggregations: result.data.aggregations, + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error( + "BLN3 score request timed out (30s). The NMI API did not respond in time.", + ) + } + throw err + } finally { + clearTimeout(timeout) } } From c0c4db826d5f24b358e786b839b0dc6d3d2b1c41 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 11:01:05 +0200 Subject: [PATCH 3/7] feat: add check for bln3 response status --- fdm-calculator/src/bln3/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fdm-calculator/src/bln3/index.ts b/fdm-calculator/src/bln3/index.ts index 06f3c11cf..9080e00c6 100644 --- a/fdm-calculator/src/bln3/index.ts +++ b/fdm-calculator/src/bln3/index.ts @@ -50,6 +50,11 @@ export async function requestBln3Score( } const result: Bln3ScoreResponse = await response.json() + if (!result.success) { + throw new Error( + `BLN3 score API returned failure (status ${result.status}): ${result.message ?? "Unknown error"}`, + ) + } // Map the API's "indicator" (singular) to "indicators" (plural) for ergonomics return { indicators: result.data.indicator, From ccee20daf29ef0d951ddd7af3f22c2ae062bd2b1 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 09:04:04 +0200 Subject: [PATCH 4/7] refactor: rename file for consistency --- fdm-calculator/src/bln3/index.ts | 2 +- fdm-calculator/src/bln3/{collect.test.ts => input.test.ts} | 2 +- fdm-calculator/src/bln3/{collect.ts => input.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename fdm-calculator/src/bln3/{collect.test.ts => input.test.ts} (99%) rename fdm-calculator/src/bln3/{collect.ts => input.ts} (100%) diff --git a/fdm-calculator/src/bln3/index.ts b/fdm-calculator/src/bln3/index.ts index 9080e00c6..6021ed274 100644 --- a/fdm-calculator/src/bln3/index.ts +++ b/fdm-calculator/src/bln3/index.ts @@ -2,7 +2,7 @@ import { withCalculationCache } from "@nmi-agro/fdm-core" import pkg from "../package" import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types" -export { collectInputForBln3Score } from "./collect" +export { collectInputForBln3Score } from "./input" /** * Requests a BLN3 score from the NMI API for a single field. diff --git a/fdm-calculator/src/bln3/collect.test.ts b/fdm-calculator/src/bln3/input.test.ts similarity index 99% rename from fdm-calculator/src/bln3/collect.test.ts rename to fdm-calculator/src/bln3/input.test.ts index 72f7d055a..739c41ed0 100644 --- a/fdm-calculator/src/bln3/collect.test.ts +++ b/fdm-calculator/src/bln3/input.test.ts @@ -14,7 +14,7 @@ import { getSoilAnalyses, } from "@nmi-agro/fdm-core" import { beforeEach, describe, expect, it, vi } from "vitest" -import { collectInputForBln3Score } from "./collect" +import { collectInputForBln3Score } from "./input" vi.mock("@nmi-agro/fdm-core", async () => { const actual = await vi.importActual("@nmi-agro/fdm-core") diff --git a/fdm-calculator/src/bln3/collect.ts b/fdm-calculator/src/bln3/input.ts similarity index 100% rename from fdm-calculator/src/bln3/collect.ts rename to fdm-calculator/src/bln3/input.ts From 960d951e872f8aaec23c6b239c796793c63befda Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 09:40:48 +0200 Subject: [PATCH 5/7] test: add test for fetching --- fdm-calculator/src/bln3/index.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/fdm-calculator/src/bln3/index.test.ts b/fdm-calculator/src/bln3/index.test.ts index 888b481c9..5e3a932a9 100644 --- a/fdm-calculator/src/bln3/index.test.ts +++ b/fdm-calculator/src/bln3/index.test.ts @@ -124,6 +124,24 @@ describe("requestBln3Score", () => { ) expect(fetch).toHaveBeenCalledTimes(1) }) + + it("should rethrow network errors from fetch", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("Network connection lost")) + + await expect(requestBln3Score(baseInputs)).rejects.toThrow( + "Network connection lost", + ) + }) + + it("should map AbortError to a specific timeout message", async () => { + const abortError = new Error("The operation was aborted") + abortError.name = "AbortError" + vi.mocked(fetch).mockRejectedValueOnce(abortError) + + await expect(requestBln3Score(baseInputs)).rejects.toThrow( + "BLN3 score request timed out (30s). The NMI API did not respond in time.", + ) + }) }) // getBln3Score is the cached wrapper around requestBln3Score via withCalculationCache. From 5414df610586567ec63973c3e46934d97f6a61c0 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 10:38:26 +0200 Subject: [PATCH 6/7] refactor: implement review feedback --- fdm-calculator/src/bln3/index.test.ts | 114 ++++++++++++++++++++++++++ fdm-calculator/src/bln3/index.ts | 7 ++ 2 files changed, 121 insertions(+) diff --git a/fdm-calculator/src/bln3/index.test.ts b/fdm-calculator/src/bln3/index.test.ts index 5e3a932a9..341d4a6be 100644 --- a/fdm-calculator/src/bln3/index.test.ts +++ b/fdm-calculator/src/bln3/index.test.ts @@ -7,6 +7,7 @@ import { it, vi, } from "vitest" +import * as fdmCore from "@nmi-agro/fdm-core" import { getBln3Score, requestBln3Score } from "./index" import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types" @@ -125,6 +126,37 @@ describe("requestBln3Score", () => { expect(fetch).toHaveBeenCalledTimes(1) }) + it("should throw if the NMI API returns success: false", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: false, + status: 400, + message: "semantic failure message", + }), + } as Response) + + await expect(requestBln3Score(baseInputs)).rejects.toThrow( + "BLN3 score API returned failure (status 400): semantic failure message", + ) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it("should throw if the NMI API returns a malformed payload", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + status: 200, + data: {}, // Missing indicator + }), + } as Response) + + await expect(requestBln3Score(baseInputs)).rejects.toThrow( + "BLN3 score API returned a malformed payload", + ) + }) + it("should rethrow network errors from fetch", async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error("Network connection lost")) @@ -144,6 +176,88 @@ describe("requestBln3Score", () => { }) }) +describe("getBln3Score cache semantics", () => { + let mockRows: any[] = [] + const mockFdm = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockImplementation(() => ({ + then: (cb: any) => Promise.resolve(cb(mockRows)), + })), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), + catch: vi.fn().mockResolvedValue(undefined), + } as any + + const mockResult: Bln3Score = { + indicators: mockBln3ScoreResponse.data.indicator, + } + + beforeAll(() => { + vi.stubGlobal("fetch", vi.fn()) + }) + + afterEach(() => { + mockRows = [] + vi.clearAllMocks() + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it("should call fetch and store result on cache miss", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockBln3ScoreResponse, + } as Response) + + mockRows = [] // Cache miss + + const result = await getBln3Score(mockFdm, baseInputs) + + expect(result).toEqual(mockResult) + expect(fetch).toHaveBeenCalledTimes(1) + expect(mockFdm.select).toHaveBeenCalled() + expect(mockFdm.insert).toHaveBeenCalled() + }) + + it("should return cached result on cache hit without calling fetch", async () => { + mockRows = [{ result: mockResult }] // Cache hit + + const result = await getBln3Score(mockFdm, baseInputs) + + expect(result).toEqual(mockResult) + expect(fetch).not.toHaveBeenCalled() + expect(mockFdm.select).toHaveBeenCalled() + expect(mockFdm.insert).not.toHaveBeenCalled() + }) + + it("should proceed with calculation and log error if cache read fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockBln3ScoreResponse, + } as Response) + + // Mock select chain to throw + mockFdm.limit.mockImplementationOnce(() => ({ + then: () => Promise.reject(new Error("DB Error")), + })) + const spyConsole = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getBln3Score(mockFdm, baseInputs) + + expect(result).toEqual(mockResult) + expect(fetch).toHaveBeenCalledTimes(1) + expect(spyConsole).toHaveBeenCalledWith( + expect.stringContaining("Failed to read from calculation cache"), + ) + spyConsole.mockRestore() + }) +}) + // getBln3Score is the cached wrapper around requestBln3Score via withCalculationCache. // Cache behaviour is tested thoroughly in fdm-core/src/calculator.test.ts. // We just verify the export exists and has the correct shape. diff --git a/fdm-calculator/src/bln3/index.ts b/fdm-calculator/src/bln3/index.ts index 6021ed274..cfdc873c3 100644 --- a/fdm-calculator/src/bln3/index.ts +++ b/fdm-calculator/src/bln3/index.ts @@ -55,6 +55,13 @@ export async function requestBln3Score( `BLN3 score API returned failure (status ${result.status}): ${result.message ?? "Unknown error"}`, ) } + + if (!result.data || !Array.isArray(result.data.indicator)) { + throw new Error( + "BLN3 score API returned a malformed payload (missing data or indicator array)", + ) + } + // Map the API's "indicator" (singular) to "indicators" (plural) for ergonomics return { indicators: result.data.indicator, From a9c414db459a5eb97fbd798b47b2ad05a503a696 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 10:40:36 +0200 Subject: [PATCH 7/7] fix: type error --- fdm-calculator/src/bln3/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/fdm-calculator/src/bln3/index.test.ts b/fdm-calculator/src/bln3/index.test.ts index 341d4a6be..f5c548be9 100644 --- a/fdm-calculator/src/bln3/index.test.ts +++ b/fdm-calculator/src/bln3/index.test.ts @@ -7,7 +7,6 @@ import { it, vi, } from "vitest" -import * as fdmCore from "@nmi-agro/fdm-core" import { getBln3Score, requestBln3Score } from "./index" import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types"