icu_tournament 1.2.8 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.rdoc CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  For reading or writing files of chess tournament data. Original project name on github was _chess_icu_.
4
4
 
5
-
6
5
  == Install
7
6
 
8
7
  For ruby 1.9.2 (version 1.1.2 was the last compatible with ruby 1.8.7).
@@ -27,6 +26,7 @@ The currently supported formats are:
27
26
  * ICU::Tournament::Krause - the format used by FIDE.
28
27
  * ICU::Tournament::ForeignCSV - used by Irish players to report their individual results in foreign tournaments.
29
28
  * ICU::Tournament::SwissPerfect - often used by Irish tournament controllers to report results.
29
+ * ICU::Tournament::SPExport - the SwissPerfect text export format.
30
30
 
31
31
  == Writing Files
32
32
 
@@ -5,6 +5,6 @@ require 'icu_name'
5
5
  icu_tournament_files = Array.new
6
6
  icu_tournament_files.concat %w{util federation}
7
7
  icu_tournament_files.concat %w{player result team tournament}
8
- icu_tournament_files.concat %w{fcsv krause sp}.map{ |f| "tournament_#{f}"}
8
+ icu_tournament_files.concat %w{fcsv krause sp spx}.map{ |f| "tournament_#{f}"}
9
9
 
10
10
  icu_tournament_files.each { |file| require "icu_tournament/#{file}" }
@@ -81,6 +81,10 @@ module ICU
81
81
  matches[0]
82
82
  end
83
83
 
84
+ # Return an array of codes and names suitable for creating a federation menu in Rails.
85
+ # ICU::Federation.menu(:order => 'code') # order federations by code (instead of by name)
86
+ # ICU::Federation.menu(:top => 'IRL') # make this federation come first
87
+ # ICU::Federation.menu(:none => 'None') # add a dummy top entry with name "None" and blank code
84
88
  def self.menu(opts = {})
85
89
  compile
86
90
  top, menu = nil, []
@@ -91,6 +95,7 @@ module ICU
91
95
  menu
92
96
  end
93
97
 
98
+ # Return an array of sorted federation codes.
94
99
  def self.codes
95
100
  compile
96
101
  @@objects.map(&:code).sort
@@ -101,6 +106,7 @@ module ICU
101
106
  @name = name
102
107
  end
103
108
 
109
+ # :enddoc:
104
110
  private
105
111
 
106
112
  def self.compile
@@ -12,9 +12,10 @@ module ICU
12
12
  #
13
13
  # In addition, players have a number of optional attributes which can be specified
14
14
  # via setters or in constructor hash options: _id_ (local or national ID), _fide_
15
- # (FIDE ID), _fed_ (federation), _title_, _rating_, _rank_ and _dob_ (date of birth).
15
+ # (FIDE ID), _fed_ (federation), _title_, _rating_ (local rating), _fide_rating,
16
+ # _rank_ and _dob_ (date of birth).
16
17
  #
17
- # peter = ICU::Player.new('Peter', 'Svidler', 21, :fed => 'rus', :title => 'g', :rating = 2700)
18
+ # peter = ICU::Player.new('Peter', 'Svidler', 21, :fed => 'rus', :title => 'g', :fide_rating = 2700)
18
19
  # peter.dob = '17th June, 1976'
19
20
  # peter.rank = 1
20
21
  #
@@ -80,11 +81,10 @@ module ICU
80
81
  # fox1.fed # => 'IRL'
81
82
  # fox1.gender # => 'M'
82
83
  #
83
- #
84
84
  class Player
85
85
  extend ICU::Accessor
86
86
  attr_integer :num
87
- attr_positive_or_nil :id, :fide, :rating, :rank
87
+ attr_positive_or_nil :id, :fide, :rating, :fide_rating, :rank
88
88
  attr_date_or_nil :dob
89
89
 
90
90
  attr_reader :results, :first_name, :last_name, :fed, :title, :gender
@@ -94,7 +94,7 @@ module ICU
94
94
  self.first_name = first_name
95
95
  self.last_name = last_name
96
96
  self.num = num
97
- [:id, :fide, :fed, :title, :rating, :rank, :dob, :gender].each do |atr|
97
+ [:id, :fide, :fed, :title, :rating, :fide_rating, :rank, :dob, :gender].each do |atr|
98
98
  self.send("#{atr}=", opt[atr]) unless opt[atr].nil?
99
99
  end
100
100
  @results = []
@@ -192,7 +192,7 @@ module ICU
192
192
  def eql?(other)
193
193
  return true if equal?(other)
194
194
  return false unless self == other
195
- [:id, :fide, :rating, :title, :gender].each do |m|
195
+ [:id, :fide, :rating, :fide_rating, :title, :gender].each do |m|
196
196
  return false if self.send(m) && other.send(m) && self.send(m) != other.send(m)
197
197
  end
198
198
  true
@@ -201,7 +201,7 @@ module ICU
201
201
  # Merge in some of the details of another player.
202
202
  def merge(other)
203
203
  raise "cannot merge two players that are not equal" unless self == other
204
- [:id, :fide, :rating, :title, :fed, :gender].each do |m|
204
+ [:id, :fide, :rating, :fide_rating, :title, :fed, :gender].each do |m|
205
205
  self.send("#{m}=", other.send(m)) unless self.send(m)
206
206
  end
207
207
  end
@@ -95,7 +95,7 @@ module ICU
95
95
  # t.validate(:rerank => true)
96
96
  #
97
97
  # Ranking is inconsistent if some but not all players have a rank or if all players
98
- # have a rank but some are ranked higher than others on lower scores.
98
+ # but at least one pair of players exist where one has a higher score but a lower rank.
99
99
  #
100
100
  # To rank the players requires a tie break method to be specified to order players on the same score.
101
101
  # The default is alphabetical (by last name then first name). Other methods can be specified by supplying
@@ -112,7 +112,7 @@ module ICU
112
112
  # * _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50%
113
113
  # * _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against
114
114
  # * _progressive_ (or _cumulative_): sum of running score for each round
115
- # * _ratings_: sum of opponents ratings
115
+ # * _ratings_: sum of opponents ratings (FIDE ratings are used in preference to local ratings if available)
116
116
  # * _blacks_: number of blacks
117
117
  # * _wins_: number of wins
118
118
  # * _name_: alphabetical by name (if _tie_breaks_ is set to an empty array, as it is initially, then this will be used as the back-up tie breaker)
@@ -382,7 +382,7 @@ module ICU
382
382
  # Convenience method to parse a file.
383
383
  def self.parse_file!(file, format, opts={})
384
384
  type = format.to_s
385
- raise "Invalid format" unless type.match(/^(SwissPerfect|Krause|ForeignCSV)$/);
385
+ raise "Invalid format" unless type.match(/^(SwissPerfect|SPExport|Krause|ForeignCSV)$/);
386
386
  parser = "ICU::Tournament::#{format}".constantize.new
387
387
  if type == 'ForeignCSV'
388
388
  # Doesn't take options.
@@ -398,15 +398,16 @@ module ICU
398
398
  # or if the tournament is unsuitable for serialisation in that format.
399
399
  def serialize(format, arg={})
400
400
  serializer = case format.to_s.downcase
401
- when 'krause' then ICU::Tournament::Krause.new
402
- when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
403
- when 'swissperfect' then ICU::Tournament::SwissPerfect.new
404
- when '' then raise "no format supplied"
401
+ when 'krause' then ICU::Tournament::Krause.new
402
+ when 'foreigncsv' then ICU::Tournament::ForeignCSV.new
403
+ when 'spexport' then ICU::Tournament::SPExport.new
404
+ when '' then raise "no format supplied"
405
405
  else raise "unsupported serialisation format: '#{format}'"
406
406
  end
407
407
  serializer.serialize(self, arg)
408
408
  end
409
409
 
410
+ # :enddoc:
410
411
  private
411
412
 
412
413
  # Check players.
@@ -500,8 +501,8 @@ module ICU
500
501
  def check_type(type)
501
502
  if type.respond_to?(:validate!)
502
503
  type.validate!(self)
503
- elsif type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect)$/)
504
- parser = "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self)
504
+ elsif type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect|SPExport)$/)
505
+ "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self)
505
506
  else
506
507
  raise "invalid type supplied for validation check"
507
508
  end
@@ -563,7 +564,7 @@ module ICU
563
564
  when 'neustadtl' then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] * r.points : 0.0) }
564
565
  when 'opp-score' then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5
565
566
  when 'progressive' then (1..rounds).inject(0.0) { |t,n| r = player.find_result(n); s = r ? r.points : 0.0; t + s * (rounds + 1 - n) }
566
- when 'ratings' then player.results.inject(0) { |t,r| t + (r.opponent && @player[r.opponent].rating ? @player[r.opponent].rating : 0) }
567
+ when 'ratings' then player.results.inject(0) { |t,r| t + (r.opponent && (@player[r.opponent].fide_rating || @player[r.opponent].rating) ? (@player[r.opponent].fide_rating || @player[r.opponent].rating) : 0) }
567
568
  when 'harkness', 'modified'
568
569
  scores = player.results.map{ |r| r.opponent ? hash['opp-score'][r.opponent] : 0.0 }.sort
569
570
  1.upto(rounds - player.results.size) { scores << 0.0 }
@@ -97,19 +97,20 @@ module ICU
97
97
  # ICU::Tournament::ForeignCSV.new.serialize(tournament)
98
98
  #
99
99
  # You can also build the tournament object from scratch using your own data and then serialize it.
100
- # For example, here are the commands to reproduce the example above.
100
+ # For example, here are the commands to reproduce the example above. Note that in this format
101
+ # opponents' ratings are FIDE while players' IDs are ICU.
101
102
  #
102
103
  # t = ICU::Tournament.new("Isle of Man Masters, 2007", '2007-09-22', :rounds => 9)
103
104
  # t.site = 'http://www.bcmchess.co.uk/monarch2007/'
104
- # t.add_player(ICU::Player.new('Anthony', 'Fox', 1, :rating => 2100, :fed => 'IRL', :id => 456))
105
- # t.add_player(ICU::Player.new('Peter P.', 'Taylor', 2, :rating => 2209, :fed => 'ENG'))
106
- # t.add_player(ICU::Player.new('Egozi', 'Nadav', 3, :rating => 2205, :fed => 'ISR'))
107
- # t.add_player(ICU::Player.new('Peter', 'Cafolla', 4, :rating => 2048, :fed => 'IRL'))
108
- # t.add_player(ICU::Player.new('Tim R.', 'Spanton', 5, :rating => 1982, :fed => 'ENG'))
109
- # t.add_player(ICU::Player.new('Alan', 'Grant', 6, :rating => 2223, :fed => 'SCO'))
110
- # t.add_player(ICU::Player.new('Alan J.', 'Walton', 7, :rating => 2223, :fed => 'ENG'))
111
- # t.add_player(ICU::Player.new('Bernard', 'Bannink', 8, :rating => 2271, :fed => 'NED', :title => 'FM'))
112
- # t.add_player(ICU::Player.new('Roy', 'Phillips', 9, :rating => 2271, :fed => 'MAU'))
105
+ # t.add_player(ICU::Player.new('Anthony', 'Fox', 1, :fide_rating => 2100, :fed => 'IRL', :id => 456))
106
+ # t.add_player(ICU::Player.new('Peter P.', 'Taylor', 2, :fide_rating => 2209, :fed => 'ENG'))
107
+ # t.add_player(ICU::Player.new('Egozi', 'Nadav', 3, :fide_rating => 2205, :fed => 'ISR'))
108
+ # t.add_player(ICU::Player.new('Peter', 'Cafolla', 4, :fide_rating => 2048, :fed => 'IRL'))
109
+ # t.add_player(ICU::Player.new('Tim R.', 'Spanton', 5, :fide_rating => 1982, :fed => 'ENG'))
110
+ # t.add_player(ICU::Player.new('Alan', 'Grant', 6, :fide_rating => 2223, :fed => 'SCO'))
111
+ # t.add_player(ICU::Player.new('Alan J.', 'Walton', 7, :fide_rating => 2223, :fed => 'ENG'))
112
+ # t.add_player(ICU::Player.new('Bernard', 'Bannink', 8, :fide_rating => 2271, :fed => 'NED', :title => 'FM'))
113
+ # t.add_player(ICU::Player.new('Roy', 'Phillips', 9, :fide_rating => 2271, :fed => 'MAU'))
113
114
  # t.add_result(ICU::Result.new(1, 1, 'L', :opponent => 2, :colour => 'B'))
114
115
  # t.add_result(ICU::Result.new(2, 1, 'D', :opponent => 3, :colour => 'W'))
115
116
  # t.add_result(ICU::Result.new(3, 1, 'D', :opponent => 4, :colour => 'B'))
@@ -221,7 +222,7 @@ module ICU
221
222
  o = t.player(r.opponent)
222
223
  data << o.last_name
223
224
  data << o.first_name
224
- data << o.rating
225
+ data << o.fide_rating
225
226
  data << o.title
226
227
  data << o.fed
227
228
  else
@@ -250,6 +251,7 @@ module ICU
250
251
  end
251
252
  end
252
253
 
254
+ # :enddoc:
253
255
  private
254
256
 
255
257
  def event
@@ -308,7 +310,7 @@ module ICU
308
310
  @tournament.add_result(result)
309
311
  else
310
312
  result.colour = @r[2]
311
- opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :rating => @r[5], :title => @r[6], :fed => @r[7])
313
+ opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :fide_rating => @r[5], :title => @r[6], :fed => @r[7])
312
314
  raise "opponent must have a federation" unless opponent.fed
313
315
  old_player = @tournament.find_player(opponent)
314
316
  if old_player
@@ -48,13 +48,15 @@ module ICU
48
48
  # daffy.fide # => nil
49
49
  # daffy.dob # => "1937-04-17"
50
50
  #
51
- # By default, ID numbers in the input are interpreted as local IDs. If, instead, they should be interpreted as
52
- # FIDE IDs, add the following option:
51
+ # By default, ID numbers and ratings in the input are interpreted as local IDs and ratings. If, instead, they should be interpreted as
52
+ # FIDE IDs and ratings, add the following option:
53
53
  #
54
54
  # tournament = parser.parse_file('tournament.tab', :fide => true)
55
55
  # daffy = tournament.player(2)
56
56
  # daffy.id # => nil
57
57
  # daffy.fide # => 7654321
58
+ # daffy.rating # => nil
59
+ # daffy.fide_rating # => 2200
58
60
  #
59
61
  # If the ranking numbers are missing from the file or inconsistent (e.g. player A is ranked above player B
60
62
  # but has less points than player B) they are recalculated as a side effect of the parse.
@@ -82,11 +84,11 @@ module ICU
82
84
  #
83
85
  # krause = tournament.serialize('Krause')
84
86
  #
85
- # By default, local (ICU) IDs are used for the serialization, but both methods accept an option that
86
- # causes FIDE IDs to be used instead:
87
+ # By default, local (ICU) IDs and ratings are used for the serialization, but both methods accept an option that
88
+ # causes FIDE IDs and ratings to be used instead:
87
89
  #
88
90
  # krause = parser.serialize(tournament, :fide => true)
89
- # krause = parser.serialize(tournament, :fide => true)
91
+ # krause = tournament.serialize('Krause', :fide => true)
90
92
  #
91
93
  # The following lists Krause data identification numbers, their description and, where available, their corresponding
92
94
  # attributes in an ICU::Tournament instance.
@@ -241,6 +243,7 @@ module ICU
241
243
  # None.
242
244
  end
243
245
 
246
+ # :enddoc:
244
247
  private
245
248
 
246
249
  def set_name
@@ -263,12 +266,12 @@ module ICU
263
266
  {
264
267
  :gender => @data[5, 1],
265
268
  :title => @data[6, 3],
266
- :rating => @data[44, 4],
267
269
  :fed => @data[49, 3],
268
270
  :dob => @data[65, 10],
269
271
  :rank => @data[81, 4],
270
272
  }
271
273
  opt[arg[:fide] ? :fide : :id] = @data[53, 11]
274
+ opt[arg[:fide] ? :fide_rating : :rating] = @data[44, 4]
272
275
  player = Player.new(nam.first, nam.last, num, opt)
273
276
  @tournament.add_player(player)
274
277
 
@@ -337,7 +340,7 @@ module ICU
337
340
  krause << sprintf(' %1s', case @gender; when 'M' then 'm'; when 'F' then 'w'; else ''; end)
338
341
  krause << sprintf(' %2s', case @title; when nil then ''; when 'IM' then 'm'; when 'WIM' then 'wm'; else @title[0, @title.length-1].downcase; end)
339
342
  krause << sprintf(' %-33s', "#{@last_name},#{@first_name}")
340
- krause << sprintf(' %4s', @rating)
343
+ krause << sprintf(' %4s', arg[:fide] ? @fide_rating : @rating)
341
344
  krause << sprintf(' %3s', @fed)
342
345
  krause << sprintf(' %11s', arg[:fide] ? @fide : @id)
343
346
  krause << sprintf(' %10s', @dob)
@@ -35,22 +35,17 @@ module ICU
35
35
  # If no start date is supplied it will default to 2000-01-01, and can be reset later.
36
36
  #
37
37
  # tournament = parser.parse_file('champs.zip')
38
- # tournament.start # => '2000-01-01'
38
+ # tournament.start # => '2000-01-01'
39
39
  # tournament.start = '2010-07-03'
40
40
  #
41
- # SwissPerfect files have slots for both local and international IDs and these, if present
42
- # and if intergers are copied to the _id_ and _fide_ attributes respectively.
43
- #
44
- # By default, the parser extracts the local rating from the SwissPerfect files and not the international one.
45
- # If international ratings are required instead, set the _rating_ option to "intl". For example:
41
+ # SwissPerfect files have slots for both local and international IDs and ratings and these, if present
42
+ # (and if integers) are copied to the _id_, _fide_, _rating_ and _fide_rating_ attributes.
46
43
  #
47
44
  # tournament = parser.parse_file('ncc', :start => '2010-05-08')
48
- # tournament.player(2).id # => 12379 (ICU ID)
49
- # tournament.player(2).fide # => 1205064 (FIDE ID)
50
- # tournament.player(2).rating # => 2556 (ICU rating)
51
- #
52
- # tournament = parser.parse_file('ncc', :start => '2010-05-08', :rating => 'intl')
53
- # tournament.player(2).rating # => 2530 (FIDE rating)
45
+ # tournament.player(2).id # => 12379 (ICU ID)
46
+ # tournament.player(2).fide # => 1205064 (FIDE ID)
47
+ # tournament.player(2).rating # => 2556 (ICU rating)
48
+ # tournament.player(2).fide_rating # => 2530 (FIDE rating)
54
49
  #
55
50
  # By default, the parse will fail completely if the ".trn" file contains any invalid federations (see ICU::Federation).
56
51
  # There are two alternative behaviours controlled by setting the _fed_ option:
@@ -62,55 +57,23 @@ module ICU
62
57
  #
63
58
  # Because the data is in three parts, some of which are in a legacy binary format, serialization to this format is
64
59
  # not supported. Instead, a method is provided to serialize any tournament type into the text export format of
65
- # erfect, an example of which is shown below.
66
- #
67
- # No Name Loc Id Total 1 2 3
68
- #
69
- # 1 Griffiths, Ryan-Rhys 6897 3 4:W 2:W 3:W
70
- # 2 Flynn, Jamie 5226 2 3:W 1:L 4:W
71
- # 3 Hulleman, Leon 6409 1 2:L 4:W 1:L
72
- # 4 Dunne, Thomas 10914 0 1:L 3:L 2:L
73
- #
74
- # This format is important in Irish chess, as it's the format used to submit results to the <em>MicroSoft Access</em>
75
- # implementation of the ICU ratings database.
76
- #
77
- # swiss_perfect = tournament.serialize('SwissPerfect')
78
- #
79
- # The order of players in the serialized output is always by player number and as a side effect of serialization,
80
- # the player numbers will be adjusted to ensure they range from 1 to the total number of players (i.e. renumbered
81
- # in order). If you would prefer rank-order instead, then you must first renumber the players by rank (the default
82
- # renumbering method) before serializing. For example:
83
- #
84
- # swiss_perfect = tournament.renumber.serialize('SwissPerfect')
85
- #
86
- # There should be no need to explicitly rank the tournament first, as that information is already present in
87
- # SwissPerfect files (i.e. each player should already have a rank after the files have been parsed).
88
- # Additionally, the tie break rules used for the tournament are available from the _tie_break_ method,
89
- # for example:
90
- #
91
- # tournament.tie_breaks # => [:buchholz, :harkness]
92
- #
93
- # Should you wish to rank the tournament using a different set of tie-break rules, you can do something like the following:
94
- #
95
- # tournament.tie_breaks = [:wins, :blacks]
96
- # swiss_perfect = tournament.rerank.renumber.serialize('SwissPerfect')
97
- #
98
- # See ICU::Tournament for more about tie-breaks.
60
+ # SwissPerfect (see ICU::Tournament::SPExport).
99
61
  #
100
62
  class SwissPerfect
101
63
  attr_reader :error
102
64
 
103
65
  TRN = {
104
- :dob => "BIRTH_DATE",
105
- :fed => "FEDER",
106
- :first_name => "FIRSTNAME",
107
- :gender => "SEX",
108
- :id => "LOC_ID",
109
- :fide => "INTL_ID",
110
- :last_name => "SURNAME",
111
- :num => "ID",
112
- :rank => "ORDER",
113
- :rating => ["LOC_RTG", "INTL_RTG"],
66
+ :dob => "BIRTH_DATE",
67
+ :fed => "FEDER",
68
+ :first_name => "FIRSTNAME",
69
+ :gender => "SEX",
70
+ :id => "LOC_ID",
71
+ :fide => "INTL_ID",
72
+ :last_name => "SURNAME",
73
+ :num => "ID",
74
+ :rank => "ORDER",
75
+ :rating => "LOC_RTG",
76
+ :fide_rating => "INTL_RTG",
114
77
  } # not used: ABSENT BOARD CLUB FORB_PAIRS LATE_ENTRY LOC_RTG2 MEMO TEAM TECH_SCORE WITHDRAWAL (START_NO, BONUS used below)
115
78
 
116
79
  SCO = %w{ROUND WHITE BLACK W_SCORE B_SCORE W_TYPE B_TYPE} # not used W_SUBSCO, B_SUBSCO
@@ -175,6 +138,7 @@ module ICU
175
138
  # None.
176
139
  end
177
140
 
141
+ # :enddoc:
178
142
  private
179
143
 
180
144
  def get_files(file, arg)
@@ -280,15 +244,15 @@ module ICU
280
244
  @start_no[r.attributes["ID"]] = r.attributes["START_NO"]
281
245
  TRN.inject(Hash.new) do |hash, pair|
282
246
  key = pair[1]
283
- key = key[arg[pair[0]].to_s == 'intl' ? 1 : 0] if key.class == Array
284
247
  val = r.attributes[key]
285
248
  case pair[0]
286
- when :fed then val = val && val.match(/^[A-Z]{3}$/i) ? val.upcase : nil
287
- when :gender then val = val.to_i > 0 ? %w(M F)[val.to_i-1] : nil
288
- when :id then val = val.to_i > 0 ? val : nil
289
- when :fide then val = val.to_i > 0 ? val : nil
290
- when :rating then val = val.to_i > 0 ? val : nil
291
- when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
249
+ when :fed then val = val && val.match(/^[A-Z]{3}$/i) ? val.upcase : nil
250
+ when :gender then val = val.to_i > 0 ? %w(M F)[val.to_i-1] : nil
251
+ when :id then val = val.to_i > 0 ? val : nil
252
+ when :fide then val = val.to_i > 0 ? val : nil
253
+ when :rating then val = val.to_i > 0 ? val : nil
254
+ when :fide_rating then val = val.to_i > 0 ? val : nil
255
+ when :title then val = val.to_i > 0 ? %w(GM WGM IM WIM FM WFM)[val.to_i-1] : nil
292
256
  end
293
257
  if pair[0] == :fed && val && arg[:fed]
294
258
  val = nil if arg[:fed].to_s == 'ignore'
@@ -0,0 +1,397 @@
1
+ module ICU
2
+ class Tournament
3
+ #
4
+ # The SWissPerfect export format used to be important in Irish chess as it was used to submit
5
+ # results to the ICU's first computerised ratings system, a <em>MicroSoft Access</em> database.
6
+ # As a text based format, it was easier to manipulate than the full binary formats of SwissPerfect.
7
+ # Here is an illustrative example of this format:
8
+ #
9
+ # No Name Feder Intl Id Loc Id Rtg Loc Title Total 1 2 3
10
+ #
11
+ # 1 Duck, Daffy IRL 12345 2200 im 2 0:= 3:W 2:D
12
+ # 2 Mouse, Minerva 1234568 1900 1.5 3:D 0:= 1:D
13
+ # 3 Mouse, Mickey USA 1234567 gm 1 2:D 1:L 0:=
14
+ #
15
+ # The format does not record either the name nor the start date of the tournament.
16
+ # Player colours are also missing. When parsing data in this format it is necessary
17
+ # to specify name and start date explicitly:
18
+ #
19
+ # parser = ICU::Tournament::SPExport.new
20
+ # tournament = parser.parse_file('sample.txt', :name => 'Mickey Mouse Masters', :start => '2011-02-06')
21
+ #
22
+ # tournament.name # => "Mickey Mouse Masters"
23
+ # tournament.start # => "2011-02-06"
24
+ # tournament.rounds # => 3
25
+ # tournament.player(1).name # => "Duck, Daffy"
26
+ # tournament.player(2).points # => 1.5
27
+ # tournament.player(3).fed # => "USA"
28
+ #
29
+ # See ICU::Tournament for further details about the object returned.
30
+ #
31
+ # The SwissPerfect application offers a number of choices when exporting a tournament cross table,
32
+ # one of which is the column separator. The ICU::Tournament::SPExport parser can only handle data
33
+ # with tab separators but is able to cope with any other configuration choices. For example, if
34
+ # some of the optional columns are missing or if the data is not formatted with space padding.
35
+ #
36
+ # To serialize an ICU::Tournament instance to the format, use the _serialize_ method of
37
+ # the appropriate parser:
38
+ #
39
+ # parser = ICU::Tournament::Krause.new
40
+ # spexport = parser.serialize(tournament)
41
+ #
42
+ # or use the _serialize_ method of the instance with the appropraie format name:
43
+ #
44
+ # spexport = tournament.serialize('SPExport')
45
+ #
46
+ # In either case the method returns a string representation of the tourament in SwissPerfect export
47
+ # format with tab separators, space padding and (by default) the local player ID and total score
48
+ # optional columns:
49
+ #
50
+ # No Name Loc Id Total 1 2 3
51
+ #
52
+ # 1 Griffiths, Ryan-Rhys 6897 3 4:W 2:W 3:W
53
+ # 2 Flynn, Jamie 5226 2 3:W 1:L 4:W
54
+ # 3 Hulleman, Leon 6409 1 2:L 4:W 1:L
55
+ # 4 Dunne, Thomas 10914 0 1:L 3:L 2:L
56
+ #
57
+ # To change which optional columns are output, use the _columns_ option with an array of the column attribute names.
58
+ # The optional attribute names, together with their column header names in SwissPerfect, are as follows:
59
+ #
60
+ # * _fed_: Feder
61
+ # * _fide_: Intl Id
62
+ # * _id_: Loc Id
63
+ # * _fide_: ting_ (Rtg
64
+ # * _rating_: Loc
65
+ # * _title_: Title
66
+ # * _points_: Total
67
+ #
68
+ # So, for example, to omitt the optional columns completely, supply an empty array of column names:
69
+ #
70
+ # tournament.serialize('SPExport', :columns => [])
71
+ #
72
+ # No Name 1 2 3
73
+ #
74
+ # 1 Griffiths, Ryan-Rhys 4:W 2:W 3:W
75
+ # 2 Flynn, Jamie 3:W 1:L 4:W
76
+ # 3 Hulleman, Leon 2:L 4:W 1:L
77
+ # 4 Dunne, Thomas 1:L 3:L 2:L
78
+ #
79
+ # Or supply whatever columns you want, for example:
80
+ #
81
+ # tournament.serialize('SPExport', :columns => [:fide, :fide_rating])
82
+ #
83
+ # Note that the column order in the serialised string is the same as it is in the SwissPerfect application.
84
+ # The order of column names in the _columns_ hash has no effect.
85
+ #
86
+ # The default, when you leave out the _columns_ option is equivalent to:
87
+ #
88
+ # tournament.serialize('SPExport', :columns => [:id, :points])
89
+ #
90
+ # The order of players in the serialized output is always by player number and as a side effect of serialization,
91
+ # the player numbers will be adjusted to ensure they range from 1 to the total number of players maintaining the
92
+ # original order. If you would prefer rank-order instead, then you must first renumber the players by rank (the
93
+ # default renumbering method) before serializing. For example:
94
+ #
95
+ # spexport = tournament.renumber(:rank).serialize('SPExport')
96
+ #
97
+ # Or equivalently, since renumbering by rank is the default, just:
98
+ #
99
+ # spexport = tournament.renumber.serialize('SPExport')
100
+ #
101
+ # You may wish set the tie-break rules before ranking:
102
+ #
103
+ # tournament.tie_breaks = [:buchholz, ::neustadtl]
104
+ # spexport = tournament.rerank.renumber.serialize('SwissPerfect')
105
+ #
106
+ # See ICU::Tournament for more about tie-breaks.
107
+ #
108
+ class SPExport
109
+ attr_reader :error
110
+
111
+ # Parse SwissPerfect export data returning a Tournament on success or raising an exception on error.
112
+ def parse!(spx, arg={})
113
+ @tournament = init_tournament(arg)
114
+ @lineno = 0
115
+ @header = nil
116
+ @results = Array.new
117
+ spx = ICU::Util.to_utf8(spx) unless arg[:is_utf8]
118
+
119
+ # Process each line.
120
+ spx.each_line do |line|
121
+ @lineno += 1
122
+ line.strip! # remove leading and trailing white space
123
+ next if line == '' # skip blank lines
124
+
125
+ if @header
126
+ process_player(line)
127
+ else
128
+ process_header(line)
129
+ end
130
+ end
131
+
132
+ # Now that all players are present, add the results to the tournament.
133
+ @results.each do |r|
134
+ lineno, player, data, result = r
135
+ begin
136
+ @tournament.add_result(result)
137
+ rescue => err
138
+ raise "line #{lineno}, player #{player}, result '#{data}': #{err.message}"
139
+ end
140
+ end
141
+
142
+ # Finally, exercise the tournament object's internal validation, reranking if neccessary.
143
+ @tournament.validate!(:rerank => true)
144
+
145
+ @tournament
146
+ end
147
+
148
+ # Parse SwissPerfect export text returning a Tournament on success or a nil on failure.
149
+ # In the case of failure, an error message can be retrived via the <em>error</em> method.
150
+ def parse(spx, arg={})
151
+ begin
152
+ parse!(spx, arg)
153
+ rescue => ex
154
+ @error = ex.message
155
+ nil
156
+ end
157
+ end
158
+
159
+ # Same as <em>parse!</em> except the input is a file name rather than file contents.
160
+ def parse_file!(file, arg={})
161
+ spx = ICU::Util.read_utf8(file)
162
+ arg[:is_utf8] = true
163
+ parse!(spx, arg)
164
+ end
165
+
166
+ # Same as <em>parse</em> except the input is a file name rather than file contents.
167
+ def parse_file(file, arg={})
168
+ begin
169
+ parse_file!(file, arg)
170
+ rescue => ex
171
+ @error = ex.message
172
+ nil
173
+ end
174
+ end
175
+
176
+ # Serialise a tournament to SwissPerfect text export format.
177
+ def serialize(t, arg={})
178
+ t.validate!(:type => self)
179
+
180
+ # Ensure a nice set of player numbers and get the number of rounds.
181
+ t.renumber(:order)
182
+ rounds = t.last_round
183
+
184
+ # Optional columns.
185
+ optional = arg[:columns] if arg.instance_of?(Hash) && arg[:columns].instance_of?(Array)
186
+ optional = [:id, :points] unless optional
187
+
188
+ # Columns identifiers in SwissPerfect order.
189
+ columns = Array.new
190
+ columns.push(:num)
191
+ columns.push(:name)
192
+ [:fed, :fide, :id, :fide_rating, :rating, :title, :points].each { |x| columns.push(x) if optional.include?(x) }
193
+
194
+ # SwissPerfect headers for each column (other than the rounds, which are treated separately).
195
+ header = Hash.new
196
+ columns.each do |col|
197
+ header[col] = case col
198
+ when :num then "No"
199
+ when :name then "Name"
200
+ when :fed then "Feder"
201
+ when :fide then "Intl Id"
202
+ when :id then "Loc Id"
203
+ when :fide_rating then "Rtg"
204
+ when :rating then "Loc"
205
+ when :title then "Title"
206
+ when :points then "Total"
207
+ end
208
+ end
209
+
210
+ # Widths and formats for each column.
211
+ width = Hash.new
212
+ format = Hash.new
213
+ columns.each do |col|
214
+ width[col] = t.players.inject(header[col].length) { |l, p| p.send(col).to_s.length > l ? p.send(col).to_s.length : l }
215
+ format[col] = "%-#{width[col]}s"
216
+ end
217
+
218
+ # The header, followed by a blank line.
219
+ formats = columns.map{ |col| format[col] }
220
+ (1..rounds).each { |r| formats << "%#{width[:num]}d " % r }
221
+ sp = formats.join("\t") % columns.map{ |col| header[col] }
222
+ sp << "\r\n\r\n"
223
+
224
+ # The round formats for players are slightly different to those for the header.
225
+ formats.pop(rounds)
226
+ (1..rounds).each{ |r| formats << "%#{2+width[:num]}s" }
227
+
228
+ # Serialize the formats already.
229
+ formats = formats.join("\t") + "\r\n"
230
+
231
+ # Now add a line for each player.
232
+ t.players.each { |p| sp << p.to_sp_text(rounds, columns, formats) }
233
+
234
+ # And return the whole lot.
235
+ sp
236
+ end
237
+
238
+ # Additional tournament validation rules for this specific type.
239
+ def validate!(t)
240
+ # None.
241
+ end
242
+
243
+ # :enddoc:
244
+ private
245
+
246
+ def init_tournament(arg)
247
+ raise "tournament name missing" unless arg[:name]
248
+ raise "tournament start date missing" unless arg[:start]
249
+ Tournament.new(arg[:name], arg[:start])
250
+ end
251
+
252
+ def process_header(line)
253
+ raise "header should always start with 'No'" unless line.match(/^No\s/)
254
+ items = line.split(/\t/).map(&:strip)
255
+ raise "header requires tab separators" unless items.size > 2
256
+ @header = Hash.new
257
+ @rounds = 1
258
+ items.each_with_index do |item, i|
259
+ key = case item
260
+ when 'No' then :num
261
+ when 'Name' then :name
262
+ when 'Total' then :total
263
+ when 'Loc Id' then :id
264
+ when 'Intl Id' then :fide
265
+ when 'Title' then :title
266
+ when 'Feder' then :fed
267
+ when 'Loc' then :rating
268
+ when 'Rtg' then :fide_rating
269
+ when /^[1-9]\d*$/
270
+ round = item.to_i
271
+ @rounds = round if round > @rounds
272
+ round
273
+ else nil
274
+ end
275
+ @header[key] = i if key
276
+ end
277
+ raise "header is missing 'No'" unless @header[:num]
278
+ raise "header is missing 'Name'" unless @header[:name]
279
+ (1..@rounds).each { |r| raise "header is missing round #{r}" unless @header[r] }
280
+ end
281
+
282
+ def process_player(line)
283
+ items = line.split(/\t/).map(&:strip)
284
+ raise "line #{@lineno} has too few items" unless items.size > 2
285
+
286
+ # Player details.
287
+ num = items[@header[:num]]
288
+ name = Name.new(items[@header[:name]])
289
+ opt = Hash.new
290
+ [:fed, :title, :id, :fide, :rating, :fide_rating].each do |key|
291
+ if @header[key]
292
+ val = items[@header[key]]
293
+ opt[key] = val unless val.nil? || val == ''
294
+ end
295
+ end
296
+
297
+ # Create the player and add it to the tournament.
298
+ player = Player.new(name.first, name.last, num, opt)
299
+ @tournament.add_player(player)
300
+
301
+ # Save the results for later processing.
302
+ points = items[@header[:total]] if @header[:total]
303
+ points = nil if points == ''
304
+ points = points.to_f if points
305
+ total = 0.0;
306
+ (1..@rounds).each do |r|
307
+ total+= process_result(r, player.num, items[@header[r]])
308
+ end
309
+ total = points if points && fix_invisible_bonuses(player.num, points - total)
310
+ raise "declared points total (#{points}) does not agree with total from summed results (#{total})" if points && points != total
311
+ end
312
+
313
+ def process_result(round, player_num, data)
314
+ raise "illegal result (#{data})" unless data.match(/^(0|[1-9]\d*)?:([-+=LWD])?$/i)
315
+ opponent = $1.to_i
316
+ score = $2 || 'L'
317
+ options = Hash.new
318
+ options[:opponent] = opponent unless opponent == 0
319
+ options[:rateable] = false unless score && score.match(/^(W|L|D)$/i)
320
+ result = Result.new(round, player_num, score, options)
321
+ @results << [@lineno, player_num, data, result]
322
+ result.points
323
+ end
324
+
325
+ def fix_invisible_bonuses(player_num, difference)
326
+ # We don't need to fix it if it's not broken.
327
+ return false if difference == 0.0
328
+ # We can't fix a summed total that is greater than the declared total.
329
+ return false if difference < 0.0
330
+ # Get the player's results objects from the temporary store.
331
+ results = @results.select{ |r| r[1] == player_num }.map{ |r| r.last }
332
+ # Get all losses and draws that don't have opponents (because their scores can be harmlessly altered).
333
+ losses = results.reject{ |r| r.opponent || r.score != 'L' }.sort{ |a,b| a.round <=> b.round }
334
+ draws = results.reject{ |r| r.opponent || r.score != 'D' }.sort{ |a,b| a.round <=> b.round }
335
+ # Give up unless these results have enough capacity to accomodate the points difference.
336
+ return false unless difference <= 1.0 * losses.size + 0.5 * draws.size
337
+ # Start promoting losses to draws.
338
+ losses.each do |loss|
339
+ loss.score = 'D'
340
+ difference -= 0.5
341
+ break if difference == 0.0
342
+ end
343
+ # If that's not enough, start promoting draws to wins.
344
+ if difference > 0.0
345
+ draws.each do |draw|
346
+ draw.score = 'W'
347
+ difference -= 0.5
348
+ break if difference == 0.0
349
+ end
350
+ end
351
+ # And if that's not enough, start promoting losses to wins.
352
+ if difference > 0.0
353
+ losses.each do |loss|
354
+ loss.score = 'W'
355
+ difference -= 0.5
356
+ break if difference == 0.0
357
+ end
358
+ end
359
+ # Signal success.
360
+ return true
361
+ end
362
+ end
363
+ end
364
+
365
+ class Player
366
+ # Format a player's record as it would appear in an SP export file.
367
+ def to_sp_text(rounds, columns, formats)
368
+ values = columns.inject([]) do |vals,col|
369
+ val = send(col).to_s
370
+ val.sub!(/\.0/, '') if col == :points
371
+ vals << val
372
+ end
373
+ (1..rounds).each do |r|
374
+ result = find_result(r)
375
+ values << (result ? result.to_sp_text : " : ")
376
+ end
377
+ formats % values
378
+ end
379
+ end
380
+
381
+ class Result
382
+ # Format a player's result as it would appear in an SP export file.
383
+ def to_sp_text
384
+ sp = opponent ? opponent.to_s : '0'
385
+ sp << ':'
386
+ if rateable
387
+ sp << score
388
+ else
389
+ sp << case score
390
+ when 'W' then '+'
391
+ when 'L' then '-'
392
+ else '='
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end