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/ExtractEndoscopyInvestigation | Version: 0.1.0 | |||
| Active as of 2026-09-14 | Computable Name: ExtractEndoscopyInvestigation | |||
The endoscopy form is a request, a procedure and a report on one sheet, so it extracts as all three: a Procedure coded by whichever scope was done, taking its own coding from the answer so a colonoscopy is coded as a colonoscopy and not as 'endoscopy'; a DiagnosticReport under LOINC 18751-8 whose conclusion is the endoscopist's conclusion and whose result points at the findings Observation; a MedicationAdministration per drug given during the procedure; and a CarePlan for the suggested treatment. The findings live in an Observation rather than only in the report's narrative so that what was seen stays queryable after the report is filed.
/// url = 'https://fhir.slade360.co.ke/fhir/StructureMap/ExtractEndoscopyInvestigation' /// name = 'ExtractEndoscopyInvestigation' /// title = 'Endoscopy Service Investigation Extraction' /// status = 'active' /// description = 'The endoscopy form is a request, a procedure and a report on one sheet, so it extracts as all three: a Procedure coded by whichever scope was done, taking its own coding from the answer so a colonoscopy is coded as a colonoscopy and not as \'endoscopy\'; a DiagnosticReport under LOINC 18751-8 whose conclusion is the endoscopist\'s conclusion and whose result points at the findings Observation; a MedicationAdministration per drug given during the procedure; and a CarePlan for the suggested treatment. The findings live in an Observation rather than only in the report\'s narrative so that what was seen stays queryable after the report is filed.' 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/Procedure" alias Procedure as target uses "http://hl7.org/fhir/StructureDefinition/DiagnosticReport" alias DiagnosticReport as target uses "http://hl7.org/fhir/StructureDefinition/MedicationAdministration" alias MedicationAdministration as target uses "http://hl7.org/fhir/StructureDefinition/CarePlan" alias CarePlan as target group ExtractEndoscopyInvestigation(source qr : QR, target bundle : Bundle) { qr -> bundle.type = 'transaction' "setBundleType"; // ── The report, and the findings Observation it points at ─────────────── // Built together and in this order because the report's `result` has to // reference the Observation, and a reference to a resource created in a // sibling rule is not reachable: the target variable does not exist there. qr.item as g where (linkId = 'study') then { qr where (item.where(linkId = 'findings').answer.value.exists() or item.where(linkId = 'conclusion').answer.value.exists()) -> bundle.entry as entry, entry.resource = create('DiagnosticReport') as dr then { qr -> dr, entry then BuildDiagnosticReportBase(qr, dr, entry) "base"; qr -> dr.status = 'final' "status"; qr.encounter as e -> dr.encounter = e; // issued is an instant and qr.authored is a dateTime, so it needs the // cast: "Unable to convert a dateTime to a Instant". The cast is safe // because a QuestionnaireResponse.authored always carries a time. qr.authored as t -> dr.issued = cast(t, 'instant') "issued"; qr -> dr.code as cc then { qr -> cc.coding as cg then { qr -> cg.system = 'http://loinc.org' "sys"; qr -> cg.code = '18751-8' "cd"; qr -> cg.display = 'Endoscopy study' "dsp"; } "coding"; } "code"; qr -> dr.category as cat then { qr -> cat.coding as cg then { qr -> cg.system = 'http://terminology.hl7.org/CodeSystem/v2-0074' "sys"; qr -> cg.code = 'OSL' "cd"; qr -> cg.display = 'Outside Lab' "dsp"; } "coding"; qr -> cat.text = 'Endoscopy unit' "catText"; } "category"; // Effective time is the study's own time, not the form's. A report filed // the next morning is still a report on yesterday's scope. g.item as pa where ((linkId = 'study/performed-at') and answer.value.exists()) then { pa.answer first as a then { a.value as v -> dr.effective = v "effectiveFromStudy"; }; } "effectiveRule"; g.item as ep where ((linkId = 'study/endoscopist') and answer.value.exists()) then { ep.answer first as a then { a.value as ref -> dr.performer = ref "performer"; }; } "performerRule"; qr.item as it where ((linkId = 'conclusion') and answer.value.exists()) then { it.answer first as a then { a.value as txt -> dr.conclusion = txt "conclusionText"; }; } "conclusionRule"; // The findings, as an Observation the report points at. qr.item as it where ((linkId = 'findings') and answer.value.exists()) -> bundle.entry as sentry, sentry.resource = create('Observation') as sobs then { qr -> sobs, sentry then BuildObsBase(qr, sobs, sentry) "base"; qr -> sobs then SetProcedureCategory(qr, sobs) "category"; it -> sobs.code as cc then { it -> cc.coding as cg then { it -> cg.system = 'https://fhir.slade360.co.ke/fhir/CodeSystem/concept-codesystem' "sys"; it -> cg.code = 'endoscopy-endoscopic-findings' "cd"; it -> cg.display = 'Endoscopic findings' "dsp"; } "coding"; } "code"; it.answer first as a then { a.value as txt -> sobs.note as n, n.text = txt "findingsText"; }; it -> dr.result = create('Reference') as fref, fref.reference = reference(sobs) "linkResult"; } "findingsObservation"; } "endoscopyReport"; // ── The procedure ───────────────────────────────────────────────────── g.item as it where ((linkId = 'study/investigation') and answer.value.exists()) -> bundle.entry as entry, entry.resource = create('Procedure') as proc then { qr -> proc, entry then BuildProcedureBase(qr, proc, entry) "base"; it -> proc.status = 'completed' "status"; qr.encounter as e -> proc.encounter = e; it.answer first as a then { a.value as cv -> proc.code as cc then { cv -> cc.coding = cv "coding"; cv.display as d -> cc.text = d "text"; } "codeFromAnswer"; }; g.item as pa where ((linkId = 'study/performed-at') and answer.value.exists()) then { pa.answer first as a then { a.value as v -> proc.occurrence = v "occurrence"; }; } "occurrenceRule"; g.item as ep where ((linkId = 'study/endoscopist') and answer.value.exists()) then { ep.answer first as a then { a.value as ref -> proc.performer as p, p.actor = ref "endoscopist"; }; } "endoscopistRule"; // The assistant is a name typed on the form, not a picked practitioner, // so it travels as the actor's display with no reference behind it. g.item as asst where ((linkId = 'study/assistant-instrumentalist') and answer.value.exists()) then { asst.answer first as a then { a.value as txt -> proc.performer as p, p.actor = create('Reference') as aref, aref.display = txt "assistant"; }; } "assistantRule"; g.item as mh where ((linkId = 'study/medical-history') and answer.value.exists()) then { mh.answer first as a then { a.value as txt -> proc.note as n, n.text = txt "historyNote"; }; } "historyRule"; } "endoscopyProcedure"; } "studyGroup"; // ── The medical history, also as its own Observation ──────────────────── // Duplicated deliberately. On the Procedure it is context for the operator; // as an Observation it is a piece of history a later clinician can find // without opening an endoscopy report to look for it. qr.item as g where (linkId = 'study') then { g.item as it where ((linkId = 'study/medical-history') and answer.value.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"; it -> obs.code as cc then { it -> cc.coding as cg then { it -> cg.system = 'http://loinc.org' "sys"; it -> cg.code = '11348-0' "cd"; it -> cg.display = 'History of Past illness note' "dsp"; } "coding"; it -> cc.text = 'Relevant medical history recorded on the endoscopy request' "codeText"; } "code"; it.answer first as a then { a.value as txt -> obs.note as n, n.text = txt "text"; }; } "narr_11348_0"; } "historyObsGroup"; // ── Drugs given during the procedure ──────────────────────────────────── qr.item as g where (linkId = 'management') then { g.item as d where ((linkId = 'management/drug') and item.where(linkId = 'management/drug/name').answer.value.exists()) -> bundle.entry as entry, entry.resource = create('MedicationAdministration') as ma then { qr -> ma, entry then BuildMedicationAdministrationBase(qr, ma, entry) "base"; d -> ma.status = 'completed' "status"; qr.encounter as e -> ma.encounter = e; qr.source as src -> ma.performer as p, p.actor = create('CodeableReference') as cr, cr.reference = src "performer"; d.item as it where (linkId = 'management/drug/name') then { it.answer first as a then { a.value as txt -> ma.medication as med, med.concept as mc, mc.text = txt "medicationText"; }; } "drugNameRule"; d.item as it where ((linkId = 'management/drug/dose') and answer.value.exists()) then { it.answer first as a then { a.value as txt -> ma.dosage as dos, dos.text = txt "dosageText"; }; } "drugDoseRule"; qr.authored as t -> ma.occurence = t "occurrence"; } "drugAdministration"; // ── Suggested treatment, as a plan ──────────────────────────────────── // A CarePlan and not a note: what the endoscopist suggests is the whole // point of the referral coming back, and the referring clinician needs to // find it as an outstanding action rather than as prose in a report. g.item as it where ((linkId = 'management/suggested-treatment') and answer.value.exists()) -> bundle.entry as entry, entry.resource = create('CarePlan') as plan then { qr -> plan, entry then BuildCarePlanBase(qr, plan, entry) "base"; it -> plan.status = 'active' "status"; it -> plan.intent = 'proposal' "intent"; it -> plan.title = 'Treatment suggested after endoscopy' "title"; qr.authored as t -> plan.created = t "created"; qr.encounter as e -> plan.encounter = e; qr.source as src -> plan.custodian = src "custodian"; it -> plan.category as cat then { it -> cat.coding as cg then { it -> cg.system = 'http://loinc.org' "sys"; it -> cg.code = '18776-5' "cd"; it -> cg.display = 'Plan of care note' "dsp"; } "coding"; } "category"; it.answer first as a then { a.value as txt -> plan.description = txt "description"; }; } "suggestedTreatmentPlan"; } "managementGroup"; } // ───────────────────────────────────────────────────────────────────────────── // 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. // `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 then SetResponseProvenance(qr, obs) "responseProvenance"; 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"; } group SetObservationFullUrl(source obs : Observation, target entry) { obs.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/Observation/', id) "assignFullUrl"; } // The two answer shapes a component can carry. Both read the answer through the // untyped `.value` accessor: reading `valueCoding` or `valueQuantity` by its // typed property name fails at transform time on HAPI R5 with "Attempt to read // invalid property ... on QuestionnaireResponse.item.answer". 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 SetComponentValue(source it, target comp) { it.answer first as a then { a.value as v -> comp.value = v "plainValue"; } "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 SetProcedureCategory(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 = 'procedure' "categoryCode"; qr -> coding.display = 'Procedure' "categoryDisplay"; } "categoryCoding"; } "category"; } group BuildProcedureBase(source qr : QR, target r : Procedure, target entry) { qr -> r then SetResponseProvenance(qr, r) "responseProvenance"; qr -> r.id = uuid() then SetProcedureFullUrl(r, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'Procedure' "requestUrl"; } "entryRequest"; // The response's own time, as a floor, the way BuildObsBase does it. A rule // that reads a time off the form overwrites this; without it a procedure whose // date column was taken off on review has no time at all, and a procedure with // no time cannot be ordered against the rest of the admission. qr.authored as t -> r.occurrence = t "occurrenceFallback"; qr.subject as s -> r.subject = s; } group SetProcedureFullUrl(source r : Procedure, target entry) { r.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/Procedure/', id) "assignFullUrl"; } group BuildDiagnosticReportBase(source qr : QR, target r : DiagnosticReport, target entry) { qr -> r then SetResponseProvenance(qr, r) "responseProvenance"; qr -> r.id = uuid() then SetDiagnosticReportFullUrl(r, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'DiagnosticReport' "requestUrl"; } "entryRequest"; qr.subject as s -> r.subject = s; } group SetDiagnosticReportFullUrl(source r : DiagnosticReport, target entry) { r.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/DiagnosticReport/', id) "assignFullUrl"; } group BuildMedicationAdministrationBase(source qr : QR, target r : MedicationAdministration, target entry) { qr -> r then SetResponseProvenance(qr, r) "responseProvenance"; qr -> r.id = uuid() then SetMedicationAdministrationFullUrl(r, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'MedicationAdministration' "requestUrl"; } "entryRequest"; qr.subject as s -> r.subject = s; // The response's own time, as a floor. A row that names the time it was given // overwrites this; a form whose time column was taken off on review would // otherwise produce an administration with no time at all, which is not a // record of an administration. Same fallback BuildObsBase already applies. qr.authored as t -> r.occurence = t "occurrenceFallback"; } group SetMedicationAdministrationFullUrl(source r : MedicationAdministration, target entry) { r.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/MedicationAdministration/', id) "assignFullUrl"; } group BuildCarePlanBase(source qr : QR, target r : CarePlan, target entry) { qr -> r then SetResponseProvenance(qr, r) "responseProvenance"; qr -> r.id = uuid() then SetCarePlanFullUrl(r, entry) "idAndFullUrl"; qr -> entry.request as request then { qr -> request.method = 'POST' "requestMethod"; qr -> request.url = 'CarePlan' "requestUrl"; } "entryRequest"; qr.subject as s -> r.subject = s; } group SetCarePlanFullUrl(source r : CarePlan, target entry) { r.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/CarePlan/', id) "assignFullUrl"; } // ── Provenance and tenancy ─────────────────────────────────────────────────── // Two tags on everything this map creates. The first says which questionnaire // produced it, so a form that reopens mid-admission recalls its own earlier // entries and not another form's. The second copies the tenant tags off the // response, because nothing else on the path puts them there. // The code is the form's document type, not the questionnaire's id: every // environment mints its own ids, and there is only one set of maps, so an id // here would tie the tag to whichever environment stamped it. // ── Provenance and tenancy ─────────────────────────────────────────────────── group SetResponseProvenance(source qr : QR, target r) { qr -> r.meta as m then { qr -> m.tag as t then { qr -> t.system = 'http://slade360edi.com/questionnaire-provenance' "provenanceSystem"; qr -> t.code = 'endoscopy-investigation' "provenanceCode"; qr -> t.display = 'Endoscopy service investigation form' "provenanceDisplay"; } "provenanceTag"; } "provenanceMeta"; // Every tag the response carries, onto the resource. `meta` is 0..1, so this // lands on the same meta the rule above created rather than a second one. qr.meta as qm then { qm.tag as qt -> r.meta as m, m.tag = qt "copyTag"; } "tenantTags"; }