제5장
참조 SDK와 명령줄 도구의 각 호출이 실제로 무엇을 하는지, 그리고 구현을 믿을 수 있는지 가르는 경계 사례를 살펴본다.
제4장은 WIA-BIO-011의 레코드를 데이터로서 설명하였다. 구성을 담은 Biosensor, CalibrationData 객체, MeasurementResult 레코드의 흐름, 그리고 이를 둘러싼 정도관리 타입이 그것이다. 이 장은 그 레코드를 만들고 바꾸는 코드를 다룬다. WIA-BIO-011 v1.0에는 두 가지 참조 인터페이스가 딸려 있다. 하나는 TypeScript 패키지 @wia/bio-011로, 주 내보내기는 BiosensorSDK 클래스이다(api/typescript/src/index.ts, 타입은 types.ts). 다른 하나는 Bash 스크립트 wia-bio-011(cli/wia-bio-011.sh)이다. 규격 본문은 API를 따로 정의하지 않는다. 그러므로 실제로는 이 두 프로그램의 동작이 표준의 사실상 인터페이스가 되며, 구현자는 그 동작을 세부까지 알아야 한다.
이 장은 의도적으로 문자 그대로 접근한다. 아래에 적은 동작은 모두 v1.0 소스를 컴파일하고 규격 부록 A의 계산 예제로 실행하여 확인한 것이다. 코드가 이름이나 README가 암시하는 것과 다르게 동작하는 곳에서는 그 사실을 밝히고 우회 방법을 제안한다. 우회 방법은 이 책의 권고이며 표준의 일부가 아니다. 운영 절차, 곧 얼마나 자주 교정하고 QC 실패에 어떻게 대응하는지는 제6장에서 다룬다. 이 장은 인터페이스가 무엇을 받고, 무엇을 돌려주고, 무엇을 던지는지에 집중한다.
BiosensorSDK는 동기식 인메모리 객체이다. 생성자는 아무 일도 하지 않는다. 모든 상태는 두 개의 비공개 맵에 있다. sensors는 센서 ID를 Biosensor에 대응시키고, measurements는 센서 ID를 MeasurementResult 배열에 대응시킨다. SDK는 입출력을 하지 않고, 영속 계층이 없으며, 사용자나 장치라는 개념도 없다. 객체가 가비지 컬렉션되면 그 안의 센서, 교정, 측정은 모두 사라진다. 따라서 운영 시스템은 SDK를 계산·검증 엔진으로 취급하고, 반환된 레코드를 제4장의 JSON 형식으로 직접 저장해야 한다.
| 메서드 | 입력 | 반환 | 실제로 던지는 오류 | 부수 효과 |
|---|---|---|---|---|
getVersion() | 없음 | '1.0.0' | 없음 | 없음 |
createSensor(config) | BiosensorConfig | Biosensor | BIO002 | 상태 idle로 센서 추가, 기본 lifetime을 붙일 수 있음 |
getSensor(id) | 센서 ID | 저장된 Biosensor 객체(실시간 참조) | BIO001 | 없음 |
calibrateSensor(req) | CalibrationRequest | CalibrationData | BIO001, BIO002, BIO010, BIO011 | 상태 calibrating 후 idle, sensor.calibration 교체 |
measureAnalyte(req) | MeasurementRequest | MeasurementResult | BIO001, BIO007, BIO003, BIO004, BIO002 | 상태 measuring 후 idle, measurementCount 증가, 측정 로그에 추가 |
calculateLOD(params) | LODCalculation | LODResult | 없음(잘못된 입력은 NaN) | 없음 |
updatePerformanceMetrics(id, lod, t) | 센서 ID, LODResult, 응답 시간(s) | PerformanceMetrics | BIO001, BIO003 | sensor.performance 교체 |
validateSensor(params) | ValidationParameters | ValidationResult | BIO001 | 없음, 결과는 저장되지 않음 |
runQualityControl(id, sample, c) | 센서 ID, QualityControlSample, 측정 농도 | QualityControlResult | BIO001 | 없음, 결과는 저장되지 않음 |
getMeasurements(id) | 센서 ID | MeasurementResult[] | 없음(모르는 ID는 []) | 없음 |
getAllSensors() | 없음 | Biosensor[] | 없음 | 없음 |
통합에서 중요한 점이 두 가지 있다. 첫째, getSensor는 복사본이 아니라 맵에 들어 있는 객체 자체를 돌려준다. 호출자가 validUntil 날짜나 상태를 바꾸면 SDK 상태가 곧바로 바뀐다. 시험에서는 편리하지만, 센서 객체를 여기저기 넘기는 운영 코드에서는 위험하다. 둘째, SDK 안에 다시 기록되는 것은 교정, 성능 지표, 측정 로그뿐이다. 검증 결과와 QC 결과는 반환된 뒤 잊힌다. 이들을 장치 이력의 일부로 남기려면 애플리케이션이 저장해야 한다.
createSensor는 제4장에서 필수로 표시한 세 필드, 곧 type, biorecognitionElement, targetAnalyte만 검사한다. 나머지 필드는 검사 없이 config에 복사되므로, appliedPotential에 0.6 대신 60을 넣어도 아무 경고 없이 받아들인다. ID 형식은 BS-<epoch 밀리초>-<36진수 난수 9자>이다. 기본 lifetime(180일, 측정 1,000회, "2-8°C, dry")은 인식 요소 문자열에 oxidase, 또는 단지 ase가 들어 있으면 붙는다. 이 규칙은 glucose-oxidase, urease, alkaline-phosphatase는 잡지만 일반값 enzyme은 놓친다. 효소 안정성에 관한 진술이 아니라 부분 문자열 휴리스틱일 뿐이다.
클래스 옆에 함수 네 개가 함께 내보내진다. calculateSensitivity, calculateLOD, processBiosensorSignal, calibrateSensor이다. 각 함수는 새 BiosensorSDK를 만들어 메서드 하나를 부르고 버린다. 앞의 세 함수에는 문제가 없다. calculateSensitivity는 최소제곱 기울기를 돌려주고(부록 A.2 데이터에서 1.15853), processBiosensorSignal은 비공개 신호 처리기를 노출한다. 이 처리기는 기저선 차감과 온도 보정만 구현한다. SignalProcessingParams의 filtering과 driftCompensation 필드는 받기만 하고 무시하며, 결과의 snr 필드는 채워지지 않는다. 그러나 독립 함수 calibrateSensor는 동작할 수 없다. 새로 만든 인스턴스에는 센서가 하나도 없으므로, 어떤 ID를 넘기든 모든 호출이 BIO001 "Sensor … not found"로 끝난다. README의 import 목록에 나오지만 사용해서는 안 된다.
패키지에는 tsup과 tsc --noEmit 스크립트가 선언되어 있지만, 공개된 소스는 타입 검사를 통과하지 못한다. 두 파일 모두 각 선언을 두 번 내보낸다. 한 번은 선언하는 자리(export class, export interface)에서, 또 한 번은 파일 끝의 재내보내기 목록에서이다. TypeScript는 이를 TS2323("Cannot redeclare exported variable")과 TS2484("Export declaration conflicts with exported declaration")로 거부한다. 수정은 기계적이며 동작을 바꾸지 않는다.
// types.ts: 파일 끝의 재내보내기 목록을 지운다 (선언부에서 이미 export 되어 있다)
// export type { BiosensorType, ... TimeSeries };
// export { BIOSENSOR_CONSTANTS, BiosensorErrorCode, BiosensorError };
// index.ts: 파일 끝의 중복 클래스 내보내기를 지운다
// export { BiosensorSDK };
// 그다음:
$ tsc --strict --target es2020 --module commonjs --outDir dist src/index.ts
이 장을 준비하면서 위와 같이 고친 두 파일은 TypeScript 5.9의 --strict 모드에서 오류 없이 컴파일되었고, 아래에 인용한 출력은 모두 그 빌드에서 나온 것이다.
calibrateSensor는 SDK에서 가장 중요한 메서드이다. 이후의 모든 농도가 이 메서드가 저장한 계수에 달려 있기 때문이다. 요청에는 센서 ID, 두 개의 평행 배열(standardConcentrations, measuredSignals), 그리고 선택 항목인 단위(기본 'mM', 'μA'), calibrationType(기본 'linear'), forceRecalibration이 들어간다. 메서드는 다음 순서로 진행한다.
calibrating으로 바꾼다.STANDARD_CALIBRATION_POINTS(5)보다 적으면 BIO010으로 거부한다.MIN_R_SQUARED(0.99)보다 작으면 BIO011로 거부한다.y − ŷ와 최대 절대 잔차를 계산한다.CAL-<epoch ms>, validUntil은 현재 + 24시간, qualityControl.passed는 true이다.sensor.calibration에 저장하고 상태를 idle로 되돌린다.핵심 코드는 짧아서 그대로 인용할 수 있다.
const regression = this.linearRegression(standardConcentrations, measuredSignals);
if (rSquared < BIOSENSOR_CONSTANTS.MIN_R_SQUARED) { // 0.99 -> BIO011
throw new BiosensorError(BiosensorErrorCode.POOR_LINEARITY, ...);
}
const linearRange = {
min: Math.min(...standardConcentrations.filter(c => c > 0)), // 0이 아닌 최저 표준
max: Math.max(...standardConcentrations),
unit: concentrationUnit,
};
// ...
validUntil: new Date(Date.now() + 24 * 60 * 60 * 1000), // +24 h
qualityControl: { passed: true, residuals, maxResidual }, // 항상 true
5점 규칙은 농도 수준이 아니라 배열 항목을 센다. 블랭크도 한 점으로 세고, 반복 측정도 각각 센다. [0, 1, 5, 10, 50]은 0이 아닌 수준이 넷뿐인데도 통과하고, [5, 5, 5, 10, 10]은 두 수준으로도 통과한다. 규격 §7.1은 블랭크에 더해 최소 다섯 농도 수준을 요구하며, 이는 제6장에서 다룬다. 규격을 강제하려는 애플리케이션은 SDK를 부르기 전에 서로 다른 0이 아닌 수준의 개수를 직접 세어야 한다.
calibrationType에 무엇을 넣든 메서드는 직선을 맞추고 slope와 intercept를 기록한다. 면역분석용으로 '4pl'을 넘기면 교정은 성공하고, 선형 계수를 담은 '4pl' 타입의 레코드가 저장된다. 그다음 첫 measureAnalyte 호출은 BIO002 "Unsupported calibration type"으로 실패한다. 역산이 'linear'에만 구현되어 있기 때문이다. 결국 오류는 교정 시점이 아니라 측정 시점에, 몇 시간 뒤 현장진단 현장에서 드러날 수 있다. 후속 버전이 비선형 역산을 구현하기 전까지는, SDK를 부르기 전에 'linear' 이외의 타입을 거부할 것을 이 책은 권고한다.
qualityControl.passed는 언제나 true이다. SDK가 적합도에 대해 하는 검사는 R² ≥ 0.99 하나뿐이며, R²가 높다고 잔차의 모양에 대해 알 수 있는 것은 많지 않다(제3장). 부록 A.2 데이터에 대해 메서드가 돌려주는 값은 다음과 같다.
| 표준 (mM) | 신호 (μA) | 적합값 (μA) | 잔차 (μA) | 농도로 환산한 잔차 (mM) |
|---|---|---|---|---|
| 0 | 0.05 | 0.0474 | +0.0026 | +0.002 |
| 1 | 1.20 | 1.2059 | −0.0059 | −0.005 |
| 5 | 5.85 | 5.8400 | +0.0100 | +0.009 |
| 10 | 11.50 | 11.6327 | −0.1327 | −0.115 |
| 50 | 58.20 | 57.9738 | +0.2262 | +0.195 |
| 100 | 115.80 | 115.9003 | −0.1003 | −0.087 |
R²(0.999993)로 보면 적합은 훌륭하다. 그런데 10 mM에서의 잔차는 그 수준의 약 −1.2 %에 해당한다. 가중치 없는 적합은 50 mM과 100 mM 표준에 좌우되므로 절대 잔차가 농도와 함께 커진다. 임상적으로 중요한 구간이 저농도라면, 가중 적합(예: 1/x)이나 더 좁은 교정 범위가 그 구간에서 더 나은 계수를 준다. SDK는 둘 다 제공하지 않는다. 다만 잔차를 돌려주므로, 호출자는 제6장의 잔차 기준을 적용하여 합격 여부를 스스로 정할 수 있다.
linearRange.min은 LOQ가 아니라 0이 아닌 최저 표준이다. A.2 데이터에서는 1 mM이며, 이 값이 뒤에서 below-lod 플래그를 좌우한다(§5.3).sensitivityUnit이 "μA/mM"이 된다. 많은 키보드와 기기는 대신 마이크로 기호(U+00B5)를 낸다. 두 글자는 똑같아 보이지만 문자열 비교에서는 다르므로, 경계에서 단위 문자열을 정규화해야 한다.forceRecalibration은 받기만 하고 읽지 않는다. CalibrationPoint.replicates, stdDev, CalibrationData.operator는 채워지지 않는다.CAL-<epoch ms>에는 난수 부분이 없어서, 같은 밀리초에 교정된 두 센서는 같은 교정 ID를 받는다. 저장할 때 센서 ID를 앞에 붙인다.NaN이 된다. JavaScript에서 NaN < 0.99는 거짓이므로 BIO011이 나지 않고 교정이 저장된다. 항상 Number.isFinite(cal.rSquared)를 확인한다.
measureAnalyte는 원시 신호 하나를 농도 레코드로 바꾼다. 요청에는 센서 ID와 신호가 들어가고, 선택 항목으로 신호 단위, 온도(기본 25), pH, 시료 ID와 종류, 명시적 calibration, replicateNumber가 들어간다. 검사는 정해진 순서로 진행되며, 여러 문제가 한꺼번에 있을 때 호출자가 어떤 오류를 보게 될지는 이 순서가 결정한다.
| 단계 | 검사 또는 연산 | 결과 |
|---|---|---|
| 1 | 센서 조회, 상태를 measuring으로 | 모르는 ID면 BIO001 |
| 2 | lifetime.expirationDate가 현재보다 이전 | BIO007 SENSOR_EXPIRED |
| 3 | request.calibration, 없으면 sensor.calibration 사용 | 둘 다 없으면 BIO003 "Sensor not calibrated" |
| 4 | validUntil이 현재보다 이전 | BIO004 CALIBRATION_EXPIRED |
| 5 | T ≠ 25 °C이면 온도 보정 | correctedSignal |
| 6 | 선형 역산 C = (y − a)/b | 타입이 'linear'이고 기울기가 0이 아닐 때만, 아니면 BIO002 |
| 7 | 품질 플래그와 withinRange | 아래 참조 |
| 8 | 휴리스틱 신뢰도와 표준오차 | 0.95/0.70, C의 5 % |
| 9 | 결과 저장, 횟수 증가, 상태 idle | MeasurementResult |
보정은 25 °C 고정 기준과 SDK 기본값 ENZYME_TEMP_COEFFICIENT 3 %/°C를 쓴다. 식은 ycorr = y / [1 + 0.03 (T − 25)]이다. 제3장에서 보였듯이 규격은 같은 인자를 곱셈으로 적으므로, 같은 센서라도 두 규약에서 α의 값이 달라진다. SDK에서 기준 온도는 config.temperature에서 가져오지 않고, 계수는 설정할 수 없으며, 효소 계수가 항체·앱타머·광학 센서에도 그대로 적용된다. 이런 센서에 이 계수를 쓸 특별한 근거는 없다. 요청에 temperature를 빼면 기본값 25가 들어가 보정도 플래그도 없이 지나간다. 온도를 잴 수 없는 판독기라면 기본값이 측정값인 척하게 두지 말고, 온도를 모른다는 사실을 레코드에 밝혀야 한다.
플래그 배열은 언제나 ['ok']로 시작하고 경고가 그 뒤에 덧붙는다. 규칙은 다음과 같다. sensor.performance.lod가 있고 C가 그보다 작으면 below-lod가 붙는다. LOQ에 대해서도 같은 방식으로 below-loq가 붙는다. C가 linearRange.min보다 작으면 below-lod가 한 번 더 붙고 withinRange가 false가 된다. linearRange.max를 넘으면 above-range가 붙는다. |T − 25| > 10 °C이면 temperature-out-of-range가 붙는다. 신뢰도는 결과가 범위 안에 있고 추가 플래그가 없을 때 0.95, 그 밖에는 0.70이다. 표준오차는 단순히 |C|의 5 %이다. 두 값 모두 교정 통계에서 나온 것이 아니므로 불확도로 보고해서는 안 된다. 아래 결과는 §5.2의 교정과 §5.4의 성능 지표를 적용한 상태에서 얻은 것이다.
| 신호 (μA) | T (°C) | C (mM) | qualityFlags | withinRange | 신뢰도 |
|---|---|---|---|---|---|
| 23.5 | 25 | 20.243 | ok | true | 0.95 |
| 23.5 | 37 | 14.874 | ok, temperature-out-of-range | true | 0.70 |
| 0.6 | 25 | 0.477 | ok, below-lod | false | 0.70 |
| 0.052 | 25 | 0.0040 | ok, below-lod, below-loq, below-lod | false | 0.70 |
| 0.04 | 25 | −0.0064 | 바로 위 행과 같음 | false | 0.70 |
| 130 | 25 | 112.17 | ok, above-range | false | 0.70 |
세 행은 따로 짚어 볼 만하다. 0.6 μA의 결과 0.477 mM은 LOD의 약 24배인데도, 최저 표준보다 낮다는 이유로 below-lod가 붙는다. 올바른 플래그는 "교정 범위 미만"이어야 한다. 0.052 μA에서는 LOD 규칙과 범위 규칙이 각각 한 번씩 붙여 같은 플래그가 두 번 나온다. 마지막으로 절편보다 작은 신호는 음의 농도를 내며, 이 값은 0으로 잘리지 않고 그대로 반환된다. 첫 행은 부록 A.2의 "20.3 mM"이 다시 나오지 않는 이유도 보여 준다. 규격은 자기 데이터에서 재현되지 않는 인쇄 계수 1.157과 0.043으로 역산하였고(제3장), SDK는 전체 정밀도의 적합값으로 역산하여 20.24 mM을 얻는다.
효과 없이 통과되는 입력이 둘 더 있다. pH는 결과에 복사되지만 config.pH와 비교되지 않고, replicateNumber는 무시된다. 또한 메서드는 다른 센서에 속한 calibration 객체도 ID 비교 없이 받아들인다. 명시적으로 넘긴 교정은 센서 자신의 레코드를 건너뛰지만, 4단계의 만료 검사는 여전히 적용된다.
calculateLOD는 블랭크 신호 배열, 감도, 선택 항목인 방법을 받는다. 블랭크 평균과 표본 표준편차(제수 n − 1)를 구한 뒤 세 가지 승수 중 하나를 적용한다. LOQ는 어떤 방법을 고르든 항상 10σ/S이다. 부록 A.1의 블랭크 10개(평균 0.056 μA, σ = 0.006992 μA)와 §5.2의 감도(1.15853 μA/mM)를 넣으면 세 방법의 결과는 다음과 같다.
method | 승수 k | LOD = kσ/S (mM) | LOQ (mM) | 비고 |
|---|---|---|---|---|
'iupac'(기본) | 3.3 | 0.01992 | 0.06035 | 흔히 쓰는 3.3σ/S 형태 |
'3-sigma' | 3 | 0.01811 | 0.06035 | 고전적인 3σ 기준 |
'epa' | 3.14(고정) | 0.01895 | 0.06035 | EPA 절차가 아님, 아래 참조 |
'epa' 분기는 상수 3.14를 곱한다. 이 분기가 흉내 내는 통계 절차에서 승수는 자유도 n − 1에 대한 단측 99 % Student-t 값이다. 그 값이 약 3.14가 되는 것은 반복이 7회일 때뿐이며, 여기서 쓴 블랭크 10개라면 약 2.82가 된다. 따라서 이 분기를 EPA 준수라고 부르면 안 된다. confidenceLevel 인자는 세 방법 모두에서 받기만 하고 쓰지 않는다. 반환되는 unit은 문자열 'concentration units' 그대로이므로, 결과를 저장할 때 호출자가 실제 단위(여기서는 mM)를 넣어야 한다.
snrAtLOD 필드는 (평균 + 3.3σ)/σ로 계산되며, 이 데이터에서는 11.31이다. 이 수는 블랭크 오프셋에 따라 달라지므로 통상적인 의미의 신호 대 잡음비가 아니다. 정의상 LOD에서의 순신호는 3.3σ이므로, LOD에서의 순 SNR은 3.3이다. CLI는 바로 그 값을 출력한다(§5.7). 결국 두 참조 도구가 같은 데이터에 서로 다른 수를 보고하는 셈이며, 어느 쪽도 측정된 성능 수치로 기록해서는 안 된다. 블랭크가 둘 미만이면 제수 n − 1 때문에 σ가 NaN이 되고, 감도가 0이면 Infinity가 나온다. 두 경우 모두 오류가 나지 않는다.
updatePerformanceMetrics(sensorId, lodResult, responseTime)는 교정이 있어야 동작한다(없으면 BIO003). 이 메서드는 LOD, LOQ, dynamicRange = log₁₀(linearRange.max / LOD)(이 예에서는 log₁₀(100 / 0.01992) = 3.70자릿수), 교정의 농도 단위, 응답 시간, 타임스탬프를 담은 PerformanceMetrics 레코드를 기록한다. 응답 시간은 호출자가 넘긴 숫자 그대로이다. SDK는 시간을 재지 않으므로 이 값은 실제 t₉₀ 실험에서 나와야 한다(제3장). 선택 필드 selectivity, accuracy, precision, stability는 비어 있으며, validateSensor도 이를 채우지 않는다.
여기에는 순서 의존성이 있다. measureAnalyte의 below-lod·below-loq 규칙은 sensor.performance를 읽는다. updatePerformanceMetrics를 부르기 전에는 범위 규칙만이 낮은 결과에 플래그를 붙일 수 있다. 또 재교정해도 이전 성능 레코드가 그대로 남으므로, 새 교정 뒤에 sensor.performance의 LOD는 이전 감도에서 나온 값일 수 있다. 교정할 때마다 다시 계산해야 한다.
SDK의 모든 실패는 BiosensorError를 던지는 방식으로 보고된다. 이 클래스는 Error를 확장하고, name을 'BiosensorError'로 정하며, 문자열 열거형 BiosensorErrorCode의 code와 선택 항목인 details 레코드를 담는다. SDK는 details를 채우지 않는다. 타입 파일은 오류를 반환값으로 다루고 싶은 코드를 위해 Result<T, E>와 AsyncResult<T, E>를 판별 유니언으로 선언하지만, SDK 메서드는 이를 하나도 쓰지 않는다. 모든 메서드가 동기식이며 예외를 던진다.
| 코드 | 열거형 멤버 | v1.0에서 발생시키는 곳 |
|---|---|---|
| BIO001 | SENSOR_NOT_FOUND | getSensor, 따라서 센서 ID를 받는 모든 메서드. 독립 함수 calibrateSensor에서는 항상 발생 |
| BIO002 | INVALID_CONFIGURATION | createSensor(필수 필드 누락), calibrateSensor(배열 길이 불일치), measureAnalyte(비선형 교정 타입) |
| BIO003 | CALIBRATION_FAILED | 교정이 없을 때의 measureAnalyte와 updatePerformanceMetrics |
| BIO004 | CALIBRATION_EXPIRED | validUntil 이후의 measureAnalyte |
| BIO005 | MEASUREMENT_OUT_OF_RANGE | 발생하지 않음(above-range 플래그로 보고) |
| BIO006 | BELOW_LOD | 발생하지 않음(below-lod 플래그로 보고) |
| BIO007 | SENSOR_EXPIRED | lifetime.expirationDate 이후의 measureAnalyte |
| BIO008 | TEMPERATURE_OUT_OF_RANGE | 발생하지 않음(플래그로 보고) |
| BIO009 | QC_FAILED | 발생하지 않음(runQualityControl이 passed: false 반환) |
| BIO010 | INSUFFICIENT_CALIBRATION_POINTS | 항목이 5개 미만인 calibrateSensor |
| BIO011 | POOR_LINEARITY | R² < 0.99인 calibrateSensor(R²가 NaN이면 발생하지 않음) |
| BIO012 | INVALID_SIGNAL | 발생하지 않음(NaN이나 음의 신호도 처리됨) |
| BIO013 | SENSOR_DEGRADED | 발생하지 않음 |
이 구분에는 한 가지 면에서 의도가 보인다. 조건부로나마 쓸 수 있는 수가 나오는 상황(범위 밖, LOD 미만, 온도 이탈)은 플래그가 되고, 어떤 수도 의미가 없어지는 상황은 예외가 된다. 예약만 된 코드 여섯 개도 쓸모가 있다. 원격 서비스나 장치 로그가, 참조 SDK가 감지하지 않는 사건에 대해 공통 어휘를 쓸 수 있게 해 준다. 센서를 잠그는 QC 실패나 잘못된 ADC 판독값이 그런 예이다. 이 코드를 발생시키는 구현은 언제 발생시키는지 문서화해야 한다. v1.0은 발생 조건을 정하지 않기 때문이다.
calibrateSensor는 상태를 calibrating으로, measureAnalyte는 measuring으로 바꾼 뒤에 검사를 한다. 검사에서 예외가 나면 상태를 되돌리는 코드가 없다. 거부된 교정 뒤에 센서는 calibrating에, 교정 만료 오류 뒤에는 measuring에 계속 머문다. 상태 열거형에 error, expired, active가 있는데도 SDK는 이 셋을 한 번도 지정하지 않는다. 아래 래퍼는 의미 있는 상태를 되살리고 예외를 Result 형태로 바꾼다. 상태 선택은 제6장의 수명주기를 미리 반영한 것이며, 이 책의 권고이다.
import { BiosensorSDK, BiosensorError, BiosensorErrorCode, MeasurementRequest, MeasurementResult, Result } from '@wia/bio-011';
function safeMeasure(sdk: BiosensorSDK, req: MeasurementRequest): Result<MeasurementResult, BiosensorError> {
const sensor = sdk.getSensor(req.sensorId); // 여기서도 BIO001이 날 수 있다
try {
return { success: true, data: sdk.measureAnalyte(req) };
} catch (e) {
if (!(e instanceof BiosensorError)) throw e; // 프로그램 오류는 다시 던진다
sensor.status = 'error'; // v1.0은 'measuring'을 남겨 둔다
switch (e.code) {
case BiosensorErrorCode.CALIBRATION_EXPIRED: // BIO004: 재교정을 예약한다
case BiosensorErrorCode.CALIBRATION_FAILED: // BIO003: 교정된 적이 없다
sensor.status = 'idle';
break;
case BiosensorErrorCode.SENSOR_EXPIRED: // BIO007: 센서를 폐기한다
sensor.status = 'expired';
break;
}
return { success: false, error: e };
}
}
다음 세션은 규격 자체의 예제 데이터로 SDK의 주 경로를 실행한다. 교정과 시료는 부록 A.2, 블랭크는 부록 A.1을 쓴다. 기댓값은 주석으로 보였으며, 컴파일한 v1.0 코드를 실행하여 재현한 값이다. 실험실에서 얻어야 하는 값, 곧 응답 시간과 QC 목표값은 예시로 표시하였다.
import { BiosensorSDK } from '@wia/bio-011';
const sdk = new BiosensorSDK();
// 1. 생성: 필수 필드 3개와 설명용 필드
const sensor = sdk.createSensor({
type: 'electrochemical',
electrochemicalType: 'amperometric',
biorecognitionElement: 'glucose-oxidase',
targetAnalyte: 'glucose',
analyteCategory: 'metabolite',
workingElectrode: 'platinum',
referenceElectrode: 'ag-agcl',
counterElectrode: 'platinum',
appliedPotential: 0.6, // V vs Ag/AgCl
sampleType: 'serum',
});
// sensor.id -> 'BS-<epoch ms>-<9 random chars>'
// sensor.status -> 'idle'
// sensor.lifetime-> { expirationDate: now + 180 d, maxMeasurements: 1000,
// storageConditions: '2-8°C, dry' }
// 2. 규격 부록 A.2 데이터로 교정 (블랭크 + 5수준 = 6점)
const cal = sdk.calibrateSensor({
sensorId: sensor.id,
standardConcentrations: [0, 1, 5, 10, 50, 100], // mM
measuredSignals: [0.05, 1.20, 5.85, 11.50, 58.20, 115.80], // μA
});
// cal.regression -> { slope: 1.15853, intercept: 0.04736 }
// cal.rSquared -> 0.999993
// cal.linearRange -> { min: 1, max: 100, unit: 'mM' }
// cal.qualityControl.maxResidual -> 0.2262 (50 mM 표준에서)
// 3. 부록 A.1 블랭크로 LOD 계산
const lod = sdk.calculateLOD({
blankSignals: [0.05, 0.06, 0.05, 0.07, 0.05, 0.06, 0.05, 0.06, 0.05, 0.06],
sensitivity: cal.sensitivity,
});
// lod.blankMean -> 0.056, lod.blankStdDev -> 0.006992
// lod.lod -> 0.01992 (mM), lod.loq -> 0.06035 (mM), lod.snrAtLOD -> 11.31
// 4. 성능 저장: 응답 시간은 입력값이며 23 s는 예시일 뿐이다(제3장과 같은 값)
const perf = sdk.updatePerformanceMetrics(sensor.id, lod, 23);
// perf.dynamicRange -> 3.70 (자릿수, log10(100 / 0.01992))
// 5. 부록 A.2 시료를 기준 온도에서 측정
const m = sdk.measureAnalyte({
sensorId: sensor.id, signal: 23.5, temperature: 25,
sampleId: 'S-0001', sampleType: 'serum',
});
// m.concentration -> 20.243 (mM) m.qualityFlags -> ['ok']
// m.confidence -> 0.95 m.standardError -> 1.012 (값의 5 %)
// 6. 중간 농도 관리물질 실행 (목표값은 예시)
const qc = sdk.runQualityControl(sensor.id, {
level: 'medium', targetConcentration: 10,
acceptableRange: { min: 9, max: 11 },
historicalMean: 10.1, historicalStdDev: 0.3, lotNumber: 'QC-LOT-EX',
}, 10.4);
// qc.passed -> true, qc.deviation -> 4.0 (%), qc.zScore -> 1.0,
// qc.correctiveAction -> undefined
각 단계는 실제 시스템이 영속화해야 할 흔적을 남긴다. 1단계 뒤에는 기본 수명이 붙은 센서 레코드가 생긴다. 2단계는 24시간 유효한 교정을 붙인다. 4단계는 센서에 LOD를 주고, 5단계는 이를 플래그 판정에 쓴다. 5단계의 측정은 인메모리 로그에 추가되어(getMeasurements가 이제 항목 하나를 돌려준다) measurementCount가 1이 된다. 6단계의 QC 결과는 반환되지만 저장되지 않는다. 목표 대비 편차는 +4.0 %, 과거 평균에 대한 z-점수는 1.0이므로 시정 조치는 제시되지 않는다.
합격 판정에는 acceptableRange만 쓰인다. z-점수는 과거 평균과 표준편차가 모두 있을 때 계산되지만 판정에는 관여하지 않는다. 관리물질이 불합격이면 목표 대비 절대 편차에 따라 조치 문구가 정해진다. 20 %를 넘으면 "Recalibrate sensor immediately", 10~20 %이면 "Run additional QC sample to confirm", 10 % 미만이면 "Monitor next QC result"이다. 마지막 경우는 실험실이 ±10 %보다 좁은 범위를 정했을 때 생긴다. 범위가 9~11 mM일 때 11.5 mM은 +15 %로 불합격이 되어 두 번째 조치를 받고, 12.5 mM은 +25 %로 첫 번째 조치를 받는다. 범위가 9.5~10.5 mM이면 10.8 mM이 +8 %로 불합격이 되어 세 번째 조치를 받는다. 조치는 자유 텍스트 문자열이다. 제6장에서 이 단계들을 z-점수와 함께 제대로 된 판정 절차로 바꾸는 방법을 보인다.
validateSensor는 선택 블록을 최대 네 개까지 평가하며, 평가한 블록 중 하나라도 불합격이면 전체 validated 플래그가 false가 된다.
const v = sdk.validateSensor({
sensorId: sensor.id,
precision: { replicates: [5.80, 5.86, 5.91, 5.83, 5.88], requiredCV: 5 },
accuracy: {
spikedSamples: [
{ expectedConcentration: 5, measuredConcentration: 4.9 },
{ expectedConcentration: 10, measuredConcentration: 10.3 },
],
requiredRecovery: [90, 110],
},
selectivity: {
targetSignal: 5.85,
interferents: [
{ name: 'fructose', concentration: 5, signal: 0.15 },
{ name: 'ascorbate', concentration: 0.1, signal: 0.08 },
],
requiredSelectivity: 100,
},
stability: { measurements: [{ time: 0, signal: 10 }, { time: 24, signal: 5 }],
requiredStability: 95 },
});
// v.precision -> { passed: true, cv: 0.73 }
// v.accuracy -> { passed: true, recovery: 100.5 }
// v.selectivity -> { passed: false, coefficients: { fructose: 39, ascorbate: 73.1 } }
// v.stability -> (없음: 신호 50 % 손실이 평가되지 않는다)
// v.validated -> false
직선성은 회귀를 다시 돌려 R²를 호출자의 기준과 비교한다. 정확도는 개별 회수율의 평균을 쓰므로, 80 %인 시료와 120 %인 시료가 함께 있으면 평균 100 %로 합격한다. 회수율을 하나씩 검사하는 편이 안전하다. 정밀도는 반복값 배열의 CV를 계산한다. 선택성은 targetSignal을 각 간섭 물질의 원시 신호로 나누며, 스스로 요구하는 concentration 필드는 무시한다. 제3장에서 보였듯이 농도로 정규화하면 아스코르브산의 비율은 73에서 약 1.5로 바뀐다. 따라서 간섭 물질을 분석물보다 낮은 농도에서 시험하면 SDK는 선택성을 과대평가한다. 끝으로 stability 블록은 타입에는 있지만 읽히지 않는다. 예제에서는 신호가 24시간 만에 절반이 되는데도 아무것도 보고되지 않는다.
wia-bio-011은 Bash 스크립트이다. 산술에는 bc, 타임스탬프에는 GNU date -d를 쓰고, 출력은 ANSI 이스케이프 코드로 색을 입힌다. 이 도구는 SDK의 프런트엔드가 아니다. 계산 일부를 셸로 다시 구현하였을 뿐이고, 호출 사이에 상태를 전혀 유지하지 않는다. --sensor-id 옵션은 출력에 되풀이될 뿐 조회되지 않는다. 빠른 계산과 시연에는 쓸모가 있지만 레코드를 만드는 데 써서는 안 된다.
| 명령 | 옵션(기본값) | 실제로 하는 일 |
|---|---|---|
create-sensor | --type(electrochemical), --bioelement(glucose-oxidase), --analyte(glucose) | 마지막 줄에 새 ID BS-<epoch 초>-<8자>를 출력한다. 전기화학 타입이면 고정된 전극 문구를 출력한다. 검증도 저장도 하지 않는다. |
calibrate | --sensor-id, --standards, --signals(둘 다 필수) | bc로 최소제곱 적합을 하고 기울기·절편·R²를 낸다. 5점 이상 검사(미달이면 종료 코드 1), 선형 범위, +24시간 "Valid Until"을 출력한다. |
measure | --signal(10.5), --temp(25), --slope(1.157), --intercept(0.043) | 1 + 0.03(T − 25)로 나누고 역산한 뒤, 고정된 0.1~30 mM 창에 대해 플래그를 붙인다. |
calc-lod | --blank-stddev(0.015), --sensitivity(1.157) | LOD = 3.3σ/S, LOQ = 10σ/S를 mM과 μM으로 출력하고 말로 된 등급을 붙인다. |
validate | --accuracy(95), --precision(3) | 회수율을 90~110 %, CV를 5 % 이하와 비교한다. R² 0.9995와 응답 시간 15 s는 하드코딩되어 있다. |
qc-report | --sensor-id | 어떤 센서든 미리 정해진 보고서(2.48, 10.15, 24.82 mM)를 출력한다. |
version, help | 없음 | 버전(1.0.0)이나 사용법을 출력한다. |
아래 세션은 종합 예제를 그대로 옮긴 것이다(출력 일부 생략).
$ wia-bio-011 create-sensor --type electrochemical --bioelement glucose-oxidase --analyte glucose
Sensor ID: BS-<epoch s>-<8 chars>
...
Applied Potential: +0.6 V
✓ Sensor created successfully
BS-<epoch s>-<8 chars> # 마지막 줄 = ID, 아무것도 저장되지 않는다
$ wia-bio-011 calibrate --sensor-id BS-001 \
--standards "0,1,5,10,50,100" --signals "0.05,1.20,5.85,11.50,58.20,115.80"
✓ Slope (Sensitivity): 1.1585 μA/mM
✓ Intercept: 0.0474 μA
✓ R²: 0.999993
Linear Range: 1 - 100 mM
Signal(μA) = 0.0474 + 1.1585 × Concentration(mM)
$ wia-bio-011 measure --sensor-id BS-001 --signal 23.5 --slope 1.1585 --intercept 0.0474
✓ Concentration: 20.24 mM
Quality Flags: OK
$ wia-bio-011 measure --sensor-id BS-001 --signal 23.5 --temp 37 --slope 1.1585 --intercept 0.0474
Temperature-Corrected Signal: 17.28 μA
✓ Concentration: 14.87 mM
Quality Flags: OK # SDK라면 temperature-out-of-range가 붙는다
$ wia-bio-011 calc-lod --blank-stddev 0.0070 --sensitivity 1.1585
✓ LOD: 0.0199 mM (19.9 μM)
✓ LOQ: 0.0604 mM (60.4 μM)
SNR at LOD: 3.3:1 # 같은 데이터에 SDK는 11.31을 보고한다
measure의 기본값은 앞선 calibrate의 출력이 아니라 부록 A.2의 인쇄 계수(1.157, 0.043)이다. --slope와 --intercept를 주지 않으면 23.5 μA 시료는 20.27 mM이 되고, SDK는 20.24 mM, 규격은 20.3 mM을 제시한다.measure의 플래그는 분석물이나 교정과 무관하게 하드코딩된 0.1~30 mM 창을 쓴다. 37 °C에서도 온도 플래그가 붙지 않고, 신뢰도는 항상 95 %로 출력된다.calibrate는 경고만 출력하고 종료 코드 0으로 끝난다. 스크립트는 종료 코드로 실패를 알아챌 수 없다. 5점 미만이면 종료 코드 1이다.grep -v '^0'으로 찾기 때문에 숫자 0으로 시작하는 표준이 모두 빠진다. 표준이 0,0.5,1,5,10,20이면 범위가 "1 - 20 mM"으로 보고되고 0.5 mM 표준은 사라진다.validate는 어떤 센서에나 R² 0.9995와 15 s를 보고하고, qc-report는 매번 같은 세 관리물질 결과를 출력한다. 둘 다 증거로 제출해서는 안 된다.--signl 23.5처럼 오타를 내면 기본값 10.5 μA로 측정한다. README의 wia-bio-011 --version은 모르는 명령으로 거부되며, 동작하는 형태는 wia-bio-011 version이다.create-sensor의 ID는 마지막 줄에 있다(| tail -n 1). 그 밖의 출력은 사람이 읽도록 만든 것이지 파싱용이 아니다.
WIA-BIO-011 v1.0은 네트워크 API를 정의하지 않는다. spec/PHASE-2-API-INTERFACE.md 파일은 다른 표준 ID가 붙은 일반 템플릿 문서여서 규범으로 취급할 수 없다. 남길 만한 것은 다른 단계 문서와 공통인 일반 보안 문장뿐이다. 인증에는 OAuth 2.0과 API 키, 전송 구간에는 TLS 1.3, 저장 시에는 AES-256, 그리고 역할 기반 접근 제어, 감사 로그, OpenAPI 3.0 기술이 그것이다. README도 WIA-OMNI-API와 WIA-HEALTH를 통합 대상으로 들지만 인터페이스는 정의하지 않는다. 따라서 아래 내용은 SDK 앞에 서비스를 두어야 하는 팀을 위해, SDK 객체를 REST 자원으로 노출하는 방법을 이 책이 그려 본 스케치이다. WIA의 요구사항이 아니며, 어떤 기본 URL이나 호출 한도도 전제하지 않는다.
| 자원과 동사(스케치) | SDK 연산 | 본문 / 응답 타입 |
|---|---|---|
POST /sensors | createSensor | BiosensorConfig → Biosensor |
GET /sensors/{id} | getSensor | Biosensor |
POST /sensors/{id}/calibrations | calibrateSensor | CalibrationRequest → CalibrationData |
POST /sensors/{id}/lod | calculateLOD + updatePerformanceMetrics | 블랭크와 응답 시간 → PerformanceMetrics |
POST /sensors/{id}/measurements | measureAnalyte | MeasurementRequest → MeasurementResult |
GET /sensors/{id}/measurements | getMeasurements | MeasurementResult[], 페이지 단위 |
POST /sensors/{id}/qc | runQualityControl | 관리물질과 측정값 → QualityControlResult(저장) |
POST /sensors/{id}/validations | validateSensor | ValidationParameters → ValidationResult(저장) |
이런 서비스는 세 가지 선택을 명시해야 한다. 첫째는 오류 대응이다. BIO001은 404에, 입력 문제(BIO002, BIO010, BIO011)는 422에, 상태 충돌(BIO003, BIO004, BIO007)은 409에 자연스럽게 대응하며, 클라이언트가 조치할 수 있도록 본문에 BIO 코드를 남긴다. 둘째는 직렬화이다. TypeScript 타입은 JavaScript Date 객체를 쓰는데, 전송할 때는 ISO 8601 UTC 문자열이어야 하고, 제4장의 권고대로 모든 수에는 단위 문자열이 따라가야 한다. 셋째는 플래그 어휘이다. 아래 예는 §5.9의 권고대로 무조건 붙는 'ok'를 뺐다. 계산된 농도를 빼면 모든 값은 예시이다.
POST /sensors/{sensorId}/measurements
Content-Type: application/json
{ "signal": 23.5, "signalUnit": "μA", "temperature": 37, "sampleId": "S-0001" }
HTTP/1.1 200 OK
{
"id": "M-...", "sensorId": "BS-...",
"concentration": 14.874, "concentrationUnit": "mM",
"signal": 23.5, "correctedSignal": 17.279, "signalUnit": "μA",
"qualityFlags": ["temperature-out-of-range"],
"withinRange": true, "temperature": 37,
"timestamp": "2026-01-15T08:30:00Z"
}
HTTP/1.1 409 Conflict
{ "error": { "code": "BIO004", "message": "Calibration has expired",
"details": { "validUntil": "2026-01-14T08:00:00Z" } } }
인메모리 SDK와 달리 이런 서비스는 QC 결과와 검증 결과도 저장하고, 각 측정에 쓰인 교정 ID를 돌려주며, 인증된 작업자를 operator에 기록할 것이다. 감사자가 가장 먼저 요구하는 것이 바로 이 필드들이다.
위에서 설명한 동작을 한곳에 모았다. 각 항목은 v1.0 코드로 재현하였고, 각각 간단한 방어책이 있다.
| 함정 | 관찰되는 현상 | 방어책 |
|---|---|---|
| 37 °C의 README SDK 예제 | 규격 A.2의 20.3 mM(25 °C, 약간 다른 데이터)이 아니라 약 14.9 mM과 temperature-out-of-range | 두 예제를 섞지 않는다. 모든 계산 결과에 온도를 밝힌다 |
README의 첫 createSensor 호출 | targetAnalyte가 없어 BIO002. transducer는 구성 필드가 아니며 calibration.lod는 undefined | electrochemicalType을 쓰고 LOD는 calculateLOD에서 얻는다 |
항상 있는 'ok' | ['ok', 'above-range'] | 배열이 정확히 ['ok']일 때만 깨끗한 결과로 보고, 저장할 때 'ok'를 뺀다 |
범위 규칙이 below-lod를 재사용 | LOD보다 높지만 최저 표준보다 낮은 결과에 below-lod가 붙고, 때로는 두 번 붙음 | 저장된 LOD, LOQ, 범위에 대해 플래그를 따로 다시 계산한다 |
maxMeasurements 미적용 | 1,001번째 측정도 성공 | 호출 전마다 measurementCount를 수명과 비교한다 |
NaN R² 허용 | 평평하거나 일정한 데이터의 교정이 저장됨 | Number.isFinite(rSquared)와 서로 다른 수준의 개수를 확인한다 |
| 오류 뒤 남는 상태 | calibrating이나 measuring에 멈춘 센서 | catch/finally에서 되돌린다(§5.5) |
독립 함수 calibrateSensor | 항상 BIO001 | 클래스 메서드를 쓴다 |
비선형 calibrationType | 교정은 성공하고 이후 모든 측정이 BIO002로 실패 | 'linear' 이외는 처음부터 거부한다 |
| 소스의 이중 내보내기 | 빌드 시 TS2323/TS2484 | 파일 끝의 재내보내기 목록을 지운다(§5.1) |
| μ(U+03BC)와 µ(U+00B5) | 같아 보이는 단위 문자열이 다르게 비교됨 | 입력 단계에서 단위를 정규화한다 |
| 인메모리 상태 | 재시작하면 모든 센서와 결과가 사라짐 | 반환된 레코드를 모두 영속화한다 |
| CLI 기본값과 무시되는 옵션 | 1.157/0.043이나 기본 신호로 계산된 결과 | --slope, --intercept, --signal을 항상 명시하고, 되풀이 출력된 입력값을 확인한다 |
이 문제들 때문에 참조 구현이 쓸모없어지는 것은 아니다. 핵심 계산인 최소제곱, LOD·LOQ, 선형 역산, 온도 스케일링은 정확하며, 이 장에서 한 것처럼 손으로 쉽게 검증할 수 있다. 문제는 가장자리에 있다. 검사하지 않고 넘어가는 것, 코드가 하는 것보다 더 많은 것을 약속하는 이름, 통계처럼 보이는 휴리스틱이 그것이다. 위에 든 검사를 더하고 받은 레코드를 모두 저장하는 구현은 규격이 의도한 대로 동작한다. 제6장은 이 토대 위에서 이 호출들을 운영 프로토콜로 엮는다. 언제 교정하고, 교정을 어떻게 수용하며, QC를 어떻게 실행하고 해석하고, 언제 센서를 사용에서 빼야 하는지를 다룬다.