数据集成-电表关联区域;运行分析客流监控和空气质量优化

parent ae21dd9b
...@@ -31,6 +31,7 @@ ...@@ -31,6 +31,7 @@
"tencentcloud-sdk-nodejs": "^4.0.562", "tencentcloud-sdk-nodejs": "^4.0.562",
"winston": "^3.17.0", "winston": "^3.17.0",
"ws": "^5.2.2", "ws": "^5.2.2",
"xlsx": "^0.18.5",
"xml2js": "^0.4.23", "xml2js": "^0.4.23",
"xmlrpc": "^1.3.2" "xmlrpc": "^1.3.2"
}, },
......
...@@ -7,6 +7,8 @@ import { ...@@ -7,6 +7,8 @@ import {
} from "./feiyiClient"; } from "./feiyiClient";
import { mysqlModelMap } from "../model/sqlModelBind"; import { mysqlModelMap } from "../model/sqlModelBind";
import moment from "moment"; import moment from "moment";
import XLSX from "xlsx";
import path from "path";
// ==================== 工具函数 ==================== // ==================== 工具函数 ====================
...@@ -17,6 +19,42 @@ function getTodayDateStr(): string { ...@@ -17,6 +19,42 @@ function getTodayDateStr(): string {
return moment().format('YYYYMMDD'); return moment().format('YYYYMMDD');
} }
/**
* 从内机四段地址(如 "2-1-0-1")提取外机地址("2-1-0")
*/
function getOuterAddress(indoorUnitAddressFull: string): string | null {
if (!indoorUnitAddressFull) return null;
const parts = indoorUnitAddressFull.split('-');
if (parts.length >= 3) {
return parts.slice(0, 3).join('-');
}
return null;
}
/**
* 从 Excel 文件读取电表设备编号 → 外机地址 的映射表
*/
function loadMeterOuterAddrMap(): Map<string, string> {
const map = new Map<string, string>();
try {
const filePath = path.resolve(__dirname, '../../res/电表对应区域.xlsx');
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows: any[] = XLSX.utils.sheet_to_json(sheet);
for (const row of rows) {
const meterCode = row['电表设备编号'];
const outerAddr = row['空调外机地址'];
if (meterCode && outerAddr) {
map.set(String(meterCode), String(outerAddr));
}
}
console.log(`[工具] 电表对应区域.xlsx 读取完成,共 ${map.size} 条映射`);
} catch (err) {
console.error('[工具] 读取电表对应区域.xlsx 失败:', err);
}
return map;
}
// ==================== 分页拉取辅助函数 ==================== // ==================== 分页拉取辅助函数 ====================
/** /**
...@@ -150,11 +188,137 @@ export async function region() { ...@@ -150,11 +188,137 @@ export async function region() {
} }
console.log(`[区域集成] 完成,新增 ${insertCount} 条,跳过 ${skipCount} 条已存在`); console.log(`[区域集成] 完成,新增 ${insertCount} 条,跳过 ${skipCount} 条已存在`);
// 2. 一次性查询所有 region,后续两步共用内存数据,避免 DB 读写时序问题
const allRegions = await regionModel.findAll({ raw: true }) as any[];
// 3. 同步空调内机 → 反写 region.address
const updatedRegions = await syncRegionAddressByAcDevices(regionModel, allRegions);
// 4. 同步电表设备 region_key(复用上一步已更新 address 的内存数据)
await syncMeterDeviceRegionKey(updatedRegions);
} catch (err) { } catch (err) {
console.error('[区域集成] 执行异常:', err); console.error('[区域集成] 执行异常:', err);
} }
} }
// ==================== 区域-设备关联同步 ====================
/**
* 通过空调内机信息反写 region 表的 address 字段(外机地址)
* 逻辑:拉取所有空调内机 → 通过 roomId 匹配 region → 用内机地址前三位反写 region.address
*/
async function syncRegionAddressByAcDevices(regionModel: any, allRegions: any[]) {
console.log('[同步region.address] 开始...');
try {
// 拉取所有空调内机
const rows = await fetchAllPages((page, limit) => getIndoorUnitList({ page, limit }));
console.log(`[同步region.address] 共获取 ${rows.length} 条空调内机`);
// 构建 roomId → region 映射(使用外部传入的 allRegions)
const roomIdToRegion = new Map<string, any>();
for (const r of allRegions) {
if (r.room_id) {
roomIdToRegion.set(r.room_id, r);
}
}
let updateCount = 0;
for (const item of rows) {
if (!item.roomId || !item.indoorUnitAddressFull) continue;
const region = roomIdToRegion.get(item.roomId);
if (!region) continue;
// 用内机四段地址前三位作为外机地址
const outerAddr = getOuterAddress(item.indoorUnitAddressFull);
if (!outerAddr) continue;
// address 为空或值不同时才更新
if (region.address === outerAddr) continue;
await regionModel.update(
{ address: outerAddr },
{ where: { id: region.id } },
);
// 同步更新内存中的值,后续 syncMeterDeviceRegionKey 可直接使用
region.address = outerAddr;
updateCount++;
}
console.log(`[同步region.address] 完成,更新 ${updateCount} 条`);
return allRegions; // 返回已更新 address 的内存数据
} catch (err) {
console.error('[同步region.address] 执行异常:', err);
return allRegions; // 异常时仍返回原始数据,不阻塞后续流程
}
}
/**
* 通过 Excel 映射 + region.address 同步电表设备的 region_key
* 逻辑:Excel(电表设备编号→外机地址) → region.address → region.id → 更新 device.region_key
*/
async function syncMeterDeviceRegionKey(allRegions: any[]) {
console.log('[同步电表region_key] 开始...');
try {
const deviceModel = mysqlModelMap['device'];
if (!deviceModel) {
console.error('[同步电表region_key] device 表模型未初始化,跳过');
return;
}
// 1. 读取 Excel 映射:电表设备编号 → 外机地址
const meterOuterMap = loadMeterOuterAddrMap();
if (meterOuterMap.size === 0) {
console.warn('[同步电表region_key] Excel 映射为空,跳过');
return;
}
// 2. 使用外部传入的 allRegions 构建 address → regionId 映射(复用已更新 address 的内存数据)
const addrToRegionId = new Map<string, number>();
for (const r of allRegions) {
if (r.address) {
addrToRegionId.set(r.address, r.id);
}
}
console.log(`[同步电表region_key] region.address 映射共 ${addrToRegionId.size} 条`);
// 3. 查询所有电表设备
const meterDevices = await deviceModel.findAll({
where: { device_type: '电能监测' },
raw: true,
});
console.log(`[同步电表region_key] 共 ${meterDevices.length} 个电表设备`);
// 4. 逐一匹配更新
let updateCount = 0;
let missCount = 0;
for (const device of meterDevices as any[]) {
const outerAddr = meterOuterMap.get(device.device_id);
if (!outerAddr) {
missCount++;
continue;
}
const regionId = addrToRegionId.get(outerAddr);
if (!regionId) {
missCount++;
continue;
}
// region_key 已正确则跳过
if (device.region_key === regionId) continue;
await deviceModel.update(
{ region_key: regionId },
{ where: { id: device.id } },
);
updateCount++;
}
console.log(`[同步电表region_key] 完成,更新 ${updateCount} 条,未匹配 ${missCount} 条`);
} catch (err) {
console.error('[同步电表region_key] 执行异常:', err);
}
}
// ==================== 设备集成 ==================== // ==================== 设备集成 ====================
/** 空调控制参数模板(按设备数据结构.md) */ /** 空调控制参数模板(按设备数据结构.md) */
......
...@@ -128,13 +128,17 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) { ...@@ -128,13 +128,17 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let airQualityDevices = await selectDataListByParam(TABLENAME.设备表, airParams, ["device_id"]); let airQualityDevices = await selectDataListByParam(TABLENAME.设备表, airParams, ["device_id"]);
let airDeviceIds = airQualityDevices.data.map(d => d.device_id); let airDeviceIds = airQualityDevices.data.map(d => d.device_id);
let qualityIndex = 300; let qualityIndex = 300;
// 今日环境质量
let todayEnvironmental = '优秀';
let currentCo2 = 0; // 当前二氧化氮
let co2Trend = []; let co2Trend = [];
let currentCo2 = '0ppm'; let currentTemperature = '0℃'; // 当前温度
let currentTemperature = '0℃';
let temperatureTrend = []; let temperatureTrend = [];
let currentHumidity = '0%'; let currentHumidity = '0%'; // 当前湿度
let humidityTrend = []; let humidityTrend = [];
let currentPm25 = '0μg/m³'; let currentPm25 = 0; // 当前PM2.5
let pm25Trend = []; let pm25Trend = [];
let co2TrendDetail = []; let co2TrendDetail = [];
...@@ -149,8 +153,8 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) { ...@@ -149,8 +153,8 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let airData = latestAir.data[0].data; let airData = latestAir.data[0].data;
currentTemperature = `${airData.temperature ?? 0}`; currentTemperature = `${airData.temperature ?? 0}`;
currentHumidity = `${airData.humidity ?? 0}%`; currentHumidity = `${airData.humidity ?? 0}%`;
currentPm25 = `${airData.pm25 ?? 0}μg/m³`; currentPm25 = airData.pm25;
currentCo2 = `${airData.co2 ?? 0}ppm`; currentCo2 = airData.co2 ?? 0;
qualityIndex = 500 - (airData.pm25 ?? 0); qualityIndex = 500 - (airData.pm25 ?? 0);
} }
// 过去24小时趋势 // 过去24小时趋势
...@@ -179,12 +183,27 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) { ...@@ -179,12 +183,27 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
co2Trend.push({ time: `${i}时`, value: co2Val.toString() }); co2Trend.push({ time: `${i}时`, value: co2Val.toString() });
} }
} }
// 计算今日环境质量
if (currentCo2 && currentPm25) {
// 优秀:CO2< 800ppm; PM2.5≤35; 甲醛≤0.055
todayEnvironmental = currentCo2<800 && currentPm25<=35 ? '优秀':
// 优秀:CO2< 1000ppm; PM2.5≤45; TVOC≤0.4
currentCo2<1000 && currentPm25<=45 ? '优秀':
// 良好:CO2≤ 1000ppm; PM2.5≤50; 甲醛≤0.08
currentCo2<=1000 && currentPm25<=50 ? '良好':
// 一般:CO2:1000~1500; PM2.5:50~75
currentCo2>1000 && currentCo2<1500 && currentPm25>50 && currentPm25<75 ? '一般': '差'
}
// 4.2. 环境监测趋势图数据 // 4.2. 环境监测趋势图数据
resultAny.environmentalTrend = { resultAny.environmentalTrend = {
todayEnvironmental: todayEnvironmental,
currentTemperature: currentTemperature, currentTemperature: currentTemperature,
currentHumidity: currentHumidity, currentHumidity: currentHumidity,
currentPm25: currentPm25, currentPm25: currentPm25+'μg/m³',
currentCo2: currentCo2, currentCo2: currentCo2+'ppm',
temperatureTrend: temperatureTrend, temperatureTrend: temperatureTrend,
humidityTrend: humidityTrend, humidityTrend: humidityTrend,
pm25Trend: pm25Trend, pm25Trend: pm25Trend,
...@@ -234,10 +253,10 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) { ...@@ -234,10 +253,10 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let regionInfos: any = []; let regionInfos: any = [];
if (regionKeys.length === 0) { if (regionKeys.length === 0) {
// 6.1.1 若没有选择区域,则查询区域表获取所有区域类型 // 6.1.1 若没有选择区域,则查询区域表获取所有区域类型
regionInfos = await selectDataListByParam(TABLENAME.区域表, {}, ["id", "name", "type"]); regionInfos = await selectDataListByParam(TABLENAME.区域表, {}, ["id", "room_id", "name", "type", "groups"]);
} else { } else {
// 6.1.2 若选择了区域,则查询选择的区域 // 6.1.2 若选择了区域,则查询选择的区域
regionInfos = await selectDataListByParam(TABLENAME.区域表, { id: { "%in%": regionKeys } }, ["id", "name", "type"]); regionInfos = await selectDataListByParam(TABLENAME.区域表, { id: { "%in%": regionKeys } }, ["id", "room_id", "name", "type", "groups"]);
} }
// 6.2 循环区域查询客流传感器设备,获取过去24小时每个区域的客流量 // 6.2 循环区域查询客流传感器设备,获取过去24小时每个区域的客流量
...@@ -246,36 +265,40 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) { ...@@ -246,36 +265,40 @@ export async function getRunAnalysis(regionGroup: string, regionType: string) {
let regionCustomerMap = new Map(); // {regionName: customerNum} let regionCustomerMap = new Map(); // {regionName: customerNum}
let regionCustomerTable = []; // [{regionName: A馆, customerNum: 5000, saturation: '80%'}] let regionCustomerTable = []; // [{regionName: A馆, customerNum: 5000, saturation: '80%'}]
for (let regionInfo of regionInfos.data) { for (let regionInfo of regionInfos.data) {
let customerDevices = await selectDataListByParam(TABLENAME.设备表, { // 区分楼栋与楼层
device_type: "客流传感器", region_key: regionInfo.id let regionName = regionKeys.length === 0 ? regionInfo.groups : regionInfo.type;
// 如果是按区域类型统计,则最大承载量取该类型下所有区域的总和;如果是按区域名称统计,则取该区域的最大承载量
let maxCapacity = regionKeys.length === 0 ? regionMaxCustomerMap.get(regionName) : regionInfo.max_capacity;
regionMaxCustomerMap.set(regionName, maxCapacity); // 区域最大承载量
// 初始化客流量
let totalNum = regionCustomerMap.get(regionName) ? regionCustomerMap.get(regionName) : 0;
let customerDevices = await selectDataListByParam(TABLENAME.设备表, {
device_type: "客流传感器", region_key: regionInfo.id
}, ["device_type", "device_id"]); }, ["device_type", "device_id"]);
let customerDeviceIds = customerDevices.data.map((d: any) => d.device_id); let customerDeviceIds = customerDevices.data.map((d: any) => d.device_id);
// 获取今日的客流量数据 // 获取今日的客流量数据
if (customerDeviceIds) { if (customerDeviceIds && customerDeviceIds.length > 0) {
let customerDeviceDatas = await selectDataListByParam(TABLENAME.设备数据表, { let customerDeviceDatas = await selectDataListByParam(TABLENAME.设备数据表, {
device_id: { "%in%": customerDeviceIds }, device_id: { "%in%": customerDeviceIds },
device_time: { "%gte%": formatLocalTime(todayStart), "%lte%": formatLocalTime(todayEnd) }, device_time: { "%gte%": formatLocalTime(todayStart), "%lte%": formatLocalTime(todayEnd) },
"%orderAsc%": "device_time" "%orderAsc%": "device_time"
}, ["device_id", "data", "device_time"]); }, ["device_id", "data", "device_time"]);
// 统计区域类型的客流量 // 统计区域类型的客流量
for (let rec of customerDeviceDatas.data) { for (let rec of customerDeviceDatas.data) {
let data = rec.data; let data = rec.data;
if (data.presence === '进') { if (data.presence === '进') {
totalRegionCustomerNum += 1; totalRegionCustomerNum += 1;
// 区分楼栋与楼层
let regionName = regionKeys.length === 0 ? regionInfo.type : regionInfo.name;
if (!regionCustomerMap.has(regionName)) { if (!regionCustomerMap.has(regionName)) {
regionCustomerMap.set(regionName, 1); regionCustomerMap.set(regionName, 1);
// 如果是按区域类型统计,则最大承载量取该类型下所有区域的总和;如果是按区域名称统计,则取该区域的最大承载量
let maxCapacity = regionKeys.length === 0 ? regionMaxCustomerMap.get(regionName) : regionInfo.max_capacity;
regionMaxCustomerMap.set(regionName, maxCapacity); // 区域最大承载量
} else { } else {
let totalNum = regionCustomerMap.get(regionName);
regionCustomerMap.set(regionName, totalNum + 1); regionCustomerMap.set(regionName, totalNum + 1);
} }
} }
} }
} else {
regionCustomerMap.set(regionName, totalNum);
} }
} }
// 计算每个区域的饱和度(客流量/区域最大承载量),并转换成表格 // 计算每个区域的饱和度(客流量/区域最大承载量),并转换成表格
......
...@@ -257,6 +257,10 @@ export const TablesConfig = [ ...@@ -257,6 +257,10 @@ export const TablesConfig = [
defaultValue: 0, defaultValue: 0,
comment: '排序序号' comment: '排序序号'
}, },
address: {
type: DataTypes.STRING(100),
comment: '外机地址'
},
created_at: { created_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment