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/ExtractMorseFallScale | Version: 0.1.0 | |||
| Active as of 2026-09-14 | Computable Name: ExtractMorseFallScale | |||
The LOINC Morse Fall Scale panel out as one Observation carrying the total, with the six weighted questions 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 questions have been answered: a Morse score short of an item is not a Morse score, and banding one puts a number a nurse can act on next to a risk tier that is wrong.
/// url = 'https://fhir.slade360.co.ke/fhir/StructureMap/ExtractMorseFallScale' /// name = 'ExtractMorseFallScale' /// title = 'Morse Fall Scale Extraction' /// status = 'active' /// description = 'The LOINC Morse Fall Scale panel out as one Observation carrying the total, with the six weighted questions 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 questions have been answered: a Morse score short of an item is not a Morse score, and banding one puts 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 ExtractMorseFallScale(source qr : QR, target bundle : Bundle) { qr -> bundle.type = 'transaction' "setBundleType"; // LOINC publishes this panel flat -- the six questions, 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 ('59454-9' | '59455-6' | '59456-4' | '59457-2' | '59458-0' | '59459-8')).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 = '59460-6' "codeCode"; qr -> coding.display = 'Fall risk total [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "code"; // The submitted total wins; otherwise sum the answers. // The weights have to be resolved question by question rather than from one // lookup, because Morse reuses LOINC's plain yes/no pair across three // questions at three different weights: LA33-6 "Yes" is worth 25 on the // history of falling, 15 on a secondary diagnosis and 20 on an intravenous // line. A global code-to-weight table would silently score all three the // same. The weights are Morse's own, as printed on the form. // 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 questions being answered. qr where (item.where(linkId in ('59454-9' | '59455-6' | '59456-4' | '59457-2' | '59458-0' | '59459-8')).answer.value.ofType(Coding).count() = 6) then { qr -> obs.value = evaluate(qr, iif(item.where(linkId = '59460-6').answer.value.exists(), item.where(linkId = '59460-6').answer.value.truncate(), iif(item.where(linkId = '59454-9').answer.value.ofType(Coding).code = 'LA33-6', 25, 0) + iif(item.where(linkId = '59455-6').answer.value.ofType(Coding).code = 'LA33-6', 15, 0) + iif(item.where(linkId = '59456-4').answer.value.ofType(Coding).code = 'LA13031-2', 15, iif(item.where(linkId = '59456-4').answer.value.ofType(Coding).code = 'LA13032-0', 30, 0)) + iif(item.where(linkId = '59457-2').answer.value.ofType(Coding).code = 'LA33-6', 20, 0) + iif(item.where(linkId = '59458-0').answer.value.ofType(Coding).code = 'LA13034-6', 10, iif(item.where(linkId = '59458-0').answer.value.ofType(Coding).code = 'LA13035-3', 20, 0)) + iif(item.where(linkId = '59459-8').answer.value.ofType(Coding).code = 'LA13037-9', 15, 0))) "totalValue"; } "totalRule"; qr.item as it where ((linkId = '59454-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 = '59454-9' "codeCode"; it -> coding.display = 'History of falling; immediate or within 3 months [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "historyCode"; it -> comp then SetComponentCodedValue(it, comp) "historyValue"; } "historyRule"; qr.item as it where ((linkId = '59455-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 = '59455-6' "codeCode"; it -> coding.display = 'Secondary diagnosis is present' "codeDisplay"; } "codeCoding"; } "diagnosisCode"; it -> comp then SetComponentCodedValue(it, comp) "diagnosisValue"; } "diagnosisRule"; qr.item as it where ((linkId = '59456-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 = '59456-4' "codeCode"; it -> coding.display = 'Ambulatory aid [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "aidCode"; it -> comp then SetComponentCodedValue(it, comp) "aidValue"; } "aidRule"; qr.item as it where ((linkId = '59457-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 = '59457-2' "codeCode"; it -> coding.display = 'Intravenous apparatus [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "ivCode"; it -> comp then SetComponentCodedValue(it, comp) "ivValue"; } "ivRule"; qr.item as it where ((linkId = '59458-0') 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 = '59458-0' "codeCode"; it -> coding.display = 'Gait [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "gaitCode"; it -> comp then SetComponentCodedValue(it, comp) "gaitValue"; } "gaitRule"; qr.item as it where ((linkId = '59459-8') 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 = '59459-8' "codeCode"; it -> coding.display = 'Mental status [Morse Fall Scale]' "codeDisplay"; } "codeCoding"; } "mentalCode"; it -> comp then SetComponentCodedValue(it, comp) "mentalValue"; } "mentalRule"; qr -> obs, bundle then AddMorseRiskAssessment(obs, qr, bundle) "band"; } "morseRule"; } // ───────────────────────────────────────────────────────────────────────────── // The band // These are the tiers LOINC's own answer list for 59461-4 carries, which is the // three-tier reading of Morse in general use and the list printed on this form: // 0-24 low, 25-45 moderate, 50 and over high. Higher is worse. // Two things to know about it. // The published list leaves 46 to 49 unnamed. A computed total never lands // there -- every one of Morse's item weights is a multiple of five, so the sum // is too -- but a total the form submits can, since that item is a free decimal. // It is folded into moderate, because the alternative is a score that extracts a // number and no band at all. // It is not the banding `ExtractInpatientFallsAndPressureUlcerRiskAssessment` // uses. That map scores the same six items from a hand-built form and bands them // on Morse's original paper -- 0-24 no risk, 25-50 low, 51 and over high -- so // the two maps can disagree between 25 and 50. The divergence is deliberate: a // band that contradicts the pick list on the form in front of the nurse is worse // than a band that differs from another form's. Reconciling them means agreeing // one Morse banding for the IG and changing whichever form loses. // qualitativeRisk carries two codings and a text. HL7's risk-probability is what // a consumer escalates on; LOINC's answer code is the tier exactly as the form // states it, ranges and all; the text is the plain wording. // 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 AddMorseRiskAssessment(source obs : Observation, source qr : QR, target bundle : Bundle) { obs where (value.ofType(integer) <= 24) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetMorseRiskCode(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 = 'LA13038-7' "loincCode"; qr -> loinc.display = 'Low Risk (MFS Score 0 - 24)' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'Low risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "morseLow"; obs where ((value.ofType(integer) >= 25) and (value.ofType(integer) <= 49)) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetMorseRiskCode(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 = 'LA13039-5' "loincCode"; qr -> loinc.display = 'Moderate Risk (MFS Score 25 - 45)' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'Moderate risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "morseModerate"; obs where (value.ofType(integer) >= 50) -> bundle.entry as entry, entry.resource = create('RiskAssessment') as ra then { qr -> ra, entry then BuildRiskBase(qr, obs, ra, entry) "riskBase"; qr -> ra then SetMorseRiskCode(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 = 'LA13040-3' "loincCode"; qr -> loinc.display = 'High Risk (MFS Score 50+)' "loincDisplay"; } "loincCoding"; qr -> risk.text = 'High risk' "riskText"; } "qualitativeRisk"; } "prediction"; } "morseHigh"; } group SetMorseRiskCode(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 = '59461-4' "codeCode"; qr -> coding.display = 'Fall risk level [Morse Fall 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"; }