Files
sac4cps-backend/microservices/demand-response-service/main.py
rafaeldpsilva 7547e6b229 demand response
2025-12-10 15:26:34 +00:00

491 lines
17 KiB
Python

"""
Demand Response Microservice
Handles grid interaction, demand response events, and load management.
Port: 8003
"""
import asyncio
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
from typing import List, Optional
from models import (
DemandResponseInvitation, InvitationResponse, EventRequest, EventStatus,
LoadReductionRequest, FlexibilityResponse, HealthResponse
)
from database import connect_to_mongo, close_mongo_connection, get_database, connect_to_redis, get_redis
from demand_response_service import DemandResponseService
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Background task functions
async def event_scheduler_task():
"""Background task for checking and executing scheduled events"""
logger.info("Starting event scheduler task")
while True:
try:
db = await get_database()
redis = await get_redis()
service = DemandResponseService(db, redis)
# Check for events that need to be executed
await service.check_scheduled_events()
# Sleep for 60 seconds between checks
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("Event scheduler task cancelled")
raise
except Exception as e:
logger.error(f"Error in event scheduler task: {e}")
await asyncio.sleep(120) # Wait longer on error
async def auto_response_task():
"""Background task for automatic demand response"""
logger.info("Starting auto-response task")
while True:
try:
db = await get_database()
redis = await get_redis()
service = DemandResponseService(db, redis)
# Check for auto-response opportunities
await service.process_auto_responses()
# Sleep for 30 seconds between checks
await asyncio.sleep(30)
except asyncio.CancelledError:
logger.info("Auto-response task cancelled")
raise
except Exception as e:
logger.error(f"Error in auto-response task: {e}")
await asyncio.sleep(90) # Wait longer on error
async def energy_data_subscriber_task():
"""Subscribe to energy_data Redis channel for device power updates"""
logger.info("Starting energy data subscriber task")
try:
redis = await get_redis()
db = await get_database()
service = DemandResponseService(db, redis)
pubsub = redis.pubsub()
await pubsub.subscribe("energy_data")
logger.info("Subscribed to energy_data channel")
while True:
try:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message and message.get('type') == 'message':
import json
data = json.loads(message['data'])
# Format: {"sensorId": "sensor_1", "timestamp": 123, "value": 3.5, "unit": "kWh"}
sensor_id = data.get("sensorId")
power_kw = data.get("value", 0.0)
# Update service cache
service.update_device_power_cache(sensor_id, power_kw)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in energy_data message: {e}")
except Exception as e:
logger.error(f"Error processing energy data message: {e}")
await asyncio.sleep(5)
except asyncio.CancelledError:
logger.info("Energy data subscriber task cancelled")
raise
except Exception as e:
logger.error(f"Energy data subscriber task failed: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager"""
logger.info("Demand Response Service starting up...")
await connect_to_mongo()
await connect_to_redis()
# Create global service instance for shutdown cleanup
db = await get_database()
redis = await get_redis()
app.state.dr_service = DemandResponseService(db, redis)
# Start background tasks
asyncio.create_task(event_scheduler_task())
asyncio.create_task(auto_response_task())
asyncio.create_task(energy_data_subscriber_task())
logger.info("Demand Response Service startup complete")
yield
logger.info("Demand Response Service shutting down...")
# Cancel all active DR events gracefully
if hasattr(app.state, 'dr_service'):
active_event_ids = list(app.state.dr_service.active_events.keys())
if active_event_ids:
logger.info(f"Cancelling {len(active_event_ids)} active events...")
for event_id in active_event_ids:
try:
await app.state.dr_service.cancel_event(event_id)
except Exception as e:
logger.error(f"Error cancelling event {event_id}: {e}")
await close_mongo_connection()
logger.info("Demand Response Service shutdown complete")
app = FastAPI(
title="Demand Response Service",
description="Grid interaction and demand response event management microservice",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Dependencies
async def get_db():
return await get_database()
async def get_dr_service(db=Depends(get_db)):
redis = await get_redis()
return DemandResponseService(db, redis)
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
try:
db = await get_database()
await db.command("ping")
redis = await get_redis()
await redis.ping()
return HealthResponse(
service="demand-response-service",
status="healthy",
timestamp=datetime.utcnow(),
version="1.0.0"
)
except Exception as e:
logger.error(f"Health check failed: {e}")
raise HTTPException(status_code=503, detail="Service Unavailable")
@app.post("/invitations/send")
async def send_invitation(
event_request: EventRequest,
service: DemandResponseService = Depends(get_dr_service)
):
"""Send demand response invitation to specified IoT devices"""
try:
result = await service.send_invitation(
event_time=event_request.event_time,
load_kwh=event_request.load_kwh,
load_percentage=event_request.load_percentage,
iots=event_request.iots,
duration_minutes=event_request.duration_minutes
)
return {
"message": "Demand response invitation sent successfully",
"event_id": result["event_id"],
"event_time": event_request.event_time.isoformat(),
"participants": len(event_request.iots),
"load_kwh": event_request.load_kwh,
"load_percentage": event_request.load_percentage
}
except Exception as e:
logger.error(f"Error sending invitation: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/invitations/unanswered")
async def get_unanswered_invitations(
service: DemandResponseService = Depends(get_dr_service)
):
"""Get all unanswered demand response invitations"""
try:
invitations = await service.get_unanswered_invitations()
return {
"invitations": invitations,
"count": len(invitations)
}
except Exception as e:
logger.error(f"Error getting unanswered invitations: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/invitations/answered")
async def get_answered_invitations(
hours: int = 24,
service: DemandResponseService = Depends(get_dr_service)
):
"""Get answered demand response invitations"""
try:
invitations = await service.get_answered_invitations(hours)
return {
"invitations": invitations,
"count": len(invitations),
"period_hours": hours
}
except Exception as e:
logger.error(f"Error getting answered invitations: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/invitations/answer")
async def answer_invitation(
response: InvitationResponse,
service: DemandResponseService = Depends(get_dr_service)
):
"""Answer a demand response invitation"""
try:
result = await service.answer_invitation(
event_id=response.event_id,
iot_id=response.iot_id,
response=response.response,
committed_reduction_kw=response.committed_reduction_kw
)
return {
"message": "Invitation response recorded successfully",
"event_id": response.event_id,
"iot_id": response.iot_id,
"response": response.response,
"committed_reduction": response.committed_reduction_kw
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error answering invitation: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/invitations/{event_id}")
async def get_invitation(
event_id: str,
service: DemandResponseService = Depends(get_dr_service)
):
"""Get details of a specific demand response invitation"""
try:
invitation = await service.get_invitation(event_id)
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
return invitation
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting invitation {event_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/events/schedule")
async def schedule_event(
event_request: EventRequest,
service: DemandResponseService = Depends(get_dr_service)
):
"""Schedule a demand response event"""
try:
result = await service.schedule_event(
event_time=event_request.event_time,
iots=event_request.iots,
load_reduction_kw=event_request.load_kwh * 1000, # Convert to kW
duration_minutes=event_request.duration_minutes
)
return {
"message": "Demand response event scheduled successfully",
"event_id": result["event_id"],
"scheduled_time": event_request.event_time.isoformat(),
"participants": len(event_request.iots)
}
except Exception as e:
logger.error(f"Error scheduling event: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/events/active")
async def get_active_events(
service: DemandResponseService = Depends(get_dr_service)
):
"""Get currently active demand response events"""
try:
events = await service.get_active_events()
return {
"events": events,
"count": len(events)
}
except Exception as e:
logger.error(f"Error getting active events: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/flexibility/current")
async def get_current_flexibility(
service: DemandResponseService = Depends(get_dr_service)
):
"""Get current system flexibility capacity"""
try:
flexibility = await service.get_current_flexibility()
return {
"timestamp": datetime.utcnow().isoformat(),
"flexibility": flexibility
}
except Exception as e:
logger.error(f"Error getting current flexibility: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/flexibility/forecast")
async def get_flexibility_forecast(
hours: int = 24,
service: DemandResponseService = Depends(get_dr_service)
):
"""Get forecasted flexibility for the next specified hours"""
try:
forecast = await service.get_flexibility_forecast(hours)
return {
"forecast_hours": hours,
"flexibility_forecast": forecast,
"generated_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error getting flexibility forecast: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/load-reduction/execute")
async def execute_load_reduction(
request: LoadReductionRequest,
service: DemandResponseService = Depends(get_dr_service)
):
"""Execute immediate load reduction"""
try:
result = await service.execute_load_reduction(
target_reduction_kw=request.target_reduction_kw,
duration_minutes=request.duration_minutes,
priority_iots=request.priority_iots
)
return {
"message": "Load reduction executed successfully",
"target_reduction_kw": request.target_reduction_kw,
"actual_reduction_kw": result.get("actual_reduction_kw"),
"participating_devices": result.get("participating_devices", []),
"execution_time": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error executing load reduction: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/auto-response/config")
async def get_auto_response_config(
service: DemandResponseService = Depends(get_dr_service)
):
"""Get auto-response configuration"""
try:
config = await service.get_auto_response_config()
return {"auto_response_config": config}
except Exception as e:
logger.error(f"Error getting auto-response config: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/auto-response/config")
async def set_auto_response_config(
enabled: bool,
max_reduction_percentage: float = 20.0,
response_delay_seconds: int = 300,
service: DemandResponseService = Depends(get_dr_service)
):
"""Set auto-response configuration"""
try:
await service.set_auto_response_config(
enabled=enabled,
max_reduction_percentage=max_reduction_percentage,
response_delay_seconds=response_delay_seconds
)
return {
"message": "Auto-response configuration updated successfully",
"enabled": enabled,
"max_reduction_percentage": max_reduction_percentage,
"response_delay_seconds": response_delay_seconds
}
except Exception as e:
logger.error(f"Error setting auto-response config: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/analytics/performance")
async def get_performance_analytics(
days: int = 30,
service: DemandResponseService = Depends(get_dr_service)
):
"""Get demand response performance analytics"""
try:
analytics = await service.get_performance_analytics(days)
return {
"period_days": days,
"analytics": analytics,
"generated_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error getting performance analytics: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
async def event_scheduler_task():
"""Background task for scheduling and executing demand response events"""
logger.info("Starting event scheduler task")
while True:
try:
db = await get_database()
redis = await get_redis()
service = DemandResponseService(db, redis)
# Check for events that need to be executed
await service.check_scheduled_events()
# Sleep for 60 seconds between checks
await asyncio.sleep(60)
except Exception as e:
logger.error(f"Error in event scheduler task: {e}")
await asyncio.sleep(120) # Wait longer on error
async def auto_response_task():
"""Background task for automatic demand response"""
logger.info("Starting auto-response task")
while True:
try:
db = await get_database()
redis = await get_redis()
service = DemandResponseService(db, redis)
# Check for auto-response opportunities
await service.process_auto_responses()
# Sleep for 30 seconds between checks
await asyncio.sleep(30)
except Exception as e:
logger.error(f"Error in auto-response task: {e}")
await asyncio.sleep(90)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8003)