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

StructureMap: Braden Scale Skin Assessment Extraction

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";
}