pradsa / PRA.py
plokmii's picture
Upload PRA.py with huggingface_hub
4d7523c verified
Raw
History Blame Contribute Delete
39.5 kB
# -*- coding: utf-8 -*-
"""
PRA Class I 分析腳本
解析 Luminex xPONENT CSV 匯出檔,計算 Normal / Ratio / Rxn / PRA%
適用於 One Lambda LABScreen PRA Class I (Lot 21)
用法:
python PRA.py <csv檔案路徑>
python PRA.py <csv檔案路徑> --threshold X4
python PRA.py <csv檔案路徑> --xlsx
"""
import csv
import sys
import os
import io
import math
from pathlib import Path
from collections import OrderedDict
# Windows 終端機中文編碼修正
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
# ============================================================
# 常數定義
# ============================================================
NC_BEAD = '001' # Negative Control bead
PC_BEAD = '002' # Positive Control bead
CONTROL_BEADS = [NC_BEAD, PC_BEAD]
# 預設閾值 (Sero level),可被 CutOff.txt 覆蓋
DEFAULT_CUTOFFS = OrderedDict([
('X2', 50),
('X4', 250),
('X6', 500),
('X8', 1000),
])
def load_cutoffs(cutoff_file=None):
"""
從 CutOff.txt 讀取閾值設定。
格式: 每行 "X2\t50" (tab 分隔)
找不到檔案時使用預設值。
"""
if cutoff_file is None:
cutoff_file = Path(__file__).parent / 'CutOff.txt'
else:
cutoff_file = Path(cutoff_file)
cutoffs = OrderedDict(DEFAULT_CUTOFFS)
if not cutoff_file.exists():
return cutoffs
with open(cutoff_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split('\t')
if len(parts) >= 2 and parts[0].upper().startswith('X'):
key = parts[0].upper()
try:
cutoffs[key] = float(parts[1])
except ValueError:
pass
return cutoffs
CUTOFFS = load_cutoffs()
# ============================================================
# Lot 21 — LABScreen PRA Class I Bead-to-HLA Mapping
# 56 HLA beads: 003-036, 069-089, 095
# ============================================================
BEAD_HLA_LOT21 = OrderedDict([
('003', {'sero': 'A2, B46, Bw6, Cw1',
'allele': 'A*02:01, A*02:07, B*46:01, C*01:02'}),
('004', {'sero': 'A1, A23, B49, B55, Bw4, Bw6, Cw9, Cw7',
'allele': 'A*01:01, A*23:01, B*49:01, B*55:01, C*03:03, C*07:01'}),
('005', {'sero': 'A11, A24, B27, B60, Bw4, Bw6, Cw10',
'allele': 'A*11:02, A*24:02, B*27:06, B*40:01, C*03:04'}),
('006', {'sero': 'A11, A24, B54, B59, Bw6, Bw4, Cw1',
'allele': 'A*11:01, A*24:02, B*54:01, B*59:01, C*01:02'}),
('007', {'sero': 'A11, A24, B60, B59, Bw6, Bw4, Cw1, Cw10',
'allele': 'A*11:01, A*24:02, B*40:01, B*59:01, C*01:02, C*03:04'}),
('008', {'sero': 'A1, A11, B47, B51, Bw4, Cw9, Cw6',
'allele': 'A*01:01, A*11:01, B*47:01, B*51:01, C*03:03, C*06:02'}),
('009', {'sero': 'A2, B44, B51, Bw4, Cw7, Cw14',
'allele': 'A*02:01, B*44:02, B*51:01, C*07:674, C*14:02'}),
('010', {'sero': 'A1, A2, B7, B13, Bw6, Bw4, Cw6, Cw7',
'allele': 'A*01:01, A*02:01, B*07:02, B*13:02, C*06:02, C*07:02'}),
('011', {'sero': 'A2, A11, B76, B51, Bw6, Bw4, Cw10, Cw14',
'allele': 'A*02:06, A*11:01, B*15:12, B*51:01, C*03:04, C*14:02'}),
('012', {'sero': 'A2, A24, B54, B67, Bw6, Cw1, Cw7',
'allele': 'A*02:06, A*24:02, B*54:01, B*67:01, C*01:02, C*07:02'}),
('013', {'sero': 'A11, A24, B62, B35, Bw6, Cw9, Cw4',
'allele': 'A*11:01, A*24:02, B*15:01, B*35:01, C*03:03, C*04:01'}),
('014', {'sero': 'A25, A36, B7, B44, Bw6, Bw4, Cw5, Cw7',
'allele': 'A*25:01, A*36:01, B*07:02, B*44:02, C*05:01, C*07:02'}),
('015', {'sero': 'A3, A32, B61, B47, Bw6, Bw4, Cw2, Cw6',
'allele': 'A*03:01, A*32:01, B*40:02, B*47:01, C*02:02, C*06:02'}),
('016', {'sero': 'A11, A30, B13, B75, Bw4, Bw6, Cw6, Cw8',
'allele': 'A*11:01, A*30:01, B*13:02, B*15:02, C*06:02, C*08:01'}),
('017', {'sero': 'A2, A29, B64, B57, Bw6, Bw4, Cw8, Cw18',
'allele': 'A*02:01, A*29:02, B*14:01, B*57:02, C*08:02, C*18:02'}),
('018', {'sero': 'A2, A80, B18, B27, Bw6, Bw4, Cw2',
'allele': 'A*02:06, A*80:01, B*18:01, B*27:05, C*02:02'}),
('019', {'sero': 'A2, A31, B39, B48, Bw6, Cw7, Cw8',
'allele': 'A*02:06, A*31:01, B*39:01, B*48:01, C*07:02, C*08:22'}),
('020', {'sero': 'A25, A30, B42, B44, Bw6, Bw4, Cw16, Cw17',
'allele': 'A*25:01, A*30:01, B*42:01, B*44:03, C*16:01, C*17:01'}),
('021', {'sero': 'A2, A66, B65, B63, Bw6, Bw4, Cw8, Cw16',
'allele': 'A*02:01, A*66:01, B*14:02, B*15:16, C*08:02, C*16:01'}),
('022', {'sero': 'A24, A29, B7, B65, Bw6, Cw7, Cw8',
'allele': 'A*24:02, A*29:02, B*07:02, B*14:02, C*07:02, C*08:02'}),
('023', {'sero': 'A2, A31, B62, B67, Bw6, Cw7',
'allele': 'A*02:01, A*31:01, B*15:01, B*67:01, C*07:02'}),
('024', {'sero': 'A2, B52, B73, Bw4, Bw6, Cw7, Cw15',
'allele': 'A*02:01, B*52:01, B*73:01, C*07:01, C*15:05'}),
('025', {'sero': 'A2, A11, B75, B38, Bw6, Bw4, Cw7, Cw8',
'allele': 'A*02:03, A*11:01, B*15:02, B*38:02, C*07:02, C*08:147'}),
('026', {'sero': 'A23, A32, B41, B44, Bw6, Bw4, Cw4, Cw17',
'allele': 'A*23:01, A*32:01, B*41:02, B*44:03, C*04:01, C*17:03'}),
('027', {'sero': 'A33, A68, B37, B58, Bw4, Cw2, Cw7',
'allele': 'A*33:03, A*68:02, B*37:01, B*58:01, C*02:10, C*07:18'}),
('028', {'sero': 'A24, A30, B37, B48, Bw4, Bw6, Cw6, Cw8',
'allele': 'A*24:02, A*30:02, B*37:01, B*48:01, C*06:02, C*08:01'}),
('029', {'sero': 'A25, A68, B65, B58, Bw6, Bw4, Cw7, Cw8',
'allele': 'A*25:01, A*68:01, B*14:02, B*58:01, C*07:18, C*08:02'}),
('030', {'sero': 'A26, A30, B18, B60, Bw6, Cw10, Cw5',
'allele': 'A*26:01, A*30:02, B*18:01, B*40:01, C*03:04, C*05:01'}),
('031', {'sero': 'A34, A66, B8, B35, Bw6, Cw7, Cw16',
'allele': 'A*34:02, A*66:03, B*08:01, B*35:01, C*07:01, C*16:01'}),
('032', {'sero': 'A29, A69, B39, B55, Bw6, Cw1, Cw7',
'allele': 'A*29:02, A*69:01, B*39:06, B*55:01, C*01:02, C*07:02'}),
('033', {'sero': 'A3, A23, B41, B49, Bw6, Bw4, Cw7, Cw8',
'allele': 'A*03:01, A*23:01, B*41:01, B*49:01, C*07:01, C*08:02'}),
('034', {'sero': 'A2, A68, B50, B53, Bw6, Bw4, Cw4, Cw6',
'allele': 'A*02:02, A*68:01, B*50:01, B*53:01, C*04:01, C*06:02'}),
('035', {'sero': 'A1, A29, B8, B45, Bw6, Cw6, Cw7',
'allele': 'A*01:01, A*29:02, B*08:01, B*45:01, C*06:02, C*07:01'}),
('036', {'sero': 'A3, A66, B7, B81, Bw6, Cw15, Cw18',
'allele': 'A*03:01, A*66:01, B*07:02, B*81:01, C*15:05, C*18:01'}),
('069', {'sero': 'A3, A26, B27, B57, Bw4, Cw6, Cw7',
'allele': 'A*03:01, A*26:01, B*27:05, B*57:01, C*06:02, C*07:04'}),
('070', {'sero': 'A30, A36, B71, B35, Bw6, Cw10, Cw4',
'allele': 'A*30:01, A*36:01, B*15:10, B*35:01, C*03:04, C*04:01'}),
('071', {'sero': 'A2, B61, B50, Bw6, Cw10, Cw6',
'allele': 'A*02:01, B*40:02, B*50:01, C*03:04, C*06:02'}),
('072', {'sero': 'A24, A32, B64, B18, Bw6, Cw7, Cw8',
'allele': 'A*24:02, A*32:01, B*14:01, B*18:01, C*07:01, C*08:02'}),
('073', {'sero': 'A2, B46, Bw6, Cw1, Cw8',
'allele': 'A*02:06, A*02:07, B*46:01, C*01:02, C*08:01'}),
('074', {'sero': 'A33, A36, B63, B53, Bw4, Cw4, Cw14',
'allele': 'A*33:03, A*36:01, B*15:16, B*53:01, C*04:01, C*14:02'}),
('075', {'sero': 'A33, A74, B63, B42, Bw4, Bw6, Cw14, Cw17',
'allele': 'A*33:03, A*74:01, B*15:16, B*42:01, C*14:02, C*17:01'}),
('076', {'sero': 'A34, B61, B56, Bw6, Cw1, Cw15',
'allele': 'A*34:01, B*40:02, B*56:01, C*01:02, C*15:02'}),
('077', {'sero': 'A1, A2, B57, B82, Bw4, Bw6, Cw10, Cw6',
'allele': 'A*01:01, A*02:01, B*57:01, B*82:01, C*03:02, C*06:02'}),
('078', {'sero': 'A11, A34, B62, B61, Bw6, Cw7, Cw15',
'allele': 'A*11:01, A*34:01, B*15:35, B*40:02, C*07:02, C*15:02'}),
('079', {'sero': 'A1, A29, B13, B81, Bw4, Bw6, Cw6, Cw18',
'allele': 'A*01:01, A*29:02, B*13:02, B*81:01, C*06:02, C*18:01'}),
('080', {'sero': 'A1, A30, B71, B42, Bw6, Cw10, Cw17',
'allele': 'A*01:01, A*30:01, B*15:10, B*42:01, C*03:04, C*17:01'}),
('081', {'sero': 'A26, A34, B75, B38, Bw6, Bw4, Cw4, Cw7',
'allele': 'A*26:01, A*34:01, B*15:21, B*38:02, C*04:03, C*07:02'}),
('082', {'sero': 'A1, A69, B8, B55, Bw6, Cw1, Cw7',
'allele': 'A*01:01, A*69:01, B*08:01, B*55:01, C*01:02, C*07:01'}),
('083', {'sero': 'A23, A80, B8, B18, Bw6, Cw2, Cw10',
'allele': 'A*23:01, A*80:01, B*08:01, B*18:01, C*02:02, C*03:04'}),
('084', {'sero': 'A3, A66, B72, B52, Bw6, Bw4, Cw2, Cw12',
'allele': 'A*03:01, A*66:01, B*15:03, B*52:01, C*02:10, C*12:02'}),
('085', {'sero': 'A3, B35, B56, Bw6, Cw1, Cw4',
'allele': 'A*03:01, B*35:01, B*56:01, C*01:02, C*04:01'}),
('086', {'sero': 'A11, A26, B64, B38, Bw6, Bw4, Cw8, Cw12',
'allele': 'A*11:01, A*26:01, B*14:01, B*38:01, C*08:02, C*12:03'}),
('087', {'sero': 'A33, A74, B72, B78, Bw6, Cw2, Cw16',
'allele': 'A*33:01, A*74:01, B*15:03, B*78:01, C*02:10, C*16:01'}),
('088', {'sero': 'A23, A24, B77, B45, Bw4, Bw6, Cw6, Cw8',
'allele': 'A*23:01, A*24:02, B*15:13, B*45:01, C*06:02, C*08:01'}),
('089', {'sero': 'A29, A68, B52, B78, Bw4, Bw6, Cw16',
'allele': 'A*29:02, A*68:02, B*52:01, B*78:01, C*16:01'}),
('095', {'sero': 'A68, A74, B72, B45, Bw6, Cw2, Cw16',
'allele': 'A*68:02, A*74:01, B*15:03, B*45:01, C*02:10, C*16:01'}),
])
HLA_BEADS = list(BEAD_HLA_LOT21.keys())
def _build_allele_to_sero_map():
"""從 bead 資料建立 allele→sero 對照表 (e.g. B*15:01→B62, C*03:02→Cw10)"""
mapping = {}
for bid, hla in BEAD_HLA_LOT21.items():
sero_parts = [s.strip() for s in hla['sero'].split(',')]
allele_parts = [a.strip() for a in hla['allele'].split(',')]
# 按 locus 分類 sero (排除 Bw4/Bw6/空白)
sero_a = [s for s in sero_parts if s and s.startswith('A')]
sero_b = [s for s in sero_parts if s and s.startswith('B') and s not in ('Bw4', 'Bw6')]
sero_c = [s for s in sero_parts if s and (s.startswith('Cw') or s.startswith('C'))]
# 按 locus 分類 allele (排除 - 和空白)
allele_a = [a for a in allele_parts if a and a.startswith('A') and a != '-']
allele_b = [a for a in allele_parts if a and a.startswith('B') and a != '-']
allele_c = [a for a in allele_parts if a and a.startswith('C') and a != '-']
for i, ag in enumerate(allele_a):
if i < len(sero_a):
mapping[ag] = sero_a[i]
for i, ag in enumerate(allele_b):
if i < len(sero_b):
mapping[ag] = sero_b[i]
for i, ag in enumerate(allele_c):
if i < len(sero_c):
mapping[ag] = sero_c[i]
return mapping
ALLELE_TO_SERO = _build_allele_to_sero_map()
# ============================================================
# CSV 解析
# ============================================================
def parse_xponent_csv(filepath):
"""
解析 xPONENT 匯出的多區塊 CSV 檔案。
回傳 (metadata_dict, data_blocks_dict)
data_blocks 的 key 為 DataType 名稱 (如 'Median', 'Net MFI', 'Count' 等),
value 為 dict: {sample_name: {bead_id: float_value}}
"""
with open(filepath, 'r', encoding='utf-8-sig') as f:
raw = f.read()
# 處理 Windows 換行
lines = raw.replace('\r\n', '\n').replace('\r', '\n').split('\n')
metadata = {}
data_blocks = {}
bead_ids = []
i = 0
# 解析 header metadata
while i < len(lines):
line = lines[i].strip()
if line.startswith('"Results"'):
i += 1
break
# 解析 key-value metadata
parts = _parse_csv_line(line)
if len(parts) >= 2:
metadata[parts[0]] = parts[1:]
i += 1
# 解析 Results 區塊
current_datatype = None
current_header = None
while i < len(lines):
line = lines[i].strip()
i += 1
if not line:
continue
parts = _parse_csv_line(line)
if not parts:
continue
# 檢測 DataType 標記
if parts[0] == 'DataType:':
current_datatype = parts[1] if len(parts) > 1 else None
data_blocks[current_datatype] = {}
current_header = None
continue
# 偵測 header 行 (含 bead ID)
if current_datatype and current_header is None:
if parts[0] in ('Location', 'Sample'):
current_header = parts
if not bead_ids:
# 取得 bead ID 列表
if parts[0] == 'Location':
bead_ids = parts[2:] # 跳過 Location, Sample
else:
bead_ids = parts[1:] # 跳過 Sample
# 移除 "Total Events"
if bead_ids and bead_ids[-1] == 'Total Events':
bead_ids = bead_ids[:-1]
continue
# 資料行
if current_datatype and current_header:
if current_header[0] == 'Location':
if len(parts) < 3:
continue
sample_name = parts[1]
values = parts[2:]
else:
if len(parts) < 2:
continue
sample_name = parts[0]
values = parts[1:]
# 移除 Total Events (最後一個值)
if len(values) > len(bead_ids):
values = values[:len(bead_ids)]
sample_data = {}
for j, bid in enumerate(bead_ids):
if j < len(values):
try:
sample_data[bid] = float(values[j])
except (ValueError, TypeError):
sample_data[bid] = 0.0
data_blocks[current_datatype][sample_name] = sample_data
return metadata, bead_ids, data_blocks
def _parse_csv_line(line):
"""解析 CSV 行,處理引號欄位"""
if not line:
return []
try:
reader = csv.reader([line])
return next(reader)
except Exception:
return []
# ============================================================
# 分析計算
# ============================================================
def get_rxn(normal):
"""根據 Normal 值判定 Rxn 等級"""
if normal >= CUTOFFS['X8']:
return 8
elif normal >= CUTOFFS['X6']:
return 6
elif normal >= CUTOFFS['X4']:
return 4
elif normal >= CUTOFFS['X2']:
return 2
else:
return 1
def analyze_sample(sample_medians, nc_medians, bead_ids):
"""
分析單一病人樣本。
回傳 OrderedDict: bead_id -> {raw, ns_raw, normal, ratio, rxn, count}
"""
sample_nc_bead = sample_medians.get(NC_BEAD, 0)
nc_nc_bead = nc_medians.get(NC_BEAD, 0)
results = OrderedDict()
for bid in bead_ids:
if bid in CONTROL_BEADS:
continue
if bid not in BEAD_HLA_LOT21:
continue
raw = sample_medians.get(bid, 0)
ns_raw = nc_medians.get(bid, 0)
# Normal = max(0, Raw - SampleNC_bead001 - NS_Raw + NSNC_bead001)
normal = max(0.0, raw - sample_nc_bead - ns_raw + nc_nc_bead)
# Ratio = (Raw * NSNC) / (NS_Raw * SampleNC)
denom = ns_raw * sample_nc_bead
if denom > 0:
ratio = (raw * nc_nc_bead) / denom
else:
ratio = 0.0
rxn = get_rxn(normal)
results[bid] = {
'raw': raw,
'ns_raw': ns_raw,
'sample_nc': sample_nc_bead,
'nsnc': nc_nc_bead,
'normal': round(normal, 2),
'ratio': round(ratio, 2),
'rxn': rxn,
}
return results
def calculate_pra(bead_results, threshold='X6'):
"""
計算指定閾值下的 PRA%。
回傳 (pra_percent, positive_count, total_count)
"""
threshold_rxn = int(threshold[1:])
total = len(bead_results)
positive = sum(1 for r in bead_results.values() if r['rxn'] >= threshold_rxn)
pra = round(positive / total * 100) if total > 0 else 0
return pra, positive, total
def get_positive_specificities(bead_results, threshold='X6'):
"""
取得在指定閾值下陽性 bead 的 HLA 特異性 (Sero 及 Allele)。
回傳 (sero_set, allele_set, positive_beads_list)
"""
threshold_rxn = int(threshold[1:])
sero_set = set()
allele_set = set()
positive_beads = []
for bid, r in bead_results.items():
if r['rxn'] >= threshold_rxn:
positive_beads.append(bid)
hla = BEAD_HLA_LOT21.get(bid, {})
if hla.get('sero'):
for ag in hla['sero'].split(','):
ag = ag.strip()
if ag and ag not in ('Bw4', 'Bw6', ''):
sero_set.add(ag)
if hla.get('allele'):
for ag in hla['allele'].split(','):
ag = ag.strip()
if ag and ag != '-' and ag != '':
allele_set.add(ag)
return sero_set, allele_set, positive_beads
def _parse_allele_list(allele_str):
"""解析 allele 字串,回傳有效 allele 的集合。
清除命名後綴: /276N (null allele), =DPB1*105:01 (別名)"""
result = set()
if not allele_str:
return result
for ag in allele_str.split(','):
ag = ag.strip()
if ag and ag != '-':
ag = ag.split('/')[0] # 移除 /276N
ag = ag.split('=')[0] # 移除 =DPB1*105:01
result.add(ag)
return result
def get_confident_alleles(bead_results):
"""
篩選高信度 Allele:只出現在 X6/X8 (Rxn≥6) 的 bead 上,
所有 bead 都必須 Rxn≥6,任何 Rxn<6 (X4/X2/Rxn1) 都排除。
回傳 (confident_alleles, x6x8_alleles, not_x6x8_alleles, detail_list)
detail_list: [(allele, source_beads, max_normal)] 按 max_normal 降序
"""
x6x8_alleles = {} # allele -> [(bead, normal)]
not_x6x8_alleles = {} # allele -> [(bead, normal)] Rxn<6 全部
for bid, r in bead_results.items():
hla = BEAD_HLA_LOT21.get(bid, {})
alleles = _parse_allele_list(hla.get('allele', ''))
if r['rxn'] >= 6:
for ag in alleles:
x6x8_alleles.setdefault(ag, []).append((bid, r['normal']))
else:
for ag in alleles:
not_x6x8_alleles.setdefault(ag, []).append((bid, r['normal']))
# 只在 X6/X8 出現、不在任何 Rxn<6 bead 出現的 allele
confident = set(x6x8_alleles.keys()) - set(not_x6x8_alleles.keys())
# 建立詳細列表,按最大 Normal 降序
detail = []
for ag in confident:
beads = x6x8_alleles[ag]
max_normal = max(n for _, n in beads)
bead_list = [b for b, _ in beads]
detail.append((ag, bead_list, max_normal))
detail.sort(key=lambda x: -x[2])
# 被排除的 allele (在 X6/X8 也在 Rxn<6)
excluded = set(x6x8_alleles.keys()) & set(not_x6x8_alleles.keys())
return confident, excluded, x6x8_alleles, not_x6x8_alleles, detail
def generate_specificity_string(bead_results):
"""
用 confident allele 分析生成 Specificity 字串(模擬 HLA Fusion 報告格式)。
格式: Sero名 或 Sero名(Allele名) ,空白分隔
例如: A11(A*11:02) A23 A24 B7 B62(B*15:01) Cw1
"""
confident, _, _, _, _ = get_confident_alleles(bead_results)
if not confident:
return '(-)'
# 將 confident allele 按 sero group 分組
sero_groups = {} # sero_name -> set of alleles
all_alleles_of_sero = {} # sero_name -> set of ALL alleles on any bead (不分 rxn)
# 先統計每個 sero group 在所有 bead 上有哪些 allele
for bid, hla in BEAD_HLA_LOT21.items():
alleles = _parse_allele_list(hla.get('allele', ''))
for ag in alleles:
sero = ALLELE_TO_SERO.get(ag)
if sero:
all_alleles_of_sero.setdefault(sero, set()).add(ag)
# 再統計 confident allele 的 sero 分組
for ag in confident:
sero = ALLELE_TO_SERO.get(ag)
if sero:
sero_groups.setdefault(sero, set()).add(ag)
# 生成字串
parts = []
for sero in _sort_hla(sero_groups.keys()):
conf_alleles = sero_groups[sero]
all_alleles = all_alleles_of_sero.get(sero, set())
if conf_alleles >= all_alleles:
# 該 sero group 的所有 allele 都是 confident → 只寫 sero 名
parts.append(sero)
else:
# 只有部分 allele 是 confident → 寫 sero(allele1 allele2)
sorted_alleles = sorted(conf_alleles)
parts.append(f'{sero}({" ".join(sorted_alleles)})')
return ' '.join(parts)
def parse_docx_report(docx_path):
"""
解析確認報告 docx,回傳 list of dict:
[{sample_id, overall, pra_percent, specificities_str, specificities_set}, ...]
"""
from docx import Document
doc = Document(docx_path)
results = []
current = None
expect_spec = False
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
if text.startswith('EL'):
# 樣本行: EL20022_G3K6HLE_0027996823 ...
sample_id = text.split('_')[0] + '_' + text.split('_')[-1].split()[0]
current = {'sample_id_prefix': text.split('_')[0],
'full_line': text,
'overall': '', 'pra_percent': 0,
'spec_str': '', 'spec_sero': set()}
results.append(current)
expect_spec = False
elif current and text.startswith('Overall:'):
current['overall'] = text.split(':')[1].strip()
elif current and text.startswith('%SA') or (current and text.startswith('%PRA')):
if ':' in text:
val = text.split(':')[1].strip()
try:
current['pra_percent'] = int(val)
except ValueError:
current['pra_percent'] = 0
elif current and text == 'Specificity:':
expect_spec = True
elif current and expect_spec:
current['spec_str'] = text
expect_spec = False
# 用 regex 解析 sero 名稱 (處理括號內含空白的情況)
if text != '(-)':
import re
current['spec_sero'] = set(re.findall(
r'(?<!\*)(A\d+|B\d+|Cw\d+)', text))
elif current and text == 'COMMENT:':
current = None
return results
def compare_with_docx(docx_path, all_results, threshold='X6'):
"""比對程式分析結果與確認報告"""
docx_results = parse_docx_report(docx_path)
print(f'\n{"="*72}')
print(f' 比對確認報告: {Path(docx_path).name}')
print(f'{"="*72}')
for doc_r in docx_results:
prefix = doc_r['sample_id_prefix']
# 找到對應的分析結果
matched_name = None
matched_br = None
for sname, br in all_results.items():
if sname.startswith(prefix):
matched_name = sname
matched_br = br
break
if not matched_br:
print(f'\n [!] 找不到 {prefix} 對應的分析結果')
continue
# PRA% 和 Overall
pra_computed, _, _ = calculate_pra(matched_br, threshold)
overall_computed = 'Positive' if pra_computed > 0 else 'Negative'
pra_match = '✓' if pra_computed == doc_r['pra_percent'] else '✗'
overall_match = '✓' if overall_computed == doc_r['overall'] else '✗'
print(f'\n ── {matched_name} ──')
print(f' {"":20} {"報告":>12} {"程式":>12} 比對')
print(f' {"Overall":<20} {doc_r["overall"]:>12} {overall_computed:>12} {overall_match}')
print(f' {"PRA%":<20} {doc_r["pra_percent"]:>12} {pra_computed:>12} {pra_match}')
# Specificity 比對
computed_spec_str = generate_specificity_string(matched_br)
# 解析程式產生的 sero set
import re
computed_sero = set()
if computed_spec_str != '(-)':
computed_sero = set(re.findall(
r'(?<!\*)(A\d+|B\d+|Cw\d+)', computed_spec_str))
report_sero = doc_r['spec_sero']
in_both = report_sero & computed_sero
only_report = report_sero - computed_sero
only_program = computed_sero - report_sero
print(f'\n 報告 Specificity:')
print(f' {doc_r["spec_str"]}')
print(f' 程式 Specificity:')
print(f' {computed_spec_str}')
if report_sero or computed_sero:
print(f'\n 吻合 ({len(in_both)}): {" ".join(_sort_hla(in_both)) if in_both else "—"}')
if only_report:
print(f' 僅報告有 ({len(only_report)}): {" ".join(_sort_hla(only_report))}')
if only_program:
print(f' 僅程式有 ({len(only_program)}): {" ".join(_sort_hla(only_program))}')
total_union = len(report_sero | computed_sero)
accuracy = len(in_both) / total_union * 100 if total_union > 0 else 100
print(f' 吻合率: {accuracy:.0f}% ({len(in_both)}/{total_union})')
def _sort_hla(antigens):
"""將 HLA 抗原按 locus (A, B, C) 排序"""
def sort_key(ag):
ag_upper = ag.upper()
if ag_upper.startswith('A'):
return (0, ag)
elif ag_upper.startswith('B'):
return (1, ag)
elif ag_upper.startswith('C'):
return (2, ag)
else:
return (3, ag)
return sorted(antigens, key=sort_key)
# ============================================================
# 輸出
# ============================================================
def find_nc_sample(sample_names):
"""從樣本名稱列表中找出 NC (Negative Control) 樣本"""
for name in sample_names:
if name.upper().startswith('NC') or name.upper().startswith('NC_'):
return name
return None
def print_results(sample_name, bead_results, counts=None, threshold='X6'):
"""印出單一樣本的分析結果"""
# PRA% at all thresholds
print(f'\n{"="*72}')
print(f' 樣本: {sample_name}')
print(f'{"="*72}')
sample_nc_val = next(iter(bead_results.values()))['sample_nc'] if bead_results else 0
nsnc_val = next(iter(bead_results.values()))['nsnc'] if bead_results else 0
print(f' Sample NC (bead 001): {sample_nc_val:.1f} '
f'NSNC (bead 001): {nsnc_val:.1f}')
print(f'\n {"Threshold":<12} {"PRA%":>6} {"Positive":>10} / {"Total":<6}')
print(f' {"-"*40}')
for t in ['X2', 'X4', 'X6', 'X8']:
pra, pos, tot = calculate_pra(bead_results, t)
marker = ' <--' if t == threshold else ''
print(f' {t:<12} {pra:>5}% {pos:>10} / {tot:<6}{marker}')
# Bead detail table
print(f'\n {"Bead":>4} {"Rxn":>4} {"Raw":>10} {"NS Raw":>10} '
f'{"Normal":>10} {"Ratio":>8} {"Count":>6} Specificity')
print(f' {"-"*90}')
threshold_rxn = int(threshold[1:])
for bid, r in bead_results.items():
count_str = ''
if counts and bid in counts:
count_str = f'{counts[bid]:.0f}'
marker = '+' if r['rxn'] >= threshold_rxn else ' '
sero = BEAD_HLA_LOT21.get(bid, {}).get('sero', '')
print(f'{marker} {bid:>4} {r["rxn"]:>4} {r["raw"]:>10.1f} {r["ns_raw"]:>10.1f} '
f'{r["normal"]:>10.2f} {r["ratio"]:>8.2f} {count_str:>6} {sero}')
# Positive specificities
sero_set, allele_set, pos_beads = get_positive_specificities(bead_results, threshold)
pra, pos_count, total = calculate_pra(bead_results, threshold)
print(f'\n 判定 (Threshold {threshold}): ', end='')
if pra > 0:
print(f'Positive | PRA% = {pra}%')
else:
print(f'Negative | PRA% = 0%')
if pos_beads:
print(f' 陽性 Beads: {", ".join(pos_beads)}')
# Confident Allele 分析:只在 X6/X8,不在 X2/X4
confident, excluded, x6x8_ag, x2x4_ag, detail = get_confident_alleles(bead_results)
if detail:
print(f'\n ── Confident Alleles (只在 X6/X8,不在 X2/X4) ──')
sorted_confident = _sort_hla(confident)
print(f' {", ".join(sorted_confident)}')
print(f'\n {"Allele":<16} {"Max Normal":>10} 來源 Beads')
print(f' {"-"*50}')
for ag, beads, max_n in detail:
print(f' {ag:<16} {max_n:>10.1f} {", ".join(beads)}')
if excluded:
sorted_excluded = _sort_hla(excluded)
print(f'\n ── 排除 (同時出現在 X6/X8 和 X2/X4) ──')
print(f' {", ".join(sorted_excluded)}')
print(f'\n ※ 正式報告請以 HLA Fusion 軟體 pattern analysis 為準。')
def export_xlsx(filepath, all_results, nc_name, metadata, bead_ids, data_blocks, threshold='X6'):
"""匯出分析結果至 Excel 檔案"""
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
except ImportError:
print('\n [警告] 需要 openpyxl 套件才能匯出 Excel。請執行: pip install openpyxl')
return None
wb = Workbook()
# 樣式
header_font = Font(bold=True)
positive_fill = PatternFill(start_color='FFCCCC', end_color='FFCCCC', fill_type='solid')
header_fill = PatternFill(start_color='CCE5FF', end_color='CCE5FF', fill_type='solid')
thin_border = Border(
left=Side(style='thin'), right=Side(style='thin'),
top=Side(style='thin'), bottom=Side(style='thin')
)
# 取得 Count 資料
count_data = data_blocks.get('Count', {})
# Summary sheet
ws_sum = wb.active
ws_sum.title = 'Summary'
ws_sum.append(['PRA Class I Analysis Summary'])
ws_sum['A1'].font = Font(bold=True, size=14)
batch = metadata.get('Batch', [''])[0] if 'Batch' in metadata else ''
date_val = metadata.get('Date', [''])[0] if 'Date' in metadata else ''
ws_sum.append([])
ws_sum.append(['Batch:', batch])
ws_sum.append(['Date:', date_val])
ws_sum.append(['NC Sample:', nc_name])
ws_sum.append(['Threshold:', threshold])
ws_sum.append([])
# Summary table
sum_headers = ['Sample', 'PRA% (X2)', 'PRA% (X4)', 'PRA% (X6)', 'PRA% (X8)',
'Result', 'Sample NC', 'PC Signal']
ws_sum.append(sum_headers)
for c_idx, _ in enumerate(sum_headers, 1):
cell = ws_sum.cell(row=ws_sum.max_row, column=c_idx)
cell.font = header_font
cell.fill = header_fill
cell.border = thin_border
median_data = data_blocks.get('Median', {})
for sample_name, bead_results in all_results.items():
pra2, _, _ = calculate_pra(bead_results, 'X2')
pra4, _, _ = calculate_pra(bead_results, 'X4')
pra6, _, _ = calculate_pra(bead_results, 'X6')
pra8, _, _ = calculate_pra(bead_results, 'X8')
pra_main, _, _ = calculate_pra(bead_results, threshold)
result = 'Positive' if pra_main > 0 else 'Negative'
sample_nc_val = median_data.get(sample_name, {}).get(NC_BEAD, 0)
pc_val = median_data.get(sample_name, {}).get(PC_BEAD, 0)
row = [sample_name, pra2, pra4, pra6, pra8, result,
round(sample_nc_val, 1), round(pc_val, 1)]
ws_sum.append(row)
r = ws_sum.max_row
for c_idx in range(1, len(row) + 1):
cell = ws_sum.cell(row=r, column=c_idx)
cell.border = thin_border
if result == 'Positive':
cell.fill = positive_fill
# 設定欄寬
ws_sum.column_dimensions['A'].width = 25
for col_letter in ['B', 'C', 'D', 'E']:
ws_sum.column_dimensions[col_letter].width = 12
ws_sum.column_dimensions['F'].width = 12
ws_sum.column_dimensions['G'].width = 12
ws_sum.column_dimensions['H'].width = 12
# Per-patient sheets
threshold_rxn = int(threshold[1:])
for sample_name, bead_results in all_results.items():
# Sheet name 限制 31 字元
sheet_name = sample_name[:31]
ws = wb.create_sheet(title=sheet_name)
ws.append([sample_name])
ws['A1'].font = Font(bold=True, size=12)
ws.append([])
pra_main, pos_count, total = calculate_pra(bead_results, threshold)
result = 'Positive' if pra_main > 0 else 'Negative'
ws.append(['Result:', result, '', 'PRA%:', pra_main, '', 'Threshold:', threshold])
ws.append([])
# Header
headers = ['BeadID', 'Rxn', 'Raw', 'NS Raw', 'Sample NC', 'NSNC',
'Normal', 'Ratio', 'Count', 'Specificity', 'Allele Specificity']
ws.append(headers)
for c_idx, h in enumerate(headers, 1):
cell = ws.cell(row=ws.max_row, column=c_idx)
cell.font = header_font
cell.fill = header_fill
cell.border = thin_border
# Bead data
sample_counts = count_data.get(sample_name, {})
for bid, r in bead_results.items():
hla = BEAD_HLA_LOT21.get(bid, {})
count_val = sample_counts.get(bid, '')
if count_val:
count_val = int(count_val)
row = [bid, r['rxn'], round(r['raw'], 2), round(r['ns_raw'], 2),
round(r['sample_nc'], 2), round(r['nsnc'], 2),
r['normal'], r['ratio'], count_val,
hla.get('sero', ''), hla.get('allele', '')]
ws.append(row)
row_num = ws.max_row
for c_idx in range(1, len(row) + 1):
cell = ws.cell(row=row_num, column=c_idx)
cell.border = thin_border
if r['rxn'] >= threshold_rxn:
for c_idx in range(1, len(row) + 1):
ws.cell(row=row_num, column=c_idx).fill = positive_fill
# Control beads
ws.append([])
sample_med = median_data.get(sample_name, {})
nc_med = median_data.get(nc_name, {})
nc_bead_val = sample_med.get(NC_BEAD, 0)
pc_bead_val = sample_med.get(PC_BEAD, 0)
nc_nc_val = nc_med.get(NC_BEAD, 0)
pc_nc_val = nc_med.get(PC_BEAD, 0)
ws.append([NC_BEAD, 'NC', round(nc_bead_val, 2), round(nc_nc_val, 2)])
ws.append([PC_BEAD, 'PC', round(pc_bead_val, 2), round(pc_nc_val, 2)])
# Positive specificities
sero_set, allele_set, pos_beads = get_positive_specificities(bead_results, threshold)
ws.append([])
if sero_set:
ws.append(['Candidate Antibodies (Sero):', ', '.join(_sort_hla(sero_set))])
if allele_set:
ws.append(['Candidate Antibodies (Allele):', ', '.join(_sort_hla(allele_set))])
ws.append(['Positive Beads:', ', '.join(pos_beads)])
# 設定欄寬
col_widths = {'A': 8, 'B': 6, 'C': 12, 'D': 12, 'E': 12, 'F': 10,
'G': 12, 'H': 10, 'I': 8, 'J': 35, 'K': 45}
for col_letter, w in col_widths.items():
ws.column_dimensions[col_letter].width = w
wb.save(filepath)
return filepath
# ============================================================
# 主程式
# ============================================================
def main():
import argparse
parser = argparse.ArgumentParser(
description='PRA Class I 分析 — 解析 xPONENT CSV 並計算 PRA%')
parser.add_argument('csv_path', help='xPONENT CSV 檔案路徑 (PRA1)')
parser.add_argument('--threshold', default='X6', choices=['X2', 'X4', 'X6', 'X8'],
help='判定閾值 (預設: X6)')
parser.add_argument('--xlsx', action='store_true',
help='匯出 Excel 結果檔')
parser.add_argument('--nc', default=None,
help='指定 NC 樣本名稱 (預設: 自動偵測)')
parser.add_argument('--compare', default=None,
help='與確認報告 docx 比對 (指定 docx 路徑)')
args = parser.parse_args()
csv_path = Path(args.csv_path)
if not csv_path.exists():
print(f'[錯誤] 找不到檔案: {csv_path}')
sys.exit(1)
# 解析 CSV
print(f'解析 CSV: {csv_path.name}')
metadata, bead_ids, data_blocks = parse_xponent_csv(str(csv_path))
# 取得 Median 資料
median_data = data_blocks.get('Median')
if not median_data:
# 嘗試 Avg Net MFI
median_data = data_blocks.get('Avg Net MFI')
if not median_data:
print('[錯誤] CSV 中找不到 Median 或 Avg Net MFI 資料區塊')
sys.exit(1)
# 偵測 NC 樣本
sample_names = list(median_data.keys())
nc_name = args.nc or find_nc_sample(sample_names)
if not nc_name or nc_name not in median_data:
print(f'[錯誤] 無法找到 NC 樣本。可用樣本: {sample_names}')
print(' 請使用 --nc 參數指定 NC 樣本名稱')
sys.exit(1)
nc_medians = median_data[nc_name]
# 取得 Count 資料
count_data = data_blocks.get('Count', {})
# 顯示基本資訊
batch = metadata.get('Batch', [''])[0] if 'Batch' in metadata else ''
protocol = metadata.get('ProtocolName', [''])[0] if 'ProtocolName' in metadata else ''
date_val = metadata.get('Date', [''])[0] if 'Date' in metadata else ''
print(f'Batch: {batch}')
print(f'Protocol: {protocol}')
print(f'Date: {date_val}')
print(f'NC 樣本: {nc_name}')
print(f'樣本數: {len(sample_names)} (含 NC)')
pc_nc = nc_medians.get(PC_BEAD, 0)
nc_nc = nc_medians.get(NC_BEAD, 0)
if nc_nc > 0:
print(f'NC QC — PC: {pc_nc:.0f}, NC: {nc_nc:.0f}, PC/NC: {pc_nc/nc_nc:.1f}')
# 分析每個病人樣本
patient_samples = [s for s in sample_names if s != nc_name]
all_results = OrderedDict()
for sample_name in patient_samples:
sample_medians = median_data[sample_name]
bead_results = analyze_sample(sample_medians, nc_medians, bead_ids)
all_results[sample_name] = bead_results
counts = count_data.get(sample_name, {})
print_results(sample_name, bead_results, counts, args.threshold)
# 比對確認報告
if args.compare:
compare_path = Path(args.compare)
if compare_path.exists():
compare_with_docx(str(compare_path), all_results, args.threshold)
else:
print(f'\n[警告] 找不到比對檔案: {compare_path}')
# 匯出 Excel
if args.xlsx:
xlsx_name = csv_path.stem + '_PRA1_results.xlsx'
xlsx_path = csv_path.parent / xlsx_name
result_path = export_xlsx(
str(xlsx_path), all_results, nc_name, metadata,
bead_ids, data_blocks, args.threshold)
if result_path:
print(f'\n已匯出 Excel: {xlsx_name}')
if __name__ == '__main__':
main()