diff --git a/.gitignore b/.gitignore index e4ea2ba77f..eea7e07112 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ buildstockbatch_crash_details.log # Postprocessing build outputs /postprocessing/build/ /postprocessing/resstockpostproc.egg-info/ + +.history/ \ No newline at end of file diff --git a/measures/OCHRE/measure.rb b/measures/OCHRE/measure.rb new file mode 100644 index 0000000000..5b1d6e5d3c --- /dev/null +++ b/measures/OCHRE/measure.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ + +require 'openstudio' +require 'json' +require 'csv' + +# Load HPXML resources +resources_path = File.absolute_path(File.join(File.dirname(__FILE__), '../../resources/hpxml-measures/HPXMLtoOpenStudio/resources')) +Dir["#{resources_path}/*.rb"].each do |resource_file| + next if resource_file.include? 'minitest_helper.rb' + + require resource_file +end + +# start the measure +class OCHRE < OpenStudio::Measure::ModelMeasure + # human readable name + def name + return 'OCHRE Simulator' + end + + # human readable description + def description + return 'Runs OCHRE (Object-oriented Controllable High-resolution Residential Energy Model) simulation as an alternative to HPXMLtoOpenStudio/EnergyPlus.' + end + + # human readable description of modeling approach + def modeler_description + return 'This measure replaces the HPXMLtoOpenStudio + EnergyPlus workflow with OCHRE simulation. It takes an HPXML file as input, runs OCHRE via command line, and converts the outputs to match the expected format for downstream reporting measures.' + end + + # define the arguments that the user will input + def arguments(model) # rubocop:disable Lint/UnusedMethodArgument + 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('Output Directory') + arg.setDescription('Absolute/relative path for outputs.') + args << arg + + arg = OpenStudio::Measure::OSArgument.makeIntegerArgument('time_res_minutes', false) + arg.setDisplayName('Time Resolution (minutes)') + arg.setDescription('Time resolution for OCHRE simulation in minutes.') + arg.setDefaultValue(60) + args << arg + + arg = OpenStudio::Measure::OSArgument.makeIntegerArgument('duration_days', false) + arg.setDisplayName('Duration (days)') + arg.setDescription('Simulation duration in days.') + arg.setDefaultValue(365) + args << arg + + arg = OpenStudio::Measure::OSArgument.makeBoolArgument('debug', false) + arg.setDisplayName('Debug Mode') + arg.setDescription('If true, generates additional debug output.') + arg.setDefaultValue(false) + 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 + + # assign the user inputs to variables + args = runner.getArgumentValues(arguments(model), user_arguments) + args = Hash[args.map { |k, v| [k.to_sym, v] }] + + # Validate required inputs + hpxml_path = args[:hpxml_path] + if hpxml_path.nil? || hpxml_path.empty? + runner.registerError('HPXML file path is required.') + return false + end + + output_dir = args[:output_dir] + if output_dir.nil? || output_dir.empty? + output_dir = '.' + end + + # Create output directory if it doesn't exist + unless Dir.exist?(output_dir) + Dir.mkdir(output_dir) + end + + # Get absolute paths + hpxml_path = File.absolute_path(hpxml_path) if File.exist?(hpxml_path) + output_dir = File.absolute_path(output_dir) + + # Check if HPXML file exists + unless File.exist?(hpxml_path) + runner.registerError("HPXML file not found: #{hpxml_path}") + return false + end + + runner.registerInfo("HPXML file: #{hpxml_path}") + runner.registerInfo("Output directory: #{output_dir}") + + # Load HPXML to extract weather and schedule files + begin + hpxml = HPXML.new(hpxml_path: hpxml_path) + rescue => e + runner.registerError("Failed to load HPXML file: #{e.message}") + return false + end + + # Extract weather file + weather_file = nil + if hpxml.buildings.size > 0 + weather_file = hpxml.buildings[0].climate_and_risk_zones.weather_station_epw_filepath + end + + if weather_file.nil? || weather_file.empty? + runner.registerWarning('Weather file path not found in HPXML.') + else + weather_file = File.expand_path(weather_file, File.dirname(hpxml_path)) + runner.registerInfo("Found weather file in HPXML: #{weather_file}") + end + + # Extract schedule file + schedule_file = nil + if hpxml.buildings.size > 0 && !hpxml.buildings[0].header.schedules_filepaths.nil? && !hpxml.buildings[0].header.schedules_filepaths.empty? + schedule_file = hpxml.buildings[0].header.schedules_filepaths[0] + runner.registerInfo("Found schedule file in HPXML: #{schedule_file}") + else + runner.registerWarning('Schedule file path not found in HPXML.') + end + + # Build OCHRE command line + time_res_minutes = args[:time_res_minutes] || 10 + duration_days = args[:duration_days] || 365 + + # Build OCHRE CLI command + ochre_cmd = build_ochre_command(hpxml_path, output_dir, + time_res_minutes, duration_days, + schedule_file, weather_file) + + runner.registerInfo("Running OCHRE simulation: #{ochre_cmd}") + + begin + output = `#{ochre_cmd} 2>&1` + exit_status = $?.exitstatus + + if args[:debug] + runner.registerInfo("OCHRE output:\n#{output}") + end + + if exit_status != 0 + runner.registerError("OCHRE simulation failed with exit code #{exit_status}") + runner.registerError("Output:\n#{output}") + return false + end + + runner.registerInfo('OCHRE simulation completed successfully') + rescue => e + runner.registerError("Failed to run OCHRE: #{e.message}") + return false + end + + runner.registerFinalCondition('OCHRE simulation completed successfully') + + return true + end + + private + + # Build OCHRE command to run via CLI + def build_ochre_command(hpxml_path, output_dir, time_res_minutes, duration_days, schedule_file, weather_file) + # Get directory and filename for HPXML + hpxml_dir = File.dirname(hpxml_path) + hpxml_name = File.basename(hpxml_path) + + # Escape paths for shell + hpxml_dir_safe = hpxml_dir.gsub("'", "\\\\'") + hpxml_name_safe = hpxml_name.gsub("'", "\\\\'") + output_dir_safe = output_dir.gsub("'", "\\\\'") + + # Build the ochre command + # Usage: ochre single [OPTIONS] INPUT_PATH + # INPUT_PATH is the directory containing the HPXML file + cmd = "/Users/radhikar/Documents/buildstock2025/OCHRE/.venv/bin/python /Users/radhikar/Documents/buildstock2025/OCHRE/ochre/cli.py single '#{hpxml_dir_safe}'" + cmd += " --hpxml_file '#{hpxml_name_safe}'" + cmd += " --output_path '#{output_dir_safe}'" + cmd += " --time_res #{time_res_minutes}" + cmd += " --duration #{duration_days}" + cmd += ' --start_year 2018 --start_month 1 --start_day 1' + cmd += ' --verbosity=9' + + if schedule_file && !schedule_file.empty? + schedule_file_safe = schedule_file.gsub("'", "\\\\'") + cmd += " --hpxml_schedule_file '#{schedule_file_safe}'" + end + + if weather_file && !weather_file.empty? + weather_file_safe = weather_file.gsub("'", "\\\\'") + cmd += " --weather_file_or_path '#{weather_file_safe}'" + end + + return cmd + end +end + +# register the measure to be used by the application +OCHRE.new.registerWithApplication diff --git a/measures/OCHRE/tests/test_ochre.rb b/measures/OCHRE/tests/test_ochre.rb new file mode 100644 index 0000000000..6c50975127 --- /dev/null +++ b/measures/OCHRE/tests/test_ochre.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'openstudio' +require 'openstudio/measure/ShowRunnerOutput' +require 'minitest/autorun' +require_relative '../measure.rb' +require 'fileutils' + +class OCHRETest < Minitest::Test + def setup + # Create a temporary directory for tests + @test_dir = File.join(File.dirname(__FILE__), 'output') + FileUtils.mkdir_p(@test_dir) unless File.exist?(@test_dir) + end + + def teardown + # Clean up test directory + FileUtils.rm_rf(@test_dir) if File.exist?(@test_dir) + end + + def test_number_of_arguments_and_argument_names + # Create an instance of the measure + measure = OCHRE.new + + # Make an empty model + model = OpenStudio::Model::Model.new + + # Get arguments and test that they are what we are expecting + arguments = measure.arguments(model) + assert_equal(5, arguments.size) + + # Check argument names + arg_names = arguments.map(&:name) + assert_includes(arg_names, 'hpxml_path') + assert_includes(arg_names, 'output_dir') + assert_includes(arg_names, 'time_res_minutes') + assert_includes(arg_names, 'duration_days') + assert_includes(arg_names, 'debug') + end + + def test_bad_hpxml_path + # Create an instance of the measure + measure = OCHRE.new + + # Create runner with empty OSW + runner = OpenStudio::Measure::OSRunner.new(OpenStudio::WorkflowJSON.new) + + # Make an empty model + model = OpenStudio::Model::Model.new + + # Get arguments + arguments = measure.arguments(model) + argument_map = OpenStudio::Measure.convertOSArgumentVectorToMap(arguments) + + # Set argument values + argument_map['hpxml_path'].setValue('nonexistent_file.xml') + argument_map['output_dir'].setValue(@test_dir) + + # Run the measure + measure.run(model, runner, argument_map) + result = runner.result + + # Assert that it failed (because file doesn't exist) + assert_equal('Fail', result.value.valueName) + end + + def test_measure_name + # Create an instance of the measure + measure = OCHRE.new + + # Check the name + assert_equal('OCHRE Simulator', measure.name) + end + + def test_description + # Create an instance of the measure + measure = OCHRE.new + + # Check the description + assert(!measure.description.empty?) + assert(measure.description.include?('OCHRE')) + end + + def test_modeler_description + # Create an instance of the measure + measure = OCHRE.new + + # Check the modeler description + assert(!measure.modeler_description.empty?) + assert(measure.modeler_description.include?('HPXMLtoOpenStudio')) + end +end diff --git a/workflow/run_analysis.rb b/workflow/run_analysis.rb index d83a280124..ce6f25068c 100644 --- a/workflow/run_analysis.rb +++ b/workflow/run_analysis.rb @@ -14,7 +14,7 @@ $start_time = Time.now -def run_workflow(yml, in_threads, measures_only, debug_arg, overwrite, building_ids, upgrade_names, keep_run_folders, samplingonly) +def run_workflow(yml, in_threads, measures_only, debug_arg, overwrite, building_ids, upgrade_names, keep_run_folders, samplingonly, use_ochre = false) if !File.exist?(yml) puts "Error: YML file does not exist at '#{yml}'." return false @@ -287,43 +287,62 @@ def run_workflow(yml, in_threads, measures_only, debug_arg, overwrite, building_ } server_dir_cleanup_args.update(workflow_args['server_directory_cleanup']) - osw['steps'] += [ - { - 'measure_dir_name' => 'HPXMLtoOpenStudio', - 'arguments' => { - 'hpxml_path' => '', - 'output_dir' => '', - 'debug' => debug, - 'add_component_loads' => add_component_loads, - 'skip_validation' => true + if use_ochre + measures_only = true + osw['steps'] += [ + { + 'measure_dir_name' => 'OCHRE', + 'arguments' => { + 'hpxml_path' => '', + 'output_dir' => '', + 'debug' => debug + } } - }, - { - 'measure_dir_name' => 'UpgradeCosts', - 'arguments' => { 'debug' => debug } - } - ] - - osw['steps'] += workflow_args['measures'] - - osw['steps'] += [ - { - 'measure_dir_name' => 'ReportSimulationOutput', - 'arguments' => sim_out_rep_args - }, - { - 'measure_dir_name' => 'ReportUtilityBills', - 'arguments' => { 'output_format' => 'csv', - 'include_annual_bills' => include_annual_bills, - 'include_monthly_bills' => include_monthly_bills, - 'register_annual_bills' => register_annual_bills, - 'register_monthly_bills' => register_monthly_bills } - }, - { - 'measure_dir_name' => 'ServerDirectoryCleanup', - 'arguments' => server_dir_cleanup_args - } - ] + ] + else + osw['steps'] += [ + { + 'measure_dir_name' => 'HPXMLtoOpenStudio', + 'arguments' => { + 'hpxml_path' => '', + 'output_dir' => '', + 'debug' => debug, + 'add_component_loads' => add_component_loads, + 'skip_validation' => true + } + } + ] + osw['steps'] += workflow_args['measures'] + + osw['steps'] += [ + { + 'measure_dir_name' => 'UpgradeCosts', + 'arguments' => { 'debug' => debug } + } + ] + osw['steps'] += [ + { + 'measure_dir_name' => 'ReportSimulationOutput', + 'arguments' => sim_out_rep_args + }, + { + 'measure_dir_name' => 'ReportUtilityBills', + 'arguments' => { 'output_format' => 'csv', + 'include_annual_bills' => include_annual_bills, + 'include_monthly_bills' => include_monthly_bills, + 'register_annual_bills' => register_annual_bills, + 'register_monthly_bills' => register_monthly_bills } + }, + { + 'measure_dir_name' => 'ServerDirectoryCleanup', + 'arguments' => server_dir_cleanup_args + } + ] + end + puts ("Generating OSW for upgrade: #{workflow_args}") + puts (workflow_args['measures']) + + if upgrade_name != 'Baseline' apply_upgrade_measure = { 'measure_dir_name' => 'ApplyUpgrade', @@ -359,7 +378,7 @@ def run_workflow(yml, in_threads, measures_only, debug_arg, overwrite, building_ osw['steps'].insert(build_existing_model_idx + 1, apply_upgrade_measure) end - if workflow_args.keys.include?('reporting_measures') + if workflow_args.keys.include?('reporting_measures') && !use_ochre workflow_args['reporting_measures'].each do |reporting_measure| if !reporting_measure.keys.include?('arguments') reporting_measure['arguments'] = {} @@ -582,7 +601,7 @@ def change_arguments(osw, building_id, hpxml_path, output_dir) json[:steps].each do |measure| if measure[:measure_dir_name] == 'BuildExistingModel' measure[:arguments][:building_id] = "#{building_id}" - elsif measure[:measure_dir_name] == 'HPXMLtoOpenStudio' + elsif measure[:measure_dir_name] == 'HPXMLtoOpenStudio' || measure[:measure_dir_name] == 'OCHRE' measure[:arguments][:hpxml_path] = hpxml_path measure[:arguments][:output_dir] = output_dir end @@ -672,6 +691,11 @@ def make_apply_logic_arg(logic) options[:overwrite] = true end + options[:use_ochre] = false + opts.on('--use_ochre', 'Use OCHRE measure instead of HPXMLtoOpenStudio') do |_t| + options[:use_ochre] = true + end + opts.on_tail('-h', '--help', 'Display help') do puts opts exit! @@ -692,7 +716,8 @@ def make_apply_logic_arg(logic) # Run analysis puts "YML: #{options[:yml]}" success = run_workflow(options[:yml], options[:threads], options[:measures_only], options[:debug], options[:overwrite], - options[:building_ids], options[:upgrade_names], options[:keep_run_folders], options[:samplingonly]) + options[:building_ids], options[:upgrade_names], options[:keep_run_folders], options[:samplingonly], + options[:use_ochre]) puts "\nCompleted in #{get_elapsed_time(Time.now, $start_time)}." if success end