| name | writing-openstudio-model-measures |
| description | Write OpenStudio ModelMeasures (Ruby scripts that modify .osm files) for building energy models. Use when creating measures, writing measure.rb files, or modifying OpenStudio models programmatically. Targets OpenStudio 3.9 with best practices from NREL documentation. |
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