// Generated by dts-bundle v0.7.3 // Dependencies for this module: // ../debug // ../chalk // ../cheerio/lib/slim // ../domhandler // ../json-pointer // ../@oada/types/modus/v1/modus-result.js // ../@oada/types/modus/v1/modus-submit.js // ../@oada/types/modus/slim/v1/0.js // ../xlsx // ../@overleaf/o-error // ../@modusjs/industry // ../@modusjs/units // ../jszip // ../fast-deep-equal // ../md5 // ../moment // ../wicket // ../excel-date-to-js // ../dayjs // ../jsonpath declare module '@modusjs/convert' { export * as xml from '@modusjs/convert/xml.js'; export * as csv from '@modusjs/convert/fromCsvToModusV1.js'; export * as json from '@modusjs/convert/json.js'; export { ModusResult, assertModusResult, InputFile } from '@modusjs/convert/json.js'; export { tree } from '@modusjs/convert/tree.js'; export { Slim, assertSlim } from '@modusjs/convert/slim.js'; export * as slim from '@modusjs/convert/slim.js'; } import debug from 'debug'; import chalk from 'chalk'; import { load, } from 'cheerio/lib/slim'; import { isTag } from 'domhandler'; import { fixModus } from '@modusjs/convert/fromCsvToModusV1.js'; import jp from 'json-pointer'; import { assert as assertModusResult, is as isModusResult, } from '@oada/types/modus/v1/modus-result.js'; import { assert as assertModusSubmit, is as isModusSubmit, } from '@oada/types/modus/v1/modus-submit.js'; import { fromModusV1 } from '@modusjs/convert/slim.js'; import { assert as assertModusSlim, is as isModusSlim, } from '@oada/types/modus/slim/v1/0.js'; export { assertModusSubmit, isModusSubmit }; export { assertModusResult, isModusResult }; export { assertModusSlim, isModusSlim }; const error = debug('@modusjs/convert#xml:error'); const warn = debug('@modusjs/convert#xml:error'); const info = debug('@modusjs/convert:info'); const trace = debug('@modusjs/convert:trace'); const { red, yellow } = chalk; export function parseModusSubmit(xmlstring) { const ms = parse(xmlstring); assertModusSubmit(ms); return ms; } export function parseModusResult(xmlstring) { const mr = parse(xmlstring); //Handle broken implementations of modus if (jp.has(mr, '/Events/0/EventSamples/Soil/SoilSamples')) { let samples = jp.get(mr, '/Events/0/EventSamples/Soil/SoilSamples'); samples = samples.map((sample) => { if (sample.NutrientRecommendations) { sample.NutrientRecommendations = sample.NutrientRecommendations.map((nr) => { if (!Array.isArray(nr)) nr = [nr]; return nr.map((nr) => ({ ...nr, RecID: '' + nr.RecID })); }); } return sample; }); jp.set(mr, '/Events/0/EventSamples/Soil/SoilSamples', samples); } assertModusResult(mr); return mr; } export function parseModusSlim(xmlstring) { const mr = fromModusV1(parse(xmlstring)); assertModusSlim(mr); return mr; } export function parse(xmlstring) { const $ = load(xmlstring, { xmlMode: true }); // Put this here so we have access to $. // Does basic, repeatable parsing of common Modus XML structures into JSON: const parseObject = ({ xml, opts, path, }) => { const ret = {}; path = path || ''; if (!xml) return null; const { emptyTagsBecomeTrue } = opts; opts.emptyTagsBecomeTrue = false; // do not propagate this in recursive calls // If this tag has attributes, keep them as keys on the object for (const a of xml.attributes) { if (opts.ignoreKeys[a.name]) continue; ret[a.name] = parseStringOrNumberOrBoolean({ str: a.value, tagname: a.name, path: `${path}/${a.name}`, opts, }); } // Loop through all the children and parse them into keys on the "ret" object for (const child of xml.children) { if (!isTag(child)) { // ignore random character data continue; } if (opts.ignoreKeys[child.tagName]) { continue; } const childpath = `${path}/${child.tagName}`; // If caller wants to override parsing this tag type: if (opts.overrides?.[child.tagName]) { const o = opts.overrides[child.tagName]; switch (o.type) { case 'keyedSet': ret[child.tagName] = parseKeyedSet({ xml: child, id_attrib: o.id_attrib, opts, path: childpath, }); break; case 'accumulateArray': parseArrayAccumulate({ ret, tag: o.finalKeynameForArrayInParent, xml: child, opts, path: childpath, }); break; case 'array': ret[child.tagName] = parseArray({ xml: child, opts, path: childpath, }); break; case 'emptyTagsBecomeTrue': ret[child.tagName] = parseObject({ xml: child, opts: { ...opts, emptyTagsBecomeTrue: true }, path: childpath, }); break; } continue; } // If this has child tags, parse it as another simple object if (countChildrenThatAreTags(child) > 0) { ret[child.tagName] = parseObject({ xml: child, opts, path: childpath }); continue; } // If this is an empty object, like the "Soil" object in , the convention is to // just make it boolean true instead of an empty object if (emptyTagsBecomeTrue && child.children.length === 0) { ret[child.tagName] = true; continue; } // Otherwise, this tag's contents are just character data const str = $(child).text().trim(); if (str === '') continue; // ignore empty entries ret[child.tagName] = parseStringOrNumberOrBoolean({ str, tagname: child.tagName, path: childpath, opts, }); } return ret; }; function parseKeyedSet({ xml, id_attrib, opts, path, }) { if (!xml) return null; // All the "tag" children at this level should become values in an object, // keyed by the attribute from the XML tag named : // foo // bar // => // { "1": foo, "2": bar } let ret = {}; for (const child of xml.children) { if (!isTag(child)) continue; const id = child.attribs[id_attrib]; if (!id) { warn(yellow('WARNING:'), 'key ', child.tagName, ' under ', xml.tagName, 'at path', path, 'does not contain required ID attribute ', id_attrib, ', ignoring'); trace('Attributes available are: ', child.attribs); continue; } ret[id] = parseObject({ xml: child, opts, path: `${path}/${id}` }); } return ret; } // NutrientSamples and Events are simple arrays: each child is pushed onto the parent array function parseArray({ xml, opts, path, }) { const ret = []; for (const child of xml.children) { if (!isTag(child)) continue; // ignore random text nodes const obj = parseObject({ xml: child, opts, path: `${path}/${child.tagName}`, }); if (obj) { ret.push(obj); } } return ret; } // SoilSample is a child under the "Soil" tag, but they are not collected uner a "SoilSamples" parent. // So this accumulator will just keep adding the children to a parent array as they appear. function parseArrayAccumulate({ ret, tag, xml, opts, path, }) { if (!Array.isArray(ret[tag])) { ret[tag] = []; } const obj = parseObject({ xml, opts, path }); if (obj) { ret[tag].push(obj); } } function parseStringOrNumberOrBoolean({ str, tagname, path, opts, }) { // Easiest parsing for numbers is just based on their keyname. However, // the "Value" key is sometimes a string and sometimes a number. So Value is parsed as a number // unless the path matches a pre-defined set of known "string" paths for Value. const parse_as_number = !!opts.parseAsNumbers[tagname] && !opts.pathRegexParseAsStrings.find((re) => path.match(re)); if (parse_as_number) return +str; if (str.toLowerCase() === 'false') return false; if (str.toLowerCase() === 'true') return true; return str; } //--------------------------------------------------------------------- // Do the actual parsing..... // Grab the "ModusResult" from the DOM as the starting point: const modus_result = exactlyOneTag($('ModusResult')); if (!modus_result) { error(red('ERROR:'), ' no ModusResult found in file.'); throw new Error('No ModusResult found.'); } let ret = parseObject({ xml: modus_result, opts: { overrides: { Event: { type: 'accumulateArray', finalKeynameForArrayInParent: 'Events', }, Reports: { type: 'array' }, DepthRefs: { type: 'array' }, Depths: { type: 'array' }, SoilSample: { type: 'accumulateArray', finalKeynameForArrayInParent: 'SoilSamples', }, PlantSample: { type: 'accumulateArray', finalKeynameForArrayInParent: 'PlantSamples', }, NutrientResults: { type: 'array' }, EventType: { type: 'emptyTagsBecomeTrue' }, FMISAllowedLabEquations: { type: 'array' }, SiteAttributes: { type: 'array' }, RecommendationRefs: { type: 'array' }, RecommendationRequests: { type: 'array' }, NutrientRecommendations: { type: 'array' }, NutrientRecommendation: { type: 'array' }, NematodeResults: { type: 'array' }, LifeStageValues: { type: 'array' }, ResidueResults: { type: 'array' }, TextureResults: { type: 'array' }, SensorResults: { type: 'array' }, SubSamples: { type: 'array' }, TestPackages: { type: 'array' }, TestPackageRefs: { type: 'array' }, Variables: { type: 'array' }, NematodeSample: { type: 'accumulateArray', finalKeynameForArrayInParent: 'NematodeSamples', }, WaterSample: { type: 'accumulateArray', finalKeynameForArrayInParent: 'WaterSamples', }, ResidueSample: { type: 'accumulateArray', finalKeynameForArrayInParent: 'ResidueSamples', }, Warnings: { type: 'array' }, }, parseAsNumbers: { SampleGroupID: true, ReportID: true, epsg: true, SubSampleNumber: true, StartingDepth: true, EndingDepth: true, ColumnDepth: true, DepthID: true, DisplayOrder: true, RecID: true, Value: true, }, // Sadly, some "Value" keys in the standard are actually strings, so use the path override to // convert just particular Values back into strings. pathRegexParseAsStrings: [ /Variables\/[^\/]+\/Value/, // SiteAttributes/Variables/*/Value, Recommendation/Variables/*/Value /LifeStageValues\/[^\/]+\/Value/, // LifeStageValues/*/Value // /LabMetaData\/Reports\/[^\/]+\/ReportID/, // LabMetaData/Reports/*/ReportID /Depths\/[^\/]+\/DepthID/, // LabMetaData/Reports/*/ReportID ], ignoreKeys: { 'xmlns:xsi': true, 'xsi:noNamespaceSchemaLocation': true, }, }, }); //TODO: Some additional massaging into valid Modus... ret = fixModus(ret); ret._type = 'application/vnd.modus.v1.modus-result+json'; return ret; } function firstChildThatIsATag(elem) { for (const child of elem.children) { if (isTag(child)) return child; } return null; } function hasOnlyOneTagChildAndChildTagIsEmpty(elem) { if (countChildrenThatAreTags(elem) !== 1) return false; const child = firstChildThatIsATag(elem); if (!child) return false; return child.children.length < 1; } function countChildrenThatAreTags(elem) { if (!elem.children) return 0; let count = 0; for (const c of elem.children) { if (isTag(c)) count++; } return count; } function exactlyOneTag(xml) { const all = xml.toArray().filter(isTag); if (all.length < 1) return null; if (all.length > 1) { warn(yellow('WARNING:'), `Tag ${all[0].tagName} can only exist once at this level, but multiple tags found. Only using first one.`); } return all[0]; } import debug from 'debug'; import * as xlsx from 'xlsx'; import oerror from '@overleaf/o-error'; import { modusTests } from '@modusjs/industry'; import { simpleConvert } from '@modusjs/units'; import { convertUnits } from '@modusjs/units'; import { supportedFileTypes, typeFromFilename } from '@modusjs/convert/json.js'; import * as units from '@modusjs/units'; import { parseDate } from '@modusjs/convert/labs/index.js'; import { assert as assertModusResult, } from '@oada/types/modus/v1/modus-result.js'; import { autodetectLabConfig, cobbleLabConfig, labConfigsMap, modusKeyToValue, modusKeyToHeader, setMappings } from '@modusjs/convert/labs/index.js'; const error = debug('@modusjs/convert#csv:error'); const warn = debug('@modusjs/convert#csv:error'); const info = debug('@modusjs/convert#csv:info'); const trace = debug('@modusjs/convert#csv:trace'); const DEPTHUNITS = 'cm'; const BASEPAT = /^Base Saturation - /; export * as labs from '@modusjs/convert/labs/index.js'; export const supportedFormats = ['generic']; export function parseWorkbook({ wb, str, arrbuf, base64, }) { // Make sure we have an actual workbook to work with: if (!wb) { try { //if (str) wb = xlsx.read(str, { type: 'string'}); if (str) wb = xlsx.read(str, { type: 'string', cellDates: true }); if (arrbuf) wb = xlsx.read(arrbuf, { type: 'array', cellDates: true }); if (base64) wb = xlsx.read(base64, { type: 'base64', cellDates: true }); } catch (e) { throw oerror.tag(e, 'Failed to parse input data with xlsx/csv reader'); } } if (!wb) { throw new Error('No readable input data found.'); } return wb; } export function parse({ wb, str, arrbuf, base64, format, lab, labConfigs, allowOverrides = true, }) { // Make sure we have an actual workbook to work with: wb = parseWorkbook({ wb, str, arrbuf, base64 }); if (!format) format = 'generic'; switch (format) { case 'generic': return convert({ ...parseWorksheets({ wb, lab, labConfigs }), allowOverrides }); default: throw new Error(`format type ${format} not currently supported`); } } export function prep({ wb, str, arrbuf, base64, format, lab, labConfigs, }) { // Make sure we have an actual workbook to work with: wb = parseWorkbook({ wb, str, arrbuf, base64 }); if (!format) format = 'generic'; switch (format) { case 'generic': return parseWorksheets({ wb, lab, labConfigs }); default: throw new Error(`format type ${format} not currently supported`); } } export function partitionSheets(wb) { // Grab the point meta data out of any master sheet: // Any sheet whose name contains "point meta" regardless of spacing, case, or punctuation will be considered // point metadata. i.e. 'Point Meta-Data' would match as well as 'pointmetadata' const matchsheet = wb.SheetNames.find(isPointMetadataSheetname); let metadatasheet; if (matchsheet) { const rows = xlsx.utils // Without { raw: false } in sheet_to_json, dates will be parsed as excel ints instead of the formatted strings .sheet_to_json(wb.Sheets[matchsheet], { raw: false }) .map(keysToUpperNoSpacesDashesOrUnderscores); metadatasheet = { rows, sheetname: matchsheet }; trace('metadatasheet:', metadatasheet.sheetname); } let sheetnames = wb.SheetNames.length > 1 && wb.SheetNames.some(name => name.toLowerCase().includes("raw")) ? wb.SheetNames.filter(name => name.toLocaleLowerCase().includes("raw")) : wb.SheetNames; const datasheets = sheetnames .filter(sh => !isPointMetadataSheetname(sh)) .map(sheetname => { const sheet = wb.Sheets[sheetname]; const allrows = xlsx.utils.sheet_to_json(sheet, { defval: '' }); // include empty column values! undefined doesn't seem to get empty cols to show up. const rows = allrows .map((r) => Object.fromEntries(Object.entries(r) .filter(([key, _]) => !key.startsWith('__EMPTY')) .map(([key, val]) => ([key.trim(), val])))) .filter(isDataRow); // Get a list of all the header names for future reference as needed. Since // keys are omitted for rows where a column has no value, we must look // through all the rows and accumulate the unique set of column headers. const colnames = [...new Set(rows.map((obj) => Object.keys(obj)) .reduce((prev, cur) => prev.concat(cur), []))]; return { sheetname, allrows, rows, colnames }; }); return { metadatasheet, datasheets }; } function getPointMeta(metadatasheet, labConfig) { const pointmeta = {}; for (const r of metadatasheet.rows) { const id = modusKeyToValue(r, 'SampleNumber', labConfig) || r['POINTID'] || r['FMISSAMPLEID'] || r['SAMPLEID']; if (!id) continue; pointmeta[id] = r; } return pointmeta; } export function getOrAutodetectLab({ datasheets, allowImprovise, labConfigs: userLabConfigs, }) { const labConfig = datasheets.map(({ sheetname, colnames }) => autodetectLabConfig({ headers: colnames, sheetname })) .find(sh => sh); if (labConfig) info(`Using LabConfig: ${labConfig.name}`); if (!allowImprovise) return labConfig; return labConfig || datasheets.map(({ colnames }) => cobbleLabConfig(colnames)) .find(sh => sh); } function groupRows(rows, datecol) { return rows.reduce((groups, r) => { let date = datecol ? r[datecol] : 'Unknown Date'; if (date === 'NA') date = 'Unknown Date'; if (date !== 'Unknown Date') date = parseDate(date); date = date instanceof Date ? date.toISOString().split('T')[0] : date; trace('Determined row date from column', datecol, 'as', date); if (!date) { warn('WARNING: row does not have the column we chose for the date (', datecol, '), the row is: ', r); return groups; } if (!groups[date]) groups[date] = []; groups[date].push(r); return groups; }, {}); } function parseWorksheets({ wb, lab, labConfigs, }) { const { metadatasheet, datasheets } = partitionSheets(wb); const labConfig = (lab && typeof lab === 'string') ? labConfigsMap.get(lab) : getOrAutodetectLab({ datasheets }); if (!labConfig) warn(`LabConfig was either not supplied or not auto-detected. It may parse if using standardized CSV input...`); // if (!labConfig) throw new Error('Unable to detect or generate lab configuration.') let pointMeta; if (metadatasheet) pointMeta = getPointMeta(metadatasheet, labConfig); trace('datasheets:', datasheets); //return convert({ datasheets, labConfig, pointMeta, allowOverrides }) return { datasheets, labConfig, pointMeta }; } function convert({ datasheets, labConfig, pointMeta, allowOverrides = true, }) { const ret = []; for (const { sheetname, allrows, rows, colnames } of datasheets) { // Grab the unit overrides and get rid of comment/units columns let unitOverrides = allowOverrides ? extractUnitOverrides(allrows) : undefined; trace('Have', rows.length, 'rows from sheetname: ', sheetname); // Parse structured header format for reverse conversion let headers = Object.fromEntries(colnames.map(n => ([n, { ...parseColumnHeaderName(n, labConfig), unitsOverride: unitOverrides?.[n], }]))); // Determine a "date" column for this dataset let datecol = 'EventDate' in rows[0] ? 'EventDate' : modusKeyToHeader('ReportDate', colnames, labConfig) ?? colnames.find((name) => name.toUpperCase().match(/DATE/)); if (!datecol) { error('No date column in sheet', sheetname, ', columns are:', colnames); } // Loop through all the rows and group them by that date. This group will // become a single ModusResult file. const grouped_rows = groupRows(rows, datecol); // Now we can loop through all the groups and actually make the darn Modus // file: let groupcount = 0; for (const [date, g_rows] of Object.entries(grouped_rows)) { groupcount++; // Start to build the modus output, this is one "Event" //TODO: Soil labtype isn't the best default because its really the only "different" one. // We could also use existence of depth info to determine if its soil. const type = labConfig?.type || modusKeyToValue(g_rows[0], 'ReportType', labConfig) || 'Soil'; const sampleType = `${type}Samples`; const output = { Events: [ { EventMetaData: { EventDate: date, // TODO: process this into the actual date format we are allowed to have in schema. EventType: { [type]: true }, }, EventSamples: { [type]: { [sampleType]: [], }, }, }, ], }; let event = output.Events[0]; if (type === 'Soil') event.EventSamples[type].DepthRefs = []; if (type === 'Plant') event.EventMetaData.EventType.Plant = { Crop: { Name: modusKeyToValue(g_rows[0], 'Crop', labConfig) || 'Unknown Crop', ClientID: modusKeyToValue(g_rows[0], 'ClientAccountNumber', labConfig) || 'Unknown Client', GrowthStage: { Name: modusKeyToValue(g_rows[0], 'GrowthStage', labConfig) || 'Unknown Growth Stage', ClientID: modusKeyToValue(g_rows[0], 'ClientAccountNumber', labConfig) || 'Unknown Client', }, SubGrowthStage: { Name: modusKeyToValue(g_rows[0], 'SubGrowthStage', labConfig) || 'Unknown Sub-Growth Stage', ClientID: modusKeyToValue(g_rows[0], 'ClientAccountNumber', labConfig) || 'Unknown Client', }, }, PlantPart: modusKeyToValue(g_rows[0], 'PlantPart', labConfig) || 'Unknown Plant Part', }; const depthrefs = type === 'Soil' ? event.EventSamples[type].DepthRefs : undefined; const samples = event.EventSamples[type][sampleType]; for (const [_, row] of g_rows.entries()) { event = setMappings(event, 'event', row, labConfig); //TODO: Like this or { ...event, getMappings } ? event.LabMetaData = { LabName: modusKeyToValue(row, 'LabName', labConfig) || labConfig?.name || 'Unknown Lab', LabEventID: modusKeyToValue(row, 'LabEventID', labConfig) || 'Unknown Lab Event ID', ProcessedDate: modusKeyToValue(row, 'ProcessedDate', labConfig) || date, ReceivedDate: modusKeyToValue(row, 'ReceivedDate', labConfig) || date, Reports: [], ClientAccount: { AccountNumber: modusKeyToValue(row, 'ClientAccountNumber', labConfig) || 'Unknown Client Account', Company: modusKeyToValue(row, 'ClientCompany', labConfig) || 'Unknown Client Company', Name: modusKeyToValue(row, 'ClientName', labConfig) || 'Unknown Client Name', City: modusKeyToValue(row, 'ClientCity', labConfig) || 'Unknown Client City', State: modusKeyToValue(row, 'ClientState', labConfig) || 'Unknown Client State', Zip: modusKeyToValue(row, 'ClientZip', labConfig) || 'Unknown Client Zip', } }; const id = modusKeyToValue(row, 'SampleNumber', labConfig); const meta = pointMeta?.[id]; event.FMISMetaData = { FMISProfile: { Grower: modusKeyToValue(row, 'GrowerName', labConfig) || modusKeyToValue(meta, 'Grower', labConfig) || 'Unknown Grower', Farm: modusKeyToValue(row, 'FarmName', labConfig) || modusKeyToValue(meta, 'Farm', labConfig) || 'Unknown Farm', Field: modusKeyToValue(row, 'FieldName', labConfig) || modusKeyToValue(meta, 'Field', labConfig) || 'Unknown Field', 'Sub-Field': modusKeyToValue(row, 'SubFieldName', labConfig) || modusKeyToValue(meta, 'SubField', labConfig) || 'Unknown Sub-Field', } }; let nutrientResults = parseNutrientResults({ row, headers, labConfig, }); // Units can come from several places nutrientResults = setNutrientResultUnits({ nutrientResults, unitOverrides, labConfig, headers, }); nutrientResults = units.convertUnits(nutrientResults); let DepthID; if (type === 'Soil') { const depth = parseDepth(row, headers, labConfig); // mutates depthrefs if missing, returns depthid DepthID = '' + ensureDepthInDepthRefs(depth, depthrefs); } // Get the ReportID integer from the LabReportID string after ensuring // a Reports entry exists for it. const labReportId = modusKeyToValue(row, 'LabReportID', labConfig); const fileDescription = `${sheetname}_${groupcount}`; const ReportID = ensureLabReportId(event.LabMetaData.Reports, labReportId, fileDescription); let sample = { SampleMetaData: { ReportID: ReportID || 1, //TODO: TestPackage } }; if (type === 'Soil') { sample.Depths = [{ DepthID, NutrientResults: nutrientResults }]; } else { sample.NutrientResults = nutrientResults; } //TODO: Will this negatively overwrite anything sample = setMappings(sample, 'sample', row, labConfig); // Parse locations: either in the sample itself or in the meta. Sample takes precedence over meta. let wkt = parseWKTFromPointMetaOrRow(meta) || parseWKTFromPointMetaOrRow(row); if (wkt) { sample.SampleMetaData.Geometry = wkt; } samples.push(sample); //Write/override any additional labConfig Mappings into the modus output } // end rows for this group try { assertModusResult(output); } catch (e) { error('assertModusResult failed for sheetname', sheetname, ', group date', date); throw oerror.tag(e, `Could not construct a valid ModusResult from sheet ${sheetname}, group date ${date}`); } ret.push(output); } // end looping over all groups } // end looping over all the sheets return ret; } function keysToUpperNoSpacesDashesOrUnderscores(obj) { const ret = {}; for (const [key, val] of Object.entries(obj)) { const newkey = key.toUpperCase().replace(/([ _]|-)*/g, ''); ret[newkey] = typeof val === 'object' ? keysToUpperNoSpacesDashesOrUnderscores(val) : val; } return ret; } function isPointMetadataSheetname(name) { return name .replace(/([ _,]|-)*/g, '') .toUpperCase() .match('POINTMETA'); } function setNutrientResultUnits({ nutrientResults, headers, unitOverrides, labConfig, }) { return nutrientResults.map(nr => { const header = Object.values(headers).find(h => h.original === nr.CsvHeader); const override = header?.original ? unitOverrides?.[header?.original] : undefined; const headerUnit = header?.units; const labConfigUnit = header ? labConfig?.units?.[header.original] : undefined; trace(`Ordered unit prioritization of ${nr.Element}: Override:[${override}] ` + `> Header:[${headerUnit}] > LabConfig:[${labConfigUnit}]`); return { ...nr, ValueUnit: override || headerUnit || labConfigUnit, }; }); } function extractUnitOverrides(rows) { const overrides = {}; const unitrows = rows.filter(isUnitRow); // There really should only be one units row for (const r of unitrows) { for (const [key, val] of Object.entries(r)) { if (!val) continue; // type-inferred 'nothing' should just not be here // keep all the key/value pairs EXCEPT the one that indicated this was a UNITS row if (typeof val === 'string' && val.trim() === 'UNITS') continue; overrides[key] = val; } } return overrides; } function isDataRow(row) { const first = !isCommentRow(row); const second = !isUnitRow(row); const third = !isEmptyRow(row); return first && second && third; } function isEmptyRow(row) { if (typeof row !== 'object') return true; for (const val of Object.values(row)) { if (val) return false; // found anything in the object that is not empty } return true; } function isCommentRow(row) { return !!Object.values(row).find((val) => typeof val === 'string' && val.trim() === 'COMMENT'); } function isUnitRow(row) { return !!Object.values(row).find((val) => typeof val === 'string' && val.trim() === 'UNITS'); } function depthsEqual(ref, depth) { if (ref['DepthUnit'] !== depth['DepthUnit']) return false; if (ref['StartingDepth'] !== depth['StartingDepth']) return false; if (ref['ColumnDepth'] !== depth['ColumnDepth']) return false; if (ref['Name'] !== depth['Name']) return false; if (ref['EndingDepth'] !== depth['EndingDepth']) return false; return true; } function ensureDepthInDepthRefs(depth, depthrefs) { let match = depthrefs.find((ref) => depthsEqual(ref, depth)); if (!match) { let DepthID = depthrefs.length + 1; depthrefs.push({ ...depth, DepthID, }); return DepthID; } return match.DepthID; } function parseWKTFromPointMetaOrRow(meta_or_row) { if (meta_or_row === undefined) return undefined; let copy = keysToUpperNoSpacesDashesOrUnderscores(meta_or_row); let longKey = Object.keys(copy).find((key) => key.includes('LONGITUDE')); let latKey = Object.keys(copy).find((key) => key.includes('LATITUDE')); if (copy['LONG']) longKey = 'LONG'; if (copy['LNG']) longKey = 'LNG'; if (copy['LON']) longKey = 'LON'; if (copy['LAT']) latKey = 'LAT'; if (!longKey) { //trace('No longitude for point: ', meta_or_row.POINTID || meta_or_row.FMISSAMPLEID || meta_or_row.SAMPLEID); return ''; } if (!latKey) { //trace('No latitude for point: ', meta_or_row.POINTID || meta_or_row.FMISSAMPLEID || meta_or_row.SAMPLEID); return ''; } let long = copy[longKey]; let lat = copy[latKey]; return `POINT(${long} ${lat})`; } function extractBetween(str, startChar, endChar) { const start = str.indexOf(startChar); const end = str.lastIndexOf(endChar); if (start < 0) return; // start char not found if (start > str.length - 1) return ''; // start char at end of string if (end < 0) return str.slice(start + 1); // end not found, return start through end of string return str.slice(start + 1, end); // start+1 to avoid including the start/end chars in output } function extractBefore(str, startChars) { let chars = Array.isArray(startChars) ? startChars : [startChars]; const first = chars.find(key => str.indexOf(key) >= 0); if (!first) return str; // start char not found const start = str.indexOf(first); return str.slice(0, start); } export function parseColumnHeaderName(original, labConfig) { original = original .trim() .replace(/\n/g, ' ') .replace(/ +/g, ' '); const element = extractBefore(original, ['(', '[']).trim() || original; const modusid = extractBetween(original, '(', ')')?.trim(); const units = extractBetween(original, '[', ']')?.trim(); const nutrientResult = labConfig?.analytes[element] || { Element: element }; return { original, element, modusid, units, nutrientResult, }; } function parseNutrientResults({ row, headers, labConfig }) { let nutrientResults = Object.keys(row).filter(key => { // Rows outside of the labconfig one way or another if (!labConfig?.analytes[key]) { // Things we know are not analytes: depth values (may have units); if (key.toLowerCase().includes('depth')) return false; // Standardized CSV output information if (headers?.[key]?.units || headers?.[key]?.modusid) return true; return false; } else { // if (!isNaN(+(row[key].replace('>', '').replace('<', ''))) && row[key] !== '') if (typeof row[key] === 'string' || typeof row[key] === 'number') return true; return false; // Filter things that fail the else statement below, i.e., headers[key] does not exist } }).map((key) => { let Value = !isNaN(+row[key]) ? +row[key] : row[key]; if (labConfig?.analytes?.[key]) { return { ...labConfig.analytes[key], Value, }; } else { return { Element: headers[key].element, ValueUnit: headers?.[key]?.units, ModusTestID: headers?.[key]?.modusid, CsvHeader: headers?.[key]?.original, Value, }; } }); // Now eliminate duplicate Base Saturation entries return nutrientResults.filter((v, i) => !nutrientResults.some((item, j) => (v.Element === item.Element && i !== j && BASEPAT.test(v.Element) && BASEPAT.test(item.Element) && v.ValueUnit !== '%'))); } function parseDepth(row, headers, labConfig) { let depthInfo = typeof labConfig?.depthInfo !== 'function' ? // @ts-ignore labConfig?.depthInfo : labConfig?.depthInfo(row); // Depth info can come from: // 1) Within the spreadsheet, as columns for each property // 2) Within the spreadsheet with units parsed from headers and overrides // 3) Data that lives outside of the spreadsheet, e.g., based on some lab config // 4) Some combination of the row data with custom logic applied (depthInfo as // a function) const colnames = Object.keys(row); const depth = {}; let startHeader = modusKeyToHeader('Top', colnames, labConfig); let startVal = startHeader ? row[startHeader] : undefined; let startOverride = startHeader ? headers[startHeader]?.unitsOverride : undefined; depth.StartingDepth = modusKeyToValue(row, 'Top', labConfig) || depthInfo?.StartingDepth || 0; let endHeader = modusKeyToHeader('Bottom', colnames, labConfig); let endVal = endHeader ? row[endHeader] : undefined; let endOverride = endHeader ? headers[endHeader]?.unitsOverride : undefined; depth.EndingDepth = modusKeyToValue(row, 'Bottom', labConfig) || depthInfo?.EndingDepth || depth.StartingDepth; let depHeader = modusKeyToHeader('ColumnDepth', colnames, labConfig); let depVal = depHeader ? row[depHeader] : undefined; let depOverride = depHeader ? headers[depHeader]?.unitsOverride : undefined; depth.ColumnDepth = modusKeyToValue(row, 'ColumnDepth', labConfig) || depthInfo?.ColumnDepth || Math.abs(depth.EndingDepth - depth.StartingDepth) || 0; // 0 is allowed in our json schema, but technically not allowed per xsd // Pull the information from the column value let valDepthUnit; if (!depVal) { [' to ', ' - '].some((pattern) => [startVal, endVal, depVal].some((val) => { if (typeof val === 'string' && val?.match(pattern)) { const pieces = val.split(pattern); depth.StartingDepth = parseInt(pieces[0]) || 0; depth.EndingDepth = parseInt(pieces[1]) || 0; depth.ColumnDepth = depth.EndingDepth - depth.StartingDepth; if (pieces[1]?.includes('cm')) valDepthUnit = 'cm'; if (pieces[1]?.includes('mm')) valDepthUnit = 'mm'; if (pieces[1]?.includes('in')) valDepthUnit = 'in'; } })); } const depthUnit = startOverride || endOverride || depOverride; depth.DepthUnit = depthUnit || valDepthUnit || modusKeyToValue(row, 'Units', labConfig) || depthInfo?.DepthUnit || 'cm'; depth.Name = modusKeyToValue(row, 'DepthName', labConfig) || depthInfo?.Name || depth.EndingDepth === 0 ? 'Unknown Depth' : `${depth.StartingDepth} to ${depth.EndingDepth} ${depth.DepthUnit}`; return depth; } export function toCsv(input, opts) { if (opts?.ssurgo) { warn('SSURGO option coming soon for CSV output'); } let data = []; if (Array.isArray(input)) { data = input.map((mr) => toCsvObject(mr)).flat(1); } else { data = toCsvObject(input); } let sheet = xlsx.utils.json_to_sheet(data); return { wb: { Sheets: { Sheet1: sheet }, SheetNames: ['Sheet1'], }, str: xlsx.utils.sheet_to_csv(sheet), }; } export function toCsvObject(input, separateMetadata) { return input .Events.map((event) => { let eventMeta = { EventDate: event.EventMetaData.EventDate, EventCode: event.EventMetaData?.EventCode, EventType: Object.keys(event.EventMetaData?.EventType || {})[0] || 'Soil' // Hard-coded for now. This is all soil data at the moment }; const type = eventMeta.EventType; if (type === 'Plant') { eventMeta = { ...eventMeta, // @ts-expect-error messed up schema I guess? Fix Crop: event.EventMetaData?.EventType?.Plant?.Crop?.Name, PlantClient: event.EventMetaData?.EventType?.Plant?.Crop?.ClientID, GrowthStage: event.EventMetaData?.EventType?.Plant?.Crop?.GrowthStage?.Name, GrowthStageClient: event.EventMetaData?.EventType?.Plant?.Crop?.GrowthStage?.ClientID, SubGrowthStage: event.EventMetaData?.EventType?.Plant?.Crop?.SubGrowthStage?.Name, SubGrowthStageClient: event.EventMetaData?.EventType?.Plant?.Crop?.SubGrowthStage?.ClientID, PlantPart: event.EventMetaData?.EventType?.Plant?.PlantPart, }; } let labMeta = { LabName: event.LabMetaData?.LabName, LabID: event.LabMetaData?.LabID, LabReportID: event.LabMetaData?.LabReportID, LabEventID: event.LabMetaData?.LabEventID, ReceivedDate: event.LabMetaData?.ReceivedDate, ProcessedDate: event.LabMetaData?.ProcessedDate, ClientAccountNumber: event.LabMetaData?.ClientAccount?.AccountNumber, ClientName: event.LabMetaData?.ClientAccount?.Name, ClientCompany: event.LabMetaData?.ClientAccount?.Company, ClientCity: event.LabMetaData?.ClientAccount?.City, ClientState: event.LabMetaData?.ClientAccount?.State, ClientZip: event.LabMetaData?.ClientAccount?.Zip, LabContactName: event.LabMetaData?.Contact?.Name, LabContactPhone: event.LabMetaData?.Contact?.Phone, LabContactAddress: event.LabMetaData?.Contact?.Address, }; let fmisMeta = { FMISEventID: event.FMISMetadata?.FMISEventID, FMISProfileGrower: event.FMISMetadata?.FMISProfile?.Grower, FMISProfileFarm: event.FMISMetadata?.FMISProfile?.Farm, FMISProfileField: event.FMISMetadata?.FMISProfile?.Field, 'FMISProfileSubField': event.FMISMetadata?.FMISProfile?.['Sub-Field'], }; let allReports = toReportsObj(event.LabMetaData.Reports); let allDepthRefs = toDepthRefsObj(event.EventSamples?.Soil?.DepthRefs); let samplesType = `${type}Samples`; // @ts-expect-error make union type later return event.EventSamples[type][samplesType].map((sample) => { let sampleMeta = toSampleMetaObj(sample.SampleMetaData, allReports); if (type === 'Soil') { return sample.Depths.map((depth) => { let nutrients = toNutrientResultsObj(depth); return { ...eventMeta, ...labMeta, ...fmisMeta, ...sampleMeta, ...allReports[sampleMeta.ReportID], ...allDepthRefs[depth.DepthID], ...nutrients, }; }); } else { let nutrients = toNutrientResultsObj(sample); return { ...eventMeta, ...labMeta, ...fmisMeta, ...sampleMeta, ...allReports[sampleMeta.ReportID], ...nutrients, }; } ; ; }); }) .flat(3); // TODO: Flatten 3 for soil and 2 for other types? Check when we get there. } function toSampleMetaObj(sampleMeta, allReports) { const base = { SampleNumber: sampleMeta.SampleNumber, ...allReports[sampleMeta.ReportID], FMISSampleID: sampleMeta.FMISSampleID, SampleContainerID: sampleMeta.SampleContainerID, ReportID: sampleMeta.ReportID, }; let ll = sampleMeta?.Geometry ?.replace('POINT(', '') .replace(')', '') .trim() .split(' '); if (!ll) return base; return { ...base, Latitude: +ll[0], Longitude: +ll[1], }; } function toDepthRefsObj(depthRefs) { if (!depthRefs) return undefined; return Object.fromEntries(depthRefs.map((dr) => [ dr.DepthID, { DepthID: '' + dr.DepthID, [`StartingDepth [${dr.DepthUnit}]`]: dr.StartingDepth, [`EndingDepth [${dr.DepthUnit}]`]: dr.EndingDepth, [`ColumnDepth [${dr.DepthUnit}]`]: dr.ColumnDepth, }, ])); } function toReportsObj(reports) { return Object.fromEntries(reports.map((r) => [ r.ReportID, { FileDescription: r.FileDescription, ReportID: r.ReportID, LabReportID: r.LabReportID, }, ])); } function toNutrientResultsObj(sampleDepth) { return Object.fromEntries(sampleDepth.NutrientResults.map((nr) => [ `${nr.Element}${nr.ModusTestID ? ` (${nr.ModusTestID})` : ''} [${nr.ValueUnit}]`, nr.Value, ])); } function ensureLabReportId(reports, LabReportID, FileDescription) { let rep = reports.find((r) => r.LabReportID === LabReportID); // First and only report is missing LabReportID. Use it. if (!rep) { const ReportID = reports.length + 1; rep = { LabReportID: LabReportID || `Report ${ReportID}`, ReportID, FileDescription }; reports.push(rep); return rep.ReportID; } else return rep.ReportID; } export async function toLabConfig(files, labConfigs) { if (!Array.isArray(files)) { files = [files]; } let results = []; for (const file of files) { const format = file.format || 'generic'; let original_type = typeFromFilename(file.filename); if (!original_type) { warn('WARNING: unable to determine file type from filename', file.filename, '. Supported types are:', supportedFileTypes, '. Skipping file.'); continue; } if (original_type === 'csv' || original_type === 'xlsx') { //TODO: Implement this against LabConfigs? if (!supportedFormats.find((f) => f === format)) { warn('ERROR: format', format, 'is not supported for file', file.filename, '. Supported formats are: ', supportedFormats, '. Skipping file.'); continue; } } switch (original_type) { case 'zip': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'xml': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'json': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'csv': break; case 'xlsx': break; } const base = { original_filename: file.filename, original_type }; const type = original_type; // just to make things shorter later in json filename determination const filename = file.filename; let output_filename = ''; let wbinfo = null; try { switch (original_type) { case 'csv': case 'xlsx': let parseargs; if (original_type === 'csv') parseargs = { str: file.str, format }; else { if (file.arrbuf) parseargs = { arrbuf: file.arrbuf, format, }; // checked for at least one of these above else parseargs = { base64: file.base64, format }; } const wbinfo = prep({ ...parseargs, labConfigs }); // for (const [index, wbinfo] of all_wbinfo.entries()) { const filename_args = { wbinfo, type, filename }; // if (all_wbinfo.length > 1) { // multiple things, then use the index // filename_args.index = index; //} results.push({ ...wbinfo, ...base }); //} break; } } catch (e) { if (e.errors && e.input && Array.isArray(e.errors)) { // AJV error warn('ERROR: failed to validate file', file.filename); for (const ajv_error of e.errors) { warn('Path', ajv_error.instancePath, ajv_error.message); // '/path/to/item' 'must be an array' } } else { warn('ERROR: failed to read file', file.filename); console.log(e); } continue; // if error, move on to the next file } } // end for loop on filenames return results; } export function fixModus(modus) { //Fix Non-Standard (not specified by modus) Depth Units modus = fixDepthUnits(modus); //Fix Non-Standard Nutrient Results (lookup modus id and append everything else) modus = setModusNRUnits(modus); return modus; } export function fixDepthUnits(modus) { let evts = (modus.Events || []).map((evt) => { if (evt.EventSamples?.Soil) { evt.EventSamples.Soil.DepthRefs = evt.EventSamples.Soil.DepthRefs?.map((dr) => { const StartingDepth = simpleConvert(dr.StartingDepth, dr.DepthUnit, DEPTHUNITS); const EndingDepth = simpleConvert(dr.EndingDepth, dr.DepthUnit, DEPTHUNITS); const ColumnDepth = simpleConvert(dr.ColumnDepth, dr.DepthUnit, DEPTHUNITS); //@ts-ignore if (StartingDepth?.status === 'failed' || EndingDepth?.status === 'failed' || ColumnDepth?.status === 'failed') { warn(`Standardizing soil depth units failed. Falling back to input.`); return dr; } return { ...dr, //@ts-ignore StartingDepth: Math.round(StartingDepth.toVal), //@ts-ignore EndingDepth: Math.round(EndingDepth.toVal), //@ts-ignore ColumnDepth: Math.round(ColumnDepth.toVal), DepthUnit: DEPTHUNITS }; }); } return evt; }); return { ...modus, Events: evts }; } export function setModusNRUnits(modus, units) { let evts = (modus.Events || []).map((evt) => { let evtSamples = Object.fromEntries(Object.entries(evt.EventSamples || {}).map(([key, value]) => { let samplesKey = `${key}Samples`; if (key === 'Soil') { value[samplesKey] = value[samplesKey].map((sample) => ({ ...sample, Depths: sample.Depths.map((dep) => ({ ...dep, //Will convert to standard units or else NutrientResults: convertUnits(dep.NutrientResults.map((nr) => ({ ...nr, Element: modusTests[nr.ModusTestID]?.Element || nr.Element, ModusTestIDv2: modusTests[nr.ModusTestID]?.ModusTestIDv2 || nr.ModusTestIDv2, })), units) })) })); } return [key, value]; })); return { ...evt, EventSamples: evtSamples }; }); return { ...modus, Events: evts }; } import debug from 'debug'; import jszip from 'jszip'; import { assert as assertSlim } from '@oada/types/modus/slim/v1/0.js'; import { assert as assertModusResult } from '@oada/types/modus/v1/modus-result.js'; import pointer from 'json-pointer'; import { supportedFormats } from '@modusjs/convert/fromCsvToModusV1.js'; import { parseCsv as csvParse } from '@modusjs/convert/csv.js'; import { parseModusResult as xmlParseModusResult } from '@modusjs/convert/xml.js'; import { fromModusV1 } from '@modusjs/convert/slim.js'; export * as slim from '@modusjs/convert/slim.js'; const error = debug('@modusjs/convert#tojson:error'); const warn = debug('@modusjs/convert#tojson:error'); const info = debug('@modusjs/convert#tojson:info'); const trace = debug('@modusjs/convert#tojson:trace'); export const supportedFileTypes = ['xml', 'csv', 'xlsx', 'json', 'zip']; export { assertModusResult }; export async function toJson(files, labConfigs) { if (!Array.isArray(files)) { files = [files]; } let results = []; for (const file of files) { const format = file.format || 'generic'; let original_type = typeFromFilename(file.filename); if (!original_type) { warn('WARNING: unable to determine file type from filename', file.filename, '. Supported types are:', supportedFileTypes, '. Skipping file.'); continue; } if (original_type === 'csv' || original_type === 'xlsx') { if (!supportedFormats.find((f) => f === format)) { warn('ERROR: format', format, 'is not supported for file', file.filename, '. Supported formats are: ', supportedFormats, '. Skipping file.'); continue; } } switch (original_type) { case 'xlsx': case 'zip': if (!file.arrbuf && !file.base64) { warn('Type of', file.filename, 'was', original_type, 'but that must be an ArrayBuffer or Base64 encoded string. Skipping.'); continue; } break; case 'csv': case 'xml': case 'json': if (!file.str) { warn('CSV, XML, and JSON input files must be strings, but file', file.filename, 'is not.'); continue; } } const base = { original_filename: file.filename, original_type }; const type = original_type; // just to make things shorter later in json filename determination const filename = file.filename; let output_filename = ''; let modus = null; try { switch (original_type) { case 'zip': const zip_modus = await zipParse(file); results = [...results, ...zip_modus]; break; case 'json': modus = typeof file.str === 'string' ? JSON.parse(file.str) : file.str; if (modus._type === 'application/vnd.modus.v1.modus-result+json' || modus.Events) { modus = fromModusV1(modus); } assertSlim(modus); // catch below will inform if parsing or assertion failed. output_filename = jsonFilenameFromOriginalFilename({ modus, type, filename, }); results.push({ modus, output_filename, ...base }); // just one Modus in this case break; case 'xml': modus = xmlParseModusResult(file.str); output_filename = jsonFilenameFromOriginalFilename({ modus, type, filename, }); if (modus) { if (modus._type === 'application/vnd.modus.v1.modus-result+json' || modus.Events) { modus = fromModusV1(modus); } results.push({ modus, output_filename, ...base }); // just one } break; case 'csv': case 'xlsx': let parseargs; if (original_type === 'csv') parseargs = { str: file.str, format, filename }; else { if (file.arrbuf) parseargs = { arrbuf: file.arrbuf, format, filename, }; // checked for at least one of these above else parseargs = { base64: file.base64, format, filename }; } const all_modus = csvParse({ ...parseargs, labConfigs }); for (const [index, modus] of all_modus.entries()) { const filename_args = { modus, type, filename }; if (all_modus.length > 1) { // multiple things, then use the index filename_args.index = index; } output_filename = jsonFilenameFromOriginalFilename(filename_args); results.push({ modus, output_filename, ...base }); } break; } } catch (e) { if (e.errors && e.input && Array.isArray(e.errors)) { // AJV error warn('ERROR: failed to validate file', file.filename); for (const ajv_error of e.errors) { warn('Path', ajv_error.instancePath, ajv_error.message); // '/path/to/item' 'must be an array' } } else { warn('ERROR: failed to read file', file.filename); console.log(e); } continue; // if error, move on to the next file } } // end for loop on filenames return results; } export function jsonFilenameFromOriginalFilename({ modus, index, filename, type, }) { const output_filename_base = filename.replace(/\.(xml|csv|xlsx|zip)$/, '.json'); let output_filename = output_filename_base; // xslx and csv store the sheetname + group number in FileDescription, we can name things by that const filedescription = pointer.has(modus, '/lab/files/0/description') ? pointer.get(modus, '/lab/files/0/description') : ''; if ((type === 'xlsx' || type === 'csv' || type === 'zip') && filedescription) { output_filename = output_filename.replace(/\.json$/, `${filedescription.replace(/[^a-zA-Z0-9_\\-]*/g, '')}.json`); } else { if (typeof index !== 'undefined') { // more than one result, have to number the output files output_filename = output_filename.replace(/\.json$/, `_${index}.json`); } } return output_filename; } export function typeFromFilename(filename) { if (filename.match(/\.xml$/)) return 'xml'; if (filename.match(/\.csv$/)) return 'csv'; if (filename.match(/\.xlsx$/)) return 'xlsx'; if (filename.match(/.json$/)) return 'json'; if (filename.match(/.zip/)) return 'zip'; return null; } export async function zipParse(file) { let opts = {}; const data = file.arrbuf || file.base64; if (file.base64) { opts = { base64: true }; } if (!data) { error('ERROR: Zip input file had neither arrbuf nor base64. At least one is required.'); throw new Error('Zip must have either array buffer or base64-encoded string'); } const zip = await jszip.loadAsync(data, opts); let all_convert_inputs = []; for (const zf of Object.values(zip.files)) { if (zf.dir) continue; const type = typeFromFilename(zf.name); // Strip all path info from the filename: const filename = zf.name.replace(/^(.*[\/\\])*/g, ''); trace('Found file', filename, 'of type', type, 'in zip'); let convert_input = { filename, format: file.format }; switch (type) { case 'zip': case 'xlsx': convert_input.arrbuf = await zf.async('arraybuffer'); break; case 'xml': case 'csv': case 'json': convert_input.str = await zf.async('string'); break; } all_convert_inputs.push(convert_input); } return toJson(all_convert_inputs); } const eventDateIndex = { '_type': 'application/vnd.modus.lab-results.index.1+json', '_rev': 0, 'event-date-index': { '*': { '_type': 'application/vnd.modus.lab-results.index.1+json', '_rev': 0, 'md5-index': { '*': { '_type': 'application/vnd.modus.slim.v1.0+json', '_rev': 0, }, }, }, }, }; export const tree = { 'bookmarks': { '_type': 'application/vnd.oada.bookmarks.1+json', '_rev': 0, 'lab-results': { '_type': 'application/vnd.trellis.lab-results.1+json', '_rev': 0, 'soil': eventDateIndex, 'plant-tissue': eventDateIndex, 'nematode': eventDateIndex, 'water': eventDateIndex, 'residue': eventDateIndex, }, }, }; export default tree; import fde from 'fast-deep-equal'; import debug from 'debug'; import md5 from 'md5'; import moment from 'moment'; import * as xlsx from 'xlsx'; import { assert as assertSlim } from '@oada/types/modus/slim/v1/0.js'; import jp from 'json-pointer'; import wicket from 'wicket'; import { parseColumnHeaderName } from '@modusjs/convert/csv.js'; export { assertSlim }; const error = debug('@modusjs/convert#slim:error'); const warn = debug('@modusjs/convert#slim:error'); const info = debug('@modusjs/convert#slim:info'); const trace = debug('@modusjs/convert#slim:trace'); export function fromModusV1(input) { let result = { _type: 'application/vnd.modus.slim.v1.0+json', }; for (const event of input.Events || []) { setPath(event, `/EventMetaData/EventCode`, result, `/id`); setPath(event, `/EventMetaData/EventDate`, result, `/date`); setPath(event, `/EventMetaData/EventType`, result, `/type`, (v) => Object.keys(v)[0].toLowerCase()); if (result.type === 'plant') result.type = 'plant-tissue'; //Lab setPath(event, `/LabMetaData/LabID`, result, `/lab/id/value`); setPath(event, `/LabMetaData/LabID`, result, `/lab/id/source`, () => 'local'); setPath(event, `/LabMetaData/LabName`, result, `/lab/name`); setPath(event, `/LabMetaData/Contact/Name`, result, `/lab/contact/name`); setPath(event, `/LabMetaData/Contact/Phone`, result, `/lab/contact/phone`); setPath(event, `/LabMetaData/Contact/Address`, result, `/lab/contact/address`); setPath(event, `/LabMetaData/Contact/Email`, result, `/lab/contact/email`); setPath(event, `/LabMetaData/Contact/State`, result, `/lab/contact/state`); //TODO: the spec uses 'address', not city + state setPath(event, `/LabMetaData/ReceivedDate`, result, `/lab/dateReceived`); if (result?.lab?.dateReceived) { result.lab.dateReceived = moment(result.lab.dateReceived).toISOString(); } setPath(event, `/LabMetaData/ProcessedDate`, result, `/lab/dateProcessed`); if (result?.lab?.dateProcessed) { result.lab.dateProcessed = moment(result.lab.dateProcessed).toISOString(); } setPath(event, `/LabMetaData/ClientAccount/Name`, result, `/lab/clientAccount/name`); setPath(event, `/LabMetaData/ClientAccount/AccountNumber`, result, `/lab/clientAccount/accountNumber`); setPath(event, `/LabMetaData/ClientAccount/Company`, result, `/lab/clientAccount/company`); setPath(event, `/LabMetaData/ClientAccount/City`, result, `/lab/clientAccount/city`); setPath(event, `/LabMetaData/ClientAccount/State`, result, `/lab/clientAccount/state`); setPath(event, `/FMISMetaData/FMISEventID`, result, `/source/report/id`); setPath(event, `/FMISMetaData/FMISProfile/Grower`, result, `/source/grower/name`); setPath(event, `/FMISMetaData/FMISProfile/Farm`, result, `/source/farm/name`); setPath(event, `/FMISMetaData/FMISProfile/Field`, result, `/source/field/name`); setPath(event, `/FMISMetaData/FMISProfile/Sub-Field`, result, `/source/subfield/name`); // Lab report setPath(event, `/LabMetaData/ProcessedDate`, result, `/lab/report/date`); if (result.lab?.report?.date) { result.lab.report.date = result.lab.report.date.split('T')[0]; } setPath(event, `/LabMetaData/Reports/0/LabReportID`, result, `/lab/report/id`); setPath(event, `/LabMetaData/Reports`, result, `/lab/files`, (reports) => reports.map((r) => { const file = {}; //FIXME: I believe ReportID is just an internal pointer to tie samples to Reports setPath(r, `/ReportID`, file, `/id`); setPath(r, `/LabReportID`, file, `/id`); setPath(r, `/FileDescription`, file, `/description`); setPath(r, `/File/URL/Path`, file, `/uri`); setPath(r, `/File/URL/FileName`, file, `/name`); setPath(r, `/File/FileData/FileName`, file, `/name`); setPath(r, `/File/FileData/FileData`, file, `/name`); return file; })); if (result.lab.files.length === 0) delete result.lab.files; for (const [type, eventSamples] of Object.entries(event.EventSamples)) { // Handle depths //TODO: Or nematode?? const depths = type === 'Soil' ? eventSamples.DepthRefs.map((dr) => ({ name: dr.Name, top: dr.StartingDepth, bottom: dr.EndingDepth, units: dr.DepthUnit })) : undefined; if (depths && depths.length === 1) result.depth = depths[0]; const sampleName = `${type}Samples`; // Samples for (const eventSample of eventSamples[sampleName]) { const sample = {}; let sampleid = jp.has(eventSample, '/SampleMetaData/FMISSampleID') ? jp.get(eventSample, '/SampleMetaData/FMISSampleID') : jp.has(eventSample, '/SampleMetaData/SampleNumber') ? jp.get(eventSample, '/SampleMetaData/SampleNumber') : jp.has(eventSample, '/SampleMetaData/SampleContainerID') ? jp.get(eventSample, '/SampleMetaData/SampleContainerID') : undefined; setPath(eventSample, `/SampleMetaData/SampleNumber`, sample, `/lab/sampleid`); setPath(eventSample, `/SampleMetaData/SampleContainerID`, sample, `/source/sampleid`); // Nutrient results const nutrientResults = type === 'Soil' ? eventSample.Depths.map((d) => d.NutrientResults).flat(1) : eventSample.NutrientResults; sample.results = Object.fromEntries(nutrientResults.map((d) => { const nr = {}; setPath(d, `/ModusTestID`, nr, `/analyte`, (v) => v.split('_')[3]); setPath(d, `/Element`, nr, `/analyte`); setPath(d, `/ModusTestID`, nr, `/modusTestID`); setPath(d, `/ValueUnit`, nr, `/units`); setPath(d, `/Value`, nr, `/value`); // d.ValueDesc??? // d.ValueType??? // TODO: Some other return [`${sampleid}-${md5(JSON.stringify(nr))}`, nr]; })); //Geolocation setPath(eventSample, `/SampleMetaData/Geometry`, sample, `/geolocation`, ({ wkt }) => { if (wkt.toLowerCase().startsWith('point')) { const ll = wkt.split('(')[1].replace(/\)$/, '').split(' '); return { lat: +ll[1], lon: +ll[0], }; } return { geojson: new wicket.Wkt().read(wkt).toJson(), }; }); //Plant if (type === 'Plant') { setPath(event, `/EventMetaData/EventType/Plant/Crop/Name`, result, `/crop/name`); // Modus V1 only allows top-level, but this could go down into the samples, too setPath(event, `/EventMetaData/EventType/Plant/PlantPart`, result, `/plantPart`); setPath(event, `/EventMetaData/EventType/Plant/Crop/GrowthStage/Name`, result, `/crop/growthStage`); setPath(event, `/EventMetaData/EventType/Plant/Crop/SubGrowthStage/Name`, result, `/crop/subGrowthStage`); } // Shouldn't need this; xml shouldn't be missing every form of ID sampleid = sampleid || md5(JSON.stringify(sample)); result.samples = result.samples || {}; result.samples[sampleid] = sample; } } if (!result.id) { result.id = md5(JSON.stringify(result)); } } return result; } export function setPath(obj, objPath, newObj, newPath, func) { if (jp.has(obj, objPath)) { const curVal = (jp.has(newObj, newPath) ? jp.get(newObj, newPath) : undefined); const newVal = func ? func(jp.get(obj, objPath)) ?? curVal : jp.get(obj, objPath) ?? curVal; if (Array.isArray(newVal) && newVal.length > 0) { jp.set(newObj, newPath, newVal); } else if (newVal || newVal === 0) { jp.set(newObj, newPath, newVal); } } } export function flatten(input) { const dict = Object.fromEntries(Object.entries(jp.dict(input)) .filter(([key, _]) => !key.startsWith('/samples'))); const samples = Object.fromEntries(Object.entries(input.samples || {}).map(([id, sample]) => { for (const [path, val] of Object.entries(dict)) { // Do not override the sample-level overrides; if (jp.has(sample, path)) continue; // Put the value up higher in the slim doc jp.set(sample, path, val); } return [id, sample]; })); return { samples, id: '', type: 'soil', date: '', }; } export function toStandardCsv(input, separateMetadata) { const flat = flatten(JSON.parse(JSON.stringify(input))); const type = input.type; return Object.entries(flat.samples || {}).map(([sampleid, sample]) => { // get all of the top-level things let dict = Object.fromEntries(Object.entries(jp.dict(sample) || {}) .filter(([key, _]) => !key.includes('/results')) .map(([key, val]) => ([key.replace(/^\//, '').replace(/\//g, '.'), val]))); let nutrients = convertNutrientResults(sample.results); return { // ...source, // ...lab, ...dict, ...nutrients, // ...plant, // ...soil, }; }); } export function fromStandardCsv(input) { let slim = { id: '', type: 'soil', // have to pick something here date: '', samples: {}, }; slim.samples = Object.fromEntries(input.map((row) => { // Parse analytes // FIXME can we recognize an analyte if we have no brackets or parenthesis? let results = Object.fromEntries(Object.entries(row || {}) .filter(([key, _]) => (key.includes('[') && key.includes(']')) || (key.includes('(') && key.includes(')')))); // Parse other data which have headesr that are json pointers const dict = Object.fromEntries(Object.entries(row || {}) .filter(([key, _]) => !(key in results)) .map(([path, val]) => (['/' + path.replace(/\./g, '/'), val]))); const sampleid = dict['/lab/sampleid'] || dict['/source/sampleid']; let sample = {}; Object.entries(dict).forEach(([path, val]) => { jp.set(sample, path, val); }); results = Object.fromEntries(Object.entries(results || {}) .map(([key, val]) => { let { modusid, element, units } = parseColumnHeaderName(key); let result = {}; if (element) result.analyte = element; if (modusid) result.modusTestID = modusid; if (units) result.units = units; if (val || val === 0) result.value = val; let resultid = `${sampleid}-${md5(JSON.stringify(result))}`; return [resultid, result]; })); return [sampleid, { ...sample, results, }]; })); return unflatten(slim); } function convertNutrientResults(sample) { return Object.fromEntries(Object.values(sample).map((nr) => ([ `${nr.analyte}${nr.modusTestID ? ` (${nr.modusTestID})` : ''} [${nr.units ? nr.units : ''}]`, nr.value, ]))); } export function toCsv(input) { let data = []; if (Array.isArray(input)) { //data = input.map((mr: Slim) => toCsvObject(mr)).flat(1); data = input.map((mr) => toStandardCsv(mr)).flat(1); } else { data = toStandardCsv(input); } let sheet = xlsx.utils.json_to_sheet(data); return { wb: { Sheets: { Sheet1: sheet }, SheetNames: ['Sheet1'], }, str: xlsx.utils.sheet_to_csv(sheet), }; } export function aggregateSlims(slims) { // Handle lists // Files list const slim = { id: '', date: '', type: slims[0].type, samples: {} }; for (const slim of slims) { const flat = flatten(slim); slim.samples = { ...slim.samples, ...flat.samples, }; } return slim; } function aggregateObj(output, input) { return Object.fromEntries(Object.entries(input).map(([key, value]) => { if (typeof output === 'object') { // Things like files array should just get appended together and // utilize IDs to refer samples to each if (Array.isArray(output)) { return [key, output.push(value)]; } else if (output === null) { return [key, value]; } else { // actually is an Object return [key, aggregateObj(value, value)]; } return [key, value]; } return [key, value]; })); } export function disaggregate(slim) { let slims = []; let sampleGroups = Object.values(slim.samples || {}).reduce((groups, sample) => { (groups[sample.documentId] = groups[sample.documentId] || []).push(sample); return groups; }, {}); return Object.values(sampleGroups).map(samples => aggregateSlims(samples)); } export function unflatten(slim) { // iterate over stuff here and if its equal, pull it up top let sample = JSON.parse(JSON.stringify(Object.values(slim.samples || {})[0])); if (!sample) return slim; // Skip results delete sample.results; const dict = jp.dict(sample); delete dict['/lab/sampleid']; delete dict['/lab/containerid']; delete dict['/source/sampleid']; // Create object of {: mostFrequent value} const modeDict = Object.fromEntries(Object.entries(dict).map(([path, val]) => ([ path, mostFrequent(Object.values(slim.samples || {}) .map((sample) => jp.has(sample, path) ? jp.get(sample, path) : undefined)) ]))); // FIXME: Do we limit this process to certain parts of the samples? // Apply the most-common items to the top-level Object.entries(modeDict).forEach(([path, val]) => { jp.set(slim, path, val); }); let sampleEntries = Object.fromEntries(Object.entries(slim.samples || {}).map(([key, sample]) => { for (const [path, val] of Object.entries(modeDict)) { if (fde(val, jp.has(sample, path) ? jp.get(sample, path) : undefined)) { // Remove the path from samples jp.remove(sample, path); // eliminate empty objects after removing stuff } } let cleared = clearEmpties(sample); return [key, cleared]; })); slim.samples = sampleEntries; return slim; } export function unflatten2(slim) { let sample = JSON.parse(JSON.stringify(Object.values(slim.samples || {})[0])); if (!sample) return slim; // Skip results delete sample.results; const dict = jp.dict(sample); let sampleEntries = Object.entries(slim.samples || {}); for (const [path, val] of Object.entries(dict)) { // Every item must match to pull it up to the top if (sampleEntries.every(([k, item]) => fde(val, jp.has(item, path) ? jp.get(item, path) : undefined))) { // Put the value up higher in the slim doc jp.set(slim, path, val); // Remove the path from samples sampleEntries.forEach(([_, sv]) => jp.remove(sv, path)); // eliminate empty objects after removing stuff sampleEntries = sampleEntries.map(se => clearEmpties(se)); } } slim.samples = Object.fromEntries(sampleEntries); return slim; } function mostFrequent(arr) { let countArr = []; let maxCount = 0; let val; for (const item of arr) { let idx = countArr.findIndex(({ value, count }) => item === value); let newCount = 1; if (idx >= 0) { newCount = countArr[idx].count + 1; countArr[idx].count = newCount; } else { countArr.push({ value: item, count: newCount }); } if (newCount > maxCount) { val = item; } } return val; } function clearEmpties(o) { for (var k in o) { if (!o[k] || typeof o[k] !== "object") { continue; // If null or not an object, skip to the next iteration } // The property is an object clearEmpties(o[k]); // <-- Make a recursive call on the nested object if (Object.keys(o[k]).length === 0) { delete o[k]; // The object had no properties, so delete that property } } return o; } export * from '@modusjs/convert/labs/automated.js'; export * from '@modusjs/convert/labs/labConfigs.js'; import debug from 'debug'; import * as xlsx from 'xlsx'; import oerror from '@overleaf/o-error'; import { supportedFileTypes, typeFromFilename } from '@modusjs/convert/json.js'; import * as units from '@modusjs/units'; import jp from 'json-pointer'; import { parseDate } from '@modusjs/convert/labs/index.js'; import { assert as assertSlim } from '@oada/types/modus/slim/v1/0.js'; import md5 from 'md5'; import { autodetectLabConfig, cobbleLabConfig, labConfigsMap, modusKeyToValue, modusKeyToHeader } from '@modusjs/convert/labs/index.js'; const error = debug('@modusjs/convert#csv:error'); const warn = debug('@modusjs/convert#csv:error'); const info = debug('@modusjs/convert#csv:info'); const trace = debug('@modusjs/convert#csv:trace'); const BASEPAT = /^Base Saturation - /; export * as labs from '@modusjs/convert/labs/index.js'; export const supportedFormats = ['generic']; export function parseWorkbook({ wb, str, arrbuf, base64, }) { // Make sure we have an actual workbook to work with: if (!wb) { try { //if (str) wb = xlsx.read(str, { type: 'string'}); if (str) wb = xlsx.read(str, { type: 'string', cellDates: true }); if (arrbuf) wb = xlsx.read(arrbuf, { type: 'array', cellDates: true }); if (base64) wb = xlsx.read(base64, { type: 'base64', cellDates: true }); } catch (e) { throw oerror.tag(e, 'Failed to parse input data with xlsx/csv reader'); } } if (!wb) { throw new Error('No readable input data found.'); } return wb; } export function parseCsv({ wb, str, arrbuf, base64, format, lab, labConfigs, filename, allowOverrides = true, }) { return convert({ ...prep({ wb, str, arrbuf, base64, format, lab, labConfigs }), filename, allowOverrides }); } export function prep({ wb, str, arrbuf, base64, format, lab, labConfigs, filename, }) { // Make sure we have an actual workbook to work with: wb = parseWorkbook({ wb, str, arrbuf, base64 }); if (!format) format = 'generic'; switch (format) { case 'generic': return parseWorksheets({ wb, lab, labConfigs }); default: throw new Error(`format type ${format} not currently supported`); } } export function partitionSheets(wb) { // Grab the point meta data out of any master sheet: // Any sheet whose name contains "point meta" regardless of spacing, case, or punctuation will be considered // point metadata. i.e. 'Point Meta-Data' would match as well as 'pointmetadata' const matchsheet = wb.SheetNames.find(isPointMetadataSheetname); let metadatasheet; if (matchsheet) { const rows = xlsx.utils // Without { raw: false } in sheet_to_json, dates will be parsed as excel ints instead of the formatted strings .sheet_to_json(wb.Sheets[matchsheet], { raw: false }) .map(keysToUpperNoSpacesDashesOrUnderscores); metadatasheet = { rows, sheetname: matchsheet }; trace('metadatasheet:', metadatasheet.sheetname); } let sheetnames = wb.SheetNames.length > 1 && wb.SheetNames.some(name => name.toLowerCase().includes("raw")) ? wb.SheetNames.filter(name => name.toLocaleLowerCase().includes("raw")) : wb.SheetNames; const datasheets = sheetnames .filter(sh => !isPointMetadataSheetname(sh)) .map(sheetname => { const sheet = wb.Sheets[sheetname]; const allrows = xlsx.utils.sheet_to_json(sheet, { defval: '' }); // include empty column values! undefined doesn't seem to get empty cols to show up. const rows = allrows .map((r) => Object.fromEntries(Object.entries(r) .filter(([key, _]) => !key.startsWith('__EMPTY')) .map(([key, val]) => ([key.trim(), val])))) .filter(isDataRow); // Get a list of all the header names for future reference as needed. Since // keys are omitted for rows where a column has no value, we must look // through all the rows and accumulate the unique set of column headers. const colnames = [...new Set(rows.map((obj) => Object.keys(obj)) .reduce((prev, cur) => prev.concat(cur), []))]; return { sheetname, allrows, rows, colnames }; }); return { metadatasheet, datasheets }; } function getPointMeta(metadatasheet, labConfig) { const pointmeta = {}; for (const r of metadatasheet.rows) { const id = modusKeyToValue(r, 'SampleNumber', labConfig) || r['POINTID'] || r['FMISSAMPLEID'] || r['SAMPLEID']; if (!id) continue; pointmeta[id] = r; } return pointmeta; } export function getOrAutodetectLab({ datasheets, allowImprovise, labConfigs: userLabConfigs, }) { const labConfig = datasheets.map(({ sheetname, colnames }) => autodetectLabConfig({ headers: colnames, sheetname })) .find(sh => sh); if (labConfig) info(`Using LabConfig: ${labConfig.name}`); if (!allowImprovise) return labConfig; return labConfig || datasheets.map(({ colnames }) => cobbleLabConfig(colnames)) .find(sh => sh); } function groupRows(rows, datecol) { return rows.reduce((groups, r) => { let date = datecol ? r[datecol] : 'Unknown Date'; if (date === 'NA') date = 'Unknown Date'; if (date !== 'Unknown Date') date = parseDate(date); date = date instanceof Date ? date.toISOString().split('T')[0] : date; trace('Determined row date from column', datecol, 'as', date); if (!date) { warn('WARNING: row does not have the column we chose for the date (', datecol, '), the row is: ', r); return groups; } if (!groups[date]) groups[date] = []; groups[date].push(r); return groups; }, {}); } function parseWorksheets({ wb, lab, labConfigs, }) { const { metadatasheet, datasheets } = partitionSheets(wb); const labConfig = (lab && typeof lab === 'string') ? labConfigsMap.get(lab) : getOrAutodetectLab({ datasheets }); if (!labConfig) warn(`LabConfig was either not supplied or not auto-detected. It may parse if using standardized CSV input...`); let pointMeta; if (metadatasheet) pointMeta = getPointMeta(metadatasheet, labConfig); trace('datasheets:', datasheets); return { datasheets, labConfig, pointMeta }; } function convert({ datasheets, labConfig, pointMeta, filename, allowOverrides = true, }) { const ret = []; for (const { sheetname, allrows, rows, colnames } of datasheets) { // Grab the unit overrides and get rid of comment/units columns let unitOverrides = allowOverrides ? extractUnitOverrides(allrows) : undefined; trace('Have', rows.length, 'rows from sheetname: ', sheetname); // Parse structured header format for reverse conversion let headers = Object.fromEntries(colnames.map(n => ([n, { ...parseColumnHeaderName(n, labConfig), unitsOverride: unitOverrides?.[n], }]))); // Determine a "date" column for this dataset // FIXME: This date is important; consider enumerating some options let datecol = 'ReportDate' in rows[0] ? 'ReportDate' : modusKeyToHeader('ReportDate', colnames, labConfig) ?? colnames.find((name) => name.toUpperCase().match(/DATE/)); if (!datecol) { error('No date column in sheet', sheetname, ', columns are:', colnames); } // Loop through all the rows and group them by that date. This group will // become a single Slim file. const grouped_rows = groupRows(rows, datecol); let groupcount = 0; for (const [date, g_rows] of Object.entries(grouped_rows)) { if (date === 'Unknown Date') { //FIXME: Quick fix: just skip it. Or should we rearchitect to losslessly transform and // retain missing data in the output. continue; } groupcount++; let output = { date, lab: {}, samples: {}, }; setPath(output, '/type', (labConfig?.type || modusKeyToValue(g_rows[0], 'LabType', labConfig) || 'Soil').toLowerCase()); if (output.type === 'plant') output.type = 'plant-tissue'; for (const [_, row] of g_rows.entries()) { //event = setMappings(event, 'event', row, labConfig); //TODO: Like this or { ...event, getMappings } ? setPath(output, `/lab/name`, modusKeyToValue(row, 'LabName', labConfig) || labConfig?.name); //FIXME: Which should be used here? Lab Event or Lab Report?? setPath(output, `/lab/report/id`, modusKeyToValue(row, 'LabReportID', labConfig)); setPath(output, `/id`, modusKeyToValue(row, 'ReportID', labConfig) || modusKeyToValue(row, 'LabEventtID', labConfig)); setPath(output, `/lab/dateProcessed`, modusKeyToValue(row, 'DateProcessed', labConfig) || date); if (output.lab.dateProcessed && !output.lab.dateProcessed.includes('T')) output.lab.dateProcessed += 'T00:00:00+00:00'; setPath(output, `/lab/dateReceived`, modusKeyToValue(row, 'DateReceived', labConfig) || date); if (output.lab.dateReceived && !output.lab.dateReceived.includes('T')) output.lab.dateReceived += 'T00:00:00+00:00'; setPath(output, `/lab/contact/name`, modusKeyToValue(row, 'LabContactName', labConfig)); setPath(output, `/lab/contact/address`, modusKeyToValue(row, 'LabContactAddress', labConfig)); setPath(output, `/lab/contact/Phone`, modusKeyToValue(row, 'LabContactPhone', labConfig)); setPath(output, `/lab/clientAccount/accountNumber`, modusKeyToValue(row, 'ClientAccountNumber', labConfig)); setPath(output, `/lab/clientAccount/name`, modusKeyToValue(row, 'ClientName', labConfig)); setPath(output, `/lab/clientAccount/company`, modusKeyToValue(row, 'ClientCompany', labConfig)); //setPath(output, `/lab/clientAccount/contact/name`, modusKeyToValue(row, 'ClientContactName', labConfig)) setPath(output, `/lab/clientAccount/address`, modusKeyToValue(row, 'ClientAddress', labConfig)); //setPath(output, `/lab/clientAccount/address2`, modusKeyToValue(row, 'ClientAddress2', labConfig)); setPath(output, `/lab/clientAccount/city`, modusKeyToValue(row, 'ClientCity', labConfig)); setPath(output, `/lab/clientAccount/state`, modusKeyToValue(row, 'ClientState', labConfig)); setPath(output, `/lab/clientAccount/zip`, modusKeyToValue(row, 'ClientZip', labConfig)); // TODO: Sheet name details within that file? setPath(output, `/lab/files`, [{ name: filename, //id: ? //description: ? //base64: ? }]); // Ensure the IDs are different let id = '' + modusKeyToValue(row, 'SampleContainerID', labConfig) || '' + modusKeyToValue(row, 'SampleNumber', labConfig) || '' + modusKeyToValue(row, 'FMISSampleID', labConfig); let counter = 1; let counterId = id; while (jp.has(output, `/samples/${counterId}`)) { counter++; counterId = `${id}-${counter}`; } id = counterId; const meta = pointMeta?.[id]; setPath(output, `/source/grower/name`, modusValFromRowOrMeta(row, 'GrowerName', labConfig, meta)); setPath(output, `/source/grower/id`, modusValFromRowOrMeta(row, 'Grower', labConfig, meta)); setPath(output, `/source/farm/name`, modusValFromRowOrMeta(row, 'FarmName', labConfig, meta)); setPath(output, `/source/farm/id`, modusValFromRowOrMeta(row, 'Farm', labConfig, meta)); setPath(output, `/source/field/name`, modusValFromRowOrMeta(row, 'FieldName', labConfig, meta)); setPath(output, `/source/field/id`, modusValFromRowOrMeta(row, 'Field', labConfig, meta)); setPath(output, `/source/subfield/name`, modusValFromRowOrMeta(row, 'SubFieldName', labConfig, meta)); setPath(output, `/source/subfield/id`, modusValFromRowOrMeta(row, 'SubField', labConfig, meta)); let nutrientResults = parseNutrientResults({ row, headers, labConfig, }); // Units can come from several places nutrientResults = setNutrientResultUnits({ nutrientResults, unitOverrides, labConfig, headers, }); nutrientResults = units.convertUnits(nutrientResults); // Why did this thing need ''+modusValFromRowOrMeta(...)?? Its no good if it becomes 'undefined' //setPath(output, `/samples/${id}/source/sampleid`, ''+modusValFromRowOrMeta(row, 'FMISSampleID', labConfig, meta)); setPath(output, `/samples/${id}/source/sampleid`, modusValFromRowOrMeta(row, 'FMISSampleID', labConfig, meta)); setPath(output, `/samples/${id}/lab/containerid`, modusKeyToValue(row, 'SampleContainerID', labConfig)); setPath(output, `/samples/${id}/lab/sampleid`, modusKeyToValue(row, 'SampleNumber', labConfig)); setPath(output, `/samples/${id}/results`, Object.fromEntries(nutrientResults.map(nr => { const out = {}; //TODO: Spec out all of these things in slim // Decide on modus test id handling v1 vs v2 if (nr.Element) out.analyte = nr.Element; if (nr.CsvHeader) out.csvHeader = nr.CsvHeader; if (nr.ModusTestID) out.modusTestID = nr.ModusTestID; if (nr.ModusTestIDv2) out.modusTestID = nr.ModusTestIDv2; if (nr.UCUM_ValueUnit) out.ucumUnits = nr.UCUM_ValueUnit; if (nr.ValueUnit) out.units = nr.ValueUnit; if (nr.Value || nr.Value === 0) out.value = nr.Value; if (nr.ValueDesc) out.valueDescription = nr.ValueDesc; if (nr.ValueType) out.valueType = nr.ValueType; return [`${id}-${md5(JSON.stringify(out))}`, out]; }))); //TODO: make extra unmapped columns of the CSV into additional properties of the sample let additionalCols = Object.entries(labConfig?.mappings || {}) .filter(([key, val]) => val === undefined) .map(([key, val]) => key) .forEach(([key, val]) => { //setPath(output, `/samples/${id}/${key}`, row[key]); }); if (output.type === 'soil') { setPath(output, `/samples/${id}/depth`, parseDepth(row, headers, labConfig)); } if (output.type === 'plant-tissue') { setPath(output, `/source/crop`, modusValFromRowOrMeta(row, 'Crop', labConfig, meta)); setPath(output, `/source/growthStage`, modusValFromRowOrMeta(row, 'GrowthStage', labConfig, meta)); setPath(output, `/source/subGrowthStage`, modusValFromRowOrMeta(row, 'SubGrowthStage', labConfig, meta)); setPath(output, `/source/plantPart`, modusValFromRowOrMeta(row, 'PlantPart', labConfig, meta)); } // FIXME: Implement this? // Write/override any additional labConfig Mappings into the modus output //sample = setMappings(sample, 'sample', row, labConfig); // Parse locations: either in the sample itself or in the meta. Sample takes precedence over meta. let ll = parseLocation(meta) || parseLocation(row); if (ll) { setPath(output, `/samples/${id}/geolocation`, ll); } } // end rows for this group if (!output.id) { output.id = md5(JSON.stringify(output)); } //Collapse everything up into the templates //output = flatten(output); //FIXME: remove top-level keys if they are empty try { assertSlim(output); } catch (e) { error('assertSlim failed for sheetname', sheetname, ', group date', date); throw oerror.tag(e, `Could not construct a valid Slim from sheet ${sheetname}, group date ${date}`); } ret.push(output); } // end looping over all groups } // end looping over all the sheets return ret; } function keysToUpperNoSpacesDashesOrUnderscores(obj) { const ret = {}; for (const [key, val] of Object.entries(obj)) { const newkey = key.toUpperCase().replace(/([ _]|-)*/g, ''); ret[newkey] = typeof val === 'object' ? keysToUpperNoSpacesDashesOrUnderscores(val) : val; } return ret; } function isPointMetadataSheetname(name) { return name .replace(/([ _,]|-)*/g, '') .toUpperCase() .match('POINTMETA'); } function setNutrientResultUnits({ nutrientResults, headers, unitOverrides, labConfig, }) { return nutrientResults.map(nr => { const header = Object.values(headers).find(h => h.original === nr.CsvHeader); const override = header?.original ? unitOverrides?.[header?.original] : undefined; const headerUnit = header?.units; const labConfigUnit = header ? labConfig?.units?.[header.original] : undefined; trace(`Ordered unit prioritization of ${nr.Element}: Override:[${override}] ` + `> Header:[${headerUnit}] > LabConfig:[${labConfigUnit}]`); return { ...nr, ValueUnit: override || headerUnit || labConfigUnit, }; }); } function extractUnitOverrides(rows) { const overrides = {}; const unitrows = rows.filter(isUnitRow); // There really should only be one units row for (const r of unitrows) { for (const [key, val] of Object.entries(r)) { if (!val) continue; // type-inferred 'nothing' should just not be here // keep all the key/value pairs EXCEPT the one that indicated this was a UNITS row if (typeof val === 'string' && val.trim() === 'UNITS') continue; overrides[key] = val; } } return overrides; } function modusValFromRowOrMeta(row, item, labConfig, meta) { let val = modusKeyToValue(row, item, labConfig) || modusKeyToValue(meta, item, labConfig); if (val === undefined) return val; return '' + val; } function isDataRow(row) { const first = !isCommentRow(row); const second = !isUnitRow(row); const third = !isEmptyRow(row); return first && second && third; } function isEmptyRow(row) { if (typeof row !== 'object') return true; for (const val of Object.values(row)) { if (val) return false; // found anything in the object that is not empty } return true; } function isCommentRow(row) { return !!Object.values(row).find((val) => typeof val === 'string' && val.trim() === 'COMMENT'); } function isUnitRow(row) { return !!Object.values(row).find((val) => typeof val === 'string' && val.trim() === 'UNITS'); } function parseLocation(meta_or_row) { if (meta_or_row === undefined) return undefined; let copy = keysToUpperNoSpacesDashesOrUnderscores(meta_or_row); let lonKey = Object.keys(copy).find((key) => key.includes('LONGITUDE')); let latKey = Object.keys(copy).find((key) => key.includes('LATITUDE')); if (copy['LONG']) lonKey = 'LONG'; if (copy['LNG']) lonKey = 'LNG'; if (copy['LON']) lonKey = 'LON'; if (copy['LAT']) latKey = 'LAT'; if (!lonKey) { //trace('No longitude for point: ', meta_or_row.POINTID || meta_or_row.FMISSAMPLEID || meta_or_row.SAMPLEID); return; } if (!latKey) { //trace('No latitude for point: ', meta_or_row.POINTID || meta_or_row.FMISSAMPLEID || meta_or_row.SAMPLEID); return; } let lon = +copy[lonKey]; let lat = +copy[latKey]; return { lon, lat }; } function extractBetween(str, startChar, endChar) { const start = str.indexOf(startChar); const end = str.lastIndexOf(endChar); if (start < 0) return; // start char not found if (start > str.length - 1) return ''; // start char at end of string if (end < 0) return str.slice(start + 1); // end not found, return start through end of string return str.slice(start + 1, end); // start+1 to avoid including the start/end chars in output } function extractBefore(str, startChars) { let chars = Array.isArray(startChars) ? startChars : [startChars]; const firsts = chars .map(char => str.indexOf(char)) .filter(idx => idx > -1); if (firsts.length === 0) return str; // start char not found const start = Math.min(...firsts); return str.slice(0, start); } export function parseColumnHeaderName(original, labConfig) { original = original .trim() .replace(/\n/g, ' ') .replace(/ +/g, ' '); const element = extractBefore(original, ['(', '[']).trim() || original; const units = extractBetween(original, '[', ']')?.trim(); original = original.replace(`${units}`, ''); const modusid = extractBetween(original, '(', ')')?.trim(); const nutrientResult = labConfig?.analytes[element] || { Element: element }; return { original, element, modusid, units, nutrientResult, }; } function parseNutrientResults({ row, headers, labConfig }) { // How is Object.keys(row) different from headers? let nutrientResults = Object.keys(row).filter(key => { // Rows outside of the labconfig one way or another if (!labConfig?.analytes[key]) { // Things we know are not analytes: depth values (may have units); if (key.toLowerCase().includes('depth')) return false; // Standardized CSV output information if (headers?.[key]?.units || headers?.[key]?.modusid) return true; return false; } else { // if (!isNaN(+(row[key].replace('>', '').replace('<', ''))) && row[key] !== '') if ((typeof row[key] === 'string' && row[key] !== '') || typeof row[key] === 'number') return true; return false; } }).map((key) => { let Value = !isNaN(+row[key]) ? +row[key] : row[key]; if (labConfig?.analytes?.[key]) { return { ...labConfig.analytes[key], Value, }; } else { return { Element: headers[key].element, ValueUnit: headers?.[key]?.units, ModusTestID: headers?.[key]?.modusid, CsvHeader: headers?.[key]?.original, Value, }; } }); // Now eliminate duplicate Base Saturation entries return nutrientResults.filter((v, i) => !nutrientResults.some((item, j) => (v.Element === item.Element && i !== j && BASEPAT.test(v.Element) && BASEPAT.test(item.Element) && v.ValueUnit !== '%'))); } function parseDepth(row, headers, labConfig) { let depthInfo = typeof labConfig?.depthInfo !== 'function' ? // @ts-ignore labConfig?.depthInfo : labConfig?.depthInfo(row); // Depth info can come from: // 1) Within the spreadsheet, as columns for each property // 2) Within the spreadsheet with units parsed from headers and overrides // 3) Data that lives outside of the spreadsheet, e.g., based on some lab config // 4) Some combination of the row data with custom logic applied (depthInfo as // a function) const colnames = Object.keys(row); const depth = {}; let startHeader = modusKeyToHeader('StartingDepth', colnames, labConfig); let startVal = startHeader ? row[startHeader] : undefined; let startOverride = startHeader ? headers[startHeader]?.unitsOverride : undefined; depth.top = modusKeyToValue(row, 'StartingDepth', labConfig) || depthInfo?.StartingDepth || 0; let endHeader = modusKeyToHeader('EndingDepth', colnames, labConfig); let endVal = endHeader ? row[endHeader] : undefined; let endOverride = endHeader ? headers[endHeader]?.unitsOverride : undefined; depth.bottom = modusKeyToValue(row, 'EndingDepth', labConfig) || depthInfo?.EndingDepth || depth.top; let depHeader = modusKeyToHeader('ColumnDepth', colnames, labConfig); let depVal = depHeader ? row[depHeader] : undefined; let depOverride = depHeader ? headers[depHeader]?.unitsOverride : undefined; // Pull the information from the column value let valDepthUnit; if (!depVal) { [' to ', ' - '].some((pattern) => [startVal, endVal, depVal].some((val) => { if (typeof val === 'string' && val?.match(pattern)) { const pieces = val.split(pattern); depth.top = parseInt(pieces[0]) || 0; depth.bottom = parseInt(pieces[1]) || 0; if (pieces[1]?.includes('cm')) valDepthUnit = 'cm'; if (pieces[1]?.includes('mm')) valDepthUnit = 'mm'; if (pieces[1]?.includes('in')) valDepthUnit = 'in'; } })); } if (depth.bottom === 0) return undefined; const depthUnit = startOverride || endOverride || depOverride; depth.units = depthUnit || valDepthUnit || modusKeyToValue(row, 'DepthUnit', labConfig) || depthInfo?.DepthUnit; // || 'cm'; // We have to have units so default to cm? if (!depth.units) delete depth.units; depth.name = modusKeyToValue(row, 'DepthName', labConfig) || depthInfo?.Name || depth.bottom === 0 ? 'Unknown Depth' : `${depth.top} to ${depth.bottom}${depth.units ? ' ' + depth.units : ''}`; return depth; } export function handleExtraHeaders(row, labConfig) { // Does the header have the same value across all rows? Apply it to the top-level // Is the thing an analyte with units? Should it have already been detected even if it was // not in the labConfig? const extras = Object.keys(row).filter((key) => !(key in labConfig.analytes || key in labConfig.mappings)); } export async function toLabConfig(files, labConfigs) { if (!Array.isArray(files)) { files = [files]; } let results = []; for (const file of files) { const format = file.format || 'generic'; let original_type = typeFromFilename(file.filename); if (!original_type) { warn('WARNING: unable to determine file type from filename', file.filename, '. Supported types are:', supportedFileTypes, '. Skipping file.'); continue; } if (original_type === 'csv' || original_type === 'xlsx') { //TODO: Implement this against LabConfigs? if (!supportedFormats.find((f) => f === format)) { warn('ERROR: format', format, 'is not supported for file', file.filename, '. Supported formats are: ', supportedFormats, '. Skipping file.'); continue; } } switch (original_type) { case 'zip': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'xml': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'json': info(`Lab configurations can only be generated from .csv/.xlsx files. Skipping ${file.filename}`); continue; case 'csv': break; case 'xlsx': break; } const base = { original_filename: file.filename, original_type }; const type = original_type; // just to make things shorter later in json filename determination const filename = file.filename; let output_filename = ''; let wbinfo = null; try { switch (original_type) { case 'csv': case 'xlsx': let parseargs; if (original_type === 'csv') parseargs = { str: file.str, format }; else { if (file.arrbuf) parseargs = { arrbuf: file.arrbuf, format, }; // checked for at least one of these above else parseargs = { base64: file.base64, format }; } const wbinfo = prep({ ...parseargs, labConfigs }); // for (const [index, wbinfo] of all_wbinfo.entries()) { const filename_args = { wbinfo, type, filename }; // if (all_wbinfo.length > 1) { // multiple things, then use the index // filename_args.index = index; //} results.push({ ...wbinfo, ...base }); //} break; } } catch (e) { if (e.errors && e.input && Array.isArray(e.errors)) { // AJV error warn('ERROR: failed to validate file', file.filename); for (const ajv_error of e.errors) { warn('Path', ajv_error.instancePath, ajv_error.message); // '/path/to/item' 'must be an array' } } else { warn('ERROR: failed to read file', file.filename); console.log(e); } continue; // if error, move on to the next file } } // end for loop on filenames return results; } export function setPath(output, outPath, data, condition) { const curVal = jp.has(output, outPath) ? jp.get(output, outPath) : undefined; const newVal = curVal ?? data; if (newVal === undefined) { return; } // FIXME: Revisit these rules // don't set empty arrays or certain falsy values if ((Array.isArray(newVal) && newVal.length > 0) || (newVal || newVal === 0 || newVal === false)) { jp.set(output, outPath, newVal); } return; } import debug from 'debug'; import { labConfigsMap, parseMappingValue, toModusJsonPath } from '@modusjs/convert/labs/labConfigs.js'; const info = debug('@modusjs/convert#labs-automated:info'); const trace = debug('@modusjs/convert#labs-automated:trace'); const warn = debug('@modusjs/convert#labs-automated:warn'); const error = debug('@modusjs/convert#labs-automated:error'); export function cobbleLabConfig(headers, userLabConfigs) { const list = userLabConfigs || Array.from(labConfigsMap.values()); warn(`Attempting to identify header matches individually.`); //1. Find modus mappings (non-analytes) let lcMappings = list .map(lc => Object.fromEntries(Object.entries(lc.mappings).map(([k, v]) => ([keysToUpperNoSpacesDashesOrUnderscores(k), v])))); let mappings = {}; headers.forEach((h) => { //Find potential matches const copy = keysToUpperNoSpacesDashesOrUnderscores(h); let lcMatch = lcMappings.find(lc => lc[copy]); if (lcMatch !== undefined) mappings[h] = lcMatch[h]; }); let remaining = headers.filter(h => !mappings[h]); //2. Look for date column if (!Object.values(mappings).find(v => v === 'ReportDate')) { let datecol = getDateColumn(headers); mappings[datecol] = "ReportDate"; remaining = remaining.filter(h => h !== datecol); } //2b. Look for lab number //3. Look for depth data columns //remaining = remaining.filter(h => h !== ) let lcAnalytes = list .map(lc => Object.fromEntries(Object.entries(lc.analytes).map(([_, v]) => ([keysToUpperNoSpacesDashesOrUnderscores(v.CsvHeader || v.Element), v])))).flat(1); let analytes = {}; remaining.forEach((h) => { //Find potential matches const copy = keysToUpperNoSpacesDashesOrUnderscores(h); let lcMatch = lcAnalytes.find(lc => lc[copy]); if (lcMatch) analytes[h] = { Element: lcMatch[h].Element }; }); remaining = remaining.filter(h => !analytes[h]); const units = Object.fromEntries(Object.entries(analytes).map(([key, val]) => ([key, val?.ValueUnit]))); if (remaining.length > 0) trace(`Remaining unrecognized headers:`, remaining); return { units, analytes, headers, name: 'Automated', type: 'Automated', mappings, }; } export function getDateColumn(headers) { // Ensure we have a "date" column for this dataset let datecol = headers .sort() .find((name) => name.toUpperCase().match(/DATE/)); if (headers.find((c) => c.match(/DATESUB/))) { trace(`Found DATESUB column, using that for date.`); datecol = 'DATESUB'; // A&L West Semios return datecol; } else { error('No date column in sheet, columns are:', headers); throw new Error(`Could not find a column containing 'date' in the name to use as the date in sheet. A date is required.`); } } export function autodetectLabConfig({ headers, sheetname, labConfigs: userLabConfigs, }) { let match = (userLabConfigs || Array.from(labConfigsMap.values())).find((labConfig) => labMatches(headers, labConfig)); if (match) { info(`Recognized sheet ${sheetname !== undefined ? `[${sheetname}] ` : ''}as lab: ${match.name}`); return match; } else { warn(`No matches found while attempting to autodetect LabConfig.`); return undefined; } } function labMatches(headers, lab) { return headers.every((header) => { if (lab.headers.indexOf(header) <= -1) { // If the same header appears twice in a csv, the library results in //
_ where n is 1,2,3...for each repeat; let reg = /[_\d]+$/; if (reg.test(header)) { const h = header.replace(reg, ''); if (lab.headers.indexOf(h) > -1) return true; } trace(`Header string "${header}" not in ${lab.name} LabConfig`); } return lab.headers.indexOf(header) > -1; }); } export function keysToUpperNoSpacesDashesOrUnderscores(obj) { const ret = {}; for (const [key, val] of Object.entries(obj)) { const newkey = key.toUpperCase().replace(/([ _]|-)*/g, ''); ret[newkey] = typeof val === 'object' ? keysToUpperNoSpacesDashesOrUnderscores(val) : val; } return ret; } export function modusKeyToHeader(item, colnames, labConfig) { if (!labConfig) return undefined; let match = Object.entries(labConfig.mappings).find(([k, v]) => (Array.isArray(v) ? v.some(k => k === item) : v === item) && colnames.includes(k)); return match?.[0]; } export function modusKeyToValue(row, item, labConfig) { // Handle undefined metasheet if (!row) return; // The standard CSV no longer uses the same set of headers as used in 'item' strings; it instead uses jsonpaths //if (item in row) return row[item]; // Handle the universal CSV let match = modusKeyToHeader(item, Object.keys(row), labConfig); if (match) { let mapping = toModusJsonPath[item]; return parseMappingValue(row[match], mapping); } return ''; } function parseDepth(row, labConfig, units) { let obj = { DepthUnit: 'cm', //default to cm }; // Get columns with the word depth const copy = keysToUpperNoSpacesDashesOrUnderscores(row); const unitsCopy = keysToUpperNoSpacesDashesOrUnderscores(units); let depthKey = Object.keys(copy).find((key) => key.match(/DEPTH/)); if (depthKey) { let value = copy[depthKey].toString(); if (unitsCopy[depthKey]) obj.DepthUnit = unitsCopy[depthKey]; if (value.match(' to ')) { obj.StartingDepth = +value.split(' to ')[0]; obj.EndingDepth = +value.split(' to ')[1]; obj.Name = value; } else if (value.match(' - ')) { obj.StartingDepth = +value.split(' - ')[0]; obj.EndingDepth = +value.split(' - ')[1]; obj.Name = value; } else { obj.StartingDepth = +value; obj.Name = value; } } if (row['B Depth']) obj.StartingDepth = +row['B Depth']; if (row['B Depth']) obj.Name = '' + row['B Depth']; if (units['B Depth']) obj.DepthUnit = units['B Depth']; // Assume same for both top and bottom if (row['E Depth']) obj.EndingDepth = +row['E Depth']; //Insufficient data found if (typeof obj.StartingDepth === 'undefined') { warn('No depth data was found. Falling back to default depth object.'); trace('Row without depth was: ', row); return { StartingDepth: 0, EndingDepth: 8, DepthUnit: 'in', Name: 'Unknown Depth', ColumnDepth: 8, }; } //Handle single depth value obj.EndingDepth = obj.EndingDepth || obj.StartingDepth; //Now compute column depth obj.ColumnDepth = Math.abs(obj.EndingDepth - obj.StartingDepth); return obj; } import debug from 'debug'; import * as industry from '@modusjs/industry'; import moment from 'moment'; import { getJsDateFromExcel } from 'excel-date-to-js'; import dayjs from 'dayjs'; import jp from 'jsonpath'; import { default as a_l_ftwayne_soil } from '@modusjs/convert/labs/soil/a_l_ftwayne.js'; import { default as a_l_west_soil } from '@modusjs/convert/labs/soil/a_l_west.js'; import { default as a_l_wisc_soil } from '@modusjs/convert/labs/soil/a_l_wisc.js'; import { default as soiltest_soil } from '@modusjs/convert/labs/soil/soiltestfarmconsultants.js'; import { default as tomkat_soil } from '@modusjs/convert/labs/soil/tomkat.js'; import { default as a_l_west_plant } from '@modusjs/convert/labs/plant/a_l_west.js'; import { default as kuo_soil } from '@modusjs/convert/labs/soil/kuo.js'; import { default as brookside_soil } from '@modusjs/convert/labs/soil/brookside.js'; import { default as cquester_soil } from '@modusjs/convert/labs/soil/cquester.js'; import { default as uga } from '@modusjs/convert/labs/soil/uga.js'; import { default as ward_soil } from '@modusjs/convert/labs/soil/ward.js'; const warn = debug('@modusjs/convert#labConfigs:warn'); export let localLabConfigs = [ a_l_ftwayne_soil, a_l_west_soil, a_l_wisc_soil, a_l_west_plant, brookside_soil, tomkat_soil, soiltest_soil, kuo_soil, cquester_soil, uga, ward_soil, ]; const industryLabConfigs = industry.labConfigs; export const labConfigs = Object.fromEntries(Object.entries(industryLabConfigs) // Merge the industry config data with local lab configs .map(([labName, lab]) => ([labName, Object.fromEntries(Object.entries(lab) .map(([labType, industryLabConf]) => { const localLabConf = localLabConfigs.find((llc) => llc.name === labName && llc.type === labType); return [labType, composeLabConfig(localLabConf, industryLabConf)]; }))]))); localLabConfigs .filter((llc) => !industryLabConfigs[llc.name]?.[llc.type]) .forEach(llc => { labConfigs[llc.name] = labConfigs[llc.name] ?? {}; labConfigs[llc.name][llc.type] = composeLabConfig(llc); }); function composeLabConfig(a, b) { if (!a && !b) throw new Error('At least one of local or industry lab config must be supplied'); let combined = { ...b, ...a, // Make sure these keys exist and merge favoring local. mappings: { ...b?.mappings, ...a?.mappings, }, analytes: { ...b?.analytes, ...a?.analytes } }; // @ts-expect-error name will exist if the code reaches here return { ...combined, units: Object.fromEntries(Object.entries(combined.analytes).map(([k, val]) => ([k, val?.ValueUnit]))), headers: [ ...Object.keys(combined.analytes), ...Object.keys(combined.mappings || {}), ], }; } export const labConfigsMap = new Map(Object.values(labConfigs).map(lab => Object.values(lab).map((labConf, i) => [ `${labConf.name}-${labConf.type ?? i}`, labConf ])).flat(1)); export const toModusJsonPath = { //Per-event basis 'ReportDate': { type: 'event', path: '$.EventMetaData.EventDate', fullpath: '$.Events.*.EventMetaData.EventDate', parse: 'date', description: 'Top-level date assigned to this MODUS event', slim: '/date', }, 'ReportID': { type: 'event', path: '$.EventMetaData.EventCode', fullpath: '$.Events.*.EventMetaData.EventCode', description: 'Top-level code assigned to this MODUS event', slim: '/id', }, 'ReportType': { type: 'event', path: '$.EventMetaData.EventType', fullpath: '$.Events.*.EventMetaData.EventCode', description: 'Top-level code assigned to this MODUS event', slim: '/id', }, 'Crop': { //type: 'event', path: '$.EventMetaData.EventType.Plant.Crop', fullpath: '$.Events.*.EventMetaData.EventType.Plant.Crop', description: 'Crop name of the plant tissue sample submitted', slim: `/crop` }, 'PlantPart': { type: 'event', path: '$.EventType.Plant.PlantPart', fullpath: '$.Events.*.EventMetaData.EventType.Plant.PlantPart', description: 'Plant part name of the plant tissue sample submitted', slim: `/plantPart` }, 'Grower': { type: 'event', path: '$.FMISMetaData.FMISProfile.Grower', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Grower', description: 'Grower name assigned by the FMIS that submitted the samples', slim: `/source/grower/id` }, 'GrowerName': { type: 'event', path: '$.FMISMetaData.FMISProfile.Grower', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Grower', description: 'Grower name assigned by the FMIS that submitted the samples', slim: `/source/grower/name` }, 'FarmName': { type: 'event', path: '$.FMISMetaData.FMISProfile.Farm', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Farm', description: 'Farm name assigned by the FMIS that submitted the samples', slim: `/source/farm/name` }, 'Farm': { type: 'event', path: '$.FMISMetaData.FMISProfile.Farm', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Farm', description: 'Farm name assigned by the FMIS that submitted the samples', slim: `/source/farm/id` }, 'Field': { type: 'event', path: '$.FMISMetaData.FMISProfile.Field', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Field', description: 'Field name assigned by the FMIS that submitted the samples', slim: `/source/field/id` }, 'FieldName': { type: 'event', path: '$.FMISMetaData.FMISProfile.Field', fullpath: '$.Events.*.FMISMetaData.FMISProfile.Field', description: 'Field name assigned by the FMIS that submitted the samples', slim: `/source/field/name` }, 'SubField': { type: 'event', path: `$.FMISMetaData.FMISProfile["Sub-Field"]`, fullpath: '$.Events.*.FMISMetaData.FMISProfile["Sub-Field"]', description: 'Subfield name assigned by the FMIS that submitted the samples', slim: `/source/subfield/id` }, 'SubFieldName': { type: 'event', path: `$.FMISMetaData.FMISProfile["Sub-Field"]`, fullpath: '$.Events.*.FMISMetaData.FMISProfile["Sub-Field"]', description: 'Subfield name assigned by the FMIS that submitted the samples', slim: `/source/subfield/name` }, 'LabEventID': { type: 'event', path: '$.LabMetaData.LabEventID', fullpath: '$.Events.*.LabMetaData.LabEventID', parse: 'string', description: 'The ID of the sample processing event as assigned by the lab', slim: `/lab/report/id` }, 'LabID': { type: 'event', path: '$.LabMetaData.LabID', fullpath: '$.Events.*.LabMetaData.LabID', parse: 'string', description: 'The ID of the lab that performed the analysis.', slim: '/lab/id', }, 'DateProcessed': { type: 'event', path: '$.LabMetaData.ProcessedDate', fullpath: '$.Events.*.LabMetaData.ProcessedDate', parse: 'date', description: 'Date samples processed by the lab', slim: '/lab/dateProcessed', }, 'DateReceived': { type: 'event', path: '$.LabMetaData.ReceivedDate', fullpath: '$.Events.*.LabMetaData.ReceivedDate', parse: 'date', description: 'Date samples received by the lab', slim: '/lab/dateReceived', }, 'LabContactName': { type: 'event', path: '$.LabMetaData.Contact.Name', fullpath: '$.Events.*.LabMetaData.Contact.Name', description: 'The name of the lab contact that submitted the samples.', slim: '/lab/contact/name', }, 'LabContactAddress': { type: 'event', path: `$.LabMetaData.Contact.Address`, fullpath: `$.Events.*.LabMetaData.Contact.Address`, description: 'The street address of the lab client that submitted the samples.', slim: '/lab/contact/address', }, 'LabContactPhone': { type: 'event', path: `$.LabMetaData.Contact.Phone`, fullpath: `$.Events.*.LabMetaData.Contact.Phone]`, description: 'The phone number of the lab contact.', slim: '/lab/contact/phone', }, 'ClientAddress': { type: 'event', path: `$.LabMetaData.ClientAccount['Address 1']`, fullpath: `$.Events.*.LabMetaData.ClientAccount['Address 1']`, description: 'The street address of the lab client that submitted the samples.', slim: '/lab/clientAccount/address', }, 'ClientAddress2': { type: 'event', path: `$.LabMetaData.ClientAccount['Address 2']`, fullpath: `$.Events.*.LabMetaData.ClientAccount['Address 2']`, description: 'The street address (line 2) of the lab client that submitted the samples.', // slim: '/lab/contact/address', }, 'ClientName': { type: 'event', path: '$.LabMetaData.ClientAccount.Name', fullpath: '$.Events.*.LabMetaData.ClientAccount.Name', parse: 'string', description: 'The name of the lab client that submitted the samples.', slim: `/lab/clientAccount/name` }, 'ClientAccountNumber': { type: 'event', path: '$.LabMetaData.ClientAccount.AccountNumber', fullpath: '$.Events.*.LabMetaData.ClientAccount.AccountNumber', parse: 'string', description: 'The account number of the lab client that submitted the samples.', slim: `/lab/clientAccount/accountNumber` }, 'ClientZip': { type: 'event', path: '$.LabMetaData.ClientAccount.Zip', fullpath: '$.Events.*.LabMetaData.ClientAccount.Zip', description: 'The zip code of the lab client that submitted the samples.', slim: `/lab/clientAccount/zip` }, 'ClientState': { type: 'event', path: '$.LabMetaData.ClientAccount.State', fullpath: '$.Events.*.LabMetaData.ClientAccount.State', description: 'The state of the lab client that submitted the samples.', slim: `/lab/clientAccount/state` }, 'ClientCity': { type: 'event', path: '$.LabMetaData.ClientAccount.City', fullpath: '$.Events.*.LabMetaData.ClientAccount.City', description: 'The city of the lab client that submitted the samples.', slim: `/lab/clientAccount/city` }, 'ClientCompany': { type: 'event', path: '$.LabMetaData.ClientAccount.Company', fullpath: '$.Events.*.LabMetaData.ClientAccount.Company', description: 'The company name of the lab client that submitted the samples.', slim: `/lab/clientAccount/company` }, 'ClientPhone': { type: 'event', path: `$.LabMetaData.ClientAccount.Phone`, fullpath: `$.Events.*.LabMetaData.ClientAccount.Phone]`, description: 'The phone number of the lab contact.', slim: '/lab/clientAccount/phone', }, // Per-event and per-report 'LabReportID': { type: 'report', path: '$.LabMetaData.Reports.*.LabReportID', fullpath: '$.Events.*.LabMetaData.Reports.*.LabReportID', parse: 'string', description: 'ID of the Lab Report', slim: `/lab/report/id` }, // Per-event and per-row 'SampleNumber': { type: 'sample', path: '$.SampleMetaData.SampleNumber', fullpath: '$.Events.*.EventSamples.Soil.SoilSamples.*.SampleMetaData.SampleNumber', parse: 'string', description: 'Sample number as numbered by the lab', slim: '/lab/sampleid', }, 'SampleContainerID': { type: 'sample', path: '$.SampleMetaData.SampleContainerID', fullpath: '$.Events.*.EventSamples.Soil.SoilSamples.*.SampleMetaData.SampleContainerID', parse: 'string', description: 'Sample container ID as submitted by the client', slim: '/source/containerid', }, 'FMISSampleID': { type: 'sample', path: '$.SampleMetaData.FMISSampleID', fullpath: '$.Events.*.EventSamples.Soil.SoilSamples*.FMISSampleID', description: 'Sample ID assigned by the FMIS that submitted the samples', slim: '/source/sampleid', }, 'StartingDepth': { type: 'depth', path: '$.StartingDepth', fullpath: '$.Events.*.EventSamples.Soil.DepthRefs.*.StartingDepth', parse: 'number', description: 'Starting depth (top) of the soil sample', slim: '/depth/top' }, 'EndingDepth': { type: 'depth', path: '$.EndingDepth', fullpath: '$.Events.*.EventSamples.Soil.DepthRefs.*.EndingDepth', description: 'Ending depth (bottom) of the soil sample', slim: '/depth/bottom' }, 'ColumnDepth': { type: 'depth', path: '$.EndingDepth', fullpath: '$.Events.*.EventSamples.Soil.DepthRefs.*.EndingDepth', description: 'Column depth (top to bottom) of the soil sample', // slim: '/depth/column' }, }; export function toDetailedMappings(mm) { const arr = []; Object.entries(mm || {}).forEach(([k, v]) => { if (Array.isArray(v)) { v.forEach((vv) => arr.push({ key: k, mm: toModusJsonPath[vv] })); } else { arr.push({ key: k, mm: toModusJsonPath[v] }); } }); return arr; } export function parseDate(date) { // Date object if (date instanceof Date) return date; // 8-digit date if (('' + date).length === 8 && parseInt('' + date)) { date = '' + date; return new Date(`${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6)}`); } else if (+date < 100000 && +date > 100) { // this is an excel date (# days since 1/1/1900), parse it out return new Date(dayjs(getJsDateFromExcel(date)).format('YYYY-MM-DD')); } else if (new Date('' + date).toString() !== 'Invalid Date') { // Various parseable string formats return new Date('' + date); } else if (moment('' + date, 'DD-MM-YYYY').toString() !== 'Invalid Date') { // DD-MM-YYYY format which cannot be parsed automagically elsewhere return new Date(moment('' + date, 'DD-MM-YYYY').toString()); } else if (new Date(date).toString() !== 'Invalid Date') { // Timestamp or other non-string, parsable thing. return new Date(date); } else { // Just return whatever came back in instead of an Invalid Date object return new Date(date); //return date; } } export function parseMappingValue(val, mapping) { switch (mapping.parse) { case 'number': return +val; case 'date': if (val === 'NA') return false; return val ? parseDate(val).toISOString().split('T')[0] : val; case 'string': return '' + val; default: return val; } } export function setMappings(modusPiece, type, row, labConfig) { if (labConfig?.mappings === undefined) return modusPiece; toDetailedMappings(labConfig.mappings) .filter(({ mm }) => mm !== undefined && mm.type === type) .filter(({ key }) => key in row) .forEach(({ key, mm }) => { let val = parseMappingValue(row[key], mm); jp.value(modusPiece, mm.path, val); }); return modusPiece; } const mappings = { // ID numbers 'SAMPLEID': 'SampleNumber', 'LABNUM': 'SampleContainerID', 'REPORTNUM': 'LabEventID', //Other metadata // DATESAMPL might map to something, but its all empty strings in our samples // so it won't convert to a valid date... 'DATESAMPL': undefined, 'DATESUB': ['ReportDate', 'DateReceived'], 'CLIENT': 'ClientAccountNumber', 'GROWER': 'GrowerName', 'PERSON': 'ClientName', // I don't think these map to anything in modus: 'TIMESUB': undefined, 'CROP': undefined, 'TYPE': undefined, }; const analytes = { 'OM': { ValueUnit: '%', Element: 'OM', ModusTestID: 'S-SOM-LOI.15', }, 'ENR': { ValueUnit: 'lb/ac', Element: 'ENR', ModusTestID: 'S-ENR.19' }, 'P1': { ValueUnit: 'ppm', Element: 'P (Bray P1 1:10)', ModusTestID: 'S-P-B1-1:10.01.03', }, 'P2': { ValueUnit: 'ppm', Element: 'P (Bray P2 1:10)', ModusTestID: 'S-P-B2-1:10.01.03', }, //TODO: I believe this is referencing Olsen P which uses HCO3 'HCO3_P': { ValueUnit: 'ppm', Element: 'P (Olsen)', ModusTestID: 'S-P-BIC.04', }, 'PH': { ValueUnit: 'none', Element: 'pH', ModusTestID: 'S-PH-SP.02', }, 'K': { ValueUnit: 'ppm', Element: 'K', ModusTestID: 'S-K-NH4AC.05', }, 'MG': { ValueUnit: 'ppm', Element: 'Mg', ModusTestID: 'S-MG-NH4AC.05', }, 'CA': { ValueUnit: 'ppm', Element: 'Ca', ModusTestID: 'S-CA-NH4AC.05', }, 'NA': { ValueUnit: 'ppm', Element: 'Na', ModusTestID: 'S-NA-NH4AC.05', }, 'BUFFER_PH': { ValueUnit: 'none', Element: 'B-pH', ModusTestID: 'S-BPH-SIK1.02', // Unsure whether sikora 1 or 2; can also be //calculated, i.e., S-BPH.19 }, 'CEC': { ValueUnit: 'meq/100 g', Element: 'CEC', ModusTestID: 'S-CEC.19', //OR S-CEC-NH4N.05 OR S-CEC-AA.23 }, 'NO3_N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-1:5.01.01', }, 'ZN': { ValueUnit: 'ppm', Element: 'Zn', ModusTestID: 'S-ZN-DTPA-SORB.05', }, 'MN': { ValueUnit: 'ppm', Element: 'Mn', ModusTestID: 'S-MN-DTPA-SORB.05', }, 'FE': { ValueUnit: 'ppm', Element: 'Fe', ModusTestID: 'S-FE-DTPA-SORB.05', }, 'CU': { ValueUnit: 'ppm', Element: 'Cu', ModusTestID: 'S-CU-DTPA-SORB.05', }, 'MO': { ValueUnit: 'ppm', Element: 'Mo', // ModusTestID: 'S-MO-DTPA-SORB.05', }, 'B': { ValueUnit: 'ppm', Element: 'B', ModusTestID: 'S-B-DTPA-SORB.05', }, 'CL': { ValueUnit: 'ppm', Element: 'Cl', ModusTestID: 'S-CL-SP.01', //unclear which saturated paste method }, 'SO4_S': { ValueUnit: 'ppm', Element: 'SO4-S', ModusTestID: 'S-S-NH4AC.05', }, 'SAT_PCT': { ValueUnit: '%', Element: 'Sat-Pct', ModusTestID: 'S-SP%.19', }, 'S__SALTS': { ValueUnit: 'mmho/cm', Element: 'SS', //Modus only lists calculated methods, ALWest says they use saturated paste }, 'ESP': { ValueUnit: '%', Element: 'ESP', ModusTestID: 'S-ESP.19', }, 'SAR': { ValueUnit: 'ppm', Element: 'SAR', //Calculated is not an option in modus }, 'NH4': { ValueUnit: 'ppm', Element: 'NH4-N', // Not sure which method 2.0 Normals of KCl would map to in Modus }, 'EC': { ValueUnit: 'dS/m', Element: 'EC', }, 'CO3': { ValueUnit: 'ppm', Element: 'CO3', ModusTestID: 'S-CO3-SP.12', }, 'HCO3': { ValueUnit: 'ppm', Element: 'HCO3', ModusTestID: 'S-HCO3-SP.12', }, //TODO: Methods unlisted in data provided by ALWest 'H': { ValueUnit: 'meq/100 g', Element: 'H', // If Calculated, use 'S-H.19' }, 'S': { ValueUnit: 'ppm', Element: 'S', // Maybe calculated from SO4-S? }, 'AL': { ValueUnit: 'ppm', Element: 'Al', }, //TODO: Analyte doesn't appear to exist in modus 'EX__LIME': { Element: 'Excess-Lime', }, 'K_PCT': { ValueUnit: '%', Element: 'BS-K', ModusTestID: 'S-BS-K.19', }, 'MG_PCT': { ValueUnit: '%', Element: 'BS-Mg', ModusTestID: 'S-BS-MG.19', }, 'CA_PCT': { ValueUnit: '%', Element: 'BS-Ca', ModusTestID: 'S-BS-CA.19', }, 'H_PCT': { ValueUnit: '%', Element: 'BS-H', ModusTestID: 'S-BS-H.19', }, 'NA_PCT': { ValueUnit: '%', Element: 'BS-Na', ModusTestID: 'S-BS-NA.19', }, // Alternative units to existing analytes 'CA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Ca', }, 'MG_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Mg', }, 'NA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Na', }, 'B_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-B', }, }; const config = { name: 'A&L Great Lakes Laboratory - Fort Wayne, IN', mappings, //analytes, //headers: [...Object.keys(analytes), ...Object.keys(mappings)], examplesKey: 'a_l_ftwayne', type: 'Soil', }; export default config; const mappings = { // ID numbers 'SAMPLEID': 'FMISSampleID', 'LABNUM': 'SampleNumber', 'REPORTNUM': ['LabEventID', 'LabReportID'], //Other metadata // DATESAMPL might map to something, but its all empty strings in our samples // so it won't convert to a valid date... 'DATESAMPL': undefined, 'DATESUB': ['ReportDate', 'DateReceived'], 'CLIENT': 'ClientAccountNumber', 'GROWER': 'GrowerName', 'PERSON': 'ClientName', // I don't think these map to anything in modus: 'TIMESUB': undefined, 'CROP': undefined, 'TYPE': undefined, }; const analytes = { 'OM': { ValueUnit: '%', Element: 'OM', ModusTestID: 'S-SOM-LOI.15', }, 'ENR': { ValueUnit: 'lb/ac', Element: 'ENR', ModusTestID: 'S-ENR.19' }, 'P1': { ValueUnit: 'ppm', Element: 'P (Bray P1 1:10)', ModusTestID: 'S-P-B1-1:10.01.03', }, 'P2': { ValueUnit: 'ppm', Element: 'P (Bray P2 1:10)', ModusTestID: 'S-P-B2-1:10.01.03', }, //TODO: I believe this is referencing Olsen P which uses HCO3 'HCO3_P': { ValueUnit: 'ppm', Element: 'P (Olsen)', ModusTestID: 'S-P-BIC.04', }, 'PH': { ValueUnit: 'none', Element: 'pH', ModusTestID: 'S-PH-SP.02', }, 'K': { ValueUnit: 'ppm', Element: 'K', ModusTestID: 'S-K-NH4AC.05', }, 'MG': { ValueUnit: 'ppm', Element: 'Mg', ModusTestID: 'S-MG-NH4AC.05', }, 'CA': { ValueUnit: 'ppm', Element: 'Ca', ModusTestID: 'S-CA-NH4AC.05', }, 'NA': { ValueUnit: 'ppm', Element: 'Na', ModusTestID: 'S-NA-NH4AC.05', }, 'BUFFER_PH': { ValueUnit: 'none', Element: 'B-pH', ModusTestID: 'S-BPH-SIK1.02', // Unsure whether sikora 1 or 2; can also be //calculated, i.e., S-BPH.19 }, 'CEC': { ValueUnit: 'meq/100 g', Element: 'CEC', ModusTestID: 'S-CEC.19', //OR S-CEC-NH4N.05 OR S-CEC-AA.23 }, 'NO3_N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-1:5.01.01', }, 'ZN': { ValueUnit: 'ppm', Element: 'Zn', ModusTestID: 'S-ZN-DTPA-SORB.05', }, 'MN': { ValueUnit: 'ppm', Element: 'Mn', ModusTestID: 'S-MN-DTPA-SORB.05', }, 'FE': { ValueUnit: 'ppm', Element: 'Fe', ModusTestID: 'S-FE-DTPA-SORB.05', }, 'CU': { ValueUnit: 'ppm', Element: 'Cu', ModusTestID: 'S-CU-DTPA-SORB.05', }, 'MO': { ValueUnit: 'ppm', Element: 'Mo', // ModusTestID: 'S-MO-DTPA-SORB.05', }, 'B': { ValueUnit: 'ppm', Element: 'B', ModusTestID: 'S-B-DTPA-SORB.05', }, 'CL': { ValueUnit: 'ppm', Element: 'Cl', ModusTestID: 'S-CL-SP.01', //unclear which saturated paste method }, 'SO4_S': { ValueUnit: 'ppm', Element: 'SO4-S', ModusTestID: 'S-S-NH4AC.05', }, 'SAT_PCT': { ValueUnit: '%', Element: 'Sat-Pct', ModusTestID: 'S-SP%.19', }, 'S__SALTS': { ValueUnit: 'mmho/cm', Element: 'SS', //Modus only lists calculated methods, ALWest says they use saturated paste }, 'ESP': { ValueUnit: '%', Element: 'ESP', ModusTestID: 'S-ESP.19', }, 'SAR': { ValueUnit: 'ppm', Element: 'SAR', //Calculated is not an option in modus }, 'NH4': { ValueUnit: 'ppm', Element: 'NH4-N', // Not sure which method 2.0 Normals of KCl would map to in Modus }, 'EC': { ValueUnit: 'dS/m', Element: 'EC', }, 'CO3': { ValueUnit: 'ppm', Element: 'CO3', ModusTestID: 'S-CO3-SP.12', }, 'HCO3': { ValueUnit: 'ppm', Element: 'HCO3', ModusTestID: 'S-HCO3-SP.12', }, //TODO: Methods unlisted in data provided by ALWest 'H': { ValueUnit: 'meq/100 g', Element: 'H', // If Calculated, use 'S-H.19' }, 'S': { ValueUnit: 'ppm', Element: 'S', // Maybe calculated from SO4-S? }, 'AL': { ValueUnit: 'ppm', Element: 'Al', }, //TODO: Analyte doesn't appear to exist in modus 'EX__LIME': { Element: 'Excess-Lime', }, 'K_PCT': { ValueUnit: '%', Element: 'BS-K', ModusTestID: 'S-BS-K.19', }, 'MG_PCT': { ValueUnit: '%', Element: 'BS-Mg', ModusTestID: 'S-BS-MG.19', }, 'CA_PCT': { ValueUnit: '%', Element: 'BS-Ca', ModusTestID: 'S-BS-CA.19', }, 'H_PCT': { ValueUnit: '%', Element: 'BS-H', ModusTestID: 'S-BS-H.19', }, 'NA_PCT': { ValueUnit: '%', Element: 'BS-Na', ModusTestID: 'S-BS-NA.19', }, // Alternative units to existing analytes 'CA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Ca', }, 'MG_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Mg', }, 'NA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Na', }, 'B_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-B', }, }; const config = { name: 'A&L Western Agricultural Labs - Modesto, CA', mappings, //analytes, //headers: [...Object.keys(analytes), ...Object.keys(mappings)], examplesKey: 'a_l_west', type: 'Soil', }; export default config; const mappings = { // ID numbers 'SAMPLEID': 'SampleNumber', 'LABNUM': 'SampleContainerID', 'REPORTNUM': 'LabEventID', //Other metadata // DATESAMPL might map to something, but its all empty strings in our samples // so it won't convert to a valid date... 'DATESAMPL': undefined, 'DATESUB': ['ReportDate', 'DateReceived'], 'CLIENT': 'ClientAccountNumber', 'GROWER': 'GrowerName', 'PERSON': 'ClientName', // I don't think these map to anything in modus: 'TIMESUB': undefined, 'CROP': undefined, 'TYPE': undefined, }; const analytes = { 'OM': { ValueUnit: '%', Element: 'OM', ModusTestID: 'S-SOM-LOI.15', }, 'ENR': { ValueUnit: 'lb/ac', Element: 'ENR', ModusTestID: 'S-ENR.19' }, 'P1': { ValueUnit: 'ppm', Element: 'P (Bray P1 1:10)', ModusTestID: 'S-P-B1-1:10.01.03', }, 'P2': { ValueUnit: 'ppm', Element: 'P (Bray P2 1:10)', ModusTestID: 'S-P-B2-1:10.01.03', }, //TODO: I believe this is referencing Olsen P which uses HCO3 'HCO3_P': { ValueUnit: 'ppm', Element: 'P (Olsen)', ModusTestID: 'S-P-BIC.04', }, 'PH': { ValueUnit: 'none', Element: 'pH', ModusTestID: 'S-PH-SP.02', }, 'K': { ValueUnit: 'ppm', Element: 'K', ModusTestID: 'S-K-NH4AC.05', }, 'MG': { ValueUnit: 'ppm', Element: 'Mg', ModusTestID: 'S-MG-NH4AC.05', }, 'CA': { ValueUnit: 'ppm', Element: 'Ca', ModusTestID: 'S-CA-NH4AC.05', }, 'NA': { ValueUnit: 'ppm', Element: 'Na', ModusTestID: 'S-NA-NH4AC.05', }, 'BUFFER_PH': { ValueUnit: 'none', Element: 'B-pH', ModusTestID: 'S-BPH-SIK1.02', // Unsure whether sikora 1 or 2; can also be //calculated, i.e., S-BPH.19 }, 'CEC': { ValueUnit: 'meq/100 g', Element: 'CEC', ModusTestID: 'S-CEC.19', //OR S-CEC-NH4N.05 OR S-CEC-AA.23 }, 'NO3_N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-1:5.01.01', }, 'ZN': { ValueUnit: 'ppm', Element: 'Zn', ModusTestID: 'S-ZN-DTPA-SORB.05', }, 'MN': { ValueUnit: 'ppm', Element: 'Mn', ModusTestID: 'S-MN-DTPA-SORB.05', }, 'FE': { ValueUnit: 'ppm', Element: 'Fe', ModusTestID: 'S-FE-DTPA-SORB.05', }, 'CU': { ValueUnit: 'ppm', Element: 'Cu', ModusTestID: 'S-CU-DTPA-SORB.05', }, 'MO': { ValueUnit: 'ppm', Element: 'Mo', // ModusTestID: 'S-MO-DTPA-SORB.05', }, 'B': { ValueUnit: 'ppm', Element: 'B', ModusTestID: 'S-B-DTPA-SORB.05', }, 'CL': { ValueUnit: 'ppm', Element: 'Cl', ModusTestID: 'S-CL-SP.01', //unclear which saturated paste method }, 'SO4_S': { ValueUnit: 'ppm', Element: 'SO4-S', ModusTestID: 'S-S-NH4AC.05', }, 'SAT_PCT': { ValueUnit: '%', Element: 'Sat-Pct', ModusTestID: 'S-SP%.19', }, 'S__SALTS': { ValueUnit: 'mmho/cm', Element: 'SS', //Modus only lists calculated methods, ALWest says they use saturated paste }, 'ESP': { ValueUnit: '%', Element: 'ESP', ModusTestID: 'S-ESP.19', }, 'SAR': { ValueUnit: 'ppm', Element: 'SAR', //Calculated is not an option in modus }, 'NH4': { ValueUnit: 'ppm', Element: 'NH4-N', // Not sure which method 2.0 Normals of KCl would map to in Modus }, 'EC': { ValueUnit: 'dS/m', Element: 'EC', }, 'CO3': { ValueUnit: 'ppm', Element: 'CO3', ModusTestID: 'S-CO3-SP.12', }, 'HCO3': { ValueUnit: 'ppm', Element: 'HCO3', ModusTestID: 'S-HCO3-SP.12', }, //TODO: Methods unlisted in data provided by ALWest 'H': { ValueUnit: 'meq/100 g', Element: 'H', // If Calculated, use 'S-H.19' }, 'S': { ValueUnit: 'ppm', Element: 'S', // Maybe calculated from SO4-S? }, 'AL': { ValueUnit: 'ppm', Element: 'Al', }, //TODO: Analyte doesn't appear to exist in modus 'EX__LIME': { Element: 'Excess-Lime', }, 'K_PCT': { ValueUnit: '%', Element: 'BS-K', ModusTestID: 'S-BS-K.19', }, 'MG_PCT': { ValueUnit: '%', Element: 'BS-Mg', ModusTestID: 'S-BS-MG.19', }, 'CA_PCT': { ValueUnit: '%', Element: 'BS-Ca', ModusTestID: 'S-BS-CA.19', }, 'H_PCT': { ValueUnit: '%', Element: 'BS-H', ModusTestID: 'S-BS-H.19', }, 'NA_PCT': { ValueUnit: '%', Element: 'BS-Na', ModusTestID: 'S-BS-NA.19', }, // Alternative units to existing analytes 'CA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Ca', }, 'MG_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Mg', }, 'NA_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-Na', }, 'B_SAT': { ValueUnit: 'meq/100 g', Element: 'BS-B', }, }; const config = { name: 'A&L Great Lakes Laboratory - Wisconsin', mappings, //analytes, //headers: [...Object.keys(analytes), ...Object.keys(mappings)], examplesKey: 'a_l_wisc', type: 'Soil', }; export default config; import debug from 'debug'; const warn = debug('@modusjs/convert#labs:warn'); const mappings = { 'id code': 'LabReportID', // Not really sure what this is 'grower name': 'Grower', // despite the header, the values appear to be IDs e.g. B500... 'account name': 'ClientCompany', 'Field ID': 'Field', 'Grid': undefined, 'Lab Number': 'SampleNumber', 'Field Desc': 'FieldName', 'Date Tested': ['ReportDate', 'DateReceived'], 'Crop': undefined, 'YLDGOAL': undefined, 'depth range': 'StartingDepth', 'OTHER ID#': undefined, }; const depthInfo = function (row) { let obj = {}; // if we do this match, it'll allow someone to append units afterward // and generically find it and attempt this let match = Object.keys(row).find(k => /^Depth/.test(k)); if (!match) { warn(`Depth info could not be found`); return undefined; //throw new Error('Depth info could not be found') } let value = row[match].toString(); if (value.match(' to ')) { obj.StartingDepth = +value.split(' to ')[0]; obj.EndingDepth = +value.split(' to ')[1]; obj.ColumnDepth = obj.EndingDepth - obj.StartingDepth; obj.Name = value; obj.DepthUnit = 'cm'; } return obj; }; const config = { name: 'Soiltest Farm Consultants, Inc. - Moses Lake, WA', type: 'Soil', mappings, //depthInfo, examplesKey: 'soiltestfarmconsultants' }; export default config; import debug from 'debug'; const warn = debug('@modusjs/convert#labs-tomkat:warn'); const mappings = { // I don't think these map to anything in modus: 'Past Crop': undefined, 'Point ID': 'SampleNumber', 'CollectDate': ['ReportDate', 'DateReceived'], 'Depth': 'StartingDepth', 'Depth (cm)': 'StartingDepth', 'Collect Date 1 (RMN metrics)': ['ReportDate', 'DateReceived'], 'CollectDate (Appx)': ['ReportDate', 'DateReceived'], 'Ecosystem Type': 'SubField', }; let analytes = { '1:1 Soil pH': { Element: 'pH', ModusTestID: 'S-PH-1:1.02.07', ValueUnit: 'none', }, 'WDRF Buffer pH': { Element: 'B-pH', ValueUnit: 'none', ModusTestID: 'S-BPH-WB.02', // assume this is not the modified woodruff }, 'pH': { Element: 'pH', ValueUnit: 'none', }, 'Soil pH': { Element: 'pH', ValueUnit: 'none', }, '1:1 S Salts': { ValueUnit: 'mmho/cm', ModusTestID: 'S-SS.19', Element: 'Soluble Salts', }, '1:1 S Salts mmho/cm': { ValueUnit: 'mmho/cm', ModusTestID: 'S-SS.19', Element: 'Soluble Salts', }, 'Excess Lime': { Element: 'Excess-Lime', }, 'Texture No': { Element: 'Texture', ValueUnit: '%', }, 'Organic Matter LOI %': { ValueUnit: '%', Element: 'OM', ModusTestID: 'S-SOM-LOI.15', }, 'Organic Matter': { ValueUnit: '%', Element: 'OM', }, 'Nitrate-N ppm N': { ValueUnit: 'ppm', Element: 'NO3-N', }, 'Nitrate': { ValueUnit: 'ppm', Element: 'NO3-N', }, 'lbs N/A': { ValueUnit: 'lb/ac', Element: 'N' }, 'Bray P-1 ppm P': { ValueUnit: 'ppm', Element: 'P (Bray P1 1:10)', ModusTestID: 'S-P-B1-1:10.01.03', }, 'Olsen P ppm P': { ValueUnit: 'ppm', Element: 'P (Olsen)', ModusTestID: 'S-P-BIC.04', }, 'Olsen P': { ValueUnit: 'ppm', Element: 'P (Olsen)', ModusTestID: 'S-P-BIC.04', }, 'Potassium ppm K': { ValueUnit: 'ppm', Element: 'K', }, 'Potassium': { ValueUnit: 'cmol/kg', Element: 'K', }, 'Sulfate-S ppm S': { ValueUnit: 'ppm', Element: 'SO4-S', }, 'Zinc ppm Zn': { ValueUnit: 'ppm', Element: 'Zn', }, 'Iron ppm Fe': { ValueUnit: 'ppm', Element: 'Fe', }, 'Iron': { ValueUnit: 'ppm', Element: 'Fe', }, 'Manganese ppm Mn': { ValueUnit: 'ppm', Element: 'Mn', }, 'Copper ppm Cu': { ValueUnit: 'ppm', Element: 'Cu', }, 'Calcium ppm Ca': { ValueUnit: 'ppm', Element: 'Ca', }, 'Calcium': { ValueUnit: 'cmol/kg', Element: 'Ca', }, 'Magnesium ppm Mg': { ValueUnit: 'ppm', Element: 'Mg', }, 'Magnesium': { ValueUnit: 'cmol/kg', Element: 'Mg', }, 'Sodium ppm Na': { ValueUnit: 'ppm', Element: 'Na', }, 'Sodium': { ValueUnit: 'cmol/kg', Element: 'Na', }, 'Boron ppm B': { ValueUnit: 'ppm', Element: 'B', }, 'CEC/Sum of Cations me/100g': { ValueUnit: 'meq/100g', Element: 'CEC', }, 'CEC': { ValueUnit: 'cmol/kg', Element: 'CEC', }, 'CEC (Estimated)': { ValueUnit: 'cmol/kg', Element: 'CEC', }, '2N KCl NO3-N ppm N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-1:5.01.01', }, 'KCl NH4-N ppm': { ValueUnit: 'ppm', Element: 'NH4-N', }, 'Aluminum ppm Al': { ValueUnit: 'ppm', Element: 'Al', }, 'Aluminum': { ValueUnit: 'ppm', Element: 'Al', }, 'Chloride ppm Cl': { ValueUnit: 'ppm', Element: 'Cl', }, 'Bray P-2 ppm P': { ValueUnit: 'ppm', Element: 'P (Bray P2 1:10)', ModusTestID: 'S-P-B2-1:10.01.03', }, 'Mehlich P-II ppm P': { ValueUnit: 'ppm', Element: 'P', ModusTestID: 'S-P-M2.04', }, 'Mehlich P-III ppm P': { Element: 'P', ValueUnit: 'ppm', ModusTestID: 'S-P-M3.04', }, 'Salt pH': { Element: 'pH', ValueUnit: 'none', //multiple salt pH, could be 1:1, 1:2 or 1:5 salt }, 'Salt Buffer pH': { Element: 'pH', ValueUnit: 'none', }, 'WB OM %': { ValueUnit: '%', Element: 'OM', // 4 different Walkley-Black methods exist }, 'Total Nitrogen': { ValueUnit: '%', Element: 'TN', }, 'Total Nitrogen^': { ValueUnit: '%', Element: 'TN', }, 'Total N ppm': { ValueUnit: 'ppm', Element: 'TN', }, 'Water Extractable Total N': { ValueUnit: 'ppm', Element: 'TN', }, 'Soil Moisture %': { ValueUnit: '%', Element: 'Soil-Moisture', ModusTestID: 'S-MOIST-GRAV.00', // Could also be S-MOIST-GRAVAR.15 (as received), but I think this was taken in the field }, 'Total P ppm': { ValueUnit: 'ppm', Element: 'TP', }, 'Total Phosphorus': { ValueUnit: 'ppm', Element: 'TP', }, 'Total Zn ppm': { ValueUnit: 'ppm', Element: 'TZn', }, 'Nitrite-N ppm': { ValueUnit: 'ppm', Element: 'NO2-N', ModusTestID: 'S-NO2-KCL.01', // only method for nitrite }, 'Sand': { ValueUnit: '%', Element: 'Sand', }, 'Silt': { ValueUnit: '%', Element: 'Silt', }, 'Clay': { ValueUnit: '%', Element: 'Clay', }, '% Sand': { ValueUnit: '%', Element: 'Sand', }, '% Silt': { ValueUnit: '%', Element: 'Silt', }, '% Clay': { ValueUnit: '%', Element: 'Clay', }, 'Texture': { ValueUnit: 'none', Element: 'Texture', }, 'Texture*': { ValueUnit: 'none', Element: 'Texture', }, 'Paste % Sat': { ValueUnit: '%', Element: 'Sat-Pct', ModusTestID: 'S-SP%.19', }, 'Paste pH': { Element: 'pH', ValueUnit: 'none', ModusTestID: 'S-PH-SP.02', //only paste method }, 'Paste EC mmho/cm': { ValueUnit: 'mmho/cm', Element: 'Electrical Conductivity', ModusTestID: 'S-EC-SP.03', // only paste method }, 'Paste HCO3 ppm': { ValueUnit: 'ppm', Element: 'HCO3', }, 'Paste Cl ppm': { ValueUnit: 'ppm', Element: 'Cl', }, 'Paste Ca ppm': { ValueUnit: 'ppm', Element: 'Ca', }, 'Paste Mg ppm': { ValueUnit: 'ppm', Element: 'Mg', }, 'Paste Na ppm': { ValueUnit: 'ppm', Element: 'Na', ModusTestID: 'S-NA-SP.05', //only paste method }, 'Paste S ppm': { ValueUnit: 'ppm', Element: 'S', }, 'Paste SAR': { ValueUnit: 'ppm', Element: 'SAR', ModusTestID: 'S-SAR-SP.00', //only paste method }, // Recommendations 'Crop 1': { ValueUnit: 'none', Element: 'Crop 1', }, 'YG 1': { ValueUnit: 'bu/ac', Element: 'YG 1', }, 'Crop 2': { ValueUnit: 'none', Element: 'Crop 2', }, 'YG 2': { ValueUnit: 'bu/ac', Element: 'YG 2', }, 'Crop 3': { ValueUnit: 'none', Element: 'Crop 3', }, 'YG 3': { ValueUnit: 'bu/ac', Element: 'YG 3', }, 'Nitrogen Rec': { ValueUnit: 'lb/ac', Element: 'N-Rec', }, //TODO: Fix this after addressing duplicate header bug in examples. // Without these Rec_1, Rec_2, etc, autorecognition cannot occur 'P2O5 Rec': { ValueUnit: 'lb/ac', Element: 'P2O5-Rec', }, 'K2O Rec': { ValueUnit: 'lb/ac', Element: 'K2O-Rec', }, 'Sulfur Rec': { ValueUnit: 'lb/ac', Element: 'S-Rec', }, 'Zinc Rec': { ValueUnit: 'lb/ac', Element: 'Zn-Rec', }, 'Magnesium Rec': { ValueUnit: 'lb/ac', Element: 'Mg-Rec', }, 'Iron Rec': { ValueUnit: 'lb/ac', Element: 'Fe-Rec', }, 'Manganese Rec': { ValueUnit: 'lb/ac', Element: 'Mn-Rec', }, 'Copper Rec': { ValueUnit: 'lb/ac', Element: 'Cu-Rec', }, 'Boron Rec': { ValueUnit: 'lb/ac', Element: 'B-Rec', }, 'Lime Rec': { ValueUnit: 'lb/ac', Element: 'Lime-Rec', }, 'Organic Carbon %': { ValueUnit: '%', Element: 'OC', }, 'Total Organic Carbon': { ValueUnit: '%', Element: 'TOC', }, 'Total Org Carbon': { ValueUnit: '%', Element: 'TOC', }, 'Water Extractable Organic C': { ValueUnit: '%', Element: 'OC', }, 'Water Soluble K': { Element: 'K', }, 'Total Carbon %': { ValueUnit: '%', Element: 'TC', ModusTestID: 'S-TC-COMB.15', //only one method exists }, 'H2O NO3-N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-W1:1.01.01', //only water extraction method }, 'Total Dry Weight': { Element: 'Dry-Weight', }, 'Total S': { ValueUnit: 'ppm', Element: 'TS', ModusTestID: 'S-S-EPA6010B.00', //only TS method }, 'PSNT N/A': { // this is a sampling procedure rather than necessarily a lab procedure ValueUnit: 'lb/ac', Element: 'PSNT-N', }, 'H2O Ca': { Element: 'Ca', // 3 different water extraction methods exist }, 'Paste CO3 ppm': { ValueUnit: 'ppm', Element: 'CO3', // 2 different paste methods exist }, 'Phosphorus M3 ICAP ppm P': { ValueUnit: 'ppm', Element: 'P', ModusTestID: 'S-P-M3.04', // I think... ICAP == ICP? }, 'Potassium M3 ICAP ppm K': { ValueUnit: 'ppm', Element: 'K', ModusTestID: 'S-K-M3.05', // I think... ICAP == ICP? }, 'Sulfur M3 ICAP ppm S': { ValueUnit: 'ppm', Element: 'S', ModusTestID: 'S-S-M3.05', // I think... ICAP == ICP? }, 'Zinc M3 ICAP ppm Zn': { ValueUnit: 'ppm', Element: 'Zn', ModusTestID: 'S-ZN-M3.05', // I think... ICAP == ICP? }, 'Iron M3 ICAP ppm Fe': { ValueUnit: 'ppm', Element: 'Fe', ModusTestID: 'S-FE-M3.05', // I think... ICAP == ICP? }, 'Manganese M3 ICAP ppm Mn': { ValueUnit: 'ppm', Element: 'Mn', ModusTestID: 'S-MN-M3.05', // I think... ICAP == ICP? }, 'Copper M3 ICAP ppm Cu': { ValueUnit: 'ppm', Element: 'Cu', ModusTestID: 'S-CU-M3.05', // I think... ICAP == ICP? }, 'Calcium M3 ICAP ppm Ca': { ValueUnit: 'ppm', Element: 'Ca', ModusTestID: 'S-CA-M3.05', // I think... ICAP == ICP? }, 'Magnesium M3 ICAP ppm Mg': { ValueUnit: 'ppm', Element: 'Mg', ModusTestID: 'S-MG-M3.05', // I think... ICAP == ICP? }, 'Sodium M3 ICAP ppm Na': { ValueUnit: 'ppm', Element: 'Na', ModusTestID: 'S-NA-M3.05', // I think... ICAP == ICP? }, 'Boron M3 ICAP ppm B': { ValueUnit: 'ppm', Element: 'B', ModusTestID: 'S-B-M3.05', // I think... ICAP == ICP? }, '1N KCl NO3-N ppm N': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-KCL.01.01', }, 'KCl NH4-N ppm (Old)': { ValueUnit: 'ppm', Element: 'NH4-N', ModusTestID: 'S-NH4-KCL.01.05', }, '2N KCl NO3-N ppm N (Old)': { ValueUnit: 'ppm', Element: 'NO3-N', ModusTestID: 'S-NO3-1:5.01.01', }, '2N KCL NO3 Lbs-Acre': { ValueUnit: 'lb/ac', Element: 'NO3-N', }, 'Ammonium Lbs-Acre': { ValueUnit: 'lb/ac', Element: 'NH4-N', }, 'Ammonium': { ValueUnit: 'ppm', Element: 'NH4-N', }, 'Aluminium M3 ICAP ppm Al': { ValueUnit: 'ppm', Element: 'Al', ModusTestID: 'S-AL-M3.05', }, 'Organic C H2O ppm': { ValueUnit: 'ppm', Element: 'OC', }, 'Organic N H2O ppm': { ValueUnit: 'ppm', Element: 'ON', ModusTestID: 'S-ON.19', //only option }, 'Organic C:N H2O': { ValueUnit: 'none', Element: 'OCNR', }, 'PSNT ppm N': { ValueUnit: 'ppm', Element: 'PSNT-N', }, 'Sikora pH': { Element: 'pH', ValueUnit: 'none', // modus lists no sikora ph methods }, 'Sikora Buffer': { ValueUnit: 'none', Element: 'B-pH', // modus lists two sikora buffer ph methods }, 'Bulk Density': { ValueUnit: 'g/cm3', Element: 'Bulk-Density', }, '2:1 Soil pH': { Element: 'pH', ValueUnit: 'none', //2:1 water or CaCl method? }, '2:1 Soluble Salts': { ValueUnit: 'ppm', //assumed this Element: 'SS', //modus only has 'calculated' method }, 'POX-C ppm C': { ValueUnit: 'ppm', Element: 'Active-Carbon', ModusTestID: 'S-AC-KMNO4.01', }, // These are more of soil texture/moisture analysis results; stability is not // included in modus 'Aggregate Stability 1-2mm %': { ValueUnit: '%', Element: 'Aggregate Stability 1-2mm %', }, 'Aggregate Stability 1-2mm in bulk soil %': { ValueUnit: '%', Element: 'Aggregate Stability 1-2mm in bulk soil %', }, 'Available Water g H2O g-1 soil': { ValueUnit: '%', Element: 'Moisture', }, 'Available Water inch H2O inch-1 of soil': { ValueUnit: '%', Element: 'Moisture', }, 'Total Available Water inches H2O sample-1': { ValueUnit: '%', Element: 'Moisture', }, 'Field Capacity % (wt.)': { ValueUnit: '%', Element: 'Moisture', }, 'Permanent Wilting Point % (wt.': { ValueUnit: '%', Element: 'Moisture' }, 'Total K ppm K': { ValueUnit: 'ppm', Element: 'TK', }, 'Total C Concentration %': { ValueUnit: '%', Element: 'C', }, 'Total C lbs/Acre': { ValueUnit: 'lb/ac', Element: 'C', }, 'Total N Concentration %': { ValueUnit: '%', Element: 'N', }, 'Total N lbs/Acre': { ValueUnit: 'lb/ac', Element: 'N', }, 'Total C:N lbs/Acre Ratio': { ValueUnit: 'none', Element: 'TCNR', ModusTestID: 'S-TC:TN.19', //only option }, 'Total P Concentration %': { ValueUnit: '%', Element: 'TP', }, 'Total P lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TP', }, 'Total P2O5 Concentration %': { ValueUnit: '%', Element: 'TP205', }, 'Total P2O5 lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TP205', }, 'Total K Concentration %': { ValueUnit: '%', Element: 'TK', }, 'Total K lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TK', }, 'Total K2O Concentration %': { ValueUnit: '%', Element: 'TK2O', }, 'Total K2O lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TK20', }, 'Total Ca Concentration %': { ValueUnit: '%', Element: 'TCa', }, 'Total Ca lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TCa', }, 'Total Mg Concentration %': { ValueUnit: '%', Element: 'TMg', }, 'Total Mg lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TMg', }, 'Total S Concentration %': { ValueUnit: '%', Element: 'TS', //No methods for TS with units of % exist }, 'Total S lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TS', //No methods for TS with units of lb/ac }, 'Total Zn Concentration ppm': { ValueUnit: 'ppm', Element: 'TZn', }, 'Total Zn lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TZn', }, 'Total Fe Concentration ppm': { ValueUnit: 'ppm', Element: 'TFe', }, 'Total Fe lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TFe', }, 'Total Mn Concentration ppm': { ValueUnit: 'ppm', Element: 'TMn', }, 'Total Mn lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TMn', }, 'Total Cu Concentration ppm': { ValueUnit: 'ppm', Element: 'TCu', }, 'Total Cu lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TCu', }, 'Total B Concentration ppm': { ValueUnit: 'ppm', Element: 'TB', }, 'Total B lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TB', }, 'Total Mo Concentration ppm': { ValueUnit: 'ppm', Element: 'TMo', }, 'Total Mo lbs/Acre': { ValueUnit: 'lb/ac', Element: 'TMo', }, 'Total Ni ppm Ni': { ValueUnit: 'ppm', Element: 'TNi', }, 'Total As ppm As': { ValueUnit: 'ppm', Element: 'TAs', }, 'Total Cd ppm Cd': { ValueUnit: 'ppm', Element: 'TCd', }, 'Total Pb ppm Pb': { ValueUnit: 'ppm', Element: 'TPd', }, 'Total Cr ppm Cr': { ValueUnit: 'ppm', Element: 'TCr', }, 'Total Co ppm Co': { ValueUnit: 'ppm', Element: 'TCo', }, 'Total Se ppm Se': { ValueUnit: 'ppm', Element: 'TSe', }, 'Total Mo ppm Mo': { ValueUnit: 'ppm', Element: 'TMo', }, 'H2O NH4-N': { ValueUnit: 'ppm', Element: 'NH4-N', ModusTestID: 'S-NH4N-W1:1.01', //only water method }, 'Sample Density g/cc': { Element: 'Bulk-Density', //I think this is right? ValueUnit: 'g/cc', }, 'Molybdenum Hot Water ppm Mo': { ValueUnit: 'ppm', Element: 'Mo', ModusTestID: 'S-MO-HOTH2O.04', }, 'H2O P': { ValueUnit: 'ppm', Element: 'P', }, 'Texture By Feel': { ValueUnit: 'none', Element: 'Texture', //ModusTestID: 'S-TEXTURE.19' }, 'Comprehensive Bulk Density': { Element: 'Bulk-Density', }, 'H3A K': { ValueUnit: 'ppm', Element: 'K', ModusTestID: 'S-K-H3A1.01.04', }, 'CO2 Soil Respiration': { Element: 'CO2 Respiration', ModusTestID: 'S-CO2-RESP.01', }, 'Water Infiltration': { ValueUnit: 'min', Element: 'Permeability', }, //These are troublemakers if not defined here because they contain parenthesis //and parseCsvHeaders will break it up. 'Gram (-) Biomass': { ValueUnit: 'ppb', Element: 'Gram (-) Biomass', }, 'Gram (+) Biomass': { ValueUnit: 'ppb', Element: 'Gram (+) Biomass', }, 'Gram(+):Gram(-)': { ValueUnit: 'ratio', Element: 'Gram(+):Gram(-) Biomass', }, // Not in modus 'Ace Protein g/Kg': { ValueUnit: 'g/kg', Element: 'Ace-Protein', }, 'Rock Volume cm3': { ValueUnit: 'cm3', Element: 'Rock-Volume', }, 'Rock Density g/cm3': { ValueUnit: 'g/cm3', Element: 'Rock-Density', }, 'Rocks grams': { ValueUnit: 'g', Element: 'Rocks', }, 'Roots grams': { ValueUnit: 'g', Element: 'Roots', }, // Alternate Units '%H Sat': { ValueUnit: '%', Element: 'BS-H', ModusTestID: 'S-BS-H.19', }, '%K Sat': { ValueUnit: '%', Element: 'BS-K', ModusTestID: 'S-BS-K.19', }, '%K Sat_1': { ValueUnit: '%', Element: 'BS-K', ModusTestID: 'S-BS-K.19', }, '%Ca Sat': { Element: 'BS-Ca', ValueUnit: '%', ModusTestID: 'S-BS-CA.19', }, '%Mg Sat': { Element: 'BS-Mg', ValueUnit: '%', ModusTestID: 'S-BS-MG.19', }, '%Na Sat': { Element: 'BS-Na', ValueUnit: '%', ModusTestID: 'S-BS-NA.19', }, }; analytes = Object.fromEntries(Object.entries(analytes).map(([key, val]) => [ key, { ...val, CsvHeader: key } ])); const config = { name: 'TomKat Ranch', type: 'Soil', mappings, analytes, //headers: [...Object.keys(analytes), ...Object.keys(mappings)], examplesKey: 'tomkat_historic', //depthInfo, }; export default config; const mappings = { // ID numbers 'SAMPLEID': 'FMISSampleID', 'LABNUM': 'SampleNumber', 'REPORTNUM': ['LabEventID', 'LabReportID'], //Other metadata // DATESAMPL might map to something, but its all empty strings in our samples // so it won't convert to a valid date... 'DATESAMPL': undefined, 'DATESUB': ['ReportDate', 'DateReceived'], 'CLIENT': 'ClientAccountNumber', 'GROWER': 'Grower', 'PERSON': 'ClientName', // I don't think these map to anything in modus: 'TIMESUB': undefined, 'CROP': 'Crop', 'PLANTPART': 'PlantPart', 'TYPE': undefined, 'TEST_A': undefined, 'TEST_B': undefined, 'TEST_C': undefined, }; const analytes = { 'N': { ValueUnit: '%', Element: 'N', }, 'P': { ValueUnit: '%', Element: 'Phosphorus', }, 'K': { ValueUnit: '%', Element: 'K', }, 'MG': { ValueUnit: '%', Element: 'Mg', }, 'CA': { ValueUnit: '%', Element: 'Ca', }, 'NA': { ValueUnit: '%', Element: 'Na', }, 'NO3_N': { ValueUnit: 'ppm', Element: 'NO3-N', }, 'S': { ValueUnit: '%', Element: 'S', }, 'ZN': { ValueUnit: 'ppm', Element: 'Zn', }, 'MN': { ValueUnit: 'ppm', Element: 'Mn', }, 'FE': { ValueUnit: 'ppm', Element: 'Fe', }, 'CU': { ValueUnit: 'ppm', Element: 'Cu', }, 'B': { ValueUnit: 'ppm', Element: 'B', }, 'CL': { ValueUnit: '%', Element: 'Cl', }, 'MO': { ValueUnit: 'ppm', Element: 'Mo', }, 'AL': { ValueUnit: 'ppm', Element: 'Al', }, 'PO4_P': { ValueUnit: 'meq/100g', Element: 'PO4_P', }, 'K_EXT': { ValueUnit: 'meq/100g', Element: 'Potassium Extracted', }, 'SO4_S': { ValueUnit: 'ppm', Element: 'SO4-S', }, }; const config = { name: 'A&L Western Agricultural Labs - Modesto, CA', type: 'Plant', mappings, analytes, examplesKey: 'a_l_west', }; export default config; const mappings = { IncKey: 'FMISSampleID', //Incoming Key? RequestIncKey: undefined, // Request Incoming Key? Same as RptNo Client: 'ClientCompany', Grower: 'Grower', Sampler: undefined, LabNo: 'SampleNumber', RptNo: 'LabReportID', Date: 'DateReceived', SampleDate: 'ReportDate', Field: 'Field', SampleID: undefined, //was blank and we already have lab-assigned and sampler-assigned ids, Crop: 'Crop', StartingDepth: 'StartingDepth', EndingDepth: 'EndingDepth', Test: undefined, ProjectId: undefined, ProjectNumber: undefined, ProjectName: undefined, MODUSEvent: 'ReportID' }; const config = { name: 'Kuo Testing Laboratories', type: 'Soil', mappings, examplesKey: 'kuo' }; export default config; const mappings = { 'Sample Location': 'Field', 'Sample ID1': 'FMISSampleID', 'Sample ID2': undefined, 'Lab Number': 'SampleNumber', 'Client Number': 'ClientAccountNumber', 'Client Name': 'ClientAccountName', 'Sample Date': ['ReportDate', 'DateReceived'], 'Consultant Name': 'Grower' }; const config = { name: 'Brookside Laboratories, Inc. - New Bremen, OH', type: 'Soil', mappings, examplesKey: 'brookside' }; export default config; const mappings = { 'Sample ID': 'SampleNumber', }; const config = { name: 'Cquester Analytics', type: 'Soil', mappings, examplesKey: 'cquester' }; export default config; const mappings = { 'Lab': 'SampleNumber', 'Point ID': 'FMISSampleID', 'Date': ['ReportDate', 'DateReceived'], }; const analytes = { 'Al\naluminum': { ValueUnit: "ppm", Element: "Al", ModusTestID: "" }, 'As\narsenic': { ValueUnit: "ppm", Element: "As", ModusTestID: "" }, 'B\nboron': { ValueUnit: "ppm", Element: "B", ModusTestID: "" }, 'Ca\ncalcium': { ValueUnit: "ppm", Element: "Ca", ModusTestID: "" }, 'Cd\ncadmium': { ValueUnit: "ppm", Element: "Cd", ModusTestID: "" }, 'Cr\nchromium': { ValueUnit: "ppm", Element: "Cr", ModusTestID: "" }, 'Cu\ncopper': { ValueUnit: "ppm", Element: "Cu", ModusTestID: "" }, 'Fe\niron': { ValueUnit: "ppm", Element: "Fe", ModusTestID: "" }, 'K\npotassium': { ValueUnit: "ppm", Element: "K", ModusTestID: "" }, 'Mg\nmagnesium': { ValueUnit: "ppm", Element: "Mg", ModusTestID: "" }, 'Mn\nmanganese': { ValueUnit: "ppm", Element: "Mn", ModusTestID: "" }, 'Mo\nmolybdenum': { ValueUnit: "ppm", Element: "Mo", ModusTestID: "" }, 'Na\nsodium': { ValueUnit: "ppm", Element: "Na", ModusTestID: "" }, 'Ni\nnickel': { ValueUnit: "ppm", Element: "Ni", ModusTestID: "" }, 'P\nphosphorus': { ValueUnit: "ppm", Element: "P", ModusTestID: "" }, 'Pb\nlead': { ValueUnit: "ppm", Element: "Pb", ModusTestID: "" }, 'S\nsulfur': { ValueUnit: "ppm", Element: "S", ModusTestID: "" }, 'Zn\nzinc': { ValueUnit: "ppm", Element: "Zn", ModusTestID: "" }, }; const config = { name: 'University of Georgia Extension Ag & Environmental Services Labs - Athens, GA', mappings, analytes, //headers: [...Object.keys(analytes), ...Object.keys(mappings)], examplesKey: 'UGA', type: 'Soil', }; export default config; const mappings = { 'Kind Of Sample': undefined, 'Lab No': 'SampleNumber', 'Cust No': 'ClientAccountNumber', Name: 'ClientName', Company: 'ClientCompany', 'Address 1': 'ClientAddress', 'Address 2': undefined, City: 'ClientCity', State: 'ClientState', Zip: 'ClientZip', Grower: 'GrowerName', 'Field ID': 'FieldName', 'Sample ID': 'FMISSampleID', 'Date Recd': 'DateReceived', 'Date Rept': 'DateProcessed', 'B Depth': 'StartingDepth', 'E Depth': 'EndingDepth', 'Past Crop': undefined, //'Crop', }; const analytes = {}; const config = { name: 'Ward Laboratories, Inc. - Kearney, NE', mappings, analytes, examplesKey: 'ward', type: 'Soil', }; export default config;