diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/controller/VibrationBoardController.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/controller/VibrationBoardController.java new file mode 100644 index 0000000..7f2fbd2 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/controller/VibrationBoardController.java @@ -0,0 +1,108 @@ +package org.dromara.ems.report.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAdvancedPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationComparisonPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationOverviewPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationQualityPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationTrendPageVo; +import org.dromara.ems.report.service.IVibrationBoardService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 振动看板 Controller。 + * + *

这里单独暴露振动专用接口,而不是继续借道通用物联列表接口, + * 是为了让前端调用关系、权限点和后端 SQL 口径都能围绕“振动报表”独立演进。

+ * + *

按页面粒度拆分为 7 个 GET 接口,而不是合并为一个大接口,是因为: + * 1) 每个页面的数据结构和计算逻辑差异大,合并会导致返回体臃肿; + * 2) 前端每个页面独立路由,只调自己对应的接口,减少无效数据传输; + * 3) 后续可对各接口独立做缓存、限流等运维策略。 + * 所有接口统一使用同一权限点,简化菜单权限配置。

+ */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/ems/report/vibrationBoard") +public class VibrationBoardController { + + /** 振动看板服务——所有业务逻辑收敛在 Service 层,Controller 只做参数透传 */ + private final IVibrationBoardService vibrationBoardService; + + /** + * 总览页:指标卡、仪表盘、设备排名,帮助运维人员一屏掌握振动全局态势。 + *

这是用户打开振动报表后的第一个接口调用,必须快速响应。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/overview") + public R overview(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listOverviewData(bo)); + } + + /** + * 趋势页:单设备多维趋势 / 多设备同维趋势 + 小时均值柱状图。 + *

用于观察振动随时间的变化规律,后端已做趋势点压缩以避免浏览器渲染卡顿。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/trend") + public R trend(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listTrendData(bo)); + } + + /** + * 对比页:设备排名 + 散点分布。 + *

用于横向比较不同设备振动水平高低,后端已限制最多展示设备数以保证图表可读性。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/comparison") + public R comparison(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listComparisonData(bo)); + } + + /** + * 质量页:各振动指标有效采集率。 + *

用于评估传感器工况和数据完整度,而非振动值本身的好坏。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/quality") + public R quality(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listQualityData(bo)); + } + + /** + * 分布页:四分位区间、直方图、日历/小时热力图。 + *

用于掌握振动数值的统计分布特征,所有聚合均在后端完成。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/distribution") + public R distribution(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listDistributionData(bo)); + } + + /** + * 异常页:高风险/连续超标/突变/抖动四类异常事件。 + *

用于预警故障和运维巡检,阈值支持前端传参覆盖默认值。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/anomaly") + public R anomaly(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listAnomalyData(bo)); + } + + /** + * 高级页:桑基图/矩形树图/平行坐标。 + *

用于多设备多维度交叉画像,帮助发现设备群体中的振动规律。

+ */ + @SaCheckPermission("ems/report:vibrationBoard:query") + @GetMapping("/advanced") + public R advanced(VibrationBoardQueryBo bo) { + return R.ok(vibrationBoardService.listAdvancedData(bo)); + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/VibrationBoardQueryBo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/VibrationBoardQueryBo.java new file mode 100644 index 0000000..6fe2794 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/VibrationBoardQueryBo.java @@ -0,0 +1,97 @@ +package org.dromara.ems.report.domain.bo; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动看板查询参数。 + * + *

单独抽 BO,而不是继续复用通用物联查询对象, + * 是为了把振动专属的时间、设备、抽样和主看指标口径固定下来, + * 后续再加振动专属统计时不会和其它物联页面互相污染参数语义。

+ * + *

异常相关参数(highThreshold、warningThreshold 等)仅在异常页使用, + * 其余页面会自动忽略这些字段。这样设计是为了让 7 个页面共用同一个 BO, + * 避免为每个页面单独建查询对象导致文件数量失控。

+ */ +@Data +public class VibrationBoardQueryBo { + + /** + * 单设备编码。 + *

前端点击设备树叶子节点时传入,进入“单设备趋势”模式。 + * Service 层会将其合并进 monitorIds 列表,统一用 IN 查询以简化 SQL 分支。

+ */ + private String monitorId; + + /** + * 多设备编码列表。 + *

前端点击设备树父节点时传入,进入“多设备对比”模式。 + * 与 monitorId 可同时存在,Service 层会去重合并。

+ */ + private List monitorIds; + + /** + * 开始记录时间,格式 yyyy-MM-dd HH:mm:ss。 + *

必填,未传时 Service 层会 fail fast 报错。 + * 前端默认填充“最近 24 小时”,避免菜单首次打开时空白。

+ */ + private String beginRecordTime; + + /** + * 结束记录时间,格式 yyyy-MM-dd HH:mm:ss。 + *

必填,且与 beginRecordTime 的跨度不得超过 90 天。

+ */ + private String endRecordTime; + + /** + * 抽样间隔(分钟)。 + *

默认 1(不抽样),大于 1 时后端走 ROW_NUMBER 窗口抽样 SQL, + * 在数据库层就压缩数据量,减少网络传输和 Java 内存消耗。 + * 上限 1440(即一天),避免误传极端值后抽成“无数据”。

+ */ + private Integer samplingInterval; + + /** + * 主看振动指标。 + *

可选值:vibrationSpeed / vibrationDisplacement / vibrationAcceleration / vibrationTemp。 + * 未传时默认 vibrationSpeed(振动速度是最常用的振动评估指标)。 + * 传入非法值时 Service 层会 fail fast 报错。

+ */ + private String vibrationParam; + + /** + * 高风险阈值(仅异常页使用)。 + *

前端可不传,后端会根据主看指标自动填充经验默认值(如振动速度 7.1mm/s, + * 对应 ISO 10816-3 D 区边界)。前端传参可覆盖默认值,实现“可配置不硬编码”。

+ */ + private BigDecimal highThreshold; + + /** + * 预警阈值(仅异常页使用)。 + *

用于“连续超标”检测,低于高风险阈值但持续偏高本身就是预警信号。

+ */ + private BigDecimal warningThreshold; + + /** + * 连续超标最小样本数(仅异常页使用)。 + *

默认 3,即连续 3 个采样点超过预警阈值才记为一次连续超标事件。

+ */ + private Integer minContinuousSamples; + + /** + * 变化过快阈值(仅异常页使用)。 + *

相邻两个采样点的差值超过此值时记为“变化过快”事件, + * 用于检测突发故障场景。

+ */ + private BigDecimal rapidRiseThreshold; + + /** + * 抖动阈值——标准差(仅异常页使用)。 + *

某小时内指标值的标准差超过此值时记为“抖动异常”, + * 用于检测传感器不稳定或设备间歇性异常振动场景。

+ */ + private BigDecimal stddevThreshold; +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAdvancedPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAdvancedPageVo.java new file mode 100644 index 0000000..ad1e9d1 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAdvancedPageVo.java @@ -0,0 +1,110 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动高级页聚合结果。 + * + *

高级页提供多设备多维度交叉画像,包括: + * 桑基图(设备→振动等级流向)、矩形树图(设备振动量级占比)、 + * 平行坐标(四维振动指标综合画像)。 + * 后端用均值的 33%/66% 分位将设备分为“平稳/关注/高位”三级, + * 不使用绝对阈值是因为不同指标量纲差异大,相对分位更具可比性。

+ */ +@Data +public class VibrationAdvancedPageVo { + + /** 当前主看指标字段名 */ + private String metricField; + + /** 主看指标显示标签 */ + private String metricLabel; + + /** 主看指标单位 */ + private String unit; + + /** “平稳”与“关注”的分界线(33% 分位),前端可用于图例着色 */ + private BigDecimal lowBandUpper; + + /** “关注”与“高位”的分界线(66% 分位) */ + private BigDecimal focusBandUpper; + + /** 桑基图节点:设备名 + 流向阶段(平稳/关注/高位) */ + private List sankeyNodes; + + /** 桑基图连线:设备→等级的流量关系 */ + private List sankeyLinks; + + /** 矩形树图节点:每台设备一个矩形,面积正比于振动均值 */ + private List treemapItems; + + /** 平行坐标轴:四个振动维度各一根轴,上限放大 20% 避免贴边 */ + private List parallelAxes; + + /** 平行坐标系列:每台设备一条折线,四个维度均值串联 */ + private List parallelSeries; + + /** + * 桑基图节点:包含设备名和流向阶段名(平稳/关注/高位)。 + */ + @Data + public static class SankeyNodeItem { + /** 节点名称:设备名或阶段名 */ + private String name; + } + + /** + * 桑基图连线:每台设备流向其所属的振动等级。 + */ + @Data + public static class SankeyLinkItem { + /** 源节点:设备名 */ + private String source; + /** 目标节点:等级名(平稳/关注/高位) */ + private String target; + /** 流量值:采样点数,体现设备数据量 */ + private Integer value; + } + + /** + * 矩形树图项:每台设备一个矩形,面积代表振动量级。 + */ + @Data + public static class TreemapItem { + /** 设备名称 */ + private String name; + /** 主看指标均值,映射为矩形面积 */ + private BigDecimal value; + /** 等级标签(平稳/关注/高位),可用于颜色区分 */ + private String levelTag; + } + + /** + * 平行坐标轴项:四个振动维度各一根轴。 + */ + @Data + public static class ParallelAxisItem { + /** 维度索引(0~3),对应速度/位移/加速度/温度 */ + private Integer dim; + /** 轴名称(指标标签) */ + private String name; + /** 轴上限,已放大 20% 避免数据点贴边 */ + private BigDecimal max; + } + + /** + * 平行坐标系列项:每台设备一条折线,串联四个维度均值。 + */ + @Data + public static class ParallelSeriesItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称,用于 tooltip */ + private String monitorName; + /** 四维均值数组,顺序与 parallelAxes 一一对应 */ + private List values; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java new file mode 100644 index 0000000..8cc9987 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java @@ -0,0 +1,135 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 振动异常页聚合结果。 + * + *

异常页是振动报表的核心预警能力,覆盖四类典型异常场景: + * 1) 单点高风险——立即报警场景 + * 2) 连续超标——慢性劣化场景(如轴承磨损) + * 3) 变化过快——突发故障场景 + * 4) 抖动异常——传感器不稳定或设备间歇性异常场景 + * 阈值支持前端传参,后端负责默认值兜底与事件识别。

+ */ +@Data +public class VibrationAnomalyPageVo { + + /** 当前主看指标字段名 */ + private String metricField; + + /** 主看指标显示标签 */ + private String metricLabel; + + /** 主看指标单位 */ + private String unit; + + /** 实际生效的高风险阈值——回传前端便于显示当前生效值并允许用户调整后重新查询 */ + private BigDecimal highThreshold; + + /** 实际生效的预警阈值 */ + private BigDecimal warningThreshold; + + /** 实际生效的变化过快阈值 */ + private BigDecimal rapidRiseThreshold; + + /** 实际生效的抖动标准差阈值 */ + private BigDecimal stddevThreshold; + + /** 实际生效的连续超标最小样本数 */ + private Integer minContinuousSamples; + + /** 高风险事件总数,供前端汇总卡片展示 */ + private Integer highEventCount; + + /** 连续超标事件总数 */ + private Integer continuousEventCount; + + /** 变化过快事件总数 */ + private Integer rapidRiseEventCount; + + /** 抖动异常事件总数 */ + private Integer jitterEventCount; + + /** 高风险事件列表:单点值 >= highThreshold 的记录,按值倒序取 TOP200 */ + private List highEvents; + + /** 连续超标事件列表:连续多个采样点超过预警阈值 */ + private List continuousEvents; + + /** 变化过快事件列表:相邻采样点差值 >= rapidRiseThreshold */ + private List rapidRiseEvents; + + /** 抖动异常事件列表:某小时内标准差 >= stddevThreshold */ + private List jitterEvents; + + /** + * 高风险事件项:单点值超过高阈值,对应“立即报警”场景。 + */ + @Data + public static class HighEventItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称 */ + private String monitorName; + /** 触发高风险的指标值 */ + private BigDecimal value; + /** 事件发生时刻 */ + private Date recodeTime; + } + + /** + * 连续超标事件项:连续 N 个采样点超过预警阈值,对应“慢性劣化”场景。 + */ + @Data + public static class ContinuousEventItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称 */ + private String monitorName; + /** 连续超标段开始时刻 */ + private Date startTime; + /** 连续超标段结束时刻 */ + private Date endTime; + /** 段内峰值 */ + private BigDecimal maxValue; + /** 连续超标的采样点数,越长说明劣化越严重 */ + private Integer sampleCount; + } + + /** + * 变化过快事件项:相邻采样点差值超限,对应“突发故障”场景。 + */ + @Data + public static class RapidRiseEventItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称 */ + private String monitorName; + /** 相邻两点的差值 */ + private BigDecimal diff; + /** 突变发生时刻(后一个点的时间) */ + private Date recodeTime; + } + + /** + * 抖动异常事件项:某小时内标准差超限,对应“传感器不稳定”或“设备间歇性异常”场景。 + */ + @Data + public static class JitterEventItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称 */ + private String monitorName; + /** 小时桶标签,如 "2026-04-01 08:00:00" */ + private String hourBucket; + /** 该小时内指标值的标准差 */ + private BigDecimal stddev; + /** 该小时内的采样点数 */ + private Integer sampleCount; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationComparisonPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationComparisonPageVo.java new file mode 100644 index 0000000..b756751 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationComparisonPageVo.java @@ -0,0 +1,65 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动对比页聚合结果。 + * + *

对比页用于横向比较不同设备的振动水平, + * 提供“排名柱状图”和“散点分布图”两种视角, + * 帮助运维快速识别振动异常设备。 + * 后端已限制最多展示设备数,避免图表难以辨认。

+ */ +@Data +public class VibrationComparisonPageVo { + + /** 当前主看指标字段名 */ + private String metricField; + + /** 主看指标显示标签 */ + private String metricLabel; + + /** 主看指标单位 */ + private String unit; + + /** 排名柱状图数据:按均值降序 + latest 辅助,用于“哪台振动最高”的第一印象 */ + private List rankItems; + + /** 散点图数据:X=均值 Y=峰值,离群点代表“偏高且尖峰明显”的设备 */ + private List scatterItems; + + /** + * 排名项:用于柱状图横向对比各设备振动水平。 + */ + @Data + public static class RankItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称,作为柱状图的类别轴标签 */ + private String monitorName; + /** 主看指标均值,作为柱状高度 */ + private BigDecimal avg; + /** 最新值,辅助参考 */ + private BigDecimal latest; + } + + /** + * 散点项:用于散点图观察“均值 vs 峰值”的分布关系,辅助发现离群设备。 + */ + @Data + public static class ScatterItem { + /** 设备编码 */ + private String monitorId; + /** 设备名称,用于 tooltip 展示 */ + private String monitorName; + /** X 轴:均值 */ + private BigDecimal avg; + /** Y 轴:峰值 */ + private BigDecimal max; + /** 采样点数,可映射为散点大小以体现数据量 */ + private Integer sampleCount; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationDistributionPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationDistributionPageVo.java new file mode 100644 index 0000000..3fdfcb9 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationDistributionPageVo.java @@ -0,0 +1,88 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动分布页聚合结果。 + * + *

分布页提供四类聚合视图,帮助运维从不同角度理解振动数据的统计特征: + * 四分位区间看“数据集中在哪里”、直方图看“分布形态”、 + * 日历热力图看“哪天偏高”、小时热力图看“一天中哪个时段偏高”。 + * 所有聚合均在后端完成,前端不再基于原始点位做分桶和热力聚合。

+ */ +@Data +public class VibrationDistributionPageVo { + + /** 当前主看指标字段名 */ + private String metricField; + + /** 主看指标显示标签 */ + private String metricLabel; + + /** 主看指标单位 */ + private String unit; + + /** 四分位区间分布(P25/P50/P75),直观看出数据集中在哪个范围 */ + private List intervalBuckets; + + /** 等宽直方图桶,补充四分位无法展示的尾部分布形态 */ + private List histogramBuckets; + + /** 日历热力图:按天聚合均值,观察“哪天振动明显偏高” */ + private List calendarHeatmap; + + /** 小时热力图:按“日期×小时”二维聚合,观察振动在一天中的分布规律 */ + private List hourlyHeatmap; + + /** + * 四分位区间项:将数据按 P25/P50/P75 分为四段, + * 相比等宽直方图更能反映“大多数数据集中在哪里”。 + */ + @Data + public static class IntervalBucketItem { + /** 区间标签,如 "<= 2.50" 或 "2.50 ~ 5.00" */ + private String label; + /** 落入该区间的采样点数 */ + private Long count; + } + + /** + * 直方图桶项:按等宽分桶展示数值分布形态。 + */ + @Data + public static class HistogramBucketItem { + /** 桶左边界(含) */ + private BigDecimal startValue; + /** 桶右边界(不含,最后一个桶除外) */ + private BigDecimal endValue; + /** 落入该桶的采样点数 */ + private Long count; + } + + /** + * 日历热力图项:按天聚合均值,便于与生产排班或设备检修事件关联分析。 + */ + @Data + public static class CalendarHeatmapItem { + /** 统计日期,格式 yyyy-MM-dd */ + private String statDate; + /** 当天所有采样点的指标均值 */ + private BigDecimal avgValue; + } + + /** + * 小时热力图项:按“日期×小时”聚合,同一小时不同天的数据不会混淆。 + */ + @Data + public static class HourlyHeatmapItem { + /** 统计日期,格式 yyyy-MM-dd */ + private String statDate; + /** 小时(0~23) */ + private Integer statHour; + /** 该日该小时所有采样点的均值 */ + private BigDecimal avgValue; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationOverviewPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationOverviewPageVo.java new file mode 100644 index 0000000..81c31e3 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationOverviewPageVo.java @@ -0,0 +1,115 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动总览页聚合结果。 + * + *

总览页是运维人员打开振动报表后的第一屏, + * 需要用尽可能少的数据结构传达“全局振动态势”, + * 因此 VO 同时包含指标卡片、仪表盘、主看统计和设备排名四类数据, + * 前端无需二次计算即可直接渲染。

+ */ +@Data +public class VibrationOverviewPageVo { + + /** 当前主看振动指标的 Java 字段名,如 vibrationSpeed,便于前端识别当前统计维度 */ + private String metricField; + + /** 主看指标的显示标签(如“振动速度”),由后端统一编排,避免前后端标签不一致 */ + private String metricLabel; + + /** 主看指标的单位(如 mm/s、um、g、℃),配合 label 一起用于图表坐标轴和卡片展示 */ + private String unit; + + /** 本次查询返回的采样点总数,帮助运维判断数据量级是否合理 */ + private Integer sampleCount; + + /** 本次查询涉及的振动设备台数,用于区分“单设备”和“多设备”展示模式 */ + private Integer deviceCount; + + /** 采集覆盖率 = 所有指标有效值总数 / (样本数 × 指标数),衡量传感器整体采集完整度 */ + private BigDecimal coverageRate; + + /** 四个振动维度各一张指标卡片,含 latest/avg/max 三个统计值 */ + private List metricCards; + + /** 仪表盘数据:单设备看多维度、多设备看群组均值,与设备数量动态适配 */ + private List gaugeItems; + + /** 主看指标的 latest/min/avg/max 四值统计,供前端数字卡片展示 */ + private PrimaryMetricStats primaryMetricStats; + + /** 设备排名——按主看指标均值倒序,帮助快速定位振动最剧烈的设备 */ + private List deviceRanks; + + /** + * 指标卡片项:每个振动维度一张卡片,让运维人员快速浏览全部振动维度的当前状态。 + */ + @Data + public static class MetricCardItem { + /** 指标字段名,如 vibrationSpeed,前端用于匹配图表数据源 */ + private String field; + /** 显示标签,如“振动速度” */ + private String label; + /** 单位,如 mm/s */ + private String unit; + /** 时间上最新一条记录的值(非数值最大值),体现“当前状态” */ + private BigDecimal latest; + /** 时间范围内均值,体现“整体水平” */ + private BigDecimal avg; + /** 时间范围内峰值,体现“最坏情况” */ + private BigDecimal max; + } + + /** + * 仪表盘项:模拟工业仪表盘,直观展示当前值在量程中的位置。 + */ + @Data + public static class GaugeItem { + /** 仪表名称,单设备时为指标名、多设备时为“群组均值” */ + private String name; + /** 当前值(单设备取 latest,多设备取群组均值) */ + private BigDecimal value; + /** 仪表上限,已放大 20% 避免指针贴边 */ + private BigDecimal maxValue; + private String unit; + } + + /** + * 主看指标统计四值,用于页面顶部数字卡片展示。 + */ + @Data + public static class PrimaryMetricStats { + /** 时间上最新一条的值,代表“此刻状态” */ + private BigDecimal latest; + /** 时间范围内最小值 */ + private BigDecimal min; + /** 时间范围内均值 */ + private BigDecimal avg; + /** 时间范围内峰值 */ + private BigDecimal max; + } + + /** + * 设备排名项:按主看指标均值倒序,帮助运维快速定位需要关注的设备。 + */ + @Data + public static class DeviceRankItem { + /** 设备编码,用于前端点击跳转或联动查询 */ + private String monitorId; + /** 设备名称,用于列表和图表展示 */ + private String monitorName; + /** 主看指标均值,排序依据 */ + private BigDecimal avg; + /** 最新值 */ + private BigDecimal latest; + /** 峰值 */ + private BigDecimal max; + /** 采样点数,辅助判断数据可信度 */ + private Integer sampleCount; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationQualityPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationQualityPageVo.java new file mode 100644 index 0000000..0cc4cc4 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationQualityPageVo.java @@ -0,0 +1,46 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动质量页聚合结果。 + * + *

质量页用于评估传感器工况和数据完整度, + * 而不是振动值本身的好坏。当某个维度有效率明显低于其它维度时, + * 通常说明对应传感器存在批量失效或采集程序异常。

+ */ +@Data +public class VibrationQualityPageVo { + + /** 采样点总数 */ + private Integer sampleCount; + + /** 设备台数 */ + private Integer deviceCount; + + /** 总覆盖率,与总览页复用同一计算逻辑保证数值一致 */ + private BigDecimal coverageRate; + + /** 各振动指标的独立有效采集率,拆到每个维度便于发现“某类传感器批量失效” */ + private List metricQualityItems; + + /** + * 单指标质量项:每个振动维度独立统计有效采集率。 + */ + @Data + public static class MetricQualityItem { + /** 指标字段名 */ + private String field; + /** 指标显示标签 */ + private String label; + /** 指标单位 */ + private String unit; + /** 有效采集率 = validCount / sampleCount,值越低说明该维度丢数越严重 */ + private BigDecimal validRate; + /** 有效采样点数(非 null 的记录数) */ + private Integer validCount; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationTrendPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationTrendPageVo.java new file mode 100644 index 0000000..2b7ab8d --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationTrendPageVo.java @@ -0,0 +1,72 @@ +package org.dromara.ems.report.domain.vo.vibrationboard; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 振动趋势页聚合结果。 + * + *

趋势页核心目标是展示“振动随时间的变化规律”。 + * 单设备时同时展示四个振动维度的趋势线,多设备时每台设备一条线(同一主看指标)。 + * 后端已做压缩控制,避免浏览器持有超长时间序列导致 ECharts 渲染卡顿。

+ */ +@Data +public class VibrationTrendPageVo { + + /** 当前主看指标字段名 */ + private String metricField; + + /** 主看指标显示标签 */ + private String metricLabel; + + /** 主看指标单位 */ + private String unit; + + /** 是否多设备模式——前端据此决定图例展示“指标名”还是“设备名” */ + private Boolean multiDevice; + + /** 趋势曲线序列:单设备=四条维度线,多设备=每台设备一条线(已压缩至 MAX_TREND_POINTS 以内) */ + private List series; + + /** 小时均值柱状图——折叠到24h,用于识别“哪个时段振动偏高” */ + private List hourlyItems; + + /** + * 趋势序列项:每条线对应一个指标或一台设备。 + */ + @Data + public static class TrendSeriesItem { + /** 序列名称:单设备时为指标标签,多设备时为设备名称 */ + private String name; + /** 对应的指标字段名 */ + private String field; + /** 序列单位,用于 Y 轴标注 */ + private String unit; + /** 时间点序列,已按时间升序 */ + private List points; + } + + /** + * 趋势点:单个采样时刻的指标值。 + */ + @Data + public static class TrendPointItem { + /** 采样时刻,格式 yyyy-MM-dd HH:mm:ss */ + private String time; + /** 指标值,保留两位小数 */ + private BigDecimal value; + } + + /** + * 小时均值项:将所有采样点折叠到24小时维度,用于发现时段性振动规律。 + */ + @Data + public static class HourlyItem { + /** 小时标签,如 "08:00" */ + private String hour; + /** 该小时所有采样点的均值 */ + private BigDecimal avgValue; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/VibrationBoardMapper.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/VibrationBoardMapper.java new file mode 100644 index 0000000..6e2ab2c --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/VibrationBoardMapper.java @@ -0,0 +1,44 @@ +package org.dromara.ems.report.mapper; + +import org.apache.ibatis.annotations.Param; +import org.dromara.ems.record.domain.RecordIotenvInstant; +import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo; + +import java.util.List; + +/** + * 振动看板 Mapper。 + * + *

这里单独抽振动专用 Mapper,而不是继续往通用物联 Mapper 里叠加 if-else, + * 目的是把振动设备过滤(monitor_type=10)、分表 UNION ALL 查询和 ROW_NUMBER 抽样口径 + * 固定在同一处,避免后续页面越多越难维护。

+ * + *

只暴露两个方法(原始查询 + 抽样查询),而不是按页面拆 7 个方法, + * 是因为 7 个页面的底层 SQL 口径完全相同,只是上层 Java 聚合逻辑不同。 + * 这样做能确保所有页面的数据源完全一致,避免“总览和趋势页数据对不上”的问题。

+ */ +public interface VibrationBoardMapper { + + /** + * 查询振动看板原始明细。 + *

当抽样间隔 <= 1 时走这条路径,返回所有符合条件的采样点。 + * XML 中通过 UNION ALL 多日分表 + INNER JOIN monitor_type=10 确保只查振动设备。

+ * + * @param tableNames 已经通过白名单正则校验的日分表名列表,绝对不会包含非法字符串 + * @param query 查询参数(时间范围、设备列表、主看指标类型) + */ + List selectRawData(@Param("tableNames") List tableNames, + @Param("query") VibrationBoardQueryBo query); + + /** + * 查询振动看板抽样明细。 + *

当抽样间隔 > 1 时走这条路径,在 DB 层通过 ROW_NUMBER 窗口函数 + * 按“设备 + 时间桶”分区降采样,每个桶只保留最新一条记录。 + * 比客户端抽样更高效——数据在 DB 层就被压缩,减少网络传输和 Java 内存消耗。

+ * + * @param tableNames 已经通过白名单正则校验的日分表名列表 + * @param query 查询参数(时间范围、设备列表、主看指标类型、抽样间隔) + */ + List selectSampledData(@Param("tableNames") List tableNames, + @Param("query") VibrationBoardQueryBo query); +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/IVibrationBoardService.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/IVibrationBoardService.java new file mode 100644 index 0000000..364e4aa --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/IVibrationBoardService.java @@ -0,0 +1,63 @@ +package org.dromara.ems.report.service; + +import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAdvancedPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationComparisonPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationOverviewPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationQualityPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationTrendPageVo; + +/** + * 振动看板服务接口。 + * + *

单独抽接口而不是在通用物联服务上加方法, + * 是因为振动报表的查询口径、返回结构和业务语义与通用物联完全不同, + * 混在一起会导致接口语义模糊、参数互相污染。 + * 后续若扩展箱线图、健康评分等能力,也在此接口上继续演进。

+ */ +public interface IVibrationBoardService { + + /** + * 查询振动总览页聚合数据。 + *

返回指标卡片、仪表盘、主看统计和设备排名,前端无需二次计算即可渲染第一屏。

+ */ + VibrationOverviewPageVo listOverviewData(VibrationBoardQueryBo bo); + + /** + * 查询振动趋势页聚合数据。 + *

根据设备数自动切换“单设备多维度”或“多设备同维度”模式,并包含小时均值。

+ */ + VibrationTrendPageVo listTrendData(VibrationBoardQueryBo bo); + + /** + * 查询振动对比页聚合数据。 + *

返回排名柱状图和散点图所需结构,前端不再本地按设备聚合均值、峰值和样本数。

+ */ + VibrationComparisonPageVo listComparisonData(VibrationBoardQueryBo bo); + + /** + * 查询振动质量页聚合数据。 + *

返回采样数、设备数、覆盖率和各指标有效率,前端不再逐字段统计有效值比例。

+ */ + VibrationQualityPageVo listQualityData(VibrationBoardQueryBo bo); + + /** + * 查询振动分布页聚合数据。 + *

返回四分位区间、直方图桶、日历热力图和小时热力图四类聚合结果。

+ */ + VibrationDistributionPageVo listDistributionData(VibrationBoardQueryBo bo); + + /** + * 查询振动异常页聚合数据。 + *

返回高风险事件、连续超标段、变化过快事件和抖动异常四类结果及汇总计数。

+ */ + VibrationAnomalyPageVo listAnomalyData(VibrationBoardQueryBo bo); + + /** + * 查询振动高级页聚合数据。 + *

返回桑基节点/连线、矩形树图节点、平行坐标轴和系列数据,前端不再基于明细逐设备计算。

+ */ + VibrationAdvancedPageVo listAdvancedData(VibrationBoardQueryBo bo); +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java new file mode 100644 index 0000000..ee98f27 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java @@ -0,0 +1,1616 @@ +package org.dromara.ems.report.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.ems.record.domain.RecordIotenvInstant; +import org.dromara.ems.record.service.RecordIotenvPartitionService; +import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAdvancedPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationComparisonPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationOverviewPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationQualityPageVo; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationTrendPageVo; +import org.dromara.ems.report.mapper.VibrationBoardMapper; +import org.dromara.ems.report.service.IVibrationBoardService; +import org.dromara.ems.report.service.impl.support.VibrationAnomalyAnalyzer; +import org.dromara.ems.report.service.impl.support.VibrationDistributionAggregator; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 振动看板服务实现。 + * + *

振动报表虽然仍然基于 record_iotenv_instant 分表, + * 但这里单独抽服务,是为了把“仅振动设备 + 按分钟抽样 + 主看指标筛选”的业务口径固定下来, + * 避免继续依赖通用物联查询导致页面统计结果和振动业务语义错位。

+ */ +@Service +@RequiredArgsConstructor +public class VibrationBoardServiceImpl implements IVibrationBoardService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 分表名白名单正则:仅允许 record_iotenv_instant_YYYYMMDD 格式。 + * 目的:MyBatis XML 中使用 ${tableName} 拼接 SQL,必须从根源上杜绝非法字符串注入。 + * 即使当前 tableNames 来自内部分表路由服务,加白名单也是纵深防御——万一上游逻辑被改动也不会传播风险。 + */ + private static final Pattern TABLE_NAME_PATTERN = Pattern.compile("^record_iotenv_instant_\\d{8}$"); + + /** + * 前端可选主看指标白名单。 + * 用 Set 而非 if-else 判断,是为了后续新增振动指标(如频谱峰值等)时只改一行即可。 + */ + private static final Set SUPPORTED_VIBRATION_PARAMS = Set.of( + "vibrationSpeed", + "vibrationDisplacement", + "vibrationAcceleration", + "vibrationTemp" + ); + + /** 查询跨度上限(天),防止一次拉取数月数据导致内存或 SQL Server 超时 */ + private static final int MAX_QUERY_DAYS = 90; + + /** 趋势页最大点数——超过后进行桶中位数压缩,避免 ECharts 渲染卡顿 */ + private static final int MAX_TREND_POINTS = 1200; + + /** 对比页最多展示设备数——过多会导致柱状图和散点图难以辨认 */ + private static final int MAX_COMPARE_DEVICES = 12; + + /** 单次查询允许返回的估算最大记录数,避免多设备分钟级全量拉取把 JVM 堆打爆 */ + private static final long MAX_ESTIMATED_QUERY_ROWS = 500_000L; + + /** 分布统计聚合器(无状态工具类,用单例避免重复创建) */ + private static final VibrationDistributionAggregator DISTRIBUTION_AGGREGATOR = new VibrationDistributionAggregator(); + + /** 异常分析器(无状态工具类,用单例避免重复创建) */ + private static final VibrationAnomalyAnalyzer ANOMALY_ANALYZER = new VibrationAnomalyAnalyzer(); + + private final VibrationBoardMapper vibrationBoardMapper; + private final RecordIotenvPartitionService recordIotenvPartitionService; + + @Override + public VibrationOverviewPageVo listOverviewData(VibrationBoardQueryBo bo) { + // 所有页面共享同一个底层查询方法 listPageData,确保 SQL 口径、白名单校验、抽样逻辑统一 + List rows = listPageData(bo); + // 根据前端传入的主看指标字段名解析对应的元数据(标签名、单位) + MetricMeta primaryMetric = resolveMetricMeta(bo); + // 创建总览页响应 VO + VibrationOverviewPageVo vo = new VibrationOverviewPageVo(); + // 设置主看指标字段名,供前端识别当前选中的指标 + vo.setMetricField(primaryMetric.field()); + // 设置主看指标的中文显示标签 + vo.setMetricLabel(primaryMetric.label()); + // 设置主看指标的物理单位 + vo.setUnit(primaryMetric.unit()); + // 空数据时直接返回带零值的结构化 VO,而非 null——前端无需做空判断即可渲染空态 + if (CollUtil.isEmpty(rows)) { + // 设置采样总数为0 + vo.setSampleCount(0); + // 设置设备总数为0 + vo.setDeviceCount(0); + // 覆盖率统一保留4位小数,避免空态和有数据时小数位数不一致 + vo.setCoverageRate(BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP)); + // 设置指标卡片为空列表 + vo.setMetricCards(Collections.emptyList()); + // 设置仪表盘项为空列表 + vo.setGaugeItems(Collections.emptyList()); + // 设置设备排名为空列表 + vo.setDeviceRanks(Collections.emptyList()); + // 设置主看指标统计为空对象 + vo.setPrimaryMetricStats(new VibrationOverviewPageVo.PrimaryMetricStats()); + // 提前返回空态 VO + return vo; + } + + // 总览页后续还要统计设备数和设备排名,先缓存分组结果避免重复构建 LinkedHashMap + Map> grouped = groupByMonitor(rows); + // 设备画像既能支撑排名,也能让后续扩展时直接复用设备级统计结果 + List profiles = buildDeviceProfiles(grouped, primaryMetric.field()); + // 总览页会多次读取四个振动维度统计,这里先单次遍历汇总,避免重复扫 rows + Map metricSnapshots = buildMetricSnapshots(rows); + // 指标卡片涵盖四个振动维度,每张卡片包含 latest/avg/max 三个统计值 + List metricCards = buildMetricCards(metricSnapshots); + // 将构建好的指标卡片列表设置到 VO + vo.setMetricCards(metricCards); + // 设置采样总数 + vo.setSampleCount(rows.size()); + // 直接复用已缓存的分组结果统计设备数,避免重复遍历 rows + vo.setDeviceCount(grouped.size()); + // 覆盖率 = 所有指标有效值总数 / (样本数 × 指标数),用于衡量采集完整度 + vo.setCoverageRate(calculateCoverageRate(rows.size(), metricCards.size(), metricSnapshots)); + // 主看指标的 min/avg/max/latest 统计,供前端仪表盘数字展示 + vo.setPrimaryMetricStats(buildPrimaryMetricStats(metricSnapshots.get(primaryMetric.field()))); + // 设备排名直接复用画像结果,避免再次按设备聚合 + vo.setDeviceRanks(buildOverviewDeviceRanks(profiles)); + // 仪表盘:单设备看多维度、多设备看群组均值,与设备数量动态适配 + vo.setGaugeItems(buildOverviewGaugeItems(metricCards, primaryMetric.field(), vo.getDeviceCount())); + // 返回填充完毕的总览页 VO + return vo; + } + + @Override + public VibrationTrendPageVo listTrendData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 解析主看指标的元数据(字段名、标签、单位) + MetricMeta primaryMetric = resolveMetricMeta(bo); + // 创建趋势页响应 VO + VibrationTrendPageVo vo = new VibrationTrendPageVo(); + // 设置主看指标字段名 + vo.setMetricField(primaryMetric.field()); + // 设置主看指标中文标签 + vo.setMetricLabel(primaryMetric.label()); + // 设置主看指标单位 + vo.setUnit(primaryMetric.unit()); + // 空数据时返回空态结构 + if (CollUtil.isEmpty(rows)) { + // 设置非多设备模式 + vo.setMultiDevice(Boolean.FALSE); + // 设置趋势系列为空列表 + vo.setSeries(Collections.emptyList()); + // 设置小时均值为空列表 + vo.setHourlyItems(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + + // 按设备分组,用于判断是单设备还是多设备场景 + Map> grouped = groupByMonitor(rows); + // 分组数 > 1 则为多设备模式 + boolean multiDevice = grouped.size() > 1; + // 设置多设备标志,前端据此决定渲染策略 + vo.setMultiDevice(multiDevice); + // 多设备时→每台设备一条线(同一主看指标),单设备时→四个振动维度各一条线 + // 这样前端不用自己判断“此次是单/多设备”,直接用 series 渲染即可 + vo.setSeries(multiDevice ? buildTrendDeviceSeries(grouped, primaryMetric.field(), primaryMetric.unit()) : buildTrendMetricSeries(rows)); + // 小时均值柱状图——折叠到 24h,帮助识别“哪个时段振动偏高” + vo.setHourlyItems(buildTrendHourlyItems(rows, primaryMetric.field())); + // 返回填充完毕的趋势页 VO + return vo; + } + + @Override + public VibrationComparisonPageVo listComparisonData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 解析主看指标的元数据(字段名、标签、单位) + MetricMeta primaryMetric = resolveMetricMeta(bo); + // 创建对比页响应 VO + VibrationComparisonPageVo vo = new VibrationComparisonPageVo(); + // 设置主看指标字段名 + vo.setMetricField(primaryMetric.field()); + // 设置主看指标中文标签 + vo.setMetricLabel(primaryMetric.label()); + // 设置主看指标单位 + vo.setUnit(primaryMetric.unit()); + // 空数据时返回空态结构 + if (CollUtil.isEmpty(rows)) { + // 设置排名列表为空 + vo.setRankItems(Collections.emptyList()); + // 设置散点图列表为空 + vo.setScatterItems(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + // DeviceMetricProfile 聚合了每台设备的 avg/latest/max + 四维度均值,供对比/高级页共用 + List profiles = buildDeviceProfiles(rows, primaryMetric.field()); + // 排名视角:按均值降序 + latest 值辅助,帮助快速定位“哪台振动最高” + vo.setRankItems(buildComparisonRanks(profiles)); + // 散点图视角:X=均值 Y=峰值,离群点代表“偏高且尖锋明显”的设备 + vo.setScatterItems(buildComparisonScatters(profiles)); + // 返回填充完毕的对比页 VO + return vo; + } + + @Override + public VibrationQualityPageVo listQualityData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 创建质量页响应 VO + VibrationQualityPageVo vo = new VibrationQualityPageVo(); + // 空数据时返回空态结构 + if (CollUtil.isEmpty(rows)) { + // 设置采样总数为0 + vo.setSampleCount(0); + // 设置设备总数为0 + vo.setDeviceCount(0); + // 覆盖率统一保留4位小数,避免空态和有数据时小数位数不一致 + vo.setCoverageRate(BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP)); + // 设置指标质量列表为空 + vo.setMetricQualityItems(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + // 质量页也要统计设备数,先缓存分组结果,避免与覆盖率统计各自重复遍历 + Map> grouped = groupByMonitor(rows); + // 质量页和总览页都要消费四个振动维度统计,先单次遍历汇总,避免重复扫 rows + Map metricSnapshots = buildMetricSnapshots(rows); + // 复用总览页卡片统计口径构造有效维度集合,保证覆盖率数值一致 + List metricCards = buildMetricCards(metricSnapshots); + // 设置采样总数 + vo.setSampleCount(rows.size()); + // 按设备分组后取组数作为设备总数 + vo.setDeviceCount(grouped.size()); + // 计算并设置总体覆盖率 + vo.setCoverageRate(calculateCoverageRate(rows.size(), metricCards.size(), metricSnapshots)); + // 各指标独立计算有效采集率,帮助发现“哪个传感器维度采集丢失严重” + vo.setMetricQualityItems(buildMetricQualityItems(metricSnapshots, rows.size())); + // 返回填充完毕的质量页 VO + return vo; + } + + @Override + public VibrationDistributionPageVo listDistributionData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 解析主看指标的元数据(字段名、标签、单位) + MetricMeta metricMeta = resolveMetricMeta(bo); + // 创建分布页响应 VO + VibrationDistributionPageVo vo = new VibrationDistributionPageVo(); + // 设置主看指标字段名 + vo.setMetricField(metricMeta.field()); + // 设置主看指标中文标签 + vo.setMetricLabel(metricMeta.label()); + // 设置主看指标单位 + vo.setUnit(metricMeta.unit()); + // 空数据时返回空态结构 + if (CollUtil.isEmpty(rows)) { + // 设置四分位区间桶为空列表 + vo.setIntervalBuckets(Collections.emptyList()); + // 设置直方图桶为空列表 + vo.setHistogramBuckets(Collections.emptyList()); + // 设置日历热力图为空列表 + vo.setCalendarHeatmap(Collections.emptyList()); + // 设置小时热力图为空列表 + vo.setHourlyHeatmap(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + + // 两维抽取:extractMetricValues 给排序后的数字值供区间分布和直方图使用 + List values = extractMetricValues(rows, metricMeta.field()); + // 提取后无有效值时也返回空态 + if (CollUtil.isEmpty(values)) { + // 设置四分位区间桶为空列表 + vo.setIntervalBuckets(Collections.emptyList()); + // 设置直方图桶为空列表 + vo.setHistogramBuckets(Collections.emptyList()); + // 设置日历热力图为空列表 + vo.setCalendarHeatmap(Collections.emptyList()); + // 设置小时热力图为空列表 + vo.setHourlyHeatmap(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + // 四分位区间(P25/P50/P75)——能直观看出数据集中在哪个范围 + vo.setIntervalBuckets(DISTRIBUTION_AGGREGATOR.buildIntervalBuckets(values)); + // 10 桶等宽直方图,补充四分位无法展示的尾部分布形态 + vo.setHistogramBuckets(DISTRIBUTION_AGGREGATOR.buildHistogramBuckets(values, 10)); + // 日历热力图:按天聚合均值,观察振动哪天偏高 + vo.setCalendarHeatmap(DISTRIBUTION_AGGREGATOR.buildCalendarHeatmap(rows, row -> extractMetricValue(row, metricMeta.field()))); + // 小时热力图:按日×小时聚合,观察振动在一天中的分布规律 + vo.setHourlyHeatmap(DISTRIBUTION_AGGREGATOR.buildHourlyHeatmap(rows, row -> extractMetricValue(row, metricMeta.field()))); + // 返回填充完毕的分布页 VO + return vo; + } + + @Override + public VibrationAnomalyPageVo listAnomalyData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 解析主看指标的元数据(字段名、标签、单位) + MetricMeta metricMeta = resolveMetricMeta(bo); + // 阈值解析:前端无传则使用各指标的经验默认值,降低首次打开页面的配置成本 + ThresholdProfile thresholdProfile = resolveThresholdProfile(bo, metricMeta.field()); + // 创建异常页响应 VO + VibrationAnomalyPageVo vo = new VibrationAnomalyPageVo(); + // 设置主看指标字段名 + vo.setMetricField(metricMeta.field()); + // 设置主看指标中文标签 + vo.setMetricLabel(metricMeta.label()); + // 设置主看指标单位 + vo.setUnit(metricMeta.unit()); + // 把实际用到的阈值回传前端,便于前端显示当前生效值并允许用户调整后重新查询 + // 设置高风险阈值 + vo.setHighThreshold(thresholdProfile.highThreshold()); + // 设置预警阈值 + vo.setWarningThreshold(thresholdProfile.warningThreshold()); + // 设置快速上升阈值 + vo.setRapidRiseThreshold(thresholdProfile.rapidRiseThreshold()); + // 设置标准差阈值 + vo.setStddevThreshold(thresholdProfile.stddevThreshold()); + // 设置最小连续采样点数 + vo.setMinContinuousSamples(thresholdProfile.minContinuousSamples()); + // 空数据时返回空态结构 + if (CollUtil.isEmpty(rows)) { + // 设置高风险事件计数为0 + vo.setHighEventCount(0); + // 设置连续超标事件计数为0 + vo.setContinuousEventCount(0); + // 设置快速上升事件计数为0 + vo.setRapidRiseEventCount(0); + // 设置抖动异常事件计数为0 + vo.setJitterEventCount(0); + // 设置高风险事件列表为空 + vo.setHighEvents(Collections.emptyList()); + // 设置连续超标事件列表为空 + vo.setContinuousEvents(Collections.emptyList()); + // 设置快速上升事件列表为空 + vo.setRapidRiseEvents(Collections.emptyList()); + // 设置抖动异常事件列表为空 + vo.setJitterEvents(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + + // 四类异常检测独立计算,互不干扰: + // 1. 高风险事件:单点超过高阈值(立即报警场景) + List highEvents = ANOMALY_ANALYZER.buildHighEvents( + rows, + row -> extractMetricValue(row, metricMeta.field()), + thresholdProfile.highThreshold() + ); + // 2. 变化过快事件:相邻采样点差值超限(突发故障场景) + List rapidRiseEvents = ANOMALY_ANALYZER.buildRapidRiseEvents( + rows, + row -> extractMetricValue(row, metricMeta.field()), + thresholdProfile.rapidRiseThreshold() + ); + // 3. 连续超标事件:连续 N 个采样点超过预警阈值(慢性劣化场景) + List continuousEvents = ANOMALY_ANALYZER.buildContinuousEvents( + rows, + row -> extractMetricValue(row, metricMeta.field()), + thresholdProfile.warningThreshold(), + thresholdProfile.minContinuousSamples() + ); + // 4. 抖动异常:某小时内标准差超限(传感器抖动或设备不稳定场景) + List jitterEvents = ANOMALY_ANALYZER.buildJitterEvents( + rows, + row -> extractMetricValue(row, metricMeta.field()), + thresholdProfile.stddevThreshold() + ); + // 设置高风险事件总数 + vo.setHighEventCount(highEvents.size()); + // 设置连续超标事件总数 + vo.setContinuousEventCount(continuousEvents.size()); + // 设置快速上升事件总数 + vo.setRapidRiseEventCount(rapidRiseEvents.size()); + // 设置抖动异常事件总数 + vo.setJitterEventCount(jitterEvents.size()); + // 设置高风险事件列表 + vo.setHighEvents(highEvents); + // 设置连续超标事件列表 + vo.setContinuousEvents(continuousEvents); + // 设置快速上升事件列表 + vo.setRapidRiseEvents(rapidRiseEvents); + // 设置抖动异常事件列表 + vo.setJitterEvents(jitterEvents); + // 返回填充完毕的异常页 VO + return vo; + } + + @Override + public VibrationAdvancedPageVo listAdvancedData(VibrationBoardQueryBo bo) { + // 调用统一底层查询方法获取振动采样数据 + List rows = listPageData(bo); + // 解析主看指标的元数据(字段名、标签、单位) + MetricMeta metricMeta = resolveMetricMeta(bo); + // 创建高级页响应 VO + VibrationAdvancedPageVo vo = new VibrationAdvancedPageVo(); + // 设置主看指标字段名 + vo.setMetricField(metricMeta.field()); + // 设置主看指标中文标签 + vo.setMetricLabel(metricMeta.label()); + // 设置主看指标单位 + vo.setUnit(metricMeta.unit()); + // 高级页需要设备画像数据,空数据时直接返回空结构避免前端报错 + if (CollUtil.isEmpty(rows)) { + // 设置桑基图节点为空列表 + vo.setSankeyNodes(Collections.emptyList()); + // 设置桑基图连线为空列表 + vo.setSankeyLinks(Collections.emptyList()); + // 设置矩形树图为空列表 + vo.setTreemapItems(Collections.emptyList()); + // 设置平行坐标轴为空列表 + vo.setParallelAxes(Collections.emptyList()); + // 设置平行坐标系列为空列表 + vo.setParallelSeries(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + + // 构建设备维度画像,聚合每台设备的四维振动均值和当前指标统计 + List profiles = buildDeviceProfiles(rows, metricMeta.field()); + // 画像列表为空时也返回空态结构 + if (CollUtil.isEmpty(profiles)) { + // 设置桑基图节点为空列表 + vo.setSankeyNodes(Collections.emptyList()); + // 设置桑基图连线为空列表 + vo.setSankeyLinks(Collections.emptyList()); + // 设置矩形树图为空列表 + vo.setTreemapItems(Collections.emptyList()); + // 设置平行坐标轴为空列表 + vo.setParallelAxes(Collections.emptyList()); + // 设置平行坐标系列为空列表 + vo.setParallelSeries(Collections.emptyList()); + // 提前返回空态 VO + return vo; + } + + // 用均值的 33%、66% 分位将设备分为“平稳/关注/高位”三级 + // 不使用绝对阈值,是因为不同指标量纲差异大,相对分位更具可比性 + // 分位数算法要求输入按升序排列;profiles 当前按均值倒序,所以这里显式升序再取 P33/P66 + List currentMetricAverages = profiles.stream() + .map(DeviceMetricProfile::currentMetricAvg) + .filter(Objects::nonNull) + .sorted() + .toList(); + // 计算第33分位作为“平稳”与“关注”的分界线 + BigDecimal lowBandUpper = percentile(currentMetricAverages, 0.33D); + // 计算第66分位作为“关注”与“高位”的分界线 + BigDecimal focusBandUpper = percentile(currentMetricAverages, 0.66D); + // 设置平稳带上界,供前端渲染分带色 + vo.setLowBandUpper(lowBandUpper); + // 设置关注带上界 + vo.setFocusBandUpper(focusBandUpper); + // 构建桑基图节点(设备名 + 流向阶段) + vo.setSankeyNodes(buildSankeyNodes(profiles, lowBandUpper, focusBandUpper)); + // 构建桑基图连线(设备 → 阶段) + vo.setSankeyLinks(buildSankeyLinks(profiles, lowBandUpper, focusBandUpper)); + // 构建矩形树图节点(面积代表均值) + vo.setTreemapItems(buildTreemapItems(profiles, lowBandUpper, focusBandUpper)); + // 构建平行坐标轴(四个振动维度) + vo.setParallelAxes(buildParallelAxes(profiles)); + // 构建平行坐标系列(每台设备一条折线) + vo.setParallelSeries(buildParallelSeries(profiles)); + // 返回填充完毕的高级页 VO + return vo; + } + + /** + * 统一底层查询入口:参数校验 → 分表路由 → 白名单防注入 → 抽样/原始分流。 + * 所有七个页面的数据都经由这一个方法,确保 SQL 口径和权限校验处理完全一致。 + */ + private List listPageData(VibrationBoardQueryBo bo) { + // 参数非空校验:查询参数为 null 时招入口即拒绝 + if (bo == null) { + throw new ServiceException("查询参数不能为空"); + } + + // 时间校验:解析+顺序+跨度一次性全做完,失败早返回明确错误 + // 解析开始时间字符串为 Date 对象 + Date beginTime = parseDateTime(bo.getBeginRecordTime(), "开始记录时间"); + // 解析结束时间字符串为 Date 对象 + Date endTime = parseDateTime(bo.getEndRecordTime(), "结束记录时间"); + // 校验时间顺序:开始时间不能晚于结束时间 + if (beginTime.after(endTime)) { + throw new ServiceException("开始记录时间不能晚于结束记录时间"); + } + // 校验查询跨度不超过上限天数 + validateQuerySpan(beginTime, endTime); + + // 合并去重前端传入的设备ID + List monitorIds = normalizeMonitorIds(bo); + // 校验至少选择了一个振动设备 + if (CollUtil.isEmpty(monitorIds)) { + throw new ServiceException("请选择至少一个振动设备"); + } + + // 查询 BO 来自 Controller,同一请求内若被复用,底层查询不应回写副作用到上层对象 + VibrationBoardQueryBo query = normalizeQueryBo(bo, monitorIds); + // 查询规模保护:分钟级多设备跨月拉取很容易把数百万行一次性装进 JVM,必须在入口 fail fast + validateEstimatedQueryRows(beginTime, endTime, monitorIds.size(), query.getSamplingInterval()); + + // 根据时间范围解析出所有涉及的日分表名(如 record_iotenv_instant_20260326) + List tableNames = recordIotenvPartitionService.resolveTables(beginTime, endTime); + // 无分表时直接返回空列表(说明该时间范围没有分表数据) + if (CollUtil.isEmpty(tableNames)) { + return Collections.emptyList(); + } + // 白名单校验——纵深防御,即使 resolveTables 内部逻辑被修改也不会发生 SQL 注入 + validateResolvedTableNames(tableNames); + + // 抽样间隔 > 1 分钟时使用 ROW_NUMBER 窗口抽样,否则直查原始数据 + if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) { + // 调用抽样查询:按抽样间隔窗口取数据 + return vibrationBoardMapper.selectSampledData(tableNames, query); + } + // 直查原始数据:不抽样,返回全部符合条件的记录 + return vibrationBoardMapper.selectRawData(tableNames, query); + } + + /** + * 根据时间跨度、设备数和抽样间隔估算结果集大小。 + * 振动报表当前默认按分钟级数据查询,因此这里用“分钟桶数 × 设备数”做近似保护。 + */ + private void validateEstimatedQueryRows(Date beginTime, Date endTime, int monitorCount, Integer samplingInterval) { + // 抽样间隔至少按 1 分钟估算,避免出现除零或负值 + int effectiveSamplingInterval = normalizeSamplingInterval(samplingInterval); + // 记录时间是闭区间,分钟数至少按 1 计,避免极短查询被估成 0 行 + long diffMinutes = Math.max(1L, (endTime.getTime() - beginTime.getTime()) / (60L * 1000L) + 1L); + // 估算每台设备的返回行数;抽样越大,结果集越小 + long rowsPerMonitor = (long) Math.ceil((double) diffMinutes / effectiveSamplingInterval); + // 总结果集约等于每台设备返回行数乘设备数 + long estimatedRows = rowsPerMonitor * Math.max(1, monitorCount); + // 超过上限则直接拒绝,提示用户增大抽样或缩小范围,避免把风险留到 JVM 堆阶段 + if (estimatedRows > MAX_ESTIMATED_QUERY_ROWS) { + long recommendedSamplingInterval = Math.max(1L, (long) Math.ceil((double) diffMinutes * Math.max(1, monitorCount) / MAX_ESTIMATED_QUERY_ROWS)); + throw new ServiceException("当前查询预计返回约" + estimatedRows + + "条记录,超过系统上限" + MAX_ESTIMATED_QUERY_ROWS + + "条,请将抽样间隔至少调整为" + recommendedSamplingInterval + "分钟,或缩小时间范围/设备范围"); + } + } + + /** + * 底层查询使用局部副本承接归一化结果,避免修改 Controller 传入的共享 BO。 + */ + private VibrationBoardQueryBo normalizeQueryBo(VibrationBoardQueryBo source, List monitorIds) { + // 创建局部查询对象,将 SQL 所需的归一化参数都收口在副本中 + VibrationBoardQueryBo query = new VibrationBoardQueryBo(); + // 时间条件直接透传,保持 Controller 入参与底层查询口径一致 + query.setBeginRecordTime(source.getBeginRecordTime()); + query.setEndRecordTime(source.getEndRecordTime()); + // 单设备走等值过滤,多设备走 IN 过滤,避免 SQL 同时命中 monitorId 与 monitorIds 两套条件 + query.setMonitorId(monitorIds.size() == 1 ? monitorIds.get(0) : null); + query.setMonitorIds(monitorIds.size() > 1 ? monitorIds : null); + // 抽样和主看指标在副本中归一化,既保证查询稳定,也不污染调用方 + query.setSamplingInterval(normalizeSamplingInterval(source.getSamplingInterval())); + query.setVibrationParam(normalizeVibrationParam(source.getVibrationParam())); + return query; + } + + /** + * 校验分表名白名单,拦截不符合命名规则的表名。 + * 避免 MyBatis ${tableName} 拼接污染——这是整个链路中唯一的“硬编码表名”风险点。 + */ + private void validateResolvedTableNames(List tableNames) { + // 遍历所有分表名,逐一进行白名单正则校验 + for (String tableName : tableNames) { + // 用预编译的正则判断表名是否符合 record_iotenv_instant_YYYYMMDD 格式 + if (!TABLE_NAME_PATTERN.matcher(tableName).matches()) { + // 不符合规则则拒绝查询,防止 SQL 注入 + throw new ServiceException("非法分表名称:" + tableName); + } + } + } + + /** + * 时间解析:统一使用严格格式解析,拒绝模糊格式避免分表路由错位。 + */ + private Date parseDateTime(String value, String fieldName) { + // 空值校验:时间字符串为空或空白时直接报错 + if (StrUtil.isBlank(value)) { + throw new ServiceException(fieldName + "不能为空"); + } + try { + // 去除前后空格后用严格格式解析为 LocalDateTime + LocalDateTime localDateTime = LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER); + // 将 LocalDateTime 转为系统时区的 Date 对象返回 + return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); + } catch (DateTimeParseException ex) { + // 解析失败时抛出明确格式要求的错误信息 + throw new ServiceException(fieldName + "格式不正确,请使用 yyyy-MM-dd HH:mm:ss"); + } + } + + /** + * 限制查询跨度——三个月已能覆盖多数振动分析场景,超出则拒绝查询以保护数据库资源。 + */ + private void validateQuerySpan(Date beginTime, Date endTime) { + // 计算结束时间与开始时间的毫秒差 + long diffMs = endTime.getTime() - beginTime.getTime(); + // 将毫秒差转换为天数 + long diffDays = diffMs / (24L * 3600L * 1000L); + // 超出则拒绝查询以保护数据库资源——90 天已覆盖多数振动分析场景 + if (diffDays > MAX_QUERY_DAYS) { + // 报错并提示用户缩小时间范围 + throw new ServiceException("查询跨度不能超过" + MAX_QUERY_DAYS + "天,请缩小时间范围"); + } + } + + /** + * 合并去重设备 ID——前端可能同时传 monitorId + monitorIds,这里统一整合。 + */ + private List normalizeMonitorIds(VibrationBoardQueryBo bo) { + // 使用 LinkedHashSet 去重并保留插入顺序 + Set monitorIdSet = new LinkedHashSet<>(); + // 如果单设备ID不为空,去除前后空格后加入集合 + if (StrUtil.isNotBlank(bo.getMonitorId())) { + monitorIdSet.add(bo.getMonitorId().trim()); + } + // 如果多设备ID列表不为空,遍历并加入集合 + if (CollUtil.isNotEmpty(bo.getMonitorIds())) { + // 遍历每个设备ID + for (String monitorId : bo.getMonitorIds()) { + // 跳过空白的设备ID + if (StrUtil.isNotBlank(monitorId)) { + // 去除前后空格后加入集合(LinkedHashSet 自动去重) + monitorIdSet.add(monitorId.trim()); + } + } + } + // 将去重后的集合转为 ArrayList 返回 + return new ArrayList<>(monitorIdSet); + } + + /** + * 抽样间隔归一化: + * - null 或负值视为“不抽样”,返回 1 + * - 上限 1440 分钟(一天),避免误传极端值后把抽样直接抽成“无数据” + */ + private Integer normalizeSamplingInterval(Integer samplingInterval) { + // null 或负值视为“不抽样”,返回 1(即每分钟取一条) + if (samplingInterval == null || samplingInterval < 1) { + // 返回默认抽样间隔 1 分钟 + return 1; + } + // 防御性上限:超过一天的抽样间隔在业务上无意义,且会导致窗口函数分区过宽从而抽不出数据 + // 取传入值和 1440 中的较小值作为实际抽样间隔 + return Math.min(samplingInterval, 1440); + } + + /** + * 主看指标归一化: + * - 未传时默认 vibrationSpeed(振动速度是最常用的振动评估指标) + * - 非法值直接报错,而不是静默回退到默认值——避免前端传错参数却不知情 + */ + private String normalizeVibrationParam(String vibrationParam) { + // 未传时默认使用振动速度 + if (StrUtil.isBlank(vibrationParam)) { + // 默认使用振动速度:ISO 10816 标准中最常用的振动严重性评估指标 + return "vibrationSpeed"; + } + // 校验传入的指标是否在支持的白名单中 + if (!SUPPORTED_VIBRATION_PARAMS.contains(vibrationParam)) { + // 非法指标直接报错,而不是静默回退到默认值 + throw new ServiceException("不支持的振动指标类型"); + } + // 返回校验通过的指标字段名 + return vibrationParam; + } + + /** + * 列表页展示口径也必须基于归一化后的主看指标,避免“查询用默认值,展示靠 switch 默认分支兜底”这种隐式耦合。 + */ + private MetricMeta resolveMetricMeta(VibrationBoardQueryBo bo) { + // 统一先走主看指标归一化,再映射标签和单位,保证查询口径与展示口径完全一致 + return resolveMetricMeta(normalizeVibrationParam(bo == null ? null : bo.getVibrationParam())); + } + + /** + * 指标元数据映射:将 Java 字段名转为显示标签和单位。 + * 集中在这里维护,而不是散落在前端,是因为单位和标签属于业务元数据,应由后端统一编排。 + */ + private MetricMeta resolveMetricMeta(String metricField) { + // 根据字段名匹配对应的显示标签和单位,未匹配时默认使用振动速度 + return switch (metricField) { + // 振动位移,单位微米 + case "vibrationDisplacement" -> new MetricMeta(metricField, "振动位移", "um"); + // 振动加速度,单位重力加速度 + case "vibrationAcceleration" -> new MetricMeta(metricField, "振动加速度", "g"); + // 振动温度,单位摄氏度 + case "vibrationTemp" -> new MetricMeta(metricField, "振动温度", "℃"); + // 默认振动速度,单位毫米/秒 + default -> new MetricMeta("vibrationSpeed", "振动速度", "mm/s"); + }; + } + + /** + * 异常阈值解析:每个振动指标量纲不同,经验默认值也不同。 + * 例如振动速度 7.1mm/s 对应 ISO 10816-3 的 D 区边界,常被作为“高风险”参考线。 + * 前端可通过 BO 参数覆盖默认值,实现“可配置不硬编码”的产品意图。 + */ + private ThresholdProfile resolveThresholdProfile(VibrationBoardQueryBo bo, String metricField) { + // 根据指标类型选择对应的经验默认阈值,前端有传则使用前端值 + return switch (metricField) { + // 振动位移阈值:高阈300um、预警100um、快速上升30um、标准差20um + case "vibrationDisplacement" -> new ThresholdProfile( + toScaledBigDecimal(bo.getHighThreshold(), BigDecimal.valueOf(300D)), + toScaledBigDecimal(bo.getWarningThreshold(), BigDecimal.valueOf(100D)), + toScaledBigDecimal(bo.getRapidRiseThreshold(), BigDecimal.valueOf(30D)), + toScaledBigDecimal(bo.getStddevThreshold(), BigDecimal.valueOf(20D)), + bo.getMinContinuousSamples() == null ? 3 : bo.getMinContinuousSamples() + ); + // 振动加速度阈值:高阈40g、预警10g、快速上升5g、标准差4g + case "vibrationAcceleration" -> new ThresholdProfile( + toScaledBigDecimal(bo.getHighThreshold(), BigDecimal.valueOf(40D)), + toScaledBigDecimal(bo.getWarningThreshold(), BigDecimal.valueOf(10D)), + toScaledBigDecimal(bo.getRapidRiseThreshold(), BigDecimal.valueOf(5D)), + toScaledBigDecimal(bo.getStddevThreshold(), BigDecimal.valueOf(4D)), + bo.getMinContinuousSamples() == null ? 3 : bo.getMinContinuousSamples() + ); + // 振动温度阈值:高阈60℃、预警40℃、快速上升3℃、标准差2℃ + case "vibrationTemp" -> new ThresholdProfile( + toScaledBigDecimal(bo.getHighThreshold(), BigDecimal.valueOf(60D)), + toScaledBigDecimal(bo.getWarningThreshold(), BigDecimal.valueOf(40D)), + toScaledBigDecimal(bo.getRapidRiseThreshold(), BigDecimal.valueOf(3D)), + toScaledBigDecimal(bo.getStddevThreshold(), BigDecimal.valueOf(2D)), + bo.getMinContinuousSamples() == null ? 3 : bo.getMinContinuousSamples() + ); + // 默认振动速度阈值:高阈7.1mm/s(ISO 10816-3 D区)、预警2.5、快速上升1.0、标准差2.0 + default -> new ThresholdProfile( + toScaledBigDecimal(bo.getHighThreshold(), BigDecimal.valueOf(7.1D)), + toScaledBigDecimal(bo.getWarningThreshold(), BigDecimal.valueOf(2.5D)), + toScaledBigDecimal(bo.getRapidRiseThreshold(), BigDecimal.valueOf(1.0D)), + toScaledBigDecimal(bo.getStddevThreshold(), BigDecimal.valueOf(2.0D)), + bo.getMinContinuousSamples() == null ? 3 : bo.getMinContinuousSamples() + ); + }; + } + + /** + * 阈值统一精度处理:前端未传时用默认值,统一保留两位小数。 + * 两位小数对振动报表场景精度已超充分,且与前端展示对齐。 + */ + private BigDecimal toScaledBigDecimal(BigDecimal value, BigDecimal defaultValue) { + // 前端未传时用默认值,统一保留两位小数并四舍五入 + return (value == null ? defaultValue : value).setScale(2, RoundingMode.HALF_UP); + } + + /** + * 从原始行中提取指定指标值。 + * 用 switch 而非反射,是因为振动指标只有四个且不会频繁变动, + * switch 比反射更类型安全、更容易在编译期发现拼写错误。 + */ + private BigDecimal extractMetricValue(RecordIotenvInstant row, String metricField) { + // 根据指标字段名从原始行中提取对应的振动指标值 + return switch (metricField) { + // 提取振动位移值 + case "vibrationDisplacement" -> row.getVibrationDisplacement(); + // 提取振动加速度值 + case "vibrationAcceleration" -> row.getVibrationAcceleration(); + // 提取振动温度值 + case "vibrationTemp" -> row.getVibrationTemp(); + // 默认提取振动速度值 + default -> row.getVibrationSpeed(); + }; + } + + /** + * 从原始行中提取指定指标值并升序排列。 + * 返回已排序列表——供分位数、统计、直方图等多处复用,避免重复排序。 + */ + private List extractMetricValues(List rows, String metricField) { + // 流式处理:提取指标值 → 过滤空值 → 升序排序 → 收集为不可变列表 + return rows.stream() + // 从每行中提取指定指标的值 + .map(row -> extractMetricValue(row, metricField)) + // 过滤掉空值 + .filter(Objects::nonNull) + // 按自然顺序升序排列 + .sorted() + // 收集为不可变列表返回 + .toList(); + } + + /** + * 分位数计算:ceil 向上取整索引,确保 P33/P66 等分位至少覆盖对应比例的数据。 + * 比插值算法简单,对报表场景精度已超充分。 + */ + private BigDecimal percentile(List values, double ratio) { + // 空列表时返回零值,避免上层空判断 + if (CollUtil.isEmpty(values)) { + // 返回零作为兜底分位数 + return BigDecimal.ZERO; + } + // 用 ceil 向上取整计算分位索引,再用 min/max 钳位到合法范围 [0, size-1] + int index = Math.min(values.size() - 1, Math.max(0, (int) Math.ceil(values.size() * ratio) - 1)); + // 按索引取值并保留两位小数返回 + return values.get(index).setScale(2, RoundingMode.HALF_UP); + } + + /** + * 取时间上最近的一条记录的指标值。 + * 必须从原始行按 recodeTime 排序取最后一条,而不能从排序后的均值列表中取—— + * 否则“latest”实际拿到的是数值最大值,与逻辑语义相差很远。 + */ + private BigDecimal extractLatestMetricValue(List rows, String metricField) { + // 流式处理:过滤指标值非空的行 → 按时间取最新的一条 → 提取指标值 + return rows.stream() + // 过滤指标值为空的行 + .filter(row -> extractMetricValue(row, metricField) != null) + // 按记录时间升序取最大值(即最新时间),时间相同时按主键 objid 排序保证稳定性 + .max(Comparator.comparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder()))) + // 从最新记录中提取指标值 + .map(row -> extractMetricValue(row, metricField)) + // 无符合条件的记录时返回 null + .orElse(null); + } + + /** + * 构建设备维度画像:将每台设备的四维振动均值 + 当前指标 latest/max/count 聚合成一个 Profile。 + * 多个页面(对比、高级、总览)共用这个 Profile,避免重复分组+统计。 + */ + private List buildDeviceProfiles(List rows, String currentMetricField) { + // 普通入口统一走已分组重载,便于调用方在缓存 groupByMonitor 后直接复用 + return buildDeviceProfiles(groupByMonitor(rows), currentMetricField); + } + + /** + * 已分组数据的画像构建入口。 + * 调用方若已缓存 groupByMonitor 结果,应优先走此方法避免重复分组。 + */ + private List buildDeviceProfiles(Map> grouped, String currentMetricField) { + // 每个 entry 本身就是单设备数据,直接构建画像即可,避免“单设备再分组”带来的无效开销 + return grouped.entrySet().stream() + .map(entry -> buildDeviceProfile(entry.getKey(), entry.getValue(), currentMetricField)) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(DeviceMetricProfile::currentMetricAvg, Comparator.reverseOrder())) + .toList(); + } + + /** + * 单设备画像构建:趋势页、高级页和总览页都可以直接复用,避免重复 groupByMonitor。 + */ + private DeviceMetricProfile buildDeviceProfile(String monitorId, List monitorRows, String currentMetricField) { + // 当前设备在主看指标上的有效值已按升序排列,后续 max/分布统计都可直接复用 + List currentValues = extractMetricValues(monitorRows, currentMetricField); + // 无有效值时不构建设备画像,避免空设备参与后续排名 + if (CollUtil.isEmpty(currentValues)) { + return null; + } + // latestValue 必须从时间维度取最近记录,而不是排序后的尾部 + BigDecimal latestValue = extractLatestMetricValue(monitorRows, currentMetricField); + // 构建设备画像,四个维度均值保留下来供高级页平行坐标和对比页复用 + return new DeviceMetricProfile( + monitorId, + monitorRows.get(0).getMonitorName(), + avg(currentValues), + latestValue == null ? null : latestValue.setScale(2, RoundingMode.HALF_UP), + currentValues.get(currentValues.size() - 1).setScale(2, RoundingMode.HALF_UP), + currentValues.size(), + avg(extractMetricValues(monitorRows, "vibrationSpeed")), + avg(extractMetricValues(monitorRows, "vibrationDisplacement")), + avg(extractMetricValues(monitorRows, "vibrationAcceleration")), + avg(extractMetricValues(monitorRows, "vibrationTemp")) + ); + } + + /** + * 桑基图节点:设备名 + 流向阶段(平稳/关注/高位)。 + * 限制 20 台设备,避免 ECharts 桑基图节点过多时渲染崩溃。 + */ + private List buildSankeyNodes(List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) { + // 使用 LinkedHashSet 去重并保持插入顺序,收集设备名和阶段名作为桑基图节点 + Set nodeNames = new LinkedHashSet<>(); + // 取 TOP20 设备,避免节点过多导致 ECharts 渲染崩溃 + profiles.stream().limit(20).forEach(profile -> { + // 设备名在现场常有重名,拼上设备ID后才能保证桑基节点稳定唯一 + nodeNames.add(buildDeviceDisplayName(profile)); + // 添加设备对应的阶段名(平稳/关注/高位)作为目标节点 + nodeNames.add(resolveStage(profile.currentMetricAvg(), lowBandUpper, focusBandUpper)); + }); + // 将所有节点名映射为 SankeyNodeItem 对象列表 + return nodeNames.stream().map(name -> { + // 创建桑基图节点项 + VibrationAdvancedPageVo.SankeyNodeItem item = new VibrationAdvancedPageVo.SankeyNodeItem(); + // 设置节点名称 + item.setName(name); + return item; + }).toList(); + } + + /** + * 桑基连线:设备 → 阶段,权重为样本数,前端据此渲染流向粗细。 + */ + private List buildSankeyLinks(List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) { + // 取 TOP20 设备,将每台设备映射为一条桑基图连线(设备 → 阶段) + return profiles.stream().limit(20).map(profile -> { + // 创建桑基图连线项 + VibrationAdvancedPageVo.SankeyLinkItem item = new VibrationAdvancedPageVo.SankeyLinkItem(); + // 源节点使用唯一展示名,避免重名设备的流向被错误合并 + item.setSource(buildDeviceDisplayName(profile)); + // 设置目标节点为该设备对应的阶段(平稳/关注/高位) + item.setTarget(resolveStage(profile.currentMetricAvg(), lowBandUpper, focusBandUpper)); + // 设置权重为采样点数,前端据此渲染流向粗细 + item.setValue(profile.sampleCount()); + return item; + }).toList(); + } + + /** + * 矩形树图节点:面积代表指标均值,levelTag 用于前端映射填充色。 + * 限制 30 台设备,避免树图块过多导致文本重叠无法阅读。 + */ + private List buildTreemapItems(List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) { + // 取 TOP30 设备,将每台设备映射为一个矩形树图节点 + return profiles.stream().limit(30).map(profile -> { + // 创建矩形树图节点项 + VibrationAdvancedPageVo.TreemapItem item = new VibrationAdvancedPageVo.TreemapItem(); + // 树图节点同样使用唯一展示名,避免重名设备的矩形块被误认为同一对象 + item.setName(buildDeviceDisplayName(profile)); + // 设置节点值为均值,前端据此渲染矩形面积 + item.setValue(profile.currentMetricAvg()); + // 设置分级标签(平稳/关注/高位),前端据此映射填充色 + item.setLevelTag(resolveStage(profile.currentMetricAvg(), lowBandUpper, focusBandUpper)); + return item; + }).toList(); + } + + /** + * 平行坐标轴:四个振动维度各一根轴,上限放大 20% 避免数据点贴边。 + */ + private List buildParallelAxes(List profiles) { + // 定义四个振动维度的元数据,作为平行坐标轴的数据源 + List metas = List.of( + resolveMetricMeta("vibrationSpeed"), + resolveMetricMeta("vibrationDisplacement"), + resolveMetricMeta("vibrationAcceleration"), + resolveMetricMeta("vibrationTemp") + ); + // 计算每个维度在所有设备中的最大均值,用于确定坐标轴上限 + List maxValues = List.of( + // 振动速度维度的最大均值,无数据时默认为1 + profiles.stream().map(DeviceMetricProfile::avgSpeed).max(Comparator.naturalOrder()).orElse(BigDecimal.ONE), + // 振动位移维度的最大均值 + profiles.stream().map(DeviceMetricProfile::avgDisplacement).max(Comparator.naturalOrder()).orElse(BigDecimal.ONE), + // 振动加速度维度的最大均值 + profiles.stream().map(DeviceMetricProfile::avgAcceleration).max(Comparator.naturalOrder()).orElse(BigDecimal.ONE), + // 振动温度维度的最大均值 + profiles.stream().map(DeviceMetricProfile::avgTemp).max(Comparator.naturalOrder()).orElse(BigDecimal.ONE) + ); + // 创建可变列表用于收集坐标轴项 + List axes = new ArrayList<>(); + // 遍历每个维度,构建对应的坐标轴项 + for (int i = 0; i < metas.size(); i++) { + // 创建平行坐标轴项实例 + VibrationAdvancedPageVo.ParallelAxisItem item = new VibrationAdvancedPageVo.ParallelAxisItem(); + // 设置维度索引(从0开始) + item.setDim(i); + // 设置轴标签为指标中文名 + item.setName(metas.get(i).label()); + // 轴上限放大20%,避免数据点贴边影响视觉体验 + item.setMax(maxValues.get(i).multiply(BigDecimal.valueOf(1.2D)).setScale(2, RoundingMode.HALF_UP)); + // 将轴项添加到结果列表 + axes.add(item); + } + // 返回四根平行坐标轴的列表 + return axes; + } + + /** + * 平行坐标系列:每台设备一条折线,四个维度均值作为坐标。 + * 限制 20 台避免折线过多导致 ECharts 渲染凌乱。 + */ + private List buildParallelSeries(List profiles) { + // 取 TOP20 设备,将每台设备映射为一条平行坐标折线 + return profiles.stream().limit(20).map(profile -> { + // 创建平行坐标系列项 + VibrationAdvancedPageVo.ParallelSeriesItem item = new VibrationAdvancedPageVo.ParallelSeriesItem(); + // 设置设备ID + item.setMonitorId(profile.monitorId()); + // 设置设备名称 + item.setMonitorName(profile.monitorName()); + // 设置四维度均值作为坐标值:[速度, 位移, 加速度, 温度] + item.setValues(List.of(profile.avgSpeed(), profile.avgDisplacement(), profile.avgAcceleration(), profile.avgTemp())); + return item; + }).toList(); + } + + /** + * 根据均值在分位数中的位置将设备分为三级。 + * 使用相对分位而非绝对阈值,是因为不同指标量纲差异大, + * 相对分位在切换指标时仍然具有可比性。 + */ + private String resolveStage(BigDecimal value, BigDecimal lowBandUpper, BigDecimal focusBandUpper) { + // 均值 <= P33 分位时归为“平稳”阶段 + if (value.compareTo(lowBandUpper) <= 0) { + return "平稳"; + } + // P33 < 均值 <= P66 分位时归为“关注”阶段 + if (value.compareTo(focusBandUpper) <= 0) { + return "关注"; + } + // 均值 > P66 分位时归为“高位”阶段 + return "高位"; + } + + /** + * 按设备分组——返回 LinkedHashMap 保留插入顺序,便于后续稳定排序。 + */ + private Map> groupByMonitor(List rows) { + // 使用 LinkedHashMap 按设备ID分组,保持插入顺序 + Map> grouped = new LinkedHashMap<>(); + // 遍历所有行,按设备ID分组到各自列表中 + for (RecordIotenvInstant row : rows) { + // 将当前行追加到对应设备的列表中,列表不存在时自动创建 + grouped.computeIfAbsent(row.getMonitorId(), key -> new ArrayList<>()).add(row); + } + // 返回分组后的 Map + return grouped; + } + + /** + * 均值计算:空列表返回零而非 null,避免上层做空判断。 + * 保留两位小数与前端展示对齐。 + */ + private BigDecimal avg(List values) { + // 空列表返回零值(保留两位小数),避免上层做空判断 + if (CollUtil.isEmpty(values)) { + // 返回 0.00 作为默认均值 + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + // 对列表所有元素求和,初始值为零 + BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + // 求和除以元素个数得到均值,保留两位小数并四舍五入 + return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP); + } + + /** + * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 + */ + private String formatDate(Date date, String pattern) { + // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 + return DateTimeFormatter.ofPattern(pattern) + .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + } + + /** 指标元数据:字段名 + 显示标签 + 单位,集中维护避免前后端不一致 */ + private record MetricMeta(String field, String label, String unit) { + } + + /** 异常阈值配置:每个指标量纲不同,经验默认值也不同,打包为一个 record 便于传递 */ + private record ThresholdProfile( + BigDecimal highThreshold, + BigDecimal warningThreshold, + BigDecimal rapidRiseThreshold, + BigDecimal stddevThreshold, + Integer minContinuousSamples + ) { + } + + /** + * 设备维度画像:将每台设备的四维振动均值 + 当前指标 latest/max/count 聚合成一个 Profile。 + * 多个页面(对比、高级、总览)共用此结构,避免重复分组+统计。 + */ + private record DeviceMetricProfile( + String monitorId, + String monitorName, + BigDecimal currentMetricAvg, + BigDecimal currentMetricLatest, + BigDecimal currentMetricMax, + Integer sampleCount, + BigDecimal avgSpeed, + BigDecimal avgDisplacement, + BigDecimal avgAcceleration, + BigDecimal avgTemp + ) { + } + + /** + * 单指标汇总快照:总览页/质量页可直接消费,避免同一批 rows 被 latest/avg/max/validCount 反复扫描。 + */ + private record MetricSnapshot( + int validCount, + BigDecimal minValue, + BigDecimal avgValue, + BigDecimal maxValue, + BigDecimal latestValue + ) { + } + + /** + * 指标统计的可变累加器。 + * 只在单次构建快照时使用,最终会被收敛为只读 MetricSnapshot。 + */ + private static final class MetricAccumulator { + private int validCount; + private BigDecimal sum = BigDecimal.ZERO; + private BigDecimal minValue; + private BigDecimal maxValue; + private BigDecimal latestValue; + private Date latestTime; + private Long latestObjid; + + private void accept(BigDecimal value, Date recodeTime, Long objid) { + // 只对有效指标值累加,空值在调用方已被拦截 + validCount++; + sum = sum.add(value); + minValue = minValue == null ? value : minValue.min(value); + maxValue = maxValue == null ? value : maxValue.max(value); + // latest 仍以时间优先、objid 兜底的规则选取,保持与旧逻辑一致 + if (latestValue == null || isLaterRecord(recodeTime, objid, latestTime, latestObjid)) { + latestValue = value; + latestTime = recodeTime; + latestObjid = objid; + } + } + + private MetricSnapshot snapshot() { + // 无有效值时不产生快照,避免上层出现 0 值冒充真实采样结果 + if (validCount == 0 || latestValue == null || minValue == null || maxValue == null) { + return null; + } + return new MetricSnapshot( + validCount, + minValue.setScale(2, RoundingMode.HALF_UP), + sum.divide(BigDecimal.valueOf(validCount), 2, RoundingMode.HALF_UP), + maxValue.setScale(2, RoundingMode.HALF_UP), + latestValue.setScale(2, RoundingMode.HALF_UP) + ); + } + } + + /** + * 单次遍历构建四个振动维度的统计快照。 + * 这样总览页和质量页就不需要为了 latest/avg/max/validCount 分别反复扫描整批 rows。 + */ + private Map buildMetricSnapshots(List rows) { + // 先用可变累加器承接过程值,最终再收敛成只读快照供上层消费 + Map accumulatorMap = new LinkedHashMap<>(); + for (RecordIotenvInstant row : rows) { + // 四个维度各自独立聚合,避免后续 buildMetricCards/calculateCoverageRate 再次全量遍历 + updateMetricAccumulator(accumulatorMap, "vibrationSpeed", row.getVibrationSpeed(), row); + updateMetricAccumulator(accumulatorMap, "vibrationDisplacement", row.getVibrationDisplacement(), row); + updateMetricAccumulator(accumulatorMap, "vibrationAcceleration", row.getVibrationAcceleration(), row); + updateMetricAccumulator(accumulatorMap, "vibrationTemp", row.getVibrationTemp(), row); + } + Map snapshotMap = new LinkedHashMap<>(); + accumulatorMap.forEach((field, accumulator) -> { + MetricSnapshot snapshot = accumulator.snapshot(); + if (snapshot != null) { + snapshotMap.put(field, snapshot); + } + }); + return snapshotMap; + } + + /** + * 将单条记录的某个指标值并入累加器。 + * 值为空时直接跳过,避免把“缺采”误计入统计。 + */ + private void updateMetricAccumulator(Map accumulatorMap, String field, BigDecimal value, RecordIotenvInstant row) { + if (value == null) { + return; + } + accumulatorMap.computeIfAbsent(field, key -> new MetricAccumulator()).accept(value, row.getRecodeTime(), row.getObjid()); + } + + /** + * 判断当前记录是否比已记录的 latest 更晚。 + * 规则与 extractLatestMetricValue 保持一致:先比 recodeTime,再用 objid 打破同时刻并列。 + */ + private static boolean isLaterRecord(Date currentTime, Long currentObjid, Date latestTime, Long latestObjid) { + int timeCompare = Comparator.nullsFirst(Comparator.naturalOrder()).compare(currentTime, latestTime); + if (timeCompare != 0) { + return timeCompare > 0; + } + return Comparator.nullsFirst(Comparator.naturalOrder()).compare(currentObjid, latestObjid) > 0; + } + + /** + * 指标卡——四个振动维度各一张卡片,显示 latest + avg + max。 + * latest 必须取时间最新值(而非数值最大值),这是上次审阅修复的核心 BUG。 + */ + private List buildMetricCards(Map metricSnapshots) { + // 定义四个振动维度的元数据 + List metas = List.of( + resolveMetricMeta("vibrationSpeed"), + resolveMetricMeta("vibrationDisplacement"), + resolveMetricMeta("vibrationAcceleration"), + resolveMetricMeta("vibrationTemp") + ); + // 创建可变列表用于收集每个维度的指标卡片 + List cards = new ArrayList<>(); + // 遍历每个振动维度,构建对应的指标卡片 + for (MetricMeta meta : metas) { + // 汇总快照里没有该维度时,说明本次查询没有任何有效采样值 + MetricSnapshot snapshot = metricSnapshots.get(meta.field()); + if (snapshot == null) { + continue; + } + // 创建指标卡片项实例 + VibrationOverviewPageVo.MetricCardItem item = new VibrationOverviewPageVo.MetricCardItem(); + // 设置指标字段名 + item.setField(meta.field()); + // 设置指标中文标签 + item.setLabel(meta.label()); + // 设置指标单位 + item.setUnit(meta.unit()); + // latest 必须来自时间上最近记录,不能复用排序值列表,否则会被误算成 max——这是 CR #2 的修复点 + item.setLatest(snapshot.latestValue()); + // 设置均值 + item.setAvg(snapshot.avgValue()); + // 设置峰值 + item.setMax(snapshot.maxValue()); + // 将卡片添加到结果列表 + cards.add(item); + } + // 返回四个维度的指标卡片列表 + return cards; + } + + /** + * 覆盖率 = “所有指标受检值数” / “样本数 × 指标数”。 + * 这个比例能直观反映传感器的整体采集完整度,值越低说明越多传感器存在丢数或脱机。 + */ + private BigDecimal calculateCoverageRate(int sampleCount, int metricCount, Map metricSnapshots) { + // 空数据时返回 0.00 作为默认覆盖率 + if (sampleCount <= 0 || metricCount <= 0 || CollUtil.isEmpty(metricSnapshots)) { + return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP); + } + // 汇总快照已经记录了每个维度的有效值数量,这里直接求和即可,避免再扫 rows + long validCount = metricSnapshots.values().stream().mapToLong(MetricSnapshot::validCount).sum(); + // 计算理论总数 = 样本数 × 指标维度数 + BigDecimal total = BigDecimal.valueOf((long) sampleCount * metricCount); + // 覆盖率 = 有效值总数 / 理论总数,保留四位小数 + return BigDecimal.valueOf(validCount).divide(total, 4, RoundingMode.HALF_UP); + } + + /** + * 主看指标的 min/avg/max/latest 统计,供前端仪表盘数字展示。 + * latest 同样必须按时间取最新值,而非数值排序后取尾部。 + */ + private VibrationOverviewPageVo.PrimaryMetricStats buildPrimaryMetricStats(MetricSnapshot snapshot) { + // 创建主看指标统计对象 + VibrationOverviewPageVo.PrimaryMetricStats stats = new VibrationOverviewPageVo.PrimaryMetricStats(); + // 无有效值时直接返回空统计对象 + if (snapshot == null) { + return stats; + } + // 设置最新值,保留两位小数 + stats.setLatest(snapshot.latestValue()); + // 设置最小值 + stats.setMin(snapshot.minValue()); + // 设置均值 + stats.setAvg(snapshot.avgValue()); + // 设置最大值 + stats.setMax(snapshot.maxValue()); + // 返回填充完毕的统计对象 + return stats; + } + + /** + * 总览页设备排名:均值降序 TOP10,帮助运维快速定位振动最剧烈的设备。 + */ + private List buildOverviewDeviceRanks(List profiles) { + // 总览页排名直接复用上游已算好的画像,避免再次按设备聚合 + return profiles.stream().limit(10).map(profile -> { + // 创建设备排名项实例 + VibrationOverviewPageVo.DeviceRankItem item = new VibrationOverviewPageVo.DeviceRankItem(); + // 设置设备ID + item.setMonitorId(profile.monitorId()); + // 设置设备名称 + item.setMonitorName(profile.monitorName()); + // 设置当前指标均值 + item.setAvg(profile.currentMetricAvg()); + // 设置当前指标最新值 + item.setLatest(profile.currentMetricLatest()); + // 设置当前指标峰值 + item.setMax(profile.currentMetricMax()); + // 设置采样点数 + item.setSampleCount(profile.sampleCount()); + return item; + }).toList(); + } + + /** + * 仪表盘生成策略: + * - 单设备时返回最多 3 个维度的 latest 仪表,让运维人员一眼看到当前值 + * - 多设备时只显示“群组均值”单个仪表,避免仪表过多导致信息过载 + */ + private List buildOverviewGaugeItems(List cards, String primaryMetricField, Integer deviceCount) { + // 无卡片时返回空列表 + if (CollUtil.isEmpty(cards)) { + return Collections.emptyList(); + } + // 多设备时只显示“群组均值”单个仪表,避免仪表过多导致信息过载 + if (deviceCount != null && deviceCount > 1) { + // 从卡片列表中找到主看指标对应的卡片,找不到时取第一张 + VibrationOverviewPageVo.MetricCardItem primaryCard = cards.stream() + .filter(card -> Objects.equals(card.getField(), primaryMetricField)) + .findFirst() + .orElse(cards.get(0)); + // 返回单个群组均值仪表 + return List.of(buildGaugeItem("群组均值", primaryCard.getAvg(), primaryCard.getMax(), primaryCard.getUnit())); + } + // 单设备时取前3个维度的 latest 值构建仪表 + return cards.stream().limit(3).map(card -> buildGaugeItem(card.getLabel(), card.getLatest(), card.getMax(), card.getUnit())).toList(); + } + + /** 仪表盘项工厂方法:上限自动放大 20%,避免指针紧贴天花板影响视觉体验 */ + private VibrationOverviewPageVo.GaugeItem buildGaugeItem(String name, BigDecimal value, BigDecimal maxValue, String unit) { + // 创建仪表盘项实例 + VibrationOverviewPageVo.GaugeItem item = new VibrationOverviewPageVo.GaugeItem(); + // 设置仪表名称 + item.setName(name); + // 设置仪表当前值 + item.setValue(value); + // 仪表上限放大 20%,避免指针紧贴天花板,保证视觉体验 + item.setMaxValue(maxValue == null ? BigDecimal.ONE : maxValue.multiply(BigDecimal.valueOf(1.2D)).setScale(2, RoundingMode.HALF_UP)); + // 设置单位 + item.setUnit(unit); + // 返回构建好的仪表盘项 + return item; + } + + /** + * 单设备趋势:同时展示四个振动维度,帮助判断“哪个维度最值得关注”。 + * 先压缩到 MAX_TREND_POINTS 以内,避免 ECharts 渲染超过万点时浏览器卡死。 + */ + private List buildTrendMetricSeries(List rows) { + // 先压缩到 MAX_TREND_POINTS 以内,避免 ECharts 渲染超过万点时浏览器卡死 + List compressed = compressRows(rows, MAX_TREND_POINTS); + // 定义四个振动维度的元数据 + List metas = List.of( + resolveMetricMeta("vibrationSpeed"), + resolveMetricMeta("vibrationDisplacement"), + resolveMetricMeta("vibrationAcceleration"), + resolveMetricMeta("vibrationTemp") + ); + // 创建可变列表用于收集每个维度的趋势系列 + List series = new ArrayList<>(); + // 遍历每个振动维度,构建对应的趋势系列 + for (MetricMeta meta : metas) { + // 将压缩后的每行映射为趋势点,过滤时间或值为空的点 + List points = compressed.stream() + .map(row -> buildTrendPoint(row.getRecodeTime(), extractMetricValue(row, meta.field()))) + .filter(Objects::nonNull) + .toList(); + // 无有效点时跳过该维度 + if (CollUtil.isEmpty(points)) { + continue; + } + // 创建趋势系列项实例 + VibrationTrendPageVo.TrendSeriesItem item = new VibrationTrendPageVo.TrendSeriesItem(); + // 设置系列名称为指标中文标签 + item.setName(meta.label()); + // 设置指标字段名 + item.setField(meta.field()); + // 设置指标单位 + item.setUnit(meta.unit()); + // 设置趋势点列表 + item.setPoints(points); + // 将系列添加到结果列表 + series.add(item); + } + // 返回四个维度的趋势系列列表 + return series; + } + + /** + * 多设备趋势:取主看指标均值 TOP6 设备上图,避免曲线过多难以辨认。 + */ + private List buildTrendDeviceSeries(Map> grouped, String metricField, String unit) { + // 从每台设备中构建画像,按均值倒序取 TOP6 设备上图 + List topProfiles = grouped.entrySet().stream() + // entry.getValue() 已经是单设备数据,直接构建画像,避免再次 groupByMonitor + .map(entry -> buildDeviceProfile(entry.getKey(), entry.getValue(), metricField)) + // 过滤无有效画像的设备 + .filter(Objects::nonNull) + // 按均值倒序排列 + .sorted(Comparator.comparing(DeviceMetricProfile::currentMetricAvg, Comparator.reverseOrder())) + // 取 TOP6 设备,避免曲线过多难以辨认 + .limit(6) + .toList(); + // 创建可变列表用于收集每台设备的趋势系列 + List series = new ArrayList<>(); + // 遍历 TOP6 设备,构建每台设备的趋势曲线 + for (DeviceMetricProfile profile : topProfiles) { + // 压缩当前设备的采样数据到 MAX_TREND_POINTS 以内 + List compressed = compressRows(grouped.get(profile.monitorId()), MAX_TREND_POINTS); + // 将压缩后的每行映射为趋势点,过滤时间或值为空的点 + List points = compressed.stream() + .map(row -> buildTrendPoint(row.getRecodeTime(), extractMetricValue(row, metricField))) + .filter(Objects::nonNull) + .toList(); + // 无有效点时跳过该设备 + if (CollUtil.isEmpty(points)) { + continue; + } + // 创建趋势系列项实例 + VibrationTrendPageVo.TrendSeriesItem item = new VibrationTrendPageVo.TrendSeriesItem(); + // 设置系列名称为设备名 + item.setName(profile.monitorName()); + // 设置指标字段名 + item.setField(metricField); + // 设置指标单位 + item.setUnit(unit); + // 设置趋势点列表 + item.setPoints(points); + // 将系列添加到结果列表 + series.add(item); + } + // 返回 TOP6 设备的趋势系列列表 + return series; + } + + /** + * 高级页节点的唯一展示名。 + * 生产现场常见“1#振动传感器”这类重名,拼接设备ID可以避免图节点被错误合并。 + */ + private String buildDeviceDisplayName(DeviceMetricProfile profile) { + // 名称为空时直接回退设备ID,避免渲染出“null(xxx)”这类脏文案 + if (StrUtil.isBlank(profile.monitorName())) { + return profile.monitorId(); + } + // 用全角括号包住设备ID,既能保证唯一性,也尽量不破坏原有展示习惯 + return profile.monitorName() + "(" + profile.monitorId() + ")"; + } + + /** 趋势点工厂方法:时间或值为 null 时返回 null,由上层 filter 统一过滤以简化流式调用 */ + private VibrationTrendPageVo.TrendPointItem buildTrendPoint(Date time, BigDecimal value) { + // 时间或值为 null 时返回 null,由上层 filter 统一过滤 + if (time == null || value == null) { + return null; + } + // 创建趋势点实例 + VibrationTrendPageVo.TrendPointItem item = new VibrationTrendPageVo.TrendPointItem(); + // 设置时间字符串,格式为 yyyy-MM-dd HH:mm:ss + item.setTime(formatDate(time, "yyyy-MM-dd HH:mm:ss")); + // 设置指标值,保留两位小数 + item.setValue(value.setScale(2, RoundingMode.HALF_UP)); + // 返回构建好的趋势点 + return item; + } + + /** + * 趋势小时均值:将所有采样点折叠到24小时上求均值。 + * 用于发现“比如凌晨振动偏高”“中午停机时振动低”等规律。 + */ + private List buildTrendHourlyItems(List rows, String metricField) { + // 使用 LinkedHashMap 按小时分桶,保持插入顺序 + Map> bucketMap = new LinkedHashMap<>(); + // 遍历所有采样记录,按小时分组收集指标值 + for (RecordIotenvInstant row : rows) { + // 提取当前行的指标值 + BigDecimal value = extractMetricValue(row, metricField); + // 跳过指标值为空或时间为空的行 + if (value == null || row.getRecodeTime() == null) { + continue; + } + // 提取小时数字符串(如 "08"、"14") + String hour = formatDate(row.getRecodeTime(), "HH"); + // 将指标值追加到对应小时桶中 + bucketMap.computeIfAbsent(hour, key -> new ArrayList<>()).add(value); + } + // 创建可变列表用于收集每个小时的均值项 + List items = new ArrayList<>(); + // 遍历每个小时桶,计算均值并构建小时项 + bucketMap.forEach((hour, values) -> { + // 创建小时均值项实例 + VibrationTrendPageVo.HourlyItem item = new VibrationTrendPageVo.HourlyItem(); + // 设置小时标签(如 "08:00"、"14:00") + item.setHour(hour + ":00"); + // 设置该小时所有采样点的均值 + item.setAvgValue(avg(values)); + // 将小时项添加到结果列表 + items.add(item); + }); + // 最终按小时自然顺序返回,避免受 SQL 行序和 LinkedHashMap 插入顺序影响导致前端柱状图错位 + return items.stream() + .sorted(Comparator.comparing(VibrationTrendPageVo.HourlyItem::getHour)) + .toList(); + } + + /** + * 对比页排名:均值降序 + latest 值辅助,帮助快速定位“哪台振动最高”。 + * 限制 MAX_COMPARE_DEVICES 台设备避免柱状图拥挤。 + */ + private List buildComparisonRanks(List profiles) { + // 取前 MAX_COMPARE_DEVICES 台设备,将每台设备映射为排名项 + return profiles.stream().limit(MAX_COMPARE_DEVICES).map(profile -> { + // 创建对比排名项实例 + VibrationComparisonPageVo.RankItem item = new VibrationComparisonPageVo.RankItem(); + // 设置设备ID + item.setMonitorId(profile.monitorId()); + // 设置设备名称 + item.setMonitorName(profile.monitorName()); + // 设置当前指标均值 + item.setAvg(profile.currentMetricAvg()); + // 设置当前指标最新值 + item.setLatest(profile.currentMetricLatest()); + return item; + }).toList(); + } + + /** + * 对比页散点图:X=均值 Y=峰值,离群点代表“偏高且尖锋明显”的设备。 + * 附带 sampleCount 让前端可用气泡大小表示样本量。 + */ + private List buildComparisonScatters(List profiles) { + // 取前 MAX_COMPARE_DEVICES 台设备,将每台设备映射为散点图项 + return profiles.stream().limit(MAX_COMPARE_DEVICES).map(profile -> { + // 创建散点图项实例 + VibrationComparisonPageVo.ScatterItem item = new VibrationComparisonPageVo.ScatterItem(); + // 设置设备ID + item.setMonitorId(profile.monitorId()); + // 设置设备名称 + item.setMonitorName(profile.monitorName()); + // 设置 X 轴值:当前指标均值 + item.setAvg(profile.currentMetricAvg()); + // 设置 Y 轴值:当前指标峰值 + item.setMax(profile.currentMetricMax()); + // 设置采样点数,前端可用气泡大小表示 + item.setSampleCount(profile.sampleCount()); + return item; + }).toList(); + } + + /** + * 质量评估:每个振动指标独立统计有效采集率。 + * 与“总覆盖率”不同,这里拆到每个维度——便于发现“某类传感器批量失效”。 + */ + private List buildMetricQualityItems(Map metricSnapshots, int sampleCount) { + // 定义四个振动维度的元数据 + List metas = List.of( + resolveMetricMeta("vibrationSpeed"), + resolveMetricMeta("vibrationDisplacement"), + resolveMetricMeta("vibrationAcceleration"), + resolveMetricMeta("vibrationTemp") + ); + // 创建可变列表用于收集每个维度的质量评估项 + List result = new ArrayList<>(); + // 遍历每个振动维度,独立统计有效采集率 + for (MetricMeta meta : metas) { + // 汇总快照已记录每个维度的有效值数,无需再次遍历 rows + MetricSnapshot snapshot = metricSnapshots.get(meta.field()); + if (snapshot == null || snapshot.validCount() == 0 || sampleCount <= 0) { + continue; + } + // 创建指标质量项实例 + VibrationQualityPageVo.MetricQualityItem item = new VibrationQualityPageVo.MetricQualityItem(); + // 设置指标字段名 + item.setField(meta.field()); + // 设置指标中文标签 + item.setLabel(meta.label()); + // 设置指标单位 + item.setUnit(meta.unit()); + // 设置有效值数量 + item.setValidCount(snapshot.validCount()); + // 计算有效采集率 = 有效值数 / 总样本数,保留四位小数 + item.setValidRate(BigDecimal.valueOf(snapshot.validCount()).divide(BigDecimal.valueOf(sampleCount), 4, RoundingMode.HALF_UP)); + // 将质量项添加到结果列表 + result.add(item); + } + // 返回四个维度的质量评估列表 + return result; + } + + /** + * 桶中位数压缩:按等宽分桶后取每桶中间点,保留数据分布形态。 + * 比简单的“每 N 点取一个”更能保留峰谷特征,避免抽样后趋势变形。 + */ + private List compressRows(List rows, int maxPoints) { + // 空列表或数据量未超过上限时直接返回原始数据,无需压缩 + if (CollUtil.isEmpty(rows) || rows.size() <= maxPoints) { + return rows; + } + // 计算每个桶的大小:向上取整确保桶数 <= maxPoints + int bucketSize = (int) Math.ceil((double) rows.size() / maxPoints); + // 创建可变列表用于收集压缩后的采样点 + List result = new ArrayList<>(); + // 按桶大小步进遍历原始数据 + for (int index = 0; index < rows.size(); index += bucketSize) { + // 截取当前桶的子列表,最后一个桶可能不足 bucketSize + List bucket = rows.subList(index, Math.min(rows.size(), index + bucketSize)); + // 取桶中间位置的元素作为代表点,比取首/尾更能保留峰谷特征 + result.add(bucket.get(bucket.size() / 2)); + } + // 返回压缩后的采样点列表 + return result; + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java new file mode 100644 index 0000000..4f07f55 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java @@ -0,0 +1,363 @@ +package org.dromara.ems.report.service.impl.support; + +import cn.hutool.core.collection.CollUtil; +import org.dromara.ems.record.domain.RecordIotenvInstant; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * 振动异常分析器。 + * + *

将振动采样数据按四种最常见异常场景检测: + * 1) 单点高风险 2) 相邻点突变 3) 连续超标 4) 小时桶内抖动。

+ *

无状态工具类,可安全作为单例或 static 常量使用。

+ */ +public class VibrationAnomalyAnalyzer { + + /** + * 高风险事件:单点值 >= highThreshold 的记录。 + * 按值倒序取 TOP200——前端只展示“最严重”的部分,避免列表过长影响体验。 + */ + public List buildHighEvents(List rows, + Function metricExtractor, + BigDecimal highThreshold) { + // 对所有采样行进行流式处理:过滤→排序→截取→映射→收集 + return rows.stream() + // 过滤出指标值不为空且 >= 高风险阈值的记录 + .filter(row -> { + // 通过提取函数获取当前行的指标值 + BigDecimal value = metricExtractor.apply(row); + // 保留指标值非空且达到或超过高阈值的记录 + return value != null && value.compareTo(highThreshold) >= 0; + }) + // 按指标值倒序排列,值相同时按时间倒序(最严重、最近的排前面) + .sorted(Comparator.comparing((RecordIotenvInstant row) -> metricExtractor.apply(row), Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsLast(Comparator.reverseOrder()))) + // 取 TOP200 条最严重的记录,避免前端列表过长影响体验 + .limit(200) + // 将每条记录映射为高风险事件项 VO + .map(row -> { + // 创建高风险事件项实例 + VibrationAnomalyPageVo.HighEventItem item = new VibrationAnomalyPageVo.HighEventItem(); + // 设置设备ID + item.setMonitorId(row.getMonitorId()); + // 设置设备名称 + item.setMonitorName(row.getMonitorName()); + // 设置指标值,保留两位小数 + item.setValue(metricExtractor.apply(row).setScale(2, RoundingMode.HALF_UP)); + // 设置记录时间 + item.setRecodeTime(row.getRecodeTime()); + // 返回构建好的事件项 + return item; + }) + // 收集为不可变列表返回 + .toList(); + } + + /** + * 变化过快事件:相邻采样点差值 >= rapidRiseThreshold。 + * 先按设备分组再按时间排序,确保只比较同一台设备的前后采样点, + * 而不是不同设备的数据交叉对比——否则会产生大量误报。 + */ + public List buildRapidRiseEvents(List rows, + Function metricExtractor, + BigDecimal rapidRiseThreshold) { + // 创建可变结果列表,用于收集所有变化过快事件 + List result = new ArrayList<>(); + // 按设备分组并按时间排序,确保只比较同一台设备的前后采样点 + Map> grouped = groupByMonitorAndSort(rows); + // 遍历每台设备的时间有序采样数据 + grouped.forEach((monitorId, monitorRows) -> { + // 从第二个采样点开始,逐一与前一个采样点比较差值 + for (int i = 1; i < monitorRows.size(); i++) { + // 提取当前采样点的指标值 + BigDecimal currentValue = metricExtractor.apply(monitorRows.get(i)); + // 提取前一个采样点的指标值 + BigDecimal previousValue = metricExtractor.apply(monitorRows.get(i - 1)); + // 任一值为空则跳过,无法计算差值 + if (currentValue == null || previousValue == null) { + continue; + } + // 计算当前点与前一点的差值;正值表示上升,负值表示下降 + BigDecimal diff = currentValue.subtract(previousValue); + // 急升和急降都属于突变信号,所以按绝对值判断是否达到变化过快阈值 + if (diff.abs().compareTo(rapidRiseThreshold) >= 0) { + // 创建变化过快事件项实例 + VibrationAnomalyPageVo.RapidRiseEventItem item = new VibrationAnomalyPageVo.RapidRiseEventItem(); + // 设置设备ID + item.setMonitorId(monitorId); + // 设置设备名称 + item.setMonitorName(monitorRows.get(i).getMonitorName()); + // 设置差值,保留两位小数 + item.setDiff(diff.setScale(2, RoundingMode.HALF_UP)); + // 设置发生时间(取当前点的时间) + item.setRecodeTime(monitorRows.get(i).getRecodeTime()); + // 将事件添加到结果列表 + result.add(item); + } + } + }); + // 按变化幅度绝对值倒序排列并取 TOP200 返回,确保急降不会因为负号被排到后面 + return result.stream() + .sorted(Comparator.comparing((VibrationAnomalyPageVo.RapidRiseEventItem item) -> item.getDiff().abs(), Comparator.reverseOrder())) + .limit(200) + .toList(); + } + + /** + * 连续超标事件:连续 >= minSamples 个采样点超过 warningThreshold。 + * 这类异常对应“慢性劣化”场景(如轴承磨损),单点可能不触发高阈值, + * 但持续偏高本身就是预警信号。 + * 算法:状态机扫描,维护 startIndex + maxValue,中断时判定是否达到最小样本数。 + */ + public List buildContinuousEvents(List rows, + Function metricExtractor, + BigDecimal warningThreshold, + Integer minSamples) { + // 创建可变结果列表,用于收集所有连续超标事件 + List result = new ArrayList<>(); + // 按设备分组并按时间排序,确保同一设备的采样点按时间顺序可追溯 + Map> grouped = groupByMonitorAndSort(rows); + // 遍历每台设备的时间有序采样数据,使用状态机检测连续超标段 + grouped.forEach((monitorId, monitorRows) -> { + // 状态机变量:连续超标段的起始索引,-1 表示当前不在超标段内 + int startIndex = -1; + // 状态机变量:当前连续超标段内出现的最大指标值 + BigDecimal maxValue = BigDecimal.ZERO; + // 逐一扫描每个采样点 + for (int i = 0; i < monitorRows.size(); i++) { + // 提取当前采样点的指标值 + BigDecimal value = metricExtractor.apply(monitorRows.get(i)); + // 判断当前点是否超过预警阈值(flagged=true 表示超标) + boolean flagged = value != null && value.compareTo(warningThreshold) >= 0; + // 当前点超标且尚未进入超标段时,标记为超标段起点 + if (flagged && startIndex < 0) { + // 记录超标段的起始索引 + startIndex = i; + // 初始化超标段内最大值 + maxValue = value; + } + // 当前点超标时,持续更新超标段内最大值 + if (flagged && value != null) { + // 取当前值与已记录最大值中的较大者 + maxValue = maxValue.max(value); + } + // 超标段中断(当前点未超标)或到达数据末尾时,判定并结算超标段 + if ((!flagged || i == monitorRows.size() - 1) && startIndex >= 0) { + // 确定超标段的结束索引:如果当前点仍超标(末尾情况)则包含当前点 + int endIndex = flagged ? i : i - 1; + // 计算超标段包含的采样点数 + int sampleCount = endIndex - startIndex + 1; + // 仅当连续超标点数 >= 最小要求时才记录为事件 + if (sampleCount >= minSamples) { + // 创建连续超标事件项实例 + VibrationAnomalyPageVo.ContinuousEventItem item = new VibrationAnomalyPageVo.ContinuousEventItem(); + // 设置设备ID + item.setMonitorId(monitorId); + // 设置设备名称(取超标段起始点的设备名) + item.setMonitorName(monitorRows.get(startIndex).getMonitorName()); + // 设置超标段开始时间 + item.setStartTime(monitorRows.get(startIndex).getRecodeTime()); + // 设置超标段结束时间 + item.setEndTime(monitorRows.get(endIndex).getRecodeTime()); + // 设置超标段内的峰值,保留两位小数 + item.setMaxValue(maxValue.setScale(2, RoundingMode.HALF_UP)); + // 设置超标段内的采样点数 + item.setSampleCount(sampleCount); + // 将事件添加到结果列表 + result.add(item); + } + // 重置状态机,准备检测下一个超标段 + startIndex = -1; + } + } + }); + // 按连续超标采样点数倒序排列并取 TOP100,优先展示持续时间最长的事件 + return result.stream() + .sorted(Comparator.comparing(VibrationAnomalyPageVo.ContinuousEventItem::getSampleCount, Comparator.reverseOrder())) + .limit(100) + .toList(); + } + + /** + * 抖动异常:按“设备+小时”分桶,统计桶内标准差 >= stddevThreshold 的桶。 + * 这类异常对应“传感器报数不稳定”或“设备产生间歇性异常振动”场景。 + * 单点 < 2 的桶无法计算标准差,直接跳过。 + */ + public List buildJitterEvents(List rows, + Function metricExtractor, + BigDecimal stddevThreshold) { + // 使用 LinkedHashMap 按"设备ID_小时桶"分组,保持插入顺序 + Map> bucketMap = new LinkedHashMap<>(); + // 用于记录每个桶 key 对应的设备名称,供后续构建事件项时使用 + Map monitorNameMap = new LinkedHashMap<>(); + // 遍历所有采样记录,按设备和小时维度分桶收集指标值 + for (RecordIotenvInstant row : rows) { + // 通过提取函数获取当前行的指标值 + BigDecimal value = metricExtractor.apply(row); + // 跳过指标值为空或记录时间为空的行 + if (value == null || row.getRecodeTime() == null) { + continue; + } + // 将记录时间格式化为小时级别的时间桶(如 "2026-03-26 14:00:00") + String hourBucket = formatDate(row.getRecodeTime(), "yyyy-MM-dd HH") + ":00:00"; + // 拼接"设备ID_小时桶"作为分桶 key,确保不同设备同一小时不会混淆 + String key = row.getMonitorId() + "_" + hourBucket; + // 将指标值追加到对应桶中,桶不存在时自动创建 + bucketMap.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + // 记录该桶对应的设备名称(同一桶内设备名相同,覆盖写入即可) + monitorNameMap.put(key, row.getMonitorName()); + } + // 创建可变结果列表,用于收集所有抖动异常事件 + List result = new ArrayList<>(); + // 遍历每个"设备_小时"桶,计算桶内标准差并判断是否超过抖动阈值 + bucketMap.forEach((key, values) -> { + // 桶内样本数 < 2 时无法计算标准差,直接跳过 + if (values.size() < 2) { + return; + } + // 计算桶内所有指标值的总体标准差 + BigDecimal stddev = stddev(values); + // 标准差达到或超过抖动阈值时,记录为抖动异常事件 + if (stddev.compareTo(stddevThreshold) >= 0) { + // 按第一个下划线拆分 key,parts[0]=设备ID,parts[1]=小时桶时间 + String[] parts = key.split("_", 2); + // 创建抖动异常事件项实例 + VibrationAnomalyPageVo.JitterEventItem item = new VibrationAnomalyPageVo.JitterEventItem(); + // 设置设备ID + item.setMonitorId(parts[0]); + // 设置设备名称(从名称映射表中获取) + item.setMonitorName(monitorNameMap.get(key)); + // 设置小时桶时间标识 + item.setHourBucket(parts[1]); + // 设置该桶的标准差值 + item.setStddev(stddev); + // 设置该桶内的采样点数 + item.setSampleCount(values.size()); + // 将事件添加到结果列表 + result.add(item); + } + }); + // 按标准差倒序排列并取 TOP100,优先展示抖动最剧烈的时段 + return result.stream() + .sorted(Comparator.comparing(VibrationAnomalyPageVo.JitterEventItem::getStddev, Comparator.reverseOrder())) + .limit(100) + .toList(); + } + + /** + * 按设备分组并按时间排序。 + * 分组后再排序——确保同一设备的采样点按时间顺序可追溯, + * 这是“变化过快”和“连续超标”算法的前置条件。 + */ + private Map> groupByMonitorAndSort(List rows) { + // 使用 LinkedHashMap 按设备ID分组,保持插入顺序 + Map> grouped = new LinkedHashMap<>(); + // 遍历所有行,按设备ID分组到各自列表中 + for (RecordIotenvInstant row : rows) { + // 将当前行追加到对应设备的列表中,列表不存在时自动创建 + grouped.computeIfAbsent(row.getMonitorId(), key -> new ArrayList<>()).add(row); + } + // 对每台设备的采样数据按时间升序排列,时间相同时按主键 objid 排序保证稳定性 + grouped.values().forEach(monitorRows -> monitorRows.sort( + Comparator.comparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder())) + )); + // 返回分组并排序后的 Map + return grouped; + } + + /** + * 标准差计算:使用总体统计标准差(除以 N 而非 N-1)。 + * 报表场景只关心“一小时内波动是否可接受”,总体方差足够。 + * BigDecimal 开方采用牛顿迭代,避免 double 往返的精度损失。 + */ + private BigDecimal stddev(List values) { + // 空列表或仅一个元素时无法计算标准差,返回零值(保留4位小数) + if (CollUtil.isEmpty(values) || values.size() < 2) { + // 返回 0.0000 作为默认标准差 + return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP); + } + // 计算列表所有值的均值,作为方差计算的基准 + BigDecimal avgValue = avg(values); + // 计算总体方差:各值与均值差的平方和 / N,保留8位小数减少中间截断误差 + BigDecimal variance = values.stream() + // 计算每个值与均值的差 + .map(value -> value.subtract(avgValue)) + // 将差值平方 + .map(value -> value.multiply(value)) + // 累加所有平方差 + .reduce(BigDecimal.ZERO, BigDecimal::add) + // 除以样本总数 N(总体方差),保留8位小数 + .divide(BigDecimal.valueOf(values.size()), 8, RoundingMode.HALF_UP); + // 采用 BigDecimal 牛顿迭代开方,避免 double 往返带来的精度抖动。 + return sqrt(variance, 4); + } + + /** + * 均值计算:保留 6 位小数以减少后续方差计算的累积截断误差。 + * 比 Service 层的 avg(2 位)精度更高,因为此处作为标准差计算的中间步骤。 + */ + private BigDecimal avg(List values) { + // 空列表返回零值(保留两位小数),避免上层做空判断 + if (CollUtil.isEmpty(values)) { + // 返回 0.00 作为默认均值 + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + // 对列表所有元素求和,初始值为零 + BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + // 求和除以元素个数得到均值,保留6位小数(比展示层多,减少标准差计算的累积截断误差) + return sum.divide(BigDecimal.valueOf(values.size()), 6, RoundingMode.HALF_UP); + } + + /** + * BigDecimal 牛顿迭代开方。 + * 先用 Math.sqrt(double) 获得初始猜测值(足够接近),再迭代收敛到指定精度。 + * 收敛条件很紧(scale+4 位),通常 2~3 次迭代即可结束。 + */ + private BigDecimal sqrt(BigDecimal value, int scale) { + // 空值或非正数直接返回零(无法对负数开方) + if (value == null || value.signum() <= 0) { + // 返回指定精度的零值 + return BigDecimal.ZERO.setScale(scale, RoundingMode.HALF_UP); + } + // 定义常量2,用于牛顿迭代公式 x_new = (x + value/x) / 2 + BigDecimal two = BigDecimal.valueOf(2); + // 设定收敛精度阈值,比目标精度多4位以确保最终结果足够精确 + BigDecimal precision = BigDecimal.ONE.movePointLeft(scale + 4); + // 用 Math.sqrt(double) 获得初始猜测值(足够接近真实值,加速迭代收敛) + BigDecimal current = BigDecimal.valueOf(Math.sqrt(value.doubleValue())); + // 上一轮的迭代结果,用于判断是否收敛 + BigDecimal previous; + // 牛顿迭代循环:x_new = (x + value/x) / 2,直到相邻两次迭代差 < 精度阈值 + do { + // 保存当前值作为上一轮结果 + previous = current; + // 执行一步牛顿迭代:(current + value/current) / 2,中间计算保留 scale+8 位小数 + current = current.add(value.divide(current, scale + 8, RoundingMode.HALF_UP)) + .divide(two, scale + 8, RoundingMode.HALF_UP); + // 当相邻两次迭代结果之差的绝对值 <= 精度阈值时退出循环 + } while (current.subtract(previous).abs().compareTo(precision) > 0); + // 将最终结果截断到指定精度并返回 + return current.setScale(scale, RoundingMode.HALF_UP); + } + + /** + * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 + */ + private String formatDate(java.util.Date date, String pattern) { + // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 + return DateTimeFormatter.ofPattern(pattern) + .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + } +} diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java new file mode 100644 index 0000000..9979830 --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java @@ -0,0 +1,300 @@ +package org.dromara.ems.report.service.impl.support; + +import cn.hutool.core.collection.CollUtil; +import org.dromara.ems.record.domain.RecordIotenvInstant; +import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * 振动分布统计聚合器。 + * + *

将原始采样数据聚合成四类分布视图所需的结构化输出: + * 四分位区间、等宽直方图、日历热力图、小时热力图。

+ *

无状态工具类,可安全作为单例或 static 常量使用。

+ */ +public class VibrationDistributionAggregator { + + /** + * 四分位区间分布:按 P25/P50/P75 将数据分为四段。 + * 相比等宽直方图,四分位更能反映“大多数数据集中在哪里”而非“数值范围有多宽”。 + */ + public List buildIntervalBuckets(List sortedValues) { + // 空值保护:传入的已排序值列表为空时直接返回空集合,避免后续分位计算抛异常 + if (CollUtil.isEmpty(sortedValues)) { + // 返回不可变空列表,语义清晰且节省内存分配 + return Collections.emptyList(); + } + // 计算第25百分位数(P25),作为"低值区"与"中低值区"的分界线 + BigDecimal p25 = percentile(sortedValues, 0.25D); + // 计算第50百分位数(P50/中位数),作为"中低值区"与"中高值区"的分界线 + BigDecimal p50 = percentile(sortedValues, 0.50D); + // 计算第75百分位数(P75),作为"中高值区"与"高值区"的分界线 + BigDecimal p75 = percentile(sortedValues, 0.75D); + // 已排序列表可直接用二分定位阈值边界,避免每个区间都全量 filter 一遍 + int p25EndIndex = upperBound(sortedValues, p25); + int p50EndIndex = upperBound(sortedValues, p50); + int p75EndIndex = upperBound(sortedValues, p75); + // 构建四个四分位区间桶并以不可变列表返回,区间为:[<=P25], (P25~P50], (P50~P75], (>P75) + return List.of( + // 第一区间桶:统计值 <= P25 的样本数量,代表数据集中最低的约25% + buildIntervalBucket("<= " + p25, p25EndIndex), + // 第二区间桶:统计 P25 < 值 <= P50 的样本数量,代表25%~50%之间的数据 + buildIntervalBucket(p25 + " ~ " + p50, p50EndIndex - p25EndIndex), + // 第三区间桶:统计 P50 < 值 <= P75 的样本数量,代表50%~75%之间的数据 + buildIntervalBucket(p50 + " ~ " + p75, p75EndIndex - p50EndIndex), + // 第四区间桶:统计值 > P75 的样本数量,代表数据集中最高的约25% + buildIntervalBucket("> " + p75, sortedValues.size() - p75EndIndex) + ); + } + + /** + * 等宽直方图:按 (max-min)/bucketCount 等分数值范围。 + * 与四分位互补——四分位看“占比”,直方图看“形态”。 + */ + public List buildHistogramBuckets(List sortedValues, int bucketCount) { + // 参数校验:已排序值列表为空或桶数量非正时,直接返回空集合 + if (CollUtil.isEmpty(sortedValues) || bucketCount <= 0) { + // 返回不可变空列表,避免返回 null 导致上层空指针 + return Collections.emptyList(); + } + // 取已排序列表的第一个元素作为最小值(列表已按升序排列) + BigDecimal min = sortedValues.get(0); + // 取已排序列表的最后一个元素作为最大值 + BigDecimal max = sortedValues.get(sortedValues.size() - 1); + // min == max 时说明所有值完全相同,没有分布可言,直接单桶返回避免制造空桶 + if (min.compareTo(max) == 0) { + // 全值相同直接单桶返回,避免制造大量业务无意义的空桶。 + // 创建唯一的直方图桶项实例 + VibrationDistributionPageVo.HistogramBucketItem item = new VibrationDistributionPageVo.HistogramBucketItem(); + // 将最小值保留两位小数,作为桶的起止值 + BigDecimal scaleValue = min.setScale(2, RoundingMode.HALF_UP); + // 设置桶的起始值(等于唯一值) + item.setStartValue(scaleValue); + // 设置桶的结束值(等于唯一值,因为所有数据完全相同) + item.setEndValue(scaleValue); + // 设置桶内计数为全部样本数 + item.setCount((long) sortedValues.size()); + // 以单元素不可变列表返回 + return List.of(item); + } + + // 计算每个桶的步长 = (最大值 - 最小值) / 桶数,保留6位小数防止截断误差累积 + BigDecimal step = max.subtract(min).divide(BigDecimal.valueOf(bucketCount), 6, RoundingMode.HALF_UP); + // 创建可变列表用于收集所有桶项 + List bucketItems = new ArrayList<>(); + // 遍历每个桶索引,依次计算每个桶的范围和计数 + for (int i = 0; i < bucketCount; i++) { + // 计算当前桶的左边界:min + step * i + BigDecimal rawStart = min.add(step.multiply(BigDecimal.valueOf(i))); + // 计算当前桶的右边界:min + step * (i+1) + BigDecimal rawEnd = min.add(step.multiply(BigDecimal.valueOf(i + 1L))); + // 已排序列表可以直接用二分定位桶边界,避免每个桶都全量扫描一遍数据 + int startIndex = lowerBound(sortedValues, rawStart); + // 最后一桶要兜住最大值,所以右边界直接取 size;其他桶仍按左闭右开 [start, end) 统计 + int endIndex = i == bucketCount - 1 ? sortedValues.size() : lowerBound(sortedValues, rawEnd); + long count = endIndex - startIndex; + // 创建当前桶的直方图项对象 + VibrationDistributionPageVo.HistogramBucketItem item = new VibrationDistributionPageVo.HistogramBucketItem(); + // 设置桶的起始值,保留两位小数与前端展示对齐 + item.setStartValue(rawStart.setScale(2, RoundingMode.HALF_UP)); + // 设置桶的结束值,保留两位小数 + item.setEndValue(rawEnd.setScale(2, RoundingMode.HALF_UP)); + // 设置该桶内的样本计数 + item.setCount(count); + // 将当前桶项添加到结果列表 + bucketItems.add(item); + } + // 返回包含所有等宽直方图桶的列表 + return bucketItems; + } + + /** + * 二分查找 lower bound:返回第一个 >= target 的位置。 + * 直方图在已排序列表上用这个方法即可把每个桶的计数复杂度降到 O(log n)。 + */ + private int lowerBound(List sortedValues, BigDecimal target) { + // 手写左侧二分,直接返回第一个 >= target 的位置,避免重复值场景再线性左移 + int left = 0; + int right = sortedValues.size(); + while (left < right) { + int mid = left + (right - left) / 2; + if (sortedValues.get(mid).compareTo(target) >= 0) { + right = mid; + } else { + left = mid + 1; + } + } + return left; + } + + /** + * 二分查找 upper bound:返回第一个 > target 的位置。 + * 四分位区间用它计算“<= 阈值”的累计数量,比全量 filter 更稳定高效。 + */ + private int upperBound(List sortedValues, BigDecimal target) { + int left = 0; + int right = sortedValues.size(); + while (left < right) { + int mid = left + (right - left) / 2; + if (sortedValues.get(mid).compareTo(target) > 0) { + right = mid; + } else { + left = mid + 1; + } + } + return left; + } + + /** + * 日历热力图:按天聚合均值。 + * 便于观察“哪天振动明显偏高”,与生产排班或设备检修事件关联分析。 + */ + public List buildCalendarHeatmap(List rows, + Function metricExtractor) { + // 使用 LinkedHashMap 按日期分桶,保持插入顺序以便结果按时间排列 + Map> bucketMap = new LinkedHashMap<>(); + // 遍历所有采样记录,按日期分组收集指标值 + for (RecordIotenvInstant row : rows) { + // 通过传入的提取函数获取当前行的指标值 + BigDecimal value = metricExtractor.apply(row); + // 跳过指标值为空或记录时间为空的行,避免空指针 + if (value == null || row.getRecodeTime() == null) { + continue; + } + // 将记录时间格式化为"年-月-日"字符串作为日期桶的 key + String statDate = formatDate(row.getRecodeTime(), "yyyy-MM-dd"); + // 将指标值追加到对应日期桶中,桶不存在时自动创建 + bucketMap.computeIfAbsent(statDate, key -> new ArrayList<>()).add(value); + } + // 创建结果列表,用于收集每天的热力图项 + List result = new ArrayList<>(); + // 遍历每个日期桶,计算当天的均值并构建热力图项 + bucketMap.forEach((statDate, values) -> { + // 创建日历热力图单日项 + VibrationDistributionPageVo.CalendarHeatmapItem item = new VibrationDistributionPageVo.CalendarHeatmapItem(); + // 设置统计日期 + item.setStatDate(statDate); + // 计算当天所有采样点的均值作为热力值 + item.setAvgValue(avg(values)); + // 将当天热力图项添加到结果列表 + result.add(item); + }); + // 返回按日期排列的日历热力图数据 + return result; + } + + /** + * 小时热力图:按“日期 × 小时”二维聚合均值。 + * 检索 key 使用 date_hour 拼接,确保同一小时不同天的数据不会混淆。 + */ + public List buildHourlyHeatmap(List rows, + Function metricExtractor) { + // 使用 LinkedHashMap 按"日期_小时"分桶,保持插入顺序 + Map> bucketMap = new LinkedHashMap<>(); + // 遍历所有采样记录,按日期和小时二维聚合指标值 + for (RecordIotenvInstant row : rows) { + // 通过传入的提取函数获取当前行的指标值 + BigDecimal value = metricExtractor.apply(row); + // 跳过指标值为空或记录时间为空的行 + if (value == null || row.getRecodeTime() == null) { + continue; + } + // 将记录时间格式化为"年-月-日"字符串 + String statDate = formatDate(row.getRecodeTime(), "yyyy-MM-dd"); + // 提取记录时间的小时数(0~23) + Integer hour = parseHour(row.getRecodeTime()); + // 使用"日期_小时"拼接作为桶 key,确保不同天同一小时的数据不会混淆 + bucketMap.computeIfAbsent(statDate + "_" + hour, key -> new ArrayList<>()).add(value); + } + // 创建结果列表,用于收集每个日期×小时格子的热力图项 + List result = new ArrayList<>(); + // 遍历每个"日期_小时"桶,计算该时段的均值并构建热力图项 + bucketMap.forEach((key, values) -> { + // 按下划线拆分 key,parts[0]=日期,parts[1]=小时 + String[] parts = key.split("_"); + // 创建小时热力图单格项 + VibrationDistributionPageVo.HourlyHeatmapItem item = new VibrationDistributionPageVo.HourlyHeatmapItem(); + // 设置统计日期 + item.setStatDate(parts[0]); + // 设置统计小时(整数 0~23) + item.setStatHour(Integer.parseInt(parts[1])); + // 计算该时段所有采样点的均值作为热力值 + item.setAvgValue(avg(values)); + // 将热力图项添加到结果列表 + result.add(item); + }); + // 返回按日期×小时排列的热力图数据 + return result; + } + + /** 构建四分位区间项——纯工厂方法,简化上层调用 */ + private VibrationDistributionPageVo.IntervalBucketItem buildIntervalBucket(String label, long count) { + // 创建四分位区间桶项实例 + VibrationDistributionPageVo.IntervalBucketItem item = new VibrationDistributionPageVo.IntervalBucketItem(); + // 设置区间标签(如 "<= 2.50" 或 "2.50 ~ 5.00") + item.setLabel(label); + // 设置该区间内的样本计数 + item.setCount(count); + // 返回构建好的区间桶项 + return item; + } + + /** + * 分位数计算:用 ceil 向上取整索引以确保 P25 覆盖至少 25% 的数据。 + * 这比插值算法简单且对报表场景精度已超充分。 + */ + private BigDecimal percentile(List sortedValues, double ratio) { + // 空列表时返回零值,避免上层空判断 + if (CollUtil.isEmpty(sortedValues)) { + // 返回零作为兜底分位数 + return BigDecimal.ZERO; + } + // 用 ceil 向上取整计算分位索引,再用 Math.min/max 钳位到合法索引范围 [0, size-1] + int index = Math.min(sortedValues.size() - 1, Math.max(0, (int) Math.ceil(sortedValues.size() * ratio) - 1)); + // 按索引取值并保留两位小数返回 + return sortedValues.get(index).setScale(2, RoundingMode.HALF_UP); + } + + /** + * 均值计算:空列表返回零而非 null,避免上层做空判断。 + * 保留两位小数与前端热力图色值对齐。 + */ + private BigDecimal avg(List values) { + // 空列表返回零值(保留两位小数),避免上层做空判断 + if (CollUtil.isEmpty(values)) { + // 返回 0.00 作为默认均值 + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + // 对列表所有元素求和,初始值为零 + BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + // 求和除以元素个数得到均值,保留两位小数并四舍五入 + return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP); + } + + /** + * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 + */ + private String formatDate(java.util.Date date, String pattern) { + // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 + return DateTimeFormatter.ofPattern(pattern) + .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + } + + /** + * 提取小时数(0~23):用于小时热力图的“日期×小时”二维聚合 key。 + */ + private Integer parseHour(java.util.Date date) { + // 将 Date 转为系统时区的 ZonedDateTime,提取小时数(0~23)返回 + return date.toInstant().atZone(ZoneId.systemDefault()).getHour(); + } +} diff --git a/ruoyi-ems/src/main/resources/mapper/ems/report/VibrationBoardMapper.xml b/ruoyi-ems/src/main/resources/mapper/ems/report/VibrationBoardMapper.xml new file mode 100644 index 0000000..4dc7a88 --- /dev/null +++ b/ruoyi-ems/src/main/resources/mapper/ems/report/VibrationBoardMapper.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + t.objid, + t.monitorId, + t.temperature, + t.humidity, + t.illuminance, + t.noise, + t.concentration, + t.vibration_speed, + t.vibration_displacement, + t.vibration_acceleration, + t.vibration_temp, + t.collectTime, + t.recodeTime, + COALESCE(ebmi.monitor_name, t.monitorId) AS monitor_name, + t.monitorId AS monitor_code + + + + + t.recodeTime >= #{query.beginRecordTime} + AND t.recodeTime <= #{query.endRecordTime} + + + AND t.monitorId = #{query.monitorId} + + + AND t.monitorId IN + + #{monitorId} + + + + + + + AND t.vibration_speed IS NOT NULL + AND t.vibration_speed > 0 + + + AND t.vibration_displacement IS NOT NULL + AND t.vibration_displacement > 0 + + + AND t.vibration_acceleration IS NOT NULL + AND t.vibration_acceleration > 0 + + + AND t.vibration_temp IS NOT NULL + + + AND ( + (t.vibration_speed IS NOT NULL AND t.vibration_speed > 0) + OR (t.vibration_displacement IS NOT NULL AND t.vibration_displacement > 0) + OR (t.vibration_acceleration IS NOT NULL AND t.vibration_acceleration > 0) + OR (t.vibration_temp IS NOT NULL) + ) + + + + + + + SELECT * + FROM ( + + SELECT + + FROM ${tableName} t + INNER JOIN ems_base_monitor_info ebmi + ON t.monitorId = ebmi.monitor_code + AND ebmi.monitor_type = 10 + + + + + ) vibration_data + + ORDER BY vibration_data.monitorId ASC, vibration_data.recodeTime ASC, vibration_data.objid ASC + + + + + WITH sampled AS ( + + SELECT + , + ROW_NUMBER() OVER ( + PARTITION BY t.monitorId, + + + CAST(TIMESTAMPDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS SIGNED) + + + CAST(EXTRACT(EPOCH FROM (t.recodeTime - TIMESTAMP '2000-01-01 00:00:00')) / 60 / #{query.samplingInterval} AS BIGINT) + + + CAST(DATEDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS BIGINT) + + + ORDER BY t.recodeTime DESC, t.objid DESC + ) AS rn + FROM ${tableName} t + INNER JOIN ems_base_monitor_info ebmi + ON t.monitorId = ebmi.monitor_code + AND ebmi.monitor_type = 10 + + + + + ) + SELECT + objid, + monitorId, + monitor_code, + monitor_name, + temperature, + humidity, + illuminance, + noise, + concentration, + vibration_speed, + vibration_displacement, + vibration_acceleration, + vibration_temp, + collectTime, + recodeTime + FROM sampled + + WHERE rn = 1 + + ORDER BY monitorId ASC, recodeTime ASC, objid ASC + + + + + + + +