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/index.test.ts b/fdm-calculator/src/bln3/index.test.ts new file mode 100644 index 000000000..f5c548be9 --- /dev/null +++ b/fdm-calculator/src/bln3/index.test.ts @@ -0,0 +1,265 @@ +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) + }) + + 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")) + + 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.", + ) + }) +}) + +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. +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..cfdc873c3 --- /dev/null +++ b/fdm-calculator/src/bln3/index.ts @@ -0,0 +1,96 @@ +import { withCalculationCache } from "@nmi-agro/fdm-core" +import pkg from "../package" +import type { Bln3Score, Bln3ScoreInputs, Bln3ScoreResponse } from "./types" + +export { collectInputForBln3Score } from "./input" + +/** + * 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 controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout + + 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, + }, + ) + + 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() + if (!result.success) { + throw new Error( + `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, + 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) + } +} + +/** + * 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/input.test.ts b/fdm-calculator/src/bln3/input.test.ts new file mode 100644 index 000000000..739c41ed0 --- /dev/null +++ b/fdm-calculator/src/bln3/input.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 "./input" + +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/input.ts b/fdm-calculator/src/bln3/input.ts new file mode 100644 index 000000000..daa6e7310 --- /dev/null +++ b/fdm-calculator/src/bln3/input.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/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,