diff --git a/python/engine/PythonEngine.cpp b/python/engine/PythonEngine.cpp index e474a947a5..ad53b802ce 100644 --- a/python/engine/PythonEngine.cpp +++ b/python/engine/PythonEngine.cpp @@ -413,6 +413,39 @@ int PythonEngine::numberOfArguments(ScriptObject& methodObject, std::string_view return numberOfArguments; } +bool PythonEngine::hasMethod(ScriptObject& methodObject, std::string_view methodName, bool overriden_only) { + auto val = std::any_cast(methodObject.object); + if (PyObject_HasAttrString(val.obj_, methodName.data()) == 0) { + return false; + } + PyObject* method = PyObject_GetAttrString(val.obj_, methodName.data()); // New reference + if (PyMethod_Check(method) == 0) { + // Should never happen with modelOutputRequests since the Base class (C++) has it + return false; + } + Py_DECREF(method); + if (!overriden_only) { + return true; + } + + // equivalent to getattr(instance_obj.__class__, method_name) == getattr(instance_obj.__class__.__bases__[0], method_name) + PyTypeObject* class_type = Py_TYPE(val.obj_); // PyObject_Type returns a strong (New) reference, not needed for us + // cppcheck-suppress cstyleCast + PyObject* class_method = PyObject_GetAttrString((PyObject*)class_type, methodName.data()); // New reference + + assert(class_type->tp_base != nullptr); + // cppcheck-suppress cstyleCast + auto* base = (PyTypeObject*)class_type->tp_base; + // cppcheck-suppress cstyleCast + PyObject* base_method = PyObject_GetAttrString((PyObject*)base, methodName.data()); // New reference + + bool result = class_method != base_method; + Py_DECREF(class_method); + Py_DECREF(base_method); + + return result; +} + } // namespace openstudio extern "C" diff --git a/python/engine/PythonEngine.hpp b/python/engine/PythonEngine.hpp index f05019e0e9..17f4da7e61 100644 --- a/python/engine/PythonEngine.hpp +++ b/python/engine/PythonEngine.hpp @@ -38,6 +38,8 @@ class PythonEngine final : public ScriptEngine virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) override; + virtual bool hasMethod(ScriptObject& methodObject, std::string_view methodName, bool overriden_only) override; + protected: void* getAs_impl(ScriptObject& obj, const std::type_info&) override; diff --git a/python/engine/test/PythonEngine_GTest.cpp b/python/engine/test/PythonEngine_GTest.cpp index 2fa8d4a0a7..a5fee52a8d 100644 --- a/python/engine/test/PythonEngine_GTest.cpp +++ b/python/engine/test/PythonEngine_GTest.cpp @@ -174,3 +174,22 @@ TEST_F(PythonEngineFixture, AlfalfaMeasure) { measurePtr->run(model, runner, arguments); EXPECT_EQ(5, runner.alfalfa().points().size()); } + +TEST_F(PythonEngineFixture, hasMethod) { + { + const std::string classAndDirName = "ReportingMeasureWithoutModelOutputs"; + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "doesNotExists")); + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests", false)); // overriden_only = false + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests")); + } + { + const std::string classAndDirName = "ReportingMeasureWithModelOutputs"; + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "doesNotExists")); + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests", false)); // overriden_only = false + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests")); + } +} diff --git a/python/engine/test/ReportingMeasureWithModelOutputs/measure.py b/python/engine/test/ReportingMeasureWithModelOutputs/measure.py new file mode 100644 index 0000000000..b637626f48 --- /dev/null +++ b/python/engine/test/ReportingMeasureWithModelOutputs/measure.py @@ -0,0 +1,52 @@ +"""insert your copyright here. + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ +""" + +from pathlib import Path + +import openstudio + + +class ReportingMeasureWithModelOutputs(openstudio.measure.ReportingMeasure): + """An ReportingMeasure.""" + + def name(self): + return "ReportingMeasureWithModelOutputs" + + def description(self): + return "DESCRIPTION_TEXT" + + def modeler_description(self): + return "MODELER_DESCRIPTION_TEXT" + + def arguments(self, model: openstudio.model.Model): + args = openstudio.measure.OSArgumentVector() + return args + + def outputs(self): + outs = openstudio.measure.OSOutputVector() + return outs + + def modelOutputRequests( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ) -> bool: + return True + + def energyPlusOutputRequests( + self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap + ): + result = openstudio.IdfObjectVector() + return result + + def run( + self, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + super().run(runner, user_arguments) + return True diff --git a/python/engine/test/ReportingMeasureWithModelOutputs/measure.xml b/python/engine/test/ReportingMeasureWithModelOutputs/measure.xml new file mode 100644 index 0000000000..ec14086ad6 --- /dev/null +++ b/python/engine/test/ReportingMeasureWithModelOutputs/measure.xml @@ -0,0 +1,44 @@ + + + 3.1 + reporting_measure_with_model_outputs + 9971eb7b-3225-4795-9690-9941a7471ce0 + be88754a-3d7f-433e-9912-94a2993c9463 + 2025-03-14T13:15:30Z + 1DA817AF + ReportingMeasureWithModelOutputs + ReportingMeasureWithModelOutputs + DESCRIPTION_TEXT + MODELER_DESCRIPTION_TEXT + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Python + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.py + py + script + CFB17771 + + + diff --git a/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.py b/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.py new file mode 100644 index 0000000000..642ed33e97 --- /dev/null +++ b/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.py @@ -0,0 +1,44 @@ +"""insert your copyright here. + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ +""" + +from pathlib import Path + +import openstudio + + +class ReportingMeasureWithoutModelOutputs(openstudio.measure.ReportingMeasure): + """An ReportingMeasure.""" + + def name(self): + return "ReportingMeasureWithoutModelOutputs" + + def description(self): + return "DESCRIPTION_TEXT" + + def modeler_description(self): + return "MODELER_DESCRIPTION_TEXT" + + def arguments(self, model: openstudio.model.Model): + args = openstudio.measure.OSArgumentVector() + return args + + def outputs(self): + outs = openstudio.measure.OSOutputVector() + return outs + + def energyPlusOutputRequests( + self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap + ): + result = openstudio.IdfObjectVector() + return result + + def run( + self, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + super().run(runner, user_arguments) + return True diff --git a/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml b/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml new file mode 100644 index 0000000000..3c10f0a0b6 --- /dev/null +++ b/python/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml @@ -0,0 +1,44 @@ + + + 3.1 + reporting_measure_without_model_outputs + a0876945-a15b-469d-bc22-90f133720e7c + 49740c2c-1804-4a4e-ace5-c30b6d350c5f + 2025-03-14T13:15:17Z + 1DA817AF + ReportingMeasureWithoutModelOutputs + ReportingMeasureWithoutModelOutputs + DESCRIPTION_TEXT + MODELER_DESCRIPTION_TEXT + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Python + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.py + py + script + 816BF07D + + + diff --git a/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.py b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.py new file mode 100644 index 0000000000..c9eed01cdf --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.py @@ -0,0 +1,168 @@ +"""insert your copyright here. + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ +""" + +from pathlib import Path + +import openstudio + + +class PythonReportingMeasureWithModelOutputRequests(openstudio.measure.ReportingMeasure): + """An ReportingMeasure.""" + + def name(self) -> str: + """Returns the human readable name. + + Measure name should be the title case of the class name. + The measure name is the first contact a user has with the measure; + it is also shared throughout the measure workflow, visible in the OpenStudio Application, + PAT, Server Management Consoles, and in output reports. + As such, measure names should clearly describe the measure's function, + while remaining general in nature + """ + return "Reporting Measure with Model Output Requests" + + def description(self) -> str: + """Human readable description. + + The measure description is intended for a general audience and should not assume + that the reader is familiar with the design and construction practices suggested by the measure. + """ + return "A new reporting measure that has a modelOutputRequests" + + def modeler_description(self) -> str: + """Human readable description of modeling approach. + + The modeler description is intended for the energy modeler using the measure. + It should explain the measure's intent, and include any requirements about + how the baseline model must be set up, major assumptions made by the measure, + and relevant citations or references to applicable modeling resources + """ + return "This was added at 3.10.0" + + def arguments(self, model: openstudio.model.Model) -> openstudio.measure.OSArgumentVector: + """Prepares user arguments for the measure. + + Measure arguments define which -- if any -- input parameters the user may set before running the measure. + """ + args = openstudio.measure.OSArgumentVector() + + report_drybulb_temp = openstudio.measure.OSArgument.makeBoolArgument("report_drybulb_temp", True) + report_drybulb_temp.setDisplayName("Add output variables for Drybulb Temperature") + report_drybulb_temp.setDescription("Will add drybulb temp and report min/max values in html.") + report_drybulb_temp.setDefaultValue(True) + args.append(report_drybulb_temp) + + add_output_json = openstudio.measure.OSArgument.makeBoolArgument("add_output_json", True) + add_output_json.setDisplayName("Request JSON output") + add_output_json.setDescription("Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true") + add_output_json.setDefaultValue(True) + args.append(add_output_json) + + return args + + def outputs(self) -> openstudio.measure.OSOutputVector: + """Define the outputs that the measure will create.""" + outs = openstudio.measure.OSOutputVector() + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + + def modelOutputRequests( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ) -> bool: + """This method is called on all reporting measures immediately before the translation to E+ IDF. + + There is an implicit contract that this method should NOT be modifying your model in a way that would produce + different results, meaning it should only add or modify reporting-related elements + (eg: OutputTableSummaryReports, OutputControlFiles, etc) + + If you mean to modify the model in a significant way, use a `ModelMeasure` + NOTE: this method will ONLY be called if you use the C++ CLI, not the `classic` (Ruby) one + """ + if runner.getBoolArgumentValue("add_output_json", user_arguments): + output_json = model.getOutputJSON() + output_json.setOptionType("TimeSeriesAndTabular") + output_json.setOutputJSON(True) + output_json.setOutputCBOR(False) + output_json.setOutputMessagePack(False) + + return True + + def energyPlusOutputRequests( + self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap + ) -> openstudio.IdfObjectVector: + """Returns a vector of IdfObject's to request EnergyPlus objects needed by the run method. + + This is done after ForwardTranslation to IDF, and there is a list of accepted objects. + """ + super().energyPlusOutputRequests(runner, user_arguments) # Do **NOT** remove this line + + result = openstudio.IdfObjectVector() + + # To use the built-in error checking we need the model... + # get the last model and sql file + model = runner.lastOpenStudioModel() + if not model.is_initialized(): + runner.registerError("Cannot find last model.") + return result + + model = model.get() + + # use the built-in error checking + if not runner.validateUserArguments(self.arguments(model), user_arguments): + return result + + if runner.getBoolArgumentValue("report_drybulb_temp", user_arguments): + request = openstudio.IdfObject.load( + "Output:Variable, , Site Outdoor Air Drybulb Temperature, Hourly;" + ).get() + result.append(request) + + return result + + def run( + self, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ) -> bool: + """Defines what happens when the measure is run.""" + super().run(runner, user_arguments) + + # get the last model and sql file + model = runner.lastOpenStudioModel() + if not model.is_initialized(): + runner.registerError("Cannot find last model.") + return False + model = model.get() + + # use the built-in error checking (need model) + if not runner.validateUserArguments(self.arguments(model), user_arguments): + return False + + # load sql file + sql_file = runner.lastEnergyPlusSqlFile() + if not sql_file.is_initialized(): + runner.registerError("Cannot find last sql file.") + return False + + sql_file = sql_file.get() + model.setSqlFile(sql_file) + # write file: any file named 'report*.*' in the current working directory + # will be copied to the ./reports/ folder as 'reports/_.html' + Path("report_python_with.txt").write_text("OK") + + # Close the sql file + sql_file.close() + + return True + + +# register the measure to be used by the application +PythonReportingMeasureWithModelOutputRequests().registerWithApplication() diff --git a/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.xml b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.xml new file mode 100644 index 0000000000..36bc0f8031 --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithModelOutputRequests/measure.xml @@ -0,0 +1,83 @@ + + + 3.1 + python_reporting_measure_with_model_output_requests + 90b5aa6c-a23d-4be3-b24a-a695d27e3848 + 398d6d2e-3240-4390-83b4-2477193285eb + 2025-03-14T16:51:11Z + 1DA817AF + PythonReportingMeasureWithModelOutputRequests + Reporting Measure with Model Output Requests + A new reporting measure that has a modelOutputRequests + This was added at 3.10.0 + + + report_drybulb_temp + Add output variables for Drybulb Temperature + Will add drybulb temp and report min/max values in html. + Boolean + true + false + true + + + true + true + + + false + false + + + + + add_output_json + Request JSON output + Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true + Boolean + true + false + true + + + true + true + + + false + false + + + + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Python + string + + + + + + OpenStudio + 3.10.0 + 3.10.0 + + measure.py + py + script + 0B936742 + + + diff --git a/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.py b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.py new file mode 100644 index 0000000000..ceec071ac7 --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.py @@ -0,0 +1,135 @@ +"""insert your copyright here. + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ +""" + +from pathlib import Path + +import openstudio + + +class PythonReportingMeasureWithoutModelOutputRequests(openstudio.measure.ReportingMeasure): + """An ReportingMeasure.""" + + def name(self): + """Returns the human readable name. + + Measure name should be the title case of the class name. + The measure name is the first contact a user has with the measure; + it is also shared throughout the measure workflow, visible in the OpenStudio Application, + PAT, Server Management Consoles, and in output reports. + As such, measure names should clearly describe the measure's function, + while remaining general in nature + """ + return "Reporting Measure without Model Output Requests" + + def description(self): + """Human readable description. + + The measure description is intended for a general audience and should not assume + that the reader is familiar with the design and construction practices suggested by the measure. + """ + return "An older reporting measure that does not have modelOutputRequests" + + def modeler_description(self): + """Human readable description of modeling approach. + + The modeler description is intended for the energy modeler using the measure. + It should explain the measure's intent, and include any requirements about + how the baseline model must be set up, major assumptions made by the measure, + and relevant citations or references to applicable modeling resources + """ + return "This is a measure that was added before 3.10.0" + + def arguments(self, model: openstudio.model.Model): + """Prepares user arguments for the measure. + + Measure arguments define which -- if any -- input parameters the user may set before running the measure. + """ + args = openstudio.measure.OSArgumentVector() + + report_drybulb_temp = openstudio.measure.OSArgument.makeBoolArgument("report_drybulb_temp", True) + report_drybulb_temp.setDisplayName("Add output variables for Drybulb Temperature") + report_drybulb_temp.setDescription("Will add drybulb temp and report min/max values in html.") + report_drybulb_temp.setValue(True) + args.append(report_drybulb_temp) + + return args + + def outputs(self): + """Define the outputs that the measure will create.""" + outs = openstudio.measure.OSOutputVector() + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + + def energyPlusOutputRequests( + self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap + ): + """Returns a vector of IdfObject's to request EnergyPlus objects needed by the run method.""" + super().energyPlusOutputRequests(runner, user_arguments) # Do **NOT** remove this line + + result = openstudio.IdfObjectVector() + + # To use the built-in error checking we need the model... + # get the last model and sql file + model = runner.lastOpenStudioModel() + if not model.is_initialized(): + runner.registerError("Cannot find last model.") + return result + + model = model.get() + + # use the built-in error checking + if not runner.validateUserArguments(self.arguments(model), user_arguments): + return result + + if runner.getBoolArgumentValue("report_drybulb_temp", user_arguments): + request = openstudio.IdfObject.load( + "Output:Variable, , Site Outdoor Air Drybulb Temperature, Hourly;" + ).get() + result.append(request) + + return result + + def run( + self, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + """Defines what happens when the measure is run.""" + super().run(runner, user_arguments) + + # get the last model and sql file + model = runner.lastOpenStudioModel() + if not model.is_initialized(): + runner.registerError("Cannot find last model.") + return False + model = model.get() + + # use the built-in error checking (need model) + if not runner.validateUserArguments(self.arguments(model), user_arguments): + return False + + # load sql file + sql_file = runner.lastEnergyPlusSqlFile() + if not sql_file.is_initialized(): + runner.registerError("Cannot find last sql file.") + return False + + sql_file = sql_file.get() + model.setSqlFile(sql_file) + # write file: any file named 'report*.*' in the current working directory + # will be copied to the ./reports/ folder as 'reports/_.html' + Path("report_python_without.txt").write_text("OK") + + # Close the sql file + sql_file.close() + + return True + + +# register the measure to be used by the application +PythonReportingMeasureWithoutModelOutputRequests().registerWithApplication() diff --git a/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.xml b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.xml new file mode 100644 index 0000000000..3fe627fb3b --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/PythonReportingMeasureWithoutModelOutputRequests/measure.xml @@ -0,0 +1,63 @@ + + + 3.1 + python_reporting_measure_without_model_output_requests + f1cbb7b8-e068-417e-8b2f-505f8441ab14 + 26cc30f8-03b9-4f32-a48c-6106c078e625 + 2025-03-14T16:42:43Z + 1DA817AF + PythonReportingMeasureWithoutModelOutputRequests + Reporting Measure without Model Output Requests + An older reporting measure that does not have modelOutputRequests + This is a measure that was added before 3.10.0 + + + report_drybulb_temp + Add output variables for Drybulb Temperature + Will add drybulb temp and report min/max values in html. + Boolean + true + false + + + true + true + + + false + false + + + + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Python + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.py + py + script + 9AA7CFE7 + + + diff --git a/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.rb b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.rb new file mode 100644 index 0000000000..6ff83d7f65 --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.rb @@ -0,0 +1,145 @@ +# insert your copyright here + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ + +require 'erb' + +# start the measure +class RubyReportingMeasureWithModelOutputRequests < OpenStudio::Measure::ReportingMeasure + # human readable name + def name + # Measure name should be the title case of the class name. + return 'Reporting Measure with Model Output Requests' + end + + # human readable description + def description + return 'A new reporting measure that has a modelOutputRequests' + end + + # human readable description of modeling approach + def modeler_description + return 'This was added at 3.10.0' + end + + # define the arguments that the user will input + def arguments(model = nil) + args = OpenStudio::Measure::OSArgumentVector.new + + # bool argument to report report_drybulb_temp + report_drybulb_temp = OpenStudio::Measure::OSArgument.makeBoolArgument('report_drybulb_temp', true) + report_drybulb_temp.setDisplayName('Add output variables for Drybulb Temperature') + report_drybulb_temp.setDescription('Will add drybulb temp and report min/mix value in html.') + report_drybulb_temp.setDefaultValue(true) + args << report_drybulb_temp + + add_output_json = OpenStudio::Measure::OSArgument.makeBoolArgument('add_output_json', true) + add_output_json.setDisplayName('Request JSON output') + add_output_json.setDescription('Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true') + add_output_json.setDefaultValue(true) + args << add_output_json + + return args + end + + # define the outputs that the measure will create + def outputs + outs = OpenStudio::Measure::OSOutputVector.new + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + end + + # This method is called on all reporting measures immediately before the translation to E+ IDF + # There is an implicit contract that this method should NOT be modifying your model in a way that would produce + # different results, meaning it should only add or modify reporting-related elements + # (eg: OutputTableSummaryReports, OutputControlFiles, etc) + # If you mean to modify the model in a significant way, use a `ModelMeasure` + # NOTE: this method will ONLY be called if you use the C++ CLI, not the `classic` (Ruby) one + def modelOutputRequests(model, runner, user_arguments) + if runner.getBoolArgumentValue('add_output_json', user_arguments) + output_json = model.getOutputJSON + output_json.setOptionType('TimeSeriesAndTabular') + output_json.setOutputJSON(true) + output_json.setOutputCBOR(false) + output_json.setOutputMessagePack(false) + end + + return true + end + + # return a vector of IdfObject's to request EnergyPlus objects needed by the run method + # Warning: Do not change the name of this method to be snake_case. The method must be lowerCamelCase. + # This is done after ForwardTranslation to IDF, and there is a list of + # accepted objects + def energyPlusOutputRequests(runner, user_arguments) + super(runner, user_arguments) # Do **NOT** remove this line + + result = OpenStudio::IdfObjectVector.new + + # To use the built-in error checking we need the model... + # get the last model and sql file + model = runner.lastOpenStudioModel + if model.empty? + runner.registerError('Cannot find last model.') + return result + end + model = model.get + + # use the built-in error checking + if !runner.validateUserArguments(arguments(model), user_arguments) + return result + end + + # NOTE: this should rather be done in modelOutputRequests + if runner.getBoolArgumentValue('report_drybulb_temp', user_arguments) + request = OpenStudio::IdfObject.load('Output:Variable,,Site Outdoor Air Drybulb Temperature,Hourly;').get + result << request + end + + return result + end + + # define what happens when the measure is run + def run(runner, user_arguments) + super(runner, user_arguments) + + # get the last model and sql file + model = runner.lastOpenStudioModel + if model.empty? + runner.registerError('Cannot find last model.') + return result + end + model = model.get + + # use the built-in error checking (need model) + if !runner.validateUserArguments(arguments(model), user_arguments) + return result + end + + # get measure arguments + report_drybulb_temp = runner.getBoolArgumentValue('report_drybulb_temp', user_arguments) + + # load sql file + sql_file = runner.lastEnergyPlusSqlFile + if sql_file.empty? + runner.registerError('Cannot find last sql file.') + return result + end + sql_file = sql_file.get + model.setSqlFile(sql_file) + # write file: any file named 'report*.*' in the current working directory + # will be copied to the ./reports/ folder as 'reports/_.html' + File.write("./report_ruby_with.txt", "OK") + + # close the sql file + sql_file.close + + return true + end +end + +# register the measure to be used by the application +RubyReportingMeasureWithModelOutputRequests.new.registerWithApplication diff --git a/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.xml b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.xml new file mode 100644 index 0000000000..4f34e86feb --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithModelOutputRequests/measure.xml @@ -0,0 +1,83 @@ + + + 3.1 + ruby_reporting_measure_with_model_output_requests + 6663879f-2582-42e8-967d-b209507f1721 + 70a45976-2ac0-43a6-9853-b6fae76f5908 + 2025-03-14T16:51:16Z + 58F01CAA + RubyReportingMeasureWithModelOutputRequests + Reporting Measure with Model Output Requests + A new reporting measure that has a modelOutputRequests + This was added at 3.10.0 + + + report_drybulb_temp + Add output variables for Drybulb Temperature + Will add drybulb temp and report min/mix value in html. + Boolean + true + false + true + + + true + true + + + false + false + + + + + add_output_json + Request JSON output + Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true + Boolean + true + false + true + + + true + true + + + false + false + + + + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.10.0 + 3.10.0 + + measure.rb + rb + script + 1E726765 + + + diff --git a/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.rb b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.rb new file mode 100644 index 0000000000..85dde9808d --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.rb @@ -0,0 +1,121 @@ +# insert your copyright here + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ + +require 'erb' + +# start the measure +class RubyReportingMeasureWithoutModelOutputRequests < OpenStudio::Measure::ReportingMeasure + # human readable name + def name + # Measure name should be the title case of the class name. + return 'Reporting Measure without Model Output Requests' + end + + # human readable description + def description + return 'An older reporting measure that does not have modelOutputRequests' + end + + # human readable description of modeling approach + def modeler_description + return 'This is a measure that was added before 3.10.0' + end + + # define the arguments that the user will input + def arguments(model = nil) + args = OpenStudio::Measure::OSArgumentVector.new + + # bool argument to report report_drybulb_temp + report_drybulb_temp = OpenStudio::Measure::OSArgument.makeBoolArgument('report_drybulb_temp', true) + report_drybulb_temp.setDisplayName('Add output variables for Drybulb Temperature') + report_drybulb_temp.setDescription('Will add drybulb temp and report min/mix value in html.') + report_drybulb_temp.setValue(true) + args << report_drybulb_temp + + return args + end + + # define the outputs that the measure will create + def outputs + outs = OpenStudio::Measure::OSOutputVector.new + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + end + + # return a vector of IdfObject's to request EnergyPlus objects needed by the run method + # Warning: Do not change the name of this method to be snake_case. The method must be lowerCamelCase. + # This is done after ForwardTranslation to IDF, and there is a list of + # accepted objects + def energyPlusOutputRequests(runner, user_arguments) + super(runner, user_arguments) # Do **NOT** remove this line + + result = OpenStudio::IdfObjectVector.new + + # To use the built-in error checking we need the model... + # get the last model and sql file + model = runner.lastOpenStudioModel + if model.empty? + runner.registerError('Cannot find last model.') + return result + end + model = model.get + + # use the built-in error checking + if !runner.validateUserArguments(arguments(model), user_arguments) + return result + end + + # NOTE: this should rather be done in modelOutputRequests + if runner.getBoolArgumentValue('report_drybulb_temp', user_arguments) + request = OpenStudio::IdfObject.load('Output:Variable,,Site Outdoor Air Drybulb Temperature,Hourly;').get + result << request + end + + return result + end + + # define what happens when the measure is run + def run(runner, user_arguments) + super(runner, user_arguments) + + # get the last model and sql file + model = runner.lastOpenStudioModel + if model.empty? + runner.registerError('Cannot find last model.') + return result + end + model = model.get + + # use the built-in error checking (need model) + if !runner.validateUserArguments(arguments(model), user_arguments) + return result + end + + # get measure arguments + report_drybulb_temp = runner.getBoolArgumentValue('report_drybulb_temp', user_arguments) + + # load sql file + sql_file = runner.lastEnergyPlusSqlFile + if sql_file.empty? + runner.registerError('Cannot find last sql file.') + return false + end + sql_file = sql_file.get + model.setSqlFile(sql_file) + # write file: any file named 'report*.*' in the current working directory + # will be copied to the ./reports/ folder as 'reports/_.html' + File.write("./report_ruby_without.txt", "OK") + + # close the sql file + sql_file.close + + return true + end +end + +# register the measure to be used by the application +RubyReportingMeasureWithoutModelOutputRequests.new.registerWithApplication diff --git a/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.xml b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.xml new file mode 100644 index 0000000000..47d8bccdcb --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/measures/RubyReportingMeasureWithoutModelOutputRequests/measure.xml @@ -0,0 +1,63 @@ + + + 3.1 + ruby_reporting_measure_without_model_output_requests + 7fe3d1eb-2118-4cac-91e3-94fd489b97af + d07158b5-8a23-44ea-85d0-dd234c9f9cb9 + 2025-03-14T16:51:24Z + 58F01CAA + RubyReportingMeasureWithoutModelOutputRequests + Reporting Measure without Model Output Requests + An older reporting measure that does not have modelOutputRequests + This is a measure that was added before 3.10.0 + + + report_drybulb_temp + Add output variables for Drybulb Temperature + Will add drybulb temp and report min/mix value in html. + Boolean + true + false + + + true + true + + + false + false + + + + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.rb + rb + script + B3C25E43 + + + diff --git a/resources/workflow/reporting_modeloutputrequests/python.osw b/resources/workflow/reporting_modeloutputrequests/python.osw new file mode 100644 index 0000000000..8a480fd325 --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/python.osw @@ -0,0 +1,12 @@ +{ + "weather_file": "../../Examples/compact_osw/files/srrl_2013_amy.epw", + "seed_file": "../example_model.osm", + "steps": [ + { + "measure_dir_name": "PythonReportingMeasureWithModelOutputRequests" + }, + { + "measure_dir_name": "PythonReportingMeasureWithoutModelOutputRequests" + } + ] +} diff --git a/resources/workflow/reporting_modeloutputrequests/ruby.osw b/resources/workflow/reporting_modeloutputrequests/ruby.osw new file mode 100644 index 0000000000..32a008fa2c --- /dev/null +++ b/resources/workflow/reporting_modeloutputrequests/ruby.osw @@ -0,0 +1,12 @@ +{ + "weather_file": "../../Examples/compact_osw/files/srrl_2013_amy.epw", + "seed_file": "../example_model.osm", + "steps": [ + { + "measure_dir_name": "RubyReportingMeasureWithModelOutputRequests" + }, + { + "measure_dir_name": "RubyReportingMeasureWithoutModelOutputRequests" + } + ] +} diff --git a/ruby/engine/RubyEngine.cpp b/ruby/engine/RubyEngine.cpp index 7db74faec6..26e338efda 100644 --- a/ruby/engine/RubyEngine.cpp +++ b/ruby/engine/RubyEngine.cpp @@ -237,6 +237,25 @@ int RubyEngine::numberOfArguments(ScriptObject& methodObject, std::string_view m return rb_obj_method_arity(val, method_id); } +bool RubyEngine::hasMethod(ScriptObject& methodObject, std::string_view methodName, bool overriden_only) { + auto val = std::any_cast(methodObject.object); + ID method_id = rb_intern(methodName.data()); + if (rb_respond_to(val, method_id) == 0) { + return false; + } + if (!overriden_only) { + return true; + } + + // I'd have prefered to do the equivalent of `instance_obj.method(:methodName).owner == instance_obj.class` but that is borderline impossible + // Instead, this is equivalent to `instance_obj.class.instance_methods(false).include?(:methodName)` + VALUE klass = rb_obj_class(val); + // include_methods_from_ancestors: false; + VALUE args[1] = {Qfalse}; + VALUE methods_without_ancestors = rb_class_instance_methods(1, args, klass); + return rb_ary_includes(methods_without_ancestors, ID2SYM(method_id)) == Qtrue; +} + } // namespace openstudio extern "C" diff --git a/ruby/engine/RubyEngine.hpp b/ruby/engine/RubyEngine.hpp index 671da26643..90c0bb1153 100644 --- a/ruby/engine/RubyEngine.hpp +++ b/ruby/engine/RubyEngine.hpp @@ -39,6 +39,8 @@ class RubyEngine final : public ScriptEngine virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) override; + virtual bool hasMethod(ScriptObject& methodObject, std::string_view methodName, bool overriden_only) override; + protected: // convert the underlying object to the correct type, then return it as a void * // so the above template function can provide it back to the caller. diff --git a/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.rb b/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.rb new file mode 100644 index 0000000000..4f192f1638 --- /dev/null +++ b/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.rb @@ -0,0 +1,63 @@ +# insert your copyright here + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ + +require 'erb' + +# start the measure +class ReportingMeasureWithModelOutputs < OpenStudio::Measure::ReportingMeasure + # human readable name + def name + # Measure name should be the title case of the class name. + return 'ReportingMeasureWithModelOutputs' + end + + # human readable description + def description + return 'DESCRIPTION_TEXT' + end + + # human readable description of modeling approach + def modeler_description + return 'MODELER_DESCRIPTION_TEXT' + end + + # define the arguments that the user will input + def arguments(model = nil) + args = OpenStudio::Measure::OSArgumentVector.new + + return args + end + + # define the outputs that the measure will create + def outputs + outs = OpenStudio::Measure::OSOutputVector.new + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + end + + def modelOutputRequests(model, runner, user_arguments) + return true + end + + # return a vector of IdfObject's to request EnergyPlus objects needed by the run method + # Warning: Do not change the name of this method to be snake_case. The method must be lowerCamelCase. + def energyPlusOutputRequests(runner, user_arguments) + result = OpenStudio::IdfObjectVector.new + + return result + end + + # define what happens when the measure is run + def run(runner, user_arguments) + super(runner, user_arguments) + + return true + end +end + +# register the measure to be used by the application +ReportingMeasureWithModelOutputs.new.registerWithApplication diff --git a/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.xml b/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.xml new file mode 100644 index 0000000000..ebdf15d302 --- /dev/null +++ b/ruby/engine/test/ReportingMeasureWithModelOutputs/measure.xml @@ -0,0 +1,44 @@ + + + 3.1 + reporting_measure_with_model_outputs + 3bfd478b-0575-4a4e-b347-ea3d82f66c21 + 95038fac-6565-4c3a-be46-fd14ac12bcee + 2025-03-14T13:14:52Z + 58F01CAA + ReportingMeasureWithModelOutputs + ReportingMeasureWithModelOutputs + DESCRIPTION_TEXT + MODELER_DESCRIPTION_TEXT + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.rb + rb + script + 811D6B05 + + + diff --git a/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.rb b/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.rb new file mode 100644 index 0000000000..526aecad57 --- /dev/null +++ b/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.rb @@ -0,0 +1,59 @@ +# insert your copyright here + +# see the URL below for information on how to write OpenStudio measures +# http://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/ + +require 'erb' + +# start the measure +class ReportingMeasureWithoutModelOutputs < OpenStudio::Measure::ReportingMeasure + # human readable name + def name + # Measure name should be the title case of the class name. + return 'ReportingMeasureWithoutModelOutputs' + end + + # human readable description + def description + return 'DESCRIPTION_TEXT' + end + + # human readable description of modeling approach + def modeler_description + return 'MODELER_DESCRIPTION_TEXT' + end + + # define the arguments that the user will input + def arguments(model = nil) + args = OpenStudio::Measure::OSArgumentVector.new + + return args + end + + # define the outputs that the measure will create + def outputs + outs = OpenStudio::Measure::OSOutputVector.new + + # this measure does not produce machine readable outputs with registerValue, return an empty list + + return outs + end + + # return a vector of IdfObject's to request EnergyPlus objects needed by the run method + # Warning: Do not change the name of this method to be snake_case. The method must be lowerCamelCase. + def energyPlusOutputRequests(runner, user_arguments) + result = OpenStudio::IdfObjectVector.new + + return result + end + + # define what happens when the measure is run + def run(runner, user_arguments) + super(runner, user_arguments) + + return true + end +end + +# register the measure to be used by the application +ReportingMeasureWithoutModelOutputs.new.registerWithApplication diff --git a/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml b/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml new file mode 100644 index 0000000000..0455fdf89d --- /dev/null +++ b/ruby/engine/test/ReportingMeasureWithoutModelOutputs/measure.xml @@ -0,0 +1,44 @@ + + + 3.1 + reporting_measure_without_model_outputs + 0a873e3c-1dba-49c2-8c22-1f4e8661e74b + b7c5702f-b0bd-4b6c-9ff5-53f977e68f3e + 2025-03-14T13:15:10Z + 58F01CAA + ReportingMeasureWithoutModelOutputs + ReportingMeasureWithoutModelOutputs + DESCRIPTION_TEXT + MODELER_DESCRIPTION_TEXT + + + + + Reporting.QAQC + + + + Measure Type + ReportingMeasure + string + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.9.0 + 3.9.0 + + measure.rb + rb + script + 171A0196 + + + diff --git a/ruby/engine/test/RubyEngine_GTest.cpp b/ruby/engine/test/RubyEngine_GTest.cpp index 5373525171..4e89b28a0e 100644 --- a/ruby/engine/test/RubyEngine_GTest.cpp +++ b/ruby/engine/test/RubyEngine_GTest.cpp @@ -176,3 +176,22 @@ Traceback (most recent call last): }, "stack level too deep"); } + +TEST_F(RubyEngineFixture, hasMethod) { + { + const std::string classAndDirName = "ReportingMeasureWithoutModelOutputs"; + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "doesNotExists")); + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests", false)); // overriden_only = false + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests")); + } + { + const std::string classAndDirName = "ReportingMeasureWithModelOutputs"; + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + EXPECT_FALSE((*thisEngine)->hasMethod(measureScriptObject, "doesNotExists")); + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests", false)); // overriden_only = false + EXPECT_TRUE((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests")); + } +} diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 74c557322a..152c06c4e3 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -358,6 +358,11 @@ if(BUILD_TESTING) WORKING_DIRECTORY "${PROJECT_BINARY_DIR}/resources/workflow/output_test/" ) + add_test(NAME OpenStudioCLI.test_reporting_modeloutputrequests + COMMAND ${Python_EXECUTABLE} -m pytest --verbose --os-cli-path $ "${CMAKE_CURRENT_SOURCE_DIR}/test/test_reporting_modeloutputrequests.py" + WORKING_DIRECTORY "${PROJECT_BINARY_DIR}/resources/workflow/reporting_modeloutputrequests/" + ) + file(MAKE_DIRECTORY "${PROJECT_BINARY_DIR}/Testing/") add_test(NAME OpenStudioCLI.test_bcl_measure_templates COMMAND ${Python_EXECUTABLE} -m pytest --verbose --os-cli-path $ "${CMAKE_CURRENT_SOURCE_DIR}/test/test_bcl_measure_templates.py" diff --git a/src/cli/test/test_reporting_modeloutputrequests.py b/src/cli/test/test_reporting_modeloutputrequests.py new file mode 100644 index 0000000000..d593c63381 --- /dev/null +++ b/src/cli/test/test_reporting_modeloutputrequests.py @@ -0,0 +1,29 @@ +import pytest + +from workflow_helpers import run_workflow + +@pytest.mark.parametrize( + "language, is_labs", + [ + ["ruby", False], + ["ruby", True], + # ["python", False], // Not possible + ["python", True], + ], +) + +def test_reportingmeasure_model_output_requests(osclipath, language: str, is_labs: bool): + suffix = "labs" if is_labs else "classic" + base_osw_name = f"{language}.osw" + + runDir, r = run_workflow( + osclipath=osclipath, + base_osw_name=base_osw_name, + suffix=suffix, + is_labs=is_labs, + verbose=False, + debug=True, + post_process_only=False, + ) + r.check_returncode() + assert r.returncode == 0 diff --git a/src/measure/ReportingMeasure.cpp b/src/measure/ReportingMeasure.cpp index e40ad4f8b5..710217e640 100644 --- a/src/measure/ReportingMeasure.cpp +++ b/src/measure/ReportingMeasure.cpp @@ -29,6 +29,11 @@ namespace measure { return true; } + bool ReportingMeasure::modelOutputRequests(openstudio::model::Model& /*model*/, OSRunner& /*runner*/, + const std::map& /*user_arguments*/) const { + return true; + } + std::vector ReportingMeasure::energyPlusOutputRequests(OSRunner& /*runner*/, const std::map& /*user_arguments*/) const { return {}; diff --git a/src/measure/ReportingMeasure.hpp b/src/measure/ReportingMeasure.hpp index 0fdc2142d6..64a1293252 100644 --- a/src/measure/ReportingMeasure.hpp +++ b/src/measure/ReportingMeasure.hpp @@ -55,6 +55,15 @@ namespace measure { * super(runner, user_arguments). */ virtual bool run(OSRunner& runner, const std::map& user_arguments) const; + /** This method is called on all reporting measures immediately before the translation to E+ IDF. + * There is an implicit contract that this method should NOT be modifying your model in a way that would produce + * different results, meaning it should only add or modify reporting-related elements (eg: OutputTableSummaryReports, OutputControlFiles, etc) + * but that is not going to be enforced. + * If you mean to modify the model in a significant way, use a ModelMeasure. + */ + virtual bool modelOutputRequests(openstudio::model::Model& model, OSRunner& runner, + const std::map& user_arguments) const; + /** This method is called on all reporting measures immediately before the E+ * simulation. The code that injects these objects into the IDF checks that * only objects of allowed types are added to prevent changes that impact diff --git a/src/scriptengine/ScriptEngine.hpp b/src/scriptengine/ScriptEngine.hpp index cca628b50a..d33f63208b 100644 --- a/src/scriptengine/ScriptEngine.hpp +++ b/src/scriptengine/ScriptEngine.hpp @@ -77,8 +77,12 @@ class ScriptEngine // issue for the underlying ScriptObject (and VALUE or PyObject), so just return the ScriptObject virtual ScriptObject loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) = 0; + // Returns number of arguments for methodName of the object methodObject virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) = 0; + // Check if methodObject has a method called methodName. If overriden_only, will only look into the current subclass, not the parent ones + virtual bool hasMethod(ScriptObject& methodObject, std::string_view methodName, bool overriden_only = true) = 0; + template T getAs(ScriptObject& obj) { if constexpr (std::is_same_v) { diff --git a/src/utilities/bcl/templates/ReportingMeasure/measure.py b/src/utilities/bcl/templates/ReportingMeasure/measure.py index 47577a2150..abcfe383a6 100644 --- a/src/utilities/bcl/templates/ReportingMeasure/measure.py +++ b/src/utilities/bcl/templates/ReportingMeasure/measure.py @@ -12,7 +12,7 @@ class ReportingMeasureName(openstudio.measure.ReportingMeasure): """An ReportingMeasure.""" - def name(self): + def name(self) -> str: """Returns the human readable name. Measure name should be the title case of the class name. @@ -24,7 +24,7 @@ def name(self): """ return "NAME_TEXT" - def description(self): + def description(self) -> str: """Human readable description. The measure description is intended for a general audience and should not assume @@ -32,7 +32,7 @@ def description(self): """ return "DESCRIPTION_TEXT" - def modeler_description(self): + def modeler_description(self) -> str: """Human readable description of modeling approach. The modeler description is intended for the energy modeler using the measure. @@ -42,7 +42,7 @@ def modeler_description(self): """ return "MODELER_DESCRIPTION_TEXT" - def arguments(self, model: openstudio.model.Model): + def arguments(self, model: openstudio.model.Model) -> openstudio.measure.OSArgumentVector: """Prepares user arguments for the measure. Measure arguments define which -- if any -- input parameters the user may set before running the measure. @@ -52,12 +52,18 @@ def arguments(self, model: openstudio.model.Model): report_drybulb_temp = openstudio.measure.OSArgument.makeBoolArgument("report_drybulb_temp", True) report_drybulb_temp.setDisplayName("Add output variables for Drybulb Temperature") report_drybulb_temp.setDescription("Will add drybulb temp and report min/max values in html.") - report_drybulb_temp.setValue(True) + report_drybulb_temp.setDefaultValue(True) args.append(report_drybulb_temp) + add_output_json = openstudio.measure.OSArgument.makeBoolArgument("add_output_json", True) + add_output_json.setDisplayName("Request JSON output") + add_output_json.setDescription("Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true") + add_output_json.setDefaultValue(True) + args.append(add_output_json) + return args - def outputs(self): + def outputs(self) -> openstudio.measure.OSOutputVector: """Define the outputs that the measure will create.""" outs = openstudio.measure.OSOutputVector() @@ -65,10 +71,37 @@ def outputs(self): return outs + def modelOutputRequests( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ) -> bool: + """This method is called on all reporting measures immediately before the translation to E+ IDF. + + There is an implicit contract that this method should NOT be modifying your model in a way that would produce + different results, meaning it should only add or modify reporting-related elements + (eg: OutputTableSummaryReports, OutputControlFiles, etc) + + If you mean to modify the model in a significant way, use a `ModelMeasure` + NOTE: this method will ONLY be called if you use the C++ CLI, not the `classic` (Ruby) one + """ + if runner.getBoolArgumentValue("add_output_json", user_arguments): + output_json = model.getOutputJSON() + output_json.setOptionType("TimeSeriesAndTabular") + output_json.setOutputJSON(True) + output_json.setOutputCBOR(False) + output_json.setOutputMessagePack(False) + + return True + def energyPlusOutputRequests( self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap - ): - """Returns a vector of IdfObject's to request EnergyPlus objects needed by the run method.""" + ) -> openstudio.IdfObjectVector: + """Returns a vector of IdfObject's to request EnergyPlus objects needed by the run method. + + This is done after ForwardTranslation to IDF, and there is a list of accepted objects. + """ super().energyPlusOutputRequests(runner, user_arguments) # Do **NOT** remove this line result = openstudio.IdfObjectVector() @@ -78,13 +111,13 @@ def energyPlusOutputRequests( model = runner.lastOpenStudioModel() if not model.is_initialized(): runner.registerError("Cannot find last model.") - return False + return result model = model.get() # use the built-in error checking if not runner.validateUserArguments(self.arguments(model), user_arguments): - return False + return result if runner.getBoolArgumentValue("report_drybulb_temp", user_arguments): request = openstudio.IdfObject.load( @@ -98,7 +131,7 @@ def run( self, runner: openstudio.measure.OSRunner, user_arguments: openstudio.measure.OSArgumentMap, - ): + ) -> bool: """Defines what happens when the measure is run.""" super().run(runner, user_arguments) diff --git a/src/utilities/bcl/templates/ReportingMeasure/measure.rb b/src/utilities/bcl/templates/ReportingMeasure/measure.rb index ece24e3fd1..ab773e0a9a 100644 --- a/src/utilities/bcl/templates/ReportingMeasure/measure.rb +++ b/src/utilities/bcl/templates/ReportingMeasure/measure.rb @@ -31,9 +31,15 @@ def arguments(model = nil) report_drybulb_temp = OpenStudio::Measure::OSArgument.makeBoolArgument('report_drybulb_temp', true) report_drybulb_temp.setDisplayName('Add output variables for Drybulb Temperature') report_drybulb_temp.setDescription('Will add drybulb temp and report min/mix value in html.') - report_drybulb_temp.setValue(true) + report_drybulb_temp.setDefaultValue(true) args << report_drybulb_temp + add_output_json = OpenStudio::Measure::OSArgument.makeBoolArgument('add_output_json', true) + add_output_json.setDisplayName('Request JSON output') + add_output_json.setDescription('Will add Output:JSON with TimeSeriesAndTabular and set Output JSON to true') + add_output_json.setDefaultValue(true) + args << add_output_json + return args end @@ -46,8 +52,28 @@ def outputs return outs end + # This method is called on all reporting measures immediately before the translation to E+ IDF + # There is an implicit contract that this method should NOT be modifying your model in a way that would produce + # different results, meaning it should only add or modify reporting-related elements + # (eg: OutputTableSummaryReports, OutputControlFiles, etc) + # If you mean to modify the model in a significant way, use a `ModelMeasure` + # NOTE: this method will ONLY be called if you use the C++ CLI, not the `classic` (Ruby) one + def modelOutputRequests(model, runner, user_arguments) + if runner.getBoolArgumentValue('add_output_json', user_arguments) + output_json = model.getOutputJSON + output_json.setOptionType('TimeSeriesAndTabular') + output_json.setOutputJSON(true) + output_json.setOutputCBOR(false) + output_json.setOutputMessagePack(false) + end + + return true + end + # return a vector of IdfObject's to request EnergyPlus objects needed by the run method # Warning: Do not change the name of this method to be snake_case. The method must be lowerCamelCase. + # This is done after ForwardTranslation to IDF, and there is a list of + # accepted objects def energyPlusOutputRequests(runner, user_arguments) super(runner, user_arguments) # Do **NOT** remove this line @@ -58,15 +84,16 @@ def energyPlusOutputRequests(runner, user_arguments) model = runner.lastOpenStudioModel if model.empty? runner.registerError('Cannot find last model.') - return false + return result end model = model.get # use the built-in error checking if !runner.validateUserArguments(arguments(model), user_arguments) - return false + return result end + # NOTE: this should rather be done in modelOutputRequests if runner.getBoolArgumentValue('report_drybulb_temp', user_arguments) request = OpenStudio::IdfObject.load('Output:Variable,,Site Outdoor Air Drybulb Temperature,Hourly;').get result << request diff --git a/src/utilities/bcl/templates/ReportingMeasure/tests/reporting_measure_test.rb b/src/utilities/bcl/templates/ReportingMeasure/tests/reporting_measure_test.rb index 15e35d7e56..21dc3bf426 100644 --- a/src/utilities/bcl/templates/ReportingMeasure/tests/reporting_measure_test.rb +++ b/src/utilities/bcl/templates/ReportingMeasure/tests/reporting_measure_test.rb @@ -84,7 +84,6 @@ def setup_test(test_name, idf_output_requests, model_in_path = model_in_path_def cmd = "\"#{cli_path}\" run -w \"#{osw_path}\"" puts cmd system(cmd) - end def test_number_of_arguments_and_argument_names @@ -96,7 +95,14 @@ def test_number_of_arguments_and_argument_names # get arguments and test that they are what we are expecting arguments = measure.arguments(model) - assert_equal(1, arguments.size) + assert_equal(2, arguments.size) + assert_equal("report_drybulb_temp", arguments[0].name) + assert(arguments[0].hasDefaultValue) + assert(arguments[0].defaultValueAsBool) + + assert_equal("add_output_json", arguments[1].name) + assert(arguments[1].hasDefaultValue) + assert(arguments[1].defaultValueAsBool) end def test_with_drybulb_temp @@ -181,10 +187,19 @@ def test_without_drybulb_temp arguments = measure.arguments(model) argument_map = OpenStudio::Measure.convertOSArgumentVectorToMap(arguments) + # create hash of argument values # set non-default measure argument - report_drybulb_temp = arguments[0].clone - assert(report_drybulb_temp.setValue(false)) - argument_map['report_drybulb_temp'] = report_drybulb_temp + args_hash = {} + args_hash['report_drybulb_temp'] = false + + # populate argument with specified hash value if specified + arguments.each do |arg| + temp_arg_var = arg.clone + if args_hash.key?(arg.name) + assert(temp_arg_var.setValue(args_hash[arg.name])) + end + argument_map[arg.name] = temp_arg_var + end # temp set path so idf_output_requests work runner.setLastOpenStudioModelPath(model_in_path_default) @@ -235,5 +250,67 @@ def test_without_drybulb_temp # make sure the report file exists assert_path_exists(report_path(test_name)) end + + def test_model_output_requests_with_output_json + # create an instance of the measure + measure = ReportingMeasureName.new + + # create runner with empty OSW + osw = OpenStudio::WorkflowJSON.new + runner = OpenStudio::Measure::OSRunner.new(osw) + + # Make an empty model + model = OpenStudio::Model::Model.new + + # get arguments + arguments = measure.arguments(model) + argument_map = OpenStudio::Measure.convertOSArgumentVectorToMap(arguments) + + assert_empty(model.outputJSON) + + assert(measure.modelOutputRequests(model, runner, argument_map)) + + refute_empty(model.outputJSON) + output_json = model.getOutputJSON + assert_equal('TimeSeriesAndTabular', output_json.optionType) + assert(output_json.outputJSON) + refute(output_json.outputCBOR) + refute(output_json.outputMessagePack) + end + + def test_model_output_requests_without_output_json + # create an instance of the measure + measure = ReportingMeasureName.new + + # create runner with empty OSW + osw = OpenStudio::WorkflowJSON.new + runner = OpenStudio::Measure::OSRunner.new(osw) + + # Make an empty model + model = OpenStudio::Model::Model.new + + # get arguments + arguments = measure.arguments(model) + argument_map = OpenStudio::Measure.convertOSArgumentVectorToMap(arguments) + + # create hash of argument values + args_hash = {} + args_hash['add_output_json'] = false + + # populate argument with specified hash value if specified + arguments.each do |arg| + temp_arg_var = arg.clone + if args_hash.key?(arg.name) + assert(temp_arg_var.setValue(args_hash[arg.name])) + end + argument_map[arg.name] = temp_arg_var + end + + assert_empty(model.outputJSON) + + assert(measure.modelOutputRequests(model, runner, argument_map)) + + assert_empty(model.outputJSON) + end end diff --git a/src/utilities/bcl/templates/ReportingMeasure/tests/test_reporting_measure.py b/src/utilities/bcl/templates/ReportingMeasure/tests/test_reporting_measure.py index 7c04eadb71..43a6eabc20 100644 --- a/src/utilities/bcl/templates/ReportingMeasure/tests/test_reporting_measure.py +++ b/src/utilities/bcl/templates/ReportingMeasure/tests/test_reporting_measure.py @@ -12,8 +12,9 @@ CURRENT_DIR_PATH = Path(__file__).parent.absolute() sys.path.insert(0, str(CURRENT_DIR_PATH.parent)) from measure import ReportingMeasureName + sys.path.pop(0) -del sys.modules['measure'] +del sys.modules["measure"] MODEL_IN_PATH_DEFAULT = CURRENT_DIR_PATH / "example_model.osm" @@ -103,8 +104,14 @@ def test_number_of_arguments_and_argument_names(self): # get arguments and test that they are what we are expecting arguments = measure.arguments(model) - assert arguments.size() == 1 + assert arguments.size() == 2 assert arguments[0].name() == "report_drybulb_temp" + assert arguments[0].hasDefaultValue() + assert arguments[0].defaultValueAsBool() + + assert arguments[1].name() == "add_output_json" + assert arguments[1].hasDefaultValue() + assert arguments[1].defaultValueAsBool() def test_with_drybulb_temp(self): """Test running the measure with appropriate arguments, with db temp.""" @@ -124,11 +131,6 @@ def test_with_drybulb_temp(self): arguments = measure.arguments(model) argument_map = openstudio.measure.convertOSArgumentVectorToMap(arguments) - # set argument values to bad value - report_drybulb_temp = arguments[0].clone() - assert report_drybulb_temp.setValue(True) - argument_map["report_drybulb_temp"] = report_drybulb_temp - # temp set path so idf_output_requests work runner.setLastOpenStudioModelPath(MODEL_IN_PATH_DEFAULT) @@ -199,10 +201,18 @@ def test_without_drybulb_temp(self): arguments = measure.arguments(model) argument_map = openstudio.measure.convertOSArgumentVectorToMap(arguments) - # set argument values to bad value - report_drybulb_temp = arguments[0].clone() - assert report_drybulb_temp.setValue(False) - argument_map["report_drybulb_temp"] = report_drybulb_temp + # create hash of argument values. + # If the argument has a default that you want to use, + # you don't need it in the dict + args_dict = {} + args_dict["report_drybulb_temp"] = False + + # populate argument with specified hash value if specified + for arg in arguments: + temp_arg_var = arg.clone() + if arg.name() in args_dict: + assert temp_arg_var.setValue(args_dict[arg.name()]) + argument_map[arg.name()] = temp_arg_var # temp set path so idf_output_requests work runner.setLastOpenStudioModelPath(MODEL_IN_PATH_DEFAULT) @@ -256,10 +266,82 @@ def test_without_drybulb_temp(self): # make sure the report file exists assert report_path.exists() + def test_model_output_requests_with_output_json(self): + """Test running the modelOutputRequests with output_json.""" + # create an instance of the measure + measure = ReportingMeasureName() + + # create runner with empty OSW + osw = openstudio.WorkflowJSON() + runner = openstudio.measure.OSRunner(osw) + + # make an empty model + model = openstudio.model.Model() + + # get arguments + arguments = measure.arguments(model) + argument_map = openstudio.measure.convertOSArgumentVectorToMap(arguments) + + # create hash of argument values. + args_dict = {} + args_dict["add_output_json"] = True + + # populate argument with specified hash value if specified + for arg in arguments: + temp_arg_var = arg.clone() + if arg.name() in args_dict: + assert temp_arg_var.setValue(args_dict[arg.name()]) + argument_map[arg.name()] = temp_arg_var + + assert not model.outputJSON().is_initialized() + + assert measure.modelOutputRequests(model, runner, argument_map) + + assert model.outputJSON().is_initialized() + output_json = model.getOutputJSON() + assert output_json.optionType() == "TimeSeriesAndTabular" + assert output_json.outputJSON() + assert not output_json.outputCBOR() + assert not output_json.outputMessagePack() + + def test_model_output_requests_without_output_json(self): + """Test running the modelOutputRequests without output_json.""" + # create an instance of the measure + measure = ReportingMeasureName() + + # create runner with empty OSW + osw = openstudio.WorkflowJSON() + runner = openstudio.measure.OSRunner(osw) + + # make an empty model + model = openstudio.model.Model() + + # get arguments + arguments = measure.arguments(model) + argument_map = openstudio.measure.convertOSArgumentVectorToMap(arguments) + + # create hash of argument values. + args_dict = {} + args_dict["add_output_json"] = False + + # populate argument with specified hash value if specified + for arg in arguments: + temp_arg_var = arg.clone() + if arg.name() in args_dict: + assert temp_arg_var.setValue(args_dict[arg.name()]) + argument_map[arg.name()] = temp_arg_var + + assert not model.outputJSON().is_initialized() + + assert measure.modelOutputRequests(model, runner, argument_map) + + assert not model.outputJSON().is_initialized() + # This allows running openstudio CLI on this file (`openstudio test_measure.py`, maybe with extra args) if __name__ == "__main__": import sys + if len(sys.argv) > 1: pytest.main([__file__] + sys.argv[1:]) else: diff --git a/src/workflow/ApplyMeasure.cpp b/src/workflow/ApplyMeasure.cpp index d2d9a2e122..c522604bd2 100644 --- a/src/workflow/ApplyMeasure.cpp +++ b/src/workflow/ApplyMeasure.cpp @@ -45,7 +45,7 @@ bool isStepMarkedSkip(const std::map& stepArgs return skip_measure; } -void OSWorkflow::applyMeasures(MeasureType measureType, bool energyplus_output_requests) { +void OSWorkflow::applyMeasures(MeasureType measureType, ApplyMeasureType apply_measure_type) { if (m_add_timings && m_detailed_timings) { m_timers->newTimer(fmt::format("{}:apply_measures", measureType.valueName()), 1); @@ -66,7 +66,7 @@ void OSWorkflow::applyMeasures(MeasureType measureType, bool energyplus_output_r auto stepArgs = step.arguments(); const bool skip_measure = isStepMarkedSkip(stepArgs); if (skip_measure || runner.halted()) { - if (!energyplus_output_requests) { + if (apply_measure_type == ApplyMeasureType::Regular) { if (runner.halted()) { LOG(Info, fmt::format("Skipping measure '{}' because simulation halted", measureDirName)); } else { @@ -276,7 +276,16 @@ end } else if (measureType == MeasureType::EnergyPlusMeasure) { static_cast(measurePtr)->run(workspace_.get(), runner, argmap); } else if (measureType == MeasureType::ReportingMeasure) { - if (energyplus_output_requests) { + if (apply_measure_type == ApplyMeasureType::ModelOutputRequests) { + if ((*thisEngine)->hasMethod(measureScriptObject, "modelOutputRequests")) { + auto n = model.numObjects(); + LOG(Debug, "Calling measure.modelOutputRequests for '" << measureDirName << "'"); + static_cast(measurePtr)->modelOutputRequests(model, runner, argmap); + LOG(Debug, "Finished measure.modelOutputRequests for '" << measureDirName << "', " << (model.numObjects() - n) << " objects added"); + } else { + LOG(Debug, "Reporting Measure '" << measureDirName << "' does not have a modelOutputRequests method"); + } + } else if (apply_measure_type == ApplyMeasureType::EnergyPlusOutputRequest) { LOG(Debug, "Calling measure.energyPlusOutputRequests for '" << measureDirName << "'"); std::vector idfObjects; @@ -295,7 +304,7 @@ end } } catch (const std::exception& e) { runner.registerError(e.what()); - if (!energyplus_output_requests) { + if (apply_measure_type == ApplyMeasureType::Regular) { WorkflowStepResult result = runner.result(); // incrementStep must be called after run runner.incrementStep(); @@ -306,7 +315,7 @@ end } // if doing output requests we are done now - if (!energyplus_output_requests) { + if (apply_measure_type == ApplyMeasureType::Regular) { WorkflowStepResult result = runner.result(); // incrementStep must be called after run diff --git a/src/workflow/OSWorkflow.hpp b/src/workflow/OSWorkflow.hpp index 77e3f92bd2..ad558b7a3c 100644 --- a/src/workflow/OSWorkflow.hpp +++ b/src/workflow/OSWorkflow.hpp @@ -124,7 +124,15 @@ class OSWorkflow void initializeWeatherFileFromOSW(); void updateLastWeatherFileFromModel(); - void applyMeasures(MeasureType measureType, bool energyplus_output_requests = false); + + enum class ApplyMeasureType + { + Regular = 0, + ModelOutputRequests, + EnergyPlusOutputRequest + }; + + void applyMeasures(MeasureType measureType, ApplyMeasureType = ApplyMeasureType::Regular); static void applyArguments(measure::OSArgumentMap& argumentMap, const std::string& argumentName, const openstudio::Variant& argumentValue); void saveOSMToRootDirIfDebug(); void saveIDFToRootDirIfDebug(); diff --git a/src/workflow/RunEnergyPlusMeasures.cpp b/src/workflow/RunEnergyPlusMeasures.cpp index 08c2f5a685..596962a257 100644 --- a/src/workflow/RunEnergyPlusMeasures.cpp +++ b/src/workflow/RunEnergyPlusMeasures.cpp @@ -19,7 +19,7 @@ void OSWorkflow::runEnergyPlusMeasures() { // Weather file is handled in runInitialization LOG(Info, "Beginning to execute EnergyPlus Measures."); - applyMeasures(MeasureType::EnergyPlusMeasure, false); + applyMeasures(MeasureType::EnergyPlusMeasure, ApplyMeasureType::Regular); LOG(Info, "Finished applying EnergyPlus Measures."); communicateMeasureAttributes(); diff --git a/src/workflow/RunOpenStudioMeasures.cpp b/src/workflow/RunOpenStudioMeasures.cpp index 50d2961261..bad1ec3314 100644 --- a/src/workflow/RunOpenStudioMeasures.cpp +++ b/src/workflow/RunOpenStudioMeasures.cpp @@ -19,9 +19,13 @@ void OSWorkflow::runOpenStudioMeasures() { // Weather file is handled in runInitialization LOG(Info, "Beginning to execute OpenStudio Measures"); - applyMeasures(MeasureType::ModelMeasure, false); + applyMeasures(MeasureType::ModelMeasure, ApplyMeasureType::Regular); LOG(Info, "Finished applying OpenStudio Measures."); + LOG(Info, "Beginning to execute Reporting Measures's Model Output Requests"); + applyMeasures(MeasureType::ReportingMeasure, ApplyMeasureType::ModelOutputRequests); + LOG(Info, "Finished applying Reporting Measures's Model Output Requests.") + // Save final OSM if (!workflowJSON.runOptions()->fast()) { // Save to run dir diff --git a/src/workflow/RunPreProcess.cpp b/src/workflow/RunPreProcess.cpp index 12ce6c2aea..4d4049dde1 100644 --- a/src/workflow/RunPreProcess.cpp +++ b/src/workflow/RunPreProcess.cpp @@ -43,10 +43,9 @@ void OSWorkflow::runPreProcess() { } // Add any EnergyPlus Output Requests from Reporting Measures - LOG(Info, "Beginning to collect output requests from Reporting measures."); - const bool energyplus_output_requests = true; - applyMeasures(MeasureType::ReportingMeasure, energyplus_output_requests); - LOG(Info, "Finished collecting output requests from Reporting measures."); + LOG(Info, "Beginning to collect EnergyPlus output requests from Reporting measures."); + applyMeasures(MeasureType::ReportingMeasure, ApplyMeasureType::EnergyPlusOutputRequest); + LOG(Info, "Finished collecting EnergyPlus output requests from Reporting measures."); // Skip the pre-processor if halted if (runner.halted()) { diff --git a/src/workflow/RunReportingMeasures.cpp b/src/workflow/RunReportingMeasures.cpp index 365e269fcd..c97563ba30 100644 --- a/src/workflow/RunReportingMeasures.cpp +++ b/src/workflow/RunReportingMeasures.cpp @@ -54,7 +54,7 @@ void OSWorkflow::runReportingMeasures() { } LOG(Info, "Beginning to execute Reporting Measures."); - applyMeasures(MeasureType::ReportingMeasure, false); + applyMeasures(MeasureType::ReportingMeasure, ApplyMeasureType::Regular); LOG(Info, "Finished applying Reporting Measures."); communicateMeasureAttributes();