# -*- coding: utf-8 -*- """ PRA Class I 分析腳本 解析 Luminex xPONENT CSV 匯出檔,計算 Normal / Ratio / Rxn / PRA% 適用於 One Lambda LABScreen PRA Class I (Lot 21) 用法: python PRA.py python PRA.py --threshold X4 python PRA.py --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'(? 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'(? 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()