Quellcode durchsuchen

[101901] 增加功率误差统计功能

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
taosj vor 2 Wochen
Ursprung
Commit
01264d5109
19 geänderte Dateien mit 852 neuen und 4 gelöschten Zeilen
  1. 55 0
      forecast-backend-server/src/main/java/com/ufo/project/data/controller/DataController.java
  2. 100 0
      forecast-backend-server/src/main/java/com/ufo/project/data/domain/vo/PowerErrorStatAccuracyVO.java
  3. 86 0
      forecast-backend-server/src/main/java/com/ufo/project/data/domain/vo/PowerErrorStatCurveVO.java
  4. 68 0
      forecast-backend-server/src/main/java/com/ufo/project/data/domain/vo/PowerErrorStatQuery.java
  5. 31 0
      forecast-backend-server/src/main/java/com/ufo/project/data/service/IPowerErrorStatService.java
  6. 344 0
      forecast-backend-server/src/main/java/com/ufo/project/data/service/impl/PowerErrorStatServiceImpl.java
  7. 11 0
      forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/mapper/DataMediumTermPowerForecastIssuedMapper.java
  8. 10 0
      forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/service/IDataMediumTermPowerForecastIssuedService.java
  9. 15 1
      forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/service/impl/DataMediumTermPowerForecastIssuedServiceImpl.java
  10. 11 0
      forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/mapper/DataShortTermPowerForecastIssuedMapper.java
  11. 10 0
      forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/service/IDataShortTermPowerForecastIssuedService.java
  12. 15 1
      forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/service/impl/DataShortTermPowerForecastIssuedServiceImpl.java
  13. 13 1
      forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/mapper/DataSuperShortTermForecastIssuedMapper.java
  14. 11 1
      forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/service/IDataSuperShortTermForecastIssuedService.java
  15. 14 0
      forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/service/impl/DataSuperShortTermForecastIssuedServiceImpl.java
  16. 11 0
      forecast-backend-server/src/main/resources/mybatis/mediumpower/DataMediumTermPowerForecastIssuedMapper.xml
  17. 11 0
      forecast-backend-server/src/main/resources/mybatis/power/DataShortTermPowerForecastIssuedMapper.xml
  18. 11 0
      forecast-backend-server/src/main/resources/mybatis/supershort/DataSuperShortTermForecastIssuedMapper.xml
  19. 25 0
      release_note/20261015/20261015_power_error_stat_menu.sql

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

@@ -37,10 +37,14 @@ import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.ShortTermPowerForecastReportVO;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportQuery;
 import com.ufo.project.data.domain.vo.UltraShortTermPowerForecastReportVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatQuery;
+import com.ufo.project.data.domain.vo.PowerErrorStatCurveVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatAccuracyVO;
 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.IUltraShortTermPowerForecastReportService;
+import com.ufo.project.data.service.IPowerErrorStatService;
 import com.ufo.project.forecast.shortterm.power.domain.DataShortTermPowerForecastIssued;
 import com.ufo.project.forecast.shortterm.power.service.IDataShortTermPowerForecastIssuedService;
 import com.ufo.project.forecast.shortterm.weather.domain.DataShortTermWeatherForecastIssued;
@@ -86,6 +90,9 @@ public class DataController extends BaseController
     @Autowired
     private IPvPlantCumulativeReportService pvPlantCumulativeReportService;
 
+    @Autowired
+    private IPowerErrorStatService powerErrorStatService;
+
     /**
      * 辐照度功率对比
      */
@@ -239,6 +246,54 @@ public class DataController extends BaseController
         util.exportExcel(response, list, "光伏气象资源数据");
     }
 
+    /**
+     * 功率误差统计曲线报表
+     */
+    @PreAuthorize("@ss.hasPermi('data:powerErrorStat:list')")
+    @PostMapping("/powerErrorStatCurve")
+    public TableDataInfo powerErrorStatCurve(@RequestBody PowerErrorStatQuery query)
+    {
+        List<PowerErrorStatCurveVO> list = powerErrorStatService.selectCurveList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 功率误差统计曲线报表导出
+     */
+    @PreAuthorize("@ss.hasPermi('data:powerErrorStat:export')")
+    @Log(title = "功率误差统计", businessType = BusinessType.EXPORT)
+    @PostMapping("/powerErrorStatCurve/export")
+    public void powerErrorStatCurveExport(HttpServletResponse response, @RequestBody PowerErrorStatQuery query)
+    {
+        List<PowerErrorStatCurveVO> list = powerErrorStatService.selectCurveList(query);
+        ExcelUtil<PowerErrorStatCurveVO> util = new ExcelUtil<>(PowerErrorStatCurveVO.class);
+        util.exportExcel(response, list, "功率误差统计曲线");
+    }
+
+    /**
+     * 功率误差统计精度报表
+     */
+    @PreAuthorize("@ss.hasPermi('data:powerErrorStat:list')")
+    @PostMapping("/powerErrorStatAccuracy")
+    public TableDataInfo powerErrorStatAccuracy(@RequestBody PowerErrorStatQuery query)
+    {
+        List<PowerErrorStatAccuracyVO> list = powerErrorStatService.selectAccuracyList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 功率误差统计精度报表导出
+     */
+    @PreAuthorize("@ss.hasPermi('data:powerErrorStat:export')")
+    @Log(title = "功率误差统计", businessType = BusinessType.EXPORT)
+    @PostMapping("/powerErrorStatAccuracy/export")
+    public void powerErrorStatAccuracyExport(HttpServletResponse response, @RequestBody PowerErrorStatQuery query)
+    {
+        List<PowerErrorStatAccuracyVO> list = powerErrorStatService.selectAccuracyList(query);
+        ExcelUtil<PowerErrorStatAccuracyVO> util = new ExcelUtil<>(PowerErrorStatAccuracyVO.class);
+        util.exportExcel(response, list, "功率误差统计精度");
+    }
+
     /*
      * 辐照度累计值报表
      */

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

@@ -0,0 +1,100 @@
+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-10-19
+ */
+public class PowerErrorStatAccuracyVO implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 日期 yyyy-MM-dd(GMT+8) */
+    @Excel(name = "日期")
+    private String time;
+
+    /** 均方根误差 */
+    @Excel(name = "均方根误差")
+    private BigDecimal rmse;
+
+    /** 平均绝对误差 */
+    @Excel(name = "平均绝对误差")
+    private BigDecimal mae;
+
+    /** 相关性系数 */
+    @Excel(name = "相关性系数")
+    private BigDecimal correlation;
+
+    /** 准确率(100 - rmse * 100,保留3位小数) */
+    @Excel(name = "准确率")
+    private BigDecimal passRate;
+
+    /** 合格率 */
+    @Excel(name = "合格率")
+    private BigDecimal acc;
+
+    public String getTime()
+    {
+        return time;
+    }
+
+    public void setTime(String time)
+    {
+        this.time = time;
+    }
+
+    public BigDecimal getRmse()
+    {
+        return rmse;
+    }
+
+    public void setRmse(BigDecimal rmse)
+    {
+        this.rmse = rmse;
+    }
+
+    public BigDecimal getMae()
+    {
+        return mae;
+    }
+
+    public void setMae(BigDecimal mae)
+    {
+        this.mae = mae;
+    }
+
+    public BigDecimal getCorrelation()
+    {
+        return correlation;
+    }
+
+    public void setCorrelation(BigDecimal correlation)
+    {
+        this.correlation = correlation;
+    }
+
+    public BigDecimal getPassRate()
+    {
+        return passRate;
+    }
+
+    public void setPassRate(BigDecimal passRate)
+    {
+        this.passRate = passRate;
+    }
+
+    public BigDecimal getAcc()
+    {
+        return acc;
+    }
+
+    public void setAcc(BigDecimal acc)
+    {
+        this.acc = acc;
+    }
+}

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

@@ -0,0 +1,86 @@
+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-10-19
+ */
+public class PowerErrorStatCurveVO implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 时间 yyyy-MM-dd HH:mm:ss(GMT+8) */
+    @Excel(name = "时间")
+    private String time;
+
+    /** 实测功率(kW) */
+    @Excel(name = "实测功率(kW)")
+    private BigDecimal power;
+
+    /** 预测功率(kW) */
+    @Excel(name = "预测功率(kW)")
+    private BigDecimal forecastPower;
+
+    /** 置信下限 */
+    @Excel(name = "置信下限")
+    private BigDecimal lcl;
+
+    /** 置信上限 */
+    @Excel(name = "置信上限")
+    private BigDecimal ucl;
+
+    public String getTime()
+    {
+        return time;
+    }
+
+    public void setTime(String time)
+    {
+        this.time = time;
+    }
+
+    public BigDecimal getPower()
+    {
+        return power;
+    }
+
+    public void setPower(BigDecimal power)
+    {
+        this.power = power;
+    }
+
+    public BigDecimal getForecastPower()
+    {
+        return forecastPower;
+    }
+
+    public void setForecastPower(BigDecimal forecastPower)
+    {
+        this.forecastPower = forecastPower;
+    }
+
+    public BigDecimal getLcl()
+    {
+        return lcl;
+    }
+
+    public void setLcl(BigDecimal lcl)
+    {
+        this.lcl = lcl;
+    }
+
+    public BigDecimal getUcl()
+    {
+        return ucl;
+    }
+
+    public void setUcl(BigDecimal ucl)
+    {
+        this.ucl = ucl;
+    }
+}

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

@@ -0,0 +1,68 @@
+package com.ufo.project.data.domain.vo;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 功率误差统计查询参数
+ *
+ * @author ufo
+ * @date 2026-10-19
+ */
+public class PowerErrorStatQuery
+{
+    /** 场站编码 */
+    @NotBlank(message = "场站编码不能为空")
+    private String stationCode;
+
+    /** 预测类型:short-短期, medium-中期, ultra-超短期 */
+    @NotBlank(message = "预测类型不能为空")
+    private String forecastType;
+
+    /** 开始时间 yyyy-MM-dd(包含) */
+    @NotBlank(message = "开始时间不能为空")
+    private String startTime;
+
+    /** 截止时间 yyyy-MM-dd(不包含) */
+    @NotBlank(message = "截止时间不能为空")
+    private String endTime;
+
+    public String getStationCode()
+    {
+        return stationCode;
+    }
+
+    public void setStationCode(String stationCode)
+    {
+        this.stationCode = stationCode;
+    }
+
+    public String getForecastType()
+    {
+        return forecastType;
+    }
+
+    public void setForecastType(String forecastType)
+    {
+        this.forecastType = forecastType;
+    }
+
+    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;
+    }
+}

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

@@ -0,0 +1,31 @@
+package com.ufo.project.data.service;
+
+import java.util.List;
+import com.ufo.project.data.domain.vo.PowerErrorStatAccuracyVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatCurveVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatQuery;
+
+/**
+ * 功率误差统计Service接口
+ *
+ * @author ufo
+ * @date 2026-10-19
+ */
+public interface IPowerErrorStatService
+{
+    /**
+     * 查询功率误差统计曲线列表
+     *
+     * @param query 查询参数
+     * @return 曲线数据集合(按时间升序)
+     */
+    public List<PowerErrorStatCurveVO> selectCurveList(PowerErrorStatQuery query);
+
+    /**
+     * 查询功率误差统计精度列表
+     *
+     * @param query 查询参数
+     * @return 精度数据集合(按日期升序)
+     */
+    public List<PowerErrorStatAccuracyVO> selectAccuracyList(PowerErrorStatQuery query);
+}

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

@@ -0,0 +1,344 @@
+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.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+import java.util.function.Function;
+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.PowerErrorStatAccuracyVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatCurveVO;
+import com.ufo.project.data.domain.vo.PowerErrorStatQuery;
+import com.ufo.project.data.service.IPowerErrorStatService;
+import com.ufo.project.forecast.mediumterm.power.domain.DataMediumTermPowerForecastIssued;
+import com.ufo.project.forecast.mediumterm.power.service.IDataMediumTermPowerForecastIssuedService;
+import com.ufo.project.forecast.shortterm.power.domain.DataShortTermPowerForecastIssued;
+import com.ufo.project.forecast.shortterm.power.service.IDataShortTermPowerForecastIssuedService;
+import com.ufo.project.forecast.supershort.domain.DataSuperShortTermForecastIssued;
+import com.ufo.project.forecast.supershort.service.IDataSuperShortTermForecastIssuedService;
+import com.ufo.project.model.domain.DataElectricityLocal;
+import com.ufo.project.model.service.IDataElectricityLocalService;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 功率误差统计Service业务层处理
+ *
+ * @author ufo
+ * @date 2026-10-19
+ */
+@Slf4j
+@Service
+public class PowerErrorStatServiceImpl extends AbstractPowerForecastReportServiceImpl implements IPowerErrorStatService
+{
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private static final BigDecimal HUNDRED = new BigDecimal(100);
+    private static final BigDecimal ZERO = BigDecimal.ZERO;
+
+    @Autowired
+    private IDataElectricityLocalService electricityLocalService;
+
+    @Autowired
+    private IDataShortTermPowerForecastIssuedService shortTermIssuedService;
+
+    @Autowired
+    private IDataMediumTermPowerForecastIssuedService mediumTermIssuedService;
+
+    @Autowired
+    private IDataSuperShortTermForecastIssuedService superShortTermIssuedService;
+
+    @Override
+    public List<PowerErrorStatCurveVO> selectCurveList(PowerErrorStatQuery query)
+    {
+        Long stationId = parseStationId(query.getStationCode());
+        long[] range = parseRange(query);
+        if (stationId == null || range == null)
+        {
+            return Collections.emptyList();
+        }
+        long startSec = range[0];
+        long endSec = range[1];
+
+        // 1. 实测功率(kW),key 为 unix 秒
+        Map<Long, BigDecimal> actualMap = new LinkedHashMap<>();
+        for (DataElectricityLocal r : queryActualList(stationId, query, startSec, endSec))
+        {
+            if (r.getTm() == null || r.getActualPower() == null)
+            {
+                continue;
+            }
+            actualMap.put(r.getTm().getTime() / 1000, BigDecimal.valueOf(r.getActualPower()));
+        }
+
+        // 2. 预测功率(kW),key 为 unix 秒
+        Map<Long, BigDecimal> forecastMap = queryForecastMap(query.getStationCode(), query.getForecastType(), startSec, endSec);
+
+        // 3. 时间取双方合集,缺失一方默认为 0
+        TreeSet<Long> times = new TreeSet<>();
+        times.addAll(actualMap.keySet());
+        times.addAll(forecastMap.keySet());
+
+        List<PowerErrorStatCurveVO> result = new ArrayList<>();
+        for (Long ts : times)
+        {
+            PowerErrorStatCurveVO vo = new PowerErrorStatCurveVO();
+            vo.setTime(formatDateTime(ts));
+            vo.setPower(actualMap.getOrDefault(ts, ZERO));
+            vo.setForecastPower(forecastMap.getOrDefault(ts, ZERO));
+            vo.setLcl(ZERO);
+            vo.setUcl(ZERO);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    @Override
+    public List<PowerErrorStatAccuracyVO> selectAccuracyList(PowerErrorStatQuery query)
+    {
+        Long stationId = parseStationId(query.getStationCode());
+        long[] range = parseRange(query);
+        if (stationId == null || range == null)
+        {
+            return Collections.emptyList();
+        }
+        long startSec = range[0];
+        long endSec = range[1];
+
+        List<DataElectricityLocal> actualList = queryActualList(stationId, query, startSec, endSec);
+        Map<Long, BigDecimal> forecastMap = queryForecastMap(query.getStationCode(), query.getForecastType(), startSec, endSec);
+
+        // 实测数据按天分组(GMT+8)
+        Map<String, List<DataElectricityLocal>> actualByDay = new LinkedHashMap<>();
+        for (DataElectricityLocal r : actualList)
+        {
+            if (r.getTm() == null)
+            {
+                continue;
+            }
+            actualByDay.computeIfAbsent(dayKey(r.getTm().getTime() / 1000), k -> new ArrayList<>()).add(r);
+        }
+
+        // 预测数据按天分组(GMT+8)
+        Map<String, Map<Long, BigDecimal>> forecastByDay = new LinkedHashMap<>();
+        for (Map.Entry<Long, BigDecimal> entry : forecastMap.entrySet())
+        {
+            forecastByDay.computeIfAbsent(dayKey(entry.getKey()), k -> new LinkedHashMap<>()).put(entry.getKey(), entry.getValue());
+        }
+
+        // 日期取双方合集
+        TreeSet<String> days = new TreeSet<>();
+        days.addAll(actualByDay.keySet());
+        days.addAll(forecastByDay.keySet());
+
+        // 开机容量(MW),全天共用
+        BigDecimal operatingCapacity = null;
+        try
+        {
+            operatingCapacity = computeOperatingCapacity(query.getStationCode(), actualList);
+        }
+        catch (Exception e)
+        {
+            log.warn("查询场站装机容量失败, stationCode={}", query.getStationCode(), e);
+        }
+
+        List<PowerErrorStatAccuracyVO> result = new ArrayList<>();
+        for (String day : days)
+        {
+            List<DataElectricityLocal> dayActual = actualByDay.getOrDefault(day, Collections.emptyList());
+            Map<Long, BigDecimal> dayForecast = forecastByDay.getOrDefault(day, Collections.emptyMap());
+
+            PowerErrorStatAccuracyVO vo = new PowerErrorStatAccuracyVO();
+            vo.setTime(day);
+
+            BigDecimal rmse = operatingCapacity == null ? ZERO
+                    : computeRmse(operatingCapacity, dayActual, dayForecast);
+            BigDecimal mae = operatingCapacity == null ? ZERO
+                    : computeMae(operatingCapacity, dayActual, dayForecast);
+            vo.setRmse(rmse);
+            vo.setMae(mae);
+            vo.setCorrelation(computeCorrelation(dayActual, dayForecast));
+
+            BigDecimal passRate = HUNDRED.subtract(rmse.multiply(HUNDRED)).setScale(SCALE, RoundingMode.HALF_UP);
+            if (passRate.compareTo(HUNDRED) > 0)
+            {
+                passRate = HUNDRED;
+            }
+            if (passRate.compareTo(ZERO) < 0)
+            {
+                passRate = ZERO;
+            }
+            vo.setPassRate(passRate);
+
+            vo.setAcc(computeAccuracy(dayActual, dayForecast, operatingCapacity, null, (rawList, ts) -> null));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 查询实测功率列表,并按 [startSec, endSec) 过滤(截止时间不含)
+     */
+    private List<DataElectricityLocal> queryActualList(Long stationId, PowerErrorStatQuery query, long startSec, long endSec)
+    {
+        List<DataElectricityLocal> list = electricityLocalService.selectActualPowerList(
+                stationId, query.getStartTime() + " 00:00:00", query.getEndTime() + " 00:00:00");
+        if (list == null || list.isEmpty())
+        {
+            return Collections.emptyList();
+        }
+        List<DataElectricityLocal> result = new ArrayList<>();
+        for (DataElectricityLocal r : list)
+        {
+            if (r.getTm() == null)
+            {
+                continue;
+            }
+            long ts = r.getTm().getTime() / 1000;
+            if (ts >= startSec && ts < endSec)
+            {
+                result.add(r);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 按预测类型查询下发预测功率(同一 forecast_time 取 start_time 最新的一条),
+     * 构建 时间戳(秒) -> 功率(kW) 映射
+     */
+    private Map<Long, BigDecimal> queryForecastMap(String stationCode, String forecastType, long startSec, long endSec)
+    {
+        String type = forecastType == null ? "" : forecastType;
+        switch (type)
+        {
+            case "short":
+                return toForecastMap(shortTermIssuedService.selectLatestByTimeRange(stationCode, startSec, endSec),
+                        DataShortTermPowerForecastIssued::getForecastTime, DataShortTermPowerForecastIssued::getPower);
+            case "medium":
+                return toForecastMap(mediumTermIssuedService.selectLatestByTimeRange(stationCode, startSec, endSec),
+                        DataMediumTermPowerForecastIssued::getForecastTime, DataMediumTermPowerForecastIssued::getPower);
+            case "ultra":
+                return toForecastMap(superShortTermIssuedService.selectLatestByTimeRange(stationCode, startSec, endSec),
+                        DataSuperShortTermForecastIssued::getForecastTime, DataSuperShortTermForecastIssued::getPower);
+            default:
+                log.warn("不支持的预测类型: {}", forecastType);
+                return Collections.emptyMap();
+        }
+    }
+
+    /**
+     * 构建预测数据映射:forecastTime(秒) -> power(kW)
+     */
+    private <T> Map<Long, BigDecimal> toForecastMap(List<T> list, Function<T, Long> timeGetter, Function<T, String> powerGetter)
+    {
+        Map<Long, BigDecimal> map = new LinkedHashMap<>();
+        if (list == null)
+        {
+            return map;
+        }
+        for (T r : list)
+        {
+            Long forecastTime = timeGetter.apply(r);
+            if (forecastTime == null)
+            {
+                continue;
+            }
+            BigDecimal power = parsePower(powerGetter.apply(r));
+            if (power == null)
+            {
+                continue;
+            }
+            map.put(forecastTime, power);
+        }
+        return map;
+    }
+
+    /**
+     * 安全解析功率字符串为 BigDecimal
+     */
+    private BigDecimal parsePower(String power)
+    {
+        if (StringUtils.isEmpty(power))
+        {
+            return null;
+        }
+        try
+        {
+            return new BigDecimal(power.trim());
+        }
+        catch (NumberFormatException e)
+        {
+            return null;
+        }
+    }
+
+    /**
+     * 安全解析场站编码为 Long
+     */
+    private Long parseStationId(String stationCode)
+    {
+        if (StringUtils.isEmpty(stationCode))
+        {
+            return null;
+        }
+        try
+        {
+            return Long.parseLong(stationCode.trim());
+        }
+        catch (NumberFormatException e)
+        {
+            return null;
+        }
+    }
+
+    /**
+     * 解析查询时间范围(GMT+8):开始日 00:00:00(包含)至截止日 00:00:00(不包含)
+     *
+     * @return [startSec, endSec],日期非法时返回 null
+     */
+    private long[] parseRange(PowerErrorStatQuery query)
+    {
+        try
+        {
+            long startSec = LocalDate.parse(query.getStartTime(), DATE_FMT)
+                    .atStartOfDay(ZONE_SHANGHAI).toInstant().getEpochSecond();
+            long endSec = LocalDate.parse(query.getEndTime(), DATE_FMT)
+                    .atStartOfDay(ZONE_SHANGHAI).toInstant().getEpochSecond();
+            return new long[] { startSec, endSec };
+        }
+        catch (DateTimeParseException e)
+        {
+            log.warn("时间参数非法, startTime={}, endTime={}", query.getStartTime(), query.getEndTime(), e);
+            return null;
+        }
+    }
+
+    /**
+     * unix 秒 -> yyyy-MM-dd HH:mm:ss(GMT+8)
+     */
+    private String formatDateTime(long epochSecond)
+    {
+        return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZONE_SHANGHAI).format(DATE_TIME_FMT);
+    }
+
+    /**
+     * unix 秒 -> 日期(yyyy-MM-dd,GMT+8)
+     */
+    private String dayKey(long epochSecond)
+    {
+        return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZONE_SHANGHAI).toLocalDate().format(DATE_FMT);
+    }
+}

+ 11 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/mapper/DataMediumTermPowerForecastIssuedMapper.java

@@ -70,4 +70,15 @@ public interface DataMediumTermPowerForecastIssuedMapper
      */
     public List<DataMediumTermPowerForecastIssued> selectByTimeRange(@Param("entityId") String entityId,
             @Param("startTime") Long startTime, @Param("endTime") Long endTime);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataMediumTermPowerForecastIssued> selectLatestByTimeRange(@Param("entityId") String entityId,
+            @Param("startTime") Long startTime, @Param("endTime") Long endTime);
 }

+ 10 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/service/IDataMediumTermPowerForecastIssuedService.java

@@ -67,4 +67,14 @@ public interface IDataMediumTermPowerForecastIssuedService
      * @return 预测功率下发数据集合(按 forecast_time 升序)
      */
     public List<DataMediumTermPowerForecastIssued> selectByTimeRange(String entityId, Long startTime, Long endTime);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataMediumTermPowerForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime);
 }

+ 15 - 1
forecast-backend-server/src/main/java/com/ufo/project/forecast/mediumterm/power/service/impl/DataMediumTermPowerForecastIssuedServiceImpl.java

@@ -106,5 +106,19 @@ public class DataMediumTermPowerForecastIssuedServiceImpl implements IDataMedium
     {
         return dataMediumTermPowerForecastIssuedMapper.selectByTimeRange(entityId, startTime, endTime);
     }
-    
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合
+     */
+    @Override
+    public List<DataMediumTermPowerForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime)
+    {
+        return dataMediumTermPowerForecastIssuedMapper.selectLatestByTimeRange(entityId, startTime, endTime);
+    }
+
 }

+ 11 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/mapper/DataShortTermPowerForecastIssuedMapper.java

@@ -82,4 +82,15 @@ public interface DataShortTermPowerForecastIssuedMapper
      */
     public List<ForecastPowerAverage> selectAveragePowerByTimeRange(@Param("entityId") String entityId,
             @Param("startTime") Long startTime, @Param("endTime") Long endTime);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataShortTermPowerForecastIssued> selectLatestByTimeRange(@Param("entityId") String entityId,
+            @Param("startTime") Long startTime, @Param("endTime") Long endTime);
 }

+ 10 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/service/IDataShortTermPowerForecastIssuedService.java

@@ -79,4 +79,14 @@ public interface IDataShortTermPowerForecastIssuedService
      * @return 按 forecast_time 分组的平均功率列表(按 forecast_time 升序)
      */
     public List<ForecastPowerAverage> selectAveragePowerByTimeRange(String entityId, Long startTime, Long endTime);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataShortTermPowerForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime);
 }

+ 15 - 1
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/power/service/impl/DataShortTermPowerForecastIssuedServiceImpl.java

@@ -121,5 +121,19 @@ public class DataShortTermPowerForecastIssuedServiceImpl implements IDataShortTe
     {
         return dataShortTermPowerForecastIssuedMapper.selectAveragePowerByTimeRange(entityId, startTime, endTime);
     }
-    
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合
+     */
+    @Override
+    public List<DataShortTermPowerForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime)
+    {
+        return dataShortTermPowerForecastIssuedMapper.selectLatestByTimeRange(entityId, startTime, endTime);
+    }
+
 }

+ 13 - 1
forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/mapper/DataSuperShortTermForecastIssuedMapper.java

@@ -1,6 +1,7 @@
 package com.ufo.project.forecast.supershort.mapper;
 
 import java.util.List;
+import org.apache.ibatis.annotations.Param;
 import com.ufo.project.forecast.supershort.domain.DataSuperShortTermForecastIssued;
 
 /**
@@ -53,9 +54,20 @@ public interface DataSuperShortTermForecastIssuedMapper
 
     /**
      * 批量删除超短期功率下发数据
-     * 
+     *
      * @param Ids 需要删除的数据主键集合
      * @return 结果
      */
     public int deleteDataSuperShortTermForecastIssuedByIds(Long[] Ids);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataSuperShortTermForecastIssued> selectLatestByTimeRange(@Param("entityId") String entityId,
+            @Param("startTime") Long startTime, @Param("endTime") Long endTime);
 }

+ 11 - 1
forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/service/IDataSuperShortTermForecastIssuedService.java

@@ -53,9 +53,19 @@ public interface IDataSuperShortTermForecastIssuedService
 
     /**
      * 删除超短期功率下发数据信息
-     * 
+     *
      * @param Id 超短期功率下发数据主键
      * @return 结果
      */
     public int deleteDataSuperShortTermForecastIssuedById(Long Id);
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合(按 forecast_time 升序,同一 forecast_time 取 start_time 最大的一条)
+     */
+    public List<DataSuperShortTermForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime);
 }

+ 14 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/supershort/service/impl/DataSuperShortTermForecastIssuedServiceImpl.java

@@ -92,4 +92,18 @@ public class DataSuperShortTermForecastIssuedServiceImpl implements IDataSuperSh
     {
         return dataSuperShortTermForecastIssuedMapper.deleteDataSuperShortTermForecastIssuedById(Id);
     }
+
+    /**
+     * 查询场站指定时间范围内每个预测时刻的最新一条下发数据
+     *
+     * @param entityId 实体ID
+     * @param startTime 起始时间(unix秒,包含)
+     * @param endTime 结束时间(unix秒,不包含)
+     * @return 预测功率下发数据集合
+     */
+    @Override
+    public List<DataSuperShortTermForecastIssued> selectLatestByTimeRange(String entityId, Long startTime, Long endTime)
+    {
+        return dataSuperShortTermForecastIssuedMapper.selectLatestByTimeRange(entityId, startTime, endTime);
+    }
 }

+ 11 - 0
forecast-backend-server/src/main/resources/mybatis/mediumpower/DataMediumTermPowerForecastIssuedMapper.xml

@@ -111,6 +111,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by forecast_time asc
     </select>
 
+    <!-- 按预测时刻取最新一条下发数据(start_time 最大),结束时间不含 -->
+    <select id="selectLatestByTimeRange" resultMap="DataMediumTermPowerForecastIssuedResult">
+        select distinct on (forecast_time)
+        _id, data_sourcegrab_id, data_sourcegrab_pattern_name, entity_id, entity_type, entity_name, to_timestamp(create_time) AS create_time, start_time, forecast_time, predays, power
+        from data_medium_term_power_forecast_issued
+        where entity_id = #{entityId}
+          and forecast_time &gt;= #{startTime}
+          and forecast_time &lt; #{endTime}
+        order by forecast_time, start_time desc
+    </select>
+
     <delete id="deleteDataMediumTermPowerForecastIssuedByIds" parameterType="String">
         delete from data_medium_term_power_forecast_issued where _id in 
         <foreach item="Id" collection="array" open="(" separator="," close=")">

+ 11 - 0
forecast-backend-server/src/main/resources/mybatis/power/DataShortTermPowerForecastIssuedMapper.xml

@@ -111,6 +111,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by forecast_time asc
     </select>
 
+    <!-- 按预测时刻取最新一条下发数据(start_time 最大),结束时间不含 -->
+    <select id="selectLatestByTimeRange" resultMap="DataShortTermPowerForecastIssuedResult">
+        select distinct on (forecast_time)
+        _id, data_sourcegrab_id, data_sourcegrab_pattern_name, entity_id, entity_type, entity_name, to_timestamp(create_time) AS create_time, start_time, forecast_time, predays, power
+        from data_short_term_power_forecast_issued
+        where entity_id = #{entityId}
+          and forecast_time &gt;= #{startTime}
+          and forecast_time &lt; #{endTime}
+        order by forecast_time, start_time desc
+    </select>
+
     <!-- 按预测时间分组查询平均功率 -->
     <resultMap type="ForecastPowerAverage" id="ForecastPowerAverageResult">
         <result property="forecastTime" column="forecast_time" />

+ 11 - 0
forecast-backend-server/src/main/resources/mybatis/supershort/DataSuperShortTermForecastIssuedMapper.xml

@@ -93,6 +93,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         delete from data_super_short_term_forecast_issued where _id = #{Id}
     </delete>
 
+    <!-- 按预测时刻取最新一条下发数据(start_time 最大),结束时间不含 -->
+    <select id="selectLatestByTimeRange" resultMap="DataSuperShortTermForecastIssuedResult">
+        select distinct on (forecast_time)
+        _id, data_sourcegrab_id, data_sourcegrab_pattern_name, entity_id, entity_type, entity_name, to_timestamp(create_time) AS create_time, start_time, forecast_time, pre_qrts, power
+        from data_super_short_term_forecast_issued
+        where entity_id = #{entityId}
+          and forecast_time &gt;= #{startTime}
+          and forecast_time &lt; #{endTime}
+        order by forecast_time, start_time desc
+    </select>
+
     <delete id="deleteDataSuperShortTermForecastIssuedByIds" parameterType="String">
         delete from data_super_short_term_forecast_issued where _id in 
         <foreach item="Id" collection="array" open="(" separator="," close=")">

+ 25 - 0
release_note/20261015/20261015_power_error_stat_menu.sql

@@ -0,0 +1,25 @@
+-- 功率误差统计菜单补丁(幂等,可重复执行)
+-- 1. 增加 统计分析 一级目录菜单
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+nextval(pg_get_serial_sequence('sys_menu', 'menu_id')),
+'统计分析', 0, 20, 'statanalysis', '', '', '', 1, 0, 'M', '0', '0', '', 'BarChartOutlined', 'admin', now(), '', NULL, '统计分析目录'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu t WHERE t.menu_name = '统计分析' AND t.parent_id = 0);
+
+-- 2. 增加 功率误差统计 二级菜单
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+nextval(pg_get_serial_sequence('sys_menu', 'menu_id')),
+'功率误差统计',
+(select menu_id from sys_menu t where t.menu_name = '统计分析' and t.parent_id = 0),
+10, 'power-error-stat', 'ufo/electricity/datareport/powerErrorStat', '', 'PowerErrorStat', 1, 0, 'C', '0', '0', 'data:powerErrorStat:list', 'CalculatorOutlined', 'admin', now(), '', NULL, '功率误差统计页面'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu t WHERE t.menu_name = '功率误差统计');
+
+-- 3. 授权给管理员角色(role_id = 1)
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, menu_id FROM sys_menu t WHERE t.menu_name = '统计分析' AND t.parent_id = 0
+WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu r WHERE r.role_id = 1 AND r.menu_id = (SELECT menu_id FROM sys_menu t WHERE t.menu_name = '统计分析' AND t.parent_id = 0));
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, menu_id FROM sys_menu t WHERE t.menu_name = '功率误差统计'
+WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu r WHERE r.role_id = 1 AND r.menu_id = (SELECT menu_id FROM sys_menu t WHERE t.menu_name = '功率误差统计'));