Merge commit '026c837b919ab4380e8a6e6c052364bbf9bbe8a3' as 'core'
This commit is contained in:
419
core/backend/app/services/energy_strategy.py
Normal file
419
core/backend/app/services/energy_strategy.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""能源策略优化服务 - 峰谷电价策略、谷电蓄热、负荷转移、光伏自消纳"""
|
||||
|
||||
from datetime import datetime, date, timedelta, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.energy_strategy import (
|
||||
TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport,
|
||||
)
|
||||
from app.models.energy import EnergyData, EnergyDailySummary
|
||||
|
||||
|
||||
# Beijing TZ offset
|
||||
BJT = timezone(timedelta(hours=8))
|
||||
|
||||
# Default Beijing industrial TOU pricing
|
||||
DEFAULT_PERIODS = [
|
||||
{"period_type": "sharp_peak", "start_time": "10:00", "end_time": "15:00", "price": 1.3761},
|
||||
{"period_type": "sharp_peak", "start_time": "18:00", "end_time": "21:00", "price": 1.3761},
|
||||
{"period_type": "peak", "start_time": "08:00", "end_time": "10:00", "price": 1.1883},
|
||||
{"period_type": "peak", "start_time": "15:00", "end_time": "18:00", "price": 1.1883},
|
||||
{"period_type": "peak", "start_time": "21:00", "end_time": "23:00", "price": 1.1883},
|
||||
{"period_type": "flat", "start_time": "07:00", "end_time": "08:00", "price": 0.7467},
|
||||
{"period_type": "valley", "start_time": "23:00", "end_time": "07:00", "price": 0.3048},
|
||||
]
|
||||
|
||||
PERIOD_LABELS = {
|
||||
"sharp_peak": "尖峰",
|
||||
"peak": "高峰",
|
||||
"flat": "平段",
|
||||
"valley": "低谷",
|
||||
}
|
||||
|
||||
|
||||
def parse_month_range(month_range: str | None) -> list[int] | None:
|
||||
"""Parse month range string like '1-3,11-12' into list of month ints."""
|
||||
if not month_range:
|
||||
return None
|
||||
months = []
|
||||
for part in month_range.split(","):
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
start, end = part.split("-")
|
||||
months.extend(range(int(start), int(end) + 1))
|
||||
else:
|
||||
months.append(int(part))
|
||||
return months
|
||||
|
||||
|
||||
def get_period_for_hour(periods: list[TouPricingPeriod], hour: int, month: int | None = None) -> TouPricingPeriod | None:
|
||||
"""Determine which TOU period an hour falls into."""
|
||||
hour_str = f"{hour:02d}:00"
|
||||
for p in periods:
|
||||
if month is not None and p.month_range:
|
||||
applicable = parse_month_range(p.month_range)
|
||||
if applicable and month not in applicable:
|
||||
continue
|
||||
start = p.start_time
|
||||
end = p.end_time
|
||||
if start <= end:
|
||||
if start <= hour_str < end:
|
||||
return p
|
||||
else: # crosses midnight
|
||||
if hour_str >= start or hour_str < end:
|
||||
return p
|
||||
return periods[0] if periods else None
|
||||
|
||||
|
||||
async def get_active_tou_pricing(db: AsyncSession, target_date: date | None = None) -> TouPricing | None:
|
||||
"""Get active TOU pricing plan."""
|
||||
q = select(TouPricing).where(TouPricing.is_active == True)
|
||||
if target_date:
|
||||
q = q.where(
|
||||
and_(
|
||||
(TouPricing.effective_date == None) | (TouPricing.effective_date <= target_date),
|
||||
(TouPricing.end_date == None) | (TouPricing.end_date >= target_date),
|
||||
)
|
||||
)
|
||||
q = q.order_by(TouPricing.created_at.desc()).limit(1)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_tou_periods(db: AsyncSession, pricing_id: int) -> list[TouPricingPeriod]:
|
||||
"""Get pricing periods for a TOU plan."""
|
||||
result = await db.execute(
|
||||
select(TouPricingPeriod).where(TouPricingPeriod.pricing_id == pricing_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def calculate_hourly_cost(
|
||||
db: AsyncSession, target_date: date, periods: list[TouPricingPeriod],
|
||||
) -> dict:
|
||||
"""Calculate hourly electricity cost for a specific date."""
|
||||
day_start = datetime(target_date.year, target_date.month, target_date.day, tzinfo=BJT)
|
||||
hourly_data = []
|
||||
total_cost = 0.0
|
||||
total_kwh = 0.0
|
||||
|
||||
for hour in range(24):
|
||||
hour_start = day_start + timedelta(hours=hour)
|
||||
hour_end = hour_start + timedelta(hours=1)
|
||||
|
||||
q = select(func.sum(EnergyData.value)).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= hour_start,
|
||||
EnergyData.timestamp < hour_end,
|
||||
EnergyData.data_type == "energy",
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
hour_kwh = result.scalar() or 0.0
|
||||
|
||||
period = get_period_for_hour(periods, hour, target_date.month)
|
||||
price = period.price_yuan_per_kwh if period else 0.7467
|
||||
period_type = period.period_type if period else "flat"
|
||||
cost = hour_kwh * price
|
||||
|
||||
total_cost += cost
|
||||
total_kwh += hour_kwh
|
||||
|
||||
hourly_data.append({
|
||||
"hour": hour,
|
||||
"consumption_kwh": round(hour_kwh, 2),
|
||||
"price": price,
|
||||
"cost": round(cost, 2),
|
||||
"period_type": period_type,
|
||||
"period_label": PERIOD_LABELS.get(period_type, period_type),
|
||||
})
|
||||
|
||||
return {
|
||||
"date": str(target_date),
|
||||
"hourly": hourly_data,
|
||||
"total_cost": round(total_cost, 2),
|
||||
"total_kwh": round(total_kwh, 2),
|
||||
}
|
||||
|
||||
|
||||
async def calculate_monthly_cost_breakdown(
|
||||
db: AsyncSession, year: int, month: int,
|
||||
) -> dict:
|
||||
"""Calculate monthly cost breakdown by TOU period type."""
|
||||
pricing = await get_active_tou_pricing(db, date(year, month, 1))
|
||||
if not pricing:
|
||||
return _empty_cost_breakdown(year, month)
|
||||
|
||||
periods = await get_tou_periods(db, pricing.id)
|
||||
if not periods:
|
||||
return _empty_cost_breakdown(year, month)
|
||||
|
||||
# Build hour -> period mapping
|
||||
period_stats = {pt: {"kwh": 0.0, "cost": 0.0, "hours": 0}
|
||||
for pt in ["sharp_peak", "peak", "flat", "valley"]}
|
||||
|
||||
for hour in range(24):
|
||||
period = get_period_for_hour(periods, hour, month)
|
||||
if not period:
|
||||
continue
|
||||
pt = period.period_type
|
||||
if pt not in period_stats:
|
||||
period_stats[pt] = {"kwh": 0.0, "cost": 0.0, "hours": 0}
|
||||
period_stats[pt]["hours"] += 1
|
||||
|
||||
# Get daily summaries for the month
|
||||
month_start = date(year, month, 1)
|
||||
if month == 12:
|
||||
month_end = date(year + 1, 1, 1)
|
||||
else:
|
||||
month_end = date(year, month + 1, 1)
|
||||
|
||||
q = select(
|
||||
func.sum(EnergyDailySummary.total_consumption),
|
||||
).where(
|
||||
and_(
|
||||
EnergyDailySummary.date >= datetime(month_start.year, month_start.month, month_start.day),
|
||||
EnergyDailySummary.date < datetime(month_end.year, month_end.month, month_end.day),
|
||||
EnergyDailySummary.energy_type == "electricity",
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
total_monthly_kwh = result.scalar() or 0.0
|
||||
|
||||
# Distribute by hour proportion
|
||||
total_hours = sum(ps["hours"] for ps in period_stats.values())
|
||||
for pt, ps in period_stats.items():
|
||||
proportion = ps["hours"] / total_hours if total_hours > 0 else 0
|
||||
ps["kwh"] = total_monthly_kwh * proportion
|
||||
period_obj = next((p for p in periods if p.period_type == pt), None)
|
||||
price = period_obj.price_yuan_per_kwh if period_obj else 0
|
||||
ps["cost"] = ps["kwh"] * price
|
||||
|
||||
total_cost = sum(ps["cost"] for ps in period_stats.values())
|
||||
|
||||
breakdown = []
|
||||
for pt, ps in period_stats.items():
|
||||
if ps["hours"] == 0:
|
||||
continue
|
||||
breakdown.append({
|
||||
"period_type": pt,
|
||||
"period_label": PERIOD_LABELS.get(pt, pt),
|
||||
"consumption_kwh": round(ps["kwh"], 2),
|
||||
"cost_yuan": round(ps["cost"], 2),
|
||||
"hours_per_day": ps["hours"],
|
||||
"proportion": round(ps["kwh"] / total_monthly_kwh * 100, 1) if total_monthly_kwh > 0 else 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"year_month": f"{year}-{month:02d}",
|
||||
"total_consumption_kwh": round(total_monthly_kwh, 2),
|
||||
"total_cost_yuan": round(total_cost, 2),
|
||||
"breakdown": breakdown,
|
||||
"pricing_name": pricing.name,
|
||||
}
|
||||
|
||||
|
||||
def _empty_cost_breakdown(year: int, month: int) -> dict:
|
||||
return {
|
||||
"year_month": f"{year}-{month:02d}",
|
||||
"total_consumption_kwh": 0,
|
||||
"total_cost_yuan": 0,
|
||||
"breakdown": [],
|
||||
"pricing_name": "未配置",
|
||||
}
|
||||
|
||||
|
||||
def calculate_heat_storage_savings(
|
||||
daily_kwh: float, periods: list[TouPricingPeriod], shift_ratio: float = 0.3,
|
||||
) -> dict:
|
||||
"""Calculate savings from valley-electricity heat storage strategy (谷电蓄热).
|
||||
|
||||
Assumes shift_ratio of heat pump load can be moved from peak/sharp_peak to valley hours.
|
||||
"""
|
||||
peak_prices = []
|
||||
valley_price = 0.3048
|
||||
|
||||
for p in periods:
|
||||
if p.period_type in ("sharp_peak", "peak"):
|
||||
peak_prices.append(p.price_yuan_per_kwh)
|
||||
elif p.period_type == "valley":
|
||||
valley_price = p.price_yuan_per_kwh
|
||||
|
||||
avg_peak_price = sum(peak_prices) / len(peak_prices) if peak_prices else 1.2
|
||||
shifted_kwh = daily_kwh * shift_ratio
|
||||
savings_per_day = shifted_kwh * (avg_peak_price - valley_price)
|
||||
|
||||
return {
|
||||
"shifted_kwh": round(shifted_kwh, 2),
|
||||
"avg_peak_price": round(avg_peak_price, 4),
|
||||
"valley_price": round(valley_price, 4),
|
||||
"savings_per_day": round(savings_per_day, 2),
|
||||
"savings_per_month": round(savings_per_day * 30, 2),
|
||||
"savings_per_year": round(savings_per_day * 365, 2),
|
||||
"strategy": "谷电蓄热",
|
||||
"description": f"将{shift_ratio*100:.0f}%的热泵负荷从尖峰/高峰时段转移至低谷时段(23:00-7:00)预热水箱",
|
||||
}
|
||||
|
||||
|
||||
def calculate_pv_priority_savings(
|
||||
pv_daily_kwh: float, grid_price: float = 0.7467, feed_in_price: float = 0.3548,
|
||||
) -> dict:
|
||||
"""Calculate savings from PV self-consumption priority strategy."""
|
||||
self_consume_value = pv_daily_kwh * grid_price
|
||||
feed_in_value = pv_daily_kwh * feed_in_price
|
||||
savings_per_day = self_consume_value - feed_in_value
|
||||
|
||||
return {
|
||||
"pv_daily_kwh": round(pv_daily_kwh, 2),
|
||||
"self_consume_value": round(self_consume_value, 2),
|
||||
"feed_in_value": round(feed_in_value, 2),
|
||||
"savings_per_day": round(savings_per_day, 2),
|
||||
"savings_per_month": round(savings_per_day * 30, 2),
|
||||
"strategy": "光伏自消纳优先",
|
||||
"description": "优先使用光伏发电供给园区负荷,减少向电网购电",
|
||||
}
|
||||
|
||||
|
||||
def simulate_strategy_impact(
|
||||
daily_consumption_kwh: float,
|
||||
pv_daily_kwh: float,
|
||||
periods: list[TouPricingPeriod],
|
||||
strategies: list[str],
|
||||
) -> dict:
|
||||
"""Simulate impact of enabling various strategies."""
|
||||
baseline_cost = 0.0
|
||||
optimized_cost = 0.0
|
||||
|
||||
# Calculate baseline cost (proportional by hours)
|
||||
period_hours = {}
|
||||
for p in periods:
|
||||
start_h = int(p.start_time.split(":")[0])
|
||||
end_h = int(p.end_time.split(":")[0])
|
||||
if start_h < end_h:
|
||||
hours = end_h - start_h
|
||||
else:
|
||||
hours = (24 - start_h) + end_h
|
||||
period_hours[p.period_type] = period_hours.get(p.period_type, 0) + hours
|
||||
|
||||
total_hours = sum(period_hours.values()) or 24
|
||||
for p in periods:
|
||||
start_h = int(p.start_time.split(":")[0])
|
||||
end_h = int(p.end_time.split(":")[0])
|
||||
hours = end_h - start_h if start_h < end_h else (24 - start_h) + end_h
|
||||
proportion = hours / total_hours
|
||||
kwh = daily_consumption_kwh * proportion
|
||||
baseline_cost += kwh * p.price_yuan_per_kwh
|
||||
|
||||
optimized_cost = baseline_cost
|
||||
savings_details = []
|
||||
|
||||
if "heat_storage" in strategies:
|
||||
hs = calculate_heat_storage_savings(daily_consumption_kwh * 0.4, periods, 0.3)
|
||||
optimized_cost -= hs["savings_per_day"]
|
||||
savings_details.append(hs)
|
||||
|
||||
if "pv_priority" in strategies:
|
||||
pv = calculate_pv_priority_savings(pv_daily_kwh)
|
||||
optimized_cost -= pv["savings_per_day"]
|
||||
savings_details.append(pv)
|
||||
|
||||
if "load_shift" in strategies:
|
||||
# Shift 15% of peak load to flat/valley
|
||||
valley_p = next((p for p in periods if p.period_type == "valley"), None)
|
||||
peak_p = next((p for p in periods if p.period_type == "sharp_peak"), None)
|
||||
if valley_p and peak_p:
|
||||
shift_kwh = daily_consumption_kwh * 0.15
|
||||
saved = shift_kwh * (peak_p.price_yuan_per_kwh - valley_p.price_yuan_per_kwh)
|
||||
optimized_cost -= saved
|
||||
savings_details.append({
|
||||
"strategy": "负荷转移",
|
||||
"savings_per_day": round(saved, 2),
|
||||
"savings_per_month": round(saved * 30, 2),
|
||||
"description": "将15%的尖峰时段负荷转移至低谷时段",
|
||||
})
|
||||
|
||||
return {
|
||||
"baseline_cost_per_day": round(baseline_cost, 2),
|
||||
"optimized_cost_per_day": round(max(0, optimized_cost), 2),
|
||||
"total_savings_per_day": round(baseline_cost - max(0, optimized_cost), 2),
|
||||
"total_savings_per_month": round((baseline_cost - max(0, optimized_cost)) * 30, 2),
|
||||
"total_savings_per_year": round((baseline_cost - max(0, optimized_cost)) * 365, 2),
|
||||
"savings_percentage": round((1 - max(0, optimized_cost) / baseline_cost) * 100, 1) if baseline_cost > 0 else 0,
|
||||
"details": savings_details,
|
||||
}
|
||||
|
||||
|
||||
async def get_recommendations(db: AsyncSession) -> list[dict]:
|
||||
"""Generate current strategy recommendations based on data."""
|
||||
recommendations = []
|
||||
|
||||
# Always recommend valley heat storage for heating season
|
||||
now = datetime.now(BJT)
|
||||
month = now.month
|
||||
if month in (11, 12, 1, 2, 3):
|
||||
recommendations.append({
|
||||
"type": "heat_storage",
|
||||
"title": "谷电蓄热策略",
|
||||
"description": "当前为采暖季,建议在低谷时段(23:00-7:00)预热水箱,减少尖峰时段热泵运行",
|
||||
"priority": "high",
|
||||
"estimated_savings": "每月可节约约3000-5000元",
|
||||
})
|
||||
|
||||
# PV priority during daytime
|
||||
recommendations.append({
|
||||
"type": "pv_priority",
|
||||
"title": "光伏自消纳优先",
|
||||
"description": "优先使用屋顶光伏发电满足园区负荷,减少购电成本",
|
||||
"priority": "medium",
|
||||
"estimated_savings": "每月可节约约1500-2500元",
|
||||
})
|
||||
|
||||
# Load shifting
|
||||
hour = now.hour
|
||||
if 10 <= hour <= 15 or 18 <= hour <= 21:
|
||||
recommendations.append({
|
||||
"type": "load_shift",
|
||||
"title": "当前处于尖峰时段",
|
||||
"description": "建议减少非必要大功率设备运行,可延迟至低谷时段执行",
|
||||
"priority": "high",
|
||||
"estimated_savings": "尖峰电价1.3761元/kWh,低谷电价0.3048元/kWh",
|
||||
})
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
async def get_savings_report(db: AsyncSession, year: int) -> dict:
|
||||
"""Generate yearly savings report."""
|
||||
reports = []
|
||||
total_savings = 0.0
|
||||
total_baseline = 0.0
|
||||
total_optimized = 0.0
|
||||
|
||||
result = await db.execute(
|
||||
select(MonthlyCostReport).where(
|
||||
MonthlyCostReport.year_month.like(f"{year}-%")
|
||||
).order_by(MonthlyCostReport.year_month)
|
||||
)
|
||||
monthly_reports = result.scalars().all()
|
||||
|
||||
for r in monthly_reports:
|
||||
reports.append({
|
||||
"year_month": r.year_month,
|
||||
"total_consumption_kwh": r.total_consumption_kwh,
|
||||
"total_cost_yuan": r.total_cost_yuan,
|
||||
"baseline_cost": r.baseline_cost,
|
||||
"optimized_cost": r.optimized_cost,
|
||||
"savings_yuan": r.savings_yuan,
|
||||
})
|
||||
total_savings += r.savings_yuan
|
||||
total_baseline += r.baseline_cost
|
||||
total_optimized += r.optimized_cost
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"monthly_reports": reports,
|
||||
"total_savings_yuan": round(total_savings, 2),
|
||||
"total_baseline_cost": round(total_baseline, 2),
|
||||
"total_optimized_cost": round(total_optimized, 2),
|
||||
"savings_percentage": round(total_savings / total_baseline * 100, 1) if total_baseline > 0 else 0,
|
||||
}
|
||||
Reference in New Issue
Block a user