简介:全国各流域水质数据来自环保部门公开的权威监测,每日更新,系统覆盖国内主要河流、湖泊与水库等水体,可用于研究各地水质状况与污染动态。压缩包共309个文件,其中308个为按日期命名的JSON数据文件,适合做时间序列分析与跨区域对比;另有1个Markdown说明文档,用于解释字段口径与数据字典;包体仅2.68MB,轻量易用。目前已有2620人学习下载。数据包含pH、溶解氧、氨氮、高锰酸盐指数、化学需氧量、生化需氧量、总磷、总氮等常规指标,能够反映水体自净能力与污染负荷。样本时间覆盖2020年至2021年多个监测日期,使用者可据此识别工业、农业或生活污染源,评估季节性与长期水质变化趋势。JSON字段结构规范,便于程序化解析与入库,可搭配Python、Excel等工具快速完成清洗和可视化,也可支撑饮用水源地保护、断面考核和水环境治理决策。对环保科研、数据分析及政策制定人员,这是一份可直接读取、持续更新的高质量基础数据。
1. water_quality:全国各流域水质数据到底能回答什么问题
某天同事丢给我一个压缩包,看起来平平无奇,解压后是几百个CSV文件加起来几万行,名字就叫“全国各流域水质数据”。我当时急着做流域治理项目的汇报,以为导入数据库就能出图,结果光是把站点编码、指标名称、单位统一就磨了一下午。这类数据集的价值从来不在“有没有数据”,而在于你把时间、站点、指标三个维度对齐之后,它真正能回答的问题只有三个:某个流域水质在变好还是变差、哪个断面最拖后腿、超标背后的主要污染类型是耗氧有机物还是营养盐。这三件事对应趋势分析、断面对比和污染类型识别,也正是接下来要走的路径。前两年大家都在拼算法,实际落地时反而被数据格式卡住,所以这篇笔记更适合环保信息化、流域治理和数据分析从业者直接照着做。
2. 先看懂全国各流域水质数据的骨架:站点、指标与时间粒度
2.1 数据文件里到底有哪些字段
下载或拷贝到的“全国各流域水质数据”通常不是单张表,而是由主监测表和站点表组成。常见做法是:主表一行对应某个站点在某个时刻对某个指标的测定值,即长表;另一类版本是宽表,一行对应“站点+时间”,后续每列是一个指标。这两种结构各有好处,长表适合按指标筛选,宽表适合直接算相关矩阵。拿到数据后第一步不是写分析代码,而是先读字段清单,搞清楚它到底是长表还是宽表,再决定后面的处理方式。
我把常见字段整理成下面这张表,你可以对着自己的数据核对:
| 字段名 | 示例 | 说明 | 常见坑 |
|---|---|---|---|
| 站点代码 | 101A | 断面唯一标识 | 前后可能带空格,不同版本大小写不一致 |
| 站点名称 | 某河闸上 | 展示用名称 | 同一个断面可能多种写法 |
| 所属流域 | 东南某水系 | 大流域归类 | 有的版本缺省,需靠站点表补充 |
| 监测时间 | 2024-03-15 | 采样日期 | 可能是字符型,有的带时分秒 |
| 指标名称 | 氨氮 | 中文简称 | 同一指标可能有多个别名 |
| 指标值 | 0.42 | 监测结果 | 单位不统一是最大的坑 |
| 指标单位 | mg/L | 计量单位 | 有的是 μg/L |
| 断面类型 | 国控 | 考核级别 | 部分版本缺失 |
注意表格里的示例全是虚构的,只是说明格式。实际数据里字段名可能是英文码,比如 NH3N、CODmn,这时需要做一个字段映射字典,把英文码翻译成中文,同时记录单位。我一般会在清洗开始前就把这套映射写进一个CSV,后面所有脚本统一引用,而不是手写在代码里。这里容易翻车的是把所有指标都默认按 mg/L 处理,电导率、浊度这些量纲完全不同的指标也会混进来,导致统计结果完全没法解释。
2.2 站点编码与流域归属怎么对上
站点编码不是乱编的,常见做法是前几位表示省份或水系的数字/字母,后几位是断面序号。但在流传的多版数据里编码规则经常被改,甚至同一断面在不同年份的编码前后不一致。因此不要试图从编码里“推断”出一切,正确做法是拿站点表作为权威映射,让编码和流域归属在合并时自动对齐。
具体操作我一般这样写:先把站点表读进来,检查站点代码的唯一性。然后做主表与站点表的合并,用“站点代码”作为键值。
import pandas as pd site = pd.read_csv('site_info.csv', dtype={'站点代码': str}) main = pd.read_csv('water_quality_raw.csv', dtype={'站点代码': str}) # 检查站点代码是否唯一 duplicate_codes = site['站点代码'].duplicated().sum() print(f'重复站点代码行数: {duplicate_codes}') # 确认主表中站点是否都能在站点表找到 missing_codes = set(main['站点代码']) - set(site['站点代码']) print(f'主表里找不到的站点代码数量: {len(missing_codes)}')逻辑说明:dtype参数把站点代码确定为字符串,避免“101A”被读成100并丢失字母;duplicated()检查站点表内部是否有重复;集合减法直接暴露主表与站点表的映射缺口,这一步能避免后面merge出一堆NaN。参数说明:dtype是读取阶段最容易被忽略的参数,如果站点代码是纯数字编码,pandas会默认读成int,合并时会和字符串版的代码匹配不上,这是第一次翻车的常见位置。
如果站点表里没有“所属流域”,但给了经纬度,可以先用空间分箱粗略划到流域片,但这只是初筛。真正要用于汇报,最好是拿水利或环境部门发布的流域分区矢量文件,用空间join把经纬度落到流域编码上。多数数据集已经自带流域字段,你要做的是校验它是否准确,而不是重新发明一套归属规则。
2.3 指标单位与地表水限值对照
水质指标的单位和限值是后续所有评价和画图的基础。常用指标和地表水环境质量标准限值大致如下:
| 指标 | 单位 | Ⅰ类 | Ⅱ类 | Ⅲ类 | Ⅳ类 | Ⅴ类 |
|---|---|---|---|---|---|---|
| pH | 无量纲 | 6~9 | 6~9 | 6~9 | 6~9 | 6~9 |
| 溶解氧 | mg/L | ≥7.5 | ≥6 | ≥5 | ≥3 | ≥2 |
| 高锰酸盐指数 | mg/L | ≤2 | ≤4 | ≤6 | ≤10 | ≤15 |
| 氨氮(NH3-N) | mg/L | ≤0.15 | ≤0.5 | ≤1.0 | ≤1.5 | ≤2.0 |
| 总磷(以P计) | mg/L | ≤0.02 | ≤0.1 | ≤0.2 | ≤0.3 | ≤0.4 |
这里要特别注意:pH 是区间约束,只有在 6~9 范围外才算超标;溶解氧是高优指标,不是“超过限值就超标”,而是低于对应类别的限值才超标。氨氮和总氮不是一回事,总氮限值在标准里单列,如果分析时把总氮数值混入氨氮序列,画出来的趋势会莫名其妙地整体偏高。另一类容易搞混的是“高锰酸盐指数”和“化学需氧量(COD)”,前者标注为 CODMn,后者是 CODCr,两者方法不同、限值不同。拿到指标名先确认是不是英文缩写,避免把 CODMn 当成 COD。把这个对照表存成一份独立的CSV,清洗脚本直接读取它,后续如果标准更新,只需要改表,不用改代码。
3. 把原始水质表清洗成可分析的样子:Pandas 处理流程与参数细节
3.1 加载与缺失值处理
清洗的第一步是加载,我一般会同时完成三件事:站点代码转字符串、日期解析、数值列强制转 float。日期解析用 parse_dates=[列名] 比事后 pd.to_datetime 更省事;但要注意,如果数据里日期格式不统一,比如有的是“2024/3/1”,有的是“2024-03-01”,parse_dates 也救不了,必须自定义解析函数。
import pandas as pd import numpy as np # 假设原始数据是宽表:每行一个站点时间,列是各指标 df = pd.read_csv('water_wide.csv', dtype={'站点代码': str}, parse_dates=['监测时间']) df['监测时间'] = pd.to_datetime(df['监测时间'], errors='coerce') # 检查每个指标的缺失率 na_rate = df.isna().mean().sort_values(ascending=False) print(na_rate[na_rate > 0.1]) # 查看按月分组的样本量,识别低频监测站点 df['年'] = df['监测时间'].dt.year df['月'] = df['监测时间'].dt.month counts = df.groupby(['站点代码', '年', '月']).size() print(counts[counts < 4].head(10))逻辑说明:isna().mean()按列算出缺失占比,先挑出缺失严重的指标,而不是一开始就 dropna,否则会把所有含缺测的日期都删掉。第二段按站点、年、月分组计数的目的是发现监测频率异常,比如某站一个月只有一次采样,它的月均值置信度就比每天采样的断面低很多。参数说明:errors='coerce' 让无法解析的日期变成 NaT,之后你就能统计出哪些行需要回填或删除。在真实数据里,日期列经常混入“2024-02-30”这种错误日期,coerce 可以让它现出原形。
缺失值处理策略不是一刀切。对于氨氮、总磷这种核心指标,如果缺失率超过30%,我通常会放弃该断面,而不是插补。插补水质数据会引入虚假规律,后续做超标率统计时会严重低估或高估。如果只是个别月份缺失,可以用前后两个月的均值填充,但要在报告里注明“已插补”,避免误导。
3.2 从宽表转长表:melt 的正确参数
宽表结构读着舒服,但对按指标筛选和批量限值判断很麻烦。常见做法是把宽表转成长表,也就是把指标名列变成一列“指标”,数值变成一列“数值”。
# 转成长表 long_df = df.melt( id_vars=['站点代码', '站点名称', '流域', '监测时间'], value_vars=['pH', '溶解氧', '高锰酸盐指数', '氨氮', '总磷'], var_name='指标', value_name='数值' ) # 把明显不合法的负值转成 NaN long_df.loc[long_df['数值'] < 0, '数值'] = np.nan print(long_df.head()) print(long_df['指标'].value_counts())逻辑说明:melt 把宽表里指定的数值列全部堆叠到“指标”和“数值”两列,id_vars 保持不变。转换后每一行是“某站点某时刻的某指标值”,后面按指标做限值比较、按流域做分组聚合都很自然。参数说明:value_vars 不写的话,melt 会默认使用所有非 id_vars 列,这会把站点名称、流域等列误当指标值,所以显式列出指标列是必须养成的习惯。
这里有个经常翻车的地方:melt 后的“数值”列是 float,但原始宽表某列可能被 pandas 读成 object,因为里面混了“ND”表示未检出。遇到这种值,先用 pd.to_numeric(errors='coerce') 把非数值转 NaN,再决定是填检出限的一半还是直接剔除。水质检测报告里的“未检出”通常有专门的表示法,把它当作 0 是不对的,应该在后续统计时单独处理。
3.3 基于国标限值生成超标标记
有了长表和限值表,下一步就是给每条记录打上“是否超标”的标签。这个环节最忌讳把所有指标用同一个条件判断,因为 pH 有区间,溶解氧是低于限值才超标。
# 读取限值表,需要包含指标、评价类别、下限/上限或限值 limit_df = pd.read_csv('water_quality_limit.csv', dtype={'指标': str}) def is_over_standard(row, limit_table): indicator = row['指标'] value = row['数值'] target = limit_table[limit_table['指标'] == indicator] if target.empty: return np.nan if indicator == 'pH': low = target['下限'].iloc[0] high = target['上限'].iloc[0] return value < low or value > high # 溶解氧是越高越好,低于限值为超标 if indicator == '溶解氧': return value < target['限值'].iloc[0] # 其他指标是越高越差,高于限值为超标 return value > target['限值'].iloc[0] long_df['是否超标'] = long_df.apply(is_over_standard, axis=1, args=(limit_df,)) print(long_df['是否超标'].value_counts(dropna=False))逻辑说明:这个函数最核心的是“按指标分流判断方向”。很多入门分析把 pH 和氨氮统一用大于限值判断,导致 pH 在 7~9 之间被标成超额。参数说明:apply 的 axis=1 让函数处理每一行,效率不高但胜在逻辑清晰;如果想批量加速,可以按指标分组后分别比较,但对几万行数据来说差异可以忽略。limit_table 里必须有“下限/上限”或“限值”几个列,并且指标名称大小写要和主表一致,这里经常失配。
另一种常见需求是按“单因子评价法”给断面定类别:同一断面所有指标落到哪个类别,以最差的那个指标为准。实现思路是先按“站点+时间+指标”标记每个记录对应类别,再 groupby 取最大值作为断面类别。注意这个做法得到的是“断面水质类别”,不是“是否超标”,两者的汇报口径要分开写,别混在一起。
4. 全国各流域水质数据必须做的 3 个分析维度:趋势、对比与污染类型
4.1 月均值趋势与季节性分解
水质数据最常被问的问题是“这几年到底变好还是变差”。直接画原始散点图经常会因为监测频率不规律而难以判断,所以第一步一般是按月聚合,把噪声压下去再看整体趋势。
# 先按站点时间指标求日均,再按月求均值 trend = long_df.groupby(['流域', '监测时间', '指标'])['数值'].mean().reset_index() trend['年'] = trend['监测时间'].dt.year trend['月'] = trend['监测时间'].dt.month monthly = trend.groupby(['流域', '年', '月', '指标'])['数值'].mean().reset_index() monthly['时间'] = pd.to_datetime(monthly['年'].astype(str) + '-' + monthly['月'].astype(str)) print(monthly.head())逻辑说明:先按“流域+时间+指标”求均值,再按月求均值,得到每个流域每个月每种指标的平均浓度。这样处理之后,时间序列变得稳定,不再受单次采样异常点干扰。参数说明:groupby 后 reset_index 是为了把索引变回普通列,方便后续 pivot 或绘图。
进一步可以用 statsmodels 的 seasonal_decompose 看季节分量。但要注意水质月数据一般只有几年历史,样本点少,做分解前至少要有连续 24 个月的数据,否则分解出来的趋势和季节成分不可信。
from statsmodels.tsa.seasonal import seasonal_decompose # 挑出某流域氨氮的月度序列 one_series = monthly[(monthly['流域'] == '某流域') & (monthly['指标'] == '氨氮')] one_series = one_series.set_index('时间')['数值'].resample('ME').mean() result = seasonal_decompose(one_series.dropna(), model='additive', period=12) trend_part = result.trend seasonal_part = result.seasonal参数说明:resample('ME') 是较新版本 pandas 的月末频率写法,旧版本用 'M',如果你用的是旧版要按自己的 pandas 版本调整,否则会报错。period=12 表示一年周期,适用于月度数据。model='additive' 假设季节波动幅度不随时间变化,如果浓度整体升高时季节波动也在变大,改成 multiplicative 更合理。
这段代码看起来简单,真正的坑是时间索引必须连续。如果某个月没有采样,分解模型会强制插值,结果看起来光滑但实际是虚构的。所以我在做分解前会先检查每个月的采样站点数量,少于 3 个站点的月份直接剔除或补全,不要交给模型瞎猜。
4.2 流域间断面指标对比
汇报会上最有杀伤力的图是“各流域主要指标对比箱线图”。它能直接看出哪个流域浓度中位数高、哪个流域极差大。中位数比均值更抗异常值,水质数据偏偏常有极端值,所以我优先用箱线图。
import seaborn as sns import matplotlib.pyplot as plt plot_df = long_df[long_df['指标'].isin(['氨氮', '总磷', '高锰酸盐指数'])] sns.boxplot(data=plot_df, x='流域', y='数值', hue='指标') plt.xticks(rotation=45) plt.title('不同流域主要指标浓度分布对比') plt.tight_layout() plt.show()逻辑说明:箱线图同时显示了中位数、四分位距和离群点,能避免单点对比的误导。比如某流域平均浓度看着还行,但箱线图显示绝大多数站点都贴着上限,情况其实不容乐观。参数说明:hue 参数让不同指标分色,但要注意三个指标单位都是 mg/L 才能放一张图,如果混入电导率这种不同单位的,y 轴就乱了。建议只选同类单位的指标对比。
先做整体对比,再做断面排名,是整个分析里最快出成果的部分。常见做法是找出每个流域里“超标次数最多”的断面,这就要用到第 3 章生成的“是否超标”标记。
over_count = long_df[long_df['是否超标'] == True].groupby(['流域', '站点名称']).size().reset_index(name='超标次数') top = over_count.sort_values('超标次数', ascending=False).groupby('流域').head(3) print(top)逻辑说明:先过滤出超标记录,按流域和站点聚合并计数,再在每个流域内部取前 3 名。注意是先排序再 groupby.head(3),否则拿到的是原始顺序。这个结果可以直接转录成汇报表格,列名改成“断面名称、超标次数、主要超标指标”。如果想进一步看“主要超标指标”,就在聚合前再按指标分组,看看每个断面的超标记录里哪个指标占比最大,这对后面污染治理很有参考价值。
4.3 基于主成分的污染类型粗判
当指标较多时,可以用 PCA 把多维指标压缩成两三个主成分,辅助判断污染类型。比如第一主成分可能代表总体污染水平,第二主成分可能区分“有机污染主导”和“营养盐主导”。这个分析只能作为探索手段,不能代替化学溯源,但在初期摸数据时非常有用。
from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA # 构造站点-指标均值矩阵 mat = long_df.pivot_table(index='站点代码', columns='指标', values='数值', aggfunc='mean') mat = mat[['pH', '溶解氧', '高锰酸盐指数', '氨氮', '总磷']].dropna() X_scaled = StandardScaler().fit_transform(mat) pca = PCA(n_components=2) scores = pca.fit_transform(X_scaled) loadings = pd.DataFrame(pca.components_, columns=mat.columns, index=['PC1', 'PC2']) print(loadings)逻辑说明:pivot_table 把每行变成一个站点的平均浓度矩阵,然后标准化,让单位和量级不同的指标可以公平参与 PCA。载荷表里的值能看出 PC1 和 PC2 主要由哪些指标驱动。例如 PC1 在氨氮、总磷上载荷很大,说明这一轴主要反映营养盐污染;PC2 在高锰酸盐指数上载荷大,则可能代表有机物污染维度。参数说明:dropna() 会删除任何一个指标缺失的站点,如果站点太多因缺失被删,说明前面缺失值处理没做好;n_components 取 2 是为了散点图,取 3 也行,但解释复杂度上升。
把 PC1、PC2 散点画出来,按流域着色,能直观看到哪些流域在污染类型上扎堆。不过水质 PCA 的结果经常被个别极端值带偏,所以画图前最好做一个稳健性检查:把最大 1% 的数值替换为 99% 分位数再跑一遍,看载荷有没有大幅变化。如果变化大,说明结论依赖于少数离群点,报告中要用更谨慎的措辞。
5. 全国各流域水质数据落地避坑:5 个常见问题与排查办法
5.1 站点经纬度往地图上画后集体偏移
现象:把站点坐标映射到地图上,所有点都落在河道外面,甚至跑到水库正中央或山坡上。 原因:多版本数据混合时坐标系不统一,有的是 WGS84,有的是 GCJ02,还有的在导出时把度分秒转成了小数度但保留了三列原始字段,导致取值张冠李戴。 解决:加载后先画散点图检查坐标范围。中国区域常见的 WGS84 经纬度约是经度 73~135、纬度 18~54,如果发现经度大于 140 或纬度小于 4,基本可以判断坐标系或单位弄错了。通过站点名称和流域关系反查已知位置的断面,确认当前坐标系;如果是 GCJ02,需要转成 WGS84 再做空间统计。转换要写进专门的坐标转换函数,不要手工作业。一个具体检查方法是筛选出流域字段为“某流域”的站点,看经纬度是否都落在河流上下游合理范围内。如果最上游站点的纬度比最下游还低,说明经度或纬度列被互换。这种错误在合并多个来源数据时非常常见,用排序和相邻站点距离可以自动排查。
5.2 氨氮数值突然差一个数量级
现象:同一断面同一天的氨氮,从两个数据来源拿出来一个 0.42,一个 4.2,或者一个 0.042。 原因:单位没有归一。常规地表水氨氮是 mg/L,但有些台账记录为 μg/L,或者报表为了显示方便直接除以 1000 再填。 解决:清洗第一步就把单位列读进来,建立一个“指标-单位-转换系数”表。比如氨氮单位是 μg/L 时除以 1000 转成 mg/L。注意总磷也有同样问题。不要以为字段名前写了个“总磷”单位就统一,一定要在加载后对数值量级做上下界检查:氨氮正常在 0.02~5 之间,如果出现 50 以上且连续多个点,先怀疑单位。用一行代码打印每个指标的最小值和最大值,一眼就能看到量级异常。
numeric_summary = long_df.groupby('指标')['数值'].describe() print(numeric_summary[['min', 'max', 'mean']])原理说明:min/max 能直接暴露量级问题。如果氨氮 max 是 52,而大部分值在 0.1~2 之间,基本可以确定是某几个月的单位写错了。再把 max 对应的时间段查出来,定位到具体数据来源,而不是全局乘系数。这是数据修复中“局部异常局部处理”的典型做法。
5.3 按同一份标准评价,I 类断面数量忽多忽少
现象:历史年度对比时,某个断面去年是 II 类,今年变成 I 类,但原始监测值几乎没变。 原因:评价用的标准版本变过。地表水环境质量标准在不同年份对总磷、氨氮的部分限值有调整;更常见的是有人把评价类别搞错,比如把 IV 类限值当 III 类用。 解决:把限值表做成带“标准版本”列的常量表,每次评价脚本必须显式传入版本参数。报告中也要标注“依据某版本标准评价”。如果数据跨多个版本时期,建议统一用现版标准回算历史,保证时间序列可比。这个回算需要用原始浓度重新评级,不能直接沿用当年的评价结果。另外,“断面类别”与“水质目标”是两回事。有些站点是考核断面,地方制定的水质目标可能是 IV 类,即使实际监测达到 III 类,也不算考核超标;而评价报告里“III 类”只是水质类别,不代表考核达标。不要混用。
5.4 合并站点表后大量断面匹配成 NaN
现象:主表和站点表 merge 之后,站点名称或“所属流域”出现大量 NaN,之前看起来每个站点都有归属。 原因:站点编码在不同年份发生过调整,比如某个代码在某个版本写成带横线的形式;还有可能是空格、全角数字、字母大小写造成的。 解决:合并之前做标准化。先 strip 去空格,再统一字母大写,必要时把站点代码里的分隔符去掉。注意站点名称不要作为主键,因为同一个断面的别名太多。用代码做标准化比肉眼快得多。
def normalize_code(code): if pd.isna(code): return code return str(code).strip().upper().replace('-', '').replace('_', '') main['站点代码_标准'] = main['站点代码'].apply(normalize_code) site['站点代码_标准'] = site['站点代码'].apply(normalize_code) merged = main.merge(site[['站点代码_标准', '流域']], on='站点代码_标准', how='left') missing_after = merged['流域'].isna().mean() print(f'合并后流域缺失率: {missing_after:.2%}')逻辑说明:先用一个标准化函数把两个表的键值统一,再做合并。参数说明:apply 方法逐行处理,虽然比向量化稍慢,但对几万行数据没问题。缺失率如果仍然超过 5%,说明还有另一套站点代码体系,需要找到新旧编码对照表,不能直接合并。
5.5 月均值曲线出现阶梯状跳变
现象:画出来的浓度时间序列不是平滑波动,而是在每年 1 月左右出现明显的台阶,且不同站点跳变方向一致。 原因:监测频率和采样时段变动。很多断面在丰水期加密采样、枯水期少采,简单按自然月求均值时,样本构成发生了变化;还有的是年底更新了仪器或方法,导致同一指标检测限变化。 解决:先检查每个“站点-月份”的样本数分布。如果某个月只有 1 次样本,该月均值的置信度就低,绘图时用点线区分或直接标记为低置信度。更稳妥的做法是只在每月至少保证 3 次有效监测的站点参与趋势比较。对检测限变化引起的断点,可以用突变点检测大概定位,但结论需要结合运维记录,不能只看数据。
6. 把分析结果做成能汇报的图表与自动报告
6.1 一张图看懂流域水质:分面折线图加超标点标注
汇报时最怕“一堆图看不出重点”。我习惯用分面折线图:x 轴时间,y 轴浓度,每一行一个流域,再加一条限值水平线,超标点用红色散点标出来。这样领导和同事一眼看到“哪个流域、在什么时候、哪种指标破线”。
report_data = monthly[monthly['指标'] == '氨氮'] g = sns.relplot( data=report_data, x='时间', y='数值', col='流域', col_wrap=3, kind='line', height=3.5, aspect=1.3, color='steelblue', ci=None ) g.map(plt.axhline, y=1.0, color='red', ls='--', label='III类限值') plt.show()逻辑说明:relplot 的 col 参数按流域分面,col_wrap 控制每行图数量。这里把氨氮的 III 类限值 1.0 mg/L 画成红色虚线,便于直接比较。参数说明:ci=None 是让阴影不显示,因为月度均值已经聚合过,再算置信区间意义不大;aspect 可以调节每个小图的宽高比,防止时间轴被拉得太扁。如果指标是 pH 或溶解氧,限值判断方向不同,线型和标注方式也要跟着改。
6.2 把图表与统计表拼成自包含的 HTML 报告
与其交付一堆散图,不如把所有图表、统计表和结论说明拼成一个 HTML 文件,浏览器直接打开就能汇报。常见做法是先把图存成 PNG,再用模板把图片和数据表嵌进去。这样脚本可以被定时任务反复调用,数据更新后重新生成。
import base64 # 把图片编码成 base64 嵌入 HTML,避免外部图片路径丢失 with open('trend_ammonia.png', 'rb') as f: img_base64 = base64.b64encode(f.read()).decode('utf-8') html_content = f"""<!DOCTYPE html> <html><head><meta charset="utf-8"><title>某流域水质月报</title></head> <body> <h1>某流域水质月报(自动生成)</h1> <p>统计周期:2023-01 至 2024-12</p> <img src="data:image/png;base64,{img_base64}" alt="氨氮趋势图"> <h2>主要指标超标断面 Top5</h2> {table_html} </body></html>""" with open('report.html', 'w', encoding='utf-8') as f: f.write(html_content)逻辑说明:base64 图片内嵌避免了把 PNG 文件一起分发时丢失路径的问题,这在跨电脑汇报时非常实用。table_html 是之前用 DataFrame 的 to_html() 生成的带格式表格字符串,可以直接插进模板。参数说明:encoding='utf-8' 一定要写,否则中文会乱码。写成 HTML 后,可以再用相关的 PDF 工具转成 PDF,但这类工具链安装较重,不是必需,先用 HTML 就够了。
这套自动化流程我给身边同事用了好几轮,大家反馈最值钱的是“把限值版本和单位核对逻辑固化在脚本里”,这样每次新数据进来,重新跑一遍清洗,结果依然可信。我现在拿到新数据的习惯是先写三行检查:站点编码是否唯一、指标单位是否统一、限值表版本是否指定;三行都过,再谈分析和模型。这是花钱买来的血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取