Completed
Push — scheduler-improvements ( dabd67 )
by
unknown
02:08
created

Schedule.attendance_score_bounds()   A

Complexity

Conditions 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 6
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!(
16
        event.sessions
17
          .where(manually_scheduled: false)
18
          .includes(:timeslot)
19
          .to_a)
20
    end
21
22
    def initialize_copy(source)  # deep copy; called by dup
23
      @slots_by_session = @slots_by_session.dup
24
      @sessions_by_slot = Hash.new { |h,k| h[k] = [] }
25
      @slots_by_session.each { |session, slot| @sessions_by_slot[slot] << session }
26
    end
27
28
    def slot_id_for(session_id)
29
      @slots_by_session[session_id].id
30
    end
31
32
  private
33
34
    attr_reader :ctx
35
36
    class Unassigned  # placeholder for empty room
37
      def to_s
38
        "<< open >>"
39
      end
40
    end
41
42
  public
43
44
    # ------------ Scoring ------------
45
46
    # This is the metric we're trying to minimize. It's called "energy" in simulated annealing by analogy to the
47
    # real-life physical process of annealing, in which a cooling material minimizes the energy of its chemical bonds.
48
    #
49
    def energy
50
       (1 - attendance_score) +
51
       (1 - presenter_score) * ctx.people.size  # Multiplier b/c presenter double-bookings trump attendance prefs
52
    end
53
54
    # Average attendee satisfaction with the schedule, measured as the estimated fraction of the total value
55
    # of all their desired sessions that this schedule will give them. (1 = perfect schedule for everyone;
56
    # 0 = no attendees can attend any desired sessions at all.)
57
    #
58
    def attendance_score
59
      score(:attending)
60
    end
61
62
    # Average ability of presenter to present all the sessions they signed up for. For an optimized schedule,
63
    # this should always be 1 unless a presenter created an inherent conflict (e.g. signing up to present more
64
    # sessions than there are timeslots.)
65
    #
66
    def presenter_score
67
      score(:presenting)
68
    end
69
70
    # Gives bounds on the possible range of attendance_score. Returns [best, random, worst] scores.
71
    def attendance_score_metrics
72
      count = ctx.people.size.to_f
73
      %i(
74
        worst_possible_score
75
        random_schedule_score
76
        best_possible_score
77
      ).map do |metric|
78
        ctx.people.sum { |p| p.attending.send(metric) } / count
79
      end
80
    end
81
82
  private
83
84
    def score(role)
85
      count = ctx.people.size
86
      score = ctx.people.sum do |person|
87
        person.send(role).score(self)
88
      end
89
90
      if count == 0
91
        1
92
      else
93
        score / count
94
      end
95
    end
96
97
  public
98
99
    # ------------ State space traversal ------------
100
101
    # Fill the schedule, using existing timeslots if any are present, assigning the remaining sessions
102
    # randomly, then adding Unassigned placeholders to openings in the schedule.
103
    #
104
    def fill_schedule!(sessions)
105
      sessions.each do |session|
106
        schedule(session.id, session.timeslot) if session.timeslot
107
      end
108
109
      unassigned = sessions.reject(&:timeslot)
110
      room_count = ctx.rooms.size
111
      ctx.timeslots.each do |slot|
112
        opening_count = room_count - @sessions_by_slot[slot].size
113
        slot_sessions = (unassigned.slice!(0, opening_count) || []).map(&:id)
114
        slot_sessions << Unassigned.new while slot_sessions.size < opening_count
115
        slot_sessions.each { |session| schedule(session, slot)  }
116
      end
117
      unless unassigned.empty?
118
        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."
119
      end
120
    end
121
122
    # Return a similar but slightly different schedule. Used by the annealing algorithm to explore
123
    # the scheduling space.
124
    #
125
    def random_neighbor
126
      dup.random_neighbor!
127
    end
128
129
    def random_neighbor!
130
      # Choose 2 or more random sessions in distinct time slots
131
      k = 1.0 / (ctx.timeslots.size - 1)
132
      cycle_size = 1 + ((1 + k) / (rand + k)).floor
133
      slot_cycle = ctx.timeslots.shuffle.slice(0, cycle_size)
134
135
      # Rotate their assignments
136
      slot_cycle.each_with_index do |old_slot, i|
137
        new_slot = slot_cycle[(i+1) % slot_cycle.size]
138
        schedule @sessions_by_slot[old_slot].sample, new_slot
139
      end
140
141
      self
142
    end
143
144
  private
145
146
    def schedule(session, new_slot)
147
      old_slot = @slots_by_session[session]
148
      @sessions_by_slot[old_slot].delete(session) if old_slot
149
150
      @slots_by_session[session] = new_slot
151
      @sessions_by_slot[new_slot] << session if new_slot
152
    end
153
154
  public
155
156
    # ------------ Managing results ------------
157
158
    def assign_rooms_and_save!
159
      Session.transaction do
160
        rooms_by_capacity = ctx.rooms.sort_by { |r| -r.capacity }
161
        @sessions_by_slot.sort_by { |k,v| k.starts_at }.each do |slot_id, session_ids|
162
          slot = Timeslot.find(slot_id)
163
          puts slot
164
          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...
165
          sessions.zip(rooms_by_capacity) do |session, room|
166
            puts "    #{session.id} #{session.title}" +
167
                 " (#{session.attendances.count} vote(s) / #{'%1.1f' % session.estimated_interest} interest)" +
168
                 " in #{room.name} (#{room.capacity})"
169
            session.timeslot = slot
170
            session.room = room
171
            session.save!
172
          end
173
        end
174
      end
175
    end
176
177
    def inspect
178
      worst_score, random_score, best_score = self.attendance_score_metrics
179
      attendance_score_scaled = (attendance_score - random_score) / (best_score - random_score)
180
181
      s = "Schedule\n"
182
      s << "| quality = #{format_percent attendance_score_scaled} (100% is unachievable; > 50% is good)\n"
183
      s << "| satisfaction score = #{format_percent attendance_score} of possible value ()\n"
184
      s << "| presenter score    = #{format_percent presenter_score} (if < 100 then speakers are double-booked)\n"
185
      ctx.timeslots.each do |slot|
186
        s << "  #{slot}: #{@sessions_by_slot[slot].join(' ')}\n"
187
      end
188
      s
189
    end
190
191
    def inspect_bounds
192
      worst_score, random_score, best_score = self.attendance_score_metrics
193
      s =  "If we could give everyone a different schedule optimized just for them,\n"
194
      s << "the schedule quality could be...\n"
195
      s << "\n"
196
      s << "    at worst #{'%03.3f' % ((worst_score) * 100)}%\n"
197
      s << "     at best #{'%03.3f' % ((best_score ) * 100)}%\n"
198
      s << "\n"
199
      s << "Note that these are outer limits on what is possible.\n"
200
      s << "Neither the best nor the worst score is likely to be achievable.\n"
201
      s << "\n"
202
      s << "If we pick a schedule at random, its score will be about\n"
203
      s << "\n"
204
      s << "             #{'%03.3f' % ((random_score) * 100)}%\n"
205
      s << "\n"
206
      s << "...and that's what we're trying to improve on.\n"
207
    end
208
209
  private
210
211
    def format_percent(x)
212
      "#{'%1.3f' % (x * 100)}%"
213
    end
214
215
  end
216
end
217
218