Step 3: Goal Programming with Teacher Preferences

Overview

This step extends Step 2 by adding teacher preferences as soft constraints using LumiX’s goal programming feature. Teachers can express preferences, and these are converted to goals with priorities based on teacher seniority.

What’s New in Step 3:

  • Teacher preferences (DAY_OFF, SPECIFIC_TIME)

  • Priority-based scheduling using teacher seniority

  • Goal programming with weighted objectives

  • Preference satisfaction analysis

Prerequisites:

pip install lumix gurobi sqlalchemy  # Gurobi recommended for goal programming

Problem Description

Same as Steps 1 & 2, but now with:

  • Hard constraints: Basic timetabling rules (must be satisfied)

  • Soft constraints (goals): Teacher preferences (minimize violations)

  • Priority levels: Senior teachers (Priority 1) > Mid-level (Priority 2) > Junior (Priority 3)

Mathematical Formulation

Hard Constraints (Same as Steps 1 & 2)

  • Each lecture assigned exactly once

  • No classroom/teacher/class conflicts

  • Classroom capacity constraints

Soft Constraints (Goals)

DAY_OFF Preference: Minimize work on preferred day off

\[\begin{split}\sum_{\substack{l \in \text{Lectures} \\ \text{teacher}(l) = t}} \sum_{\substack{s \in \text{TimeSlots} \\ \text{day}(s) = d}} \sum_{r \in \text{Classrooms}} \text{assignment}[l, s, r] \leq 0 \quad \text{(Goal)}\end{split}\]

SPECIFIC_TIME Preference: Assign lecture to preferred timeslot

\[\sum_{r \in \text{Classrooms}} \text{assignment}[l_{pref}, t_{pref}, r] \geq 1 \quad \text{(Goal)}\]

Priorities

Based on years of service (work_years):

  • Priority 1: Teachers with 15+ years (highest priority)

  • Priority 2: Teachers with 7-14 years (medium priority)

  • Priority 3: Teachers with 0-6 years (lower priority)

Key Features Demonstrated

1. Goal Programming

Converting preferences to soft constraints:

# Hard constraint (must satisfy)
model.add_constraint(
    LXConstraint("lecture_coverage")
    .expression(expr)
    .eq()
    .rhs(1)
)

# Soft goal (minimize violation with priority)
model.add_constraint(
    LXConstraint("teacher_preference")
    .expression(expr)
    .le()
    .rhs(0)
    .as_goal(priority=1, weight=1.0)  # Priority 1 = highest
)

# Prepare goal programming
model.set_goal_mode("weighted")
model.prepare_goal_programming()

2. Priority-Based Scheduling

def calculate_priority_from_work_years(work_years: int) -> int:
    """Calculate goal programming priority from teacher work years.

    Priority scheme:
        - 15+ years → Priority 1 (highest)
        - 7-14 years → Priority 2
        - 0-6 years → Priority 3 (lowest)

    Args:
        work_years: Number of years the teacher has worked.

    Returns:
        Priority level (1, 2, or 3).

    Example:
        >>> priority = calculate_priority_from_work_years(15)
        >>> print(priority)  # 1 (senior teacher)
    """
    if work_years >= 15:
        return 1  # Senior teachers
    elif work_years >= 7:
        return 2  # Mid-level teachers
    else:
        return 3  # Junior teachers

3. Preference Types

DAY_OFF: Teacher wants a specific day completely free

if not teacher:
    continue

# Calculate priority from teacher's work years
priority = calculate_priority_from_work_years(teacher.work_years)
teacher_name = teacher.name

if pref.preference_type == "DAY_OFF":
    # Goal: Minimize assignments on the preferred day off
    # Get all timeslots for the specified day using ORM filtering
    day_timeslot_ids = [
        ts.id for ts in session.query(TimeSlot).filter_by(day_of_week=pref.day_of_week).all()
    ]

    # Get all lectures taught by this teacher using ORM filtering
    teacher_lecture_ids = [
        lec.id for lec in session.query(Lecture).filter_by(teacher_id=teacher.id).all()
    ]

    # Sum of all assignments on that day for this teacher
    expr = LXLinearExpression().add_multi_term(
        assignment,
        coeff=lambda lec, ts, room: 1.0,
        where=lambda lec, ts, room, t_lec_ids=teacher_lecture_ids, d_slot_ids=day_timeslot_ids: lec.id
        in t_lec_ids
        and ts.id in d_slot_ids,
    )

    # Goal: minimize this sum (ideally 0 = complete day off)
    day_timeslot = session.query(TimeSlot).filter_by(day_of_week=pref.day_of_week).first()
    day_name = day_timeslot.day_name if day_timeslot else "Unknown"
    goal_name = f"day_off_teacher_{teacher.id}_{day_name}"

    model.add_constraint(

SPECIFIC_TIME: Teacher wants to teach a specific lecture at a specific time

        .expression(expr)
        .le()
        .rhs(0)  # Target: 0 assignments on this day
        .as_goal(priority=priority, weight=1.0)
    )

    print(
        f"    [P{priority}] {teacher_name}: wants {day_name} off (goal: 0 lectures)"
    )

elif pref.preference_type == "SPECIFIC_TIME":
    # Goal: Assign specific lecture to specific timeslot
    # Expression: sum of assignments for this lecture at this timeslot (across all classrooms)
    expr = LXLinearExpression().add_multi_term(
        assignment,
        coeff=lambda lec, ts, room: 1.0,
        where=lambda lec, ts, room, target_lec=pref.lecture_id, target_ts=pref.timeslot_id: lec.id
        == target_lec
        and ts.id == target_ts,
    )

    # Goal: this sum should equal 1 (lecture is assigned to that timeslot)
    goal_name = f"specific_time_teacher_{teacher.id}_lecture_{pref.lecture_id}"

    model.add_constraint(
        LXConstraint(goal_name)
        .expression(expr)
        .eq()
        .rhs(1)  # Target: exactly 1 (assigned to this timeslot)
        .as_goal(priority=priority, weight=1.0)

Database Schema Extensions

New Tables

teacher_preferences table:

  • id: Primary key

  • teacher_id: Foreign key to teachers

  • preference_type: ‘DAY_OFF’ or ‘SPECIFIC_TIME’

  • day_of_week: Preferred day (for DAY_OFF)

  • lecture_id: Specific lecture (for SPECIFIC_TIME)

  • timeslot_id: Specific timeslot (for SPECIFIC_TIME)

  • description: Human-readable description

Updated Models

Teacher model now includes work_years:

class Teacher(Base):
    """Teacher ORM model with seniority information.

    Attributes:
        id: Primary key
        name: Teacher's name
        work_years: Years of service (used for priority calculation)
    """
    __tablename__ = 'teachers'

    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    work_years = Column(Integer, nullable=False, default=0)

    def __repr__(self):
        return f"<Teacher(id={self.id}, name='{self.name}', work_years={self.work_years})>"

Running the Example

Step 1: Populate Database with Preferences

cd tutorials/timetabling/step3_goal_programming
python sample_data.py

Step 2: Run Goal Programming

python timetabling_goals.py

Expected Output

  1. Data loading with preference counts

  2. Model building with goal constraints

  3. Solution status

  4. Timetables for teachers and classes

  5. Preference satisfaction analysis: - Per-teacher satisfaction rates - Priority-level satisfaction rates - Overall satisfaction percentage

Code Walkthrough

1. Load Preferences from Database

Args:
    session: SQLAlchemy Session instance.

Returns:
    An LXModel instance with both hard constraints and soft goals.
"""
print("\nBuilding course timetabling model with goal programming...")
print("  Using LumiX's from_model() for direct database querying")

# Create cached checker for efficient lookups (avoids redundant DB queries)
fits_checker = create_cached_class_fits_checker(session)

# Decision variable: assignment[lecture, timeslot, classroom]
# LumiX queries the database directly using from_model()

2. Set Goal Programming Mode

            .as_goal(priority=priority, weight=1.0)
        )

3. Add Goal Constraints

For each preference, create a goal constraint with calculated priority.

4. Analyze Goal Satisfaction

    print("GOAL SATISFACTION ANALYSIS")
    print(f"{'=' * 80}")

    # Group preferences by priority
    priority_groups = {1: [], 2: [], 3: []}

    # Query preferences directly from database (no pre-loading into list)
    for pref in session.query(TeacherPreference).all():
        # Query teacher using ORM
        teacher = session.query(Teacher).filter_by(id=pref.teacher_id).first()
        if not teacher:
            continue

        priority = calculate_priority_from_work_years(teacher.work_years)

        if pref.preference_type == "DAY_OFF":
            # Query timeslot for day name using ORM
            day_timeslot = session.query(TimeSlot).filter_by(day_of_week=pref.day_of_week).first()
            day_name = day_timeslot.day_name if day_timeslot else "Unknown"
            goal_name = f"day_off_teacher_{teacher.id}_{day_name}"
        else:  # SPECIFIC_TIME
            goal_name = f"specific_time_teacher_{teacher.id}_lecture_{pref.lecture_id}"

        # Check goal satisfaction
        try:
            deviations = solution.get_goal_deviations(goal_name)
            satisfied = solution.is_goal_satisfied(goal_name, tolerance=1e-6)

            priority_groups[priority].append(
                {
                    "teacher": teacher,
                    "preference": pref,
                    "goal_name": goal_name,
                    "satisfied": satisfied,
                    "deviations": deviations,
                }
            )
        except Exception as e:
            print(f"  Warning: Could not analyze goal {goal_name}: {e}")

    # Display by priority
    for priority in [1, 2, 3]:
        priority_name = (
            "Senior (15+ years)"
            if priority == 1
            else "Mid-level (7-14 years)" if priority == 2 else "Junior (0-6 years)"
        )
        print(f"\nPriority {priority} ({priority_name}):")
        print("-" * 80)

        group = priority_groups[priority]
        if not group:
            print("  (No preferences at this priority level)")
            continue

        satisfied_count = sum(1 for g in group if g["satisfied"])
        total_count = len(group)

Key Learnings

Goal Programming Concepts

Hard vs. Soft Constraints:

  • Hard: Must be satisfied (infeasible if violated)

  • Soft (Goals): Minimize violations weighted by priority

Priority Levels:

Higher priority goals are satisfied first, even if lower priority goals must be violated.

Weighted vs. Sequential:

  • Weighted: Single optimization with priority weights

  • Sequential: Multiple optimizations, one per priority level

Performance Considerations

Goal programming increases solve time:

  • More variables (deviation variables added automatically)

  • More constraints (one per goal)

  • Use commercial solvers (Gurobi, CPLEX) for better performance

Typical Results

With 7 preferences across 3 priority levels:

  • Priority 1 (Senior): 80-100% satisfaction

  • Priority 2 (Mid-level): 60-80% satisfaction

  • Priority 3 (Junior): 40-60% satisfaction

  • Overall: 60-80% satisfaction

Next Steps

After completing Step 3, proceed to:

See Also

Related Examples:

Related User Guide:

API Reference:

Tutorial Step 3 Complete!

You’ve learned how to use goal programming for multi-objective optimization with teacher preferences. Now move on to Step 4: Large-Scale Optimization with Room Types to see how LumiX scales to production-ready problems.