diff --git a/detection/src/main/java/com/njcn/gather/detection/handler/SocketContrastResponseService.java b/detection/src/main/java/com/njcn/gather/detection/handler/SocketContrastResponseService.java index 1cddf38d..e12c8aff 100644 --- a/detection/src/main/java/com/njcn/gather/detection/handler/SocketContrastResponseService.java +++ b/detection/src/main/java/com/njcn/gather/detection/handler/SocketContrastResponseService.java @@ -29,6 +29,7 @@ import com.njcn.gather.device.pojo.enums.PatternEnum; import com.njcn.gather.device.pojo.vo.PreDetection; import com.njcn.gather.device.service.IPqDevService; import com.njcn.gather.device.service.IPqStandardDevService; +import com.njcn.gather.monitor.service.IPqMonitorService; import com.njcn.gather.plan.pojo.enums.DataSourceEnum; import com.njcn.gather.plan.service.IAdPlanService; import com.njcn.gather.storage.pojo.po.ContrastHarmonicResult; @@ -65,6 +66,7 @@ public class SocketContrastResponseService { private final IAdPlanService adPlanService; private final IDictDataService dictDataService; private final IPqDevService pqDevService; + private final IPqMonitorService pqMonitorService; private final IPqStandardDevService pqStandardDevService; private final IDictTreeService dictTreeService; private final DetectionDataDealService detectionDataDealService; @@ -242,7 +244,6 @@ public class SocketContrastResponseService { FormalTestManager.devIdMapComm.putAll(FormalTestManager.devList.stream().collect(Collectors.toMap(PreDetection::getDevIP, PreDetection::getDevId))); FormalTestManager.devIdMapComm.putAll(FormalTestManager.standardDevList.stream().collect(Collectors.toMap(PreDetection::getDevIP, PreDetection::getDevId))); - FormalTestManager.currentStep = SourceOperateCodeEnum.YJC_SBTXJY; } @@ -1206,11 +1207,14 @@ public class SocketContrastResponseService { WebServiceManager.sendMsg(userId, JSON.toJSONString(webSend)); } + // 获取被检设备的额定电流 + Double ratedCurrent = pqMonitorService.getRatedCurrent(devMonitorId); + // 是否存在电流 boolean notHasCurrent = iDev.stream().allMatch( - p -> p.getList().getA() != null && DetectionUtil.isZero(p.getList().getA()) - && p.getList().getB() != null && DetectionUtil.isZero(p.getList().getB()) - && p.getList().getC() != null && DetectionUtil.isZero(p.getList().getC())); + p -> p.getList().getA() != null && DetectionUtil.isZero(p.getList().getA(),ratedCurrent) + && p.getList().getB() != null && DetectionUtil.isZero(p.getList().getB(),ratedCurrent) + && p.getList().getC() != null && DetectionUtil.isZero(p.getList().getC(),ratedCurrent)); if (!notHasCurrent) { // 相角校验 List vaUnblanceDev = getSingleMonitorSqlData(devData, DetectionCodeEnum.VA.getCode()); diff --git a/detection/src/main/java/com/njcn/gather/detection/util/DetectionUtil.java b/detection/src/main/java/com/njcn/gather/detection/util/DetectionUtil.java index 03cd5165..37ac9607 100644 --- a/detection/src/main/java/com/njcn/gather/detection/util/DetectionUtil.java +++ b/detection/src/main/java/com/njcn/gather/detection/util/DetectionUtil.java @@ -3,59 +3,138 @@ package com.njcn.gather.detection.util; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import com.njcn.gather.detection.pojo.po.DevData; +import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; /** + * 检测工具类 + *

+ * 提供电能质量检测相关的数据处理、时间转换、统计计算等工具方法。 + * 主要功能包括: + *

+ * * @author caozehui - * @data 2025-07-28 + * @version 1.0 + * @since 2025-07-28 */ +@Slf4j public class DetectionUtil { - // ISO 8601格式 + /** + * ISO 8601日期时间格式化器 + * 用于解析和格式化符合ISO 8601标准的日期时间字符串 + */ public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_DATE_TIME; /** - * 相角矫正到统一个区间 + * 毫秒转秒的转换因子 + */ + private static final long MILLIS_TO_SECONDS = 1000L; + + /** + * 时间对齐判断的容差毫秒数 + * 当两个时间戳差值小于此值时,认为时间是对齐的 + */ + private static final long TIME_ALIGNMENT_TOLERANCE_MS = 100L; + + /** + * 角度相关常量 + */ + private static final double ANGLE_180 = 180.0; + private static final double ANGLE_360 = 360.0; + private static final double ANGLE_MINUS_180 = -180.0; + + /** + * CP95算法相关常量 + */ + private static final int CP95_DATA_SIZE_THRESHOLD = 21; + private static final double CP95_PERCENTILE = 0.05; + private static final int CP95_SMALL_DATA_INDEX = 1; + + /** + * 数据处理相关常量 + */ + private static final int MIN_DATA_SIZE_FOR_SECTION_VALUE = 2; + + + /** + * 相角矫正到统一区间[-180°, 180°] + *

+ * 将任意角度值标准化到[-180°, 180°]范围内,便于相角比较和计算。 + * 使用模运算处理任意大小的角度值,能正确处理超出多个360°范围的情况。 + *

+ * 示例: + *

* - * @param phase - * @return + * @param phase 待矫正的相角值(单位:度) + * @return 矫正后的相角值,范围在[-180°, 180°]内 */ public static Double adjustPhase(Double phase) { - if (phase < -180) { - return phase + 360; + if (phase == null) { + return null; } - if (phase > 180) { - return phase - 360; + // 使用模运算将角度标准化到[-180, 180]范围 + double normalizedPhase = phase % ANGLE_360; + // 处理超出[-180, 180]范围的情况 + if (normalizedPhase > ANGLE_180) { + normalizedPhase -= ANGLE_360; + } else if (normalizedPhase < ANGLE_MINUS_180) { + normalizedPhase += ANGLE_360; } - return phase; + + return normalizedPhase; } /** - * 判断数据是否对齐 + * 判断被检设备数据与标准设备数据的时间是否对齐 + *

+ * 数据对齐的判断标准(满足任一条件即可): + * 1. 将时间戳四舍五入到秒级精度后完全相等(处理跨秒边界情况) + * 2. 两个时间戳的毫秒差值小于容差值(处理同秒内的精确对齐) + *

+ * 示例:499ms vs 509ms → 四舍五入后不同秒,但差值仅10ms < 100ms → 对齐 * - * @param devData 被检设备数据 - * @param standardDevData 标准设备数据 - * @return + * @param devData 被检设备数据,包含时间戳信息 + * @param standardDevData 标准设备数据,包含时间戳信息 + * @return true表示数据时间对齐,false表示不对齐 */ public static boolean isAlignData(DevData devData, DevData standardDevData) { if (ObjectUtil.isNotNull(devData) && ObjectUtil.isNotNull(standardDevData)) { - + // 获取两个设备数据的时间戳(毫秒) long devMillis = getMillis(devData.getTime()); long standardMillis = getMillis(standardDevData.getTime()); - if (BigDecimal.valueOf(devMillis).divide(BigDecimal.valueOf(1000), 0, BigDecimal.ROUND_HALF_UP).compareTo(BigDecimal.valueOf(standardMillis).divide(BigDecimal.valueOf(1000), 0, BigDecimal.ROUND_HALF_UP)) == 0) { + + // 方式1:将时间戳转换为秒级精度进行比较(处理跨秒边界情况) + BigDecimal devSeconds = BigDecimal.valueOf(devMillis).divide(BigDecimal.valueOf(MILLIS_TO_SECONDS), 0, RoundingMode.HALF_UP); + BigDecimal standardSeconds = BigDecimal.valueOf(standardMillis).divide(BigDecimal.valueOf(MILLIS_TO_SECONDS), 0, RoundingMode.HALF_UP); + if (devSeconds.compareTo(standardSeconds) == 0) { return true; - } else if (Math.abs(devMillis - standardMillis) < 100) { + } + + // 方式2:毫秒级时间差小于容差值也认为是对齐的(处理精确对齐) + if (Math.abs(devMillis - standardMillis) < TIME_ALIGNMENT_TOLERANCE_MS) { return true; } } @@ -63,167 +142,244 @@ public class DetectionUtil { } /** - * 将字符串日期时间转换为指定格式的LocalDateTime + * 将字符串日期时间转换为LocalDateTime对象 + *

+ * 使用指定的格式解析时间字符串,支持带时区的ISO 8601格式。 + * 解析时会将时间统一转换为UTC时区的LocalDateTime。 * - * @param dateTimeStr - * @param formatter - * @return + * @param dateTimeStr 日期时间字符串,应符合指定格式 + * @param formatter 时间格式化器,用于解析字符串 + * @return 解析成功返回LocalDateTime对象,解析失败返回null */ public static LocalDateTime timeFormat(String dateTimeStr, DateTimeFormatter formatter) { try { + // 使用UTC时区解析时间字符串 ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStr, formatter.withZone(ZoneId.of("UTC"))); - LocalDateTime localDateTime = zonedDateTime.toLocalDateTime(); - return localDateTime; + // 转换为LocalDateTime对象 + return zonedDateTime.toLocalDateTime(); } catch (DateTimeParseException e) { - System.err.println("日期时间字符串格式错误: " + e.getMessage()); + log.error("日期时间字符串格式错误: {}", e.getMessage()); return null; } } /** - * 获取字符串日期时间对应的毫秒数 + * 获取字符串日期时间对应的UTC毫秒时间戳 + *

+ * 使用默认的ISO_DATE_TIME格式解析时间字符串, + * 并转换为UTC时区的毫秒时间戳。 * - * @param dateTimeStr - * @return + * @param dateTimeStr ISO 8601格式的日期时间字符串 + * @return UTC时区的毫秒时间戳 */ public static long getMillis(String dateTimeStr) { + if (dateTimeStr == null || dateTimeStr.trim().isEmpty()) { + throw new IllegalArgumentException("日期时间字符串不能为空"); + } LocalDateTime localDateTime = timeFormat(dateTimeStr, FORMATTER); + if (localDateTime == null) { + throw new IllegalArgumentException("无法解析日期时间字符串: " + dateTimeStr); + } return getMillis(localDateTime); } /** - * 获取LocalDateTime的所对应的毫秒数 + * 获取LocalDateTime对应的UTC毫秒时间戳 + *

+ * 将LocalDateTime对象转换为UTC时区的毫秒时间戳。 * - * @param localDateTime - * @return + * @param localDateTime 本地日期时间对象 + * @return UTC时区的毫秒时间戳 */ public static long getMillis(LocalDateTime localDateTime) { + if (localDateTime == null) { + throw new IllegalArgumentException("LocalDateTime参数不能为null"); + } return localDateTime.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); } /** - * 判断value是否为0 + * 判断数值是否为零(在容差范围内) + *

+ * 使用BigDecimal进行精确计算,避免浮点数精度问题。 + * 当数值的绝对值小于预设阈值(0.01)时,认为该数值为零。 + * 主要用于电流等物理量的零值判断。 * - * @param value - * @return + * @param value 待判断的数值,null值被认为是零 + * @param ratedCurrent 额定电流,用于计算阈值 + * @return true表示数值为零(在容差范围内),false表示非零 */ - public static boolean isZero(Double value) { - // todo 电流为0判断 - - BigDecimal bd = BigDecimal.valueOf(value); - if (bd.subtract(BigDecimal.ZERO).abs().compareTo(BigDecimal.valueOf(0.01)) < 0) { + public static boolean isZero(Double value, Double ratedCurrent) { + if (value == null) { return true; } - return false; + double threshold = 0.01 * ratedCurrent; + BigDecimal bd = BigDecimal.valueOf(value); + return bd.subtract(BigDecimal.ZERO).abs().compareTo(BigDecimal.valueOf(threshold)) < 0; } /** - * 获取CP95值 + * 获取CP95分位数值 + *

+ * CP95表示95%分位数,即有95%的数据小于等于此值。 + * 算法逻辑: + *

* - * @param t - * @return + * @param t 已排序的数据列表(从大到小排序) + * @return CP95分位数值列表,包含单个元素 */ public static List getCP95Doubles(List t) { - if (CollUtil.isNotEmpty(t)) { - if (t.size() < 21) { - if (t.size() == 1) { - return t; - } - if (t.size() > 1) { - return t.subList(1, 2); - } - } else { - int v = (int) (t.size() * 0.5); - return t.subList(v, v + 1); - } + if (CollUtil.isEmpty(t)) { + return new ArrayList<>(); } - return t; + + // 单个数据直接返回 + if (t.size() == 1) { + return new ArrayList<>(t); + } + + // 数据量较少时,取第2个数据作为CP95值 + if (t.size() < CP95_DATA_SIZE_THRESHOLD) { + return t.subList(CP95_SMALL_DATA_INDEX, CP95_SMALL_DATA_INDEX + 1); + } + + // 数据量充足时,计算真正的95%分位数 + // 由于数据已从大到小排序,95%分位数位于5%位置 + int cp95Index = (int) Math.ceil(t.size() * CP95_PERCENTILE) - 1; + return t.subList(cp95Index, cp95Index + 1); } /** - * 获取CP95值所在索引 + * 获取CP95分位数值在列表中的索引位置 + *

+ * 计算CP95分位数在已排序列表中的索引位置。 + * 索引计算规则与getCP95Doubles方法保持一致。 * - * @param t - * @return + * @param t 已排序的数据列表(从大到小排序) + * @return CP95分位数的索引位置,列表为空时返回-1 */ public static int getCP95Idx(List t) { - if (CollUtil.isNotEmpty(t)) { - if (t.size() < 21) { - if (t.size() == 1) { - return 0; - } - if (t.size() > 1) { - return 1; - } - } else { - int v = (int) (t.size() * 0.5); - return v; - } + if (CollUtil.isEmpty(t)) { + return -1; } - return -1; + + // 单个数据返回索引0 + if (t.size() == 1) { + return 0; + } + + // 数据量较少时,返回索引1 + if (t.size() < CP95_DATA_SIZE_THRESHOLD) { + return CP95_SMALL_DATA_INDEX; + } + + // 数据量充足时,计算95%分位数的索引位置 + // 由于数据已从大到小排序,95%分位数索引为5%位置 + return (int) Math.ceil(t.size() * CP95_PERCENTILE) - 1; } /** - * 获取部分值 + * 获取部分值(去除最大最小值后的数据) + *

+ * 用于数据预处理,去除可能的异常值。 + * 算法逻辑: + *

    + *
  • 数据量≤2时:返回原数据副本
  • + *
  • 数据量>2时:移除一个最大值和一个最小值后返回剩余数据
  • + *
+ * 注意:该方法不会修改原始列表,而是返回新的列表。 * - * @param t - * @return + * @param t 原始数据列表 + * @return 去除最大最小值后的数据列表副本 */ public static List getSectionValueDoubles(List t) { - if (CollUtil.isNotEmpty(t)) { - if (t.size() > 2) { - Double max = Collections.max(t); - Double min = Collections.min(t); - t.remove(max); - t.remove(min); - } + if (CollUtil.isEmpty(t) || t.size() <= MIN_DATA_SIZE_FOR_SECTION_VALUE) { + return new ArrayList<>(t); } - return t; + + // 创建副本避免修改原始列表 + List result = new ArrayList<>(t); + Double max = Collections.max(result); + Double min = Collections.min(result); + result.remove(max); + result.remove(min); + return result; } /** - * 获取平均值 + * 计算数据列表的算术平均值 + *

+ * 对输入的数值列表计算算术平均值,并以单元素列表形式返回。 + * 空列表会返回空列表。 * - * @param t - * @return + * @param t 数值列表 + * @return 包含平均值的单元素列表,输入为空时返回空列表 */ public static List getAvgDoubles(List t) { - if (CollUtil.isNotEmpty(t)) { - t = Arrays.asList(t.stream().mapToDouble(Double::doubleValue).average().orElse(0.0)); + if (CollUtil.isEmpty(t)) { + return new ArrayList<>(); } - return t; + + // 计算列表中所有数值的算术平均值 + double average = t.stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + // 将平均值包装为单元素列表返回 + return Collections.singletonList(average); } /** - * 对list进行排序,并返回排序后的索引序列 + * 对数据列表进行排序并返回原始索引序列 + *

+ * 使用选择排序算法对列表进行排序,同时跟踪每个元素的原始索引位置。 + * 这样可以在数据排序后仍然知道每个数据在原始列表中的位置。 * - * @param list - * @param isAsc 是否升序 - * @return + * 注意:该方法会直接修改输入的列表。 + * + * @param list 待排序的数据列表(会被直接修改) + * @param isAsc 排序方式,true为升序,false为降序 + * @return 排序后各元素在原始列表中的索引位置 */ public static List sort(List list, Boolean isAsc) { + if (CollUtil.isEmpty(list)) { + return new ArrayList<>(); + } + if (isAsc == null) { + throw new IllegalArgumentException("排序方式参数不能为null"); + } + // 创建索引列表,记录每个元素的原始位置 List indexList = Stream.iterate(0, i -> i + 1).limit(list.size()).collect(Collectors.toList()); + // 使用选择排序算法,同时维护索引映射 for (int i = 0; i < list.size(); i++) { - int maxIdx = i; + // 当前轮次要放置的目标位置 + int targetIdx = i; + // 在未排序部分寻找最值 for (int j = i + 1; j < list.size(); j++) { if (isAsc) { - if (list.get(j) < list.get(maxIdx)) { - maxIdx = j; + // 升序:寻找最小值 + if (list.get(j) < list.get(targetIdx)) { + targetIdx = j; } } else { - if (list.get(j) > list.get(maxIdx)) { - maxIdx = j; + // 降序:寻找最大值 + if (list.get(j) > list.get(targetIdx)) { + targetIdx = j; } } } - if (maxIdx != i) { + // 交换数据值和对应的索引 + if (targetIdx != i) { + // 交换数据值 double temp = list.get(i); - list.set(i, list.get(maxIdx)); - list.set(maxIdx, temp); + list.set(i, list.get(targetIdx)); + list.set(targetIdx, temp); + // 交换对应的原始索引 int tempIdx = indexList.get(i); - indexList.set(i, indexList.get(maxIdx)); - indexList.set(maxIdx, tempIdx); + indexList.set(i, indexList.get(targetIdx)); + indexList.set(targetIdx, tempIdx); } } return indexList; diff --git a/detection/src/main/java/com/njcn/gather/monitor/service/IPqMonitorService.java b/detection/src/main/java/com/njcn/gather/monitor/service/IPqMonitorService.java index 32d82a5d..501bd95a 100644 --- a/detection/src/main/java/com/njcn/gather/monitor/service/IPqMonitorService.java +++ b/detection/src/main/java/com/njcn/gather/monitor/service/IPqMonitorService.java @@ -1,6 +1,5 @@ package com.njcn.gather.monitor.service; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.njcn.gather.monitor.pojo.param.PqMonitorParam; import com.njcn.gather.monitor.pojo.po.PqMonitor; @@ -62,4 +61,18 @@ public interface IPqMonitorService extends IService { * @param monitorList */ void reverseVisualizeMonitor(List monitorList); + + /** + * 根据被检设备id获取额定电流 + * @param devMonitorId 被检设备id + * @return 额定电流 + */ + Double getRatedCurrent(String devMonitorId); + + /** + * 根据被检设备id获取额定电压 + * @param devMonitorId 被检设备id + * @return 额定电压 + */ + Double getRatedVoltage(String devMonitorId); } diff --git a/detection/src/main/java/com/njcn/gather/monitor/service/impl/PqMonitorServiceImpl.java b/detection/src/main/java/com/njcn/gather/monitor/service/impl/PqMonitorServiceImpl.java index ff1a5a4f..a31b2090 100644 --- a/detection/src/main/java/com/njcn/gather/monitor/service/impl/PqMonitorServiceImpl.java +++ b/detection/src/main/java/com/njcn/gather/monitor/service/impl/PqMonitorServiceImpl.java @@ -2,6 +2,7 @@ package com.njcn.gather.monitor.service.impl; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.njcn.common.pojo.exception.BusinessException; @@ -84,4 +85,30 @@ public class PqMonitorServiceImpl extends ServiceImpl