Quellcode durchsuchen

开发基线管理 - 负荷曲线接口,开发响应申报容量接口,优化站点管理 - 分页列表、站点管理 - 新增、站点管理 - 编辑三个接口,增加所属户号和所属街镇两个字段的逻辑

james vor 5 Tagen
Ursprung
Commit
650bd2be68

+ 8 - 2
service-vpp/service-vpp-biz/pom.xml

@@ -75,14 +75,20 @@
         </dependency>
         <dependency>
             <groupId>cn.hutool</groupId>
-            <artifactId>hutool-crypto</artifactId>
-            <version>5.8.41</version>
+            <artifactId>hutool-all</artifactId>
+            <version>5.8.22</version>
         </dependency>
         <dependency>
             <groupId>cn.hutool</groupId>
             <artifactId>hutool-core</artifactId>
             <version>5.8.41</version>
         </dependency>
+        <!-- 中国法定节假日 + 调休(Hutool core 无 ChineseHoliday API) -->
+        <dependency>
+            <groupId>io.github.shawyeok</groupId>
+            <artifactId>chinese-workday-calendar</artifactId>
+            <version>2026.0.0</version>
+        </dependency>
 
         <!-- TDengine JDBC(设备运行曲线) -->
         <dependency>

+ 34 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java

@@ -12,6 +12,7 @@ import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.DeviceRequest;
 import com.usky.vpp.service.vo.ResourcePointRequest;
 import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
 import com.usky.vpp.service.vo.SiteConfigDetailVO;
 import com.usky.vpp.service.vo.SiteConfigRequest;
 import com.usky.vpp.service.vo.SiteListVO;
@@ -171,6 +172,39 @@ public class SiteController {
         return ApiResult.success(vppBaselineService.getSiteBaseline(siteId, responseStartTime));
     }
 
+    /**
+     * 响应申报容量
+     * <p><b>GET</b> /site/{siteId}/declared-capacity</p>
+     * <p>完整路径:GET /prod-api/service-vpp/site/{siteId}/declared-capacity</p>
+     *
+     * <p>根据站点 ID、响应开始/结束时间,计算需求响应申报容量:</p>
+     * <p><b>申报容量 = 响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2</b></p>
+     *
+     * <p><b>Query 参数</b></p>
+     * <ul>
+     *   <li>responseStartTime — 响应开始时间,必填,格式 yyyy-MM-dd HH:mm:ss</li>
+     *   <li>responseEndTime — 响应结束时间,必填,格式 yyyy-MM-dd HH:mm:ss(需与开始时间同一天)</li>
+     * </ul>
+     *
+     * <p>典型历史日、修正系数 K 规则与基线管理接口一致;历史均值仅统计响应开始至结束时段。</p>
+     *
+     * <p><b>响应 data:SiteDeclaredCapacityVO</b></p>
+     * <ul>
+     *   <li>declaredCapacityKw — 申报容量 kW</li>
+     *   <li>correctionFactorK — 修正系数 K</li>
+     *   <li>referenceDates — 典型历史日</li>
+     *   <li>points — 响应时段各时间点申报容量(DeclaredCapacityPointVO)</li>
+     * </ul>
+     *
+     * <p><b>示例</b>:GET /site/1/declared-capacity?responseStartTime=2026-07-08 19:00:00&responseEndTime=2026-07-08 21:00:00</p>
+     */
+    @GetMapping("/{siteId}/declared-capacity")
+    public ApiResult<SiteDeclaredCapacityVO> getSiteDeclaredCapacity(@PathVariable("siteId") Long siteId,
+                                                                   @RequestParam("responseStartTime") String responseStartTime,
+                                                                   @RequestParam("responseEndTime") String responseEndTime) {
+        return ApiResult.success(vppBaselineService.getSiteDeclaredCapacity(siteId, responseStartTime, responseEndTime));
+    }
+
     /**
      * 站点下资源点列表
      */

+ 15 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBaselineService.java

@@ -1,6 +1,7 @@
 package com.usky.vpp.service;
 
 import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
 
 /**
  * 站点基线管理
@@ -17,4 +18,18 @@ public interface VppBaselineService {
      * @return 基线曲线及修正系数等信息
      */
     SiteBaselineVO getSiteBaseline(Long siteId, String responseStartTime);
+
+    /**
+     * 查询站点响应申报容量。
+     *
+     * <p>响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。</p>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间,格式 yyyy-MM-dd HH:mm:ss
+     * @param responseEndTime   响应结束时间,格式 yyyy-MM-dd HH:mm:ss
+     * @return 申报容量及响应时段明细
+     */
+    SiteDeclaredCapacityVO getSiteDeclaredCapacity(Long siteId,
+                                                   String responseStartTime,
+                                                   String responseEndTime);
 }

+ 114 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java

@@ -14,6 +14,7 @@ import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppTsdbQueryService;
 import com.usky.vpp.service.vo.BaselinePointVO;
 import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.service.vo.SiteDeclaredCapacityVO;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppBaselineHelper;
 import com.usky.vpp.util.VppWorkdayHelper;
@@ -174,6 +175,100 @@ public class VppBaselineServiceImpl implements VppBaselineService {
         return result;
     }
 
+    /**
+     * 查询站点响应申报容量。
+     *
+     * <p>公式:响应时段内同时间段历史负荷均值 × 修正系数 K × 执行时段小时数 / 2。</p>
+     * <p>典型历史日、修正系数 K 计算规则与基线管理一致;历史均值仅取响应开始至结束时段。</p>
+     */
+    @Override
+    public SiteDeclaredCapacityVO getSiteDeclaredCapacity(Long siteId,
+                                                          String responseStartTime,
+                                                          String responseEndTime) {
+        VppSite site = resolveSite(siteId);
+        LocalDateTime startTime = parseResponseStartTime(responseStartTime);
+        LocalDateTime endTime = parseResponseEndTime(responseEndTime, startTime);
+        LocalDate responseDate = startTime.toLocalDate();
+
+        SiteDeclaredCapacityVO result = new SiteDeclaredCapacityVO();
+        result.setSiteId(siteId);
+        result.setSiteName(site.getSiteName());
+        result.setResponseStartTime(startTime);
+        result.setResponseEndTime(endTime);
+
+        List<String> deviceUuids = listControllableDeviceUuids(siteId);
+        if (deviceUuids.isEmpty()) {
+            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+        }
+
+        Set<LocalDate> excludeDates = loadResponseHistoryDates();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(responseDate);
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                responseDate, workday, excludeDates, requiredCount);
+        result.setReferenceDates(referenceDates.stream()
+                .map(VppBaselineHelper::formatDate)
+                .collect(Collectors.toList()));
+
+        LocalDateTime correctionWindowStart = startTime.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                correctionWindowStart.toLocalTime(),
+                startTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        Map<String, BigDecimal> todayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, responseDate.atStartOfDay(), startTime, VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                startTime, todayActualByTime, correctionBaselineByTime);
+
+        Map<String, BigDecimal> historicalAvgByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                startTime.toLocalTime(),
+                endTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        if (historicalAvgByTime.isEmpty()) {
+            return buildMockDeclaredCapacityResult(result, siteId, responseDate, startTime, endTime);
+        }
+
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setDeclaredCapacityKw(VppBaselineHelper.calculateDeclaredCapacityKw(
+                historicalAvgByTime, startTime, endTime, correctionFactorK));
+        result.setPoints(VppBaselineHelper.buildDeclaredCapacityPoints(
+                historicalAvgByTime, startTime, endTime, correctionFactorK));
+        result.setDataSource("tsdb");
+        return result;
+    }
+
+    private SiteDeclaredCapacityVO buildMockDeclaredCapacityResult(SiteDeclaredCapacityVO result,
+                                                                 Long siteId,
+                                                                 LocalDate responseDate,
+                                                                 LocalDateTime startTime,
+                                                                 LocalDateTime endTime) {
+        BigDecimal correctionFactorK = VppBaselineHelper.mockCorrectionFactor(siteId, responseDate);
+        result.setReferenceDates(new ArrayList<>());
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setDeclaredCapacityKw(VppBaselineHelper.mockDeclaredCapacityKw(
+                siteId, responseDate, startTime, endTime, correctionFactorK));
+        result.setPoints(VppBaselineHelper.buildMockDeclaredCapacityPoints(
+                siteId, responseDate, startTime, endTime, correctionFactorK));
+        result.setDataSource("mock");
+        return result;
+    }
+
     private Map<String, BigDecimal> applyCorrection(Map<String, BigDecimal> originalBaselineByTime,
                                                     BigDecimal correctionFactorK) {
         Map<String, BigDecimal> corrected = new TreeMap<>();
@@ -263,4 +358,23 @@ public class VppBaselineServiceImpl implements VppBaselineService {
             throw new BusinessException("响应开始时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
         }
     }
+
+    private LocalDateTime parseResponseEndTime(String responseEndTime, LocalDateTime responseStartTime) {
+        if (!StringUtils.hasText(responseEndTime)) {
+            throw new BusinessException("响应结束时间不能为空");
+        }
+        LocalDateTime endTime;
+        try {
+            endTime = LocalDateTime.parse(responseEndTime.trim(), DATE_TIME_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("响应结束时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
+        }
+        if (endTime.isBefore(responseStartTime)) {
+            throw new BusinessException("响应结束时间不能早于响应开始时间");
+        }
+        if (!endTime.toLocalDate().equals(responseStartTime.toLocalDate())) {
+            throw new BusinessException("响应结束时间需与响应开始时间在同一天");
+        }
+        return endTime;
+    }
 }

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeclaredCapacityPointVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 响应申报容量 - 单时间点
+ */
+@Data
+public class DeclaredCapacityPointVO {
+
+    /** 时刻 HH:mm */
+    private String time;
+    /** 同时间段历史负荷均值 kW */
+    private BigDecimal historicalAvgKw;
+    /** 申报容量 kW = 历史均值 × K × 执行时段小时数 / 2 */
+    private BigDecimal declaredCapacityKw;
+}

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 站点响应申报容量查询结果
+ */
+@Data
+public class SiteDeclaredCapacityVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 响应开始时间 */
+    private LocalDateTime responseStartTime;
+    /** 响应结束时间 */
+    private LocalDateTime responseEndTime;
+    /** 日内修正系数 K(0.7 ~ 1.2) */
+    private BigDecimal correctionFactorK;
+    /** 响应申报容量 kW(历史均值 × K × 执行时段小时数 / 2) */
+    private BigDecimal declaredCapacityKw;
+    /** 典型历史日(yyyy-MM-dd) */
+    private List<String> referenceDates;
+    /** 响应日类型:WORKDAY / NON_WORKDAY */
+    private String responseDayType;
+    /** 响应日类型中文 */
+    private String responseDayTypeLabel;
+    /** 数据来源:tsdb / mock */
+    private String dataSource;
+    /** 响应时段内各时间点申报容量 */
+    private List<DeclaredCapacityPointVO> points;
+}

+ 125 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java

@@ -2,6 +2,7 @@ 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;
@@ -268,6 +269,130 @@ public final class VppBaselineHelper {
         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<String, BigDecimal> 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);
+    }
+
+    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<DeclaredCapacityPointVO> buildDeclaredCapacityPoints(Map<String, BigDecimal> historicalAvgByTime,
+                                                                          LocalDateTime windowStart,
+                                                                          LocalDateTime windowEnd,
+                                                                          BigDecimal correctionFactorK) {
+        List<DeclaredCapacityPointVO> points = new ArrayList<>();
+        if (historicalAvgByTime == null || historicalAvgByTime.isEmpty()) {
+            return points;
+        }
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> 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<DeclaredCapacityPointVO> buildMockDeclaredCapacityPoints(Long siteId,
+                                                                                LocalDate responseDate,
+                                                                                LocalDateTime windowStart,
+                                                                                LocalDateTime windowEnd,
+                                                                                BigDecimal correctionFactorK) {
+        return buildDeclaredCapacityPoints(
+                buildMockHistoricalWindow(siteId, responseDate, windowStart, windowEnd),
+                windowStart,
+                windowEnd,
+                correctionFactorK);
+    }
+
+    private static Map<String, BigDecimal> buildMockHistoricalWindow(Long siteId,
+                                                                     LocalDate responseDate,
+                                                                     LocalDateTime windowStart,
+                                                                     LocalDateTime windowEnd) {
+        Map<String, BigDecimal> 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<String, BigDecimal> valuesByTime,
+                                                    LocalDateTime windowStart,
+                                                    LocalDateTime windowEnd) {
+        if (valuesByTime == null || valuesByTime.isEmpty()) {
+            return null;
+        }
+        List<BigDecimal> values = new ArrayList<>();
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> 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<String, List<BigDecimal>> bucketSeries(TreeMap<LocalDateTime, BigDecimal> series,
                                                               int intervalMinutes) {
         Map<String, List<BigDecimal>> buckets = new TreeMap<>();

+ 18 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppWorkdayHelper.java

@@ -1,18 +1,23 @@
 package com.usky.vpp.util;
 
 import cn.hutool.core.date.DateUtil;
+import io.github.shawyeok.chinese.workday.WorkdayCalendar;
 
 import java.time.LocalDate;
 import java.util.Date;
 
 /**
- * 工作日 / 非工作日判定(Hutool 法定节假日 + 调休)
+ * 工作日 / 非工作日判定(法定节假日 + 调休)
+ * <p>Hutool {@link DateUtil} 负责日期转换;节假日数据由 chinese-workday-calendar 提供
+ * (Hutool core 未内置 {@code ChineseHoliday})。</p>
  */
 public final class VppWorkdayHelper {
 
     public static final String DAY_TYPE_WORKDAY = "WORKDAY";
     public static final String DAY_TYPE_NON_WORKDAY = "NON_WORKDAY";
 
+    private static final WorkdayCalendar WORKDAY_CALENDAR = WorkdayCalendar.builder().build();
+
     private VppWorkdayHelper() {
     }
 
@@ -23,7 +28,10 @@ public final class VppWorkdayHelper {
         if (date == null) {
             return false;
         }
-        return DateUtil.isWorkDay(toDate(date));
+        Date targetDate = DateUtil.date(date);
+        boolean isHoliday = isRestDay(targetDate, date);
+        boolean isAdjustWork = isMakeupWorkday(targetDate, date);
+        return !isHoliday || isAdjustWork;
     }
 
     /**
@@ -41,7 +49,13 @@ public final class VppWorkdayHelper {
         return isWorkday(date) ? "工作日" : "非工作日";
     }
 
-    private static Date toDate(LocalDate date) {
-        return DateUtil.date(date);
+    /** 是否为休息日(法定节假日或正常周末,不含调休上班) */
+    private static boolean isRestDay(Date targetDate, LocalDate localDate) {
+        return !WORKDAY_CALENDAR.isWorkday(localDate);
+    }
+
+    /** 是否为调休上班日(周末补班) */
+    private static boolean isMakeupWorkday(Date targetDate, LocalDate localDate) {
+        return DateUtil.isWeekend(targetDate) && WORKDAY_CALENDAR.isWorkday(localDate);
     }
 }