Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1ca6991
Add sample file for whole building with batteries.
joseph-robertson Dec 24, 2025
e50fe3c
Stub approach for creating custom meters for each unit.
joseph-robertson Dec 24, 2025
47b1a3c
Share get_object_outputs_by_key so we can use it to set up custom met…
joseph-robertson Dec 26, 2025
bc5dc74
Merge branch 'master' into whole-building-batteries
joseph-robertson Dec 29, 2025
5d39251
Create custom fuel meters for electricity, electricity produced, and …
joseph-robertson Dec 29, 2025
723aa52
Update the docs.
joseph-robertson Dec 29, 2025
d8e3505
Update resilience calculations to be max across units.
joseph-robertson Dec 29, 2025
911cc54
Merge branch 'master' into whole-building-batteries
joseph-robertson Jan 5, 2026
7e05cb6
Use model objects instead of workspace objects.
joseph-robertson Jan 5, 2026
a0d20a6
Remove whole building battery test from ruby error messages test.
joseph-robertson Jan 5, 2026
7d6dfd9
Let multiple references warning slide for now.
joseph-robertson Jan 5, 2026
6756f9a
More specific warning message exception.
joseph-robertson Jan 5, 2026
458cdf2
Comment out unitary system ancillary output variables due to nuances.
joseph-robertson Jan 5, 2026
26afb20
Need upcase on report meter data.
joseph-robertson Jan 5, 2026
a7da128
Exclude resilience output type from unit multiplier check.
joseph-robertson Jan 5, 2026
44522f9
Move vehicle charging from electric storage to electricity facility.
joseph-robertson Jan 5, 2026
30dcdef
Update battery and vehicle tests for new storage operation scheme.
joseph-robertson Jan 5, 2026
c760a6c
Only add production or storage output meters for units with pv/genera…
joseph-robertson Jan 5, 2026
9189c59
Instead just except missing custom meters, for now.
joseph-robertson Jan 5, 2026
112c14e
Actually check batteries and vehicles with unit multipliers.
joseph-robertson Jan 6, 2026
6eaece2
Merge branch 'master' into whole-building-batteries
joseph-robertson Jan 6, 2026
555c94c
Merge branch 'unit-fuel-meters' into whole-building-batteries
joseph-robertson Jan 7, 2026
7afce5a
Merge branch 'unit-fuel-meters' into whole-building-batteries
joseph-robertson Jan 13, 2026
ada42d3
Update unit tests.
joseph-robertson Jan 13, 2026
3e1cb0b
Request output meters for critical load by unit.
joseph-robertson Jan 13, 2026
c888e49
Create unit meters for single unit models so we can track electricity…
joseph-robertson Jan 15, 2026
e703540
Update battery and vehicle tests.
joseph-robertson Jan 15, 2026
04d60c3
Latest results.
Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions HPXMLtoOpenStudio/measure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ def process_whole_sfa_mf_inputs(hpxml)
# SFA/MF building simulations, we'd need to create custom meters with electricity usage *for each unit*
# and switch to "TrackMeterDemandStoreExcessOnSite".
# https://github.com/NREL/OpenStudio-HPXML/issues/1499
fail 'Modeling batteries for whole SFA/MF buildings is not currently supported.'
# fail 'Modeling batteries for whole SFA/MF buildings is not currently supported.'
end
end
end
Expand Down Expand Up @@ -388,8 +388,8 @@ def create_unit_model(hpxml, hpxml_bldg, runner, model, weather, schedules_file)
# Other
PV.apply(runner, model, hpxml_bldg)
Generator.apply(model, hpxml_bldg)
Battery.apply(runner, model, spaces, hpxml_bldg, schedules_file)
Vehicle.apply(runner, model, spaces, hpxml_bldg, hpxml.header, schedules_file)
Battery.apply(runner, model, spaces, hpxml, hpxml_bldg, schedules_file)
Vehicle.apply(runner, model, spaces, hpxml, hpxml_bldg, hpxml.header, schedules_file)

# Unit Meters
Outputs.create_custom_unit_meters(model, hpxml)
Expand Down
18 changes: 9 additions & 9 deletions HPXMLtoOpenStudio/measure.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
<schema_version>3.1</schema_version>
<name>hpxm_lto_openstudio</name>
<uid>b1543b30-9465-45ff-ba04-1d1f85e763bc</uid>
<version_id>f69f8c71-e00a-4bc7-8683-566de62c07f5</version_id>
<version_modified>2026-01-13T20:00:36Z</version_modified>
<version_id>40743261-db1b-4704-85cb-babb19c24b04</version_id>
<version_modified>2026-01-15T22:06:45Z</version_modified>
<xml_checksum>D8922A73</xml_checksum>
<class_name>HPXMLtoOpenStudio</class_name>
<display_name>HPXML to OpenStudio Translator</display_name>
Expand Down Expand Up @@ -192,7 +192,7 @@
<filename>measure.rb</filename>
<filetype>rb</filetype>
<usage_type>script</usage_type>
<checksum>2001BC79</checksum>
<checksum>E3D309E8</checksum>
</file>
<file>
<filename>airflow.rb</filename>
Expand All @@ -204,7 +204,7 @@
<filename>battery.rb</filename>
<filetype>rb</filetype>
<usage_type>resource</usage_type>
<checksum>F9BB0DB6</checksum>
<checksum>9C83CBF5</checksum>
</file>
<file>
<filename>calendar.rb</filename>
Expand Down Expand Up @@ -480,7 +480,7 @@
<filename>output.rb</filename>
<filetype>rb</filetype>
<usage_type>resource</usage_type>
<checksum>B6BE2EE5</checksum>
<checksum>0CE7A472</checksum>
</file>
<file>
<filename>psychrometrics.rb</filename>
Expand Down Expand Up @@ -684,7 +684,7 @@
<filename>vehicle.rb</filename>
<filetype>rb</filetype>
<usage_type>resource</usage_type>
<checksum>F4638081</checksum>
<checksum>C249066B</checksum>
</file>
<file>
<filename>version.rb</filename>
Expand Down Expand Up @@ -726,7 +726,7 @@
<filename>test_battery.rb</filename>
<filetype>rb</filetype>
<usage_type>test</usage_type>
<checksum>BD69BEA3</checksum>
<checksum>11254D6D</checksum>
</file>
<file>
<filename>test_defaults.rb</filename>
Expand Down Expand Up @@ -810,13 +810,13 @@
<filename>test_validation.rb</filename>
<filetype>rb</filetype>
<usage_type>test</usage_type>
<checksum>1A14E141</checksum>
<checksum>DEE2BEDA</checksum>
</file>
<file>
<filename>test_vehicle.rb</filename>
<filetype>rb</filetype>
<usage_type>test</usage_type>
<checksum>2EB7C07E</checksum>
<checksum>39B48E52</checksum>
</file>
<file>
<filename>test_water_heater.rb</filename>
Expand Down
22 changes: 18 additions & 4 deletions HPXMLtoOpenStudio/resources/battery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ module Battery
# @param runner [OpenStudio::Measure::OSRunner] Object typically used to display warnings
# @param model [OpenStudio::Model::Model] OpenStudio Model object
# @param spaces [Hash] Map of HPXML locations => OpenStudio Space objects
# @param hpxml [HPXML] HPXML object
# @param hpxml_bldg [HPXML::Building] HPXML Building object representing an individual dwelling unit
# @param schedules_file [SchedulesFile] SchedulesFile wrapper class instance of detailed schedule files
# @return [nil]
def self.apply(runner, model, spaces, hpxml_bldg, schedules_file)
def self.apply(runner, model, spaces, hpxml, hpxml_bldg, schedules_file)
charging_schedule, discharging_schedule = nil, nil
if not schedules_file.nil?
charging_schedule = schedules_file.create_schedule_file(model, col_name: SchedulesFile::Columns[:BatteryCharging].name)
discharging_schedule = schedules_file.create_schedule_file(model, col_name: SchedulesFile::Columns[:BatteryDischarging].name)
end
hpxml_bldg.batteries.each do |battery|
apply_battery(runner, model, spaces, hpxml_bldg, battery, charging_schedule, discharging_schedule)
apply_battery(runner, model, spaces, hpxml, hpxml_bldg, battery, charging_schedule, discharging_schedule)
end
end

Expand All @@ -32,12 +33,13 @@ def self.apply(runner, model, spaces, hpxml_bldg, schedules_file)
# @param runner [OpenStudio::Measure::OSRunner] Object typically used to display warnings
# @param model [OpenStudio::Model::Model] OpenStudio Model object
# @param spaces [Hash] Map of HPXML locations => OpenStudio Space objects
# @param hpxml [HPXML] HPXML object
# @param hpxml_bldg [HPXML::Building] HPXML Building object representing an individual dwelling unit
# @param battery [HPXML::Battery, HPXML::Vehicle] Object that defines a single home battery or EV battery
# @param charging_schedule [OpenStudio::Model::ScheduleXXX] The battery charging schedule
# @param discharging_schedule [OpenStudio::Model::ScheduleXXX] The battery discharging schedule
# @return [nil]
def self.apply_battery(runner, model, spaces, hpxml_bldg, battery, charging_schedule, discharging_schedule)
def self.apply_battery(runner, model, spaces, hpxml, hpxml_bldg, battery, charging_schedule, discharging_schedule)
nbeds = hpxml_bldg.building_construction.number_of_bedrooms
unit_multiplier = hpxml_bldg.building_construction.number_of_units
pv_systems = hpxml_bldg.pv_systems
Expand Down Expand Up @@ -149,7 +151,18 @@ def self.apply_battery(runner, model, spaces, hpxml_bldg, battery, charging_sche
elcd = elcds.find { |elcd| elcd.name.to_s.include?('PVSystem') }
if elcd
elcd.setElectricalBussType('DirectCurrentWithInverterACStorage')
elcd.setStorageOperationScheme('TrackFacilityElectricDemandStoreExcessOnSite')
# Use TrackMeterDemandStoreExcessOnSite for single unit simulations.
# Even when custom Electricity_Facility mirrors Electricity:Facility
# exactly, TrackMeterDemandStoreExcessOnSite produces different results
# than TrackFacilityElectricDemandStoreExcessOnSite.
elcd.setStorageOperationScheme('TrackMeterDemandStoreExcessOnSite')
if hpxml.buildings.size == 1
meter_name = 'Electricity_Facility'
else
unit_num = hpxml.buildings.index(hpxml_bldg) + 1
meter_name = "unit#{unit_num}_Electricity_Facility"
end
elcd.setStorageControlTrackMeterName(meter_name)
else
elcd = OpenStudio::Model::ElectricLoadCenterDistribution.new(model)
elcd.setName("#{obj_name} elec load center dist")
Expand All @@ -165,6 +178,7 @@ def self.apply_battery(runner, model, spaces, hpxml_bldg, battery, charging_sche

if (not charging_schedule.nil?) && (not discharging_schedule.nil?)
elcd.setStorageOperationScheme('TrackChargeDischargeSchedules')
elcd.resetStorageControlTrackMeterName # In case scheduled battery w/PV
elcd.setStorageChargePowerFractionSchedule(charging_schedule)
elcd.setStorageDischargePowerFractionSchedule(discharging_schedule)

Expand Down
24 changes: 13 additions & 11 deletions HPXMLtoOpenStudio/resources/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,8 @@ def self.create_custom_meters(model, custom_unit_meter = nil)
if custom_unit_meter.nil?
key_vars << ['', 'Electricity:Facility']
else
# Since custom meter cannot reference other custom meters, copy
# all custom meter key/variable groups to this custom meter.
key_vars = custom_unit_meter.keyVarGroups
Comment on lines +1515 to 1517
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

end
Model.add_meter_custom(
Expand Down Expand Up @@ -1548,8 +1550,6 @@ def self.create_custom_meters(model, custom_unit_meter = nil)
# @param hpxml [HPXML] HPXML object
# @return [nil]
def self.create_custom_unit_meters(model, hpxml)
return if hpxml.buildings.size == 1

to_eplus = { FT::Elec => EPlus::FuelTypeElectricity,
FT::Gas => EPlus::FuelTypeNaturalGas,
FT::Oil => EPlus::FuelTypeOil,
Expand All @@ -1575,15 +1575,14 @@ def self.create_custom_unit_meters(model, hpxml)
next unless to_eplus[ft] == fuel_type

output_vars.each do |output_var|
next if output_var.include? 'ExteriorLights:Electricity' # not associated with a zone, so the meter is across all units
next if output_var.include? 'InteriorLights:Electricity' # same as above; like interior equipment, we *could* switch to zone level
next if output_var.include? 'Electric Storage Charge Energy' # vehicles
next if output_var.include? 'ExteriorLights:Electricity' # Not associated with a zone, so the meter is across all units.
next if output_var.include? 'InteriorLights:Electricity' # Same as above; like interior equipment, we *could* switch to zone level.

key_vars << [object.name.to_s, output_var]
end
end

# FIXME: can we simplify all this special stuff?
# FIXME: Can we simplify/avoid all this special stuff?
if fuel_type == EPlus::FuelTypeElectricity
if object.to_ElectricLoadCenterInverterPVWatts.is_initialized
key_vars << [object.name.to_s, 'Inverter Ancillary AC Electricity Energy']
Expand All @@ -1603,7 +1602,10 @@ def self.create_custom_unit_meters(model, hpxml)
end
end

# Avoid the "Output Variable or Meter Name="x:y:z" referenced multiple times, only first instance will be used" E+ warning
next if key_vars.empty?

# Avoid the "Output Variable or Meter Name="x:y:z" referenced multiple
# times, only first instance will be used" E+ warning.
key_vars.each_with_index do |key_var1, i|
key_vars.each_with_index do |key_var2, j|
next if key_var1 == key_var2
Expand All @@ -1615,16 +1617,16 @@ def self.create_custom_unit_meters(model, hpxml)
end
end

next if key_vars.empty?

meter_custom = Model.add_meter_custom(
model,
name: "#{fuel_type}:Facility",
name: "#{fuel_type}_Facility",
fuel_type: fuel_type,
key_var_pairs: key_vars
)

if fuel_type == EPlus::FuelTypeElectricity
# We only need the electricity Total, Net, PV, Critical meters by unit when
# there are multiple units.
if (fuel_type == EPlus::FuelTypeElectricity) && (hpxml.buildings.size > 1)
Outputs.create_custom_meters(model, meter_custom)
end
end
Expand Down
10 changes: 6 additions & 4 deletions HPXMLtoOpenStudio/resources/vehicle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ module Vehicle
# @param runner [OpenStudio::Measure::OSRunner] Object typically used to display warnings
# @param model [OpenStudio::Model::Model] OpenStudio Model object
# @param spaces [Hash] Map of HPXML locations => OpenStudio Space objects
# @param hpxml [HPXML] HPXML object
# @param hpxml_bldg [HPXML::Building] HPXML Building object representing an individual dwelling unit
# @param hpxml_header [HPXML::Header] HPXML Header object (one per HPXML file)
# @param schedules_file [SchedulesFile] SchedulesFile wrapper class instance of detailed schedule files
# @return [nil]
def self.apply(runner, model, spaces, hpxml_bldg, hpxml_header, schedules_file)
def self.apply(runner, model, spaces, hpxml, hpxml_bldg, hpxml_header, schedules_file)
hpxml_bldg.vehicles.each do |vehicle|
if vehicle.vehicle_type != HPXML::VehicleTypeBEV
# Warning issued by Schematron validator
next
end

apply_electric_vehicle(runner, model, spaces, hpxml_bldg, hpxml_header, vehicle, schedules_file)
apply_electric_vehicle(runner, model, spaces, hpxml, hpxml_bldg, hpxml_header, vehicle, schedules_file)
end
end

Expand All @@ -30,12 +31,13 @@ def self.apply(runner, model, spaces, hpxml_bldg, hpxml_header, schedules_file)
# @param runner [OpenStudio::Measure::OSRunner] Object typically used to display warnings
# @param model [OpenStudio::Model::Model] OpenStudio Model object
# @param spaces [Hash] Map of HPXML locations => OpenStudio Space objects
# @param hpxml [HPXML] HPXML object
# @param hpxml_bldg [HPXML::Building] HPXML Building object representing an individual dwelling unit
# @param hpxml_header [HPXML::Header] HPXML Header object (one per HPXML file)
# @param vehicle [HPXML::Vehicle] Object that defines a single electric vehicle
# @param schedules_file [SchedulesFile] SchedulesFile wrapper class instance of detailed schedule files
# @return [nil]
def self.apply_electric_vehicle(runner, model, spaces, hpxml_bldg, hpxml_header, vehicle, schedules_file)
def self.apply_electric_vehicle(runner, model, spaces, hpxml, hpxml_bldg, hpxml_header, vehicle, schedules_file)
unit_multiplier = hpxml_bldg.building_construction.number_of_units
if hpxml_bldg.plug_loads.any? { |pl| pl.plug_load_type == HPXML::PlugLoadTypeElectricVehicleCharging }
# Warning issued by Schematron validator
Expand Down Expand Up @@ -95,7 +97,7 @@ def self.apply_electric_vehicle(runner, model, spaces, hpxml_bldg, hpxml_header,
vehicle.additional_properties.eff_discharge_power = eff_discharge_power * unit_multiplier

# Apply vehicle battery to model
Battery.apply_battery(runner, model, spaces, hpxml_bldg, vehicle, charging_schedule, discharging_schedule)
Battery.apply_battery(runner, model, spaces, hpxml, hpxml_bldg, vehicle, charging_schedule, discharging_schedule)

temp_sensor = Model.add_ems_sensor(
model,
Expand Down
20 changes: 14 additions & 6 deletions HPXMLtoOpenStudio/tests/test_battery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ def test_battery_default
assert_equal(5000.0, elcd.designStorageControlChargePower.get)
assert_equal(5000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
end
end

Expand Down Expand Up @@ -128,6 +129,7 @@ def test_battery_scheduled
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackChargeDischargeSchedules', elcd.storageOperationScheme)
assert(!elcd.storageControlTrackMeterName.is_initialized)
assert(elcd.storageChargePowerFractionSchedule.is_initialized)
assert(elcd.storageDischargePowerFractionSchedule.is_initialized)
assert(elcd.storageConverter.is_initialized)
Expand Down Expand Up @@ -171,7 +173,8 @@ def test_pv_battery
assert_equal(6000.0, elcd.designStorageControlChargePower.get)
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
assert(!elcd.storageChargePowerFractionSchedule.is_initialized)
assert(!elcd.storageDischargePowerFractionSchedule.is_initialized)
assert(!elcd.storageConverter.is_initialized)
Expand Down Expand Up @@ -210,7 +213,8 @@ def test_pv_battery_shared
assert_equal(6000.0, elcd.designStorageControlChargePower.get)
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
assert(!elcd.storageChargePowerFractionSchedule.is_initialized)
assert(!elcd.storageDischargePowerFractionSchedule.is_initialized)
assert(!elcd.storageConverter.is_initialized)
Expand Down Expand Up @@ -250,6 +254,7 @@ def test_pv_battery_scheduled
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackChargeDischargeSchedules', elcd.storageOperationScheme)
assert(!elcd.storageControlTrackMeterName.is_initialized)
assert(elcd.storageChargePowerFractionSchedule.is_initialized)
assert(elcd.storageDischargePowerFractionSchedule.is_initialized)
assert(elcd.storageConverter.is_initialized)
Expand Down Expand Up @@ -293,7 +298,8 @@ def test_pv_battery_round_trip_efficiency
assert_equal(6000.0, elcd.designStorageControlChargePower.get)
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
assert(!elcd.storageChargePowerFractionSchedule.is_initialized)
assert(!elcd.storageDischargePowerFractionSchedule.is_initialized)
assert(!elcd.storageConverter.is_initialized)
Expand Down Expand Up @@ -333,7 +339,8 @@ def test_pv_battery_garage
assert_equal(6000.0, elcd.designStorageControlChargePower.get)
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
end
end

Expand Down Expand Up @@ -369,7 +376,8 @@ def test_pv_battery_ah
assert_equal(6000.0, elcd.designStorageControlChargePower.get)
assert_equal(6000.0, elcd.designStorageControlDischargePower.get)
assert(!elcd.demandLimitSchemePurchasedElectricDemandLimit.is_initialized)
assert_equal('TrackFacilityElectricDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('TrackMeterDemandStoreExcessOnSite', elcd.storageOperationScheme)
assert_equal('Electricity_Facility', elcd.storageControlTrackMeterName.get)
end
end

Expand Down
Loading