下拉重新整理

呈現臨床資料(一)

4,388 字 11 分鐘閱讀 15 次閱讀

火線超人有一個給病人填的表單,五個欄位:收縮壓、舒張壓、脈搏、血糖、體重。

每個欄位都是選填。病人今天只量了血壓就只填血壓,有填值的那幾項才會被寫成 Observation。收縮壓 138、舒張壓 85、脈搏 72 送出去,會變成三筆各自獨立的資源。

把這台 sandbox 打開才發現,它的血壓不是這樣存的。收縮壓與舒張壓掛在同一筆資源上,用兩個 component 裝。

同一件事,兩種寫法。哪一種都可能遇到,不過今天這支程式只處理 sandbox 這種 component 結構。手上只有這台的資料可以驗,把另一種也寫進去等於寫一段沒跑過的程式碼。

先看兩筆 Observation 長什麼樣

昨天的 app 只讀了 Patient。今天要讀 Observation,得先多要一項權限。SCOPE 那一行加上 patient/Observation.rs 就行。

要補一句實話。這台 sandbox 對權限的執行很寬鬆,不加這個 scope 照樣讀得到。連 Authorization header 都不帶也一樣回 200。所以你在這裡拿掉它也不會壞。

但那是這台的行為,不是規範保證的行為。正式的伺服器會擋,該要的權限還是要要。

先看一筆血壓實際回來的樣子,這裡只留跟畫面有關的欄位:

{
  "resourceType": "Observation",
  "status": "final",
  "code": {
    "coding": [
      { "system": "http://loinc.org", "code": "55284-4", "display": "Blood Pressure" }
    ],
    "text": "Blood Pressure"
  },
  "effectiveDateTime": "2020-12-04T23:37:54+00:00",
  "component": [
    {
      "code": { "coding": [{ "code": "8462-4", "display": "Diastolic Blood Pressure" }] },
      "valueQuantity": { "value": 86.44305537931304, "unit": "mm[Hg]" }
    },
    {
      "code": { "coding": [{ "code": "8480-6", "display": "Systolic Blood Pressure" }] },
      "valueQuantity": { "value": 129.75969944651848, "unit": "mm[Hg]" }
    }
  ]
}

再看一筆體重:

{
  "resourceType": "Observation",
  "code": {
    "coding": [
      { "system": "http://loinc.org", "code": "29463-7", "display": "Body Weight" }
    ]
  },
  "effectiveDateTime": "2020-12-04T23:37:54+00:00",
  "valueQuantity": { "value": 93.29082484586738, "unit": "kg" }
}

兩者對照圖,米色底,標題「體重的值在資源上,血壓的值在 component 裡」,副標「同一次量測回來的兩筆 Observation,同一台 sandbox,同一位病人」。畫面左右各一張白色圓角卡片,兩張卡片上緣都是深藍色標頭。左卡標頭左側是等寬字白色的 55284-4,右側是灰藍色小字 Blood Pressure。卡片內第一列是灰色等寬字 valueQuantity,右邊一個珊瑚色圓形叉號接珊瑚色粗體字「資源層沒有這一欄」。分隔線之下是灰色小字 component 兩項,接著兩組值:第一組左邊一個灰底小方塊寫索引 0,右邊是深藍色等寬字 8462-4 與「舒張壓」,下一行是黑色粗體等寬字 86.44305537931304 與灰色小字 mm Hg;第二組小方塊寫索引 1,代碼是 8480-6 與「收縮壓」,值是 129.75969944651848 與 mm Hg。再下面一條淺珊瑚底的橫條,深紅字寫著「陣列裡舒張壓排在收縮壓前面」。卡片最底部是一條淺藍色橫帶,等寬字寫 observation.component 的 valueQuantity.value。右卡標頭左側是 29463-7,右側是 Body Weight。卡片內第一列是灰色等寬字 component,右邊同樣一個珊瑚色圓形叉號接「整個資源沒有這一層」。分隔線之下是 valueQuantity 一項,底下是放大的黑色粗體等寬字 93.29082484586738 與灰色的 kg,再下面兩行灰色小字分別是 "unit": "kg" 與 "system": "http://unitsofmeasure.org"。同樣位置的淺藍色橫條寫著「一筆資源只掛一個值」。卡片最底部的淺藍橫帶等寬字寫 observation.valueQuantity.value。圖片最下方一行灰字:兩筆的 status 都是 final,effectiveDateTime 都是 2020-12-04T23:37:54+00:00

原因是血壓本來就不是一個數字。收縮壓跟舒張壓是同一次量測的兩個結果。拆成兩筆資源不是不行,火線超人就是這樣做的。只是那兩筆的關聯要靠讀取端自己比對時間戳推出來。component 把它們綁在同一筆上,關聯是資料自己講的,不用猜。

照直覺寫,這三個地方會出錯

第一個:血壓的代碼跟你在別處看到的不一樣。 不少文章講血壓都寫 85354-9,這台伺服器用的是 55284-4

LOINC 是一套給檢驗與量測項目編號的國際代碼表。血壓、體重這類項目各有自己的號碼。

85354-9 是 FHIR 生命徵象 profile 指定的血壓代碼,那些文章照著規範寫沒有錯。這台 sandbox 用的 55284-4 不在那份 profile 裡。有意思的是內層對得上,component 用的 8480-68462-4 正是 profile 規定的那兩個。

所以代碼要以伺服器實際回的為準,連規範指定的都可能對不上。

第二個:component 的順序不保證。 上面那筆資料裡,舒張壓排在收縮壓前面。如果你寫 component[0] 當收縮壓,在這台伺服器上會拿到舒張壓。圖畫出來高低顛倒,而且不會有任何錯誤訊息。要認代碼,不要認索引。

第三個:那些小數。 129.75969944651848 是合成資料的產物,真實血壓計不會給你十四位小數。但程式不能假設它不會出現,畫上圖或印在畫面上之前都得先四捨五入。

接一台新伺服器,先盤代碼

既然代碼不能照抄,接手一台沒看過的伺服器時,第一件事就是盤點它到底用了哪些代碼。

做法是撈一批下來數:

GET /Observation?patient=<id>&category=vital-signs&_count=100

把回來的每一筆的 code.coding[].code 拉出來統計。撈回來的 50 筆共五種代碼:

LOINC 筆數 display 值的位置
39156-5 10 Body Mass Index valueQuantity
55284-4 10 Blood Pressure component 兩項
29463-7 10 Body Weight valueQuantity
8302-2 10 Body Height valueQuantity
72514-3 10 Pain severity - 0-10 verbal numeric rating [Score] - Reported valueQuantity

最後那一列的 display 就是 LOINC 的完整名稱,不是我沒截乾淨。真的接起資料來,這種長名稱很常見,要顯示到畫面上得自己決定截到哪裡。

十分鐘的事,換來的是知道自己要處理哪幾種結構。這張表也直接回答了「這台有沒有心跳」,答案是沒有。

排序不寫會亂跳,狀態不看會出事

_sort=date 不是為了整齊。不寫的話,這台伺服器回來的順序是這樣:

2012-10-19
2017-11-17
2019-11-29
2014-10-31
2013-10-25

完全沒有時間順序。FHIR 規範沒有規定搜尋結果的預設排序,伺服器愛怎麼排就怎麼排。趨勢圖的 x 軸照這個順序排,線會來回亂跳。你可以把那個參數拿掉重跑一次,畫面立刻壞給你看。

另一個是 status。Observation 有這個欄位,常見的有 finalpreliminaryentered-in-error。最後那個的意思是「這筆記錄被標記為誤登」,畫上趨勢圖是危險的。

FHIR 把 status 標成 modifier element。這個標記的意思是它會改變整筆資料該怎麼被理解,不是一個可以順手跳過的欄位。同一組數字,final 是「這是量測結果」,entered-in-error 是「這筆不算數」。

這批 sandbox 資料全部是 final。這位病人全部類別合計 94 筆,我一筆一筆數過,沒有第二種值。全站查 entered-in-error 也是零筆。所以你在這裡加過濾看不出任何效果。真實系統可能會有,而且那是臨床安全問題不是顯示問題,所以待會的程式碼還是會擋。

血壓要挖兩層,體重一層就到

component 裡找代碼:

function componentValue(observation, loincCode) {
  const hit = observation.component?.find((one) =>
    one.code?.coding?.some((coding) => coding.code === loincCode)
  )
  return hit?.valueQuantity?.value ?? null
}

三層可選鏈。component 可能不存在,code.coding 可能不存在,valueQuantity 也可能不存在。找不到就回 null

這個 null 不只是防禦,它是有意義的。一筆血壓只量到收縮壓,舒張壓那一段缺了。畫圖時該點就是空的,不是零。血壓 0 跟沒量到血壓是完全不同的兩件事,混在一起會畫出一張很嚇人的圖。

體重那邊簡單得多,observation.valueQuantity?.value ?? null 就結束了。

單位不同就得分兩個 y 軸

把三條線畫在同一張圖上,會遇到一個單位問題。收縮壓落在 106 到 137,舒張壓落在 72 到 90,體重落在 87 到 93。數字看起來很接近,但單位一個是 mm[Hg] 一個是 kg。放在同一個刻度上是在比較兩件不可比較的事。

兩者對照圖,米色底,標題「共用一個軸,體重那條線就沒了」,副標「同一批 10 個點,左軸 70 到 140 是 mm Hg,右軸 87 到 93 是 kg」。標題下方一列圖例:深紅色線段代表收縮壓 (mm Hg)、淺珊瑚色線段代表舒張壓 (mm Hg)、深藍色線段代表體重 (kg)。畫面左右各一張白色圓角卡片。左卡左上角標「一個 y 軸」,右上角灰字註明三條線共用 70 到 140,y 軸標 mm Hg,刻度由下而上是 70、80、90、100、110、120、130、140。三條折線畫在同一個刻度上:深紅色的收縮壓在 106 到 137 之間上下起伏,淺珊瑚色的舒張壓在 72 到 90 之間起伏,深藍色的體重從 87 升到 93 之後打平。體重那條線落在一條珊瑚色的細長橫帶裡,橫帶上緣是 93、下緣是 87,帶內以深紅色粗體標著「體重 87 到 93 全擠在這一條」,這條帶完全落在舒張壓那條線的起伏範圍內。右卡左上角標「兩個 y 軸」,右上角灰字註明血壓左、體重右。左側 y 軸標 mm Hg,刻度同樣是 70 到 140;右側多一條深藍色的軸,標 kg,刻度由下而上是 87、88、89、90、91、92、93。兩條血壓線的位置與左卡完全相同,體重那條線改掛右軸之後從左下角一路升到右上角再打平,前四個點的上升幅度完整看得出來。兩張卡片的 x 軸兩端各標 2011-10-14 與 2020-12-04。圖片最下方一行灰字:這張圖 Chart.js 註冊了三個軸:pressure、weight、x,x 軸是 category 不是時間軸

Chart.js 給每個 dataset 指定 yAxisID 就能分軸。畫圖那段獨立成一個函式,重畫之前先把舊的砍掉:

let chart = null

export function renderChart(canvas, data) {
  chart?.destroy()
  chart = new Chart(canvas, {
    type: 'line',
    data,
    options: {
      responsive: true,
      scales: {
        pressure: {
          type: 'linear',
          position: 'left',
          title: { display: true, text: 'mm[Hg]' },
        },
        weight: {
          type: 'linear',
          position: 'right',
          title: { display: true, text: 'kg' },
        },
      },
    },
  })
  return chart
}

那個 chart?.destroy() 不能省。Chart.js 會在 canvas 上留一個實例,同一張畫第二次會直接報錯。

x 軸這邊用的是 category,不是時間軸。這批資料一年一筆,間隔本來就不平均。要畫成真正等距的時間軸,Chart.js 4 得另外掛一個日期轉接器。vendor 目錄要多放轉接器跟它依賴的日期函式庫。換來的好處在這種稀疏取樣上看不出來。

這台 sandbox 沒有心跳

火線超人的表單有脈搏欄位,寫的是 8867-4。我原本要用心跳當第二條線。

實際查下去,這台 sandbox 的 8867-4 是零筆。不是我挑的那位病人沒有,是整批資料一筆都沒有。呼吸速率、血氧也一樣查無。我另外抽了 250 筆 vital-signs 來看,出現的代碼是六種。分別是血壓、體重、身高、BMI、疼痛評分、口溫。

所以第二條線改用體重。這不是退而求其次。體重的值直接掛在 valueQuantity,跟血壓的 component 剛好是一組對照。單位也真的不同,一篇文章要講的兩件事都齊了。

要提醒一件事:體重那條線從 2014 年之後七筆完全相同,圖上後半是一條水平線。那是合成資料的產物,不是你畫錯了。

跟著做:把血壓與體重畫成趨勢圖

起點是 day15 結束時的專案:index.htmlapp.jspatient.jsvendor/fhir-client.pure.min.js,跑起來畫面上有四欄基本資料。

第一步,把圖表庫抓下來

curl -o vendor/chart.umd.js \
  https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.js

208518 bytes,約 204 KB。這是目前這個專案最大的一個相依,比 fhirclient 大了將近四倍。

為什麼是 Chart.js?先被刷掉的是那些只出 ESM 或要靠打包工具的函式庫。這個系列從 day04 就定調不用建置步驟。vendor/ 裡的檔案要能用一個 script 標籤直接載進來。Chart.js 4 有出 chart.umd.js,上面那行 curl 抓的就是它。

剩下兩個理由這一篇剛好都用得到。雙 y 軸只要給每個 dataset 指定 yAxisID 就分好了。缺值要斷還是要接,spanGaps 一個選項就切換得了。這批資料稀疏,接過去比斷成一截一截好讀,所以待會三條線都會設成 true

值不值得?自己刻 SVG 折線大概三十行就能動。但軸標籤、雙單位、缺值斷線會讓那三十行長到上百行,而那些行數跟 FHIR 無關。這裡選擇用檔案大小換教學焦點。正式專案有建置步驟,可以只打包用得到的那部分。

第二步,加 scope

app.jsSCOPE 常數加上 patient/Observation.rs

const SCOPE =
  'launch/patient patient/Patient.r patient/Observation.rs openid fhirUser offline_access'

.rs 是 read 加 search。這次授權的同意畫面會從三行變五行,多出來的是 Read Observation recordsSearch for Observation records。一個 scope 兩個字母,同意畫面上各給一行,day10 講的語法在這裡看得見。

第三步,新增 vitals.js

export const BLOOD_PRESSURE = '55284-4'
export const BODY_WEIGHT = '29463-7'
const SYSTOLIC = '8480-6'
const DIASTOLIC = '8462-4'

const USABLE_STATUS = new Set(['final', 'amended', 'corrected'])

function usable(observation) {
  return USABLE_STATUS.has(observation.status)
}

function componentValue(observation, loincCode) {
  const hit = observation.component?.find((one) =>
    one.code?.coding?.some((coding) => coding.code === loincCode)
  )
  return hit?.valueQuantity?.value ?? null
}

function day(observation) {
  return observation.effectiveDateTime?.slice(0, 10) ?? ''
}

function round(value) {
  return value == null ? null : Math.round(value)
}

export async function loadVitals(client) {
  const query = (code) =>
    client.request(
      `Observation?patient=${client.patient.id}&code=${code}&_sort=date&_count=100`,
      { pageLimit: 0, flat: true }
    )

  const [bloodPressure, weight] = await Promise.all([
    query(BLOOD_PRESSURE),
    query(BODY_WEIGHT),
  ])

  return {
    bloodPressure: bloodPressure.filter(usable),
    weight: weight.filter(usable),
  }
}

USABLE_STATUS 就是前面講的那件事。final 是定稿,amendedcorrected 是後來改過但仍然算數的結果,這三種可以畫。其餘的一律不畫,包括 entered-in-error,也包括還沒定稿的 preliminary 跟根本沒有值的 registered

過濾放在 loadVitals() 而不是畫圖那一段,是因為資料一進來就該篩掉。後面組圖表資料的程式碼就不必每個地方都再想一次這件事。

client.request() 的兩個選項值得記一下。pageLimit: 0 是「跟到最後一頁」,flat: true 是「把 Bundle 的 entry 攤平成資源陣列」。這兩件事自己做要寫十幾行,細節 day20 會攤開來講。

兩個查詢沒有先後關係,用 Promise.all 一起發出去,不要一個等一個。

前面那個 renderChart() 也放進這個檔案一起 export,app.js 才叫得到。

第四步,組出圖表資料

export function toChartData({ bloodPressure, weight }) {
  const labels = [...new Set([...bloodPressure, ...weight].map(day))]
    .filter(Boolean)
    .sort()

  const pick = (list, getValue) =>
    labels.map((label) => {
      const hit = list.find((one) => day(one) === label)
      return hit ? round(getValue(hit)) : null
    })

  return {
    labels,
    datasets: [
      {
        label: '收縮壓 (mm[Hg])',
        data: pick(bloodPressure, (one) => componentValue(one, SYSTOLIC)),
        yAxisID: 'pressure',
        borderColor: '#a8483e',
        spanGaps: true,
      },
      {
        label: '舒張壓 (mm[Hg])',
        data: pick(bloodPressure, (one) => componentValue(one, DIASTOLIC)),
        yAxisID: 'pressure',
        borderColor: '#d98b7f',
        spanGaps: true,
      },
      {
        label: '體重 (kg)',
        data: pick(weight, (one) => one.valueQuantity?.value ?? null),
        yAxisID: 'weight',
        borderColor: '#2f4858',
        spanGaps: true,
      },
    ],
  }
}

先把兩種量測的日期合成一組去重過的標籤,再讓每條線按這組標籤對齊。某條線在某個日期沒有資料就填 null,配上 spanGaps: true,圖會在那裡把線接過去而不是斷成兩截。

第五步,畫上去

index.html 加一個 canvas 跟圖表庫:

<h2>生命徵象趨勢</h2>
<canvas id="vitals" height="140"></canvas>

<script src="vendor/fhir-client.pure.min.js"></script>
<script src="vendor/chart.umd.js"></script>

app.js 頂端加一行抓 canvas,然後在顯示完基本資料之後接上:

const vitalsCanvas = document.querySelector('#vitals')

async function showVitals(client) {
  const data = toChartData(await loadVitals(client))
  renderChart(vitalsCanvas, data)

  const counted = data.datasets.map(
    (one) => one.data.filter((value) => value !== null).length
  )
  console.log('趨勢圖:', data.labels.length, '個日期,各線', counted)
}

跑起來 console 應該印出:

趨勢圖: 10 個日期,各線 [10, 10, 10]

執行結果圖,淺灰綠底色,標題「趨勢圖跑出來的樣子」,副標「10 個日期,三條線各 10 個點,左軸是血壓右軸是體重」。畫面上有三個綠色圓形編號,分別掛在瀏覽器視窗右上角、Console 第二行與第三行的左側,與圖片下方三條註記一一對應。中央是一個白色圓角的瀏覽器視窗,左上角三個灰色小圓點,網址列顯示 http://localhost:5175/,右上角是編號 1。頁面內容區左上角是粗體標題「生命徵象趨勢」,底下是一張折線圖。圖表上方置中的圖例依序是深紅色的收縮壓 (mm Hg)、淺珊瑚色的舒張壓 (mm Hg)、深藍色的體重 (kg)。左側 y 軸標 mm Hg,刻度由下而上是 70 到 140;右側 y 軸標 kg,刻度是 87 到 93。深紅色的收縮壓在 106 到 137 之間上下起伏,淺珊瑚色的舒張壓在 72 到 90 之間起伏,深藍色的體重從左下角一路升到頂端後保持水平。x 軸有 10 個刻度,兩端標著 2011-10-14 與 2020-12-04。視窗下半是 Console 面板,三行等寬字輸出:第一行 patient id 是 018f428e-34f6-4707-8009-5ad742f901e7;第二行 scope 是 launch/patient patient/Patient.r patient/Observation.rs openid fhirUser offline_access,其中 patient/Observation.rs 以珊瑚色標示;第三行是趨勢圖: 10 個日期,各線 10、10、10。圖片下方三條綠色編號註記:一,埠號 5175 是實跑當下起的靜態伺服器,換一個號碼不影響;二,珊瑚色那段是這篇加的 patient/Observation.rs,同意畫面因此從三行變五行;三,10、10、10 是三條線各自的點數,體重後七個點都是 93

完整可跑的版本在 GitHub 上的 day16-clinical-data。那一份的 vitals.jsapp.js 比上面多了幾行註解,邏輯逐字相同,只有 FHIR_BASE_URL 那一行要換成自己的。

小結

同樣是 Observation,值可能長在 component 裡,也可能直接掛在資源上。取值要認代碼不認索引,缺值要回 null 不要回零。

血壓體重是數字,畫成圖就懂了。明天那兩種資料沒這麼好對付。病況跟用藥是文字,而 FHIR 裡有三層可能放名字的地方,三層都可能是空的。