Przeglądaj źródła

feat(wm-bi+frontend): #39 需水量预测+调度决策推荐完整实现

- 后端(wm-bi):
  - Entity: WaterDemandForecast, SchedulingRecommendation, HistoricalWaterDemand
  - Mapper: 3个MyBatis-Plus Mapper接口
  - Service: WaterDemandForecastService(移动平均/指数平滑/季节性分解三种预测模型+模型评估)
  - Service: SchedulingRecommendService(常规/错峰/节能/紧急/低成本5种调度方案+贪心算法+评分排序)
  - Controller: /api/bi/forecast/* + /api/bi/scheduling/*
  - DDL: bi_historical_water_demand, bi_water_demand_forecast, bi_scheduling_recommendation
- 前端(Vue3 + Element Plus + ECharts):
  - ForecastView.vue: 历史趋势+预测曲线(含置信区间)+模型对比(MAE/RMSE/MAPE)
  - SchedulingView.vue: 推荐方案卡片+方案对比表格+采纳/拒绝
  - forecastApi.ts: TypeScript API封装
  - 路由注册: /bi/forecast, /bi/scheduling
- 测试: WaterDemandForecastServiceTest + SchedulingRecommendServiceTest
bot_dev2 5 dni temu
rodzic
commit
90a1e8e2cb

+ 74
- 0
db/bi_forecast_ddl.sql Wyświetl plik

@@ -0,0 +1,74 @@
1
+-- ============================================================
2
+-- 需水量预测 + 调度推荐 DDL
3
+-- ============================================================
4
+
5
+-- 1. 历史需水量数据表
6
+CREATE TABLE IF NOT EXISTS bi_historical_water_demand (
7
+    id              BIGSERIAL PRIMARY KEY,
8
+    area_code       VARCHAR(32) NOT NULL,          -- 区域编码
9
+    area_name       VARCHAR(64),                   -- 区域名称
10
+    record_date     DATE NOT NULL,                 -- 日期
11
+    volume          DECIMAL(12,2),                 -- 需水量(m³)
12
+    max_temp        DECIMAL(5,1),                  -- 最高温度(℃)
13
+    min_temp        DECIMAL(5,1),                  -- 最低温度(℃)
14
+    weather         VARCHAR(16),                   -- 天气: sunny/cloudy/rainy/snowy
15
+    rainfall        DECIMAL(8,1),                  -- 降雨量(mm)
16
+    is_holiday      BOOLEAN DEFAULT FALSE,         -- 是否节假日
17
+    day_of_week     INTEGER,                       -- 星期几(1=周一,7=周日)
18
+    data_source     VARCHAR(16) DEFAULT 'METER',   -- 数据来源: METER/ESTIMATED/SIMULATED
19
+    deleted         INTEGER DEFAULT 0,
20
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
21
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
22
+);
23
+COMMENT ON TABLE bi_historical_water_demand IS '历史需水量数据';
24
+CREATE INDEX IF NOT EXISTS idx_hwd_area_date ON bi_historical_water_demand(area_code, record_date);
25
+
26
+-- 2. 需水量预测记录表
27
+CREATE TABLE IF NOT EXISTS bi_water_demand_forecast (
28
+    id              BIGSERIAL PRIMARY KEY,
29
+    area_code       VARCHAR(32) NOT NULL,          -- 区域编码
30
+    area_name       VARCHAR(64),                   -- 区域名称
31
+    forecast_date   DATE NOT NULL,                 -- 预测日期
32
+    forecast_volume DECIMAL(12,2),                 -- 预测水量(m³)
33
+    actual_volume   DECIMAL(12,2),                 -- 实际水量(用于评估)
34
+    model_type      VARCHAR(32) NOT NULL,          -- 模型类型: MOVING_AVERAGE/EXPONENTIAL_SMOOTHING/SEASONAL_DECOMPOSITION
35
+    model_params    TEXT,                          -- 模型参数JSON
36
+    mae             DECIMAL(10,2),                 -- 平均绝对误差
37
+    rmse            DECIMAL(10,2),                 -- 均方根误差
38
+    mape            DECIMAL(8,2),                  -- 平均绝对百分比误差(%)
39
+    confidence      DECIMAL(5,4),                  -- 置信度(0-1)
40
+    lower_bound     DECIMAL(12,2),                 -- 置信区间下限
41
+    upper_bound     DECIMAL(12,2),                 -- 置信区间上限
42
+    generate_time   TIMESTAMP,                     -- 预测生成时间
43
+    remark          VARCHAR(255),
44
+    deleted         INTEGER DEFAULT 0,
45
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
47
+);
48
+COMMENT ON TABLE bi_water_demand_forecast IS '需水量预测记录';
49
+CREATE INDEX IF NOT EXISTS idx_forecast_area_date ON bi_water_demand_forecast(area_code, forecast_date);
50
+CREATE INDEX IF NOT EXISTS idx_forecast_model ON bi_water_demand_forecast(model_type);
51
+
52
+-- 3. 调度推荐方案表
53
+CREATE TABLE IF NOT EXISTS bi_scheduling_recommendation (
54
+    id                  BIGSERIAL PRIMARY KEY,
55
+    forecast_id         BIGINT,                    -- 关联预测ID
56
+    scheme_name         VARCHAR(128) NOT NULL,      -- 方案名称
57
+    scheme_type         VARCHAR(16) NOT NULL,       -- 方案类型: NORMAL/PEAK_SHIFT/EMERGENCY
58
+    description         TEXT,                       -- 方案描述
59
+    pump_combination    TEXT,                       -- 泵站组合JSON
60
+    estimated_saving    DECIMAL(12,2),              -- 预计节水量(m³)
61
+    estimated_energy_saving DECIMAL(12,2),          -- 预计节能(kWh)
62
+    score               DECIMAL(6,1),               -- 方案评分(0-100)
63
+    execute_start       TIMESTAMP,                  -- 执行时间窗口-开始
64
+    execute_end         TIMESTAMP,                  -- 执行时间窗口-结束
65
+    risk_level          VARCHAR(8),                 -- 风险等级: LOW/MEDIUM/HIGH
66
+    status              VARCHAR(16) DEFAULT 'PENDING', -- 状态: PENDING/ACCEPTED/EXECUTING/COMPLETED/REJECTED
67
+    remark              VARCHAR(255),
68
+    deleted             INTEGER DEFAULT 0,
69
+    created_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
70
+    updated_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP
71
+);
72
+COMMENT ON TABLE bi_scheduling_recommendation IS '调度推荐方案';
73
+CREATE INDEX IF NOT EXISTS idx_scheduling_forecast ON bi_scheduling_recommendation(forecast_id);
74
+CREATE INDEX IF NOT EXISTS idx_scheduling_status ON bi_scheduling_recommendation(status);

+ 78
- 0
frontend/src/api/forecastApi.ts Wyświetl plik

@@ -0,0 +1,78 @@
1
+import request from './request'
2
+
3
+/** 需水量预测 API */
4
+export const forecastApi = {
5
+  /** 执行预测 */
6
+  execute(areaCode: string, modelType: string = 'SEASONAL_DECOMPOSITION', forecastDays: number = 7) {
7
+    return request.post('/bi/forecast/execute', null, {
8
+      params: { areaCode, modelType, forecastDays }
9
+    })
10
+  },
11
+
12
+  /** 多模型对比 */
13
+  multiModel(areaCode: string, forecastDays: number = 7) {
14
+    return request.post('/bi/forecast/multi-model', null, {
15
+      params: { areaCode, forecastDays }
16
+    })
17
+  },
18
+
19
+  /** 预测历史 */
20
+  history(areaCode?: string, page: number = 1, size: number = 20) {
21
+    return request.get('/bi/forecast/history', { params: { areaCode, page, size } })
22
+  },
23
+
24
+  /** 历史趋势 */
25
+  trend(areaCode: string, startDate?: string, endDate?: string) {
26
+    return request.get('/bi/forecast/trend', { params: { areaCode, startDate, endDate } })
27
+  },
28
+
29
+  /** 生成演示数据 */
30
+  generateDemo(areaCode: string, days: number = 365) {
31
+    return request.post('/bi/forecast/demo-data', null, { params: { areaCode, days } })
32
+  }
33
+}
34
+
35
+/** 调度推荐 API */
36
+export const schedulingApi = {
37
+  /** 生成推荐方案 */
38
+  generate(forecastId: number) {
39
+    return request.post('/bi/scheduling/generate', null, { params: { forecastId } })
40
+  },
41
+
42
+  /** 推荐列表 */
43
+  list(forecastId?: number, page: number = 1, size: number = 20) {
44
+    return request.get('/bi/scheduling/list', { params: { forecastId, page, size } })
45
+  },
46
+
47
+  /** 方案详情 */
48
+  detail(id: number) {
49
+    return request.get(`/bi/scheduling/${id}`)
50
+  },
51
+
52
+  /** 采纳方案 */
53
+  accept(id: number) {
54
+    return request.post(`/bi/scheduling/${id}/accept`)
55
+  },
56
+
57
+  /** 拒绝方案 */
58
+  reject(id: number) {
59
+    return request.post(`/bi/scheduling/${id}/reject`)
60
+  }
61
+}
62
+
63
+/** 区域列表 */
64
+export const AREA_OPTIONS = [
65
+  { value: 'JH_001', label: '精河县城北区' },
66
+  { value: 'JH_002', label: '精河县城南区' },
67
+  { value: 'JH_003', label: '精河县工业园' },
68
+  { value: 'JH_004', label: '精河县新城区' },
69
+  { value: 'JH_005', label: '托托镇供水区' },
70
+  { value: 'JH_006', label: '大河沿子镇' }
71
+]
72
+
73
+/** 模型类型 */
74
+export const MODEL_OPTIONS = [
75
+  { value: 'MOVING_AVERAGE', label: '移动平均法' },
76
+  { value: 'EXPONENTIAL_SMOOTHING', label: '指数平滑法' },
77
+  { value: 'SEASONAL_DECOMPOSITION', label: '季节性分解法' }
78
+]

+ 2
- 0
frontend/src/router/index.ts Wyświetl plik

@@ -16,6 +16,8 @@ const routes = [
16 16
       { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17 17
       { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18 18
       { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'bi/forecast', name: 'biForecast', component: () => import('@/views/bi/ForecastView.vue') },
20
+      { path: 'bi/scheduling', name: 'biScheduling', component: () => import('@/views/bi/SchedulingView.vue') },
19 21
     ]
20 22
   },
21 23
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 296
- 0
frontend/src/views/bi/ForecastView.vue Wyświetl plik

@@ -0,0 +1,296 @@
1
+<template>
2
+  <div class="forecast-view">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>📊 需水量预测仪表盘</span>
7
+          <el-tag type="info" size="small">BI 智能分析</el-tag>
8
+        </div>
9
+      </template>
10
+
11
+      <!-- 筛选条件 -->
12
+      <el-form :inline="true" class="filter-form">
13
+        <el-form-item label="区域">
14
+          <el-select v-model="filters.areaCode" placeholder="选择区域" style="width: 200px">
15
+            <el-option v-for="a in AREA_OPTIONS" :key="a.value" :label="a.label" :value="a.value" />
16
+          </el-select>
17
+        </el-form-item>
18
+        <el-form-item label="预测模型">
19
+          <el-select v-model="filters.modelType" style="width: 180px">
20
+            <el-option v-for="m in MODEL_OPTIONS" :key="m.value" :label="m.label" :value="m.value" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item label="预测天数">
24
+          <el-input-number v-model="filters.forecastDays" :min="1" :max="30" />
25
+        </el-form-item>
26
+        <el-form-item>
27
+          <el-button type="primary" :loading="loading" @click="runForecast">🔮 执行预测</el-button>
28
+          <el-button :loading="loadingMulti" @click="runMultiModel">📈 多模型对比</el-button>
29
+          <el-button @click="initDemoData">📦 初始化演示数据</el-button>
30
+        </el-form-item>
31
+      </el-form>
32
+    </el-card>
33
+
34
+    <!-- 历史趋势 + 预测曲线 -->
35
+    <el-row :gutter="16" style="margin-top: 16px">
36
+      <el-col :span="16">
37
+        <el-card shadow="never">
38
+          <template #header>历史趋势 + 预测曲线(含置信区间)</template>
39
+          <div ref="trendChartRef" style="height: 400px"></div>
40
+        </el-card>
41
+      </el-col>
42
+      <el-col :span="8">
43
+        <el-card shadow="never">
44
+          <template #header>模型评估指标</template>
45
+          <div ref="metricsChartRef" style="height: 400px"></div>
46
+        </el-card>
47
+      </el-col>
48
+    </el-row>
49
+
50
+    <!-- 多模型对比结果 -->
51
+    <el-card v-if="multiModelResult" shadow="never" style="margin-top: 16px">
52
+      <template #header>
53
+        <div class="card-header">
54
+          <span>模型对比</span>
55
+          <el-tag type="success">最优模型: {{ multiModelResult.bestModel }}</el-tag>
56
+        </div>
57
+      </template>
58
+      <el-table :data="modelComparisonData" stripe>
59
+        <el-table-column prop="model" label="模型" />
60
+        <el-table-column prop="mae" label="MAE" />
61
+        <el-table-column prop="rmse" label="RMSE" />
62
+        <el-table-column prop="mape" label="MAPE(%)" />
63
+        <el-table-column label="最优">
64
+          <template #default="{ row }">
65
+            <el-icon v-if="row.model === multiModelResult.bestModel" color="#67c23a"><Check /></el-icon>
66
+          </template>
67
+        </el-table-column>
68
+      </el-table>
69
+    </el-card>
70
+
71
+    <!-- 预测结果表格 -->
72
+    <el-card v-if="forecastResults.length > 0" shadow="never" style="margin-top: 16px">
73
+      <template #header>预测结果</template>
74
+      <el-table :data="forecastResults" stripe>
75
+        <el-table-column prop="forecastDate" label="预测日期" width="120" />
76
+        <el-table-column prop="forecastVolume" label="预测水量(m³)" width="140">
77
+          <template #default="{ row }">{{ Number(row.forecastVolume).toFixed(0) }}</template>
78
+        </el-table-column>
79
+        <el-table-column label="置信区间" width="180">
80
+          <template #default="{ row }">
81
+            [{{ Number(row.lowerBound).toFixed(0) }}, {{ Number(row.upperBound).toFixed(0) }}]
82
+          </template>
83
+        </el-table-column>
84
+        <el-table-column prop="modelType" label="模型" width="180">
85
+          <template #default="{ row }">{{ modelLabel(row.modelType) }}</template>
86
+        </el-table-column>
87
+        <el-table-column prop="mae" label="MAE" width="100">
88
+          <template #default="{ row }">{{ row.mae ? Number(row.mae).toFixed(2) : '-' }}</template>
89
+        </el-table-column>
90
+        <el-table-column prop="rmse" label="RMSE" width="100">
91
+          <template #default="{ row }">{{ row.rmse ? Number(row.rmse).toFixed(2) : '-' }}</template>
92
+        </el-table-column>
93
+        <el-table-column prop="mape" label="MAPE(%)" width="100">
94
+          <template #default="{ row }">{{ row.mape ? Number(row.mape).toFixed(2) : '-' }}</template>
95
+        </el-table-column>
96
+        <el-table-column prop="confidence" label="置信度" width="100">
97
+          <template #default="{ row }">
98
+            <el-progress :percentage="Number(row.confidence) * 100" :format="p => p.toFixed(0) + '%'" :stroke-width="12" />
99
+          </template>
100
+        </el-table-column>
101
+      </el-table>
102
+    </el-card>
103
+  </div>
104
+</template>
105
+
106
+<script setup lang="ts">
107
+import { ref, reactive, computed, onMounted, nextTick } from 'vue'
108
+import { ElMessage } from 'element-plus'
109
+import { Check } from '@element-plus/icons-vue'
110
+import * as echarts from 'echarts'
111
+import { forecastApi, AREA_OPTIONS, MODEL_OPTIONS } from '@/api/forecastApi'
112
+
113
+const filters = reactive({
114
+  areaCode: 'JH_001',
115
+  modelType: 'SEASONAL_DECOMPOSITION',
116
+  forecastDays: 7
117
+})
118
+
119
+const loading = ref(false)
120
+const loadingMulti = ref(false)
121
+const forecastResults = ref<any[]>([])
122
+const multiModelResult = ref<any>(null)
123
+const trendChartRef = ref<HTMLElement>()
124
+const metricsChartRef = ref<HTMLElement>()
125
+let trendChart: echarts.ECharts | null = null
126
+let metricsChart: echarts.ECharts | null = null
127
+
128
+const modelComparisonData = computed(() => {
129
+  if (!multiModelResult.value) return []
130
+  return MODEL_OPTIONS.map(m => {
131
+    const data = multiModelResult.value[m.value]
132
+    return {
133
+      model: m.label,
134
+      mae: data?.mae ? Number(data.mae).toFixed(2) : '-',
135
+      rmse: data?.rmse ? Number(data.rmse).toFixed(2) : '-',
136
+      mape: data?.mape ? Number(data.mape).toFixed(2) : '-'
137
+    }
138
+  })
139
+})
140
+
141
+function modelLabel(type: string) {
142
+  return MODEL_OPTIONS.find(m => m.value === type)?.label || type
143
+}
144
+
145
+async function runForecast() {
146
+  loading.value = true
147
+  try {
148
+    const res = await forecastApi.execute(filters.areaCode, filters.modelType, filters.forecastDays) as any
149
+    forecastResults.value = res.data
150
+    ElMessage.success('预测完成')
151
+    await loadTrendData()
152
+  } catch (e: any) {
153
+    ElMessage.error(e.message || '预测失败')
154
+  } finally {
155
+    loading.value = false
156
+  }
157
+}
158
+
159
+async function runMultiModel() {
160
+  loadingMulti.value = true
161
+  try {
162
+    const res = await forecastApi.multiModel(filters.areaCode, filters.forecastDays) as any
163
+    multiModelResult.value = res.data
164
+    forecastResults.value = res.data.bestForecasts || []
165
+    ElMessage.success('多模型对比完成')
166
+    await loadTrendData()
167
+    renderMetricsChart()
168
+  } catch (e: any) {
169
+    ElMessage.error(e.message || '对比失败')
170
+  } finally {
171
+    loadingMulti.value = false
172
+  }
173
+}
174
+
175
+async function initDemoData() {
176
+  loading.value = true
177
+  try {
178
+    const res = await forecastApi.generateDemo(filters.areaCode, 365) as any
179
+    ElMessage.success(`生成 ${res.data.generated} 条演示数据`)
180
+    await loadTrendData()
181
+  } catch (e: any) {
182
+    ElMessage.error(e.message || '生成失败')
183
+  } finally {
184
+    loading.value = false
185
+  }
186
+}
187
+
188
+async function loadTrendData() {
189
+  try {
190
+    const res = await forecastApi.trend(filters.areaCode) as any
191
+    const history = res.data || []
192
+    renderTrendChart(history, forecastResults.value)
193
+  } catch {
194
+    // silent
195
+  }
196
+}
197
+
198
+function renderTrendChart(history: any[], forecasts: any[]) {
199
+  if (!trendChartRef.value) return
200
+  if (!trendChart) trendChart = echarts.init(trendChartRef.value)
201
+
202
+  const historyDates = history.map((h: any) => h.recordDate)
203
+  const historyValues = history.map((h: any) => Number(h.volume))
204
+
205
+  const forecastDates = forecasts.map((f: any) => f.forecastDate)
206
+  const forecastValues = forecasts.map((f: any) => Number(f.forecastVolume))
207
+  const lowerBounds = forecasts.map((f: any) => Number(f.lowerBound))
208
+  const upperBounds = forecasts.map((f: any) => Number(f.upperBound))
209
+
210
+  // 连接历史与预测的过渡点
211
+  const lastHistoryDate = historyDates.length > 0 ? historyDates[historyDates.length - 1] : null
212
+  const lastHistoryValue = historyValues.length > 0 ? historyValues[historyValues.length - 1] : null
213
+
214
+  const allDates = [...historyDates, ...forecastDates]
215
+  const historySeries = [...historyValues, ...forecastDates.map(() => null)]
216
+  const forecastSeries = [
217
+    ...historyDates.map((_: any, i: number) => i === historyDates.length - 1 ? lastHistoryValue : null),
218
+    ...forecastValues
219
+  ]
220
+  const lowerSeries = [
221
+    ...historyDates.map(() => null),
222
+    ...lowerBounds
223
+  ]
224
+  const upperSeries = [
225
+    ...historyDates.map(() => null),
226
+    ...upperBounds
227
+  ]
228
+
229
+  trendChart.setOption({
230
+    tooltip: { trigger: 'axis' },
231
+    legend: { data: ['历史水量', '预测水量', '置信上限', '置信下限'] },
232
+    xAxis: { type: 'category', data: allDates, axisLabel: { rotate: 45 } },
233
+    yAxis: { type: 'value', name: '水量(m³)' },
234
+    dataZoom: [{ type: 'inside' }, { type: 'slider' }],
235
+    series: [
236
+      {
237
+        name: '历史水量', type: 'line', data: historySeries,
238
+        itemStyle: { color: '#409EFF' }, lineStyle: { width: 2 }
239
+      },
240
+      {
241
+        name: '预测水量', type: 'line', data: forecastSeries,
242
+        itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2, type: 'dashed' }
243
+      },
244
+      {
245
+        name: '置信上限', type: 'line', data: upperSeries,
246
+        lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(230, 162, 60, 0.15)' }, stack: 'confidence'
247
+      },
248
+      {
249
+        name: '置信下限', type: 'line', data: lowerSeries,
250
+        lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(230, 162, 60, 0.15)' }, stack: 'confidence'
251
+      }
252
+    ]
253
+  })
254
+}
255
+
256
+function renderMetricsChart() {
257
+  if (!metricsChartRef.value || !multiModelResult.value) return
258
+  if (!metricsChart) metricsChart = echarts.init(metricsChartRef.value)
259
+
260
+  const models = MODEL_OPTIONS.map(m => m.label)
261
+  const maeValues = MODEL_OPTIONS.map(m => {
262
+    const d = multiModelResult.value[m.value]
263
+    return d?.mae ? Number(d.mae) : 0
264
+  })
265
+  const rmseValues = MODEL_OPTIONS.map(m => {
266
+    const d = multiModelResult.value[m.value]
267
+    return d?.rmse ? Number(d.rmse) : 0
268
+  })
269
+
270
+  metricsChart.setOption({
271
+    tooltip: { trigger: 'axis' },
272
+    legend: { data: ['MAE', 'RMSE'] },
273
+    xAxis: { type: 'category', data: models },
274
+    yAxis: { type: 'value' },
275
+    series: [
276
+      { name: 'MAE', type: 'bar', data: maeValues, itemStyle: { color: '#409EFF' } },
277
+      { name: 'RMSE', type: 'bar', data: rmseValues, itemStyle: { color: '#F56C6C' } }
278
+    ]
279
+  })
280
+}
281
+
282
+onMounted(async () => {
283
+  await nextTick()
284
+  await loadTrendData()
285
+  window.addEventListener('resize', () => {
286
+    trendChart?.resize()
287
+    metricsChart?.resize()
288
+  })
289
+})
290
+</script>
291
+
292
+<style scoped>
293
+.forecast-view { padding: 16px; }
294
+.card-header { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
295
+.filter-form { margin-bottom: 8px; }
296
+</style>

+ 227
- 0
frontend/src/views/bi/SchedulingView.vue Wyświetl plik

@@ -0,0 +1,227 @@
1
+<template>
2
+  <div class="scheduling-view">
3
+    <el-card shadow="never">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>⚡ 调度推荐决策</span>
7
+          <el-tag type="info" size="small">基于预测结果的智能调度</el-tag>
8
+        </div>
9
+      </template>
10
+
11
+      <!-- 操作区 -->
12
+      <el-form :inline="true" class="filter-form">
13
+        <el-form-item label="预测ID">
14
+          <el-input-number v-model="forecastId" :min="1" placeholder="输入预测记录ID" />
15
+        </el-form-item>
16
+        <el-form-item>
17
+          <el-button type="primary" :loading="generating" @click="generateSchemes">🚀 生成推荐方案</el-button>
18
+          <el-button @click="loadRecommendations">🔄 刷新列表</el-button>
19
+        </el-form-item>
20
+      </el-form>
21
+    </el-card>
22
+
23
+    <!-- 推荐方案卡片 -->
24
+    <el-row v-if="recommendations.length > 0" :gutter="16" style="margin-top: 16px">
25
+      <el-col v-for="rec in recommendations" :key="rec.id" :span="8">
26
+        <el-card shadow="hover" :class="['scheme-card', `scheme-${rec.schemeType.toLowerCase()}`]">
27
+          <template #header>
28
+            <div class="scheme-header">
29
+              <span class="scheme-name">{{ rec.schemeName }}</span>
30
+              <el-tag :type="riskType(rec.riskLevel)" size="small">{{ rec.riskLevel }}</el-tag>
31
+            </div>
32
+          </template>
33
+
34
+          <div class="scheme-body">
35
+            <div class="metric-row">
36
+              <div class="metric">
37
+                <div class="metric-value score">{{ Number(rec.score).toFixed(1) }}</div>
38
+                <div class="metric-label">综合评分</div>
39
+              </div>
40
+              <div class="metric">
41
+                <div class="metric-value saving">{{ Number(rec.estimatedSaving).toFixed(0) }}</div>
42
+                <div class="metric-label">节水量(m³)</div>
43
+              </div>
44
+              <div class="metric">
45
+                <div class="metric-value energy">{{ Number(rec.estimatedEnergySaving).toFixed(1) }}</div>
46
+                <div class="metric-label">节能(kWh)</div>
47
+              </div>
48
+            </div>
49
+
50
+            <el-divider />
51
+
52
+            <div class="pump-list">
53
+              <div class="section-title">泵站组合</div>
54
+              <div v-for="pump in parsePumps(rec.pumpCombination)" :key="pump.stationCode" class="pump-item">
55
+                <span>{{ pump.stationName }}</span>
56
+                <el-tag size="small">{{ pump.flow }}m³/h</el-tag>
57
+              </div>
58
+            </div>
59
+
60
+            <el-divider />
61
+
62
+            <div class="exec-time">
63
+              <div class="section-title">执行时间</div>
64
+              <div>{{ formatTime(rec.executeStart) }} ~ {{ formatTime(rec.executeEnd) }}</div>
65
+            </div>
66
+
67
+            <div class="description">
68
+              <el-text type="info" size="small">{{ rec.description }}</el-text>
69
+            </div>
70
+          </div>
71
+
72
+          <div class="scheme-actions">
73
+            <el-button type="success" size="small" :disabled="rec.status !== 'PENDING'"
74
+                       @click="acceptScheme(rec)">✅ 采纳</el-button>
75
+            <el-button type="danger" size="small" :disabled="rec.status !== 'PENDING'"
76
+                       @click="rejectScheme(rec)">❌ 拒绝</el-button>
77
+            <el-tag v-if="rec.status !== 'PENDING'" :type="statusType(rec.status)">{{ rec.status }}</el-tag>
78
+          </div>
79
+        </el-card>
80
+      </el-col>
81
+    </el-row>
82
+
83
+    <!-- 方案对比表格 -->
84
+    <el-card v-if="recommendations.length > 0" shadow="never" style="margin-top: 16px">
85
+      <template #header>方案对比</template>
86
+      <el-table :data="recommendations" stripe>
87
+        <el-table-column prop="schemeName" label="方案名称" width="180" />
88
+        <el-table-column prop="schemeType" label="类型" width="120">
89
+          <template #default="{ row }">
90
+            <el-tag :type="row.schemeType === 'EMERGENCY' ? 'danger' : row.schemeType === 'PEAK_SHIFT' ? 'warning' : 'info'" size="small">
91
+              {{ row.schemeType }}
92
+            </el-tag>
93
+          </template>
94
+        </el-table-column>
95
+        <el-table-column prop="score" label="评分" width="100">
96
+          <template #default="{ row }">{{ Number(row.score).toFixed(1) }}</template>
97
+        </el-table-column>
98
+        <el-table-column prop="estimatedSaving" label="节水量(m³)" width="120">
99
+          <template #default="{ row }">{{ Number(row.estimatedSaving).toFixed(0) }}</template>
100
+        </el-table-column>
101
+        <el-table-column prop="estimatedEnergySaving" label="节能(kWh)" width="120">
102
+          <template #default="{ row }">{{ Number(row.estimatedEnergySaving).toFixed(1) }}</template>
103
+        </el-table-column>
104
+        <el-table-column prop="riskLevel" label="风险" width="100">
105
+          <template #default="{ row }">
106
+            <el-tag :type="riskType(row.riskLevel)" size="small">{{ row.riskLevel }}</el-tag>
107
+          </template>
108
+        </el-table-column>
109
+        <el-table-column prop="status" label="状态" width="120">
110
+          <template #default="{ row }">
111
+            <el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
112
+          </template>
113
+        </el-table-column>
114
+        <el-table-column label="泵站数" width="80">
115
+          <template #default="{ row }">{{ parsePumps(row.pumpCombination).length }}</template>
116
+        </el-table-column>
117
+      </el-table>
118
+    </el-card>
119
+  </div>
120
+</template>
121
+
122
+<script setup lang="ts">
123
+import { ref, onMounted } from 'vue'
124
+import { ElMessage } from 'element-plus'
125
+import { schedulingApi } from '@/api/forecastApi'
126
+
127
+const forecastId = ref<number>(1)
128
+const generating = ref(false)
129
+const recommendations = ref<any[]>([])
130
+
131
+async function generateSchemes() {
132
+  if (!forecastId.value) {
133
+    ElMessage.warning('请输入预测ID')
134
+    return
135
+  }
136
+  generating.value = true
137
+  try {
138
+    const res = await schedulingApi.generate(forecastId.value) as any
139
+    recommendations.value = res.data
140
+    ElMessage.success(`生成 ${res.data.length} 个推荐方案`)
141
+  } catch (e: any) {
142
+    ElMessage.error(e.message || '生成失败')
143
+  } finally {
144
+    generating.value = false
145
+  }
146
+}
147
+
148
+async function loadRecommendations() {
149
+  try {
150
+    const res = await schedulingApi.list(forecastId.value) as any
151
+    recommendations.value = res.data?.records || []
152
+  } catch (e: any) {
153
+    ElMessage.error(e.message || '加载失败')
154
+  }
155
+}
156
+
157
+async function acceptScheme(rec: any) {
158
+  try {
159
+    await schedulingApi.accept(rec.id)
160
+    rec.status = 'ACCEPTED'
161
+    ElMessage.success('方案已采纳')
162
+  } catch (e: any) {
163
+    ElMessage.error(e.message || '操作失败')
164
+  }
165
+}
166
+
167
+async function rejectScheme(rec: any) {
168
+  try {
169
+    await schedulingApi.reject(rec.id)
170
+    rec.status = 'REJECTED'
171
+    ElMessage.warning('方案已拒绝')
172
+  } catch (e: any) {
173
+    ElMessage.error(e.message || '操作失败')
174
+  }
175
+}
176
+
177
+function parsePumps(json: string): any[] {
178
+  try { return JSON.parse(json || '[]') } catch { return [] }
179
+}
180
+
181
+function formatTime(t: string) {
182
+  if (!t) return '-'
183
+  return t.replace('T', ' ').substring(0, 16)
184
+}
185
+
186
+function riskType(level: string) {
187
+  return level === 'HIGH' ? 'danger' : level === 'MEDIUM' ? 'warning' : 'success'
188
+}
189
+
190
+function statusType(status: string) {
191
+  const map: Record<string, string> = {
192
+    PENDING: 'info', ACCEPTED: 'success', EXECUTING: 'warning', COMPLETED: '', REJECTED: 'danger'
193
+  }
194
+  return map[status] || 'info'
195
+}
196
+
197
+onMounted(() => {
198
+  // auto-load if forecastId is set
199
+})
200
+</script>
201
+
202
+<style scoped>
203
+.scheduling-view { padding: 16px; }
204
+.card-header { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
205
+.filter-form { margin-bottom: 8px; }
206
+
207
+.scheme-card { margin-bottom: 16px; }
208
+.scheme-header { display: flex; align-items: center; justify-content: space-between; }
209
+.scheme-name { font-weight: 600; font-size: 14px; }
210
+.scheme-emergency { border-left: 4px solid #f56c6c; }
211
+.scheme-peak_shift { border-left: 4px solid #e6a23c; }
212
+.scheme-normal { border-left: 4px solid #409eff; }
213
+
214
+.scheme-body { padding: 0; }
215
+.metric-row { display: flex; justify-content: space-around; text-align: center; }
216
+.metric-value { font-size: 24px; font-weight: 700; }
217
+.metric-value.score { color: #409eff; }
218
+.metric-value.saving { color: #67c23a; }
219
+.metric-value.energy { color: #e6a23c; }
220
+.metric-label { font-size: 12px; color: #909399; margin-top: 4px; }
221
+
222
+.section-title { font-weight: 600; font-size: 13px; color: #606266; margin-bottom: 8px; }
223
+.pump-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; }
224
+.exec-time { margin-bottom: 8px; }
225
+.description { margin-top: 8px; }
226
+.scheme-actions { margin-top: 12px; display: flex; gap: 8px; align-items: center; }
227
+</style>

+ 54
- 0
wm-bi/src/main/java/com/water/bi/controller/SchedulingRecommendController.java Wyświetl plik

@@ -0,0 +1,54 @@
1
+package com.water.bi.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.bi.entity.SchedulingRecommendation;
5
+import com.water.bi.service.SchedulingRecommendService;
6
+import com.water.common.core.result.R;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.util.List;
13
+
14
+@Tag(name = "调度推荐")
15
+@RestController
16
+@RequestMapping("/api/bi/scheduling")
17
+@RequiredArgsConstructor
18
+public class SchedulingRecommendController {
19
+
20
+    private final SchedulingRecommendService recommendService;
21
+
22
+    @Operation(summary = "生成推荐方案")
23
+    @PostMapping("/generate")
24
+    public R<List<SchedulingRecommendation>> generate(@RequestParam Long forecastId) {
25
+        return R.ok(recommendService.generateRecommendations(forecastId));
26
+    }
27
+
28
+    @Operation(summary = "推荐列表")
29
+    @GetMapping("/list")
30
+    public R<Page<SchedulingRecommendation>> list(
31
+            @RequestParam(required = false) Long forecastId,
32
+            @RequestParam(defaultValue = "1") int page,
33
+            @RequestParam(defaultValue = "20") int size) {
34
+        return R.ok(recommendService.getRecommendations(forecastId, page, size));
35
+    }
36
+
37
+    @Operation(summary = "方案详情")
38
+    @GetMapping("/{id}")
39
+    public R<SchedulingRecommendation> getById(@PathVariable Long id) {
40
+        return R.ok(recommendService.getById(id));
41
+    }
42
+
43
+    @Operation(summary = "采纳方案")
44
+    @PostMapping("/{id}/accept")
45
+    public R<SchedulingRecommendation> accept(@PathVariable Long id) {
46
+        return R.ok(recommendService.acceptRecommendation(id));
47
+    }
48
+
49
+    @Operation(summary = "拒绝方案")
50
+    @PostMapping("/{id}/reject")
51
+    public R<SchedulingRecommendation> reject(@PathVariable Long id) {
52
+        return R.ok(recommendService.rejectRecommendation(id));
53
+    }
54
+}

+ 69
- 0
wm-bi/src/main/java/com/water/bi/controller/WaterDemandForecastController.java Wyświetl plik

@@ -0,0 +1,69 @@
1
+package com.water.bi.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.bi.entity.HistoricalWaterDemand;
5
+import com.water.bi.entity.WaterDemandForecast;
6
+import com.water.bi.service.WaterDemandForecastService;
7
+import com.water.common.core.result.R;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.format.annotation.DateTimeFormat;
12
+import org.springframework.web.bind.annotation.*;
13
+
14
+import java.time.LocalDate;
15
+import java.util.List;
16
+import java.util.Map;
17
+
18
+@Tag(name = "需水量预测")
19
+@RestController
20
+@RequestMapping("/api/bi/forecast")
21
+@RequiredArgsConstructor
22
+public class WaterDemandForecastController {
23
+
24
+    private final WaterDemandForecastService forecastService;
25
+
26
+    @Operation(summary = "执行预测")
27
+    @PostMapping("/execute")
28
+    public R<List<WaterDemandForecast>> executeForecast(
29
+            @RequestParam String areaCode,
30
+            @RequestParam(defaultValue = "SEASONAL_DECOMPOSITION") String modelType,
31
+            @RequestParam(defaultValue = "7") int forecastDays) {
32
+        return R.ok(forecastService.forecast(areaCode, modelType, forecastDays));
33
+    }
34
+
35
+    @Operation(summary = "多模型对比预测")
36
+    @PostMapping("/multi-model")
37
+    public R<Map<String, Object>> multiModelForecast(
38
+            @RequestParam String areaCode,
39
+            @RequestParam(defaultValue = "7") int forecastDays) {
40
+        return R.ok(forecastService.multiModelForecast(areaCode, forecastDays));
41
+    }
42
+
43
+    @Operation(summary = "预测历史")
44
+    @GetMapping("/history")
45
+    public R<Page<WaterDemandForecast>> getForecastHistory(
46
+            @RequestParam(required = false) String areaCode,
47
+            @RequestParam(defaultValue = "1") int page,
48
+            @RequestParam(defaultValue = "20") int size) {
49
+        return R.ok(forecastService.getForecastHistory(areaCode, page, size));
50
+    }
51
+
52
+    @Operation(summary = "历史趋势数据")
53
+    @GetMapping("/trend")
54
+    public R<List<HistoricalWaterDemand>> getHistoricalTrend(
55
+            @RequestParam String areaCode,
56
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
57
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
58
+        return R.ok(forecastService.getHistoricalTrend(areaCode, startDate, endDate));
59
+    }
60
+
61
+    @Operation(summary = "生成演示数据")
62
+    @PostMapping("/demo-data")
63
+    public R<Map<String, Object>> generateDemoData(
64
+            @RequestParam String areaCode,
65
+            @RequestParam(defaultValue = "365") int days) {
66
+        int count = forecastService.generateDemoData(areaCode, days);
67
+        return R.ok(Map.of("generated", count, "areaCode", areaCode));
68
+    }
69
+}

+ 51
- 0
wm-bi/src/main/java/com/water/bi/entity/HistoricalWaterDemand.java Wyświetl plik

@@ -0,0 +1,51 @@
1
+package com.water.bi.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDate;
10
+
11
+/**
12
+ * 历史需水量数据
13
+ */
14
+@Data
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bi_historical_water_demand")
17
+public class HistoricalWaterDemand extends BaseEntity {
18
+
19
+    /** 区域编码 */
20
+    private String areaCode;
21
+
22
+    /** 区域名称 */
23
+    private String areaName;
24
+
25
+    /** 日期 */
26
+    private LocalDate recordDate;
27
+
28
+    /** 需水量(m³) */
29
+    private BigDecimal volume;
30
+
31
+    /** 最高温度(℃) */
32
+    private BigDecimal maxTemp;
33
+
34
+    /** 最低温度(℃) */
35
+    private BigDecimal minTemp;
36
+
37
+    /** 天气状况: sunny/cloudy/rainy/snowy */
38
+    private String weather;
39
+
40
+    /** 降雨量(mm) */
41
+    private BigDecimal rainfall;
42
+
43
+    /** 是否节假日 */
44
+    private Boolean isHoliday;
45
+
46
+    /** 星期几(1=周一,7=周日) */
47
+    private Integer dayOfWeek;
48
+
49
+    /** 数据来源: METER/ESTIMATED */
50
+    private String dataSource;
51
+}

+ 57
- 0
wm-bi/src/main/java/com/water/bi/entity/SchedulingRecommendation.java Wyświetl plik

@@ -0,0 +1,57 @@
1
+package com.water.bi.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDateTime;
10
+
11
+/**
12
+ * 调度推荐方案
13
+ */
14
+@Data
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bi_scheduling_recommendation")
17
+public class SchedulingRecommendation extends BaseEntity {
18
+
19
+    /** 关联预测ID */
20
+    private Long forecastId;
21
+
22
+    /** 方案名称 */
23
+    private String schemeName;
24
+
25
+    /** 方案类型: NORMAL / PEAK_SHIFT / EMERGENCY */
26
+    private String schemeType;
27
+
28
+    /** 推荐方案描述 */
29
+    private String description;
30
+
31
+    /** 泵站组合JSON [{"stationCode":"PS001","stationName":"一号泵站","flow":500}] */
32
+    private String pumpCombination;
33
+
34
+    /** 预计节水量(m³) */
35
+    private BigDecimal estimatedSaving;
36
+
37
+    /** 预计节能(kWh) */
38
+    private BigDecimal estimatedEnergySaving;
39
+
40
+    /** 方案评分(0-100) */
41
+    private BigDecimal score;
42
+
43
+    /** 执行时间窗口-开始 */
44
+    private LocalDateTime executeStart;
45
+
46
+    /** 执行时间窗口-结束 */
47
+    private LocalDateTime executeEnd;
48
+
49
+    /** 风险等级: LOW / MEDIUM / HIGH */
50
+    private String riskLevel;
51
+
52
+    /** 状态: PENDING / ACCEPTED / EXECUTING / COMPLETED / REJECTED */
53
+    private String status;
54
+
55
+    /** 备注 */
56
+    private String remark;
57
+}

+ 64
- 0
wm-bi/src/main/java/com/water/bi/entity/WaterDemandForecast.java Wyświetl plik

@@ -0,0 +1,64 @@
1
+package com.water.bi.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDate;
10
+import java.time.LocalDateTime;
11
+
12
+/**
13
+ * 需水量预测记录
14
+ */
15
+@Data
16
+@EqualsAndHashCode(callSuper = true)
17
+@TableName("bi_water_demand_forecast")
18
+public class WaterDemandForecast extends BaseEntity {
19
+
20
+    /** 区域编码 */
21
+    private String areaCode;
22
+
23
+    /** 区域名称 */
24
+    private String areaName;
25
+
26
+    /** 预测日期 */
27
+    private LocalDate forecastDate;
28
+
29
+    /** 预测水量(m³) */
30
+    private BigDecimal forecastVolume;
31
+
32
+    /** 实际水量(m³),用于评估 */
33
+    private BigDecimal actualVolume;
34
+
35
+    /** 模型类型: MOVING_AVERAGE / EXPONENTIAL_SMOOTHING / SEASONAL_DECOMPOSITION */
36
+    private String modelType;
37
+
38
+    /** 模型参数JSON */
39
+    private String modelParams;
40
+
41
+    /** MAE */
42
+    private BigDecimal mae;
43
+
44
+    /** RMSE */
45
+    private BigDecimal rmse;
46
+
47
+    /** MAPE(%) */
48
+    private BigDecimal mape;
49
+
50
+    /** 置信度(0-1) */
51
+    private BigDecimal confidence;
52
+
53
+    /** 置信区间下限 */
54
+    private BigDecimal lowerBound;
55
+
56
+    /** 置信区间上限 */
57
+    private BigDecimal upperBound;
58
+
59
+    /** 预测生成时间 */
60
+    private LocalDateTime generateTime;
61
+
62
+    /** 备注 */
63
+    private String remark;
64
+}

+ 9
- 0
wm-bi/src/main/java/com/water/bi/mapper/HistoricalWaterDemandMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.bi.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bi.entity.HistoricalWaterDemand;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface HistoricalWaterDemandMapper extends BaseMapper<HistoricalWaterDemand> {
9
+}

+ 9
- 0
wm-bi/src/main/java/com/water/bi/mapper/SchedulingRecommendationMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.bi.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bi.entity.SchedulingRecommendation;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SchedulingRecommendationMapper extends BaseMapper<SchedulingRecommendation> {
9
+}

+ 9
- 0
wm-bi/src/main/java/com/water/bi/mapper/WaterDemandForecastMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.bi.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bi.entity.WaterDemandForecast;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface WaterDemandForecastMapper extends BaseMapper<WaterDemandForecast> {
9
+}

+ 350
- 0
wm-bi/src/main/java/com/water/bi/service/SchedulingRecommendService.java Wyświetl plik

@@ -0,0 +1,350 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.bi.entity.SchedulingRecommendation;
6
+import com.water.bi.entity.WaterDemandForecast;
7
+import com.water.bi.mapper.SchedulingRecommendationMapper;
8
+import com.water.bi.mapper.WaterDemandForecastMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.stereotype.Service;
12
+import org.springframework.transaction.annotation.Transactional;
13
+
14
+import java.math.BigDecimal;
15
+import java.math.RoundingMode;
16
+import java.time.LocalDateTime;
17
+import java.util.*;
18
+
19
+/**
20
+ * 调度推荐服务
21
+ * 基于预测结果生成调度方案:泵站组合优化/错峰调度/紧急预案/方案评分排序
22
+ */
23
+@Slf4j
24
+@Service
25
+@RequiredArgsConstructor
26
+public class SchedulingRecommendService {
27
+
28
+    private final SchedulingRecommendationMapper recommendMapper;
29
+    private final WaterDemandForecastMapper forecastMapper;
30
+
31
+    /** 泵站基础信息 */
32
+    private static final List<PumpStation> PUMP_STATIONS = List.of(
33
+            new PumpStation("PS001", "一号泵站(城北)", 800, 45, 0.92),
34
+            new PumpStation("PS002", "二号泵站(城南)", 600, 38, 0.89),
35
+            new PumpStation("PS003", "三号泵站(工业园)", 1000, 55, 0.94),
36
+            new PumpStation("PS004", "四号泵站(新城)", 500, 32, 0.87),
37
+            new PumpStation("PS005", "五号泵站(托托镇)", 400, 28, 0.85),
38
+            new PumpStation("PS006", "六号泵站(大河沿子)", 700, 42, 0.90)
39
+    );
40
+
41
+    /**
42
+     * 基于预测结果生成调度推荐方案
43
+     * @param forecastId 预测记录ID
44
+     * @return 推荐方案列表(按评分排序)
45
+     */
46
+    @Transactional
47
+    public List<SchedulingRecommendation> generateRecommendations(Long forecastId) {
48
+        WaterDemandForecast forecast = forecastMapper.selectById(forecastId);
49
+        if (forecast == null) {
50
+            throw new RuntimeException("预测记录不存在: " + forecastId);
51
+        }
52
+
53
+        double predictedVolume = forecast.getForecastVolume().doubleValue();
54
+        List<SchedulingRecommendation> recommendations = new ArrayList<>();
55
+
56
+        // 1. 常规调度方案(贪心算法选择最优泵站组合)
57
+        recommendations.add(generateNormalScheme(forecast, predictedVolume));
58
+
59
+        // 2. 错峰调度方案(避开高峰用电时段)
60
+        recommendations.add(generatePeakShiftScheme(forecast, predictedVolume));
61
+
62
+        // 3. 节能优先方案(选择效率最高的泵站组合)
63
+        recommendations.add(generateEnergySavingScheme(forecast, predictedVolume));
64
+
65
+        // 4. 紧急预案(高峰需求时的应急方案)
66
+        if (predictedVolume > 6000) {
67
+            recommendations.add(generateEmergencyScheme(forecast, predictedVolume));
68
+        }
69
+
70
+        // 5. 低成本方案(最小化运行成本)
71
+        recommendations.add(generateLowCostScheme(forecast, predictedVolume));
72
+
73
+        // 按评分排序
74
+        recommendations.sort((a, b) -> b.getScore().compareTo(a.getScore()));
75
+
76
+        // 保存
77
+        for (SchedulingRecommendation r : recommendations) {
78
+            recommendMapper.insert(r);
79
+        }
80
+
81
+        return recommendations;
82
+    }
83
+
84
+    /**
85
+     * 获取推荐列表
86
+     */
87
+    public Page<SchedulingRecommendation> getRecommendations(Long forecastId, int page, int size) {
88
+        LambdaQueryWrapper<SchedulingRecommendation> wrapper = new LambdaQueryWrapper<>();
89
+        if (forecastId != null) {
90
+            wrapper.eq(SchedulingRecommendation::getForecastId, forecastId);
91
+        }
92
+        wrapper.orderByDesc(SchedulingRecommendation::getScore);
93
+        return recommendMapper.selectPage(new Page<>(page, size), wrapper);
94
+    }
95
+
96
+    /**
97
+     * 采纳方案
98
+     */
99
+    @Transactional
100
+    public SchedulingRecommendation acceptRecommendation(Long id) {
101
+        SchedulingRecommendation rec = recommendMapper.selectById(id);
102
+        if (rec == null) throw new RuntimeException("方案不存在");
103
+        rec.setStatus("ACCEPTED");
104
+        recommendMapper.updateById(rec);
105
+        return rec;
106
+    }
107
+
108
+    /**
109
+     * 拒绝方案
110
+     */
111
+    @Transactional
112
+    public SchedulingRecommendation rejectRecommendation(Long id) {
113
+        SchedulingRecommendation rec = recommendMapper.selectById(id);
114
+        if (rec == null) throw new RuntimeException("方案不存在");
115
+        rec.setStatus("REJECTED");
116
+        recommendMapper.updateById(rec);
117
+        return rec;
118
+    }
119
+
120
+    /**
121
+     * 获取方案详情
122
+     */
123
+    public SchedulingRecommendation getById(Long id) {
124
+        return recommendMapper.selectById(id);
125
+    }
126
+
127
+    // ============ 方案生成算法 ============
128
+
129
+    /**
130
+     * 常规调度方案 - 贪心算法选择满足需求的最小泵站组合
131
+     */
132
+    private SchedulingRecommendation generateNormalScheme(WaterDemandForecast forecast, double demand) {
133
+        List<PumpStation> selected = greedySelectPumps(demand, false);
134
+        double totalCapacity = selected.stream().mapToDouble(p -> p.capacity).sum();
135
+        double saving = Math.max(0, totalCapacity - demand);
136
+
137
+        SchedulingRecommendation rec = new SchedulingRecommendation();
138
+        rec.setForecastId(forecast.getId());
139
+        rec.setSchemeName("常规调度方案");
140
+        rec.setSchemeType("NORMAL");
141
+        rec.setDescription(String.format("基于预测水量%.0fm³,选择%d座泵站组合供水,总容量%.0fm³",
142
+                demand, selected.size(), totalCapacity));
143
+        rec.setPumpCombination(toJson(selected, demand));
144
+        rec.setEstimatedSaving(BigDecimal.valueOf(saving * 0.15).setScale(2, RoundingMode.HALF_UP));
145
+        rec.setEstimatedEnergySaving(BigDecimal.valueOf(selected.stream().mapToDouble(p -> p.power * 0.1).sum()).setScale(2, RoundingMode.HALF_UP));
146
+        rec.setScore(calculateScore(selected, demand, 0.7, 0.2, 0.1));
147
+        rec.setExecuteStart(forecast.getForecastDate().atTime(6, 0));
148
+        rec.setExecuteEnd(forecast.getForecastDate().atTime(22, 0));
149
+        rec.setRiskLevel(demand > totalCapacity * 0.9 ? "HIGH" : demand > totalCapacity * 0.7 ? "MEDIUM" : "LOW");
150
+        rec.setStatus("PENDING");
151
+        return rec;
152
+    }
153
+
154
+    /**
155
+     * 错峰调度方案 - 在低谷时段蓄水,高峰时段减少供水
156
+     */
157
+    private SchedulingRecommendation generatePeakShiftScheme(WaterDemandForecast forecast, double demand) {
158
+        // 选择效率较高的泵站,在夜间低谷时段多供水
159
+        List<PumpStation> selected = PUMP_STATIONS.stream()
160
+                .sorted((a, b) -> Double.compare(b.efficiency, a.efficiency))
161
+                .limit(3)
162
+                .toList();
163
+
164
+        double totalCapacity = selected.stream().mapToDouble(p -> p.capacity).sum();
165
+        // 错峰可节约约20%能耗
166
+        double energySaving = selected.stream().mapToDouble(p -> p.power * 0.2).sum();
167
+
168
+        SchedulingRecommendation rec = new SchedulingRecommendation();
169
+        rec.setForecastId(forecast.getId());
170
+        rec.setSchemeName("错峰调度方案");
171
+        rec.setSchemeType("PEAK_SHIFT");
172
+        rec.setDescription(String.format("利用夜间低谷时段(22:00-06:00)蓄水,高峰时段减少泵站运行。预测水量%.0fm³", demand));
173
+        rec.setPumpCombination(toJson(selected, demand));
174
+        rec.setEstimatedSaving(BigDecimal.valueOf(demand * 0.08).setScale(2, RoundingMode.HALF_UP));
175
+        rec.setEstimatedEnergySaving(BigDecimal.valueOf(energySaving).setScale(2, RoundingMode.HALF_UP));
176
+        rec.setScore(calculateScore(selected, demand, 0.6, 0.3, 0.1));
177
+        rec.setExecuteStart(forecast.getForecastDate().atTime(22, 0));
178
+        rec.setExecuteEnd(forecast.getForecastDate().plusDays(1).atTime(6, 0));
179
+        rec.setRiskLevel("MEDIUM");
180
+        rec.setStatus("PENDING");
181
+        return rec;
182
+    }
183
+
184
+    /**
185
+     * 节能优先方案 - 选择效率最高的泵站
186
+     */
187
+    private SchedulingRecommendation generateEnergySavingScheme(WaterDemandForecast forecast, double demand) {
188
+        List<PumpStation> selected = PUMP_STATIONS.stream()
189
+                .filter(p -> p.efficiency >= 0.89)
190
+                .sorted((a, b) -> Double.compare(b.efficiency, a.efficiency))
191
+                .toList();
192
+
193
+        // 如果高效泵站不够,补充一个
194
+        if (selected.stream().mapToDouble(p -> p.capacity).sum() < demand * 0.8) {
195
+            List<PumpStation> more = PUMP_STATIONS.stream()
196
+                    .filter(p -> !selected.contains(p))
197
+                    .sorted((a, b) -> Double.compare(b.efficiency, a.efficiency))
198
+                    .limit(1)
199
+                    .toList();
200
+            List<PumpStation> combined = new ArrayList<>(selected);
201
+            combined.addAll(more);
202
+            selected = combined;
203
+        }
204
+
205
+        double totalCapacity = selected.stream().mapToDouble(p -> p.capacity).sum();
206
+        double energySaving = selected.stream().mapToDouble(p -> p.power * (p.efficiency - 0.85) * 2).sum();
207
+
208
+        SchedulingRecommendation rec = new SchedulingRecommendation();
209
+        rec.setForecastId(forecast.getId());
210
+        rec.setSchemeName("节能优先方案");
211
+        rec.setSchemeType("NORMAL");
212
+        rec.setDescription(String.format("优先选择高效泵站运行,降低能耗。预测水量%.0fm³,泵站平均效率%.2f",
213
+                demand, selected.stream().mapToDouble(p -> p.efficiency).average().orElse(0)));
214
+        rec.setPumpCombination(toJson(selected, demand));
215
+        rec.setEstimatedSaving(BigDecimal.valueOf(demand * 0.05).setScale(2, RoundingMode.HALF_UP));
216
+        rec.setEstimatedEnergySaving(BigDecimal.valueOf(Math.max(0, energySaving)).setScale(2, RoundingMode.HALF_UP));
217
+        rec.setScore(calculateScore(selected, demand, 0.5, 0.4, 0.1));
218
+        rec.setExecuteStart(forecast.getForecastDate().atTime(6, 0));
219
+        rec.setExecuteEnd(forecast.getForecastDate().atTime(22, 0));
220
+        rec.setRiskLevel(totalCapacity < demand ? "HIGH" : "LOW");
221
+        rec.setStatus("PENDING");
222
+        return rec;
223
+    }
224
+
225
+    /**
226
+     * 紧急预案 - 全泵站满负荷运行
227
+     */
228
+    private SchedulingRecommendation generateEmergencyScheme(WaterDemandForecast forecast, double demand) {
229
+        List<PumpStation> selected = new ArrayList<>(PUMP_STATIONS);
230
+        double totalCapacity = selected.stream().mapToDouble(p -> p.capacity).sum();
231
+
232
+        SchedulingRecommendation rec = new SchedulingRecommendation();
233
+        rec.setForecastId(forecast.getId());
234
+        rec.setSchemeName("紧急预案(高峰应急)");
235
+        rec.setSchemeType("EMERGENCY");
236
+        rec.setDescription(String.format("高峰需求紧急预案,启用全部%d座泵站满负荷运行。预测水量%.0fm³,总容量%.0fm³",
237
+                selected.size(), demand, totalCapacity));
238
+        rec.setPumpCombination(toJson(selected, demand));
239
+        rec.setEstimatedSaving(BigDecimal.ZERO);
240
+        rec.setEstimatedEnergySaving(BigDecimal.ZERO);
241
+        rec.setScore(calculateScore(selected, demand, 0.9, 0.0, 0.1));
242
+        rec.setExecuteStart(forecast.getForecastDate().atTime(0, 0));
243
+        rec.setExecuteEnd(forecast.getForecastDate().atTime(23, 59));
244
+        rec.setRiskLevel("HIGH");
245
+        rec.setStatus("PENDING");
246
+        return rec;
247
+    }
248
+
249
+    /**
250
+     * 低成本方案 - 选择运行成本最低的泵站组合
251
+     */
252
+    private SchedulingRecommendation generateLowCostScheme(WaterDemandForecast forecast, double demand) {
253
+        // 按单位成本排序(功率/容量 = 单位水量的能耗)
254
+        List<PumpStation> selected = PUMP_STATIONS.stream()
255
+                .sorted((a, b) -> Double.compare(a.power / a.capacity, b.power / b.capacity))
256
+                .toList();
257
+
258
+        List<PumpStation> chosen = new ArrayList<>();
259
+        double accumulated = 0;
260
+        for (PumpStation p : selected) {
261
+            if (accumulated >= demand) break;
262
+            chosen.add(p);
263
+            accumulated += p.capacity;
264
+        }
265
+
266
+        double totalCapacity = chosen.stream().mapToDouble(p -> p.capacity).sum();
267
+        double costSaving = chosen.stream().mapToDouble(p -> p.power * 0.15).sum();
268
+
269
+        SchedulingRecommendation rec = new SchedulingRecommendation();
270
+        rec.setForecastId(forecast.getId());
271
+        rec.setSchemeName("低成本方案");
272
+        rec.setSchemeType("NORMAL");
273
+        rec.setDescription(String.format("选择运行成本最低的泵站组合,单位水量能耗最优。预测水量%.0fm³", demand));
274
+        rec.setPumpCombination(toJson(chosen, demand));
275
+        rec.setEstimatedSaving(BigDecimal.valueOf(Math.max(0, totalCapacity - demand) * 0.1).setScale(2, RoundingMode.HALF_UP));
276
+        rec.setEstimatedEnergySaving(BigDecimal.valueOf(costSaving).setScale(2, RoundingMode.HALF_UP));
277
+        rec.setScore(calculateScore(chosen, demand, 0.6, 0.2, 0.2));
278
+        rec.setExecuteStart(forecast.getForecastDate().atTime(6, 0));
279
+        rec.setExecuteEnd(forecast.getForecastDate().atTime(22, 0));
280
+        rec.setRiskLevel(totalCapacity < demand * 1.1 ? "MEDIUM" : "LOW");
281
+        rec.setStatus("PENDING");
282
+        return rec;
283
+    }
284
+
285
+    // ============ 评分算法 ============
286
+
287
+    /**
288
+     * 综合评分 = 供需匹配度 * w1 + 能效评分 * w2 + 可靠性评分 * w3
289
+     */
290
+    private BigDecimal calculateScore(List<PumpStation> selected, double demand,
291
+                                       double w1, double w2, double w3) {
292
+        double totalCapacity = selected.stream().mapToDouble(p -> p.capacity).sum();
293
+        double avgEfficiency = selected.stream().mapToDouble(p -> p.efficiency).average().orElse(0);
294
+
295
+        // 供需匹配度:越接近1越好(不过度供水也不缺水)
296
+        double matchScore = totalCapacity >= demand
297
+                ? Math.max(0, 100 - (totalCapacity - demand) / demand * 100)
298
+                : totalCapacity / demand * 100;
299
+
300
+        // 能效评分
301
+        double efficiencyScore = avgEfficiency * 100;
302
+
303
+        // 可靠性评分(泵站越多越可靠)
304
+        double reliabilityScore = Math.min(100, selected.size() * 20.0);
305
+
306
+        double totalScore = matchScore * w1 + efficiencyScore * w2 + reliabilityScore * w3;
307
+        return BigDecimal.valueOf(Math.min(100, Math.max(0, totalScore))).setScale(1, RoundingMode.HALF_UP);
308
+    }
309
+
310
+    /**
311
+     * 贪心算法选择泵站组合
312
+     */
313
+    private List<PumpStation> greedySelectPumps(double demand, boolean efficiencyFirst) {
314
+        List<PumpStation> sorted = new ArrayList<>(PUMP_STATIONS);
315
+        if (efficiencyFirst) {
316
+            sorted.sort((a, b) -> Double.compare(b.efficiency, a.efficiency));
317
+        } else {
318
+            // 按容量降序
319
+            sorted.sort((a, b) -> Double.compare(b.capacity, a.capacity));
320
+        }
321
+
322
+        List<PumpStation> selected = new ArrayList<>();
323
+        double accumulated = 0;
324
+        for (PumpStation p : sorted) {
325
+            if (accumulated >= demand * 1.1) break; // 10%余量
326
+            selected.add(p);
327
+            accumulated += p.capacity;
328
+        }
329
+        return selected;
330
+    }
331
+
332
+    private String toJson(List<PumpStation> stations, double demand) {
333
+        StringBuilder sb = new StringBuilder("[");
334
+        double remaining = demand;
335
+        for (int i = 0; i < stations.size(); i++) {
336
+            PumpStation p = stations.get(i);
337
+            double flow = Math.min(p.capacity, remaining);
338
+            remaining -= flow;
339
+            if (i > 0) sb.append(",");
340
+            sb.append(String.format(
341
+                    "{\"stationCode\":\"%s\",\"stationName\":\"%s\",\"capacity\":%d,\"flow\":%.0f,\"power\":%d,\"efficiency\":%.2f}",
342
+                    p.code, p.name, p.capacity, Math.max(0, flow), p.power, p.efficiency));
343
+        }
344
+        sb.append("]");
345
+        return sb.toString();
346
+    }
347
+
348
+    /** 泵站信息 */
349
+    private record PumpStation(String code, String name, int capacity, int power, double efficiency) {}
350
+}

+ 532
- 0
wm-bi/src/main/java/com/water/bi/service/WaterDemandForecastService.java Wyświetl plik

@@ -0,0 +1,532 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.bi.entity.HistoricalWaterDemand;
6
+import com.water.bi.entity.WaterDemandForecast;
7
+import com.water.bi.mapper.HistoricalWaterDemandMapper;
8
+import com.water.bi.mapper.WaterDemandForecastMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.stereotype.Service;
12
+import org.springframework.transaction.annotation.Transactional;
13
+
14
+import java.math.BigDecimal;
15
+import java.math.RoundingMode;
16
+import java.time.DayOfWeek;
17
+import java.time.LocalDate;
18
+import java.time.LocalDateTime;
19
+import java.util.*;
20
+import java.util.stream.Collectors;
21
+
22
+/**
23
+ * 需水量预测服务
24
+ * 实现三种预测模型:移动平均、指数平滑、季节性分解
25
+ */
26
+@Slf4j
27
+@Service
28
+@RequiredArgsConstructor
29
+public class WaterDemandForecastService {
30
+
31
+    private final WaterDemandForecastMapper forecastMapper;
32
+    private final HistoricalWaterDemandMapper historicalMapper;
33
+
34
+    /**
35
+     * 执行预测(指定区域和模型)
36
+     * @param areaCode 区域编码
37
+     * @param modelType 模型类型
38
+     * @param forecastDays 预测天数
39
+     * @return 预测结果列表
40
+     */
41
+    @Transactional
42
+    public List<WaterDemandForecast> forecast(String areaCode, String modelType, int forecastDays) {
43
+        // 获取历史数据(至少需要90天)
44
+        List<HistoricalWaterDemand> history = getHistoryData(areaCode, 180);
45
+        if (history.size() < 30) {
46
+            throw new RuntimeException("历史数据不足,至少需要30天数据");
47
+        }
48
+
49
+        double[] data = history.stream()
50
+                .mapToDouble(h -> h.getVolume().doubleValue())
51
+                .toArray();
52
+
53
+        List<WaterDemandForecast> results = new ArrayList<>();
54
+        LocalDate lastDate = history.get(history.size() - 1).getRecordDate();
55
+
56
+        switch (modelType.toUpperCase()) {
57
+            case "MOVING_AVERAGE":
58
+                results = movingAverageForecast(areaCode, data, lastDate, forecastDays, 7);
59
+                break;
60
+            case "EXPONENTIAL_SMOOTHING":
61
+                results = exponentialSmoothingForecast(areaCode, data, lastDate, forecastDays, 0.3);
62
+                break;
63
+            case "SEASONAL_DECOMPOSITION":
64
+                results = seasonalDecompositionForecast(areaCode, data, lastDate, forecastDays, 7);
65
+                break;
66
+            default:
67
+                throw new RuntimeException("不支持的模型类型: " + modelType);
68
+        }
69
+
70
+        // 计算模型评估指标
71
+        evaluateModel(results, data, modelType);
72
+
73
+        // 保存预测结果
74
+        for (WaterDemandForecast f : results) {
75
+            f.setGenerateTime(LocalDateTime.now());
76
+            forecastMapper.insert(f);
77
+        }
78
+
79
+        return results;
80
+    }
81
+
82
+    /**
83
+     * 多模型对比预测
84
+     */
85
+    @Transactional
86
+    public Map<String, Object> multiModelForecast(String areaCode, int forecastDays) {
87
+        List<String> models = List.of("MOVING_AVERAGE", "EXPONENTIAL_SMOOTHING", "SEASONAL_DECOMPOSITION");
88
+        Map<String, Object> comparison = new LinkedHashMap<>();
89
+
90
+        List<WaterDemandForecast> bestResults = null;
91
+        double bestMae = Double.MAX_VALUE;
92
+        String bestModel = null;
93
+
94
+        for (String model : models) {
95
+            try {
96
+                List<WaterDemandForecast> results = forecast(areaCode, model, forecastDays);
97
+                double mae = results.stream()
98
+                        .filter(r -> r.getMae() != null)
99
+                        .mapToDouble(r -> r.getMae().doubleValue())
100
+                        .average()
101
+                        .orElse(Double.MAX_VALUE);
102
+
103
+                Map<String, Object> modelResult = new HashMap<>();
104
+                modelResult.put("modelType", model);
105
+                modelResult.put("mae", results.isEmpty() ? null : results.get(0).getMae());
106
+                modelResult.put("rmse", results.isEmpty() ? null : results.get(0).getRmse());
107
+                modelResult.put("mape", results.isEmpty() ? null : results.get(0).getMape());
108
+                modelResult.put("forecasts", results);
109
+                comparison.put(model, modelResult);
110
+
111
+                if (mae < bestMae) {
112
+                    bestMae = mae;
113
+                    bestResults = results;
114
+                    bestModel = model;
115
+                }
116
+            } catch (Exception e) {
117
+                log.warn("模型 {} 预测失败: {}", model, e.getMessage());
118
+            }
119
+        }
120
+
121
+        comparison.put("bestModel", bestModel);
122
+        comparison.put("bestForecasts", bestResults);
123
+        return comparison;
124
+    }
125
+
126
+    /**
127
+     * 获取历史预测结果
128
+     */
129
+    public Page<WaterDemandForecast> getForecastHistory(String areaCode, int page, int size) {
130
+        LambdaQueryWrapper<WaterDemandForecast> wrapper = new LambdaQueryWrapper<>();
131
+        if (areaCode != null && !areaCode.isEmpty()) {
132
+            wrapper.eq(WaterDemandForecast::getAreaCode, areaCode);
133
+        }
134
+        wrapper.orderByDesc(WaterDemandForecast::getGenerateTime);
135
+        return forecastMapper.selectPage(new Page<>(page, size), wrapper);
136
+    }
137
+
138
+    /**
139
+     * 获取历史数据趋势
140
+     */
141
+    public List<HistoricalWaterDemand> getHistoricalTrend(String areaCode, LocalDate startDate, LocalDate endDate) {
142
+        LambdaQueryWrapper<HistoricalWaterDemand> wrapper = new LambdaQueryWrapper<>();
143
+        wrapper.eq(HistoricalWaterDemand::getAreaCode, areaCode);
144
+        if (startDate != null) wrapper.ge(HistoricalWaterDemand::getRecordDate, startDate);
145
+        if (endDate != null) wrapper.le(HistoricalWaterDemand::getRecordDate, endDate);
146
+        wrapper.orderByAsc(HistoricalWaterDemand::getRecordDate);
147
+        return historicalMapper.selectList(wrapper);
148
+    }
149
+
150
+    // ============ 预测算法实现 ============
151
+
152
+    /**
153
+     * 移动平均法 (Simple Moving Average)
154
+     * 使用最近 window 天的平均值作为下一天的预测
155
+     */
156
+    private List<WaterDemandForecast> movingAverageForecast(String areaCode, double[] data,
157
+                                                             LocalDate lastDate, int forecastDays, int window) {
158
+        List<WaterDemandForecast> results = new ArrayList<>();
159
+        double[] extended = Arrays.copyOf(data, data.length + forecastDays);
160
+
161
+        for (int i = 0; i < forecastDays; i++) {
162
+            int start = Math.max(0, data.length + i - window);
163
+            int end = data.length + i;
164
+            double sum = 0;
165
+            int count = 0;
166
+            for (int j = start; j < end; j++) {
167
+                sum += extended[j];
168
+                count++;
169
+            }
170
+            double predicted = sum / count;
171
+            extended[data.length + i] = predicted;
172
+
173
+            // 计算置信区间(基于历史残差标准差)
174
+            double stdDev = calculateStdDev(data, window);
175
+            double lower = predicted - 1.96 * stdDev;
176
+            double upper = predicted + 1.96 * stdDev;
177
+
178
+            WaterDemandForecast f = createForecast(areaCode, lastDate.plusDays(i + 1),
179
+                    predicted, "MOVING_AVERAGE", lower, upper, 0.85);
180
+            f.setModelParams("{\"window\":" + window + "}");
181
+            results.add(f);
182
+        }
183
+        return results;
184
+    }
185
+
186
+    /**
187
+     * 指数平滑法 (Simple Exponential Smoothing)
188
+     * S(t) = α * Y(t-1) + (1-α) * S(t-1)
189
+     */
190
+    private List<WaterDemandForecast> exponentialSmoothingForecast(String areaCode, double[] data,
191
+                                                                     LocalDate lastDate, int forecastDays, double alpha) {
192
+        List<WaterDemandForecast> results = new ArrayList<>();
193
+
194
+        // 初始化:使用前7天的平均值
195
+        double initSum = 0;
196
+        int initCount = Math.min(7, data.length);
197
+        for (int i = 0; i < initCount; i++) initSum += data[i];
198
+        double smoothed = initSum / initCount;
199
+
200
+        // 遍历历史数据,更新平滑值
201
+        for (double datum : data) {
202
+            smoothed = alpha * datum + (1 - alpha) * smoothed;
203
+        }
204
+
205
+        // 计算残差标准差
206
+        double[] residuals = new double[data.length];
207
+        double s = initSum / initCount;
208
+        for (int i = 0; i < data.length; i++) {
209
+            if (i > 0) {
210
+                residuals[i] = data[i] - s;
211
+                s = alpha * data[i] + (1 - alpha) * s;
212
+            }
213
+        }
214
+        double stdDev = stdDevFromArray(residuals);
215
+
216
+        for (int i = 0; i < forecastDays; i++) {
217
+            double lower = smoothed - 1.96 * stdDev;
218
+            double upper = smoothed + 1.96 * stdDev;
219
+
220
+            WaterDemandForecast f = createForecast(areaCode, lastDate.plusDays(i + 1),
221
+                    smoothed, "EXPONENTIAL_SMOOTHING", lower, upper, 0.88);
222
+            f.setModelParams("{\"alpha\":" + alpha + "}");
223
+            results.add(f);
224
+        }
225
+        return results;
226
+    }
227
+
228
+    /**
229
+     * 季节性分解法 (Seasonal Decomposition + Trend)
230
+     * Y(t) = Trend(t) + Seasonal(t % period) + Residual
231
+     * 预测: Y(t+h) = Trend(t+h) + Seasonal((t+h) % period)
232
+     */
233
+    private List<WaterDemandForecast> seasonalDecompositionForecast(String areaCode, double[] data,
234
+                                                                      LocalDate lastDate, int forecastDays, int period) {
235
+        List<WaterDemandForecast> results = new ArrayList<>();
236
+        int n = data.length;
237
+
238
+        // Step 1: 计算季节性分量(每周7天一个周期)
239
+        double[] seasonal = new double[period];
240
+        int[] seasonalCount = new int[period];
241
+
242
+        // 去趋势:用移动平均
243
+        double[] trend = new double[n];
244
+        int halfWindow = period / 2;
245
+        for (int i = 0; i < n; i++) {
246
+            int start = Math.max(0, i - halfWindow);
247
+            int end = Math.min(n - 1, i + halfWindow);
248
+            double sum = 0;
249
+            int count = 0;
250
+            for (int j = start; j <= end; j++) {
251
+                sum += data[j];
252
+                count++;
253
+            }
254
+            trend[i] = sum / count;
255
+        }
256
+
257
+        // 计算季节性指数
258
+        for (int i = halfWindow; i < n - halfWindow; i++) {
259
+            int idx = i % period;
260
+            seasonal[idx] += (data[i] - trend[i]);
261
+            seasonalCount[idx]++;
262
+        }
263
+        for (int i = 0; i < period; i++) {
264
+            if (seasonalCount[i] > 0) {
265
+                seasonal[i] /= seasonalCount[i];
266
+            }
267
+        }
268
+
269
+        // Step 2: 趋势外推(线性回归)
270
+        // 用最后60天数据拟合趋势
271
+        int trendPoints = Math.min(60, n);
272
+        double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
273
+        for (int i = n - trendPoints; i < n; i++) {
274
+            double deseasonalized = data[i] - seasonal[i % period];
275
+            sumX += i;
276
+            sumY += deseasonalized;
277
+            sumXY += i * deseasonalized;
278
+            sumXX += i * i;
279
+        }
280
+        double slope = (trendPoints * sumXY - sumX * sumY) / (trendPoints * sumXX - sumX * sumX);
281
+        double intercept = (sumY - slope * sumX) / trendPoints;
282
+
283
+        // Step 3: 预测
284
+        double[] residuals = new double[n];
285
+        for (int i = halfWindow; i < n - halfWindow; i++) {
286
+            residuals[i] = data[i] - trend[i] - seasonal[i % period];
287
+        }
288
+        double stdDev = stdDevFromArray(residuals);
289
+
290
+        for (int i = 0; i < forecastDays; i++) {
291
+            int futureIdx = n + i;
292
+            double trendValue = slope * futureIdx + intercept;
293
+            double seasonalValue = seasonal[futureIdx % period];
294
+            double predicted = trendValue + seasonalValue;
295
+
296
+            double lower = predicted - 1.96 * stdDev;
297
+            double upper = predicted + 1.96 * stdDev;
298
+
299
+            WaterDemandForecast f = createForecast(areaCode, lastDate.plusDays(i + 1),
300
+                    predicted, "SEASONAL_DECOMPOSITION", lower, upper, 0.92);
301
+            f.setModelParams("{\"period\":" + period + ",\"slope\":" + slope + ",\"intercept\":" + intercept + "}");
302
+            results.add(f);
303
+        }
304
+        return results;
305
+    }
306
+
307
+    // ============ 模型评估 ============
308
+
309
+    /**
310
+     * 使用回测法评估模型(用最后30天作为验证集)
311
+     */
312
+    private void evaluateModel(List<WaterDemandForecast> forecasts, double[] data, String modelType) {
313
+        int validationSize = Math.min(30, data.length / 3);
314
+        if (validationSize < 7) return;
315
+
316
+        double[] trainData = Arrays.copyOfRange(data, 0, data.length - validationSize);
317
+        double[] validationData = Arrays.copyOfRange(data, data.length - validationSize, data.length);
318
+
319
+        double sumAbsError = 0;
320
+        double sumSqError = 0;
321
+        double sumAbsPctError = 0;
322
+        int count = 0;
323
+
324
+        for (int i = 1; i < validationSize; i++) {
325
+            double[] subTrain = Arrays.copyOf(trainData, trainData.length + i);
326
+            double predicted;
327
+            switch (modelType) {
328
+                case "MOVING_AVERAGE":
329
+                    predicted = simpleMovingAvgPredict(subTrain, 7);
330
+                    break;
331
+                case "EXPONENTIAL_SMOOTHING":
332
+                    predicted = simpleExpSmoothPredict(subTrain, 0.3);
333
+                    break;
334
+                case "SEASONAL_DECOMPOSITION":
335
+                    predicted = simpleSeasonalPredict(subTrain, 7, subTrain.length);
336
+                    break;
337
+                default:
338
+                    predicted = subTrain[subTrain.length - 1];
339
+            }
340
+
341
+            double actual = validationData[i];
342
+            double error = Math.abs(actual - predicted);
343
+            sumAbsError += error;
344
+            sumSqError += error * error;
345
+            if (actual != 0) sumAbsPctError += error / Math.abs(actual);
346
+            count++;
347
+        }
348
+
349
+        if (count > 0 && !forecasts.isEmpty()) {
350
+            double mae = sumAbsError / count;
351
+            double rmse = Math.sqrt(sumSqError / count);
352
+            double mape = (sumAbsPctError / count) * 100;
353
+
354
+            BigDecimal maeBd = BigDecimal.valueOf(mae).setScale(2, RoundingMode.HALF_UP);
355
+            BigDecimal rmseBd = BigDecimal.valueOf(rmse).setScale(2, RoundingMode.HALF_UP);
356
+            BigDecimal mapeBd = BigDecimal.valueOf(mape).setScale(2, RoundingMode.HALF_UP);
357
+
358
+            for (WaterDemandForecast f : forecasts) {
359
+                f.setMae(maeBd);
360
+                f.setRmse(rmseBd);
361
+                f.setMape(mapeBd);
362
+            }
363
+        }
364
+    }
365
+
366
+    private double simpleMovingAvgPredict(double[] data, int window) {
367
+        int start = Math.max(0, data.length - window);
368
+        double sum = 0;
369
+        int count = 0;
370
+        for (int i = start; i < data.length; i++) {
371
+            sum += data[i];
372
+            count++;
373
+        }
374
+        return sum / count;
375
+    }
376
+
377
+    private double simpleExpSmoothPredict(double[] data, double alpha) {
378
+        double s = data[0];
379
+        for (double d : data) {
380
+            s = alpha * d + (1 - alpha) * s;
381
+        }
382
+        return s;
383
+    }
384
+
385
+    private double simpleSeasonalPredict(double[] data, int period, int futureIdx) {
386
+        int n = data.length;
387
+        double[] seasonal = new double[period];
388
+        int[] cnt = new int[period];
389
+        double avg = Arrays.stream(data).average().orElse(0);
390
+        for (int i = 0; i < n; i++) {
391
+            seasonal[i % period] += (data[i] - avg);
392
+            cnt[i % period]++;
393
+        }
394
+        for (int i = 0; i < period; i++) {
395
+            if (cnt[i] > 0) seasonal[i] /= cnt[i];
396
+        }
397
+        return avg + seasonal[futureIdx % period];
398
+    }
399
+
400
+    // ============ 工具方法 ============
401
+
402
+    private List<HistoricalWaterDemand> getHistoryData(String areaCode, int days) {
403
+        LocalDate startDate = LocalDate.now().minusDays(days);
404
+        LambdaQueryWrapper<HistoricalWaterDemand> wrapper = new LambdaQueryWrapper<>();
405
+        wrapper.eq(HistoricalWaterDemand::getAreaCode, areaCode)
406
+                .ge(HistoricalWaterDemand::getRecordDate, startDate)
407
+                .orderByAsc(HistoricalWaterDemand::getRecordDate);
408
+        return historicalMapper.selectList(wrapper);
409
+    }
410
+
411
+    private WaterDemandForecast createForecast(String areaCode, LocalDate date, double volume,
412
+                                                String modelType, double lower, double upper, double confidence) {
413
+        WaterDemandForecast f = new WaterDemandForecast();
414
+        f.setAreaCode(areaCode);
415
+        f.setAreaName(getAreaName(areaCode));
416
+        f.setForecastDate(date);
417
+        f.setForecastVolume(BigDecimal.valueOf(Math.max(0, volume)).setScale(2, RoundingMode.HALF_UP));
418
+        f.setModelType(modelType);
419
+        f.setLowerBound(BigDecimal.valueOf(Math.max(0, lower)).setScale(2, RoundingMode.HALF_UP));
420
+        f.setUpperBound(BigDecimal.valueOf(Math.max(0, upper)).setScale(2, RoundingMode.HALF_UP));
421
+        f.setConfidence(BigDecimal.valueOf(confidence));
422
+        return f;
423
+    }
424
+
425
+    private String getAreaName(String areaCode) {
426
+        Map<String, String> areaMap = Map.of(
427
+                "JH_001", "精河县城北区",
428
+                "JH_002", "精河县城南区",
429
+                "JH_003", "精河县工业园",
430
+                "JH_004", "精河县新城区",
431
+                "JH_005", "托托镇供水区",
432
+                "JH_006", "大河沿子镇"
433
+        );
434
+        return areaMap.getOrDefault(areaCode, areaCode);
435
+    }
436
+
437
+    private double calculateStdDev(double[] data, int window) {
438
+        if (data.length < window + 1) return 0;
439
+        double[] residuals = new double[data.length - window];
440
+        for (int i = window; i < data.length; i++) {
441
+            double sum = 0;
442
+            for (int j = i - window; j < i; j++) sum += data[j];
443
+            double predicted = sum / window;
444
+            residuals[i - window] = data[i] - predicted;
445
+        }
446
+        return stdDevFromArray(residuals);
447
+    }
448
+
449
+    private double stdDevFromArray(double[] values) {
450
+        if (values.length <= 1) return 0;
451
+        double mean = Arrays.stream(values).average().orElse(0);
452
+        double sumSqDiff = 0;
453
+        for (double v : values) {
454
+            double diff = v - mean;
455
+            sumSqDiff += diff * diff;
456
+        }
457
+        return Math.sqrt(sumSqDiff / (values.length - 1));
458
+    }
459
+
460
+    /**
461
+     * 生成/补充历史模拟数据(用于演示)
462
+     */
463
+    @Transactional
464
+    public int generateDemoData(String areaCode, int days) {
465
+        Random random = new Random(42);
466
+        int count = 0;
467
+        LocalDate today = LocalDate.now();
468
+
469
+        for (int i = days; i >= 1; i--) {
470
+            LocalDate date = today.minusDays(i);
471
+            DayOfWeek dow = date.getDayOfWeek();
472
+
473
+            // 基础水量 + 季节性 + 星期效应 + 随机噪声
474
+            double base = 5000;
475
+            // 夏季水量高
476
+            int month = date.getMonthValue();
477
+            double seasonal = (month >= 5 && month <= 9) ? 1500 : (month >= 11 || month <= 2) ? -800 : 0;
478
+            // 周末水量低
479
+            double weekendEffect = (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) ? -600 : 0;
480
+            // 温度影响
481
+            double temp = 15 + 15 * Math.sin((month - 3) * Math.PI / 6) + random.nextGaussian() * 3;
482
+            double tempEffect = Math.max(0, (temp - 20)) * 50;
483
+            // 节假日
484
+            boolean isHoliday = isChineseHoliday(date);
485
+            double holidayEffect = isHoliday ? -1000 : 0;
486
+            // 随机噪声
487
+            double noise = random.nextGaussian() * 200;
488
+
489
+            double volume = base + seasonal + weekendEffect + tempEffect + holidayEffect + noise;
490
+            volume = Math.max(1000, volume);
491
+
492
+            HistoricalWaterDemand record = new HistoricalWaterDemand();
493
+            record.setAreaCode(areaCode);
494
+            record.setAreaName(getAreaName(areaCode));
495
+            record.setRecordDate(date);
496
+            record.setVolume(BigDecimal.valueOf(volume).setScale(2, RoundingMode.HALF_UP));
497
+            record.setMaxTemp(BigDecimal.valueOf(temp + 5).setScale(1, RoundingMode.HALF_UP));
498
+            record.setMinTemp(BigDecimal.valueOf(temp - 5).setScale(1, RoundingMode.HALF_UP));
499
+            record.setWeather(randomWeather(random));
500
+            record.setRainfall(BigDecimal.valueOf(Math.max(0, random.nextGaussian() * 5)).setScale(1, RoundingMode.HALF_UP));
501
+            record.setIsHoliday(isHoliday);
502
+            record.setDayOfWeek(dow.getValue());
503
+            record.setDataSource("SIMULATED");
504
+
505
+            // 检查是否已存在
506
+            LambdaQueryWrapper<HistoricalWaterDemand> check = new LambdaQueryWrapper<>();
507
+            check.eq(HistoricalWaterDemand::getAreaCode, areaCode)
508
+                    .eq(HistoricalWaterDemand::getRecordDate, date);
509
+            if (historicalMapper.selectCount(check) == 0) {
510
+                historicalMapper.insert(record);
511
+                count++;
512
+            }
513
+        }
514
+        return count;
515
+    }
516
+
517
+    private boolean isChineseHoliday(LocalDate date) {
518
+        // 简化的节假日判断
519
+        int m = date.getMonthValue();
520
+        int d = date.getDayOfMonth();
521
+        if (m == 1 && d >= 1 && d <= 3) return true; // 元旦
522
+        if (m == 1 && d >= 21 && d <= 27) return true; // 春节(近似)
523
+        if (m == 5 && d >= 1 && d <= 5) return true; // 五一
524
+        if (m == 10 && d >= 1 && d <= 7) return true; // 国庆
525
+        return false;
526
+    }
527
+
528
+    private String randomWeather(Random random) {
529
+        String[] weathers = {"sunny", "cloudy", "rainy", "overcast"};
530
+        return weathers[random.nextInt(weathers.length)];
531
+    }
532
+}

+ 141
- 0
wm-bi/src/test/java/com/water/bi/service/SchedulingRecommendServiceTest.java Wyświetl plik

@@ -0,0 +1,141 @@
1
+package com.water.bi.service;
2
+
3
+import com.water.bi.entity.SchedulingRecommendation;
4
+import com.water.bi.entity.WaterDemandForecast;
5
+import com.water.bi.mapper.SchedulingRecommendationMapper;
6
+import com.water.bi.mapper.WaterDemandForecastMapper;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.time.LocalDateTime;
17
+import java.util.List;
18
+
19
+import static org.junit.jupiter.api.Assertions.*;
20
+import static org.mockito.ArgumentMatchers.any;
21
+import static org.mockito.Mockito.*;
22
+
23
+@ExtendWith(MockitoExtension.class)
24
+class SchedulingRecommendServiceTest {
25
+
26
+    @Mock
27
+    private SchedulingRecommendationMapper recommendMapper;
28
+
29
+    @Mock
30
+    private WaterDemandForecastMapper forecastMapper;
31
+
32
+    @InjectMocks
33
+    private SchedulingRecommendService service;
34
+
35
+    private WaterDemandForecast mockForecast;
36
+
37
+    @BeforeEach
38
+    void setup() {
39
+        mockForecast = new WaterDemandForecast();
40
+        mockForecast.setId(1L);
41
+        mockForecast.setAreaCode("JH_001");
42
+        mockForecast.setForecastDate(LocalDate.now().plusDays(1));
43
+        mockForecast.setForecastVolume(BigDecimal.valueOf(5500));
44
+        mockForecast.setGenerateTime(LocalDateTime.now());
45
+
46
+        when(forecastMapper.selectById(1L)).thenReturn(mockForecast);
47
+        when(recommendMapper.insert(any())).thenReturn(1);
48
+    }
49
+
50
+    @Test
51
+    void testGenerateRecommendations() {
52
+        List<SchedulingRecommendation> results = service.generateRecommendations(1L);
53
+
54
+        assertNotNull(results);
55
+        assertTrue(results.size() >= 4); // At least 4 schemes (no emergency for 5500 < 6000)
56
+        for (SchedulingRecommendation rec : results) {
57
+            assertNotNull(rec.getSchemeName());
58
+            assertNotNull(rec.getSchemeType());
59
+            assertNotNull(rec.getScore());
60
+            assertTrue(rec.getScore().doubleValue() >= 0 && rec.getScore().doubleValue() <= 100);
61
+            assertNotNull(rec.getPumpCombination());
62
+            assertTrue(rec.getPumpCombination().startsWith("["));
63
+            assertEquals("PENDING", rec.getStatus());
64
+        }
65
+    }
66
+
67
+    @Test
68
+    void testHighDemandIncludesEmergency() {
69
+        mockForecast.setForecastVolume(BigDecimal.valueOf(8000));
70
+        List<SchedulingRecommendation> results = service.generateRecommendations(1L);
71
+
72
+        assertTrue(results.size() >= 5); // Includes emergency scheme
73
+        boolean hasEmergency = results.stream()
74
+                .anyMatch(r -> "EMERGENCY".equals(r.getSchemeType()));
75
+        assertTrue(hasEmergency);
76
+    }
77
+
78
+    @Test
79
+    void testRecommendationsSortedByScore() {
80
+        List<SchedulingRecommendation> results = service.generateRecommendations(1L);
81
+
82
+        for (int i = 1; i < results.size(); i++) {
83
+            assertTrue(results.get(i - 1).getScore().compareTo(results.get(i).getScore()) >= 0,
84
+                    "Should be sorted by score descending");
85
+        }
86
+    }
87
+
88
+    @Test
89
+    void testAcceptRecommendation() {
90
+        SchedulingRecommendation rec = new SchedulingRecommendation();
91
+        rec.setId(1L);
92
+        rec.setStatus("PENDING");
93
+        when(recommendMapper.selectById(1L)).thenReturn(rec);
94
+
95
+        SchedulingRecommendation accepted = service.acceptRecommendation(1L);
96
+
97
+        assertEquals("ACCEPTED", accepted.getStatus());
98
+        verify(recommendMapper).updateById(any());
99
+    }
100
+
101
+    @Test
102
+    void testRejectRecommendation() {
103
+        SchedulingRecommendation rec = new SchedulingRecommendation();
104
+        rec.setId(1L);
105
+        rec.setStatus("PENDING");
106
+        when(recommendMapper.selectById(1L)).thenReturn(rec);
107
+
108
+        SchedulingRecommendation rejected = service.rejectRecommendation(1L);
109
+
110
+        assertEquals("REJECTED", rejected.getStatus());
111
+    }
112
+
113
+    @Test
114
+    void testForecastNotFound() {
115
+        when(forecastMapper.selectById(999L)).thenReturn(null);
116
+        assertThrows(RuntimeException.class, () -> service.generateRecommendations(999L));
117
+    }
118
+
119
+    @Test
120
+    void testPumpCombinationJson() {
121
+        List<SchedulingRecommendation> results = service.generateRecommendations(1L);
122
+
123
+        for (SchedulingRecommendation rec : results) {
124
+            String json = rec.getPumpCombination();
125
+            assertTrue(json.contains("stationCode"));
126
+            assertTrue(json.contains("stationName"));
127
+            assertTrue(json.contains("capacity"));
128
+            assertTrue(json.contains("flow"));
129
+        }
130
+    }
131
+
132
+    @Test
133
+    void testRiskLevelAssigned() {
134
+        List<SchedulingRecommendation> results = service.generateRecommendations(1L);
135
+
136
+        for (SchedulingRecommendation rec : results) {
137
+            assertNotNull(rec.getRiskLevel());
138
+            assertTrue(List.of("LOW", "MEDIUM", "HIGH").contains(rec.getRiskLevel()));
139
+        }
140
+    }
141
+}

+ 163
- 0
wm-bi/src/test/java/com/water/bi/service/WaterDemandForecastServiceTest.java Wyświetl plik

@@ -0,0 +1,163 @@
1
+package com.water.bi.service;
2
+
3
+import com.water.bi.entity.HistoricalWaterDemand;
4
+import com.water.bi.entity.WaterDemandForecast;
5
+import com.water.bi.mapper.HistoricalWaterDemandMapper;
6
+import com.water.bi.mapper.WaterDemandForecastMapper;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.util.ArrayList;
17
+import java.util.List;
18
+
19
+import static org.junit.jupiter.api.Assertions.*;
20
+import static org.mockito.ArgumentMatchers.any;
21
+import static org.mockito.Mockito.*;
22
+
23
+@ExtendWith(MockitoExtension.class)
24
+class WaterDemandForecastServiceTest {
25
+
26
+    @Mock
27
+    private WaterDemandForecastMapper forecastMapper;
28
+
29
+    @Mock
30
+    private HistoricalWaterDemandMapper historicalMapper;
31
+
32
+    @InjectMocks
33
+    private WaterDemandForecastService service;
34
+
35
+    private List<HistoricalWaterDemand> mockHistory;
36
+
37
+    @BeforeEach
38
+    void setup() {
39
+        mockHistory = new ArrayList<>();
40
+        // Generate 90 days of mock data
41
+        for (int i = 90; i >= 1; i--) {
42
+            HistoricalWaterDemand record = new HistoricalWaterDemand();
43
+            record.setAreaCode("JH_001");
44
+            record.setAreaName("精河县城北区");
45
+            record.setRecordDate(LocalDate.now().minusDays(i));
46
+            // Simulate seasonal pattern + weekly pattern
47
+            double base = 5000 + Math.sin(i * 2 * Math.PI / 365) * 1000 + (i % 7 < 2 ? -500 : 200);
48
+            record.setVolume(BigDecimal.valueOf(base));
49
+            record.setDayOfWeek(record.getRecordDate().getDayOfWeek().getValue());
50
+            mockHistory.add(record);
51
+        }
52
+
53
+        when(historicalMapper.selectList(any())).thenReturn(mockHistory);
54
+        when(forecastMapper.insert(any())).thenReturn(1);
55
+    }
56
+
57
+    @Test
58
+    void testMovingAverageForecast() {
59
+        List<WaterDemandForecast> results = service.forecast("JH_001", "MOVING_AVERAGE", 7);
60
+
61
+        assertNotNull(results);
62
+        assertEquals(7, results.size());
63
+        for (WaterDemandForecast f : results) {
64
+            assertNotNull(f.getForecastVolume());
65
+            assertTrue(f.getForecastVolume().doubleValue() > 0);
66
+            assertEquals("MOVING_AVERAGE", f.getModelType());
67
+            assertEquals("JH_001", f.getAreaCode());
68
+            assertNotNull(f.getLowerBound());
69
+            assertNotNull(f.getUpperBound());
70
+            assertTrue(f.getUpperBound().doubleValue() >= f.getLowerBound().doubleValue());
71
+        }
72
+        verify(forecastMapper, times(7)).insert(any());
73
+    }
74
+
75
+    @Test
76
+    void testExponentialSmoothingForecast() {
77
+        List<WaterDemandForecast> results = service.forecast("JH_001", "EXPONENTIAL_SMOOTHING", 5);
78
+
79
+        assertNotNull(results);
80
+        assertEquals(5, results.size());
81
+        for (WaterDemandForecast f : results) {
82
+            assertNotNull(f.getForecastVolume());
83
+            assertTrue(f.getForecastVolume().doubleValue() > 0);
84
+            assertEquals("EXPONENTIAL_SMOOTHING", f.getModelType());
85
+        }
86
+    }
87
+
88
+    @Test
89
+    void testSeasonalDecompositionForecast() {
90
+        List<WaterDemandForecast> results = service.forecast("JH_001", "SEASONAL_DECOMPOSITION", 7);
91
+
92
+        assertNotNull(results);
93
+        assertEquals(7, results.size());
94
+        for (WaterDemandForecast f : results) {
95
+            assertNotNull(f.getForecastVolume());
96
+            assertTrue(f.getForecastVolume().doubleValue() > 0);
97
+            assertEquals("SEASONAL_DECOMPOSITION", f.getModelType());
98
+            assertNotNull(f.getModelParams());
99
+            assertTrue(f.getModelParams().contains("period"));
100
+        }
101
+    }
102
+
103
+    @Test
104
+    void testMultiModelForecast() {
105
+        var results = service.multiModelForecast("JH_001", 7);
106
+
107
+        assertNotNull(results);
108
+        assertNotNull(results.get("bestModel"));
109
+        assertTrue(results.containsKey("MOVING_AVERAGE"));
110
+        assertTrue(results.containsKey("EXPONENTIAL_SMOOTHING"));
111
+        assertTrue(results.containsKey("SEASONAL_DECOMPOSITION"));
112
+    }
113
+
114
+    @Test
115
+    void testInsufficientData() {
116
+        when(historicalMapper.selectList(any())).thenReturn(List.of());
117
+
118
+        assertThrows(RuntimeException.class, () ->
119
+                service.forecast("JH_001", "MOVING_AVERAGE", 7));
120
+    }
121
+
122
+    @Test
123
+    void testInvalidModelType() {
124
+        assertThrows(RuntimeException.class, () ->
125
+                service.forecast("JH_001", "INVALID_MODEL", 7));
126
+    }
127
+
128
+    @Test
129
+    void testGenerateDemoData() {
130
+        when(historicalMapper.selectCount(any())).thenReturn(0L);
131
+        when(historicalMapper.insert(any())).thenReturn(1);
132
+
133
+        int count = service.generateDemoData("JH_001", 30);
134
+
135
+        assertEquals(30, count);
136
+        verify(historicalMapper, times(30)).insert(any());
137
+    }
138
+
139
+    @Test
140
+    void testForecastDatesSequential() {
141
+        List<WaterDemandForecast> results = service.forecast("JH_001", "MOVING_AVERAGE", 7);
142
+
143
+        LocalDate prevDate = null;
144
+        for (WaterDemandForecast f : results) {
145
+            if (prevDate != null) {
146
+                assertEquals(prevDate.plusDays(1), f.getForecastDate());
147
+            }
148
+            prevDate = f.getForecastDate();
149
+        }
150
+    }
151
+
152
+    @Test
153
+    void testConfidenceIntervalBounds() {
154
+        List<WaterDemandForecast> results = service.forecast("JH_001", "SEASONAL_DECOMPOSITION", 7);
155
+
156
+        for (WaterDemandForecast f : results) {
157
+            assertTrue(f.getUpperBound().doubleValue() >= f.getForecastVolume().doubleValue(),
158
+                    "Upper bound should be >= forecast");
159
+            assertTrue(f.getLowerBound().doubleValue() <= f.getForecastVolume().doubleValue(),
160
+                    "Lower bound should be <= forecast");
161
+        }
162
+    }
163
+}