283 lines
10 KiB
Python
283 lines
10 KiB
Python
import time
|
|
import uuid
|
|
import threading
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
from typing import List, Dict, Any, Optional
|
|
from flask import current_app
|
|
|
|
from app.models.genetic_algorithm import (
|
|
ReservationOptimizer,
|
|
GeneticAlgorithm,
|
|
ReservationRequest,
|
|
Classroom
|
|
)
|
|
from app.models.subject import Commission
|
|
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
|
|
|
|
|
|
class OptimizerJobStatus(str, Enum):
|
|
PENDING = "PENDING"
|
|
RUNNING = "RUNNING"
|
|
COMPLETED = "COMPLETED"
|
|
FAILED = "FAILED"
|
|
CANCELLED = "CANCELLED"
|
|
|
|
|
|
class OptimizerJob:
|
|
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
|
|
|
|
def __init__(self, job_id: str, params: Dict[str, Any]):
|
|
self.job_id = job_id
|
|
self.status = OptimizerJobStatus.PENDING
|
|
self.progress = 0
|
|
self.generation = 0
|
|
self.total_generations = params.get("generations", 80)
|
|
self.fitness_score = 0.0
|
|
self.total_requests = 0
|
|
self.assigned_reservations = 0
|
|
self.reservations: List[Dict[str, Any]] = []
|
|
self.conflicts: List[Dict[str, Any]] = []
|
|
self.error: Optional[str] = None
|
|
self.created_at = datetime.utcnow()
|
|
self.started_at: Optional[datetime] = None
|
|
self.completed_at: Optional[datetime] = None
|
|
self.execution_time_ms: Optional[float] = None
|
|
self.params = params
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"job_id": self.job_id,
|
|
"status": self.status.value,
|
|
"progress": self.progress,
|
|
"generation": self.generation,
|
|
"total_generations": self.total_generations,
|
|
"fitness_score": round(self.fitness_score, 2),
|
|
"total_requests": self.total_requests,
|
|
"assigned_reservations": self.assigned_reservations,
|
|
"reservations": self.reservations,
|
|
"conflict_count": len(self.conflicts),
|
|
"conflicts": self.conflicts,
|
|
"error": self.error,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
"execution_time_ms": self.execution_time_ms,
|
|
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
|
|
}
|
|
|
|
|
|
class OptimizerService:
|
|
"""
|
|
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
|
|
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
|
|
"""
|
|
|
|
_jobs: Dict[str, OptimizerJob] = {}
|
|
_lock = threading.Lock()
|
|
|
|
@classmethod
|
|
def submit_job(
|
|
cls,
|
|
commission_ids: List[int],
|
|
admin_user_id: int,
|
|
start_date: Optional[datetime] = None,
|
|
end_date: Optional[datetime] = None,
|
|
generations: int = 80,
|
|
population_size: int = 40,
|
|
workers: int = 4,
|
|
app = None
|
|
) -> OptimizerJob:
|
|
"""
|
|
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
|
|
"""
|
|
job_id = str(uuid.uuid4())
|
|
params = {
|
|
"commission_ids": commission_ids,
|
|
"admin_user_id": admin_user_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"generations": generations,
|
|
"population_size": population_size,
|
|
"workers": workers
|
|
}
|
|
|
|
job = OptimizerJob(job_id, params)
|
|
with cls._lock:
|
|
cls._jobs[job_id] = job
|
|
|
|
# Si no se pasó app explícitamente, intentar obtener current_app
|
|
if app is None:
|
|
try:
|
|
app = current_app._get_current_object()
|
|
except Exception:
|
|
app = None
|
|
|
|
thread = threading.Thread(
|
|
target=cls._run_job_worker,
|
|
args=(job_id, app),
|
|
daemon=True,
|
|
name=f"optimizer-worker-{job_id[:8]}"
|
|
)
|
|
thread.start()
|
|
return job
|
|
|
|
@classmethod
|
|
def _run_job_worker(cls, job_id: str, app):
|
|
"""Worker en segundo plano para ejecutar el algoritmo genético."""
|
|
job = cls.get_job(job_id)
|
|
if not job:
|
|
return
|
|
|
|
def _execute():
|
|
t_start = time.perf_counter()
|
|
with cls._lock:
|
|
job.status = OptimizerJobStatus.RUNNING
|
|
job.started_at = datetime.utcnow()
|
|
|
|
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
|
|
with cls._lock:
|
|
job.generation = gen
|
|
job.total_generations = total_gen
|
|
job.progress = pct
|
|
job.fitness_score = best_fit
|
|
|
|
optimizer = ReservationOptimizer(
|
|
population_size=job.params.get("population_size", 40),
|
|
generations=job.params.get("generations", 80),
|
|
workers=job.params.get("workers", 4)
|
|
)
|
|
|
|
result = optimizer.optimize_schedule(
|
|
commission_ids=job.params["commission_ids"],
|
|
admin_user_id=job.params["admin_user_id"],
|
|
start_date=job.params.get("start_date"),
|
|
end_date=job.params.get("end_date"),
|
|
progress_callback=progress_callback
|
|
)
|
|
|
|
t_end = time.perf_counter()
|
|
exec_time_ms = round((t_end - t_start) * 1000, 2)
|
|
|
|
with cls._lock:
|
|
job.status = OptimizerJobStatus.COMPLETED
|
|
job.progress = 100
|
|
job.fitness_score = result.get("fitness_score", 0.0)
|
|
job.total_requests = result.get("total_requests", 0)
|
|
job.assigned_reservations = result.get("assigned_reservations", 0)
|
|
job.reservations = result.get("reservations", [])
|
|
job.conflicts = result.get("conflicts", [])
|
|
job.completed_at = datetime.utcnow()
|
|
job.execution_time_ms = exec_time_ms
|
|
|
|
try:
|
|
if app:
|
|
with app.app_context():
|
|
_execute()
|
|
else:
|
|
_execute()
|
|
except Exception as ex:
|
|
with cls._lock:
|
|
job.status = OptimizerJobStatus.FAILED
|
|
job.error = str(ex)
|
|
job.completed_at = datetime.utcnow()
|
|
|
|
@classmethod
|
|
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
|
|
"""Obtiene un trabajo por su identificador único."""
|
|
with cls._lock:
|
|
return cls._jobs.get(job_id)
|
|
|
|
@classmethod
|
|
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
|
|
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
|
|
with cls._lock:
|
|
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
|
|
return [j.to_dict() for j in sorted_jobs[:limit]]
|
|
|
|
@classmethod
|
|
def sync_with_spring_boot(
|
|
cls,
|
|
payload: SpringBootSyncPayloadDTO,
|
|
admin_user_id: int = 1
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
|
|
Garantiza respuesta conforme al contrato JSON del microservicio.
|
|
"""
|
|
t_start = time.perf_counter()
|
|
|
|
# 1. Resolver aulas disponibles
|
|
if payload.classrooms and len(payload.classrooms) > 0:
|
|
classrooms = []
|
|
for c_dto in payload.classrooms:
|
|
room = Classroom(
|
|
room_number=c_dto.roomNumber,
|
|
capacity=c_dto.capacity,
|
|
is_virtual=c_dto.isVirtual,
|
|
is_active=True
|
|
)
|
|
room.id = c_dto.classroomId
|
|
classrooms.append(room)
|
|
else:
|
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
|
|
|
# 2. Generar ReservationRequests desde el DTO de Spring Boot
|
|
requests: List[ReservationRequest] = []
|
|
for c in payload.commissions:
|
|
req = ReservationRequest(
|
|
commission_id=c.commissionId,
|
|
expected_attendees=c.expectedAttendees,
|
|
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
|
|
preferred_start_time=c.preferredStart,
|
|
preferred_end_time=c.preferredEnd,
|
|
priority=1,
|
|
flexibility_hours=2
|
|
)
|
|
requests.append(req)
|
|
|
|
# 3. Opciones de configuración de algoritmo
|
|
options = payload.options or {}
|
|
generations = options.get("generations", 60)
|
|
pop_size = options.get("populationSize", 30)
|
|
workers = options.get("workers", 4)
|
|
|
|
ga = GeneticAlgorithm(
|
|
population_size=pop_size,
|
|
generations=generations,
|
|
workers=workers
|
|
)
|
|
|
|
start_date = min(c.preferredStart for c in payload.commissions)
|
|
end_date = max(c.preferredEnd for c in payload.commissions)
|
|
|
|
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
|
|
|
t_end = time.perf_counter()
|
|
exec_ms = round((t_end - t_start) * 1000, 2)
|
|
|
|
scheduled_assignments = []
|
|
for gene in best_individual.genes:
|
|
scheduled_assignments.append({
|
|
"commissionId": gene.commission_id,
|
|
"classroomId": gene.classroom_id,
|
|
"startTime": gene.start_time.isoformat() if gene.start_time else None,
|
|
"endTime": gene.end_time.isoformat() if gene.end_time else None,
|
|
"purpose": gene.purpose,
|
|
"expectedAttendees": gene.expected_attendees,
|
|
"status": "SCHEDULED"
|
|
})
|
|
|
|
return {
|
|
"requestId": payload.requestId,
|
|
"status": "SUCCESS",
|
|
"academicTerm": payload.academicTerm,
|
|
"campusCode": payload.campusCode,
|
|
"totalCommissions": len(payload.commissions),
|
|
"scheduledAssignments": scheduled_assignments,
|
|
"conflictCount": len(best_individual.conflicts),
|
|
"fitnessScore": round(best_individual.fitness, 2),
|
|
"executionTimeMs": exec_ms,
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|
}
|