1. 引言:为什么关注避暑房的负氧离子
每年夏天,城市里的高温与闷热总让人想往山里跑。云澜栖这类避暑房之所以受欢迎,除了气温低、湿度适中,还有一个常被提及的卖点------负氧离子浓度高。
负氧离子(Negative Air Ion)是空气中带负电荷的氧分子团簇,常被作为衡量空气清新程度的指标之一。森林、瀑布、溪流附近,由于水分子碰撞、植物光合作用与雷电作用,负氧离子浓度往往显著高于城市室内环境。
但问题在于:商家宣传的"负氧离子上万"到底可不可信? 想回答这个问题,光靠一句广告语是不够的,需要一套完整的数据链路:
- 用传感器持续采集原始数据;
- 对原始数据做清洗,剔除异常与漂移;
- 用可视化把"一天中什么时候空气最好""不同房间差异有多大"讲清楚。
本文就以云澜栖避暑房为场景,把这条链路拆成可复用的模板。即使你监测的不是负氧离子,而是 PM2.5、温湿度、CO₂,这套流程同样适用。
2. 数据采集:从传感器到原始数据表
2.1 监测点位设计
避暑房的监测不能只放一个点。建议至少覆盖三类位置:
| 点位编号 | 位置 | 说明 |
|---|---|---|
| P1 | 客厅中央 | 主要活动区域,代表室内整体水平 |
| P2 | 卧室床头 | 睡眠场景,关注夜间空气质量 |
| P3 | 阳台/露台 | 半室外,接近自然环境的参考值 |
| P4 | 附近林间空地 | 室外基准点,用于对比 |
点位设计的原则是:既有代表性,又能形成对照。P4 作为室外基准,能帮我们判断"室内负氧离子下降是设备问题还是环境问题"。
2.2 采集频率与设备
常见的负氧离子传感器(如基于电容式或 Gerdien 冷凝器的模块)输出频率可以从每秒一次到每分钟一次。对居住环境评估而言,1 分钟一次已经足够,既能捕捉短时波动,又不会让数据量爆炸。
采集端可以用一块 ESP32 或树莓派,配合 MQTT 上报到本地服务器。下面是一个简化的采集脚本示例:
python
import time
import json
import paho.mqtt.client as mqtt
from datetime import datetime
# 传感器读取函数(此处为示意,实际需对接具体硬件协议)
def read_negative_ion(sensor_port):
# 返回单位:个/cm³
raw = sensor_port.read()
return float(raw)
client = mqtt.Client()
client.connect("localhost", 1883, 60)
POINTS = {
"P1": "/dev/ttyUSB0",
"P2": "/dev/ttyUSB1",
"P3": "/dev/ttyUSB2",
}
while True:
ts = datetime.now().isoformat(timespec="seconds")
for pid, port in POINTS.items():
value = read_negative_ion(port)
payload = {
"point_id": pid,
"timestamp": ts,
"negative_ion": value,
}
client.publish("env/ion", json.dumps(payload))
time.sleep(60)
2.3 原始数据表结构
无论用什么方式落库,建议原始表至少包含以下字段:
sql
CREATE TABLE raw_ion_data (
id BIGSERIAL PRIMARY KEY,
point_id VARCHAR(16) NOT NULL,
ts TIMESTAMPTZ NOT NULL,
negative_ion DOUBLE PRECISION,
device_id VARCHAR(64),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_raw_ion_ts ON raw_ion_data (point_id, ts);
关键点:原始数据只追加、不修改。 清洗结果另存一张表,这样任何时候都能回溯"这条数据是怎么来的"。
3. 数据清洗:把"脏数据"变成"可用数据"
原始数据几乎不可能直接拿来分析。常见问题包括:
- 缺失值:设备断连、重启导致的时间空洞;
- 异常尖峰:电磁干扰或传感器瞬时故障造成的离群值;
- 零点漂移:长期运行后基线缓慢偏移;
- 重复上报:网络重传导致的同一时刻多条记录。
3.1 清洗流程总览
#mermaid-svg-uxuTeTBwMF2J2ryM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uxuTeTBwMF2J2ryM .error-icon{fill:#552222;}#mermaid-svg-uxuTeTBwMF2J2ryM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uxuTeTBwMF2J2ryM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uxuTeTBwMF2J2ryM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uxuTeTBwMF2J2ryM .marker.cross{stroke:#333333;}#mermaid-svg-uxuTeTBwMF2J2ryM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uxuTeTBwMF2J2ryM p{margin:0;}#mermaid-svg-uxuTeTBwMF2J2ryM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster-label text{fill:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster-label span{color:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster-label span p{background-color:transparent;}#mermaid-svg-uxuTeTBwMF2J2ryM .label text,#mermaid-svg-uxuTeTBwMF2J2ryM span{fill:#333;color:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM .node rect,#mermaid-svg-uxuTeTBwMF2J2ryM .node circle,#mermaid-svg-uxuTeTBwMF2J2ryM .node ellipse,#mermaid-svg-uxuTeTBwMF2J2ryM .node polygon,#mermaid-svg-uxuTeTBwMF2J2ryM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uxuTeTBwMF2J2ryM .rough-node .label text,#mermaid-svg-uxuTeTBwMF2J2ryM .node .label text,#mermaid-svg-uxuTeTBwMF2J2ryM .image-shape .label,#mermaid-svg-uxuTeTBwMF2J2ryM .icon-shape .label{text-anchor:middle;}#mermaid-svg-uxuTeTBwMF2J2ryM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uxuTeTBwMF2J2ryM .rough-node .label,#mermaid-svg-uxuTeTBwMF2J2ryM .node .label,#mermaid-svg-uxuTeTBwMF2J2ryM .image-shape .label,#mermaid-svg-uxuTeTBwMF2J2ryM .icon-shape .label{text-align:center;}#mermaid-svg-uxuTeTBwMF2J2ryM .node.clickable{cursor:pointer;}#mermaid-svg-uxuTeTBwMF2J2ryM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uxuTeTBwMF2J2ryM .arrowheadPath{fill:#333333;}#mermaid-svg-uxuTeTBwMF2J2ryM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uxuTeTBwMF2J2ryM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uxuTeTBwMF2J2ryM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uxuTeTBwMF2J2ryM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uxuTeTBwMF2J2ryM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uxuTeTBwMF2J2ryM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster text{fill:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM .cluster span{color:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uxuTeTBwMF2J2ryM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uxuTeTBwMF2J2ryM rect.text{fill:none;stroke-width:0;}#mermaid-svg-uxuTeTBwMF2J2ryM .icon-shape,#mermaid-svg-uxuTeTBwMF2J2ryM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uxuTeTBwMF2J2ryM .icon-shape p,#mermaid-svg-uxuTeTBwMF2J2ryM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uxuTeTBwMF2J2ryM .icon-shape .label rect,#mermaid-svg-uxuTeTBwMF2J2ryM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uxuTeTBwMF2J2ryM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uxuTeTBwMF2J2ryM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uxuTeTBwMF2J2ryM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始数据 raw_ion_data
去重:按 point_id + ts 保留一条
缺失检测:按分钟重采样
异常值剔除:3σ 或 IQR
漂移校正:滑动基线
平滑:滚动中位数
清洗结果 clean_ion_data
3.2 用 Pandas 实现清洗
下面这段代码把上面的流程串起来,可以直接复用:
python
import pandas as pd
import numpy as np
def clean_ion_data(df: pd.DataFrame) -> pd.DataFrame:
"""
df 需包含列:point_id, ts, negative_ion
"""
df = df.copy()
df["ts"] = pd.to_datetime(df["ts"])
df = df.sort_values(["point_id", "ts"])
# 1. 去重
df = df.drop_duplicates(subset=["point_id", "ts"], keep="last")
results = []
for pid, g in df.groupby("point_id"):
g = g.set_index("ts").sort_index()
# 2. 按分钟重采样,补齐时间轴
g = g.resample("1min").asfreq()
# 3. 异常值剔除:IQR 法
q1, q3 = g["negative_ion"].quantile([0.25, 0.75])
iqr = q3 - q1
lower, upper = q1 - 3 * iqr, q3 + 3 * iqr
g.loc[(g["negative_ion"] < lower) | (g["negative_ion"] > upper),
"negative_ion"] = np.nan
# 4. 漂移校正:减去 24 小时滑动中位数基线
baseline = g["negative_ion"].rolling("24h", min_periods=60).median()
g["negative_ion_corrected"] = g["negative_ion"] - baseline
# 5. 平滑:15 分钟滚动中位数
g["negative_ion_smooth"] = (
g["negative_ion_corrected"].rolling(15, min_periods=5).median()
)
g["point_id"] = pid
results.append(g.reset_index())
return pd.concat(results, ignore_index=True)
3.3 清洗参数怎么定
清洗不是"越狠越好"。参数选择建议遵循:
- IQR 系数:3 倍 IQR 比 1.5 倍更保守,适合负氧离子这种本身波动大的指标;
- 基线窗口:24 小时能覆盖一个完整的昼夜周期,避免把正常的日变化当成漂移;
- 平滑窗口:15 分钟足以压掉高频噪声,又不会抹平"清晨浓度上升"这类真实趋势。
建议把参数写成配置项,不同点位、不同季节可以分别调整,而不是硬编码在函数里。
4. 可视化:让数据自己说话
清洗完的数据如果只躺在表里,价值有限。可视化的目标是回答几个具体问题:
- 一天中负氧离子浓度如何变化?
- 不同点位之间差异有多大?
- 室内外差距是否稳定?
4.1 日变化曲线
python
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
def plot_daily_curve(df, point_id, date):
sub = df[(df["point_id"] == point_id) &
(df["ts"].dt.date == pd.to_datetime(date).date())]
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(sub["ts"], sub["negative_ion_smooth"], label="平滑后浓度")
ax.set_title(f"{point_id} 负氧离子日变化 - {date}")
ax.set_ylabel("个/cm³")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax.legend()
plt.tight_layout()
plt.show()
4.2 多点位对比箱线图
箱线图能一眼看出各点位的分布差异与离群情况:
python
def plot_point_comparison(df):
fig, ax = plt.subplots(figsize=(8, 5))
data = [g["negative_ion_smooth"].dropna().values
for _, g in df.groupby("point_id")]
labels = sorted(df["point_id"].unique())
ax.boxplot(data, labels=labels)
ax.set_ylabel("个/cm³")
ax.set_title("各监测点位负氧离子分布对比")
plt.tight_layout()
plt.show()
4.3 室内外差值热力图
把"室内 - 室外"的差值按小时 × 日期铺成热力图,可以直观看到差距在哪些时段被拉大:
python
import seaborn as sns
def plot_indoor_outdoor_diff(df, indoor="P1", outdoor="P4"):
pivot = df.pivot_table(
index=df["ts"].dt.hour,
columns=df["ts"].dt.date,
values="negative_ion_smooth",
aggfunc="mean",
)
# 简化示意:实际需分别透视 indoor / outdoor 后相减
sns.heatmap(pivot, cmap="YlGnBu")
plt.title(f"{indoor} 与 {outdoor} 差值热力图")
plt.xlabel("日期")
plt.ylabel("小时")
plt.tight_layout()
plt.show()
注:上面的热力图函数为结构示意,实际使用时需先分别对 indoor、outdoor 做 pivot,再相减得到差值矩阵。
5. 模板化:把一次性分析变成可复用资产
做完一次分析不难,难的是下个夏天、换个房子还能快速复用。建议把整套流程沉淀为三层结构:
#mermaid-svg-tDrk6J3L3fclSiZa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tDrk6J3L3fclSiZa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tDrk6J3L3fclSiZa .error-icon{fill:#552222;}#mermaid-svg-tDrk6J3L3fclSiZa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tDrk6J3L3fclSiZa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tDrk6J3L3fclSiZa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tDrk6J3L3fclSiZa .marker.cross{stroke:#333333;}#mermaid-svg-tDrk6J3L3fclSiZa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tDrk6J3L3fclSiZa p{margin:0;}#mermaid-svg-tDrk6J3L3fclSiZa .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-tDrk6J3L3fclSiZa .cluster-label text{fill:#333;}#mermaid-svg-tDrk6J3L3fclSiZa .cluster-label span{color:#333;}#mermaid-svg-tDrk6J3L3fclSiZa .cluster-label span p{background-color:transparent;}#mermaid-svg-tDrk6J3L3fclSiZa .label text,#mermaid-svg-tDrk6J3L3fclSiZa span{fill:#333;color:#333;}#mermaid-svg-tDrk6J3L3fclSiZa .node rect,#mermaid-svg-tDrk6J3L3fclSiZa .node circle,#mermaid-svg-tDrk6J3L3fclSiZa .node ellipse,#mermaid-svg-tDrk6J3L3fclSiZa .node polygon,#mermaid-svg-tDrk6J3L3fclSiZa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tDrk6J3L3fclSiZa .rough-node .label text,#mermaid-svg-tDrk6J3L3fclSiZa .node .label text,#mermaid-svg-tDrk6J3L3fclSiZa .image-shape .label,#mermaid-svg-tDrk6J3L3fclSiZa .icon-shape .label{text-anchor:middle;}#mermaid-svg-tDrk6J3L3fclSiZa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tDrk6J3L3fclSiZa .rough-node .label,#mermaid-svg-tDrk6J3L3fclSiZa .node .label,#mermaid-svg-tDrk6J3L3fclSiZa .image-shape .label,#mermaid-svg-tDrk6J3L3fclSiZa .icon-shape .label{text-align:center;}#mermaid-svg-tDrk6J3L3fclSiZa .node.clickable{cursor:pointer;}#mermaid-svg-tDrk6J3L3fclSiZa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tDrk6J3L3fclSiZa .arrowheadPath{fill:#333333;}#mermaid-svg-tDrk6J3L3fclSiZa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tDrk6J3L3fclSiZa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tDrk6J3L3fclSiZa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tDrk6J3L3fclSiZa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tDrk6J3L3fclSiZa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tDrk6J3L3fclSiZa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tDrk6J3L3fclSiZa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tDrk6J3L3fclSiZa .cluster text{fill:#333;}#mermaid-svg-tDrk6J3L3fclSiZa .cluster span{color:#333;}#mermaid-svg-tDrk6J3L3fclSiZa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-tDrk6J3L3fclSiZa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tDrk6J3L3fclSiZa rect.text{fill:none;stroke-width:0;}#mermaid-svg-tDrk6J3L3fclSiZa .icon-shape,#mermaid-svg-tDrk6J3L3fclSiZa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tDrk6J3L3fclSiZa .icon-shape p,#mermaid-svg-tDrk6J3L3fclSiZa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tDrk6J3L3fclSiZa .icon-shape .label rect,#mermaid-svg-tDrk6J3L3fclSiZa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tDrk6J3L3fclSiZa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tDrk6J3L3fclSiZa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tDrk6J3L3fclSiZa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 配置层 config.yaml
处理层 pipeline.py
展示层 report.ipynb
输出:图表 + 结论摘要
- 配置层:点位定义、清洗参数、时间范围、输出路径;
- 处理层:采集、清洗、聚合的纯函数,输入输出都是 DataFrame;
- 展示层:只负责画图和写结论,不掺业务逻辑。
一个最小配置示例:
yaml
points:
- id: P1
name: 客厅
- id: P2
name: 卧室
- id: P3
name: 阳台
- id: P4
name: 林间基准
cleaning:
iqr_factor: 3.0
baseline_window: "24h"
smooth_window: 15
report:
start_date: "2026-07-01"
end_date: "2026-08-31"
output_dir: "./reports"
这样下次换一个避暑房,只需要改 config.yaml,代码一行不动。
6. 常见坑与经验总结
- 别迷信单点数据:一个传感器坏了,结论就全错。多点位互相对照是最低成本的校验。
- 原始数据一定要留:清洗参数改了要能重跑,没有原始数据就只能重采。
- 负氧离子不是越高越好:不同设备量纲与标定差异很大,跨设备比较前先做统一标定。
- 可视化先想问题:每张图都应该对应一个具体问题,否则只是"好看的噪声"。
- 把结论写下来:图表会过期,文字结论才是可传承的部分。
7. 结语
从传感器上报的一行 JSON,到一张能说明问题的热力图,中间隔着采集、清洗、可视化三道工序。云澜栖避暑房只是一个具体场景,真正有价值的是这套可配置、可回溯、可复用的分析模板。
下次你面对任何环境监测数据------不管是负氧离子、PM2.5 还是温湿度------都可以按这个骨架搭起来:先保证数据干净,再让图表回答问题,最后把流程固化成配置。