Package bosco :: Module ranking
[hide private]
[frames] | no frames]

Source Code for Module bosco.ranking

   1  # 
   2  #    Copyright (C) 2008  Gaudenz Steinlin <gaudenz@soziologie.ch> 
   3  # 
   4  #    This program is free software: you can redistribute it and/or modify 
   5  #    it under the terms of the GNU General Public License as published by 
   6  #    the Free Software Foundation, either version 3 of the License, or 
   7  #    (at your option) any later version. 
   8  # 
   9  #    This program is distributed in the hope that it will be useful, 
  10  #    but WITHOUT ANY WARRANTY; without even the implied warranty of 
  11  #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
  12  #    GNU General Public License for more details. 
  13  # 
  14  #    You should have received a copy of the GNU General Public License 
  15  #    along with this program.  If not, see <http://www.gnu.org/licenses/>. 
  16  """ 
  17  ranking.py - Classes to produce rankings of objects that implement 
  18               Rankable. Each class that has a ranking (currently 
  19               Course and Category) has to inherit from Rankable. 
  20               Rankable specifies the interface of rankable classes. 
  21  """ 
  22   
  23  from datetime import timedelta, datetime 
  24  from copy import copy 
  25  from traceback import print_exc 
  26  import sys, re 
  27   
  28  from storm.exceptions import NotOneError 
  29  from storm.locals import * 
30 31 -class RankableItem:
32 """Defines the interface for all rankable items (currently Runner, Team, Run). 33 This interfaces specifies the methods to access information about the RankableItem 34 like name and score (time, points, ...) in an independent way. This is ensures that 35 RankingFormatters don't need information about the objects in the ranking.""" 36
37 - def start(self):
38 raise UnscoreableException('You have to override start to rank this object with this scoreing strategy.')
39
40 - def finish(self):
41 raise UnscoreableException('You have to override finish to rank this object with this scoreing strategy.')
42
43 - def _get_complete(self):
44 raise ValidationError('You have to override the complete property to validate this object with this validation strategy.')
45 complete = property(_get_complete) 46
47 - def __str__(self):
48 return 'override __str__ for a more meaningful value'
49
50 -class Ranking(object):
51 """A Ranking objects combines a scoreing strategy, a validation strategy and a 52 rankable object (course or category) and computes a ranking. The Ranking object 53 is an interable object, which means you can use it much like a list. It returns 54 a new iterator on every call to __iter__. 55 The order of the ranking is defined by the scoreing strategy. Strategy has the be 56 a subclass of AbstractScoreing compatible with the RankableItem objects of this Rankable. 57 The ranking is generated in lowest first order. Reverse rankings are possible. 58 59 The iterator returns dictionaries with the keys 'rank', 'scoreing', 'validation', 60 'item'. 61 62 Rankings are lazyly computed, but not updated unless you eihter call the update mehtod 63 or iterate over them. 64 """ 65
66 - def __init__(self, rankable, event, scoreing_class = None, validator_class = None, 67 scoreing_args = None, validator_args = None, reverse = False):
68 self.rankable = rankable 69 self._event = event 70 self._scoreing_class = scoreing_class 71 self._validator_class = validator_class 72 self.scoreing_args = scoreing_args 73 self.validator_args = validator_args 74 self._reverse = reverse 75 self._ranking_list = [] 76 self._ranking_dict = {} 77 78 # lazy initialization flag 79 self._initialized = False
80
81 - def __iter__(self):
82 """ 83 Iterating over the ranking automatically updates the ranking. This is mainly for 84 backwards compatibility. 85 """ 86 87 def ranking_generator(ranking_list): 88 for result in ranking_list: 89 yield result
90 91 self.update() 92 # return the generator 93 return ranking_generator(self._ranking_list)
94
95 - def __getitem__(self, key):
96 if not self._initialized: 97 self.update() 98 99 return self._ranking_list[key]
100
101 - def rank(self, item):
102 """ 103 @param item: Item ranked in this ranking 104 @return: Rank of this item 105 """ 106 return self.info(item)['rank']
107
108 - def score(self, item):
109 """ 110 @param item: Item ranked in this ranking 111 @return: Score of this item 112 """ 113 return self.info(item)['scoreing']['score']
114
115 - def info(self, item):
116 """ 117 Return information dict for one item 118 @param item: Item ranked in this ranking 119 @return: dict with all information about this item (same as when iterating 120 over the ranking) 121 """ 122 # lazy initialization 123 if not self._initialized: 124 self.update() 125 126 try: 127 return self._ranking_dict[item] 128 except KeyError: 129 raise KeyError('%s not in ranking.' % item)
130 131 @property
132 - def member_count(self):
133 if not self._initialized: 134 self.update() 135 return self._member_count
136 137 @property
138 - def completed_count(self):
139 if not self._initialized: 140 self.update() 141 return self._completed_count
142
143 - def _update_ranking_list(self):
144 # Create list of (score, member) tuples and sort by score 145 self._ranking_list = [] 146 self._member_count = 0 147 self._completed_count = 0 148 149 # convert to a list for counting as it may either be 150 # a strom result set or a real list 151 if not len(list(self.rankable.members)) > 0: 152 # stop if rankable has no members 153 return 154 155 for m in self.rankable.members: 156 try: 157 # copy arguments as they might get modified 158 args = None if self.scoreing_args is None else self.scoreing_args.copy() 159 score = self._event.score(m, self._scoreing_class, args) 160 except UnscoreableException: 161 score = {'score': timedelta(0)} 162 163 try: 164 # copy arguments as they might get modified 165 args = None if self.validator_args is None else self.validator_args.copy() 166 valid = self._event.validate(m, self._validator_class, args) 167 except ValidationError: 168 print_exc(file=sys.stderr) 169 continue 170 171 self._member_count += 1 172 if valid['status'] != Validator.NOT_COMPLETED: 173 self._completed_count += 1 174 175 self._ranking_list.append({'scoreing': score, 176 'validation': valid, 177 'item': m}) 178 179 # Sort by number first, then by score and finally by validation status 180 # x['item'] is either a Run, Runner or Team 181 from run import Run 182 self._ranking_list.sort(key = lambda x: (type(x['item']) == Run) and x['item'].sicard.runner and (x['item'].sicard.runner.number or '0') or x['item'].number or '0') 183 self._ranking_list.sort(key = lambda x: x['scoreing']['score'], reverse = self._reverse) 184 self._ranking_list.sort(key = lambda x: x['validation']['status']) 185 186 rank = 1 187 winner_score = self._ranking_list[0]['scoreing']['score'] 188 for i, m in enumerate(self._ranking_list): 189 # Only increase the rank if the current item scores higher than the previous item 190 if (i > 0 and (self._ranking_list[i]['scoreing']['score'] 191 > self._ranking_list[i-1]['scoreing']['score']) 192 or (self._reverse and (self._ranking_list[i]['scoreing']['score'] 193 < self._ranking_list[i-1]['scoreing']['score']))): 194 rank = i + 1 195 # only assign rank if run is OK 196 m['rank'] = rank if m['validation']['status'] == Validator.OK else None 197 m['scoreing']['behind'] = ((m['scoreing']['score'] - winner_score) * (self._reverse and -1 or 1) 198 if m['validation']['status'] == Validator.OK 199 else None)
200
201 - def _update_ranking_dict(self):
202 # create dictionary with ranked objects as keys for random access 203 self._ranking_dict = {} 204 for obj in self._ranking_list: 205 self._ranking_dict[obj['item']] = obj
206
207 - def update(self):
208 """ 209 Update the ranking. Rankings are not updated automatically. 210 """ 211 self._update_ranking_list() 212 self._update_ranking_dict() 213 self._initialized = True
214
215 -class RelayRanking(Ranking):
216
217 - def update(self):
218 self._update_ranking_list() 219 220 leg_rankings = {} 221 for leg in self._event.list_legs(self.rankable): 222 r = self._event.ranking(leg) 223 leg_rankings.update([(k, r) for k in leg.course_list]) 224 225 # Relay rankings for splittimes 226 split_rankings = [] 227 for i in range(len(self._event.list_legs(self.rankable))): 228 # Don't use self._event.ranking here to avoid the infinite recursion this 229 # would cause otherwise. Explicitly create a ranking without split rankings 230 r = Ranking(self.rankable, self._event, scoreing_args = {'legs': i+1}, 231 validator_args = {'legs': i+1}) 232 split_rankings.append(r) 233 234 for team in self._ranking_list: 235 team['runs'] = [] 236 team['splits'] = [] 237 for i, run in enumerate(team['scoreing']['runs']): 238 if run: 239 team['runs'].append(leg_rankings[run.course].info(run)) 240 else: 241 team['runs'].append(None) 242 team['splits'].append(split_rankings[i].info(team['item'])) 243 244 self._update_ranking_dict() 245 self._initialized = True
246
247 -class Rankable(object):
248 """Defines the interface for rankable objects like courses and categories. 249 The following attributes must be available in subclasses: 250 - members 251 """ 252 pass
253
254 -class OpenRuns(Rankable):
255
256 - def __init__(self, store, control = None):
257 """ 258 @param store: Store for the open runs. This class uses a Storm store to 259 search for open runs, but it is not a Storm object itself! 260 @param control: Only list open runs which should pass at this control or 261 punched this control. (not yet implemented) 262 """ 263 self._store = store 264 self._control = control
265
266 - def _get_runs(self):
267 from run import Run 268 return self._store.find(Run, Run.complete == False)
269 270 members = property(_get_runs)
271
272 -class Cache(object):
273 """Cache for scoreing and validation results.""" 274
275 - def __init__(self, observer = None):
276 """ 277 @param observer: Observer wich notifys the cache of changes in 278 cached objects. 279 @type observer: object of class EventObserver 280 """ 281 self._cache = {} 282 self._observer = observer
283
284 - def __getitem__(self, key):
285 (obj, func) = key 286 return self._cache[obj][func]
287
288 - def __setitem__(self, key, value):
289 (obj, func) = key 290 if not obj in self._cache: 291 self._cache[obj] = {} 292 self._cache[obj][func] = value 293 if self._observer: 294 self._observer.register(self, obj)
295
296 - def __delitem__(self, obj):
297 if self._observer: 298 self._observer.unregister(self, obj) 299 del self._cache[obj]
300
301 - def __contains__(self, key):
302 (obj, func) = key 303 return obj in self._cache and func in self._cache[obj]
304
305 - def clear(self):
306 self._cache = {}
307
308 - def update(self, obj):
309 del self[obj]
310
311 - def set_observer(self, observer):
312 """ 313 Sets an observer for this cache. 314 """ 315 if self._observer is not None: 316 self.remove_observer() 317 for obj in self._cache: 318 observer.register(self, obj) 319 self._observer = observer
320
321 - def remove_observer(self):
322 """ 323 Removes any existing observer for this cache. 324 """ 325 if self._observer is not None: 326 for obj in self._cache: 327 self._observer.unregister(self, obj) 328 self._observer = None
329
330 -class CachingObject(object):
331 """Common parent class for all caching objects.""" 332
333 - def __init__(self, cache = None):
334 """ 335 @param cache: scoreing cache 336 """ 337 self._cache = cache
338
339 - def _from_cache(self, func, obj):
340 """ 341 Check cache and return cached result if it exists. 342 @param func: function which produced the result 343 @param obj: Object of the startegy. 344 @type obj: object of class RankableItem 345 @raises: KeyError if object is not in the cache 346 """ 347 if self._cache: 348 return self._cache[(obj, func)] 349 else: 350 raise KeyError
351
352 - def _to_cache(self, func, obj, result):
353 """ 354 Add result to cache 355 @param func: function which produced the result 356 @param obj: Object of the startegy. 357 @type obj: object of class RankableItem 358 """ 359 if self._cache: 360 self._cache[(obj, func)] = result
361
362 363 -class AbstractScoreing(CachingObject):
364 """Defines a strategy for scoring objects (runs, runners, teams). The scoreing 365 strategy is tightly coupled to the objects it scores. 366 367 Computed scores can (and should!) be stored in a cache. 368 """ 369
370 - def score(self, object):
371 """Returns the score of the given objects. Throws UnscoreableException if the 372 object can't be scored with this strategy. The returned score must implement 373 __cmp__.""" 374 raise UnscoreableException('Can\'t score with AbstractScoreing')
375 376 """ 377 descriptive string about the special parameters set for this 378 Scoreing. Override if you have to pass additional information 379 to rankings (and formatted rankings). 380 string or None 381 """ 382 information = None
383
384 -class TimeScoreing(AbstractScoreing):
385 """Builds the score from difference of start and finish times. The start time is 386 calculated by the start time strategy object. The finish time is the time of the 387 finish punch. 388 Subclasses can override _start and _finish the extract the start and finsh times in 389 other ways.""" 390
391 - def __init__(self, starttime_strategy, cache = None):
392 """ 393 @param start_time_strategy: Strategy to find the start time 394 @type start_time_strategy: object of class StartTimeStrategy or a subclass 395 """ 396 AbstractScoreing.__init__(self, cache) 397 self._starttime_strategy = starttime_strategy
398
399 - def _start(self, obj):
400 """Returns the start time as a datetime object.""" 401 return self._starttime_strategy.starttime(obj)
402
403 - def score(self, obj):
404 """Returns a timedelta object as the score by calling start and finish on 405 the object.""" 406 try: 407 return self._from_cache(self.score, obj) 408 except KeyError: 409 pass 410 411 result = {} 412 result['start'] = self._start(obj) 413 result['finish'] = obj.finish_time 414 try: 415 result['score'] = result['finish'] - result['start'] 416 except TypeError: 417 # is this really the best thing to do? 418 result['score'] = timedelta(0) 419 420 if result['score'] < timedelta(0): 421 raise UnscoreableException('Scoreing Error, negative runtime: %(finish)s - %(start)s = %(score)s' 422 % result) 423 424 self._to_cache(self.score, obj, result) 425 return result
426
427 -class Starttime(CachingObject):
428 """Basic start time strategy. """ 429
430 - def starttime(self, obj):
431 return obj.manual_start_time
432
433 -class SelfstartStarttime(Starttime):
434 """StarttimeStrategy for Selfstart""" 435
436 - def starttime(self, obj):
437 return obj.start_time
438
439 -class MassstartStarttime(Starttime):
440 """Returns start time relative to a fixed mass start time.""" 441
442 - def __init__(self, starttime, cache = None):
443 """ 444 @param starttime: Mass start time 445 @type starttime: datetime 446 """ 447 448 CachingObject.__init__(self, cache) 449 self._starttime = starttime
450
451 - def starttime(self, obj):
452 start = Starttime.starttime(self, obj) 453 if start: 454 return start 455 else: 456 return self._starttime
457
458 -class RelayStarttime(MassstartStarttime):
459 """Returns start time computed from starttime == finish time of the previous runner 460 in a relay team. If the computed time is later than the mass start time, the 461 mass start time is returned.""" 462
463 - def __init__(self, massstart_time, ordered = True, cache = None):
464 """ 465 @param massstart_time: Time of the mass start 466 @type massstart_time: datetime 467 @param ordered: If true this assumes that the runners follow in a 468 order defined by their number. 469 @type ordered: boolean 470 """ 471 472 MassstartStarttime.__init__(self, massstart_time, cache) 473 self._prev_finish = (ordered and self._prev_finish_ordered 474 or self._prev_finish_unordered)
475
476 - def _prev_finish_ordered(self, obj):
477 478 from runner import RunnerException 479 480 # Get the list of runners for this team 481 try: 482 runners = list(obj.sicard.runner.team.members.order_by('number')) 483 except AttributeError: 484 raise UnscoreableException("Runner must be part of a team!") 485 486 i = runners.index(obj.sicard.runner) 487 if i == 0: 488 return None 489 else: 490 # this assumes that each runner runs only once, use unordered if this 491 # is not the case 492 try: 493 return runners[i-1].run.finish_time 494 except RunnerException, e: 495 raise UnscoreableException(u'Unable to get finish time of previous runner: %s' 496 % e.message)
497
498 - def _prev_finish_unordered(self, obj):
499 500 # Get the team for this run 501 team = obj.sicard.runner.team 502 503 # This makes the whole thing dependant on the exact database layout, 504 # but it is a huge perfomance win 505 from course import SIStation 506 from run import Punch 507 store = Store.of(obj) 508 509 510 # get reference time (search for finish punch of the previous runner 511 # before this time 512 reftime = obj.finish_time 513 if reftime is None: 514 punchlist = [p for p,c in obj.punchlist()] 515 if len(punchlist) == 0: 516 # Assume this is the last run of this team 517 reftime = datetime.max 518 else: 519 # last real punch of this run 520 reftime = obj.punchlist()[-1][0].punchtime 521 522 prev_finish = store.execute( 523 """SELECT MAX(COALESCE(run.manual_finish_time, run.card_finish_time)) 524 FROM team JOIN runner ON team.id = runner.team 525 JOIN sicard ON runner.id = sicard.runner 526 JOIN run ON sicard.id = run.sicard 527 WHERE team.id = %s 528 AND COALESCE(run.manual_finish_time, run.card_finish_time) < %s 529 AND run.complete = true""", 530 # """SELECT MAX(COALESCE(run.manual_finish_time, run.card_finish_time)) 531 # FROM team JOIN runner ON team.id = runner.team 532 # JOIN sicard ON runner.id = sicard.runner 533 # JOIN run ON sicard.id = run.sicard 534 # WHERE team.id = (SELECT runner.team 535 # FROM run 536 # JOIN sicard ON run.sicard = sicard.id 537 # JOIN runner ON sicard.runner = runner.id 538 # WHERE run.id = %s) 539 # AND COALESCE(run.manual_finish_time, run.card_finish_time) < %s 540 # AND run.complete = true""", 541 # params = (obj.id, 542 params = (team.id, 543 reftime, 544 ) 545 ).get_one()[0] 546 547 return prev_finish
548
549 - def starttime(self, obj):
550 start = Starttime.starttime(self, obj) 551 if start: 552 return start 553 554 prev_finish = self._prev_finish(obj) 555 if prev_finish is None: 556 return self._starttime 557 else: 558 return prev_finish
559
560 -class RelayMassstartStarttime(RelayStarttime):
561
562 - def starttime(self, obj):
563 start = Starttime.starttime(self, obj) 564 if start: 565 return start 566 567 prev_finish = self._prev_finish(obj) 568 if prev_finish is None: 569 return self._starttime 570 else: 571 return self._starttime < prev_finish and self._starttime or prev_finish
572
573 -class Validator(CachingObject):
574 """Defines a strategy for validating objects (runs, runners, teams). The validation 575 strategy is tightly coupled to the objects it validates.""" 576 577 # Ranking constants (return values for validate) 578 # This also defines to sorting order of these states in the 579 # ranking. 580 OK = 1 # valid run 581 NOT_COMPLETED = 2 # run not yet completed 582 MISSING_CONTROLS = 3 # missing one or more obligatory controls 583 DID_NOT_FINISH = 4 # runner did not finish (but run is complete) 584 DISQUALIFIED = 5 # disqualified for regulatory reasons 585 DID_NOT_START = 6 # runner / team did not start 586
587 - def validate(self, obj):
588 """Returns OK for every object. Override in subclasses for more meaningfull 589 validations.""" 590 return {'status':Validator.OK}
591
592 -class CourseValidator(Validator):
593 """Validation strategy for courses.""" 594
595 - def __init__(self, course, cache = None):
596 Validator.__init__(self, cache) 597 self._course = course
598
599 - def validate(self, run):
600 """Check if run is a valid run for this course. This only checks if the run 601 is complete has a start punch and a finish punch. It does not check any controls! 602 Override this in subclasses for more usefull validation (and call super() to not 603 duplicate this code).""" 604 try: 605 return self._from_cache(self.validate, run) 606 except KeyError: 607 pass 608 609 result = {'override':False} 610 if run.override is not None: 611 if run.override == Validator.OK and run.complete == False: 612 # return not completed even if override is OK to avoid inconsistencies 613 result['status'] = Validator.NOT_COMPLETED 614 else: 615 result['status'] = run.override 616 result['override'] = True 617 elif not run.complete: 618 result['status'] = Validator.NOT_COMPLETED 619 elif not run.finish_time: 620 result['status'] = Validator.DID_NOT_FINISH 621 else: 622 result['status'] = Validator.OK 623 624 # add all punches to punchlist 625 result['punchlist'] = [ ('ok', p[0]) for p in run.punchlist() ] 626 result['punchlist'].extend([ ('ignored', p[0]) for p in run.punchlist(ignored=True) ]) 627 628 self._to_cache(self.validate, run, result) 629 return result
630
631 -class SequenceCourseValidator(CourseValidator):
632 """Validation strategy for a normal orienteering course. 633 This class uses a dynamic programming "longest common subsequence" 634 algorithm to validate the run and find missing and additional punches. 635 @see http://en.wikipedia.org/wiki/Longest_common_subsequence_problem. 636 """ 637
638 - def __init__(self, course, reorder = None, cache = None):
639 640 CourseValidator.__init__(self, course, cache) 641 642 if reorder is not None: 643 # make reorder a zero based list, this aligns better with list indexes 644 self._reorder = map(lambda x: x-1, reorder) 645 else: 646 self._reorder = None 647 648 # list of all controls which have sistations 649 self._controllist = self._course.controllist()
650 651 @staticmethod
652 - def _exact_match(plist, clist):
653 """check if plist exactly matches clist 654 @param plist: list of punches 655 @param clist: list of controls. All controls with no sistations or which are 656 overriden must be removed! 657 @return: True or False 658 """ 659 if len(plist) != len(clist): 660 return False 661 662 for i,c in enumerate(clist): 663 if not plist[i][1] is c: 664 return False 665 666 return True
667 668 @staticmethod
669 - def _build_lcs_matrix(plist, clist):
670 """Builds the matrix of lcs subsequence lengths. 671 @param plist: list of punches 672 @param clist: list of controls. All controls with no sistations or which are 673 overriden must be removed! 674 @return: matrix of lcs subsequence lengths. 675 """ 676 677 m = len(plist) 678 n = len(clist) 679 680 # build (m+1) * (n+1) matrix 681 C = [[0] * (n+1) for i in range(m+1) ] 682 683 i = j = 1 684 for i in range(1, m+1): 685 for j in range(1, n+1): 686 if plist[i-1][1] is clist[j-1]: 687 C[i][j] = C[i-1][j-1] + 1 688 else: 689 C[i][j] = max(C[i][j-1], C[i-1][j]) 690 691 return C
692 693 @staticmethod
694 - def _backtrack(C, plist, clist, i = None, j = None):
695 """Backtrack through the LCS Matrix to find one of possibly 696 several longest common subsequences.""" 697 698 if i is None: 699 i = len(plist) 700 if j is None: 701 j = len(clist) 702 703 if i == 0 or j == 0: 704 return [] 705 elif plist[i-1][1] is clist[j-1]: 706 return SequenceCourseValidator._backtrack(C, plist, clist, i-1, j-1) + [ clist[j-1] ] 707 else: 708 if C[i][j-1] > C[i-1][j]: 709 return SequenceCourseValidator._backtrack(C, plist, clist, i, j-1) 710 else: 711 return SequenceCourseValidator._backtrack(C, plist, clist, i-1, j)
712 713 @staticmethod
714 - def _diff(C, plist, clist, i = None, j = None):
715 """ 716 @return: list of (status, punch or control) tuples. status is 'ok', 'additional' or 717 'missing' or '' (for special SIStations) 718 """ 719 720 from course import SIStation 721 722 if i is None: 723 i = len(plist) 724 if j is None: 725 j = len(clist) 726 727 if i > 0 and j > 0 and plist[i-1][1] is clist[j-1]: 728 result_list = SequenceCourseValidator._diff(C, plist, clist, i-1, j-1) 729 result_list.append(('ok',plist[i-1][0])) 730 else: 731 if j > 0 and (i == 0 or C[i][j-1] >= C[i-1][j]): 732 result_list = SequenceCourseValidator._diff(C, plist, clist, i, j-1) 733 result_list.append(('missing', clist[j-1])) 734 elif i > 0 and (j == 0 or C[i][j-1] < C[i-1][j]): 735 result_list = SequenceCourseValidator._diff(C, plist, clist, i-1, j) 736 result_list.append(((plist[i-1][0].sistation.id > SIStation.SPECIAL_MAX 737 and 'additional' 738 or ''), 739 plist[i-1][0])) 740 else: 741 result_list = [] 742 return result_list
743
744 - def validate(self, run):
745 """Validate the run if it is valid for this course.""" 746 747 try: 748 return self._from_cache(self.validate, run) 749 except KeyError: 750 pass 751 752 from course import SIStation 753 754 # do basic checks from CourseValidator 755 result = super(type(self), self).validate(run) 756 757 punchlist = run.punchlist() 758 759 if SequenceCourseValidator._exact_match(punchlist, self._controllist): 760 diff_list = [('ok', p[0]) for p in punchlist] 761 else: 762 C = SequenceCourseValidator._build_lcs_matrix(punchlist, self._controllist) 763 diff_list = SequenceCourseValidator._diff(C, 764 punchlist, 765 self._controllist) 766 767 # add ignored and special punches into diff_list 768 ignorelist = run.punchlist(ignored=True) 769 i = 0 770 for p,c in ignorelist: 771 while i < len(diff_list): 772 if (diff_list[i][0] == 'missing' 773 or p.punchtime > diff_list[i][1].punchtime): 774 i += 1 775 else: 776 break 777 diff_list.insert(i, ('ignored', p)) 778 779 if result['status'] == Validator.OK and result['override'] is False: 780 if 'missing' in dict(diff_list): 781 result['status'] = Validator.MISSING_CONTROLS 782 783 result['punchlist'] = diff_list 784 785 if self._reorder: 786 787 from run import ShiftedPunch 788 789 # remove any additional punches from the validated punchlist 790 orig_punchlist = [(status, punch) for status, punch in result['punchlist'] if status in ('ok', 'missing')] 791 punchlist = [] 792 for i, control_pos in enumerate(self._reorder): 793 # i is zero based, control_pos is 1 based 794 if i == control_pos: 795 punchlist.append(orig_punchlist[i]) 796 elif (orig_punchlist[control_pos-1][0] == 'ok' 797 and orig_punchlist[control_pos][0] == 'ok' 798 and punchlist[i-1][0] == 'ok'): 799 # the current punch plus the previous punch in the original 800 # punchlist and the previous punch in the reordered punchlist 801 # must be available for reordering to be possible 802 orig_punch = orig_punchlist[control_pos][1] 803 orig_punchtime = orig_punch.punchtime 804 legtime = orig_punchtime - orig_punchlist[control_pos-1][1].punchtime 805 # TODO: This fails if the first punch is reordered, would need starttime to fix this 806 shifted_punchtime = punchlist[i-1][1].punchtime + legtime 807 punchlist.append(('ok', ShiftedPunch(orig_punch, shifted_punchtime - orig_punchtime))) 808 elif orig_punchlist[control_pos][0] == 'missing': 809 # punch to be reordered is missing, after reordering it's still missing ;-) 810 punchlist.append(('missing', orig_punchlist[control_pos][1])) 811 else: 812 # reordering needed, but not possible due to missing previous punch 813 punchlist.append(('missing', orig_punchlist[control_pos][1].sistation.control)) 814 815 result['reordered_punchlist'] = punchlist 816 817 self._to_cache(self.validate, run, result) 818 return result
819
820 -class AbstractRelayScoreing(AbstractScoreing, Validator):
821 """Base class for all relay scoreing classes. This class contains some 822 common mehtods used in relay scoreing. 823 """ 824
825 - def __init__(self, event, cache = None):
826 AbstractScoreing.__init__(self, cache) 827 self._event = event
828
829 - def _runs(self, team):
830 """ 831 Return a sorted list of all completed runs of a team that have a course out of a given set. 832 @return: dict with the following keys: 833 * finish: finish time of the run 834 * runner: runner id of the run 835 * course: course of the run 836 * validation: validation code 837 * lkm: lkm run on this code 838 * score: score of the run 839 * expected_time: expected time of the run 840 """ 841 try: 842 return self._from_cache(self._runs, team) 843 except KeyError: 844 runs = [] 845 for r in team.runs: 846 if r.complete is False: 847 continue 848 if r.course is None: 849 continue 850 if not r.course.code in self._courses: 851 continue 852 runs.append( {'finish':r.finish_time, 853 'runner':r.sicard.runner.id, 854 'course':r.course.code, 855 'validation':self._event.validate(r)['status']}) 856 if runs[-1]['validation'] == Validator.OK: 857 runs[-1]['lkm'] = r.course.lkm() 858 else: 859 runs[-1]['score'] = self._event.score(r)['score'] 860 runs[-1]['expected_time'] = r.course.expected_time(self._speed) 861 862 runs.sort(key=lambda x:x['finish'] or datetime.max) 863 self._to_cache(self._runs, team, runs) 864 return runs
865
866 -class RelayScoreing(AbstractRelayScoreing):
867 """Combined validator and scoreing class for relay teams. This 868 class validates and scores teams in a classical relay with 869 different legs and a fixed order of the legs. It supports optional 870 legs with a default time if the leg is not run successfully or if 871 a runner runs longer than the default time. It also supports 872 multiple course variants for a leg.""" 873
874 - def __init__(self, legs, event, cache=None):
875 """ 876 @param legs: list of dicts with the following keys: 877 * 'variants': tuple of course codes that are valid variants for this leg. 878 * 'starttime': start time for all non replaced runners, type datetime 879 * 'defaulttime': time scored if no runner of the team successfully 880 completes this leg or if the runner on this legs needs more time than 881 the defaulttime, type timedelta or None if there is no defaulttime 882 @type legs: dict 883 @param event: Event object used to validate and score individual runs. 884 @type event: instance of a class derived from Event 885 """ 886 super(RelayScoreing, self).__init__(event,cache) 887 self._legs = legs 888 self._speed = 0 889 self._courses = [] 890 for l in self._legs: 891 self._courses.extend(l['variants'])
892
893 - def _runs(self, team):
894 """ 895 Return a list of all completed runs for this team. Ordered by leg. If there is 896 no run for a leg the list contains None at this index. If there is more than one run for 897 a leg the result is undefined. 898 @type team: instance of Team 899 @return type: list of runs 900 """ 901 try: 902 return self._from_cache(self._runs, team) 903 except KeyError: 904 pass 905 906 runs = dict([(r.course.code, r) for r in team.runs 907 if r.course is not None]) 908 909 result = [] 910 for l in self._legs: 911 for v in l['variants']: 912 if v in runs.keys(): 913 result.append(runs[v]) 914 break 915 else: 916 # no valid run for this leg 917 result.append(None) 918 919 self._to_cache(self._runs, team, result) 920 return result
921
922 - def validate(self, team):
923 """Validates a relay team. 924 @see: Validator 925 """ 926 try: 927 return self._from_cache(self.validate, team) 928 except KeyError: 929 pass 930 931 from runner import RunnerException 932 933 result = {'override':False} 934 # check for override 935 if team.override is not None: 936 result['status'] = team.override 937 result['override'] = True 938 else: 939 runners = team.members.order_by('number') 940 i = 0 941 for l in self._legs: 942 try: 943 run = runners[i].run 944 except IndexError: 945 # no more runners in team 946 if l['defaulttime'] is not None: 947 # the status remains the same 948 continue 949 else: 950 result['status'] = Validator.DISQUALIFIED 951 break 952 except RunnerException: 953 # no run or multiple runs for this runner 954 if l['defaulttime'] is not None: 955 i += 1 956 continue 957 else: 958 result['status'] = Validator.DISQUALIFIED 959 break 960 961 try: 962 code = run.course.code 963 except AttributeError: 964 # run does not have a course 965 result['status'] = Validator.DISQUALIFIED 966 break 967 968 valid = self._event.validate(run)['status'] 969 if code in l['variants'] and (l['defaulttime'] is not None or valid == Validator.OK): 970 # everything is OK 971 i += 1 972 continue 973 elif l['defaulttime'] is not None: 974 # perhaps missing runner, just continue without increasing the runner index 975 continue 976 elif code in l['variants']: 977 # correct course, but not valid 978 result['status'] = valid 979 break 980 else: 981 # wrong course 982 result['status'] = Validator.DISQUALIFIED 983 break 984 985 if not 'status' in result: 986 # if no status is assigned everything is OK 987 result['status'] = Validator.OK 988 989 self._to_cache(self.validate, team, result) 990 return result
991
992 - def score(self, team):
993 """Score a relay team. 994 @see: AbstractScoreing 995 """ 996 997 try: 998 return self._from_cache(self.score, team) 999 except KeyError: 1000 pass 1001 1002 time = timedelta(0) 1003 1004 # compute sum of individual run times 1005 # this automatically takes mass starts into account 1006 1007 runs = self._runs(team) 1008 for i,l in enumerate(self._legs): 1009 default = l['defaulttime'] 1010 1011 if runs[i] is not None and self._event.validate(runs[i])['status'] == Validator.OK: 1012 # We have a valid run 1013 legscore = self._event.score(runs[i])['score'] 1014 if default is None or legscore < default: 1015 time += legscore 1016 else: 1017 time += default 1018 else: 1019 # No run on this leg 1020 if default is None: 1021 # But we should have a run, set score to 0 1022 # and stop processing 1023 time = timedelta(0) 1024 break 1025 else: 1026 time += default 1027 1028 # if len(runs) == 0: 1029 # raise UnscoreableException(u'Unable to score team %s (%s): no ' 1030 # u'valid run.' % 1031 # (team.name, team.number)) 1032 # missing = 0 # count of missing legs 1033 # legs = [] 1034 # valid = True 1035 # for i,l in enumerate(self._legs): 1036 # default = l['defaulttime'] 1037 # try: 1038 # r = runs[i-missing] 1039 # except IndexError: 1040 # # last run is missing 1041 # # check for incomplete run for this leg 1042 # incomplete = False 1043 # for run in [r for r in team.runs if r.complete == False]: 1044 # if run.course.code in l['variants']: 1045 # incomplete = True 1046 # break 1047 # if default is None or incomplete: 1048 # # missing run for this leg and no default time 1049 # # continue scoreing to get leg info 1050 # valid = False 1051 # else: 1052 # time += default 1053 # legs.append(None) 1054 1055 # if r.course.code not in l['variants']: 1056 # # missing leg 1057 # missing += 1 1058 # if default is None: 1059 # valid = False 1060 # else: 1061 # time += default 1062 # legs.append(None) 1063 # else: 1064 # legscore = self._event.score(r)['score'] 1065 # legs.append(r) 1066 # if default is None or legscore < default: 1067 # time += legscore 1068 # else: 1069 # time += default 1070 1071 # if not valid: 1072 # time = timedelta(0) 1073 1074 result = {'score': time, 1075 'runs': runs} 1076 1077 self._to_cache(self.score, team, result) 1078 return result
1079
1080 -class Relay24hScoreing(AbstractRelayScoreing):
1081 """This class is both a validation strategy and a scoreing strategy. The strategies 1082 are combined because they use some common private functions. This class validates 1083 and scores teams for the 24h relay.""" 1084 1085 START = re.compile('^SF[1-4]$') 1086 NIGHT = re.compile('^[LS][DE]N[1-5]$') 1087 DAY = re.compile('^[LS][DE][1-4]$') 1088 FINISH = re.compile('^FF[1-6]$') 1089 1090 POOLNAMES = ['start','night', 'day','finish'] 1091
1092 - def __init__(self, starttime, speed, duration, event, method = 'runcount', 1093 blocks = 'finish', cache = None):
1094 """ 1095 @param starttime: Start time of the event. 1096 @type starttime: datetime.datetime 1097 @param speed: expected speed in minutes per kilometers. Used to compute 1098 penalty time for invalid runs 1099 @type speed: int 1100 @param event: object of class Event to score and 1101 validate single runs 1102 @param duration: Duration of the event 1103 @param method: score calculation method, currently implemented: 1104 runcount: count valid runs 1105 lkm: sum of lkms of all valid runs 1106 @type method: str 1107 @param blocks: score until the end of this block. Possible blocks: 1108 'start', 'night', 'day', 'finish' (default) 1109 """ 1110 AbstractRelayScoreing.__init__(self, event, cache) 1111 self._starttime = starttime 1112 self._speed = speed 1113 self._duration = duration 1114 self._method = method 1115 1116 self._courses = [] 1117 all_courses = self._event.list_courses() 1118 if blocks in self.POOLNAMES: 1119 self._courses.extend([ c.code for c in all_courses 1120 if self.START.match(c.code) ]) 1121 if blocks in self.POOLNAMES[1:]: 1122 self._courses.extend([ c.code for c in all_courses 1123 if self.NIGHT.match(c.code) ]) 1124 if blocks in self.POOLNAMES[2:]: 1125 self._courses.extend([ c.code for c in all_courses 1126 if self.DAY.match(c.code) ]) 1127 if blocks in self.POOLNAMES[3:]: 1128 self._courses.extend([ c.code for c in all_courses 1129 if self.FINISH.match(c.code) ]) 1130 1131 self._pool = [[ c for c in self._courses if self.START.match(c) ], 1132 [ c for c in self._courses if self.NIGHT.match(c) ], 1133 [ c for c in self._courses if self.DAY.match(c) ], 1134 ] 1135 1136 self._blocks = blocks
1137
1138 - def _loop_over_runs(self, team):
1139 """This is an utility function to not duplicate code in _check_order and 1140 _omitted_runners. It loops over all runs, counts the omitted runners and 1141 checks the order. Returns a tuple 1142 (validation_code, number_of_omitted_runners).""" 1143 1144 # try fetching from cache 1145 try: 1146 return self._from_cache(self._loop_over_runs, team) 1147 except KeyError: 1148 pass 1149 1150 # collect team members and runs 1151 members = [ r.id for r in team.members.order_by('number')] 1152 runs = self._runs(team) 1153 1154 # check runner order 1155 remaining = copy(members) 1156 next_runner = 0 1157 status = Validator.OK 1158 for i,r in enumerate(runs): 1159 while not r['runner'] == remaining[next_runner]: 1160 if i < len(members): 1161 # not every runner has run at least once 1162 status = Validator.DISQUALIFIED 1163 # next_runner gave up -> delete 1164 del(remaining[next_runner]) 1165 # any runners left? 1166 if len(remaining) == 0: 1167 status = Validator.DISQUALIFIED 1168 break 1169 if next_runner >= len(remaining): 1170 # wrap around 1171 next_runner = 0 1172 1173 if len(remaining) == 0: 1174 break 1175 1176 next_runner = (next_runner+1)%len(remaining) 1177 1178 result = (status, len(members) - len(remaining)) 1179 1180 # save to cache and return result 1181 self._to_cache(self._loop_over_runs, team, result) 1182 return result
1183
1184 - def _check_order(self, team):
1185 """Checks if the order of the runners is correct.""" 1186 return self._loop_over_runs(team)[0]
1187
1188 - def _omitted_runners(self, team):
1189 """Count the runners that were omitted during the event and gave up.""" 1190 return self._loop_over_runs(team)[1]
1191
1192 - def _check_pools(self, team):
1193 # check for pool completion 1194 1195 # make semi-deep copy of self._pool, it will be modified! 1196 pool = [ copy(p) for p in self._pool ] 1197 1198 i = 0 1199 runs = self._runs(team) 1200 remaining_runs = copy(runs) 1201 for r in runs: 1202 while i <= 2 and len(pool[i]) == 0: 1203 # pool finished 1204 i += 1 1205 1206 if i > 2: 1207 # all pools finished 1208 break 1209 1210 try: 1211 pool[i].remove(r['course']) 1212 remaining_runs.remove(r) 1213 except ValueError: 1214 return {'status': Validator.DISQUALIFIED, 1215 'unfinished pool': self.POOLNAMES[i], 1216 'run': r['course']} 1217 1218 1219 # check for proper order of finish courses 1220 finish_pool = [ c for c in self._courses if self.FINISH.match(c) ] 1221 finish_pool.sort() 1222 for i,c in enumerate(finish_pool): 1223 if i >= len(remaining_runs): 1224 # no more runs 1225 break 1226 1227 if c != remaining_runs[i]['course']: 1228 return {'status': Validator.DISQUALIFIED, 1229 'unfinished pool': self.POOLNAMES[3], 1230 'run': remaining_runs[i]['course']} 1231 1232 return {'status': Validator.OK}
1233
1234 - def validate(self, team):
1235 """Validate the runs of this team according to the rules 1236 of the 24h orienteering event. 1237 The following rules apply: 1238 - runners must run in the defined order, omitted runners may not 1239 run again 1240 - every runner must run at least once at the beginning of the event 1241 - runs must be run in the following order: 1242 - 4 start courses (Codes SF1-4), fixed order defined for each 1243 team 1244 - night courses (Codes LDNx, LENx, SDNx, SENx), free order 1245 choosen by team 1246 - day courses (Codes LDx, LEx, SDx, SEx), free order 1247 choosen by team 1248 - 6 finish courses (Codes FF1-6), same order for each team""" 1249 1250 try: 1251 return self._from_cache(self.validate, team) 1252 except KeyError: 1253 pass 1254 1255 result = {'override':False} 1256 # check for override 1257 if team.override is not None: 1258 result['status'] = team.override 1259 result['override'] = True 1260 else: 1261 # check runner order 1262 result['status'] = self._check_order(team) 1263 if result['status'] == Validator.OK: 1264 result = self._check_pools(team) 1265 else: 1266 result['runner order'] = False 1267 1268 result['information'] = {'blocks': self._blocks} 1269 self._to_cache(self.validate, team, result) 1270 return result
1271
1272 - def score(self, team):
1273 """Compute score for a 24h team according to the following rules: 1274 - compute finish time: 1275 - 24h - (omitted_runners - 1) * 30min - sum(expected_time - 1276 running_time) for all failed runs 1277 - count valid runs completed before the finish time 1278 - record finish time of the last VALID run 1279 The result is a 24hScore object consisting of the number of runs 1280 and the finish time of the last run (not equal to the finish time 1281 computed in the first step!).""" 1282 1283 try: 1284 return self._from_cache(self.score, team) 1285 except KeyError: 1286 pass 1287 1288 runs = self._runs(team) 1289 1290 failed = [] 1291 for r in runs: 1292 status = r['validation'] 1293 if status in [Validator.MISSING_CONTROLS, 1294 Validator.DID_NOT_FINISH, 1295 Validator.DISQUALIFIED]: 1296 failed.append(r) 1297 1298 fail_penalty = timedelta(0) 1299 for f in failed: 1300 penalty = (f['expected_time'] 1301 - f['score']) 1302 if penalty < timedelta(0): 1303 # no negative penalty 1304 penalty = timedelta(0) 1305 fail_penalty += penalty 1306 1307 give_up_penalty = (self._omitted_runners(team)-1) * timedelta(minutes=30) 1308 if give_up_penalty < timedelta(0): 1309 # correct penalty if no runner gave up 1310 give_up_penalty = timedelta(0) 1311 1312 finish_time = (self._starttime + self._duration 1313 - fail_penalty - give_up_penalty) 1314 1315 valid_runs = [ r for r in runs 1316 if r['validation'] == Validator.OK 1317 and r['finish'] <= finish_time] 1318 1319 if len(valid_runs) > 0: 1320 runtime = max(valid_runs, key=lambda x: x['finish'])['finish'] - self._starttime 1321 else: 1322 runtime = timedelta(0) 1323 1324 if self._method == 'runcount': 1325 result = Relay24hScore(len(valid_runs),runtime) 1326 elif self._method in ['lkm', 'speed']: 1327 lkm = 0 1328 for r in valid_runs: 1329 lkm += r['lkm'] 1330 if self._method == 'lkm': 1331 result = Relay24hScore(lkm, runtime) 1332 elif self._method == 'speed': 1333 result = lkm > 0 and Relay24hScore((runtime.seconds/60.0)/lkm, runtime) or Relay24hScore(0, runtime) 1334 else: 1335 raise UnscoreableException("Unknown scoreing method '%s'." % self._method) 1336 1337 ret = {'score':result, 1338 'finishtime': finish_time, 1339 'information': {'method': self._method, 1340 'blocks': self._blocks}, 1341 } 1342 self._to_cache(self.score, team, ret) 1343 return ret
1344
1345 -class Relay12hScoreing(Relay24hScoreing):
1346 """This class is both a valiadtion and a scoreing strategy. It implements the 1347 different validation algorithm of the 12h relay.""" 1348 1349 START = re.compile('^SF[1-2]$') 1350 NIGHT = re.compile('^$') 1351 DAY = re.compile('^[LS][DE][1-4]$') 1352 FINISH = re.compile('^FF[1-2]$') 1353
1354 - def _omitted_runners(self, team):
1355 """The order of the runners for the 12h relay is free. So no runner may 1356 be omitted. Runner that give up don't cause a time penalty.""" 1357 return 0
1358
1359 - def validate(self, team):
1360 """Validate the runs of this team according to the rules 1361 of the 12h orienteering event. 1362 The following rules apply: 1363 - the order of the runners is free the team may change it at 1364 any time 1365 - runs must be run in the following order: 1366 - 2 start course (Code SF1-2), individually for each team 1367 - day courses (Codes LDx, LEx, SDx, SEx), free order 1368 choosen by team 1369 - 2 finish courses (Codes FF1-2), same order for each team""" 1370 1371 try: 1372 return self._from_cache(self.validate, team) 1373 except KeyError: 1374 pass 1375 1376 # only check pools 1377 result = self._check_pools(team) 1378 1379 result['information'] = {'blocks':self._blocks} 1380 1381 self._to_cache(self.validate, team, result) 1382 return result
1383
1384 -class Relay24hScore(object):
1385
1386 - def __init__(self, runs, time):
1387 self.runs = runs 1388 self.time = time
1389
1390 - def __cmp__(self, other):
1391 """compares two Relay24hScore objects.""" 1392 1393 if self.runs > other.runs: 1394 return -1 1395 elif self.runs < other.runs: 1396 return 1 1397 else: 1398 if self.time < other.time: 1399 return -1 1400 elif self.time > other.time: 1401 return 1 1402 else: 1403 return 0
1404
1405 - def __sub__(self, other):
1406 """Subtracts Relay24hScore objects. This is mainly usefull to calculate 1407 differences between teams.""" 1408 return Relay24hScore(self.runs - other.runs, self.time - other.time)
1409
1410 - def __mul__(self, other):
1411 """Multiplication of Relay24hScore objects. This is to theoretically allow 1412 reverse Rankings (behind score multiplied by -1). 1413 """ 1414 return Relay24hScore(self.runs * other, self.time * other)
1415
1416 - def __str__(self):
1417 return "Runs: %s, Time: %s" % (self.runs, self.time)
1418
1419 -class ControlPunchtimeScoreing(AbstractScoreing, Validator):
1420 """Scores according to the (expected) absolute punchtime at a given control. 1421 This is not intended for preliminary results but to watch a control near the 1422 finish and to show incomming runners. 1423 To make effective use of this you need a rankable with all open runs as members. 1424 RankableItems scored with this strategy should be open runs. 1425 This is a combined scoreing and validation strategy. 1426 """ 1427
1428 - def __init__(self, control_list, distance_to_finish = 0, 1429 cache = None):
1430 """ 1431 @param control_list: score at these controls 1432 @param distance_to_finish: distance form control to the finish. 1433 This is used to compute the expected 1434 time at this control (not yet implemented) 1435 """ 1436 AbstractScoreing.__init__(self, cache) 1437 self._controls = control_list 1438 self._distance_to_finish = distance_to_finish
1439
1440 - def validate(self, run):
1441 """ 1442 @return: Validator.OK if the control was punched, 1443 Validator.NOT_COMPLETED if the control was not yet punched 1444 """ 1445 try: 1446 return self._from_cache(self.validate, run) 1447 except KeyError: 1448 pass 1449 1450 punchlist = [c for p,c in run.punchlist()] 1451 result = Validator.NOT_COMPLETED 1452 for c in self._controls: 1453 if c in punchlist: 1454 result = Validator.OK 1455 break 1456 1457 ret = {'status': result} 1458 self._to_cache(self.validate, run, ret) 1459 return ret
1460
1461 - def score(self, run):
1462 """ 1463 @return: time of the punch or datetime.min if punchtime is unknown. 1464 If more than one of the control was punched, the punchtime 1465 of the first in the list is returned. 1466 """ 1467 try: 1468 return self._from_cache(self.score, run) 1469 except KeyError: 1470 pass 1471 1472 try: 1473 (controls, punchtimes) = zip(*[(p.sistation.control, p.punchtime) 1474 for p in run.punches]) 1475 1476 result = datetime.min 1477 for c in self._controls: 1478 try: 1479 index = list(controls).index(c) 1480 result = punchtimes[index] 1481 break 1482 except ValueError: 1483 pass 1484 1485 except ValueError: 1486 # no punches in this run 1487 result = datetime.min 1488 1489 ret = {'score':result} 1490 self._to_cache(self.score, run, ret) 1491 return ret
1492
1493 -class RoundCountScoreing(AbstractScoreing, CourseValidator):
1494 """This scoreing strategy computes the score as the count of complete runs of a course. 1495 This is mostly usefull for non orienteering events where runners have to run as much rounds 1496 as possible in a predifined time intervall. 1497 Be aware that SI Card 5 and SI Card 8 only hold 30 punches! 1498 1499 This is a combined validator and scoreing strategy. 1500 """ 1501
1502 - def __init__(self, course, mindiff = timedelta(0), cache = None):
1503 """ 1504 @param course Course object with all controls on the roundcourse. In the simplest case 1505 this course has only 1 control. 1506 @param mindiff Minimal time difference between valid punches. This is necessary to avoid 1507 counting two runs if runners punch the only control of the course two times 1508 without running the round in between. 1509 @type mindiff timedelta 1510 """ 1511 super(RoundCountScoreing, self).__init__(cache) 1512 self._course = course 1513 self._mindiff = mindiff 1514 1515 # list of all controls which have sistations 1516 self._controllist = self._course.controllist()
1517
1518 - def validate(self, run):
1519 1520 try: 1521 return self._from_cache(self.validate, run) 1522 except KeyError: 1523 pass 1524 1525 # do basic checks from CourseValidator 1526 result = CourseValidator.validate(self, run) 1527 1528 result['score'] = 0 1529 i = 0 1530 lastpunch = None 1531 punchlist = [] 1532 for code, punch in result['punchlist']: 1533 1534 if not code == 'ok': 1535 punchlist.append((code, punch)) 1536 continue 1537 1538 if (not punch.sistation.control 1539 or punch.sistation.control not in self._controllist): 1540 punchlist.append(('ignored', punch)) 1541 continue 1542 1543 if lastpunch and punch.punchtime - lastpunch < self._mindiff: 1544 # additional punch for same round 1545 punchlist.append(('additional', punch)) 1546 continue 1547 1548 if punch.sistation.control is self._controllist[i]: 1549 lastpunch = punch.punchtime 1550 i += 1 1551 punchlist.append(('ok', punch)) 1552 1553 if i == len(self._controllist): 1554 # round completed 1555 result['score'] += 1 1556 i = 0 1557 1558 result['punchlist'] = punchlist 1559 self._to_cache(self.validate, run, result) 1560 return result
1561
1562 - def score(self, run):
1563 1564 return self.validate(run)
1565
1566 -class UnscoreableException(Exception):
1567 pass
1568
1569 -class ValidationError(Exception):
1570 pass
1571