4. 科学计算数据加载与预处理管道
科学计算看板的第一道关口不是绘图,而是数据管道。真实项目里,同一份物理量可能以 NetCDF 的网格场、HDF5 的分块数据集、CSV 的观测表或内存中的 NumPy 数组四种形态出现。如果每次交互都从磁盘重新读取、重新清洗、重新插值,看板就会在拖动滑块时卡顿。本节围绕“读得进、洗得净、降得下、缓得住”四个环节,讲清一条可复用的预处理管道。
一、四种格式的读取要点
NetCDF 适合多维规则网格,典型如气象温度场 temp(time, lat, lon)。用 xarray 打开时优先使用惰性加载,只有真正取值才触发读取:
import xarray as xr
ds = xr.open_dataset("era5_temp.nc", chunks={"time": 24})
temp = ds["t2m"] # 此时尚未读入全部数据
sub = temp.sel(time="2024-06-01", lat=slice(30, 40)) # 触发分块读取
HDF5 更像一个文件内的目录树,用 h5py 按路径访问数据集,务必只读需要的切片,避免整块载入:
import h5py
with h5py.File("sim.h5", "r") as f:
print(list(f.keys())) # 先看结构
block = f["fields/pressure"][0, :, :] # 只取第 0 帧
CSV 是观测表或小规模结果表,读取时用 dtype 与 usecols 控制内存,并把时间列显式解析为时间类型。NumPy 数组则是管道内部的统一交换格式:无论上游是什么,清洗后都转成带明确形状与单位的 ndarray,下游绘图只认这一种。
二、清洗:把“能读”变成“可用”
科学数据最常见的脏点有四类:缺测值(NetCDF 常用 _FillValue、CSV 常用空串或 -9999)、物理越界(温度 300 K 却出现 1e6)、重复时间戳、以及单位不一致。清洗策略应显式声明,而不是静默处理。例如把缺测统一转为 NaN,再决定是剔除、前向填充还是标记为无效掩膜:
import numpy as np
arr = np.where(arr == -9999, np.nan, arr)
mask = np.isnan(arr)
arr = np.where(mask, np.nanmean(arr), arr) # 示例:均值填补
需要强调的是,填补只适用于展示层;若下游要做统计或物理计算,应保留 mask 并在结果中标注有效样本数,避免“填补后的均值”被误当作真实观测。
三、降采样:按显示分辨率而非原始分辨率计算
看板画布通常只有几百到一千像素宽,把 4000×4000 的网格原样送进前端既慢又无意义。降采样要区分两种目的:显示降采样追求视觉保真,常用块平均或最大值聚合;分析降采样追求统计无偏,需先聚合再计算。对二维场,块平均可用 reshape 实现:
def block_mean(a, k=4):
h, w = a.shape[0]//k*k, a.shape[1]//k*k
return a[:h, :w].reshape(h//k, k, w//k, k).mean(axis=(1, 3))
对时间序列,则按目标帧率抽取关键帧,或先用滑动平均抑制高频噪声再抽样。判断降采样是否可接受,可比较降采样前后同一区域的最大值、均值与极值位置;若极值位置明显漂移,说明聚合方式不适合该物理量(例如降水极值应保留最大值而非均值)。
四、缓存策略:让交互只算一次
缓存的关键是“键要能唯一标识一次计算”。推荐以 (数据源指纹, 变量名, 时间范围, 空间范围, 降采样因子, 清洗规则版本) 组成缓存键,命中则直接返回,未命中才走读取—清洗—降采样链路。小规模结果放内存 LRU,大规模中间结果落盘为 .npz 或 parquet。同时要设置失效条件:源文件修改时间变化、清洗规则版本升级时,旧缓存必须作废,否则看板会展示过期结果。
五、一个可复用的管道骨架
把上述环节串成函数式管道,便于测试与替换。下面示例以“读取 NetCDF 温度场并降采样”为场景,参数均为教学示例值:
import hashlib, numpy as np, xarray as xr
def cache_key(path, var, t0, t1, k, rule_ver):
raw = f"{path}|{var}|{t0}|{t1}|{k}|{rule_ver}".encode()
return hashlib.md5(raw).hexdigest()
def load_clean_downsample(path, var, t0, t1, k=4, rule_ver="v1"):
ds = xr.open_dataset(path, chunks={"time": 24})
da = ds[var].sel(time=slice(t0, t1))
arr = da.values.astype("float32")
arr = np.where(arr == da.attrs.get("_FillValue", -9999), np.nan, arr)
arr = np.where(np.abs(arr) > 1e5, np.nan, arr) # 越界示例阈值
h, w = arr.shape[-2]//k*k, arr.shape[-1]//k*k
arr = arr[..., :h, :w].reshape(*arr.shape[:-2], h//k, k, w//k, k).mean(axis=(-3, -1))
return arr
调用时先算缓存键,命中则跳过整个函数体。这样,拖动时间滑块时只有首次访问某个时间窗会真正读盘,后续交互都在毫秒级完成。
至此,一条从异构格式到统一数组、再到可缓存降采样结果的管道就完整了。它的价值不在于某个库的用法,而在于把“读取—清洗—降采样—缓存”的边界划清:每一段都可单独测试、单独替换,看板的交互性能才有稳定保障。