Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions docs/scenario_workflow_translation_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ 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 | 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. |
| 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. |
Expand Down Expand Up @@ -228,6 +228,26 @@ Keep detailed rationale in the relevant PR description or code review thread. Ke
- 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.
Expand Down
199 changes: 196 additions & 3 deletions lib/BOSS/buildingsync_reader/buildingsync_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,33 @@ 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
Expand Down Expand Up @@ -328,6 +355,162 @@ def get_total_weighted_average_load

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)

Expand Down Expand Up @@ -365,15 +548,25 @@ 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 _linked_premises_idrefs(scenario_xml)
linked_premises_xml = scenario_xml.elements["#{@ns}LinkedPremises"]
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading
Loading