DeFire 可以理解为一套面向森林场景的火灾检测与分级预警方案,核心目标不是等到明火蔓延后再告警,而是在火险指标进入危险区间时提前通知林区值守人员。用 Node.js 实现这套系统,关键能力在于把气象站、烟雾传感器、摄像头图像识别结果、卫星热点等多源数据汇聚到同一条处理链路上,在秒级时间内完成风险评分和分级推送。单纯从模型算法看,森林火灾预测已经有相对成熟的气象指标,而工程上更容易被忽视的是接入层吞吐、实时推送和误报抑制。这三件事恰好是 Node.js 事件驱动模型能够发挥优势的地方。

一、DeFire 的数据链路与整体架构
森林防火监测点的部署位置通常比较分散,一个县级林区可能有上百个气象站和烟雾探测器,它们会以固定间隔上报温度、湿度、风速、降雨量以及颗粒物浓度。除了物联网设备直接上报,还有两类数据需要接入:一是摄像头抓拍画面经过识别后产生的事件,二是气象部门提供的网格化预报数据。Node.js 在这类场景中适合做统一的接入层,因为大部分请求是轻量的数据写入,不会在服务端做复杂计算。
设计上可以把系统分为四个部分。接入层用 Express 接收 HTTP 上报,缓存层用 Redis 保存最近一段时间的观测值和去重标记,持久层用 PostgreSQL 记录历史告警与设备元数据,推送层则通过 WebSocket 向前端值守页面广播高风险变化。图像识别任务不建议直接放在 Node.js 主进程中,可以使用 worker_threads 或独立的消息队列消费者来处理,Node.js 只负责把任务写入队列并接收结果。
下面这个接口展示了如何接收一个监测点的气象与烟雾数据。服务端拿到数据后先做基础校验,再写入 Redis 的时间序列键,方便后续风险计算时快速读取最近样本。
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/v1/readings', async function(req, res) {
const body = req.body;
if (!body.siteId || body.temp === undefined) {
return res.status(400).json({ error: 'invalid reading' });
}
const reading = {
siteId: body.siteId,
temp: body.temp,
humidity: body.humidity,
wind: body.wind,
rain: body.rain,
smoke: body.smoke,
ts: Date.now()
};
const key = 'site:' + body.siteId + ':readings';
await redis.zadd(key, String(Date.now()), JSON.stringify(reading));
res.status(202).json({ accepted: true });
});
Redis 在这里承担的职责是短时间窗口缓存。zset 用时间戳作为 score,可以快速获取某个站点最近 72 小时的数据,而 Redis 的 TTL 机制又天然适合做告警去重。PostgreSQL 则更适合保存已经确认的告警记录,以及站点的基础信息、负责人联系方式和历史统计报表。
如果直接让每个上报请求都同步计算风险,高峰期容易产生重复计算和锁竞争。更合理的做法是数据先入缓存,由定时任务每隔一分钟扫描活跃站点,批量计算风险分数。这种异步拆分让上报接口的响应时间保持在毫秒级,也方便后续把计算任务独立扩缩容。
二、风险评分与分级阈值设计
森林火险评估常用 FWI(Fire Weather Index)作为参考,它由气温、相对湿度、风速和降雨量等气象因子推导得到。完整 FWI 模型涉及多个中间指数,实际项目中也可以先用简化加权评分来验证链路。评分的核心逻辑是温度越高、湿度越低、风速越大,风险越高;降雨量越大,风险越低。烟雾浓度则可以作为辅助项,当颗粒物数值异常升高时,说明附近可能已经出现明火或阴燃。
下面是一个简化版风险评分函数,它把各因子映射为 0 到 100 分区间。实际生产环境需要根据当地植被类型和季节调整系数,但这些参数最好放在配置文件中,而不是硬编码在函数内。
function calcRisk(reading) {
const temp = Number(reading.temp);
const humidity = Number(reading.humidity);
const wind = Number(reading.wind);
const rain = Number(reading.rain);
const tempScore = Math.max(0, (temp - 20) * 0.8);
const dryScore = Math.max(0, (30 - humidity) * 1.2);
const windScore = Math.min(40, wind * 3);
const rainDeduction = rain * 4;
const total = tempScore + dryScore + windScore - rainDeduction;
return Math.max(0, Math.round(total));
}
阈值的设定不能只看绝对分数。不同地区的基础火险差异很大,例如南方湿润林区和北方干旱林区在同一个分数下的实际风险并不相同。更稳妥的方式是采用动态基线:取该站点过去 72 小时的分数序列,计算 90 分位和 95 分位作为黄、红预警门槛。当当前分数超过对应分位,并且连续三个上报周期都维持在高位时,才触发告警。连续周期判断可以过滤掉单个传感器抖动造成的误报。
function getLevel(score, baseline90, baseline95) {
if (score < baseline90) return 'normal';
if (score < baseline95) return 'yellow';
if (score < 100) return 'red';
return 'extreme';
}
除了分数阈值,系统还要考虑两个现实问题。一是同一站点在短时间内可能反复触发同一级别告警,如果每次都推送,值守人员很快会对告警疲劳。二是不同传感器可能同时异常,形成告警风暴。针对第一点,可以在 Redis 中为每个站点和级别设置一个带过期时间的键,只有键不存在时才真正发送告警;针对第二点,可以把同一区域内的多个告警聚合为一条摘要消息,并标注影响站点数量。
下面用表格整理常见的分级建议。表格中的数值只是示例,实际参数需要结合历史火灾数据和林业部门要求进行调整。
| 等级 | 风险分数 | 附加条件 | 推送策略 |
|---|---|---|---|
| 正常 | 小于 90 分位 | 无 | 不推送 |
| 黄色预警 | 90 至 95 分位 | 连续 3 个周期 | 每 5 分钟最多 1 次 |
| 红色预警 | 95 分位以上 | 湿度低于 20% | 立即推送并短信提醒 |
| 极端 | 接近 100 | 烟雾浓度陡增 | 立即推送并电话通知 |
这套策略的重点不是追求模型精度,而是先保证告警链路可用、误报可控。数据积累到一定程度后,可以再引入随机森林或梯度提升树等模型,将预测结果转化为评分或概率,替换掉简化版函数。
三、实时推送与去重实现
实时性是森林火灾预警能否发挥作用的关键。传统 HTTP 轮询方案里,客户端每隔几十秒请求一次最新告警,既浪费带宽,也无法真正做到秒级送达。WebSocket 更适合这种需要服务端主动推送的场景。服务端可以在风险级别发生变化时,把事件广播给所有订阅了该区域的客户端,前端页面据此更新地图和提示列表。
使用原生 ws 库实现一个简单的广播函数,不需要额外引入 Socket.IO。广播时建议只发送变化事件,不发送完整的站点列表,这样单个消息体可以控制在几百字节。客户端首次打开页面时,再通过 REST 接口一次性拉取当前所有高温险站点。
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8081 });
function broadcastRisk(siteId, level, score) {
const msg = JSON.stringify({
siteId: siteId,
level: level,
score: score,
ts: Date.now()
});
wss.clients.forEach(function(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
});
}
推送链路中最容易被忽视的是去重。比如一个站点在 10 分钟内从黄色升级到红色,又从红色降回黄色,如果每次都推,值班页面会不断弹出提示。用 Redis 的 set 命令配合过期时间,可以让同一站点同一级别在指定时间窗口内只产生一次告警。下面的函数接收站点编号和级别,返回是否应该推送。如果返回 false,调用方直接跳过后续通知逻辑。
async function shouldAlert(redis, siteId, level, ttlSeconds) {
const key = 'alert:' + siteId + ':' + level;
const exists = await redis.get(key);
if (exists) {
return false;
}
await redis.set(key, '1', 'EX', ttlSeconds);
return true;
}
前端展示部分不需要从零实现地图引擎,使用现成的 Web 地图组件即可。服务端返回站点经纬度和风险等级,前端在对应位置绘制热力点。风险等级高时用红色,中等用橙色,正常站点可以不显示。若要在页面上绘制自定义图形,可以用 <canvas> 元素或 SVG,但更常见的做法是直接在地图 SDK 上叠加图层。对 Node.js 开发者来说,这部分工作大多集中在前端工程,后端只要保证告警事件顺序不乱、层级清晰即可。
四、工程化落地与性能注意点
原型系统可以跑在单进程里,但正式部署时要考虑多进程扩展。Node.js 可以通过 cluster 模块启动多个工作进程,共享同一个端口。此时要特别注意 WebSocket 连接的状态不能只存在进程内,否则不同进程之间无法互相感知客户端。解决方案是使用 Redis 的发布订阅机制,让所有进程订阅同一个告警频道,由某个进程负责计算并发布,其他进程只负责向自己持有的 WebSocket 连接广播。
图像识别和视频抽帧属于 CPU 密集型任务,如果直接放在 Node.js 事件循环中执行,会导致上报接口和推送连接被卡住。较合理的做法是拆分成独立队列。比如摄像头服务把图片地址写入 Redis 队列,另一个 Python 或 Node.js worker 消费队列并执行推理,完成后把结构化结果写回 Node.js 接口。Node.js 这边主要做协调、缓存和推送,避免承担过重的计算。
数据校验和异常恢复同样重要。野外传感器会因为供电、网络或设备老化产生明显异常值,例如湿度达到 200% 或温度突然跳变。接入层必须在写入缓存前做范围校验,将不合理数据标记为无效,不参与风险计算。即使某个站点暂时离线,也要保留它的最近一次有效读数,避免误判为风险消失。定时任务在扫描站点时,如果发现超过指定时间没有新数据,应单独生成设备离线提示,而不是直接忽略。
监控指标应该覆盖上报延迟、队列积压、告警发送成功率和误报反馈。误报反馈可以设计一个简单的回执机制:值守人员在页面上标记某次告警属于误报,后端将这条记录用于调整阈值或模型参数。这样系统可以在使用中逐步适应当地环境,减少对人工经验的依赖。

