夜雨聆风学习资料网

ARTICLE · 1241523

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

仪器原始数据要导成 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), enc        except UnicodeDecodeError:            continue    return 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 std    return Nonedef split_file(text):    """切成「元数据字典 + 段落列表」,每行都带自己的原始行号。"""    meta, sections, cur = {}, [], None    for 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)            continue        if 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": []}, 0    fields = split_cells(lines[0][1])    rows, dup = [], 0    for no, s in lines[1:]:        cells = split_cells(s)        if cells == fields:            dup += 1            continue        if 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 带 BOM → UTF-8 → GBK 顺序试
utf-8-sig
 能吃掉 BOM;GBK 放前面会把正常中文读成乱码
用 [段名] 定位表头,不数行数
元数据行数随仪器变,数行数换台机器就错位
每行带 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 = [], [], 0    for sec in sections:        parsed, dup = parse_section(sec)        if not parsed["fields"]:            warnings.append(f"段[{sec['name']}]没有表头")            continue        if 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 += 1            for 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.active    ws.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 = True    ok &= 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+ 逐条核对原始数据与记录是否对得上

手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。

相关学习资料