Measure Authoring

SkillAI & models

Create, test, and apply custom OpenStudio measures. Use when user asks to "write a measure", "create a custom measure", "modify the model with Ruby/Python code", or needs logic beyond what existing tools provide.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Measure Authoring skill

What this skill tells your AI

The instructions your AI receives, as published by natlabrockies/openstudio-mcp in .claude/skills/measure-authoring/SKILL.md and read by ahel’s review.

Create custom OpenStudio ModelMeasures with user-provided logic, test them, and apply them to models.

When to Use

Use measure authoring when:

  • No existing MCP tool does what the user needs
  • User explicitly asks for a "custom measure" or "Ruby/Python measure"
  • Complex model modifications requiring iteration over many objects
  • User wants reusable, repeatable model transformations

Do NOT use when an existing tool already does the job (e.g., replace_air_terminals for terminal swaps, adjust_thermostat_setpoints for thermostat changes).

Reuse Before Writing (find_measure / BCL routing)

For requests to use an EXISTING measure by name, BCL page title, or intent, call find_measure first. It searches your own custom measures, bundled common measures, ComStock measures, and your BCL cache before searching BCL; a strong BCL match is downloaded into your per-user bcl dir. Pass its returned measure_dir straight to list_measure_arguments or apply_measure. Use search_bcl_measures only to inspect BCL candidates without downloading; list_custom_measures lists what you've authored. Custom measures are private per user — never tell users measures are shared.

Workflow

1. Create the Measure

create_measure(
    name="set_lights_8w",
    description="Set all lights to 8 W/m2",
    language="Ruby",
    run_body="    model.getLightsDefinitions.each { |ld| ld.setWattsperSpaceFloorArea(8.0) }\n    runner.registerFinalCondition('Done')"
)

2. Test It

test_measure(measure_dir="/measures/<user>/custom/set_lights_8w")

Tests run against the currently loaded model (or SystemD_baseline.osm fallback), so measures that depend on HVAC, plant loops, or zones will work correctly. Use model_path to test against a specific model. Custom measures are per-user: they land under /measures/<user>/custom and are private to each HTTP user. Always use the measure_dir path that create_measure returns, not a hardcoded one.

3. Apply to Model

apply_measure(measure_dir="/measures/<user>/custom/set_lights_8w")

When test_measure / apply_measure fail

Both return log_path (the full log on disk), a log_tail excerpt, exit_code, and crash_marker. The excerpt is a window, not the whole story: read the full log before guessing.

read_file(file_path="<log_path from the failure response>")
  • crash_marker set (e.g. [BUG] Segmentation fault, error mentions SIGABRT/SIGSEGV): the Ruby/Python process died in native SDK code, not in a raised exception. The excerpt shows the -- Ruby level backtrace with the measure.rb line. Almost always a use-after-delete: component.remove() followed by addToNode on a node that remove() deleted. Insert the replacement first, then remove the old one.
  • crash_marker null: an ordinary Ruby/Python error; the message and backtrace are in log_tail / test_output.

4. Verify Results (Before/After Comparison)

For rigorous validation, run a baseline simulation BEFORE applying the measure:

save_osm_model(save_name="baseline")            # response carries osm_path
run_simulation(osm_path=<osm_path from save>, epw_path="<epw>")
extract_summary_metrics(run_id=<baseline_id>)   # record baseline EUI

# reload, apply measure, re-simulate
load_osm_model(osm_path="<original>")
apply_measure(measure_dir="/measures/<user>/custom/set_lights_8w")
save_osm_model(save_name="retrofit")
run_simulation(osm_path=<osm_path from save>, epw_path="<epw>")
extract_summary_metrics(run_id=<retrofit_id>)   # compare to baseline

Verify SDK Methods Before Writing run_body

Guessed method names are the top cause of NoMethodError / AttributeError in measures. search_api returns each method as a signature, not a bare name:

search_api("BoilerHotWater", method_pattern="Efficiency")
# -> "setNominalThermalEfficiency(nominalThermalEfficiency) -> Boolean"
search_api("SqlFile", method_pattern="^annual")     # non-model classes too (openstudio root, gbxml, alfalfa, ...)

Read the signature literally; names are identical in Ruby and Python:

  • -> Float plain value; -> Float, nil is an Optional (.is_initialized then .get)
  • -> ? return type unknown (no header/annotation); probe the object before calling .get
  • -> Array<TimeSeries> | TimeSeries, nil overloaded; the return depends on the arguments
  • [static] load(path) -> Model, nil class-level call (Model.load), never on an instance
  • signatures_available: false in the response = signatures could not be loaded; the method names are still real, only the parameter/return details are missing

Language Choice

Both are fully supportedcreate_measure(language="Ruby"|"Python", ...) scaffolds, tests (minitest for Ruby, pytest for Python), and applies either. Pick per the user's request or project convention:

  • Ruby — matches most OpenStudio SDK docs and existing measure libraries; a fine default when the user has no preference.
  • Python — first-class. Same OpenStudio model API, Python syntax (() on method calls, 8-space indent, openstudio.model.* classes). One caveat the tools handle for you: openstudio measure -u can't regenerate measure.xml for Python (SDK limitation), so create_measure/edit_measure write the XML directly — arguments still work at runtime.

Common run_body Patterns (Ruby)

Envelope

    model.getLightsDefinitions.each { |ld| ld.setWattsperSpaceFloorArea(8.0) }
    model.getSpaceInfiltrationDesignFlowRates.each { |inf| inf.setFlowperExteriorSurfaceArea(0.0003) }
    model.getSurfaces.each { |s| next unless s.outsideBoundaryCondition == 'Outdoors'; ... }

HVAC

    model.getAirLoopHVACs.each do |loop|
      loop.thermalZones.each do |zone|
        loop.removeBranchForZone(zone)
        # create new terminal...
        loop.addBranchForZone(zone, terminal.to_StraightComponent.get)
      end
    end

Replace a supply-branch coil or fan in place (add first, THEN remove). For a plain swap prefer the tool replace_air_loop_supply_component (no measure needed); the snippet is for measures that must do it themselves:

    old_coil = model.getCoilCoolingDXSingleSpeedByName('Main Cooling Coil').get
    inlet_node = old_coil.inletModelObject.get.to_Node.get
    new_coil = OpenStudio::Model::CoilCoolingDXTwoSpeed.new(model)
    raise 'addToNode failed' unless new_coil.addToNode(inlet_node)
    old_name = old_coil.nameString
    old_coil.remove
    new_coil.setName(old_name)

WARNING: remove() deletes the component's outlet node. addToNode on a node handle captured before remove() segfaults the measure process ([BUG] Segmentation fault, exit 134, no Ruby exception). The terminal pattern above is safe only because addBranchForZone is keyed by zone, not node. search_wiring_patterns("replace coil") returns the full recipe.

Zone Equipment

    model.getThermalZones.each do |zone|
      bb = OpenStudio::Model::ZoneHVACBaseboardConvectiveElectric.new(model)
      bb.setName("#{zone.name} Baseboard")
      bb.addToThermalZone(zone)
    end

Air Terminals (beams)

    # CooledBeam (2-pipe, cooling only)
    coil = OpenStudio::Model::CoilCoolingCooledBeam.new(model)
    terminal = OpenStudio::Model::AirTerminalSingleDuctConstantVolumeCooledBeam.new(model, sch, coil)

    # FourPipeBeam (4-pipe, heating + cooling)
    cc = OpenStudio::Model::CoilCoolingFourPipeBeam.new(model)
    hc = OpenStudio::Model::CoilHeatingFourPipeBeam.new(model)
    terminal = OpenStudio::Model::AirTerminalSingleDuctConstantVolumeFourPipeBeam.new(model, cc, hc)

WARNING: Beams are AIR TERMINALS (connect via air_loop.addBranchForZone), NOT zone equipment (addToThermalZone).

Common run_body Patterns (Python)

Same model API, Python syntax: method calls take (), classes are openstudio.model.*, indent 8 spaces. Argument values are auto-extracted by the scaffolding — reference them by name.

Python gotchas (different from Ruby):

  • obj.name() returns an OptionalString — use str(obj.name()) or obj.name().get(), never bare obj.name().
  • runner.registerError("msg") must be followed by return False (capital F; it does not halt).
  • Optionals: opt = surface.construction() then if opt.is_initialized(): c = opt.get().
  • Dangling nodes crash Python too: addToNode(node) on a node captured before component.remove() segfaults the interpreter (exit 139, no exception), same as Ruby. Add the new component first, then remove.
  • Unit conversion: openstudio.convert(val, "W/m^2", "Btu/hr*ft^2").get() — full unit-string table: get_skill_file(skill_name="measure-authoring", filename="unit-conversions.md").

Envelope

        for ld in model.getLightsDefinitions():
            ld.setWattsperSpaceFloorArea(8.0)
        for inf in model.getSpaceInfiltrationDesignFlowRates():
            inf.setFlowperExteriorSurfaceArea(0.0003)
        for s in model.getSurfaces():
            if s.outsideBoundaryCondition() != "Outdoors":
                continue
            # ...

HVAC

        for loop in model.getAirLoopHVACs():
            for zone in loop.thermalZones():
                loop.removeBranchForZone(zone)
                # create new terminal...
                loop.addBranchForZone(zone, terminal.to_StraightComponent().get())

Replace a supply-branch coil or fan in place (add first, THEN remove):

        old_coil = model.getCoilCoolingDXSingleSpeedByName("Main Cooling Coil").get()
        inlet_node = old_coil.inletModelObject().get().to_Node().get()
        new_coil = openstudio.model.CoilCoolingDXTwoSpeed(model)
        if not new_coil.addToNode(inlet_node):
            runner.registerError("addToNode failed")
            return False
        old_name = old_coil.nameString()
        old_coil.remove()
        new_coil.setName(old_name)

Zone Equipment

        for zone in model.getThermalZones():
            bb = openstudio.model.ZoneHVACBaseboardConvectiveElectric(model)
            bb.setName(f"{str(zone.name())} Baseboard")
            bb.addToThermalZone(zone)

Air Terminals (beams)

        # CooledBeam (2-pipe, cooling only)
        coil = openstudio.model.CoilCoolingCooledBeam(model)
        terminal = openstudio.model.AirTerminalSingleDuctConstantVolumeCooledBeam(model, sch, coil)

        # FourPipeBeam (4-pipe, heating + cooling)
        cc = openstudio.model.CoilCoolingFourPipeBeam(model)
        hc = openstudio.model.CoilHeatingFourPipeBeam(model)
        terminal = openstudio.model.AirTerminalSingleDuctConstantVolumeFourPipeBeam(model, cc, hc)

(Same beam warning applies: connect via air_loop.addBranchForZone, not addToThermalZone.)

ReportingMeasures

ReportingMeasures run after simulation and access SQL results. Use when the user wants to generate custom reports, extract specific metrics, or post-process simulation output — but only when the existing extract_* / query_timeseries tools don't already cover the metric (CLAUDE.md's "never write scripts to parse SQL" rule; this is the sanctioned exception).

Create a ReportingMeasure

create_measure(
    name="custom_eui_report",
    description="Extract and report custom EUI breakdown",
    language="Ruby",
    measure_type="ReportingMeasure",
    run_body='    query = "SELECT Value FROM TabularDataWithStrings WHERE ReportName=\'AnnualBuildingUtilityPerformanceSummary\' AND TableName=\'Site and Source Energy\' AND RowName=\'Total Site Energy\' AND ColumnName=\'Total Energy\' AND Units=\'GJ\'"\n    val = sql.execAndReturnFirstDouble(query)\n    if val.is_initialized\n      runner.registerValue("total_site_energy_gj", val.get)\n      runner.registerInfo("Total Site Energy: #{val.get} GJ")\n    end\n    runner.registerFinalCondition("Report complete")'
)

Test with Simulation Results

# ReportingMeasures need SQL — provide run_id from a completed sim
test_measure(measure_dir="/measures/<user>/custom/custom_eui_report", run_id="<completed_run_id>")

Without run_id, only argument validation tests run (no run() execution).

Apply to Completed Simulation

apply_measure(measure_dir="/measures/<user>/custom/custom_eui_report", run_id="<completed_run_id>")

Key Differences from ModelMeasure

  • run() signature: (runner, user_arguments) — no model param
  • Model & SQL boilerplate is auto-generated: model and sql variables are available in run_body
  • arguments() takes no params (not model)
  • Includes empty energyPlusOutputRequests() stub (edit via edit_measure if needed)
  • Works in Python too — language="Python". Query SQL with val = sql.execAndReturnFirstDouble(query) then if val.is_initialized(): runner.registerValue("k", val.get()).

Notes

  • run_body indentation matters: Ruby = 4 spaces, Python = 8 spaces
  • Always call runner.registerFinalCondition("msg") at end of run body
  • create_measure is idempotent (overwrites existing measure with same name)
  • Use edit_measure to modify an existing measure without recreating from scratch
  • Use list_custom_measures to see all created measures

Signals

GitHub stars
33
Forks
8
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
measure-authoring
Source
github.com/natlabrockies/openstudio-mcp