Repository: NREL/OpenStudio (language: python)

Issue:
[Python Workflow] Complete the C++ workflow to match the OpenStudio-workflow gem
* use temp subdirectories for each step
* process output requests from reporting measures
* run the IDF through E+ (as a library or a system call like current)
* run the post processing for reporting measure
[Python Workflow] Complete the C++ workflow to match the OpenStudio-workflow gem
* use temp subdirectories for each step
* process output requests from reporting measures
* run the IDF through E+ (as a library or a system call like current)
* run the post processing for reporting measure
[Python Workflow] Cannot run a workflow with both Python + Ruby on Ubuntu
* It segfaults on ubuntu when trying to run a workflow with both Python and Ruby
* works on Mac and windows though
* And we can run a Ruby Only or Python Only on all platforms



Discussion / hints:
One question I have is do we need to strictly follow the current CLI options? My vote is actually no. Let us take this opportunity to clean up any rough edges that we might see in the existing interface.
One question I have is do we need to strictly follow the current CLI options? My vote is actually no. Let us take this opportunity to clean up any rough edges that we might see in the existing interface.
@jmarrec  I would enjoy taking a crack at this. I'm curious if we can get to the bottom of it.

Fix patch:
diff --git a/.github/workflows/cppcheck.yml b/.github/workflows/cppcheck.yml
index 4c8b6361912..ec5df3dbd40 100644
--- a/.github/workflows/cppcheck.yml
+++ b/.github/workflows/cppcheck.yml
@@ -20,6 +20,7 @@ jobs:
           cppcheck \
             --std=c++17 \
             --enable=warning,style,information \
+            --suppress=toomanyconfigs \
             --suppress=noExplicitConstructor \
             --suppress=useStlAlgorithm \
             --suppress=unmatchedSuppression \
diff --git a/ConanInstall.cmake b/ConanInstall.cmake
index 3f221bf6dfa..67709a4eb0a 100644
--- a/ConanInstall.cmake
+++ b/ConanInstall.cmake
@@ -172,7 +172,7 @@ if(NOT CONAN_OPENSTUDIO_ALREADY_RUN)
     "jsoncpp/1.9.5#536d080aa154e5853332339bf576747c"
     "minizip/1.2.12#0b5296887a2558500d0323c6c94c8d02" # This depends on zlib, and basically patches it
     "zlib/1.2.12#3b9e037ae1c615d045a06c67d88491ae" # Also needed, so we can find zlib.h and co (+ pinning exactly is good)
-    "fmt/8.1.1#b3e969f8561a85087bd0365c09bbf4fb"
+    "fmt/9.1.0#811e918ca4b4e0b9ddd6d5a2883efa82"
     "sqlite3/3.38.5#010911927ce1889b5cf824f45e7cd3d2"
     "cpprestsdk/2.10.18#df2f6ac88e47cadd9c9e8e0971e00d89"
     "websocketpp/0.8.2#3fd704c4c5388d9c08b11af86f79f616"
diff --git a/ProjectMacros.cmake b/ProjectMacros.cmake
index 3aa69688294..c1ebc32f3c0 100644
--- a/ProjectMacros.cmake
+++ b/ProjectMacros.cmake
@@ -271,7 +271,7 @@ macro(MAKE_SWIG_TARGET NAME SIMPLENAME KEY_I_FILE I_FILES PARENT_TARGET PARENT_S
         # Suppress 'register' storage class specified warnings (coming from Ruby)
         set_target_properties(${swig_target} PROPERTIES COMPILE_FLAGS "-Wno-dynamic-class-memaccess -Wno-deprecated-declarations -Wno-sign-compare -Wno-register -Wno-sometimes-uninitialized")
       else()
-        set_target_properties(${swig_target} PROPERTIES COMPILE_FLAGS "-Wno-deprecated-declarations -Wno-sign-compare -Wno-register -Wno-conversion-null -Wno-misleading-indentation")
+        set_target_properties(${swig_target} PROPERTIES COMPILE_FLAGS "-Wno-deprecated-declarations -Wno-sign-compare -Wno-register -Wno-conversion-null -Wno-misleading-indentation -fno-gnu-unique")
       endif()
     endif()
 
@@ -380,7 +380,7 @@ macro(MAKE_SWIG_TARGET NAME SIMPLENAME KEY_I_FILE I_FILES PARENT_TARGET PARENT_S
         # Undefined suppress needed to avoid linking to Python::Module
         set_target_properties(${swig_target} PROPERTIES LINK_FLAGS "-flat_namespace -undefined suppress")
       else()
-        set_target_properties(${swig_target} PROPERTIES COMPILE_FLAGS "-Wno-deprecated-declarations -Wno-sign-compare -Wno-misleading-indentation")
+        set_target_properties(${swig_target} PROPERTIES COMPILE_FLAGS "-Wno-deprecated-declarations -Wno-sign-compare -Wno-misleading-indentation -fno-gnu-unique")
       endif()
     endif()
 
diff --git a/python/engine/PythonEngine.cpp b/python/engine/PythonEngine.cpp
index d1c7f1ced7d..dc4a8379506 100644
--- a/python/engine/PythonEngine.cpp
+++ b/python/engine/PythonEngine.cpp
@@ -179,20 +179,31 @@ void* PythonEngine::getAs_impl(ScriptObject& obj, const std::type_info& ti) {
 
   auto val = std::any_cast<PythonObject>(obj.object);
 
-  const auto& type_name = getRegisteredTypeName(ti);
-
   void* return_value = nullptr;
 
-  auto* type = SWIG_Python_TypeQuery(type_name.c_str());
+  if (ti == typeid(std::string*)) {
+    // TODO: this sucks, and probably needs a PyDecref or two
+    Py_ssize_t size = 0;
+    char const* pc = PyUnicode_AsUTF8AndSize(val.obj_, &size);
+    if (pc) {
+      return_value = new std::string(pc, size);
+    } else {
+      throw std::runtime_error("Unable to convert to std::string in SWIG Python");
+    }
+  } else {
+    const auto& type_name = getRegisteredTypeName(ti);
 
-  if (!type) {
-    throw std::runtime_error("Unable to find type in SWIG");
-  }
+    auto* type = SWIG_Python_TypeQuery(type_name.c_str());
 
-  const auto result = SWIG_Python_ConvertPtr(val.obj_, &return_value, type, 0);
+    if (!type) {
+      throw std::runtime_error("Unable to find type in SWIG");
+    }
+
+    const auto result = SWIG_Python_ConvertPtr(val.obj_, &return_value, type, 0);
 
-  if (!SWIG_IsOK(result)) {
-    throw std::runtime_error("Error getting object from SWIG/Python");
+    if (!SWIG_IsOK(result)) {
+      throw std::runtime_error(fmt::format("Error getting object from SWIG/Python of supposed type {}, {}\n", ti.name(), type_name.c_str()));
+    }
   }
 
   return return_value;
diff --git a/resources/Examples/compact_osw/compact_python_only_twomeasures.osw b/resources/Examples/compact_osw/compact_python_only_twomeasures.osw
index 0662c4c9c4a..8fd28a988f3 100644
--- a/resources/Examples/compact_osw/compact_python_only_twomeasures.osw
+++ b/resources/Examples/compact_osw/compact_python_only_twomeasures.osw
@@ -9,9 +9,9 @@
 			}
 		},
 		{
-			"measure_dir_name": "PythonExampleEnergyPlusMeasure",
+			"measure_dir_name": "PythonEnergyplusMeasureDXF",
 			"arguments": {
-				"zone_name": "My New Zone"
+				"dxf_type": "DXF"
 			}
 		}
 	]
diff --git a/resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/LICENSE.md b/resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/LICENSE.md
similarity index 100%
rename from resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/LICENSE.md
rename to resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/LICENSE.md
diff --git a/resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.py b/resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.py
similarity index 50%
rename from resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.py
rename to resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.py
index c9e79c769ce..f64952b9c4d 100644
--- a/resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.py
+++ b/resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.py
@@ -1,13 +1,7 @@
-"""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/
-"""
-
 import openstudio
 
 
-class PythonExampleEnergyPlusMeasure(openstudio.measure.EnergyPlusMeasure):
+class PythonEnergyplusMeasureDXF(openstudio.measure.EnergyPlusMeasure):
     """An EnergyPlusMeasure."""
 
     def name(self):
@@ -20,7 +14,7 @@ def name(self):
         As such, measure names should clearly describe the measure's function,
         while remaining general in nature
         """
-        return "Python Example EnergyPlus Measure"
+        return "Python EnergyPlusMeasure to produce a DXF"
 
     def description(self):
         """Human readable description.
@@ -47,10 +41,11 @@ def arguments(self, workspace: openstudio.Workspace):
         """
         args = openstudio.measure.OSArgumentVector()
 
-        zone_name = openstudio.measure.OSArgument.makeStringArgument("zone_name", True)
-        zone_name.setDisplayName("New zone name")
-        zone_name.setDescription("This name will be used as the name of the new zone.")
-        args.append(zone_name)
+        dxf_type = openstudio.measure.OSArgument.makeChoiceArgument('dxf_type', ["DXF", "DXF:WireFrame"], True)
+        dxf_type.setDisplayName("DXF Type")
+        dxf_type.setDescription("DXF Type for the eplusout.dxf")
+
+        args.append(dxf_type)
 
         return args
 
@@ -66,47 +61,30 @@ def run(
         if not (runner.validateUserArguments(self.arguments(workspace), user_arguments)):
             return False
 
-        # assign the user inputs to variables
-        zone_name = runner.getStringArgumentValue("zone_name", user_arguments)
+        dxf_type = runner.getStringArgumentValue('dxf_type', user_arguments)
 
-        # check the zone_name for reasonableness
-        if not zone_name:
-            runner.registerError("Empty zone name was entered.")
+        idfObject: openstudio.IdfObject = openstudio.IdfObject(openstudio.IddObjectType("Output:Surfaces:Drawing"))
+        result = idfObject.setString(0, dxf_type)
+        if not result:
+            runner.registerError(f"Something went wrong when trying to set the Report Type to {dxf_type}")
             return False
+        if dxf_type == "DXF":
+            result = idfObject.setString(1, "Triangulate3DFace")
+            if not result:
+                runner.registerError("Something went wrong when trying to set the Report Specifications 1 to Triangulate3DFace")
+                return False
+        wsObject_: openstudio.OptionalWorkspaceObject = workspace.addObject(idfObject)
+        if not wsObject_.is_initialized():
+            runner.registerError("Couldn't add idfObject to workspace:\n{idfObject}")
 
-        # get all thermal zones in the starting workspace
-        zones = workspace.getObjectsByType("Zone")
-        # report initial condition of workspace
-        runner.registerInitialCondition(f"The building started with {len(zones)} zones.")
-
-        # add a new zone to the model with the new name
-        # http://apps1.eere.energy.gov/buildings/energyplus/pdfs/inputoutputreference.pdf#nameddest=Zone
-        new_zone_string = f"""
-  Zone,
-    {zone_name},             !- Name
-    0,                       !- Direction of Relative North {{deg}}
-    0,                       !- X Origin {{m}}
-    0,                       !- Y Origin {{m}}
-    0,                       !- Z Origin {{m}}
-    1,                       !- Type
-    1,                       !- Multiplier
-    autocalculate,           !- Ceiling Height {{m}}
-    autocalculate;           !- Volume {{m3}}
-          """
-
-        idfObject_: openstudio.OptionalIdfObject = openstudio.IdfObject.load(new_zone_string)
-        idfObject: openstudio.IdfObject = idfObject_.get()
-        wsObject: openstudio.OptionalWorkspaceObject = workspace.addObject(idfObject)
-        new_zone: openstudio.WorkspaceObject = wsObject.get()
-
-        runner.registerInfo(f"A zone named '{new_zone.nameString()}' was added.")
+        runner.registerInfo(f"Report added:\n'{wsObject_.get()}'")
 
         # report final condition of model
-        finishing_zones = workspace.getObjectsByType("Zone")
-        runner.registerFinalCondition(f"The building finished with {len(finishing_zones)} zones.")
+        finishing_dxfs = workspace.getObjectsByType("Output:Surfaces:Drawing")
+        runner.registerFinalCondition(f"The building finished with {len(finishing_dxfs)} zones.")
 
         return True
 
 
 # register the measure to be used by the application
-PythonExampleEnergyPlusMeasure().registerWithApplication()
+PythonEnergyplusMeasureDXF().registerWithApplication()
diff --git a/resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.xml b/resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.xml
similarity index 55%
rename from resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.xml
rename to resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.xml
index aded9a347ba..dee2bdfd29f 100644
--- a/resources/Examples/compact_osw/measures/PythonExampleEnergyPlusMeasure/measure.xml
+++ b/resources/Examples/compact_osw/measures/PythonEnergyplusMeasureDXF/measure.xml
@@ -1,29 +1,39 @@
 <?xml version="1.0"?>
 <measure>
   <schema_version>3.0</schema_version>
-  <name>python_example_energyplus_measure</name>
+  <name>python_energyplus_measure_dxf</name>
   <uid>5a5d3f23-10eb-43ec-b845-176bd294410c</uid>
-  <version_id>1743a20a-eb23-42ce-aed9-f03bfaeac803</version_id>
-  <version_modified>20221005T143511Z</version_modified>
-  <xml_checksum>00000000</xml_checksum>
-  <class_name>PythonExampleEnergyPlusMeasure</class_name>
-  <display_name>Python Example EnergyPlus Measure</display_name>
+  <version_id>80536b82-6922-499d-ab0e-8b8164a6aaa6</version_id>
+  <version_modified>20230103T235847Z</version_modified>
+  <xml_checksum>2E99387F</xml_checksum>
+  <class_name>PythonEnergyplusMeasureDXF</class_name>
+  <display_name>Python EnergyPlusMeasure to produce a DXF</display_name>
   <description>This is just a sample</description>
   <modeler_description>it uses python</modeler_description>
   <arguments>
     <argument>
-      <name>zone_name</name>
-      <display_name>New zone name</display_name>
-      <description>This name will be used as the name of the new zone.</description>
-      <type>String</type>
+      <name>dxf_type</name>
+      <display_name>DXF Type</display_name>
+      <description>DXF Type for the eplusout.dxf</description>
+      <type>Choice</type>
       <required>true</required>
       <model_dependent>false</model_dependent>
+      <choices>
+        <choice>
+          <value>DXF</value>
+          <display_name>DXF</display_name>
+        </choice>
+        <choice>
+          <value>DXF:WireFrame</value>
+          <display_name>DXF:WireFrame</display_name>
+        </choice>
+      </choices>
     </argument>
   </arguments>
   <outputs />
   <provenances />
   <tags>
-    <tag>Envelope.Infiltration</tag>
+    <tag>Reporting.QAQC</tag>
   </tags>
   <attributes>
     <attribute>
@@ -41,12 +51,13 @@
     <file>
       <version>
         <software_program>OpenStudio</software_program>
-        <identifier>3.5.0</identifier>
+        <identifier>3.5.1</identifier>
+        <min_compatible>3.5.1</min_compatible>
       </version>
       <filename>measure.py</filename>
       <filetype>py</filetype>
       <usage_type>script</usage_type>
-      <checksum>DD7219EB</checksum>
+      <checksum>BDDB2A79</checksum>
     </file>
     <file>
       <filename>LICENSE.md</filename>
diff --git a/ruby/engine/RubyEngine.cpp b/ruby/engine/RubyEngine.cpp
index ab53dc5c643..cc1d4ab4f9c 100644
--- a/ruby/engine/RubyEngine.cpp
+++ b/ruby/engine/RubyEngine.cpp
@@ -87,22 +87,29 @@ void RubyEngine::exec(std::string_view sv) {
 void* RubyEngine::getAs_impl(ScriptObject& obj, const std::type_info& ti) {
   auto val = std::any_cast<VALUE>(obj.object);
 
-  const auto& type_name = getRegisteredTypeName(ti);
-
   void* return_value = nullptr;
 
-  auto* type = SWIG_TypeQuery(type_name.c_str());
+  // TODO: this sucks, and probably needs memory management
+  if (ti == typeid(std::string*)) {
+    char* cstr = StringValuePtr(val);
+    size_t size = RSTRING_LEN(val);  // + 1;  From trial and eror, if I had + 1 I get a string with one two many size
+    return_value = new std::string(cstr, size);
 
-  if (!type) {
-    throw std::runtime_error("Unable to find type in SWIG");
-  }
+    // std::string s = rb_string_value_cstr(&val);
+  } else {
+    const auto& type_name = getRegisteredTypeName(ti);
+    auto* type = SWIG_TypeQuery(type_name.c_str());
 
-  const auto result = SWIG_ConvertPtr(val, &return_value, type, 0);
+    if (!type) {
+      throw std::runtime_error("Unable to find type in SWIG");
+    }
 
-  if (!SWIG_IsOK(result)) {
-    throw std::runtime_error("Error getting object from SWIG/Ruby");
-  }
+    const auto result = SWIG_ConvertPtr(val, &return_value, type, 0);
 
+    if (!SWIG_IsOK(result)) {
+      throw std::runtime_error("Error getting object from SWIG/Ruby");
+    }
+  }
   return return_value;
 }
 
diff --git a/src/cli/MeasureUpdateCommand.cpp b/src/cli/MeasureUpdateCommand.cpp
index 1d02a37e364..33fe7456958 100644
--- a/src/cli/MeasureUpdateCommand.cpp
+++ b/src/cli/MeasureUpdateCommand.cpp
@@ -33,6 +33,16 @@
 #include "../utilities/core/Filesystem.hpp"
 #include "../utilities/bcl/BCLMeasure.hpp"
 #include "../scriptengine/ScriptEngine.hpp"
+#include "../measure/OSMeasure.hpp"
+#include "../measure/ModelMeasure.hpp"
+#include "../measure/EnergyPlusMeasure.hpp"
+#include "../measure/ReportingMeasure.hpp"
+#include "../measure/OSArgument.hpp"
+#include "../measure/OSOutput.hpp"
+#include "../measure/OSRunner.hpp"
+#include "../measure/OSMeasureInfoGetter.hpp"
+#include "../utilities/idf/Workspace.hpp"
+#include <utilities/idd/IddEnums.hxx>
 
 #include <fmt/format.h>
 #include <boost/optional.hpp>
@@ -42,7 +52,7 @@
 namespace openstudio {
 namespace cli {
 
-  void MeasureUpdateOptions::setupMeasureUpdateOptions(CLI::App* parentApp, ScriptEngineInstance& rubyEngine) {
+  void MeasureUpdateOptions::setupMeasureUpdateOptions(CLI::App* parentApp, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine) {
     /// Set up a subcommand and capture a shared_ptr to a struct that holds all its options.
     /// The variables of the struct are bound to the CLI options.
     /// We use a shared ptr so that the addresses of the variables remain for binding,
@@ -78,10 +88,11 @@ namespace cli {
                                               ->option_text("PORT")
                                               ->excludes(directoryPathOpt);
 
-    measureCommand->callback([opt, &rubyEngine] { MeasureUpdateOptions::execute(*opt, rubyEngine); });
+    measureCommand->callback([opt, &rubyEngine, &pythonEngine] { MeasureUpdateOptions::execute(*opt, rubyEngine, pythonEngine); });
   }
 
-  boost::optional<BCLMeasure> getAndUpdateMeasure(const openstudio::path& directoryPath) {
+  boost::optional<BCLMeasure> getAndUpdateMeasure(const openstudio::path& directoryPath, ScriptEngineInstance& rubyEngine,
+                                                  ScriptEngineInstance& pythonEngine) {
 
     const auto& directoryPathStr = directoryPath.string();
 
@@ -124,18 +135,238 @@ namespace cli {
       // TODO: the readme.md generation from readme.md.erb requires ruby.
       fmt::print("Warn: readme.md generation from ERB is not supported yet\n");
 
-      // Save the xml file with changes triggered by checkForUpdatesFiles() / checkForUpdatesXML() above
-      measure_->save();
-
-      // Also need to mimic OSMeasureInfoGetter::getInfo which is actually implemented in ruby itself...
+      // TODO: Also need to mimic OSMeasureInfoGetter::getInfo which is actually implemented in ruby itself...
       // basically we need to compute the arguments of the measures, inspect outputs... so that requires doing something similar to what we do in the
       // `run` method
+
+      fmt::print("Measure at {} uses language = {}.\n", directoryPathStr, measure_->measureLanguage().valueName());
+
+      auto scriptPath_ = measure_->primaryScriptPath();
+      if (!scriptPath_) {
+        throw std::runtime_error(
+          fmt::format("Unable to locate primary Ruby script path for BCLMeasure '{}' located at '{}'", measure_->name(), directoryPathStr));
+      }
+
+      // TODO: Here we need to do two things:
+      // * Find the class name by scanning the measure.rb/.py, then instantiate the measure
+      // * Do the same as in ApplyMeasure and compute the arguments with a dumb model/workspace, or the supplied one (for --compute_arguments)
+      ScriptEngineInstance* thisEngine = nullptr;
+      ScriptObject measureScriptObject;
+      // openstudio::measure::OSMeasure* measurePtr = nullptr;
+
+      std::string className;
+      MeasureType measureType;
+
+      std::string name;
+      std::string description;
+      std::string taxonomy;
+      std::string modelerDescription;
+
+      std::vector<measure::OSArgument> arguments;
+      std::vector<measure::OSOutput> outputs;
+
+      if (measure_->measureLanguage() == MeasureLanguage::Ruby) {
+        // same as the beginning of the OSMeasureInfoGetter::infoExtractorRubyFunction
+        auto importCmd = fmt::format(R"ruby(
+currentObjects = Hash.new
+ObjectSpace.each_object(OpenStudio::Measure::OSMeasure) do |obj|
+  currentObjects[obj] = true
+end
+
+measurePath = "{}"
+ObjectSpace.garbage_collect
+# This line is REQUIRED or the ObjectSpace order will change when garbage collection runs automatically
+# If ~12 measures are added and garbage collection runs, the following loop to grab the first measure
+# will get the wrong one and return incorrect arguments
+ObjectSpace.garbage_collect
+load measurePath # need load in case have seen this script before
+
+# Make them global, so C++ can grab them
+$measure = nil
+$measure_type = String.new
+$measure_name = String.new
+ObjectSpace.each_object(OpenStudio::Measure::OSMeasure) do |obj|
+  if not currentObjects[obj]
+    if obj.is_a? OpenStudio::Measure::ModelMeasure
+      $measure = obj
+      $measure_type = "ModelMeasure"
+    elsif obj.is_a? OpenStudio::Measure::EnergyPlusMeasure
+      $measure = obj
+      $measure_type = "EnergyPlusMeasure"
+    elsif obj.is_a? OpenStudio::Measure::ReportingMeasure
+      $measure = obj
+      $measure_type = "ReportingMeasure"
+    end
+  end
+end
+
+if not $measure
+  raise "Unable to extract OpenStudio::Measure::OSMeasure object from " +
+       measurePath + ". The script should contain a class that derives " +
+      "from OpenStudio::Measure::OSMeasure and should close with a line stating " +
+      "the class name followed by .new.registerWithApplication."
+end
+
+$measure_name = $measure.class.to_s
+puts "#{{$measure}}, #{{$measure_type}}, #{{$measure_name}}"
+)ruby",
+                                     scriptPath_->generic_string());
+        fmt::print("Debug: importCmd=\n{}\n\n", importCmd);
+        thisEngine = &rubyEngine;
+
+        rubyEngine->exec(importCmd);
+
+        fmt::print("Import done\n");
+        rubyEngine->exec("puts $measure_type");
+
+        // TODO: gotta figure out a way to retrieve a frigging string
+        // ScriptObject measureTypeObject = rubyEngine->eval("measure_type");
+        ScriptObject measureTypeObject = rubyEngine->eval("$measure_type");
+        std::string measureTypeStr = *(rubyEngine->getAs<std::string*>(measureTypeObject));
+        fmt::print("measureTypeStr={}\n", measureTypeStr);
+        measureType = MeasureType(measureTypeStr);
+
+        measureScriptObject = rubyEngine->eval("$measure");
+        ScriptObject measureClassNameObject = rubyEngine->eval("$measure_name");
+        className = *(*thisEngine)->getAs<std::string*>(measureClassNameObject);
+        fmt::print("className={}\n", className);
+
+        if (measureType == MeasureType::ModelMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
+
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::model::Model model;
+          arguments = measurePtr->arguments(model);
+          outputs = measurePtr->outputs();
+
+        } else if (measureType == MeasureType::EnergyPlusMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::Workspace workspace(openstudio::StrictnessLevel::Draft, openstudio::IddFileType::EnergyPlus);
+          arguments = measurePtr->arguments(workspace);
+          outputs = measurePtr->outputs();
+        } else if (measureType == MeasureType::ReportingMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::model::Model model;
+          arguments = measurePtr->arguments(model);
+          outputs = measurePtr->outputs();
+
+        } else {
+          throw std::runtime_error("Unknown");
+        }
+
+        if (name.empty()) {
+          name = className;
+        }
+
+      } else if (measure_->measureLanguage() == MeasureLanguage::Python) {
+        // TODO: call initialization of the pythonEngine
+        auto importCmd = fmt::format(R"python(
+import importlib.util
+import inspect
+spec = importlib.util.spec_from_file_location(f"throwaway", "{}")
+
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+class_members = inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, openstudio.measure.OSMeasure))
+assert len(class_members) == 1
+measure_name, measure_typeinfo = class_members[0]
+print(f"{{measure_name}}, {{measure_typeinfo}}")
+measure_type = None
+if issubclass(measure_typeinfo, openstudio.measure.ModelMeasure):
+    measure_type = "ModelMeasure"
+elif issubclass(measure_typeinfo, openstudio.measure.EnergyPlusMeasure):
+    measure_type = "EnergyPlusMeasure"
+elif issubclass(measure_typeinfo, openstudio.measure.ReportingMeasure):
+    measure_type = "ReportingMeasure"
+print(f"{{measure_name}}, {{measure_typeinfo}}, {{measure_type}}")
+)python",
+                                     scriptPath_->generic_string());
+        pythonEngine->exec(importCmd);
+        thisEngine = &pythonEngine;
+        // measureScriptObject = pythonEngine->eval(fmt::format("module.{}()", className));
+        measureScriptObject = pythonEngine->eval("measure_typeinfo()");
+
+        // TODO: gotta figure out a way to retrieve a frigging string
+        ScriptObject measureTypeObject = pythonEngine->eval("measure_type");
+        std::string measureTypeStr = *(*thisEngine)->getAs<std::string*>(measureTypeObject);
+        measureType = MeasureType(measureTypeStr);
+
+        ScriptObject measureClassNameObject = pythonEngine->eval("measure_name");
+        className = *(*thisEngine)->getAs<std::string*>(measureClassNameObject);
+
+        fmt::print("measureTypeStr={}\n", measureTypeStr);
+
+        if (measureType == MeasureType::ModelMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
+
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::model::Model model;
+          arguments = measurePtr->arguments(model);
+          outputs = measurePtr->outputs();
+
+        } else if (measureType == MeasureType::EnergyPlusMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::Workspace workspace(openstudio::StrictnessLevel::Draft, openstudio::IddFileType::EnergyPlus);
+          arguments = measurePtr->arguments(workspace);
+          outputs = measurePtr->outputs();
+        } else if (measureType == MeasureType::ReportingMeasure) {
+          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
+          name = measurePtr->name();
+          description = measurePtr->description();
+          taxonomy = measurePtr->taxonomy();
+          modelerDescription = measurePtr->modeler_description();
+
+          openstudio::model::Model model;
+          arguments = measurePtr->arguments(model);
+          outputs = measurePtr->outputs();
+
+        } else {
+          throw std::runtime_error("Unknown");
+        }
+
+        if (name.empty()) {
+          name = className;
+        }
+      }
+
+      openstudio::measure::OSMeasureInfo info(measureType, className, name, description, taxonomy, modelerDescription, arguments, outputs);
+      info.update(measure_.get());
+      for (auto& arg : arguments) {
+        fmt::print("arg={}\n", arg.displayName());
+        // auto outputs = measurePtr->outputs();
+      }
+
+      // Save the xml file with changes triggered by checkForUpdatesFiles() / checkForUpdatesXML() above
+      measure_->save();
     }
 
     return measure_;
   }
 
-  void MeasureUpdateOptions::execute(MeasureUpdateOptions const& opt, ScriptEngineInstance& rubyEngine) {
+  void MeasureUpdateOptions::execute(MeasureUpdateOptions const& opt, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine) {
     opt.debug_print();
 
     if (opt.server_port > 0) {
@@ -158,7 +389,7 @@ server.start)ruby",
       rubyEngine->exec(measureManagerCmd);
 
     } else if (opt.update) {
-      getAndUpdateMeasure(opt.directoryPath);
+      getAndUpdateMeasure(opt.directoryPath, rubyEngine, pythonEngine);
 
     } else if (opt.update_all) {
       std::vector<openstudio::path> subDirPaths;
@@ -171,14 +402,14 @@ server.start)ruby",
       }
       fmt::print("Found {} measure directories to update\n", subDirPaths.size());
       for (const auto& subDirPath : subDirPaths) {
-        getAndUpdateMeasure(subDirPath);
+        getAndUpdateMeasure(subDirPath, rubyEngine, pythonEngine);
       }
 
     } else if (!opt.compute_arguments_model.empty()) {
       // TODO: Same comment as above: we need to call the measure methods same as what we do in OSWorkflow::run
       throw std::runtime_error("compute_arguments_model not implemented yet");
     } else if (opt.run_tests) {
-      auto measure_ = getAndUpdateMeasure(opt.directoryPath);
+      auto measure_ = getAndUpdateMeasure(opt.directoryPath, rubyEngine, pythonEngine);
       if (measure_->measureLanguage() == MeasureLanguage::Ruby) {
         // TODO: need to capture arguments and pass as ARGV
 
diff --git a/src/cli/MeasureUpdateCommand.hpp b/src/cli/MeasureUpdateCommand.hpp
index 3bfb663317d..2538b190103 100644
--- a/src/cli/MeasureUpdateCommand.hpp
+++ b/src/cli/MeasureUpdateCommand.hpp
@@ -42,9 +42,9 @@ namespace cli {
   struct MeasureUpdateOptions
   {
    public:
-    static void setupMeasureUpdateOptions(CLI::App* parentApp, ScriptEngineInstance& rubyEngine);
+    static void setupMeasureUpdateOptions(CLI::App* parentApp, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine);
 
-    static void execute(MeasureUpdateOptions const& opt, ScriptEngineInstance& rubyEngine);
+    static void execute(MeasureUpdateOptions const& opt, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine);
 
     void debug_print() const;
 
diff --git a/src/cli/RunCommand.cpp b/src/cli/RunCommand.cpp
index eeba28a5426..89c49b6db49 100644
--- a/src/cli/RunCommand.cpp
+++ b/src/cli/RunCommand.cpp
@@ -42,8 +42,7 @@
 namespace openstudio {
 namespace cli {
 
-  void setupRunOptions(CLI::App* parentApp, ScriptEngineInstance& ruby, ScriptEngineInstance& python, std::function<void()>& runSetupEmbeddedGems,
-                       std::function<void()>& runSetupPythonPath) {
+  void setupRunOptions(CLI::App* parentApp, ScriptEngineInstance& ruby, ScriptEngineInstance& python) {
     /// Set up a subcommand and capture a shared_ptr to a struct that holds all its options.
     /// The variables of the struct are bound to the CLI options.
     /// We use a shared ptr so that the addresses of the variables remain for binding,
@@ -155,11 +154,7 @@ namespace cli {
       ->group(ftGroupName);
 
     // Subcommand callback
-    app->callback([opt, &ruby, &python, &runSetupEmbeddedGems, &runSetupPythonPath] {
-      // TODO: eventually we want to delay the call to runSetupEmbeddedGems until we KNOW we need ruby measures, so probably want to forward to
-      // OSWorkflow and deal with it there. Same for runSetupPythonPath
-      runSetupEmbeddedGems();
-      runSetupPythonPath();
+    app->callback([opt, &ruby, &python] {
       openstudio::OSWorkflow workflow(*opt, ruby, python);
       workflow.run();
     });
diff --git a/src/cli/RunCommand.hpp b/src/cli/RunCommand.hpp
index 01020d54420..789e8e83239 100644
--- a/src/cli/RunCommand.hpp
+++ b/src/cli/RunCommand.hpp
@@ -40,8 +40,7 @@ class ScriptEngineInstance;
 
 namespace cli {
 
-  void setupRunOptions(CLI::App* parentApp, ScriptEngineInstance& ruby, ScriptEngineInstance& python, std::function<void()>& runSetupEmbeddedGems,
-                       std::function<void()>& runSetupPythonPath);
+  void setupRunOptions(CLI::App* parentApp, ScriptEngineInstance& ruby, ScriptEngineInstance& python);
   // void setupRunFtOptions(CLI::App* app, FtOptions& ftOptions);
 
 }  // namespace cli
diff --git a/src/cli/main.cpp b/src/cli/main.cpp
index 6dcb5ac33df..6215caafca9 100644
--- a/src/cli/main.cpp
+++ b/src/cli/main.cpp
@@ -4,6 +4,9 @@
 #include "../workflow/OSWorkflow.hpp"
 #include "../utilities/core/Logger.hpp"
 #include "../utilities/bcl/BCLMeasure.hpp"
+#include "../measure/ModelMeasure.hpp"
+#include "../measure/EnergyPlusMeasure.hpp"
+#include "../measure/ReportingMeasure.hpp"
 
 #include "RunCommand.hpp"
 #include "MeasureUpdateCommand.hpp"
@@ -111,10 +114,19 @@ int main(int argc, char* argv[]) {
       ->option_text("WITHOUT_GROUPS")
       ->group(rubySpecificOptionsGroupName);  // ->delimiter(' ');
 
+    // This is a callback that's stored on the ScriptEngineInstance, triggered only the first time. Just need to make sure it doesn't go out of scope
+    // nor its captures
     std::function<void()> runSetupEmbeddedGems = [&rubyEngine, &includeDirs, &gemPathDirs, &gemHomeDir, &bundleGemFilePath, &bundleGemDirPath,
                                                   &bundleWithoutGroups]() {
       rubyEngine->setupEmbeddedGems(includeDirs, gemPathDirs, gemHomeDir, bundleGemFilePath, bundleGemDirPath, bundleWithoutGroups);
+      rubyEngine->registerType<openstudio::measure::ModelMeasure*>("openstudio::measure::ModelMeasure *");
+      rubyEngine->registerType<openstudio::measure::EnergyPlusMeasure*>("openstudio::measure::EnergyPlusMeasure *");
+      rubyEngine->registerType<openstudio::measure::ReportingMeasure*>("openstudio::measure::ReportingMeasure *");
+      // rubyEngine->registerType<std::string>("std::string");
+      // rubyEngine->registerType<std::string*>("std::string *");
+      rubyEngine->exec("OpenStudio::init_rest_of_openstudio()");
     };
+    rubyEngine.registerInitializationFunction(runSetupEmbeddedGems);
 
     // ========================== P Y T H O N    O P T I O N S ==========================
 
@@ -130,41 +142,44 @@ int main(int argc, char* argv[]) {
       ->option_text("DIR")
       ->group(pythonSpecificOptionsGroupName);
 
+    // This is a callback that's stored on the ScriptEngineInstance, triggered only the first time
     std::function<void()> runSetupPythonPath = [&pythonEngine, &pythonPathDirs, &pythonHomeDir]() {
       pythonEngine->setupPythonPath(pythonPathDirs, pythonHomeDir);
+      pythonEngine->registerType<openstudio::measure::ModelMeasure*>("openstudio::measure::ModelMeasure *");
+      pythonEngine->registerType<openstudio::measure::EnergyPlusMeasure*>("openstudio::measure::EnergyPlusMeasure *");
+      pythonEngine->registerType<openstudio::measure::ReportingMeasure*>("openstudio::measure::ReportingMeasure *");
+      // pythonEngine->registerType<std::string*>("std::string *");
     };
-
-    {
-      auto* execute_ruby_scriptCommand = experimentalApp->add_subcommand("execute_ruby_script", "Executes a ruby file");
-      openstudio::filesystem::path rubyScriptPath;
-      execute_ruby_scriptCommand->add_option("path", rubyScriptPath, "Path to ruby file")->required(true);
-      std::vector<std::string> executeRubyScriptCommandArgs;
-      execute_ruby_scriptCommand->add_option("arguments", executeRubyScriptCommandArgs, "Arguments to pass to the ruby file")
-        ->required(false)
-        ->option_text("args");
-      execute_ruby_scriptCommand->callback([&rubyScriptPath, &rubyEngine, &executeRubyScriptCommandArgs, &runSetupEmbeddedGems] {
-        runSetupEmbeddedGems();
-        openstudio::cli::executeRubyScriptCommand(rubyScriptPath, rubyEngine, executeRubyScriptCommandArgs);
-      });
-    }
-
-    {
-      auto* execute_python_scriptCommand = experimentalApp->add_subcommand("execute_python_script", "Executes a python file");
-      openstudio::filesystem::path pythonScriptPath;
-      execute_python_scriptCommand->add_option("path", pythonScriptPath, "Path to python file")->required(true);
-      std::vector<std::string> executePythonScriptCommandArgs;
-      execute_python_scriptCommand->add_option("arguments", executePythonScriptCommandArgs, "Arguments to pass to the python file")
-        ->required(false)
-        ->option_text("args");
-      execute_python_scriptCommand->callback([&pythonScriptPath, &pythonEngine, &executePythonScriptCommandArgs, &runSetupPythonPath] {
-        runSetupPythonPath();
-        openstudio::cli::executePythonScriptCommand(pythonScriptPath, pythonEngine, executePythonScriptCommandArgs);
-      });
-    }
+    pythonEngine.registerInitializationFunction(runSetupPythonPath);
+
+    // {
+    auto* execute_ruby_scriptCommand = experimentalApp->add_subcommand("execute_ruby_script", "Executes a ruby file");
+    openstudio::filesystem::path rubyScriptPath;
+    execute_ruby_scriptCommand->add_option("path", rubyScriptPath, "Path to ruby file")->required(true);
+    std::vector<std::string> executeRubyScriptCommandArgs;
+    execute_ruby_scriptCommand->add_option("arguments", executeRubyScriptCommandArgs, "Arguments to pass to the ruby file")
+      ->required(false)
+      ->option_text("args");
+    execute_ruby_scriptCommand->callback([&rubyScriptPath, &rubyEngine, &executeRubyScriptCommandArgs] {
+      openstudio::cli::executeRubyScriptCommand(rubyScriptPath, rubyEngine, executeRubyScriptCommandArgs);
+    });
+    // }
+
+    // {
+    auto* execute_python_scriptCommand = experimentalApp->add_subcommand("execute_python_script", "Executes a python file");
+    openstudio::filesystem::path pythonScriptPath;
+    execute_python_scriptCommand->add_option("path", pythonScriptPath, "Path to python file")->required(true);
+    std::vector<std::string> executePythonScriptCommandArgs;
+    execute_python_scriptCommand->add_option("arguments", executePythonScriptCommandArgs, "Arguments to pass to the python file")
+      ->required(false)
+      ->option_text("args");
+    execute_python_scriptCommand->callback([&pythonScriptPath, &pythonEngine, &executePythonScriptCommandArgs] {
+      openstudio::cli::executePythonScriptCommand(pythonScriptPath, pythonEngine, executePythonScriptCommandArgs);
+    });
+    // }
 
     [[maybe_unused]] auto* gem_listCommand =
-      experimentalApp->add_subcommand("gem_list", "Lists the set gems available to openstudio")->callback([&rubyEngine, &runSetupEmbeddedGems]() {
-        runSetupEmbeddedGems();
+      experimentalApp->add_subcommand("gem_list", "Lists the set gems available to openstudio")->callback([&rubyEngine]() {
         openstudio::cli::executeGemListCommand(rubyEngine);
       });
 
@@ -172,7 +187,7 @@ int main(int argc, char* argv[]) {
     // [[maybe_unused]] auto* list_commandsCommand = experimentalApp->add_subcommand("list_commands", "Lists the entire set of available commands");
 
     // run command
-    openstudio::cli::setupRunOptions(experimentalApp, rubyEngine, pythonEngine, runSetupEmbeddedGems, runSetupPythonPath);
+    openstudio::cli::setupRunOptions(experimentalApp, rubyEngine, pythonEngine);
 
     // update (model) command
     // openstudio::cli::setupUpdateCommand(experimentalApp);
@@ -191,7 +206,7 @@ int main(int argc, char* argv[]) {
       });
     }
 
-    openstudio::cli::MeasureUpdateOptions::setupMeasureUpdateOptions(experimentalApp, rubyEngine);
+    openstudio::cli::MeasureUpdateOptions::setupMeasureUpdateOptions(experimentalApp, rubyEngine, pythonEngine);
 
     // ==========================  V E R S I O N ==========================
     [[maybe_unused]] auto* openstudio_versionCommand =
@@ -217,8 +232,6 @@ int main(int argc, char* argv[]) {
 
     if (*execRubyOption) {
       //  fmt::print("--execute Flag received {} times.\n", execRubyOption->count());
-      runSetupEmbeddedGems();
-      rubyEngine->exec("OpenStudio::init_rest_of_openstudio()");
       for (auto& cmd : executeRubyCmds) {
         // fmt::print("{}\n", cmd);
         rubyEngine->exec(cmd);
@@ -226,7 +239,6 @@ int main(int argc, char* argv[]) {
     }
     if (*execPythonOption) {
       // fmt::print("--pyexecute Flag received {} times.\n", execPythonOption->count());
-      runSetupPythonPath();
       for (auto& cmd : executePythonCmds) {
         // fmt::print("{}\n", cmd);
         pythonEngine->exec(cmd);
diff --git a/src/measure/EnergyPlusMeasure.cpp b/src/measure/EnergyPlusMeasure.cpp
index 5acadb16062..4832dab94d5 100644
--- a/src/measure/EnergyPlusMeasure.cpp
+++ b/src/measure/EnergyPlusMeasure.cpp
@@ -52,19 +52,5 @@ namespace measure {
     return true;
   }
 
-  std::vector<OSArgument> PythonEnergyPlusMeasure::arguments(const openstudio::Workspace& workspace) const {
-    return {};
-  }
-
-  std::vector<OSOutput> PythonEnergyPlusMeasure::outputs() const {
-    return {};
-  }
-
-  bool PythonEnergyPlusMeasure::run(openstudio::Workspace& workspace, OSRunner& runner,
-                                    const std::map<std::string, OSArgument>& user_arguments) const {
-    runner.prepareForMeasureRun(*this);
-    return true;
-  }
-
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/EnergyPlusMeasure.hpp b/src/measure/EnergyPlusMeasure.hpp
index 583a0768dcd..629b2275a0e 100644
--- a/src/measure/EnergyPlusMeasure.hpp
+++ b/src/measure/EnergyPlusMeasure.hpp
@@ -85,50 +85,6 @@ namespace measure {
     REGISTER_LOGGER("openstudio.measure.EnergyPlusMeasure");
   };
 
-  /** PythonEnergyPlusMeasure is an abstract base class for OSMeasures that operate on PythonEnergyPlus
- *  Workspaces. */
-  class MEASURE_API PythonEnergyPlusMeasure : public OSMeasure
-  {
-   public:
-    /** @name Constructors and Destructors */
-    //@{
-
-    virtual ~PythonEnergyPlusMeasure() = default;
-
-    //@}
-    /** @name Getters */
-    //@{
-
-    /** Returns the arguments for this script. In interactive applications, an OSRunner presents
-   *  these arguments to the user to produce an OSArgumentMap of user_arguments that it then passes
-   *  to this script's run method. The same basic steps should happen in applications with non-
-   *  interactive scripts, but in that case an entity other than an OSRunner may be in charge of
-   *  collecting user arguments. The base class implementation returns an empty vector. */
-    virtual std::vector<OSArgument> arguments(const openstudio::Workspace& workspace) const;
-
-    /** Returns the outputs for this script. The base class implementation returns an empty vector. */
-    virtual std::vector<OSOutput> outputs() const;
-
-    //@}
-    /** @name Actions */
-    //@{
-
-    /** Run the script on the given workspace with the given runner and user_arguments. The base
-   *  class implementation calls runner.prepareForMeasureRun(*this) and should be called at the
-   *  beginning of derived class implementations of this method. (In C++, that call looks like
-   *  PythonEnergyPlusMeasure::run(workspace, runner, user_arguments). In Ruby that call looks like
-   *  super(workspace, runner, user_arguments). */
-    virtual bool run(openstudio::Workspace& workspace, OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const;
-
-    //@}
-
-   protected:
-    PythonEnergyPlusMeasure() = default;
-
-   private:
-    REGISTER_LOGGER("openstudio.measure.PythonEnergyPlusMeasure");
-  };
-
 }  // namespace measure
 }  // namespace openstudio
 
diff --git a/src/measure/Measure.i b/src/measure/Measure.i
index 88f2131a7ad..5c69aef5926 100644
--- a/src/measure/Measure.i
+++ b/src/measure/Measure.i
@@ -24,18 +24,10 @@
   // Help in overload resolution preferring std::string over char const *
   %ignore openstudio::measure::OSArgument::setValue(char const*);
   %ignore openstudio::measure::OSArgument::setDefaultValue(char const*);
-#elif defined(SWIGRUBY)
-  %ignore openstudio::measure::PythonModelMeasure;
-  %ignore openstudio::measure::PythonEnergyPlusMeasure;
-  %ignore openstudio::measure::PythonReportingMeasure;
 #elif defined(SWIGPYTHON)
   // Avoid triggering a SWIG warning: 'print' is a python keyword
   %rename(toString) openstudio::measure::OSArgument::print;
   %rename(toString) openstudio::measure::OSOutput::print;
-
-  %ignore openstudio::measure::ModelMeasure;
-  %ignore openstudio::measure::EnergyPlusMeasure;
-  %ignore openstudio::measure::ReportingMeasure;
 #endif
 
 %{
@@ -67,19 +59,9 @@
 %template(OptionalOSOutput) boost::optional<openstudio::measure::OSOutput>;
 
 %feature("director") OSMeasure;
-
-#if defined(SWIGPYTHON)
-  %feature("director") PythonModelMeasure;
-  %feature("director") PythonEnergyPlusMeasure;
-  %feature("director") PythonReportingMeasure;
-  %rename (ModelMeasure) openstudio::measure::PythonModelMeasure;
-  %rename (EnergyPlusMeasure) openstudio::measure::PythonEnergyPlusMeasure;
-  %rename (ReportingMeasure) openstudio::measure::PythonReportingMeasure;
-#else
-  %feature("director") ModelMeasure;
-  %feature("director") EnergyPlusMeasure;
-  %feature("director") ReportingMeasure;
-#endif
+%feature("director") ModelMeasure;
+%feature("director") EnergyPlusMeasure;
+%feature("director") ReportingMeasure;
 %feature("director") OSRunner;
 %feature("director") OSArgument;
 
diff --git a/src/measure/ModelMeasure.cpp b/src/measure/ModelMeasure.cpp
index 43b1029512c..84538c43edf 100644
--- a/src/measure/ModelMeasure.cpp
+++ b/src/measure/ModelMeasure.cpp
@@ -49,18 +49,5 @@ namespace measure {
     return true;
   }
 
-  std::vector<OSArgument> PythonModelMeasure::arguments(const openstudio::model::Model& model) const {
-    return {};
-  }
-
-  std::vector<OSOutput> PythonModelMeasure::outputs() const {
-    return {};
-  }
-
-  bool PythonModelMeasure::run(openstudio::model::Model& model, OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const {
-    runner.prepareForMeasureRun(*this);
-    return true;
-  }
-
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/ModelMeasure.hpp b/src/measure/ModelMeasure.hpp
index 55093a2b398..984b9ff6f6e 100644
--- a/src/measure/ModelMeasure.hpp
+++ b/src/measure/ModelMeasure.hpp
@@ -86,49 +86,6 @@ namespace measure {
     REGISTER_LOGGER("openstudio.measure.ModelMeasure");
   };
 
-  /** PythonModelMeasure is an abstract base class for UserScripts that operate on OpenStudio Models. */
-  class MEASURE_API PythonModelMeasure : public OSMeasure
-  {
-   public:
-    /** @name Constructors and Destructors */
-    //@{
-
-    virtual ~PythonModelMeasure() = default;
-
-    //@}
-    /** @name Getters */
-    //@{
-
-    /** Returns the arguments for this script. In interactive applications, an OSRunner presents
-   *  these arguments to the user to produce an OSArgumentMap of user_arguments that it then passes
-   *  to this script's run method. The same basic steps should happen in applications with non-
-   *  interactive scripts, but in that case an entity other than an OSRunner may be in charge of
-   *  collecting user arguments. The base class implementation returns an empty vector. */
-    virtual std::vector<OSArgument> arguments(const openstudio::model::Model& model) const;
-
-    /** Returns the outputs for this script. The base class implementation returns an empty vector. */
-    virtual std::vector<OSOutput> outputs() const;
-
-    //@}
-    /** @name Actions */
-    //@{
-
-    /** Run the script on the given model with the given runner and user_arguments. The base class
-   *  implementation calls runner.prepareForMeasureRun(*this) and should be called at the
-   *  beginning of derived class implementations of this method. (In C++, that call looks like
-   *  PythonModelMeasure::run(model, runner, user_arguments). In Ruby that call looks like
-   *  super(model, runner, user_arguments). */
-    virtual bool run(openstudio::model::Model& model, OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const;
-
-    //@}
-    //
-   protected:
-    PythonModelMeasure() = default;
-
-   private:
-    REGISTER_LOGGER("openstudio.measure.ModelMeasure");
-  };
-
 }  // namespace measure
 }  // namespace openstudio
 
diff --git a/src/measure/ReportingMeasure.cpp b/src/measure/ReportingMeasure.cpp
index 0925dc8377d..4157a17ba73 100644
--- a/src/measure/ReportingMeasure.cpp
+++ b/src/measure/ReportingMeasure.cpp
@@ -56,23 +56,5 @@ namespace measure {
     return {};
   }
 
-  std::vector<OSArgument> PythonReportingMeasure::arguments(const openstudio::model::Model& model) const {
-    return {};
-  }
-
-  std::vector<OSOutput> PythonReportingMeasure::outputs() const {
-    return {};
-  }
-
-  bool PythonReportingMeasure::run(OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const {
-    runner.prepareForMeasureRun(*this);
-    return true;
-  }
-
-  std::vector<IdfObject> PythonReportingMeasure::energyPlusOutputRequests(OSRunner& runner,
-                                                                          const std::map<std::string, OSArgument>& user_arguments) const {
-    return {};
-  }
-
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/ReportingMeasure.hpp b/src/measure/ReportingMeasure.hpp
index 03d349a6bf5..083db6d9fe4 100644
--- a/src/measure/ReportingMeasure.hpp
+++ b/src/measure/ReportingMeasure.hpp
@@ -95,56 +95,6 @@ namespace measure {
     REGISTER_LOGGER("openstudio.measure.ReportingMeasure");
   };
 
-  /** PythonReportingMeasure is an abstract base class for UserScripts that generate reports. */
-  class MEASURE_API PythonReportingMeasure : public OSMeasure
-  {
-   public:
-    /** @name Constructors and Destructors */
-    //@{
-
-    virtual ~PythonReportingMeasure() = default;
-
-    //@}
-    /** @name Getters */
-    //@{
-
-    /** Returns the arguments for this script. In interactive applications, an OSRunner presents
-   *  these arguments to the user to produce an OSArgumentMap of user_arguments that it then passes
-   *  to this script's run method. The same basic steps should happen in applications with non-
-   *  interactive scripts, but in that case an entity other than an OSRunner may be in charge of
-   *  collecting user arguments. The base class implementation returns an empty vector. */
-    virtual std::vector<OSArgument> arguments(const openstudio::model::Model& model) const;
-
-    /** Returns the outputs for this script. The base class implementation returns an empty vector. */
-    virtual std::vector<OSOutput> outputs() const;
-
-    //@}
-    /** @name Actions */
-    //@{
-
-    /** Run the script with the given runner and user_arguments. The base class implementation calls
-   *  runner.prepareForMeasureRun(*this) and should be called at the beginning of derived class
-   *  implementations of this method. (In C++, that call looks like
-   *  PythonReportingMeasure::run(runner, user_arguments). In Ruby that call looks like
-   *  super(runner, user_arguments). */
-    virtual bool run(OSRunner& runner, const std::map<std::string, OSArgument>& 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
-   *  energy use. The injector code also prevents duplicate report requests.
-   *  The default implementation of this method returns an empty vector.
-   *  The runner allows the user to load the last osm/idf and to log messages.
-   * The argument map is available with the arguments for the reporting measure. */
-    virtual std::vector<IdfObject> energyPlusOutputRequests(OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const;
-    //@}
-   protected:
-    PythonReportingMeasure() = default;
-
-   private:
-    REGISTER_LOGGER("openstudio.measure.PythonReportingMeasure");
-  };
-
 }  // namespace measure
 }  // namespace openstudio
 
diff --git a/src/scriptengine/ScriptEngine.hpp b/src/scriptengine/ScriptEngine.hpp
index dbfe7cdc7b3..f39277fd71a 100644
--- a/src/scriptengine/ScriptEngine.hpp
+++ b/src/scriptengine/ScriptEngine.hpp
@@ -111,7 +111,11 @@ class ScriptEngineInstance
   ScriptEngineInstance& operator=(const ScriptEngineInstance&) = delete;
   ScriptEngineInstance& operator=(ScriptEngineInstance&&) = delete;
 
-  openstudio::ScriptEngine& operator->() {
+  void registerInitializationFunction(const std::function<void()>& runSetupFun) {
+    m_runSetupFun = runSetupFun;
+  }
+
+  ScriptEngine& operator->() {
     if (instance) {
       return *(instance);
     } else {
@@ -124,7 +128,9 @@ class ScriptEngineInstance
       engineLib = std::make_unique<DynamicLibrary>(enginePath);
       const auto factory = engineLib->load_symbol<ScriptEngineFactoryType>("makeScriptEngine");
       instance = std::unique_ptr<ScriptEngine>(factory(args.size(), argv.data()));
-
+      if (m_runSetupFun) {
+        m_runSetupFun();
+      }
       return *instance;
     }
   }
@@ -141,6 +147,7 @@ class ScriptEngineInstance
   std::vector<std::string> args;
   std::unique_ptr<ScriptEngine> instance;
   std::unique_ptr<DynamicLibrary> engineLib;
+  std::function<void()> m_runSetupFun;
 };
 
 }  // namespace openstudio
diff --git a/src/utilities/bcl/BCLMeasure.cpp b/src/utilities/bcl/BCLMeasure.cpp
index 8ca8f752dce..40a579825a7 100644
--- a/src/utilities/bcl/BCLMeasure.cpp
+++ b/src/utilities/bcl/BCLMeasure.cpp
@@ -406,12 +406,34 @@ BCLMeasure::BCLMeasure(const openstudio::path& dir) : m_directory(openstudio::fi
   }
 
   std::vector<Attribute> measureLanguages = m_bclXML.getAttributes("Measure Language");
+  boost::optional<MeasureLanguage> measureLanguage_;
   if (measureLanguages.empty()) {
     // No-op
   } else if (measureLanguages.size() > 1) {
     LOG_AND_THROW("'" << toString(dir) << "' has multiple copies of required attribute \"Measure Language\"");
   } else if (measureLanguages[0].valueType() != AttributeValueType::String) {
     LOG_AND_THROW("'" << toString(dir) << "' has wrong type for required attribute \"Measure Language\"");
+  } else {
+    measureLanguage_ = MeasureLanguage(measureLanguages[0].valueAsString());
+  }
+
+  // Don't allow having both a ruby measure.rb and a python measure.py listed in the same XML
+  const bool hasRuby = m_bclXML.hasFile(m_directory / "measure.rb");
+  const bool hasPython = m_bclXML.hasFile(m_directory / "measure.py");
+  if (hasRuby && hasPython) {
+    LOG_AND_THROW("'" << toString(dir) << "' has both measure.rb and measure.py, and they cannot be used at the same time.");
+  } else if (hasRuby) {
+    if (measureLanguage_ && measureLanguage_.get() != MeasureLanguage::Ruby) {
+      LOG_AND_THROW("'" << toString(dir) << "' has a measure.rb; but \"Measure Language\" is not 'Ruby', it's '" << measureLanguage_->valueName()
+                        << "'.");
+    }
+  } else if (hasPython) {
+    if (measureLanguage_ && measureLanguage_.get() != MeasureLanguage::Python) {
+      LOG_AND_THROW("'" << toString(dir) << "' has a measure.py; but \"Measure Language\" is not 'Python', it's '" << measureLanguage_->valueName()
+                        << "'.");
+    }
+  } else {
+    LOG_AND_THROW("'" << toString(dir) << "' has neither measure.rb nor measure.py");
   }
 
   if (m_bclXML.xmlChecksum().empty()) {
@@ -1044,6 +1066,25 @@ bool BCLMeasure::checkForUpdatesFiles() {
     m_bclXML.addFile(file);
   }
 
+  // Don't allow having both a ruby and a python measure in the same folder
+  bool hasRuby = m_bclXML.hasFile(m_directory / "measure.rb");
+  bool hasPython = m_bclXML.hasFile(m_directory / "measure.py");
+  if (hasRuby && hasPython) {
+    LOG(Warn, "Both measure.rb and measure.py cannot be in the same measure folder. Keeping only the ruby one");
+    setMeasureLanguage(MeasureLanguage::Ruby);
+    m_bclXML.removeFile(m_directory / "measure.py");
+  } else if (hasRuby) {
+    if (measureLanguage() != MeasureLanguage::Ruby) {
+      setMeasureLanguage(MeasureLanguage::Ruby);
+    }
+  } else if (hasPython) {
+    if (measureLanguage() != MeasureLanguage::Python) {
+      setMeasureLanguage(MeasureLanguage::Python);
+    }
+  } else {
+    LOG_AND_THROW("measure at " << m_directory << " has neither measure.rb nor measure.py");
+  }
+
   // increment version if anything changed
   if (result) {
     m_bclXML.incrementVersionId();
@@ -1161,4 +1202,8 @@ boost::optional<BCLMeasure> BCLMeasure::clone(const openstudio::path& newDir) co
   return BCLMeasure::load(newDir);
 }
 
+std::string BCLMeasure::xmlString() const {
+  return m_bclXML.toString();
+}
+
 }  // namespace openstudio
diff --git a/src/utilities/bcl/BCLMeasure.hpp b/src/utilities/bcl/BCLMeasure.hpp
index 877cbe4bf6c..78bc50a3ec0 100644
--- a/src/utilities/bcl/BCLMeasure.hpp
+++ b/src/utilities/bcl/BCLMeasure.hpp
@@ -148,6 +148,9 @@ class UTILITIES_API BCLMeasure
 
   std::string xmlChecksum() const;
 
+  // string representation of the underlying BCLXML
+  std::string xmlString() const;
+
   std::string name() const;
 
   std::string displayName() const;
diff --git a/src/utilities/bcl/BCLXML.cpp b/src/utilities/bcl/BCLXML.cpp
index 836e617d4bf..c758512b1f1 100644
--- a/src/utilities/bcl/BCLXML.cpp
+++ b/src/utilities/bcl/BCLXML.cpp
@@ -47,9 +47,9 @@ BCLXML::BCLXML(const BCLXMLType& bclXMLType)
 
 BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem::system_complete(xmlPath)) {
   if (!openstudio::filesystem::exists(xmlPath)) {
-    LOG_AND_THROW("'" << toString(xmlPath) << "' does not exist");
+    LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' does not exist");
   } else if (!openstudio::filesystem::is_regular_file(xmlPath)) {
-    LOG_AND_THROW("'" << toString(xmlPath) << "' cannot be opened for reading BCL XML data");
+    LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' cannot be opened for reading BCL XML data");
   }
 
   pugi::xml_document bclXML;
@@ -58,13 +58,13 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
   if (file.is_open()) {
     auto result = bclXML.load(file);
     if (!result) {
-      LOG_AND_THROW("'" << toString(xmlPath) << "' could not be read as XML data");
+      LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' could not be read as XML data");
     }
 
     file.close();
   } else {
     file.close();
-    LOG_AND_THROW("'" << toString(xmlPath) << "' could not be opened");
+    LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' could not be opened");
   }
 
   auto element = bclXML.child("component");
@@ -75,7 +75,7 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
     if (element) {
       m_bclXMLType = BCLXMLType::MeasureXML;
     } else {
-      LOG_AND_THROW("'" << toString(xmlPath) << "' is not a correct BCL XML");
+      LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' is not a correct BCL XML");
     }
   }
 
@@ -98,14 +98,14 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
   m_versionId = element.child("version_id").text().as_string();
 
   if (m_name.empty() || m_uid.empty() || m_versionId.empty()) {
-    LOG_AND_THROW("'" << toString(xmlPath) << "' is not a correct BCL XML");
+    LOG_AND_THROW("'" << openstudio::toString(xmlPath) << "' is not a correct BCL XML");
   }
 
   // added in schema version 3
   // error is only present if something went wrong, so we check if it exists first
   // to avoid always initializing m_error to a string (even if empty)
   // note: decodeString(element.child("error").text().as_string()) would always return a string even if 'error' key didn't exist
-  pugi::xml_node errorElement = element.child("error");
+  const pugi::xml_node errorElement = element.child("error");
   if (errorElement) {
     m_error = errorElement.text().as_string();
   }
@@ -140,7 +140,7 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
     if (subelement) {
       for (auto& arg : subelement.children("argument")) {
         try {
-          m_arguments.push_back(BCLMeasureArgument(arg));
+          m_arguments.emplace_back(arg);
         } catch (const std::exception&) {
           LOG(Error, "Bad argument in BCL XML");
         }
@@ -152,7 +152,7 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
       for (auto& outputElement : subelement.children("output")) {
         if (outputElement.first_child() != nullptr) {
           try {
-            m_outputs.push_back(BCLMeasureOutput(outputElement));
+            m_outputs.emplace_back(outputElement);
           } catch (const std::exception&) {
             LOG(Error, "Bad output in BCL XML");
           }
@@ -199,10 +199,10 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
             }
           }
         }
-        std::string fileName = fileElement.child("filename").text().as_string();
+        const std::string fileName = fileElement.child("filename").text().as_string();
         //std::string fileType = fileElement.firstChildElement("filetype").firstChild().nodeValue().toStdString();
-        std::string usageType = fileElement.child("usage_type").text().as_string();
-        std::string checkSum = fileElement.child("checksum").text().as_string();
+        const std::string usageType = fileElement.child("usage_type").text().as_string();
+        const std::string checkSum = fileElement.child("checksum").text().as_string();
 
         openstudio::path relativePath;
 
@@ -251,42 +251,29 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
 
         if (datatype == "float") {
           if (units.empty()) {
-            Attribute attr(name, boost::lexical_cast<double>(value));
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, boost::lexical_cast<double>(value));
           } else {
-            Attribute attr(name, boost::lexical_cast<double>(value), units);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, boost::lexical_cast<double>(value), units);
           }
         } else if (datatype == "int") {
           if (units.empty()) {
-            Attribute attr(name, boost::lexical_cast<int>(value));
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, boost::lexical_cast<int>(value));
           } else {
-            Attribute attr(name, boost::lexical_cast<int>(value), units);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, boost::lexical_cast<int>(value), units);
           }
         } else if (datatype == "boolean") {
-          bool temp;
-          if (value == "true") {
-            temp = true;
-          } else {
-            temp = false;
-          }
+          const bool temp = (value == "true");
           if (units.empty()) {
-            Attribute attr(name, temp);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, temp);
           } else {
-            Attribute attr(name, temp, units);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, temp, units);
           }
         } else {
           // Assume string
           if (units.empty()) {
-            Attribute attr(name, value);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, value);
           } else {
-            Attribute attr(name, value, units);
-            m_attributes.push_back(attr);
+            m_attributes.emplace_back(name, value, units);
           }
         }
       } else {
@@ -300,7 +287,7 @@ BCLXML::BCLXML(const openstudio::path& xmlPath) : m_path(openstudio::filesystem:
     for (auto& tagElement : subelement.children("tag")) {
       auto text = tagElement.text();
       if (!text.empty()) {
-        m_tags.push_back(text.as_string());
+        m_tags.emplace_back(text.as_string());
       }
     }
   }
@@ -492,7 +479,7 @@ void BCLXML::addFile(const BCLFileReference& file) {
 bool BCLXML::hasFile(const openstudio::path& path) const {
   bool result = false;
 
-  openstudio::path test = openstudio::filesystem::system_complete(path);
+  const openstudio::path test = openstudio::filesystem::system_complete(path);
 
   for (const BCLFileReference& file : m_files) {
     if (file.path() == test) {
@@ -507,7 +494,7 @@ bool BCLXML::hasFile(const openstudio::path& path) const {
 bool BCLXML::removeFile(const openstudio::path& path) {
   bool result = false;
 
-  openstudio::path test = openstudio::filesystem::system_complete(path);
+  const openstudio::path test = openstudio::filesystem::system_complete(path);
 
   std::vector<BCLFileReference> newFiles;
   for (const BCLFileReference& file : m_files) {
@@ -518,7 +505,7 @@ bool BCLXML::removeFile(const openstudio::path& path) {
     }
   }
 
-  if (result == true) {
+  if (result) {
     incrementVersionId();
     m_files = newFiles;
   }
@@ -548,7 +535,7 @@ bool BCLXML::removeAttributes(const std::string& name) {
     }
   }
 
-  if (result == true) {
+  if (result) {
     incrementVersionId();
     m_attributes = newAttributes;
   }
@@ -584,12 +571,10 @@ void BCLXML::clearTags() {
   m_tags.clear();
 }
 
-bool BCLXML::save() const {
-  if (m_path.empty()) {
-    return false;
-  }
+pugi::xml_document BCLXML::toXML() const {
 
   pugi::xml_document doc;
+
   //doc.createProcessingInstruction("xml", "version=\"1.0\" encoding=\"utf-8\"");
 
   pugi::xml_node docElement;
@@ -598,7 +583,7 @@ bool BCLXML::save() const {
   } else if (m_bclXMLType == BCLXMLType::MeasureXML) {
     docElement = doc.append_child("measure");
   } else {
-    return false;
+    LOG_AND_THROW("Unknown BCLXMLType '" << m_bclXMLType.valueName() << "'.");
   }
 
   auto element = docElement.append_child("schema_version");
@@ -747,6 +732,21 @@ bool BCLXML::save() const {
     file.writeValues(subelement);
   }
 
+  return doc;
+}
+
+bool BCLXML::save() const {
+
+  if (m_path.empty()) {
+    LOG(Warn, "Cannot save, since it has no known path. Use BCLXML::save(const openstudio::path& xmlPath) instead.");
+    return false;
+  }
+
+  const pugi::xml_document doc = toXML();
+  if (!doc) {
+    return false;
+  }
+
   // write to disk
   openstudio::filesystem::ofstream file(m_path);
   if (!file.is_open()) {
@@ -764,6 +764,31 @@ bool BCLXML::saveAs(const openstudio::path& xmlPath) {
   return save();
 }
 
+std::string BCLXML::toString() const {
+
+  std::string result;
+
+  const pugi::xml_document doc = toXML();
+  if (!doc) {
+    return result;
+  }
+
+  std::stringstream ss;
+  doc.save(ss, "  ");
+  result = ss.str();
+
+  return result;
+}
+
+std::ostream& operator<<(std::ostream& os, const BCLXML& bclXML) {
+  const pugi::xml_document doc = bclXML.toXML();
+  if (doc) {
+    doc.save(os, "  ");
+  }
+
+  return os;
+}
+
 void BCLXML::changeUID() {
   m_uid = removeBraces(openstudio::createUUID());
   // DLM: should this call incrementVersionId() ?
diff --git a/src/utilities/bcl/BCLXML.hpp b/src/utilities/bcl/BCLXML.hpp
index 25dc345c510..6ec91693936 100644
--- a/src/utilities/bcl/BCLXML.hpp
+++ b/src/utilities/bcl/BCLXML.hpp
@@ -42,6 +42,10 @@
 
 #include <vector>
 
+namespace pugi {
+class xml_document;
+}  // namespace pugi
+
 namespace openstudio {
 
 class BCLComponent;
@@ -207,11 +211,15 @@ class UTILITIES_API BCLXML
   //@{
 
   /// Save the XML back to the original path, always increments version id.
+  /// TODO: does it? it wouldn't be const if it did
   bool save() const;
 
   /// Save the XML to a new path, always increments version id.
   bool saveAs(const openstudio::path& xmlPath);
 
+  /// Returns a string representation of the XML
+  std::string toString() const;
+
   void changeUID();
 
   void incrementVersionId();
@@ -222,6 +230,9 @@ class UTILITIES_API BCLXML
   bool checkForUpdatesXML();
 
   //@}
+ protected:
+  /** Prints BCLXML to os. \relates BCLXML */
+  UTILITIES_API friend std::ostream& operator<<(std::ostream& os, const BCLXML& bclXML);
 
  private:
   // configure logging
@@ -230,6 +241,8 @@ class UTILITIES_API BCLXML
   /// Compute the current xml checksum
   std::string computeXMLChecksum() const;
 
+  pugi::xml_document toXML() const;
+
   openstudio::path m_path;
   BCLXMLType m_bclXMLType;
   boost::optional<std::string> m_error;
diff --git a/src/utilities/bcl/LocalBCL.i b/src/utilities/bcl/LocalBCL.i
index 476609df443..045fc2eb93c 100644
--- a/src/utilities/bcl/LocalBCL.i
+++ b/src/utilities/bcl/LocalBCL.i
@@ -98,4 +98,12 @@
 %include <utilities/bcl/RemoteBCL.hpp>
 %include <utilities/bcl/LocalBCL.hpp>
 
+%extend openstudio::BCLXML {
+  std::string __str__() {
+    std::ostringstream os;
+    os << *self;
+    return os.str();
+  }
+};
+
 #endif // UTILITIES_BCL_LOCALBCL_I
diff --git a/src/utilities/filetypes/WorkflowJSON.cpp b/src/utilities/filetypes/WorkflowJSON.cpp
index c3c1699529c..3f227904069 100644
--- a/src/utilities/filetypes/WorkflowJSON.cpp
+++ b/src/utilities/filetypes/WorkflowJSON.cpp
@@ -641,6 +641,20 @@ namespace detail {
     return result;
   }
 
+  std::vector<std::pair<unsigned, MeasureStep>> WorkflowJSON_Impl::getMeasureStepsWithIndex(const MeasureType& measureType) const {
+    std::vector<std::pair<unsigned, MeasureStep>> result;
+    size_t n = m_steps.size();
+    OS_ASSERT(m_measureTypes.size() == n);
+    for (size_t i = 0; i < n; ++i) {
+      if (m_steps[i].optionalCast<MeasureStep>()) {
+        if (m_measureTypes[i] == measureType.value()) {
+          result.emplace_back(i, m_steps[i].cast<MeasureStep>());
+        }
+      }
+    }
+    return result;
+  }
+
   bool WorkflowJSON_Impl::setMeasureSteps(const MeasureType& measureType, const std::vector<MeasureStep>& steps) {
 
     // verify steps
@@ -1113,6 +1127,10 @@ std::vector<MeasureStep> WorkflowJSON::getMeasureSteps(const MeasureType& measur
   return getImpl<detail::WorkflowJSON_Impl>()->getMeasureSteps(measureType);
 }
 
+std::vector<std::pair<unsigned, MeasureStep>> WorkflowJSON::getMeasureStepsWithIndex(const MeasureType& measureType) const {
+  return getImpl<detail::WorkflowJSON_Impl>()->getMeasureStepsWithIndex(measureType);
+}
+
 bool WorkflowJSON::setMeasureSteps(const MeasureType& measureType, const std::vector<MeasureStep>& steps) {
   return getImpl<detail::WorkflowJSON_Impl>()->setMeasureSteps(measureType, steps);
 }
diff --git a/src/utilities/filetypes/WorkflowJSON.hpp b/src/utilities/filetypes/WorkflowJSON.hpp
index 8007d546757..65a2b8d3f77 100644
--- a/src/utilities/filetypes/WorkflowJSON.hpp
+++ b/src/utilities/filetypes/WorkflowJSON.hpp
@@ -232,6 +232,8 @@ class UTILITIES_API WorkflowJSON
   /** Gets measure steps by measure type. */
   std::vector<MeasureStep> getMeasureSteps(const MeasureType& measureType) const;
 
+  std::vector<std::pair<unsigned, MeasureStep>> getMeasureStepsWithIndex(const MeasureType& measureType) const;
+
   /** Sets measure steps of a given type. Does not change other measure steps. */
   bool setMeasureSteps(const MeasureType& measureType, const std::vector<MeasureStep>& steps);
 
diff --git a/src/utilities/filetypes/WorkflowJSON_Impl.hpp b/src/utilities/filetypes/WorkflowJSON_Impl.hpp
index fe18a0a9f70..02fba67bfcf 100644
--- a/src/utilities/filetypes/WorkflowJSON_Impl.hpp
+++ b/src/utilities/filetypes/WorkflowJSON_Impl.hpp
@@ -154,6 +154,8 @@ namespace detail {
 
     std::vector<MeasureStep> getMeasureSteps(const MeasureType& measureType) const;
 
+    std::vector<std::pair<unsigned, MeasureStep>> getMeasureStepsWithIndex(const MeasureType& measureType) const;
+
     bool setMeasureSteps(const MeasureType& measureType, const std::vector<MeasureStep>& steps);
 
     boost::optional<BCLMeasure> getBCLMeasure(const MeasureStep& step) const;
diff --git a/src/workflow/ApplyMeasure.cpp b/src/workflow/ApplyMeasure.cpp
new file mode 100644
index 00000000000..b2426bccf36
--- /dev/null
+++ b/src/workflow/ApplyMeasure.cpp
@@ -0,0 +1,269 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+#include "../osversion/VersionTranslator.hpp"
+#include "../measure/OSMeasure.hpp"
+#include "../measure/ModelMeasure.hpp"
+#include "../measure/EnergyPlusMeasure.hpp"
+#include "../measure/ReportingMeasure.hpp"
+#include "../measure/OSArgument.hpp"
+#include "../measure/OSRunner.hpp"
+#include "../model/Model.hpp"
+#include "../model/Model_Impl.hpp"
+#include "../utilities/filetypes/WorkflowStep.hpp"
+#include "../utilities/bcl/BCLMeasure.hpp"
+#include "../utilities/idf/Workspace.hpp"
+#include "../utilities/data/Variant.hpp"
+#include "../utilities/core/Filesystem.hpp"
+#include "../utilities/core/Logger.hpp"
+#include "../energyplus/ForwardTranslator.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include <boost/filesystem/operations.hpp>
+
+namespace openstudio {
+
+void OSWorkflow::applyMeasures(MeasureType measureType, bool energyplus_output_requests) {
+
+  if (m_add_timings && m_detailed_timings) {
+    m_timers->newTimer(fmt::format("{}:apply_measures", measureType.valueName()), 1);
+  }
+
+  LOG(Debug, "Finding measures of type " << measureType.valueName());
+
+  // We actually want the order of the steps preserved, so this won't cut it
+  // const auto modelSteps = workflowJSON.getMeasureSteps(stepType);
+
+  for (const auto& stepAndIndex : workflowJSON.getMeasureStepsWithIndex(measureType)) {
+    auto stepIndex = stepAndIndex.first;
+    auto step = stepAndIndex.second;
+    LOG(Debug, "Running step " << stepIndex);
+
+    //  measure_run_dir = File.join(run_dir, "#{step_index.to_s.rjust(3,'0')}_#{measure_dir_name}")
+    //  logger.debug "Creating run directory for measure in #{measure_run_dir}"
+    //  FileUtils.mkdir_p measure_run_dir
+    //  Dir.chdir measure_run_dir
+
+    if (model.numObjects() > 0) {
+      runner.setLastOpenStudioModel(model);
+    }
+    if (workspace_) {
+      runner.setLastEnergyPlusWorkspace(workspace_.get());
+    }
+    if (!sqlPath.empty()) {
+      runner.setLastEnergyPlusSqlFilePath(sqlPath);
+    }
+    if (!epwPath.empty()) {
+      runner.setLastEpwFilePath(epwPath);
+    }
+
+    const auto measureDirName = step.measureDirName();
+    if (openstudio::filesystem::path(measureDirName).is_absolute()) {
+      LOG(Warn, "measure_dir_name should not be a full path. It should be a relative path to the measure directory or the name of the measure "
+                "directory containing the measure.rb / measure.py file.");
+    }
+
+    if (m_add_timings && m_detailed_timings) {
+      m_timers->newTimer(fmt::format("Measure::{}", measureDirName), 2);
+    }
+
+    openstudio::filesystem::path curDirPath = boost::filesystem::current_path();
+    auto thisRunDir = workflowJSON.absoluteRunDir() / openstudio::toPath(fmt::format("{:03}_{}", stepIndex, measureDirName));
+    LOG(Debug, "Creating run directory for measure in '" << thisRunDir << "'");
+
+    openstudio::filesystem::create_directory(thisRunDir);
+    boost::filesystem::current_path(thisRunDir);
+
+    const auto measureDirPath_ = workflowJSON.findMeasure(measureDirName);
+    if (!measureDirPath_) {
+      LOG_AND_THROW("Could not find measure '" << measureDirName << "'.");
+      continue;
+    }
+    BCLMeasure bclMeasure(measureDirPath_.get());
+
+    const auto scriptPath_ = bclMeasure.primaryScriptPath();
+    if (!scriptPath_) {
+      LOG(Warn, "Could not find primaryScriptPath '" << measureDirName << "'");
+      continue;
+    }
+    LOG(Debug, fmt::format("Found {} at primaryScriptPath: '{}'", measureDirName, openstudio::toString(scriptPath_.get())));
+    const std::string className = bclMeasure.className();
+    const auto measureType = bclMeasure.measureType();
+    const MeasureLanguage measureLanguage = bclMeasure.measureLanguage();
+
+    // TODO: will add a Logger later
+    LOG(Debug, "Class Name: " << className);
+    LOG(Debug, "Measure Script Path: " << scriptPath_.get());
+    LOG(Debug, "Measure Type: " << bclMeasure.measureType().valueName());
+    LOG(Debug, "Measure Language: " << measureLanguage.valueName());
+
+    //openstudio::measure::ModelMeasure* modelMeasurePtr = nullptr;
+    // TODO: probably want to do that ultimately, then static_cast appropriately
+    ScriptEngineInstance* thisEngine = nullptr;
+    ScriptObject measureScriptObject;
+    openstudio::measure::OSMeasure* measurePtr = nullptr;
+
+    auto getArguments = [this, &measureType, &scriptPath_, &step](openstudio::measure::OSMeasure* measurePtr) -> measure::OSArgumentMap {
+      if (!measurePtr) {
+        throw std::runtime_error(fmt::format("Could not load measure at '{}'", openstudio::toString(scriptPath_.get())));
+      }
+      // Initialize arguments which may be model dependent, don't allow arguments method access to real model in case it changes something
+      std::vector<measure::OSArgument> arguments;
+
+      LOG(Debug, "measure->name()= '" << measurePtr->name() << "'");
+
+      if (measureType == MeasureType::ModelMeasure) {
+        // For computing arguments
+        auto modelClone = model.clone(true).cast<model::Model>();
+        arguments = static_cast<openstudio::measure::ModelMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
+      } else if (measureType == MeasureType::EnergyPlusMeasure) {
+        auto workspaceClone = workspace_->clone(true).cast<openstudio::Workspace>();
+        arguments = static_cast<openstudio::measure::EnergyPlusMeasure*>(measurePtr)->arguments(workspaceClone);  // NOLINT
+      } else if (measureType == MeasureType::ReportingMeasure) {
+        auto modelClone = model.clone(true).cast<model::Model>();
+        arguments = static_cast<openstudio::measure::ReportingMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
+      }
+
+      measure::OSArgumentMap argumentMap;
+      if (!arguments.empty()) {
+        for (const auto& argument : arguments) {
+          LOG(Debug, "Argument: " << argument.name());
+          argumentMap[argument.name()] = argument.clone();
+        }
+
+        //  logger.debug "Iterating over arguments for workflow item '#{measure_dir_name}'"
+        auto stepArgs = step.arguments();
+        LOG(Debug, "Current step has " << stepArgs.size() << " arguments");
+        // handle skip first
+        if (!stepArgs.empty() && stepArgs.contains("__SKIP__")) {
+          // TODO: handling of SKIP is incomplete here, will need to increment the runner and co, and not process the measure
+
+        } else {
+          // TODO: I've copied workflow-gem here, but it's wrong. It's trying to issue a warning if an argument is not set, so it should loop on
+          // argumentMap instead...
+          for (auto const& [argumentName, argumentValue] : stepArgs) {
+            applyArguments(argumentMap, argumentName, argumentValue);
+          }
+        }
+      }
+
+      return argumentMap;
+    };
+
+    if (measureLanguage == MeasureLanguage::Ruby) {
+      // TODO: probably need to do path formatting properly for windows
+#if USE_RUBY_ENGINE
+      auto importCmd = fmt::format("require '{}'", openstudio::toString(scriptPath_.get()));
+      rubyEngine->exec(importCmd);
+      measureScriptObject = rubyEngine->eval(fmt::format("{}.new()", className));
+      thisEngine = &rubyEngine;
+#else
+      throw std::runtime_error("Cannot run a Ruby measure when RubyEngine isn't enabled");
+#endif
+    } else if (measureLanguage == MeasureLanguage::Python) {
+#if USE_PYTHON_ENGINE
+      // place measureDirPath in sys.path; do from measure import MeasureName
+      // I think this can't work without a "as xxx" otherwise we'll repeatedly try to import a module named 'measure'
+      // pythonEngine->pyimport("measure", openstudio::toString(measureDirPath.get()));
+      //         auto importCmd = fmt::format(R"python(
+      // import sys
+      // sys.path.insert(0, r'{}')
+      // force_reload = 'measure' in sys.modules
+      // import measure
+      // if force_reload:
+      //     # print("force reload measure")
+      //     import importlib
+      //     importlib.reload(measure)
+      // )python",
+      //                                      scriptPath_->parent_path().generic_string());
+      auto importCmd = fmt::format(R"python(
+import importlib.util
+spec = importlib.util.spec_from_file_location('{}', r'{}')
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+)python",
+                                   className, scriptPath_->generic_string());
+      // fmt::print("\nimportCmd:\n{}\n", importCmd);
+      pythonEngine->exec(importCmd);
+      // measureScriptObject = pythonEngine->eval(fmt::format("measure.{}()", className));
+      measureScriptObject = pythonEngine->eval(fmt::format("module.{}()", className));
+
+      thisEngine = &pythonEngine;
+#else
+      throw std::runtime_error("Cannot run a Python measure when PythonEngine isn't enabled");
+#endif
+    }
+
+    // This pointer will only be valid for as long as the above PythonMeasure is in scope
+    // After that, dereferencing the measure pointer will crash the program
+    if (measureType == MeasureType::ModelMeasure) {
+      measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
+    } else if (measureType == MeasureType::EnergyPlusMeasure) {
+      measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
+    } else if (measureType == MeasureType::ReportingMeasure) {
+      measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
+    }
+
+    const auto argmap = getArguments(measurePtr);
+    // There is a bug. I can run one measure but not two. The one measure can be either python or ruby
+    // I think it might have to do with the operations that must be done to the runner to reset state. maybe?
+    if (measureType == MeasureType::ModelMeasure) {
+      static_cast<openstudio::measure::ModelMeasure*>(measurePtr)->run(model, runner, argmap);
+    } else if (measureType == MeasureType::EnergyPlusMeasure) {
+      static_cast<openstudio::measure::EnergyPlusMeasure*>(measurePtr)->run(workspace_.get(), runner, argmap);
+    } else if (measureType == MeasureType::ReportingMeasure) {
+      if (energyplus_output_requests) {
+        LOG(Debug, "Calling measure.energyPlusOutputRequests for '" << measureDirName << "'");
+
+        std::vector<IdfObject> idfObjects;
+        idfObjects = static_cast<openstudio::measure::ReportingMeasure*>(measurePtr)->energyPlusOutputRequests(runner, argmap);
+
+        int num_added = 0;
+        for (auto& idfObject : idfObjects) {
+          if (openstudio::workflow::util::addEnergyPlusOutputRequest(workspace_.get(), idfObject)) {
+            ++num_added;
+          }
+        }
+        LOG(Debug, "Finished measure.energyPlusOutputRequests for '" << measureDirName << "', " << num_added << " output requests added");
+      } else {
+        static_cast<openstudio::measure::ReportingMeasure*>(measurePtr)->run(runner, argmap);
+      }
+    }
+
+    WorkflowStepResult result = runner.result();
+    if (auto stepResult_ = result.stepResult()) {
+      LOG(Debug, "Step Result: " << stepResult_->valueName());
+    }
+    // incrementStep must be called after run
+    runner.incrementStep();
+    if (auto errors = result.stepErrors(); !errors.empty()) {
+      throw std::runtime_error(fmt::format("Measure {} reported an error with [{}]\n", measureDirName, fmt::join(errors, "\n")));
+    }
+
+    if (measureType == MeasureType::ModelMeasure) {
+      if (auto weatherFile_ = model.weatherFile()) {
+        if (auto p_ = weatherFile_->path()) {
+          // Probably a workflowJSON.findFile() call...
+          // m_epwPath_ = p_;
+        } else {
+          LOG(Warn, "Weather file object found in model but no path is given");
+        }
+      }
+    }
+
+    if (m_add_timings && m_detailed_timings) {
+      m_timers->tockCurrentTimer();
+    }
+
+    boost::filesystem::current_path(curDirPath);
+
+  }  // End for each step
+
+  if (m_add_timings && m_detailed_timings) {
+    m_timers->tockCurrentTimer();
+  }
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/CMakeLists.txt b/src/workflow/CMakeLists.txt
index 51912cf1526..ef4ea3a9911 100644
--- a/src/workflow/CMakeLists.txt
+++ b/src/workflow/CMakeLists.txt
@@ -4,6 +4,25 @@ add_library(openstudio_workflow
   OSWorkflow.cpp
   WorkflowRunOptions.hpp
   WorkflowRunOptions.cpp
+
+  # Jobs
+  RunInitialization.cpp
+  RunOpenStudioMeasures.cpp
+  RunTranslator.cpp
+  RunEnergyPlusMeasures.cpp
+  RunPreProcess.cpp
+  RunEnergyPlus.cpp
+  RunReportingMeasures.cpp
+  RunPostProcess.cpp
+  RunCleanup.cpp
+
+  ApplyMeasure.cpp
+
+  # Util
+  Util.hpp
+  Util.cpp
+  Timer.hpp
+  Timer.cpp
 )
 
 target_link_libraries(openstudio_workflow PRIVATE openstudiolib)
diff --git a/src/workflow/OSWorkflow.cpp b/src/workflow/OSWorkflow.cpp
index 3e69d6cb807..0a1d121ed39 100644
--- a/src/workflow/OSWorkflow.cpp
+++ b/src/workflow/OSWorkflow.cpp
@@ -10,18 +10,38 @@
 #include "../measure/OSRunner.hpp"
 #include "../model/Model.hpp"
 #include "../model/Model_Impl.hpp"
-#include "../utilities/filetypes/WorkflowStep.hpp"
 #include "../utilities/bcl/BCLMeasure.hpp"
-#include "../utilities/idf/Workspace.hpp"
-#include "../utilities/data/Variant.hpp"
+#include "../utilities/core/Assert.hpp"
 #include "../utilities/core/Filesystem.hpp"
+#include "../utilities/core/FileLogSink.hpp"
 #include "../utilities/core/Logger.hpp"
+#include "../utilities/data/Variant.hpp"
+#include "../utilities/filetypes/WorkflowStep.hpp"
+#include "../utilities/idf/Workspace.hpp"
 #include "../energyplus/ForwardTranslator.hpp"
+#include "workflow/Util.hpp"
 
+#include <fmt/color.h>
+#include <fmt/chrono.h>
 #include <fmt/format.h>
 #include <fmt/ostream.h>
+
+#include <array>
+#include <chrono>
+#include <string_view>
 #include <stdexcept>
 
+// TODO: this should really in Variant.hpp, but I'm getting pwned by the fact that ruby defines int128_t as a macro, no time to track it down
+template <>
+struct fmt::formatter<openstudio::Variant> : fmt::ostream_formatter
+{
+};
+
+template <>
+struct fmt::formatter<openstudio::path> : fmt::ostream_formatter
+{
+};
+
 namespace openstudio {
 
 OSWorkflow::OSWorkflow(const filesystem::path& oswPath, ScriptEngineInstance& ruby, ScriptEngineInstance& python)
@@ -50,11 +70,13 @@ OSWorkflow::OSWorkflow(const WorkflowRunOptions& t_workflowRunOptions, ScriptEng
     m_add_timings(t_workflowRunOptions.add_timings),
     m_style_stdout(t_workflowRunOptions.style_stdout) {
 
+  if (m_add_timings) {
+    m_timers = std::make_unique<workflow::util::TimerCollection>();
+  }
+
   if (t_workflowRunOptions.runOptions.debug() || (workflowJSON.runOptions() && workflowJSON.runOptions()->debug())) {
-    fmt::print("Original workflowJSON={}\n", workflowJSON.string());
+    LOG(Debug, fmt::format("Original workflowJSON={}\n", workflowJSON.string()));
     t_workflowRunOptions.debug_print();
-    fmt::print("m_no_simulation={}, m_post_process_only={}, m_show_stdout={}, m_add_timings={}, m_style_stdout={}", m_no_simulation,
-               m_post_process_only, m_show_stdout, m_add_timings, m_style_stdout);
   }
   auto runOpt_ = workflowJSON.runOptions();
   if (!runOpt_) {
@@ -73,7 +95,7 @@ OSWorkflow::OSWorkflow(const WorkflowRunOptions& t_workflowRunOptions, ScriptEng
 }
 
 void OSWorkflow::applyArguments(measure::OSArgumentMap& argumentMap, const std::string& argumentName, const openstudio::Variant& argumentValue) {
-  fmt::print("Info: Setting argument value '{}' to '{}'\n", argumentName, argumentValue);
+  LOG(Info, "Setting argument value '" << argumentName << "' to '" << argumentValue << "'");
 
   // if (!argumentValue.hasValue()) {
   //   fmt::print("Warn: Value for argument '{}' not set in argument list therefore will use default\n", argumentName);
@@ -104,25 +126,68 @@ void OSWorkflow::applyArguments(measure::OSArgumentMap& argumentMap, const std::
   }
 }
 
+void OSWorkflow::saveOSMToRootDirIfDebug() {
+  if (!workflowJSON.runOptions() || !workflowJSON.runOptions()->debug()) {
+    return;
+  }
+
+  LOG(Info, "Saving OSM to Root Directory");
+  auto savePath = workflowJSON.absoluteRootDir() / "in.osm";
+  detailedTimeBlock("Saving OSM", [this, &savePath]() {
+    // TODO: workflow gem was actually serializating via model.to_s for speed...
+    model.save(savePath, true);
+  });
+  LOG(Info, "Saved OSM as " << savePath);
+}
+
+void OSWorkflow::saveIDFToRootDirIfDebug() {
+  if (!workflowJSON.runOptions() || !workflowJSON.runOptions()->debug()) {
+    return;
+  }
+  LOG(Info, "Saving IDF to Root Directory");
+  auto savePath = workflowJSON.absoluteRootDir() / "in.idf";
+  detailedTimeBlock("Saving IDF To Root Directory (debug)", [this, &savePath]() {
+    // TODO: workflow gem was actually serializating via model.to_s for speed...
+    workspace_->save(savePath, true);
+  });
+  LOG(Info, "Saved IDF as " << savePath);
+}
+
 void OSWorkflow::run() {
-  if (workflowJSON.runOptions()->debug()) {
+
+  if (!m_show_stdout) {
+    openstudio::Logger::instance().standardOutLogger().disable();
+  } else if (workflowJSON.runOptions()->debug()) {
     openstudio::Logger::instance().standardOutLogger().setLogLevel(Debug);
   }
 
-#if USE_RUBY_ENGINE
-  // rubyEngine->exec("puts 'Hello from Ruby'");
-  rubyEngine->registerType<openstudio::measure::ModelMeasure*>("openstudio::measure::ModelMeasure *");
-  rubyEngine->registerType<openstudio::measure::EnergyPlusMeasure*>("openstudio::measure::EnergyPlusMeasure *");
-  rubyEngine->registerType<openstudio::measure::ReportingMeasure*>("openstudio::measure::ReportingMeasure *");
-  // -1: gotta init the rest of OpenStudio in ruby, so that Ruleset and co are defined
-  rubyEngine->exec("OpenStudio::init_rest_of_openstudio()");
-#endif
-#if USE_PYTHON_ENGINE
-  // pythonEngine->exec("print('Hello from Python')");
-  pythonEngine->registerType<openstudio::measure::PythonModelMeasure*>("openstudio::measure::PythonModelMeasure *");
-  pythonEngine->registerType<openstudio::measure::PythonEnergyPlusMeasure*>("openstudio::measure::PythonEnergyPlusMeasure *");
-  pythonEngine->registerType<openstudio::measure::PythonReportingMeasure*>("openstudio::measure::PythonReportingMeasure *");
-#endif
+  // Need to recreate the runDir as fast as possible, so I can direct a file log sink there
+  bool hasDeletedRunDir = false;
+  auto runDirPath = workflowJSON.absoluteRunDir();
+  if (!workflowJSON.runOptions()->preserveRunDir()) {
+    // We don't have a run_dir argument anyways
+    if (openstudio::filesystem::is_directory(runDirPath)) {
+      hasDeletedRunDir = true;
+      openstudio::filesystem::remove_all(runDirPath);
+    }
+    openstudio::filesystem::create_directory(runDirPath);
+  }
+  FileLogSink logFile(runDirPath / "run.log");
+  logFile.setLogLevel(Debug);
+
+  if (hasDeletedRunDir) {
+    LOG(Debug, "Removing existing run directory: " << runDirPath);
+  }
+
+  // Communicate that the workflow has been started
+  LOG(Debug, "Registering that the workflow has started with the adapter");
+  {
+    // @output_adapter.communicate_started
+    openstudio::filesystem::ofstream file(runDirPath / "started.job");
+    OS_ASSERT(file.is_open());
+    file << fmt::format("Started Workflow {}\n", std::chrono::system_clock::now());
+    file.close();
+  }
 
   // bool no_simulation = false;
   // bool post_process = false;
@@ -135,269 +200,182 @@ void OSWorkflow::run() {
   //// O. Need to apply measure steps IN ORDER. (eg: OpenStudio Measures before Eplus measures etc)
   //// https://github.com/NREL/OpenStudio-workflow-gem/blob/develop/lib/openstudio/workflow/util/measure.rb
 
-  // 1. Instantiate seed model
+  auto timeJob = [this](memJobFunPtr job, const std::string& message) {
+    if (m_style_stdout) {
+      fmt::print(fmt::fg(fmt::color::green),
+                 "\n"
+                 "┌{0:─^{2}}┐\n"
+                 "│{1: ^{2}}│\n"
+                 "└{0:─^{2}}┘\n",
+                 "", std::string("Starting State ") + message, 80);
+    }
+    if (m_add_timings) {
+      m_timers->newTimer(message);
+    }
+    (this->*job)();
+    if (m_add_timings) {
+      m_timers->tockCurrentTimer();
+    }
+    if (m_style_stdout) {
+      fmt::print(fmt::fg(fmt::color::green), "{0:#^80}\n", std::string("Returned from State ") + message);
+    }
+  };
 
-  auto runInitialization = [this]() -> openstudio::model::Model {
-    fmt::print("Debug: Finding and loading the seed file\n");
-    auto seedPath_ = workflowJSON.seedFile();
-    if (!seedPath_) {
-      return openstudio::model::Model{};
+  struct JobInfo
+  {
+    memJobFunPtr jobFun;
+    bool selected = true;
+  };
+
+  struct JobMap
+  {
+    std::array<std::pair<std::string_view, JobInfo>, 9> data;
+
+    [[nodiscard]] JobInfo& at(const std::string_view& key) {
+      auto itr = std::find_if(std::begin(data), std::end(data), [&key](const auto& v) { return v.first == key; });
+      if (itr != std::end(data)) {
+        return itr->second;
+      } else {
+        throw std::range_error("Not Found");
+      }
     }
 
-    auto modelFullPath_ = workflowJSON.findFile(seedPath_.get());
-    if (!modelFullPath_) {
-      throw std::runtime_error(fmt::format("Seed model {} specified in OSW cannot be found", seedPath_.get()));
+    // Class methods
+    auto cbegin() const {
+      return data.cbegin();
     }
-    openstudio::osversion::VersionTranslator vt;
-    auto m_ = vt.loadModel(modelFullPath_.get());
-    if (!m_) {
-      throw std::runtime_error(fmt::format("Failed to load OSM file {}\n", openstudio::toString(seedPath_.get())));
+    auto cend() const {
+      return data.cend();
+    }
+    auto begin() const {
+      return cbegin();
+    }
+    auto end() const {
+      return cend();
+    }
+    auto begin() {
+      return data.begin();
+    }
+    auto end() {
+      return data.end();
     }
-    return m_.get();
   };
 
-  model::Model model = runInitialization();
-  boost::optional<openstudio::Workspace> workspace_;
-
-  auto runDir = workflowJSON.absoluteRunDir();
-  openstudio::filesystem::remove_all(runDir);
-  openstudio::filesystem::create_directory(runDir);
-
-  // 2. determine ruby or python
-  // 3. import measure.(py|rb)
-  // 4. instantiate measure
-  // 5. run measure
-
-  // TODO: need to add the ReportingMeasure to the Mix
-  // TODO: need to run the idf through EnergyPlus
-  // TODO: need to create subdirectories for steps
-  // TODO: need to do some cleanup, like removing old sim files, maybe reports, etc
-  // TODO: need to implement some of the options/flags like --postprocess_only
-  // TODO: need to merge workflowJSON flags with flags from command line (eg: FT options)
-  // TODO: need to modify utilities/RunOptions.hpp instead of duplicating some of that work in workflow
-
-  for (const auto stepType : {MeasureType::ModelMeasure, MeasureType::EnergyPlusMeasure}) {
-    if (stepType == MeasureType::EnergyPlusMeasure) {
-      // Save final Model
-      model.save(runDir / "in.osm", true);
-      openstudio::energyplus::ForwardTranslator ft;
-      ft.setForwardTranslatorOptions(workflowJSON.runOptions()->forwardTranslatorOptions());
-      workspace_ = ft.translateModel(model);
+  // Can't use a regular map, it's not retaining order
+  static constexpr std::array<std::pair<std::string_view, JobInfo>, 9> known_jobs{{
+    {"Initialization", {&OSWorkflow::runInitialization, true}},
+    {"OpenStudioMeasures", {&OSWorkflow::runOpenStudioMeasures, true}},
+    {"Translator", {&OSWorkflow::runTranslator, true}},
+    {"EnergyPlusMeasures", {&OSWorkflow::runEnergyPlusMeasures, true}},
+    {"PreProcess", {&OSWorkflow::runPreProcess, true}},
+    {"EnergyPlus", {&OSWorkflow::runEnergyPlus, true}},
+    {"ReportingMeasures", {&OSWorkflow::runReportingMeasures, true}},
+    {"PostProcess", {&OSWorkflow::runPostProcess, true}},
+    {"Cleanup", {&OSWorkflow::runCleanup, true}},
+  }};
+
+  JobMap jobMap{{known_jobs}};
+
+  if (m_no_simulation) {
+    jobMap.at("Initialization").selected = true;
+    jobMap.at("OpenStudioMeasures").selected = true;
+    jobMap.at("Translator").selected = true;
+    jobMap.at("EnergyPlusMeasures").selected = true;
+    jobMap.at("PreProcess").selected = true;
+    jobMap.at("EnergyPlus").selected = false;
+    jobMap.at("ReportingMeasures").selected = false;
+    jobMap.at("PostProcess").selected = true;
+    jobMap.at("Cleanup").selected = true;
+
+  } else if (m_post_process_only) {
+    jobMap.at("Initialization").selected = true;
+    jobMap.at("OpenStudioMeasures").selected = false;
+    jobMap.at("Translator").selected = false;
+    jobMap.at("EnergyPlusMeasures").selected = false;
+    jobMap.at("PreProcess").selected = false;
+    jobMap.at("EnergyPlus").selected = false;
+    jobMap.at("ReportingMeasures").selected = true;
+    jobMap.at("PostProcess").selected = true;
+    jobMap.at("Cleanup").selected = true;
+
+    workflowJSON.runOptions()->setPreserveRunDir(true);
+  }
+
+  if (!workflowJSON.runOptions()->cleanup() || workflowJSON.runOptions()->debug()) {
+    jobMap.at("Cleanup").selected = false;
+  }
+
+  for (auto& [jobName, jobInfo] : jobMap) {
+    LOG(Debug, fmt::format("{} - selected = {}\n", jobName, jobInfo.selected));
+    if (jobInfo.selected) {
+      timeJob(jobInfo.jobFun, std::string{jobName});
+    } else {
+      LOG(Info, "Skipping job " << jobName);
     }
+  }
 
-    const auto modelSteps = workflowJSON.getMeasureSteps(stepType);
-    for (const auto& step : modelSteps) {
-      unsigned stepIndex = workflowJSON.currentStepIndex();
-      fmt::print("\n\nRunning step {}\n", stepIndex);
-
-      //  measure_run_dir = File.join(run_dir, "#{step_index.to_s.rjust(3,'0')}_#{measure_dir_name}")
-      //  logger.debug "Creating run directory for measure in #{measure_run_dir}"
-      //  FileUtils.mkdir_p measure_run_dir
-      //  Dir.chdir measure_run_dir
-
-      const auto measureDirName = step.measureDirName();
-      const auto measureDirPath_ = workflowJSON.findMeasure(measureDirName);
-      if (!measureDirPath_) {
-        fmt::print("Could not find measure '{}'\n", measureDirName);
-        continue;
-      }
-      BCLMeasure bclMeasure(measureDirPath_.get());
+  // Save final IDF
+  if (m_add_timings) {
+    m_timers->newTimer("Save IDF");
+  }
+  workspace_->save(runDirPath / "in.idf", true);  // TODO: Is this really necessary? Seems like it's done before already
+  if (m_add_timings) {
+    m_timers->tockCurrentTimer();
+  }
 
-      const auto scriptPath_ = bclMeasure.primaryScriptPath();
-      if (!scriptPath_) {
-        fmt::print("Could not find primaryScriptPath '{}'\n", measureDirName);
-        continue;
-      }
-      fmt::print("Found {} at primaryScriptPath: '{}'\n", measureDirName, openstudio::toString(scriptPath_.get()));
-      const std::string className = bclMeasure.className();
-      const auto measureType = bclMeasure.measureType();
-      const MeasureLanguage measureLanguage = bclMeasure.measureLanguage();
-
-      // TODO: will add a Logger later
-      fmt::print("Class Name: {}\n", className);
-      fmt::print("Measure Script Path: {}\n", openstudio::toString(scriptPath_.get()));
-      fmt::print("Measure Type: {}\n", bclMeasure.measureType().valueName());
-      fmt::print("Measure Language: {}\n", measureLanguage.valueName());
-
-      //openstudio::measure::ModelMeasure* modelMeasurePtr = nullptr;
-      // TODO: probably want to do that ultimately, then static_cast appropriately
-      ScriptEngineInstance* thisEngine;
-      ScriptObject measureScriptObject;
-      openstudio::measure::OSMeasure* measurePtr = nullptr;
-
-      auto getArguments = [&model, &workspace_, &measureType, &scriptPath_, &step,
-                           &measureLanguage](openstudio::measure::OSMeasure* measurePtr) -> measure::OSArgumentMap {
-        if (!measurePtr) {
-          throw std::runtime_error(fmt::format("Could not load measure at '{}'", openstudio::toString(scriptPath_.get())));
-        }
-        // Initialize arguments which may be model dependent, don't allow arguments method access to real model in case it changes something
-        std::vector<measure::OSArgument> arguments;
-
-        fmt::print("measure->name()= '{}'\n", measurePtr->name());
-
-        if (measureType == MeasureType::ModelMeasure) {
-          // For computing arguments
-          auto modelClone = model.clone(true).cast<model::Model>();
-          if (measureLanguage == MeasureLanguage::Ruby) {
-            arguments = static_cast<openstudio::measure::ModelMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
-          } else if (measureLanguage == MeasureLanguage::Python) {
-            arguments = static_cast<openstudio::measure::PythonModelMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
-          }
-        } else if (measureType == MeasureType::EnergyPlusMeasure) {
-          auto workspaceClone = workspace_->clone(true).cast<openstudio::Workspace>();
-          if (measureLanguage == MeasureLanguage::Ruby) {
-            arguments = static_cast<openstudio::measure::EnergyPlusMeasure*>(measurePtr)->arguments(workspaceClone);  // NOLINT
-          } else if (measureLanguage == MeasureLanguage::Python) {
-            arguments = static_cast<openstudio::measure::PythonEnergyPlusMeasure*>(measurePtr)->arguments(workspaceClone);  // NOLINT
-          }
-        } else if (measureType == MeasureType::ReportingMeasure) {
-          auto modelClone = model.clone(true).cast<model::Model>();
-          if (measureLanguage == MeasureLanguage::Ruby) {
-            arguments = static_cast<openstudio::measure::ReportingMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
-          } else if (measureLanguage == MeasureLanguage::Python) {
-            arguments = static_cast<openstudio::measure::PythonReportingMeasure*>(measurePtr)->arguments(modelClone);  // NOLINT
-          }
-        }
-
-        measure::OSArgumentMap argumentMap;
-        if (!arguments.empty()) {
-          for (const auto& argument : arguments) {
-            fmt::print("Argument: {}\n", argument.name());
-            argumentMap[argument.name()] = argument.clone();
-          }
-
-          //  logger.debug "Iterating over arguments for workflow item '#{measure_dir_name}'"
-          auto stepArgs = step.arguments();
-          fmt::print("Current step has {} arguments\n", stepArgs.size());
-          // handle skip first
-          if (!stepArgs.empty() && stepArgs.contains("__SKIP__")) {
-            // TODO: handling of SKIP is incomplete here, will need to increment the runner and co, and not process the measure
-
-          } else {
-            // TODO: I've copied workflow-gem here, but it's wrong. It's trying to issue a warning if an argument is not set, so it should loop on
-            // argumentMap instead...
-            for (auto const& [argumentName, argumentValue] : stepArgs) {
-              applyArguments(argumentMap, argumentName, argumentValue);
-            }
-          }
-        }
-
-        return argumentMap;
-      };
-
-      if (measureLanguage == MeasureLanguage::Ruby) {
-        // TODO: probably need to do path formatting properly for windows
-#if USE_RUBY_ENGINE
-        auto importCmd = fmt::format("require '{}'", openstudio::toString(scriptPath_.get()));
-        rubyEngine->exec(importCmd);
-        measureScriptObject = rubyEngine->eval(fmt::format("{}.new()", className));
-        thisEngine = &rubyEngine;
-#else
-        throw std::runtime_error("Cannot run a Ruby measure when RubyEngine isn't enabled");
-#endif
-      } else if (measureLanguage == MeasureLanguage::Python) {
-#if USE_PYTHON_ENGINE
-        // place measureDirPath in sys.path; do from measure import MeasureName
-        // I think this can't work without a "as xxx" otherwise we'll repeatedly try to import a module named 'measure'
-        // pythonEngine->pyimport("measure", openstudio::toString(measureDirPath.get()));
-        //         auto importCmd = fmt::format(R"python(
-        // import sys
-        // sys.path.insert(0, r'{}')
-        // force_reload = 'measure' in sys.modules
-        // import measure
-        // if force_reload:
-        //     # print("force reload measure")
-        //     import importlib
-        //     importlib.reload(measure)
-        // )python",
-        //                                      scriptPath_->parent_path().generic_string());
-        auto importCmd = fmt::format(R"python(
-import importlib.util
-spec = importlib.util.spec_from_file_location('{}', r'{}')
-module = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(module)
-)python",
-                                     className, scriptPath_->generic_string());
-        // fmt::print("\nimportCmd:\n{}\n", importCmd);
-        pythonEngine->exec(importCmd);
-        // measureScriptObject = pythonEngine->eval(fmt::format("measure.{}()", className));
-        measureScriptObject = pythonEngine->eval(fmt::format("module.{}()", className));
-
-        thisEngine = &pythonEngine;
-#else
-        throw std::runtime_error("Cannot run a Python measure when PythonEngine isn't enabled");
-#endif
-      }
+  if (!workflowJSON.runOptions()->fast()) {
+    if (m_add_timings) {
+      m_timers->newTimer("Zip datapoint");
+    }
+    openstudio::workflow::util::zipResults(runDirPath);
+    if (m_add_timings) {
+      m_timers->tockCurrentTimer();
+    }
+  }
 
-      // This pointer will only be valid for as long as the above PythonMeasure is in scope
-      // After that, dereferencing the measure pointer will crash the program
-      if (measureType == MeasureType::ModelMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::PythonModelMeasure*>(measureScriptObject);
-        }
-      } else if (measureType == MeasureType::EnergyPlusMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::PythonEnergyPlusMeasure*>(measureScriptObject);
-        }
-      } else if (measureType == MeasureType::ReportingMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          measurePtr = (*thisEngine)->getAs<openstudio::measure::PythonReportingMeasure*>(measureScriptObject);
-        }
-      }
+  if (state == State::Errored) {
+    workflowJSON.setCompletedStatus("Fail");
+  } else {
+    // completed status will already be set if workflow was halted
+    if (!workflowJSON.completedStatus()) {
+      workflowJSON.setCompletedStatus("Success");
+    } else if (workflowJSON.completedStatus().get() == "Fail") {
+      state = State::Errored;
+    }
+  }
 
-      const auto argmap = getArguments(measurePtr);
-      // There is a bug. I can run one measure but not two. The one measure can be either python or ruby
-      // I think it might have to do with the operations that must be done to the runner to reset state. maybe?
-      if (measureType == MeasureType::ModelMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          static_cast<openstudio::measure::ModelMeasure*>(measurePtr)->run(model, runner, argmap);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          static_cast<openstudio::measure::PythonModelMeasure*>(measurePtr)->run(model, runner, argmap);
-        }
-      } else if (measureType == MeasureType::EnergyPlusMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          static_cast<openstudio::measure::EnergyPlusMeasure*>(measurePtr)->run(workspace_.get(), runner, argmap);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          static_cast<openstudio::measure::PythonEnergyPlusMeasure*>(measurePtr)->run(workspace_.get(), runner, argmap);
-        }
-      } else if (measureType == MeasureType::ReportingMeasure) {
-        if (measureLanguage == MeasureLanguage::Ruby) {
-          static_cast<openstudio::measure::ReportingMeasure*>(measurePtr)->run(runner, argmap);
-        } else if (measureLanguage == MeasureLanguage::Python) {
-          static_cast<openstudio::measure::PythonReportingMeasure*>(measurePtr)->run(runner, argmap);
-        }
-      }
+  if (!workflowJSON.runOptions()->fast()) {
+    // Save workflow
+    if (m_add_timings) {
+      m_timers->newTimer("Save WorkflowJSON out.osw");
+    }
+    workflowJSON.saveAs(workflowJSON.absoluteOutPath());
+    if (workflowJSON.runOptions()->debug()) {
+      fmt::print("workflowJSON={}\n", workflowJSON.string());
+    }
+    if (m_add_timings) {
+      m_timers->tockCurrentTimer();
+    }
+  }
 
-      WorkflowStepResult result = runner.result();
-      if (auto stepResult_ = result.stepResult()) {
-        fmt::print("Step Result: {}\n", stepResult_->valueName());
-      }
-      // incrementStep must be called after run
-      runner.incrementStep();
-      if (auto errors = result.stepErrors(); !errors.empty()) {
-        throw std::runtime_error(fmt::format("Measure {} reported an error with [{}]\n", measureDirName, fmt::join(errors, "\n")));
-      }
+  if (state == State::Errored) {
+    // TODO: communicate_failure
+    openstudio::filesystem::ofstream file(runDirPath / "failed.job");
+    OS_ASSERT(file.is_open());
+    file << fmt::format("Failed Workflow {}\n", std::chrono::system_clock::now());
+    file.close();
+  } else {
+    // TODO: communicate_complete
+    openstudio::filesystem::ofstream file(runDirPath / "finished.job");
+    OS_ASSERT(file.is_open());
+    file << fmt::format("Finished Workflow {}\n", std::chrono::system_clock::now());
+    file.close();
+  }
 
-      if (measureType == MeasureType::ModelMeasure) {
-        if (auto weatherFile_ = model.weatherFile()) {
-          if (auto p_ = weatherFile_->path()) {
-            // Probably a workflowJSON.findFile() call...
-            // m_epwPath_ = p_;
-          } else {
-            fmt::print("Weather file object found in model but no path is given\n");
-          }
-        }
-      }
-    }  // End for (const auto& step : modelSteps)
-  }    // End for StepType
+  if (m_add_timings) {
+    fmt::print("\nTiming:\n\n{}\n", m_timers->timeReport());
 
-  // Save final IDF
-  workspace_->save(runDir / "in.idf");
+    // TODO: create profile.json in the run folder
+  }
 }
 }  // namespace openstudio
diff --git a/src/workflow/OSWorkflow.hpp b/src/workflow/OSWorkflow.hpp
index 8aa9ae890a7..275bdb228ae 100644
--- a/src/workflow/OSWorkflow.hpp
+++ b/src/workflow/OSWorkflow.hpp
@@ -1,10 +1,15 @@
 #ifndef WORKFLOW_OSWORKFLOW_HPP
 #define WORKFLOW_OSWORKFLOW_HPP
 
+#include "Timer.hpp"
+
 #include "../measure/OSRunner.hpp"
 #include "../scriptengine/ScriptEngine.hpp"
+#include "../utilities/core/Logger.hpp"
 #include "../utilities/core/Filesystem.hpp"
 #include "../utilities/filetypes/WorkflowJSON.hpp"
+
+#include <functional>
 #include <memory>
 
 #define USE_RUBY_ENGINE 1
@@ -29,7 +34,7 @@ class OSWorkflow
   void run();
 
  private:
-  // TODO: add a Logger
+  REGISTER_LOGGER("openstudio.workflow.OSWorkflow");
 #if USE_RUBY_ENGINE
   ScriptEngineInstance& rubyEngine;
 #endif
@@ -38,6 +43,13 @@ class OSWorkflow
 #endif
   WorkflowJSON workflowJSON;
   measure::OSRunner runner{workflowJSON};
+  model::Model model;
+  boost::optional<Workspace> workspace_;
+  openstudio::filesystem::path epwPath;
+  openstudio::filesystem::path sqlPath;
+
+  // TODO: use a unique_ptr or an Instance?
+  std::unique_ptr<workflow::util::TimerCollection> m_timers = nullptr;
 
   bool m_no_simulation = false;
   bool m_post_process_only = false;
@@ -45,9 +57,64 @@ class OSWorkflow
   // stdout stuff
   bool m_show_stdout = false;
   bool m_add_timings = false;
+  bool m_detailed_timings = true;
   bool m_style_stdout = false;
 
+  /** @name Jobs */
+  //@{
+  // Jobs
+
+  enum class State
+  {
+    Queued = -10,
+    Errored = -1,
+    Finished = 0,
+
+    Initialization = 1,
+    OpenStudioMeasures,
+    Translator,
+    EnergyPlusMeasures,
+    PreProcess,
+    EnergyPlus,
+    ReportingMeasures,
+    PostProcess,
+    Cleanup,
+  };
+  State state = State::Queued;
+
+  using memJobFunPtr = void (OSWorkflow::*)();
+
+  // void timeJob(memJobFunPtr, std::string message);
+
+  template <class F, class... Args>
+  auto detailedTimeBlock(std::string message, F&& func, Args&&... args) {
+    if (m_add_timings && m_detailed_timings) {
+      m_timers->newTimer(std::move(message), 1);
+    }
+    std::invoke(std::forward<F>(func), std::forward<Args>(args)...);
+    if (m_add_timings && m_detailed_timings) {
+      m_timers->tockCurrentTimer();
+    }
+  }
+
+  // Wipes and creates directory, loads the seed/idf file
+  void runInitialization();
+  void runOpenStudioMeasures();
+  void runTranslator();
+  void runEnergyPlusMeasures();
+  void runPreProcess();
+  void runEnergyPlus();
+  void runReportingMeasures();
+  void runPostProcess();
+
+  void runCleanup();
+
+  //@}
+
+  void applyMeasures(MeasureType measureType, bool energyplus_output_requests = false);
   static void applyArguments(measure::OSArgumentMap& argumentMap, const std::string& argumentName, const openstudio::Variant& argumentValue);
+  void saveOSMToRootDirIfDebug();
+  void saveIDFToRootDirIfDebug();
 };
 
 }  // namespace openstudio
diff --git a/src/workflow/RunCleanup.cpp b/src/workflow/RunCleanup.cpp
new file mode 100644
index 00000000000..0439ee8f9c4
--- /dev/null
+++ b/src/workflow/RunCleanup.cpp
@@ -0,0 +1,13 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runCleanup() {
+  state = State::Cleanup;
+
+  openstudio::workflow::util::cleanup(workflowJSON.absoluteRunDir());
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/RunEnergyPlus.cpp b/src/workflow/RunEnergyPlus.cpp
new file mode 100644
index 00000000000..67b58a7e6b8
--- /dev/null
+++ b/src/workflow/RunEnergyPlus.cpp
@@ -0,0 +1,297 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+#include "../model/Model.hpp"
+#include "../model/WeatherFile.hpp"
+#include "../model/FileOperations.hpp"
+
+#include "../energyplus/ErrorFile.hpp"
+#include "../epjson/epJSONTranslator.hpp"
+#include "../utilities/idf/Workspace.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+
+#include "../utilities/core/Filesystem.hpp"
+#include "../utilities/core/FilesystemHelpers.hpp"
+#include "../utilities/core/ASCIIStrings.hpp"
+#include "../utilities/core/ApplicationPathHelpers.hpp"
+#include "energyplus/ErrorFile.hpp"
+
+#include <fmt/format.h>
+#include <fmt/color.h>
+
+#include <json/json.h>
+#include <boost/process.hpp>
+#include <boost/regex.hpp>
+
+#include <cstdlib>
+#include <stdexcept>
+
+namespace openstudio {
+
+/** PrepareRunDirResults is an RAII helper
+  * This will locate E+ exes, copy idd/epsjon to run Directory, and Chdir to the runDirectory.
+  * It uses RAII to cleanup after itself (remove copied files, chdir back to original directory) */
+struct PrepareRunDirResults
+{
+  openstudio::filesystem::path energyPlusExe;                       // NOLINT(misc-non-private-member-variables-in-classes)
+  openstudio::filesystem::path expandObjectsExe;                    // NOLINT(misc-non-private-member-variables-in-classes)
+  std::vector<openstudio::filesystem::path> copiedEnergyPlusFiles;  // NOLINT(misc-non-private-member-variables-in-classes)
+
+  // Doing this with a destructor to ensure that the directory gets cleaned up even if I throw an exception, and I can't forget to do it
+  explicit PrepareRunDirResults(openstudio::filesystem::path runDirPath, openstudio::filesystem::path energyPlusDirectory = {})
+    : m_runDirPath(std::move(runDirPath)) {
+    LOG(Debug, "Original Directory: " << m_curDirPath);
+    LOG(Debug, "Changing To run directory: " << m_runDirPath);
+    boost::filesystem::current_path(m_runDirPath);
+
+    // TODO: is this really necessary?! the part that copies the idd ini epjson in particular I question
+    static constexpr std::array<std::string_view, 3> copyFileExtensions{".idd", ".ini", ".epjson"};
+#if defined _WIN32
+    static const boost::regex energyplusRegex(R"(^energyplus.exe$)");
+    static const boost::regex expandObjectsRegex(R"(^expandobjects.exe$)");
+#else
+    static const boost::regex energyplusRegex(R"(^energyplus\d{0,4}$)");
+    static const boost::regex expandObjectsRegex(R"(^expandobjects\d{0,4}$)");
+#endif
+    boost::smatch matches;
+
+    if (energyPlusDirectory.empty()) {
+      energyPlusDirectory = openstudio::getEnergyPlusDirectory();
+    }
+
+    for (const auto& dirEnt : openstudio::filesystem::directory_iterator{energyPlusDirectory}) {
+      const auto& dirEntryPath = dirEnt.path();
+      if (!openstudio::filesystem::is_regular_file(dirEntryPath)) {
+        continue;
+      }
+      auto lower_ext = openstudio::ascii_to_lower_copy(dirEntryPath.extension().string());
+      if (std::find(copyFileExtensions.cbegin(), copyFileExtensions.cend(), lower_ext) != copyFileExtensions.cend()) {
+        auto targetPath = m_runDirPath / dirEntryPath.filename();
+        openstudio::filesystem::copy_file(dirEntryPath, targetPath, openstudio::filesystem::copy_options::overwrite_existing);
+        copiedEnergyPlusFiles.emplace_back(std::move(targetPath));
+      } else {
+        auto lower_filename = openstudio::ascii_to_lower_copy(dirEntryPath.filename().string());
+        if (boost::regex_match(lower_filename, matches, energyplusRegex)) {
+          energyPlusExe = dirEntryPath;
+        } else if (boost::regex_match(lower_filename, matches, expandObjectsRegex)) {
+          expandObjectsExe = dirEntryPath;
+        }
+      }
+    }
+
+    if (energyPlusExe.empty()) {
+      throw std::runtime_error(fmt::format("Could not find EnergyPlus executable in {}\n", energyPlusDirectory.string()));
+    }
+
+    if (expandObjectsExe.empty()) {
+      throw std::runtime_error(fmt::format("Could not find ExpandObjects executable in {}\n", energyPlusDirectory.string()));
+    }
+
+    // for (const auto& filePath : copiedEnergyPlusFiles) {
+    //   fmt::print("{}\n", filePath.string());
+    // }
+
+    LOG(Info, "EnergyPlus executable path is " << energyPlusExe);
+    LOG(Info, "ExpandObjects executable path is " << expandObjectsExe);
+  }
+
+  PrepareRunDirResults(const PrepareRunDirResults&) noexcept = default;
+  PrepareRunDirResults(PrepareRunDirResults&&) noexcept = default;
+  PrepareRunDirResults& operator=(const PrepareRunDirResults&) noexcept = default;
+  PrepareRunDirResults& operator=(PrepareRunDirResults&&) noexcept = default;
+
+  ~PrepareRunDirResults() {
+    LOG(Info, "Removing any copied EnergyPlus files");
+    for (const auto& filePath : copiedEnergyPlusFiles) {
+      openstudio::filesystem::remove(filePath);
+    }
+
+    for (const auto& p : {m_runDirPath / "packaged_measures", m_runDirPath / "Energy+.ini"}) {
+      openstudio::filesystem::remove_all(p);
+    }
+
+    LOG(Debug, "Changing Current Directory back to: " << m_curDirPath);
+    boost::filesystem::current_path(m_curDirPath);
+  }
+
+ private:
+  REGISTER_LOGGER("openstudio.OSWorkflow.prepareEnergyPlusDir");
+  openstudio::filesystem::path m_runDirPath;
+  openstudio::filesystem::path m_curDirPath = boost::filesystem::current_path();
+};
+
+void OSWorkflow::runEnergyPlus() {
+
+  state = State::EnergyPlus;
+
+  constexpr bool useBoostProcess = true;
+
+  if (runner.halted()) {
+    LOG(Info, "Workflow halted, skipping the EnergyPlus simulation");
+    return;
+  }
+
+  // TODO: there is no point chdir'ing here. Just pass --output-directory to E+?
+  auto currentDir = boost::filesystem::current_path();
+
+  // TODO: we need to think about exception handling... workflow gem is full of try catch, instead we could just use enum return types to indicate
+  // whether it failed or not or something
+  // Eg here I'm supposed to wrap all of the above in a try/catch, so I can ensure that clean_directory is called, then reraise the exception...
+  try {
+    auto runDirPath = workflowJSON.absoluteRunDir();
+    PrepareRunDirResults runDirResults(runDirPath);
+    LOG(Info, "Starting simulation in run directory: " << runDirPath);
+
+    auto inIDF = runDirPath / "in.idf";
+    // TODO: is this the right place /Do we want to do that if we chose epJSON?
+    detailedTimeBlock("Saving IDF", [this, &inIDF] { workspace_->save(inIDF, true); });
+
+    // TODO: workflow-gem was manually running expandObjects prior to the potential serialization to json
+    // Should we rather pass -x to the E+ cmd line?
+    if (!workflowJSON.runOptions()->skipExpandObjects()) {
+      const std::string cmd = openstudio::toString(runDirResults.expandObjectsExe.native());
+      LOG(Info, "Running command '" << cmd << "'");
+
+      int result = 0;
+      detailedTimeBlock("Running ExpandObjects", [&cmd, &result] { result = std::system(cmd.c_str()); });
+      if (result != 0) {
+        LOG(Warn, "ExpandObjects returned a non-zero exit code (" << result << ").");
+      }
+      auto expanded = runDirPath / "expanded.idf";
+      if (openstudio::filesystem::exists(expanded)) {
+        if (openstudio::filesystem::is_regular_file(inIDF)) {
+          boost::filesystem::rename(inIDF, runDirPath / "pre-expand.idf");
+          boost::filesystem::rename(expanded, inIDF);
+        }
+      }
+    }
+
+    if (workflowJSON.runOptions()->epjson()) {
+      LOG(Info, "Beginning the translation to epJSON using OpenStudio");
+      Json::Value jsonVal;
+
+      detailedTimeBlock("Translating to EnergyPlus epJSON", [this, &jsonVal]() { jsonVal = openstudio::epJSON::toJSON(workspace_.get()); });
+
+      inIDF = runDirPath / openstudio::toPath("in.epJSON");
+      if (openstudio::filesystem::is_regular_file(inIDF)) {
+        openstudio::filesystem::remove(inIDF);
+      }
+      detailedTimeBlock("Saving epJSON", [&jsonVal, &inIDF]() {
+        std::ofstream ofs(openstudio::toString(inIDF), std::ofstream::trunc);
+        ofs << jsonVal.toStyledString() << '\n';
+      });
+    }
+
+    // TODO: eventually we should change this system call to be an API call to libenergyplusapi (but we need E+ to add cmake exports)
+    // cf https://github.com/NREL/EnergyPlus/pull/9712 and my proof of concept at https://github.com/jmarrec/EnergyPlus-Cpp-Demo
+    const std::string cmd = fmt::format("\"{}\" {}", openstudio::toString(runDirResults.energyPlusExe.native()), inIDF.filename().string());
+    LOG(Info, "Running command '" << cmd << "'");
+
+    // boost::process allows redirecting stdout / stderr easily, but I can no longer debug in LLDB, which is annoying
+    // Edit: actually std::system has the same issue... it captures a SIGVTALRM
+    // Disable with: `pro hand -p true -s false SIGVTALRM`
+    int result = 0;
+
+    if constexpr (useBoostProcess) {
+      detailedTimeBlock("Running EnergyPlus", [this, /*&cmd,*/ &result, &runDirPath, &runDirResults, &inIDF] {
+        // result = std::system(cmd.c_str());
+        namespace bp = boost::process;
+        bp::ipstream is;
+        // boost::iostream::file_descriptor_sink fl(runDirPath / "stdout-energyplus");
+        // boost::filesystem::ofstream ofs(runDirPath / "stdout-energyplus");
+        std::ofstream stdout_ofs(openstudio::toString(runDirPath / "stdout-energyplus"), std::ofstream::trunc);
+        std::string line;
+        // bp::child c(cmd, bp::std_out > is);
+        bp::child c(runDirResults.energyPlusExe, inIDF.filename(), bp::std_out > is);
+        while (c.running() && std::getline(is, line)) {
+          stdout_ofs << line;
+          if (m_show_stdout) {
+            fmt::print("{}\n", line);
+          }
+        }
+        c.wait();
+        result = c.exit_code();
+      });
+    } else {
+      detailedTimeBlock("Running EnergyPlus", [&cmd, &result] { result = std::system(cmd.c_str()); });
+    }
+
+    LOG(Info, "EnergyPlus returned " << result << "'");
+    if (result != 0) {
+      LOG(Warn, "EnergyPlus returned a non-zero exit code (" << result << "). Check the stdout-energyplus log");
+    }
+
+    {
+      auto errPath = runDirPath / "eplusout.err";
+      if (openstudio::filesystem::is_regular_file(errPath)) {
+
+        const auto errContent = openstudio::filesystem::read_as_string(errPath);
+        workflowJSON.setEplusoutErr(errContent);
+
+        // TODO: just do a search in the errContent?
+        if (errContent.find("EnergyPlus Terminated--Fatal Error Detected") != std::string::npos) {
+          throw std::runtime_error("EnergyPlus Terminated with a Fatal Error. Check eplusout.err log.");
+        }
+        // TODO: or we use ErrorFile... In which case what's the point of parsing the number of warnings/severe from eplusout.end?
+        // Actually, ErrorFile doesn't understand recurring warnings
+        // TODO: add a channel filter on the logger to avoid catching all the debug statements in the ErrorFile class
+        const openstudio::energyplus::ErrorFile errFile(errPath);
+        std::string status = errFile.completedSuccessfully() ? "Completed Successfully" : "Failed";
+        fmt::print("EnergyPlus {} with ", status);
+        fmt::print(fmt::fg(fmt::color::red), "{} Fatal Errors, ", errFile.fatalErrors().size());
+        fmt::print(fmt::fg(fmt::color::orange), "{} Severe Errors, ", errFile.severeErrors().size());
+        fmt::print(fmt::fg(fmt::color::yellow), "{} Warnings.\n", errFile.warnings().size());
+        if (!errFile.completedSuccessfully()) {
+          throw std::runtime_error("EnergyPlus Terminated with a Fatal Error. Check eplusout.err log.");
+        }
+      } else {
+        LOG(Warn, "EnergyPlus did not create an eplusout.err");
+      }
+    }
+
+    {
+      auto endPath = runDirPath / "eplusout.end";
+      if (openstudio::filesystem::is_regular_file(endPath)) {
+        const auto endContent = openstudio::filesystem::read_as_string(endPath);
+        static const boost::regex warningRegex(R"((\d*).Warning)");
+        static const boost::regex severeRegex(R"((\d*).Severe.Errors)");
+        boost::smatch matches;
+        std::string nWarningStr;
+        if (boost::regex_search(endContent, matches, warningRegex)) {
+          nWarningStr = std::string(matches[1].first, matches[1].second);
+        } else {
+          OS_ASSERT(false);
+        }
+        std::string nSevereStr;
+        if (boost::regex_search(endContent, matches, severeRegex)) {
+          nSevereStr = std::string(matches[1].first, matches[1].second);
+        } else {
+          OS_ASSERT(false);
+        }
+        LOG(Info, "EnergyPlus finished with " << nWarningStr << " warnings and " << nSevereStr << " severe errors");
+        if (endContent.find("EnergyPlus Terminated--Fatal Error Detected") != std::string::npos) {
+          throw std::runtime_error("EnergyPlus Terminated with a Fatal Error. Check eplusout.err log.");
+        }
+
+      } else {
+        throw std::runtime_error("EnergyPlus failed and did not create an eplusout.end file. Check the stdout-energyplus log.");
+      }
+    }
+
+  } catch (const std::exception& e) {
+    LOG_AND_THROW(e.what());
+  }
+
+  // Set the SQL path
+  auto expectedSqlPath = workflowJSON.absoluteRunDir() / "eplusout.sql";
+  if (openstudio::filesystem::is_regular_file(expectedSqlPath)) {
+    sqlPath = expectedSqlPath;
+  } else {
+    LOG(Warn, "Unable to find sql file at " << expectedSqlPath);
+  }
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/RunEnergyPlusMeasures.cpp b/src/workflow/RunEnergyPlusMeasures.cpp
new file mode 100644
index 00000000000..5a1789a1c94
--- /dev/null
+++ b/src/workflow/RunEnergyPlusMeasures.cpp
@@ -0,0 +1,22 @@
+#include "OSWorkflow.hpp"
+
+#include "../utilities/idf/Workspace.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include "../utilities/core/Filesystem.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runEnergyPlusMeasures() {
+  state = State::EnergyPlusMeasures;
+
+  // Weather file is handled in runInitialization
+
+  LOG(Info, "Beginning to execute EnergyPlus Measures.");
+  applyMeasures(MeasureType::EnergyPlusMeasure, false);
+  LOG(Info, "Finished applying EnergyPlus Measures.");
+
+  saveIDFToRootDirIfDebug();
+}
+}  // namespace openstudio
diff --git a/src/workflow/RunInitialization.cpp b/src/workflow/RunInitialization.cpp
new file mode 100644
index 00000000000..ded73ae66d8
--- /dev/null
+++ b/src/workflow/RunInitialization.cpp
@@ -0,0 +1,121 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+#include "../model/Model.hpp"
+#include "../model/WeatherFile.hpp"
+#include "../model/FileOperations.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+
+#include "../utilities/core/Assert.hpp"
+#include "../utilities/core/Filesystem.hpp"
+
+#include <fmt/chrono.h>
+
+namespace openstudio {
+
+void OSWorkflow::runInitialization() {
+
+  state = State::Initialization;
+
+  auto rootDir = workflowJSON.absoluteRootDir();
+  LOG(Debug, "The root_dir for the datapoint is " << rootDir);
+
+  detailedTimeBlock("Wiping directories", [this, &rootDir]() {
+    {
+      auto generatedFilesDir = rootDir / "generated_files";
+      if (openstudio::filesystem::is_directory(generatedFilesDir)) {
+        LOG(Debug, "Removing existing generated files directory: " << generatedFilesDir);
+        openstudio::filesystem::remove_all(generatedFilesDir);
+      }
+      LOG(Debug, "Creating generated files directory: " << generatedFilesDir);
+      openstudio::filesystem::create_directory(generatedFilesDir);
+
+      // insert the generated files directory in the first spot so all generated ExternalFiles go here
+      auto fps = workflowJSON.filePaths();
+      workflowJSON.resetFilePaths();
+      workflowJSON.addFilePath(generatedFilesDir);
+      for (const auto& fp : fps) {
+        workflowJSON.addFilePath(fp);
+      }
+
+      auto reportsDir = rootDir / "reports";
+      if (openstudio::filesystem::is_directory(reportsDir)) {
+        LOG(Debug, "Removing existing reports directory: " << reportsDir);
+        openstudio::filesystem::remove_all(reportsDir);
+      }
+    }
+  });
+
+  // TODO: create the runner with our WorkflowJSON (workflow gem uses datapoint/analysis too?!)
+
+  // TODO: Validate the OSW measures if the flag is set to true, (the default state)
+  // Note JM 2022-11-07: Is it better to try and load all measures once, instead of crashing later?
+
+  LOG(Debug, "Finding and loading the seed file");
+  auto seedPath_ = workflowJSON.seedFile();
+  if (seedPath_) {
+    auto modelFullPath_ = workflowJSON.findFile(seedPath_.get());
+    if (!modelFullPath_) {
+      state = State::Errored;
+      throw std::runtime_error(fmt::format("Seed model {} specified in OSW cannot be found", seedPath_->string()));
+    }
+
+    if (modelFullPath_->extension() == openstudio::filesystem::path(".idf")) {
+      if (m_add_timings && m_detailed_timings) {
+        m_timers->newTimer("    Loading seed IDF");
+      }
+      detailedTimeBlock("Loading seed IDF", [this, &modelFullPath_] { workspace_ = openstudio::workflow::util::loadIDF(modelFullPath_.get()); });
+
+    } else {
+      detailedTimeBlock("Loading seed OSM (VersionTranslation)",
+                        [this, &modelFullPath_] { model = openstudio::workflow::util::loadOSM(modelFullPath_.get()); });
+    }
+  } else {
+    model = openstudio::model::Model{};
+    openstudio::model::initializeModelObjects(model);
+  }
+
+  LOG(Debug, "Getting the initial weather file");
+  // Initialize the weather file
+  auto epwPath_ = workflowJSON.weatherFile();
+  bool alreadySetInModel = false;
+  if (!epwPath_) {
+    LOG(Debug, "No weather file specified in OSW, looking in model");
+    if (auto epwFile_ = model.weatherFile()) {
+      epwPath_ = epwFile_->path();
+      alreadySetInModel = true;
+    }
+  }
+
+  if (epwPath_) {
+    LOG(Debug, "Search for weather file " << epwPath_.get());
+    auto epwFullPath_ = workflowJSON.findFile(epwPath_.get());
+    if (!epwFullPath_) {
+      auto epwFullPath_ = workflowJSON.findFile(epwPath_->filename());
+    }
+    if (!epwFullPath_) {
+      throw std::runtime_error(fmt::format("Weather file {} specified but cannot be found", epwPath_->string()));
+    }
+
+    epwPath = epwFullPath_.get();
+
+    if (!alreadySetInModel) {
+      if (auto epwFile_ = openstudio::EpwFile::load(epwPath)) {
+        model::WeatherFile::setWeatherFile(model, epwFile_.get());
+      } else {
+        LOG(Warn, "Could not load weather file from " << epwPath_.get());
+      }
+    }
+
+  } else {
+    LOG(Debug, "No valid weather file defined in either the osm or osw");
+  }
+
+  // Tell the workflowJSON we have started, it'll log the start time and reset the stepResults
+  workflowJSON.start();
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/RunOpenStudioMeasures.cpp b/src/workflow/RunOpenStudioMeasures.cpp
new file mode 100644
index 00000000000..fb3bf93291f
--- /dev/null
+++ b/src/workflow/RunOpenStudioMeasures.cpp
@@ -0,0 +1,33 @@
+#include "OSWorkflow.hpp"
+
+#include "../model/Model.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include "../utilities/core/Filesystem.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runOpenStudioMeasures() {
+  state = State::OpenStudioMeasures;
+
+  // Weather file is handled in runInitialization
+
+  LOG(Info, "Beginning to execute OpenStudio Measures");
+  applyMeasures(MeasureType::ModelMeasure, false);
+  LOG(Info, "Finished applying OpenStudio Measures.");
+
+  // Save final OSM
+  if (!workflowJSON.runOptions()->fast()) {
+    // Save to run dir
+    auto savePath = workflowJSON.absoluteRunDir() / "in.osm";
+    detailedTimeBlock("Saving Final OSM to Run Dir", [this, &savePath]() {
+      // TODO: workflow gem was actually serializating via model.to_s for speed...
+      model.save(savePath, true);
+    });
+  }
+
+  saveOSMToRootDirIfDebug();
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/RunPostProcess.cpp b/src/workflow/RunPostProcess.cpp
new file mode 100644
index 00000000000..33f324c1d8a
--- /dev/null
+++ b/src/workflow/RunPostProcess.cpp
@@ -0,0 +1,14 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+#include "workflow/Util.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runPostProcess() {
+  state = State::PostProcess;
+
+  openstudio::workflow::util::gatherReports(workflowJSON.absoluteRunDir(), workflowJSON.absoluteRootDir());
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/RunPreProcess.cpp b/src/workflow/RunPreProcess.cpp
new file mode 100644
index 00000000000..770c19f2ee7
--- /dev/null
+++ b/src/workflow/RunPreProcess.cpp
@@ -0,0 +1,88 @@
+#include "OSWorkflow.hpp"
+
+#include "RunPreProcessMonthlyReports.hpp"
+
+#include "../utilities/idf/Workspace.hpp"
+#include "../utilities/idf/IdfFile.hpp"
+#include "../utilities/idf/IdfObject.hpp"
+#include "../utilities/idd/IddObject.hpp"
+#include "../utilities/idf/IdfExtensibleGroup.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include "../utilities/core/Filesystem.hpp"
+
+#include <utilities/idd/IddEnums.hxx>
+#include <utilities/idd/Output_SQLite_FieldEnums.hxx>
+
+#include <array>
+#include <string_view>
+#include <string>
+
+namespace openstudio {
+
+void OSWorkflow::runPreProcess() {
+  state = State::PreProcess;
+
+  auto runDirPath = workflowJSON.absoluteRunDir();
+  const bool skipEnergyPlusPreprocess = workflowJSON.runOptions()->skipEnergyPlusPreprocess();
+
+  // Save the preprocess file
+  if (!skipEnergyPlusPreprocess) {
+    // Save to run dir
+    auto savePath = runDirPath / "pre-preprocess.idf";
+    detailedTimeBlock("Saving Preprocess IDF to Run Dir", [this, &savePath]() {
+      // TODO: workflow gem was actually serializating via model.to_s for speed...
+      workspace_->save(savePath, true);
+    });
+  }
+
+  // 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.");
+
+  // Skip the pre-processor if halted
+  if (runner.halted()) {
+    LOG(Info, "Workflow halted, skipping the EnergyPlus pre-processor");
+    return;
+  }
+
+  // TODO: I am extremely confused as to why we force add this stuff...
+  if (!skipEnergyPlusPreprocess) {
+    LOG(Info, "Running EnergyPlus Preprocess");
+    detailedTimeBlock("Running EnergyPlus Preprocess", [this] {
+      if (workspace_->getObjectsByType(IddObjectType::Output_SQLite).empty()) {
+        LOG(Info, "Adding SQL Output to IDF");
+        auto sqliteOutput_ = workspace_->addObject(IdfObject(IddObjectType::Output_SQLite));
+        sqliteOutput_->setString(Output_SQLiteFields::OptionType, "SimpleAndTabular");
+      }
+
+      for (auto meter : c_monthlyReports) {
+        auto idfObject_ = openstudio::IdfObject::load(std::string{meter});
+        OS_ASSERT(idfObject_);
+        workspace_->addObject(idfObject_.get());
+      }
+      for (auto meter : c_metersForced) {
+        auto idfObject_ = openstudio::IdfObject::load(std::string{meter});
+        OS_ASSERT(idfObject_);
+        workspace_->addObject(idfObject_.get());
+      }
+    });
+    LOG(Info, "Finished preprocess job for EnergyPlus simulation");
+  }
+
+  // Save final IDF
+  auto savePath = runDirPath / "in.idf";
+  detailedTimeBlock("Saving Final IDF to Run Dir", [this, &savePath]() {
+    // TODO: workflow gem was actually serializating via model.to_s for speed...
+    workspace_->save(savePath, true);
+  });
+  LOG(Debug, "Saved final IDF as " << savePath);
+
+  // Perform pre-processing on in.idf to capture logic in RunManager
+
+  saveIDFToRootDirIfDebug();
+}
+}  // namespace openstudio
diff --git a/src/workflow/RunPreProcessMonthlyReports.hpp b/src/workflow/RunPreProcessMonthlyReports.hpp
new file mode 100644
index 00000000000..ec58425216f
--- /dev/null
+++ b/src/workflow/RunPreProcessMonthlyReports.hpp
@@ -0,0 +1,240 @@
+#include <array>
+#include <string_view>
+
+static constexpr std::array<std::string_view, 8> c_monthlyReports = {
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - Electricity,  !- Name
+      2,                       !- Digits After Decimal
+      InteriorLights:Electricity,  !- Variable or Meter 1 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:Electricity,  !- Variable or Meter 2 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:Electricity,  !- Variable or Meter 3 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:Electricity,  !- Variable or Meter 4 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 4
+      Fans:Electricity,        !- Variable or Meter 5 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 5
+      Pumps:Electricity,       !- Variable or Meter 6 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 6
+      Heating:Electricity,     !- Variable or Meter 7 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 7
+      Cooling:Electricity,     !- Variable or Meter 8 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:Electricity,  !- Variable or Meter 9 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:Electricity,  !- Variable or Meter 10 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:Electricity,!- Variable or Meter 11 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:Electricity,!- Variable or Meter 12 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:Electricity,!- Variable or Meter 13 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 13
+      Refrigeration:Electricity,!- Variable or Meter 14 Name
+      SumOrAverage;            !- Aggregation Type for Variable or Meter 14)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - Natural Gas,  !- Name
+      2,                       !- Digits After Decimal
+      InteriorEquipment:NaturalGas,   !- Variable or Meter 1 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 1
+      ExteriorEquipment:NaturalGas,   !- Variable or Meter 2 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 2
+      Heating:NaturalGas,             !- Variable or Meter 3 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 3
+      Cooling:NaturalGas,             !- Variable or Meter 4 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 4
+      WaterSystems:NaturalGas,        !- Variable or Meter 5 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 5
+      Cogeneration:NaturalGas,        !- Variable or Meter 6 Name
+      SumOrAverage;            !- Aggregation Type for Variable or Meter 6)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - District Heating,  !- Name
+      2,                       !- Digits After Decimal
+      InteriorLights:DistrictHeating,  !- Variable or Meter 1 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:DistrictHeating,  !- Variable or Meter 2 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:DistrictHeating,  !- Variable or Meter 3 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:DistrictHeating,  !- Variable or Meter 4 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 4
+      Fans:DistrictHeating,        !- Variable or Meter 5 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 5
+      Pumps:DistrictHeating,       !- Variable or Meter 6 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 6
+      Heating:DistrictHeating,     !- Variable or Meter 7 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 7
+      Cooling:DistrictHeating,     !- Variable or Meter 8 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:DistrictHeating,  !- Variable or Meter 9 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:DistrictHeating,  !- Variable or Meter 10 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:DistrictHeating,!- Variable or Meter 11 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:DistrictHeating,!- Variable or Meter 12 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:DistrictHeating,!- Variable or Meter 13 Name
+      SumOrAverage;            !- Aggregation Type for Variable or Meter 13)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - District Cooling,  !- Name
+      2,                       !- Digits After Decimal
+      InteriorLights:DistrictCooling,  !- Variable or Meter 1 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:DistrictCooling,  !- Variable or Meter 2 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:DistrictCooling,  !- Variable or Meter 3 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:DistrictCooling,  !- Variable or Meter 4 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 4
+      Fans:DistrictCooling,        !- Variable or Meter 5 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 5
+      Pumps:DistrictCooling,       !- Variable or Meter 6 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 6
+      Heating:DistrictCooling,     !- Variable or Meter 7 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 7
+      Cooling:DistrictCooling,     !- Variable or Meter 8 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:DistrictCooling,  !- Variable or Meter 9 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:DistrictCooling,  !- Variable or Meter 10 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:DistrictCooling,!- Variable or Meter 11 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:DistrictCooling,!- Variable or Meter 12 Name
+      SumOrAverage,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:DistrictCooling,!- Variable or Meter 13 Name
+      SumOrAverage;            !- Aggregation Type for Variable or Meter 13)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - Electricity Peak Demand,  !- Name
+      2,                       !- Digits After Decimal
+      Electricity:Facility,  !- Variable or Meter 1 Name
+      Maximum,            !- Aggregation Type for Variable or Meter 1
+      InteriorLights:Electricity,  !- Variable or Meter 1 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:Electricity,  !- Variable or Meter 2 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:Electricity,  !- Variable or Meter 3 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:Electricity,  !- Variable or Meter 4 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 4
+      Fans:Electricity,        !- Variable or Meter 5 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 5
+      Pumps:Electricity,       !- Variable or Meter 6 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 6
+      Heating:Electricity,     !- Variable or Meter 7 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 7
+      Cooling:Electricity,     !- Variable or Meter 8 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:Electricity,  !- Variable or Meter 9 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:Electricity,  !- Variable or Meter 10 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:Electricity,!- Variable or Meter 11 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:Electricity,!- Variable or Meter 12 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:Electricity,!- Variable or Meter 13 Name
+      ValueWhenMaximumOrMinimum;            !- Aggregation Type for Variable or Meter 13)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - Natural Gas Peak Demand,  !- Name
+      2,                       !- Digits After Decimal
+      NaturalGas:Facility,  !- Variable or Meter 1 Name
+      Maximum,            !- Aggregation Type for Variable or Meter 1
+      InteriorEquipment:NaturalGas,   !- Variable or Meter 1 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 1
+      ExteriorEquipment:NaturalGas,   !- Variable or Meter 2 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 2
+      Heating:NaturalGas,             !- Variable or Meter 3 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 3
+      Cooling:NaturalGas,             !- Variable or Meter 4 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 4
+      WaterSystems:NaturalGas,        !- Variable or Meter 5 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 5
+      Cogeneration:NaturalGas,        !- Variable or Meter 6 Name
+      ValueWhenMaximumOrMinimum;            !- Aggregation Type for Variable or Meter 6)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - District Heating Peak Demand,  !- Name
+      2,                       !- Digits After Decimal
+      DistrictHeating:Facility,  !- Variable or Meter 1 Name
+      Maximum,            !- Aggregation Type for Variable or Meter 1
+      InteriorLights:DistrictHeating,  !- Variable or Meter 1 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:DistrictHeating,  !- Variable or Meter 2 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:DistrictHeating,  !- Variable or Meter 3 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:DistrictHeating,  !- Variable or Meter 4 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 4
+      Fans:DistrictHeating,        !- Variable or Meter 5 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 5
+      Pumps:DistrictHeating,       !- Variable or Meter 6 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 6
+      Heating:DistrictHeating,     !- Variable or Meter 7 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 7
+      Cooling:DistrictHeating,     !- Variable or Meter 8 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:DistrictHeating,  !- Variable or Meter 9 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:DistrictHeating,  !- Variable or Meter 10 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:DistrictHeating,!- Variable or Meter 11 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:DistrictHeating,!- Variable or Meter 12 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:DistrictHeating,!- Variable or Meter 13 Name
+      ValueWhenMaximumOrMinimum;            !- Aggregation Type for Variable or Meter 13)idf",
+  R"idf(
+  Output:Table:Monthly,
+    Building Energy Performance - District Cooling Peak Demand,  !- Name
+      2,                       !- Digits After Decimal
+      DistrictCooling:Facility,  !- Variable or Meter 1 Name
+      Maximum,            !- Aggregation Type for Variable or Meter 1
+      InteriorLights:DistrictCooling,  !- Variable or Meter 1 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 1
+      ExteriorLights:DistrictCooling,  !- Variable or Meter 2 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 2
+      InteriorEquipment:DistrictCooling,  !- Variable or Meter 3 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 3
+      ExteriorEquipment:DistrictCooling,  !- Variable or Meter 4 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 4
+      Fans:DistrictCooling,        !- Variable or Meter 5 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 5
+      Pumps:DistrictCooling,       !- Variable or Meter 6 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 6
+      Heating:DistrictCooling,     !- Variable or Meter 7 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 7
+      Cooling:DistrictCooling,     !- Variable or Meter 8 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 8
+      HeatRejection:DistrictCooling,  !- Variable or Meter 9 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 9
+      Humidifier:DistrictCooling,  !- Variable or Meter 10 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 10
+      HeatRecovery:DistrictCooling,!- Variable or Meter 11 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 11
+      WaterSystems:DistrictCooling,!- Variable or Meter 12 Name
+      ValueWhenMaximumOrMinimum,            !- Aggregation Type for Variable or Meter 12
+      Cogeneration:DistrictCooling,!- Variable or Meter 13 Name
+      ValueWhenMaximumOrMinimum;            !- Aggregation Type for Variable or Meter 13
+)idf"};
+
+static constexpr std::array<std::string_view, 7> c_metersForced{
+  //These are needed for the calibration report
+  "Output:Meter:MeterFileOnly,NaturalGas:Facility,Daily;",
+  "Output:Meter:MeterFileOnly,Electricity:Facility,Timestep;",
+  "Output:Meter:MeterFileOnly,Electricity:Facility,Daily;",
+
+  // Always add in the timestep facility meters
+  "Output:Meter,Electricity:Facility,Timestep;",
+  "Output:Meter,NaturalGas:Facility,Timestep;",
+  "Output:Meter,DistrictCooling:Facility,Timestep;",
+  "Output:Meter,DistrictHeating:Facility,Timestep;",
+};
diff --git a/src/workflow/RunReportingMeasures.cpp b/src/workflow/RunReportingMeasures.cpp
new file mode 100644
index 00000000000..aa9b877566f
--- /dev/null
+++ b/src/workflow/RunReportingMeasures.cpp
@@ -0,0 +1,64 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+#include "../model/Model.hpp"
+#include "../utilities/idf/Workspace.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include "../utilities/core/Filesystem.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runReportingMeasures() {
+  state = State::ReportingMeasures;
+
+  // halted workflow is handled in applyMeasures
+
+  // TODO: urbanopt
+
+  auto runDirPath = workflowJSON.absoluteRunDir();
+
+  // Load simulation files as required. I suppose this is for the case where you only run post processing
+  if (!runner.halted()) {  // && !urbanopt
+    if (model.numObjects() == 0) {
+      if (auto p = runDirPath / "in.osm"; openstudio::filesystem::is_regular_file(p)) {
+        LOG(Debug, "Attempting to load " << p);
+        model = openstudio::workflow::util::loadOSM(p);
+      }
+    }
+    if (!workspace_) {
+      if (auto p = runDirPath / "in.idf"; openstudio::filesystem::is_regular_file(p)) {
+        LOG(Debug, "Attempting to load " << p);
+        workspace_ = openstudio::workflow::util::loadIDF(p);
+      }
+    }
+    if (sqlPath.empty()) {
+      if (auto p = runDirPath / "eplusout.sql"; openstudio::filesystem::is_regular_file(p)) {
+        sqlPath = p;
+        LOG(Debug, "Registered the sql filepath as " << p);
+      }
+    }
+    if (epwPath.empty()) {
+      if (auto p = runDirPath / "in.epw"; openstudio::filesystem::is_regular_file(p)) {
+        epwPath = p;
+        LOG(Debug, "Registered the EPW filepath as " << p);
+      }
+    }
+  }
+
+  LOG(Info, "Beginning to execute Reporting Measures.");
+  applyMeasures(MeasureType::ReportingMeasure, false);
+  LOG(Info, "Finished applying Reporting Measures.");
+
+  // TODO
+  //   # Parse the files generated by the local output adapter
+  //   results, objective_functions = run_extract_inputs_and_outputs @registry[:run_dir], @logger
+  //   @registry.register(:results) { results }
+
+  //   # Send the objective function results to the output adapter
+  //   @logger.debug "Objective Function JSON is #{objective_functions}"
+  //   @output_adapter.communicate_objective_function objective_functions
+}
+}  // namespace openstudio
diff --git a/src/workflow/RunTranslator.cpp b/src/workflow/RunTranslator.cpp
new file mode 100644
index 00000000000..fdbe2dc10c1
--- /dev/null
+++ b/src/workflow/RunTranslator.cpp
@@ -0,0 +1,56 @@
+#include "OSWorkflow.hpp"
+
+#include "Util.hpp"
+
+#include "../model/Model.hpp"
+#include "../model/WeatherFile.hpp"
+#include "../model/Facility.hpp"
+#include "../model/Facility_Impl.hpp"
+#include "../model/Building.hpp"
+#include "../model/Building_Impl.hpp"
+#include "../energyplus/ForwardTranslator.hpp"
+
+#include "../utilities/filetypes/WorkflowJSON.hpp"
+#include "../utilities/filetypes/RunOptions.hpp"
+#include "../utilities/filetypes/ForwardTranslatorOptions.hpp"
+
+#include "../utilities/core/Filesystem.hpp"
+
+namespace openstudio {
+
+void OSWorkflow::runTranslator() {
+
+  state = State::Translator;
+
+  // skip if halted
+  // TODO: I don't understand this
+  if (runner.halted()) {
+    LOG(Info, "Workflow halted, skipping OSM to IDF translation");
+    // This allows model arguments to still be calculated
+    workspace_ = Workspace{};
+  }
+
+  auto runDir = workflowJSON.absoluteRunDir();
+  OS_ASSERT(openstudio::filesystem::is_directory(runDir));
+
+  // Copy in the weather file
+  if (!epwPath.empty()) {
+    openstudio::filesystem::copy_file(epwPath, runDir / "in.epw", openstudio::filesystem::copy_options::overwrite_existing);
+  } else {
+    LOG(Warn, "EPW file not found");
+  }
+
+  // Translate the OSM to an IDF
+  LOG(Info, "Beginning the translation to IDF")
+  detailedTimeBlock("Translating to EnergyPlus IDF", [this]() {
+    openstudio::energyplus::ForwardTranslator ft;
+    ft.setForwardTranslatorOptions(workflowJSON.runOptions()->forwardTranslatorOptions());
+    workspace_ = ft.translateModel(model);
+  });
+
+  LOG(Info, "Successfully translated to IDF");
+
+  saveIDFToRootDirIfDebug();
+}
+
+}  // namespace openstudio
diff --git a/src/workflow/Timer.cpp b/src/workflow/Timer.cpp
new file mode 100644
index 00000000000..a0f40ce07dd
--- /dev/null
+++ b/src/workflow/Timer.cpp
@@ -0,0 +1,111 @@
+#include "Timer.hpp"
+
+#include "../utilities/core/Assert.hpp"
+
+#include <fmt/format.h>
+#include <fmt/chrono.h>  // Formatting for std::chrono
+#include <fmt/color.h>   // For fmt::fg
+
+namespace openstudio::workflow::util {
+
+Timer::Timer(std::string message, unsigned level) : m_message(std::move(message)), m_level(level) {}
+
+unsigned Timer::level() const {
+  return m_level;
+}
+
+auto Timer::start() const {
+  return m_start;
+}
+
+auto Timer::end() const {
+  return m_end;
+}
+
+void Timer::tick() {
+  m_end = TimePointType{};
+  m_start = ClockType::now();
+}
+
+void Timer::tock() {
+  m_end = ClockType::now();
+}
+
+std::string Timer::message() const {
+  return m_message;
+}
+
+auto Timer::duration() const {
+  OS_ASSERT(m_end != TimePointType{});
+  return std::chrono::duration_cast<DurationType>(m_end - m_start);
+}
+
+std::string Timer::format() const {
+  if (m_end == TimePointType{}) {
+    return fmt::format("Timer {} started at {}", m_message, m_start);
+  } else {
+    return fmt::format("Timer {} took {} ms", m_message, duration());
+  }
+}
+
+std::string Timer::formatRow(size_t message_len, size_t timepoint_len, size_t duration_len) const {
+  if (m_level == 1) {
+    return fmt::format(fmt::fg(fmt::color::light_gray), "| o {3:<{0}.{0}s} | {4:%T} | {5:%T} | {6:^{2}} |\n", message_len - 2, timepoint_len,
+                       duration_len, m_message, m_start, m_end, duration());
+  } else if (m_level == 2) {
+    return fmt::format(fmt::fg(fmt::color::light_gray), "|   o {3:<{0}.{0}s} | {4:%T} | {5:%T} | {6:^{2}} |\n", message_len - 4, timepoint_len,
+                       duration_len, m_message, m_start, m_end, duration());
+  } else {
+    return fmt::format("| {3:<{0}.{0}s} | {4:%T} | {5:%T} | {6:^{2}} |\n", message_len, timepoint_len, duration_len, m_message, m_start, m_end,
+                       duration());
+  }
+}
+
+Timer& TimerCollection::newTimer(std::string message, unsigned level) {
+  m_timerIndices.push_back(m_timers.size());
+  return m_timers.emplace_back(std::move(message), level);
+}
+
+void TimerCollection::tockCurrentTimer() {
+  m_timers[m_timerIndices.back()].tock();
+  m_timerIndices.pop_back();
+}
+
+// line_length is the maximum terminal width, fit = true will cause the table to be resized down as much as possible, fit = false means the table will take exactly line_length
+std::string TimerCollection::timeReport(int line_length, bool fit) const {
+  // Timer | start | end | duration
+  std::string result;
+
+  constexpr size_t timepoint_len = 8;  // 19 if not specifying %T
+  constexpr size_t duration_len = 10;
+  constexpr size_t ncols = 4;
+  constexpr size_t extra_chars = 3 * (ncols - 1) + 2 * (ncols - 2);
+  size_t message_len = line_length - (2 * timepoint_len + duration_len + extra_chars);
+  if (fit) {
+    size_t max_message_len = 0;
+    for (const auto& timer : m_timers) {
+      max_message_len = std::max(max_message_len, timer.message().size());
+    }
+    message_len = std::min(message_len, max_message_len + 4);
+    // fmt::print("line_length={}, message_len={}, max_message_len={}\n", line_length, message_len, max_message_len);
+  }
+
+  // Use │ or | ?
+  // result += fmt::format("┌{0:─^{1}}┐\n", "", total_len - 2);
+  result +=
+    fmt::format("| {3:^{0}} | {4:^{1}} | {5:^{1}} | {6:^{2}} |\n", message_len, timepoint_len, duration_len, "Timer", "start", "end", "duration");
+  // result += fmt::format("|{0:─^{1}}|\n", "", total_len - 2);
+  result += fmt::format("|:{3:-^{0}}|:{3:-^{1}}:|:{3:-^{1}}:|:{3:-^{2}}:|\n", message_len + 1, timepoint_len, duration_len, "");
+
+  for (const auto& timer : m_timers) {
+    result += timer.formatRow(message_len, timepoint_len, duration_len);
+  }
+
+  m_totalTimer.tock();
+  result += fmt::format(fmt::fg(fmt::color::red), "| {3:^{0}.{0}s} | {4:%T} | {5:%T} | {6:^{2}} |\n", message_len, timepoint_len, duration_len,
+                        m_totalTimer.message(), m_totalTimer.start(), m_totalTimer.end(), m_totalTimer.duration());
+
+  return result;
+};
+
+}  // namespace openstudio::workflow::util
diff --git a/src/workflow/Timer.hpp b/src/workflow/Timer.hpp
new file mode 100644
index 00000000000..bdffcbf9a40
--- /dev/null
+++ b/src/workflow/Timer.hpp
@@ -0,0 +1,69 @@
+#ifndef WORKFLOW_UTIL_TIMER_HPP
+#define WORKFLOW_UTIL_TIMER_HPP
+
+#include <chrono>
+#include <string>
+#include <vector>
+
+namespace openstudio::workflow::util {
+
+class Timer
+{
+ public:
+  // start is initialized to now() when ctor is called, end is not initialized
+  Timer(std::string message, unsigned level = 0);
+
+  auto start() const;
+  auto end() const;
+
+  unsigned level() const;
+
+  // Reset start to now, end to none
+  void tick();
+
+  // Capture end time
+  void tock();
+
+  std::string message() const;
+
+  auto duration() const;
+
+  std::string format() const;
+
+  std::string formatRow(size_t message_len, size_t timepoint_len, size_t duration_len) const;
+
+ private:
+  using DurationType = std::chrono::milliseconds;
+  using ClockType = std::chrono::system_clock;
+  using TimePointType = decltype(ClockType::now());
+
+  std::string m_message;
+  TimePointType m_start = ClockType::now();
+  TimePointType m_end;
+  unsigned m_level;
+};
+
+class TimerCollection
+{
+ public:
+  TimerCollection() = default;
+
+  // TODO: maybe it's a bad idea to return the Timer reference... code writer is responsible for ensuring they call tockCurrentTimer
+  // We could just return a size_t (currentTimerIndex) and have tockTimer(size_t index);
+  Timer& newTimer(std::string message, unsigned level = 0);
+  void tockCurrentTimer();
+
+  // line_length is the maximum terminal width, fit = true will cause the table to be resized down as much as possible, fit = false means the table will take exactly line_length
+  std::string timeReport(int line_length = 80, bool fit = true) const;
+
+ private:
+  std::vector<Timer> m_timers;
+  mutable Timer m_totalTimer{"Total"};
+
+  // this is so we can nest timings and detailed (sub) timings
+  std::vector<size_t> m_timerIndices;
+};
+
+}  // namespace openstudio::workflow::util
+
+#endif  // WORKFLOW_UTIL_TIMER_HPP
diff --git a/src/workflow/Util.cpp b/src/workflow/Util.cpp
new file mode 100644
index 00000000000..0f6270800f7
--- /dev/null
+++ b/src/workflow/Util.cpp
@@ -0,0 +1,301 @@
+#include "Util.hpp"
+
+#include "../model/Model.hpp"
+#include "../osversion/VersionTranslator.hpp"
+#include "../utilities/core/Logger.hpp"
+#include "../utilities/core/Filesystem.hpp"
+#include "../utilities/core/FilesystemHelpers.hpp"
+#include "../utilities/core/StringHelpers.hpp"
+#include "../utilities/core/ZipFile.hpp"
+#include "../utilities/bcl/BCLXML.hpp"
+#include "../utilities/idf/Workspace.hpp"
+#include "../utilities/idf/IdfFile.hpp"
+#include "../utilities/idf/IdfObject.hpp"
+#include "../utilities/idd/IddObject.hpp"
+#include "../utilities/idf/IdfExtensibleGroup.hpp"
+
+#include <boost/filesystem/operations.hpp>
+#include <utilities/idd/IddEnums.hxx>
+
+#include <boost/regex.hpp>
+#include <fmt/format.h>
+#include <fmt/std.h>     // Formatting for std::filesystem::path
+#include <fmt/chrono.h>  // Formatting for std::chrono
+
+#include <algorithm>
+#include <array>
+#include <iterator>
+#include <string_view>
+
+namespace openstudio::workflow::util {
+
+model::Model loadOSM(const openstudio::filesystem::path& osmPath) {
+
+  LOG_FREE(Info, "openstudio.worklow.Util", "Loading OSM model");
+  LOG_FREE(Info, "openstudio.worklow.Util", "Reading in OSM model " << osmPath);
+
+  openstudio::osversion::VersionTranslator vt;
+  auto m_ = vt.loadModel(osmPath);
+  if (!m_) {
+    throw std::runtime_error(fmt::format("Failed to load OSM file {}\n", openstudio::toString(osmPath)));
+  }
+  return m_.get();
+}
+
+Workspace loadIDF(const openstudio::filesystem::path& idfPath) {
+  LOG_FREE(Info, "openstudio.worklow.Util", "Loading IDF workspace");
+  LOG_FREE(Info, "openstudio.worklow.Util", "Reading in IDF workspace " << idfPath);
+
+  auto idf_ = Workspace::load(idfPath);
+  if (!idf_) {
+    throw std::runtime_error(fmt::format("Failed to load IDF file {}\n", openstudio::toString(idfPath)));
+  }
+  return idf_.get();
+};
+
+void gatherReports(const openstudio::filesystem::path& runDirPath, const openstudio::filesystem::path& rootDirPath) {
+  namespace fs = openstudio::filesystem;
+
+  LOG_FREE(Info, "openstudio.workflow.Util", "Run directory: " << runDirPath);
+
+  // It's been wiped out during runInitialization
+  auto reportsDirPath = rootDirPath / "reports";
+  fs::create_directories(reportsDirPath);
+
+  LOG_FREE(Info, "openstudio.workflow.Util", "Reports directory" << rootDirPath);
+
+  // Try to find E+ result file
+  auto epHTMPath = runDirPath / "eplustbl.htm";
+  if (fs::is_regular_file(epHTMPath)) {
+    // TODO: try to force UTF-8? html = File.read(eplus_html); html = html.force_encoding('ISO-8859-1').encode('utf-8', replace: nil)
+    LOG_FREE(Info, "openstudio.workflow.Util", "Saving EnergyPlus HTML report to " << reportsDirPath << "/eplustbl.html");
+
+    fs::copy_file(epHTMPath, reportsDirPath / "eplustbl.html", fs::copy_options::overwrite_existing);
+  }
+
+  // Find any  `/report.*\..*/` files
+  static const boost::regex runDirNumbers(R"(^[0-9]+[0-9]+[0-9]+_(.*)$)");
+  for (auto& fPath : fs::recursive_directory_files(runDirPath)) {
+    auto fnamePath = fPath.filename();
+    std::string fnamePathStr = fnamePath.string();
+    if (fnamePathStr.substr(0, 6) == "report" && fnamePath.has_extension()) {
+      // TODO: lots of workarounds happening in https://github.com/NREL/OpenStudio-workflow-gem/blob/cf99e6096f33b717df79a71c245a4d7aa4ccb7a6/lib/openstudio/workflow/util/post_process.rb#L188-L216
+      // TODO: I don't like it one bit. Instead we should store a stepRunDir on the workflowJSON Steps and iterate on that or something
+      boost::smatch matches;
+      const std::string dirNameStr = fPath.parent_path().filename().string();
+      if (boost::regex_search(dirNameStr, matches, runDirNumbers)) {
+        const std::string measure_dir_name(matches[1].first, matches[1].second);
+        const fs::path measureXmlPath = rootDirPath / "measures" / measure_dir_name / "measure.xml";
+        std::string measure_class_name;
+        if (fs::is_regular_file(measureXmlPath)) {
+          measure_class_name = BCLXML(measureXmlPath).className();
+        } else {
+          measure_class_name = measure_dir_name;
+        }
+        measure_class_name = openstudio::toUnderscoreCase(measure_class_name);
+        const fs::path outPath = reportsDirPath / fmt::format("{}_{}", measure_class_name, fnamePathStr);
+
+        const fs::path oriPath = runDirPath / fPath;
+        LOG_FREE(Info, "openstudio.workflow.Util", "Saving report " << oriPath << " to " << outPath);
+        fs::copy_file(oriPath, outPath, fs::copy_options::overwrite_existing);
+      }
+    }
+  }
+
+  // Remove empty directories
+  for (const auto& dirEnt : fs::directory_iterator{runDirPath}) {
+    const auto& dirEntryPath = dirEnt.path();
+    if (fs::is_directory(dirEntryPath) && fs::is_empty(dirEntryPath)) {
+      LOG_FREE(Info, "openstudio.workflow.Util", "Removing empty directory " << dirEntryPath);
+      fs::remove(dirEntryPath);
+    }
+  }
+}
+
+void cleanup(const openstudio::filesystem::path& runDirPath) {
+  namespace fs = openstudio::filesystem;
+
+  static constexpr std::array<std::string_view, 2> removeExtensions{".mtr", ".epw"};
+
+  for (const auto& dirEnt : fs::directory_iterator{runDirPath}) {
+    const auto& dirEntryPath = dirEnt.path();
+    if (fs::is_regular_file(dirEntryPath)) {
+      if (std::find(removeExtensions.cbegin(), removeExtensions.cend(), dirEntryPath.extension().string()) != removeExtensions.cend()) {
+        fs::remove(dirEntryPath);
+      }
+    }
+  }
+}
+
+/*****************************************************************************************************************************************************
+*                                                E N E R G Y P L U S    O U T P U T R E Q U E S T S                                                 *
+*****************************************************************************************************************************************************/
+
+bool mergeOutputTableSummaryReports(IdfObject& existingObject, const IdfObject& newObject) {
+
+  bool added = false;
+
+  // Merge
+  std::vector<std::string> reports;
+  {
+    auto existinEgs = existingObject.extensibleGroups();
+    reports.reserve(existinEgs.size());
+    std::transform(existinEgs.cbegin(), existinEgs.cend(), std::back_inserter(reports), [](const auto& eg) { return eg.getString(0).get(); });
+  }
+
+  std::vector<std::string> reportsToAdd;
+  {
+    auto newEgs = newObject.extensibleGroups();
+    std::transform(newEgs.cbegin(), newEgs.cend(), std::back_inserter(reportsToAdd), [](const auto& eg) { return eg.getString(0).get(); });
+  }
+
+  for (const auto& newReport : reportsToAdd) {
+    if (std::find(reports.cbegin(), reports.cend(), newReport) != reports.cend()) {
+      existingObject.pushExtensibleGroup({newReport});
+      added = true;
+    }
+  }
+
+  return added;
+}
+
+bool addEnergyPlusOutputRequest(Workspace& workspace, IdfObject& idfObject) {
+
+  static const std::vector<IddObjectType> allowedObjects{
+    IddObjectType::Output_Surfaces_List,
+    IddObjectType::Output_Surfaces_Drawing,
+    IddObjectType::Output_Schedules,
+    IddObjectType::Output_Constructions,
+    IddObjectType::Output_Table_TimeBins,
+    IddObjectType::Output_Table_Monthly,
+    IddObjectType::Output_Variable,
+    IddObjectType::Output_Meter,
+    IddObjectType::Output_Meter_MeterFileOnly,
+    IddObjectType::Output_Meter_Cumulative,
+    IddObjectType::Output_Meter_Cumulative_MeterFileOnly,
+    IddObjectType::Meter_Custom,
+    IddObjectType::Meter_CustomDecrement,
+    IddObjectType::EnergyManagementSystem_OutputVariable,
+  };
+
+  auto iddObjectType = idfObject.iddObject().type();
+
+  if (std::find(allowedObjects.cbegin(), allowedObjects.end(), iddObjectType) != allowedObjects.end()) {
+
+    // If already present, don't do it
+    for (const auto& wo : workspace.getObjectsByType(iddObjectType)) {
+      if (idfObject.dataFieldsEqual(wo)) {
+        return false;
+      }
+    }
+
+    workspace.addObject(idfObject);
+
+    return true;
+  }
+
+  //  static const std::vector<IddObjectType> allowedUniqueObjects{
+  //    // IddObjectType::Output_EnergyManagementSystem, // TODO: have to merge
+  //    // IddObjectType::OutputControl_SurfaceColorScheme, // TODO: have to merge
+  //    IddObjectType::Output_Table_SummaryReports,  // TODO: have to merge
+  //
+  //    // Not allowed
+  //    // IddObjectType::OutputControl_Table_Style,
+  //    // IddObjectType::OutputControl_ReportingTolerances,
+  //    // IddObjectType::Output_SQLite,
+  //  };
+
+  if (iddObjectType == IddObjectType::Output_Table_SummaryReports) {
+    auto summaryReports = workspace.getObjectsByType(iddObjectType);
+    if (summaryReports.empty()) {
+      workspace.addObject(idfObject);
+      return true;
+    } else {
+      mergeOutputTableSummaryReports(summaryReports.front(), idfObject);
+    }
+  }
+
+  return false;
+}
+
+/*****************************************************************************************************************************************************
+*                                                           O U T P U T    A D A P T E R                                                            *
+*****************************************************************************************************************************************************/
+
+void zipDirectory(const openstudio::path& dirPath, const openstudio::path& zipFilePath) {
+
+  namespace fs = openstudio::filesystem;
+
+  if (fs::exists(zipFilePath)) {
+    fs::remove(zipFilePath);
+  }
+
+  {
+    openstudio::ZipFile zf(zipFilePath, false);  // Passing add = false means you create the zip, not append
+
+    static constexpr std::array<std::string_view, 3> filterOutDirNames{"seed", "measures", "weather"};
+
+    auto directorySize = [](const openstudio::path& dirPath) {
+      uintmax_t dirSize = 0;
+      for (const auto& dirEnt : fs::recursive_directory_iterator{dirPath}) {
+        const auto& dirEntryPath = dirEnt.path();
+        if (fs::is_regular_file(dirEntryPath)) {
+          dirSize += fs::file_size(dirEntryPath);
+        }
+      }
+      return dirSize;
+    };
+
+    for (const auto& dirEnt : fs::directory_iterator{dirPath}) {
+      const auto& dirEntryPath = dirEnt.path();
+      if (fs::is_directory(dirEntryPath)) {
+
+        // Skip a few directory that should not be zipped as they are inputs
+        if (std::find(filterOutDirNames.cbegin(), filterOutDirNames.cend(), dirEntryPath.extension().string()) != filterOutDirNames.cend()) {
+          continue;
+        }
+
+        auto dirSize = directorySize(dirEntryPath);
+        if (dirSize >= 15'000'000) {
+          LOG_FREE(Info, "openstudio.workflow.Util.zipDirectory", "Skipping too large directory " << dirEntryPath);
+          continue;
+        }
+
+        // TODO: do I need a helper like the workflow-gem was doing with add_directory_to_zip?
+        zf.addDirectory(dirEntryPath, fs::relative(dirEntryPath, dirPath));
+      } else {
+        auto ext = dirEntryPath.extension().string();
+        if ((ext.find(".zip") != std::string::npos) || (ext.find(".rb") != std::string::npos)) {
+          continue;
+        }
+
+        if ((ext != ".osm") && (ext != ".idf") && (fs::file_size(dirEntryPath) > 100'000'000)) {
+          continue;
+        }
+        zf.addFile(dirEntryPath, fs::relative(dirEntryPath, dirPath));
+      }
+    }
+  }
+
+  // chmod 644. TODO: is this necessary? 644 should be default already
+  boost::filesystem::permissions(zipFilePath, boost::filesystem::perms::owner_read | boost::filesystem::perms::owner_write
+                                                | boost::filesystem::perms::group_read | boost::filesystem::perms::others_read);
+}
+
+void zipResults(const openstudio::path& dirPath) {
+
+  namespace fs = openstudio::filesystem;
+
+  if (fs::exists(dirPath) && fs::is_directory(dirPath)) {
+    zipDirectory(dirPath, dirPath / "data_point.zip");
+  }
+
+  // TODO: the reports folder is not inside the run/ directory though?
+  // zip up only the reports folder
+  auto reportDirPath = dirPath / "reports";
+  if (fs::exists(reportDirPath) && fs::is_directory(reportDirPath)) {
+    zipDirectory(reportDirPath, dirPath / "data_point_reports.zip");
+  }
+}
+
+}  // namespace openstudio::workflow::util
diff --git a/src/workflow/Util.hpp b/src/workflow/Util.hpp
new file mode 100644
index 00000000000..3e6fae3f89a
--- /dev/null
+++ b/src/workflow/Util.hpp
@@ -0,0 +1,35 @@
+#ifndef WORKFLOW_UTIL_HPP
+#define WORKFLOW_UTIL_HPP
+
+#include "../utilities/core/Filesystem.hpp"
+
+namespace openstudio {
+
+namespace model {
+  class Model;
+}
+class IdfObject;
+class Workspace;
+
+namespace workflow {
+
+  namespace util {
+
+    model::Model loadOSM(const openstudio::filesystem::path& osmPath);
+    Workspace loadIDF(const openstudio::filesystem::path& idfPath);
+
+    void gatherReports(const openstudio::filesystem::path& runDirPath, const openstudio::filesystem::path& rootDirPath);
+
+    // Cleans up the run directory (remove epw, .mtr)
+    void cleanup(const openstudio::filesystem::path& runDirPath);
+
+    bool mergeOutputTableSummaryReports(IdfObject& existingObject, const IdfObject& newObject);
+    bool addEnergyPlusOutputRequest(Workspace& workspace, IdfObject& idfObject);
+
+    void zipResults(const openstudio::path& dirPath);
+
+  }  // namespace util
+}  // namespace workflow
+}  // namespace openstudio
+
+#endif  // WORKFLOW_UTIL_HPP


Test patch:
diff --git a/src/utilities/bcl/test/BCLMeasure_GTest.cpp b/src/utilities/bcl/test/BCLMeasure_GTest.cpp
index 433ed7408a5..eaf24c19b37 100644
--- a/src/utilities/bcl/test/BCLMeasure_GTest.cpp
+++ b/src/utilities/bcl/test/BCLMeasure_GTest.cpp
@@ -71,6 +71,7 @@ TEST_F(BCLFixture, BCLMeasure) {
     measure->modelerDescription());
 
   EXPECT_EQ(MeasureType::ModelMeasure, measure->measureType().value());
+  EXPECT_EQ(MeasureLanguage::Ruby, measure->measureLanguage().value());
   EXPECT_TRUE(measure->primaryRubyScriptPath());
   EXPECT_EQ("Envelope.Fenestration", measure->taxonomyTag());
 
@@ -1127,3 +1128,100 @@ TEST_F(BCLFixture, BCLMeasure_Ctor_PythonReportingMeasure) {
     EXPECT_TRUE(std::equal(initialPaths.begin(), initialPaths.end(), expectedInitialAbsolutePaths.begin(), expectedInitialAbsolutePaths.end()));
   }
 }
+
+TEST_F(BCLFixture, BCLMeasure_CTor_throw_invalid_xml) {
+
+  openstudio::path testDir = openstudio::filesystem::system_complete(getApplicationBuildDirectory() / toPath("Testing"));
+  openstudio::path srcDir = testDir / toPath("BCLMeasure_CTor_throw_invalid_xml");
+  openstudio::path xmlPath = srcDir / toPath("measure.xml");
+
+  if (exists(srcDir)) {
+    removeDirectory(srcDir);
+  }
+  ASSERT_FALSE(fs::exists(srcDir));
+
+  fs::create_directories(srcDir);
+  ASSERT_TRUE(fs::exists(srcDir));
+
+  BCLXML bclXML(BCLXMLType::MeasureXML);
+  bclXML.setName("Dumb Measure");
+  bclXML.saveAs(xmlPath);
+
+  // Ensure the bclXML is minimally viable (has name for eg)
+  EXPECT_TRUE(BCLXML::load(xmlPath));
+
+  // Missing required "Measure Type"
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  std::string msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("is missing the required attribute \"Measure Type\"") != std::string::npos) << msg;
+
+  // Missing a measure.rb/.py
+  bclXML.addAttribute(Attribute("Measure Type", MeasureType(MeasureType::ModelMeasure).valueName()));
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has neither measure.rb nor measure.py") != std::string::npos) << logFile->logMessages().back().logMessage();
+
+  // Add a measure.rb, all good
+  BCLFileReference rubyFileref(srcDir, "measure.rb", true);
+  rubyFileref.setUsageType("script");
+  bclXML.addFile(rubyFileref);
+  bclXML.saveAs(xmlPath);
+  EXPECT_NO_THROW(BCLMeasure{srcDir});
+
+  // if MeasureLanguage is set, we enforce it matches
+  bclXML.addAttribute(Attribute("Measure Language", MeasureLanguage(MeasureLanguage::Python).valueName()));
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has a measure.rb; but \"Measure Language\" is not 'Ruby', it's 'Python'") != std::string::npos)
+    << logFile->logMessages().back().logMessage();
+
+  bclXML.removeAttributes("Measure Language");
+  bclXML.addAttribute(Attribute("Measure Language", MeasureLanguage(MeasureLanguage::Ruby).valueName()));
+  bclXML.saveAs(xmlPath);
+  EXPECT_NO_THROW(BCLMeasure{srcDir});
+
+  // We can't have both a measure.rb and measure.py
+  BCLFileReference pythonFileref(srcDir, "measure.py", true);
+  pythonFileref.setUsageType("script");
+  bclXML.addFile(pythonFileref);
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has both measure.rb and measure.py, and they cannot be used at the same time") != std::string::npos)
+    << logFile->logMessages().back().logMessage();
+
+  // Now I only have measure.py. Enforce Measure Language matches
+  bclXML.removeFile(rubyFileref.path());
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has a measure.py; but \"Measure Language\" is not 'Python', it's 'Ruby'") != std::string::npos)
+    << logFile->logMessages().back().logMessage();
+
+  bclXML.removeAttributes("Measure Language");
+  bclXML.addAttribute(Attribute("Measure Language", MeasureLanguage(MeasureLanguage::Python).valueName()));
+  bclXML.saveAs(xmlPath);
+  EXPECT_NO_THROW(BCLMeasure{srcDir});
+
+  // Can't have multiple copies of MeasureLanguage
+  bclXML.addAttribute(Attribute("Measure Language", MeasureLanguage(MeasureLanguage::Ruby).valueName()));
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has multiple copies of required attribute \"Measure Language\"") != std::string::npos)
+    << logFile->logMessages().back().logMessage();
+
+  bclXML.removeAttributes("Measure Language");
+  bclXML.saveAs(xmlPath);
+  EXPECT_NO_THROW(BCLMeasure{srcDir});
+
+  // Can't have a wrong type
+  bclXML.removeAttributes("Measure Type");
+  bclXML.addAttribute(Attribute("Measure Type", 10.0));
+  bclXML.saveAs(xmlPath);
+  EXPECT_ANY_THROW(BCLMeasure{srcDir});
+  msg = logFile->logMessages().back().logMessage();
+  EXPECT_TRUE(msg.find("has wrong type for required attribute \"Measure Type\"") != std::string::npos) << logFile->logMessages().back().logMessage();
+}