diff --git a/docs/scenario_workflow_translation_plan.md b/docs/scenario_workflow_translation_plan.md index 6504705..a66418b 100644 --- a/docs/scenario_workflow_translation_plan.md +++ b/docs/scenario_workflow_translation_plan.md @@ -153,9 +153,9 @@ Each PR should be independently reviewable and should not implement later PR sco | PR | Status | Delivered By | Title | Scope | Depends On | Green Light | Red Light | | --- | --- | --- | --- | --- | --- | --- | --- | | 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 | Planned | - | 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 | Planned | - | Facility measure index | Index 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. | +| 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 | 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. | @@ -193,6 +193,61 @@ When a PR step is completed or merged: When starting a future session, check this table first, then verify against `git log` and the current file tree. If the table is stale, update it before doing implementation work. +## PR Handoff Notes + +Use this section for short durable notes that help the next PR start without reconstructing context from chat history. Keep entries concise and tied to a PR number. + +Each completed or blocked implementation PR should add an entry with: + +- Scope delivered or intentionally deferred. +- Reader, mapper, OSW, CLI, or warning contracts that later PRs can rely on. +- Verification commands run and their result. +- Known blockers, local-environment issues, or follow-up decisions. + +Suggested entry format: + +```md +### PR : + +- Status: <Done | Blocked | In Progress> +- Delivered by: <PR number, commit SHA, or branch note> +- Handoff: <one or two bullets with stable contracts for later PRs> +- Verification: `<command>` - <result> +- Follow-up: <only items needed by later PRs> +``` + +Keep detailed rationale in the relevant PR description or code review thread. Keep this section focused on what future implementation work needs to know. + +### PR 2: Scenario data model and discovery + +- Status: Done +- Delivered by: Current PR-2 branch +- Handoff: `BOSS::BuildingSyncReader#get_report_scenarios` returns first-facility report scenarios as symbol-keyed hashes with `scenario_id`, `scenario_name`, `temporal_status`, `report_id`, `scenario_type`, `package_id`, `reference_case_id`, `measure_ids`, and `linked_premises_idrefs`. +- Handoff: `BOSS::BuildingSyncReader#get_package_measure_scenarios` filters those records to package-of-measures scenarios. PR 3 should use `measure_ids` to resolve package references against facility measures; it should not expect warnings or parsed measure metadata yet. +- Verification: `bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 22 examples, 0 failures. +- Verification: `bundle exec rspec spec/tests/integration/write_and_run_osws_spec.rb` - completed in 79 minutes 27 seconds with 19 examples and 1 failure. The failure is an unrelated Windows command quoting issue for `Golden Test File` output paths with spaces; 18 final baseline `out.osw` files had `completed_status: Success`. +- Follow-up: PR 3 starts facility measure indexing and should keep warning contracts deferred to PR 4. + +### PR 3: Measure index + +- Status: Done +- Delivered by: Current PR-3 branch +- Handoff: `BOSS::BuildingSyncReader#get_measures` returns first-facility measures as a hash keyed by `Measure/@ID`, with symbol-keyed metadata for `measure_id`, `system_category_affected`, `technology_category_element_name`, `measure_name`, `custom_measure_name`, `linked_premises_idrefs`, useful cost/savings fields, and `implementation_status`. +- Handoff: Missing optional measure fields return `nil` or `[]`, missing `Measures` returns `{}`, and measures without `TechnologyCategories` remain parseable; PR 4 should add structured warnings for missing IDs, unresolved refs, missing names, and missing categories. +- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 29 examples, 0 failures. +- 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 028a0b6..bf0021f 100644 --- a/lib/BOSS/buildingsync_reader/buildingsync_reader.rb +++ b/lib/BOSS/buildingsync_reader/buildingsync_reader.rb @@ -28,6 +28,48 @@ def initialize(bsync_doc, epw_file_path, standard_to_be_used) end + def get_report_scenarios + scenarios = [] + @facility_xml&.elements&.each("#{@ns}Reports/#{@ns}Report") do |report_xml| + report_xml.elements.each("#{@ns}Scenarios/#{@ns}Scenario") do |scenario_xml| + scenarios << _scenario_hash(report_xml, scenario_xml) + end + end + + return scenarios + end + + def get_package_measure_scenarios + return get_report_scenarios.select { |scenario| scenario[:scenario_type] == :package_of_measures } + end + + def get_measures + measures = {} + _measure_xmls.each do |measure_xml| + measure = _measure_hash(measure_xml) + measures[measure[:measure_id]] = measure if !measure[:measure_id].nil? + end + + 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 @@ -310,5 +352,235 @@ def get_total_weighted_average_load return nil if !all_weighted_average_loads.all? return all_weighted_average_loads.map {|s| s.to_f}.sum end + + private + + def _measure_xmls + measure_xmls = [] + @facility_xml&.elements&.each("#{@ns}Measures/#{@ns}Measure") do |measure_xml| + measure_xmls << measure_xml + end + + 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) + + return { + measure_id: measure_xml.attributes['ID'], + system_category_affected: _element_text(measure_xml, "#{@ns}SystemCategoryAffected"), + technology_category_element_name: _technology_category_element_name(technology_category_xml), + measure_name: _technology_measure_name(technology_category_xml), + custom_measure_name: _element_text(measure_xml, "#{@ns}CustomMeasureName"), + linked_premises_idrefs: _linked_premises_idrefs(measure_xml), + mv_cost: _numeric_element_text(measure_xml, "#{@ns}MVCost"), + useful_life: _numeric_element_text(measure_xml, "#{@ns}UsefulLife"), + measure_total_first_cost: _numeric_element_text(measure_xml, "#{@ns}MeasureTotalFirstCost"), + measure_installation_cost: _numeric_element_text(measure_xml, "#{@ns}MeasureInstallationCost"), + measure_material_cost: _numeric_element_text(measure_xml, "#{@ns}MeasureMaterialCost"), + om_cost_annual_savings: _numeric_element_text(measure_xml, "#{@ns}MeasureSavingsAnalysis/#{@ns}OMCostAnnualSavings"), + implementation_status: _element_text(measure_xml, "#{@ns}ImplementationStatus") + } + end + + def _technology_category_xml(measure_xml) + technology_category_xml = measure_xml.elements["#{@ns}TechnologyCategories/#{@ns}TechnologyCategory"] + + return technology_category_xml&.elements&.[](1) + end + + def _technology_category_element_name(technology_category_xml) + return nil if technology_category_xml.nil? + + return technology_category_xml.name.to_s.split(':').last + end + + def _technology_measure_name(technology_category_xml) + return nil if technology_category_xml.nil? + + return _element_text(technology_category_xml, "#{@ns}MeasureName") + end + + def _numeric_element_text(xml, path) + value = _element_text(xml, path) + return nil if value.nil? || value.strip.empty? + + return Float(value) + rescue ArgumentError + 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) + + return { + scenario_id: scenario_xml.attributes['ID'], + scenario_name: _element_text(scenario_xml, "#{@ns}ScenarioName"), + temporal_status: _element_text(scenario_xml, "#{@ns}TemporalStatus"), + report_id: report_xml.attributes['ID'], + scenario_type: _scenario_type(scenario_xml, package_xml), + package_id: package_xml&.attributes&.[]('ID'), + reference_case_id: package_xml&.elements&.[]("#{@ns}ReferenceCase")&.attributes&.[]('IDref'), + measure_ids: _measure_idrefs(package_xml), + linked_premises_idrefs: _linked_premises_idrefs(scenario_xml) + } + end + + def _scenario_type(scenario_xml, package_xml) + return :package_of_measures if !package_xml.nil? + + current_building_xml = scenario_xml.elements["#{@ns}ScenarioType/#{@ns}CurrentBuilding"] + return :current_building if !current_building_xml.nil? + + return :other + end + + def _package_of_measures_xml(scenario_xml) + return scenario_xml.elements["#{@ns}ScenarioType/#{@ns}PackageOfMeasures"] + end + + def _element_text(xml, path) + return xml.elements[path]&.text + end + + def _measure_idrefs(package_xml) + return [] if package_xml.nil? + + measure_ids = [] + _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? + + return _descendant_idrefs(linked_premises_xml) + end + + def _descendant_idrefs(xml) + idrefs = [] + xml.each_element do |child_xml| + idref = child_xml.attributes['IDref'] + idrefs << idref if !idref.nil? + idrefs.concat(_descendant_idrefs(child_xml)) + end + + return idrefs + end end end diff --git a/spec/files/v2.7.0/expected/building_151_scenario1_resolved_measures.json b/spec/files/v2.7.0/expected/building_151_scenario1_resolved_measures.json new file mode 100644 index 0000000..f5a8488 --- /dev/null +++ b/spec/files/v2.7.0/expected/building_151_scenario1_resolved_measures.json @@ -0,0 +1,38 @@ +{ + "fixture": "spec/files/v2.7.0/building_151.xml", + "measure_count": 16, + "scenario": { + "scenario_id": "Scenario1", + "scenario_name": "LED Only", + "temporal_status": null, + "report_id": "Report1", + "scenario_type": "package_of_measures", + "package_id": "PackageOfMeasures1", + "reference_case_id": "Baseline", + "measure_ids": [ + "Measure1" + ], + "linked_premises_idrefs": [ + "Building151" + ] + }, + "resolved_measures": [ + { + "measure_id": "Measure1", + "system_category_affected": "Lighting", + "technology_category_element_name": "LightingImprovements", + "measure_name": "Retrofit with light emitting diode technologies", + "custom_measure_name": "TBD", + "linked_premises_idrefs": [ + "Building151" + ], + "mv_cost": 0.0, + "useful_life": 12.0, + "measure_total_first_cost": 267390.2, + "measure_installation_cost": 0.0, + "measure_material_cost": 0.0, + "om_cost_annual_savings": null, + "implementation_status": "Proposed" + } + ] +} diff --git a/spec/tests/unit/buildingsync_reader_spec.rb b/spec/tests/unit/buildingsync_reader_spec.rb index d7317d0..8fcb3f8 100644 --- a/spec/tests/unit/buildingsync_reader_spec.rb +++ b/spec/tests/unit/buildingsync_reader_spec.rb @@ -5,8 +5,21 @@ # ******************************************************************************* require 'tempfile' +require 'json' require 'BOSS/buildingsync_reader/buildingsync_reader' +UNIT_SPEC_FILES_DIR = File.expand_path('../../files', __dir__) + +def load_fixture_doc(schema_version, file_name) + xml_path = File.join(UNIT_SPEC_FILES_DIR, schema_version, file_name) + REXML::Document.new(File.read(xml_path), ignore_whitespace_nodes: :all) +end + +def load_fixture_json(schema_version, file_name) + json_path = File.join(UNIT_SPEC_FILES_DIR, schema_version, file_name) + JSON.parse(File.read(json_path)) +end + def wrap_in_site(xml) <<~XML <BuildingSync> @@ -23,7 +36,434 @@ def wrap_in_site(xml) XML end +def wrap_in_facility(xml) + <<~XML + <BuildingSync> + <Facilities> + <Facility> + <Sites> + <Site> + <Buildings> + <Building> + </Building> + </Buildings> + </Site> + </Sites> + #{xml} + </Facility> + </Facilities> + </BuildingSync> + XML +end + RSpec.describe 'BuildingSyncReader' do + describe 'get_report_scenarios should' do + it 'discover baseline and package scenarios from building_151' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + scenarios = buidingsync_reader.get_report_scenarios + package_scenarios = buidingsync_reader.get_package_measure_scenarios + + # Assert + expected_package_ids = [ + 'Scenario1', + 'Scenario3', + 'Scenario4', + 'Scenario5', + 'Scenario6', + 'Scenario7', + 'Scenario8', + 'Scenario9', + 'Scenario10', + 'Scenario11', + 'Scenario12', + 'Scenario14', + 'Scenario16', + 'Scenario18', + 'Scenario24', + 'Scenario25' + ] + + expect(scenarios.length).to eq 17 + expect(package_scenarios.map { |scenario| scenario[:scenario_id] }).to eq expected_package_ids + expect(scenarios.count { |scenario| scenario[:scenario_type] == :current_building }).to eq 1 + expect(scenarios.count { |scenario| scenario[:scenario_type] == :package_of_measures }).to eq 16 + end + + it 'extract scenario fields from package and current-building scenarios' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + scenarios = buidingsync_reader.get_report_scenarios + baseline = scenarios.find { |scenario| scenario[:scenario_id] == 'Baseline' } + scenario1 = scenarios.find { |scenario| scenario[:scenario_id] == 'Scenario1' } + + # Assert + expect(baseline).to include( + scenario_id: 'Baseline', + scenario_name: 'Baseline', + scenario_type: :current_building, + report_id: 'Report1', + temporal_status: nil, + package_id: nil, + reference_case_id: nil, + measure_ids: [], + linked_premises_idrefs: [] + ) + + expect(scenario1).to include( + scenario_id: 'Scenario1', + scenario_name: 'LED Only', + scenario_type: :package_of_measures, + report_id: 'Report1', + temporal_status: nil, + package_id: 'PackageOfMeasures1', + reference_case_id: 'Baseline', + measure_ids: ['Measure1'], + linked_premises_idrefs: ['Building151'] + ) + end + + it 'extract temporal status and multiple measure IDrefs' do + # Set Up + doc = REXML::Document.new wrap_in_facility(<<~XML) + <Reports> + <Report ID="ReportA"> + <Scenarios> + <Scenario ID="ScenarioA"> + <ScenarioName>Package A</ScenarioName> + <TemporalStatus>Post retrofit</TemporalStatus> + <ScenarioType> + <PackageOfMeasures ID="PackageA"> + <ReferenceCase IDref="BaselineA"/> + <MeasureIDs> + <MeasureID IDref="MeasureA"/> + <MeasureID IDref="MeasureB"/> + </MeasureIDs> + </PackageOfMeasures> + </ScenarioType> + </Scenario> + </Scenarios> + </Report> + </Reports> + XML + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + scenarios = buidingsync_reader.get_package_measure_scenarios + + # Assert + expect(scenarios.first).to include( + scenario_id: 'ScenarioA', + temporal_status: 'Post retrofit', + package_id: 'PackageA', + reference_case_id: 'BaselineA', + measure_ids: ['MeasureA', 'MeasureB'] + ) + end + + it 'uses the document namespace prefix when discovering scenarios' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151_n1.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + scenarios = buidingsync_reader.get_report_scenarios + + # Assert + expect(scenarios.find { |scenario| scenario[:scenario_id] == 'Baseline' }[:scenario_type]).to eq :current_building + expect(buidingsync_reader.get_package_measure_scenarios.map { |scenario| scenario[:package_id] }).to include('PackageOfMeasures1') + end + + it 'returns empty arrays when no report scenarios exist' do + # Set Up + doc = REXML::Document.new wrap_in_facility('') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + + # Assert + expect(buidingsync_reader.get_report_scenarios).to eq [] + expect(buidingsync_reader.get_package_measure_scenarios).to eq [] + end + end + + describe 'get_measures should' do + it 'index measures from building_151 by measure ID' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + measures = buidingsync_reader.get_measures + + # Assert + expected_measure_ids = [ + 'Measure1', + 'Measure3', + 'Measure4', + 'Measure5', + 'Measure6', + 'Measure7', + 'Measure8', + 'Measure9', + 'Measure10', + 'Measure11', + 'Measure12', + 'Measure14', + 'Measure16', + 'Measure18', + 'Measure24', + 'Measure25' + ] + + expect(measures.keys).to eq expected_measure_ids + end + + it 'extract measure category, name, linked premises, cost, savings, and status metadata' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + measure = buidingsync_reader.get_measures['Measure1'] + + # Assert + expect(measure).to include( + measure_id: 'Measure1', + system_category_affected: 'Lighting', + technology_category_element_name: 'LightingImprovements', + measure_name: 'Retrofit with light emitting diode technologies', + custom_measure_name: 'TBD', + linked_premises_idrefs: ['Building151'], + mv_cost: 0.0, + useful_life: 12.0, + measure_total_first_cost: 267390.2, + measure_installation_cost: 0.0, + measure_material_cost: 0.0, + om_cost_annual_savings: nil, + implementation_status: 'Proposed' + ) + end + + it 'resolves package MeasureID references against parsed measure metadata' do + # Set Up + fixture = File.join('spec', 'files', 'v2.7.0', 'building_151.xml') + doc = load_fixture_doc('v2.7.0', 'building_151.xml') + expected_output = load_fixture_json('v2.7.0', File.join('expected', 'building_151_scenario1_resolved_measures.json')) + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + measures = buidingsync_reader.get_measures + scenario = buidingsync_reader.get_package_measure_scenarios.find { |package_scenario| package_scenario[:scenario_id] == 'Scenario1' } + parsed_output = { + fixture: fixture, + measure_count: measures.length, + scenario: scenario, + resolved_measures: scenario[:measure_ids].map { |measure_id| measures[measure_id] } + } + + # Assert + expect(JSON.parse(JSON.generate(parsed_output))).to eq expected_output + end + + it 'uses the document namespace prefix when indexing measures' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'building_151_n1.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + measure = buidingsync_reader.get_measures['Measure1'] + + # Assert + expect(measure).to include( + technology_category_element_name: 'LightingImprovements', + measure_name: 'Retrofit with light emitting diode technologies' + ) + end + + it 'returns an empty hash when no measures 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_measures).to eq({}) + end + + it 'extracts measure-owned savings metadata when present' 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) + measure = buidingsync_reader.get_measures['MeasureType-70023838998340'] + + # Assert + expect(measure).to include( + measure_id: 'MeasureType-70023838998340', + system_category_affected: 'Cooking', + technology_category_element_name: 'FutureOtherECMs', + measure_name: 'Other', + linked_premises_idrefs: ['BuildingType-70023826271140'], + useful_life: 50.0, + measure_total_first_cost: 75242.0, + om_cost_annual_savings: 260.0 + ) + end + + it 'keeps incomplete measure metadata nil-safe for later warning handling' do + # Set Up + doc = load_fixture_doc('v2.7.0', 'Golden Test File.xml') + + # Action + buidingsync_reader = BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1) + measure = buidingsync_reader.get_measures['Building1RemovePV'] + + # Assert + expect(measure).to include( + measure_id: 'Building1RemovePV', + system_category_affected: nil, + technology_category_element_name: nil, + measure_name: nil, + custom_measure_name: nil, + linked_premises_idrefs: ['Building1'], + mv_cost: nil, + useful_life: nil, + measure_total_first_cost: nil, + measure_installation_cost: nil, + measure_material_cost: nil, + om_cost_annual_savings: nil, + implementation_status: nil + ) + 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