单独抽 BO,而不是继续复用通用物联查询对象,
+ * 是为了把振动专属的时间、设备、抽样和主看指标口径固定下来,
+ * 后续再加振动专属统计时不会和其它物联页面互相污染参数语义。
+ *
+ * 异常相关参数(highThreshold、warningThreshold 等)仅在异常页使用,
+ * 其余页面会自动忽略这些字段。这样设计是为了让 7 个页面共用同一个 BO,
+ * 避免为每个页面单独建查询对象导致文件数量失控。
+ */
+@Data
+public class VibrationBoardQueryBo {
+
+ /**
+ * 单设备编码。
+ * 前端点击设备树叶子节点时传入,进入“单设备趋势”模式。
+ * Service 层会将其合并进 monitorIds 列表,统一用 IN 查询以简化 SQL 分支。
+ */
+ private String monitorId;
+
+ /**
+ * 多设备编码列表。
+ * 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
+
+
+
+
+
+
+
+