
色谱工作站跑完一批样,导出一堆结果文件;要进 LIMS,第一步就卡在「这个文件、这一行是谁的样品」。文件名有的带样品号,有的只有时间戳,样品号藏在「进样瓶位」列里,还得对着台账一条条翻。一天几十个文件,手工对下来又慢又容易错。
为了解决这个问题,Python 提供了 pandas 和 openpyxl 这套组合:读文件、按列名取数、按表归堆、写出 Excel。这里不碰 LIMS 接口,也不碰仪器协议——只解决最前面那一步:把仪器结果文件按样品号归好堆、对不上的挑出来、每一步留痕,得到一份可入库清单。
先想清楚:一份结果文件怎么认到样品号
LIMS 里的样品有自己的编号(如 0000182),可仪器吐出来的结果文件里往往没有,只有进样瓶位、文件序号、仪器内部名。对接的第一步不是连接口,是「把仪器的东西翻译成实验室的样品号」。 认号有两条路,优先级不能颠倒:
| 1 | 文件名带样品号 | 老仪器命名规范,如 RESULT_0000190_*.csv | 退回第 2 条 |
| 2 | 按「进样瓶位」反查对照表 | 文件名只有时间戳,样品号在文件里 | 整行挂起,不猜 |
对照表是外置 CSV,把「仪器标识 ↔ 实验室样品号」写在一处,仪器换了、编号规则改了改表不改代码。
第一步:配置路径与字段
把所有会变的东西——哪些是数据文件、哪张是对照表、哪些列要——提到文件顶部当常量。
出处:run.py 顶部的路径与字段常量区
BASE = Path(__file__).resolve().parent
RAW_DIR = BASE / "01_raw_data"
OUT_DIR = BASE / "02_output"
SRC_DIR = BASE / "source"
TPL_DIR = SRC_DIR / "templates"
MAP_FILE = TPL_DIR / "样品对照表.csv"
LOG_FILE = SRC_DIR / "run_log.txt"
DATA_SUFFIX = {".csv", ".xlsx", ".xls"}
ID_COL = "进样瓶位" # 仪器标识所在的列(文件名认不出来时靠它反查样品号)
FILE_ID_RE = re.compile(r"(\\d{6,})") # 文件名里 6 位以上的数字段,视为候选样品号
OUT_NAME = "仪器结果归堆表.xlsx"
NUM_COLS = ["保留时间", "峰面积", "峰高", "结果", "含量(%)"]
路径一律用 Path(__file__).resolve().parent 起头,不写绝对路径。DATA_SUFFIX 把非数据文件(如说明 txt)挡在外面;同列不同仪器写法会飘(进样瓶位 / 瓶位 / Vial),别名全收在 pick_columns() 里管。
第二步:读数据,把样品号认出来
原始表不做任何解释,先原样读成二维表;认号、切行放到后面单独做,出错时好定位是「读错」还是「认错」。 出处:run.py 的 load_sample_map() 与 sample_no_from_name()(文件前中部)
def load_sample_map(path):
"""读样品对照表,返回 (仪器标识 → 样品号, 仪器标识 → 检测项目)。
对照表是业务资产:写在表里、不写死在代码里——仪器换了、编号规则改了,改表不改代码。
"""
with path.open(encoding="utf-8-sig", newline="") as f:
rows = [r for r in csv.reader(f) if any(str(c).strip() for c in r)]
id2no, id2item = {}, {}
for r in rows[1:]:
ident = str(r[0]).strip()
no = str(r[1]).strip() if len(r) > 1 else ""
if not ident or not no:
continue
id2no[norm(ident)] = no
id2item[norm(ident)] = str(r[2]).strip() if len(r) > 2 else ""
return id2no, id2item
def sample_no_from_name(fname, id2no):
"""先从文件名里找样品号:文件名里的数字段直接命中对照表的样品号即采用。
文件名认不出来不算错——只是这一条走「按瓶位反查」的路子,不终止解析。
"""
known = set(id2no.values())
for m in FILE_ID_RE.findall(fname):
if m in known:
return m
return ""
sample_no_from_name() 要求文件名里的数字能在对照表里找到才算样品号——文件名里的 20261010 也是 8 位数字却不是样品号,靠这条把日期、批次号一并挡掉。
出处:run.py 的 read_text() 与 read_matrix()(文件中部)
def read_text(path):
"""按 utf-8-sig → gbk → utf-8 依次试;GBK 文件用 utf-8 读会直接抛异常,不是读出乱码。"""
last = None
for enc in ("utf-8-sig", "gbk", "utf-8"):
try:
return path.read_text(encoding=enc), enc
except UnicodeDecodeError as e:
last = e
raise last
编码必须依次试、不能写死 utf-8:老仪器 CSV 常见 GBK,用 utf-8 读会直接抛异常。
出处:run.py 的 find_header() 与 pick_columns()(文件中部)
def find_header(rows):
"""找表头行:第一个「非空单元格 >= 2」的行。
不能写死 header=0——CDS 导出常在表头上压几行导出信息;
也不能靠行号猜,模板改一版行号就全错。
"""
for i, r in enumerate(rows):
if sum(1 for c in r if str(c or "").strip()) >= 2:
return i
return -1
表头不写死 header=0:CDS 导出常在表头压几行信息,也不能靠行号猜(改一版模板行号就全错)。
出处:run.py 的 classify()(文件中部)
def classify(rows, hdr, cols, fname, id2no, id2item, name_no):
"""切数据行,逐行认样品号,返回 (结果行列表, 挂起列表)。
认号优先级:文件名里的样品号 > 瓶位反查。
文件名已经认出来了,这一份文件整批就是同一个样品,逐行不再挑瓶位——
老仪器导出的表干脆没有瓶位列,硬要求它有,整份文件就白白挂起了。
"""
data, suspended = [], []
for k, r in enumerate(rows[hdr + 1:]):
cells = [normalize_value(c) for c in r]
if not any(cells):
continue
pos = k + hdr + 2 # Excel 行号(1 起算,表头占 hdr+1 行)
ident = cell(cells, cols.get(ID_COL)).strip()
no = name_no or id2no.get(norm(ident), "")
if not no:
suspended.append((fname, f"第 {pos} 行", f"样品号匹配不上(瓶位「{ident}」不在对照表)"))
continue
row = {"样品号": no, "检测项目": id2item.get(norm(ident), ""),
"仪器标识": ident or fname, "来源文件": fname}
for name, i in cols.items():
if name in (ID_COL,):
continue
row[name] = cell(cells, i)
data.append(row)
return data, suspended
no = name_no or id2no.get(norm(ident), "") 决定认号优先级:文件名认出来就整批用这个样品号,不再逐行挑瓶位。
踩过的坑:第一版把「必须有 进样瓶位 列」当前置条件,一份文件名带着 0000190 的老文件因表里没瓶位列被整份挂起。认不出样品号才挂起,不是没瓶位列就挂起。
一份文件里可能混着两个样品(一次序列跑完导成一份汇总),归堆按行做。
第三步:拼数据,把重复和异常挑出来
样品号认出来后按样品归堆,同时处理两类异常:同一样品出现多条结果(复检 / 平行样),以及结果值不是纯数字(未检出 / ND / <0.05)。 出处:run.py 的 flag_dupes()(文件中部)
def flag_dupes(data):
"""同一样品号 + 同一检测项目出现多行时标记出来(复检 / 平行样),但不擅自取数。"""
from collections import Counter
counter = Counter((r["样品号"], r.get("组分", "")) for r in data)
for r in data:
key = (r["样品号"], r.get("组分", ""))
if counter[key] > 1:
r["重复标记"] = f"同一样品同一组分 {counter[key]} 条"
else:
r["重复标记"] = ""
return data
同一样品两条结果只打标、不替人挑一条(谁对谁错是业务判断,挑错比不挑更危险):
| 用 Counter 按(样品号 + 组分)计数 | 只在同一检测项内算重复,不同检测项各算各的 |
| 重复只标记、不删 | 复检结果可能要留作历史,删了就没了 |
| 结果值原样保留,不转数字 | 未检出 / <0.05 转成 0 或 NaN 就失真了 |
结果列不做单位换算、不填补缺失、不把「未检出」当 0——这是数据完整性底线:仪器报「未检出」和「结果为 0」是两回事,混一起入库时就分不清了。
出处:run.py 的 collect()(文件中部)
cols = pick_columns(rows[hdr])
name_no = sample_no_from_name(path.name, id2no)
# 两条路都走不通才挂起整份文件:文件名认不出、且表里也没有能反查的瓶位列
if not name_no and ID_COL not in cols:
suspended.append((path.name, "整个文件",
f"文件名认不出样品号,表里也没有「{ID_COL}」列"))
continue
got, susp = classify(rows, hdr, cols, path.name, id2no, id2item, name_no)
trace.append((path.name, enc, hdr + 1, len(got), len(susp),
"文件名认号" if name_no else "按瓶位反查"))
collect() 遍历目录,每个文件走一遍「读 → 找表头 → 认号 → 归堆」;if not name_no and ID_COL not in cols——两条路都不通才挂起整份文件。
第四步:输出,三份表一起落盘
最后写三份:归堆表(可入库清单)、认号留痕(每个文件怎么认的号)、挂起清单(哪些没对上、为什么)。
出处:run.py 的 write_outputs()(文件后中部)
cols = ["样品号", "检测项目", "仪器标识", "组分",
"保留时间", "峰面积", "峰高", "结果", "含量(%)", "重复标记", "来源文件"]
df = pd.DataFrame([{c: r.get(c, "") for c in cols} for r in data], columns=cols)
out = OUT_DIR / OUT_NAME
df.to_excel(out, index=False, sheet_name="归堆结果")
wb = load_workbook(out)
ws = wb["归堆结果"]
for j, name in enumerate(cols, 1): # 文本列设成文本格式,前导零才不会被吃掉
if name in NUM_COLS:
continue
for i in range(2, ws.max_row + 1):
ws.cell(row=i, column=j).number_format = "@"
wb.save(out)
样品号必须按文本格式存,否则 0000182 会被 Excel 吃掉前导零变成 182,跟 LIMS 编号就对不上。做法:pandas 先写出文件,再 load_workbook 打开、把非数值列设成 "@" 后 save,多一次读写换前导零不丢。
出处:run.py 的 main()(文件末尾)
data, trace, suspended, files = collect(id2no, id2item)
data = flag_dupes(data)
print(f"扫描文件 {len(files)} 个 → 归堆 {len(data)} 行 / 挂起 {len(suspended)} 条")
out = write_outputs(data, trace, suspended)
print(f"已生成:{out.name}")
ok = verify(data, trace, suspended)
run.py 还带一层工程外壳:缺依赖自动从清华源装、每次往 source/run_log.txt 追加日志(开头记运行主机与 IP)、生成后自动回读断言打印 OK / FAIL。不影响主线,不展开。
小结
把仪器数据对接进 LIMS,最前面那一步不是连接口,是把仪器吐出来的东西翻译成实验室认得的样品号。这篇的四步解决的就是它:
- 认号有优先级:文件名优先,认不出按「进样瓶位」反查外置对照表;
- 一份文件可能混多个样品,归堆按行做;
- 同一样品多条结果只标记、不擅自取数;
- 认不出的整行挂起、写明原因,不猜、不就近补;
- 结果非数值(未检出 / ND / <0.05)原样保留,绝不当 0。
对得上、留了痕、挂起的也标清了,再谈入库或接 LIMS 采集才不容易出岔子。
完整源码
完整可跑示例(含造数据脚本、对照表模板、回读断言):
huang_jianhua0101/examples – Gitee.com
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类: – 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错 – 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表 – 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对 – 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。
网硕互联帮助中心





评论前必须登录!
注册