4. 温室环境数据采集与预处理脚本实现

任务拆解:从多路原始读数到一份可用的清洗数据集

本节要交付的是一个可重复运行的数据预处理脚本。它面对的是温室里真实存在的三类麻烦:不同传感器采样周期不一致(温度 30 s、湿度 30 s、CO₂ 60 s、光照 15 s)、偶发跳变与丢数(通信重试、探头结露、遮阳网遮挡瞬间)、以及单位混杂(温度有 ℃ 与 0.1 ℃ 定点整数、光照有 lux 与 klx、CO₂ 有 ppm 与 %VOL)。脚本的输出必须是一张统一时间栅格、统一单位、异常已标记的宽表,供后续控制算法直接读取。

原始多路读数(采样周期不一致) 空气温度 T_air 30 s · ℃ / 0.1℃整数 空气湿度 RH 30 s · %RH CO₂ 浓度 60 s · ppm / %VOL 冠层光照 PAR 15 s · lux / klx t0 t0+60s 预处理三步(顺序不可颠倒) ① 时间对齐 重采样到 60 s 栅格 前向保持 + 最大保持窗 超窗置 NaN,不插值 ② 异常值剔除 物理量程硬限 滑动中位数 ± k·MAD 标记而非删除,保留可追溯 ③ 单位统一 0.1℃整数 → ℃(÷10) klx → lux(×1000) %VOL → ppm(×10000) 清洗数据集(60 s 宽表) ts T_air(℃) RH(%) CO₂(ppm) PAR(lux) 08:0021.468.261218400 08:0121.667.960819200 08:0221.767.560520100 08:0321.867.160321500 08:0421.966.860122800 08:0522.066.459823600 质量标记列(不删除原始值) qc_T_air:0=正常 1=量程越界 2=MAD离群 3=保持超窗 控制算法按 qc 列决定是否参与计算,避免脏值污染 关键参数(示例取值,需按现场标定) 栅格周期 60 s | 前向保持最大窗 180 s | MAD 滑动窗 15 点 | 离群阈值 k=3.5 物理量程:T_air −10~60 ℃ | RH 0~100 % | CO₂ 300~2000 ppm | PAR 0~120000 lux 单位换算:T_air_raw/10 | CO₂_%VOL×10000 | PAR_klx×1000 | 换算后统一写入 *_si 列

原始数据长什么样

假设现场网关每 10 分钟导出一个 CSV,字段为 ts, sensor_id, raw_value, unit,同一时刻不同传感器各占一行(长表)。温度通道上报的是 0.1 ℃ 定点整数(如 214 表示 21.4 ℃),CO₂ 通道部分时段上报 %VOL,光照通道部分时段上报 klx。这种长表结构必须先转成宽表才能做时间对齐。

ts,sensor_id,raw_value,unit
2024-06-12 08:00:00,T_air,214,0.1C
2024-06-12 08:00:00,RH,68.2,%RH
2024-06-12 08:00:00,CO2,612,ppm
2024-06-12 08:00:15,PAR,18.4,klx
2024-06-12 08:00:30,T_air,216,0.1C
2024-06-12 08:00:30,RH,67.9,%RH
2024-06-12 08:01:00,CO2,0.0608,%VOL
2024-06-12 08:01:00,T_air,9999,0.1C   ← 通信错误码,需剔除

脚本实现(Python + pandas)

下面脚本自包含,可直接对上述 CSV 运行。核心设计是先对齐、再判异常、最后换算:若先换算再判异常,量程阈值会随单位变化而失效;若先判异常再对齐,则无法利用对齐后的连续序列做滑动统计。

import pandas as pd
import numpy as np

GRID = "60s"                 # 统一时间栅格
MAX_HOLD = pd.Timedelta("180s")   # 前向保持最大窗
MAD_WIN = 15                 # 滑动中位数窗口(点数)
MAD_K = 3.5                  # 离群阈值倍数

# 物理量程(换算到 SI 单位后)
RANGE = {
    "T_air": (-10.0, 60.0),
    "RH":    (0.0, 100.0),
    "CO2":   (300.0, 2000.0),
    "PAR":   (0.0, 120000.0),
}

def to_si(sensor_id, value, unit):
    """把原始读数换算到统一 SI 单位。"""
    if sensor_id == "T_air" and unit == "0.1C":
        return value / 10.0
    if sensor_id == "CO2" and unit == "%VOL":
        return value * 10000.0
    if sensor_id == "PAR" and unit == "klx":
        return value * 1000.0
    return value

def load_raw(path):
    df = pd.read_csv(path, parse_dates=["ts"])
    df["si_value"] = [to_si(s, v, u) for s, v, u in
                      zip(df["sensor_id"], df["raw_value"], df["unit"])]
    return df

def pivot_wide(df):
    """长表转宽表:每个传感器一列,索引为原始时间戳。"""
    return df.pivot_table(index="ts", columns="sensor_id",
                          values="si_value", aggfunc="last")

def align_to_grid(wide):
    """重采样到统一栅格,前向保持但限制最大保持窗。"""
    grid_idx = pd.date_range(wide.index.min().floor(GRID),
                             wide.index.max().ceil(GRID), freq=GRID)
    out = wide.reindex(wide.index.union(grid_idx)).sort_index()
    out = out.ffill(limit=None)
    # 计算每个值距其原始采样点的时间差,超窗则置 NaN
    last_valid = {}
    for col in out.columns:
        src = wide[col].dropna()
        if src.empty:
            continue
        # 对每个栅格点找最近的、不晚于它的原始采样时刻
        pos = src.index.searchsorted(out.index, side="right") - 1
        age = np.where(pos >= 0,
                       (out.index - src.index[np.clip(pos, 0, len(src)-1)]),
                       pd.Timedelta.max)
        out[col] = np.where(pd.to_timedelta(age) <= MAX_HOLD,
                            out[col].values, np.nan)
    return out.loc[grid_idx]

def flag_anomalies(wide):
    """返回 qc 标记表:0 正常 / 1 量程越界 / 2 MAD 离群 / 3 保持超窗。"""
    qc = pd.DataFrame(0, index=wide.index, columns=wide.columns, dtype=int)
    for col in wide.columns:
        lo, hi = RANGE[col]
        s = wide[col]
        qc.loc[s.isna(), col] = 3
        qc.loc[s.notna() & ((s < lo) | (s > hi)), col] = 1
        med = s.rolling(MAD_WIN, min_periods=5, center=True).median()
        mad = (s - med).abs().rolling(MAD_WIN, min_periods=5, center=True).median()
        scale = 1.4826 * mad
        outlier = (scale > 0) & ((s - med).abs() > MAD_K * scale)
        qc.loc[outlier & (qc[col] == 0), col] = 2
    return qc

def build_clean_dataset(raw_path, out_path):
    raw = load_raw(raw_path)
    wide = pivot_wide(raw)
    aligned = align_to_grid(wide)
    qc = flag_anomalies(aligned)
    qc.columns = [f"qc_{c}" for c in qc.columns]
    clean = aligned.join(qc)
    clean.index.name = "ts"
    clean.to_csv(out_path, float_format="%.2f")
    return clean

if __name__ == "__main__":
    ds = build_clean_dataset("raw_20240612.csv", "clean_20240612.csv")
    print(ds.head())
    print("各通道异常计数:")
    print(ds.filter(like="qc_").apply(pd.Series.value_counts).fillna(0).astype(int))

运行与结果判读

脚本对示例 CSV 运行后,clean_20240612.csv 的索引为 60 s 等间隔时间戳,列包含四个 SI 单位通道与四个 qc_* 标记列。判读要点如下:

  • 08:01 的 T_air:原始值 9999 换算后为 999.9 ℃,超出 −10~60 ℃ 量程,qc_T_air=1,该值保留但被标记,控制算法应跳过。
  • 08:01 的 CO₂:原始 0.0608 %VOL 换算为 608 ppm,与前后 612、605 ppm 连续,qc_CO2=0,说明单位换算正确。
  • PAR 通道:15 s 采样在 60 s 栅格上每格有 4 个原始点,pivot_table 的 aggfunc="last" 取最后一个,若需均值应改为 "mean" 并在文档中注明。
  • 保持超窗:若某传感器断线超过 180 s,对应栅格点 qc=3 且值为 NaN,而不是被旧值无限填充。
MAD 离群检测在序列开头和结尾因窗口不足会漏判,脚本用 min_periods=5 缓解但无法完全消除;对控制安全关键的通道(如温度),建议在量程硬限之外再叠加变化率限(如单步变化 > 3 ℃/min 判为可疑),作为 qc=2 的补充规则。
把 RANGE、MAX_HOLD、MAD_K 抽成配置文件而非硬编码,换季或更换探头后只需改配置即可重跑,避免脚本随现场变化而分叉。