Module: URBANopt::CLI

Defined in:
lib/uo_cli.rb,
lib/uo_cli/version.rb

Defined Under Namespace

Classes: UrbanOptCLI

Constant Summary collapse

UV_TOOL_GROUPS =

Tool groups expected in [dependency-groups] in python_deps/pyproject.toml. Not the package groups, just the group names from pyrpoject.toml

[
  'disco',
  'ditto-reader',
  'thermalnetwork',
  'urbanopt-des',
  'usg'
].freeze
UV_PYTHON_VERSION_FALLBACK =

Fallback Python version when requires-python cannot be parsed.

'3.10'.freeze
'0.11.6'.freeze
UV_INSTALL_URL =
'https://docs.astral.sh/uv/getting-started/installation/'.freeze
UV_INSTALL_MESSAGE =
"\nERROR: uv is not installed or not on your PATH.\n" \
"Please install uv (recommended version #{UV_RECOMMENDED_VERSION} or later): #{UV_INSTALL_URL}\n".freeze
VERSION =
'1.3.0'.freeze

Class Method Summary collapse

Class Method Details

.build_reopt_assumptions(root_dir, scenario_name, scenario_path, feature_path, scenario_assumptions_default, subopts) ⇒ Object

Handles capital costs, fuel loads, boiler config, and community PV



1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
# File 'lib/uo_cli.rb', line 1766

def self.build_reopt_assumptions(root_dir, scenario_name, scenario_path, feature_path, scenario_assumptions_default, subopts)
  # Retrieve assumptions hash for modifications
  assumptions_hash = JSON.parse(File.read(File.expand_path(scenario_assumptions_default)), symbolize_names: true)
  
  # Parse feature file to find community photovoltaic systems
  community_photovoltaic = []
  feature_file = JSON.parse(File.read(File.expand_path(feature_path)), symbolize_names: true)
  feature_file[:features].each do |feature|
    if feature[:properties][:district_system_type] && (feature[:properties][:district_system_type] == 'Community Photovoltaic')
      community_photovoltaic << feature
    end
  rescue StandardError => e
    puts "\nERROR: #{e.message}"
  end

  # Configure Capital Costs Processing (retrieve from scenario CSV if they exist)
  scenario_file = CSV.read(File.expand_path(scenario_path), headers: true, header_converters: :symbol)
  required_columns = [:total_capital_costs, :capital_cost_per_floor_area_sqft]
  has_capital_cost_data = false
  if (scenario_file.headers & required_columns).any?
    has_capital_cost_data = true
    puts "\nINFO: Capital cost data found in ScenarioFile. Preparing wind capital costs for REopt Analysis...\n"

    has_total_costs = scenario_file.headers.include?(:total_capital_costs)
    has_cost_per_sqft = scenario_file.headers.include?(:capital_cost_per_floor_area_sqft)

    # total_costs takes precedence over cost_per_sqft
    if has_total_costs && !scenario_file.all? { |row| row[:total_capital_costs].nil? }
      puts "\nINFO: Using 'Total Capital Costs ($)' column for REopt Cost Analysis.\n"
      # warn if default values but run anyway
      if scenario_file.all? { |row| row[:total_capital_costs].to_f == 100 }
        puts "\nWARNING: 'Total Capital Costs ($)' column in ScenarioFile still contains default values for all rows. You should update these values in the scenario file with realistic capital costs and rerun REopt optimization.\n"
      end
      total_sum = scenario_file.map { |row| row[:total_capital_costs].to_f }.sum
    elsif has_cost_per_sqft && !scenario_file.all? { |row| row[:capital_cost_per_floor_area_sqft].nil? }
      puts "\nINFO: Using 'Capital Cost Per Floor Area ($/sq.ft.)' column for REopt Cost Analysis.\n"
      # warn if default values but run anyway
      if scenario_file.all? { |row| row[:capital_cost_per_floor_area_sqft].to_f == 100 }
        puts "\nWARNING: 'Capital Cost Per Floor Area ($/sq.ft.)' column in ScenarioFile still contains default values for all rows. You should update these values in the scenario file with realistic capital costs and rerun REopt optimization.\n"
      end
      total_sum = 0
      scenario_file.each do |row|
        feature_id = row[:feature_id]
        cost_per_sqft = row[:capital_cost_per_floor_area_sqft].to_f
        feature = feature_file[:features].find { |f| f[:properties][:id] == feature_id }
        floor_area = feature[:properties][:floor_area].to_f
        total_sum += floor_area * cost_per_sqft
      end
    else
      # no cost data
      puts "\nWARNING: Both 'Total Capital Costs ($)' and 'Capital Cost Per Floor Area ($/sq.ft.)' have no data. Update these values in the scenario file with realistic capital costs and rerun REopt optimization.\n"
      total_sum = 0
    end
    
    # Configure Wind assumptions with capital costs
    assumptions_hash[:Wind] ||= {}
    assumptions_hash[:Wind][:min_kw] = total_sum
    assumptions_hash[:Wind][:max_kw] = total_sum
    puts "\nINFO: Total Wind Capital Cost for Scenario set to min_kw: $#{assumptions_hash[:Wind][:min_kw]}, max_kw: $#{assumptions_hash[:Wind][:max_kw]} for REopt Analysis.\n"
    assumptions_hash[:Wind][:installed_cost_us_dollars_per_kw] = 1
    assumptions_hash[:Wind][:acres_per_kw] = assumptions_hash[:Wind][:acres_per_kw].nil? ? 0.0000000000001 : assumptions_hash[:Wind][:acres_per_kw]
    assumptions_hash[:Wind][:installed_cost_per_kw] = 1
    assumptions_hash[:Wind][:macrs_option_years] = 0
    assumptions_hash[:Wind][:macrs_bonus_fraction] = 0
    assumptions_hash[:Wind][:federal_itc_fraction] = 0
    assumptions_hash[:Wind][:production_factor_series] = Array.new(8760, 0)
  end

  # Keep legacy behavior: boiler checks and space-heating timeseries are only
  # applied when capital-cost mode is active.
  if has_capital_cost_data
    # Validate and log boiler assumptions
    boiler_assumptions = assumptions_hash[:ExistingBoiler] || assumptions_hash['ExistingBoiler']
    if boiler_assumptions.nil?
      puts "[WARN] ExistingBoiler assumptions not found. Available keys: #{assumptions_hash.keys.inspect}"
    else
      fuel_cost = boiler_assumptions[:fuel_cost_per_mmbtu] || boiler_assumptions['fuel_cost_per_mmbtu']
      if fuel_cost.nil?
        puts "WARNING: There is no 'ExistingBoiler.fuel_cost_per_mmbtu' value in the assumptions file."
      elsif fuel_cost == 11.5
        puts "WARNING: The 'fuel_cost_per_mmbtu' under 'ExistingBoiler' is still set to the default value of $11.5/MMBtu. Please update this value with a site-specific fuel cost."
      else
        puts "INFO: Using ExistingBoiler fuel cost of #{fuel_cost} $/MMBtu."
      end
    end

    # Add timeseries fuel consumption data if default report exists
    scenario_csv_path = File.join(root_dir, 'run', scenario_name.downcase, 'default_scenario_report.csv')
    if File.exist?(scenario_csv_path)
      assumptions_hash[:SpaceHeatingLoad] ||= {}
      assumptions_hash[:SpaceHeatingLoad][:fuel_loads_mmbtu_per_hour] ||= []
      scenario_csv = CSV.read(scenario_csv_path, headers: true)
      column_name = 'NaturalGas:Facility(kBtu)'

      if scenario_csv.headers.include?(column_name)
        scenario_csv.each do |row|
          kbtu_value = row[column_name].to_f
          mmbtu_value = kbtu_value / 1000.0
          if assumptions_hash[:SpaceHeatingLoad][:fuel_loads_mmbtu_per_hour].is_a?(Array)
            assumptions_hash[:SpaceHeatingLoad][:fuel_loads_mmbtu_per_hour] << mmbtu_value
          end
        end
      end
    end
  end

  { assumptions_hash: assumptions_hash, community_photovoltaic: community_photovoltaic }
end

.check_uvObject

Check that uv is available on the system PATH



1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
# File 'lib/uo_cli.rb', line 1395

def self.check_uv
  puts 'Checking for uv...'
  stdout, stderr, status = Open3.capture3('uv', '--version')
  if status.success?
    puts "...uv found: #{stdout.strip}"
    return true
  else
    puts UV_INSTALL_MESSAGE
    return false
  end
end

.create_project_folder(dir_name, empty_folder: false, overwrite_project: false) ⇒ Object

Create project folder params\

dir_name

string Name of new project folder

Includes weather for example location, a base workflow file, and mapper files to show a baseline and a high-efficiency option.



896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
# File 'lib/uo_cli.rb', line 896

def self.create_project_folder(dir_name, empty_folder: false, overwrite_project: false)
  project_path = Pathname(dir_name)
  case overwrite_project
  when true
    if Dir.exist?(project_path)
      FileUtils.rm_rf(project_path)
    end
  when false
    if Dir.exist?(project_path)
      abort("\nERROR:  there is already a directory at #{project_path}... aborting\n---\n\n")
    end
  end

  $LOAD_PATH.each do |path_item|
    if path_item.to_s.end_with?('example_files')
      example_files_dir = Pathname(path_item)

      case empty_folder
      when false

        project_path.mkdir
        project_path.join('weather').mkdir
        project_path.join('mappers').mkdir
        project_path.join('osm_building').mkdir
        project_path.join('visualization').mkdir
        if @opthash.subopts[:electric] == true || @opthash.subopts[:disco] == true
          # make opendss folder
          project_path.join('opendss').mkdir
          if @opthash.subopts[:disco] == true
            # make disco folder
            project_path.join('disco').mkdir
          end
        end

        # copy config file
        FileUtils.cp(example_files_dir / 'runner.conf', project_path)
        use_num_parallel(project_path)

        # if env variable for gemfile_path and bundle_install_path is set, open the runner.conf
        # and update the gemfile_path and bundle_install_path values
        if ENV['UO_GEMFILE_PATH'] || ENV['UO_BUNDLE_INSTALL_PATH']
          runner_file_path = project_path / 'runner.conf'
          runner_conf_hash = JSON.parse(File.read(runner_file_path))
          if ENV['UO_GEMFILE_PATH']
            runner_conf_hash['gemfile_path'] = ENV['UO_GEMFILE_PATH']
          end
          if ENV['UO_BUNDLE_INSTALL_PATH']
            runner_conf_hash['bundle_install_path'] = ENV['UO_BUNDLE_INSTALL_PATH']
          end
          File.open(runner_file_path, 'w+') do |f|
            f << JSON.pretty_generate(runner_conf_hash)
          end
        end

        # copy gemfile
        FileUtils.cp(example_files_dir / 'Gemfile', project_path)

        # Copy measures dir
        FileUtils.cp_r(example_files_dir / 'measures', project_path / 'measures')

        # copy validation schema
        FileUtils.cp(example_files_dir / 'validation_schema.yaml', project_path)

        # copy weather files
        weather_files = example_files_dir / 'weather'
        weather_files.children.each { |weather_file| FileUtils.cp(weather_file, project_path / 'weather') }

        # copy visualization files
        viz_files = example_files_dir / 'visualization'
        viz_files.children.each { |viz_file| FileUtils.cp(viz_file, project_path / 'visualization') }

        if @opthash.subopts[:electric] == true || @opthash.subopts[:disco] == true
          # also copy opendss files
          dss_files = example_files_dir / 'opendss'
          dss_files.children.each { |file| FileUtils.cp(file, project_path / 'opendss') }
          if @opthash.subopts[:electric] == true
            FileUtils.cp(example_files_dir / 'example_project_with_electric_network.json', project_path)
          elsif @opthash.subopts[:disco] == true
            # TODO: update this once there is a FeatureFile for Disco
            FileUtils.cp(example_files_dir / 'example_project_with_electric_network.json', project_path)
            disco_files = example_files_dir / 'disco'
            disco_files.children.each { |file| FileUtils.cp(file, project_path / 'disco') }
          end
        elsif @opthash.subopts[:ghe] == true
          FileUtils.cp(example_files_dir / 'example_project_with_ghe.json', project_path)
        elsif @opthash.subopts[:streets] == true
          FileUtils.cp(example_files_dir / 'example_project_with_streets.json', project_path)
        elsif @opthash.subopts[:photovoltaic] == true
          FileUtils.cp(example_files_dir / 'example_project_with_PV.json', project_path)
        end

        case @opthash.subopts[:floorspace]
        when false

          if @opthash.subopts[:electric] != true && @opthash.subopts[:streets] != true && @opthash.subopts[:photovoltaic] != true && @opthash.subopts[:disco] != true && @opthash.subopts[:ghe] != true
            # copy feature file
            FileUtils.cp(example_files_dir / 'example_project.json', project_path)
          end

          # copy osm
          FileUtils.cp(example_files_dir / 'osm_building' / '7.osm', project_path / 'osm_building')
          FileUtils.cp(example_files_dir / 'osm_building' / '8.osm', project_path / 'osm_building')
          FileUtils.cp(example_files_dir / 'osm_building' / '9.osm', project_path / 'osm_building')

          case @opthash.subopts[:create_bar]
          when false

            # copy the mappers
            FileUtils.cp(example_files_dir / 'mappers' / 'Baseline.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'HighEfficiency.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'ThermalStorage.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'EvCharging.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'FlexibleHotWater.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'ChilledWaterStorage.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'PeakHoursThermostatAdjust.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'PeakHoursMelsShedding.rb', project_path / 'mappers')

            # copy osw file
            FileUtils.cp(example_files_dir / 'mappers' / 'base_workflow.osw', project_path / 'mappers')

          when true

            # copy the mappers
            FileUtils.cp(example_files_dir / 'mappers' / 'CreateBar.rb', project_path / 'mappers')
            FileUtils.cp(example_files_dir / 'mappers' / 'HighEfficiencyCreateBar.rb', project_path / 'mappers')

            # copy osw file
            FileUtils.cp(example_files_dir / 'mappers' / 'createbar_workflow.osw', project_path / 'mappers')

          end

        when true

          # copy the mappers
          FileUtils.cp(example_files_dir / 'mappers' / 'Floorspace.rb', project_path / 'mappers')
          FileUtils.cp(example_files_dir / 'mappers' / 'HighEfficiencyFloorspace.rb', project_path / 'mappers')

          # copy osw file
          FileUtils.cp(example_files_dir / 'mappers' / 'floorspace_workflow.osw', project_path / 'mappers')

          # copy feature file
          FileUtils.cp(example_files_dir / 'example_floorspace_project.json', project_path)

          # copy osm
          FileUtils.cp(example_files_dir / 'osm_building' / '7_floorspace.json', project_path / 'osm_building')
          FileUtils.cp(example_files_dir / 'osm_building' / '7_floorspace.osm', project_path / 'osm_building')
          FileUtils.cp(example_files_dir / 'osm_building' / '8.osm', project_path / 'osm_building')
          FileUtils.cp(example_files_dir / 'osm_building' / '9.osm', project_path / 'osm_building')
        end

        if @opthash.subopts[:class_coincident]
          # copy residential files
          FileUtils.cp_r(example_files_dir / 'mappers' / 'residential', project_path / 'mappers' / 'residential')
          FileUtils.cp_r(example_files_dir / 'resources', project_path / 'resources')
          FileUtils.cp_r(example_files_dir / 'xml_building', project_path / 'xml_building')
          # copy class project files
          FileUtils.cp(example_files_dir / 'class_project_coincident.json', dir_name)
          FileUtils.cp(example_files_dir / 'mappers' / 'class_project_workflow.osw', project_path / 'mappers' / 'base_workflow.osw')
          FileUtils.cp(example_files_dir / 'mappers' / 'ClassProject.rb', project_path / 'mappers')

          if File.exist?(project_path / 'example_project.json')
            FileUtils.remove(project_path / 'example_project.json')
          end

        end

        if @opthash.subopts[:class_diverse]
          # copy residential files
          FileUtils.cp_r(example_files_dir / 'mappers' / 'residential', project_path / 'mappers' / 'residential')
          FileUtils.cp_r(example_files_dir / 'resources', project_path / 'resources')
          FileUtils.cp_r(example_files_dir / 'xml_building', project_path / 'xml_building')
          # copy class project files
          FileUtils.cp(example_files_dir / 'class_project_diverse.json', dir_name)
          FileUtils.cp(example_files_dir / 'mappers' / 'class_project_workflow.osw', project_path / 'mappers' / 'base_workflow.osw')
          FileUtils.cp(example_files_dir / 'mappers' / 'ClassProject.rb', project_path / 'mappers')

          if File.exist?(project_path / 'example_project.json')
            FileUtils.remove(project_path / 'example_project.json')
          end

        end

        if @opthash.subopts[:combined]
          # copy residential files
          FileUtils.cp_r(example_files_dir / 'mappers' / 'residential', project_path / 'mappers' / 'residential')
          FileUtils.cp_r(example_files_dir / 'resources', project_path / 'resources')
          FileUtils.cp(example_files_dir / 'example_project_combined.json', dir_name)
          FileUtils.cp_r(example_files_dir / 'xml_building', project_path / 'xml_building')
          if File.exist?(project_path / 'example_project.json')
            FileUtils.remove(project_path / 'example_project.json')
          end
        end

      when true
        project_path.mkdir
        FileUtils.cp(example_files_dir / 'Gemfile', project_path / 'Gemfile')
        FileUtils.cp_r(example_files_dir / 'mappers', project_path / 'mappers')
        FileUtils.cp_r(example_files_dir / 'visualization', project_path / 'visualization')

        if @opthash.subopts[:combined]
          # copy residential files
          FileUtils.cp_r(example_files_dir / 'mappers' / 'residential', project_path / 'mappers' / 'residential')
          FileUtils.cp_r(example_files_dir / 'resources', project_path / 'resources')
          FileUtils.cp(example_files_dir / 'example_project_combined.json', dir_name)
          if File.exist?(project_path / 'example_project.json')
            FileUtils.remove(project_path / 'example_project.json')
          end
        end
      end
    end
  end
end

.create_reopt_erp_scenario_file(existing_scenario_file) ⇒ Object

Write new ScenarioFile with REopt column for ERP functionality params \

existing_scenario_file

string - Name of existing ScenarioFile



845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
# File 'lib/uo_cli.rb', line 845

def self.create_reopt_erp_scenario_file(existing_scenario_file)
  existing_path, existing_name = File.split(File.expand_path(existing_scenario_file))
  # make reopt folder (if it does not exist)
  unless Dir.exist?(File.join(existing_path, 'reopt'))
    Dir.mkdir File.join(existing_path, 'reopt')
    # copy reopt files from cli examples
    $LOAD_PATH.each do |path_item|
      if path_item.to_s.end_with?('example_files')
        reopt_files = File.join(path_item, 'reopt')
        Pathname.new(reopt_files).children.each { |reopt_file| FileUtils.cp(reopt_file, File.join(existing_path, 'reopt')) }
      end
    end
  end

  table = CSV.read(existing_scenario_file, headers: true, col_sep: ',')
  # Add another column, row by row:
  table.each do |row|
    row['REopt Assumptions'] = 'multiPV_assumptions_ERP.json'
  end
  # write new file (name it REopt + existing scenario name)
  CSV.open(File.join(existing_path, "REopt_ERP_#{existing_name}"), 'w') do |f|
    f << table.headers
    table.each { |row| f << row }
  end
end

.create_reopt_scenario_cost_file(existing_scenario_file) ⇒ Object

Write new ScenarioFile with REopt column and capital cost columns params \

existing_scenario_file

string - Name of existing ScenarioFile



818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
# File 'lib/uo_cli.rb', line 818

def self.create_reopt_scenario_cost_file(existing_scenario_file)
  existing_path, existing_name = File.split(File.expand_path(existing_scenario_file))
  # first create a scenario file with reopt assumption file column
  # pass in 'cost' for a different filename so the regular REopt scenario 
  # doesn't get overwritten
  self.create_reopt_scenario_file(existing_scenario_file, 'cost')

  # read the newly created REopt scenario file
  reopt_scenario_file = File.join(existing_path, "REopt_cost_#{existing_name}")
  table = CSV.read(reopt_scenario_file, headers: true, col_sep: ',')

  # add additional capital cost columns to it
  table.each do |row|
    row['Total Capital Costs ($)'] = 1 
    row['Capital Cost Per Floor Area ($/sq.ft.)'] = 1
  end

  # write the updated table back to the file
  CSV.open(reopt_scenario_file, 'w') do |f|
    f << table.headers
    table.each { |row| f << row }
  end
end

.create_reopt_scenario_file(existing_scenario_file, type = 'reopt') ⇒ Object

Write new ScenarioFile with REopt column params \

existing_scenario_file

string - Name of existing ScenarioFile



784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
# File 'lib/uo_cli.rb', line 784

def self.create_reopt_scenario_file(existing_scenario_file, type='reopt')
  existing_path, existing_name = File.split(File.expand_path(existing_scenario_file))
  # make reopt folder (if it does not exist)
  unless Dir.exist?(File.join(existing_path, 'reopt'))
    Dir.mkdir File.join(existing_path, 'reopt')
    # copy reopt files from cli examples
    $LOAD_PATH.each do |path_item|
      if path_item.to_s.end_with?('example_files')
        reopt_files = File.join(path_item, 'reopt')
        Pathname.new(reopt_files).children.each { |reopt_file| FileUtils.cp(reopt_file, File.join(existing_path, 'reopt')) }
      end
    end
  end

  table = CSV.read(existing_scenario_file, headers: true, col_sep: ',')
  # Add another column, row by row:
  table.each do |row|
    row['REopt Assumptions'] = 'multiPV_assumptions.json'
  end
  # write new file (name it REopt + existing scenario name)
  # determine filename (REopt or REopt_cost)
  s_name = "REopt_"
  if type == 'cost'
    s_name += "cost_"
  end
  CSV.open(File.join(existing_path, "#{s_name}#{existing_name}"), 'w') do |f|
    f << table.headers
    table.each { |row| f << row }
  end
end

.create_scenario_csv_file(feature_id) ⇒ Object

Create a scenario csv file from a FeatureFile params\

feature_file_path

string Optional - ID of a single feature in a feature file.



735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
# File 'lib/uo_cli.rb', line 735

def self.create_scenario_csv_file(feature_id)
  begin
    feature_file_json = JSON.parse(File.read(File.expand_path(@opthash.subopts[:scenario_file])), symbolize_names: true)
  # Rescue if user provides path to a dir and not a file
  rescue Errno::EISDIR => e
    wrong_path = e.to_s.split(' - ')[-1]
    abort("\nOops! '#{wrong_path}' is a directory. Please provide path to the geojson feature_file")
    # Rescue if file isn't json
  rescue JSON::ParserError => e
    abort("\nOops! You didn't provide a json file. Please provide path to the geojson feature_file")
  rescue StandardError => e
    puts "\nERROR: #{e.message}"
  end
  Dir["#{@feature_path}/mappers/*.rb"].each do |mapper_file|
    mapper_name = File.basename(mapper_file, File.extname(mapper_file))
    scenario_file_name = if feature_id == 'SKIP'
                           "#{mapper_name.downcase}_scenario.csv"
                         else
                           "#{mapper_name.downcase}_scenario-#{feature_id}.csv"
                         end
    CSV.open(File.join(@feature_path, scenario_file_name), 'wb', write_headers: true,
                                                                 headers: ['Feature Id', 'Feature Name', 'Mapper Class']) do |csv|
      feature_file_json[:features].each do |feature|
        if feature_id == 'SKIP'
          # ensure that feature is a building
          if feature[:properties][:type] == 'Building'
            csv << [feature[:properties][:id], feature[:properties][:name], "URBANopt::Scenario::#{mapper_name}Mapper"]
          end
        elsif feature_id == feature[:properties][:id]
          csv << [feature[:properties][:id], feature[:properties][:name], "URBANopt::Scenario::#{mapper_name}Mapper"]
        else
          unless feature_file_json[:features].any? { |hash| hash[:properties][:id].include?(feature_id.to_s) }
            abort("\nYou must provide Feature ID from FeatureFile!\n---\n\n")
          end
          # If Feature ID specified does not exist in the Feature File raise error
        end
      end
      # Rescue if json isn't a geojson feature_file
    rescue NoMethodError
      abort("\nOops! You didn't provide a valid feature_file. Please provide path to the geojson feature_file")
    rescue StandardError => e
      puts "\nERROR: #{e.message}"
    end
  end
end

.install_python_dependenciesObject

Install all Python tool dependencies (pre-caches each tool's environment)



1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
# File 'lib/uo_cli.rb', line 1436

def self.install_python_dependencies
  errors = []
  python_version = uv_python_version
  uv_tool_packages.each do |group, package|
    puts "Installing '#{group}' (#{package})..."
    stdout, stderr, status = Open3.capture3('uv', 'tool', 'install', '--python', python_version, package)
    if status.success?
      puts "...#{group} installed successfully"
    else
      puts "ERROR installing #{group}:"
      puts "  stdout: #{stdout}" unless stdout.strip.empty?
      puts "  stderr: #{stderr}" unless stderr.strip.empty?
      errors << group
    end
  end

  if errors.empty?
    puts "\nAll Python tools successfully installed"
  else
    abort("\nThe following tools failed to install: #{errors.join(', ')}")
  end
end

.load_uv_dependency_groupsObject

Return dependency-groups hash parsed from python_deps/pyproject.toml. Expected shape: { 'group-name' => ['package-spec', ...], ... }



1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
# File 'lib/uo_cli.rb', line 1309

def self.load_uv_dependency_groups
  pyproject_path = uv_pyproject_path

  unless File.exist?(pyproject_path)
    abort("\nERROR: Could not find pyproject.toml at #{pyproject_path}\n")
  end

  groups = {}
  in_dependency_groups = false
  current_group = nil
  current_specs = []

  File.readlines(pyproject_path, chomp: true).each do |raw_line|
    line = raw_line.strip
    next if line.empty? || line.start_with?('#')

    section_match = line.match(/^\[([^\]]+)\]$/)
    if section_match
      if in_dependency_groups && !current_group.nil?
        groups[current_group] = current_specs.dup
        current_group = nil
        current_specs = []
      end
      in_dependency_groups = section_match[1] == 'dependency-groups'
      next
    end

    next unless in_dependency_groups

    unless current_group.nil?
      if line.start_with?(']')
        groups[current_group] = current_specs.dup
        current_group = nil
        current_specs = []
      else
        line.scan(/"([^"]+)"/) { |match| current_specs << match[0] }
      end
      next
    end

    group_match = line.match(/^([A-Za-z0-9_-]+)\s*=\s*\[(.*)$/)
    next if group_match.nil?

    group_name = group_match[1]
    remainder = group_match[2].strip
    inline_specs = []
    remainder.scan(/"([^"]+)"/) { |match| inline_specs << match[0] }

    if remainder.include?(']')
      groups[group_name] = inline_specs
    else
      current_group = group_name
      current_specs = inline_specs
    end
  end

  if in_dependency_groups && !current_group.nil?
    groups[current_group] = current_specs.dup
  end

  groups
end

.require_uvObject

Check for uv and abort if not found



1408
1409
1410
# File 'lib/uo_cli.rb', line 1408

def self.require_uv
  abort(UV_INSTALL_MESSAGE) unless check_uv
end

.run_funcObject

Simulate energy usage as defined by ScenarioCSV



644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
# File 'lib/uo_cli.rb', line 644

def self.run_func
  run_dir = @root_dir / 'run' / @scenario_name.downcase
  csv_file = @root_dir / @scenario_file_name
  featurefile = @root_dir / @feature_name
  mapper_files_dir = @root_dir / 'mappers'
  reopt_files_dir = @root_dir / 'reopt/'
  num_header_rows = 1

  # Ensure the scenario run directory exists before handing off to the
  # scenario runner. Some downstream OpenStudio/Scenario workflows assume
  # the parent path already exists when writing status or output files.
  FileUtils.mkdir_p(run_dir)

  # Preserve a copy of the Scenario CSV inside the run directory so later
  # process steps can recover if the project-root copy disappears during an
  # interrupted or partially failed simulation workflow.
  begin
    if File.exist?(csv_file)
      scenario_backup_csv = run_dir / @scenario_file_name
      FileUtils.cp(csv_file, scenario_backup_csv) unless scenario_backup_csv.exist?

      persistent_scenario_backup_csv = @root_dir / ".#{@scenario_file_name}.backup"
      FileUtils.cp(csv_file, persistent_scenario_backup_csv) unless persistent_scenario_backup_csv.exist?
    end
  rescue StandardError => e
    puts "WARNING: Could not back up scenario CSV into run directory: #{e.message}"
  end

  # Pre-create per-feature run directories from the Scenario CSV to reduce
  # intermittent failures where downstream tools attempt to write into a
  # feature run directory that has not yet been materialized.
  begin
    if File.exist?(csv_file)
      parsed_run_csv = CSV.read(csv_file, headers: true, col_sep: ',')
      feature_id_header = if parsed_run_csv.headers.include?('Feature Id')
                            'Feature Id'
                          elsif parsed_run_csv.headers.include?('feature_id')
                            'feature_id'
                          end

      if feature_id_header
        parsed_run_csv[feature_id_header].compact.each do |feature_id|
          FileUtils.mkdir_p(run_dir / feature_id.to_s)
        end
      end
    end
  rescue StandardError => e
    puts "WARNING: Could not pre-create scenario run directories: #{e.message}"
  end

  if @feature_id
    feature_run_dir = run_dir / @feature_id
    # If run folder for feature exists, remove it
    FileUtils.rm_rf(feature_run_dir) if feature_run_dir.exist?
  end

  feature_file = URBANopt::GeoJSON::GeoFile.from_file(featurefile)
  if @opthash.subopts[:reopt] == true || @opthash.subopts[:reopt_scenario] == true || @opthash.subopts[:reopt_feature] == true
    parsed_scenario_file = CSV.read(csv_file, headers: true, col_sep: ',')
    # TODO: determine what to do if multiple assumptions are provided
    # num_unique_reopt_assumptions = parsed_scenario_file['REopt Assumptions'].tally.size
    # Use the first assumption as the default
    reopt_assumptions_filename = parsed_scenario_file['REopt Assumptions'][0]
    scenario_output = URBANopt::Scenario::REoptScenarioCSV.new(
      @scenario_name.downcase,
      @root_dir,
      run_dir,
      feature_file,
      mapper_files_dir,
      csv_file,
      num_header_rows,
      reopt_files_dir,
      reopt_assumptions_filename
    )
  else
    scenario_output = URBANopt::Scenario::ScenarioCSV.new(
      @scenario_name.downcase,
      @root_dir,
      run_dir,
      feature_file,
      mapper_files_dir,
      csv_file,
      num_header_rows
    )
  end
  scenario_output
end

.run_uv_tool(group, command, use_system: true) ⇒ Object

Run a Python tool via uv tool run --from <package>. Each tool runs in an isolated ephemeral environment — no shared lockfile needed.

group

dependency group name (key in pyproject [dependency-groups], e.g. 'ditto-reader')

command

the CLI command and arguments to run (e.g. 'ditto_reader_cli run-opendss ...')

use_system

if true, use system() for interactive output; if false, use Open3.capture3



1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
# File 'lib/uo_cli.rb', line 1417

def self.run_uv_tool(group, command, use_system: true)
  package = uv_tool_packages[group]
  abort("\nERROR: Unknown tool group '#{group}'") if package.nil?

  python_version = uv_python_version
  base_args = ['uv', 'tool', 'run', '--python', python_version, '--from', package]
  cmd_args = Shellwords.shellsplit(command)
  full_args = base_args + cmd_args

  puts "Running: #{full_args.shelljoin}"
  if use_system
    system(*full_args)
  else
    stdout, stderr, status = Open3.capture3(*full_args)
    return stdout, stderr, status
  end
end

.setup_python_variablesObject

Locate python_deps from the loaded example_files path, or fall back to the repo/gem-relative example_files directory if it is not present on $LOAD_PATH.



1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
# File 'lib/uo_cli.rb', line 1240

def self.setup_python_variables
  pvars = {
    python_install_path: nil
  }

  $LOAD_PATH.each do |path_item|
    if path_item.to_s.end_with?('example_files')
      pvars[:python_install_path] = File.join(path_item, 'python_deps')
      break
    end
  end

  if pvars[:python_install_path].nil?
    fallback_example_files = File.expand_path('../example_files', __dir__)
    fallback_python_deps = File.join(fallback_example_files, 'python_deps')
    if Dir.exist?(fallback_python_deps)
      pvars[:python_install_path] = fallback_python_deps
    end
  end

  if pvars[:python_install_path].nil?
    abort("\nERROR: Could not locate example_files/python_deps in LOAD_PATH\n")
  end

  pvars
end

.update_project(existing_project_folder, new_project_directory) ⇒ Object

Update an existing URBANopt Project params\

existing_project_folder

string Name of existing project folder

new_project_directory

string Location of updated URBANopt project

Includes weather for example location, a base workflow file, and mapper files to show a baseline and a high-efficiency option.



1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
# File 'lib/uo_cli.rb', line 1115

def self.update_project(existing_project_folder, new_project_directory)
  original_path = Pathname.new(existing_project_folder).expand_path
  new_path = Pathname.new(new_project_directory)

  if Dir.exist?(new_path)
    abort("\nERROR:  there is already a directory here named #{new_path}... aborting\n---\n\n")
  end

  FileUtils.copy_entry(original_path, new_path)

  $LOAD_PATH.each do |path_item|
    if path_item.to_s.end_with?('example_files')
      example_files_dir = Pathname(path_item)

      # copy gemfile
      FileUtils.cp_r(example_files_dir / 'Gemfile', new_path, remove_destination: true)

      # copy validation schema
      FileUtils.cp_r(example_files_dir / 'validation_schema.yaml', new_path, remove_destination: true)

      # copy config file
      FileUtils.cp_r(example_files_dir / 'runner.conf', new_path, remove_destination: true)
      use_num_parallel(new_path)

      # if env variable for gemfile_path and bundle_install_path is set, open the runner.conf
      # and update the gemfile_path and bundle_install_path values
      if ENV['UO_GEMFILE_PATH'] || ENV['UO_BUNDLE_INSTALL_PATH']
        runner_file_path = new_path / 'runner.conf'
        runner_conf_hash = JSON.parse(File.read(runner_file_path))
        if ENV['UO_GEMFILE_PATH']
          runner_conf_hash['gemfile_path'] = ENV['UO_GEMFILE_PATH']
        end
        if ENV['UO_BUNDLE_INSTALL_PATH']
          runner_conf_hash['bundle_install_path'] = ENV['UO_BUNDLE_INSTALL_PATH']
        end
        File.open(runner_file_path, 'w+') do |f|
          f << JSON.pretty_generate(runner_conf_hash)
        end
      end

      # Replace standard mappers
      # FIXME: this also copies createBar and Floorspace without checking project type (for now)
      mappers = example_files_dir / 'mappers'
      mappers.children.each { |mapper| FileUtils.cp_r(mapper, new_path / 'mappers', remove_destination: true) }

      # Replace OSM files
      if (original_path / 'osm_building').directory?
        (example_files_dir / 'osm_building').children.each { |res| FileUtils.cp_r(res, new_path / 'osm_building', remove_destination: true) }
      end

      # Replace weather
      if (original_path / 'weather').directory?
        (example_files_dir / 'weather').children.each { |weather_file| FileUtils.cp_r(weather_file, new_path / 'weather', remove_destination: true) }
      end

      # Replace visualization files
      (example_files_dir / 'visualization').children.each { |viz| FileUtils.cp_r(viz, new_path / 'visualization', remove_destination: true) }

      # Replace Residential files
      if (original_path / 'residential').directory?
        (example_files_dir / 'residential').children.each { |res| FileUtils.cp_r(res, new_path / 'mappers' / 'residential', remove_destination: true) }
      end
      if (original_path / 'measures').directory?
        (example_files_dir / 'measures').children.each { |res| FileUtils.cp_r(res, new_path / 'measures', remove_destination: true) }
      end
      if (original_path / 'resources').directory?
        (example_files_dir / 'resources').children.each { |res| FileUtils.cp_r(res, new_path / 'resources', remove_destination: true) }
        # hpxml-measures is included in resources/residential-measures/resources/ and is redundant if present in an existing project when updating
        if (original_path / 'resources' / 'hpxml-measures').directory?
          FileUtils.rm_rf(new_path / 'resources' / 'hpxml-measures')
        end
      end
      # adjust for residential workflow
      if (original_path / 'xml_building').directory?
        (example_files_dir / 'xml_building').children.each { |res| FileUtils.cp_r(res, new_path / 'xml_building', remove_destination: true) }
      end

      # Replace Reopt assumption files
      if (original_path / 'reopt').directory?
        (example_files_dir / 'reopt').children.each { |reopt_file| FileUtils.cp_r(reopt_file, new_path / 'reopt', remove_destination: true) }
      end

      # Replace OpenDSS files
      if (original_path / 'opendss').directory?
        (example_files_dir / 'opendss').children.each { |opendss_file| FileUtils.cp_r(opendss_file, new_path / 'opendss', remove_destination: true) }
      end

      if (original_path / 'disco').directory?
        (example_files_dir / 'disco').children.each { |disco_file| FileUtils.cp_r(disco_file, new_path / 'disco', remove_destination: true) }
      end

      original_path.children.each do |file|
        if File.extname(file) == '.json'
          puts file
          if File.exist?(example_files_dir / file)
            FileUtils.cp_r(example_files_dir / file, new_path)
          end
        end
      end

    end
  end
end

.use_num_parallel(project_dir) ⇒ Object

Change num_parallel in runner.conf to set number of cores to use when running simulations This function is called during project_dir creation/updating so users aren't surprised if they look at the config file



873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
# File 'lib/uo_cli.rb', line 873

def self.use_num_parallel(project_dir)
  if ENV['UO_NUM_PARALLEL'] || @opthash.subopts[:num_parallel]
    runner_file_path = Pathname(project_dir) / 'runner.conf'
    runner_conf_hash = JSON.parse(File.read(runner_file_path))
    if @opthash.subopts[:num_parallel]
      runner_conf_hash['num_parallel'] = @opthash.subopts[:num_parallel]
      File.open(runner_file_path, 'w+') do |f|
        f << runner_conf_hash.to_json
      end
    elsif ENV['UO_NUM_PARALLEL']
      runner_conf_hash['num_parallel'] = ENV['UO_NUM_PARALLEL'].to_i
      File.open(runner_file_path, 'w+') do |f|
        f << runner_conf_hash.to_json
      end
    end
  end
end

.uv_pyproject_pathObject

Return full path to python_deps/pyproject.toml.



1268
1269
1270
1271
# File 'lib/uo_cli.rb', line 1268

def self.uv_pyproject_path
  pvars = setup_python_variables
  File.join(pvars[:python_install_path], 'pyproject.toml')
end

.uv_python_versionObject

Return python version used for uv commands, derived from pyproject requires-python. Example supported specs: "==3.10.*", ">=3.10,<3.12", "~=3.10".



1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
# File 'lib/uo_cli.rb', line 1275

def self.uv_python_version
  pyproject_path = uv_pyproject_path
  unless File.exist?(pyproject_path)
    abort("\nERROR: Could not find pyproject.toml at #{pyproject_path}\n")
  end

  requires_python = nil
  File.readlines(pyproject_path, chomp: true).each do |raw_line|
    line = raw_line.strip
    next if line.empty? || line.start_with?('#')

    match = line.match(/^requires-python\s*=\s*"([^"]+)"/)
    if match
      requires_python = match[1]
      break
    end
  end

  if requires_python.nil?
    puts "WARNING: requires-python not found in pyproject.toml; using fallback #{UV_PYTHON_VERSION_FALLBACK}"
    return UV_PYTHON_VERSION_FALLBACK
  end

  version_match = requires_python.match(/(\d+\.\d+)/)
  if version_match.nil?
    puts "WARNING: could not parse requires-python '#{requires_python}'; using fallback #{UV_PYTHON_VERSION_FALLBACK}"
    return UV_PYTHON_VERSION_FALLBACK
  end

  version_match[1]
end

.uv_tool_packagesObject

Return map of tool group to package spec. The first package listed in each group is used as the uv tool package.



1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
# File 'lib/uo_cli.rb', line 1374

def self.uv_tool_packages
  dependency_groups = load_uv_dependency_groups
  package_map = {}

  UV_TOOL_GROUPS.each do |group|
    specs = dependency_groups[group]
    if specs.nil? || specs.empty?
      abort("\nERROR: Missing dependency group '#{group}' in pyproject.toml\n")
    end

    if specs.length > 1
      puts "WARNING: dependency group '#{group}' has multiple package specs; using first one for uv tool commands"
    end

    package_map[group] = specs.first
  end

  package_map
end