import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import pg from 'pg'; import { excludedReason, fineliGroup, isExcluded, storedGroup } from '../groups.ts'; import { isInfantFood } from './european.ts'; import { fillEnergy, importDrafts } from './national.ts'; import type { Draft, NutrientKey, Parsed, ValueType } from './national.ts'; // Fineli, the Finnish food composition database of THL, open data "basic package 1" (CSV, ISO-8859-1, ';', decimal // comma). Licence CC BY 4.0; THL asks for "Finnish Institute for Health and Welfare, Fineli" and the release. export const fineliSourceName = 'Fineli, Finnish Institute for Health and Welfare (THL)'; export const fineliAttribution = 'Finnish Institute for Health and Welfare, Fineli. Open data, CC BY 4.0.'; // EUFDNAME -> [key, unit of component.csv]. CHOAVL is the available carbohydrate (FD-11 `available`). const nutrients: Record = { ENERC: ['enerc_kj', 'KJ'], CHOAVL: ['chocdf_g', 'G'], FAT: ['fat_g', 'G'], PROT: ['procnt_g', 'G'], ALC: ['alc_g', 'G'], FIBC: ['fibtg_g', 'G'], SUGAR: ['sugar_g', 'G'], FASAT: ['fasat_g', 'G'], NA: ['na_mg', 'MG'], CHOLE: ['chole_mg', 'MG'], K: ['k_mg', 'MG'], CA: ['ca_mg', 'MG'], FE: ['fe_mg', 'MG'], VITD: ['vitd_ug', 'UG'], }; // FD-07 type from the acquisition type (where the value comes from) and the method type (how it was made). export function fineliValueType(acquisition: string, method: string, value: number): ValueType | null { if (acquisition === 'F') return 'borrowed'; if (acquisition === 'L') return 'label'; if (['A', 'AG', 'D'].includes(method)) return 'analytic'; if (['CG', 'G', 'P', 'R', 'S', 'T'].includes(method)) return 'calculated'; if (['I', 'IG', 'K', 'L', 'O'].includes(method)) return 'imputed'; if (method === 'U') return value === 0 ? 'assumed_zero' : 'imputed'; if (['D', 'I', 'O', 'P', 'C'].includes(acquisition)) return 'analytic'; if (acquisition === 'S') return 'calculated'; return null; } function csv(bytes: Buffer, what: string, columns: string[]) { const lines = bytes.toString('latin1').split(/\r?\n/).filter(line => line.trim()); const header = lines.shift()?.split(';') ?? []; if (columns.some((column, index) => header[index] !== column)) throw new Error(`Fineli ${what}: header ${header.join(';')}`); return lines.map(line => { const cells = line.split(';'); if (cells.length !== header.length) throw new Error(`Fineli ${what}: ${cells.length} cells in "${line.slice(0, 60)}"`); return Object.fromEntries(header.map((column, index) => [column, cells[index]!.trim()])); }); } const number = (text: string | undefined) => { const value = Number((text ?? '').replace(',', '.')); return text && Number.isFinite(value) ? value : null; }; export const fineliFiles = { foods: 'food.csv', names: 'foodname_EN.csv', values: 'component_value.csv', components: 'component.csv', measures: 'foodaddunit.csv', units: 'foodunit_EN.csv' } as const; export function parseFineli(files: Record): Parsed { const units = new Map(csv(files.components, 'components', ['EUFDNAME', 'COMPUNIT']).map(row => [row.EUFDNAME!, row.COMPUNIT!])); for (const [id, [, unit]] of Object.entries(nutrients)) if (units.get(id) !== unit) throw new Error(`Fineli component ${id} unit changed`); const english = new Map(csv(files.names, 'names', ['FOODID', 'FOODNAME']).map(row => [row.FOODID!, row.FOODNAME!])); const unitNames = new Map(csv(files.units, 'units', ['THSCODE', 'DESCRIPT']).map(row => [row.THSCODE!, row.DESCRIPT!])); const values = new Map[]>(); for (const row of csv(files.values, 'values', ['FOODID', 'EUFDNAME', 'BESTLOC', 'ACQTYPE', 'METHTYPE'])) (values.get(row.FOODID!) ?? values.set(row.FOODID!, []).get(row.FOODID!)!).push(row); const portions = new Map[]>(); for (const row of csv(files.measures, 'measures', ['FOODID', 'FOODUNIT', 'MASS'])) (portions.get(row.FOODID!) ?? portions.set(row.FOODID!, []).get(row.FOODID!)!).push(row); const out: Parsed = { drafts: [], skipped: [] }; const seen = new Set(); for (const food of csv(files.foods, 'foods', ['FOODID', 'FOODNAME', 'FOODTYPE', 'PROCESS', 'EDPORT', 'IGCLASS', 'IGCLASSP', 'FUCLASS', 'FUCLASSP'])) { const id = food.FOODID!; if (!id || seen.has(id)) { out.skipped.push({ recordId: id || null, reason: id ? 'duplicate source identifier' : 'missing source identifier' }); continue; } seen.add(id); const name = english.get(id)?.replace(/\s+/g, ' ').trim(); if (!name) { out.skipped.push({ recordId: id, reason: 'no English name' }); continue; } const entry = fineliGroup({ fuclass: food.FUCLASS, igclass: food.IGCLASS, igclassp: food.IGCLASSP, foodtype: food.FOODTYPE }); if (isExcluded(entry)) { out.skipped.push({ recordId: id, reason: excludedReason(entry) }); continue; } if (isInfantFood(name)) { out.skipped.push({ recordId: id, reason: 'baby food (C-23)' }); continue; } const draftValues: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; const rows = values.get(id) ?? []; for (const row of rows) { const known = nutrients[row.EUFDNAME!]; if (!known) continue; const value = number(row.BESTLOC); const type = value === null ? null : fineliValueType(row.ACQTYPE!, row.METHTYPE!, value); if (value === null || value < 0 || !type) continue; draftValues[known[0]] = value; kinds[known[0]] = type; } fillEnergy(draftValues, kinds); const measures = (portions.get(id) ?? []).filter(row => !['KG', 'PORT1000KJ'].includes(row.FOODUNIT!)) .map(row => ({ name: unitNames.get(row.FOODUNIT!) ?? row.FOODUNIT!, basis_amount: number(row.MASS) ?? 0 })) .filter(measure => measure.basis_amount > 0); const added: Record = { name_language: 'en', native_name: food.FOODNAME || null, native_language: 'fi', fineli_fuclass: food.FUCLASS || null, fineli_igclass: food.IGCLASS || null, fineli_foodtype: food.FOODTYPE || null }; const edible = number(food.EDPORT); if (edible !== null && edible > 0 && edible < 100) added.refuse_fraction = 1 - edible / 100; out.drafts.push({ recordId: id, name, basis: '100g', carbohydrateDefinition: 'available', values: draftValues, kinds, measures, added, group: storedGroup(entry), raw: Buffer.from(JSON.stringify({ food, name, values: rows, measures: portions.get(id) ?? [] })) }); } return out; } export async function readFineliFolder(folder: string) { return Object.fromEntries(await Promise.all(Object.entries(fineliFiles).map(async ([key, file]) => [key, await readFile(join(folder, file))]))) as Record; } export async function importFineliFolder(client: pg.Client, folder: string, releasedAt: string) { return importDrafts(client, { src: 'SRC-16', market: 'FI', family: 'open' }, parseFineli(await readFineliFolder(folder)), releasedAt, fineliSourceName); }