ARTICLE · 1241527
仪器原始数据要导成 Excel,找了半天没有能批量跑的脚本

仪器工作站最省事的导出方式,就是菜单里那句「另存为」——一次存一份。一个批次跑完,几十份文件躺在文件夹里,接下来全是手工:打开、全选、复制、粘到 Excel、调列宽,再来下一份。搬的过程里数字会串行,批号 0010 粘进去变成 10,1.235E+05 变成文本,出问题还得一份份打开对,找是哪台仪器哪个文件第几行。
为了解决这个问题,我们没去数「跳过前几行」,而是换了个思路:把文件当成有结构的文本看——用 [段名] 定位每一段,段内第一行就是表头,行数随仪器变也不用改代码;每个值先原样留一份字符串,再单独判一次能不能当数值,判不了的绝不硬转。技术上就三样:按编码候选回退解码、正则切段切字段、openpyxl 写表后回读断言。
本文只讲一件事:把一批仪器导出的文本原始数据,导成一张能筛选、能回溯的 Excel。四步走——配置、读数据、判定取值、输出校验,讲到一个能直接跑的脚本为止。二进制格式(色谱 .raw、qPCR .rdml 这类)不在范围内。
一、先看清原始数据长什么样
先打开一份原始文件,会看到三个特征:
- 表头不在第一行
。前面几行是仪器型号、采集时间、操作员,行数每台不同,写死「跳过 5 行」换台仪器就崩。 - 一个文件好几段
。峰表、系统适用性、溶出曲线各段列数不同,硬塞进同一组列就串行。 - 值不干净
。 1.235E+05、<0.05、12.5 mg/L、0010、N/A全都真实存在。
对应到做法上,就四步:配置、读数据、判定取值、输出校验。
二、第①步:把路径和字段名钉死
配置区只做一件事:把会变的集中到一处。路径用 Path(__file__).resolve().parent 定位,拷到哪台机器都能跑。
出处:run.py 顶部的路径与字段常量区
BASE = Path(__file__).resolve().parentRAW_DIR = BASE / "01_raw_data" / "仪器导出"OUT_DIR = BASE / "02_output"OUT_FILE = OUT_DIR / "仪器原始数据汇总_20261024.xlsx"LOG_FILE = BASE / "source" / "run_log.txt"# 仪器工作站吐出来的文本编码五花八门:新版写 UTF-8、老版写 GBK,先按顺序试ENC_CANDIDATES = ("utf-8-sig", "utf-8", "gbk", "latin-1")# 同一台仪器不同版本、不同厂家的字段名写法不一,先归一成我们认的三个META_ALIASES = {"仪器型号": ("仪器型号", "仪器", "Instrument", "instrument"),"采集时间": ("采集时间", "采集日期", "Acquired", "acquired"),"操作员": ("操作员", "检验员", "Operator", "operator"),}MISSING = "(文件未提供)"HEAD_FILES = ["文件名", "编码", "仪器型号", "采集时间", "操作员", "段数", "数据行", "状态", "备注"]HEAD_DATA = ["来源文件", "数据段", "原始行号", "记录号", "字段名", "原始值", "数值", "类型", "备注"]
两个细节:编码列进结果表——同批 UTF-8 和 GBK 混着是常事,要知道按什么读的;缺字段写「(文件未提供)」——留空分不清是仪器没打还是脚本没读到。
三、第②步:认编码,再按段切开
这一步是地基:按编码候选试读、按 [段名] 切成元数据和若干段、段内第一行当表头,每行都记自己的原始行号(回溯全靠它)。
出处:run.py 的 read_text() 与 split_file()(文件前半段)
SECTION_RE = re.compile(r"^\[(.+?)\]")def read_text(path):"""按编码候选逐个试,返回(文本, 实际用到的编码)。"""raw = path.read_bytes()for enc in ENC_CANDIDATES:try:return raw.decode(enc), encexcept UnicodeDecodeError:continuereturn raw.decode("latin-1", "replace"), "latin-1(兜底)"def norm_key(key):"""把字段名归一:去空格、去下划线、查别名表;认不出就返回 None。"""k = key.strip().replace(" ", "").replace("_", "")for std, aliases in META_ALIASES.items():if k in aliases:return stdreturn Nonedef split_file(text):"""切成「元数据字典 + 段落列表」,每行都带自己的原始行号。"""meta, sections, cur = {}, [], Nonefor no, line in enumerate(text.splitlines(), 1):s = line.strip()m = SECTION_RE.match(s)if m: # 段标题行,如 [峰表]cur = {"name": m.group(1).strip(), "start": no, "lines": []}sections.append(cur)continueif cur is None: # 段之前的部分才算元数据mm = META_RE.match(s)if mm:key = norm_key(mm.group(1))if key:meta[key] = mm.group(2).strip()else:cur["lines"].append((no, s))return meta, sections
META_RE 里的 [::] 是半角全角一起认——两种冒号同批文件里混着出现,只写一个就有一半文件读不到操作员。接着切字段。分隔符不能写死,常见三种:制表符、逗号、两个以上空格。
出处:run.py 的 split_cells() 与 parse_section()(紧跟 split_file())
def split_cells(line):"""一行切成字段:制表符优先,其次逗号,最后当两个以上空格是分隔符。"""if "\t" in line:return [c.strip() for c in line.split("\t")]if "," in line:return [c.strip() for c in line.split(",")]return [c.strip() for c in re.split(r"\s{2,}", line)]def parse_section(sec):"""段内第一行是表头,其余是数据;表头重复出现(打印分页)要跳过,不能当数据。"""lines = [(no, s) for no, s in sec["lines"] if s]if not lines:return {"fields": [], "rows": []}, 0fields = split_cells(lines[0][1])rows, dup = [], 0for no, s in lines[1:]:cells = split_cells(s)if cells == fields:dup += 1continueif len(cells) < len(fields): # 尾部空列被吃掉,补回来对齐cells = cells + [""] * (len(fields) - len(cells))rows.append((no, cells[:len(fields)]))return {"fields": fields, "rows": rows}, dup
分页重复表头是最容易被忽略的坑:打印分页时表头会重复打一遍,当数据读进来就多出一行把「序号、峰面积、含量」塞进数值列。所以判一句 cells == fields,一模一样就跳过,并计数进清单备注。
utf-8-sig | |
[段名] 定位表头,不数行数 | |
no(原始行号) | |
四、第③步:一个值判两遍
本文最核心的一步。每个值判两次:原样留一份原始值,再判一次能不能当数值。两者并存,该算的算,该对的原文对。
出处:run.py 的 classify_value() 与 make_rows()(文件中部)
ZERO_LEAD_RE = re.compile(r"^0\d+$")UNIT_RE = re.compile(r"^\d+(\.\d+)?\s*[A-Za-z%℃]+")BLANK_MARKS = ("", "-", "—", "N/A", "NA", "nd", "ND")def classify_value(raw):"""返回(类型, 数值或 None, 备注)。判不了的绝不硬转,宁可留原始值。"""s = raw.strip()if s in BLANK_MARKS:return "空值", None, "空值或未检出标记"if s[0] in "<>≥≤":return "限值", None, "限值写法,原样保留"if ZERO_LEAD_RE.match(s):return "文本", None, "前导零,按文本存"try:return "数值", float(s), ""except ValueError:if UNIT_RE.match(s):return "文本", None, "带单位,未拆列"return "文本", None, "非数值"def make_rows(fname, meta, sections):"""把一份文件的所有段摊成一张长表的行,字段名单独占一列;顺带数出记录行数。"""out, warnings, n_rec = [], [], 0for sec in sections:parsed, dup = parse_section(sec)if not parsed["fields"]:warnings.append(f"段[{sec['name']}]没有表头")continueif not parsed["rows"]:warnings.append(f"段[{sec['name']}]只有表头没有数据")if dup:warnings.append(f"段[{sec['name']}]跳过重复表头 {dup} 行")for rec_no, (line_no, cells) in enumerate(parsed["rows"], 1):n_rec += 1for field, raw in zip(parsed["fields"], cells):kind, num, note = classify_value(raw)out.append([fname, sec["name"], line_no, rec_no, field, raw, num, kind, note])return out, warnings, n_rec
float() 放中间有讲究:先试,能转就当数值,转不了才判限值、带单位还是纯文本;顺序反了就得枚举几十种脏值写法。
为什么落长表? 各段列数不同,宽表要取并集,几十个文件跑完会得到「峰面积、峰面积_2、塔板数、溶出度」一堆稀疏列。长表固定九列,段名与字段名各占一列,字段再多也不炸;要出报告时按 来源文件 + 记录号 透视。
五、第④步:写出去,再读回来核对
写完不核对等于没做。verify() 关掉文件重开一遍,逐项断言。
出处:run.py 的 write_book()(文件后段)与 verify()(紧跟其后)
def write_book(files, rows):wb = Workbook()ws = wb.activews.title = "文件清单"ws.append(HEAD_FILES)for f in files:ws.append([f["name"], f["enc"], f["meta"].get("仪器型号", MISSING),f["meta"].get("采集时间", MISSING), f["meta"].get("操作员", MISSING),f["sections"], f["records"], f["status"], f["note"]])ws2 = wb.create_sheet("数据")ws2.append(HEAD_DATA)for r in rows:ws2.append(r)
books = list(ws.iter_rows(min_row=2, values_only=True))data = list(ws2.iter_rows(min_row=2, values_only=True))ok = Trueok &= check(len(books) == 6, "文件清单 6 个文件", f"实际 {len(books)}")
ok &= 是为了一次跑完所有断言:一项挂了后面照样执行,一次看到全部问题,不用改一条跑一次。run.py 的工程外壳(自动装依赖、运行日志、暂停等回车)不贴,跑起来自然就有。
六、跑一遍看结果
样例 6 个文件、5 个纳入、1 个 0 字节挂起,1519 条记录摊成长表 4569 行:
文件 6 个,纳入 5,挂起 1记录 1519 行,长表 4569 行 → 仪器原始数据汇总_20261024.xlsxOK 批号 0010 保住前导零 '0010'OK 科学计数法原始值与数值并存 1.235E+05 → 123500OK 限值写法不硬转数值OK 带单位的值留原文OK 原始行号能定位回原文第 8 行判定:PASS
14 项里最值钱的是最后一条:拿表里的「来源文件 + 原始行号」回原文读第 8 行,能对上 1.235E+05。对得上才算能追溯,对不上只是长得像报表的截图。
总结
仪器原始数据导不进 Excel,卡的不是会不会写 Python,是三件事没定清楚:表头在哪一行、几段数据怎么摆、值算不算数。做法四条:用 [段名] 定位而不数行数;段内第一行当表头,重复的跳过;每个值同留原始值和数值,判不了的写清原因不硬转;每行带文件名与原始行号,写完回读核对。四条定死了,换台仪器改的是配置区的别名和分隔符。
完整源码
本文配套的可运行示例已开源,造好的样例数据一起在里面,克隆下来直接复跑:
https://gitee.com/huang-jianhua-gitee/examples/tree/main/20261024
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类: - 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错 - 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表 - 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对 - 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。















































































