Files
admin-edu-space/backend/app/models/genetic_algorithm.py
T

473 lines
20 KiB
Python

import random
import copy
from datetime import datetime, timedelta
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
from app import db
from app.models.classroom import Classroom
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission
@dataclass
class ReservationRequest:
"""Represents a reservation request from the teaching committee"""
commission_id: int
expected_attendees: int
purpose: str
preferred_start_time: datetime
preferred_end_time: datetime
priority: int = 1 # 1=highest, 5=lowest
flexibility_hours: int = 2 # How flexible the start time can be
subject_requirements: Dict = None # Special requirements for the subject
def __post_init__(self):
if self.subject_requirements is None:
self.subject_requirements = {}
@dataclass
class Gene:
"""Represents a single reservation assignment"""
commission_id: int
classroom_id: int
start_time: datetime
end_time: datetime
expected_attendees: int
purpose: str
class Individual:
"""Represents a complete reservation schedule (chromosome)"""
def __init__(self, genes: List[Gene]):
self.genes = genes
self.fitness = 0.0
self.conflicts = []
def calculate_fitness(self, classrooms: Dict[int, Classroom], reservation_requests: Dict[int, ReservationRequest]) -> float:
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
score = 0.0
self.conflicts = []
# Pre-group genes by classroom to calculate conflicts in O(K log K)
by_classroom = {}
for g in self.genes:
by_classroom.setdefault(g.classroom_id, []).append(g)
conflict_count_by_id = {}
for cid, room_genes in by_classroom.items():
if len(room_genes) <= 1:
continue
# Sort chronologically
room_genes.sort(key=lambda x: x.start_time)
for i in range(len(room_genes)):
g_i = room_genes[i]
for j in range(i + 1, len(room_genes)):
g_j = room_genes[j]
if g_i.end_time > g_j.start_time:
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
else:
break # Gene j and subsequent ones start later than gene i ends
for gene in self.genes:
classroom = classrooms.get(gene.classroom_id)
res_req = reservation_requests.get(gene.commission_id)
if not classroom or not res_req:
continue
# Factor 1: Capacity efficiency (40% weight)
capacity_score = self._calculate_capacity_score(classroom, gene.expected_attendees)
score += capacity_score * 0.4
# Factor 2: Time preference satisfaction (30% weight)
time_score = self._calculate_time_score(gene, res_req)
score += time_score * 0.3
# Factor 3: Conflict penalty (20% weight)
c_count = conflict_count_by_id.get(id(gene), 0)
if c_count > 0:
conflict_score = -c_count * 0.5
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
else:
conflict_score = 1.0
score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, res_req.subject_requirements)
score += resource_score * 0.1
self.fitness = score
return score
def _calculate_capacity_score(self, classroom: Classroom, expected_attendees: int) -> float:
"""Calculate how well the classroom capacity matches the expected attendees"""
if classroom.capacity < expected_attendees:
return 0.0 # Overfilled classroom
# Calculate efficiency: closer capacity match = higher score
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
if waste_ratio <= 0.1: # Less than 10% waste
return 1.0
elif waste_ratio <= 0.3: # Less than 30% waste
return 0.8
elif waste_ratio <= 0.5: # Less than 50% waste
return 0.6
else:
return 0.4
def _calculate_time_score(self, gene: Gene, request: ReservationRequest) -> float:
"""Calculate how well the assigned time matches preferences"""
# Calculate time difference from preferred time
time_diff = abs((gene.start_time - request.preferred_start_time).total_seconds() / 3600)
if time_diff <= 0.5: # Within 30 minutes
return 1.0
elif time_diff <= request.flexibility_hours: # Within flexible window
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
else:
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
conflicts = 0
for other in all_genes:
if gene != other and gene.classroom_id == other.classroom_id:
if self._times_overlap(gene.start_time, gene.end_time, other.start_time, other.end_time):
conflicts += 1
if conflicts > 0:
return -conflicts * 0.5 # Penalty for each conflict
return 1.0
def _calculate_resource_score(self, classroom: Classroom, requirements: Dict) -> float:
"""Check if classroom meets subject requirements"""
if not requirements:
return 1.0
return 0.8
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
"""Check if two time periods overlap"""
return start1 < end2 and end1 > start2
class GeneticAlgorithm:
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
def __init__(self, population_size: int = 50, generations: int = 100,
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
workers: int = 4):
self.population_size = population_size
self.generations = generations
self.mutation_rate = mutation_rate
self.crossover_rate = crossover_rate
self.workers = max(1, workers)
def optimize_reservations(self, requests: List[ReservationRequest],
available_classrooms: List[Classroom],
start_date: datetime, end_date: datetime,
progress_callback=None) -> Individual:
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
import concurrent.futures
classrooms_dict = {c.id: c for c in available_classrooms}
requests_dict = {r.commission_id: r for r in requests}
# Initialize population
population = self._initialize_population(requests, available_classrooms, start_date, end_date)
# Evolve population
for generation in range(self.generations):
# Calculate fitness for all individuals concurrently when workers > 1
if self.workers > 1 and len(population) > 10:
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
concurrent.futures.wait(futures)
else:
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Sort by fitness (best first)
population.sort(key=lambda x: x.fitness, reverse=True)
# Report progress if callback is provided
if progress_callback:
pct = int(((generation + 1) / self.generations) * 100)
best_fit = population[0].fitness if population else 0.0
progress_callback(generation + 1, self.generations, pct, best_fit)
# Create new generation
new_population = []
# Elitism: keep best 10%
elite_size = max(2, int(self.population_size * 0.1))
new_population.extend(population[:elite_size])
# Crossover and mutation for remaining
while len(new_population) < self.population_size:
if random.random() < self.crossover_rate:
parent1 = self._tournament_selection(population)
parent2 = self._tournament_selection(population)
child1, child2 = self._crossover(parent1, parent2)
# Mutate children
if random.random() < self.mutation_rate:
child1 = self._mutate(child1, available_classrooms, start_date, end_date)
if random.random() < self.mutation_rate:
child2 = self._mutate(child2, available_classrooms, start_date, end_date)
new_population.extend([child1, child2])
else:
# Direct copy with potential mutation
selected = self._tournament_selection(population)
if random.random() < self.mutation_rate:
selected = self._mutate(selected, available_classrooms, start_date, end_date)
new_population.append(selected)
population = new_population[:self.population_size]
# Calculate fitness for final generation
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
population.sort(key=lambda x: x.fitness, reverse=True)
return population[0]
def _initialize_population(self, requests: List[ReservationRequest],
classrooms: List[Classroom],
start_date: datetime, end_date: datetime) -> List[Individual]:
"""Create initial population with random assignments"""
population = []
for _ in range(self.population_size):
genes = []
for request in requests:
# Random classroom selection (with capacity constraint)
suitable_classrooms = [c for c in classrooms if c.capacity >= request.expected_attendees]
if not suitable_classrooms:
continue
classroom = random.choice(suitable_classrooms)
# Random time slot (within flexible window)
time_offset = random.uniform(-request.flexibility_hours, request.flexibility_hours)
start_time = request.preferred_start_time + timedelta(hours=time_offset)
duration = request.preferred_end_time - request.preferred_start_time
end_time = start_time + duration
gene = Gene(
commission_id=request.commission_id,
classroom_id=classroom.id,
start_time=start_time,
end_time=end_time,
expected_attendees=request.expected_attendees,
purpose=request.purpose
)
genes.append(gene)
population.append(Individual(genes))
return population
def _tournament_selection(self, population: List[Individual], tournament_size: int = 3) -> Individual:
"""Select individual using tournament selection"""
tournament = random.sample(population, min(tournament_size, len(population)))
return max(tournament, key=lambda x: x.fitness)
def _crossover(self, parent1: Individual, parent2: Individual) -> Tuple[Individual, Individual]:
"""Perform crossover between two parents"""
# Simple one-point crossover
if len(parent1.genes) <= 1 or len(parent2.genes) <= 1:
return Individual(copy.deepcopy(parent1.genes)), Individual(copy.deepcopy(parent2.genes))
crossover_point = random.randint(1, min(len(parent1.genes), len(parent2.genes)) - 1)
child1_genes = copy.deepcopy(parent1.genes[:crossover_point] + parent2.genes[crossover_point:])
child2_genes = copy.deepcopy(parent2.genes[:crossover_point] + parent1.genes[crossover_point:])
return Individual(child1_genes), Individual(child2_genes)
def _mutate(self, individual: Individual, classrooms: List[Classroom],
start_date: datetime, end_date: datetime) -> Individual:
"""Apply mutation to an individual"""
if not individual.genes:
return individual
# Random gene mutation
gene_to_mutate = random.choice(individual.genes)
# Mutation types
mutation_type = random.choice(['classroom', 'time'])
if mutation_type == 'classroom':
# Change classroom
suitable_classrooms = [c for c in classrooms if c.capacity >= gene_to_mutate.expected_attendees]
if suitable_classrooms:
gene_to_mutate.classroom_id = random.choice(suitable_classrooms).id
elif mutation_type == 'time':
# Slightly adjust time
time_adjustment = timedelta(hours=random.uniform(-1, 1))
gene_to_mutate.start_time += time_adjustment
gene_to_mutate.end_time += time_adjustment
return individual
class ReservationOptimizer:
"""High-level interface for the reservation optimization system"""
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
def optimize_schedule(self, commission_ids: List[int],
admin_user_id: int,
start_date: datetime = None,
end_date: datetime = None,
progress_callback = None) -> Dict:
"""Optimize reservation schedule for given commissions"""
# Default date range if not provided
if not start_date:
start_date = datetime.utcnow()
if not end_date:
end_date = start_date + timedelta(days=30)
# Get commissions and create requests
commissions = Commission.query.filter(
Commission.id.in_(commission_ids),
Commission.active == True
).all()
requests = []
for commission in commissions:
# Calculate preferred time based on semester/year
preferred_time = self._calculate_preferred_time(commission)
request = ReservationRequest(
commission_id=commission.id,
expected_attendees=commission.max_students,
purpose=f"Regular class - {commission.subject.name if commission.subject else 'Unknown subject'}",
preferred_start_time=preferred_time['start'],
preferred_end_time=preferred_time['end'],
priority=1, # All regular classes have same priority
flexibility_hours=2
)
requests.append(request)
# Get available classrooms
classrooms = Classroom.query.filter_by(is_active=True).all()
# Run genetic algorithm
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
# Convert to reservation format
optimized_reservations = []
for gene in best_individual.genes:
reservation_data = {
'commission_id': gene.commission_id,
'classroom_id': gene.classroom_id,
'user_id': admin_user_id,
'start_time': gene.start_time.isoformat() if gene.start_time else None,
'end_time': gene.end_time.isoformat() if gene.end_time else None,
'purpose': gene.purpose,
'expected_attendees': gene.expected_attendees,
'status': ReservationStatus.PENDING.value # Convert enum to string
}
optimized_reservations.append(reservation_data)
return {
'success': True,
'fitness_score': best_individual.fitness,
'reservations': optimized_reservations,
'conflicts': best_individual.conflicts,
'total_requests': len(requests),
'assigned_reservations': len(optimized_reservations)
}
def _calculate_preferred_time(self, commission: Commission) -> Dict:
"""Calculate preferred time slots for a commission based on shift and schedule"""
current_date = datetime.utcnow()
# Determine day of week from commission schedule or default to Monday
target_weekday = 0 # Monday
sched = (commission.schedule or '').lower()
if 'martes' in sched:
target_weekday = 1
elif 'miercoles' in sched or 'miércoles' in sched:
target_weekday = 2
elif 'jueves' in sched:
target_weekday = 3
elif 'viernes' in sched:
target_weekday = 4
elif 'sabado' in sched or 'sábado' in sched:
target_weekday = 5
days_ahead = (target_weekday - current_date.weekday()) % 7
if days_ahead == 0:
days_ahead = 7
preferred_date = current_date + timedelta(days=days_ahead)
# Determine hours based on shift
shift = (commission.shift or '').strip().lower()
if 'mañana' in shift or 'manana' in shift:
start_h, start_m = 8, 0
end_h, end_m = 12, 0
elif 'tarde' in shift:
start_h, start_m = 14, 0
end_h, end_m = 18, 0
elif 'noche' in shift or 'vespertino' in shift:
start_h, start_m = 18, 30
end_h, end_m = 22, 30
else:
start_h, start_m = 9, 0
end_h, end_m = 13, 0
return {
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
}
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
applied_reservations = []
for item in reservation_data:
data = dict(item)
if isinstance(data.get('start_time'), str):
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
if isinstance(data.get('end_time'), str):
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
# Check for physical room conflicts before creating
conflicts = Reservation.find_conflicts(
data['classroom_id'],
data['start_time'],
data['end_time']
)
if not conflicts:
# Ensure status enum value compatibility
if 'status' in data and isinstance(data['status'], str):
try:
data['status'] = ReservationStatus(data['status']).value
except ValueError:
data['status'] = ReservationStatus.CONFIRMED.value
reservation = Reservation(**data)
db.session.add(reservation)
applied_reservations.append(reservation)
else:
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
try:
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
return applied_reservations