420 lines
15 KiB
Python
420 lines
15 KiB
Python
|
|
"""能源策略优化服务 - 峰谷电价策略、谷电蓄热、负荷转移、光伏自消纳"""
|
||
|
|
|
||
|
|
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,
|
||
|
|
}
|