Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add structure map generation tool to efsity #202

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions efsity/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ fhircore-tooling.iml
# build files
bin
target
generated-structure-map.txt
generated-json-map.json
13 changes: 13 additions & 0 deletions efsity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ $ fct validateFileStructure -i ~/Workspace/fhir-resources/<project> -s ~/path/to
If the file structure matches the schema then a positive result is printed to the terminal, otherwise an error
is thrown showing the issue.

### Generating a structure map
To generate a Structure Map, you need to provide the questionnaire, the configuration path, and the questionnaire response path. The output will be based on the specified inputs
```console
$ fct generateStructureMap --questionnaire /path/to/questionnaire.json --configPath /path/to/config/StructureMap.xls --questionnaireResponsePath /path/to/questionnaire-response.json
```
This process will generate two output files: a .map file and its JSON equivalent
**Options**
```
-q or --questionnaire Path to the Questionnaire JSON file.
-c or --configPath Path to the Structure Map configuration file (XLS format).
-qr or --questionnaireResponsePath Path to the Questionnaire Response JSON file.
```

### Localization
Tool that supports localization by the use of the translation extension

Expand Down
4 changes: 4 additions & 0 deletions efsity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,14 @@ dependencies {
implementation(deps.xstream)
implementation(deps.icu4j)
implementation(deps.javafaker)
implementation("org.apache.poi:poi:4.1.1")
sharon2719 marked this conversation as resolved.
Show resolved Hide resolved
implementation("org.apache.poi:poi-ooxml:4.1.1")

testImplementation(kotlin("test"))
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-inline:3.12.4")
testImplementation("io.mockk:mockk:1.13.7")
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
}

tasks.withType<JavaCompile> { options.encoding = deps.versions.project.build.sourceEncoding.get() }
Expand Down
3 changes: 2 additions & 1 deletion efsity/src/main/java/org/smartregister/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
TranslateCommand.class,
QuestionnaireResponseGeneratorCommand.class,
ValidateFileStructureCommand.class,
PublishFhirResourcesCommand.class
PublishFhirResourcesCommand.class,
GenerateStructureMapCommand.class
})
public class Main implements Runnable {
public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.smartregister.command;

import java.io.IOException;
import org.smartregister.structuremaptool.GenerateStructureMapServiceKt;
import org.smartregister.util.FctUtils;
import picocli.CommandLine;

@CommandLine.Command(name = "generateStructureMap")
public class GenerateStructureMapCommand implements Runnable {
@CommandLine.Option(
names = {"-q", "--questionnaire"},
description = "Questionnaire",
required = true)
private String questionnairePath;

@CommandLine.Option(
names = {"-c", "--configPath"},
description = "StructureMap generation configuration in an excel sheet",
sharon2719 marked this conversation as resolved.
Show resolved Hide resolved
required = true)
private String configPath;

@CommandLine.Option(
names = {"-qr", "--questionnaireResponsePath"},
description = "Questionnaire response",
sharon2719 marked this conversation as resolved.
Show resolved Hide resolved
required = true)
private String questionnaireResponsePath;

@Override
public void run() {
if (configPath != null) {
try {
generateStructureMap(configPath, questionnairePath, questionnaireResponsePath);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

public static void generateStructureMap(
String configPath, String questionnairePath, String questionnaireResponsePath)
throws IOException {
long start = System.currentTimeMillis();
FctUtils.printInfo("Starting StructureMap generation");
GenerateStructureMapServiceKt.main(configPath, questionnairePath, questionnaireResponsePath);

FctUtils.printCompletedInDuration(start);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import org.hl7.fhir.r4.utils.StructureMapUtilities.ITransformerServices
class TransformSupportServices constructor(private val simpleWorkerContext: SimpleWorkerContext) :
ITransformerServices {

private val outputs: MutableList<Base> = mutableListOf()
val outputs: MutableList<Base> = mutableListOf()

override fun log(message: String) {
// logger.info(message)
Expand All @@ -40,6 +40,7 @@ class TransformSupportServices constructor(private val simpleWorkerContext: Simp
override fun createType(appInfo: Any, name: String): Base {
return when (name) {
"RiskAssessment_Prediction" -> RiskAssessmentPredictionComponent()
"RiskAssessment\$RiskAssessmentPredictionComponent" -> RiskAssessmentPredictionComponent()
"Immunization_AppliedProtocol" -> Immunization.ImmunizationProtocolAppliedComponent()
"Immunization_Reaction" -> Immunization.ImmunizationReactionComponent()
"EpisodeOfCare_Diagnosis" -> EpisodeOfCare.DiagnosisComponent()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package org.smartregister.structuremaptool

import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import java.io.File
import java.io.FileInputStream
import java.nio.charset.Charset
import java.util.*
import org.apache.commons.io.FileUtils
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.WorkbookFactory
import org.hl7.fhir.r4.context.SimpleWorkerContext
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Parameters
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager
import org.hl7.fhir.utilities.npm.ToolsVersion
import org.smartregister.external.TransformSupportServices
import org.smartregister.structuremaptool.extensions.addIndentation
import org.smartregister.structuremaptool.extensions.clean

fun main(
xlsConfigFilePath: String,
questionnaireFilePath: String,
questionnaireResponsePath: String
) {
val contextR4 = FhirContext.forR4()
val fhirJsonParser = contextR4.newJsonParser()
val questionnaire: Questionnaire =
fhirJsonParser.parseResource(
Questionnaire::class.java,
FileUtils.readFileToString(File(questionnaireFilePath), Charset.defaultCharset())
)
val questionnaireResponse: QuestionnaireResponse =
fhirJsonParser.parseResource(
QuestionnaireResponse::class.java,
FileUtils.readFileToString(File(questionnaireResponsePath), Charset.defaultCharset())
)

// reads the xls
val xlsConfigFile = FileInputStream(xlsConfigFilePath)
val xlWorkbook = WorkbookFactory.create(xlsConfigFile)

// TODO: Check that all the Resource(s) ub the Resource column are the correct name and type eg.
// RiskFlag in the previous XLSX was not valid
// TODO: Check that all the path's and other entries in the excel sheet are valid
sharon2719 marked this conversation as resolved.
Show resolved Hide resolved
// TODO: Add instructions for adding embedded classes like
// `RiskAssessment$RiskAssessmentPredictionComponent` to the TransformSupportServices

// read the settings sheet
val settingsWorkbook = xlWorkbook.getSheet("Settings")
var questionnaireId: String? = null

for (i in 0..settingsWorkbook.lastRowNum) {
val cell = settingsWorkbook.getRow(i).getCell(0)
if (cell.stringCellValue == "questionnaire-id") {
questionnaireId = settingsWorkbook.getRow(i).getCell(1).stringCellValue
}
}
sharon2719 marked this conversation as resolved.
Show resolved Hide resolved

/*
TODO: Fix Groups calling sequence so that Groups that depend on other resources to be generated need to be called first
We can also throw an exception if to figure out cyclic dependency. Good candidate for Floyd's tortoise and/or topological sorting 😁. Cool!!!!
*/

val questionnaireResponseItemIds = questionnaireResponse.item.map { it.id }
if (questionnaireId != null && questionnaireResponseItemIds.isNotEmpty()) {

val sb = StringBuilder()
val structureMapHeader =
"""
map "http://hl7.org/fhir/StructureMap/$questionnaireId" = '${questionnaireId.clean()}'

uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireReponse" as source
uses "http://hl7.org/fhir/StructureDefinition/Bundle" as target
"""
.trimIndent()

val structureMapBody =
"""
group ${questionnaireId.clean()}(source src : QuestionnaireResponse, target bundle: Bundle) {
src -> bundle.id = uuid() "rule_c";
src -> bundle.type = 'collection' "rule_b";
src -> bundle.entry as entry then """
.trimIndent()

val resourceConversionInstructions = hashMapOf<String, MutableList<Instruction>>()

// Group the rules according to the resource
val fieldMappingsSheet = xlWorkbook.getSheet("Field Mappings")
fieldMappingsSheet.forEachIndexed { index, row ->
if (index == 0) return@forEachIndexed
if (row.isEmpty()) {
return@forEachIndexed
}

val instruction = row.getInstruction()
val xlsId = instruction.responseFieldId
val comparedResponseAndXlsId = questionnaireResponseItemIds.contains(xlsId)
if (instruction.resource.isNotEmpty() && comparedResponseAndXlsId) {
resourceConversionInstructions
.computeIfAbsent(instruction.searchKey(), { key -> mutableListOf() })
.add(instruction)
}
}

// val resource = ?: Class.forName("org.hl7.fhir.r4.model.$resourceName").newInstance() as
// Resource

// Perform the extraction for the row
/*generateStructureMapLine(structureMapBody, row, resource, extractionResources)

extractionResources[resourceName + resourceIndex] = resource*/

sb.append(structureMapHeader)
sb.appendNewLine(2)
sb.append(structureMapBody)

// Fix the question path
val questionsPath = getQuestionsPath(questionnaire)

// TODO: Generate the links to the group names here
var index = 0
var len = resourceConversionInstructions.size
var resourceName = ""
resourceConversionInstructions.forEach { entry ->
resourceName =
entry.key.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
if (index++ != 0) sb.append(",")
if (resourceName.isNotEmpty()) sb.append("Extract$resourceName(src, bundle)")
}
sb.append(""" "rule_a";""".trimMargin())
sb.appendNewLine()
sb.append("}")

// Add the embedded instructions
val groupNames = mutableListOf<String>()
sb.appendNewLine(3)

resourceConversionInstructions.forEach {
Group(it, sb, questionsPath).generateGroup(questionnaireResponse)
}

val structureMapString = sb.toString()
try {
val simpleWorkerContext =
SimpleWorkerContext().apply {
setExpansionProfile(Parameters())
isCanRunWithoutTerminology = true
}
val transformSupportServices = TransformSupportServices(simpleWorkerContext)
val scu =
org.hl7.fhir.r4.utils.StructureMapUtilities(simpleWorkerContext, transformSupportServices)
val structureMap = scu.parse(structureMapString, questionnaireId.clean())
// DataFormatException | FHIRLexerException

try {
val bundle = Bundle()
scu.transform(contextR4, questionnaireResponse, structureMap, bundle)
val jsonParser = FhirContext.forR4().newJsonParser()

println(jsonParser.encodeResourceToString(bundle))
} catch (e: Exception) {
e.printStackTrace()
}
} catch (ex: Exception) {
println("The generated StructureMap has a formatting error")
ex.printStackTrace()
}

var finalStructureMap = sb.toString()
finalStructureMap = finalStructureMap.addIndentation()
println(finalStructureMap)
writeStructureMapOutput(sb.toString().addIndentation())
}
}

fun Row.isEmpty(): Boolean {
return getCell(0) == null && getCell(1) == null && getCell(2) == null
}

fun Row.getCellAsString(cellnum: Int): String? {
val cell = getCell(cellnum) ?: return null
return when (cell.cellTypeEnum) {
CellType.STRING -> cell.stringCellValue
CellType.BLANK -> null
CellType.BOOLEAN -> cell.booleanCellValue.toString()
CellType.NUMERIC -> cell.numericCellValue.toString()
else -> null
}
}

fun Row.getInstruction(): Instruction {
return Instruction().apply {
responseFieldId = getCell(0)?.stringCellValue
constantValue = getCellAsString(1)
resource = getCell(2).stringCellValue
resourceIndex = getCell(3)?.numericCellValue?.toInt() ?: 0
fieldPath = getCell(4)?.stringCellValue ?: ""
fullFieldPath = fieldPath
field = getCell(5)?.stringCellValue
conversion = getCell(6)?.stringCellValue
fhirPathStructureMapFunctions = getCell(7)?.stringCellValue
}
}

class Instruction {
var responseFieldId: String? = null
var constantValue: String? = null
var resource: String = ""
var resourceIndex: Int = 0
var fieldPath: String = ""
var field: String? = null
var conversion: String? = null
var fhirPathStructureMapFunctions: String? = null

// TODO: Clean the following properties
var fullFieldPath = ""

fun fullPropertyPath(): String = "$resource.$fullFieldPath"

fun searchKey() = resource + resourceIndex
}

fun Instruction.copyFrom(instruction: Instruction) {
constantValue = instruction.constantValue
resource = instruction.resource
resourceIndex = instruction.resourceIndex
fieldPath = instruction.fieldPath
fullFieldPath = instruction.fullFieldPath
field = instruction.field
conversion = instruction.conversion
fhirPathStructureMapFunctions = instruction.fhirPathStructureMapFunctions
}

fun writeStructureMapOutput(structureMap: String) {
File("generated-structure-map.txt").writeText(structureMap.addIndentation())
val pcm = FilesystemPackageCacheManager(true, ToolsVersion.TOOLS_VERSION)
val contextR5 = SimpleWorkerContext.fromPackage(pcm.loadPackage("hl7.fhir.r4.core", "4.0.1"))
contextR5.setExpansionProfile(Parameters())
contextR5.isCanRunWithoutTerminology = true
val transformSupportServices = TransformSupportServices(contextR5)
val scu = org.hl7.fhir.r4.utils.StructureMapUtilities(contextR5, transformSupportServices)
val map = scu.parse(structureMap, "LocationRegistration")
val iParser: IParser =
FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().setPrettyPrint(true)
val mapString = iParser.encodeResourceToString(map)
File("generated-json-map.json").writeText(mapString)
}
Loading