diff --git a/docs/scenario_workflow_translation_plan.md b/docs/scenario_workflow_translation_plan.md
index 0854ed8..a66418b 100644
--- a/docs/scenario_workflow_translation_plan.md
+++ b/docs/scenario_workflow_translation_plan.md
@@ -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. |
@@ -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.
diff --git a/lib/BOSS/buildingsync_reader/buildingsync_reader.rb b/lib/BOSS/buildingsync_reader/buildingsync_reader.rb
index db191d8..bf0021f 100644
--- a/lib/BOSS/buildingsync_reader/buildingsync_reader.rb
+++ b/lib/BOSS/buildingsync_reader/buildingsync_reader.rb
@@ -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
@@ -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)
@@ -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)
@@ -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?
diff --git a/spec/tests/unit/buildingsync_reader_spec.rb b/spec/tests/unit/buildingsync_reader_spec.rb
index 2835e73..8fcb3f8 100644
--- a/spec/tests/unit/buildingsync_reader_spec.rb
+++ b/spec/tests/unit/buildingsync_reader_spec.rb
@@ -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)
+
+
+ Lighting
+
+
+
+ Install lighting controls
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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