# frozen_string_literal: true

# Require all gems up front; this is much faster than multiple resource
# files lazy loading as needed, as it prevents multiple lookups for the
# same gem.
require 'pathname'
require 'csv'
require 'oga'
require_relative 'resources/airflow'
require_relative 'resources/constants'
require_relative 'resources/constructions'
require_relative 'resources/energyplus'
require_relative 'resources/generator'
require_relative 'resources/geometry'
require_relative 'resources/hotwater_appliances'
require_relative 'resources/hpxml'
require_relative 'resources/hpxml_defaults'
require_relative 'resources/hvac'
require_relative 'resources/hvac_sizing'
require_relative 'resources/lighting'
require_relative 'resources/location'
require_relative 'resources/materials'
require_relative 'resources/misc_loads'
require_relative 'resources/psychrometrics'
require_relative 'resources/pv'
require_relative 'resources/schedules'
require_relative 'resources/simcontrols'
require_relative 'resources/unit_conversions'
require_relative 'resources/util'
require_relative 'resources/validator'
require_relative 'resources/version'
require_relative 'resources/waterheater'
require_relative 'resources/weather'
require_relative 'resources/xmlhelper'
require_relative '../BuildResidentialHPXML/resources/constants'
require_relative '../BuildResidentialHPXML/resources/schedules'

# start the measure
class HPXMLtoOpenStudio < OpenStudio::Measure::ModelMeasure
  # human readable name
  def name
    return 'HPXML to OpenStudio Translator'
  end

  # human readable description
  def description
    return 'Translates HPXML file to OpenStudio Model'
  end

  # human readable description of modeling approach
  def modeler_description
    return ''
  end

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

    arg = OpenStudio::Measure::OSArgument.makeStringArgument('hpxml_path', true)
    arg.setDisplayName('HPXML File Path')
    arg.setDescription('Absolute/relative path of the HPXML file.')
    args << arg

    arg = OpenStudio::Measure::OSArgument.makeStringArgument('output_dir', true)
    arg.setDisplayName('Directory for Output Files')
    arg.setDescription('Absolute/relative path for the output files directory.')
    args << arg

    arg = OpenStudio::Measure::OSArgument.makeBoolArgument('debug', false)
    arg.setDisplayName('Debug Mode?')
    arg.setDescription('If true: 1) Writes in.osm file, 2) Generates additional log output, and 3) Creates all EnergyPlus output files.')
    arg.setDefaultValue(false)
    args << arg

    arg = OpenStudio::Measure::OSArgument.makeBoolArgument('skip_validation', false)
    arg.setDisplayName('Skip Validation?')
    arg.setDescription('If true, bypasses HPXML input validation for faster performance. WARNING: This should only be used if the supplied HPXML file has already been validated against the Schema & Schematron documents.')
    arg.setDefaultValue(false)
    args << arg

    arg = OpenStudio::Measure::OSArgument.makeStringArgument('building_id', false)
    arg.setDisplayName('BuildingID')
    arg.setDescription('The ID of the HPXML Building. Only required if there are multiple Building elements in the HPXML file.')
    args << arg

    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

    Geometry.tear_down_model(model, runner)

    Version.check_openstudio_version()

    # assign the user inputs to variables
    hpxml_path = runner.getStringArgumentValue('hpxml_path', user_arguments)
    output_dir = runner.getStringArgumentValue('output_dir', user_arguments)
    debug = runner.getBoolArgumentValue('debug', user_arguments)
    skip_validation = runner.getBoolArgumentValue('skip_validation', user_arguments)
    building_id = runner.getOptionalStringArgumentValue('building_id', user_arguments)

    unless (Pathname.new hpxml_path).absolute?
      hpxml_path = File.expand_path(File.join(File.dirname(__FILE__), hpxml_path))
    end
    unless File.exist?(hpxml_path) && hpxml_path.downcase.end_with?('.xml')
      fail "'#{hpxml_path}' does not exist or is not an .xml file."
    end

    unless (Pathname.new output_dir).absolute?
      output_dir = File.expand_path(File.join(File.dirname(__FILE__), output_dir))
    end

    if building_id.is_initialized
      building_id = building_id.get
    else
      building_id = nil
    end

    begin
      if skip_validation
        stron_paths = []
      else
        stron_paths = [File.join(File.dirname(__FILE__), 'resources', 'HPXMLvalidator.xml'),
                       File.join(File.dirname(__FILE__), 'resources', 'EPvalidator.xml')]
      end
      hpxml = HPXML.new(hpxml_path: hpxml_path, schematron_validators: stron_paths, building_id: building_id)
      hpxml.errors.each do |error|
        runner.registerError(error)
      end
      hpxml.warnings.each do |warning|
        runner.registerWarning(warning)
      end
      return false unless hpxml.errors.empty?

      epw_path, cache_path = process_weather(hpxml, runner, model, hpxml_path)

      if debug
        epw_output_path = File.join(output_dir, 'in.epw')
        FileUtils.cp(epw_path, epw_output_path)
      end

      OSModel.create(hpxml, runner, model, hpxml_path, epw_path, cache_path, output_dir, building_id, debug)
    rescue Exception => e
      runner.registerError("#{e.message}\n#{e.backtrace.join("\n")}")
      return false
    end

    return true
  end

  def process_weather(hpxml, runner, model, hpxml_path)
    epw_path = hpxml.climate_and_risk_zones.weather_station_epw_filepath

    if not File.exist? epw_path
      test_epw_path = File.join(File.dirname(hpxml_path), epw_path)
      epw_path = test_epw_path if File.exist? test_epw_path
    end
    if not File.exist? epw_path
      test_epw_path = File.join(File.dirname(__FILE__), '..', 'weather', epw_path)
      epw_path = test_epw_path if File.exist? test_epw_path
    end
    if not File.exist? epw_path
      test_epw_path = File.join(File.dirname(__FILE__), '..', '..', 'weather', epw_path)
      epw_path = test_epw_path if File.exist? test_epw_path
    end
    if not File.exist?(epw_path)
      fail "'#{epw_path}' could not be found."
    end

    cache_path = epw_path.gsub('.epw', '-cache.csv')
    if not File.exist?(cache_path)
      # Process weather file to create cache .csv
      runner.registerWarning("'#{cache_path}' could not be found; regenerating it.")
      epw_file = OpenStudio::EpwFile.new(epw_path)
      OpenStudio::Model::WeatherFile.setWeatherFile(model, epw_file)
      weather = WeatherProcess.new(model, runner)
      begin
        File.open(cache_path, 'wb') do |file|
          weather.dump_to_csv(file)
        end
      rescue SystemCallError
        runner.registerWarning("#{cache_path} could not be written, skipping.")
      end
    end

    return epw_path, cache_path
  end
end

class OSModel
  def self.create(hpxml, runner, model, hpxml_path, epw_path, cache_path, output_dir, building_id, debug)
    @hpxml = hpxml
    @debug = debug

    @eri_version = @hpxml.header.eri_calculation_version # Hidden feature
    @eri_version = 'latest' if @eri_version.nil?
    @eri_version = Constants.ERIVersions[-1] if @eri_version == 'latest'

    @apply_ashrae140_assumptions = @hpxml.header.apply_ashrae140_assumptions # Hidden feature
    @apply_ashrae140_assumptions = false if @apply_ashrae140_assumptions.nil?

    # Init

    weather, epw_file = Location.apply_weather_file(model, runner, epw_path, cache_path)
    set_defaults_and_globals(runner, output_dir, epw_file, weather)
    Location.apply(model, runner, weather, epw_file, @hpxml)
    add_simulation_params(model)

    @schedules_file = nil
    unless @hpxml.header.schedules_path.nil?
      @schedules_file = SchedulesFile.new(runner: runner, model: model, schedules_path: @hpxml.header.schedules_path, col_names: ScheduleGenerator.col_names.keys)
    end

    # Conditioned space/zone

    spaces = {}
    create_or_get_space(model, spaces, HPXML::LocationLivingSpace)
    set_foundation_and_walls_top()
    add_setpoints(runner, model, weather, spaces)

    # Geometry/Envelope
    add_roofs(runner, model, spaces)
    add_walls(runner, model, spaces)
    add_rim_joists(runner, model, spaces)
    add_frame_floors(runner, model, spaces)
    add_foundation_walls_slabs(runner, model, spaces)
    add_shading_schedule(runner, model, weather)
    add_windows(runner, model, spaces, weather)
    add_doors(runner, model, spaces)
    add_skylights(runner, model, spaces, weather)
    add_conditioned_floor_area(runner, model, spaces)
    add_thermal_mass(runner, model, spaces)
    update_conditioned_basement(runner, model, spaces)
    set_zone_volumes(runner, model, spaces)
    explode_surfaces(runner, model)
    add_num_occupants(model, runner, spaces)

    # HVAC

    add_ideal_system(runner, model, spaces, epw_path)
    add_cooling_system(runner, model, spaces)
    add_heating_system(runner, model, spaces)
    add_heat_pump(runner, model, weather, spaces)
    add_dehumidifiers(runner, model, spaces)
    add_residual_ideal_system(runner, model, spaces)
    add_ceiling_fans(runner, model, weather, spaces)

    # Hot Water

    add_hot_water_and_appliances(runner, model, weather, spaces)

    # Plug Loads & Fuel Loads & Lighting

    add_mels(runner, model, spaces)
    add_mfls(runner, model, spaces)
    add_lighting(runner, model, epw_file, spaces)

    # Pools & Hot Tubs
    add_pools_and_hot_tubs(runner, model, spaces)

    # Other

    add_airflow(runner, model, weather, spaces)
    add_photovoltaics(runner, model)
    add_generators(runner, model)
    add_additional_properties(runner, model, hpxml_path, building_id)

    # Output

    add_component_loads_output(runner, model, spaces)
    add_output_control_files(runner, model)
    # Uncomment to debug EMS
    # add_ems_debug_output(runner, model)

    # Vacancy
    unless @schedules_file.nil?
      @schedules_file.set_vacancy
    end

    if debug
      osm_output_path = File.join(output_dir, 'in.osm')
      File.write(osm_output_path, model.to_s)
      runner.registerInfo("Wrote file: #{osm_output_path}")
    end
  end

  private

  def self.set_defaults_and_globals(runner, output_dir, epw_file, weather)
    # Initialize
    @remaining_heat_load_frac = 1.0
    @remaining_cool_load_frac = 1.0
    @hvac_map = {} # mapping between HPXML HVAC systems and model objects
    @dhw_map = {}  # mapping between HPXML Water Heating systems and model objects
    @cond_bsmnt_surfaces = [] # list of surfaces in conditioned basement, used for modification of some surface properties, eg. solar absorptance, view factor, etc.

    # Set globals
    @cfa = @hpxml.building_construction.conditioned_floor_area
    @ncfl = @hpxml.building_construction.number_of_conditioned_floors
    @ncfl_ag = @hpxml.building_construction.number_of_conditioned_floors_above_grade
    @nbeds = @hpxml.building_construction.number_of_bedrooms
    @default_azimuths = get_default_azimuths()

    # Apply defaults to HPXML object
    HPXMLDefaults.apply(@hpxml, @eri_version, weather, epw_file)

    @frac_windows_operable = @hpxml.fraction_of_windows_operable()

    # Write updated HPXML object (w/ defaults) to file for inspection
    hpxml_defaults_path = File.join(output_dir, 'in.xml')
    XMLHelper.write_file(@hpxml.to_oga, hpxml_defaults_path)

    # Now that we've written in.xml, ensure that no capacities/airflows
    # are zero in order to prevent potential E+ errors.
    HVAC.ensure_nonzero_sizing_values(@hpxml)
  end

  def self.add_simulation_params(model)
    SimControls.apply(model, @hpxml.header)
  end

  def self.set_zone_volumes(runner, model, spaces)
    # Living space
    spaces[HPXML::LocationLivingSpace].thermalZone.get.setVolume(UnitConversions.convert(@hpxml.building_construction.conditioned_building_volume, 'ft^3', 'm^3'))

    # Basement, crawlspace, garage
    spaces.keys.each do |space_type|
      next unless [HPXML::LocationBasementUnconditioned, HPXML::LocationCrawlspaceUnvented, HPXML::LocationCrawlspaceVented, HPXML::LocationGarage].include? space_type

      floor_area = @hpxml.slabs.select { |s| s.interior_adjacent_to == space_type }.map { |s| s.area }.sum(0.0)
      if space_type == HPXML::LocationGarage
        height = 8.0
      else
        height = @hpxml.foundation_walls.select { |w| w.interior_adjacent_to == space_type }.map { |w| w.height }.max
      end

      spaces[space_type].thermalZone.get.setVolume(UnitConversions.convert(floor_area * height, 'ft^3', 'm^3'))
    end

    # Attic
    spaces.keys.each do |space_type|
      next unless [HPXML::LocationAtticUnvented, HPXML::LocationAtticVented].include? space_type

      floor_area = @hpxml.frame_floors.select { |f| [f.interior_adjacent_to, f.exterior_adjacent_to].include? space_type }.map { |s| s.area }.sum(0.0)
      roofs = @hpxml.roofs.select { |r| r.interior_adjacent_to == space_type }
      avg_pitch = roofs.map { |r| r.pitch }.sum(0.0) / roofs.size

      if @apply_ashrae140_assumptions
        # Hardcode the attic volume to match ASHRAE 140 Table 7-2 specification
        volume = 3463
      else
        # Assume square hip roof for volume calculations; energy results are very insensitive to actual volume
        length = floor_area**0.5
        height = 0.5 * Math.sin(Math.atan(avg_pitch / 12.0)) * length
        volume = [floor_area * height / 3.0, 0.01].max
      end

      spaces[space_type].thermalZone.get.setVolume(UnitConversions.convert(volume, 'ft^3', 'm^3'))
    end
  end

  def self.explode_surfaces(runner, model)
    # Re-position surfaces so as to not shade each other and to make it easier to visualize the building.

    gap_distance = UnitConversions.convert(10.0, 'ft', 'm') # distance between surfaces of the same azimuth
    rad90 = UnitConversions.convert(90, 'deg', 'rad')

    # Determine surfaces to shift and distance with which to explode surfaces horizontally outward
    surfaces = []
    azimuth_lengths = {}
    model.getSurfaces.sort.each do |surface|
      next unless ['wall', 'roofceiling'].include? surface.surfaceType.downcase
      next unless ['outdoors', 'foundation', 'adiabatic'].include? surface.outsideBoundaryCondition.downcase
      next if surface.additionalProperties.getFeatureAsDouble('Tilt').get <= 0 # skip flat roofs

      surfaces << surface
      azimuth = surface.additionalProperties.getFeatureAsInteger('Azimuth').get
      if azimuth_lengths[azimuth].nil?
        azimuth_lengths[azimuth] = 0.0
      end
      azimuth_lengths[azimuth] += surface.additionalProperties.getFeatureAsDouble('Length').get + gap_distance
    end
    max_azimuth_length = azimuth_lengths.values.max

    # Using the max length for a given azimuth, calculate the apothem (radius of the incircle) of a regular
    # n-sided polygon to create the smallest polygon possible without self-shading. The number of polygon
    # sides is defined by the minimum difference between two azimuths.
    min_azimuth_diff = 360
    azimuths_sorted = azimuth_lengths.keys.sort
    azimuths_sorted.each_with_index do |az, idx|
      diff1 = (az - azimuths_sorted[(idx + 1) % azimuths_sorted.size]).abs
      diff2 = 360.0 - diff1 # opposite direction
      if diff1 < min_azimuth_diff
        min_azimuth_diff = diff1
      end
      if diff2 < min_azimuth_diff
        min_azimuth_diff = diff2
      end
    end
    if min_azimuth_diff > 0
      nsides = [(360.0 / min_azimuth_diff).ceil, 4].max # assume rectangle at the minimum
    else
      nsides = 4
    end
    explode_distance = max_azimuth_length / (2.0 * Math.tan(UnitConversions.convert(180.0 / nsides, 'deg', 'rad')))

    add_neighbors(runner, model, max_azimuth_length)

    # Initial distance of shifts at 90-degrees to horizontal outward
    azimuth_side_shifts = {}
    azimuth_lengths.keys.each do |azimuth|
      azimuth_side_shifts[azimuth] = max_azimuth_length / 2.0
    end

    # Explode neighbors
    model.getShadingSurfaceGroups.each do |shading_group|
      next unless shading_group.name.to_s == Constants.ObjectNameNeighbors

      shading_group.shadingSurfaces.each do |shading_surface|
        azimuth = shading_surface.additionalProperties.getFeatureAsInteger('Azimuth').get
        azimuth_rad = UnitConversions.convert(azimuth, 'deg', 'rad')
        distance = shading_surface.additionalProperties.getFeatureAsDouble('Distance').get

        unless azimuth_lengths.keys.include? azimuth
          fail "A neighbor building has an azimuth (#{azimuth}) not equal to the azimuth of any wall."
        end

        # Push out horizontally
        distance += explode_distance
        transformation = get_surface_transformation(distance, Math::sin(azimuth_rad), Math::cos(azimuth_rad), 0)

        shading_surface.setVertices(transformation * shading_surface.vertices)
      end
    end

    # Explode walls, windows, doors, roofs, and skylights
    surfaces_moved = []

    surfaces.sort.each do |surface|
      next if surface.additionalProperties.getFeatureAsDouble('Tilt').get <= 0 # skip flat roofs

      if surface.adjacentSurface.is_initialized
        next if surfaces_moved.include? surface.adjacentSurface.get
      end

      azimuth = surface.additionalProperties.getFeatureAsInteger('Azimuth').get
      azimuth_rad = UnitConversions.convert(azimuth, 'deg', 'rad')

      # Get associated shading surfaces (e.g., overhangs, interior shading surfaces)
      overhang_surfaces = []
      shading_surfaces = []
      surface.subSurfaces.each do |subsurface|
        next unless subsurface.subSurfaceType.downcase == 'fixedwindow'

        subsurface.shadingSurfaceGroups.each do |overhang_group|
          overhang_group.shadingSurfaces.each do |overhang|
            overhang_surfaces << overhang
          end
        end
      end
      model.getShadingSurfaceGroups.each do |shading_group|
        next unless [Constants.ObjectNameSkylightShade, Constants.ObjectNameWindowShade].include? shading_group.name.to_s

        shading_group.shadingSurfaces.each do |window_shade|
          next unless window_shade.additionalProperties.getFeatureAsString('ParentSurface').get == surface.name.to_s

          shading_surfaces << window_shade
        end
      end

      # Push out horizontally
      distance = explode_distance

      if surface.surfaceType.downcase == 'roofceiling'
        # Ensure pitched surfaces are positioned outward justified with walls, etc.
        tilt = surface.additionalProperties.getFeatureAsDouble('Tilt').get
        width = surface.additionalProperties.getFeatureAsDouble('Width').get
        distance -= 0.5 * Math.cos(Math.atan(tilt)) * width
      end
      transformation = get_surface_transformation(distance, Math::sin(azimuth_rad), Math::cos(azimuth_rad), 0)
      transformation_shade = get_surface_transformation(distance + 0.001, Math::sin(azimuth_rad), Math::cos(azimuth_rad), 0) # Offset slightly from window

      ([surface] + surface.subSurfaces + overhang_surfaces).each do |s|
        s.setVertices(transformation * s.vertices)
      end
      shading_surfaces.each do |s|
        s.setVertices(transformation_shade * s.vertices)
      end
      if surface.adjacentSurface.is_initialized
        surface.adjacentSurface.get.setVertices(transformation * surface.adjacentSurface.get.vertices)
      end

      # Shift at 90-degrees to previous transformation, so surfaces don't overlap and shade each other
      azimuth_side_shifts[azimuth] -= surface.additionalProperties.getFeatureAsDouble('Length').get / 2.0
      transformation_shift = get_surface_transformation(azimuth_side_shifts[azimuth], Math::sin(azimuth_rad + rad90), Math::cos(azimuth_rad + rad90), 0)

      ([surface] + surface.subSurfaces + overhang_surfaces + shading_surfaces).each do |s|
        s.setVertices(transformation_shift * s.vertices)
      end
      if surface.adjacentSurface.is_initialized
        surface.adjacentSurface.get.setVertices(transformation_shift * surface.adjacentSurface.get.vertices)
      end

      azimuth_side_shifts[azimuth] -= (surface.additionalProperties.getFeatureAsDouble('Length').get / 2.0 + gap_distance)

      surfaces_moved << surface
    end
  end

  def self.update_conditioned_basement(runner, model, spaces)
    return if @cond_bsmnt_surfaces.empty?
    # Update @cond_bsmnt_surfaces to include subsurfaces
    new_cond_bsmnt_surfaces = @cond_bsmnt_surfaces.dup
    @cond_bsmnt_surfaces.each do |cond_bsmnt_surface|
      next if cond_bsmnt_surface.is_a? OpenStudio::Model::InternalMassDefinition
      next if cond_bsmnt_surface.subSurfaces.empty?
      cond_bsmnt_surface.subSurfaces.each do |ss|
        new_cond_bsmnt_surfaces << ss
      end
    end
    @cond_bsmnt_surfaces = new_cond_bsmnt_surfaces.dup

    update_solar_absorptances(runner, model)
    assign_view_factors(runner, model, spaces)
  end

  def self.update_solar_absorptances(runner, model)
    # modify conditioned basement surface properties
    # zero out interior solar absorptance in conditioned basement

    @cond_bsmnt_surfaces.each do |cond_bsmnt_surface|
      # skip windows because windows don't have such property to change.
      next if cond_bsmnt_surface.is_a?(OpenStudio::Model::SubSurface) && (cond_bsmnt_surface.subSurfaceType.downcase == 'fixedwindow')
      adj_surface = nil
      if not cond_bsmnt_surface.is_a? OpenStudio::Model::InternalMassDefinition
        if not cond_bsmnt_surface.is_a? OpenStudio::Model::SubSurface
          adj_surface = cond_bsmnt_surface.adjacentSurface.get if cond_bsmnt_surface.adjacentSurface.is_initialized
        else
          adj_surface = cond_bsmnt_surface.adjacentSubSurface.get if cond_bsmnt_surface.adjacentSubSurface.is_initialized
        end
      end
      const = cond_bsmnt_surface.construction.get
      layered_const = const.to_LayeredConstruction.get
      innermost_material = layered_const.layers[layered_const.numLayers() - 1].to_StandardOpaqueMaterial.get
      # check if target surface is sharing its interior material/construction object with other surfaces
      # if so, need to clone the material/construction and make changes there, then reassign it to target surface
      mat_share = (innermost_material.directUseCount != 1)
      const_share = (const.directUseCount != 1)
      if const_share
        # create new construction + new material for these surfaces
        new_const = const.clone.to_Construction.get
        cond_bsmnt_surface.setConstruction(new_const)
        new_material = innermost_material.clone.to_StandardOpaqueMaterial.get
        layered_const = new_const.to_LayeredConstruction.get
        layered_const.setLayer(layered_const.numLayers() - 1, new_material)
      elsif mat_share
        # create new material for existing unique construction
        new_material = innermost_material.clone.to_StandardOpaqueMaterial.get
        layered_const.setLayer(layered_const.numLayers() - 1, new_material)
      end
      if layered_const.numLayers() == 1
        # split single layer into two to only change its inside facing property
        layer_mat = layered_const.layers[0].to_StandardOpaqueMaterial.get
        layer_mat.setThickness(layer_mat.thickness / 2)
        layered_const.insertLayer(1, layer_mat.clone.to_StandardOpaqueMaterial.get)
      end
      # Re-read innermost material and assign properties after adjustment
      innermost_material = layered_const.layers[layered_const.numLayers() - 1].to_StandardOpaqueMaterial.get
      innermost_material.setSolarAbsorptance(0.0)
      innermost_material.setVisibleAbsorptance(0.0)
      next if adj_surface.nil?
      # Create new construction in case of shared construciton.
      layered_const_adj = OpenStudio::Model::Construction.new(model)
      layered_const_adj.setName(cond_bsmnt_surface.construction.get.name.get + ' Reversed Bsmnt')
      adj_surface.setConstruction(layered_const_adj)
      layered_const_adj.setLayers(cond_bsmnt_surface.construction.get.to_LayeredConstruction.get.layers.reverse())
    end
  end

  def self.assign_view_factors(runner, model, spaces)
    # zero out view factors between conditioned basement surfaces and living zone surfaces
    all_surfaces = [] # all surfaces in single conditioned space
    lv_surfaces = []  # surfaces in living
    cond_base_surfaces = [] # surfaces in conditioned basement

    spaces[HPXML::LocationLivingSpace].surfaces.each do |surface|
      surface.subSurfaces.each do |sub_surface|
        all_surfaces << sub_surface
      end
      all_surfaces << surface
    end
    spaces[HPXML::LocationLivingSpace].internalMass.each do |im|
      all_surfaces << im
    end

    all_surfaces.each do |surface|
      if @cond_bsmnt_surfaces.include?(surface) ||
         ((@cond_bsmnt_surfaces.include? surface.internalMassDefinition) if surface.is_a? OpenStudio::Model::InternalMass)
        cond_base_surfaces << surface
      else
        lv_surfaces << surface
      end
    end

    all_surfaces.sort!

    # calculate view factors separately for living and conditioned basement
    vf_map_lv = calc_approximate_view_factor(runner, model, lv_surfaces)
    vf_map_cb = calc_approximate_view_factor(runner, model, cond_base_surfaces)

    zone_prop = spaces[HPXML::LocationLivingSpace].thermalZone.get.getZonePropertyUserViewFactorsBySurfaceName

    all_surfaces.each do |from_surface|
      all_surfaces.each do |to_surface|
        next if (vf_map_lv[from_surface].nil? || vf_map_lv[from_surface][to_surface].nil?) &&
                (vf_map_cb[from_surface].nil? || vf_map_cb[from_surface][to_surface].nil?)

        if lv_surfaces.include? from_surface
          vf = vf_map_lv[from_surface][to_surface]
        else
          vf = vf_map_cb[from_surface][to_surface]
        end
        next if vf < 0.05 # Skip small view factors to reduce runtime

        os_vf = OpenStudio::Model::ViewFactor.new(from_surface, to_surface, vf.round(10))
        zone_prop.addViewFactor(os_vf)
      end
    end
  end

  def self.calc_approximate_view_factor(runner, model, all_surfaces)
    # calculate approximate view factor using E+ approach
    # used for recalculating single thermal zone view factor matrix
    return {} if all_surfaces.size == 0
    if all_surfaces.size <= 3
      fail 'less than three surfaces in conditioned space. Please double check.'
    end

    s_azimuths = {}
    s_tilts = {}
    s_types = {}
    all_surfaces.each do |surface|
      if surface.is_a? OpenStudio::Model::InternalMass
        # Assumed values consistent with EnergyPlus source code
        s_azimuths[surface] = 0.0
        s_tilts[surface] = 90.0
      else
        s_azimuths[surface] = UnitConversions.convert(surface.azimuth, 'rad', 'deg')
        s_tilts[surface] = UnitConversions.convert(surface.tilt, 'rad', 'deg')
        if surface.is_a? OpenStudio::Model::SubSurface
          s_types[surface] = surface.surface.get.surfaceType.downcase
        else
          s_types[surface] = surface.surfaceType.downcase
        end
      end
    end

    same_ang_limit = 10.0
    vf_map = {}
    all_surfaces.each do |surface| # surface, subsurface, and internal mass
      surface_vf_map = {}

      # sum all the surface area that could be seen by surface1 up
      zone_seen_area = 0.0
      seen_surface = {}
      all_surfaces.each do |surface2|
        next if surface2 == surface
        next if surface2.is_a? OpenStudio::Model::SubSurface

        seen_surface[surface2] = false
        if surface2.is_a? OpenStudio::Model::InternalMass
          # all surfaces see internal mass
          zone_seen_area += surface2.surfaceArea.get
          seen_surface[surface2] = true
        else
          if (s_types[surface2] == 'floor') ||
             ((s_types[surface] == 'floor') && (s_types[surface2] == 'roofceiling')) ||
             ((s_azimuths[surface] - s_azimuths[surface2]).abs > same_ang_limit) ||
             ((s_tilts[surface] - s_tilts[surface2]).abs > same_ang_limit)
            zone_seen_area += surface2.grossArea # include subsurface area
            seen_surface[surface2] = true
          end
        end
      end

      all_surfaces.each do |surface2|
        next if surface2 == surface
        next if surface2.is_a? OpenStudio::Model::SubSurface # handled together with its parent surface
        next unless seen_surface[surface2]

        if surface2.is_a? OpenStudio::Model::InternalMass
          surface_vf_map[surface2] = surface2.surfaceArea.get / zone_seen_area
        else # surfaces
          if surface2.subSurfaces.size > 0
            # calculate surface and its sub surfaces view factors
            if surface2.netArea > 0.1 # base surface of a sub surface: window/door etc.
              fail "Unexpected net area for surface '#{surface2.name}'."
            end

            surface2.subSurfaces.each do |sub_surface|
              surface_vf_map[sub_surface] = sub_surface.grossArea / zone_seen_area
            end
          else # no subsurface
            surface_vf_map[surface2] = surface2.grossArea / zone_seen_area
          end
        end
      end
      vf_map[surface] = surface_vf_map
    end
    return vf_map
  end

  # FUTURE: Move this method and many below to geometry.rb
  def self.create_space_and_zone(model, spaces, space_type)
    if not spaces.keys.include? space_type
      thermal_zone = OpenStudio::Model::ThermalZone.new(model)
      thermal_zone.setName(space_type)

      space = OpenStudio::Model::Space.new(model)
      space.setName(space_type)

      st = OpenStudio::Model::SpaceType.new(model)
      st.setStandardsSpaceType(space_type)
      space.setSpaceType(st)

      space.setThermalZone(thermal_zone)
      spaces[space_type] = space
    end
  end

  def self.get_surface_transformation(offset, x, y, z)
    x = UnitConversions.convert(x, 'ft', 'm')
    y = UnitConversions.convert(y, 'ft', 'm')
    z = UnitConversions.convert(z, 'ft', 'm')

    m = OpenStudio::Matrix.new(4, 4, 0)
    m[0, 0] = 1
    m[1, 1] = 1
    m[2, 2] = 1
    m[3, 3] = 1
    m[0, 3] = x * offset
    m[1, 3] = y * offset
    m[2, 3] = z.abs * offset

    return OpenStudio::Transformation.new(m)
  end

  def self.add_floor_polygon(x, y, z)
    x = UnitConversions.convert(x, 'ft', 'm')
    y = UnitConversions.convert(y, 'ft', 'm')
    z = UnitConversions.convert(z, 'ft', 'm')

    vertices = OpenStudio::Point3dVector.new
    vertices << OpenStudio::Point3d.new(0 - x / 2, 0 - y / 2, z)
    vertices << OpenStudio::Point3d.new(0 - x / 2, y / 2, z)
    vertices << OpenStudio::Point3d.new(x / 2, y / 2, z)
    vertices << OpenStudio::Point3d.new(x / 2, 0 - y / 2, z)

    # Rotate about the z axis
    # This is not strictly needed, but will make the floor edges
    # parallel to the walls for a better geometry rendering.
    azimuth_rad = UnitConversions.convert(@default_azimuths[0], 'deg', 'rad')
    m = OpenStudio::Matrix.new(4, 4, 0)
    m[0, 0] = Math::cos(-azimuth_rad)
    m[1, 1] = Math::cos(-azimuth_rad)
    m[0, 1] = -Math::sin(-azimuth_rad)
    m[1, 0] = Math::sin(-azimuth_rad)
    m[2, 2] = 1
    m[3, 3] = 1
    transformation = OpenStudio::Transformation.new(m)

    return transformation * vertices
  end

  def self.add_wall_polygon(x, y, z, azimuth, offsets = [0] * 4, subsurface_area = 0)
    x = UnitConversions.convert(x, 'ft', 'm')
    y = UnitConversions.convert(y, 'ft', 'm')
    z = UnitConversions.convert(z, 'ft', 'm')

    vertices = OpenStudio::Point3dVector.new
    vertices << OpenStudio::Point3d.new(0 - (x / 2) - offsets[1], 0, z - offsets[0])
    vertices << OpenStudio::Point3d.new(0 - (x / 2) - offsets[1], 0, z + y + offsets[2])
    if subsurface_area > 0
      subsurface_area = UnitConversions.convert(subsurface_area, 'ft^2', 'm^2')
      sub_length = x / 10.0
      sub_height = subsurface_area / sub_length
      if sub_height >= y
        sub_height = y - 0.1
        sub_length = subsurface_area / sub_height
      end
      vertices << OpenStudio::Point3d.new(x - (x / 2) + offsets[3] - sub_length, 0, z + y + offsets[2])
      vertices << OpenStudio::Point3d.new(x - (x / 2) + offsets[3] - sub_length, 0, z + y + offsets[2] - sub_height)
      vertices << OpenStudio::Point3d.new(x - (x / 2) + offsets[3], 0, z + y + offsets[2] - sub_height)
    else
      vertices << OpenStudio::Point3d.new(x - (x / 2) + offsets[3], 0, z + y + offsets[2])
    end
    vertices << OpenStudio::Point3d.new(x - (x / 2) + offsets[3], 0, z - offsets[0])

    # Rotate about the z axis
    azimuth_rad = UnitConversions.convert(azimuth, 'deg', 'rad')
    m = OpenStudio::Matrix.new(4, 4, 0)
    m[0, 0] = Math::cos(-azimuth_rad)
    m[1, 1] = Math::cos(-azimuth_rad)
    m[0, 1] = -Math::sin(-azimuth_rad)
    m[1, 0] = Math::sin(-azimuth_rad)
    m[2, 2] = 1
    m[3, 3] = 1
    transformation = OpenStudio::Transformation.new(m)

    return transformation * vertices
  end

  def self.add_roof_polygon(x, y, z, azimuth, tilt)
    x = UnitConversions.convert(x, 'ft', 'm')
    y = UnitConversions.convert(y, 'ft', 'm')
    z = UnitConversions.convert(z, 'ft', 'm')

    vertices = OpenStudio::Point3dVector.new
    vertices << OpenStudio::Point3d.new(x / 2, -y / 2, 0)
    vertices << OpenStudio::Point3d.new(x / 2, y / 2, 0)
    vertices << OpenStudio::Point3d.new(-x / 2, y / 2, 0)
    vertices << OpenStudio::Point3d.new(-x / 2, -y / 2, 0)

    # Rotate about the x axis
    m = OpenStudio::Matrix.new(4, 4, 0)
    m[0, 0] = 1
    m[1, 1] = Math::cos(Math::atan(tilt))
    m[1, 2] = -Math::sin(Math::atan(tilt))
    m[2, 1] = Math::sin(Math::atan(tilt))
    m[2, 2] = Math::cos(Math::atan(tilt))
    m[3, 3] = 1
    transformation = OpenStudio::Transformation.new(m)
    vertices = transformation * vertices

    # Rotate about the z axis
    azimuth_rad = UnitConversions.convert(azimuth, 'deg', 'rad')
    rad180 = UnitConversions.convert(180, 'deg', 'rad')
    m = OpenStudio::Matrix.new(4, 4, 0)
    m[0, 0] = Math::cos(rad180 - azimuth_rad)
    m[1, 1] = Math::cos(rad180 - azimuth_rad)
    m[0, 1] = -Math::sin(rad180 - azimuth_rad)
    m[1, 0] = Math::sin(rad180 - azimuth_rad)
    m[2, 2] = 1
    m[3, 3] = 1
    transformation = OpenStudio::Transformation.new(m)
    vertices = transformation * vertices

    # Shift up by z
    new_vertices = OpenStudio::Point3dVector.new
    vertices.each do |vertex|
      new_vertices << OpenStudio::Point3d.new(vertex.x, vertex.y, vertex.z + z)
    end

    return new_vertices
  end

  def self.add_ceiling_polygon(x, y, z)
    return OpenStudio::reverse(add_floor_polygon(x, y, z))
  end

  def self.add_num_occupants(model, runner, spaces)
    # Occupants
    num_occ = @hpxml.building_occupancy.number_of_residents
    return if num_occ <= 0

    Geometry.process_occupants(model, num_occ, @cfa, spaces[HPXML::LocationLivingSpace], @schedules_file)
  end

  def self.get_default_azimuths()
    # Returns a list of four azimuths (facing each direction). Determined based
    # on the primary azimuth, as defined by the azimuth with the largest surface
    # area, plus azimuths that are offset by 90/180/270 degrees. Used for
    # surfaces that may not have an azimuth defined (e.g., walls).
    azimuth_areas = {}
    (@hpxml.roofs + @hpxml.rim_joists + @hpxml.walls + @hpxml.foundation_walls +
     @hpxml.windows + @hpxml.skylights + @hpxml.doors).each do |surface|
      az = surface.azimuth
      next if az.nil?

      azimuth_areas[az] = 0 if azimuth_areas[az].nil?
      azimuth_areas[az] += surface.area
    end
    if azimuth_areas.empty?
      primary_azimuth = 0
    else
      primary_azimuth = azimuth_areas.max_by { |k, v| v }[0]
    end
    return [primary_azimuth,
            sanitize_azimuth(primary_azimuth + 90),
            sanitize_azimuth(primary_azimuth + 180),
            sanitize_azimuth(primary_azimuth + 270)].sort
  end

  def self.sanitize_azimuth(azimuth)
    # Ensure 0 <= orientation < 360
    while azimuth < 0
      azimuth += 360
    end
    while azimuth >= 360
      azimuth -= 360
    end
    return azimuth
  end

  def self.create_or_get_space(model, spaces, spacetype)
    if spaces[spacetype].nil?
      create_space_and_zone(model, spaces, spacetype)
    end
    return spaces[spacetype]
  end

  def self.add_roofs(runner, model, spaces)
    @hpxml.roofs.each do |roof|
      next if roof.net_area < 1.0 # skip modeling net surface area for surfaces comprised entirely of subsurface area

      if roof.azimuth.nil?
        if roof.pitch > 0
          azimuths = @default_azimuths # Model as four directions for average exterior incident solar
        else
          azimuths = [@default_azimuths[0]] # Arbitrary azimuth for flat roof
        end
      else
        azimuths = [roof.azimuth]
      end

      surfaces = []

      azimuths.each do |azimuth|
        width = Math::sqrt(roof.net_area)
        length = (roof.net_area / width) / azimuths.size
        tilt = roof.pitch / 12.0
        z_origin = @walls_top + 0.5 * Math.sin(Math.atan(tilt)) * width

        surface = OpenStudio::Model::Surface.new(add_roof_polygon(length, width, z_origin, azimuth, tilt), model)
        surfaces << surface
        surface.additionalProperties.setFeature('Length', length)
        surface.additionalProperties.setFeature('Width', width)
        surface.additionalProperties.setFeature('Azimuth', azimuth)
        surface.additionalProperties.setFeature('Tilt', tilt)
        surface.additionalProperties.setFeature('SurfaceType', 'Roof')
        if azimuths.size > 1
          surface.setName("#{roof.id}:#{azimuth}")
        else
          surface.setName(roof.id)
        end
        surface.setSurfaceType('RoofCeiling')
        surface.setOutsideBoundaryCondition('Outdoors')
        set_surface_interior(model, spaces, surface, roof)
      end

      next if surfaces.empty?

      # Apply construction
      solar_abs = roof.solar_absorptance
      emitt = roof.emittance
      has_radiant_barrier = roof.radiant_barrier
      if has_radiant_barrier
        radiant_barrier_grade = roof.radiant_barrier_grade
      end
      # FUTURE: Create Constructions.get_air_film(surface) method; use in measure.rb and hpxml_translator_test.rb
      inside_film = Material.AirFilmRoof(Geometry.get_roof_pitch([surfaces[0]]))
      outside_film = Material.AirFilmOutside
      mat_roofing = Material.RoofMaterial(roof.roof_type, emitt, solar_abs)
      if @apply_ashrae140_assumptions
        inside_film = Material.AirFilmRoofASHRAE140
        outside_film = Material.AirFilmOutsideASHRAE140
      end

      install_grade = 1
      assembly_r = roof.insulation_assembly_r_value

      if roof.is_thermal_boundary
        constr_sets = [
          WoodStudConstructionSet.new(Material.Stud2x(8.0), 0.07, 20.0, 0.75, 0.5, mat_roofing), # 2x8, 24" o.c. + R20
          WoodStudConstructionSet.new(Material.Stud2x(8.0), 0.07, 10.0, 0.75, 0.5, mat_roofing), # 2x8, 24" o.c. + R10
          WoodStudConstructionSet.new(Material.Stud2x(8.0), 0.07, 0.0, 0.75, 0.5, mat_roofing),  # 2x8, 24" o.c.
          WoodStudConstructionSet.new(Material.Stud2x6, 0.07, 0.0, 0.75, 0.5, mat_roofing),      # 2x6, 24" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.07, 0.0, 0.5, 0.5, mat_roofing),       # 2x4, 16" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.01, 0.0, 0.0, 0.0, mat_roofing),       # Fallback
        ]
        match, constr_set, cavity_r = Constructions.pick_wood_stud_construction_set(assembly_r, constr_sets, inside_film, outside_film, roof.id)

        Constructions.apply_closed_cavity_roof(runner, model, surfaces, "#{roof.id} construction",
                                               cavity_r, install_grade,
                                               constr_set.stud.thick_in,
                                               true, constr_set.framing_factor,
                                               constr_set.drywall_thick_in,
                                               constr_set.osb_thick_in, constr_set.rigid_r,
                                               constr_set.exterior_material, has_radiant_barrier,
                                               inside_film, outside_film, radiant_barrier_grade)
      else
        constr_sets = [
          GenericConstructionSet.new(10.0, 0.5, 0.0, mat_roofing), # w/R-10 rigid
          GenericConstructionSet.new(0.0, 0.5, 0.0, mat_roofing),  # Standard
          GenericConstructionSet.new(0.0, 0.0, 0.0, mat_roofing),  # Fallback
        ]
        match, constr_set, layer_r = Constructions.pick_generic_construction_set(assembly_r, constr_sets, inside_film, outside_film, roof.id)

        cavity_r = 0
        cavity_ins_thick_in = 0
        framing_factor = 0
        framing_thick_in = 0

        Constructions.apply_open_cavity_roof(runner, model, surfaces, "#{roof.id} construction",
                                             cavity_r, install_grade, cavity_ins_thick_in,
                                             framing_factor, framing_thick_in,
                                             constr_set.osb_thick_in, layer_r + constr_set.rigid_r,
                                             mat_roofing, has_radiant_barrier,
                                             inside_film, outside_film, radiant_barrier_grade)
      end
      Constructions.check_surface_assembly_rvalue(runner, surfaces, inside_film, outside_film, assembly_r, match)
    end
  end

  def self.add_walls(runner, model, spaces)
    @hpxml.walls.each do |wall|
      next if wall.net_area < 1.0 # skip modeling net surface area for surfaces comprised entirely of subsurface area

      if wall.azimuth.nil?
        if wall.is_exterior
          azimuths = @default_azimuths # Model as four directions for average exterior incident solar
        else
          azimuths = [@default_azimuths[0]] # Arbitrary direction, doesn't receive exterior incident solar
        end
      else
        azimuths = [wall.azimuth]
      end

      surfaces = []

      azimuths.each do |azimuth|
        height = 8.0 * @ncfl_ag
        length = (wall.net_area / height) / azimuths.size
        z_origin = @foundation_top

        surface = OpenStudio::Model::Surface.new(add_wall_polygon(length, height, z_origin, azimuth), model)
        surfaces << surface
        surface.additionalProperties.setFeature('Length', length)
        surface.additionalProperties.setFeature('Azimuth', azimuth)
        surface.additionalProperties.setFeature('Tilt', 90.0)
        surface.additionalProperties.setFeature('SurfaceType', 'Wall')
        if azimuths.size > 1
          surface.setName("#{wall.id}:#{azimuth}")
        else
          surface.setName(wall.id)
        end
        surface.setSurfaceType('Wall')
        set_surface_interior(model, spaces, surface, wall)
        set_surface_exterior(model, spaces, surface, wall)
        if wall.is_interior
          surface.setSunExposure('NoSun')
          surface.setWindExposure('NoWind')
        end
      end

      next if surfaces.empty?

      # Apply construction
      # The code below constructs a reasonable wall construction based on the
      # wall type while ensuring the correct assembly R-value.

      if wall.is_thermal_boundary
        drywall_thick_in = 0.5
      else
        drywall_thick_in = 0.0
      end
      inside_film = Material.AirFilmVertical
      if wall.is_exterior
        outside_film = Material.AirFilmOutside
        mat_ext_finish = Material.ExteriorFinishMaterial(wall.siding, wall.emittance, wall.solar_absorptance)
      else
        outside_film = Material.AirFilmVertical
        mat_ext_finish = nil
      end
      if @apply_ashrae140_assumptions
        inside_film = Material.AirFilmVerticalASHRAE140
        outside_film = Material.AirFilmOutsideASHRAE140
      end

      Constructions.apply_wall_construction(runner, model, surfaces, wall, wall.id, wall.wall_type, wall.insulation_assembly_r_value,
                                            drywall_thick_in, inside_film, outside_film, mat_ext_finish)
    end
  end

  def self.add_rim_joists(runner, model, spaces)
    @hpxml.rim_joists.each do |rim_joist|
      if rim_joist.azimuth.nil?
        if rim_joist.is_exterior
          azimuths = @default_azimuths # Model as four directions for average exterior incident solar
        else
          azimuths = [@default_azimuths[0]] # Arbitrary direction, doesn't receive exterior incident solar
        end
      else
        azimuths = [rim_joist.azimuth]
      end

      surfaces = []

      azimuths.each do |azimuth|
        height = 1.0
        length = (rim_joist.area / height) / azimuths.size
        z_origin = @foundation_top

        surface = OpenStudio::Model::Surface.new(add_wall_polygon(length, height, z_origin, azimuth), model)
        surfaces << surface
        surface.additionalProperties.setFeature('Length', length)
        surface.additionalProperties.setFeature('Azimuth', azimuth)
        surface.additionalProperties.setFeature('Tilt', 90.0)
        surface.additionalProperties.setFeature('SurfaceType', 'RimJoist')
        if azimuths.size > 1
          surface.setName("#{rim_joist.id}:#{azimuth}")
        else
          surface.setName(rim_joist.id)
        end
        surface.setSurfaceType('Wall')
        set_surface_interior(model, spaces, surface, rim_joist)
        set_surface_exterior(model, spaces, surface, rim_joist)
        if rim_joist.is_interior
          surface.setSunExposure('NoSun')
          surface.setWindExposure('NoWind')
        end
      end

      # Apply construction

      if rim_joist.is_thermal_boundary
        drywall_thick_in = 0.5
      else
        drywall_thick_in = 0.0
      end
      inside_film = Material.AirFilmVertical
      if rim_joist.is_exterior
        outside_film = Material.AirFilmOutside
        mat_ext_finish = Material.ExteriorFinishMaterial(rim_joist.siding, rim_joist.emittance, rim_joist.solar_absorptance)
      else
        outside_film = Material.AirFilmVertical
        mat_ext_finish = nil
      end

      assembly_r = rim_joist.insulation_assembly_r_value

      constr_sets = [
        WoodStudConstructionSet.new(Material.Stud2x(2.0), 0.17, 20.0, 2.0, drywall_thick_in, mat_ext_finish),  # 2x4 + R20
        WoodStudConstructionSet.new(Material.Stud2x(2.0), 0.17, 10.0, 2.0, drywall_thick_in, mat_ext_finish),  # 2x4 + R10
        WoodStudConstructionSet.new(Material.Stud2x(2.0), 0.17, 0.0, 2.0, drywall_thick_in, mat_ext_finish),   # 2x4
        WoodStudConstructionSet.new(Material.Stud2x(2.0), 0.01, 0.0, 0.0, 0.0, mat_ext_finish),                # Fallback
      ]
      match, constr_set, cavity_r = Constructions.pick_wood_stud_construction_set(assembly_r, constr_sets, inside_film, outside_film, rim_joist.id)
      install_grade = 1

      Constructions.apply_rim_joist(runner, model, surfaces, rim_joist, "#{rim_joist.id} construction",
                                    cavity_r, install_grade, constr_set.framing_factor,
                                    constr_set.drywall_thick_in, constr_set.osb_thick_in,
                                    constr_set.rigid_r, constr_set.exterior_material,
                                    inside_film, outside_film)
      Constructions.check_surface_assembly_rvalue(runner, surfaces, inside_film, outside_film, assembly_r, match)
    end
  end

  def self.add_frame_floors(runner, model, spaces)
    @hpxml.frame_floors.each do |frame_floor|
      area = frame_floor.area
      width = Math::sqrt(area)
      length = area / width
      if frame_floor.interior_adjacent_to.include?('attic') || frame_floor.exterior_adjacent_to.include?('attic')
        z_origin = @walls_top
      else
        z_origin = @foundation_top
      end

      if frame_floor.is_ceiling
        surface = OpenStudio::Model::Surface.new(add_ceiling_polygon(length, width, z_origin), model)
        surface.additionalProperties.setFeature('SurfaceType', 'Ceiling')
      else
        surface = OpenStudio::Model::Surface.new(add_floor_polygon(length, width, z_origin), model)
        surface.additionalProperties.setFeature('SurfaceType', 'Floor')
      end
      surface.additionalProperties.setFeature('Tilt', 0.0)
      set_surface_interior(model, spaces, surface, frame_floor)
      set_surface_exterior(model, spaces, surface, frame_floor)
      surface.setName(frame_floor.id)
      if frame_floor.is_interior
        surface.setSunExposure('NoSun')
        surface.setWindExposure('NoWind')
      elsif frame_floor.is_floor
        surface.setSunExposure('NoSun')
      end

      # Apply construction

      if frame_floor.is_ceiling
        if @apply_ashrae140_assumptions
          # Attic floor
          inside_film = Material.AirFilmFloorASHRAE140
          outside_film = Material.AirFilmFloorASHRAE140
        else
          inside_film = Material.AirFilmFloorAverage
          outside_film = Material.AirFilmFloorAverage
        end
        constr_sets = [
          WoodStudConstructionSet.new(Material.Stud2x6, 0.10, 0.0, 0.0, 0.5, nil),  # 2x6, 24" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.13, 0.0, 0.0, 0.5, nil),  # 2x4, 16" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.01, 0.0, 0.0, 0.0, nil), # Fallback
        ]
      else # Floor
        if @apply_ashrae140_assumptions
          # Raised floor
          inside_film = Material.AirFilmFloorASHRAE140
          outside_film = Material.AirFilmFloorZeroWindASHRAE140
          surface.setWindExposure('NoWind')
          covering = Material.CoveringBare(1.0)
        else
          inside_film = Material.AirFilmFloorReduced
          if frame_floor.is_exterior
            outside_film = Material.AirFilmOutside
          else
            outside_film = Material.AirFilmFloorReduced
          end
          if frame_floor.interior_adjacent_to == HPXML::LocationLivingSpace
            covering = Material.CoveringBare
          end
        end
        constr_sets = [
          WoodStudConstructionSet.new(Material.Stud2x6, 0.10, 20.0, 0.75, 0.0, covering), # 2x6, 24" o.c. + R20
          WoodStudConstructionSet.new(Material.Stud2x6, 0.10, 10.0, 0.75, 0.0, covering), # 2x6, 24" o.c. + R10
          WoodStudConstructionSet.new(Material.Stud2x6, 0.10, 0.0, 0.75, 0.0, covering),  # 2x6, 24" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.13, 0.0, 0.5, 0.0, covering),   # 2x4, 16" o.c.
          WoodStudConstructionSet.new(Material.Stud2x4, 0.01, 0.0, 0.0, 0.0, nil), # Fallback
        ]
      end
      assembly_r = frame_floor.insulation_assembly_r_value

      match, constr_set, cavity_r = Constructions.pick_wood_stud_construction_set(assembly_r, constr_sets, inside_film, outside_film, frame_floor.id)

      install_grade = 1
      if frame_floor.is_ceiling
        Constructions.apply_ceiling(runner, model, [surface], "#{frame_floor.id} construction",
                                    cavity_r, install_grade,
                                    constr_set.stud.thick_in, constr_set.framing_factor,
                                    constr_set.stud.thick_in, constr_set.drywall_thick_in,
                                    inside_film, outside_film)

      else # Floor
        Constructions.apply_floor(runner, model, [surface], "#{frame_floor.id} construction",
                                  cavity_r, install_grade,
                                  constr_set.framing_factor, constr_set.stud.thick_in,
                                  constr_set.osb_thick_in, constr_set.rigid_r,
                                  constr_set.exterior_material, inside_film, outside_film)
      end

      Constructions.check_surface_assembly_rvalue(runner, [surface], inside_film, outside_film, assembly_r, match)
    end
  end

  def self.add_foundation_walls_slabs(runner, model, spaces)
    foundation_types = @hpxml.slabs.map { |s| s.interior_adjacent_to }.uniq

    foundation_types.each do |foundation_type|
      # Get attached foundation walls/slabs
      fnd_walls = []
      slabs = []
      @hpxml.foundation_walls.each do |foundation_wall|
        next unless foundation_wall.interior_adjacent_to == foundation_type
        next if foundation_wall.net_area < 1.0 # skip modeling net surface area for surfaces comprised entirely of subsurface area

        fnd_walls << foundation_wall
      end
      @hpxml.slabs.each do |slab|
        next unless slab.interior_adjacent_to == foundation_type

        slabs << slab
        slab.exposed_perimeter = [slab.exposed_perimeter, 1.0].max # minimum value to prevent error if no exposed slab
      end

      # Calculate combinations of slabs/walls for each Kiva instance
      kiva_instances = get_kiva_instances(fnd_walls, slabs)

      # Obtain some wall/slab information
      fnd_wall_lengths = {}
      fnd_walls.each do |foundation_wall|
        next unless foundation_wall.is_exterior

        fnd_wall_lengths[foundation_wall] = foundation_wall.area / foundation_wall.height
      end
      slab_exp_perims = {}
      slab_areas = {}
      slabs.each do |slab|
        slab_exp_perims[slab] = slab.exposed_perimeter
        slab_areas[slab] = slab.area
      end
      total_slab_exp_perim = slab_exp_perims.values.sum(0.0)
      total_slab_area = slab_areas.values.sum(0.0)
      total_fnd_wall_length = fnd_wall_lengths.values.sum(0.0)

      no_wall_slab_exp_perim = {}

      kiva_instances.each do |foundation_wall, slab|
        # Apportion referenced walls/slabs for this Kiva instance
        slab_frac = slab_exp_perims[slab] / total_slab_exp_perim
        if total_fnd_wall_length > 0
          fnd_wall_frac = fnd_wall_lengths[foundation_wall] / total_fnd_wall_length
        else
          fnd_wall_frac = 1.0 # Handle slab foundation type
        end

        kiva_foundation = nil
        if not foundation_wall.nil?
          # Add exterior foundation wall surface
          kiva_foundation = add_foundation_wall(runner, model, spaces, foundation_wall, slab_frac,
                                                total_fnd_wall_length, total_slab_exp_perim)
        end

        # Add single combined foundation slab surface (for similar surfaces)
        slab_exp_perim = slab_exp_perims[slab] * fnd_wall_frac
        slab_area = slab_areas[slab] * fnd_wall_frac
        no_wall_slab_exp_perim[slab] = 0.0 if no_wall_slab_exp_perim[slab].nil?
        if (not foundation_wall.nil?) && (slab_exp_perim > fnd_wall_lengths[foundation_wall] * slab_frac)
          # Keep track of no-wall slab exposed perimeter
          no_wall_slab_exp_perim[slab] += (slab_exp_perim - fnd_wall_lengths[foundation_wall] * slab_frac)

          # Reduce this slab's exposed perimeter so that EnergyPlus does not automatically
          # create a second no-wall Kiva instance for each of our Kiva instances.
          # Instead, we will later create our own Kiva instance to account for it.
          # This reduces the number of Kiva instances we end up with.
          exp_perim_frac = (fnd_wall_lengths[foundation_wall] * slab_frac) / slab_exp_perim
          slab_exp_perim *= exp_perim_frac
          slab_area *= exp_perim_frac
        end
        if not foundation_wall.nil?
          z_origin = -1 * foundation_wall.depth_below_grade # Position based on adjacent foundation walls
        else
          z_origin = -1 * slab.depth_below_grade
        end
        kiva_foundation = add_foundation_slab(runner, model, spaces, slab, slab_exp_perim,
                                              slab_area, z_origin, kiva_foundation)
      end

      # For each slab, create a no-wall Kiva slab instance if needed.
      slabs.each do |slab|
        next unless no_wall_slab_exp_perim[slab] > 1.0

        z_origin = 0
        slab_area = total_slab_area * no_wall_slab_exp_perim[slab] / total_slab_exp_perim
        kiva_foundation = add_foundation_slab(runner, model, spaces, slab, no_wall_slab_exp_perim[slab],
                                              slab_area, z_origin, nil)
      end

      # Interzonal foundation wall surfaces
      # The above-grade portion of these walls are modeled as EnergyPlus surfaces with standard adjacency.
      # The below-grade portion of these walls (in contact with ground) are not modeled, as Kiva does not
      # calculate heat flow between two zones through the ground.
      fnd_walls.each do |foundation_wall|
        next unless foundation_wall.is_interior

        ag_height = foundation_wall.height - foundation_wall.depth_below_grade
        ag_net_area = foundation_wall.net_area * ag_height / foundation_wall.height
        next if ag_net_area < 1.0

        length = ag_net_area / ag_height
        z_origin = -1 * ag_height
        if foundation_wall.azimuth.nil?
          azimuth = @default_azimuths[0] # Arbitrary direction, doesn't receive exterior incident solar
        else
          azimuth = foundation_wall.azimuth
        end

        surface = OpenStudio::Model::Surface.new(add_wall_polygon(length, ag_height, z_origin, azimuth), model)
        surface.additionalProperties.setFeature('Length', length)
        surface.additionalProperties.setFeature('Azimuth', azimuth)
        surface.additionalProperties.setFeature('Tilt', 90.0)
        surface.additionalProperties.setFeature('SurfaceType', 'FoundationWall')
        surface.setName(foundation_wall.id)
        surface.setSurfaceType('Wall')
        set_surface_interior(model, spaces, surface, foundation_wall)
        set_surface_exterior(model, spaces, surface, foundation_wall)
        surface.setSunExposure('NoSun')
        surface.setWindExposure('NoWind')

        # Apply construction

        wall_type = HPXML::WallTypeConcrete
        if foundation_wall.is_thermal_boundary
          drywall_thick_in = 0.5
        else
          drywall_thick_in = 0.0
        end
        inside_film = Material.AirFilmVertical
        outside_film = Material.AirFilmVertical
        assembly_r = foundation_wall.insulation_assembly_r_value
        if assembly_r.nil?
          concrete_thick_in = foundation_wall.thickness
          int_r = foundation_wall.insulation_interior_r_value
          ext_r = foundation_wall.insulation_exterior_r_value
          assembly_r = int_r + ext_r + Material.Concrete(concrete_thick_in).rvalue + Material.GypsumWall(drywall_thick_in).rvalue + inside_film.rvalue + outside_film.rvalue
        end
        mat_ext_finish = nil

        Constructions.apply_wall_construction(runner, model, [surface], foundation_wall, foundation_wall.id, wall_type, assembly_r,
                                              drywall_thick_in, inside_film, outside_film, mat_ext_finish)
      end
    end
  end

  def self.add_foundation_wall(runner, model, spaces, foundation_wall, slab_frac,
                               total_fnd_wall_length, total_slab_exp_perim)

    net_area = foundation_wall.net_area * slab_frac
    gross_area = foundation_wall.area * slab_frac
    height = foundation_wall.height
    height_ag = height - foundation_wall.depth_below_grade
    z_origin = -1 * foundation_wall.depth_below_grade
    length = gross_area / height
    if foundation_wall.azimuth.nil?
      azimuth = @default_azimuths[0] # Arbitrary; solar incidence in Kiva is applied as an orientation average (to the above grade portion of the wall)
    else
      azimuth = foundation_wall.azimuth
    end

    if total_fnd_wall_length > total_slab_exp_perim
      # Calculate exposed section of wall based on slab's total exposed perimeter.
      length *= total_slab_exp_perim / total_fnd_wall_length
    end

    if gross_area > net_area
      # Create a "notch" in the wall to account for the subsurfaces. This ensures that
      # we preserve the appropriate wall height, length, and area for Kiva.
      subsurface_area = gross_area - net_area
    else
      subsurface_area = 0
    end

    surface = OpenStudio::Model::Surface.new(add_wall_polygon(length, height, z_origin, azimuth, [0] * 4, subsurface_area), model)
    surface.additionalProperties.setFeature('Length', length)
    surface.additionalProperties.setFeature('Azimuth', azimuth)
    surface.additionalProperties.setFeature('Tilt', 90.0)
    surface.additionalProperties.setFeature('SurfaceType', 'FoundationWall')
    surface.setName(foundation_wall.id)
    surface.setSurfaceType('Wall')
    set_surface_interior(model, spaces, surface, foundation_wall)
    set_surface_exterior(model, spaces, surface, foundation_wall)

    if foundation_wall.is_thermal_boundary
      drywall_thick_in = 0.5
    else
      drywall_thick_in = 0.0
    end
    concrete_thick_in = foundation_wall.thickness
    assembly_r = foundation_wall.insulation_assembly_r_value
    if not assembly_r.nil?
      ext_rigid_height = height
      ext_rigid_offset = 0.0
      inside_film = Material.AirFilmVertical
      ext_rigid_r = assembly_r - Material.Concrete(concrete_thick_in).rvalue - Material.GypsumWall(drywall_thick_in).rvalue - inside_film.rvalue
      int_rigid_r = 0.0
      if ext_rigid_r < 0 # Try without drywall
        drywall_thick_in = 0.0
        ext_rigid_r = assembly_r - Material.Concrete(concrete_thick_in).rvalue - Material.GypsumWall(drywall_thick_in).rvalue - inside_film.rvalue
      end
      if (ext_rigid_r > 0) && (ext_rigid_r < 0.1)
        ext_rigid_r = 0.0 # Prevent tiny strip of insulation
      end
      if ext_rigid_r < 0
        ext_rigid_r = 0.0
        match = false
      else
        match = true
      end
    else
      ext_rigid_offset = foundation_wall.insulation_exterior_distance_to_top
      ext_rigid_height = foundation_wall.insulation_exterior_distance_to_bottom - ext_rigid_offset
      ext_rigid_r = foundation_wall.insulation_exterior_r_value
      int_rigid_offset = foundation_wall.insulation_interior_distance_to_top
      int_rigid_height = foundation_wall.insulation_interior_distance_to_bottom - int_rigid_offset
      int_rigid_r = foundation_wall.insulation_interior_r_value
    end

    Constructions.apply_foundation_wall(runner, model, [surface], "#{foundation_wall.id} construction",
                                        ext_rigid_offset, int_rigid_offset, ext_rigid_height, int_rigid_height,
                                        ext_rigid_r, int_rigid_r, drywall_thick_in, concrete_thick_in, height_ag)

    if not assembly_r.nil?
      Constructions.check_surface_assembly_rvalue(runner, [surface], inside_film, nil, assembly_r, match)
    end

    return surface.adjacentFoundation.get
  end

  def self.add_foundation_slab(runner, model, spaces, slab, slab_exp_perim,
                               slab_area, z_origin, kiva_foundation)

    slab_tot_perim = slab_exp_perim
    if slab_tot_perim**2 - 16.0 * slab_area <= 0
      # Cannot construct rectangle with this perimeter/area. Some of the
      # perimeter is presumably not exposed, so bump up perimeter value.
      slab_tot_perim = Math.sqrt(16.0 * slab_area)
    end
    sqrt_term = [slab_tot_perim**2 - 16.0 * slab_area, 0.0].max
    slab_length = slab_tot_perim / 4.0 + Math.sqrt(sqrt_term) / 4.0
    slab_width = slab_tot_perim / 4.0 - Math.sqrt(sqrt_term) / 4.0

    surface = OpenStudio::Model::Surface.new(add_floor_polygon(slab_length, slab_width, z_origin), model)
    surface.setName(slab.id)
    surface.setSurfaceType('Floor')
    surface.setOutsideBoundaryCondition('Foundation')
    surface.additionalProperties.setFeature('SurfaceType', 'Slab')
    set_surface_interior(model, spaces, surface, slab)
    surface.setSunExposure('NoSun')
    surface.setWindExposure('NoWind')

    slab_perim_r = slab.perimeter_insulation_r_value
    slab_perim_depth = slab.perimeter_insulation_depth
    if (slab_perim_r == 0) || (slab_perim_depth == 0)
      slab_perim_r = 0
      slab_perim_depth = 0
    end

    if slab.under_slab_insulation_spans_entire_slab
      slab_whole_r = slab.under_slab_insulation_r_value
      slab_under_r = 0
      slab_under_width = 0
    else
      slab_under_r = slab.under_slab_insulation_r_value
      slab_under_width = slab.under_slab_insulation_width
      if (slab_under_r == 0) || (slab_under_width == 0)
        slab_under_r = 0
        slab_under_width = 0
      end
      slab_whole_r = 0
    end
    slab_gap_r = slab_under_r

    mat_carpet = nil
    if (slab.carpet_fraction > 0) && (slab.carpet_r_value > 0)
      mat_carpet = Material.CoveringBare(slab.carpet_fraction,
                                         slab.carpet_r_value)
    end

    Constructions.apply_foundation_slab(runner, model, surface, "#{slab.id} construction",
                                        slab_under_r, slab_under_width, slab_gap_r, slab_perim_r,
                                        slab_perim_depth, slab_whole_r, slab.thickness,
                                        slab_exp_perim, mat_carpet, kiva_foundation)

    return surface.adjacentFoundation.get
  end

  def self.add_conditioned_floor_area(runner, model, spaces)
    # Check if we need to add floors between conditioned spaces (e.g., between first
    # and second story or conditioned basement ceiling).
    # This ensures that the E+ reported Conditioned Floor Area is correct.

    sum_cfa = 0.0
    @hpxml.frame_floors.each do |frame_floor|
      next unless frame_floor.is_floor
      next unless [HPXML::LocationLivingSpace, HPXML::LocationBasementConditioned].include?(frame_floor.interior_adjacent_to) ||
                  [HPXML::LocationLivingSpace, HPXML::LocationBasementConditioned].include?(frame_floor.exterior_adjacent_to)

      sum_cfa += frame_floor.area
    end
    @hpxml.slabs.each do |slab|
      next unless [HPXML::LocationLivingSpace, HPXML::LocationBasementConditioned].include? slab.interior_adjacent_to

      sum_cfa += slab.area
    end

    addtl_cfa = @cfa - sum_cfa

    fail if addtl_cfa < -1.0 # Allow some rounding; EPvalidator.xml should prevent this

    return unless addtl_cfa > 1.0 # Allow some rounding

    floor_width = Math::sqrt(addtl_cfa)
    floor_length = addtl_cfa / floor_width
    z_origin = @foundation_top + 8.0 * (@ncfl_ag - 1)

    # Add floor surface
    floor_surface = OpenStudio::Model::Surface.new(add_floor_polygon(-floor_width, -floor_length, z_origin), model)

    floor_surface.setSunExposure('NoSun')
    floor_surface.setWindExposure('NoWind')
    floor_surface.setName('inferred conditioned floor')
    floor_surface.setSurfaceType('Floor')
    floor_surface.setSpace(create_or_get_space(model, spaces, HPXML::LocationLivingSpace))
    floor_surface.setOutsideBoundaryCondition('Adiabatic')
    floor_surface.additionalProperties.setFeature('SurfaceType', 'InferredFloor')
    floor_surface.additionalProperties.setFeature('Tilt', 0.0)

    # Add ceiling surface
    ceiling_surface = OpenStudio::Model::Surface.new(add_ceiling_polygon(-floor_width, -floor_length, z_origin), model)

    ceiling_surface.setSunExposure('NoSun')
    ceiling_surface.setWindExposure('NoWind')
    ceiling_surface.setName('inferred conditioned ceiling')
    ceiling_surface.setSurfaceType('RoofCeiling')
    ceiling_surface.setSpace(create_or_get_space(model, spaces, HPXML::LocationLivingSpace))
    ceiling_surface.setOutsideBoundaryCondition('Adiabatic')
    ceiling_surface.additionalProperties.setFeature('SurfaceType', 'InferredCeiling')
    ceiling_surface.additionalProperties.setFeature('Tilt', 0.0)

    if not @cond_bsmnt_surfaces.empty?
      # assuming added ceiling is in conditioned basement
      @cond_bsmnt_surfaces << ceiling_surface
    end

    # Apply Construction
    apply_adiabatic_construction(runner, model, [floor_surface, ceiling_surface], 'floor')
  end

  def self.add_thermal_mass(runner, model, spaces)
    cfa_basement = @hpxml.slabs.select { |s| s.interior_adjacent_to == HPXML::LocationBasementConditioned }.map { |s| s.area }.sum(0.0)
    if @apply_ashrae140_assumptions
      # 1024 ft2 of interior partition wall mass, no furniture mass
      drywall_thick_in = 0.5
      partition_frac_of_cfa = (1024.0 * 2) / @cfa # Ratio of exposed partition wall area (both sides) to conditioned floor area
      basement_frac_of_cfa = cfa_basement / @cfa
      Constructions.apply_partition_walls(runner, model, 'PartitionWallConstruction', drywall_thick_in, partition_frac_of_cfa,
                                          basement_frac_of_cfa, @cond_bsmnt_surfaces, spaces[HPXML::LocationLivingSpace])
    else
      drywall_thick_in = 0.5
      partition_frac_of_cfa = 1.0 # Ratio of exposed partition wall area (both sides) to conditioned floor area
      basement_frac_of_cfa = cfa_basement / @cfa
      Constructions.apply_partition_walls(runner, model, 'PartitionWallConstruction', drywall_thick_in, partition_frac_of_cfa,
                                          basement_frac_of_cfa, @cond_bsmnt_surfaces, spaces[HPXML::LocationLivingSpace])

      mass_lb_per_sqft = 8.0
      density_lb_per_cuft = 40.0
      mat = BaseMaterial.Wood
      Constructions.apply_furniture(runner, model, mass_lb_per_sqft, density_lb_per_cuft, mat,
                                    basement_frac_of_cfa, @cond_bsmnt_surfaces, spaces[HPXML::LocationLivingSpace])
    end
  end

  def self.add_neighbors(runner, model, length)
    z_origin = 0 # shading surface always starts at grade

    shading_surfaces = []
    @hpxml.neighbor_buildings.each do |neighbor_building|
      height = neighbor_building.height.nil? ? @walls_top : neighbor_building.height

      shading_surface = OpenStudio::Model::ShadingSurface.new(add_wall_polygon(length, height, z_origin, neighbor_building.azimuth), model)
      shading_surface.additionalProperties.setFeature('Azimuth', neighbor_building.azimuth)
      shading_surface.additionalProperties.setFeature('Distance', neighbor_building.distance)
      shading_surface.setName("Neighbor azimuth #{neighbor_building.azimuth} distance #{neighbor_building.distance}")

      shading_surfaces << shading_surface
    end

    unless shading_surfaces.empty?
      shading_surface_group = OpenStudio::Model::ShadingSurfaceGroup.new(model)
      shading_surface_group.setName(Constants.ObjectNameNeighbors)
      shading_surfaces.each do |shading_surface|
        shading_surface.setShadingSurfaceGroup(shading_surface_group)
      end
    end
  end

  def self.add_shading_schedule(runner, model, weather)
    heating_season, @cooling_season = HVAC.get_default_heating_and_cooling_seasons(weather)

    # Create cooling season schedule
    clg_season_sch = MonthWeekdayWeekendSchedule.new(model, 'cooling season schedule', Array.new(24, 1), Array.new(24, 1), @cooling_season, Constants.ScheduleTypeLimitsFraction)
    @clg_ssn_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Schedule Value')
    @clg_ssn_sensor.setName('cool_season')
    @clg_ssn_sensor.setKeyName(clg_season_sch.schedule.name.to_s)
  end

  def self.add_windows(runner, model, spaces, weather)
    # We already stored @fraction_of_windows_operable, so lets remove the
    # fraction_operable properties from windows and re-collapse the enclosure
    # so as to prevent potentially modeling multiple identical windows in E+,
    # which can increase simulation runtime.
    @hpxml.windows.each do |window|
      window.fraction_operable = nil
    end
    @hpxml.collapse_enclosure_surfaces()

    shading_group = nil
    shading_schedules = {}

    surfaces = []
    @hpxml.windows.each do |window|
      window_height = 4.0 # ft, default

      overhang_depth = nil
      if (not window.overhangs_depth.nil?) && (window.overhangs_depth > 0)
        overhang_depth = window.overhangs_depth
        overhang_distance_to_top = window.overhangs_distance_to_top_of_window
        overhang_distance_to_bottom = window.overhangs_distance_to_bottom_of_window
        window_height = overhang_distance_to_bottom - overhang_distance_to_top
      end

      window_width = window.area / window_height
      z_origin = @foundation_top

      if window.is_exterior

        # Create parent surface slightly bigger than window
        surface = OpenStudio::Model::Surface.new(add_wall_polygon(window_width, window_height, z_origin,
                                                                  window.azimuth, [0, 0.001, 0.001, 0.001]), model)

        surface.additionalProperties.setFeature('Length', window_width)
        surface.additionalProperties.setFeature('Azimuth', window.azimuth)
        surface.additionalProperties.setFeature('Tilt', 90.0)
        surface.additionalProperties.setFeature('SurfaceType', 'Window')
        surface.setName("surface #{window.id}")
        surface.setSurfaceType('Wall')
        set_surface_interior(model, spaces, surface, window.wall)

        sub_surface = OpenStudio::Model::SubSurface.new(add_wall_polygon(window_width, window_height, z_origin,
                                                                         window.azimuth, [-0.001, 0, 0.001, 0]), model)
        sub_surface.setName(window.id)
        sub_surface.setSurface(surface)
        sub_surface.setSubSurfaceType('FixedWindow')

        set_subsurface_exterior(surface, spaces, model, window.wall)
        surfaces << surface

        if not overhang_depth.nil?
          overhang = sub_surface.addOverhang(UnitConversions.convert(overhang_depth, 'ft', 'm'), UnitConversions.convert(overhang_distance_to_top, 'ft', 'm'))
          overhang.get.setName("#{sub_surface.name} - #{Constants.ObjectNameOverhangs}")
        end

        # Apply construction
        Constructions.apply_window(runner, model, sub_surface, 'WindowConstruction', window.ufactor, window.shgc)

        # Apply interior/exterior shading (as needed)
        shading_polygon = add_wall_polygon(window_width, window_height, z_origin, window.azimuth, [0, 0, 0, 0])
        shading_group = apply_shading(model, window, shading_polygon, surface, sub_surface, shading_group, shading_schedules, Constants.ObjectNameWindowShade)
      else
        # Window is on an interior surface, which E+ does not allow. Model
        # as a door instead so that we can get the appropriate conduction
        # heat transfer; there is no solar gains anyway.

        # Create parent surface slightly bigger than window
        surface = OpenStudio::Model::Surface.new(add_wall_polygon(window_width, window_height, z_origin,
                                                                  window.azimuth, [0, 0.001, 0.001, 0.001]), model)

        surface.additionalProperties.setFeature('Length', window_width)
        surface.additionalProperties.setFeature('Azimuth', window.azimuth)
        surface.additionalProperties.setFeature('Tilt', 90.0)
        surface.additionalProperties.setFeature('SurfaceType', 'Door')
        surface.setName("surface #{window.id}")
        surface.setSurfaceType('Wall')
        set_surface_interior(model, spaces, surface, window.wall)

        sub_surface = OpenStudio::Model::SubSurface.new(add_wall_polygon(window_width, window_height, z_origin,
                                                                         window.azimuth, [0, 0, 0, 0]), model)
        sub_surface.setName(window.id)
        sub_surface.setSurface(surface)
        sub_surface.setSubSurfaceType('Door')

        set_subsurface_exterior(surface, spaces, model, window.wall)
        surfaces << surface

        # Apply construction
        inside_film = Material.AirFilmVertical
        outside_film = Material.AirFilmVertical
        Constructions.apply_door(runner, model, [sub_surface], 'Window', window.ufactor, inside_film, outside_film)
      end
    end

    apply_adiabatic_construction(runner, model, surfaces, 'wall')
  end

  def self.add_skylights(runner, model, spaces, weather)
    surfaces = []

    shading_group = nil
    shading_schedules = {}

    @hpxml.skylights.each do |skylight|
      tilt = skylight.roof.pitch / 12.0
      width = Math::sqrt(skylight.area)
      length = skylight.area / width
      z_origin = @walls_top + 0.5 * Math.sin(Math.atan(tilt)) * width

      # Create parent surface slightly bigger than skylight
      surface = OpenStudio::Model::Surface.new(add_roof_polygon(length + 0.001, width + 0.001, z_origin,
                                                                skylight.azimuth, tilt), model)

      surface.additionalProperties.setFeature('Length', length)
      surface.additionalProperties.setFeature('Width', width)
      surface.additionalProperties.setFeature('Azimuth', skylight.azimuth)
      surface.additionalProperties.setFeature('Tilt', tilt)
      surface.additionalProperties.setFeature('SurfaceType', 'Skylight')
      surface.setName("surface #{skylight.id}")
      surface.setSurfaceType('RoofCeiling')
      surface.setSpace(create_or_get_space(model, spaces, HPXML::LocationLivingSpace)) # Ensures it is included in Manual J sizing
      surface.setOutsideBoundaryCondition('Outdoors') # cannot be adiabatic because subsurfaces won't be created
      surfaces << surface

      sub_surface = OpenStudio::Model::SubSurface.new(add_roof_polygon(length, width, z_origin,
                                                                       skylight.azimuth, tilt), model)
      sub_surface.setName(skylight.id)
      sub_surface.setSurface(surface)
      sub_surface.setSubSurfaceType('Skylight')

      # Apply construction
      Constructions.apply_skylight(runner, model, sub_surface, 'SkylightConstruction', skylight.ufactor, skylight.shgc)

      # Apply interior/exterior shading (as needed)
      shading_polygon = add_roof_polygon(length, width, z_origin, skylight.azimuth, tilt)
      shading_group = apply_shading(model, skylight, shading_polygon, surface, sub_surface, shading_group, shading_schedules, Constants.ObjectNameSkylightShade)
    end

    apply_adiabatic_construction(runner, model, surfaces, 'roof')
  end

  def self.apply_shading(model, window_or_skylight, shading_polygon, parent_surface, sub_surface, shading_group, shading_schedules, name)
    sf_summer = window_or_skylight.interior_shading_factor_summer * window_or_skylight.exterior_shading_factor_summer
    sf_winter = window_or_skylight.interior_shading_factor_winter * window_or_skylight.exterior_shading_factor_winter
    if (sf_summer < 1.0) || (sf_winter < 1.0)
      # Apply shading
      # We use a ShadingSurface instead of a Shade so that we perfectly get the result we want.
      # The latter object is complex and it is essentially impossible to achieve the target reduction in transmitted
      # solar (due to, e.g., re-reflectance, absorptance, angle modifiers, effects on convection, etc.).

      # Shading surface is used to reduce beam solar and sky diffuse solar
      shading_surface = OpenStudio::Model::ShadingSurface.new(shading_polygon, model)
      shading_surface.setName("#{window_or_skylight.id} shading surface")
      shading_surface.additionalProperties.setFeature('Azimuth', window_or_skylight.azimuth)
      shading_surface.additionalProperties.setFeature('ParentSurface', parent_surface.name.to_s)

      # Create transmittance schedule for heating/cooling seasons
      trans_values = @cooling_season.map { |c| c == 1 ? sf_summer : sf_winter }
      if shading_schedules[trans_values].nil?
        trans_sch = MonthWeekdayWeekendSchedule.new(model, "trans schedule winter=#{sf_winter} summer=#{sf_summer}", Array.new(24, 1), Array.new(24, 1), trans_values, Constants.ScheduleTypeLimitsFraction, false)
        shading_schedules[trans_values] = trans_sch
      end
      shading_surface.setTransmittanceSchedule(shading_schedules[trans_values].schedule)

      # Adjustment to default view factor is used to reduce ground diffuse solar
      avg_trans_value = trans_values.sum(0.0) / 12.0 # FUTURE: Create EnergyPlus actuator to adjust this
      default_vf_to_ground = ((1.0 - Math::cos(parent_surface.tilt)) / 2.0).round(2)
      sub_surface.setViewFactortoGround(default_vf_to_ground * avg_trans_value)

      if shading_group.nil?
        shading_group = OpenStudio::Model::ShadingSurfaceGroup.new(model)
        shading_group.setName(name)
      end
      shading_surface.setShadingSurfaceGroup(shading_group)
    end
    return shading_group
  end

  def self.add_doors(runner, model, spaces)
    surfaces = []
    @hpxml.doors.each do |door|
      door_height = 6.67 # ft
      door_width = door.area / door_height
      z_origin = @foundation_top

      # Create parent surface slightly bigger than door
      surface = OpenStudio::Model::Surface.new(add_wall_polygon(door_width, door_height, z_origin,
                                                                door.azimuth, [0, 0.001, 0.001, 0.001]), model)

      surface.additionalProperties.setFeature('Length', door_width)
      surface.additionalProperties.setFeature('Azimuth', door.azimuth)
      surface.additionalProperties.setFeature('Tilt', 90.0)
      surface.additionalProperties.setFeature('SurfaceType', 'Door')
      surface.setName("surface #{door.id}")
      surface.setSurfaceType('Wall')
      set_surface_interior(model, spaces, surface, door.wall)

      sub_surface = OpenStudio::Model::SubSurface.new(add_wall_polygon(door_width, door_height, z_origin,
                                                                       door.azimuth, [0, 0, 0, 0]), model)
      sub_surface.setName(door.id)
      sub_surface.setSurface(surface)
      sub_surface.setSubSurfaceType('Door')

      set_subsurface_exterior(surface, spaces, model, door.wall)
      surfaces << surface

      # Apply construction
      ufactor = 1.0 / door.r_value
      inside_film = Material.AirFilmVertical
      if door.wall.is_exterior
        outside_film = Material.AirFilmOutside
      else
        outside_film = Material.AirFilmVertical
      end
      Constructions.apply_door(runner, model, [sub_surface], 'Door', ufactor, inside_film, outside_film)
    end

    apply_adiabatic_construction(runner, model, surfaces, 'wall')
  end

  def self.apply_adiabatic_construction(runner, model, surfaces, type)
    # Arbitrary construction for heat capacitance.
    # Only applies to surfaces where outside boundary conditioned is
    # adiabatic or surface net area is near zero.
    return if surfaces.empty?

    if type == 'wall'
      Constructions.apply_wood_stud_wall(runner, model, surfaces, nil, 'AdiabaticWallConstruction',
                                         0, 1, 3.5, true, 0.1, 0.5, 0, 99,
                                         Material.ExteriorFinishMaterial(HPXML::SidingTypeWood, 0.90, 0.75),
                                         0,
                                         Material.AirFilmVertical,
                                         Material.AirFilmVertical)
    elsif type == 'floor'
      Constructions.apply_floor(runner, model, surfaces, 'AdiabaticFloorConstruction',
                                0, 1, 0.07, 5.5, 0.75, 99,
                                Material.CoveringBare,
                                Material.AirFilmFloorReduced,
                                Material.AirFilmFloorReduced)
    elsif type == 'roof'
      Constructions.apply_open_cavity_roof(runner, model, surfaces, 'AdiabaticRoofConstruction',
                                           0, 1, 7.25, 0.07, 7.25, 0.75, 99,
                                           Material.RoofMaterial(HPXML::RoofTypeAsphaltShingles, 0.90, 0.75),
                                           false,
                                           Material.AirFilmOutside,
                                           Material.AirFilmRoof(Geometry.get_roof_pitch(surfaces)), nil)
    end
  end

  def self.add_hot_water_and_appliances(runner, model, weather, spaces)
    # Assign spaces
    @hpxml.clothes_washers.each do |clothes_washer|
      clothes_washer.additional_properties.space = get_space_from_location(clothes_washer.location, 'ClothesWasher', model, spaces)
    end
    @hpxml.clothes_dryers.each do |clothes_dryer|
      clothes_dryer.additional_properties.space = get_space_from_location(clothes_dryer.location, 'ClothesDryer', model, spaces)
    end
    @hpxml.dishwashers.each do |dishwasher|
      dishwasher.additional_properties.space = get_space_from_location(dishwasher.location, 'Dishwasher', model, spaces)
    end
    @hpxml.refrigerators.each do |refrigerator|
      refrigerator.additional_properties.space = get_space_from_location(refrigerator.location, 'Refrigerator', model, spaces)
    end
    @hpxml.freezers.each do |freezer|
      freezer.additional_properties.space = get_space_from_location(freezer.location, 'Freezer', model, spaces)
    end
    @hpxml.cooking_ranges.each do |cooking_range|
      cooking_range.additional_properties.space = get_space_from_location(cooking_range.location, 'CookingRange', model, spaces)
    end

    # Distribution
    if @hpxml.water_heating_systems.size > 0
      hot_water_distribution = @hpxml.hot_water_distributions[0]
    end

    # Solar thermal system
    solar_thermal_system = nil
    if @hpxml.solar_thermal_systems.size > 0
      solar_thermal_system = @hpxml.solar_thermal_systems[0]
    end

    # Water Heater
    has_uncond_bsmnt = @hpxml.has_space_type(HPXML::LocationBasementUnconditioned)
    @hpxml.water_heating_systems.each do |water_heating_system|
      loc_space, loc_schedule = get_space_or_schedule_from_location(water_heating_system.location, 'WaterHeatingSystem', model, spaces)

      ec_adj = HotWaterAndAppliances.get_dist_energy_consumption_adjustment(has_uncond_bsmnt, @cfa, @ncfl, water_heating_system, hot_water_distribution)

      if water_heating_system.water_heater_type == HPXML::WaterHeaterTypeStorage

        Waterheater.apply_tank(model, loc_space, loc_schedule, water_heating_system, ec_adj,
                               @dhw_map, @hvac_map, solar_thermal_system)

      elsif water_heating_system.water_heater_type == HPXML::WaterHeaterTypeTankless

        Waterheater.apply_tankless(model, loc_space, loc_schedule, water_heating_system, ec_adj,
                                   @nbeds, @dhw_map, @hvac_map, solar_thermal_system)

      elsif water_heating_system.water_heater_type == HPXML::WaterHeaterTypeHeatPump

        living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get

        Waterheater.apply_heatpump(model, runner, loc_space, loc_schedule, weather, water_heating_system, ec_adj,
                                   @dhw_map, @hvac_map, solar_thermal_system, living_zone)

      elsif [HPXML::WaterHeaterTypeCombiStorage, HPXML::WaterHeaterTypeCombiTankless].include? water_heating_system.water_heater_type

        Waterheater.apply_combi(model, runner, loc_space, loc_schedule, water_heating_system, ec_adj,
                                @dhw_map, @hvac_map, solar_thermal_system)

      else

        fail "Unhandled water heater (#{water_heating_system.water_heater_type})."

      end
    end

    # Hot water fixtures and appliances
    HotWaterAndAppliances.apply(model, runner, @hpxml, weather, spaces, hot_water_distribution,
                                solar_thermal_system, @eri_version, @dhw_map, @schedules_file)

    if (not solar_thermal_system.nil?) && (not solar_thermal_system.collector_area.nil?) # Detailed solar water heater
      loc_space, loc_schedule = get_space_or_schedule_from_location(solar_thermal_system.water_heating_system.location, 'WaterHeatingSystem', model, spaces)
      Waterheater.apply_solar_thermal(model, loc_space, loc_schedule, solar_thermal_system, @dhw_map)
    end

    # Add combi-system EMS program with water use equipment information
    Waterheater.apply_combi_system_EMS(model, @dhw_map, @hpxml.water_heating_systems)
  end

  def self.add_cooling_system(runner, model, spaces)
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get

    HVAC.get_hpxml_hvac_systems(@hpxml).each do |hvac_system|
      next if hvac_system[:cooling].nil?
      next unless hvac_system[:cooling].is_a? HPXML::CoolingSystem

      cooling_system = hvac_system[:cooling]
      heating_system = hvac_system[:heating]

      check_distribution_system(cooling_system.distribution_system, cooling_system.cooling_system_type)

      if [HPXML::HVACTypeCentralAirConditioner].include? cooling_system.cooling_system_type

        HVAC.apply_central_air_conditioner_furnace(model, runner, cooling_system, heating_system,
                                                   @remaining_cool_load_frac, @remaining_heat_load_frac,
                                                   living_zone, @hvac_map)

        if not heating_system.nil?
          @remaining_heat_load_frac -= heating_system.fraction_heat_load_served
        end

      elsif [HPXML::HVACTypeRoomAirConditioner].include? cooling_system.cooling_system_type

        HVAC.apply_room_air_conditioner(model, runner, cooling_system,
                                        @remaining_cool_load_frac, living_zone,
                                        @hvac_map)

      elsif [HPXML::HVACTypeEvaporativeCooler].include? cooling_system.cooling_system_type

        HVAC.apply_evaporative_cooler(model, runner, cooling_system,
                                      @remaining_cool_load_frac, living_zone,
                                      @hvac_map)

      elsif [HPXML::HVACTypeMiniSplitAirConditioner].include? cooling_system.cooling_system_type

        HVAC.apply_mini_split_air_conditioner(model, runner, cooling_system,
                                              @remaining_cool_load_frac,
                                              living_zone, @hvac_map)
      end

      @remaining_cool_load_frac -= cooling_system.fraction_cool_load_served
    end
  end

  def self.add_heating_system(runner, model, spaces)
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get

    HVAC.get_hpxml_hvac_systems(@hpxml).each do |hvac_system|
      next if hvac_system[:heating].nil?
      next unless hvac_system[:heating].is_a? HPXML::HeatingSystem

      cooling_system = hvac_system[:cooling]
      heating_system = hvac_system[:heating]

      check_distribution_system(heating_system.distribution_system, heating_system.heating_system_type)

      if [HPXML::HVACTypeFurnace].include? heating_system.heating_system_type

        if not cooling_system.nil?
          next # Already processed combined AC+furnace
        end

        HVAC.apply_central_air_conditioner_furnace(model, runner, nil, heating_system,
                                                   nil, @remaining_heat_load_frac,
                                                   living_zone, @hvac_map)

      elsif [HPXML::HVACTypeBoiler].include? heating_system.heating_system_type

        HVAC.apply_boiler(model, runner, heating_system,
                          @remaining_heat_load_frac, living_zone, @hvac_map)

      elsif [HPXML::HVACTypeElectricResistance].include? heating_system.heating_system_type

        HVAC.apply_electric_baseboard(model, runner, heating_system,
                                      @remaining_heat_load_frac, living_zone, @hvac_map)

      elsif [HPXML::HVACTypeStove,
             HPXML::HVACTypePortableHeater,
             HPXML::HVACTypeFixedHeater,
             HPXML::HVACTypeWallFurnace,
             HPXML::HVACTypeFloorFurnace,
             HPXML::HVACTypeFireplace].include? heating_system.heating_system_type

        HVAC.apply_unit_heater(model, runner, heating_system,
                               @remaining_heat_load_frac, living_zone, @hvac_map)
      end

      @remaining_heat_load_frac -= heating_system.fraction_heat_load_served
    end
  end

  def self.add_heat_pump(runner, model, weather, spaces)
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get

    HVAC.get_hpxml_hvac_systems(@hpxml).each do |hvac_system|
      next if hvac_system[:cooling].nil?
      next unless hvac_system[:cooling].is_a? HPXML::HeatPump

      heat_pump = hvac_system[:cooling]

      check_distribution_system(heat_pump.distribution_system, heat_pump.heat_pump_type)

      if [HPXML::HVACTypeHeatPumpWaterLoopToAir].include? heat_pump.heat_pump_type

        HVAC.apply_water_loop_to_air_heat_pump(model, runner, heat_pump,
                                               @remaining_heat_load_frac,
                                               @remaining_cool_load_frac,
                                               living_zone, @hvac_map)

      elsif [HPXML::HVACTypeHeatPumpAirToAir].include? heat_pump.heat_pump_type

        HVAC.apply_central_air_to_air_heat_pump(model, runner, heat_pump,
                                                @remaining_heat_load_frac,
                                                @remaining_cool_load_frac,
                                                living_zone, @hvac_map)

      elsif [HPXML::HVACTypeHeatPumpMiniSplit].include? heat_pump.heat_pump_type

        HVAC.apply_mini_split_heat_pump(model, runner, heat_pump,
                                        @remaining_heat_load_frac,
                                        @remaining_cool_load_frac,
                                        living_zone, @hvac_map)

      elsif [HPXML::HVACTypeHeatPumpGroundToAir].include? heat_pump.heat_pump_type

        HVAC.apply_ground_to_air_heat_pump(model, runner, weather, heat_pump,
                                           @remaining_heat_load_frac,
                                           @remaining_cool_load_frac,
                                           living_zone, @hvac_map)

      end

      @remaining_heat_load_frac -= heat_pump.fraction_heat_load_served
      @remaining_cool_load_frac -= heat_pump.fraction_cool_load_served
    end
  end

  def self.add_ideal_system(runner, model, spaces, epw_path)
    # Adds an ideal air system as needed to meet the load (i.e., because the sum of fractions load
    # served is less than 1 or because we're using an ideal air system for e.g. ASHRAE 140 loads).
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get
    obj_name = Constants.ObjectNameIdealAirSystem

    if @apply_ashrae140_assumptions && (@hpxml.total_fraction_heat_load_served + @hpxml.total_fraction_heat_load_served == 0.0)
      cooling_load_frac = 1.0
      heating_load_frac = 1.0
      if @apply_ashrae140_assumptions
        if epw_path.end_with? 'USA_CO_Colorado.Springs-Peterson.Field.724660_TMY3.epw'
          cooling_load_frac = 0.0
        elsif epw_path.end_with? 'USA_NV_Las.Vegas-McCarran.Intl.AP.723860_TMY3.epw'
          heating_load_frac = 0.0
        else
          fail 'Unexpected weather file for ASHRAE 140 run.'
        end
      end
      HVAC.apply_ideal_air_loads(model, runner, obj_name, cooling_load_frac, heating_load_frac, living_zone)
      return
    end

    # Only fraction of heating load is met
    if (@hpxml.total_fraction_heat_load_served < 1.0) && (@hpxml.total_fraction_heat_load_served > 0.0)
      sequential_heat_load_frac = @remaining_heat_load_frac - @hpxml.total_fraction_heat_load_served
      @remaining_heat_load_frac -= sequential_heat_load_frac
    else
      sequential_heat_load_frac = 0.0
    end
    # Only fraction of cooling load is met
    if (@hpxml.total_fraction_cool_load_served < 1.0) && (@hpxml.total_fraction_cool_load_served > 0.0)
      sequential_cool_load_frac = @remaining_cool_load_frac - @hpxml.total_fraction_cool_load_served
      @remaining_cool_load_frac -= sequential_cool_load_frac
    else
      sequential_cool_load_frac = 0.0
    end
    if (sequential_heat_load_frac > 0.0) || (sequential_cool_load_frac > 0.0)
      HVAC.apply_ideal_air_loads(model, runner, obj_name, sequential_cool_load_frac, sequential_heat_load_frac,
                                 living_zone)
    end
  end

  def self.add_residual_ideal_system(runner, model, spaces)
    # Adds an ideal air system to meet unexpected load (i.e., because the HVAC systems are undersized to meet the load)
    #
    # Addressing unmet load ensures we can correctly calculate total heating/cooling loads without having
    # to run an additional EnergyPlus simulation solely for that purpose, as well as allows us to report
    # the unmet load (i.e., the energy delivered by the ideal air system).

    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get
    obj_name = Constants.ObjectNameIdealAirSystemResidual

    if @remaining_cool_load_frac < 1.0
      sequential_cool_load_frac = 1.0
    else
      sequential_cool_load_frac = 0.0 # no cooling system, don't add ideal air for cooling either
    end

    if @remaining_heat_load_frac < 1.0
      sequential_heat_load_frac = 1.0
    else
      sequential_heat_load_frac = 0.0 # no heating system, don't add ideal air for heating either
    end
    if (sequential_heat_load_frac > 0.0) || (sequential_cool_load_frac > 0.0)
      HVAC.apply_ideal_air_loads(model, runner, obj_name, sequential_cool_load_frac, sequential_heat_load_frac,
                                 living_zone)
    end
  end

  def self.add_setpoints(runner, model, weather, spaces)
    return if @hpxml.hvac_controls.size == 0

    hvac_control = @hpxml.hvac_controls[0]
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get
    has_ceiling_fan = (@hpxml.ceiling_fans.size > 0)

    HVAC.apply_setpoints(model, runner, weather, hvac_control, living_zone, has_ceiling_fan)
  end

  def self.add_ceiling_fans(runner, model, weather, spaces)
    return if @hpxml.ceiling_fans.size == 0

    ceiling_fan = @hpxml.ceiling_fans[0]
    HVAC.apply_ceiling_fans(model, runner, weather, ceiling_fan, spaces[HPXML::LocationLivingSpace], @schedules_file)
  end

  def self.add_dehumidifiers(runner, model, spaces)
    return if @hpxml.dehumidifiers.size == 0

    HVAC.apply_dehumidifiers(model, runner, @hpxml.dehumidifiers, spaces[HPXML::LocationLivingSpace], @hvac_map)
  end

  def self.check_distribution_system(hvac_distribution, system_type)
    return if hvac_distribution.nil?

    hvac_distribution_type_map = { HPXML::HVACTypeFurnace => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeBoiler => [HPXML::HVACDistributionTypeHydronic, HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeCentralAirConditioner => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeEvaporativeCooler => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeMiniSplitAirConditioner => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeHeatPumpAirToAir => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeHeatPumpMiniSplit => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeHeatPumpGroundToAir => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE],
                                   HPXML::HVACTypeHeatPumpWaterLoopToAir => [HPXML::HVACDistributionTypeAir, HPXML::HVACDistributionTypeDSE] }

    if not hvac_distribution_type_map[system_type].include? hvac_distribution.distribution_system_type
      # validator.rb only checks that a HVAC distribution system of the correct type (for the given HVAC system) exists
      # in the HPXML file, not that it is attached to this HVAC system. So here we perform the more rigorous check.
      fail "Incorrect HVAC distribution system type for HVAC type: '#{system_type}'. Should be one of: #{hvac_distribution_type_map[system_type]}"
    end
  end

  def self.add_mels(runner, model, spaces)
    # Misc
    @hpxml.plug_loads.each do |plug_load|
      if plug_load.plug_load_type == HPXML::PlugLoadTypeOther
        obj_name = Constants.ObjectNameMiscPlugLoads
      elsif plug_load.plug_load_type == HPXML::PlugLoadTypeTelevision
        obj_name = Constants.ObjectNameMiscTelevision
      elsif plug_load.plug_load_type == HPXML::PlugLoadTypeElectricVehicleCharging
        obj_name = Constants.ObjectNameMiscElectricVehicleCharging
      elsif plug_load.plug_load_type == HPXML::PlugLoadTypeWellPump
        obj_name = Constants.ObjectNameMiscWellPump
      end
      if obj_name.nil?
        runner.registerWarning("Unexpected plug load type '#{plug_load.plug_load_type}'. The plug load will not be modeled.")
        next
      end

      MiscLoads.apply_plug(model, plug_load, obj_name, spaces[HPXML::LocationLivingSpace], @apply_ashrae140_assumptions, @schedules_file)
    end
  end

  def self.add_mfls(runner, model, spaces)
    # Misc
    @hpxml.fuel_loads.each do |fuel_load|
      if fuel_load.fuel_load_type == HPXML::FuelLoadTypeGrill
        obj_name = Constants.ObjectNameMiscGrill
      elsif fuel_load.fuel_load_type == HPXML::FuelLoadTypeLighting
        obj_name = Constants.ObjectNameMiscLighting
      elsif fuel_load.fuel_load_type == HPXML::FuelLoadTypeFireplace
        obj_name = Constants.ObjectNameMiscFireplace
      end
      if obj_name.nil?
        runner.registerWarning("Unexpected fuel load type '#{fuel_load.fuel_load_type}'. The fuel load will not be modeled.")
        next
      end

      MiscLoads.apply_fuel(model, fuel_load, obj_name, spaces[HPXML::LocationLivingSpace], @schedules_file)
    end
  end

  def self.add_lighting(runner, model, epw_file, spaces)
    Lighting.apply(runner, model, epw_file, spaces, @hpxml.lighting_groups,
                   @hpxml.lighting, @eri_version, @schedules_file)
  end

  def self.add_pools_and_hot_tubs(runner, model, spaces)
    @hpxml.pools.each do |pool|
      next if pool.type == HPXML::TypeNone

      MiscLoads.apply_pool_or_hot_tub_heater(model, pool, Constants.ObjectNameMiscPoolHeater, spaces[HPXML::LocationLivingSpace], @schedules_file)
      MiscLoads.apply_pool_or_hot_tub_pump(model, pool, Constants.ObjectNameMiscPoolPump, spaces[HPXML::LocationLivingSpace], @schedules_file)
    end

    @hpxml.hot_tubs.each do |hot_tub|
      next if hot_tub.type == HPXML::TypeNone

      MiscLoads.apply_pool_or_hot_tub_heater(model, hot_tub, Constants.ObjectNameMiscHotTubHeater, spaces[HPXML::LocationLivingSpace], @schedules_file)
      MiscLoads.apply_pool_or_hot_tub_pump(model, hot_tub, Constants.ObjectNameMiscHotTubPump, spaces[HPXML::LocationLivingSpace], @schedules_file)
    end
  end

  def self.add_airflow(runner, model, weather, spaces)
    # Ducts
    duct_systems = {}
    @hpxml.hvac_distributions.each do |hvac_distribution|
      next unless hvac_distribution.distribution_system_type == HPXML::HVACDistributionTypeAir

      air_ducts = create_ducts(runner, model, hvac_distribution, spaces)
      next if air_ducts.empty?

      # Connect AirLoopHVACs to ducts
      added_ducts = false
      hvac_distribution.hvac_systems.each do |hvac_system|
        @hvac_map[hvac_system.id].each do |object|
          next unless object.is_a?(OpenStudio::Model::AirLoopHVAC) || object.is_a?(OpenStudio::Model::ZoneHVACFourPipeFanCoil)

          if duct_systems[air_ducts].nil?
            duct_systems[air_ducts] = object
            added_ducts = true
          elsif duct_systems[air_ducts] != object
            # Multiple air loops associated with this duct system, treat
            # as separate duct systems.
            air_ducts2 = create_ducts(runner, model, hvac_distribution, spaces)
            duct_systems[air_ducts2] = object
            added_ducts = true
          end
        end
      end
      if not added_ducts
        fail 'Unexpected error adding ducts to model.'
      end
    end

    Airflow.apply(model, runner, weather, spaces, @hpxml, @cfa, @nbeds,
                  @ncfl_ag, duct_systems, @clg_ssn_sensor, @hvac_map, @eri_version,
                  @frac_windows_operable, @apply_ashrae140_assumptions, @schedules_file)
  end

  def self.create_ducts(runner, model, hvac_distribution, spaces)
    air_ducts = []

    # Duct leakage (supply/return => [value, units])
    leakage_to_outside = { HPXML::DuctTypeSupply => [0.0, nil],
                           HPXML::DuctTypeReturn => [0.0, nil] }
    hvac_distribution.duct_leakage_measurements.each do |duct_leakage_measurement|
      next unless [HPXML::UnitsCFM25, HPXML::UnitsPercent].include?(duct_leakage_measurement.duct_leakage_units) && (duct_leakage_measurement.duct_leakage_total_or_to_outside == 'to outside')
      next if duct_leakage_measurement.duct_type.nil?

      leakage_to_outside[duct_leakage_measurement.duct_type] = [duct_leakage_measurement.duct_leakage_value, duct_leakage_measurement.duct_leakage_units]
    end

    # Duct location, R-value, Area
    total_unconditioned_duct_area = { HPXML::DuctTypeSupply => 0.0,
                                      HPXML::DuctTypeReturn => 0.0 }
    hvac_distribution.ducts.each do |ducts|
      next if [HPXML::LocationLivingSpace, HPXML::LocationBasementConditioned].include? ducts.duct_location
      next if ducts.duct_type.nil?

      # Calculate total duct area in unconditioned spaces
      total_unconditioned_duct_area[ducts.duct_type] += ducts.duct_surface_area
    end

    # Create duct objects
    hvac_distribution.ducts.each do |ducts|
      next if [HPXML::LocationLivingSpace, HPXML::LocationBasementConditioned].include? ducts.duct_location
      next if ducts.duct_type.nil?
      next if total_unconditioned_duct_area[ducts.duct_type] <= 0

      duct_loc_space, duct_loc_schedule = get_space_or_schedule_from_location(ducts.duct_location, 'Duct', model, spaces)

      # Apportion leakage to individual ducts by surface area
      duct_leakage_value = leakage_to_outside[ducts.duct_type][0] * ducts.duct_surface_area / total_unconditioned_duct_area[ducts.duct_type]
      duct_leakage_units = leakage_to_outside[ducts.duct_type][1]

      duct_leakage_cfm = nil
      duct_leakage_frac = nil
      if duct_leakage_units == HPXML::UnitsCFM25
        duct_leakage_cfm = duct_leakage_value
      elsif duct_leakage_units == HPXML::UnitsPercent
        duct_leakage_frac = duct_leakage_value
      else
        fail "#{ducts.duct_type.capitalize} ducts exist but leakage was not specified for distribution system '#{hvac_distribution.id}'."
      end

      air_ducts << Duct.new(ducts.duct_type, duct_loc_space, duct_loc_schedule, duct_leakage_frac, duct_leakage_cfm, ducts.duct_surface_area, ducts.duct_insulation_r_value)
    end

    # If all ducts are in conditioned space, model leakage as going to outside
    [HPXML::DuctTypeSupply, HPXML::DuctTypeReturn].each do |duct_side|
      next unless (leakage_to_outside[duct_side][0] > 0) && (total_unconditioned_duct_area[duct_side] == 0)

      duct_area = 0.0
      duct_rvalue = 0.0
      duct_loc_space = nil # outside
      duct_loc_schedule = nil # outside
      duct_leakage_value = leakage_to_outside[duct_side][0]
      duct_leakage_units = leakage_to_outside[duct_side][1]

      duct_leakage_cfm = nil
      duct_leakage_frac = nil
      if duct_leakage_units == HPXML::UnitsCFM25
        duct_leakage_cfm = duct_leakage_value
      elsif duct_leakage_units == HPXML::UnitsPercent
        duct_leakage_frac = duct_leakage_value
      else
        fail "#{duct_side.capitalize} ducts exist but leakage was not specified for distribution system '#{hvac_distribution.id}'."
      end

      air_ducts << Duct.new(duct_side, duct_loc_space, duct_loc_schedule, duct_leakage_frac, duct_leakage_cfm, duct_area, duct_rvalue)
    end

    return air_ducts
  end

  def self.add_photovoltaics(runner, model)
    @hpxml.pv_systems.each do |pv_system|
      PV.apply(model, @nbeds, pv_system)
    end
  end

  def self.add_generators(runner, model)
    @hpxml.generators.each do |generator|
      Generator.apply(model, @nbeds, generator)
    end
  end

  def self.add_additional_properties(runner, model, hpxml_path, building_id)
    # Store some data for use in reporting measure
    additionalProperties = model.getBuilding.additionalProperties
    additionalProperties.setFeature('hpxml_path', hpxml_path)
    additionalProperties.setFeature('building_id', building_id.to_s)
    additionalProperties.setFeature('hvac_map', map_to_string(@hvac_map))
    additionalProperties.setFeature('dhw_map', map_to_string(@dhw_map))
  end

  def self.map_to_string(map)
    map_str = {}
    map.each do |sys_id, objects|
      object_name_list = []
      objects.uniq.each do |object|
        object_name_list << object.name.to_s
      end
      map_str[sys_id] = object_name_list if object_name_list.size > 0
    end
    return map_str.to_s
  end

  def self.add_component_loads_output(runner, model, spaces)
    living_zone = spaces[HPXML::LocationLivingSpace].thermalZone.get

    # Prevent certain objects (e.g., OtherEquipment) from being counted towards both, e.g., ducts and internal gains
    objects_already_processed = []

    # EMS Sensors: Global

    liv_load_sensors = {}

    liv_load_sensors[:htg] = OpenStudio::Model::EnergyManagementSystemSensor.new(model, "Heating:EnergyTransfer:Zone:#{living_zone.name.to_s.upcase}")
    liv_load_sensors[:htg].setName('htg_load_liv')

    liv_load_sensors[:clg] = OpenStudio::Model::EnergyManagementSystemSensor.new(model, "Cooling:EnergyTransfer:Zone:#{living_zone.name.to_s.upcase}")
    liv_load_sensors[:clg].setName('clg_load_liv')

    tot_load_sensors = {}

    tot_load_sensors[:htg] = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Heating:EnergyTransfer')
    tot_load_sensors[:htg].setName('htg_load_tot')

    tot_load_sensors[:clg] = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Cooling:EnergyTransfer')
    tot_load_sensors[:clg].setName('clg_load_tot')

    load_adj_sensors = {} # Sensors used to adjust E+ EnergyTransfer meter, eg. dehumidifier as load in our program, but included in Heating:EnergyTransfer as HVAC equipment

    # EMS Sensors: Surfaces, SubSurfaces, InternalMass

    surfaces_sensors = { walls: [],
                         rim_joists: [],
                         foundation_walls: [],
                         floors: [],
                         slabs: [],
                         ceilings: [],
                         roofs: [],
                         windows: [],
                         doors: [],
                         skylights: [],
                         internal_mass: [] }

    # Output diagnostics needed for some output variables used below
    output_diagnostics = model.getOutputDiagnostics
    output_diagnostics.addKey('DisplayAdvancedReportVariables')

    area_tolerance = UnitConversions.convert(1.0, 'ft^2', 'm^2')

    model.getSurfaces.sort.each_with_index do |s, idx|
      next unless s.space.get.thermalZone.get.name.to_s == living_zone.name.to_s

      surface_type = s.additionalProperties.getFeatureAsString('SurfaceType')
      if not surface_type.is_initialized
        fail "Could not identify surface type for surface: '#{s.name}'."
      end

      surface_type = surface_type.get

      s.subSurfaces.each do |ss|
        key = { 'Window' => :windows,
                'Door' => :doors,
                'Skylight' => :skylights }[surface_type]
        fail "Unexpected subsurface for component loads: '#{ss.name}'." if key.nil?

        if (surface_type == 'Window') || (surface_type == 'Skylight')
          vars = { 'Surface Window Transmitted Solar Radiation Energy' => 'ss_trans_in',
                   'Surface Window Shortwave from Zone Back Out Window Heat Transfer Rate' => 'ss_back_out',
                   'Surface Window Total Glazing Layers Absorbed Shortwave Radiation Rate' => 'ss_sw_abs',
                   'Surface Window Total Glazing Layers Absorbed Solar Radiation Energy' => 'ss_sol_abs',
                   'Surface Inside Face Initial Transmitted Diffuse Transmitted Out Window Solar Radiation Rate' => 'ss_trans_out',
                   'Surface Inside Face Convection Heat Gain Energy' => 'ss_conv',
                   'Surface Inside Face Internal Gains Radiation Heat Gain Energy' => 'ss_ig',
                   'Surface Inside Face Net Surface Thermal Radiation Heat Gain Energy' => 'ss_surf' }
        else
          vars = { 'Surface Inside Face Solar Radiation Heat Gain Energy' => 'ss_sol',
                   'Surface Inside Face Lights Radiation Heat Gain Energy' => 'ss_lgt',
                   'Surface Inside Face Convection Heat Gain Energy' => 'ss_conv',
                   'Surface Inside Face Internal Gains Radiation Heat Gain Energy' => 'ss_ig',
                   'Surface Inside Face Net Surface Thermal Radiation Heat Gain Energy' => 'ss_surf' }
        end

        surfaces_sensors[key] << []
        vars.each do |var, name|
          sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
          sensor.setName(name)
          sensor.setKeyName(ss.name.to_s)
          surfaces_sensors[key][-1] << sensor
        end
      end

      next if s.netArea < area_tolerance # Skip parent surfaces (of subsurfaces) that have near zero net area

      key = { 'FoundationWall' => :foundation_walls,
              'RimJoist' => :rim_joists,
              'Wall' => :walls,
              'Slab' => :slabs,
              'Floor' => :floors,
              'Ceiling' => :ceilings,
              'Roof' => :roofs,
              'InferredCeiling' => :internal_mass,
              'InferredFloor' => :internal_mass }[surface_type]
      fail "Unexpected surface for component loads: '#{s.name}'." if key.nil?

      surfaces_sensors[key] << []
      { 'Surface Inside Face Convection Heat Gain Energy' => 's_conv',
        'Surface Inside Face Internal Gains Radiation Heat Gain Energy' => 's_ig',
        'Surface Inside Face Solar Radiation Heat Gain Energy' => 's_sol',
        'Surface Inside Face Lights Radiation Heat Gain Energy' => 's_lgt',
        'Surface Inside Face Net Surface Thermal Radiation Heat Gain Energy' => 's_surf' }.each do |var, name|
        sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        sensor.setName(name)
        sensor.setKeyName(s.name.to_s)
        surfaces_sensors[key][-1] << sensor
      end
    end

    model.getInternalMasss.sort.each do |m|
      next unless m.space.get.thermalZone.get.name.to_s == living_zone.name.to_s

      surfaces_sensors[:internal_mass] << []
      { 'Surface Inside Face Convection Heat Gain Energy' => 'im_conv',
        'Surface Inside Face Internal Gains Radiation Heat Gain Energy' => 'im_ig',
        'Surface Inside Face Solar Radiation Heat Gain Energy' => 'im_sol',
        'Surface Inside Face Lights Radiation Heat Gain Energy' => 'im_lgt',
        'Surface Inside Face Net Surface Thermal Radiation Heat Gain Energy' => 'im_surf' }.each do |var, name|
        sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        sensor.setName(name)
        sensor.setKeyName(m.name.to_s)
        surfaces_sensors[:internal_mass][-1] << sensor
      end
    end

    # EMS Sensors: Infiltration, Mechanical Ventilation, Natural Ventilation, Whole House Fan

    air_gain_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Zone Infiltration Sensible Heat Gain Energy')
    air_gain_sensor.setName('airflow_gain')
    air_gain_sensor.setKeyName(living_zone.name.to_s)

    air_loss_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Zone Infiltration Sensible Heat Loss Energy')
    air_loss_sensor.setName('airflow_loss')
    air_loss_sensor.setKeyName(living_zone.name.to_s)

    mechvent_sensors = []
    model.getElectricEquipments.sort.each do |o|
      next unless o.name.to_s.start_with? Constants.ObjectNameMechanicalVentilation

      { 'Electric Equipment Convective Heating Energy' => 'mv_conv',
        'Electric Equipment Radiant Heating Energy' => 'mv_rad' }.each do |var, name|
        mechvent_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        mechvent_sensor.setName(name)
        mechvent_sensor.setKeyName(o.name.to_s)
        mechvent_sensors << mechvent_sensor
        objects_already_processed << o
      end
    end
    model.getOtherEquipments.sort.each do |o|
      next unless o.name.to_s.start_with? Constants.ObjectNameMechanicalVentilationHouseFan

      { 'Other Equipment Convective Heating Energy' => 'mv_conv',
        'Other Equipment Radiant Heating Energy' => 'mv_rad' }.each do |var, name|
        mechvent_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        mechvent_sensor.setName(name)
        mechvent_sensor.setKeyName(o.name.to_s)
        mechvent_sensors << mechvent_sensor
        objects_already_processed << o
      end
    end

    infil_flow_actuators = []
    natvent_flow_actuators = []
    whf_flow_actuators = []

    model.getEnergyManagementSystemActuators.each do |actuator|
      next unless (actuator.actuatedComponentType == 'Zone Infiltration') && (actuator.actuatedComponentControlType == 'Air Exchange Flow Rate')

      if actuator.name.to_s.start_with? Constants.ObjectNameInfiltration.gsub(' ', '_')
        infil_flow_actuators << actuator
      elsif actuator.name.to_s.start_with? Constants.ObjectNameNaturalVentilation.gsub(' ', '_')
        natvent_flow_actuators << actuator
      elsif actuator.name.to_s.start_with? Constants.ObjectNameWholeHouseFan.gsub(' ', '_')
        whf_flow_actuators << actuator
      end
    end
    if (infil_flow_actuators.size != 1) || (natvent_flow_actuators.size != 1) || (whf_flow_actuators.size != 1)
      fail 'Could not find actuator for component loads.'
    end

    infil_flow_actuator = infil_flow_actuators[0]
    natvent_flow_actuator = natvent_flow_actuators[0]
    whf_flow_actuator = whf_flow_actuators[0]

    # EMS Sensors: Ducts

    ducts_sensors = []
    ducts_mix_gain_sensor = nil
    ducts_mix_loss_sensor = nil

    has_duct_zone_mixing = false
    living_zone.airLoopHVACs.sort.each do |airloop|
      living_zone.zoneMixing.each do |zone_mix|
        next unless zone_mix.name.to_s.start_with? airloop.name.to_s.gsub(' ', '_')

        has_duct_zone_mixing = true
      end
    end

    if has_duct_zone_mixing
      ducts_mix_gain_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Zone Mixing Sensible Heat Gain Energy')
      ducts_mix_gain_sensor.setName('duct_mix_gain')
      ducts_mix_gain_sensor.setKeyName(living_zone.name.to_s)

      ducts_mix_loss_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Zone Mixing Sensible Heat Loss Energy')
      ducts_mix_loss_sensor.setName('duct_mix_loss')
      ducts_mix_loss_sensor.setKeyName(living_zone.name.to_s)
    end

    # Return duct losses
    model.getOtherEquipments.sort.each do |o|
      next if objects_already_processed.include? o

      is_duct_load = o.additionalProperties.getFeatureAsBoolean(Constants.IsDuctLoadForReport)
      next unless is_duct_load.is_initialized

      objects_already_processed << o
      next unless is_duct_load.get

      ducts_sensors << []
      { 'Other Equipment Convective Heating Energy' => 'ducts_conv',
        'Other Equipment Radiant Heating Energy' => 'ducts_rad' }.each do |var, name|
        ducts_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        ducts_sensor.setName(name)
        ducts_sensor.setKeyName(o.name.to_s)
        ducts_sensors[-1] << ducts_sensor
      end
    end

    # Supply duct losses
    model.getOtherEquipments.sort.each do |o|
      next if objects_already_processed.include? o

      is_duct_load = o.additionalProperties.getFeatureAsBoolean(Constants.IsDuctLoadForReport)
      next unless is_duct_load.is_initialized

      objects_already_processed << o
      next unless is_duct_load.get

      ducts_sensors << []
      { 'Other Equipment Convective Heating Energy' => 'ducts_conv',
        'Other Equipment Radiant Heating Energy' => 'ducts_rad' }.each do |var, name|
        ducts_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        ducts_sensor.setName(name)
        ducts_sensor.setKeyName(o.name.to_s)
        ducts_sensors[-1] << ducts_sensor
      end
    end

    # EMS Sensors: Internal Gains

    intgains_sensors = []

    model.getElectricEquipments.sort.each do |o|
      next unless o.space.get.thermalZone.get.name.to_s == living_zone.name.to_s
      next if objects_already_processed.include? o

      intgains_sensors << []
      { 'Electric Equipment Convective Heating Energy' => 'ig_ee_conv',
        'Electric Equipment Radiant Heating Energy' => 'ig_ee_rad' }.each do |var, name|
        intgains_elec_equip_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgains_elec_equip_sensor.setName(name)
        intgains_elec_equip_sensor.setKeyName(o.name.to_s)
        intgains_sensors[-1] << intgains_elec_equip_sensor
      end
    end

    model.getGasEquipments.sort.each do |o|
      next unless o.space.get.thermalZone.get.name.to_s == living_zone.name.to_s
      next if objects_already_processed.include? o

      intgains_sensors << []
      { 'Gas Equipment Convective Heating Energy' => 'ig_ge_conv',
        'Gas Equipment Radiant Heating Energy' => 'ig_ge_rad' }.each do |var, name|
        intgains_gas_equip_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgains_gas_equip_sensor.setName(name)
        intgains_gas_equip_sensor.setKeyName(o.name.to_s)
        intgains_sensors[-1] << intgains_gas_equip_sensor
      end
    end

    model.getOtherEquipments.sort.each do |o|
      next unless o.space.get.thermalZone.get.name.to_s == living_zone.name.to_s
      next if objects_already_processed.include? o

      intgains_sensors << []
      { 'Other Equipment Convective Heating Energy' => 'ig_oe_conv',
        'Other Equipment Radiant Heating Energy' => 'ig_oe_rad' }.each do |var, name|
        intgains_other_equip_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgains_other_equip_sensor.setName(name)
        intgains_other_equip_sensor.setKeyName(o.name.to_s)
        intgains_sensors[-1] << intgains_other_equip_sensor
      end
    end

    model.getLightss.sort.each do |e|
      next unless e.space.get.thermalZone.get.name.to_s == living_zone.name.to_s

      intgains_sensors << []
      { 'Lights Convective Heating Energy' => 'ig_lgt_conv',
        'Lights Radiant Heating Energy' => 'ig_lgt_rad',
        'Lights Visible Radiation Heating Energy' => 'ig_lgt_vis' }.each do |var, name|
        intgains_lights_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgains_lights_sensor.setName(name)
        intgains_lights_sensor.setKeyName(e.name.to_s)
        intgains_sensors[-1] << intgains_lights_sensor
      end
    end

    model.getPeoples.sort.each do |e|
      next unless e.space.get.thermalZone.get.name.to_s == living_zone.name.to_s

      intgains_sensors << []
      { 'People Convective Heating Energy' => 'ig_ppl_conv',
        'People Radiant Heating Energy' => 'ig_ppl_rad' }.each do |var, name|
        intgains_people = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgains_people.setName(name)
        intgains_people.setKeyName(e.name.to_s)
        intgains_sensors[-1] << intgains_people
      end
    end

    model.getZoneHVACDehumidifierDXs.each do |e|
      next unless e.thermalZone.get.name.to_s == living_zone.name.to_s

      intgains_sensors << []
      { 'Zone Dehumidifier Sensible Heating Energy' => 'ig_dehumidifier' }.each do |var, name|
        intgain_dehumidifier = OpenStudio::Model::EnergyManagementSystemSensor.new(model, var)
        intgain_dehumidifier.setName(name)
        intgain_dehumidifier.setKeyName(e.name.to_s)
        load_adj_sensors[:dehumidifier] = intgain_dehumidifier
        intgains_sensors[-1] << intgain_dehumidifier
      end
    end

    intgains_dhw_sensors = {}

    (model.getWaterHeaterMixeds + model.getWaterHeaterStratifieds).sort.each do |wh|
      next unless wh.ambientTemperatureThermalZone.is_initialized
      next unless wh.ambientTemperatureThermalZone.get.name.to_s == living_zone.name.to_s

      dhw_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Water Heater Heat Loss Energy')
      dhw_sensor.setName('dhw_loss')
      dhw_sensor.setKeyName(wh.name.to_s)

      if wh.is_a? OpenStudio::Model::WaterHeaterMixed
        oncycle_loss = wh.onCycleLossFractiontoThermalZone
        offcycle_loss = wh.offCycleLossFractiontoThermalZone
      else
        oncycle_loss = wh.skinLossFractiontoZone
        offcycle_loss = wh.offCycleFlueLossFractiontoZone
      end

      dhw_rtf_sensor = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Water Heater Runtime Fraction')
      dhw_rtf_sensor.setName('dhw_rtf')
      dhw_rtf_sensor.setKeyName(wh.name.to_s)

      intgains_dhw_sensors[dhw_sensor] = [offcycle_loss, oncycle_loss, dhw_rtf_sensor]
    end

    nonsurf_names = ['intgains', 'infil', 'mechvent', 'natvent', 'whf', 'ducts']

    # EMS program
    program = OpenStudio::Model::EnergyManagementSystemProgram.new(model)
    program.setName(Constants.ObjectNameComponentLoadsProgram)

    # EMS program: Surfaces
    surfaces_sensors.each do |k, surface_sensors|
      program.addLine("Set hr_#{k} = 0")
      surface_sensors.each do |sensors|
        s = "Set hr_#{k} = hr_#{k}"
        sensors.each do |sensor|
          # remove ss_net if switch
          if sensor.name.to_s.start_with?('ss_net', 'ss_sol_abs', 'ss_trans_in')
            s += " - #{sensor.name}"
          elsif sensor.name.to_s.start_with?('ss_sw_abs', 'ss_trans_out', 'ss_back_out')
            s += " + #{sensor.name} * ZoneTimestep * 3600"
          else
            s += " + #{sensor.name}"
          end
        end
        program.addLine(s) if sensors.size > 0
      end
    end

    # EMS program: Internal gains
    program.addLine('Set hr_intgains = 0')
    intgains_sensors.each do |intgain_sensors|
      s = 'Set hr_intgains = hr_intgains'
      intgain_sensors.each do |sensor|
        s += " - #{sensor.name}"
      end
      program.addLine(s) if intgain_sensors.size > 0
    end
    intgains_dhw_sensors.each do |sensor, vals|
      off_loss, on_loss, rtf_sensor = vals
      program.addLine("Set hr_intgains = hr_intgains + #{sensor.name} * (#{off_loss}*(1-#{rtf_sensor.name}) + #{on_loss}*#{rtf_sensor.name})") # Water heater tank losses to zone
    end

    # EMS program: Infiltration, Natural Ventilation, Mechanical Ventilation, Ducts
    program.addLine("Set hr_airflow_rate = #{infil_flow_actuator.name} + #{natvent_flow_actuator.name} + #{whf_flow_actuator.name}")
    program.addLine('If hr_airflow_rate > 0')
    program.addLine("  Set hr_infil = (#{air_loss_sensor.name} - #{air_gain_sensor.name}) * #{infil_flow_actuator.name} / hr_airflow_rate") # Airflow heat attributed to infiltration
    program.addLine("  Set hr_natvent = (#{air_loss_sensor.name} - #{air_gain_sensor.name}) * #{natvent_flow_actuator.name} / hr_airflow_rate") # Airflow heat attributed to natural ventilation
    program.addLine("  Set hr_whf = (#{air_loss_sensor.name} - #{air_gain_sensor.name}) * #{whf_flow_actuator.name} / hr_airflow_rate") # Airflow heat attributed to whole house fan
    program.addLine('Else')
    program.addLine('  Set hr_infil = 0')
    program.addLine('  Set hr_natvent = 0')
    program.addLine('  Set hr_whf = 0')
    program.addLine('  Set hr_mechvent = 0')
    program.addLine('EndIf')
    program.addLine('Set hr_mechvent = 0')
    mechvent_sensors.each do |sensor|
      program.addLine("Set hr_mechvent = hr_mechvent - #{sensor.name}")
    end
    program.addLine('Set hr_ducts = 0')
    ducts_sensors.each do |duct_sensors|
      s = 'Set hr_ducts = hr_ducts'
      duct_sensors.each do |sensor|
        s += " - #{sensor.name}"
      end
      program.addLine(s) if duct_sensors.size > 0
    end
    if (not ducts_mix_loss_sensor.nil?) && (not ducts_mix_gain_sensor.nil?)
      program.addLine("Set hr_ducts = hr_ducts + (#{ducts_mix_loss_sensor.name} - #{ducts_mix_gain_sensor.name})")
    end

    # EMS program: Heating vs Cooling logic
    program.addLine('Set htg_mode = 0')
    program.addLine('Set clg_mode = 0')
    program.addLine("If (#{liv_load_sensors[:htg].name} > 0)") # Assign hour to heating if heating load
    program.addLine('  Set htg_mode = 1')
    program.addLine("ElseIf (#{liv_load_sensors[:clg].name} > 0)") # Assign hour to cooling if cooling load
    program.addLine('  Set clg_mode = 1')
    program.addLine("ElseIf (#{@clg_ssn_sensor.name} > 0)") # No load, assign hour to cooling if in cooling season definition (Note: natural ventilation & whole house fan only operate during the cooling season)
    program.addLine('  Set clg_mode = 1')
    program.addLine('Else') # No load, assign hour to heating if not in cooling season definition
    program.addLine('  Set htg_mode = 1')
    program.addLine('EndIf')

    [:htg, :clg].each do |mode|
      if mode == :htg
        sign = ''
      else
        sign = '-'
      end
      surfaces_sensors.keys.each do |k|
        program.addLine("Set loads_#{mode}_#{k} = #{sign}hr_#{k} * #{mode}_mode")
      end
      nonsurf_names.each do |nonsurf_name|
        program.addLine("Set loads_#{mode}_#{nonsurf_name} = #{sign}hr_#{nonsurf_name} * #{mode}_mode")
      end
    end

    # EMS program: Total loads
    program.addLine('Set loads_htg_tot = 0')
    program.addLine('Set loads_clg_tot = 0')
    program.addLine("If #{liv_load_sensors[:htg].name} > 0")
    s = "  Set loads_htg_tot = #{tot_load_sensors[:htg].name} - #{tot_load_sensors[:clg].name}"
    load_adj_sensors.each do |key, adj_sensor|
      if ['dehumidifier'].include? key.to_s
        s += " - #{adj_sensor.name}"
      end
    end
    program.addLine(s)
    program.addLine("ElseIf #{liv_load_sensors[:clg].name} > 0")
    s = "  Set loads_clg_tot = #{tot_load_sensors[:clg].name} - #{tot_load_sensors[:htg].name}"
    load_adj_sensors.each do |key, adj_sensor|
      if ['dehumidifier'].include? key.to_s
        s += " + #{adj_sensor.name}"
      end
    end
    program.addLine(s)
    program.addLine('EndIf')

    # EMS calling manager
    program_calling_manager = OpenStudio::Model::EnergyManagementSystemProgramCallingManager.new(model)
    program_calling_manager.setName("#{program.name} calling manager")
    program_calling_manager.setCallingPoint('EndOfZoneTimestepAfterZoneReporting')
    program_calling_manager.addProgram(program)
  end

  def self.add_output_control_files(runner, model)
    return if @debug

    # Disable various output files
    ocf = model.getOutputControlFiles
    ocf.setOutputAUDIT(false)
    ocf.setOutputBND(false)
    ocf.setOutputEIO(false)
    ocf.setOutputESO(false)
    ocf.setOutputMDD(false)
    ocf.setOutputMTD(false)
    ocf.setOutputMTR(false)
    ocf.setOutputRDD(false)
    ocf.setOutputSHD(false)
    ocf.setOutputTabular(false)
  end

  def self.add_ems_debug_output(runner, model)
    oems = model.getOutputEnergyManagementSystem
    oems.setActuatorAvailabilityDictionaryReporting('Verbose')
    oems.setInternalVariableAvailabilityDictionaryReporting('Verbose')
    oems.setEMSRuntimeLanguageDebugOutputLevel('Verbose')
  end

  def self.set_surface_interior(model, spaces, surface, hpxml_surface)
    interior_adjacent_to = hpxml_surface.interior_adjacent_to
    if [HPXML::LocationBasementConditioned].include? interior_adjacent_to
      surface.setSpace(create_or_get_space(model, spaces, HPXML::LocationLivingSpace))
      @cond_bsmnt_surfaces << surface
    else
      surface.setSpace(create_or_get_space(model, spaces, interior_adjacent_to))
    end
  end

  def self.set_surface_exterior(model, spaces, surface, hpxml_surface)
    exterior_adjacent_to = hpxml_surface.exterior_adjacent_to
    interior_adjacent_to = hpxml_surface.interior_adjacent_to
    is_adiabatic = hpxml_surface.is_adiabatic
    if exterior_adjacent_to == HPXML::LocationOutside
      surface.setOutsideBoundaryCondition('Outdoors')
    elsif exterior_adjacent_to == HPXML::LocationGround
      surface.setOutsideBoundaryCondition('Foundation')
    elsif is_adiabatic
      surface.setOutsideBoundaryCondition('Adiabatic')
    elsif [HPXML::LocationOtherHeatedSpace, HPXML::LocationOtherMultifamilyBufferSpace,
           HPXML::LocationOtherNonFreezingSpace, HPXML::LocationOtherHousingUnit].include? exterior_adjacent_to
      set_surface_otherside_coefficients(surface, exterior_adjacent_to, model, spaces)
    elsif exterior_adjacent_to == HPXML::LocationBasementConditioned
      surface.createAdjacentSurface(create_or_get_space(model, spaces, HPXML::LocationLivingSpace))
      @cond_bsmnt_surfaces << surface.adjacentSurface.get
    else
      surface.createAdjacentSurface(create_or_get_space(model, spaces, exterior_adjacent_to))
    end
  end

  def self.set_surface_otherside_coefficients(surface, exterior_adjacent_to, model, spaces)
    if spaces[exterior_adjacent_to].nil?
      # Create E+ other side coefficient object
      otherside_object = OpenStudio::Model::SurfacePropertyOtherSideCoefficients.new(model)
      otherside_object.setName(exterior_adjacent_to)
      otherside_object.setCombinedConvectiveRadiativeFilmCoefficient(UnitConversions.convert(1.0 / Material.AirFilmVertical.rvalue, 'Btu/(hr*ft^2*F)', 'W/(m^2*K)'))
      # Schedule of space temperature, can be shared with water heater/ducts
      sch = get_space_temperature_schedule(model, exterior_adjacent_to, spaces)
      otherside_object.setConstantTemperatureSchedule(sch)
      surface.setSurfacePropertyOtherSideCoefficients(otherside_object)
      spaces[exterior_adjacent_to] = otherside_object
    else
      surface.setSurfacePropertyOtherSideCoefficients(spaces[exterior_adjacent_to])
    end
    surface.setSunExposure('NoSun')
    surface.setWindExposure('NoWind')
  end

  def self.get_space_temperature_schedule(model, location, spaces)
    # Create outside boundary schedules to be actuated by EMS,
    # can be shared by any surface, duct adjacent to / located in those spaces

    # return if already exists
    model.getScheduleConstants.each do |sch|
      next unless sch.name.to_s == location

      return sch
    end

    sch = OpenStudio::Model::ScheduleConstant.new(model)
    sch.setName(location)

    space_values = Geometry.get_temperature_scheduled_space_values(location)

    if location == HPXML::LocationOtherHeatedSpace
      # Create a sensor to get dynamic heating setpoint
      htg_sch = spaces[HPXML::LocationLivingSpace].thermalZone.get.thermostatSetpointDualSetpoint.get.heatingSetpointTemperatureSchedule.get
      sensor_htg_spt = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Schedule Value')
      sensor_htg_spt.setName('htg_spt')
      sensor_htg_spt.setKeyName(htg_sch.name.to_s)
      space_values[:temp_min] = sensor_htg_spt.name.to_s
    end

    # Schedule type limits compatible
    schedule_type_limits = OpenStudio::Model::ScheduleTypeLimits.new(model)
    schedule_type_limits.setUnitType('Temperature')
    sch.setScheduleTypeLimits(schedule_type_limits)

    # Sensors
    if space_values[:indoor_weight] > 0
      sensor_ia = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Zone Air Temperature')
      sensor_ia.setName('cond_zone_temp')
      sensor_ia.setKeyName(spaces[HPXML::LocationLivingSpace].name.to_s)
    end

    if space_values[:outdoor_weight] > 0
      sensor_oa = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Site Outdoor Air Drybulb Temperature')
      sensor_oa.setName('oa_temp')
    end

    if space_values[:ground_weight] > 0
      sensor_gnd = OpenStudio::Model::EnergyManagementSystemSensor.new(model, 'Site Surface Ground Temperature')
      sensor_gnd.setName('ground_temp')
    end

    actuator = OpenStudio::Model::EnergyManagementSystemActuator.new(sch, *EPlus::EMSActuatorScheduleConstantValue)
    actuator.setName("#{location.gsub(' ', '_').gsub('-', '_')}_temp_sch")

    # EMS to actuate schedule
    program = OpenStudio::Model::EnergyManagementSystemProgram.new(model)
    program.setName("#{location.gsub('-', '_')} Temperature Program")
    program.addLine("Set #{actuator.name} = 0.0")
    if not sensor_ia.nil?
      program.addLine("Set #{actuator.name} = #{actuator.name} + (#{sensor_ia.name} * #{space_values[:indoor_weight]})")
    end
    if not sensor_oa.nil?
      program.addLine("Set #{actuator.name} = #{actuator.name} + (#{sensor_oa.name} * #{space_values[:outdoor_weight]})")
    end
    if not sensor_gnd.nil?
      program.addLine("Set #{actuator.name} = #{actuator.name} + (#{sensor_gnd.name} * #{space_values[:ground_weight]})")
    end
    if not space_values[:temp_min].nil?
      if space_values[:temp_min].is_a? String
        min_temp_c = space_values[:temp_min]
      else
        min_temp_c = UnitConversions.convert(space_values[:temp_min], 'F', 'C')
      end
      program.addLine("If #{actuator.name} < #{min_temp_c}")
      program.addLine("Set #{actuator.name} = #{min_temp_c}")
      program.addLine('EndIf')
    end

    program_cm = OpenStudio::Model::EnergyManagementSystemProgramCallingManager.new(model)
    program_cm.setName("#{program.name} calling manager")
    program_cm.setCallingPoint('EndOfSystemTimestepAfterHVACReporting')
    program_cm.addProgram(program)

    return sch
  end

  # Returns an OS:Space, or temperature OS:Schedule for a MF space, or nil if outside
  # Should be called when the object's energy use is sensitive to ambient temperature
  # (e.g., water heaters and ducts).
  def self.get_space_or_schedule_from_location(location, object_name, model, spaces)
    return if [HPXML::LocationOtherExterior, HPXML::LocationOutside, HPXML::LocationRoofDeck].include? location

    sch = nil
    space = nil
    if [HPXML::LocationOtherHeatedSpace, HPXML::LocationOtherHousingUnit, HPXML::LocationOtherMultifamilyBufferSpace,
        HPXML::LocationOtherNonFreezingSpace, HPXML::LocationExteriorWall, HPXML::LocationUnderSlab].include? location
      # if located in spaces where we don't model a thermal zone, create and return temperature schedule
      sch = get_space_temperature_schedule(model, location, spaces)
    else
      space = get_space_from_location(location, object_name, model, spaces)
    end

    return space, sch
  end

  # Returns an OS:Space, or nil if a MF space
  # Should be called when the object's energy use is NOT sensitive to ambient temperature
  # (e.g., appliances).
  def self.get_space_from_location(location, object_name, model, spaces)
    return if [HPXML::LocationOtherHeatedSpace,
               HPXML::LocationOtherHousingUnit,
               HPXML::LocationOtherMultifamilyBufferSpace,
               HPXML::LocationOtherNonFreezingSpace].include? location

    num_orig_spaces = spaces.size

    if location == HPXML::LocationBasementConditioned
      space = create_or_get_space(model, spaces, HPXML::LocationLivingSpace)
    else
      space = create_or_get_space(model, spaces, location)
    end

    fail if spaces.size != num_orig_spaces # EPvalidator.xml should prevent this

    return space
  end

  def self.set_subsurface_exterior(surface, spaces, model, hpxml_surface)
    # Set its parent surface outside boundary condition, which will be also applied to subsurfaces through OS
    # The parent surface is entirely comprised of the subsurface.

    # Subsurface on foundation wall, set it to be adjacent to outdoors
    if hpxml_surface.exterior_adjacent_to == HPXML::LocationGround
      surface.setOutsideBoundaryCondition('Outdoors')
    else
      set_surface_exterior(model, spaces, surface, hpxml_surface)
    end
  end

  def self.get_kiva_instances(fnd_walls, slabs)
    # Identify unique Kiva foundations that are required.
    kiva_fnd_walls = []
    fnd_walls.each do |foundation_wall|
      next unless foundation_wall.is_exterior

      kiva_fnd_walls << foundation_wall
    end
    if kiva_fnd_walls.empty? # Handle slab foundation type
      kiva_fnd_walls << nil
    end

    kiva_slabs = slabs

    return kiva_fnd_walls.product(kiva_slabs)
  end

  def self.set_foundation_and_walls_top()
    @foundation_top = 0
    @hpxml.foundation_walls.each do |foundation_wall|
      top = -1 * foundation_wall.depth_below_grade + foundation_wall.height
      @foundation_top = top if top > @foundation_top
    end
    @walls_top = @foundation_top + 8.0 * @ncfl_ag
  end
end

# register the measure to be used by the application
HPXMLtoOpenStudio.new.registerWithApplication