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