SGHI FHIR Profile Implementation Guide
0.1.0 - ci-build
SGHI FHIR Profile Implementation Guide, published by Kathurima Kimathi. This guide is not an authorized publication; it is the continuous build for version 0.1.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/savannahghi/sil_fhir_profile_ig/ and changes regularly. See the Directory of published versions
| Official URL: https://fhir.slade360.co.ke/fhir/StructureMap/ExtractBradenScale | Version: 0.1.0 | |||
| Active as of 2026-09-14 | Computable Name: ExtractBradenScale | |||
The LOINC Braden scale panel out as one Observation carrying the total, with the six sub-scores as components, plus a RiskAssessment for the band the total falls in. The total prefers what the form submitted and otherwise sums the answers itself, and it only extracts when all six sub-scores have been answered: a Braden computed from four of six items is not a Braden score, and banding one would put a number a nurse can act on next to a risk tier that is wrong.
/// url = 'https://fhir.slade360.co.ke/fhir/StructureMap/ExtractBradenScale' /// name = 'ExtractBradenScale' /// title = 'Braden Scale Skin Assessment Extraction' /// status = 'active' /// description = 'The LOINC Braden scale panel out as one Observation carrying the total, with the six sub-scores as components, plus a RiskAssessment for the band the total falls in. The total prefers what the form submitted and otherwise sums the answers itself, and it only extracts when all six sub-scores have been answered: a Braden computed from four of six items is not a Braden score, and banding one would put a number a nurse can act on next to a risk tier that is wrong.' uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse" alias QR as source uses "http://hl7.org/fhir/StructureDefinition/Bundle" alias Bundle as target uses "http://hl7.org/fhir/StructureDefinition/Observation" alias Observation as target uses "http://hl7.org/fhir/StructureDefinition/RiskAssessment" alias RiskAssessment as target group ExtractBradenScale(source qr : QR, target bundle : Bundle) { qr -> bundle.type = 'transaction' "setBundleType"; // LOINC publishes this panel flat -- the six sub-scores, the total and the risk // level are all top-level items, keyed by their own LOINC code -- so there is no // group to hang the Observation off and the response itself is the anchor. qr where (item.where(linkId in ('38222-6' | '38229-1' | '38223-4' | '38224-2' | '38225-9' | '38226-7')).answer.value.ofType(Coding).exists()) -> bundle.entry as entry, entry.resource = create('Observation') as obs then { qr -> obs, entry then BuildObsBase(qr, obs, entry) "base"; qr -> obs then SetSurveyCategory(qr, obs) "category"; qr -> obs.code as code then { qr -> code.coding as coding then { qr -> coding.system = 'http://loinc.org' "codeSystem"; qr -> coding.code = '38227-5' "codeCode"; qr -> coding.display = 'Braden scale total score' "codeDisplay"; } "codeCoding"; } "code"; // The submitted total wins; otherwise sum the answers. // The weight cannot be read off the code the way the SGHI-coded instruments // allow, because these are LOINC answer codes and LA9605-2 says nothing about // being worth 3. It is read off a lookup instead. That lookup is safe to write // globally rather than per sub-score: the twenty-one answer codes across the // six items collapse to nineteen distinct ones, and the two that repeat -- // LA9604-5 "Very limited" and LA9605-2 "Slightly limited", shared by sensory // perception and mobility -- carry the same weight in both. Both paths are the // arithmetic Bergstrom published, so they cannot disagree. // truncate(), not toInteger(): the form types the total `decimal`, and this // engine's toInteger() returns empty for anything with a fractional part. // Observation.value has no decimal choice, so it has to land as an integer. // Guarded on all six sub-scores being present. qr where (item.where(linkId in ('38222-6' | '38229-1' | '38223-4' | '38224-2' | '38225-9' | '38226-7')).answer.value.ofType(Coding).count() = 6) then { qr -> obs.value = evaluate(qr, iif(item.where(linkId = '38227-5').answer.value.exists(), item.where(linkId = '38227-5').answer.value.truncate(), item.where(linkId in ('38222-6' | '38229-1' | '38223-4' | '38224-2' | '38225-9' | '38226-7')).answer.value.ofType(Coding).code.select(iif($this in ('LA9603-7' | 'LA9607-8' | 'LA6742-6' | 'LA9614-4' | 'LA9615-1' | 'LA9617-7'), 1, iif($this in ('LA9604-5' | 'LA9608-6' | 'LA9611-0' | 'LA9616-9' | 'LA9618-5'), 2, iif($this in ('LA9605-2' | 'LA9609-4' | 'LA9612-8' | 'LA8913-1' | 'LA9619-3'), 3, iif($this in ('LA9606-0' | 'LA9610-2' | 'LA9613-6' | 'LA120-8' | 'LA9206-9'), 4, 0))))).aggregate($this + $total, 0))) "totalValue"; } "totalRule"; qr.item as it where ((linkId = '38222-6') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38222-6' "codeCode"; it -> coding.display = 'Sensory perception Braden scale' "codeDisplay"; } "codeCoding"; } "sensoryCode"; it -> comp then SetComponentCodedValue(it, comp) "sensoryValue"; } "sensoryRule"; qr.item as it where ((linkId = '38229-1') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38229-1' "codeCode"; it -> coding.display = 'Moisture exposure Braden scale' "codeDisplay"; } "codeCoding"; } "moistureCode"; it -> comp then SetComponentCodedValue(it, comp) "moistureValue"; } "moistureRule"; qr.item as it where ((linkId = '38223-4') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38223-4' "codeCode"; it -> coding.display = 'Physical activity Braden scale' "codeDisplay"; } "codeCoding"; } "activityCode"; it -> comp then SetComponentCodedValue(it, comp) "activityValue"; } "activityRule"; qr.item as it where ((linkId = '38224-2') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38224-2' "codeCode"; it -> coding.display = 'Physical mobility Braden scale' "codeDisplay"; } "codeCoding"; } "mobilityCode"; it -> comp then SetComponentCodedValue(it, comp) "mobilityValue"; } "mobilityRule"; qr.item as it where ((linkId = '38225-9') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38225-9' "codeCode"; it -> coding.display = 'Nutrition intake pattern Braden scale' "codeDisplay"; } "codeCoding"; } "nutritionCode"; it -> comp then SetComponentCodedValue(it, comp) "nutritionValue"; } "nutritionRule"; qr.item as it where ((linkId = '38226-7') and answer.value.exists()) -> obs.component as comp then { it -> comp.code as code then { it -> code.coding as coding then { it -> coding.system = 'http://loinc.org' "codeSystem"; it -> coding.code = '38226-7' "codeCode"; it -> coding.display = 'Friction and shear Braden scale' "codeDisplay"; } "codeCoding"; } "frictionCode"; it -> comp then SetComponentCodedValue(it, comp) "frictionValue"; } "frictionValueRule"; qr -> obs, bundle then AddBradenRiskAssessment(obs, qr, bundle) "band"; } "bradenRule"; } // ───────────────────────────────────────────────────────────────────────────── // The band // Bergstrom's published tiers: 19 and over no risk, 15-18 mild, 13-14 moderate, // 10-12 high, 9 and under very high. Lower is worse. // qualitativeRisk carries two codings and a text. HL7's risk-probability is what // a consumer escalates on, and it has five concepts against Braden's five tiers // but not the same five, so "very high" and "high" both land on #high. LOINC's // own answer list for 81636-3 -- the list printed on the form the nurse filled // in -- has the tier exactly, so it rides alongside and the two never drift. The // text spells the published wording out for anything that only prints. // The band is derived from the total rather than copied from the form's own // risk-level item. That item is a free pick list with nothing calculating it, so // deriving is the only way the number and the tier are guaranteed to agree. // ───────────────────────────────────────────────────────────────────────────── group AddBradenRiskAssessment(source obs : Observation, source qr : QR, target bundle : Bundle) { obs where (value.ofType(integer) <= 9) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetBradenRiskCode(qr, ra) "riskCode"; qr -> ra.prediction as pred then { qr -> pred.qualitativeRisk as risk then { qr -> risk.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/risk-probability' "riskSystem"; qr -> coding.code = 'high' "riskCode"; qr -> coding.display = 'High likelihood' "riskDisplay"; } "hl7Coding"; qr -> risk.coding as loinc then { qr -> loinc.system = 'http://loinc.org' "loincSystem"; qr -> loinc.code = 'LA14637-5' "loincCode"; qr -> loinc.display = 'Very high' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'Very high risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "bradenVeryHigh"; obs where ((value.ofType(integer) >= 10) and (value.ofType(integer) <= 12)) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetBradenRiskCode(qr, ra) "riskCode"; qr -> ra.prediction as pred then { qr -> pred.qualitativeRisk as risk then { qr -> risk.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/risk-probability' "riskSystem"; qr -> coding.code = 'high' "riskCode"; qr -> coding.display = 'High likelihood' "riskDisplay"; } "hl7Coding"; qr -> risk.coding as loinc then { qr -> loinc.system = 'http://loinc.org' "loincSystem"; qr -> loinc.code = 'LA9193-9' "loincCode"; qr -> loinc.display = 'High' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'High risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "bradenHigh"; obs where ((value.ofType(integer) >= 13) and (value.ofType(integer) <= 14)) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetBradenRiskCode(qr, ra) "riskCode"; qr -> ra.prediction as pred then { qr -> pred.qualitativeRisk as risk then { qr -> risk.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/risk-probability' "riskSystem"; qr -> coding.code = 'moderate' "riskCode"; qr -> coding.display = 'Moderate likelihood' "riskDisplay"; } "hl7Coding"; qr -> risk.coding as loinc then { qr -> loinc.system = 'http://loinc.org' "loincSystem"; qr -> loinc.code = 'LA6751-7' "loincCode"; qr -> loinc.display = 'Moderate' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'Moderate risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "bradenModerate"; obs where ((value.ofType(integer) >= 15) and (value.ofType(integer) <= 18)) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetBradenRiskCode(qr, ra) "riskCode"; qr -> ra.prediction as pred then { qr -> pred.qualitativeRisk as risk then { qr -> risk.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/risk-probability' "riskSystem"; qr -> coding.code = 'low' "riskCode"; qr -> coding.display = 'Low likelihood' "riskDisplay"; } "hl7Coding"; qr -> risk.coding as loinc then { qr -> loinc.system = 'http://loinc.org' "loincSystem"; qr -> loinc.code = 'LA6752-5' "loincCode"; qr -> loinc.display = 'Mild' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'Mild risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "bradenMild"; obs where (value.ofType(integer) >= 19) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetBradenRiskCode(qr, ra) "riskCode"; qr -> ra.prediction as pred then { qr -> pred.qualitativeRisk as risk then { qr -> risk.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/risk-probability' "riskSystem"; qr -> coding.code = 'negligible' "riskCode"; qr -> coding.display = 'Negligible likelihood' "riskDisplay"; } "hl7Coding"; qr -> risk.coding as loinc then { qr -> loinc.system = 'http://loinc.org' "loincSystem"; qr -> loinc.code = 'LA19983-8' "loincCode"; qr -> loinc.display = 'No risk' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'No risk identified' "riskText"; } "qualitativeRisk"; } "prediction"; } "bradenNoRisk"; } group SetBradenRiskCode(source qr : QR, target ra : RiskAssessment) { qr -> ra.code as code then { qr -> code.coding as coding then { qr -> coding.system = 'http://loinc.org' "codeSystem"; qr -> coding.code = '81636-3' "codeCode"; qr -> coding.display = 'Pressure injury risk level Braden scale' "codeDisplay"; } "codeCoding"; } "code"; } // ───────────────────────────────────────────────────────────────────────────── // Shared scaffolding. Repeated in each map rather than imported: the transform // engine resolves `imports` against whatever StructureMaps happen to be on the // server, and a form that stops extracting because a shared map was not // uploaded is a worse failure than a duplicated group. // ───────────────────────────────────────────────────────────────────────────── // Two notes on what the publisher's StructureMap validator says about this file. // Source parameters below are left untyped on purpose. Writing `: Element` makes // the validator compare QuestionnaireResponse.item against the literal 'Element' // and report them as incompatible with each other, which is noise, not a finding. // Untyped, it says only that it cannot check the paths. // `evaluate(context, expression)` is flagged as "takes 1 parameter but 2 were // found". Leave it. The FML parser in the same jar refuses the one-argument form // outright -- it wants a parameter, a comma, then the FHIRPath -- so the publisher // is objecting to what its own compiler emits. Two arguments is the only form that // compiles, and it is what the R5 transform engine executes. group BuildObsBase(source qr : QR, target obs : Observation, target entry) { qr -> obs.id = uuid() then SetObservationFullUrl(obs, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'Observation' "requestUrl"; } "entryRequest"; qr -> obs.status = 'final' "status"; qr.subject as s -> obs.subject = s; qr.encounter as e -> obs.encounter = e; qr.authored as t -> obs.effective = t "effective"; qr.source as src -> obs.performer = src "performerFromSource"; qr where (source.exists().not()) then { qr.author as a -> obs.performer = a "performerFromAuthor"; } "performerFallback"; // A stored response's id already reads "QuestionnaireResponse/<id>", so it is a // relative reference as it stands. The hasValue() guard is what stops an // unsaved response -- one posted straight to $extract rather than saved first -- // from writing "QuestionnaireResponse/null" into the record. qr.id as qid where ($this.hasValue()) -> obs.derivedFrom = create('Reference') as ref, ref.reference = qid "linkQuestionnaireResponse"; } // The RiskAssessment stands on two things: the response that was filled in and // the Observation that holds the number. Both go in basis, which is what the // SGHI RiskAssessment profile binds to a QuestionnaireResponse. group BuildRiskBase(source qr : QR, source obs : Observation, target ra : RiskAssessment, target entry) { qr -> ra.id = uuid() then SetRiskAssessmentFullUrl(ra, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'RiskAssessment' "requestUrl"; } "entryRequest"; qr -> ra.status = 'final' "status"; qr.subject as s -> ra.subject = s; qr.encounter as e -> ra.encounter = e; qr.authored as t -> ra.occurrence = t "occurrence"; qr.author as a -> ra.performer = a "performerFromAuthor"; qr where (author.exists().not()) then { qr.source as src -> ra.performer = src "performerFromSource"; } "performerFallback"; qr.id as qid where ($this.hasValue()) -> ra.basis = create('Reference') as qref, qref.reference = qid "basisResponse"; obs.id as oid -> ra.basis = create('Reference') as oref then { oid -> oref.reference = append('Observation/', oid) "basisObservationRef"; } "basisObservation"; } group SetComponentCodedValue(source it, target comp) { it.answer first as a then { a.value as cv -> comp.value = create('CodeableConcept') as cc then { cv -> cc.coding = cv "valueCoding"; cv.display as d -> cc.text = d "valueText"; } "codedValue"; } "componentAnswer"; } group SetSurveyCategory(source qr : QR, target obs : Observation) { qr -> obs.category as cat then { qr -> cat.coding as coding then { qr -> coding.system = 'http://terminology.hl7.org/CodeSystem/observation-category' "categorySystem"; qr -> coding.code = 'survey' "categoryCode"; qr -> coding.display = 'Survey' "categoryDisplay"; } "categoryCoding"; } "category"; } group SetObservationFullUrl(source obs : Observation, target entry) { obs.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/Observation/', id) "assignFullUrl"; } group SetRiskAssessmentFullUrl(source ra : RiskAssessment, target entry) { ra.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/RiskAssessment/', id) "assignFullUrl"; }