2. 多站点监测数据合并与站点元数据关联

任务拆解:从“多文件”到“一张可分析的长表”

本节要解决的核心问题是:多个站点各自导出的监测 CSV 文件,字段结构一致但站点信息只体现在文件名或某一列中;同时另有一张站点元数据表,记录站点编码、名称、经纬度、海拔。目标是先把多文件纵向拼接成统一长表,再按站点编码左连接元数据,最后验证两件事:每个站点的记录数是否与源文件一致,以及元数据中站点编码是否唯一(否则连接会放大行数)。

① 多站点源文件(纵向合并) ST001_2024.csv time, pm25, pm10, no2 站点编码列:ST001 ST002_2024.csv time, pm25, pm10, no2 站点编码列:ST002 ST003_2024.csv time, pm25, pm10, no2 站点编码列:ST003 pd.concat ② 统一长表 long_df station_code time pm25 pm10 no2 ST001 … n₁ 行 ST002 … n₂ 行 ST003 … n₃ 行 总行数 = n₁+n₂+n₃ ③ 站点元数据 meta_df station_code (唯一键) station_name latitude / longitude altitude ST001 / ST002 / ST003 每站仅 1 行 merge on station_code ④ 关联结果 merged_df(左连接,行数应保持不变) station_code | time | pm25 | pm10 | no2 | station_name | latitude | longitude | altitude ST001 | 2024-01-01 00:00 | 35 | 58 | 22 | 城东站 | 30.12 | 120.34 | 18 ST002 | 2024-01-01 00:00 | 41 | 66 | 19 | 城西站 | 30.20 | 120.10 | 25 验证要点 · 各站记录数 = 源文件行数 · meta 中 station_code 无重复

准备示例数据与读取

为便于复现,先构造三个站点文件与一张元数据表。实际项目中文件来自不同站点导出,这里用字典模拟“文件名 → DataFrame”的读取结果,字段与真实监测数据一致。

import pandas as pd
import numpy as np

# 模拟三个站点源文件(实际中由 pd.read_csv 逐个读入)
def make_station(code, n, base):
    rng = pd.date_range("2024-01-01", periods=n, freq="h")
    return pd.DataFrame({
        "station_code": code,
        "time": rng,
        "pm25": np.round(base + np.random.randn(n) * 5, 1),
        "pm10": np.round(base * 1.6 + np.random.randn(n) * 8, 1),
        "no2": np.round(base * 0.5 + np.random.randn(n) * 3, 1),
    })

files = {
    "ST001_2024.csv": make_station("ST001", 24, 35),
    "ST002_2024.csv": make_station("ST002", 20, 42),
    "ST003_2024.csv": make_station("ST003", 18, 28),
}

# 站点元数据:station_code 为唯一键
meta_df = pd.DataFrame({
    "station_code": ["ST001", "ST002", "ST003"],
    "station_name": ["城东站", "城西站", "城北站"],
    "latitude": [30.12, 30.20, 30.31],
    "longitude": [120.34, 120.10, 120.22],
    "altitude": [18, 25, 12],
})

纵向合并:多文件拼成统一长表

合并时保留 station_code 列作为站点标识,并记录每个源文件的原始行数,供后续核对。使用 ignore_index=True 重建行索引,避免多文件索引重复。

source_counts = {}
frames = []
for fname, df in files.items():
    source_counts[df["station_code"].iloc[0]] = len(df)
    frames.append(df)

long_df = pd.concat(frames, ignore_index=True)
print("合并后总行数:", len(long_df))
print("各站源文件行数:", source_counts)

预期输出:合并后总行数为 24+20+18=62,各站源文件行数分别为 24、20、18。这一步只做纵向堆叠,不做任何去重或过滤,保证后续核对有基准。

元数据唯一性检查与关联

左连接前必须先确认 meta_df 中 station_code 唯一。若存在重复,merge 会产生笛卡尔放大,导致记录数虚增。检查通过后再执行左连接,保证长表每一行都能匹配到且仅匹配到一条元数据。

# 1) 唯一性检查
dup = meta_df["station_code"].duplicated().sum()
assert dup == 0, f"元数据站点编码存在 {dup} 条重复,需先清洗"

# 2) 左连接:以长表为主,保留全部监测记录
merged_df = long_df.merge(
    meta_df,
    on="station_code",
    how="left",
    validate="many_to_one",   # 显式声明多对一,重复键会直接报错
)

print("关联后行数:", len(merged_df))
print("未匹配元数据的行数:", merged_df["station_name"].isna().sum())

validate="many_to_one" 是关键防护:它要求右侧 meta_df 的键唯一,一旦元数据重复会立即抛出 MergeError,比事后比对行数更早暴露问题。

关联结果验证

验证分两步:先按站点统计关联后记录数,与源文件行数逐一比对;再确认没有未匹配行。两者都通过,才说明合并没有丢行、也没有被放大。

# 各站关联后记录数
merged_counts = merged_df.groupby("station_code").size().to_dict()
print("关联后各站记录数:", merged_counts)

# 与源文件行数比对
for code, n in source_counts.items():
    assert merged_counts[code] == n, f"{code} 记录数不一致: {merged_counts[code]} vs {n}"

# 未匹配检查
assert merged_df["station_name"].isna().sum() == 0, "存在未匹配元数据的站点"
print("验证通过:行数一致且元数据全部匹配")
若某站点在元数据表中缺失,左连接后该站 station_name 等字段为 NaN,记录数仍与源文件一致,但元数据不完整。此时应决定是补充元数据还是改用内连接并记录被丢弃的站点,而不是直接忽略 NaN。
不要用 how="inner" 做默认关联。内连接会静默丢弃元数据缺失的站点记录,导致记录数减少却不易察觉。若确需内连接,应先统计被丢弃的站点与行数并留档。

可复用的核对清单

检查项判断方法通过条件
合并总行数len(long_df) 与各源文件行数之和比较完全相等
元数据键唯一meta_df["station_code"].duplicated().sum()等于 0
关联后行数len(merged_df) 与 len(long_df) 比较相等(左连接且键唯一)
各站记录数按 station_code 分组计数与源文件比对逐站相等
元数据匹配率merged_df["station_name"].isna().sum()等于 0