diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f99c32d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,109 @@ +# GitHub Copilot Instructions for BOSS + +These instructions apply to AI-assisted work in this repository. Keep changes small, reviewable, and grounded in the plan documents and tests. + +## Repository Context + +BOSS translates BuildingSync XML into OpenStudio workflows. Current production behavior is baseline-only: write `baseline/in.osw`, then run that workflow. Preserve that behavior unless the task explicitly changes it. + +Important project context lives in: + +- `README.md` — setup, CLI use, current baseline workflow, and test commands. +- `docs/scenario_workflow_translation_plan.md` — source of truth for the scenario workflow translation roadmap. +- `spec/files/v2.7.0/` — BuildingSync fixtures used by unit and integration tests. +- `spec/tests/` — existing RSpec coverage patterns. + +## Related Repositories and Dependencies + +BOSS sits between BuildingSync XML data and OpenStudio workflow execution. Sibling repositories can provide context, but keep implementation changes in BOSS unless the user asks for cross-repo edits. + +- `../schema` — BuildingSync schema source and examples. Use it to verify XML structure, element names, `ScenarioType`, `PackageOfMeasures`, `Measure`, `MeasureID`, and schema-version behavior. Do not edit it unless the task asks for schema work. +- `../BuildingSync-gem` — legacy Ruby translator. Use it for workflow concepts and mapping ideas, but verify every behavior against current BOSS dependencies and BuildingSync v2.7 fixtures. +- `../buildingsync-measures-gem` — related BuildingSync/OpenStudio measure work. Use it as reference for measure patterns. +- `../openstudio-standards` — OpenStudio standards logic used by the `openstudio-standards` gem. Do not edit it from BOSS unless the issue is proven upstream and the user asks for that change. +- OpenStudio measure gems declared in `BOSS.gemspec` — `openstudio-common-measures`, `openstudio-ee`, `openstudio-extension`, `openstudio-model-articulation`, and `openstudio-standards`. Verify scenario mapping measure names and arguments against the installed versions used by BOSS. + +Rule of thumb: inspect sibling repos freely, but keep BOSS PRs scoped to BOSS files. If a fix belongs upstream, record it as follow-up work. + +## Scenario Workflow Translation Work + +For work related to BuildingSync `PackageOfMeasures` scenario translation: + +1. Read `docs/scenario_workflow_translation_plan.md` before editing code. +2. Check the git state and recent commits to determine which PR steps have already landed. +3. Start with the first roadmap step whose acceptance criteria are not satisfied. +4. Keep changes scoped to that PR step unless a small supporting edit is necessary to make the step coherent. +5. Treat each step's acceptance criteria as done and its avoid list as merge blockers. +6. Preserve existing baseline-only API, CLI behavior, output layout, and integration tests unless the current PR step explicitly changes them. +7. If implementation decisions change the roadmap, update `docs/scenario_workflow_translation_plan.md` in the same PR. + +Do not jump ahead because nearby code is tempting. If you find a later concern, record it in the plan or final notes and keep the current PR focused. + +## Scope Discipline + +Before opening or finalizing a PR: + +- Review every changed file and drop exploratory edits that are not needed. +- Keep generated outputs, local run artifacts, and unrelated formatting churn out of the PR. +- Prefer existing project patterns over new abstractions unless the new abstraction clearly removes repeated complexity. +- If a change is documentation-only, say so and do not run unnecessary simulation tests. + +## Tests and Verification + +Whenever you make code changes, decide whether the change is unit-testable. If it is, add or update focused tests in the same PR. + +Use the narrowest useful command first, then broaden as risk increases: + +```bash +bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb +bundle exec rspec spec/tests/unit/scenario_measure_mapper_spec.rb +bundle exec rspec spec/tests/integration/write_and_run_osws_spec.rb +bundle exec rake +``` + +If a test cannot be run locally because dependencies, OpenStudio, weather files, or runtime are unavailable, say so and name the command that should be run later. + +## Ruby and Style Checks + +For Ruby code changes, run project commands through the bundle when possible: + +```bash +bundle exec rubocop +``` + +If RuboCop or the bundle is unavailable locally, do not guess. Say what failed and what the reviewer should run after dependencies are available. + +## Documentation and Comments + +Write for maintainers first. + +- Keep comments short and focused on non-obvious behavior. +- Do not narrate what the next line of code already says. +- Put roadmap, rationale, and cross-PR history in `docs/scenario_workflow_translation_plan.md`, not source comments. +- Keep README updates matched to implemented behavior; do not document future result writeback or mapping coverage until it exists. + +## Pull Request Description Template + +Use a short, plain-language PR body: + +```md +## Why do we need this PR + + +## Core change explanation + + +## Which files are affected +- `path/to/file_or_folder` + +## What kind of tests are done +- +``` + +Optional `Out of scope` or `Follow-up work` sections are useful when they prevent reviewer confusion. Keep long reasoning in the plan doc instead of the PR body. + +## CI and Merge Hygiene + +- Treat green local tests as necessary but not always sufficient; CI can still catch clean-environment issues. +- If a workflow fails, inspect the failing job logs before suggesting fixes. +- When a scenario workflow PR changes the roadmap or PR boundaries, update `docs/scenario_workflow_translation_plan.md` so future sessions inherit the current truth. \ No newline at end of file diff --git a/docs/scenario_workflow_translation_plan.md b/docs/scenario_workflow_translation_plan.md new file mode 100644 index 0000000..5017e89 --- /dev/null +++ b/docs/scenario_workflow_translation_plan.md @@ -0,0 +1,346 @@ +# Scenario Workflow Translation Plan + +This plan tracks the work needed for BOSS to translate BuildingSync package scenarios into OpenStudio workflows. PRs 1-4 are covered by the current PR. Future PRs should stay small and follow the roadmap below. + +## Goal + +BOSS should keep writing and running the baseline workflow it supports today, then add scenario workflows for BuildingSync `PackageOfMeasures` scenarios. + +The first scenario release should: + +- Find package scenarios under `Reports/Report/Scenarios/Scenario/ScenarioType/PackageOfMeasures`. +- Resolve each package `MeasureID` against `Facilities/Facility/Measures/Measure`. +- Map supported BuildingSync measures to OpenStudio measure steps with a BOSS-owned JSON map. +- Write `baseline/in.osw` plus one scenario OSW for each package scenario with at least one mapped measure. +- Run baseline and scenario OSWs through `OpenStudio::Extension::Runner`. +- Warn about skipped or unmapped measures without failing the whole translation. + +## Out Of Scope For The First Release + +The first release should not: + +- Write simulation results back into BuildingSync XML. +- Add full multi-facility or multi-site selection. +- Introspect every OpenStudio measure argument at runtime. +- Treat reported savings or costs as simulation inputs unless a mapping explicitly says to do so. +- Require user-supplied mapping files. + +## Current BOSS Anchors + +- Baseline writing starts in `BOSS::Boss.write_baseline_osw` in `lib/BOSS/boss.rb`. +- Baseline execution starts in `BOSS::Boss.run_baseline_osw` in `lib/BOSS/boss.rb`. +- BuildingSync XML parsing is in `BOSS::BuildingSyncReader` in `lib/BOSS/buildingsync_reader/buildingsync_reader.rb`. +- Baseline OpenStudio steps are assembled in `lib/BOSS/osw_arg_populator.rb`. +- The CLI is Thor-based in `lib/boss_cli.rb`. +- Baseline integration tests live in `spec/tests/integration/write_and_run_osws_spec.rb`. + +## BuildingSync Data To Read + +For now, scenario translation should use the first facility, matching the current BOSS assumptions. + +Read scenarios from: + +```text +BuildingSync/Facilities/Facility/Reports/Report/Scenarios/Scenario +``` + +Read package data from: + +```text +Scenario/ScenarioType/PackageOfMeasures +``` + +Read measures from: + +```text +BuildingSync/Facilities/Facility/Measures/Measure +``` + +Each package scenario should expose: + +- Scenario ID, name, temporal status, and source report ID. +- Package ID and reference case ID when present. +- Package `MeasureID` references. +- Linked premises references when present. + +Each measure should expose: + +- Measure ID. +- `SystemCategoryAffected`. +- Technology category element name. +- `MeasureName` and `CustomMeasureName`. +- Linked premises references. +- Useful cost and savings metadata. +- Implementation status. + +## Output Layout + +Keep the existing baseline path unchanged: + +```text +/baseline/in.osw +``` + +Write scenario workflows here: + +```text +/scenarios//in.osw +``` + +Each scenario OSW should deep-copy the baseline workflow and append mapped retrofit steps. This keeps every scenario runnable on its own and makes parallel runs possible later. + +## Mapping Rules + +Mappings should live in: + +```text +lib/BOSS/scenario_measure_map.json +``` + +Lookup order: + +1. `SystemCategoryAffected` plus `MeasureName`. +2. Technology category element plus `MeasureName`, when `SystemCategoryAffected` is missing or not useful. + +The legacy `../BuildingSync-gem` mapping can be used as background, but do not copy it directly. Every measure directory and argument added to BOSS must be checked against the OpenStudio gems declared in `BOSS.gemspec`. + +Conditional arguments should be data-driven. Use context from the reader, such as building type or principal HVAC system type, instead of per-measure Ruby condition chains. + +## Legacy BuildingSync-gem Reference + +Use `../BuildingSync-gem` only as a reference. It targets older BuildingSync and OpenStudio versions, so stale assumptions are expected. + +Useful files: + +- `../BuildingSync-gem/lib/buildingsync/report.rb` for report and scenario grouping. +- `../BuildingSync-gem/lib/buildingsync/scenario.rb` for scenario measure references and OSW ownership. +- `../BuildingSync-gem/lib/buildingsync/model_articulation/measure.rb` for the measure wrapper idea. +- `../BuildingSync-gem/lib/buildingsync/makers/workflow_maker.rb` for the write/run workflow shape. +- `../BuildingSync-gem/lib/buildingsync/makers/workflow_maker.json` for possible mapping seeds. + +Keep result writeback from the legacy gem out of this first release unless this plan is updated. + +## Warning Rules + +Warnings should be structured data and should also be logged clearly. These cases are warnings, not fatal errors: + +- A package has no `MeasureID` references. +- A package references an unknown measure ID. +- A measure is missing `SystemCategoryAffected`. +- A measure is missing a usable technology category. +- A measure is missing `MeasureName`. +- A measure cannot be mapped to an OpenStudio step. + +If a scenario has at least one mapped measure, write its OSW and report warnings for skipped measures. If a scenario has no mapped measures, skip its OSW and report why. + +## Roadmap + +Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a PR lands. + +### PR 1: Guiding Plan Doc + +- Status: Done +- Delivered by: Current PR #5 branch +- Scope: Add this plan and set the first-release boundaries. +- Done when: The plan explains PR order, acceptance gates, mapping ownership, warnings, scenario selection, and result-writeback scope. +- Avoid: Leaving scope or ownership ambiguous. + +### PR 2: Scenario Data Model And Discovery + +- Status: Done +- Delivered by: Current PR #5 branch +- Scope: Add reader-returned structures for first-facility report-level package scenarios. +- Done when: Unit tests show `building_151.xml` discovers baseline plus package scenarios and existing reader behavior remains unchanged. +- Avoid: Hardcoded namespaces, wrong scenario paths, or reader regressions. + +### PR 3: Measure Index + +- Status: Done +- Delivered by: Current PR #5 branch +- Scope: Index first-facility measures by ID and extract category, name, linked premises, cost/savings, and implementation metadata. +- Done when: Tests resolve package `MeasureID` references in `building_151.xml` to parsed measure metadata. +- Avoid: Silently dropping unresolved refs or crashing on measures without `TechnologyCategories`. + +### PR 4: Parser Warning Contract + +- Status: Done +- Delivered by: Current PR #5 branch +- Scope: Add structured parser warnings for missing IDs, unresolved refs, empty packages, missing names, missing categories, and packages with no usable measures. +- Done when: Tests cover warning cases using `BuildingEQ-1.0.0.xml`, `Golden Test File.xml`, and no-measure fixtures. +- Avoid: Warnings that only print to stdout or malformed package data that aborts all discovery. + +### PR 5: Initial Mapping JSON + +- Status: Planned +- Delivered by: - +- Scope: Add `lib/BOSS/scenario_measure_map.json` with a small verified mapping set from `building_151.xml`. +- Done when: The JSON is valid and each entry has a source category/name, target `measure_dir_name`, and arguments. +- Avoid: Copying legacy mappings without checking current measure directories and arguments. + +### PR 6: Basic `ScenarioMeasureMapper` + +- Status: Planned +- Delivered by: - +- Scope: Load the JSON, normalize lookup keys, and map one parsed BuildingSync measure to OpenStudio step specs by `SystemCategoryAffected` plus `MeasureName`. +- Done when: Unit tests return expected steps and structured unmapped warnings. +- Avoid: Mutating reader data, raising on unmapped measures, or hardcoding mapping rules outside JSON. + +### PR 7: Technology Category Fallback + +- Status: Planned +- Delivered by: - +- Scope: Add fallback lookup by technology category plus `MeasureName`. +- Done when: Tests prove fallback works when `SystemCategoryAffected` is missing and normal lookup priority still wins. +- Avoid: Changing normal category/name lookup behavior. + +### PR 8: Conditional Mapping Rules + +- Status: Planned +- Delivered by: - +- Scope: Add data-driven conditional arguments for building type and principal HVAC/system context. +- Done when: Tests prove conditions include and exclude arguments predictably. +- Avoid: Per-measure Ruby condition chains. + +### PR 9: Structured Mapping Results + +- Status: Planned +- Delivered by: - +- Scope: Return mapped steps, skipped measure IDs, warnings, and scenario write/skip status. +- Done when: Scenarios with mapped measures are writable and zero-mapped scenarios are skipped with clear warnings. +- Avoid: Requiring callers to infer status from logs or empty arrays. + +### PR 10: Baseline OSW Builder Refactor + +- Status: Planned +- Delivered by: - +- Scope: Move baseline writing into an internal builder while preserving public behavior. +- Done when: Existing baseline integration tests pass and generated baseline steps are unchanged. +- Avoid: Unexpected API, CLI, or baseline output changes. + +### PR 11: Scenario OSW Writer API + +- Status: Planned +- Delivered by: - +- Scope: Add an API that writes baseline plus scenario OSWs for package scenarios with at least one mapped measure. +- Done when: Generation tests show expected directories and skipped-scenario reporting. +- Avoid: Writing OSWs for zero-mapped scenarios or changing baseline output. + +### PR 12: Scenario Workflow Step Assembly + +- Status: Planned +- Delivered by: - +- Scope: Deep-copy baseline OSW and append mapped retrofit steps in deterministic order. +- Done when: OSW tests assert baseline steps plus expected mapped measure steps and arguments. +- Avoid: Mutating the baseline OSW, omitting baseline creation steps, or using nondeterministic step order. + +### PR 13: Generic OSW Step Helpers + +- Status: Planned +- Delivered by: - +- Scope: Centralize mapped step appending and `OpenStudio::Extension.set_measure_argument` use. +- Done when: Tests prove boolean, numeric, and string arguments serialize correctly. +- Avoid: Broad rewrites of baseline populator methods or unintended baseline JSON changes. + +### PR 14: Generalized Runner + +- Status: Planned +- Delivered by: - +- Scope: Run `baseline/in.osw` and `scenarios/**/in.osw` while keeping `run_baseline_osw` intact. +- Done when: Tests or smoke runs show the correct OSW list and baseline-only compatibility. +- Avoid: Running skipped or missing OSWs, or removing baseline-only behavior. + +### PR 15: CLI Command + +- Status: Planned +- Delivered by: - +- Scope: Add a Thor command for baseline plus package scenarios using existing output, weather, standard, and run options. +- Done when: CLI help documents the command, write-only mode creates OSWs, and run mode invokes the generalized runner. +- Avoid: Changing existing `write_baseline_osw` or `run_osw` behavior. + +### PR 16: User Documentation + +- Status: Planned +- Delivered by: - +- Scope: Update README or user docs with scenario paths, output layout, mapping policy, warning behavior, and CLI examples. +- Done when: Docs match implemented command names and options and include one known fixture example. +- Avoid: Promising result writeback or unsupported mapping coverage. + +### PR 17: Mapper Unit Test Expansion + +- Status: Planned +- Delivered by: - +- Scope: Add focused mapper tests for mapped, conditional, fallback, and unmapped cases. +- Done when: Mapper behavior is covered without relying only on slow integration tests. +- Avoid: Leaving important mapper paths covered only by integration tests. + +### PR 18: Scenario Generation And Integration Coverage + +- Status: Planned +- Delivered by: - +- Scope: Add OSW generation tests and focused write/run integration for a small known-translatable scenario subset. +- Done when: Focused commands pass and supported scenario `out.osw` files report `completed_status: Success`. +- Avoid: Running every package scenario by default, making CI too slow, or hiding failures behind broad skips. + +## Progress Tracking + +Allowed status values: + +- `Planned`: Not started. +- `In Progress`: Actively being implemented. +- `Done`: Delivered and `Done when` criteria are satisfied. +- `Blocked`: Waiting on a decision, dependency, or external fix. +- `Closed`: No longer needed; include the reason. + +When a PR step changes: + +1. Update its `Status`. +2. Update `Delivered by` with the PR number, commit SHA, or short branch note. +3. Confirm the `Done when` criteria still match the implementation. +4. Update later PRs if scope or order changed. + +At the start of a future session, read this roadmap, then verify it against `git log` and the current file tree. If it is stale, fix the roadmap before starting new implementation work. + +## Handoff Notes + +Use this section for stable facts future PRs need. Keep entries short and tied to a PR number. + +### PR 2: Scenario Data Model And Discovery + +- Status: Done +- Delivered by: Current PR #5 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. +- 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` - 19 examples, 1 unrelated Windows path-quoting failure; 18 baseline runs completed successfully. +- Follow-up: PR 3 should add facility measure indexing. Warning contracts stay deferred to PR 4. + +### PR 3: Measure Index + +- Status: Done +- Delivered by: Current PR #5 branch +- Handoff: `BOSS::BuildingSyncReader#get_measures` returns first-facility measures as a hash keyed by `Measure/@ID`. Values include `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 fields return `nil` or `[]`. Missing `Measures` returns `{}`. Measures without `TechnologyCategories` remain parseable. +- 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` - no offenses. +- Follow-up: PR 4 should report unresolved `MeasureID` references and incomplete measure metadata without changing reader return shapes unless needed for the warning contract. + +### PR 4: Parser Warning Contract + +- Status: Done +- Delivered by: Current PR #5 branch +- Handoff: `BOSS::BuildingSyncReader#get_parser_warnings` returns symbol-keyed warning hashes with `code`, `severity`, `message`, and relevant scenario/package/measure context. Existing scenario and measure reader return shapes remain unchanged. +- Handoff: Warning codes cover missing scenario/package/measure IDs, missing `MeasureID` references, unresolved measure references, packages without `MeasureID` references, 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` - still reports existing style debt and new-cop configuration warnings. +- Follow-up: PR 5 can add mapping JSON. Mapping-specific warnings remain PR 6/PR 9 scope. + +## Verification Commands + +Use the narrowest command that proves the current PR. Broaden only when the change justifies it. + +```bash +bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb +bundle exec rspec spec/tests/unit/scenario_measure_mapper_spec.rb +bundle exec rspec spec/tests/integration/write_and_run_osws_spec.rb +bundle exec rake +``` 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 @@ -23,7 +36,434 @@ def wrap_in_site(xml) XML end +def wrap_in_facility(xml) + <<~XML + + + + + + + + + + + + #{xml} + + + + 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) + + + + + Package A + Post retrofit + + + + + + + + + + + + + + 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) + + + 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