diff --git a/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/context/MappingContextLoader.scala b/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/context/MappingContextLoader.scala index 6c9fc0b2..be80411a 100644 --- a/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/context/MappingContextLoader.scala +++ b/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/context/MappingContextLoader.scala @@ -28,10 +28,7 @@ class MappingContextLoader extends IMappingContextLoader { if (contextDefinition.url.isDefined) { //logger.debug("The context definition for the mapping repository is defined at a URL:{}. It will be loaded...", contextDefinition.url.get) // FIXME: To build the context, we only accept CSV files with a header to read from. - contextDefinition.category match { - case FhirMappingContextCategories.CONCEPT_MAP => readConceptMapContextFromCSV(contextDefinition.url.get).map { concepts => ConceptMapContext(concepts) } - case FhirMappingContextCategories.UNIT_CONVERSION_FUNCTIONS => readUnitConversionFunctionsFromCSV(contextDefinition.url.get).map { conversionFunctions => UnitConversionContext(conversionFunctions) } - } + readConceptMapContextFromCSV(contextDefinition.url.get) } else { // FIXME: If there is no URL to read from, then the context definition may be given through the value of the FhirMappingContextDefinition as a JSON object. // It needs to be converted to a FhirMappingContext object. @@ -61,48 +58,73 @@ class MappingContextLoader extends IMappingContextLoader { } /** - * Read concept mappings from the given CSV file. - * Example dataset to understand what this function does: - * Input CSV content (concept mappings), assumed to be at some file path specified: + * Reads Concept Maps and with potential Unit Conversion-related fields from a CSV file, + * + * The CSV's header row, 'source code' is used as a key to group the entries. + * Grouped entries form the Concept Map view. + * + * If the CSV also includes the following columns, the method additionally builds a Unit Conversion view where 'source_code' and 'source_unit' entries are keyed to map 'conversion_function' and 'target_unit' entries: + * - `source_unit` + * - `target_unit` + * - `conversion_function` + * + * Example Composite CSV Data * ----------------------------- - * source_code,target_code,display_value - * 001,A1,Foo - * 001,A2,Bar - * 002,B1,Baz + * source_code,source_unit,target_code,target_unit,conversion_function + * "1988-5","mg/L","1988-5","mg/L","$this" + * "59260-0","mmol/L","718-7","g/L","$this * 16.114" * ----------------------------- * - * Explanation of this structure: - * - "source_code" is the key (the first column header), which will group the rows. - * - "target_code" and "display_value" are part of the data for each key grouping. - * - * Expected output of processing: + * Concept Map view: * Map( - * "001" -> Seq( - * Map("source_code" -> "001", "target_code" -> "A1", "display_value" -> "Foo"), - * Map("source_code" -> "001", "target_code" -> "A2", "display_value" -> "Bar") + * "1988-5" -> Seq( + * Map("source_code" -> "1988-5", "source_unit" -> "mg/L", "target_code" -> "1988-5", "target_unit" -> "mg/L", "conversion_function" -> "$this" ) * ), - * "002" -> Seq( - * Map("source_code" -> "002", "target_code" -> "B1", "display_value" -> "Baz") + * "59260-0" -> Seq( + * Map("source_code" -> "59260-0", "source_unit" -> "mmol/L", "target_code" -> "718-7", "target_unit" -> "g/L", "conversion_function" -> "$this * 16.114" ) * ) * ) * - * @param filePath + * Unit Conversion view: + * Map( + * ("1988-5","mg/L") -> ("mg/L", "$this"), + * ("59260-0", "mmol/L")-> ("g/L", "$this * 16.114") + * ) + * + * @param filePath file path of the CSV file * @return */ - private def readConceptMapContextFromCSV(filePath: String): Future[Map[String, Seq[Map[String, String]]]] = { + private def readConceptMapContextFromCSV(filePath: String): Future[ConceptMapContext] = { readFromCSV(filePath) map { case (columns, records) => //val (firstColumnName, _) = records.head.head // Get the first element in the records list and then get the first (k,v) pair to get the name of the first column. val columnHeadKey = columns.head - records.foldLeft(Map[String, Seq[Map[String, String]]]()) { (conceptMap, columnMap) => - val key = columnMap(columnHeadKey) - // If a source code has not been encountered before, add it as the first element. - // Otherwise, append the new target values to the existing sequence. - conceptMap.updatedWith(key) { - case Some(existingValues) => Some(existingValues :+ columnMap) - case None => Some(Seq(columnMap)) - } + val lowerCols = columns.map(_.toLowerCase) + + def colName(wanted: String): Option[String] = { + val i = lowerCols.indexOf(wanted) + if (i == -1) None else Some(columns(i)) } + + // Concept Map view + val concepts: Map[String, Seq[Map[String, String]]] = + records.groupBy(_(columnHeadKey)).view.mapValues(_.toSeq).toMap + + // Unit Conversion view + val maybeSrcUnit = colName("source_unit") + val maybeTgtUnit = colName("target_unit") + val maybeFn = colName("conversion_function") + + val conversionFunctions: Map[(String, String), (String, String)] = + (maybeSrcUnit, maybeTgtUnit, maybeFn) match { + case (Some(srcU), Some(tgtU), Some(fn)) => + records.foldLeft(Map.empty[(String, String), (String, String)]) { + (acc, row) => + acc + ((row(columnHeadKey) -> row(srcU)) -> (row(tgtU) -> row(fn)))} + case _ => Map.empty + } + + ConceptMapContext(concepts = concepts, conversionFunctions = conversionFunctions) } } diff --git a/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/fhirPath/FhirPathMappingFunctions.scala b/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/fhirPath/FhirPathMappingFunctions.scala index f2cd3538..c9507941 100644 --- a/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/fhirPath/FhirPathMappingFunctions.scala +++ b/tofhir-engine/src/main/scala/io/tofhir/engine/mapping/fhirPath/FhirPathMappingFunctions.scala @@ -410,8 +410,12 @@ class FhirPathMappingFunctions(context: FhirPathEnvironment, current: Seq[FhirPa ) def convertAndReturnQuantity(conversionFunctionsMap: ExpressionContext, keyExpr: ExpressionContext, valueExpr: ExpressionContext, unitExpr: ExpressionContext): Seq[FhirPathResult] = { val mapName = conversionFunctionsMap.getText.substring(1) // skip the leading % character - val unitConversionContext = try { - mappingContext(mapName).asInstanceOf[UnitConversionContext] + val unitConversionContext: UnitConversionContext = try { + mappingContext(mapName) match { + case u: UnitConversionContext => u + case c: ConceptMapContext => UnitConversionContext(c.conversionFunctions) + case _ => throw new Exception() + } } catch { case e: Exception => throw new FhirPathException(s"Invalid function call 'convertAndReturnQuantity', given expression for conversionFunctionsMap:${conversionFunctionsMap.getText} should point to a valid map entry in the provided mapping context!") } diff --git a/tofhir-engine/src/main/scala/io/tofhir/engine/model/FhirMappingContext.scala b/tofhir-engine/src/main/scala/io/tofhir/engine/model/FhirMappingContext.scala index 8773c85f..dcfe8d1f 100644 --- a/tofhir-engine/src/main/scala/io/tofhir/engine/model/FhirMappingContext.scala +++ b/tofhir-engine/src/main/scala/io/tofhir/engine/model/FhirMappingContext.scala @@ -19,10 +19,25 @@ trait FhirMappingContext { * (unit -> mL), * (profile -> https://aiccelerate.eu/fhir/StructureDefinition/AIC-IntraOperativeObservation)] * ]] + * @param conversionFunctions Optionally given unit conversion data + * + * If the parsed CSV uses "source_unit, target_unit, conversion_function" columns on top of the essential column data, the model will also hold those as Unit Conversion specifications. + * + * For example, in a composite mapping context like the following, the conversion specific data looks as given below it: + * + * ----------------------------- + * source_code,source_unit,target_code,target_unit,conversion_function + * "1988-5","mg/L","1988-5","mg/L","$this" + * "59260-0","mmol/L","718-7","g/L","$this * 16.114" + * ----------------------------- + * Map( + * ("1988-5","mg/L") -> ("mg/L", "$this"), + * ("59260-0", "mmol/L")-> ("g/L", "$this * 16.114") + * ) * * */ -case class ConceptMapContext(concepts: Map[String, Seq[Map[String, String]]]) extends FhirMappingContext { +case class ConceptMapContext(concepts: Map[String, Seq[Map[String, String]]], conversionFunctions: Map[(String, String), (String, String)] = Map.empty) extends FhirMappingContext { override def toContextObject: JObject = JObject() } diff --git a/tofhir-engine/src/test/scala/io/tofhir/test/FhirMappingFolderRepositoryTest.scala b/tofhir-engine/src/test/scala/io/tofhir/test/FhirMappingFolderRepositoryTest.scala index 0081b74c..81d31ab9 100644 --- a/tofhir-engine/src/test/scala/io/tofhir/test/FhirMappingFolderRepositoryTest.scala +++ b/tofhir-engine/src/test/scala/io/tofhir/test/FhirMappingFolderRepositoryTest.scala @@ -3,7 +3,7 @@ package io.tofhir.test import io.tofhir.ToFhirTestSpec import io.tofhir.engine.mapping.context.MappingContextLoader import io.tofhir.engine.model.exception.FhirMappingException -import io.tofhir.engine.model.{ConceptMapContext, UnitConversionContext} +import io.tofhir.engine.model.ConceptMapContext import org.scalatest.flatspec.AsyncFlatSpec import java.io.File @@ -83,7 +83,7 @@ class FhirMappingFolderRepositoryTest extends AsyncFlatSpec with ToFhirTestSpec val unitConversionContextDefinition = labResultsMapping.context("labResultUnitConversion") val mappingContextLoader = new MappingContextLoader mappingContextLoader.retrieveContext(unitConversionContextDefinition) map { context => - val unitConversionContext = context.asInstanceOf[UnitConversionContext] + val unitConversionContext = context.asInstanceOf[ConceptMapContext] unitConversionContext.conversionFunctions.size shouldBe 25 // source_code,source_unit,target_unit,conversion_function