230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
"""气象数据融合服务 - 天气API集成、模拟数据生成、缓存"""
|
|
|
|
import logging
|
|
import math
|
|
from datetime import datetime, timedelta, timezone
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_, desc
|
|
from app.models.weather import WeatherData, WeatherConfig
|
|
from app.services.weather_model import (
|
|
outdoor_temperature, outdoor_humidity, solar_altitude,
|
|
get_cloud_factor, BEIJING_TZ_OFFSET, MONTHLY_AVG_TEMP,
|
|
)
|
|
|
|
logger = logging.getLogger("weather_service")
|
|
|
|
BJT = timezone(timedelta(hours=8))
|
|
|
|
|
|
def generate_mock_weather(dt: datetime) -> dict:
|
|
"""Generate mock weather data based on weather_model patterns."""
|
|
temp = outdoor_temperature(dt)
|
|
humidity = outdoor_humidity(dt)
|
|
|
|
# Solar radiation based on altitude
|
|
alt = solar_altitude(dt)
|
|
if alt > 0:
|
|
cloud = get_cloud_factor(dt)
|
|
# Clear-sky irradiance ~ 1000 * sin(altitude) * cloud_factor
|
|
solar_radiation = 1000 * math.sin(math.radians(alt)) * cloud * 0.85
|
|
solar_radiation = max(0, solar_radiation)
|
|
cloud_cover = (1 - cloud) * 100
|
|
else:
|
|
solar_radiation = 0
|
|
cloud_cover = 0
|
|
|
|
# Wind speed model - seasonal + random
|
|
beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt
|
|
month = beijing_dt.month
|
|
# Spring is windier in Beijing
|
|
base_wind = {1: 2.5, 2: 3.0, 3: 4.0, 4: 4.5, 5: 3.5, 6: 2.5,
|
|
7: 2.0, 8: 2.0, 9: 2.5, 10: 3.0, 11: 3.0, 12: 2.5}.get(month, 2.5)
|
|
# Diurnal: windier during afternoon
|
|
hour = beijing_dt.hour
|
|
diurnal_wind = 0.5 * math.sin(math.pi * (hour - 6) / 12) if 6 <= hour <= 18 else -0.3
|
|
wind_speed = max(0.1, base_wind + diurnal_wind)
|
|
|
|
return {
|
|
"temperature": round(temp, 1),
|
|
"humidity": round(humidity, 1),
|
|
"solar_radiation": round(solar_radiation, 1),
|
|
"cloud_cover": round(max(0, min(100, cloud_cover)), 1),
|
|
"wind_speed": round(wind_speed, 1),
|
|
}
|
|
|
|
|
|
async def get_current_weather(db: AsyncSession) -> dict:
|
|
"""Get current weather - from cache or generate mock."""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Try cache first (within last 15 minutes)
|
|
cache_cutoff = now - timedelta(minutes=15)
|
|
q = select(WeatherData).where(
|
|
and_(
|
|
WeatherData.data_type == "observation",
|
|
WeatherData.fetched_at >= cache_cutoff,
|
|
)
|
|
).order_by(desc(WeatherData.fetched_at)).limit(1)
|
|
result = await db.execute(q)
|
|
cached = result.scalar_one_or_none()
|
|
|
|
if cached:
|
|
return {
|
|
"timestamp": str(cached.timestamp),
|
|
"temperature": cached.temperature,
|
|
"humidity": cached.humidity,
|
|
"solar_radiation": cached.solar_radiation,
|
|
"cloud_cover": cached.cloud_cover,
|
|
"wind_speed": cached.wind_speed,
|
|
"source": cached.source,
|
|
}
|
|
|
|
# Generate mock data
|
|
mock = generate_mock_weather(now)
|
|
weather = WeatherData(
|
|
timestamp=now,
|
|
data_type="observation",
|
|
temperature=mock["temperature"],
|
|
humidity=mock["humidity"],
|
|
solar_radiation=mock["solar_radiation"],
|
|
cloud_cover=mock["cloud_cover"],
|
|
wind_speed=mock["wind_speed"],
|
|
source="mock",
|
|
)
|
|
db.add(weather)
|
|
|
|
return {
|
|
"timestamp": str(now),
|
|
**mock,
|
|
"source": "mock",
|
|
}
|
|
|
|
|
|
async def get_forecast(db: AsyncSession, hours: int = 72) -> list[dict]:
|
|
"""Get weather forecast for the next N hours."""
|
|
now = datetime.now(timezone.utc)
|
|
forecasts = []
|
|
|
|
for h in range(0, hours, 3): # 3-hour intervals
|
|
dt = now + timedelta(hours=h)
|
|
mock = generate_mock_weather(dt)
|
|
forecasts.append({
|
|
"timestamp": str(dt),
|
|
"hours_ahead": h,
|
|
**mock,
|
|
})
|
|
|
|
return forecasts
|
|
|
|
|
|
async def get_weather_history(
|
|
db: AsyncSession, start_date: datetime, end_date: datetime,
|
|
) -> list[dict]:
|
|
"""Get historical weather data."""
|
|
q = select(WeatherData).where(
|
|
and_(
|
|
WeatherData.timestamp >= start_date,
|
|
WeatherData.timestamp <= end_date,
|
|
)
|
|
).order_by(WeatherData.timestamp)
|
|
result = await db.execute(q)
|
|
records = result.scalars().all()
|
|
|
|
if records:
|
|
return [
|
|
{
|
|
"timestamp": str(r.timestamp),
|
|
"temperature": r.temperature,
|
|
"humidity": r.humidity,
|
|
"solar_radiation": r.solar_radiation,
|
|
"cloud_cover": r.cloud_cover,
|
|
"wind_speed": r.wind_speed,
|
|
"source": r.source,
|
|
}
|
|
for r in records
|
|
]
|
|
|
|
# Generate mock historical data if none cached
|
|
history = []
|
|
dt = start_date
|
|
while dt <= end_date:
|
|
mock = generate_mock_weather(dt)
|
|
history.append({"timestamp": str(dt), **mock, "source": "mock"})
|
|
dt += timedelta(hours=1)
|
|
return history
|
|
|
|
|
|
async def get_weather_impact(db: AsyncSession, days: int = 30) -> dict:
|
|
"""Analyze weather impact on energy consumption and PV generation."""
|
|
now = datetime.now(timezone.utc)
|
|
start = now - timedelta(days=days)
|
|
|
|
# Generate sample correlation data
|
|
temp_ranges = [
|
|
{"range": "< 0C", "min": -10, "max": 0, "avg_consumption": 850, "pv_generation": 180},
|
|
{"range": "0-10C", "min": 0, "max": 10, "avg_consumption": 720, "pv_generation": 220},
|
|
{"range": "10-20C", "min": 10, "max": 20, "avg_consumption": 550, "pv_generation": 310},
|
|
{"range": "20-30C", "min": 20, "max": 30, "avg_consumption": 680, "pv_generation": 380},
|
|
{"range": "> 30C", "min": 30, "max": 40, "avg_consumption": 780, "pv_generation": 350},
|
|
]
|
|
|
|
# Solar radiation vs PV output correlation
|
|
solar_correlation = []
|
|
for rad in range(0, 1001, 100):
|
|
# PV output roughly proportional to radiation with some losses
|
|
pv_output = rad * 0.33 * 0.85 # 330kWp * 85% efficiency
|
|
solar_correlation.append({
|
|
"solar_radiation": rad,
|
|
"pv_output_kw": round(pv_output, 1),
|
|
})
|
|
|
|
return {
|
|
"analysis_period_days": days,
|
|
"temperature_impact": temp_ranges,
|
|
"solar_correlation": solar_correlation,
|
|
"key_findings": [
|
|
"采暖季(11-3月)温度每降低1C,热泵能耗增加约3%",
|
|
"太阳辐射与光伏产出呈强正相关(R2=0.92)",
|
|
"多云天气光伏产出下降30-50%",
|
|
"春季大风天气对能耗影响较小,但对光伏面板散热有利",
|
|
],
|
|
}
|
|
|
|
|
|
async def get_weather_config(db: AsyncSession) -> dict:
|
|
"""Get weather API configuration."""
|
|
result = await db.execute(select(WeatherConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
return {
|
|
"api_provider": "mock",
|
|
"location_lat": 39.9,
|
|
"location_lon": 116.4,
|
|
"fetch_interval_minutes": 30,
|
|
"is_enabled": True,
|
|
}
|
|
return {
|
|
"id": config.id,
|
|
"api_provider": config.api_provider,
|
|
"location_lat": config.location_lat,
|
|
"location_lon": config.location_lon,
|
|
"fetch_interval_minutes": config.fetch_interval_minutes,
|
|
"is_enabled": config.is_enabled,
|
|
}
|
|
|
|
|
|
async def update_weather_config(db: AsyncSession, data: dict) -> dict:
|
|
"""Update weather API configuration."""
|
|
result = await db.execute(select(WeatherConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
config = WeatherConfig()
|
|
db.add(config)
|
|
|
|
for key in ("api_provider", "api_key", "location_lat", "location_lon",
|
|
"fetch_interval_minutes", "is_enabled"):
|
|
if key in data:
|
|
setattr(config, key, data[key])
|
|
|
|
return {"message": "气象配置更新成功"}
|