symian 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,97 @@
1
+ require 'symian/work_shift'
2
+
3
+
4
+ module Symian
5
+ class Activity < Struct.new(:iid, :start_time, :end_time)
6
+ end
7
+
8
+
9
+ class Operator
10
+
11
+ extend Forwardable
12
+
13
+ REQUIRED_ATTRIBUTES = [
14
+ :oid,
15
+ :support_group_id,
16
+ ]
17
+
18
+ OTHER_ATTRIBUTES = [
19
+ :workshift,
20
+ :specialization,
21
+ :work_record,
22
+ :support_group_id,
23
+ ]
24
+
25
+ attr_reader *(REQUIRED_ATTRIBUTES + OTHER_ATTRIBUTES)
26
+
27
+ def_delegators :@workshift, :active_at?, :secs_to_begin_of_shift, :secs_to_end_of_shift
28
+ def_delegator :@workshift, :duration, :workshift_duration
29
+
30
+
31
+ def initialize(oid, support_group_id, opts={})
32
+ @oid = oid
33
+ @support_group_id = support_group_id
34
+
35
+ # set correspondent instance variables for optional arguments
36
+ opts.each do |k, v|
37
+ # ignore invalid attributes
38
+ instance_variable_set("@#{k}", v) if OTHER_ATTRIBUTES.include?(k)
39
+ end
40
+
41
+ # support :workshift => :all_day_long shortcut
42
+ if @workshift == :all_day_long
43
+ @workshift = WorkShift::WORKSHIFT_24x7
44
+ end
45
+
46
+ # default workshift is 24x7
47
+ @workshift ||= WorkShift::WORKSHIFT_24x7
48
+
49
+ @specialization ||= {}
50
+ @work_record ||= []
51
+ end
52
+
53
+
54
+ def assign(incident, incident_info, time)
55
+
56
+ # initialize incident start work time if needed
57
+ incident.start_work_time ||= time
58
+
59
+ # calculate time to end of shift
60
+ tteos = @workshift.secs_to_end_of_shift(time)
61
+ raise "tteos: #{tteos}" if tteos < 0.0
62
+
63
+ # specialization
64
+ specialization = @specialization[incident.category] || 1.0
65
+
66
+ # calculate time to incident escalation
67
+ ttie = incident_info[:needed_work_time] / specialization.to_f
68
+ raise "ttie: #{ttie}" if ttie < 0.0
69
+
70
+ # handle incident
71
+ if tteos < ttie # end of shift first
72
+ work_time = tteos
73
+ reason = :operator_off_duty
74
+ else # escalation first
75
+ work_time = ttie
76
+ reason = :incident_escalation
77
+ end
78
+
79
+ # update needed (effective) incident work time
80
+ incident_info[:needed_work_time] -= work_time * specialization
81
+
82
+ # update incident tracking
83
+ incident.add_tracking_information(:type => :work,
84
+ :at => time,
85
+ :duration => work_time,
86
+ :sg => @support_group_id,
87
+ :operator => @oid)
88
+
89
+ # update operator work record
90
+ @work_record << Activity.new(incident.iid, time, time + work_time)
91
+
92
+ # return [ reason, time_when_operator_stops_working ]
93
+ [ reason, time + work_time ]
94
+ end
95
+
96
+ end
97
+ end
@@ -0,0 +1,60 @@
1
+ require 'symian/event'
2
+ require 'symian/generator'
3
+ require 'symian/incident'
4
+ require 'symian/operator'
5
+ require 'symian/support_group'
6
+
7
+
8
+ module Symian
9
+ class PerformanceAnalyzer
10
+ def initialize(config)
11
+ @warmup_threshold = config.start_time + config.warmup_duration
12
+ end
13
+
14
+ def calculate_kpis(trace)
15
+ raise ArgumentError, 'Argument must be a TraceCollector' unless TraceCollector === trace
16
+
17
+ kpis = {}
18
+
19
+ # these metrics are considered as kpis
20
+ kpis[:all_incidents] = trace.incidents
21
+ kpis[:incidents_considered] = 0
22
+ kpis[:closed_incidents] = 0
23
+ kpis[:mean_ttr] = 0
24
+ kpis[:max_ttr] = 0
25
+ kpis[:mean_waiting_time] = 0
26
+
27
+ max_ttr = 0
28
+ ttr_sum = 0
29
+ wt_sum = 0
30
+ trace.with_incidents do |i|
31
+
32
+ next if @warmup_threshold and i.arrival_time < @warmup_threshold
33
+
34
+ kpis[:incidents_considered] += 1
35
+
36
+ if i.closed?
37
+ kpis[:closed_incidents] += 1
38
+ ttr = i.total_work_time
39
+ ttr_sum += ttr
40
+ wt_sum += i.total_queue_time
41
+ max_ttr = ttr if ttr > max_ttr
42
+ end
43
+
44
+ end
45
+
46
+ kpis[:max_ttr] = max_ttr
47
+ if kpis[:closed_incidents] == 0
48
+ kpis[:mean_ttr] = Float::MAX
49
+ kpis[:mean_waiting_time] = Float::MAX
50
+ else
51
+ kpis[:mean_ttr] = ttr_sum / kpis[:closed_incidents]
52
+ kpis[:mean_waiting_time] = wt_sum / kpis[:closed_incidents]
53
+ end
54
+
55
+ # return kpis
56
+ kpis
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,171 @@
1
+ require 'symian/event'
2
+ require 'symian/generator'
3
+ require 'symian/operator'
4
+ require 'symian/performance_analyzer'
5
+ require 'symian/sorted_array'
6
+ require 'symian/support_group'
7
+ require 'symian/transition_matrix'
8
+ require 'symian/trace_collector'
9
+
10
+
11
+ module Symian
12
+ class Simulation
13
+
14
+ ATTRIBUTES = [ :start_time ]
15
+
16
+ attr_reader *ATTRIBUTES
17
+
18
+
19
+ def initialize(configuration, performance_analyzer, trace=TraceCollector.new(:memory))
20
+ @configuration = configuration
21
+
22
+ # setup performance analyzer and simulation trace
23
+ @performance_analyzer = performance_analyzer
24
+ @trace = trace
25
+
26
+ # setup simulation start and current time
27
+ @current_time = @start_time = @configuration.start_time
28
+
29
+ # create support groups
30
+ @support_groups = {}
31
+ @configuration.support_groups.each do |name,conf|
32
+ @support_groups[name] = SupportGroup.new(name, self, conf[:work_time], conf[:operators])
33
+ end
34
+
35
+ # create transition matrix
36
+ @transition_matrix = TransitionMatrix.new(@configuration.transition_matrix)
37
+
38
+ # create event queue
39
+ @event_queue = SortedArray.new
40
+ end
41
+
42
+
43
+ def new_event(type, data, time, destination)
44
+ @event_queue << Event.new(type, data, time, destination)
45
+ end
46
+
47
+
48
+ def now
49
+ @current_time
50
+ end
51
+
52
+
53
+ def run
54
+
55
+ # initialize support groups
56
+ @support_groups.values.each do |sg|
57
+ sg.initialize_at(@configuration.start_time)
58
+ end
59
+
60
+ # generate first incident
61
+ ig = IncidentGenerator.new(self, @configuration.incident_generation)
62
+ ig.generate
63
+
64
+ # schedule end of simulation
65
+ unless @configuration.end_time.nil?
66
+ # puts "Simulation ends at: #{@configuration.end_time}"
67
+ new_event(Event::ET_END_OF_SIMULATION, nil, @configuration.end_time, nil)
68
+ end
69
+
70
+ # calculate warmup threshold
71
+ warmup_threshold = @configuration.start_time + @configuration.warmup_duration
72
+
73
+ @incidents_being_worked_on ||= []
74
+
75
+ # launch simulation
76
+ until @event_queue.empty?
77
+ e = @event_queue.shift
78
+
79
+ # sanity check on simulation time flow
80
+ if @current_time > e.time
81
+ raise 'Error: simulation time inconsistency for event ' +
82
+ "e.type=#{e.type} @current_time=#{@current_time}, e.time=#{e.time}"
83
+ end
84
+
85
+ @current_time = e.time
86
+
87
+ #Trace.new_event e
88
+ case e.type
89
+ when Event::ET_INCIDENT_ARRIVAL
90
+
91
+ sg_name = @transition_matrix.escalation('In')
92
+
93
+ sg = @support_groups[sg_name]
94
+ sg.new_incident(e.data, e.time)
95
+
96
+ @incidents_being_worked_on << e.data
97
+
98
+ # generate next incident
99
+ ig.generate
100
+
101
+ # TODO: pinpoint instant for calculation of time spent in enqueued state
102
+
103
+
104
+ when Event::ET_INCIDENT_ASSIGNMENT
105
+
106
+ # TODO: implement calculation of time spent in suspended state
107
+
108
+
109
+ when Event::ET_OPERATOR_ACTIVITY_STARTS
110
+
111
+
112
+ when Event::ET_OPERATOR_ACTIVITY_FINISHES
113
+
114
+ sg = @support_groups[e.destination]
115
+ sg.operator_finished_working(e.data[0], e.time)
116
+
117
+
118
+ when Event::ET_INCIDENT_RESCHEDULING
119
+
120
+ sg = @support_groups[e.destination]
121
+ sg.schedule_incident_for_reassignment(e.data[0], e.data[1], e.time)
122
+
123
+ # TODO: pinpoint instant for calculation of time spent in suspended state
124
+
125
+
126
+ when Event::ET_INCIDENT_ESCALATION
127
+ inc = e.data
128
+
129
+ sg_name = @transition_matrix.escalation(e.destination)
130
+ if sg_name == "Out"
131
+ inc.closure_time = e.time
132
+
133
+ # remove incident from list of incidents being worked on and add it to trace
134
+ @incidents_being_worked_on.delete(inc)
135
+ @trace.record_incidents(inc)
136
+ else
137
+ sg = @support_groups[sg_name]
138
+ sg.new_incident(inc, e.time)
139
+ end
140
+
141
+
142
+ when Event::ET_OPERATOR_LEAVING
143
+
144
+ sg = @support_groups[e.destination]
145
+ sg.operator_going_home(e.data, e.time)
146
+
147
+
148
+ when Event::ET_OPERATOR_RETURNING
149
+
150
+ sg = @support_groups[e.destination]
151
+ sg.operator_arrived_at_work(e.data, e.time)
152
+
153
+
154
+ when Event::ET_SUPPORT_GROUP_QUEUE_SIZE_CHANGE
155
+
156
+
157
+ when Event::ET_END_OF_SIMULATION
158
+ break
159
+
160
+ end
161
+ end
162
+
163
+ # save trace file
164
+ @trace.save_and_close
165
+ kpis = @performance_analyzer.calculate_kpis(@trace)
166
+ kpis.merge(incidents_being_worked_on: @incidents_being_worked_on.size)
167
+
168
+ end
169
+
170
+ end
171
+ end
@@ -0,0 +1,57 @@
1
+ module Symian
2
+ # the SortedArray class was taken from the ruby cookbook
3
+ class SortedArray < Array
4
+ def initialize(*args, &sort_by)
5
+ @sort_by = sort_by || Proc.new { |x,y| x <=> y }
6
+ super(*args)
7
+ sort! &sort_by
8
+ end
9
+
10
+ def insert(i, v)
11
+ if size == 0 or v < self[0]
12
+ super(0, v)
13
+ elsif v > self[-1]
14
+ super(-1, v)
15
+ else
16
+ left = 0
17
+ right = size - 1
18
+ middle = (left + right)/2
19
+ while left < right
20
+ if v >= self[middle]
21
+ left = middle + 1
22
+ else
23
+ right = middle
24
+ end
25
+ middle = (left + right)/2
26
+ end
27
+ super(middle, v)
28
+ end
29
+ end
30
+
31
+ def <<(el)
32
+ insert(0, el)
33
+ end
34
+
35
+ alias push <<
36
+ alias unshift <<
37
+
38
+ # some methods, like collect!, can modify the items in an array,
39
+ # taking them out of sort order. we need to redefine those
40
+ # methods.
41
+ # we can't use define_method to define these methods because in
42
+ # Ruby 1.8 you can't use define_method to create a method that
43
+ # takes a block argument.
44
+ [ "collect!", "flatten!", "[]=" ].each do |method_name|
45
+ class_eval %{
46
+ def #{method_name}(*args)
47
+ super
48
+ sort! &@sort_by
49
+ end
50
+ }
51
+ end
52
+
53
+ def reverse!
54
+ # do nothing: reversing the array would disorder it.
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,18 @@
1
+ # taken from Jim Freeze's excellent article "Creating DSLs with Ruby"
2
+ # http://www.artima.com/rubycs/articles/ruby_as_dsl.html
3
+
4
+ class Module
5
+ def dsl_accessor(*symbols)
6
+ symbols.each { |sym|
7
+ class_eval %{
8
+ def #{sym}(*val)
9
+ if val.empty?
10
+ @#{sym}
11
+ else
12
+ @#{sym} = val.size == 1 ? val[0] : val
13
+ end
14
+ end
15
+ }
16
+ }
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ require 'yaml'
2
+
3
+ module Symian
4
+ module YAMLSerializable
5
+ def to_yaml_properties
6
+ self.class.const_get('TRACED_ATTRIBUTES').map{|attr| "@#{attr.to_s}"}
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,182 @@
1
+ require 'erv'
2
+
3
+ require 'symian/event'
4
+ require 'symian/operator'
5
+ require 'symian/work_shift'
6
+
7
+ require 'symian/support/yaml_io'
8
+
9
+
10
+ module Symian
11
+
12
+ class SupportGroup
13
+
14
+ # setup readable/accessible attributes
15
+ ATTRIBUTES = [ :sgid, :operators ]
16
+
17
+ attr_reader *ATTRIBUTES
18
+
19
+
20
+ # setup attributes saved in traces
21
+ include YAMLSerializable
22
+
23
+ TRACED_ATTRIBUTES = ATTRIBUTES + [ :incident_queue_info ]
24
+
25
+
26
+ def initialize(support_group_id, simulation, work_time_characterization, operator_characterizations)
27
+ @sgid = support_group_id
28
+ @simulation = simulation
29
+
30
+ # initialize needed_work_time_rng
31
+ @needed_work_time_rv = ERV::RandomVariable.new(work_time_characterization)
32
+
33
+ # create operators
34
+ @operators = []
35
+
36
+ if operator_characterizations.kind_of?(Hash)
37
+ operator_characterizations = [ operator_characterizations ]
38
+ end
39
+
40
+ next_op_id = 1
41
+ operator_characterizations.each do |x|
42
+ x[:number].times do |y|
43
+ op = Operator.new("OP#{next_op_id}_#{@sgid}", @sgid, x.reject { |k, v| k == :number })
44
+ @operators << op
45
+ next_op_id = next_op_id + 1
46
+ end
47
+ end
48
+
49
+ # initialize incident queue and related tracking information
50
+ @incident_queue = []
51
+ @incident_queue_info = []
52
+ end
53
+
54
+
55
+ def initialize_at(time)
56
+ # find out which operators are off duty and schedule their comeback
57
+ @operators_off_work = @operators.select do |x|
58
+ !x.workshift.active_at?(time)
59
+ end
60
+ @operators_off_work.each do |op|
61
+ t = op.workshift.secs_to_begin_of_shift(time)
62
+ @simulation.new_event(Event::ET_OPERATOR_RETURNING, op.oid,
63
+ time + t, @sgid)
64
+ end
65
+
66
+ # find out which operators are on duty and schedule their leaving
67
+ @available_operators = @operators - @operators_off_work
68
+ @available_operators.each do |op|
69
+ t = op.workshift.secs_to_end_of_shift(time)
70
+ @simulation.new_event(Event::ET_OPERATOR_LEAVING, op.oid,
71
+ time + t, @sgid) unless t == WorkShift::Infinity
72
+ end
73
+ end
74
+
75
+
76
+ def new_incident(incident, time)
77
+ # increase number of visited SGs
78
+ incident.visited_support_groups += 1
79
+
80
+ incident_info = {
81
+ # set up incident needed work time
82
+ :needed_work_time => @needed_work_time_rv.next,
83
+ # # reset queue_time_at_last_sg attribute
84
+ # :queue_time => 0
85
+ }
86
+
87
+ # put incident at the end of the queue
88
+ @incident_queue << [ incident, incident_info, time ]
89
+
90
+ # update queue size tracking information
91
+ @incident_queue_info << { :size => @incident_queue.size, :time => time }
92
+ @simulation.new_event(Event::ET_SUPPORT_GROUP_QUEUE_SIZE_CHANGE, @incident_queue.size,
93
+ time, @sgid)
94
+
95
+ # try to allocate operator
96
+ try_to_allocate_operator(time)
97
+ end
98
+
99
+
100
+ def schedule_incident_for_reassignment(incident, incident_info, time)
101
+ @incident_queue.unshift [ incident, incident_info, time ]
102
+ # update queue size tracking information
103
+ @incident_queue_info << { :size => @incident_queue.size, :time => time }
104
+ try_to_allocate_operator(time)
105
+ end
106
+
107
+
108
+ def operator_going_home(operator_id, time)
109
+ op = @operators.find{|x| x.oid == operator_id }
110
+ @available_operators.delete_if{|x| x.oid == operator_id }
111
+ @operators_off_work << op
112
+ @simulation.new_event(Event::ET_OPERATOR_RETURNING, op.oid,
113
+ time + 86400 - op.workshift.duration, @sgid)
114
+ end
115
+
116
+
117
+ def operator_arrived_at_work(operator_id, time)
118
+ op = @operators.find{|x| x.oid == operator_id }
119
+ @operators_off_work.delete(op)
120
+ @available_operators << op
121
+ try_to_allocate_operator(time)
122
+ @simulation.new_event(Event::ET_OPERATOR_LEAVING, op.oid,
123
+ time + op.workshift.duration, @sgid)
124
+ end
125
+
126
+
127
+ def operator_finished_working(operator_id, time)
128
+ op = @operators.find{|x| x.oid == operator_id }
129
+ if op.workshift.secs_to_end_of_shift(time) > 0
130
+ @available_operators << op
131
+ end
132
+ try_to_allocate_operator(time)
133
+ end
134
+
135
+
136
+ private
137
+
138
+ def try_to_allocate_operator(time)
139
+ if !@available_operators.empty? and !@incident_queue.empty?
140
+ op = @available_operators.shift
141
+ i, inc_info, t = @incident_queue.shift
142
+
143
+ # update incident tracking information
144
+ queue_time = time.to_i - t.to_i
145
+ i.add_tracking_information(:type => :queue,
146
+ :at => t,
147
+ :duration => queue_time,
148
+ :sg => @support_group_id)
149
+
150
+
151
+ @simulation.new_event(Event::ET_SUPPORT_GROUP_QUEUE_SIZE_CHANGE,
152
+ @incident_queue.size, time, @sgid)
153
+ @simulation.new_event(Event::ET_INCIDENT_ASSIGNMENT,
154
+ [ i.iid, op.oid ], time, @sgid)
155
+ @simulation.new_event(Event::ET_OPERATOR_ACTIVITY_STARTS,
156
+ [ op.oid, i.iid ], time, @sgid)
157
+
158
+ # assign updates inc_info
159
+ report = op.assign(i, inc_info, time)
160
+ finish_time = report[1]
161
+
162
+ puts "finish_time: #{finish_time}, time: #{time}" if time > finish_time
163
+ @simulation.new_event(Event::ET_OPERATOR_ACTIVITY_FINISHES,
164
+ [ op.oid, i.iid ], finish_time, @sgid)
165
+
166
+ case report[0]
167
+ when :incident_escalation
168
+ @simulation.new_event(Event::ET_INCIDENT_ESCALATION,
169
+ i, finish_time, @sgid)
170
+ when :operator_off_duty
171
+ # TODO: implement configurable rescheduling policy
172
+ @incident_queue << [ i, inc_info, finish_time ]
173
+ @simulation.new_event(Event::ET_INCIDENT_RESCHEDULING,
174
+ [ i, inc_info, op.oid ], finish_time, @sgid)
175
+ end
176
+ end
177
+ end
178
+
179
+ end
180
+
181
+ end
182
+