Claude Code Plugins

Community-maintained marketplace

Feedback

writing-openstudio-model-measures

@mattnigh/skills_collection
0
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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.rb with complete boilerplate
  • measure.xml (auto-generated by OpenStudio)
  • tests/ directory with MiniTest template
  • resources/ directory for helpers
  • README.md stub

Measure Structure

Every ModelMeasure requires:

Required Files:

Recommended Files:

Core Implementation Pattern

Every ModelMeasure must:

  1. Inherit from base class:

    class YourMeasure < OpenStudio::Measure::ModelMeasure
    
  2. Implement five required methods:

    • name() - User-visible title
    • description() - General audience explanation
    • modeler_description() - Technical details for modelers
    • arguments(model) - Define user inputs
    • run(model, runner, user_arguments) - Main logic
  3. Return boolean from run():

    • true if measure succeeds
    • false if 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 numbers
  • makeIntegerArgument(name, required) - Whole numbers
  • makeBoolArgument(name, required) - True/false
  • makeStringArgument(name, required) - Text input
  • makeChoiceArgument(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 false only after logging error with registerError()

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.xml auto-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:

Scripts:

External References:

Workflow Summary

  1. Scaffold measure using Node.js script or manually create directory
  2. Write measure.rb using template as starting point
  3. Implement five required methods (name, description, modeler_description, arguments, run)
  4. Add input validation and error handling
  5. Test with MiniTest - create tests in tests/ directory
  6. Run in OpenStudio Application to generate/update measure.xml
  7. 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