Skip to content

Commit

Permalink
Implement ConceptMap generation
Browse files Browse the repository at this point in the history
Feature/conceptmap
  • Loading branch information
jpwiedekopf authored Mar 4, 2022
2 parents 3d36c91 + babf406 commit e77d784
Show file tree
Hide file tree
Showing 33 changed files with 1,726 additions and 490 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ on:
push:
branches: [ develop, main ]
pull_request:
branches: [ develop, main ]
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

jobs:
build-ubuntu:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stopship.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ jobs:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Test for STOPSHIP comments
run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopshipasdf' && echo exit 1 || echo exit 0
run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopship' && exit 1 || exit 0
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ as the computed difference graph. To illustrate, consider these two CodeSystems:
|-----------------------------------------------|-------------------------------------------------|
| ![Left CS for diff graph](images/left-cs.png) | ![Right CS for diff graph](images/right-cs.png) |


Going from "left" to "right", the concept `C` was removed, leading to changes in the edge going from `D` to `A`. Also, a
new type of edge, `related-to` was introduced.

Expand Down Expand Up @@ -156,7 +155,9 @@ We utilize the following libraries alongside *Compose*:
- [Apache Commons Lang](https://commons.apache.org/proper/commons-lang/)
- [FlatLaf](https://www.formdev.com/flatlaf/) for dark window chrome on Windows
- [ktor](https://ktor.io) for coroutine-based HTTP
- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python
- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port
of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python
- [RSyntaxTextArea](https://github.com/bobbylight/RSyntaxTextArea) for the JSON editor in the `ConceptMap` panel

### Localization

Expand All @@ -178,7 +179,8 @@ implementation takes in a list of column specifications that render the provided
The table supports merging adjacent columns (if a predicate returns `true`), tooltips (e.g. on the lower table, when
English is not selected as the language, the default name of the FHIR attribute is shown in the tooltip of the left-hand
column), and zebra striping. In the top table, the table data is also pre-filtered using a generic set of filter
buttons.
buttons. Column specs can declare that they are searchable, which yields a search button next to the column name. Search
parameters can be combined at will.

### Graph window

Expand Down Expand Up @@ -211,7 +213,7 @@ We are looking at implementing the following features:
- [x] support for the `vread` mechanism to compare across instance versions on FHIR Terminology servers
- [ ] a visualization of the neighborhood of any concept in the graph, to view the connections a concept has across the
network of concepts
- integrating this feature into the difference graph, so that layers of context can be added iteratively
- integrating this feature into the difference graph, so that layers of context can be added iteratively
- support for other types of resources in FHIR, especially `ValueSet` and `ConceptMap`, likely with TS support.

## How do I cite this?
Expand Down
5 changes: 3 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ repositories {
}

val hapiVersion = "5.6.2"
val slf4jVersion = "1.7.35"
val slf4jVersion = "1.7.36"
val graphStreamVersion = "2.0"
val jGraphTVersion = "1.5.1"
val material3DesktopVersion = "1.0.0"
Expand Down Expand Up @@ -51,6 +51,7 @@ dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
implementation("com.fifesoft:rsyntaxtextarea:3.1.6")
}

tasks.test {
Expand Down Expand Up @@ -87,7 +88,7 @@ compose.desktop {
packageName = "TerminoDiff"
packageVersion = composeBuildVersion
description = "Visually compare HL7 FHIR Terminology"
vendor = "IT Center for Clinical Reserach, University of Lübeck"
vendor = "IT Center for Clinical Research, University of Lübeck"
copyright = "Joshua Wiedekopf / IT Center for Clinical Research, 2022-"

when (composeBuildOs) {
Expand Down
19 changes: 4 additions & 15 deletions src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.narrative.DefaultThymeleafNarrativeGenerator
import com.formdev.flatlaf.FlatDarkLaf
import org.apache.commons.lang3.SystemUtils
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
Expand Down Expand Up @@ -61,13 +62,7 @@ fun AppWindow(
useDarkTheme: Boolean,
onChangeDarkTheme: () -> Unit,
) {

when (SystemUtils.IS_OS_WINDOWS) {
//when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) {
//setting this does not make sense if not on Windows
true -> FlatDarkLaf.setup()
//else -> FlatLightLaf.setup()
}
FlatDarkLaf.setup()
var locale by remember { mutableStateOf(SupportedLocale.valueOf(AppPreferences.language)) }
val localizedStrings by derivedStateOf { getStrings(locale) }
val scrollState = rememberScrollState()
Expand All @@ -83,19 +78,13 @@ fun AppWindow(
this.window.iconImage = ImageIO.read(it.resolve("[email protected]"))
}
UIManager.setLookAndFeel(FlatDarkLaf())
when (SystemUtils.IS_OS_WINDOWS) {
//when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) {
/*false -> UIManager.setLookAndFeel(FlatLightLaf())
else -> UIManager.setLookAndFeel(FlatDarkLaf())*/
true -> UIManager.setLookAndFeel(FlatDarkLaf())
}

if (!hasResizedWindow) {
// app crashes if we use state for the window, when the locale is changed, with the error
// that the window is already on screen.
// this is because everything is recomposed when the locale changes, and that breaks AWT.
// using the mutable state, we change the window size exactly once, during the first (re-) composition,
// so that the user can then change the res as they require.
// using the mutable state, we programatically change the window size exactly once,
// during the first (re-) composition, so that the user can then change the res as they require.
// A resolution of 1280x960 is 4:3.
this.window.size = Dimension(1280, 960)
hasResizedWindow = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package libraries.pager_indicators
package libraries.accompanist.pager_indicators

import libraries.accompanist.pager.PagerState
import androidx.compose.foundation.background
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package libraries.pager_indicators
package libraries.accompanist.pager_indicators

import libraries.accompanist.pager.PagerState
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package terminodiff.ui.util
package libraries.sahruday.carousel

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.MutatePriority
Expand Down
239 changes: 239 additions & 0 deletions src/main/kotlin/terminodiff/engine/conceptmap/ConceptMapState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package terminodiff.terminodiff.engine.conceptmap

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Verified
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.vector.ImageVector
import org.hl7.fhir.r4.model.ConceptMap
import org.hl7.fhir.r4.model.ConceptMap.*
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.Enumerations
import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence
import org.jgrapht.GraphPath
import org.jgrapht.alg.shortestpath.AllDirectedPaths
import terminodiff.engine.resources.DiffDataContainer
import terminodiff.i18n.LocalizedStrings
import terminodiff.terminodiff.engine.graph.CombinedEdge
import terminodiff.terminodiff.engine.graph.CombinedVertex
import terminodiff.terminodiff.engine.graph.GraphSide
import terminodiff.terminodiff.ui.panes.diff.NeighborhoodDisplay

class ConceptMapState(
diffDataContainer: DiffDataContainer,
) {
fun acceptAll() = conceptMap.group.elements.forEach { element ->
element.targets.forEach { target ->
target.isAutomaticallySet = false
}
}

val conceptMap by mutableStateOf(TerminodiffConceptMap(diffDataContainer))
}

class TerminodiffConceptMap(diffDataContainer: DiffDataContainer) {

val id: MutableState<String?> = mutableStateOf(null)
val canonicalUrl: MutableState<String?> = mutableStateOf(null)
val version: MutableState<String?> = mutableStateOf(null)
val name: MutableState<String?> =
mutableStateOf(null)
val title: MutableState<String?> =
mutableStateOf(null)
val sourceValueSet: MutableState<String?> =
mutableStateOf(null)
val targetValueSet: MutableState<String?> =
mutableStateOf(null)
var group by mutableStateOf(ConceptMapGroup(diffDataContainer))


val toFhir by derivedStateOf {
ConceptMap().apply {
this.id = this@TerminodiffConceptMap.id.value
this.url = this@TerminodiffConceptMap.canonicalUrl.value
this.version = this@TerminodiffConceptMap.version.value
this.name = this@TerminodiffConceptMap.version.value
this.title = this@TerminodiffConceptMap.title.value
this.dateElement = DateTimeType.now()
this.group.add(this@TerminodiffConceptMap.group.toFhir)
}
}

override fun toString(): String {
return "TerminodiffConceptMap(id=${id.value}, canonicalUrl=${canonicalUrl.value}, version=${version.value}, name=${name.value}, title=${title.value}, sourceValueSet=${sourceValueSet.value}, targetValueSet=${targetValueSet.value})"
}
}

class ConceptMapGroup(diffDataContainer: DiffDataContainer) {
val sourceUri = mutableStateOf(diffDataContainer.leftCodeSystem?.url)
val sourceVersion = mutableStateOf(diffDataContainer.leftCodeSystem?.version)
val targetUri = mutableStateOf(diffDataContainer.rightCodeSystem?.url)
val targetVersion = mutableStateOf(diffDataContainer.rightCodeSystem?.version)
val elements = mutableStateListOf<ConceptMapElement>()

init {
populateElements(diffDataContainer)
}

private fun populateElements(diff: DiffDataContainer) {
diff.codeSystemDiff!!.combinedGraph!!.affectedVertices.forEach { vertex ->
elements.add(ConceptMapElement(diff, vertex.code, vertex.getTooltip()))
}
}

override fun toString(): String {
return "ConceptMapGroup(sourceUri=${sourceUri.value}, sourceVersion=${sourceVersion.value}, targetUri=${targetUri.value}, targetVersion=${targetVersion.value})"
}

val toFhir: ConceptMapGroupComponent by derivedStateOf {
ConceptMapGroupComponent().apply {
this.source = this@ConceptMapGroup.sourceUri.value
this.sourceVersion = this@ConceptMapGroup.sourceVersion.value
this.target = this@ConceptMapGroup.targetUri.value
this.targetVersion = this@ConceptMapGroup.targetVersion.value
this.element.addAll(this@ConceptMapGroup.elements.map { it.toFhir }
.filter(SourceElementComponent::hasTarget))
}
}
}

class ConceptMapElement(private val diffDataContainer: DiffDataContainer, code: String, display: String?) {
val code: MutableState<String> = mutableStateOf(code)
val display: MutableState<String?> = mutableStateOf(display)

val neighborhood by derivedStateOf {
NeighborhoodDisplay(this.code.value, diffDataContainer.codeSystemDiff!!)
}

private val neighborhoodGraph by derivedStateOf {
neighborhood.getNeighborhoodGraph()
}

private val suitableTargets by derivedStateOf {
// the list of targets is calculated from the neighborhood graph of the current vertex
neighborhoodGraph.vertexSet().filter { it.code != code } // the node itself can't be mapped to
.filter { it.side == GraphSide.BOTH } // we can only map to nodes that are shared across versions
.filter { v ->
// consider nodes that are reachable from the source vertex
val paths = getPaths(getVertexByCode(code), getVertexByCode(v.code))
paths.any { p ->
p.edgeList.any { e -> e.side != GraphSide.BOTH }
//disregard those paths that are entirely following nodes in both CS versions
}
}
}

val targets = mutableStateListOf<ConceptMapTarget>().apply {
suitableTargets.forEach { t ->
this.add(ConceptMapTarget(diffDataContainer).apply {
this.code.value = t.code
this.equivalence.value = inferEquivalence(this@ConceptMapElement.code.value, t.code)
})
}
}

val toFhir: SourceElementComponent by derivedStateOf {
SourceElementComponent().apply {
this.code = this@ConceptMapElement.code.value
this.display = this@ConceptMapElement.display.value
this.target.addAll(this@ConceptMapElement.targets.filter { it.state == ConceptMapTarget.MappingState.VALID }
.map { it.toFhir })
}
}

private fun inferEquivalence(sourceCode: String, targetCode: String): ConceptMapEquivalence? {
val sourceVertex = getVertexByCode(sourceCode) ?: return null
val targetVertex = getVertexByCode(targetCode) ?: return null
val (allPaths, originalOrder) = getPaths(sourceVertex, targetVertex).let { walk ->
when (walk.isEmpty()) {
true -> getPaths(targetVertex, sourceVertex) to false // flip the edge order
else -> walk to true
}
}
return when {
allPaths.isEmpty() -> null
allPaths.size == 1 -> inferEquivalenceFromPath(allPaths.first(), originalOrder)
else -> allPaths.maxByOrNull { it.length }?.let { shortestPath ->
inferEquivalenceFromPath(shortestPath, originalOrder)
}
}
}

private fun inferEquivalenceFromPath(
path: GraphPath<CombinedVertex, CombinedEdge>,
originalOrder: Boolean,
): ConceptMapEquivalence? {
return when {
path.edgeList.all { it.side == GraphSide.LEFT } -> if (originalOrder) ConceptMapEquivalence.WIDER else ConceptMapEquivalence.NARROWER
path.edgeList.all { it.side == GraphSide.RIGHT } -> if (originalOrder) ConceptMapEquivalence.NARROWER else ConceptMapEquivalence.WIDER
else -> null
}
}

private fun getVertexByCode(searchCode: String) = neighborhoodGraph.vertexSet().find { it.code == searchCode }

private fun getPaths(
sourceVertex: CombinedVertex?,
targetVertex: CombinedVertex?,
): List<GraphPath<CombinedVertex, CombinedEdge>> = when {
sourceVertex == null || targetVertex == null -> listOf()
else -> AllDirectedPaths(neighborhoodGraph).getAllPaths(sourceVertex,
targetVertex,
true,
neighborhoodGraph.edgeSet().size) // a path can't be longer than one using all edges
}


override fun toString(): String {
return "ConceptMapElement(code=${code.value}, display=${display.value})"
}
}

class ConceptMapTarget(diffDataContainer: DiffDataContainer) {
val code: MutableState<String?> = mutableStateOf(null)
val display: String? by derivedStateOf {
code.value?.let { c -> diffDataContainer.rightGraphBuilder?.nodeTree?.get(c)?.display }
}
val equivalence: MutableState<ConceptMapEquivalence?> = mutableStateOf(null)
val comment: MutableState<String?> = mutableStateOf(null)

var isAutomaticallySet by mutableStateOf(true)
private val valid by derivedStateOf {
when {
code.value == null -> false
code.value !in diffDataContainer.allCodes -> false
isAutomaticallySet -> equivalence.value != null
equivalence.value == null -> false
else -> true
}
}

val state by derivedStateOf {
when {
!valid -> MappingState.INVALID
valid && isAutomaticallySet -> MappingState.AUTO
else -> MappingState.VALID
}
}


val toFhir: TargetElementComponent by derivedStateOf {
TargetElementComponent().apply {
this.code = this@ConceptMapTarget.code.value
this.display = this@ConceptMapTarget.display
this.comment = this@ConceptMapTarget.comment.value
this.equivalence = this@ConceptMapTarget.equivalence.value
}
}

enum class MappingState(val image: ImageVector, val description: LocalizedStrings.() -> String) {
AUTO(Icons.Default.AutoAwesome, { automatic }), VALID(Icons.Default.Verified,
{ ok }),
INVALID(Icons.Default.Error, { invalid })
}

override fun toString(): String {
return "ConceptMapTarget(code=${code.value}, display=${display}, equivalence=${equivalence.value}, comment=${comment.value}, state=${state})"
}
}
Loading

0 comments on commit e77d784

Please sign in to comment.