摘要

MFC校准不是把控制器反馈值调到与设定值相同,而是在规定气体、标准状态、入口压力、出口压力、温度和稳定时间下,用具有有效校准状态的独立参考流量标准,建立设定值、MFC反馈值与参考流量之间的关系,并给出测量不确定度。

本文中的MFC是质量流量控制器,英文为Mass Flow Controller。推荐使用覆盖零点、低点、中点和高点的多个流量点,分别执行升序与降序测试并保留as-found原始结果。每个点重复测量后,可计算平均参考流量、样本标准差、相对设定值误差、反馈与参考差;线性拟合只用于描述趋势,不能自动等同于准确度、线性度合格结论或校准证书。

本文Python代码只分析离线CSV,不调整MFC参数。内置数据由公式生成,用于验证分组、拟合和方向差计算,不是任何质量流量控制器的实测数据。

1. 校准、调整和验证有什么区别

术语 工程含义 不能混淆的行为
校准 在规定条件下建立参考标准给出的量值与被测设备示值之间的关系,并考虑不确定度 校准本身不必改变设备参数
调整 改变零点、增益、查表或其他参数,使输出符合目标 调整后需要重新校准或验证
验证 用客观证据确认规定要求是否满足 通过几个点不能自动代表整个量程与全部工况
修正 根据校准关系对示值或设定值进行数学补偿 修正不能消除参考不确定度、漂移和动态误差
as-found 任何调整前记录的到达状态 不能被调整后的较好结果覆盖
as-left 完成允许调整后离开校准过程的状态 必须和as-found分开保存

2. MFC校准为什么必须统一气体和标准状态

质量流量通常以sccm、slm等标准体积流量单位表示,但不同文件采用的标准温度和标准压力可能不同。校准气体、实际气体、转换系数和标准状态必须明确,否则相同数字可能对应不同质量流量。

条件 需要记录 可能造成的偏差
气体 名称、纯度、组分、校准气体和转换方法 热物性差异与气体模型不一致
标准状态 标准温度、标准压力、流量单位 标准体积流量换算基准不同
入口与出口压力 绝压、压差、稳定范围和测点 阀控状态、传感器与参考仪器工作点变化
温度 环境、气体、设备温度和预热时间 零点、传感器响应与参考量变化
管路 管径、容积、过滤、阀门、泄漏和安装方向 稳定时间、压力损失和动态差异

3. 多流量点与升降序列怎样设计

  1. 先确认量程、允许气体、压力、温度、材料、安全和参考标准能力。
  2. 在不调整MFC的状态下完成as-found测试,保留全部原始记录。
  3. 至少覆盖零点或近零点、低流量、中间流量和高流量,具体点位按规范与风险确定。
  4. 每个点等待压力、温度和参考流量进入预先规定的稳定窗口,再重复采样。
  5. 分别执行升序与降序,避免把方向差平均后只剩一条较好直线。
  6. 同步保存设定值、MFC反馈、参考流量、入口绝压、出口绝压和温度。
  7. 若允许调整,调整前后使用相同条件重测,并分别标记as-found和as-left。
error_vs_setpoint = mean_reference - setpoint
error_percent_FS = 100 * error_vs_setpoint / full_scale
feedback_reference_difference = mean_feedback - mean_reference
fit_residual = mean_reference - (intercept + slope * setpoint)

误差百分比必须写明分母。本文代码使用满量程FS作为分母,仅用于示例;若适用规范使用读数百分比、组合误差或其他定义,应按原文件计算。

3.1 线性拟合能得到什么

对每个方向,可用一阶直线描述平均参考流量与设定值之间的总体增益和零点趋势。斜率不等于灵敏度的通用产品规格,截距也不一定等于关断状态零点。最大拟合残差只描述这些测试点相对该直线的偏离,不自动等于标准或数据手册定义的线性度。

3.2 为什么不能直接反演拟合公式

把目标流量代入反函数可得到候选设定值,但这种修正会受方向差、温度、压力、气体、时间漂移和拟合区间影响。任何修正表都需要独立数据验证,并保留越界、单调性与故障处理。

4. MFC校准测试架构

在这里插入图片描述
MFC多点校准测试架构。独立参考流量、压力和温度需要同步记录。

在这里插入图片描述
升降序校准曲线与残差示意。方向差与拟合残差应分别保留。

5. 软件、硬件与校准条件

类别 必须记录的内容
被测MFC 型号、序列号、量程、固件、安装方向、预热、as-found或as-left状态
参考标准 原理、型号、量程、校准状态、气体、标准状态、响应与不确定度
气路 气体、纯度、过滤、管径、容积、阀门、泄漏检查和排放
压力温度 入口绝压、出口绝压、压差、气体温度、环境温度与稳定范围
点位序列 满量程、点位、升序、降序、重复次数、保持时间与稳定判据
数据与统计 原始采样率、同步方式、异常值规则、平均窗口、误差定义和不确定度预算
本文软件 Windows 11 64位,Python 3.12,仅使用标准库

6. Python多点校准分析代码

CSV列名为series_id、direction、setpoint_sccm、mfc_feedback_sccm、reference_sccm、inlet_abs_kpa、outlet_abs_kpa和temperature_c。direction只允许up与down。每个方向的每个设定点至少需要3条记录。

python mfc_calibration.py --self-test
python mfc_calibration.py calibration.csv --full-scale 100
from __future__ import annotations

import argparse
import csv
import json
import math
import statistics
from collections import defaultdict
from pathlib import Path

REQUIRED_COLUMNS = {
    "series_id",
    "direction",
    "setpoint_sccm",
    "mfc_feedback_sccm",
    "reference_sccm",
    "inlet_abs_kpa",
    "outlet_abs_kpa",
    "temperature_c",
}
VALID_DIRECTIONS = {"up", "down"}

def finite_float(value: str, field: str, line_number: int) -> float:
    try:
        number = float(value)
    except ValueError as exc:
        raise ValueError(f"invalid {field} at line {line_number}") from exc
    if not math.isfinite(number):
        raise ValueError(f"non-finite {field} at line {line_number}")
    return number

def load_csv(path: Path) -> list[dict[str, float | str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        missing = REQUIRED_COLUMNS - set(reader.fieldnames or [])
        if missing:
            raise ValueError(f"missing columns: {sorted(missing)}")
        rows = []
        for line_number, raw in enumerate(reader, start=2):
            series_id = raw["series_id"].strip()
            direction = raw["direction"].strip()
            if not series_id:
                raise ValueError(f"empty series_id at line {line_number}")
            if direction not in VALID_DIRECTIONS:
                raise ValueError(f"invalid direction at line {line_number}")
            row = {"series_id": series_id, "direction": direction}
            for field in REQUIRED_COLUMNS - {"series_id", "direction"}:
                row[field] = finite_float(raw[field], field, line_number)
            rows.append(row)
    if not rows:
        raise ValueError("input file is empty")
    return rows

def linear_fit(x_values: list[float], y_values: list[float]) -> tuple[float, float]:
    if len(x_values) < 2 or len(set(x_values)) < 2:
        raise ValueError("at least two distinct setpoints are required")
    x_mean = statistics.fmean(x_values)
    y_mean = statistics.fmean(y_values)
    denominator = sum((value - x_mean) ** 2 for value in x_values)
    slope = sum(
        (x - x_mean) * (y - y_mean)
        for x, y in zip(x_values, y_values)
    ) / denominator
    intercept = y_mean - slope * x_mean
    return slope, intercept

def analyze(
    rows: list[dict[str, float | str]], full_scale_sccm: float
) -> dict[str, object]:
    if full_scale_sccm <= 0.0:
        raise ValueError("full scale must be positive")
    grouped = defaultdict(list)
    for row in rows:
        key = (str(row["direction"]), float(row["setpoint_sccm"]))
        grouped[key].append(row)
    if any(len(group) < 3 for group in grouped.values()):
        raise ValueError("each direction and setpoint needs at least 3 rows")

    point_results = []
    for (direction, setpoint), group in sorted(grouped.items()):
        reference = [float(row["reference_sccm"]) for row in group]
        feedback = [float(row["mfc_feedback_sccm"]) for row in group]
        mean_reference = statistics.fmean(reference)
        mean_feedback = statistics.fmean(feedback)
        point_results.append({
            "direction": direction,
            "setpoint_sccm": setpoint,
            "sample_count": len(group),
            "mean_reference_sccm": mean_reference,
            "reference_sample_std_sccm": statistics.stdev(reference),
            "mean_feedback_sccm": mean_feedback,
            "reference_minus_setpoint_sccm": mean_reference - setpoint,
            "reference_minus_setpoint_percent_fs": (
                100.0 * (mean_reference - setpoint) / full_scale_sccm
            ),
            "feedback_minus_reference_sccm": mean_feedback - mean_reference,
            "mean_inlet_abs_kpa": statistics.fmean(
                float(row["inlet_abs_kpa"]) for row in group
            ),
            "mean_outlet_abs_kpa": statistics.fmean(
                float(row["outlet_abs_kpa"]) for row in group
            ),
            "mean_temperature_c": statistics.fmean(
                float(row["temperature_c"]) for row in group
            ),
        })

    fits = {}
    for direction in sorted(VALID_DIRECTIONS):
        points = [item for item in point_results if item["direction"] == direction]
        x_values = [float(item["setpoint_sccm"]) for item in points]
        y_values = [float(item["mean_reference_sccm"]) for item in points]
        slope, intercept = linear_fit(x_values, y_values)
        residuals = [
            y - (intercept + slope * x)
            for x, y in zip(x_values, y_values)
        ]
        fits[direction] = {
            "slope": slope,
            "intercept_sccm": intercept,
            "maximum_absolute_fit_residual_sccm": max(abs(r) for r in residuals),
            "point_count": len(points),
        }

    point_map = {
        (str(item["direction"]), float(item["setpoint_sccm"])): item
        for item in point_results
    }
    common_setpoints = sorted(
        set(key[1] for key in point_map if key[0] == "up")
        & set(key[1] for key in point_map if key[0] == "down")
    )
    direction_differences = [
        {
            "setpoint_sccm": setpoint,
            "up_minus_down_reference_sccm": (
                float(point_map[("up", setpoint)]["mean_reference_sccm"])
                - float(point_map[("down", setpoint)]["mean_reference_sccm"])
            ),
        }
        for setpoint in common_setpoints
    ]
    return {
        "row_count": len(rows),
        "full_scale_sccm": full_scale_sccm,
        "points": point_results,
        "fits_by_direction": fits,
        "direction_differences": direction_differences,
    }

def self_test() -> None:
    offsets = [-0.06, -0.02, 0.02, 0.06]
    rows = []
    for direction, slope, intercept in (
        ("up", 0.980, 0.500),
        ("down", 0.979, 0.600),
    ):
        for setpoint in (0.0, 25.0, 50.0, 75.0, 100.0):
            for repeat, offset in enumerate(offsets, start=1):
                rows.append({
                    "series_id": f"{direction}_{repeat}",
                    "direction": direction,
                    "setpoint_sccm": setpoint,
                    "mfc_feedback_sccm": setpoint + 0.2 * offset,
                    "reference_sccm": intercept + slope * setpoint + offset,
                    "inlet_abs_kpa": 200.0,
                    "outlet_abs_kpa": 100.0,
                    "temperature_c": 23.0,
                })
    result = analyze(rows, 100.0)
    assert result["row_count"] == 40
    up_fit = result["fits_by_direction"]["up"]
    down_fit = result["fits_by_direction"]["down"]
    assert math.isclose(up_fit["slope"], 0.980, abs_tol=1e-12)
    assert math.isclose(up_fit["intercept_sccm"], 0.500, abs_tol=1e-12)
    assert math.isclose(down_fit["slope"], 0.979, abs_tol=1e-12)
    assert math.isclose(down_fit["intercept_sccm"], 0.600, abs_tol=1e-12)
    assert up_fit["maximum_absolute_fit_residual_sccm"] < 1e-12
    difference_50 = next(
        item for item in result["direction_differences"]
        if math.isclose(item["setpoint_sccm"], 50.0)
    )
    assert math.isclose(
        difference_50["up_minus_down_reference_sccm"], -0.05, abs_tol=1e-12
    )
    print("SELF_TEST_OK")
    print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))

def main() -> None:
    parser = argparse.ArgumentParser(description="Analyze teaching MFC calibration data.")
    parser.add_argument("csv_file", nargs="?", type=Path)
    parser.add_argument("--full-scale", type=float)
    parser.add_argument("--self-test", action="store_true")
    args = parser.parse_args()
    if args.self_test:
        self_test()
        return
    if args.csv_file is None or args.full_scale is None:
        parser.error("csv_file and --full-scale are required")
    result = analyze(load_csv(args.csv_file), args.full_scale)
    print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))

if __name__ == "__main__":
    main()

6.1 CSV输入示例

series_id,direction,setpoint_sccm,mfc_feedback_sccm,reference_sccm,inlet_abs_kpa,outlet_abs_kpa,temperature_c
up_1,up,25.0,25.0,24.9,200.0,100.0,23.0
up_2,up,25.0,25.0,25.0,200.0,100.0,23.0
down_1,down,25.0,25.0,25.1,200.0,100.0,23.0

以上三行仅展示格式,不能单独运行完整分析。真实CSV应保留每个方向、每个点的全部重复记录,不应只保存平均值。

7. 可验证的教学结果

自检包含升序与降序各5个流量点,每点4次,共40行。升序参考流量按0.500加0.980乘设定值构造,降序按0.600加0.979乘设定值构造;每点叠加均值为零的四个确定性偏移。所有值仅用于验证程序。

在这里插入图片描述

验证项 预设结果 本稿运行判据 数据状态
总行数 40 等于40 公式生成
升序点数 5 等于5 公式生成
升序斜率与截距 0.980与0.500 sccm 绝对误差小于1e-12 拟合恢复
降序斜率与截距 0.979与0.600 sccm 绝对误差小于1e-12 拟合恢复
50 sccm方向差 升序减降序为-0.05 sccm 绝对误差小于1e-12 方向差计算
最大拟合残差 接近0 小于1e-12 sccm 理想直线数据

理想直线自检不能证明真实MFC呈线性,也不能证明误差和方向差满足任何规格。真实校准结果还要包含参考不确定度、环境条件、原始记录、校准日期和设备状态。

8. 校准结果异常时怎样排查

在这里插入图片描述

现象 优先检查 下一步验证
零点偏差明显 预热、真正零流量、阀门泄漏、安装方向和温度 隔离流路并延长零点记录,区分示值零点与关断泄漏
斜率偏离但残差较小 增益、气体或标准状态定义 核对单位、转换系数和参考标准后再考虑调整
中间点残差弯曲 非线性、参考量程、压力条件或分段修正 增加点位并检查残差,不盲目提高多项式阶数
升降序方向差明显 阀控迟滞、稳定时间、压力与管路容积 延长稳定窗口并重复完整升降序列
MFC反馈正常但参考偏差 内部反馈标定、气体模型或真实流量偏差 核查独立参考与实际气体,不用内部一致性替代校准
重复标准差突然变大 流量波动、压力、通信毛刺或参考仪器动态 查看原始时间序列并同步压力温度,不直接删除异常点

9. 常见问题

9.1 MFC多久需要校准一次

没有适用于所有设备的固定周期。应根据使用频率、气体、污染风险、漂移历史、工艺风险、法规和质量体系制定,并用历史as-found结果调整周期。

在这里插入图片描述

9.2 MFC校准必须使用实际气体吗

不一定,但替代气体必须有适用的转换方法和不确定度评估。反应性、多组分或真实气体效应明显时,简单转换可能不足。

9.3 设定值等于反馈值说明校准合格吗

不能说明。设定值与反馈值都属于被测MFC内部链路,仍需独立参考流量判断真实输出。

9.4 MFC校准需要几个流量点

点数由适用规范、量程、风险和预期非线性决定。至少应覆盖关键工作区间,只有零点和满量程通常不足以描述中间点与方向差。

9.5 线性拟合的斜率越接近1越好吗

斜率接近1只说明总体增益接近理想。仍需检查截距、残差、方向差、重复性、不确定度和每个点的允许误差。

9.6 可以用拟合公式直接生成修正表吗

只能作为候选。修正关系必须在独立数据、升降方向、温度、压力和边界点上验证,并规定越界与故障处理。

9.7 校准后为什么还要做as-left测试

调整改变了设备状态。as-left测试用于确认调整后的关系,而as-found结果用于判断设备到达时是否可能影响此前使用数据,两者不能互相覆盖。

9.8 Python分析结果能代替校准证书吗

不能。代码只完成描述性统计与拟合,不提供溯源性、不确定度、授权签发、环境记录或实验室能力证明。

10. 结论

MFC校准的核心是用独立、可追溯且适合当前气体和量程的参考流量标准,在明确压力、温度、标准状态和稳定时间的条件下,建立设定值、反馈值与真实参考流量之间的关系。多点升降序能够同时暴露零点、增益、残差、重复性和方向差。

本文Python代码能够复现多点分组、一阶拟合与方向差分析,但不执行设备调整,也不能生成校准证书或合格判定。正式校准还需完整不确定度、原始记录、as-found与as-left状态以及安全审核。

参考资料

  1. JCGM 200:2012,International vocabulary of metrology - Basic and general concepts and associated terms,BIPM。https://www.bipm.org/en/committees/jc/jcgm/publications
  2. ISO/IEC 17025:2017,General requirements for the competence of testing and calibration laboratories。https://www.iso.org/search.html?q=ISO%2FIEC%2017025%3A2017
  3. JCGM 100:2008,Evaluation of measurement data - Guide to the expression of uncertainty in measurement,BIPM。https://www.bipm.org/en/committees/jc/jcgm/publications
  4. ISO 14511:2019,Measurement of fluid flow in closed conduits - Thermal mass flowmeters。https://www.iso.org/search.html?q=ISO%2014511%3A2019
  5. SEMI E27,Standard for Mass Flow Controller and Mass Flow Meter Linearity。https://store-us.semi.org/search?q=SEMI%20E27
  6. Python Software Foundation,statistics - Mathematical statistics functions,Python 3.12文档。https://docs.python.org/3.12/library/statistics.html

正式校准还必须引用被测MFC、参考标准、压力传感器和数据采集系统的当前规格书、校准文件与安全文件。外部链接发布前需在当前网络环境复核。

更多推荐