// 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;