Writing OpenStudio Model Measures
Expert guidance for creating OpenStudio ModelMeasures - Ruby scripts that programmatically modify building energy models (.osm files).
Target Version: OpenStudio 3.9
Measure Type: ModelMeasure (modifies OSM files)
Measure Directory: C:\Users\mcoalson\OpenStudio\Measures
Reference: https://nrel.github.io/OpenStudio-user-documentation/reference/measure_writing_guide/
When to Use This Skill
Invoke when you need to:
- Write a new OpenStudio measure from scratch
- Modify existing measure.rb files
- Create measure test files
- Understand OpenStudio measure structure and best practices
- Scaffold a complete measure directory with boilerplate code
Quick Start: Creating a New Measure
Use the Node.js scaffolding script to generate a complete measure structure in your measures directory:
cd "C:\Users\mcoalson\OpenStudio\Measures"
node "C:\Users\mcoalson\Documents\WorkPath\.claude\skills\writing-openstudio-model-measures\scripts\scaffold-measure.js" "Your Measure Name"
This creates:
- Measure directory with snake_case name
measure.rbwith complete boilerplatemeasure.xml(auto-generated by OpenStudio)tests/directory with MiniTest templateresources/directory for helpersREADME.mdstub
Measure Structure
Every ModelMeasure requires:
Required Files:
measure.rb- Main Ruby script (see ./templates/model-measure-template.rb)measure.xml- Metadata (auto-generated when measure runs)
Recommended Files:
tests/measure_test.rb- MiniTest unit tests (see ./templates/measure-test-template.rb)README.md- Documentationresources/- Helper Ruby filesLICENSE.md- Distribution license
Core Implementation Pattern
Every ModelMeasure must:
Inherit from base class:
class YourMeasure < OpenStudio::Measure::ModelMeasureImplement five required methods:
name()- User-visible titledescription()- General audience explanationmodeler_description()- Technical details for modelersarguments(model)- Define user inputsrun(model, runner, user_arguments)- Main logic
Return boolean from run():
trueif measure succeedsfalseif measure fails (after logging error)
Required Methods Details
1. name()
def name
return "Add Window Overhangs"
end
- Short, descriptive, general
- User-visible in OpenStudio Application
2. description()
def description
return "Adds overhangs to all exterior windows based on user-specified projection factor. Improves solar control and reduces cooling loads."
end
- General audience explanation
- Include what measure does and benefits
3. modeler_description()
def modeler_description
return "Iterates through all SubSurfaces with 'Window' type, creates ShadingSurfaceGroup and ShadingSurface objects with projection calculated as window height × projection factor. Assumes windows face outward from building."
end
- Technical implementation details
- Assumptions, algorithms, references
4. arguments(model)
def arguments(model)
args = OpenStudio::Measure::OSArgumentVector.new
# Double argument
projection_factor = OpenStudio::Measure::OSArgument.makeDoubleArgument('projection_factor', true)
projection_factor.setDisplayName('Projection Factor')
projection_factor.setDescription('Overhang depth as multiple of window height')
projection_factor.setDefaultValue(0.5)
args << projection_factor
# Boolean argument
apply_to_north = OpenStudio::Measure::OSArgument.makeBoolArgument('apply_to_north', true)
apply_to_north.setDisplayName('Apply to North-Facing Windows?')
apply_to_north.setDefaultValue(false)
args << apply_to_north
# Choice argument
choices = OpenStudio::StringVector.new
choices << "All Windows"
choices << "South-Facing Only"
choices << "East and West Only"
orientation = OpenStudio::Measure::OSArgument.makeChoiceArgument('orientation', choices, true)
orientation.setDisplayName('Window Orientation Filter')
orientation.setDefaultValue("All Windows")
args << orientation
return args
end
Argument Types:
makeDoubleArgument(name, required)- Real numbersmakeIntegerArgument(name, required)- Whole numbersmakeBoolArgument(name, required)- True/falsemakeStringArgument(name, required)- Text inputmakeChoiceArgument(name, choices_vector, required)- Dropdown selection
5. run(model, runner, user_arguments)
def run(model, runner, user_arguments)
super(model, runner, user_arguments)
# 1. Validate and extract arguments
if !runner.validateUserArguments(arguments(model), user_arguments)
return false
end
projection_factor = runner.getDoubleArgumentValue('projection_factor', user_arguments)
apply_to_north = runner.getBoolArgumentValue('apply_to_north', user_arguments)
orientation = runner.getStringArgumentValue('orientation', user_arguments)
# 2. Additional validation
if projection_factor < 0 || projection_factor > 5
runner.registerError("Projection factor must be between 0 and 5, got #{projection_factor}")
return false
end
# 3. Register initial condition
initial_overhang_count = model.getShadingSurfaces.length
runner.registerInitialCondition("Model has #{initial_overhang_count} shading surfaces.")
# 4. Main measure logic
windows_modified = 0
model.getSubSurfaces.each do |sub_surface|
next unless sub_surface.subSurfaceType == "FixedWindow" || sub_surface.subSurfaceType == "OperableWindow"
# Your logic here
windows_modified += 1
runner.registerInfo("Added overhang to window: #{sub_surface.name}")
end
# 5. Handle not applicable case
if windows_modified == 0
runner.registerAsNotApplicable("No windows found in model.")
return true
end
# 6. Register final condition
final_overhang_count = model.getShadingSurfaces.length
runner.registerFinalCondition("Added #{final_overhang_count - initial_overhang_count} overhangs to #{windows_modified} windows.")
return true
end
Logging and User Communication
Use runner methods to communicate:
Info Messages (measure continues):
runner.registerInfo("Processing 42 windows...")
Warning Messages (measure continues):
runner.registerWarning("Window '#{name}' has no parent surface, skipping.")
Error Messages (measure stops):
runner.registerError("Invalid projection factor: #{value}")
return false
Condition Tracking:
runner.registerInitialCondition("Before: #{count} objects")
runner.registerFinalCondition("After: #{count} objects, added #{delta}")
Not Applicable:
runner.registerAsNotApplicable("No HVAC systems found in model.")
return true # Still return true!
OpenStudio API Patterns
Getting Objects from Model
Non-unique objects (can have multiple instances):
# Get all instances
spaces = model.getSpaces
thermal_zones = model.getThermalZones
# Get by name
space = model.getSpaceByName("Office 101")
if space.is_initialized
space_obj = space.get
# Use space_obj
end
Unique objects (only one instance):
# Get the single instance
building = model.getBuilding
site = model.getSite
Safe Optional Handling
OpenStudio uses boost::optional types extensively. Always check before using .get():
# UNSAFE - will crash if empty
zone = space.thermalZone.get # BAD!
# SAFE - check first
if !space.thermalZone.empty?
zone = space.thermalZone.get
runner.registerInfo("Space is in zone: #{zone.name}")
else
runner.registerWarning("Space has no thermal zone assigned.")
end
# Alternative safe pattern
if space.thermalZone.is_initialized
zone = space.thermalZone.get
# Use zone
end
Common Model Queries
# Spaces and Zones
model.getSpaces.each do |space|
puts space.name
end
model.getThermalZones.each do |zone|
puts zone.name
end
# Surfaces
model.getSurfaces.each do |surface|
puts "#{surface.name}: #{surface.surfaceType}"
end
# SubSurfaces (windows, doors)
model.getSubSurfaces.each do |sub_surface|
puts "#{sub_surface.name}: #{sub_surface.subSurfaceType}"
end
# HVAC Equipment
model.getAirLoopHVACs.each do |air_loop|
puts air_loop.name
end
model.getPlantLoops.each do |plant_loop|
puts plant_loop.name
end
# Constructions and Materials
model.getConstructions.each do |construction|
puts construction.name
end
# Schedules
model.getScheduleRulesets.each do |schedule|
puts schedule.name
end
Testing with MiniTest
Every measure should include tests in tests/measure_test.rb.
Run tests:
cd /path/to/your_measure
ruby tests/measure_test.rb
See ./templates/measure-test-template.rb for complete test structure.
Basic test pattern:
def test_valid_arguments
# Load test model
model = load_test_model
# Create measure instance
measure = YourMeasure.new
# Get arguments
arguments = measure.arguments(model)
# Set argument values
argument_map = OpenStudio::Measure.convertOSArgumentVectorToMap(arguments)
projection_factor = arguments[0].clone
assert(projection_factor.setValue(0.5))
argument_map['projection_factor'] = projection_factor
# Run measure
measure.run(model, runner, argument_map)
result = runner.result
# Assertions
assert_equal('Success', result.value.valueName)
assert(result.info.size > 0)
assert_equal(0, result.warnings.size)
end
Best Practices
Code Quality
- Follow Ruby Style Guide
- Use 2-space indentation
- Keep methods focused and concise
- Add comments for complex logic
Input Validation
- Always validate user arguments
- Check value ranges and logical constraints
- Provide clear error messages
- Use
runner.validateUserArguments()first
Error Handling
- Check for empty optionals before calling
.get() - Handle edge cases (empty model, missing objects)
- Use
registerAsNotApplicable()when measure doesn't apply - Return
falseonly after logging error withregisterError()
Performance
- Avoid nested loops when possible
- Cache frequently accessed values
- Use
model.getObjectsByType()for specific object types
User Experience
- Use clear, descriptive argument names
- Set sensible default values
- Log progress for long-running operations
- Report initial and final conditions
OpenStudio 3.9 Specific Notes
Ruby Version: OpenStudio 3.9 uses Ruby 2.7.2 API Documentation: https://openstudio-sdk-documentation.s3.amazonaws.com/index.html
Common gotchas:
- Method names are camelCase (OpenStudio convention), not snake_case (Ruby convention)
- Units matter - always check if arguments need IP or SI units
measure.xmlauto-regenerates - don't manually edit structure, only metadata- Test with multiple model types (residential, commercial, different HVAC systems)
Common Measure Patterns
Iterating Through Spaces
model.getSpaces.each do |space|
runner.registerInfo("Processing space: #{space.name}")
# Get space properties
floor_area = space.floorArea # m²
# Get thermal zone
if space.thermalZone.is_initialized
zone = space.thermalZone.get
runner.registerInfo(" Zone: #{zone.name}")
end
# Get space type
if space.spaceType.is_initialized
space_type = space.spaceType.get
runner.registerInfo(" Type: #{space_type.name}")
end
end
Modifying Constructions
model.getSurfaces.each do |surface|
next unless surface.surfaceType == "RoofCeiling"
next unless surface.outsideBoundaryCondition == "Outdoors"
# Create or get construction
new_construction = model.getConstructionByName("High Performance Roof")
if new_construction.is_initialized
surface.setConstruction(new_construction.get)
runner.registerInfo("Updated roof construction: #{surface.name}")
end
end
Working with Schedules
# Get schedule by name
schedule = model.getScheduleRulesetByName("Office Occupancy")
if schedule.is_initialized
sched = schedule.get
# Modify schedule rules
end
# Create new schedule
new_schedule = OpenStudio::Model::ScheduleRuleset.new(model)
new_schedule.setName("Custom Schedule")
new_schedule.defaultDaySchedule.setName("Custom Default")
# Add time-value pairs
new_schedule.defaultDaySchedule.addValue(OpenStudio::Time.new(0,8,0,0), 0)
new_schedule.defaultDaySchedule.addValue(OpenStudio::Time.new(0,18,0,0), 1)
Upgrading to OpenStudio 3.10 (Future)
When ready to upgrade, note these areas that may require changes:
- API method deprecations
- Ruby version changes
- New object types or properties
- Updated testing frameworks
Document upgrade process in ./upgrading-to-310.md when that time comes.
Additional Resources
Templates:
- ./templates/model-measure-template.rb - Complete ModelMeasure boilerplate
- ./templates/measure-test-template.rb - MiniTest template
Scripts:
- ./scripts/scaffold-measure.js - Node.js script to create measure directory structure
External References:
- NREL Measure Writing Guide
- OpenStudio API Documentation
- Ruby Style Guide
- OpenStudio Common Measures - Example implementations
Workflow Summary
- Scaffold measure using Node.js script or manually create directory
- Write measure.rb using template as starting point
- Implement five required methods (name, description, modeler_description, arguments, run)
- Add input validation and error handling
- Test with MiniTest - create tests in
tests/directory - Run in OpenStudio Application to generate/update
measure.xml - Iterate and refine based on testing with various models
Last Updated: 2025-11-18 Target OpenStudio Version: 3.9.0 Ruby Version: 2.7.2