Completed
Push — master ( 6ab940...b32d12 )
by
unknown
02:43
created

Schedule.fill_schedule!   A

Complexity

Conditions 2

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 2
dl 0
loc 17
rs 9.4285
1
require 'pp'
2
3
4
module Scheduling
5
  # Represents a particular schedule (i.e. assignment of sessions to rooms) for the purpose of annealing.
6
  # Scores the schedule and returns nearby variations.
7
  #
8
  class Schedule
9
    def initialize(event)
10
      @ctx = Scheduling::Context.new(event)
11
12
      @slots_by_session = {}                            # map from session to timeslot
13
      @sessions_by_slot = Hash.new { |h,k| h[k] = [] }  # map from timeslot to array of sessions
14
15
      fill_schedule!(event.sessions.includes(:timeslot).to_a)
16
    end
17
18
    def initialize_copy(source)  # deep copy; called by dup
19
      @slots_by_session = @slots_by_session.dup
20
      @sessions_by_slot = Hash.new { |h,k| h[k] = [] }
21
      @slots_by_session.each { |session, slot| @sessions_by_slot[slot] << session }
22
    end
23
24
    def slot_id_for(session_id)
25
      @slots_by_session[session_id].id
26
    end
27
28
  private
29
30
    attr_reader :ctx
31
32
    class Unassigned  # placeholder for empty room
33
      def to_s
34
        "<< open >>"
35
      end
36
    end
37
38
  public
39
40
    # ------------ Scoring ------------
41
42
    # This is the metric we're trying to minimize. It's called "energy" in simulated annealing by analogy to the
43
    # real-life physical process of annealing, in which a cooling material minimizes the energy of its chemical bonds.
44
    #
45
    def energy
46
       (1 - attendance_score) +
47
       (1 - presenter_score) * ctx.people.size  # Multiplier b/c presenter double-bookings trump attendance prefs
48
    end
49
50
    # Average attendee satisfaction with the schedule, measured as the estimated fraction of the total value
51
    # of all their desired sessions that this schedule will give them. (1 = perfect schedule for everyone;
52
    # 0 = no attendees can attend any desired sessions at all.)
53
    #
54
    def attendance_score
55
      score(:attending)
56
    end
57
58
    # Average ability of presenter to present all the sessions they signed up for. For an optimized schedule,
59
    # this should always be 1 unless a presenter created an inherent conflict (e.g. signing up to present more
60
    # sessions than there are timeslots.)
61
    #
62
    def presenter_score
63
      score(:presenting)
64
    end
65
66
    # Gives lower & upper bounds on the possible range of attendance_score
67
    def attendance_score_bounds
68
      best_score  = ctx.people.sum { |p| p.attending.best_possible_score }
69
      worst_score = ctx.people.sum { |p| p.attending.worst_possible_score }
70
      count = ctx.people.size
71
      (worst_score / count) .. (best_score / count)
72
    end
73
74
  private
75
76
    def score(role)
77
      count = ctx.people.size
78
      score = ctx.people.sum do |person|
79
        person.send(role).score(self)
80
      end
81
82
      if count == 0
83
        1
84
      else
85
        score / count
86
      end
87
    end
88
89
  public
90
91
    # ------------ State space traversal ------------
92
93
    # Fill the schedule, using existing timeslots if any are present, assigning the remaining sessions
94
    # randomly, then adding Unassigned placeholders to openings in the schedule.
95
    #
96
    def fill_schedule!(sessions)
97
      sessions.each do |session|
98
        schedule(session.id, session.timeslot) if session.timeslot
99
      end
100
101
      unassigned = sessions.reject(&:timeslot)
102
      room_count = ctx.rooms.size
103
      ctx.timeslots.each do |slot|
104
        opening_count = room_count - @sessions_by_slot[slot].size
105
        slot_sessions = (unassigned.slice!(0, opening_count) || []).map(&:id)
106
        slot_sessions << Unassigned.new while slot_sessions.size < opening_count
107
        slot_sessions.each { |session| schedule(session, slot)  }
108
      end
109
      unless unassigned.empty?
110
        raise "Not enough room / slot combinations! There are #{sessions.size} sessions, but only #{ctx.timeslots.size} times slots * #{room_count} rooms = #{ctx.timeslots.size * room_count} combinations."
111
      end
112
    end
113
114
    # Return a similar but slightly different schedule. Used by the annealing algorithm to explore
115
    # the scheduling space.
116
    #
117
    def random_neighbor
118
      dup.random_neighbor!
119
    end
120
121
    def random_neighbor!
122
      # Choose 2 or more random sessions in distinct time slots
123
      k = 1.0 / (ctx.timeslots.size - 1)
124
      cycle_size = 1 + ((1 + k) / (rand + k)).floor
125
      slot_cycle = ctx.timeslots.shuffle.slice(0, cycle_size)
126
127
      # Rotate their assignments
128
      slot_cycle.each_with_index do |old_slot, i|
129
        new_slot = slot_cycle[(i+1) % slot_cycle.size]
130
        schedule @sessions_by_slot[old_slot].sample, new_slot
131
      end
132
133
      self
134
    end
135
136
  private
137
138
    def schedule(session, new_slot)
139
      old_slot = @slots_by_session[session]
140
      @sessions_by_slot[old_slot].delete(session) if old_slot
141
142
      @slots_by_session[session] = new_slot
143
      @sessions_by_slot[new_slot] << session if new_slot
144
    end
145
146
  public
147
148
    # ------------ Managing results ------------
149
150
    def assign_rooms_and_save!
151
      Session.transaction do
152
        rooms_by_capacity = ctx.rooms.sort_by { |r| -r.capacity }
153
        @sessions_by_slot.sort_by { |k,v| k.starts_at }.each do |slot_id, session_ids|
154
          slot = Timeslot.find(slot_id)
155
          puts slot
156
          sessions = Session.find(session_ids.reject { |s| Unassigned === s }).sort_by { |s| -s.estimated_interest }
0 ignored issues
show
Coding Style introduced by
Your coding style requires you to avoid the use of the case equality operator === outside of case statements. It is not an instanceof check like it is in other languages.
Loading history...
157
          sessions.zip(rooms_by_capacity) do |session, room|
158
            puts "    #{session.categories.map(&:name).inspect} #{session.title}" +
159
                 " (#{session.attendances.count} vote(s) / #{'%1.1f' % session.estimated_interest} interest)" +
160
                 " in #{room.name} (#{room.capacity})"
161
            session.timeslot = slot
162
            session.room = room
163
            session.save!
164
          end
165
        end
166
      end
167
    end
168
169
    def inspect
170
      s = "Schedule"
171
      s << " | average participant is #{format_percent attendance_score} satisfied with schedule"
172
      s << " | presenter score = #{format_percent presenter_score} (we want 100)\n"
173
      ctx.timeslots.each do |slot|
174
        s << "  #{slot}: #{@sessions_by_slot[slot].join(' ')}\n"
175
      end
176
      s
177
    end
178
179
    def inspect_bounds
180
      possible_range = self.attendance_score_bounds
181
      s = "Given the number of timeslots and sessions of interest, the average participant cannot possibly be...\n"
182
      s << "    less than #{'%03.3f' % ((possible_range.begin) * 100)}%\n"
183
      s << "    more than #{'%03.3f' % ((possible_range.end  ) * 100)}%\n"
184
      s << "...satisfied with the schedule, whatever it is."
185
      s << " (Note that these are just limits on what is possible."
186
      s << " Neither bounds is actually likely to be achievable.)"
187
    end
188
189
  private
190
191
    def format_percent(x)
192
      "#{'%1.3f' % (x * 100)}%"
193
    end
194
195
  end
196
end
197
198