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