Browse Source

[0924] 增加中期功率报表日报月报年报接口

taosj 2 weeks ago
parent
commit
df4003de9c

+ 31 - 0
forecast-backend-server/src/main/java/com/ufo/project/data/controller/DataController.java

@@ -31,11 +31,14 @@ import com.ufo.project.data.domain.vo.PvMeteorologicalResourceDataQuery;
 import com.ufo.project.data.domain.vo.PvMeteorologicalResourceDataVO;
 import com.ufo.project.data.domain.vo.PvMeteorologicalResourceDataVO;
 import com.ufo.project.data.domain.vo.PvPlantCumulativeReportQuery;
 import com.ufo.project.data.domain.vo.PvPlantCumulativeReportQuery;
 import com.ufo.project.data.domain.vo.PvPlantCumulativeReportVO;
 import com.ufo.project.data.domain.vo.PvPlantCumulativeReportVO;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportQuery;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportVO;
 import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportVO;
 import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportVO;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportVO;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportVO;
 import com.ufo.project.data.service.IPvPlantCumulativeReportService;
 import com.ufo.project.data.service.IPvPlantCumulativeReportService;
+import com.ufo.project.data.service.IMediumTermPowerForecastReportService;
 import com.ufo.project.data.service.IShortTermPowerForecastReportService;
 import com.ufo.project.data.service.IShortTermPowerForecastReportService;
 import com.ufo.project.data.service.IUltraShortTermPowerForecastReportService;
 import com.ufo.project.data.service.IUltraShortTermPowerForecastReportService;
 import com.ufo.project.forecast.shortterm.power.domain.DataShortTermPowerForecastIssued;
 import com.ufo.project.forecast.shortterm.power.domain.DataShortTermPowerForecastIssued;
@@ -77,6 +80,9 @@ public class DataController extends BaseController
     @Autowired
     @Autowired
     private IUltraShortTermPowerForecastReportService ultraShortTermPowerForecastReportService;
     private IUltraShortTermPowerForecastReportService ultraShortTermPowerForecastReportService;
 
 
+    @Autowired
+    private IMediumTermPowerForecastReportService mediumTermPowerForecastReportService;
+
     @Autowired
     @Autowired
     private IPvPlantCumulativeReportService pvPlantCumulativeReportService;
     private IPvPlantCumulativeReportService pvPlantCumulativeReportService;
 
 
@@ -131,6 +137,31 @@ public class DataController extends BaseController
         util.exportExcel(response, list, "短期预测报表");
         util.exportExcel(response, list, "短期预测报表");
     }
     }
 
 
+    /**
+     * 中期预测报表(日报、月报、年报)
+     */
+    @PreAuthorize("@ss.hasPermi('data:mediumTermPowerForecastReport:list')")
+    @PostMapping("/mediumTermPowerForecastReport")
+    public TableDataInfo mediumTermPowerForecastReport(@RequestBody MediumTermPowerForecastReportQuery query)
+    {
+        List<MediumTermPowerForecastReportVO> list = mediumTermPowerForecastReportService.selectReportList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 中期预测报表导出
+     */
+    @PreAuthorize("@ss.hasPermi('data:mediumTermPowerForecastReport:export')")
+    @Log(title = "中期预测报表", businessType = BusinessType.EXPORT)
+    @PostMapping("/mediumTermPowerForecastReport/export")
+    public void mediumTermPowerForecastReportExport(HttpServletResponse response,
+            @RequestBody MediumTermPowerForecastReportQuery query)
+    {
+        List<MediumTermPowerForecastReportVO> list = mediumTermPowerForecastReportService.selectReportList(query);
+        ExcelUtil<MediumTermPowerForecastReportVO> util = new ExcelUtil<>(MediumTermPowerForecastReportVO.class);
+        util.exportExcel(response, list, "中期预测报表");
+    }
+
     /**
     /**
      * 超短期预测报表(日报、月报、年报)
      * 超短期预测报表(日报、月报、年报)
      */
      */

+ 68 - 0
forecast-backend-server/src/main/java/com/ufo/project/data/domain/vo/MediumTermPowerForecastReportQuery.java

@@ -0,0 +1,68 @@
+package com.ufo.project.data.domain.vo;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 中期预测报表查询参数
+ *
+ * @author ufo
+ * @date 2026-09-07
+ */
+public class MediumTermPowerForecastReportQuery
+{
+    /** 报表类型:daily-日报, monthly-月报, yearly-年报 */
+    @NotBlank(message = "报表类型不能为空")
+    private String type;
+
+    /** 站点/场站 id */
+    @NotBlank(message = "站点id不能为空")
+    private String entityId;
+
+    /** 开始时间 yyyy-MM-dd */
+    @NotBlank(message = "开始时间不能为空")
+    private String startTime;
+
+    /** 结束时间 yyyy-MM-dd */
+    @NotBlank(message = "结束时间不能为空")
+    private String endTime;
+
+    public String getType()
+    {
+        return type;
+    }
+
+    public void setType(String type)
+    {
+        this.type = type;
+    }
+
+    public String getEntityId()
+    {
+        return entityId;
+    }
+
+    public void setEntityId(String entityId)
+    {
+        this.entityId = entityId;
+    }
+
+    public String getStartTime()
+    {
+        return startTime;
+    }
+
+    public void setStartTime(String startTime)
+    {
+        this.startTime = startTime;
+    }
+
+    public String getEndTime()
+    {
+        return endTime;
+    }
+
+    public void setEndTime(String endTime)
+    {
+        this.endTime = endTime;
+    }
+}

+ 141 - 0
forecast-backend-server/src/main/java/com/ufo/project/data/domain/vo/MediumTermPowerForecastReportVO.java

@@ -0,0 +1,141 @@
+package com.ufo.project.data.domain.vo;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import com.ufo.base.framework.aspectj.lang.annotation.Excel;
+
+/**
+ * 中期预测报表视图对象
+ *
+ * @author ufo
+ * @date 2026-09-07
+ */
+public class MediumTermPowerForecastReportVO implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 时间(日报: yyyy-MM-dd, 月报: yyyy-MM, 年报: yyyy) */
+    @Excel(name = "时间")
+    private String time;
+
+    /** 开机容量(MW) */
+    @Excel(name = "开机容量(MW)")
+    private BigDecimal operatingCapacity;
+
+    /** 实发功率(MW) */
+    @Excel(name = "实发功率(MW)")
+    private BigDecimal actualPower;
+
+    /** 预测功率(MW) */
+    @Excel(name = "预测功率(MW)")
+    private BigDecimal forecastPower;
+
+    /** 均方根差 */
+    @Excel(name = "均方根差")
+    private BigDecimal rootMeanSquareError;
+
+    /** 平均绝对误差 */
+    @Excel(name = "平均绝对误差")
+    private BigDecimal meanAbsoluteError;
+
+    /** 相关性系数 */
+    @Excel(name = "相关性系数")
+    private BigDecimal correlationCoefficientR;
+
+    @Excel(name = "准确率")
+    private BigDecimal maximumErrorPassRate;
+
+    /** 准确率 */
+    @Excel(name = "合格率")
+    private BigDecimal acc;
+
+    public String getTime()
+    {
+        return time;
+    }
+
+    public void setTime(String time)
+    {
+        this.time = time;
+    }
+
+    public BigDecimal getOperatingCapacity()
+    {
+        return operatingCapacity;
+    }
+
+    public void setOperatingCapacity(BigDecimal operatingCapacity)
+    {
+        this.operatingCapacity = operatingCapacity;
+    }
+
+    public BigDecimal getActualPower()
+    {
+        return actualPower;
+    }
+
+    public void setActualPower(BigDecimal actualPower)
+    {
+        this.actualPower = actualPower;
+    }
+
+    public BigDecimal getForecastPower()
+    {
+        return forecastPower;
+    }
+
+    public void setForecastPower(BigDecimal forecastPower)
+    {
+        this.forecastPower = forecastPower;
+    }
+
+    public BigDecimal getRootMeanSquareError()
+    {
+        return rootMeanSquareError;
+    }
+
+    public void setRootMeanSquareError(BigDecimal rootMeanSquareError)
+    {
+        this.rootMeanSquareError = rootMeanSquareError;
+    }
+
+    public BigDecimal getMeanAbsoluteError()
+    {
+        return meanAbsoluteError;
+    }
+
+    public void setMeanAbsoluteError(BigDecimal meanAbsoluteError)
+    {
+        this.meanAbsoluteError = meanAbsoluteError;
+    }
+
+    public BigDecimal getCorrelationCoefficientR()
+    {
+        return correlationCoefficientR;
+    }
+
+    public void setCorrelationCoefficientR(BigDecimal correlationCoefficientR)
+    {
+        this.correlationCoefficientR = correlationCoefficientR;
+    }
+
+    public BigDecimal getMaximumErrorPassRate()
+    {
+        return maximumErrorPassRate;
+    }
+
+    public void setMaximumErrorPassRate(BigDecimal maximumErrorPassRate)
+    {
+        this.maximumErrorPassRate = maximumErrorPassRate;
+    }
+
+    public BigDecimal getAcc()
+    {
+        return acc;
+    }
+
+    public void setAcc(BigDecimal acc)
+    {
+        this.acc = acc;
+    }
+}

+ 22 - 0
forecast-backend-server/src/main/java/com/ufo/project/data/service/IMediumTermPowerForecastReportService.java

@@ -0,0 +1,22 @@
+package com.ufo.project.data.service;
+
+import java.util.List;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportQuery;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportVO;
+
+/**
+ * 中期预测报表Service接口
+ *
+ * @author ufo
+ * @date 2026-09-07
+ */
+public interface IMediumTermPowerForecastReportService
+{
+    /**
+     * 查询中期预测报表数据
+     *
+     * @param query 查询参数
+     * @return 报表数据列表
+     */
+    List<MediumTermPowerForecastReportVO> selectReportList(MediumTermPowerForecastReportQuery query);
+}

+ 738 - 0
forecast-backend-server/src/main/java/com/ufo/project/data/service/impl/MediumTermPowerForecastReportServiceImpl.java

@@ -0,0 +1,738 @@
+package com.ufo.project.data.service.impl;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ufo.base.common.utils.StringUtils;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportQuery;
+import com.ufo.project.data.domain.vo.MediumTermPowerForecastReportVO;
+import com.ufo.project.data.service.IMediumTermPowerForecastReportService;
+import com.ufo.project.forecast.mediumterm.power.domain.DataMediumTermPowerForecastReport;
+import com.ufo.project.forecast.mediumterm.power.domain.ForecastPowerAverage;
+import com.ufo.project.forecast.mediumterm.power.service.IDataMediumTermPowerForecastReportService;
+import com.ufo.project.model.domain.DataElectricityLocal;
+import com.ufo.project.model.service.IDataElectricityLocalService;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 中期预测报表Service业务层处理
+ *
+ * @author ufo
+ * @date 2026-09-07
+ */
+@Slf4j 
+@Service
+public class MediumTermPowerForecastReportServiceImpl implements IMediumTermPowerForecastReportService
+{
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final ZoneId ZONE_SHANGHAI = ZoneId.of("Asia/Shanghai");
+
+    @Autowired
+    private IDataElectricityLocalService electricityLocalService;
+
+    @Autowired
+    private IDataMediumTermPowerForecastReportService powerForecastReportService;
+
+    @Override
+    public List<MediumTermPowerForecastReportVO> selectReportList(MediumTermPowerForecastReportQuery query)
+    {
+        String type = query.getType();
+        String entityId = query.getEntityId();
+        String startTime = query.getStartTime() + " 00:00:00";
+        String endTime = query.getEndTime() + " 23:59:59";
+
+        // 1. 获取实发功率数据
+        List<DataElectricityLocal> actualList = electricityLocalService.selectActualPowerList(
+                parseLong(entityId), startTime, endTime);
+        if (actualList == null || actualList.isEmpty())
+        {
+            return Collections.emptyList();
+        }
+
+        // 2. 获取预测功率数据
+        long utcStartTime = parseLong(startTime);
+        long utcEndTime = parseLong(endTime);
+        DataMediumTermPowerForecastReport dataMediumTermPowerForecastReportParam = new DataMediumTermPowerForecastReport();
+        dataMediumTermPowerForecastReportParam.setForecastStartTime(utcStartTime).setForecastEndTime(utcEndTime);
+        List<DataMediumTermPowerForecastReport> forecastList = powerForecastReportService.selectDataMediumTermPowerForecastReportList(dataMediumTermPowerForecastReportParam);
+        List<ForecastPowerAverage> pureForecastList = this.pureForecastList(forecastList);
+
+        // 3. 按基准类型分组
+        switch (type)
+        {
+            case "daily":
+                return buildDailyReport(actualList, pureForecastList);
+            case "monthly":
+                return buildMonthlyReport(actualList, pureForecastList);
+            case "yearly":
+                return buildYearlyReport(actualList, pureForecastList);
+            default:
+                return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 清洗预测数据:按 forecast_time 分组,同一时间点存在多条预测记录时,
+     * 取起报时间(start_time)最新的一条(start_time 相同则取 id 最大),封装为 ForecastPowerAverage
+     */
+    private List<ForecastPowerAverage> pureForecastList(List<DataMediumTermPowerForecastReport> forecastList)
+    {
+        if (forecastList == null || forecastList.isEmpty())
+        {
+            return Collections.emptyList();
+        }
+
+        // start_time desc, id desc:比较两条记录的新旧
+        Comparator<DataMediumTermPowerForecastReport> latest = Comparator
+                .comparing(DataMediumTermPowerForecastReport::getStartTime,
+                        Comparator.nullsFirst(Comparator.<Long>naturalOrder()))
+                .thenComparing(DataMediumTermPowerForecastReport::getId,
+                        Comparator.nullsFirst(Comparator.<Long>naturalOrder()));
+
+        // 按 forecast_time 分组,每组保留最新一条
+        Map<Long, DataMediumTermPowerForecastReport> latestByForecastTime = new LinkedHashMap<>();
+        for (DataMediumTermPowerForecastReport record : forecastList)
+        {
+            if (record.getForecastTime() == null)
+            {
+                continue;
+            }
+            latestByForecastTime.merge(record.getForecastTime(), record,
+                    (existing, candidate) -> latest.compare(candidate, existing) > 0 ? candidate : existing);
+        }
+
+        // 封装 ForecastPowerAverage,按 forecast_time 升序输出
+        List<ForecastPowerAverage> result = new ArrayList<>();
+        for (Map.Entry<Long, DataMediumTermPowerForecastReport> entry : latestByForecastTime.entrySet())
+        {
+            ForecastPowerAverage average = new ForecastPowerAverage();
+            average.setForecastTime(entry.getKey());
+            average.setAvgPower(parsePower(entry.getValue().getPower()));
+            result.add(average);
+        }
+        result.sort(Comparator.comparing(ForecastPowerAverage::getForecastTime));
+        return result;
+    }
+
+    /**
+     * 安全解析预测功率字符串为 BigDecimal
+     */
+    private BigDecimal parsePower(String power)
+    {
+        if (StringUtils.isEmpty(power))
+        {
+            return null;
+        }
+        try
+        {
+            return new BigDecimal(power.trim());
+        }
+        catch (NumberFormatException e)
+        {
+            return null;
+        }
+    }
+
+    /**
+     * 构建日报
+     */
+    private List<MediumTermPowerForecastReportVO> buildDailyReport(List<DataElectricityLocal> actualList,
+            List<ForecastPowerAverage> forecastList)
+    {
+        Map<String, List<DataElectricityLocal>> dailyMap = groupByDay(actualList);
+        Map<String, List<ForecastPowerAverage>> forecastDailyMap = groupForecastByDay(forecastList);
+
+        List<MediumTermPowerForecastReportVO> result = new ArrayList<>();
+        for (Entry<String, List<DataElectricityLocal>> entry : dailyMap.entrySet())
+        {
+            String day = entry.getKey();
+            List<DataElectricityLocal> records = entry.getValue();
+            List<ForecastPowerAverage> forecastRecords = forecastDailyMap.getOrDefault(day, Collections.emptyList());
+
+            MediumTermPowerForecastReportVO vo = new MediumTermPowerForecastReportVO();
+            vo.setTime(day);
+            computeDailyMetrics(vo, records, forecastRecords);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 构建月报
+     */
+    private List<MediumTermPowerForecastReportVO> buildMonthlyReport(List<DataElectricityLocal> actualList,
+            List<ForecastPowerAverage> forecastList)
+    {
+        Map<String, List<DataElectricityLocal>> monthlyMap = groupByMonth(actualList);
+        Map<String, List<ForecastPowerAverage>> forecastMonthlyMap = groupForecastByMonth(forecastList);
+
+        List<MediumTermPowerForecastReportVO> result = new ArrayList<>();
+        for (Entry<String, List<DataElectricityLocal>> entry : monthlyMap.entrySet())
+        {
+            String month = entry.getKey();
+            List<DataElectricityLocal> records = entry.getValue();
+            List<ForecastPowerAverage> forecastRecords =
+                    forecastMonthlyMap.getOrDefault(month, Collections.emptyList());
+
+            MediumTermPowerForecastReportVO vo = new MediumTermPowerForecastReportVO();
+            vo.setTime(month);
+            computeDailyMetrics(vo, records, forecastRecords);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 构建年报
+     */
+    private List<MediumTermPowerForecastReportVO> buildYearlyReport(List<DataElectricityLocal> actualList,
+            List<ForecastPowerAverage> forecastList)
+    {
+        Map<String, List<DataElectricityLocal>> yearlyMap = groupByYear(actualList);
+        Map<String, List<ForecastPowerAverage>> forecastYearlyMap = groupForecastByYear(forecastList);
+
+        List<MediumTermPowerForecastReportVO> result = new ArrayList<>();
+        for (Entry<String, List<DataElectricityLocal>> entry : yearlyMap.entrySet())
+        {
+            String year = entry.getKey();
+            List<DataElectricityLocal> records = entry.getValue();
+            List<ForecastPowerAverage> forecastRecords =
+                    forecastYearlyMap.getOrDefault(year, Collections.emptyList());
+
+            MediumTermPowerForecastReportVO vo = new MediumTermPowerForecastReportVO();
+            vo.setTime(year);
+            computeDailyMetrics(vo, records, forecastRecords);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 计算指标(日报/月报/年报通用)
+     */
+    private void computeDailyMetrics(MediumTermPowerForecastReportVO vo,
+            List<DataElectricityLocal> actualRecords, List<ForecastPowerAverage> forecastRecords)
+    {
+        // 预先构建预测数据映射(避免各方法重复调用 buildForecastMap)
+        Map<Long, BigDecimal> forecastMap = buildForecastMap(forecastRecords);
+
+        // 1. operatingCapacity: 筛选 device_status = 0 AND grid_status = 1, MAX(actual_power) / 1000
+        BigDecimal operatingCapacity = computeOperatingCapacity(actualRecords);
+        vo.setOperatingCapacity(operatingCapacity);
+
+        // 2. actualPower: 筛选 grid_status = 1, AVG(actual_power) / 1000
+        BigDecimal actualPower = computeActualPower(actualRecords);
+        vo.setActualPower(actualPower);
+
+        // 3. forecastPower: AVG(power) / 1000
+        BigDecimal forecastPower = computeForecastPower(forecastMap);
+        vo.setForecastPower(forecastPower);
+
+        // 4. RMSE
+        BigDecimal rmse = computeRmse(actualRecords, forecastMap);
+        vo.setRootMeanSquareError(rmse);
+
+        // 5. MAE
+        BigDecimal mae = computeMae(actualRecords, forecastMap);
+        vo.setMeanAbsoluteError(mae);
+
+        // 6. correlationCoefficientR
+        BigDecimal corr = computeCorrelation(actualRecords, forecastMap);
+        vo.setCorrelationCoefficientR(corr);
+
+        // 7. 准确率
+        BigDecimal ONE_HUNDRED = new BigDecimal(100);
+        BigDecimal passRate = ONE_HUNDRED.subtract(rmse.multiply(ONE_HUNDRED)).setScale(3, RoundingMode.HALF_UP);
+        if(passRate.compareTo(ONE_HUNDRED) > 0){
+            passRate = ONE_HUNDRED;
+        }
+        vo.setMaximumErrorPassRate(passRate);
+
+        // 8. acc: 合格率
+        BigDecimal acc = computeAccuracy(actualRecords, forecastMap, operatingCapacity);
+        vo.setAcc(acc);
+    }
+
+    /**
+     * 计算开机容量
+     */
+    private BigDecimal computeOperatingCapacity(List<DataElectricityLocal> records)
+    {
+        double maxPower = 0.0;
+        for (DataElectricityLocal r : records)
+        {
+            if (r.getDeviceStatus() != null && r.getDeviceStatus() == 0
+                    && r.getGridStatus() != null && r.getGridStatus() == 1)
+            {
+                Double ap = r.getActualPower();
+                if (ap != null && ap > maxPower)
+                {
+                    maxPower = ap;
+                }
+            }
+        }
+        return BigDecimal.valueOf(maxPower / 1000.0).setScale(3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 计算实发功率
+     */
+    private BigDecimal computeActualPower(List<DataElectricityLocal> records)
+    {
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (DataElectricityLocal r : records)
+        {
+            if (r.getGridStatus() != null && r.getGridStatus() == 1)
+            {
+                Double ap = r.getActualPower();
+                if (ap != null)
+                {
+                    sum = sum.add(BigDecimal.valueOf(ap));
+                    count++;
+                }
+            }
+        }
+        if (count == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+        return sum.divide(new BigDecimal(1000)).divide(BigDecimal.valueOf(count), 3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 计算预测功率
+     */
+    private BigDecimal computeForecastPower(Map<Long, BigDecimal> forecastMap)
+    {
+        if (forecastMap == null || forecastMap.isEmpty())
+        {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal sum = forecastMap.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal avg = sum.divide(BigDecimal.valueOf(forecastMap.size()), 10, RoundingMode.HALF_UP);
+        return avg.divide(new BigDecimal(1000), 3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 计算RMSE
+     */
+    private BigDecimal computeRmse(List<DataElectricityLocal> actualRecords,
+            Map<Long, BigDecimal> forecastMap)
+    {
+        if (forecastMap.isEmpty())
+        {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal sumSquaredError = BigDecimal.ZERO;
+        int count = 0;
+        BigDecimal thousand = BigDecimal.valueOf(1000);
+
+        for (DataElectricityLocal r : actualRecords)
+        {
+            if (r.getTm() == null || r.getActualPower() == null) continue;
+
+            Long ts = r.getTm().getTime() / 1000;
+            BigDecimal fp = forecastMap.get(ts);
+            if (fp == null) continue;
+
+            BigDecimal ap = BigDecimal.valueOf(r.getActualPower());
+            BigDecimal diffMW = ap.subtract(fp).divide(thousand, 10, RoundingMode.HALF_UP);
+            sumSquaredError = sumSquaredError.add(diffMW.multiply(diffMW));
+            count++;
+        }
+
+        if (count == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal meanSquaredError = sumSquaredError.divide(BigDecimal.valueOf(count), 10, RoundingMode.HALF_UP);
+        // 用 Math.sqrt 开方(平方根无法用 BigDecimal 精确表示,转为 double 计算后格式化)
+        double rmse = Math.sqrt(meanSquaredError.doubleValue());
+        return BigDecimal.valueOf(rmse).setScale(3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 计算MAE
+     */
+    private BigDecimal computeMae(List<DataElectricityLocal> actualRecords,
+            Map<Long, BigDecimal> forecastMap)
+    {
+        if (forecastMap.isEmpty())
+        {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal sumAbsError = BigDecimal.ZERO;
+        int count = 0;
+        BigDecimal thousand = BigDecimal.valueOf(1000);
+
+        for (DataElectricityLocal r : actualRecords)
+        {
+            if (r.getTm() == null || r.getActualPower() == null) continue;
+
+            Long ts = r.getTm().getTime() / 1000;
+            BigDecimal fp = forecastMap.get(ts);
+            if (fp == null) continue;
+
+            BigDecimal ap = BigDecimal.valueOf(r.getActualPower());
+            BigDecimal absErrorMW = ap.subtract(fp).abs().divide(thousand, 10, RoundingMode.HALF_UP);
+            sumAbsError = sumAbsError.add(absErrorMW);
+            count++;
+        }
+
+        if (count == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal mae = sumAbsError.divide(BigDecimal.valueOf(count), 3, RoundingMode.HALF_UP);
+        return mae;
+    }
+
+    /**
+     * 计算相关性系数 R
+     */
+    private BigDecimal computeCorrelation(List<DataElectricityLocal> actualRecords,
+            Map<Long, BigDecimal> forecastMap)
+    {
+        if (forecastMap.isEmpty())
+        {
+            return BigDecimal.ZERO;
+        }
+
+        // 收集成对数据(MW)
+        List<BigDecimal> xList = new ArrayList<>();
+        List<BigDecimal> yList = new ArrayList<>();
+        BigDecimal thousand = BigDecimal.valueOf(1000);
+
+        for (DataElectricityLocal r : actualRecords)
+        {
+            if (r.getTm() == null || r.getActualPower() == null) continue;
+
+            Long ts = r.getTm().getTime() / 1000;
+            BigDecimal fp = forecastMap.get(ts);
+            if (fp == null) continue;
+
+            BigDecimal ap = BigDecimal.valueOf(r.getActualPower()).divide(thousand, 10, RoundingMode.HALF_UP);
+            BigDecimal fy = fp.divide(thousand, 10, RoundingMode.HALF_UP);
+            xList.add(ap);
+            yList.add(fy);
+        }
+
+        int n = xList.size();
+        if (n < 2)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        BigDecimal nBd = BigDecimal.valueOf(n);
+        // 计算均值
+        BigDecimal sumX = xList.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal sumY = yList.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal meanX = sumX.divide(nBd, 10, RoundingMode.HALF_UP);
+        BigDecimal meanY = sumY.divide(nBd, 10, RoundingMode.HALF_UP);
+
+        // 计算相关系数:逐步累加,保持精度
+        BigDecimal numerator = BigDecimal.ZERO;
+        BigDecimal sumSqX = BigDecimal.ZERO;
+        BigDecimal sumSqY = BigDecimal.ZERO;
+
+        for (int i = 0; i < n; i++)
+        {
+            BigDecimal dx = xList.get(i).subtract(meanX);
+            BigDecimal dy = yList.get(i).subtract(meanY);
+            numerator = numerator.add(dx.multiply(dy));
+            sumSqX = sumSqX.add(dx.multiply(dx));
+            sumSqY = sumSqY.add(dy.multiply(dy));
+        }
+
+        BigDecimal denominatorSquared = sumSqX.multiply(sumSqY);
+        if (denominatorSquared.compareTo(BigDecimal.ZERO) == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        // 用 Math.sqrt 开方(平方根无法用 BigDecimal 精确表示)
+        double denominator = Math.sqrt(denominatorSquared.doubleValue());
+        double r = numerator.doubleValue() / denominator;
+        return BigDecimal.valueOf(r).setScale(3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 计算准确率 acc = (1 - sqrt(ΣSi / N)) * 100
+     *
+     * <p>其中:
+     * <ul>
+     *   <li>阈值 = 开机容量 * 0.2</li>
+     *   <li>Si = ((Pmi - Ppi) / Pmi)² 当 Pmi >= 阈值</li>
+     *   <li>Si = ((Pmi - Ppi) / 阈值)² 当 Pmi &lt; 阈值</li>
+     * </ul>
+     */
+    private BigDecimal computeAccuracy(List<DataElectricityLocal> actualRecords,
+            Map<Long, BigDecimal> forecastMap,
+            BigDecimal operatingCapacity)
+    {
+        if (forecastMap == null || forecastMap.isEmpty() || operatingCapacity == null
+                || operatingCapacity.compareTo(BigDecimal.ZERO) == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        // 阈值 = 开机容量(MW) * 0.2
+        BigDecimal threshold = operatingCapacity.multiply(new BigDecimal("0.2")).setScale(3, RoundingMode.HALF_UP);
+
+        BigDecimal stotal = BigDecimal.ZERO;
+        int n = 0;
+        int precisionLength = 20;
+
+        for (DataElectricityLocal r : actualRecords)
+        {
+            if (r.getTm() == null || r.getActualPower() == null) continue;
+
+            Long ts = r.getTm().getTime() / 1000;
+            BigDecimal fp = forecastMap.get(ts);
+            if (fp == null) continue;
+
+            BigDecimal pmiMW = BigDecimal.valueOf(r.getActualPower()).divide(BigDecimal.valueOf(1000), precisionLength, RoundingMode.HALF_UP);
+            BigDecimal ppiMW = fp.divide(BigDecimal.valueOf(1000), precisionLength, RoundingMode.HALF_UP);
+
+            BigDecimal diff = pmiMW.subtract(ppiMW);
+            BigDecimal si;
+            if (pmiMW.compareTo(threshold) >= 0)
+            {
+                BigDecimal tmpSi = diff.divide(pmiMW, precisionLength, RoundingMode.HALF_UP);
+                si = tmpSi.multiply(tmpSi);
+                log.info("actualRecord tm {}, actualRecord power {}, pmiMW {}, forecast power {}, ppiMW {}, si {}", r.getTm(), r.getActualPower(), pmiMW, fp, ppiMW, si);
+            }
+            else
+            {
+                BigDecimal tmpSi = diff.divide(threshold, precisionLength, RoundingMode.HALF_UP);
+                si = tmpSi.multiply(tmpSi);
+                log.info("actualRecord tm {}, actualRecord power {}, pmiMW {}, forecast power {}, ppiMW {}, si {}", r.getTm(), r.getActualPower(), pmiMW, fp, ppiMW, si);
+            }
+
+            stotal = stotal.add(si);
+            n++;
+        }
+
+        if (n == 0)
+        {
+            return BigDecimal.ZERO;
+        }
+
+        // acc = (1 - sqrt(stotal / n)) * 100
+        double meanSquare = stotal.divide(BigDecimal.valueOf(n), precisionLength, RoundingMode.HALF_UP).doubleValue();
+        log.info("meanSquare: {}", meanSquare);
+        double deviation = Math.sqrt(meanSquare);
+        log.info("deviation: {}", deviation);
+        double acc = (1.0 - deviation) * 100.0;
+        log.info("acc: {}", acc);
+
+        // 边界处理
+        if (acc < 0) acc = 0;
+        if (acc > 100) acc = 100;
+
+        return BigDecimal.valueOf(acc).setScale(3, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 构建预测数据映射:时间戳(秒) -> power(kW)
+     */
+    private Map<Long, BigDecimal> buildForecastMap(List<ForecastPowerAverage> records)
+    {
+        Map<Long, BigDecimal> map = new LinkedHashMap<>();
+        if (records == null) return map;
+
+        for (ForecastPowerAverage r : records)
+        {
+            if (r.getForecastTime() == null) continue;
+            BigDecimal power = r.getAvgPower();
+            if (power == null) continue;
+
+            try
+            {
+                map.put(r.getForecastTime(), power);
+            }
+            catch (NumberFormatException e)
+            {
+                // skip
+            }
+        }
+        return map;
+    }
+
+    /**
+     * 按日分组
+     */
+    private Map<String, List<DataElectricityLocal>> groupByDay(List<DataElectricityLocal> records)
+    {
+        return records.stream()
+                .filter(r -> r.getTm() != null)
+                .collect(Collectors.groupingBy(
+                        r -> LocalDateTime.ofInstant(r.getTm().toInstant(), ZONE_SHANGHAI)
+                                .toLocalDate()
+                                .format(DATE_FMT),
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 按月分组
+     */
+    private Map<String, List<DataElectricityLocal>> groupByMonth(List<DataElectricityLocal> records)
+    {
+        return records.stream()
+                .filter(r -> r.getTm() != null)
+                .collect(Collectors.groupingBy(
+                        r ->
+                        {
+                            LocalDate ld = LocalDateTime.ofInstant(r.getTm().toInstant(), ZONE_SHANGHAI)
+                                    .toLocalDate();
+                            return YearMonth.of(ld.getYear(), ld.getMonth()).format(MONTH_FMT);
+                        },
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 按年分组
+     */
+    private Map<String, List<DataElectricityLocal>> groupByYear(List<DataElectricityLocal> records)
+    {
+        return records.stream()
+                .filter(r -> r.getTm() != null)
+                .collect(Collectors.groupingBy(
+                        r -> String.valueOf(LocalDateTime.ofInstant(r.getTm().toInstant(), ZONE_SHANGHAI)
+                                .toLocalDate()
+                                .getYear()),
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 按日分组预测数据
+     */
+    private Map<String, List<ForecastPowerAverage>> groupForecastByDay(
+            List<ForecastPowerAverage> records)
+    {
+        return records.stream()
+                .filter(r -> r.getForecastTime() != null)
+                .collect(Collectors.groupingBy(
+                        r -> LocalDateTime.ofInstant(
+                                Instant.ofEpochSecond(r.getForecastTime()), ZONE_SHANGHAI)
+                                .toLocalDate()
+                                .format(DATE_FMT),
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 按月分组预测数据
+     */
+    private Map<String, List<ForecastPowerAverage>> groupForecastByMonth(
+            List<ForecastPowerAverage> records)
+    {
+        return records.stream()
+                .filter(r -> r.getForecastTime() != null)
+                .collect(Collectors.groupingBy(
+                        r ->
+                        {
+                            LocalDate ld = LocalDateTime.ofInstant(
+                                    Instant.ofEpochSecond(r.getForecastTime()), ZONE_SHANGHAI)
+                                    .toLocalDate();
+                            return YearMonth.of(ld.getYear(), ld.getMonth()).format(MONTH_FMT);
+                        },
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 按年分组预测数据
+     */
+    private Map<String, List<ForecastPowerAverage>> groupForecastByYear(
+            List<ForecastPowerAverage> records)
+    {
+        return records.stream()
+                .filter(r -> r.getForecastTime() != null)
+                .collect(Collectors.groupingBy(
+                        r -> String.valueOf(LocalDateTime.ofInstant(
+                                        Instant.ofEpochSecond(r.getForecastTime()), ZONE_SHANGHAI)
+                                .toLocalDate()
+                                .getYear()),
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+    }
+
+    /**
+     * 安全解析Long,支持纯数字时间戳或日期字符串(yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy-MM)。
+     * 输入的日期字符串均为 GMT+8 时间,解析为 UTC epoch seconds 供查询使用。
+     */
+    private Long parseLong(String value)
+    {
+        if (value == null || value.isEmpty()) return 0L;
+        String trimmed = value.trim();
+        // 先尝试直接解析为数字时间戳
+        try
+        {
+            return Long.parseLong(trimmed);
+        }
+        catch (NumberFormatException e)
+        {
+            // 尝试作为日期字符串解析为 UTC epoch seconds
+            try
+            {
+                // yyyy-MM-dd HH:mm:ss
+                ZonedDateTime zdt = LocalDateTime.parse(trimmed, DATE_TIME_FMT)
+                        .atZone(ZONE_SHANGHAI);
+                return zdt.toInstant().getEpochSecond();
+            }
+            catch (DateTimeParseException e2)
+            {
+                // yyyy-MM-dd
+                try
+                {
+                    ZonedDateTime zdt = LocalDate.parse(trimmed, DATE_FMT)
+                            .atStartOfDay(ZONE_SHANGHAI);
+                    return zdt.toInstant().getEpochSecond();
+                }
+                catch (DateTimeParseException e3)
+                {
+                    // yyyy-MM(月末)
+                    try
+                    {
+                        YearMonth ym = YearMonth.parse(trimmed, MONTH_FMT);
+                        ZonedDateTime zdt = ym.atDay(ym.lengthOfMonth()).atTime(23, 59, 59).atZone(ZONE_SHANGHAI);
+                        return zdt.toInstant().getEpochSecond();
+                    }
+                    catch (DateTimeParseException e4)
+                    {
+                        return 0L;
+                    }
+                }
+            }
+        }
+    }
+}