본문으로 건너뛰기

시드 트레인과 세포배양 오프라인 분석

📍 현재 위치: Part II · 공정 포착하기 — 8장. 히스토리안(historian)은 이미 바이오리액터(bioreactor)의 실시간 태그(tag)를 받아들이고 있다. 이 장에서는 진실의 나머지 절반 — DCS(distributed control system, 분산제어시스템)에는 결코 닿지 않는 수동 시드 트레인(seed train) 입력과 오프라인 벤치 분석 결과 — 를 포착하고, 그 각각을 배치(batch)와 그것이 대표하는 순간에 연결한다.

쉽게 말하면

바이오리액터의 온라인(online) 센서는 손목에 찬 피트니스 트래커와 같다. 늘 켜져 있고, 자동이며, 끊임없이 데이터를 흘려보낸다. 하지만 하루에 두 번, 기술자는 배양액 한 튜브를 뽑아 벤치 분석기로 가져가, 손목 밴드가 결코 줄 수 없는 숫자를 얻는다. 살아 있는 세포가 몇 개인지, 당이 얼마나 남았는지, 항체가 얼마나 축적되었는지. 그 결과는 장비에서 이메일로 전송된 CSV로, 혹은 양식에 입력된 형태로 도착한다. 어려운 부분은 그것을 측정하는 일이 아니다. 어려운 것은 각 결과를 올바른 배치시료가 채취된 정확한 시각에 다시 붙이는 일이다. 규제 당국에게는 배치 기록에 닻을 내리지 못한 숫자란 곧 숫자가 아니기 때문이다.

이 장에서 다루는 내용

5~7장에서는 DCS가 OPC UA로 내보내는 모든 것 — 온도, pH, 용존산소(dissolved oxygen), 피드 펌프(feed pump) — 을 포착했다. 하지만 CHO 유가식(fed-batch) 운전은 DCS가 결코 보지 못하는 완전히 별개의 두 번째 데이터 스트림을 만들어낸다. 시드 트레인 — 해동한 바이알(vial)부터 생산 바이오리액터까지 세포를 단계적으로 확장하는 과정 — 은 대부분 수기로 기록된다. 그리고 세포배양에서 의사결정에 가장 결정적인 숫자들, 즉 공정 과학자가 실제로 운전의 방향을 잡는 데 쓰는 숫자들은 벤치 분석기에서 나온다. 생존세포밀도(viable cell density), 생존율(viability), 글루코스(glucose), 락테이트(lactate), 역가(titer)가 그것이다.

이 장은 그 DCS 바깥 세계에 관한 것이다. 우리는 다음을 할 것이다.

  • 결정론적(deterministic) 시뮬레이터로 현실적인 오프라인/앳라인(at-line) 벤치 결과를 생성한다. 이 결과는 온라인 트레이스(trace)와 동일한 기저 배양 상태에서 뽑아낸다.
  • 그 결과를 시료-배치 계보(genealogy)를 위해 설계된 관계형 랩(lab) 스키마(schema)에 적재한다.
  • 분석기의 CSV 드롭(drop)을 자동으로 집어 올리는 파일 감시(file-watch) 인제스터(ingester)를 만든다.
  • 그리고 진짜로 어려운 부분과 마주한다. 모든 오프라인 결과가 가진 두 개의 타임스탬프(timestamp)(시료가 채취된 시각 대 측정된 시각)를 조정하는 일, 그리고 수동 데이터를 데이터 무결성(data integrity)의 지뢰밭으로 만드는 지연된, 수정된, 정정된 결과를 다루는 일이다.

이 장의 핵심에 있는 두 산출물은 모두 동반 저장소(repo)에 실제로 존재하며 테스트되어 있다. 시뮬레이터 모듈 examples/sim/bioproc_sim/offline_assays.py와 랩 스키마 examples/platform/db/30-lab-events.sql이다. 파일 감시 코드는 watchdog 라이브러리 위에 구축된 저장소의 file-ingester 서비스(examples/services/file-ingester/app.py)를 응축한 발췌로 제시되며, 등장하는 곳에 표시해 둔다.

오프라인 데이터가 다른 짐승인 이유

FDA의 PAT(Process Analytical Technology, 공정분석기술) 프레임워크가 표준으로 만든 측정 분류 체계에서, 탱크에 배선된 센서는 온라인(on-line)(프로브가 배양액 안에 잠겨 있으면 인라인(in-line))이고, 시료를 뽑아 몇 분 안에 근처 분석기로 돌리면 **앳라인(at-line)**이며, 시료를 별도의 실험실로 가져가면 **오프라인(off-line)**이다 [1]. 이 프레임워크의 핵심은 인라인에서 한 걸음 멀어질 때마다 지연이 더해진다는 점이다. 시료가 공정을 대표하는 시점과 마침내 답을 알게 되는 시점 사이의 시간 말이다. 그 지연이야말로 우리가 기록해야 하는 바로 그것이다. 결과는 측정과 동시적(contemporaneous)이지만, 그것은 배치의 더 이른 순간에 대한 증거이기 때문이다.

그 분석기에는 무엇이 올라오는가? CHO 시드 트레인과 생산 배양에서 기본 패널(panel)은 생존세포밀도(VCD)와 생존율 — 역사적으로는 수동 혈구계산기(hemocytometer)와 트리판 블루(trypan blue) 계수로, 오늘날에는 보통 자동 영상 계수기로 측정하며, 방법 선택 자체가 검증되고 무결성과 관련된 결정이다 [2] — 그리고 모든 영양 공급 전략이 기반으로 삼는 대사물질(metabolite) 세트, 즉 글루코스, 락테이트, 글루타민(glutamine), 암모늄(ammonium), 삼투압(osmolality)이다 [3]. 이들은 실험실 정보학(laboratory-informatics) 데이터다. 성숙한 공장에서는 공정 자동화 스택이 아니라 LIMS, ELN, 혹은 실험실 실행 시스템(laboratory execution system)을 통해 포착되며, 실험실 정보학에 관한 ASTM E1578 가이드가 바로 그렇게 규정하는 참조 문서다. DCS 태그와는 다른 데이터 계보, 다른 시스템, 다른 검증인 것이다 [4].

그 분리가 이 장을 조직하는 사실이다. 두 개의 스트림, 두 개의 보관 사슬(custody chain), 그리고 둘 다 함께 받들어야 하는 하나의 배치.

온라인 트레이스와 일치하는 오프라인 결과 생성하기

시뮬레이터는 의도적이고 중요한 일을 한다. 인라인 태그가 나오는 동일한 동역학적(kinetic) 상태에서 오프라인 패널을 샘플링한 다음, 분석 노이즈(noise)와 검출 한계(limit of detection)를 더한다. 그래서 벤치 VCD는 온라인 세포 성장 모델과 일치한다 — 다만 더 노이즈가 많고 훨씬 더 성긴 형태일 뿐이다. 이것은 지름길이 아니다. 그것은 이 장이 풀기 위해 존재하는 바로 그 조정(reconciliation) 문제를, 테스트 데이터에 미리 구워 넣은 것이다.

다음은 examples/sim/bioproc_sim/offline_assays.py의 핵심이다.

# examples/sim/bioproc_sim/offline_assays.py
SAMPLES_PER_DAY = 2


def sample(result: BatchResult | None = None, batch_id: str = "BATCH-2026-001") -> pd.DataFrame:
"""Two offline samples per day from the fed-batch state, with assay noise + LoD."""
if result is None:
result = simulate(batch_id)
s = result.state
rng = stream_rng("offline_assays", result.batch_id)

minutes = []
day = 0.0
while day <= 14.0 + 1e-9:
for frac in (0.25, 0.75): # ~06:00 and ~18:00
m = int(round((day + frac) * 1440))
if m < len(s):
minutes.append(m)
day += 1.0
minutes = sorted(set(minutes))

rows = []
for i, m in enumerate(minutes, start=1):
st = s.iloc[m]
rows.append({
"sample_id": f"{result.batch_id}-OFF-{i:03d}",
"batch_id": result.batch_id,
"sample_time": st["ts"],
"sample_point": "BR101",
"VCD_e6_per_mL": max(0.0, round(st.Xv_e6_per_mL * (1 + rng.normal(0, 0.05)), 2)),
"viability_pct": float(np.clip(round(st.viability_pct + rng.normal(0, 1.2), 1), 0, 100)),
"glucose_g_L": max(0.0, round(st.glucose_g_L + rng.normal(0, 0.15), 2)),
"lactate_g_L": max(0.0, round(st.lactate_g_L + rng.normal(0, 0.10), 2)),
"glutamine_mM": max(0.0, round(st.glutamine_mM + rng.normal(0, 0.10), 2)),
"ammonia_mM": max(0.0, round(st.ammonia_mM + rng.normal(0, 0.20), 2)),
"osmolality_mOsm_kg": int(round(st.osmolality_mOsm_kg + rng.normal(0, 4))),
"titer_g_L": max(0.0, round(st.titer_g_L * (1 + rng.normal(0, 0.04)), 3)),
"pH_offline": round(float(np.clip(st.pH + rng.normal(0, 0.02), 6.6, 7.4)), 2),
})
return pd.DataFrame(rows)

천천히 짚어 볼 만한 세부가 셋 있다. 실제 실험실 관행을 부호화한 부분이기 때문이다. 첫째, 스케줄이다. 하루에 두 번 frac = 0.250.75 — 대략 06:00과 18:00 — 인데, 이는 현실적인 오프라인 주기이며, 태그당 약 20,160개의 1분 단위 온라인 행에 대비해 14일 운전 동안 28개의 결과를 준다. 오프라인 데이터는 성기다. 둘째, sample_time은 함수가 실행되는 순간이 아니라 시뮬레이션된 상태 행 자체의 타임스탬프 st["ts"] — 즉 시료가 대표하는 순간 — 에서 가져온다. 셋째, 노이즈는 분석물질별이며 물리적으로 스케일링되어 있다. VCD와 역가에는 곱셈형 4~5% 오차가 붙고(계수와 분석 정밀도의 부정확성은 크기와 함께 커진다), 글루코스와 락테이트에는 작은 덧셈형 오차가 붙으며, 모든 값은 0에서 바닥이 막혀 있어 검출 한계 근처의 측정값이 음수로 내려가는 일이 없다.

rng = stream_rng("offline_assays", result.batch_id) 줄은 이 책 전체가 재현 가능한 이유다. 모든 난수 스트림은 마스터 시드(seed)(SIM_SEED=2026)에 스트림별 라벨을 더해 유도되므로, 이 데이터셋은 모든 머신과 CI에서 바이트 단위로 동일하다.

모듈을 직접 실행하면 자체 요약을 출력한다.

$ SIM_SEED=2026 python -m bioproc_sim.offline_assays
offline samples: 28 rows over 14 days
sample_id VCD_e6_per_mL viability_pct glucose_g_L titer_g_L
0 BATCH-2026-001-OFF-001 0.34 96.6 6.18 0.002
1 BATCH-2026-001-OFF-002 0.43 96.6 6.26 0.008
2 BATCH-2026-001-OFF-003 0.56 99.0 6.01 0.014
3 BATCH-2026-001-OFF-004 0.72 97.5 5.99 0.022
4 BATCH-2026-001-OFF-005 0.96 96.7 5.69 0.033
release assays: 11 rows; OOS=0

이것이 모듈이 출력하는 요약이다. 행 개수, 다섯 줄짜리 head(), 그리고 한 줄의 출하 분석 집계다. 배치별 전체 행은 generate.py가 모든 배치에 걸쳐 sample()을 이어 붙여(BATCH-2026-001부터 -006까지, 각 28행, 총 168행) 커밋된 골든 파일(golden file) examples/datasets/offline_assays.csv에 기록한다. 참조 배치에 대한 그 첫 행들은 전체 와이드(wide) 패널 — 앳라인 분석물질 세트를 한곳에 모은 것 — 을 보여 준다.

sample_id,batch_id,sample_time,sample_point,VCD_e6_per_mL,viability_pct,glucose_g_L,lactate_g_L,glutamine_mM,ammonia_mM,osmolality_mOsm_kg,titer_g_L,pH_offline
BATCH-2026-001-OFF-001,BATCH-2026-001,2026-01-05 06:00:00+00:00,BR101,0.34,96.6,6.18,0.13,4.13,0.68,293,0.002,7.06
BATCH-2026-001-OFF-002,BATCH-2026-001,2026-01-05 18:00:00+00:00,BR101,0.43,96.6,6.26,0.19,4.31,0.38,292,0.008,7.04
BATCH-2026-001-OFF-003,BATCH-2026-001,2026-01-06 06:00:00+00:00,BR101,0.56,99.0,6.01,0.32,3.83,0.45,287,0.014,7.05

VCD_e6_per_mL 열을 아래로 읽어 보라 — mL당 0.34, 0.43, 0.56백만 세포 — 그러면 초기 시드 확장 상승 곡선을 지켜보고 있는 셈이다. 저밀도 접종물(inoculum)이 생산 밀도를 향해 배가되는 모습이다. 역가 열은 사실상 0에서 시작하는데, 항체 축적이 성장보다 뒤처지기 때문이다. 이것이 숫자로 들려주는 시드 트레인 이야기다.

계보를 위해 만들어진 랩 스키마

결과가 담긴 스프레드시트는, 모든 행이 어느 배치어느 시료에 닻을 내리지 않는 한 규제 당국에게는 무가치하다. ISA-88/IEC 61512는 절차적·물리적 계층 구조 — 프로세스 셀(process cell), 유닛(unit), 배치, 로트(lot) — 를 우리에게 주며, 이는 "올바른 배치"라는 말이 무엇을 뜻하는지 정의하고, 시드 트레인이나 앳라인 시료가 올바른 계보에 붙을 수 있게 하는 척추다 [5]. 우리는 그 계층 구조를 3장에서 PostgreSQL로 모델링했다. 랩 계층은 그 위에 결과를 매다는 부분이며, examples/platform/db/30-lab-events.sql에 들어 있다.

-- examples/platform/db/30-lab-events.sql
CREATE TABLE lab.sample (
sample_id text PRIMARY KEY,
batch_id text REFERENCES s88.batch,
sample_time timestamptz NOT NULL,
sample_point text NOT NULL,
sample_type text NOT NULL DEFAULT 'in_process' -- in_process | release | stability
);

CREATE TABLE lab.test (
test_id text PRIMARY KEY,
name text NOT NULL,
unit text,
spec_low numeric,
spec_high numeric
);

CREATE TABLE lab.result (
result_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sample_id text NOT NULL REFERENCES lab.sample,
test_id text REFERENCES lab.test,
value numeric,
text_value text,
unit text,
result_ts timestamptz NOT NULL DEFAULT now(),
analyst text,
instrument_id text,
status text NOT NULL DEFAULT 'preliminary', -- preliminary | verified | rejected
UNIQUE (sample_id, test_id, result_ts)
);
CREATE INDEX ON lab.result (sample_id);

이 작은 스키마는 겉보기보다 많은 컴플라이언스(compliance) 설계를 담고 있다. 세 가지 지점이 무거운 일을 해낸다.

두 개의 타임스탬프, 의도적으로. lab.sample.sample_time시료가 채취된 시각 — 그 값이 증거가 되는 배치 안의 순간 — 이다. lab.result.result_ts결과가 기록된 시각이다. 이들은 서로 다른 사건이며, 때로는 몇 시간 떨어져 있다. PostgreSQL의 timestamp with time zone은 둘 다 절대적인 UTC 시점으로 저장하므로, "채취됨"과 "알게 됨" 사이의 간극이 사라지지 않고 질의 가능해진다 [6]. 그 간극이 바로 PAT 프레임워크가 경고하는 시료-통찰(sample-to-insight) 지연이며, 하나의 열로 구현된 것이다.

**batch_id REFERENCES s88.batch**는 외래 키(foreign key)로 구현된 계보 링크다 — 데이터베이스는 실제 배치를 지칭하지 않는 시료에 대한 결과를 기록하기를 거부한다. 시료-배치 추적성(traceability)은 관례이기를 멈추고, 엔진이 강제하는 불변식(invariant)이 된다.

statusUNIQUE (sample_id, test_id, result_ts) 제약 조건은 거짓말하지 않고 수정하는 방법이다. 예비(preliminary) 결과와 그 이후의 검증된(verified) 값은 두 개의 행이지 덮어쓰기가 아니다 — 바로 다음 절이 필요로 하는 것이다.

여기에 대응하는 CofA(certificate of analysis, 시험성적서)/출하 패널은 같은 시뮬레이터 모듈에서 나온다. release_results() 함수는 현실적인 mAb(monoclonal antibody, 단일클론항체) 규격 범위에 대해 배치당 하나의 시험성적서 행 세트를 내보낸다.

# examples/sim/bioproc_sim/offline_assays.py
# realistic mAb release-assay specs: (name, low, high, unit, target, sd)
_RELEASE_SPECS = [
("SEC_monomer_pct", 95.0, 100.0, "%", 98.5, 0.4),
("SEC_HMW_pct", 0.0, 3.0, "%", 1.1, 0.3),
("SEC_LMW_pct", 0.0, 2.0, "%", 0.4, 0.15),
("CEX_main_pct", 60.0, 80.0, "%", 70.0, 2.0),
("CEX_acidic_pct", 10.0, 30.0, "%", 20.0, 1.8),
("CEX_basic_pct", 5.0, 20.0, "%", 10.0, 1.2),
# ... HCP, residual Protein A, host-cell DNA, endotoxin, bioburden follow
]
>>> release_results().head(6).to_string(index=False)
batch_id test value unit spec_low spec_high result
BATCH-2026-001 SEC_monomer_pct 98.611 % 95.0 100.0 PASS
BATCH-2026-001 SEC_HMW_pct 1.287 % 0.0 3.0 PASS
BATCH-2026-001 SEC_LMW_pct 0.439 % 0.0 2.0 PASS
BATCH-2026-001 CEX_main_pct 70.686 % 60.0 80.0 PASS
BATCH-2026-001 CEX_acidic_pct 21.551 % 10.0 30.0 PASS
BATCH-2026-001 CEX_basic_pct 10.452 % 5.0 20.0 PASS

result 열은 그저 "PASS" if low <= val <= high else "OOS"다 — 실제 출하 결정이 의존하는 바로 그 규격 내(in-spec)/규격 외(out-of-specification) 로직이다. 이 행들은 LIMS/ELN 장(11장), 지식 그래프(knowledge graph, 16장), 그리고 상용 LIMS 브리지(bridge, 19장)로 흘러간다.

시드 트레인 플라스크 아이콘과 벤치 분석기가 하루 두 개의 오프라인 시료를 CSV 파일 드롭으로 보낸다. watchdog 파일 감시기가 그 드롭을 집어 올려 두 개의 타임스탬프 — sample_time과 result_ts — 를 파싱하고, s88.batch 외래 키를 통해 시료를 배치에 연결한 뒤, 예비 행과 검증 행을 lab.sample 및 lab.result 테이블에 기록한다. 이 테이블은 하나의 배치 타임라인 위에서 온라인 히스토리안 트레이스 옆에 나란히 놓인다.

DCS 바깥 포착 경로: 벤치 결과는 파일 드롭으로 도착하여, 시료 시각과 결과 시각 양쪽이 파싱되고, 배치 계보에 연결된 뒤, 덮어쓰지 않고 수정할 수 있는 추가 전용(append-only) 행으로 적재된다. Original diagram by the authors, created with AI assistance.

파일 드롭 인제스팅하기

실제 분석기 — Cedex 계열 세포 계수기, Nova 계열 대사물질 분석기 — 는 CSV나 벤더(vendor) 파일을 감시 폴더로 내보낸다. 우리는 그것이 떨어지는 순간 watchdog 라이브러리를 사용해 잡아챈다. watchdogObserverFileSystemEventHandler 조합은 파일시스템 이벤트에 반응하는 표준적인 파이썬(Python) 방식이다 [7]. 다음은 저장소의 file-ingester 서비스 examples/services/file-ingester/app.py의 핵심이다(DB 헬퍼와 main() 보일러플레이트는 지면을 위해 생략).

# examples/services/file-ingester/app.py (excerpt)
import pandas as pd
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer

# offline CSV column -> (test_id, unit): the preliminary panel the machine captures
INGEST_TESTS = {
"VCD_e6_per_mL": ("VCD", "e6/mL"),
"glucose_g_L": ("Glucose", "g/L"),
"titer_g_L": ("Titer", "g/L"),
}


class OfflineDrop(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory or not event.src_path.endswith(".csv"):
return
ingest(event.src_path)


def ingest(path: str) -> int:
df = pd.read_csv(path, parse_dates=["sample_time"]) # parse, don't guess
df["sample_time"] = df["sample_time"].dt.tz_convert("UTC") # one canonical zone
with psycopg.connect(DSN, autocommit=False) as conn:
for _, row in df.iterrows():
upsert_sample(conn, row.sample_id, row.batch_id,
row.sample_time.to_pydatetime(), row.sample_point)
for col, (test_id, unit) in INGEST_TESTS.items():
insert_result(conn, row.sample_id, test_id, float(row[col]), unit,
analyst="SVC_INGEST", status="preliminary")
conn.commit()

화려하지 않은 줄들이 정작 중요한 줄들이다. parse_dates=["sample_time"]tz_convert("UTC")는 pandas의 시계열 처리에 기대어, 장비가 기록한 어떤 로컬 문자열이든 단일한 타임존 인식(timezone-aware) UTC 시점으로 바꾼다 [8] — 뉴어크(Newark) 작업장의 분석기와 UTC 서버가 언제에 대해 합의해야 하며, 그렇지 않으면 다음 절의 조정은 모래 위에 세워진 셈이기 때문이다. 인제스트된 모든 행은 status="preliminary"로 기록된다. 장비가 값을 포착하지만, 그 값이 출하 결정에 반영되기 전에 사람이 여전히 검증해야 한다.

어려운 부분: 타임스탬프 조정과 지연·수정 결과 처리

이제 진짜로 어렵고 진짜로 규제적인 작업이다. 수동 입력과 지연되거나 수정된 데이터는 고전적인 데이터 무결성 위험 영역이다. FDA의 데이터 무결성 지침은, 당신을 보호하는 것은 원래 값을 기록하는 것, 동시적 타임스탬프를 남기는 것, 그리고 모든 변경에 대해 문서화된 사유를 남기는 것이라고 단호하게 말한다 [9]. MHRA는 여기서 가장 위험에 처한 두 가지 ALCOA 속성을 명시한다. 동시성(Contemporaneous)(활동이 수행되는 시점에 기록할 것)과 원본성(Original)(전사된 요약이 아니라 최초 포착을 보존할 것)이다 [10]. 그리고 PIC/S 데이터 관리 가이드는 생애주기 관점을 제공한다. 수정과 정정은 정상적이고 예상되는 일이지만, 원본이 계속 보이고 변경이 추적 가능하도록 다뤄져야 한다는 것이다 [11].

우리의 스키마는 단 한 번의 덮어쓰기 없이 이 세 가지를 모두 지키도록 만들어졌다. 하나의 오프라인 글루코스 결과가 그 생애를 거치는 모습을 따라가 보자.

-- 1. The sample is pulled at 06:00; that moment is recorded immediately.
INSERT INTO lab.sample (sample_id, batch_id, sample_time, sample_point)
VALUES ('BATCH-2026-001-OFF-003', 'BATCH-2026-001',
'2026-01-06 06:00:00+00', 'BR101');

-- 2. The analyzer reports at 06:25; ingester writes a PRELIMINARY value.
INSERT INTO lab.result (sample_id, test_id, value, unit, result_ts, analyst, status)
VALUES ('BATCH-2026-001-OFF-003', 'Glucose', 6.01, 'g/L',
'2026-01-06 06:25:00+00', 'SVC_INGEST', 'preliminary');

-- 3. The analyst reviews and VERIFIES — a NEW row, not an update.
INSERT INTO lab.result (sample_id, test_id, value, unit, result_ts, analyst, status)
VALUES ('BATCH-2026-001-OFF-003', 'Glucose', 6.01, 'g/L',
'2026-01-06 09:10:00+00', 'a.kowalski', 'verified');

두 행 모두 살아남는다. UNIQUE (sample_id, test_id, result_ts) 제약 조건은 result_ts가 서로 다르기 때문에 둘이 공존하도록 허용하며, 원래의 예비 포착은 결코 파괴되지 않는다 — 원본성과 동시성, 둘 다 보존된다. 진정한 수정(가령 다음 날 잡아낸 전사 오류)도 같은 동작이다. 수정된 값을 담은 새 행, 새 result_ts, 그리고 — 변경 사유 감사 추적(audit trail)과 변조 방지(tamper-evident) 해시 체인(hash chain)을 추가하는 20장에서는 — 기록된 사유와 서명자를 더하는 것이다. 이 장은 추가 전용 뼈대를 만들고, 20장이 근육을 붙인다.

시료 타임스탬프를 온라인 트레이스와 조정하는 일은 이제 깔끔한 조인(join)이 된다. 히스토리안이 같은 batch_id를 지니고, 시료가 자신의 sample_time을 지니기 때문이다.

-- Online DO at (or just after) the moment OFF-003 was pulled.
SELECT s.sample_id, s.sample_time, r.value AS bench_glucose,
(SELECT value FROM ts.sensor_reading t
WHERE t.batch_id = s.batch_id AND t.tag = 'BR101.DO.PV'
AND t.ts >= s.sample_time
ORDER BY t.ts LIMIT 1) AS online_do_at_sample
FROM lab.sample s
JOIN lab.result r ON r.sample_id = s.sample_id AND r.test_id = 'Glucose'
WHERE s.sample_id = 'BATCH-2026-001-OFF-003' AND r.status = 'verified';
sample_id | sample_time | bench_glucose | online_do_at_sample
------------------------+------------------------+---------------+---------------------
BATCH-2026-001-OFF-003 | 2026-01-06 06:00:00+00 | 6.01 | 39.059

06:00에 채취한 벤치 글루코스가 이제 그 동일한 순간의 온라인 용존산소 측정값 옆에 나란히 놓인다. 두 개의 보관 사슬, 하나의 타임라인 — 이것이 오프라인 데이터를 스프레드시트에 고립시켜 두지 않고 제대로 포착하는 일의 전부다.

왜 중요한가

공정 과학자는 온라인 태그가 아니라 오프라인 패널로 세포배양의 방향을 잡는다. 글루코스와 락테이트는 피드 전략을 결정하고, VCD와 생존율은 수확(harvest) 시점을 결정하며, 역가는 캠페인 전체가 평가받는 숫자다. 그런데 이것이 가장 잘못 다뤄지기 쉬운 데이터다. 사람이 손대는 데이터이기 때문이다. 엉뚱한 배치에 입력된 값, 사후에 추측한 시료 시각, 조용히 덮어써진 규격 외 결과 — 이것들이 바로 규제 경고장(warning letter)을 채우는 지적 사항이다. 원본이 보존되고, 두 타임스탬프가 구별되며, 배치 링크가 단단한 외래 키가 되도록 포착 경로를 구축하면, 공장에서 가장 위험한 데이터가 가장 방어 가능한 데이터로 바뀐다.

이는 또한 모든 하위 단계의 그림을 완성한다. 컨텍스트화(contextualization) 뷰(14장)가 온라인 오프라인 데이터를 하나의 배치에 조인할 수 있는 것은 이제 둘 다 batch_id와 신뢰할 수 있는 시각을 지니기 때문이다. 26장의 소프트 센서(soft-sensor) — 라만(Raman)-역가 모델 — 는 오프라인 역가를 학습 라벨로 필요로 한다. 잘못 연결된 시료 하나가 모델을 오염시킨다. 오프라인 데이터는 히스토리안에 대한 각주가 아니다. 그것은 나머지 절반이다.

실제 현장에서는

가동 중인 바이오의약품 제조 공장에서 이 경로는 LIMS나 실험실 실행 시스템이 소유하며, 장비는 흔히 미들웨어(middleware)를 통해 연결되어 원시 파일과 분석가의 검증 단계를 포착한다. ASTM E1578 가이드가 그 지형의 지도이며, 솔직히 말하자면 그 오픈소스 영역은 빈약하다 [4]. 우리는 인제스터와 스키마를 순수 OSS — 파이썬, watchdog(Apache-2.0), pandas(BSD), PostgreSQL — 로 구축할 수 있고, 그것은 노트북에서 결정론적으로 작동한다. 순수 OSS가 건네주지 못하는 것은, 내장된 제2자 검토(second-person review) 워크플로(workflow)와 Part 11 전자 서명(electronic signature)을 기본 제공하는, 검증되고 벤더 책임이 명확한 장비 인터페이스 계층이다. 우리가 나중에 사용하는 OSS LIMS인 SENAITE는 유능한 교육용 시스템이지만, 그 유일하게 공개된 Part-11 갭(gap) 분석은 2019년 것이며 실제 갭(전자 서명, 보존, 패스워드 통제)을 나열한다 — 그래서 이 책은 그 갭 목록을 정직한 한계로 함께 제시하고, 컴플라이언스를 주장하는 대신 SENAITE를 별도의 서명 서비스와 짝지어 둔다. 여기 추가 전용 lab.result 테이블은 OSS로 깔끔한 약 80% — 올바르고, 검사 가능하며, Git 안에 있다. 그 주위를 감싸는 검증된 검토-서명 래퍼(wrapper)가 GxP의 마지막 1마일이며, 그것은 하이브리드다.

이곳이 또한 다중 벤더 시설이 가장 먼저 고통을 느끼는 지점이다. NIIMBL — 미국의 민관 합동 바이오의약품 제조 혁신 연구소(National Institute for Innovation in Manufacturing Biopharmaceuticals) — 은 델라웨어 대학교(University of Delaware)와 함께 파일럿(pilot) 규모 cGMP(current Good Manufacturing Practice, 현행 우수 제조 관리 기준) 시설인 SABRE를 짓고 있으며, 2024년 4월에 착공했다. SABRE 같은 라인 — 여러 스키드(skid)와 여러 벤치 분석기로 꿰매어진 — 은 누군가 운전에 대해 추론할 수 있기 전에 열두 대의 장비에서 나온 오프라인 결과가 모두 하나의 배치와 하나의 시료 시각으로 조정되어야 하는 바로 그런 곳이다. SABRE는 시설이지 데이터 표준이 아니다 — 하지만 그것은 이 DCS 바깥 포착 경로가 봉사하도록 만들어진 종류의 물리적 환경이다.

핵심 용어

  • 시드 트레인(seed train) — 해동한 바이알에서 점점 더 큰 용기를 거쳐 생산 바이오리액터까지 세포를 단계적으로 확장하는 과정. 그 데이터의 상당 부분이 수기로 기록된다.
  • 오프라인/앳라인/온라인/인라인(off-line / at-line / on-line / in-line) — 값을 얻는 위치와 속도에 따른 PAT 측정 분류 체계. 오프라인(시료를 별도 실험실로), 앳라인(시료를 몇 분 내 근처 분석기로), 온라인(루프 안의 센서), 인라인(배양액 안의 프로브).
  • VCD(viable cell density, 생존세포밀도) — mL당 살아 있는 세포 수. 수동 또는 자동 계수로 측정하는 주요 오프라인 세포배양 측정값.
  • 대사물질 패널(metabolite panel) — 글루코스, 락테이트, 글루타민, 암모늄, 삼투압. 피드와 수확 결정을 좌우하는 오프라인 분석물질들.
  • 역가(titer) — 축적된 제품(항체) 농도. 캠페인이 평가받는 오프라인 결과이자 소프트 센서의 학습 라벨.
  • sample_timeresult_ts — 시료가 채취된 순간 대 그 결과가 기록된 순간. 서로 다른 사건이므로 별개의 timestamptz 열로 저장된다.
  • 예비/검증 결과(preliminary / verified result) — 장비가 보고한 값과 사람이 확인한 값을 원본을 덮어쓰지 않고 포착하는 두 행 패턴.
  • 시료-배치 계보(sample-to-batch genealogy) — 각 랩 결과를 그것이 특징짓는 특정 배치와 로트에 묶는 ISA-88 기반 연결(batch_id 외래 키).
  • CofA(certificate of analysis, 시험성적서) — 규격에 대해 PASS/OOS로 판정되는 출하 분석 행 세트(SEC, CEX, HCP, 잔류 Protein A, 엔도톡신).
  • ALCOA+(원본성, 동시성)(Original, Contemporaneous) — 수동·지연 결과에서 가장 위험에 처한 데이터 무결성 속성. 최초 포착을 보존하고, 시점에 맞춰 기록하라.

다음 이야기

우리는 이제 상류(upstream) 진실의 두 절반 — 스트리밍 DCS 태그와 성기고 사람 손을 탄 오프라인 패널 — 을 모두 포착하여 하나의 배치 타임라인에 묶었다. 하지만 모든 신호가 현대적인 프로토콜이나 깔끔한 CSV로 도착하는 것은 아니다. 다음 장 레거시 및 상용 스키드 연결하기: Modbus, Siemens S7, PLC4X는 공장의 가장 오래되고 가장 고집스러운 계층으로 내려가, 데이터가 레지스터 맵(register map)과 독점 PLC 프로토콜 뒤에 숨어 있는 곳에서, 그것을 오픈소스 드라이버로 동일한 히스토리안과 동일한 배치 모델 안으로 끌어들이는 방법을 보여 준다.