Skip to content

Commit

Permalink
Allowed instructor 2 (#65)
Browse files Browse the repository at this point in the history
* need to fix rspec

* instructor form is sorted and has tests

* no autocorrect

* figured it out
  • Loading branch information
Kingofikings441 authored Nov 21, 2024
1 parent b902227 commit 27eca63
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 58 deletions.
99 changes: 99 additions & 0 deletions app/helpers/room_bookings_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,103 @@ def available_courses(schedule)
.where(room_bookings: { id: nil })
render partial: '/shared/courses_list', locals: { courses: @courses }
end

def eligible_instructors(schedule, booking)
time_slot = TimeSlot.find_by(id: booking.time_slot_id)
current_instructor_id = booking.instructor_id
course_id = booking.course.id
before_9 = Time.parse('09:00 AM')
after_3 = Time.parse('03:00 PM')
return [] if schedule.instructors.nil?

ans = schedule.instructors&.select do |instructor| # which instructors have time
unless instructor.before_9
start_time = begin
Time.parse(time_slot.start_time)
rescue StandardError
nil
end
next if start_time < before_9 # instructor unavalible check the next
end

unless instructor.after_3
end_time = begin
Time.parse(time_slot.end_time)
rescue StandardError
nil
end
next if end_time > after_3 # instructor unavalible check the next
end
max = instructor.max_course_load || 1
next if get_teach_count(instructor) >= max
next if current_instructor_id == instructor.id
next if is_instructor_unavailable?(instructor, booking)

true # teacher is avalable
end
ans.sort_by do |instructor|
# You can customize this sorting logic based on the structure of your `InstructorPreference` model.
# For example, if `instructor.instructor_preferences` has a `preference_level` attribute,
# you could sort by this value:
preference_level = get_preference(instructor, course_id) || 0
max = instructor.max_course_load || 1
bandwidth = max - get_teach_count(instructor) # how many more courses a instructor can pick up
# Return preference_level for sorting, instructors with lower preference level will come first
[-preference_level, -bandwidth]
end
end

def is_instructor_unavailable?(instructor, booking)
time_slot = TimeSlot.find_by(id: booking.time_slot_id)
start_time = Time.strptime(time_slot.start_time, '%H:%M')
end_time = Time.strptime(time_slot.end_time, '%H:%M')

# relevant_days = calculate_relevant_days(time_slot.day)

relevant_days = case time_slot.day
when 'MWF' then %w[MWF MW F]
when 'MW' then %w[MWF MW]
when 'F' then %w[MWF F]
else [time_slot.day]
end

conflicting_time_slots = TimeSlot.where(day: relevant_days).select do |slot|
slot_start_time = begin
Time.strptime(slot.start_time, '%H:%M')
rescue StandardError
nil
end
slot_end_time = begin
Time.strptime(slot.end_time, '%H:%M')
rescue StandardError
nil
end
next unless slot_start_time && slot_end_time

slot_start_time < end_time && slot_end_time > start_time
end

conflicting_time_slots.any? do |slot|
RoomBooking.exists?(time_slot_id: slot.id, instructor_id: instructor.id)
end
end

def get_teach_count(instructor)
RoomBooking.where(instructor_id: instructor.id).count
end

def get_preference(instructor, course_id)
return 0 unless course_id

course_number = Course.find_by(id: course_id).course_number
return 0 unless course_number

courses = Course.where(course_number:)
courses.each do |course|
preference = instructor.instructor_preferences.find_by(course_id: course.id)

return preference.preference_level unless preference.nil?
end
3
end
end
87 changes: 40 additions & 47 deletions app/services/schedule_solver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ def self.solve(classes, rooms, times, instructors, locks)
num_classes = classes.length
num_rooms = rooms.length
num_times = times.length
num_instructors = instructors.length
instructors.length

# Get the total number of contracted hours and ensure it's at least the number offered classes
hours = instructors.map{ |i| i['max_course_load']}
total_course_velocity = hours.sum
# Get the total number of contracted hours and ensure it's at least the number offered classes
hours = instructors.map { |i| i['max_course_load'] }
total_course_velocity = hours.sum
if total_course_velocity < num_classes
raise StandardError, "Not enough teaching hours (#{total_course_velocity}) for given class offerings (#{num_classes})!"
elsif total_course_velocity > num_classes
elsif total_course_velocity > num_classes
# Two options: add more classes or assign professors fewer classes than their contract specifies
# We choose the latter and reduce professors' hours in a greedy fashion
hours = reduce_hours(hours, total_course_velocity - num_classes)
Expand All @@ -32,7 +32,7 @@ def self.solve(classes, rooms, times, instructors, locks)
# Create objective function
# Minimize the number of empty seats in a full section
puts 'Creating objective function'
objective =
objective =
(0...num_classes).map do |c|
(0...num_rooms).map do |r|
(0...num_times).map do |t|
Expand Down Expand Up @@ -74,20 +74,19 @@ def self.solve(classes, rooms, times, instructors, locks)

# Hash room_id to (0...num_rooms)
# Allows us to map rooms => array indices
room_id_hash = (0...num_rooms).map { |r| [rooms[r]['id'], r] }.to_h
class_id_hash = (0...num_classes).map { |c| [classes[c]['id'], c] }.to_h
time_id_hash = (0...num_times).map { |t| [times[t][3], t] }.to_h
room_id_hash = (0...num_rooms).to_h { |r| [rooms[r]['id'], r] }
class_id_hash = (0...num_classes).to_h { |c| [classes[c]['id'], c] }
time_id_hash = (0...num_times).to_h { |t| [times[t][3], t] }

# Constraint #4: Respect locked courses
# locks[i] = (class, room, time) triplet
puts 'Generating lock constraints'
lock_constraints = []
locks.each do |class_id, room_id, time_id|
lock_constraints = locks.map do |class_id, room_id, time_id|
# For unknown reasons, I can't write sched[c][r][t] == 1
# Ruby will evaluate this as a boolean expression, instead of giving the constraint
# Introduce a dummy variable that is guaranteed to be zero
# If their sum is one => sched[c][r][t] must be 1
lock_constraints.append(sched[class_id_hash[class_id]][room_id_hash[room_id]][time_id_hash[time_id]] + GuaranteedZero_b == 1)
sched[class_id_hash[class_id]][room_id_hash[room_id]][time_id_hash[time_id]] + GuaranteedZero_b == 1
end

# Constraint #5: Courses that require special designations get rooms that meet them
Expand All @@ -98,7 +97,7 @@ def self.solve(classes, rooms, times, instructors, locks)
next if classes[c]['is_lab'] == rooms[r]['is_lab']

(0...num_times).each do |t|
designation_constraints.append(sched[c][r][t] + GuaranteedZero_b == 0)
designation_constraints.append((sched[c][r][t] + GuaranteedZero_b) == GuaranteedZero_b)
end
end
end
Expand All @@ -110,15 +109,14 @@ def self.solve(classes, rooms, times, instructors, locks)
overlap_map = Hash.new { |hash, key| hash[key] = Set.new }
(0...num_times).each do |t1|
(t1 + 1...num_times).each do |t2|
if overlaps?(times[t1], times[t2])
overlapping_pairs.append([t1, t2])
overlap_map[times[t1]] << times[t2]
overlap_map[times[t2]] << times[t1] # by symmetry
end
next unless overlaps?(times[t1], times[t2])

overlapping_pairs.append([t1, t2])
overlap_map[times[t1]] << times[t2]
overlap_map[times[t2]] << times[t1] # by symmetry
end
end


# For each pair of overlapping times, ensure that at most one is used
overlap_constraints = []
(0...num_rooms).each do |r|
Expand Down Expand Up @@ -150,6 +148,7 @@ def self.solve(classes, rooms, times, instructors, locks)
(0...num_rooms).each do |r|
(0...num_times).each do |t|
next unless sched[c][r][t].value

classes[c]['time_slot'] = times[t]
classes[c]['room_id'] = rooms[r]['id']
end
Expand All @@ -166,8 +165,8 @@ def self.solve(classes, rooms, times, instructors, locks)

# matching is a 2D array, where each element [i,j] represents the assignment of professsor i to class j
# Map the course to prof_ids[prof], which gives the position of the true professor in the instructors array
matching = matching.map { |prof, course| [classes[course], prof_ids[prof]] }.to_h
matching = matching.to_h { |prof, course| [classes[course], prof_ids[prof]] }

total_unhappiness = 0
classes.each do |assigned_course|
true_prof_id = matching[assigned_course]
Expand All @@ -182,11 +181,11 @@ def self.solve(classes, rooms, times, instructors, locks)
is_lab: false,
created_at: Time.now,
updated_at: Time.now,
course_id: course_id,
course_id:,
instructor_id: instructors[true_prof_id]['id']
)
# Create room bookings for given room at all conflicting timeslots
# This manifests in the schedule and prevents the user from scheduling something that would cause conflict
)
# Create room bookings for given room at all conflicting timeslots
# This manifests in the schedule and prevents the user from scheduling something that would cause conflict
conflicting_times = overlap_map[assigned_time]
conflicting_times.each do |time_slot|
RoomBooking.create(
Expand All @@ -195,19 +194,16 @@ def self.solve(classes, rooms, times, instructors, locks)
is_available: false,
is_lab: false,
created_at: Time.now,
updated_at: Time.now,
updated_at: Time.now
)
# If we have assigned a course to
# If we have assigned a course to
overlap_map[assigned_time].delete(time_slot)
end

end

total_unhappiness
end

private

# Find if two timeslots overlap
# time1 = [days, start_time, end_time]
def self.overlaps?(time1, time2)
Expand All @@ -226,7 +222,7 @@ def self.overlaps?(time1, time2)
def self.day_overlaps?(days1, days2)
d1 = days1.scan(/M|T|W|R|F/)
d2 = days2.scan(/M|T|W|R|F/)
!(d1 & d2).empty?
!!d1.intersect?(d2)
end

def self.before_9?(time)
Expand All @@ -241,16 +237,16 @@ def self.after_3?(time)

def self.reduce_hours(hours, courses_to_drop)
# Create max heap with hour as key and index as value
heap = Containers::MaxHeap.new
hours.each_with_index do |h,i|
heap = Containers::MaxHeap.new
hours.each_with_index do |h, i|
heap.push(h, i)
end

# Reduce hours from prof with highest number of hours
courses_to_drop.times do
hour = heap.next_key
idx = heap.pop
heap.push(hour-1, idx)
heap.push(hour - 1, idx)
end

# Reconstitute modified array
Expand All @@ -270,7 +266,7 @@ def self.generate_unhappiness_matrix(classes, instructors, hours, class_hash)
unhappiness_matrix = Array.new(classes.length) { Array.new(classes.length) { 0 } }

# Create the preference matrix ahead of time to reduce DB reads
prof_hash = (0...instructors.length).map { |i| [instructors[i]['id'], i] }.to_h
prof_hash = (0...instructors.length).to_h { |i| [instructors[i]['id'], i] }
preference_matrix = Array.new(instructors.length) { Array.new(classes.length, 0) }

# Populate the matrix based on InstructorPreference data
Expand All @@ -290,14 +286,13 @@ def self.generate_unhappiness_matrix(classes, instructors, hours, class_hash)
# Since row i likely doesn't correspond to prof i, keep track of underlying prof ID
# i.e. if prof 0 has 3 courses prof_id[0...3] = 0
curr_row = 0
instructor_idx = 0
prof_ids = Array.new(classes.length) {-1}
prof_ids = Array.new(classes.length) { -1 }
(0...instructors.length).each do |instructor_idx|
# Set true professor ID
prof_ids[curr_row] = instructor_idx
hates_mornings = !instructors[instructor_idx]['before_9']
hates_evenings = !instructors[instructor_idx]['after_3']

# Gather corresponding preferences, which requires prof/course id from database
# prof_id_from_db = instructors[instructor_idx]['id']

Expand All @@ -313,21 +308,19 @@ def self.generate_unhappiness_matrix(classes, instructors, hours, class_hash)
# Account for class preference
unhappiness_matrix[curr_row][course] += class_weight * preference_matrix[instructor_idx][course]
end

# Duplicate the row according to the professor's teaching capacity
num_duplications = hours[instructor_idx]-1
num_duplications = hours[instructor_idx] - 1
num_duplications.times do
unhappiness_matrix[curr_row+1] = unhappiness_matrix[curr_row].dup
prof_ids[curr_row+1] = instructor_idx
curr_row += 1
unhappiness_matrix[curr_row + 1] = unhappiness_matrix[curr_row].dup
prof_ids[curr_row + 1] = instructor_idx
curr_row += 1
end

# Move up one, since previous loop was one ahead
curr_row += 1

end
# Return matrix and true ids
[unhappiness_matrix, prof_ids]
end

end
end
31 changes: 21 additions & 10 deletions app/views/room_bookings/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
(<%= booking.course.section_numbers %>)
</strong> |
<%= booking.course.max_seats %>
<p>Current Instructor: <%= curr = Instructor.find_by(id: booking.instructor_id )
unless curr.nil?
"#{curr.first_name} #{curr.last_name}"
else
"N/A"
end %></p>
<% if !booking.is_locked? %>
<%= link_to "Delete",
schedule_room_booking_path(@schedule, booking),
Expand All @@ -78,16 +84,21 @@
confirm: "Are you sure you want to toggle the lock status?",
turbo_method: :patch
} %>
<%= form_with model: booking,
url: update_instructor_schedule_room_booking_path(@schedule, booking),
method: :patch,
data: {
turbo_method: :patch
},
remote: true do |form| %>
<div class="field">
<%= form.select :instructor_id, options_from_collection_for_select(@instructors, :id, :first_name, booking.instructor_id), { prompt: "Select Instructor" }, onchange: "this.form.submit()" %>
</div>
<%= form_with model: booking,
url: update_instructor_schedule_room_booking_path(@schedule, booking),
method: :patch,
remote: true,
data: { turbo_method: :patch } do |form| %>
<div class="field">
<%= form.select :instructor_id,
# options_from_collection_for_select(eligible_instructors(@schedule, booking) , :id, :first_name, booking.instructor_id),
options_for_select(
eligible_instructors(@schedule, booking).map { |instructor| ["#{instructor.first_name} #{instructor.last_name} #{get_preference(instructor, booking.course.id)}", instructor.id] },
booking.instructor_id),
{ prompt: "Select Instructor", selected: nil },
id: "instructor_select_#{booking.id}",
onchange: "this.form.submit()" %>
</div>
<% end %>
<% elsif booking && !booking.is_available %>
<%= button_to "U", toggle_availability_schedule_room_bookings_path, method: :post, params: { room_id: room.id, time_slot_id: time_slot.id, active_tab: @active_tab, schedule_id: @schedule.id, is_available: true }, class: "btn btn-sm btn-success" %>
Expand Down
2 changes: 2 additions & 0 deletions db/migrate/20241113210958_add_section_numbers_to_courses.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

class AddSectionNumbersToCourses < ActiveRecord::Migration[7.2]
def change
add_column :courses, :section_numbers, :string
Expand Down
2 changes: 2 additions & 0 deletions db/migrate/20241113211539_drop_sections.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

class DropSections < ActiveRecord::Migration[7.2]
def change
remove_foreign_key :room_bookings, :sections if foreign_key_exists?(:room_bookings, :sections)
Expand Down
2 changes: 2 additions & 0 deletions db/migrate/20241114022249_add_courses_to_room_bookings.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

class AddCoursesToRoomBookings < ActiveRecord::Migration[7.2]
def change
add_reference :room_bookings, :course, foreign_key: true unless column_exists?(:room_bookings, :course_id)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

class SetDefaultMaxCourseLoadOnInstructors < ActiveRecord::Migration[7.2]
def change
change_column_default :instructors, :max_course_load, 1
Expand Down
Loading

0 comments on commit 27eca63

Please sign in to comment.