# frozen_string_literal: true

# *******************************************************************************
# OpenStudio(R), Copyright (c) 2008-2021, Alliance for Sustainable Energy, LLC.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# (1) Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# (2) Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# (3) Neither the name of the copyright holder nor the names of any contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission from the respective party.
#
# (4) Other than as required in clauses (1) and (2), distributions in any form
# of modifications or other derivative works may not use the "OpenStudio"
# trademark, "OS", "os", or any other confusingly similar designation without
# specific prior written permission from Alliance for Sustainable Energy, LLC.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), ANY CONTRIBUTORS, THE
# UNITED STATES GOVERNMENT, OR THE UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF
# THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# *******************************************************************************

# start the measure
class ReduceNightTimeElectricEquipmentLoads < OpenStudio::Measure::ModelMeasure
  # define the name that a user will see
  def name
    return 'Reduce Night Time Electric Equipment Loads'
  end

  # define the arguments that the user will input
  def arguments(model)
    args = OpenStudio::Measure::OSArgumentVector.new

    # make a choice argument for load def with one or more instances
    elec_load_def_handles = OpenStudio::StringVector.new
    elec_load_def_display_names = OpenStudio::StringVector.new

    # putting load defs and names into hash
    elec_load_def_args = model.getElectricEquipmentDefinitions
    elec_load_def_args_hash = {}
    elec_load_def_args.each do |elec_load_def_arg|
      elec_load_def_args_hash[elec_load_def_arg.name.to_s] = elec_load_def_arg
    end

    # looping through sorted hash of load defs
    elec_load_def_args_hash.sort.map do |key, value|
      if !value.instances.empty?
        elec_load_def_handles << value.handle.to_s
        elec_load_def_display_names << key
      end
    end

    # make an argument for electric equipment definition
    elec_load_def = OpenStudio::Measure::OSArgument.makeChoiceArgument('elec_load_def', elec_load_def_handles, elec_load_def_display_names)
    elec_load_def.setDisplayName('Pick an Electric Equipment Definition(schedules using this will be altered)')
    args << elec_load_def

    # make an argument for fractional value during specified time
    fraction_value = OpenStudio::Measure::OSArgument.makeDoubleArgument('fraction_value', true)
    fraction_value.setDisplayName('Fractional Value for Night Time Load.')
    fraction_value.setDefaultValue(0.1)
    args << fraction_value

    # apply to weekday
    apply_weekday = OpenStudio::Measure::OSArgument.makeBoolArgument('apply_weekday', true)
    apply_weekday.setDisplayName('Apply Schedule Changes to Weekday and Default Profiles?')
    apply_weekday.setDefaultValue(true)
    args << apply_weekday

    # weekday start time
    start_weekday = OpenStudio::Measure::OSArgument.makeDoubleArgument('start_weekday', true)
    start_weekday.setDisplayName('Weekday/Default Time to Start Night Time Fraction(24hr, use decimal for sub hour).')
    start_weekday.setDefaultValue(18.0)
    args << start_weekday

    # weekday end time
    end_weekday = OpenStudio::Measure::OSArgument.makeDoubleArgument('end_weekday', true)
    end_weekday.setDisplayName('Weekday/Default Time to End Night Time Fraction(24hr, use decimal for sub hour).')
    end_weekday.setDefaultValue(9.0)
    args << end_weekday

    # apply to saturday
    apply_saturday = OpenStudio::Measure::OSArgument.makeBoolArgument('apply_saturday', true)
    apply_saturday.setDisplayName('Apply Schedule Changes to Saturdays?')
    apply_saturday.setDefaultValue(true)
    args << apply_saturday

    # saturday start time
    start_saturday = OpenStudio::Measure::OSArgument.makeDoubleArgument('start_saturday', true)
    start_saturday.setDisplayName('Saturday Time to Start Night Time Fraction(24hr, use decimal for sub hour).')
    start_saturday.setDefaultValue(18.0)
    args << start_saturday

    # saturday end time
    end_saturday = OpenStudio::Measure::OSArgument.makeDoubleArgument('end_saturday', true)
    end_saturday.setDisplayName('Saturday Time to End Night Time Fraction(24hr, use decimal for sub hour).')
    end_saturday.setDefaultValue(9.0)
    args << end_saturday

    # apply to sunday
    apply_sunday = OpenStudio::Measure::OSArgument.makeBoolArgument('apply_sunday', true)
    apply_sunday.setDisplayName('Apply Schedule Changes to Sundays?')
    apply_sunday.setDefaultValue(true)
    args << apply_sunday

    # sunday start time
    start_sunday = OpenStudio::Measure::OSArgument.makeDoubleArgument('start_sunday', true)
    start_sunday.setDisplayName('Sunday Time to Start Night Time Fraction(24hr, use decimal for sub hour).')
    start_sunday.setDefaultValue(18.0)
    args << start_sunday

    # sunday end time
    end_sunday = OpenStudio::Measure::OSArgument.makeDoubleArgument('end_sunday', true)
    end_sunday.setDisplayName('Sunday Time to End Night Time Fraction(24hr, use decimal for sub hour).')
    end_sunday.setDefaultValue(9.0)
    args << end_sunday

    # make an argument for material and installation cost
    material_cost = OpenStudio::Measure::OSArgument.makeDoubleArgument('material_cost', true)
    material_cost.setDisplayName('Material and Installation Costs per Electric Equipment Quantity ($).')
    material_cost.setDefaultValue(0.0)
    args << material_cost

    # make an argument for duration in years until costs start
    years_until_costs_start = OpenStudio::Measure::OSArgument.makeIntegerArgument('years_until_costs_start', true)
    years_until_costs_start.setDisplayName('Years Until Costs Start (whole years).')
    years_until_costs_start.setDefaultValue(0)
    args << years_until_costs_start

    # make an argument for expected life
    expected_life = OpenStudio::Measure::OSArgument.makeIntegerArgument('expected_life', true)
    expected_life.setDisplayName('Expected Life (whole years).')
    expected_life.setDefaultValue(20)
    args << expected_life

    # make an argument for o&m cost
    om_cost = OpenStudio::Measure::OSArgument.makeDoubleArgument('om_cost', true)
    om_cost.setDisplayName('O & M Costs Costs per Electric Equipment Quantity ($).')
    om_cost.setDefaultValue(0.0)
    args << om_cost

    # make an argument for o&m frequency
    om_frequency = OpenStudio::Measure::OSArgument.makeIntegerArgument('om_frequency', true)
    om_frequency.setDisplayName('O & M Frequency (whole years).')
    om_frequency.setDefaultValue(1)
    args << om_frequency

    return args
  end

  # define what happens when the measure is run
  def run(model, runner, user_arguments)
    super(model, runner, user_arguments)

    # use the built-in error checking
    if !runner.validateUserArguments(arguments(model), user_arguments)
      return false
    end

    # assign the user inputs to variables
    elec_load_def = runner.getOptionalWorkspaceObjectChoiceValue('elec_load_def', user_arguments, model)
    fraction_value = runner.getDoubleArgumentValue('fraction_value', user_arguments)
    apply_weekday = runner.getBoolArgumentValue('apply_weekday', user_arguments)
    start_weekday = runner.getDoubleArgumentValue('start_weekday', user_arguments)
    end_weekday = runner.getDoubleArgumentValue('end_weekday', user_arguments)
    apply_saturday = runner.getBoolArgumentValue('apply_saturday', user_arguments)
    start_saturday = runner.getDoubleArgumentValue('start_saturday', user_arguments)
    end_saturday = runner.getDoubleArgumentValue('end_saturday', user_arguments)
    apply_sunday = runner.getBoolArgumentValue('apply_sunday', user_arguments)
    start_sunday = runner.getDoubleArgumentValue('start_sunday', user_arguments)
    end_sunday = runner.getDoubleArgumentValue('end_sunday', user_arguments)
    material_cost = runner.getDoubleArgumentValue('material_cost', user_arguments)
    years_until_costs_start = runner.getIntegerArgumentValue('years_until_costs_start', user_arguments)
    expected_life = runner.getIntegerArgumentValue('expected_life', user_arguments)
    om_cost = runner.getDoubleArgumentValue('om_cost', user_arguments)
    om_frequency = runner.getIntegerArgumentValue('om_frequency', user_arguments)

    # check the elec_load_def for reasonableness
    if elec_load_def.empty?
      test = runner.getStringArgumentValue('elec_load_def', user_arguments)
      if test.empty?
        runner.registerError('No Electric Equipment Definition was chosen.')
      else
        runner.registerError("An Electric Equipment Definition with handle '#{elec_load_def}' was not found in the model. It may have been removed by another measure.")
      end
      return false
    else
      if !elec_load_def.get.to_ElectricEquipmentDefinition.empty?
        elec_load_def = elec_load_def.get.to_ElectricEquipmentDefinition.get
      else
        runner.registerError('Script Error - argument not showing up as electric equipment definition.')
        return false
      end
    end

    # check the fraction for reasonableness
    if (fraction_value < 0) && (fraction_value <= 1)
      runner.registerError('Fractional value needs to be between or equal to 0 and 1.')
      return false
    end

    # check start_weekday for reasonableness and round to 15 minutes
    if (start_weekday < 0) && (start_weekday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24')
      return false
    else
      rounded_start_weekday = (start_weekday * 4).round / 4.0
      if start_weekday != rounded_start_weekday
        runner.registerInfo("Weekday start time rounded to nearest 15 minutes: #{rounded_start_weekday}")
      end
      wk_after_hour = rounded_start_weekday.truncate
      wk_after_min = (rounded_start_weekday - wk_after_hour) * 60
      wk_after_min = wk_after_min.to_i
    end

    # check end_weekday for reasonableness and round to 15 minutes
    if (end_weekday < 0) && (end_weekday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24.')
      return false
    elsif end_weekday > start_weekday
      runner.registerError('Please enter an end time earlier in the day than start time.')
      return false
    else
      rounded_end_weekday = (end_weekday * 4).round / 4.0
      if end_weekday != rounded_end_weekday
        runner.registerInfo("Weekday end time rounded to nearest 15 minutes: #{rounded_end_weekday}")
      end
      wk_before_hour = rounded_end_weekday.truncate
      wk_before_min = (rounded_end_weekday - wk_before_hour) * 60
      wk_before_min = wk_before_min.to_i
    end

    # check start_saturday for reasonableness and round to 15 minutes
    if (start_saturday < 0) && (start_saturday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24.')
      return false
    else
      rounded_start_saturday = (start_saturday * 4).round / 4.0
      if start_saturday != rounded_start_saturday
        runner.registerInfo("Saturday start time rounded to nearest 15 minutes: #{rounded_start_saturday}")
      end
      sat_after_hour = rounded_start_saturday.truncate
      sat_after_min = (rounded_start_saturday - sat_after_hour) * 60
      sat_after_min = sat_after_min.to_i
    end

    # check end_saturday for reasonableness and round to 15 minutes
    if (end_saturday < 0) && (end_saturday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24.')
      return false
    elsif end_saturday > start_saturday
      runner.registerError('Please enter an end time earlier in the day than start time.')
      return false
    else
      rounded_end_saturday = (end_saturday * 4).round / 4.0
      if end_saturday != rounded_end_saturday
        runner.registerInfo("Saturday end time rounded to nearest 15 minutes: #{rounded_end_saturday}")
      end
      sat_before_hour = rounded_end_saturday.truncate
      sat_before_min = (rounded_end_saturday - sat_before_hour) * 60
      sat_before_min = sat_before_min.to_i
    end

    # check start_sunday for reasonableness and round to 15 minutes
    if (start_sunday < 0) && (start_sunday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24.')
      return false
    else
      rounded_start_sunday = (start_sunday * 4).round / 4.0
      if start_sunday != rounded_start_sunday
        runner.registerInfo("Sunday start time rounded to nearest 15 minutes: #{rounded_start_sunday}")
      end
      sun_after_hour = rounded_start_sunday.truncate
      sun_after_min = (rounded_start_sunday - sun_after_hour) * 60
      sun_after_min = sun_after_min.to_i
    end

    # check end_sunday for reasonableness and round to 15 minutes
    if (end_sunday < 0) && (end_sunday <= 24)
      runner.registerError('Time in hours needs to be between or equal to 0 and 24.')
      return false
    elsif end_sunday > start_sunday
      runner.registerError('Please enter an end time earlier in the day than start time.')
      return false
    else
      rounded_end_sunday = (end_sunday * 4).round / 4.0
      if end_sunday != rounded_end_sunday
        runner.registerInfo("Sunday end time rounded to nearest 15 minutes: #{rounded_end_sunday}")
      end
      sun_before_hour = rounded_end_sunday.truncate
      sun_before_min = (rounded_end_sunday - sun_before_hour) * 60
      sun_before_min = sun_before_min.to_i
    end

    # set flags to use later
    costs_requested = false

    # check costs for reasonableness
    if material_cost.abs + om_cost.abs == 0
      runner.registerInfo("No costs were requested for #{elec_load_def.name}.")
    else
      costs_requested = true
    end

    # check lifecycle arguments for reasonableness
    if (years_until_costs_start < 0) && (years_until_costs_start > expected_life)
      runner.registerError('Years until costs start should be a non-negative integer less than Expected Life.')
    end
    if (expected_life < 1) && (expected_life > 100)
      runner.registerError('Choose an integer greater than 0 and less than or equal to 100 for Expected Life.')
    end
    if om_frequency < 1
      runner.registerError('Choose an integer greater than 0 for O & M Frequency.')
    end

    # short def to make numbers pretty (converts 4125001.25641 to 4,125,001.26 or 4,125,001). The definition be called through this measure
    def neat_numbers(number, roundto = 2) # round to 0 or 2)
      if roundto == 2
        number = format '%.2f', number
      else
        number = number.round
      end
      # regex to add commas
      number.to_s.reverse.gsub(/([0-9]{3}(?=([0-9])))/, '\\1,').reverse
    end

    # breakup fractional values
    wk_before_value = fraction_value
    wk_after_value = fraction_value
    sat_before_value = fraction_value
    sat_after_value = fraction_value
    sun_before_value = fraction_value
    sun_after_value = fraction_value

    equip_schs = {}
    equip_sch_names = []
    reduced_equip_schs = {}

    # get instances of definition
    equipment_instances = model.getElectricEquipments
    equipment_instances_using_def = []

    # get schedules for equipment instances that user the picked
    equipment_instances.each do |equip|
      next unless equip.electricEquipmentDefinition == elec_load_def
      equipment_instances_using_def << equip
      if !equip.schedule.empty?
        equip_sch = equip.schedule.get
        equip_schs[equip_sch.name.to_s] = equip_sch
        equip_sch_names << equip_sch.name.to_s
      end
    end

    # reporting initial condition of model
    runner.registerInitialCondition("The initial model had #{equipment_instances_using_def.size} instances of '#{elec_load_def.name}' load definition.")

    # loop through the unique list of equip schedules, cloning
    # and reducing schedule fraction before and after the specified times
    equip_sch_names.uniq.each do |equip_sch_name|
      equip_sch = equip_schs[equip_sch_name]
      if !equip_sch.to_ScheduleRuleset.empty?
        new_equip_sch = equip_sch.clone(model).to_ScheduleRuleset.get
        new_equip_sch.setName("#{equip_sch_name} NightLoadControl")
        reduced_equip_schs[equip_sch_name] = new_equip_sch
        new_equip_sch = new_equip_sch.to_ScheduleRuleset.get

        # method to reduce the values in a day schedule to a give number before and after a given time
        def reduce_schedule(day_sch, before_hour, before_min, before_value, after_hour, after_min, after_value)
          before_time = OpenStudio::Time.new(0, before_hour, before_min, 0)
          after_time = OpenStudio::Time.new(0, after_hour, after_min, 0)
          day_end_time = OpenStudio::Time.new(0, 24, 0, 0)

          # Special situation for when start time and end time are equal,
          # meaning that a 24hr reduction is desired
          if before_time == after_time
            day_sch.clearValues
            day_sch.addValue(day_end_time, after_value)
            return
          end

          original_value_at_after_time = day_sch.getValue(after_time)
          day_sch.addValue(before_time, before_value)
          day_sch.addValue(after_time, original_value_at_after_time)
          times = day_sch.times
          values = day_sch.values
          day_sch.clearValues

          new_times = []
          new_values = []
          for i in 0..(values.length - 1)
            if (times[i] >= before_time) && (times[i] <= after_time)
              new_times << times[i]
              new_values << values[i]
            end
          end

          # add the value for the time period from after time to end of the day
          new_times << day_end_time
          new_values << after_value

          for i in 0..(new_values.length - 1)
            day_sch.addValue(new_times[i], new_values[i])
          end
        end

        # Reduce default day schedules
        if new_equip_sch.scheduleRules.empty?
          runner.registerWarning("Schedule '#{new_equip_sch.name}' applies to all days.  It has been treated as a Weekday schedule.")
        end
        reduce_schedule(new_equip_sch.defaultDaySchedule, wk_before_hour, wk_before_min, wk_before_value, wk_after_hour, wk_after_min, wk_after_value)

        # reduce weekdays
        new_equip_sch.scheduleRules.each do |sch_rule|
          if apply_weekday
            if sch_rule.applyMonday || sch_rule.applyTuesday || sch_rule.applyWednesday || sch_rule.applyThursday || sch_rule.applyFriday
              reduce_schedule(sch_rule.daySchedule, wk_before_hour, wk_before_min, wk_before_value, wk_after_hour, wk_after_min, wk_after_value)
            end
          end
        end

        # reduce saturdays
        new_equip_sch.scheduleRules.each do |sch_rule|
          if apply_saturday && sch_rule.applySaturday
            if sch_rule.applyMonday || sch_rule.applyTuesday || sch_rule.applyWednesday || sch_rule.applyThursday || sch_rule.applyFriday
              runner.registerWarning("Rule '#{sch_rule.name}' for schedule '#{new_equip_sch.name}' applies to both Saturdays and Weekdays.  It has been treated as a Weekday schedule.")
            else
              reduce_schedule(sch_rule.daySchedule, sat_before_hour, sat_before_min, sat_before_value, sat_after_hour, sat_after_min, sat_after_value)
            end
          end
        end

        # reduce sundays
        new_equip_sch.scheduleRules.each do |sch_rule|
          if apply_sunday && sch_rule.applySunday
            if sch_rule.applyMonday || sch_rule.applyTuesday || sch_rule.applyWednesday || sch_rule.applyThursday || sch_rule.applyFriday
              runner.registerWarning("Rule '#{sch_rule.name}' for schedule '#{new_equip_sch.name}' applies to both Sundays and Weekdays.  It has been  treated as a Weekday schedule.")
            elsif sch_rule.applySaturday
              runner.registerWarning("Rule '#{sch_rule.name}' for schedule '#{new_equip_sch.name}' applies to both Saturdays and Sundays.  It has been treated as a Saturday schedule.")
            else
              reduce_schedule(sch_rule.daySchedule, sun_before_hour, sun_before_min, sun_before_value, sun_after_hour, sun_after_min, sun_after_value)
            end
          end
        end
      else
        runner.registerWarning("Schedule '#{equip_sch_name}' isn't a ScheduleRuleset object and won't be altered by this measure.")
      end
    end

    # loop through all electric equipment instances, replacing old equip schedules with the reduced schedules
    equipment_instances_using_def.each do |equip|
      if equip.schedule.empty?
        runner.registerWarning("There was no schedule assigned for the electric equipment object named '#{equip.name}. No schedule was added.'")
      else
        old_equip_sch_name = equip.schedule.get.name.to_s
        if reduced_equip_schs[old_equip_sch_name]
          equip.setSchedule(reduced_equip_schs[old_equip_sch_name])
          runner.registerInfo("Schedule '#{reduced_equip_schs[old_equip_sch_name].name}' was edited for the electric equipment object named '#{equip.name}'")
        end
      end
    end

    # na if no schedules to change
    if equip_sch_names.uniq.empty?
      runner.registerNotAsApplicable('There are no schedules to change.')
    end

    measure_cost = 0

    # add lifeCycleCost objects if there is a non-zero value in one of the cost arguments
    building = model.getBuilding
    if costs_requested == true
      quantity = elec_load_def.quantity
      # adding new cost items
      lcc_mat = OpenStudio::Model::LifeCycleCost.createLifeCycleCost("LCC_Mat - #{elec_load_def.name} night reduction", building, material_cost * quantity, 'CostPerEach', 'Construction', expected_life, years_until_costs_start)
      lcc_om = OpenStudio::Model::LifeCycleCost.createLifeCycleCost("LCC_OM - #{elec_load_def.name} night reduction", building, om_cost * quantity, 'CostPerEach', 'Maintenance', om_frequency, 0)
      measure_cost = material_cost * quantity
    end

    # reporting final condition of model
    runner.registerFinalCondition("#{equip_sch_names.uniq.size} schedule(s) were edited. The cost for the measure is #{neat_numbers(measure_cost, 0)}.")

    return true
  end
end

# this allows the measure to be used by the application
ReduceNightTimeElectricEquipmentLoads.new.registerWithApplication