|
@@ -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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|