# encoding: utf-8
module ICU
#
# == Creating Tournaments
#
# ICU::RatedTournament objects are created directly.
#
# t = ICU::RatedTournament.new
#
# They have some optional parameters which can be set via the constructor or by calling
# the same-named setter methods. One is called _desc_ (short for description) the value
# of which can be any object but will, if utilized, typically be the name of the
# tournament as a string.
#
# t = ICU::RatedTournament.new(:desc => "Irish Championships 2010")
# puts t.desc # "Irish Championships 2010"
#
# Another optional parameter is _start_ for the start date. A Date object or a string that can be
# parsed as a string can be used to set it. The European convention is preferred for dates like
# "03/06/2013" (3rd of June, not 6th of March). Attempting to set an invalid date will raise an
# exception.
#
# t = ICU::RatedTournament.new(:start => "01/07/2010")
# puts t.start.class # Date
# puts t.start.to_s # "2010-07-01"
#
# Also, there is the option _no_bonuses_. Bonuses are a feature of the ICU rating system. If you are
# rating a non-ICU tournament (such as a FIDE tournament) where bonues are not awarded use this option.
#
# t = ICU::RatedTournament.new(:desc => 'Linares', :start => "07/02/2009", :no_bonuses => true)
#
# Note, however, that the ICU system also has its own unique way of treating provisional, unrated and
# foreign players, so the only FIDE tournaments that can be rated using this software are those that
# consist solely of rated players.
#
# == Rating Tournaments
#
# To rate a tournament, first add the players (see ICU::RatedPlayer for details):
#
# t.add_player(1, :rating => 2534, :kfactor => 16)
# # ...
#
# Then add the results (see ICU::RatedResult for details):
#
# t.add_result(1, 1, 2, 'W')
# # ...
#
# Then rate the tournament by calling the rate! method:
#
# t.rate!
#
# Now the results of the rating calculations can be retrieved from the players in the tournement
# or their results. For example, player 1's new rating would be:
#
# t.player(1).new_rating
#
# See ICU::RatedPlayer and ICU::RatedResult for more details.
#
# The rate! method takes an optional version argument to control the precise algorithm, for example:
#
# t.rate!(version: 2)
#
# Without a version number or with version 0, the original pre-2012 algorithm is used. However, some improvements
# have since been found (see http://ratings.icu.ie/articles/18 for more details) and currently
# the recommended version to use is 2.
#
# == Error Handling
#
# Some of the above methods have the potential to raise RuntimeError exceptions.
# In the case of _add_player_ and _add_result_, the use of invalid arguments would
# cause such an error. Theoretically, the rate! method could also throw
# an exception if the iterative algorithm it uses to estimate performance ratings
# of unrated players failed to converge. However an instance of non-convergence
# has yet to be observed in practice.
#
# Since exception throwing is how errors are signalled, you should arrange
# for them to be caught and handled in some suitable place in your code.
#
class RatedTournament
attr_accessor :desc
attr_reader :start, :no_bonuses, :iterations1, :iterations2
# Add a new player to the tournament. Returns the instance of ICU::RatedPlayer created.
# See ICU::RatedPlayer for details.
def add_player(num, args={})
raise "player with number #{num} already exists" if @player[num]
args[:kfactor] = ICU::RatedPlayer.kfactor(args[:kfactor].merge({ :start => start, :rating => args[:rating] })) if args[:kfactor].is_a?(Hash)
@player[num] = ICU::RatedPlayer.factory(num, args)
end
# Add a new result to the tournament. Two instances of ICU::RatedResult are
# created. One is added to the first player and the other to the second player.
# The method returns _nil_. See ICU::RatedResult for details.
def add_result(round, player, opponent, score)
n1 = player.is_a?(ICU::RatedPlayer) ? player.num : player.to_i
n2 = opponent.is_a?(ICU::RatedPlayer) ? opponent.num : opponent.to_i
p1 = @player[n1] || raise("no such player number (#{n1})")
p2 = @player[n2] || raise("no such player number (#{n2})")
r1 = ICU::RatedResult.new(round, p2, score)
r2 = ICU::RatedResult.new(round, p1, r1.opponents_score)
p1.add_result(r1)
p2.add_result(r2)
nil
end
# Rate the tournament. Called after all players and results have been added.
def rate!(opt={})
# The original algorithm (version 0).
max_iterations = [30, 1]
phase_2_bonuses = true
update_bonuses = false
threshold = 0.5
# New versions of the algorithm.
version = opt[:version].to_i
if version >= 1
# See http://ratings.icu.ie/articles/18 (Part 1)
max_iterations[1] = 30
end
if version >= 2
# See http://ratings.icu.ie/articles/18 (Part 2)
phase_2_bonuses = false
update_bonuses = true
threshold = 0.1
end
if version >= 3
# See http://ratings.icu.ie/articles/18 (Part 3)
max_iterations = [50, 50]
end
# Phase 1.
players.each { |p| p.reset }
@iterations1 = performance_ratings(max_iterations[0], threshold)
players.each { |p| p.rate! }
# Phase 2.
if !no_bonuses && calculate_bonuses > 0
players.each { |p| p.rate!(update_bonuses) }
@iterations2 = performance_ratings(max_iterations[1], threshold)
calculate_bonuses if phase_2_bonuses
else
@iterations2 = 0
end
end
# Return an array of all players, in order of player number.
def players
@player.keys.sort.map{ |num| @player[num] }
end
# Return a player (ICU::RatedPlayer) given a player number (returns _nil_ if the number is invalid).
def player(num)
@player[num]
end
# Set the start date. Raises exception on error.
def start=(date)
@start = ICU::Util::Date.parsedate!(date)
end
# Set whether there are no bonuses (false by default).
def no_bonuses=(no_bonuses)
@no_bonuses = no_bonuses ? true : false
end
private
# Create a new, empty (no players, no results) tournament.
def initialize(opt={})
[:desc, :start, :no_bonuses].each { |atr| self.send("#{atr}=", opt[atr]) unless opt[atr].nil? }
@player = Hash.new
end
# Calculate performance ratings either iteratively up to a maximum number.
def performance_ratings(max, thresh)
stable, count = false, 0
while !stable && count < max
@player.values.each { |p| p.estimate_performance }
stable = @player.values.inject(true) { |ok, p| p.update_performance(thresh) && ok }
count+= 1
end
raise "performance rating estimation did not converge" if max > 1 && !stable
count
end
# Calculate bonuses for all players and return the number who got one.
def calculate_bonuses
@player.values.select{ |p| p.respond_to?(:bonus) }.inject(0) { |t,p| t + (p.calculate_bonus ? 1 : 0) }
end
end
end