Files
tp-ems/backend/app/services/energy_strategy.py

420 lines
15 KiB
Python
Raw Normal View History

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