scottwillson/racing_on_rails

View on GitHub
app/models/calculations/v3/calculator.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# frozen_string_literal: true

require "benchmark"
require "logger"

module Calculations
  module V3
    # Inputs:
    #   * Calculation rules
    #   * results as model SourceResults
    #
    # Steps
    #   1. Transform source results into array of calculated event categories
    #      with a calculated result for each unique participant
    #      * each source result belongs to a calculated result
    #      * every calculated result has at least one source result
    #      * source results may be duplicated later (for upgrades)
    #   2. assign points
    #   3. create upgrade results
    #   4. assign points again? What is sequence for upgrades?
    #   5. sum points
    #   6. place results (skip zero-results?)
    # many missing steps ...
    #
    # Result outcomes. The division between 1, 2, and 3 is subjective and tries
    # to answer "why didn't this result count?" without clutter.
    #   1. Rejected by initial SQL query (e.g., different year)
    #   2. Rejected for reasons like a different discipline. Not shown in final
    #      results.
    #   3. Selected, but marked as rejected (membership required)
    #   4. Selected, but not assigned points (lower places, "best of")
    #   5. Selected and assigned points
    class Calculator
      include Calculations::V3::Calculators::Categories

      attr_reader :calculations_events, :event_categories, :rules, :source_events, :year

      def initialize(calculations_events: [], logger: Logger.new($stdout, level: :fatal), rules: Rules.new, source_events: [], source_results: [], year: Date.today.year)
        raise(ArgumentError, "rules should be Rules, but are #{rules.class}") unless rules.is_a?(Rules)

        @calculations_events = calculations_events
        @logger = logger
        @rules = rules
        @source_events = source_events
        @year = year

        @event_categories = create_event_categories
        map_source_results_to_results source_results
      end

      # Do the work, all in memory with Ruby classes
      def calculate!
        @logger.debug "Calculator#calculate! source_results: #{source_results.size} rules: #{rules.to_h}"

        if rules.place_by == "place"
          # place before assigning points
          calculate_step(Steps::RejectCalculatedEvents)
            .calculate_step(Steps::RejectWeekdayEvents)
            .calculate_step(Steps::SelectInDiscipline)
            .calculate_step(Steps::RejectCategories)
            .calculate_step(Steps::AssignTeamSizes)
            .calculate_step(Steps::RejectNoParticipant)
            .calculate_step(Steps::SelectAssociationSanctioned)
            .calculate_step(Steps::SelectMembers)
            .calculate_step(Steps::RejectDnfs)
            .calculate_step(Steps::RejectNoPoints)
            .calculate_step(Steps::RejectBelowMinimumEvents)
            .calculate_step(Steps::RejectMoreThanResultsPerEvent)
            .calculate_step(Steps::RejectCategoryWorstResults)
            .calculate_step(Steps::RejectEmptySourceResults)
            .calculate_step(Steps::RejectAllSourceResultsRejected)
            .calculate_step(Steps::Place)
            .calculate_step(Steps::AssignPoints)
            .calculate_step(Steps::RejectNoSourceEventPoints)
            .calculate_step(Steps::AddMissingResultsPenalty)
            .calculate_step(Steps::RejectMoreThanMaximumEvents)
            .calculate_step(Steps::SumPoints)
            .calculate_step(Steps::Validate)
            .calculate_step(Steps::RejectEmptyCategories)
            .event_categories
        else
          # place after assigning points
          calculate_step(Steps::RejectCalculatedEvents)
            .calculate_step(Steps::RejectWeekdayEvents)
            .calculate_step(Steps::SelectInDiscipline)
            .calculate_step(Steps::RejectCategories)
            .calculate_step(Steps::AssignTeamSizes)
            .calculate_step(Steps::RejectNoParticipant)
            .calculate_step(Steps::SelectAssociationSanctioned)
            .calculate_step(Steps::SelectMembers)
            .calculate_step(Steps::RejectDnfs)
            .calculate_step(Steps::RejectNoPoints)
            .calculate_step(Steps::RejectBelowMinimumEvents)
            .calculate_step(Steps::RejectMoreThanResultsPerEvent)
            .calculate_step(Steps::RejectCategoryWorstResults)
            .calculate_step(Steps::AssignPoints)
            .calculate_step(Steps::RejectNoSourceEventPoints)
            .calculate_step(Steps::AddMissingResultsPenalty)
            .calculate_step(Steps::RejectMoreThanMaximumEvents)
            .calculate_step(Steps::RejectEmptySourceResults)
            .calculate_step(Steps::RejectAllSourceResultsRejected)
            .calculate_step(Steps::SumPoints)
            .calculate_step(Steps::Place)
            .calculate_step(Steps::Validate)
            .calculate_step(Steps::RejectEmptyCategories)
            .event_categories
        end
      end

      def calculate_step(step)
        results_count_before = results.size
        rejections_count_before = results.count(&:rejected?)
        source_results_count_before = source_results.size
        source_result_rejections_count_before = source_results.count(&:rejected?)

        time = Benchmark.measure do
          @event_categories = step.calculate!(self)
        end

        results_count_after = results.size
        rejections_count_after = results.count(&:rejected?)
        source_results_count_after = source_results.size
        source_result_rejections_count_after = source_results.count(&:rejected?)
        formatted_time = format("%.1fms", time.real)
        @logger.debug(<<~MSG
          Steps::#{step}#calculate!
          duration: #{formatted_time}
          results: #{results_count_after - results_count_before}
          rejections: #{rejections_count_after - rejections_count_before}
          source_results: #{source_results_count_after - source_results_count_before}
          source_result_rejections: #{source_result_rejections_count_after - source_result_rejections_count_before}
        MSG
                     )

        self
      end

      def map_source_results_to_results(source_results)
        source_results.each do |source_result|
          source_result.reject("not_calculation_category") unless in_calculation_category?(source_result.category, source_result.age)

          event_category = find_or_create_event_category(source_result)
          calculated_result = find_calculated_result(event_category, source_result.participant.id)

          if calculated_result
            calculated_result.source_results << source_result
          else
            calculated_result = Models::CalculatedResult.new(
              Models::Participant.new(source_result.participant.id, membership: source_result.participant.membership),
              [source_result]
            )
            calculated_result.reject("not_calculation_category") unless in_calculation_category?(event_category.category, source_result.age)

            event_category.results << calculated_result
          end
        end
      end

      def find_calculated_result(event_category, participant_id)
        return nil if rules.place_by == "place" || rules.place_by == "time"

        event_category.results.find { |r| r.participant.id == participant_id }
      end

      def results
        event_categories.flat_map(&:results)
      end

      def source_results
        results.flat_map(&:source_results)
      end

      def unrejected_results
        results.reject(&:rejected?)
      end

      def unrejected_source_results
        unrejected_results.flat_map(&:source_results).reject(&:rejected?)
      end

      def validate!
        if event_categories.size != event_categories.uniq.size
          raise("Duplicate categories in #{event_categories.map(&:name).sort}")
        end
      end

      delegate :categories, :categories?, :team?, to: :rules
    end
  end
end