module ICU
class Tournament
#
# This is the {format}[http://www.fide.com/component/content/article/5-whats-news/2245-736-general-data-exchange-format-for-tournament-results]
# used to submit tournament results to FIDE[http://www.fide.com] for rating.
#
# Suppose, for example, that the following data is the file tournament.tab:
#
# 012 Fantasy Tournament
# 032 IRL
# 042 2009.09.09
# 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
# 132 09.09.09 09.09.10 09.09.11
# 001 1 w Mouse,Minerva 1900 USA 1234567 1928.05.15 1.0 2 2 b 0 3 w 1
# 001 2 m m Duck,Daffy 2200 IRL 7654321 1937.04.17 2.0 1 1 w 1 3 b 1
# 001 3 m g Mouse,Mickey 2600 USA 1726354 1928.05.15 0.0 3 1 b 0 2 w 0
#
# This file can be parsed as follows.
#
# parser = ICU::Tournament::Krause.new
# tournament = parser.parse_file('tournament.tab')
#
# If the file is correctly specified, the return value from the parse_file method is an instance of
# ICU::Tournament (rather than nil, which indicates an error). In this example the file is valid, so:
#
# tournament.name # => "Fantasy Tournament"
# tournament.start # => "2009-09-09"
# tournament.fed # => "IRL"
# tournament.players.size # => 9
#
# Some values, not explicitly set in the file, are deduced:
#
# tournament.rounds # => 3
# tournament.finish # => "2009-09-11"
#
# A player can be retrieved from the tournament via the _players_ array or by sending a valid player number to the _player_ method.
#
# minnie = tournament.player(1)
# minnie.name # => "Mouse, Minerva"
# minnie.points # => 1.0
# minnie.results.size # => 2
#
# daffy = tournament.player(2)
# daffy.title # => "IM"
# daffy.rating # => 2200
# daffy.fide_rating # => nil
# daffy.fed # => "IRL"
# daffy.id # => nil
# daffy.fide_id # => 7654321
# daffy.dob # => "1937-04-17"
#
# By default, ratings are interpreted as ICU. If, instead, they should be interpreted as
# FIDE ratings, add the _fide_ option:
#
# tournament = parser.parse_file('tournament.tab', :fide => true)
# daffy = tournament.player(2)
# daffy.rating # => nil
# daffy.fide_rating # => 2200
#
# ID numbers, on the other hand, are automatically classified as either FIDE or ICU on the basis of size.
# IDs larger than 100000 are assumed to be FIDE IDs, while smaller numbers are treated as ICU IDs.
#
# If the ranking numbers are missing from the file or inconsistent (e.g. player A is ranked above player B
# but has less points) they are recalculated as a side effect of the parse.
#
# daffy.rank # => 1
# minnie.rank # => 2
# mickey.rank # => 3
#
# Comments in the input file (lines that do not start with a valid data identification number) are available from the parser
# instance via its _comments_ method (returning a string). Note that these comments are reset evry time the instance is used
# to parse another file.
#
# parser.comments # => "0123456789..."
#
# If the file contains errors, then the return value from parse_file is nil and
# an error message is returned by the error method of the parser object. The method
# parse_file! is similar except that it raises errors, and the methods parse
# and parse! are similar except their inputs are strings rather than file names.
#
# == Serialization
#
# A tournament can be serialized back to Krause format (the reverse of parsing) with the _serialize_ method of the parser.
#
# krause = parser.serialize(tournament)
#
# Or alternatively, by the _serialize_ method of the tournament object if the name of the serializer is supplied.
#
# krause = tournament.serialize('Krause')
#
# By default, local (ICU) IDs and ratings are used for the serialization, but both methods accept an option that
# causes FIDE IDs and ratings to be used instead:
#
# krause = parser.serialize(tournament, :fide => true)
# krause = tournament.serialize('Krause', :fide => true)
#
# By default all available information is output for each player, however, this is customizable. The player number,
# name, total points and results are always output but any of the remaining data (_gender_, _title_, _rating_ or _fide_rating_,
# _fed_, _id_ or _fide_id_, _dob_ and _rank_) can be omitted, if desired, by specifying an array of columns to include
# or exclude. To omitt all the optional data, supply an empty array:
#
# krause = tournament.serialize('Krause', :only => [])
#
# To omitt just federation and rating but include all others:
#
# krause = tournament.serialize('Krause', :except => [:fed, :rating])
#
# To include only date of birth and title:
#
# krause = tournament.serialize('Krause', :only => [:dob, :title])
#
# To output FIDE IDs and ratings use the _fide_ option in conjunctions with the _id_ and _rating_ options:
#
# krause = tournament.serialize('Krause', :only => [:gender, :id, :rating], :fide => true)
#
# == Parser Strictness
#
# In practice, Krause formatted files encontered in the wild can be produced in a variety of different ways and not always according to
# FIDE's standard, which itself is rather loose. This Ruby gem deals with that situation by not raising parsing errors when data is encountered
# where it is clear what is meant, even if it doesn't conform to the standards, such as they are. However, on output (serialisation) a strict
# interpretation of FIDE's standard is adhered to.
#
# For example in input data if a player's gender is given as "F" it's clear this means female, even though the specification calls for a lower
# case "w" (for woman) in this case. Similarly, for titles where, for example, both "GM" and FIDE's "g" are recognised as meaning Grand Master.
#
# When it comes to dates, the specification recommends the YYYY/MM/DD format for birth dates and YY/MM/DD for round dates but quotes an example where
# the start and finish dates are in the opposite order (DD.MM.YYYY) with a different separator. In practice, the author has encountered Krause files
# with US style date formatting (MM-DD-YYYY) and other bizarre formats (YY.DD.MM) which suffer from ambiguity when the day is 12 or less.
# It's not the separator ("/", "=", ".") that causes a problem but the year, month and day order. The solution adopted here is for all serialized
# dates to be in YYYY-MM-DD format (or YY-MM-DD for round dates which must fit in 8 characters), which is a recognised international standard
# (ISO 8601). However, for parsing, a much wider variation is permitted and there is some ability to detect and correct ambiguous dates. For example
# the following dates would all be interpreted as 2011-03-30:
#
# * 30th March 2011
# * 30.03.2011
# * 03/30/2011
#
# Where no additional information is available to resolve an ambiguity, the month is assumed to come in the middle, so 04/03/2011 is interpreted
# as 2011.03.04 and not 2011.04.03.
#
# Some Krause files that the author has encountered in the wild have 3-letter player federation codes that are not federations at all but something
# completely different (for example the first 3 letters of the player's club). This is a clear violation of the specification and raises a parsing
# exception. However in practice it's often necessary to deal with such files so the parser has two options to help in these cases. If the _fed_ option
# is set to "ignore" then all player federation codes will be ignored, even if valid. While when set to "skip" then invalid codes will be ignored but
# valid ones retained.
#
# tournament = parser.parse_file('tournament.tab', :fed => "ignore")
# tournament = parser.parse_file('tournament.tab', :fed => "skip")
#
# Similar options are available for parsing SwissPerfect files (see ICU::Tournament::SwissPerfect) which can suffer from the same problem.
#
# == Automatic Total Correction
#
# Another problem encountered with Krause files in practice is a mismatch between the declared total points for a player and the sum of their points
# from each round. Normally this just raises a parsing exception. However, there is one set of circumstances when such mismatches can be repaired:
#
# * the declared total score is higher than the sum of scores,
# * the player has at least one bye which isn't a full point bye or at least one round where no result is recorded,
# * the number of byes or missing results is enough to account for the difference in total score.
#
# If all these conditions are met then just enough bye scores are incremented, or new byes created, to make the sum match the total, and the
# data will parse without raising an exception.
#
# 012 Mismatched Totals
# 042 2011.03.04
# 001 1 Mouse,Minerva 1.0 2 2 b 0 0000 - =
# 001 2 Mouse,Mickey 1.5 1 1 w 1
#
# In this example both totals are underestimates. However, player 1 has a half-point bye which can be upgraded to a full-point and player 2
# has no result in round 2 which leaves room for the creation of a new half-point bye. So this data parses without error and serializes to:
#
# 012 Mismatched Totals
# 042 2011-03-04
# 001 1 Mouse,Minerva 1.0 2 2 b 0 0000 - +
# 001 2 Mouse,Mickey 1.5 1 1 w 1 0000 - =
#
# == Tournament Attributes
#
# The following lists Krause data identification numbers, their description and, where available, their corresponding
# attributes in an ICU::Tournament instance.
#
# [001 Player record] Use _players_ to get all players or _player_ with a player number to get a single instance.
# [012 Name] Get or set with _name_. Free text. A tounament name is mandatory.
# [013 Teams] Create an ICU::Team, add player numbers to it, use _add_team_ to add to tournament, _get_team_/_teams_ to retrive it/them.
# [022 City] Get or set with _city_. Free text.
# [032 Federation] Get or set with _fed_. Getter returns either _nil_ or a three letter code. Setter can take various formats (see ICU::Federation).
# [042 Start date] Get or set with _start_. Getter returns yyyy-mm-dd format, but setter can use any reasonable date format. Start date is mandadory.
# [052 End date] Get or set with _finish_. Returns either yyyy-mm-dd format or _nil_ if not set. Like _start_, can be set with various date formats.
# [062 Number of players] Not used. Treated as comment in parsed files. Can be determined from the size of the _players_ array.
# [072 Number of rated players] Not used. Treated as comment in parsed files. Can be determined by analysing the array returned by _players_.
# [082 Number of teams] Not used. Treated as comment in parsed files.
# [092 Type of tournament] Get or set with _type_. Free text.
# [102 Arbiter(s)] Get or set with -arbiter_. Free text.
# [112 Deputy(ies)] Get or set with _deputy_. Free text.
# [122 Time control] Get or set with _time_control_. Free text.
# [132 Round dates] Get an array of dates using _round_dates_ or one specific round date by calling _round_date_ with a round number.
#
class Krause
attr_reader :error, :comments
OPTIONS =
[
[:gender, "Gender"],
[:title, "Title"],
[:rating, "Rating"],
[:fed, "Federation"],
[:id, "ID"],
[:dob, "DOB"],
[:rank, "Rank"],
]
# Parse Krause data returning a Tournament on success or raising an exception on error.
def parse!(krs, arg={})
@lineno = 0
@tournament = Tournament.new('Unspecified', '2000-01-01')
@name_set, @start_set = false, false
@comments = ''
@results = Array.new
krs = ICU::Util::String.to_utf8(krs) unless arg[:is_utf8]
lines = get_lines(krs)
# Process all lines.
lines.each do |line|
@lineno += 1 # increment line number
next if line.match(/^\s*$/) # skip blank lines
@line = line # remember this line for later
# Does it have a DIN or is it just a comment?
if @line.match(/^(\d{3}) (.*)$/)
din = $1 # data identification number (DIN)
@data = $2 # the data after the DIN
else
add_comment
next
end
# Process the line given the DIN.
begin
case din
when '001' then add_player(arg) # player and results record
when '012' then set_name # name (mandatory)
when '013' then add_team # team name and members
when '022' then @tournament.city = @data # city
when '032' then @tournament.fed = @data # federation
when '042' then set_start # start date (mandatory)
when '052' then @tournament.finish = @data # end date
when '062' then add_comment # number of players (calculated from 001 records)
when '072' then add_comment # number of rated players (calculated from 001 records)
when '082' then add_comment # number of teams (calculated from 013 records)
when '092' then @tournament.type = @data # type of tournament
when '102' then @tournament.arbiter = @data # arbiter(s)
when '112' then @tournament.deputy = @data # deputy(ies)
when '122' then @tournament.time_control = @data # time control
when '132' then add_round_dates(arg) # round dates
else raise "invalid DIN #{din}"
end
rescue => err
raise err.class, "line #{@lineno}: #{err.message}", err.backtrace
end
end
# Now that all players are present, add the results to the tournament.
@results.each do |r|
lineno, player, data, result = r
begin
@tournament.add_result(result)
rescue => err
raise "line #{lineno}, player #{player}, result '#{data}': #{err.message}"
end
end
# Certain attributes are mandatory and should have been specifically set.
raise "tournament name missing" unless @name_set
raise "tournament start date missing" unless @start_set
# Finally, exercise the tournament object's internal validation, reranking if neccessary.
@tournament.validate!(:rerank => true)
@tournament
end
# Parse Krause data returning a Tournament on success or a nil on failure.
# In the case of failure, an error message can be retrived via the error method.
def parse(krs, arg={})
begin
parse!(krs, arg)
rescue => ex
@error = ex.message
nil
end
end
# Same as parse! except the input is a file name rather than file contents.
def parse_file!(file, arg={})
krause = ICU::Util::File.read_utf8(file)
arg[:is_utf8] = true
parse!(krause, arg)
end
# Same as parse except the input is a file name rather than file contents.
def parse_file(file, arg={})
begin
parse_file!(file, arg)
rescue => ex
@error = ex.message
nil
end
end
# Serialize a tournament back into Krause format.
def serialize(t, arg={})
t.validate!(:type => self)
krause = ''
krause << "012 #{t.name}\n"
krause << "022 #{t.city}\n" if t.city
krause << "032 #{t.fed}\n" if t.fed
krause << "042 #{t.start}\n"
krause << "052 #{t.finish}\n" if t.finish
krause << "092 #{t.type}\n" if t.type
krause << "102 #{t.arbiter}\n" if t.arbiter
krause << "112 #{t.deputy}\n" if t.deputy
krause << "122 #{t.time_control}\n" if t.time_control
t.teams.each do |team|
krause << sprintf('013 %-31s', team.name)
team.members.each{ |m| krause << sprintf(' %4d', m) }
krause << "\n"
end
rounds = t.last_round
round_dates = t.round_dates
if round_dates.empty?
round_dates = t.guess_round_dates.map { |d| d.to_s }
end
if round_dates.size == rounds && rounds > 0
krause << "132 #{' ' * 85}"
round_dates.each{ |d| krause << d.sub(/^../, ' ') }
krause << "\n"
end
t.players.each{ |p| krause << p.to_krause(rounds, arg) }
krause
end
# Additional tournament validation rules for this specific type.
def validate!(t)
# None.
end
# :enddoc:
private
def set_name
@tournament.name = @data
@name_set = true
end
def set_start
@tournament.start = @data
@start_set = true
end
# Split text into lines but also pad the player lines (those beginning "001 ").
def get_lines(text)
lines = text.split(/\s*\n/)
max = 99 # length up to the end of round 1 result, including DIN
lines.each do |line|
next unless line.match(/^001 /)
next unless line.length > max
max+= 10 * (1 + (line.length - max - 1) / 10) # increase by multiples of 10, the length of 1 result (including 2-space prefix)
end
lines.each_index do |i|
line = lines[i]
next unless line.match(/^001 /)
next unless line.length < max
line+= ' ' * (max - line.length)
lines[i] = line
end
lines
end
def add_player(arg)
raise "player record less than minimum length" if @line.length < 99
# Prepare player details.
num = @data[0, 4]
nam = @data[10, 32]
nams = nam.split(/,/)
raise "missing comma in name '#{nam.strip}'" unless nams.size > 1
opt =
{
:gender => @data[5, 1],
:title => @data[6, 3],
:fed => @data[49, 3],
:dob => @data[65, 10],
:rank => @data[81, 4],
}
# Ratings are assumed to be local unless otherwise specified.
rating = @data[44, 4].to_i
opt[arg[:fide] ? :fide_rating : :rating] = rating if rating > 0 && rating < 4000
# Strings that can't possibly be DOBs should just be ignored.
opt[:dob] = '' unless opt[:dob].match(/^(\d{4}.\d\d.\d\d|\d\d.\d\d.\d{4})$/);
# IDs can be determined to be FIDE or ICU on the basis of their size.
id = @data[53, 11].to_i
opt[id >= 100000 ? :fide_id : :id] = id if id > 0
# Options to remove other bad data.
opt.delete(:fed) if arg[:fed].to_s == 'ignore'
opt.delete(:fed) if arg[:fed].to_s == 'skip' && !ICU::Federation.find(opt[:fed])
# Create the player.
player = Player.new(nams.last, nams.first, num, opt)
@tournament.add_player(player)
# Results.
total = @data[76, 4].strip
total = total == '' ? nil : total.to_f
index = 87
round = 1
sum = 0.0
full_byes = []
half_byes = []
while @data.length > index
sum+= add_result(round, player.num, @data[index, 8], full_byes, half_byes)
index+= 10
round+= 1
end
if total
sum = total if total != sum && fix_sum(player.num, full_byes, half_byes, total, sum)
raise "declared points total (#{total}) does not agree with summed scores (#{sum})" if total != sum
end
end
def add_result(round, player, data, full_byes, half_byes)
data.strip!
if data.match(/^-?$/)
full_byes << round
return 0.0
end
data = "#{data} -" if data.match(/^(\d+)? (w|b|-)$/)
if data.match(/^(0{1,4}|[1-9]\d{0,3}) (w|b|-) (1|0|=|\+|-)$/)
opponent = $1.to_i
colour = $2
score = $3
elsif data.match(/- (1|0|=|\+|-)$/)
opponent = 0
colour = "-"
score = $1
else
raise "invalid result '#{data}'"
end
options = Hash.new
options[:opponent] = opponent unless opponent == 0
options[:colour] = colour unless colour == '-'
options[:rateable] = false unless score.match(/^(1|0|=)$/)
result = Result.new(round, player, score, options)
@results << [@lineno, player, data, result]
if opponent == 0
case score
when '-' then full_byes << result
when '=' then half_byes << result
end
end
result.points
end
# See if byes can be used to make the sum of scores match the declared total.
def fix_sum(player, full_byes, half_byes, total, sum)
return false unless total > sum
return false unless total <= sum + full_byes.size * 1.0 + half_byes.size * 0.5
full_byes.each_index do |i|
bye = full_byes[i]
if bye.class == Integer
# Round number - create a half-point bye in that round.
result = Result.new(bye, player, '=')
@results << ['none', player, "extra bye for player #{player} in round #{bye}", result]
full_byes[i] = result
else
# Zero point bye - upgrade to a half point.
bye.score = 'D'
end
sum += 0.5
return true if total == sum
end
(half_byes + full_byes).each do |bye|
# Upgrade to full point.
bye.score = 'W'
sum += 0.5
return true if total == sum
end
return false
end
def add_team
raise error "team record less than minimum length" if @line.length < 40
team = Team.new(@data[0, 31])
index = 32
while @data.length >= index + 4
team.add_member(@data[index, 4])
index+= 5
end
@tournament.add_team(team)
end
def add_round_dates(arg)
return if arg[:round_dates].to_s == 'ignore'
raise "round dates record less than minimum length" if @line.length < 99
index = 87
american = nil
while @data.length >= index + 8
date = @data[index, 8].strip
# Cope with heinous date formats like yy.dd.mm.
if date.match((/^(\d{2}).(\d{2}).(\d{2})$/))
if american.nil?
american = $2.to_i > 12 || (@tournament.start[5,2] == $3 && @tournament.start[8,2] != $2)
end
date = "#{$1}.#{$3}.#{$2}" if american
end
@tournament.add_round_date("20#{date}") unless date == ''
index+= 10
end
end
def add_comment
@comments << @line
@comments << "\n"
end
end
end
class Player
# Format a player's 001 record as it would appear in a Krause formatted file (including the final newline).
def to_krause(rounds, arg)
defaults = ICU::Tournament::Krause::OPTIONS.map(&:first)
# Optional columns.
case
when arg[:except].instance_of?(Array)
optional = (Set.new(defaults) - arg[:except].map!(&:to_s).map!(&:to_sym)).to_a
when arg[:only].instance_of?(Array)
optional = arg[:only].map!(&:to_s).map!(&:to_sym)
else
optional = defaults
end
optional = optional.inject({}) { |m, a| m[a] = true; m }
# Get the values to use.
val = defaults.inject({}) do |m, a|
if optional[a]
if arg[:fide] && (a == :rating || a == :id)
m[a] = send("fide_#{a}")
else
m[a] = send(a)
end
end
m
end
# Output the mandatory and optional values.
krause = '001'
krause << sprintf(' %4d', @num)
krause << sprintf(' %1s', case val[:gender]; when 'M' then 'm'; when 'F' then 'w'; else ''; end)
krause << sprintf(' %2s', case val[:title]; when nil then ''; when 'IM' then 'm'; when 'WIM' then 'wm'; else val[:title][0, val[:title].length-1].downcase; end)
krause << sprintf(' %-33s', "#{@last_name},#{@first_name}")
krause << sprintf(' %4s', val[:rating])
krause << sprintf(' %3s', val[:fed])
krause << sprintf(' %11s', val[:id])
krause << sprintf(' %10s', val[:dob])
krause << sprintf(' %4.1f', points)
krause << sprintf(' %4s', val[:rank])
# And finally the round scores.
(1..rounds).each do |r|
result = find_result(r)
krause << sprintf(' %8s', result ? result.to_krause : '')
end
krause << "\n"
end
end
class Result
# Format a player's result as it would appear in a Krause formatted file (exactly 8 characters long, including leading whitespace).
def to_krause
return ' ' * 8 if !@opponent && !@colour && @score == 'L'
krause = sprintf('%4s ', @opponent || '0000')
krause << sprintf('%1s ', @colour ? @colour.downcase : '-')
krause << case @score; when 'W' then '1'; when 'L' then '0'; else '='; end if @rateable
krause << case @score; when 'W' then '+'; when 'L' then '-'; else '='; end if !@rateable
krause
end
end
end