package com.usky.vpp.util; import com.usky.vpp.constant.VppTsdbConstants; import com.usky.vpp.service.vo.BaselinePointVO; import com.usky.vpp.service.vo.DeclaredCapacityPointVO; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.function.BiFunction; /** * 基线管理计算辅助(典型日筛选、原始基线、修正系数、曲线构建) */ public final class VppBaselineHelper { public static final int WEEKDAY_REFERENCE_COUNT = 5; public static final int NON_WEEKDAY_REFERENCE_COUNT = 3; public static final int CORRECTION_HOURS = 2; public static final int INTERVAL_MINUTES = 5; public static final BigDecimal K_MIN = new BigDecimal("0.7"); public static final BigDecimal K_MAX = new BigDecimal("1.2"); public static final BigDecimal K_DEFAULT = BigDecimal.ONE; /** 总有功功率指标(文档 p + 存量字段兼容) */ public static final List POWER_METRICS; private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm"); private static final DateTimeFormatter TIME_WITH_SEC_FMT = DateTimeFormatter.ofPattern("HH:mm:ss"); private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); static { List metrics = new ArrayList<>(); metrics.add("p"); metrics.addAll(VppTsdbConstants.ACTIVE_POWER_METRICS); POWER_METRICS = Collections.unmodifiableList(metrics); } private VppBaselineHelper() { } /** * 响应日是否为工作日(Hutool 法定节假日 + 调休规则) */ public static boolean isWorkdayResponse(LocalDate date) { return VppWorkdayHelper.isWorkday(date); } /** * @deprecated 使用 {@link #isWorkdayResponse(LocalDate)} */ @Deprecated public static boolean isWeekday(LocalDate date) { return isWorkdayResponse(date); } /** * 选取典型历史日:工作日取前 5 个工作日,非工作日取前 3 个非工作日; * 仅剔除响应当日与历史响应日(不含节假日前后日期)。 */ public static List selectReferenceDates(LocalDate responseDate, boolean workday, Set excludeDates, int requiredCount) { Set excludes = new HashSet<>(excludeDates); excludes.add(responseDate); List result = new ArrayList<>(); LocalDate cursor = responseDate.minusDays(1); int guard = 0; while (result.size() < requiredCount && guard < 400) { if (excludes.contains(cursor)) { cursor = cursor.minusDays(1); guard++; continue; } if (VppWorkdayHelper.isWorkday(cursor) == workday) { result.add(cursor); } cursor = cursor.minusDays(1); guard++; } return result; } /** * 多历史日同时段各时间点负荷求平均(先按日汇总设备负荷,再跨日取均值)。 *

通过 historyLoader 按日调用 TSDB {@code queryHistoryDeviceData} 获取原始时序后在本模块聚合。

*/ public static Map aggregateBaselineAvgByTimePoint( List referenceDates, LocalTime periodStart, LocalTime periodEnd, int intervalMinutes, BigDecimal correctionFactor, BiFunction>>> historyLoader) { if (referenceDates == null || referenceDates.isEmpty() || historyLoader == null) { return Collections.emptyMap(); } LocalTime dayStart = periodStart != null ? periodStart : LocalTime.MIN; LocalTime dayEnd = periodEnd != null ? periodEnd : LocalTime.of(23, 59, 59); int interval = intervalMinutes > 0 ? intervalMinutes : INTERVAL_MINUTES; Map> samplesByTime = new TreeMap<>(); for (LocalDate date : referenceDates) { LocalDateTime start = LocalDateTime.of(date, dayStart); LocalDateTime end = LocalDateTime.of(date, dayEnd); if (end.isBefore(start)) { continue; } Map>> dayData = historyLoader.apply(start, end); Map dayBuckets = buildActualLoadByTimePoint(dayData, interval); if (dayBuckets.isEmpty()) { continue; } for (Map.Entry entry : dayBuckets.entrySet()) { samplesByTime.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(entry.getValue()); } } Map result = new LinkedHashMap<>(); for (Map.Entry> entry : samplesByTime.entrySet()) { BigDecimal avg = average(entry.getValue()); if (correctionFactor != null) { avg = avg.multiply(correctionFactor); } result.put(entry.getKey(), scale(avg)); } return result; } /** * 将设备历史时序聚合为 HH:mm 时间点总负荷。 */ public static Map buildActualLoadByTimePoint( Map>> deviceData, int intervalMinutes) { if (deviceData == null || deviceData.isEmpty()) { return Collections.emptyMap(); } Map> samples = new TreeMap<>(); for (Map> metricMap : deviceData.values()) { TreeMap series = pickSeries(metricMap); if (series == null || series.isEmpty()) { continue; } Map> deviceBuckets = bucketSeries(series, intervalMinutes); for (Map.Entry> entry : deviceBuckets.entrySet()) { BigDecimal deviceAvg = average(entry.getValue()); samples.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(deviceAvg); } } Map result = new LinkedHashMap<>(); for (Map.Entry> entry : samples.entrySet()) { result.put(entry.getKey(), scale(sum(entry.getValue()))); } return result; } /** * 计算日内修正系数 K = 响应开始前 2 小时今日实测 / 同时段原始基线,并限制在 [0.7, 1.2]。 */ public static BigDecimal calculateCorrectionFactor(LocalDateTime responseStartTime, Map todayActualByTime, Map originalBaselineByTime) { LocalDateTime windowStart = responseStartTime.minusHours(CORRECTION_HOURS); BigDecimal todayTotal = sumValuesInWindow(todayActualByTime, windowStart, responseStartTime); BigDecimal baselineTotal = sumValuesInWindow(originalBaselineByTime, windowStart, responseStartTime); if (baselineTotal == null || baselineTotal.compareTo(BigDecimal.ZERO) <= 0) { return K_DEFAULT; } if (todayTotal == null || todayTotal.compareTo(BigDecimal.ZERO) <= 0) { return K_DEFAULT; } BigDecimal k = todayTotal.divide(baselineTotal, 6, RoundingMode.HALF_UP); if (k.compareTo(K_MIN) < 0) { return K_MIN; } if (k.compareTo(K_MAX) > 0) { return K_MAX; } return k.setScale(4, RoundingMode.HALF_UP); } public static List buildBaselinePoints(LocalDate responseDate, LocalDateTime responseStartTime, Map predictedBaselineByTime, Map todayActualByTime, Map yesterdayActualByTime) { return buildBaselinePoints(responseDate, responseStartTime, responseStartTime, predictedBaselineByTime, todayActualByTime, yesterdayActualByTime); } /** * @param todayActualUntil 当日实测曲线填充截止时间(含),用于响应后窗口展示 */ public static List buildBaselinePoints(LocalDate responseDate, LocalDateTime responseStartTime, LocalDateTime todayActualUntil, Map predictedBaselineByTime, Map todayActualByTime, Map yesterdayActualByTime) { List points = new ArrayList<>(); LocalDateTime actualUntil = todayActualUntil != null ? todayActualUntil : responseStartTime; for (Map.Entry entry : predictedBaselineByTime.entrySet()) { String timeKey = entry.getKey(); LocalTime time = parseTimeKey(timeKey); LocalDateTime pointTime = LocalDateTime.of(responseDate, time); BaselinePointVO point = new BaselinePointVO(); point.setTime(timeKey); point.setPredictedBaselineKw(scale(entry.getValue())); if (!pointTime.isAfter(actualUntil)) { point.setTodayActualKw(scale(todayActualByTime.get(timeKey))); } point.setYesterdayActualKw(scale(yesterdayActualByTime.get(timeKey))); points.add(point); } return points; } public static boolean hasTsdbPowerData(Map>> deviceMetricData) { return VppCapabilityEvalHelper.hasTsdbPowerData(deviceMetricData, POWER_METRICS); } /* public static List buildMockBaselinePoints(Long siteId, LocalDate responseDate, LocalDateTime responseStartTime) { List points = new ArrayList<>(); long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay(); for (int minute = 0; minute < 1440; minute += INTERVAL_MINUTES) { LocalTime time = LocalTime.of(minute / 60, minute % 60); String timeKey = time.format(TIME_FMT); double hourFactor = mockHourFactor(time); double noise = 0.9 + pseudoRandom(seed, minute, 0) * 0.2; BigDecimal yesterday = scale(BigDecimal.valueOf(900 * hourFactor * noise)); BigDecimal original = yesterday.multiply(BigDecimal.valueOf(0.98 + pseudoRandom(seed, minute, 1) * 0.04)) .setScale(2, RoundingMode.HALF_UP); BigDecimal predicted = original.multiply(BigDecimal.valueOf(1.03)).setScale(2, RoundingMode.HALF_UP); BaselinePointVO point = new BaselinePointVO(); point.setTime(timeKey); point.setPredictedBaselineKw(predicted); point.setYesterdayActualKw(yesterday); if (!responseDate.atTime(time).isAfter(responseStartTime)) { point.setTodayActualKw(scale(original.multiply(BigDecimal.valueOf(1.01 + pseudoRandom(seed, minute, 2) * 0.05)))); } points.add(point); } return points; } public static BigDecimal mockCorrectionFactor(Long siteId, LocalDate responseDate) { long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay(); double raw = 0.85 + pseudoRandom(seed, 99, 3) * 0.25; BigDecimal k = BigDecimal.valueOf(raw).setScale(4, RoundingMode.HALF_UP); if (k.compareTo(K_MIN) < 0) { return K_MIN; } if (k.compareTo(K_MAX) > 0) { return K_MAX; } return k; } */ public static String formatDate(LocalDate date) { return date.format(DATE_FMT); } private static final BigDecimal DECLARED_CAPACITY_DIVISOR = new BigDecimal("2"); private static final BigDecimal MINUTES_PER_HOUR = new BigDecimal("60"); /** * 响应执行时段小时数(执行开始至结束)。 */ public static BigDecimal responseWindowHours(LocalDateTime windowStart, LocalDateTime windowEnd) { if (windowStart == null || windowEnd == null || windowEnd.isBefore(windowStart)) { return BigDecimal.ZERO; } long minutes = java.time.Duration.between(windowStart, windowEnd).toMinutes(); return BigDecimal.valueOf(minutes).divide(MINUTES_PER_HOUR, 4, RoundingMode.HALF_UP); } /** * 响应申报容量 = 响应时段内历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。 */ public static BigDecimal calculateDeclaredCapacityKw(Map historicalAvgByTime, LocalDateTime windowStart, LocalDateTime windowEnd, BigDecimal correctionFactorK) { BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd); if (historicalAvg == null || historicalAvg.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } return applyDeclaredCapacityFormula(historicalAvg, correctionFactorK, windowStart, windowEnd); } /** * 预估响应容量 = 响应时段内今日负荷均值。 */ public static BigDecimal calculateEstimatedResponseCapacityKw(Map historicalAvgByTime, LocalDateTime windowStart, LocalDateTime windowEnd) { BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd); if (historicalAvg == null || historicalAvg.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } return historicalAvg; } private static BigDecimal applyDeclaredCapacityFormula(BigDecimal historicalAvg, BigDecimal correctionFactorK, LocalDateTime windowStart, LocalDateTime windowEnd) { BigDecimal k = correctionFactorK != null ? correctionFactorK : K_DEFAULT; BigDecimal windowHours = responseWindowHours(windowStart, windowEnd); return scale(historicalAvg.multiply(k).multiply(windowHours) .divide(DECLARED_CAPACITY_DIVISOR, 6, RoundingMode.HALF_UP)); } public static List buildDeclaredCapacityPoints(Map historicalAvgByTime, LocalDateTime windowStart, LocalDateTime windowEnd, BigDecimal correctionFactorK) { List points = new ArrayList<>(); if (historicalAvgByTime == null || historicalAvgByTime.isEmpty()) { return points; } LocalDate date = windowStart.toLocalDate(); for (Map.Entry entry : historicalAvgByTime.entrySet()) { LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey())); if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) { continue; } BigDecimal historicalAvg = entry.getValue() != null ? entry.getValue() : BigDecimal.ZERO; DeclaredCapacityPointVO point = new DeclaredCapacityPointVO(); point.setTime(entry.getKey()); point.setHistoricalAvgKw(scale(historicalAvg)); point.setDeclaredCapacityKw(applyDeclaredCapacityFormula( historicalAvg, correctionFactorK, windowStart, windowEnd)); points.add(point); } return points; } /* public static BigDecimal mockDeclaredCapacityKw(Long siteId, LocalDate responseDate, LocalDateTime windowStart, LocalDateTime windowEnd, BigDecimal correctionFactorK) { return calculateDeclaredCapacityKw( buildMockHistoricalWindow(siteId, responseDate, windowStart, windowEnd), windowStart, windowEnd, correctionFactorK); } public static List buildMockDeclaredCapacityPoints(Long siteId, LocalDate responseDate, LocalDateTime windowStart, LocalDateTime windowEnd, BigDecimal correctionFactorK) { return buildDeclaredCapacityPoints( buildMockHistoricalWindow(siteId, responseDate, windowStart, windowEnd), windowStart, windowEnd, correctionFactorK); } private static Map buildMockHistoricalWindow(Long siteId, LocalDate responseDate, LocalDateTime windowStart, LocalDateTime windowEnd) { Map mockHistorical = new TreeMap<>(); long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay(); LocalDateTime cursor = windowStart; while (!cursor.isAfter(windowEnd)) { double hourFactor = mockHourFactor(cursor.toLocalTime()); double noise = 0.9 + pseudoRandom(seed, cursor.getHour() * 60 + cursor.getMinute(), 4) * 0.2; mockHistorical.put(cursor.toLocalTime().format(TIME_FMT), BigDecimal.valueOf(900 * hourFactor * noise).setScale(2, RoundingMode.HALF_UP)); cursor = cursor.plusMinutes(INTERVAL_MINUTES); } return mockHistorical; } */ private static BigDecimal averageValuesInWindow(Map valuesByTime, LocalDateTime windowStart, LocalDateTime windowEnd) { if (valuesByTime == null || valuesByTime.isEmpty()) { return null; } List values = new ArrayList<>(); LocalDate date = windowStart.toLocalDate(); for (Map.Entry entry : valuesByTime.entrySet()) { LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey())); if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) { continue; } if (entry.getValue() != null) { values.add(entry.getValue()); } } return values.isEmpty() ? null : average(values); } private static Map> bucketSeries(TreeMap series, int intervalMinutes) { Map> buckets = new TreeMap<>(); for (Map.Entry entry : series.entrySet()) { if (entry.getValue() == null) { continue; } String bucket = toBucketKey(entry.getKey(), intervalMinutes); buckets.computeIfAbsent(bucket, key -> new ArrayList<>()).add(entry.getValue()); } return buckets; } private static String toBucketKey(LocalDateTime timestamp, int intervalMinutes) { int totalMinutes = timestamp.getHour() * 60 + timestamp.getMinute(); int bucketMinutes = (totalMinutes / intervalMinutes) * intervalMinutes; return LocalTime.of(bucketMinutes / 60, bucketMinutes % 60).format(TIME_FMT); } private static LocalTime parseTimeKey(String timeKey) { if (timeKey.length() == 5) { return LocalTime.parse(timeKey + ":00", TIME_WITH_SEC_FMT); } return LocalTime.parse(timeKey, TIME_WITH_SEC_FMT); } private static BigDecimal sumValuesInWindow(Map valuesByTime, LocalDateTime windowStart, LocalDateTime windowEnd) { if (valuesByTime == null || valuesByTime.isEmpty()) { return null; } BigDecimal total = BigDecimal.ZERO; int count = 0; LocalDate date = windowStart.toLocalDate(); for (Map.Entry entry : valuesByTime.entrySet()) { LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey())); if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) { continue; } if (entry.getValue() != null) { total = total.add(entry.getValue()); count++; } } return count == 0 ? null : total; } private static TreeMap pickSeries(Map> metricMap) { if (metricMap == null || metricMap.isEmpty()) { return null; } for (String metric : POWER_METRICS) { TreeMap series = metricMap.get(metric); if (series != null && !series.isEmpty()) { return series; } } for (TreeMap series : metricMap.values()) { if (series != null && !series.isEmpty()) { return series; } } return null; } private static BigDecimal sum(List values) { if (values == null || values.isEmpty()) { return BigDecimal.ZERO; } BigDecimal total = BigDecimal.ZERO; for (BigDecimal value : values) { if (value != null) { total = total.add(value); } } return total; } private static BigDecimal average(List values) { if (values == null || values.isEmpty()) { return BigDecimal.ZERO; } return sum(values).divide(BigDecimal.valueOf(values.size()), 4, RoundingMode.HALF_UP); } private static BigDecimal scale(BigDecimal value) { if (value == null) { return null; } return value.setScale(2, RoundingMode.HALF_UP); } /* private static double mockHourFactor(LocalTime time) { int hour = time.getHour(); int minute = time.getMinute(); double h = hour + minute / 60.0; if (h < 6) { return 0.45 + h / 6 * 0.15; } if (h < 9) { return 0.6 + (h - 6) / 3 * 0.35; } if (h < 12) { return 0.95 + (h - 9) / 3 * 0.05; } if (h < 14) { return 0.85; } if (h < 17) { return 0.9 + (h - 14) / 3 * 0.1; } if (h < 21) { return 1.0 - (h - 17) / 4 * 0.35; } return 0.5 - (h - 21) / 3 * 0.15; } private static double pseudoRandom(long seed, int minuteOfDay, int seriesSalt) { long mixed = seed * 31L + minuteOfDay * 17L + seriesSalt * 13L; return (mixed % 1000) / 1000.0; } */ }