|
|
@@ -15,6 +15,7 @@ import com.alibaba.fastjson2.JSONObject;
|
|
|
import com.huashe.common.utils.DateUtils;
|
|
|
import com.ruoyi.ems.common.handle.BaseDevHandler;
|
|
|
import com.ruoyi.ems.common.mqtt.MqttMessageHandler;
|
|
|
+import com.ruoyi.ems.common.mqtt.MqttTemplate;
|
|
|
import com.ruoyi.ems.domain.ElecStoreH;
|
|
|
import com.ruoyi.ems.domain.EmsDevice;
|
|
|
import com.ruoyi.ems.domain.EmsObjAttrValue;
|
|
|
@@ -37,6 +38,8 @@ import java.math.BigDecimal;
|
|
|
import java.math.RoundingMode;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.sql.Time;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.Calendar;
|
|
|
import java.util.Date;
|
|
|
@@ -44,6 +47,7 @@ import java.util.HashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
import java.util.regex.Matcher;
|
|
|
import java.util.regex.Pattern;
|
|
|
|
|
|
@@ -83,6 +87,54 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
private static final String READING_FWD = "GCC_STORE_READING_FWD";
|
|
|
private static final String READING_REV = "GCC_STORE_READING_REV";
|
|
|
|
|
|
+ /**
|
|
|
+ * 储能一体机BMS累计充放电缓存字段(redis hash field)
|
|
|
+ */
|
|
|
+ private static final String READING_CUM_C = "GCC_YTJ_CUM_CHARGE";
|
|
|
+ private static final String READING_CUM_D = "GCC_YTJ_CUM_DISCHARGE";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当日零点基线快照key前缀(按日)
|
|
|
+ */
|
|
|
+ private static final String DAY_BASE_PREFIX = "GCC_DAY_BASE_";
|
|
|
+ private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 策略下发能力key
|
|
|
+ */
|
|
|
+ public static final String ABILITY_APPLY_DEFAULT_STRATEGY = "applyDefaultStrategy";
|
|
|
+ public static final String ABILITY_APPLY_STRATEGY_IDLE = "applyStrategyIdle";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下行/应答topic后缀
|
|
|
+ */
|
|
|
+ private static final String TOPIC_SUFFIX_COMMAND = "/command";
|
|
|
+ private static final String TOPIC_SUFFIX_COMMAND_RESPONSE = "/commandResponse";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 策略时段保守功率预置值(kW,待厂家确认PCS额定功率后调整)
|
|
|
+ */
|
|
|
+ private static final int SLOT_POWER_LIMIT_KW = 100;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 默认策略时段(厂家描述:02-06谷时充电、11-13谷时补电;光伏随动时段协议无字段暂禁止)
|
|
|
+ * 字段:{峰谷选择, 开始小时, 开始分钟, 结束小时, 结束分钟, 充电最大功率kW, 放电最大功率kW}
|
|
|
+ */
|
|
|
+ private static final int[][] DEFAULT_STRATEGY_SLOTS = {
|
|
|
+ { 4, 2, 0, 6, 0, SLOT_POWER_LIMIT_KW, 0 },
|
|
|
+ { 4, 11, 0, 13, 0, SLOT_POWER_LIMIT_KW, 0 }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全静置策略:无有效时段(24时段全部禁止)
|
|
|
+ */
|
|
|
+ private static final int[][] IDLE_STRATEGY_SLOTS = {};
|
|
|
+
|
|
|
+ /**
|
|
|
+ * MQTT操作模板(由订阅器建连后绑定,用于策略下发)
|
|
|
+ */
|
|
|
+ private volatile MqttTemplate mqttTemplate;
|
|
|
+
|
|
|
private static final Pattern SUFFIX_PATTERN = Pattern.compile("(\\d+)$");
|
|
|
|
|
|
/**
|
|
|
@@ -272,8 +324,33 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 绑定MQTT操作模板(订阅器建连成功后调用)
|
|
|
+ *
|
|
|
+ * @param template MQTT模板
|
|
|
+ */
|
|
|
+ public void bindMqttTemplate(MqttTemplate template) {
|
|
|
+ this.mqttTemplate = template;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取全部站点(策略下发用)
|
|
|
+ *
|
|
|
+ * @return 站点列表
|
|
|
+ */
|
|
|
+ public List<GccSite> getSites() {
|
|
|
+ return sites;
|
|
|
+ }
|
|
|
+
|
|
|
@Override
|
|
|
public void handle(String topic, byte[] payload) {
|
|
|
+ // 策略下发应答:当前阶段仅记录日志,不走遥测解析链路
|
|
|
+ if (StringUtils.endsWith(topic, TOPIC_SUFFIX_COMMAND_RESPONSE)) {
|
|
|
+ log.info("储能策略下发应答, topic[{}], payload[{}]", topic,
|
|
|
+ new String(payload, StandardCharsets.UTF_8));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
JSONObject root = JSON.parseObject(new String(payload, StandardCharsets.UTF_8));
|
|
|
JSONObject device = root.getJSONObject("device");
|
|
|
@@ -373,6 +450,11 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
Map<String, String> attrs = new HashMap<>();
|
|
|
Map<Integer, FieldDef> indexFields = StringUtils.equals(SERVICE_PCS, serviceCode) ? PCS_FIELDS : BMS_FIELDS;
|
|
|
|
|
|
+ // BMS报文:缓存累计充放电原始值,供当日基线计算
|
|
|
+ if (StringUtils.equals(SERVICE_BMS, serviceCode)) {
|
|
|
+ cacheYtjCum(siteDevice.getDeviceCode(), data);
|
|
|
+ }
|
|
|
+
|
|
|
if (StringUtils.equals(SERVICE_FAULT, serviceCode)) {
|
|
|
for (Map.Entry<String, FieldDef> entry : FAULT_FIELDS.entrySet()) {
|
|
|
String value = convert(data, entry.getKey(), entry.getValue(), 1, 1);
|
|
|
@@ -539,6 +621,30 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 缓存储能一体机BMS累计充放电值(info_7/info_8)
|
|
|
+ *
|
|
|
+ * @param deviceCode 设备代码
|
|
|
+ * @param data 数据体
|
|
|
+ */
|
|
|
+ private void cacheYtjCum(String deviceCode, JSONObject data) {
|
|
|
+ try {
|
|
|
+ BigDecimal cumC = data.getBigDecimal("info_7");
|
|
|
+ BigDecimal cumD = data.getBigDecimal("info_8");
|
|
|
+
|
|
|
+ if (null != cumC) {
|
|
|
+ updateCacheAfterSuccess(deviceCode, READING_CUM_C, cumC.toPlainString());
|
|
|
+ }
|
|
|
+
|
|
|
+ if (null != cumD) {
|
|
|
+ updateCacheAfterSuccess(deviceCode, READING_CUM_D, cumD.toPlainString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (Exception e) {
|
|
|
+ log.error("缓存储能一体机累计充放电值失败 device[{}]", deviceCode, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 储能计量小时产出:按电表累计抄表值计算上一小时充放电增量,按设施汇总写入 adm_ems_elec_store_h
|
|
|
*
|
|
|
* @return 产出条数
|
|
|
@@ -682,32 +788,146 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 周期落库属性值
|
|
|
+ * 周期落库属性值,并刷新当日充放电增量(当前累计值 - 当日零点基线)
|
|
|
*/
|
|
|
@Scheduled(fixedDelayString = "${gcc.mqtt.save-interval-seconds:10}000")
|
|
|
public void flushPending() {
|
|
|
- if (pendingAttrs.isEmpty()) {
|
|
|
- return;
|
|
|
+ if (!pendingAttrs.isEmpty()) {
|
|
|
+ Map<String, Map<String, String>> snapshot = new HashMap<>(pendingAttrs);
|
|
|
+ pendingAttrs.clear();
|
|
|
+
|
|
|
+ for (Map.Entry<String, Map<String, String>> entry : snapshot.entrySet()) {
|
|
|
+ String[] keys = entry.getKey().split(":");
|
|
|
+ String modelCode = keys[0];
|
|
|
+ String objCode = keys[1];
|
|
|
+
|
|
|
+ for (Map.Entry<String, String> attr : entry.getValue().entrySet()) {
|
|
|
+ try {
|
|
|
+ objAttrValueService.mergeObjAttrValue(
|
|
|
+ new EmsObjAttrValue(objCode, modelCode, attr.getKey(), attr.getValue()));
|
|
|
+ }
|
|
|
+ catch (Exception e) {
|
|
|
+ log.error("储能属性值落库失败 obj[{}] key[{}]", objCode, attr.getKey(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- Map<String, Map<String, String>> snapshot = new HashMap<>(pendingAttrs);
|
|
|
- pendingAttrs.clear();
|
|
|
+ refreshDayQuantity();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 刷新当日充放电增量:电表当日正向/反向电能、一体机当日充电量/放电量,
|
|
|
+ * 计算口径 = 当前累计值 - 当日零点基线快照,结果写入物模型属性值(下一周期落库)
|
|
|
+ */
|
|
|
+ private void refreshDayQuantity() {
|
|
|
+ try {
|
|
|
+ List<EmsDevice> meters = getDeviceListByModel(MODEL_DB);
|
|
|
+ List<EmsDevice> ytjs = getDeviceListByModel(MODEL_YTJ);
|
|
|
+ if (CollectionUtils.isEmpty(meters) && CollectionUtils.isEmpty(ytjs)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String baseKey = ensureDayBase(meters, ytjs);
|
|
|
|
|
|
- for (Map.Entry<String, Map<String, String>> entry : snapshot.entrySet()) {
|
|
|
- String[] keys = entry.getKey().split(":");
|
|
|
- String modelCode = keys[0];
|
|
|
- String objCode = keys[1];
|
|
|
+ for (EmsDevice meter : meters) {
|
|
|
+ putDayDelta(baseKey, meter.getDeviceCode(), MODEL_DB, READING_FWD, "dayFwdEnergy");
|
|
|
+ putDayDelta(baseKey, meter.getDeviceCode(), MODEL_DB, READING_REV, "dayRevEnergy");
|
|
|
+ }
|
|
|
|
|
|
- for (Map.Entry<String, String> attr : entry.getValue().entrySet()) {
|
|
|
- try {
|
|
|
- objAttrValueService.mergeObjAttrValue(
|
|
|
- new EmsObjAttrValue(objCode, modelCode, attr.getKey(), attr.getValue()));
|
|
|
- }
|
|
|
- catch (Exception e) {
|
|
|
- log.error("储能属性值落库失败 obj[{}] key[{}]", objCode, attr.getKey(), e);
|
|
|
- }
|
|
|
+ for (EmsDevice ytj : ytjs) {
|
|
|
+ putDayDelta(baseKey, ytj.getDeviceCode(), MODEL_YTJ, READING_CUM_C, "dayChargeKwh");
|
|
|
+ putDayDelta(baseKey, ytj.getDeviceCode(), MODEL_YTJ, READING_CUM_D, "dayDischargeKwh");
|
|
|
}
|
|
|
}
|
|
|
+ catch (Exception e) {
|
|
|
+ log.error("刷新储能当日充放电增量失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 确保当日零点基线快照存在(缺失时以当前累计值拍照,当日增量从拍照时刻起算)
|
|
|
+ *
|
|
|
+ * @param meters 储能电表列表
|
|
|
+ * @param ytjs 储能一体机列表
|
|
|
+ * @return 基线快照redis key
|
|
|
+ */
|
|
|
+ private String ensureDayBase(List<EmsDevice> meters, List<EmsDevice> ytjs) {
|
|
|
+ String baseKey = DAY_BASE_PREFIX + LocalDate.now().format(DAY_FMT);
|
|
|
+
|
|
|
+ if (Boolean.TRUE.equals(redisService.hasKey(baseKey))) {
|
|
|
+ return baseKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ synchronized (this) {
|
|
|
+ if (Boolean.TRUE.equals(redisService.hasKey(baseKey))) {
|
|
|
+ return baseKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ for (EmsDevice meter : meters) {
|
|
|
+ snapshotReading(baseKey, meter.getDeviceCode(), READING_FWD);
|
|
|
+ snapshotReading(baseKey, meter.getDeviceCode(), READING_REV);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (EmsDevice ytj : ytjs) {
|
|
|
+ snapshotReading(baseKey, ytj.getDeviceCode(), READING_CUM_C);
|
|
|
+ snapshotReading(baseKey, ytj.getDeviceCode(), READING_CUM_D);
|
|
|
+ }
|
|
|
+
|
|
|
+ redisService.expire(baseKey, 2, TimeUnit.DAYS);
|
|
|
+ log.info("储能当日基线快照已建立 baseKey[{}]", baseKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ return baseKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 快照单设备累计读数到当日基线
|
|
|
+ *
|
|
|
+ * @param baseKey 基线key
|
|
|
+ * @param deviceCode 设备代码
|
|
|
+ * @param field 累计值缓存字段
|
|
|
+ */
|
|
|
+ private void snapshotReading(String baseKey, String deviceCode, String field) {
|
|
|
+ String value = redisService.getCacheMapValue(deviceCode, field);
|
|
|
+ if (StringUtils.isNotEmpty(value)) {
|
|
|
+ redisService.setCacheMapValue(baseKey, deviceCode + ":" + field, value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算单设备当日增量并放入待落库队列(负值视为表计复位,重置基线并从0起算)
|
|
|
+ *
|
|
|
+ * @param baseKey 基线key
|
|
|
+ * @param deviceCode 设备代码
|
|
|
+ * @param modelCode 设备模型
|
|
|
+ * @param readingField 累计值缓存字段
|
|
|
+ * @param attrKey 当日增量属性key
|
|
|
+ */
|
|
|
+ private void putDayDelta(String baseKey, String deviceCode, String modelCode, String readingField,
|
|
|
+ String attrKey) {
|
|
|
+ String current = redisService.getCacheMapValue(deviceCode, readingField);
|
|
|
+ String base = redisService.getCacheMapValue(baseKey, deviceCode + ":" + readingField);
|
|
|
+ if (StringUtils.isEmpty(current) || StringUtils.isEmpty(base)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ double delta = Double.parseDouble(current) - Double.parseDouble(base);
|
|
|
+
|
|
|
+ if (delta < 0) {
|
|
|
+ log.warn("储能当日增量为负(表计复位/基线异常),重置基线 device[{}] field[{}] base[{}] current[{}]",
|
|
|
+ deviceCode, readingField, base, current);
|
|
|
+ redisService.setCacheMapValue(baseKey, deviceCode + ":" + readingField, current);
|
|
|
+ delta = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ pendingAttrs.computeIfAbsent(modelCode + ":" + deviceCode, k -> new ConcurrentHashMap<>()).put(attrKey,
|
|
|
+ BigDecimal.valueOf(delta).setScale(2, RoundingMode.HALF_UP).toPlainString());
|
|
|
+ }
|
|
|
+ catch (NumberFormatException e) {
|
|
|
+ log.warn("储能累计值解析失败 device[{}] field[{}]", deviceCode, readingField);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -815,9 +1035,102 @@ public class GccHandler extends BaseDevHandler implements MqttMessageHandler {
|
|
|
return matcher.find() ? matcher.group(1) : dataCode;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 能力调用:策略下发(当前支持默认策略/全静置策略)
|
|
|
+ * 链路:前端 -> ems-server能力转发 -> 本方法 -> 逐站点发布command报文 -> EMS应答commandResponse(记日志)
|
|
|
+ *
|
|
|
+ * @param abilityParam 能力参数
|
|
|
+ * @return 调用结果
|
|
|
+ */
|
|
|
@Override
|
|
|
public CallResponse<Void> call(AbilityPayload abilityParam) {
|
|
|
- return new CallResponse<>(-1, "储能系统为订阅采集模式,暂不支持能力下发");
|
|
|
+ if (null == mqttTemplate) {
|
|
|
+ return new CallResponse<>(-1, "储能MQTT通道未就绪");
|
|
|
+ }
|
|
|
+
|
|
|
+ String abilityKey = abilityParam.getAbilityKey();
|
|
|
+ int[][] slots;
|
|
|
+ if (StringUtils.equals(ABILITY_APPLY_DEFAULT_STRATEGY, abilityKey)) {
|
|
|
+ slots = DEFAULT_STRATEGY_SLOTS;
|
|
|
+ }
|
|
|
+ else if (StringUtils.equals(ABILITY_APPLY_STRATEGY_IDLE, abilityKey)) {
|
|
|
+ slots = IDLE_STRATEGY_SLOTS;
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ return new CallResponse<>(-1, "不支持的能力: " + abilityKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (CollectionUtils.isEmpty(sites)) {
|
|
|
+ return new CallResponse<>(-1, "储能站点配置未加载");
|
|
|
+ }
|
|
|
+
|
|
|
+ String payloadJson = buildStrategyPayload(sites.get(0).getDeviceId(), slots);
|
|
|
+ int cnt = 0;
|
|
|
+
|
|
|
+ // 逐站点下发(报文仅deviceId不同,其余一致)
|
|
|
+ for (GccSite site : sites) {
|
|
|
+ if (StringUtils.isEmpty(site.getDeviceId())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ String topic = "/v1/devices/" + site.getDeviceId() + TOPIC_SUFFIX_COMMAND;
|
|
|
+ String msg = buildStrategyPayload(site.getDeviceId(), slots);
|
|
|
+ log.info("储能策略下发, 能力[{}], 站点[{}], topic[{}], payload[{}]", abilityKey, site.getSiteName(),
|
|
|
+ topic, msg);
|
|
|
+ mqttTemplate.sendString(topic, msg, 0, false);
|
|
|
+ cnt++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录能力调用日志(应答异步到达,仅记录下发结果)
|
|
|
+ saveCallLog(abilityParam.getObjCode(), abilityParam.getModelCode(), abilityKey, 0, payloadJson,
|
|
|
+ "已下发" + cnt + "个站点,等待EMS应答");
|
|
|
+ return new CallResponse<>(0, "策略已下发" + cnt + "个站点,等待EMS应答");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建策略报文(平台下行sheet:serviceCode=strategy, code=strategy01, 24时段×5字段)
|
|
|
+ * 时间编码:小时×256+分钟;未定义时段峰谷选择=0(禁止)
|
|
|
+ *
|
|
|
+ * @param deviceId 站点客户端ID
|
|
|
+ * @param slots 有效时段定义(最多24组)
|
|
|
+ * @return 报文JSON字符串
|
|
|
+ */
|
|
|
+ private String buildStrategyPayload(String deviceId, int[][] slots) {
|
|
|
+ JSONObject data = new JSONObject();
|
|
|
+ data.put("code", "strategy01");
|
|
|
+ // info_1 保留
|
|
|
+ data.put("info_1", 0);
|
|
|
+
|
|
|
+ for (int n = 1; n <= 24; n++) {
|
|
|
+ int base = 2 + (n - 1) * 5;
|
|
|
+ int[] slot = (n <= slots.length) ? slots[n - 1] : null;
|
|
|
+
|
|
|
+ if (null == slot) {
|
|
|
+ // 时段禁止:全部填0
|
|
|
+ for (int i = 0; i < 5; i++) {
|
|
|
+ data.put("info_" + (base + i), 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ data.put("info_" + base, slot[0]);
|
|
|
+ data.put("info_" + (base + 1), slot[1] * 256 + slot[2]);
|
|
|
+ data.put("info_" + (base + 2), slot[3] * 256 + slot[4]);
|
|
|
+ data.put("info_" + (base + 3), slot[5]);
|
|
|
+ data.put("info_" + (base + 4), slot[6]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ JSONObject service = new JSONObject();
|
|
|
+ service.put("serviceCode", "strategy");
|
|
|
+ service.put("data", data);
|
|
|
+
|
|
|
+ JSONObject device = new JSONObject();
|
|
|
+ device.put("deviceId", deviceId);
|
|
|
+ device.put("service", service);
|
|
|
+
|
|
|
+ JSONObject root = new JSONObject();
|
|
|
+ root.put("device", device);
|
|
|
+ return root.toJSONString();
|
|
|
}
|
|
|
|
|
|
@Override
|