Skip to content

Commit

Permalink
initial slim conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
sanoel committed Aug 3, 2023
1 parent 8ea37b9 commit 457dc01
Show file tree
Hide file tree
Showing 14 changed files with 568 additions and 105 deletions.
Binary file modified app/bigdemo.zip
Binary file not shown.
2 changes: 1 addition & 1 deletion bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@modusjs/convert": "workspace:^",
"@modusjs/examples": "workspace:^",
"@modusjs/units": "workspace:^",
"@oada/types": "^3.3.0",
"@oada/types": "^3.5.3",
"tslib": "^2.4.0"
}
}
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
},
"dependencies": {
"@modusjs/convert": "workspace:^",
"@oada/types": "^3.3.0",
"@oada/types": "^3.5.3",
"chalk": "^4.0.0",
"commander": "^9.4.1",
"debug": "^4.3.4",
Expand Down
9 changes: 7 additions & 2 deletions convert/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@
"@modusjs/examples": "workspace:^",
"@modusjs/industry": "workspace:^",
"@modusjs/units": "workspace:^",
"@oada/formats": "^3.4.1",
"@oada/formats": "^3.5.3",
"@rollup/plugin-commonjs": "^23.0.0",
"@rollup/plugin-dynamic-import-vars": "^2.0.1",
"@rollup/plugin-json": "^5.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@tsconfig/node16": "^1.0.3",
"@types/debug": "^4.1.7",
"@types/isomorphic-form-data": "^2.0.1",
"@types/json-pointer": "^1.0.31",
"@types/jsonpath": "^0.2.0",
"@types/md5": "^2",
"@types/node": "^18.11.2",
Expand All @@ -61,12 +62,14 @@
"rollup-plugin-pnp-resolve": "^2.0.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
},
"dependencies": {
"@lhncbc/ucum-lhc": "^4.1.6",
"@oada/types": "^3.5.2",
"@oada/types": "^3.5.3",
"@overleaf/o-error": "^3.4.0",
"@types/geojson": "^7946.0.10",
"chalk": "^4.0.0",
"cheerio": "^1.0.0-rc.12",
"date-fns": "^2.29.3",
Expand All @@ -78,13 +81,15 @@
"formdata-node": "^5.0.0",
"htmlparser2": "^8.0.1",
"isomorphic-form-data": "^2.0.0",
"json-pointer": "^0.6.2",
"jsonpath": "^1.1.1",
"jszip": "^3.10.1",
"md5": "^2.3.0",
"node-fetch": "^3.2.10",
"prompts": "^2.4.2",
"save-file": "^2.3.1",
"tslib": "^2.4.0",
"wicket": "^1.3.8",
"xlsx": "^0.18.5"
}
}
70 changes: 70 additions & 0 deletions convert/src/geojson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Functions to auto-detect types and convert a batch of files into an array of Modus JSON's,
// with suggested output filenames. Just give each input file a filename whose extension
// reflects its type, and
import debug from 'debug';
import jszip from 'jszip';
import { parse as csvParse, supportedFormats, SupportedFormats } from './csv.js';
import { parseModusResult as xmlParseModusResult } from './xml.js';
import { convertUnits } from '@modusjs/units';
import { simpleConvert } from '@modusjs/units/dist/index.js';
import { modusTests } from '@modusjs/industry';
import type ModusResult from '@oada/types/modus/v1/modus-result.js';
// @ts-ignore
import wicket from 'wicket';
import type { NutrientResult } from '@modusjs/units';
import type { LabConfig } from './labs/index.js';
import type { GeoJSON } from 'geojson';

const error = debug('@modusjs/convert#togeojson:error');
const warn = debug('@modusjs/convert#togeojson:error');
const info = debug('@modusjs/convert#togeojson:info');
const trace = debug('@modusjs/convert#togeojson:trace');
const DEPTHUNITS = 'cm';
const wkt = new wicket.Wkt();

export type SupportedFileType = 'xml' | 'csv' | 'xlsx' | 'json' | 'zip';
export const supportedFileTypes = ['xml', 'csv', 'xlsx', 'json', 'zip'];

export type ModusJSONConversionResult = {
original_filename: string;
original_type: SupportedFileType;
output_filename: string;
modus: ModusResult;
};

export type InputFile = {
filename: string; // can include the path on the front
format?: 'generic'; // only for CSV/XLSX files, default generic (same as generic for now)
str?: string;
// zip or xlsx can either be ArrayBuffer or base64 string of original file.
// Do not use for other types, they should all just be strings.
arrbuf?: ArrayBuffer;
base64?: string;
};

// This function will attempt to convert all the input files into an array of Modus JSON files
export async function toGeoJson(
input: ModusResult
): Promise<GeoJSON> {
let results: GeoJSON = {
type: 'FeatureCollection',
features: []
};
for (const event of input.Events || []) {
for (const [type, eventSamples] of Object.entries(event.EventSamples!) as [string, any]) {
const sampleName = `${type}Samples`;
for (const samples of eventSamples[sampleName]) {
const nutrientResults = type === 'Soil' ?
//TODO: Map Element names into something like Depth + Element Name
samples.Depths.map((d: any) => d.NutrientResults).flat(1)
: samples.NutrientResults;
if (!samples.Geometry.wkt) continue;
const feat = wkt.read(samples.Geometry.wkt).toJson();
for (const nr of nutrientResults) {

}
}
}
}
return results;
}
2 changes: 2 additions & 0 deletions convert/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { modusTests } from '@modusjs/industry';
import type { NutrientResult } from '@modusjs/units';
import type { LabConfig } from './labs/index.js';

export * as slim from './slim.js'

const error = debug('@modusjs/convert#tojson:error');
const warn = debug('@modusjs/convert#tojson:error');
const info = debug('@modusjs/convert#tojson:info');
Expand Down
213 changes: 213 additions & 0 deletions convert/src/slim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Functions to auto-detect types and convert a batch of files into an array of Modus JSON's,
// with suggested output filenames. Just give each input file a filename whose extension
// reflects its type, and
import debug from 'debug';
import jszip from 'jszip';
import md5 from 'md5';
import { parse as csvParse, supportedFormats, SupportedFormats } from './csv.js';
import { parseModusResult as xmlParseModusResult } from './xml.js';
import { convertUnits } from '@modusjs/units';
import { simpleConvert } from '@modusjs/units/dist/index.js';
import { modusTests } from '@modusjs/industry';
import type ModusResult from '@oada/types/modus/v1/modus-result.js';
import type Slim from '@oada/types/modus/slim/v1/0.js';
import jp from 'json-pointer';
// @ts-expect-error no types
import wicket from 'wicket';

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 type SupportedFileType = 'xml' | 'csv' | 'xlsx' | 'json' | 'zip';
export const supportedFileTypes = ['xml', 'csv', 'xlsx', 'json', 'zip'];

export type ModusJSONConversionResult = {
original_filename: string;
original_type: SupportedFileType;
output_filename: string;
modus: ModusResult;
};

export type InputFile = {
filename: string; // can include the path on the front
format?: 'generic'; // only for CSV/XLSX files, default generic (same as generic for now)
str?: string;
// zip or xlsx can either be ArrayBuffer or base64 string of original file.
// Do not use for other types, they should all just be strings.
arrbuf?: ArrayBuffer;
base64?: string;
};

// This function will attempt to convert all the input files into an array of Modus JSON files
export function toSlim(
input: ModusResult
): Slim {
let result : any = {
_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/LabEventID`, result, `/lab/id/value`);
setPath(event, `/LabMetaData/LabEventID`, 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/City`, result, `/lab/contact/city`);
setPath(event, `/LabMetaData/Contact/State`, result, `/lab/contact/state`);
//TODO: the spec uses 'address', not city + state

setPath(event, `/LabMetaData/ReceivedDate`, result, `/lab/dateReceived`);
setPath(event, `/LabMetaData/ProcessedDate`, result, `/lab/dateProcessed`);

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, `/FMISMetaData/FMISEventID`, result, `/source/report/id`);
setPath(event, `/FMISMetaData/FMISProfile/Farm`, result, `/source/farm/name`);
setPath(event, `/FMISMetaData/FMISProfile/Field`, result, `/source/field/name`);
setPath(event, `/FMISMetaData/FMISProfile/Grower`, result, `/source/grower/name`);
setPath(event, `/FMISMetaData/FMISProfile/Sub-Field`, result, `/source/subfield/name`);

// Lab report
setPath(event, `/LabMetaData/ProcessedDate`, result, `/lab/report/date`);
setPath(event, `/LabMetaData/Reports/0/LabReportID`, result, `/lab/report/id`);
setPath(event, `/LabMetaData/Reports`, result, `/lab/files`, (reports) =>
reports.map((r: any) => {
const file: any = {};
setPath(r, `/File/ReportID`, file, `/id`);
setPath(r, `/File/FileData`, file, `/base64`);
setPath(r, `/File/URL/Path`, file, `/uri`);
setPath(r, `/File/URL/FileName`, file, `/name`);
setPath(r, `/File/FileData/FileName`, file, `/name`);
return file;
})
);
if (result.lab.files.length === 0) delete result.lab.files

for (const [type, eventSamples] of Object.entries(event.EventSamples!) as [string, any]) {
// Handle depths
//TODO: Or nematode??
const depths = type === 'Soil' ? eventSamples.DepthRefs.map((dr: any) => ({
name: dr.Name,
top: dr.StartingDepth,
bottom: dr.EndingDepth,
units: dr.DepthUnit
})) : undefined;
if (depths.length === 1) result.depth = depths[0];

const sampleName = `${type}Samples`;

// Samples
for (const eventSample of eventSamples[sampleName]) {
const sample: any = {};
setPath(eventSample, `/SampleMetaData/SampleNumber`, sample, `/lab/sampleid`);
setPath(eventSample, `/SampleMetaData/SampleContainerID`, sample, `/source/sampleid`);

// Nutrient results
const nutrientResults = type === 'Soil' ?
eventSample.Depths.map((d: any) => d.NutrientResults).flat(1)
: eventSample.NutrientResults;
sample.nutrientResult = Object.fromEntries(nutrientResults.map((d: any) => {
const nr = {};
setPath(d, `/ModusTestID`, nr, `/analyte`, (v) => v.split('_')[3])
setPath(d, `/Element`, nr, `/analyte`);
setPath(d, `/Value`, nr, `/value`);
setPath(d, `/ValueUnit`, nr, `/units`);
setPath(d, `/ModusTestID`, nr, `/modusTestID`);
// d.ValueDesc???
// d.ValueType???
// TODO: Some other
const nrid = md5(JSON.stringify(nr));
return [nrid, 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(),
}
})

const sampleid = md5(JSON.stringify(sample));
result.samples = result.samples || {};
result.samples[sampleid] = sample;
}
}
}
return result as unknown as Slim;
}

// Handles a few little things:
// - don't set things when it doesn't exist on the ModusResult
// - creates parents down to the given path
// - falls back to previous attempts to set the new path value
// - won't set values that are undefined or empty arrays; will set values === 0
function setPath(obj: any, objPath: string, newObj: any, newPath: string, func?: (v:any) => any) {
if (jp.has(obj, objPath)) {
const newVal = func ?
func(jp.get(obj, objPath)) ?? jp.get(newObj, newPath)
: jp.get(obj, objPath) ?? jp.get(newObj, newPath);
if (Array.isArray(newVal) && newVal.length > 0) {
jp.set(newObj, newPath, newVal);
} else if (newVal || newVal === 0) {
jp.set(newObj, newPath, newVal);
}
}
}

// TODO: What else to flatten?
// Should it return only samples part of input?
// Should it return an object or array?
export function flatten(input: Slim) {
const samples = Object.fromEntries(
Object.entries(input.samples || {}).map(([sampleid, sample]) => {
const newSample: any = {
...sample,
lab: {
...input.lab,
...sample.lab,
},
source: {
...input.source,
...sample.lab,
}
}
if (input.depth)
sample = {
depth: input.depth,
...sample
}
if (input.geolocation)
sample = {
geolocation: input.geolocation,
...sample
};
return [sampleid, newSample];
})
);

return {
...input,
samples,
}
}
Loading

0 comments on commit 457dc01

Please sign in to comment.