Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion docs/scenario_workflow_translation_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Each PR should be independently reviewable and should not implement later PR sco
| 1 | In Progress | Current docs PR | Guiding plan doc | Add this repository plan document. | None | The doc directs PRs 2-18, names acceptance gates, and keeps first-release scope clear. | The doc leaves scenario selection, mapping ownership, warning behavior, or result writeback scope ambiguous. |
| 2 | Done | Current PR-2 branch | Scenario data model and discovery | Add reader-returned structures for first-facility report-level package scenarios. Extract scenario ID, name, temporal status, report ID, package ID, reference case ID, measure IDrefs, and linked premises. | PR 1 | Unit tests prove `building_151.xml` discovers baseline plus all package scenarios and existing reader behavior remains unchanged. | Parser assumes a hardcoded namespace, reads the wrong scenario path, or breaks existing reader tests. |
| 3 | Done | Current PR-3 branch | Measure index | Index first-facility measures by ID and extract category/name metadata plus linked premises, cost/savings fields, and implementation status. | PR 2 | Tests resolve package `MeasureID` references in `building_151.xml` to parsed measure metadata. | Unresolved refs are silently dropped or measures without `TechnologyCategories` crash parsing. |
| 4 | Planned | - | Parser warning contract | Add structured warnings for missing IDs, unresolved refs, empty packages, missing names, missing categories, and packages with no usable measures. | PR 3 | Tests cover warning cases using `BuildingEQ-1.0.0.xml`, `Golden Test File.xml`, and no-measure fixtures. | Warnings only print to stdout or malformed package data aborts all discovery. |
| 4 | Done | Current PR-4 branch | Parser warning contract | Add structured warnings for missing IDs, unresolved refs, empty packages, missing names, missing categories, and packages with no usable measures. | PR 3 | Tests cover warning cases using `BuildingEQ-1.0.0.xml`, `Golden Test File.xml`, and no-measure fixtures. | Warnings only print to stdout or malformed package data aborts all discovery. |
| 5 | Planned | - | Initial mapping JSON | Add `lib/BOSS/scenario_measure_map.json` with verified mappings for an initial supported set from `building_151.xml`. | PR 1 | Mapping JSON is valid, and every included entry has source category/name, target `measure_dir_name`, and arguments. | Legacy mappings are copied blindly without checking current OpenStudio measure dirs/args. |
| 6 | Planned | - | Basic `ScenarioMeasureMapper` | Load JSON, normalize lookup keys, and map one parsed BuildingSync measure to OpenStudio step specs using `SystemCategoryAffected` plus `MeasureName`. | PRs 3, 5 | Mapper unit tests return expected steps and structured unmapped warnings. | Mapper mutates reader data, raises on unmapped measures, or hardcodes rules outside JSON. |
| 7 | Planned | - | Technology category fallback | Add fallback lookup by technology category plus `MeasureName`. | PR 6 | Tests show fallback mapping works when `SystemCategoryAffected` is absent, while normal lookup priority is preserved. | Fallback changes normal category/name lookup behavior. |
Expand Down Expand Up @@ -238,6 +238,16 @@ Keep detailed rationale in the relevant PR description or code review thread. Ke
- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rubocop --only Lint/UnreachableLoop lib/BOSS/buildingsync_reader/buildingsync_reader.rb` - 1 file inspected, no offenses. Full changed-file RuboCop still reports existing reader/spec style debt and new-cop configuration warnings.
- Follow-up: PR 4 should consume `get_package_measure_scenarios` plus `get_measures` to report unresolved `MeasureID` references and incomplete measure metadata without changing the parser return shapes unless the warning contract requires it.

### PR 4: Parser warning contract

- Status: Done
- Delivered by: Current PR-4 branch
- Handoff: `BOSS::BuildingSyncReader#get_parser_warnings` returns structured symbol-keyed warning hashes with `code`, `severity`, `message`, and relevant scenario/package/measure context. Existing scenario and measure reader return shapes remain unchanged.
- Handoff: Parser warning codes cover missing scenario/package/measure IDs, missing `MeasureID` IDrefs, unresolved measure references, packages without `MeasureIDs`, packages with no parser-usable measures, and resolved measures missing `SystemCategoryAffected`, technology category, or `MeasureName`.
- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 33 examples, 0 failures.
- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rubocop lib/BOSS/buildingsync_reader/buildingsync_reader.rb spec/tests/unit/buildingsync_reader_spec.rb` - fails on existing reader/spec style debt and new-cop configuration warnings; after PR-4 cleanup, the focused run reports 132 existing offenses, 117 autocorrectable.
- Follow-up: PR 5 can add mapping JSON without expecting parser warnings to identify unmapped OpenStudio measures; mapping-specific warnings remain PR 6/PR 9 scope.

## Verification Commands

Use the narrowest command that verifies the current PR. Broader commands should be run before merging larger integration PRs.
Expand Down
129 changes: 128 additions & 1 deletion lib/BOSS/buildingsync_reader/buildingsync_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ def get_measures
return measures
end

def get_parser_warnings
warnings = []
measures = get_measures

_measure_xmls.each do |measure_xml|
measure = _measure_hash(measure_xml)
warnings << _parser_warning(:missing_measure_id, 'Measure is missing ID.') if _blank?(measure[:measure_id])
end

_package_measure_scenario_xmls.each do |report_xml, scenario_xml, package_xml|
scenario = _scenario_hash(report_xml, scenario_xml)
warnings.concat(_package_measure_scenario_warnings(scenario, package_xml, measures))
end

return warnings
end

# tries to get weather file from:
# 1. given weather file
# 2. city state from either building or site
Expand Down Expand Up @@ -347,6 +364,18 @@ def _measure_xmls
return measure_xmls
end

def _package_measure_scenario_xmls
scenario_xmls = []
@facility_xml&.elements&.each("#{@ns}Reports/#{@ns}Report") do |report_xml|
report_xml.elements.each("#{@ns}Scenarios/#{@ns}Scenario") do |scenario_xml|
package_xml = _package_of_measures_xml(scenario_xml)
scenario_xmls << [report_xml, scenario_xml, package_xml] if !package_xml.nil?
end
end

return scenario_xmls
end

def _measure_hash(measure_xml)
technology_category_xml = _technology_category_xml(measure_xml)

Expand Down Expand Up @@ -394,6 +423,94 @@ def _numeric_element_text(xml, path)
return nil
end

def _package_measure_scenario_warnings(scenario, package_xml, measures)
warnings = []
context = _scenario_warning_context(scenario)
measure_idref_xmls = _measure_idref_xmls(package_xml)

if _blank?(scenario[:scenario_id])
warnings << _parser_warning(:missing_scenario_id, 'Package scenario is missing ID.', context)
end
if _blank?(scenario[:package_id])
warnings << _parser_warning(:missing_package_id, 'PackageOfMeasures is missing ID.', context)
end
if measure_idref_xmls.empty?
warnings << _parser_warning(:package_missing_measure_ids, 'Package has no MeasureIDs.', context)
end

measure_idref_xmls.each do |measure_id_xml|
if _blank?(measure_id_xml.attributes['IDref'])
warnings << _parser_warning(:missing_measure_idref, 'MeasureID is missing IDref.', context)
end
end

resolved_measures = []
scenario[:measure_ids].each do |measure_idref|
measure = measures[measure_idref]
if measure.nil?
warnings << _parser_warning(
:unresolved_measure_idref,
'Package references an unknown measure ID.',
context.merge(measure_idref:)
)
else
resolved_measures << measure
warnings.concat(_measure_parser_warnings(measure, context))
end
end

if resolved_measures.none? { |measure| _usable_package_measure?(measure) }
warnings << _parser_warning(:package_has_no_usable_measures, 'Package has no usable measures.', context)
end

return warnings
end

def _measure_parser_warnings(measure, scenario_context)
warnings = []
context = scenario_context.merge(measure_id: measure[:measure_id])

if _blank?(measure[:system_category_affected])
warnings << _parser_warning(:missing_system_category_affected, 'Measure is missing SystemCategoryAffected.', context)
end
if _blank?(measure[:technology_category_element_name])
warnings << _parser_warning(:missing_technology_category, 'Measure is missing a usable technology category.', context)
end
if _blank?(measure[:measure_name])
warnings << _parser_warning(:missing_measure_name, 'Measure is missing MeasureName.', context)
end

return warnings
end

def _scenario_warning_context(scenario)
return {
report_id: scenario[:report_id],
scenario_id: scenario[:scenario_id],
package_id: scenario[:package_id]
}
end

def _parser_warning(code, message, context = {})
return {
code:,
severity: :warning,
message:
}.merge(context)
end

def _blank?(value)
return true if value.nil?

return value.to_s.strip.empty?
end

def _usable_package_measure?(measure)
return false if _blank?(measure[:measure_name])

return !_blank?(measure[:system_category_affected]) || !_blank?(measure[:technology_category_element_name])
end

def _scenario_hash(report_xml, scenario_xml)
package_xml = _package_of_measures_xml(scenario_xml)

Expand Down Expand Up @@ -431,13 +548,23 @@ def _measure_idrefs(package_xml)
return [] if package_xml.nil?

measure_ids = []
package_xml.elements.each("#{@ns}MeasureIDs/#{@ns}MeasureID") do |measure_id_xml|
_measure_idref_xmls(package_xml).each do |measure_id_xml|
measure_id = measure_id_xml.attributes['IDref']
measure_ids << measure_id if !measure_id.nil?
end
return measure_ids
end

def _measure_idref_xmls(package_xml)
return [] if package_xml.nil?

measure_idref_xmls = []
package_xml.elements.each("#{@ns}MeasureIDs/#{@ns}MeasureID") do |measure_id_xml|
measure_idref_xmls << measure_id_xml
end
return measure_idref_xmls
end

def _linked_premises_idrefs(xml)
linked_premises_xml = xml.elements["#{@ns}LinkedPremises"]
return [] if linked_premises_xml.nil?
Expand Down
118 changes: 118 additions & 0 deletions spec/tests/unit/buildingsync_reader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,124 @@ def wrap_in_facility(xml)
end
end

describe 'get_parser_warnings should' do
it 'return no warnings for complete package scenarios' do
# Set Up
doc = load_fixture_doc('v2.7.0', 'BuildingEQ-1.0.0.xml')

# Action
buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1)

# Assert
expect(buidingsync_reader.get_parser_warnings).to eq []
end

it 'return no warnings when no package scenarios exist' do
# Set Up
doc = load_fixture_doc('v2.7.0', 'building_151_no_measures.xml')

# Action
buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1)

# Assert
expect(buidingsync_reader.get_parser_warnings).to eq []
end

it 'warn for incomplete package measure metadata' do
# Set Up
doc = load_fixture_doc('v2.7.0', 'Golden Test File.xml')

# Action
buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1)
warnings = buidingsync_reader.get_parser_warnings

# Assert
expect(warnings.map { |warning| warning[:severity] }.uniq).to eq [:warning]
expect(warnings.map { |warning| warning[:code] }).to contain_exactly(
:missing_system_category_affected,
:missing_technology_category,
:missing_measure_name,
:package_has_no_usable_measures,
:missing_system_category_affected,
:missing_technology_category,
:missing_measure_name,
:package_has_no_usable_measures
)

building_measure_warning = warnings.find do |warning|
warning[:measure_id] == 'Building1RemovePV' && warning[:code] == :missing_measure_name
end
expect(building_measure_warning).to include(
report_id: 'Report-c1857e54-836b-4674-95f4-2e6e9c8510b4',
scenario_id: 'Scenario1',
package_id: 'PackageOfMeasures-b9ca1b63-acd6-4d8a-9d8f-f39a96fd8dac'
)
end

it 'warn for malformed package scenario references' do
# Set Up
doc = REXML::Document.new wrap_in_facility(<<~XML)
<Measures>
<Measure>
<SystemCategoryAffected>Lighting</SystemCategoryAffected>
<TechnologyCategories>
<TechnologyCategory>
<LightingImprovements>
<MeasureName>Install lighting controls</MeasureName>
</LightingImprovements>
</TechnologyCategory>
</TechnologyCategories>
</Measure>
</Measures>
<Reports>
<Report ID="ReportA">
<Scenarios>
<Scenario>
<ScenarioType>
<PackageOfMeasures>
<MeasureIDs>
<MeasureID/>
<MeasureID IDref="UnknownMeasure"/>
</MeasureIDs>
</PackageOfMeasures>
</ScenarioType>
</Scenario>
<Scenario ID="EmptyScenario">
<ScenarioType>
<PackageOfMeasures ID="EmptyPackage"/>
</ScenarioType>
</Scenario>
</Scenarios>
</Report>
</Reports>
XML

# Action
buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1)
warnings = buidingsync_reader.get_parser_warnings

# Assert
expect(warnings.map { |warning| warning[:code] }).to contain_exactly(
:missing_measure_id,
:missing_scenario_id,
:missing_package_id,
:missing_measure_idref,
:unresolved_measure_idref,
:package_has_no_usable_measures,
:package_missing_measure_ids,
:package_has_no_usable_measures
)

unresolved_warning = warnings.find { |warning| warning[:code] == :unresolved_measure_idref }
expect(unresolved_warning).to include(
report_id: 'ReportA',
scenario_id: nil,
package_id: nil,
measure_idref: 'UnknownMeasure'
)
end
end

describe 'get_climate_zone should' do
it "get from site" do
# Set Up
Expand Down
Loading