|
|
@@ -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 < 阈值</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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|