Measure Authoring
SkillAI & modelsCreate, 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.
No other account needed.
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_markerset (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 backtracewith themeasure.rbline. Almost always a use-after-delete:component.remove()followed byaddToNodeon a node thatremove()deleted. Insert the replacement first, then remove the old one.crash_markernull: an ordinary Ruby/Python error; the message and backtrace are inlog_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:
-> Floatplain value;-> Float, nilis an Optional (.is_initializedthen.get)-> ?return type unknown (no header/annotation); probe the object before calling.get-> Array<TimeSeries> | TimeSeries, niloverloaded; the return depends on the arguments[static] load(path) -> Model, nilclass-level call (Model.load), never on an instancesignatures_available: falsein 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 supported — create_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 -ucan't regeneratemeasure.xmlfor Python (SDK limitation), socreate_measure/edit_measurewrite 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 — usestr(obj.name())orobj.name().get(), never bareobj.name().runner.registerError("msg")must be followed byreturn False(capital F; it does not halt).- Optionals:
opt = surface.construction()thenif opt.is_initialized(): c = opt.get(). - Dangling nodes crash Python too:
addToNode(node)on a node captured beforecomponent.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)— nomodelparam- Model & SQL boilerplate is auto-generated:
modelandsqlvariables are available in run_body arguments()takes no params (notmodel)- Includes empty
energyPlusOutputRequests()stub (edit viaedit_measureif needed) - Works in Python too —
language="Python". Query SQL withval = sql.execAndReturnFirstDouble(query)thenif val.is_initialized(): runner.registerValue("k", val.get()).
Notes
run_bodyindentation matters: Ruby = 4 spaces, Python = 8 spaces- Always call
runner.registerFinalCondition("msg")at end of run body create_measureis idempotent (overwrites existing measure with same name)- Use
edit_measureto modify an existing measure without recreating from scratch - Use
list_custom_measuresto 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