feat(振动看板): 新增振动看板功能模块

实现振动看板全功能模块,包含7个分析页面(总览、趋势、对比、质量、分布、异常、高级)及其相关组件

- 新增振动看板Controller、Service、Mapper及XML映射文件
- 新增7个页面VO类,包含各页面专属数据结构
- 新增查询参数BO类VibrationBoardQueryBo
- 新增振动分布统计聚合器VibrationDistributionAggregator
- 新增振动异常分析器VibrationAnomalyAnalyzer
- 实现原始数据查询和抽样查询两种数据获取方式
- 提供四类分布视图(四分位、直方图、日历/小时热力图)聚合
- 支持四种异常检测(高风险、突变、连续超标、抖动)
main
zch 3 months ago
parent 01264401a9
commit 887324cb7d

@ -0,0 +1,108 @@
package org.dromara.ems.report.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAdvancedPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationComparisonPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationOverviewPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationQualityPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationTrendPageVo;
import org.dromara.ems.report.service.IVibrationBoardService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Controller
*
* <p>
* SQL </p>
*
* <p> 7 GET
* 1)
* 2)
* 3)
* 使</p>
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/ems/report/vibrationBoard")
public class VibrationBoardController {
/** 振动看板服务——所有业务逻辑收敛在 Service 层Controller 只做参数透传 */
private final IVibrationBoardService vibrationBoardService;
/**
*
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/overview")
public R<VibrationOverviewPageVo> overview(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listOverviewData(bo));
}
/**
* / +
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/trend")
public R<VibrationTrendPageVo> trend(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listTrendData(bo));
}
/**
* +
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/comparison")
public R<VibrationComparisonPageVo> comparison(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listComparisonData(bo));
}
/**
*
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/quality")
public R<VibrationQualityPageVo> quality(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listQualityData(bo));
}
/**
* /
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/distribution")
public R<VibrationDistributionPageVo> distribution(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listDistributionData(bo));
}
/**
* ///
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/anomaly")
public R<VibrationAnomalyPageVo> anomaly(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listAnomalyData(bo));
}
/**
* //
* <p></p>
*/
@SaCheckPermission("ems/report:vibrationBoard:query")
@GetMapping("/advanced")
public R<VibrationAdvancedPageVo> advanced(VibrationBoardQueryBo bo) {
return R.ok(vibrationBoardService.listAdvancedData(bo));
}
}

@ -0,0 +1,97 @@
package org.dromara.ems.report.domain.bo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p> BO
*
* </p>
*
* <p>highThresholdwarningThreshold 使
* 7 BO
* </p>
*/
@Data
public class VibrationBoardQueryBo {
/**
*
* <p>
* Service monitorIds IN SQL </p>
*/
private String monitorId;
/**
*
* <p>
* monitorId Service </p>
*/
private List<String> monitorIds;
/**
* yyyy-MM-dd HH:mm:ss
* <p> Service fail fast
* 24 </p>
*/
private String beginRecordTime;
/**
* yyyy-MM-dd HH:mm:ss
* <p> beginRecordTime 90 </p>
*/
private String endRecordTime;
/**
*
* <p> 1 1 ROW_NUMBER SQL
* Java
* 1440</p>
*/
private Integer samplingInterval;
/**
*
* <p>vibrationSpeed / vibrationDisplacement / vibrationAcceleration / vibrationTemp
* vibrationSpeed
* Service fail fast </p>
*/
private String vibrationParam;
/**
* 使
* <p> 7.1mm/s
* ISO 10816-3 D </p>
*/
private BigDecimal highThreshold;
/**
* 使
* <p></p>
*/
private BigDecimal warningThreshold;
/**
* 使
* <p> 3 3 </p>
*/
private Integer minContinuousSamples;
/**
* 使
* <p>
* </p>
*/
private BigDecimal rapidRiseThreshold;
/**
* 使
* <p>
* </p>
*/
private BigDecimal stddevThreshold;
}

@ -0,0 +1,110 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
*
*
* 33%/66% //
* 使</p>
*/
@Data
public class VibrationAdvancedPageVo {
/** 当前主看指标字段名 */
private String metricField;
/** 主看指标显示标签 */
private String metricLabel;
/** 主看指标单位 */
private String unit;
/** “平稳”与“关注”的分界线33% 分位),前端可用于图例着色 */
private BigDecimal lowBandUpper;
/** “关注”与“高位”的分界线66% 分位) */
private BigDecimal focusBandUpper;
/** 桑基图节点:设备名 + 流向阶段(平稳/关注/高位) */
private List<SankeyNodeItem> sankeyNodes;
/** 桑基图连线:设备→等级的流量关系 */
private List<SankeyLinkItem> sankeyLinks;
/** 矩形树图节点:每台设备一个矩形,面积正比于振动均值 */
private List<TreemapItem> treemapItems;
/** 平行坐标轴:四个振动维度各一根轴,上限放大 20% 避免贴边 */
private List<ParallelAxisItem> parallelAxes;
/** 平行坐标系列:每台设备一条折线,四个维度均值串联 */
private List<ParallelSeriesItem> 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<BigDecimal> values;
}
}

@ -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;
/**
*
*
* <p>
* 1)
* 2)
* 3)
* 4)
* </p>
*/
@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<HighEventItem> highEvents;
/** 连续超标事件列表:连续多个采样点超过预警阈值 */
private List<ContinuousEventItem> continuousEvents;
/** 变化过快事件列表:相邻采样点差值 >= rapidRiseThreshold */
private List<RapidRiseEventItem> rapidRiseEvents;
/** 抖动异常事件列表:某小时内标准差 >= stddevThreshold */
private List<JitterEventItem> 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;
}
}

@ -0,0 +1,65 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
*
*
* </p>
*/
@Data
public class VibrationComparisonPageVo {
/** 当前主看指标字段名 */
private String metricField;
/** 主看指标显示标签 */
private String metricLabel;
/** 主看指标单位 */
private String unit;
/** 排名柱状图数据:按均值降序 + latest 辅助,用于“哪台振动最高”的第一印象 */
private List<RankItem> rankItems;
/** 散点图数据X=均值 Y=峰值,离群点代表“偏高且尖峰明显”的设备 */
private List<ScatterItem> 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;
}
}

@ -0,0 +1,88 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
*
*
* </p>
*/
@Data
public class VibrationDistributionPageVo {
/** 当前主看指标字段名 */
private String metricField;
/** 主看指标显示标签 */
private String metricLabel;
/** 主看指标单位 */
private String unit;
/** 四分位区间分布P25/P50/P75直观看出数据集中在哪个范围 */
private List<IntervalBucketItem> intervalBuckets;
/** 等宽直方图桶,补充四分位无法展示的尾部分布形态 */
private List<HistogramBucketItem> histogramBuckets;
/** 日历热力图:按天聚合均值,观察“哪天振动明显偏高” */
private List<CalendarHeatmapItem> calendarHeatmap;
/** 小时热力图:按“日期×小时”二维聚合,观察振动在一天中的分布规律 */
private List<HourlyHeatmapItem> 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;
}
}

@ -0,0 +1,115 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
*
* VO
* </p>
*/
@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<MetricCardItem> metricCards;
/** 仪表盘数据:单设备看多维度、多设备看群组均值,与设备数量动态适配 */
private List<GaugeItem> gaugeItems;
/** 主看指标的 latest/min/avg/max 四值统计,供前端数字卡片展示 */
private PrimaryMetricStats primaryMetricStats;
/** 设备排名——按主看指标均值倒序,帮助快速定位振动最剧烈的设备 */
private List<DeviceRankItem> 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;
}
}

@ -0,0 +1,46 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
*
* </p>
*/
@Data
public class VibrationQualityPageVo {
/** 采样点总数 */
private Integer sampleCount;
/** 设备台数 */
private Integer deviceCount;
/** 总覆盖率,与总览页复用同一计算逻辑保证数值一致 */
private BigDecimal coverageRate;
/** 各振动指标的独立有效采集率,拆到每个维度便于发现“某类传感器批量失效” */
private List<MetricQualityItem> metricQualityItems;
/**
*
*/
@Data
public static class MetricQualityItem {
/** 指标字段名 */
private String field;
/** 指标显示标签 */
private String label;
/** 指标单位 */
private String unit;
/** 有效采集率 = validCount / sampleCount值越低说明该维度丢数越严重 */
private BigDecimal validRate;
/** 有效采样点数(非 null 的记录数) */
private Integer validCount;
}
}

@ -0,0 +1,72 @@
package org.dromara.ems.report.domain.vo.vibrationboard;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
*
*
* <p>
* 线线
* ECharts </p>
*/
@Data
public class VibrationTrendPageVo {
/** 当前主看指标字段名 */
private String metricField;
/** 主看指标显示标签 */
private String metricLabel;
/** 主看指标单位 */
private String unit;
/** 是否多设备模式——前端据此决定图例展示“指标名”还是“设备名” */
private Boolean multiDevice;
/** 趋势曲线序列:单设备=四条维度线,多设备=每台设备一条线(已压缩至 MAX_TREND_POINTS 以内) */
private List<TrendSeriesItem> series;
/** 小时均值柱状图——折叠到24h用于识别“哪个时段振动偏高” */
private List<HourlyItem> hourlyItems;
/**
* 线
*/
@Data
public static class TrendSeriesItem {
/** 序列名称:单设备时为指标标签,多设备时为设备名称 */
private String name;
/** 对应的指标字段名 */
private String field;
/** 序列单位,用于 Y 轴标注 */
private String unit;
/** 时间点序列,已按时间升序 */
private List<TrendPointItem> 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;
}
}

@ -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
*
* <p> Mapper Mapper if-else
* monitor_type=10 UNION ALL ROW_NUMBER
* </p>
*
* <p> + 7
* 7 SQL Java
* </p>
*/
public interface VibrationBoardMapper {
/**
*
* <p> <= 1
* XML UNION ALL + INNER JOIN monitor_type=10 </p>
*
* @param tableNames
* @param query
*/
List<RecordIotenvInstant> selectRawData(@Param("tableNames") List<String> tableNames,
@Param("query") VibrationBoardQueryBo query);
/**
*
* <p> > 1 DB ROW_NUMBER
* +
* DB Java </p>
*
* @param tableNames
* @param query
*/
List<RecordIotenvInstant> selectSampledData(@Param("tableNames") List<String> tableNames,
@Param("query") VibrationBoardQueryBo query);
}

@ -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;
/**
*
*
* <p>
*
*
* 线</p>
*/
public interface IVibrationBoardService {
/**
*
* <p></p>
*/
VibrationOverviewPageVo listOverviewData(VibrationBoardQueryBo bo);
/**
*
* <p></p>
*/
VibrationTrendPageVo listTrendData(VibrationBoardQueryBo bo);
/**
*
* <p></p>
*/
VibrationComparisonPageVo listComparisonData(VibrationBoardQueryBo bo);
/**
*
* <p></p>
*/
VibrationQualityPageVo listQualityData(VibrationBoardQueryBo bo);
/**
*
* <p></p>
*/
VibrationDistributionPageVo listDistributionData(VibrationBoardQueryBo bo);
/**
*
* <p></p>
*/
VibrationAnomalyPageVo listAnomalyData(VibrationBoardQueryBo bo);
/**
*
* <p>/线</p>
*/
VibrationAdvancedPageVo listAdvancedData(VibrationBoardQueryBo bo);
}

@ -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;
/**
*
*
* <p>
* 1) 2) 3) 4) </p>
* <p> static 使</p>
*/
public class VibrationAnomalyAnalyzer {
/**
* >= highThreshold
* TOP200
*/
public List<VibrationAnomalyPageVo.HighEventItem> buildHighEvents(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> 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<VibrationAnomalyPageVo.RapidRiseEventItem> buildRapidRiseEvents(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> metricExtractor,
BigDecimal rapidRiseThreshold) {
// 创建可变结果列表,用于收集所有变化过快事件
List<VibrationAnomalyPageVo.RapidRiseEventItem> result = new ArrayList<>();
// 按设备分组并按时间排序,确保只比较同一台设备的前后采样点
Map<String, List<RecordIotenvInstant>> 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<VibrationAnomalyPageVo.ContinuousEventItem> buildContinuousEvents(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> metricExtractor,
BigDecimal warningThreshold,
Integer minSamples) {
// 创建可变结果列表,用于收集所有连续超标事件
List<VibrationAnomalyPageVo.ContinuousEventItem> result = new ArrayList<>();
// 按设备分组并按时间排序,确保同一设备的采样点按时间顺序可追溯
Map<String, List<RecordIotenvInstant>> 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<VibrationAnomalyPageVo.JitterEventItem> buildJitterEvents(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> metricExtractor,
BigDecimal stddevThreshold) {
// 使用 LinkedHashMap 按"设备ID_小时桶"分组,保持插入顺序
Map<String, List<BigDecimal>> bucketMap = new LinkedHashMap<>();
// 用于记录每个桶 key 对应的设备名称,供后续构建事件项时使用
Map<String, String> 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<VibrationAnomalyPageVo.JitterEventItem> result = new ArrayList<>();
// 遍历每个"设备_小时"桶,计算桶内标准差并判断是否超过抖动阈值
bucketMap.forEach((key, values) -> {
// 桶内样本数 < 2 时无法计算标准差,直接跳过
if (values.size() < 2) {
return;
}
// 计算桶内所有指标值的总体标准差
BigDecimal stddev = stddev(values);
// 标准差达到或超过抖动阈值时,记录为抖动异常事件
if (stddev.compareTo(stddevThreshold) >= 0) {
// 按第一个下划线拆分 keyparts[0]=设备IDparts[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<String, List<RecordIotenvInstant>> groupByMonitorAndSort(List<RecordIotenvInstant> rows) {
// 使用 LinkedHashMap 按设备ID分组保持插入顺序
Map<String, List<RecordIotenvInstant>> 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<BigDecimal> 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 avg2
*/
private BigDecimal avg(List<BigDecimal> 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());
}
}

@ -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;
/**
*
*
* <p>
* </p>
* <p> static 使</p>
*/
public class VibrationDistributionAggregator {
/**
* P25/P50/P75
*
*/
public List<VibrationDistributionPageVo.IntervalBucketItem> buildIntervalBuckets(List<BigDecimal> 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<VibrationDistributionPageVo.HistogramBucketItem> buildHistogramBuckets(List<BigDecimal> 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<VibrationDistributionPageVo.HistogramBucketItem> 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<BigDecimal> 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<BigDecimal> 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<VibrationDistributionPageVo.CalendarHeatmapItem> buildCalendarHeatmap(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> metricExtractor) {
// 使用 LinkedHashMap 按日期分桶,保持插入顺序以便结果按时间排列
Map<String, List<BigDecimal>> 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<VibrationDistributionPageVo.CalendarHeatmapItem> 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<VibrationDistributionPageVo.HourlyHeatmapItem> buildHourlyHeatmap(List<RecordIotenvInstant> rows,
Function<RecordIotenvInstant, BigDecimal> metricExtractor) {
// 使用 LinkedHashMap 按"日期_小时"分桶,保持插入顺序
Map<String, List<BigDecimal>> 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<VibrationDistributionPageVo.HourlyHeatmapItem> result = new ArrayList<>();
// 遍历每个"日期_小时"桶,计算该时段的均值并构建热力图项
bucketMap.forEach((key, values) -> {
// 按下划线拆分 keyparts[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<BigDecimal> 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<BigDecimal> 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();
}
}

@ -0,0 +1,196 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.ems.report.mapper.VibrationBoardMapper">
<!--
resultMap 复用 RecordIotenvInstant 实体。
振动报表核心依赖vibration_speed / vibration_displacement / vibration_acceleration / vibration_temp。
temperature / humidity / illuminance / noise / concentration 等非振动字段当前未使用,
保留映射是因为复用了通用实体类且后续可能用于“振动 vs 环境”关联分析场景。
若确认不需要,可缩减 SELECT 列和 resultMap 以进一步减少内存开销。
-->
<resultMap type="org.dromara.ems.record.domain.RecordIotenvInstant" id="VibrationBoardResult">
<result property="objid" column="objid"/>
<result property="monitorId" column="monitorId"/>
<result property="monitorCode" column="monitor_code"/>
<result property="monitorName" column="monitor_name"/>
<result property="temperature" column="temperature"/>
<result property="humidity" column="humidity"/>
<result property="illuminance" column="illuminance"/>
<result property="noise" column="noise"/>
<result property="concentration" column="concentration"/>
<result property="vibrationSpeed" column="vibration_speed"/>
<result property="vibrationDisplacement" column="vibration_displacement"/>
<result property="vibrationAcceleration" column="vibration_acceleration"/>
<result property="vibrationTemp" column="vibration_temp"/>
<result property="collectTime" column="collectTime"/>
<result property="recodeTime" column="recodeTime"/>
</resultMap>
<!--
公共列:通过 INNER JOIN ems_base_monitor_info 获取 monitor_name
COALESCE 兜底——万一设备主数据未维护也不会返回 null 导致前端报错。
-->
<sql id="selectColumns">
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
</sql>
<!--
公共 WHERE 条件:
1. 时间范围——使用参数化绑定 #{} 防注入
2. 设备过滤——支持单设备和多设备 IN 列表
3. 主看指标非空过滤——避免传感器未接入的脏数据参与统计
注意:温度允许负值,所以 vibrationTemp 只判 NOT NULL 不判 > 0
-->
<sql id="baseWhere">
t.recodeTime &gt;= #{query.beginRecordTime}
AND t.recodeTime &lt;= #{query.endRecordTime}
<choose>
<when test="query.monitorId != null and query.monitorId != ''">
AND t.monitorId = #{query.monitorId}
</when>
<when test="query.monitorIds != null and query.monitorIds.size() > 0">
AND t.monitorId IN
<foreach collection="query.monitorIds" item="monitorId" open="(" separator="," close=")">
#{monitorId}
</foreach>
</when>
</choose>
<choose>
<!-- 这里不做危险上限截断,因为高振动本身就是业务风险信号,不能在 SQL 层先被误过滤掉。 -->
<when test="query.vibrationParam == 'vibrationSpeed'">
AND t.vibration_speed IS NOT NULL
AND t.vibration_speed &gt; 0
</when>
<when test="query.vibrationParam == 'vibrationDisplacement'">
AND t.vibration_displacement IS NOT NULL
AND t.vibration_displacement &gt; 0
</when>
<when test="query.vibrationParam == 'vibrationAcceleration'">
AND t.vibration_acceleration IS NOT NULL
AND t.vibration_acceleration &gt; 0
</when>
<when test="query.vibrationParam == 'vibrationTemp'">
AND t.vibration_temp IS NOT NULL
</when>
<otherwise>
AND (
(t.vibration_speed IS NOT NULL AND t.vibration_speed &gt; 0)
OR (t.vibration_displacement IS NOT NULL AND t.vibration_displacement &gt; 0)
OR (t.vibration_acceleration IS NOT NULL AND t.vibration_acceleration &gt; 0)
OR (t.vibration_temp IS NOT NULL)
)
</otherwise>
</choose>
</sql>
<!--
原始查询UNION ALL 多日分表 → 子查询 → 排序。
INNER JOIN monitor_type = 10 确保只查振动设备——这是「振动报表独立口径」的 SQL 层保障。
${tableName} 虽然使用拼接而非参数化,但 Java 层已有白名单正则校验TABLE_NAME_PATTERN
此处是唯一允许 ${} 的位置。
-->
<sql id="rawPageQuery">
SELECT *
FROM (
<foreach collection="tableNames" item="tableName" separator=" UNION ALL ">
SELECT
<include refid="selectColumns"/>
FROM ${tableName} t
INNER JOIN ems_base_monitor_info ebmi
ON t.monitorId = ebmi.monitor_code
AND ebmi.monitor_type = 10
<where>
<include refid="baseWhere"/>
</where>
</foreach>
) vibration_data
<!-- 排序策略:先按设备分组、再按时间升序、最后按主键打破同时刻并列的不确定性——
确保 Java 层“取最新值”“相邻点差值”等算法能基于稳定的行序工作。 -->
ORDER BY vibration_data.monitorId ASC, vibration_data.recodeTime ASC, vibration_data.objid ASC
</sql>
<!--
抽样查询:使用 ROW_NUMBER 窗口函数按「设备 + 时间桶」分区,每个桶只保留最新一条记录。
时间桶宽度 = samplingInterval 分钟,通过 DATEDIFF/TIMESTAMPDIFF 计算桶编号。
这比客户端抽样更高效——数据在 DB 层就被压缩,减少网络传输和 Java 内存消耗。
多数据库兼容:通过 _databaseId 判断 MySQL/PostgreSQL/SQL Server 语法差异。
-->
<sql id="samplingPageQuery">
WITH sampled AS (
<foreach collection="tableNames" item="tableName" separator=" UNION ALL ">
SELECT
<include refid="selectColumns"/>,
ROW_NUMBER() OVER (
PARTITION BY t.monitorId,
<choose>
<when test="_databaseId == 'mysql'">
CAST(TIMESTAMPDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS SIGNED)
</when>
<when test="_databaseId == 'postgresql' or _databaseId == 'PostgreSQL'">
CAST(EXTRACT(EPOCH FROM (t.recodeTime - TIMESTAMP '2000-01-01 00:00:00')) / 60 / #{query.samplingInterval} AS BIGINT)
</when>
<otherwise>
CAST(DATEDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS BIGINT)
</otherwise>
</choose>
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
<where>
<include refid="baseWhere"/>
</where>
</foreach>
)
SELECT
objid,
monitorId,
monitor_code,
monitor_name,
temperature,
humidity,
illuminance,
noise,
concentration,
vibration_speed,
vibration_displacement,
vibration_acceleration,
vibration_temp,
collectTime,
recodeTime
FROM sampled
<!-- rn = 1 为每个“设备+时间桶”内最新的一条,效果等价于“每 N 分钟取一个采样点” -->
WHERE rn = 1
<!-- 保持与原始查询相同的排序策略,确保 Java 层聚合逻辑行为一致 -->
ORDER BY monitorId ASC, recodeTime ASC, objid ASC
</sql>
<!-- 原始明细查询入口samplingInterval <= 1 时使用) -->
<select id="selectRawData" resultMap="VibrationBoardResult">
<include refid="rawPageQuery"/>
</select>
<!-- 抽样明细查询入口samplingInterval > 1 时使用DB 层降采样) -->
<select id="selectSampledData" resultMap="VibrationBoardResult">
<include refid="samplingPageQuery"/>
</select>
</mapper>
Loading…
Cancel
Save