From 5949d473c08a1230ce22bcff3fbd18868c53f1fa Mon Sep 17 00:00:00 2001 From: yanz571 Date: Tue, 13 Oct 2026 14:49:10 -0700 Subject: [PATCH 1/2] Add renewable energy project operations Implements renewable energy operations module for managing on-site renewable systems and off-site procurement, following the project_exterior_lighting_operations pattern. Features: - set_renewable_exception_in_project - set renewable exception on a project - update_renewable_in_project - update scalar fields on renewable singleton - CRUD operations for on-site renewable systems (add/update/remove/get_keys) - CRUD operations for offsite renewable procurement (add/update/remove/get_keys) - Support for ASHRAE 90.1 2022, IECC 2024, and IECC 2024 Appendix C/F codes - Auto-generated descriptions with prefixes for renewable systems and procurements - Placeholder methods for update_renewable_area (pending comcheck-cdk endpoint) Changes: - Added project_renewable_operations module with 10 public functions - Added RenewableSystem and OffsiteRenewableProcurement to MODEL_TO_ID_INFO - Added renewable_constants.py with supported codes and default templates - Added project_utilities.py guards for renewable operations - Updated all exports and introspection modules - Added comprehensive test coverage (32 tests, all passing) - Updated documentation and AI skill references - Removed renewable from unsupported attributes list - Added example renewable_operations.py Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 + comcheck_api/__init__.py | 2 + comcheck_api/ai/skill/SKILL.md | 65 ++- comcheck_api/ai/skill/reference/operations.md | 35 +- .../ai/skill/scripts/validate_code.py | 8 +- comcheck_api/api/api_services.py | 29 ++ comcheck_api/client/comcheck_client.py | 31 ++ comcheck_api/constants/renewable_constants.py | 34 ++ comcheck_api/defaults.py | 26 + comcheck_api/introspection.py | 2 + comcheck_api/managers/data_manager.py | 10 +- comcheck_api/project_operations/__init__.py | 2 + .../project_renewable_operations.py | 395 +++++++++++++++ comcheck_api/utilities/project_utilities.py | 79 +++ docs_site/api/operations/renewable-energy.md | 187 ++++++- .../renewable_operations.py | 169 +++++++ .../test_renewable_operations.py | 462 ++++++++++++++++++ tests/test_renewable_area_placeholder.py | 29 ++ 18 files changed, 1561 insertions(+), 21 deletions(-) create mode 100644 comcheck_api/constants/renewable_constants.py create mode 100644 comcheck_api/project_operations/project_renewable_operations.py create mode 100644 examples/project_operations/renewable_operations.py create mode 100644 tests/project_operation_tests/test_renewable_operations.py create mode 100644 tests/test_renewable_area_placeholder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83729c8..3646572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Added +- Renewable energy operations module (`project_renewable_operations`) for managing on-site renewable systems and off-site procurement +- `set_renewable_exception_in_project` — set the renewable exception on a project +- `update_renewable_in_project` — update scalar fields on the renewable singleton +- `add_renewable_system_to_project` / `update_renewable_system_in_project` / `remove_renewable_system_from_project` — manage on-site renewable systems +- `get_renewable_system_keys_from_project` — list all renewable systems in a project +- `add_offsite_renewable_procurement_to_project` / `update_offsite_renewable_procurement_in_project` / `remove_offsite_renewable_procurement_from_project` — manage offsite renewable procurement +- `get_offsite_renewable_procurement_keys_from_project` — list all offsite renewable procurements +- Renewable operations support for ASHRAE 90.1 2022, IECC 2024, and IECC 2024 Appendix C/F energy codes +- `get_default_renewable_system_template` and `get_default_offsite_renewable_procurement_template` default templates +- `COMcheckClient.update_renewable_area` and `COMCheckApiService.area_for_renewable` placeholder methods (raise `NotImplementedError` until endpoint is available) +- `RenewableSystem` and `OffsiteRenewableProcurement` added to `MODEL_TO_ID_INFO` with auto-generated descriptions + + + ## [2.0.1] - 2026-09-18 Reverts the enum renames introduced in 2.0.0. If you updated code to use the diff --git a/comcheck_api/__init__.py b/comcheck_api/__init__.py index 24cbd19..d618a4c 100644 --- a/comcheck_api/__init__.py +++ b/comcheck_api/__init__.py @@ -60,6 +60,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_renewable_operations, ) # Introspection helpers @@ -91,6 +92,7 @@ "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", + "project_renewable_operations", # Introspection "list_operations", "lookup_type", diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 8b95078..e14e6df 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -30,7 +30,7 @@ Triggers: `envelope`, `lighting` (which contains `wholeBldgUse[]` — the building areas), `hvac`, `renewable`, and `control` (energy code). No `Project`/`Control` PascalCase aliases exist. - The fields `hvac`, `renewable`, and the shared `fixtureSchedule[]` + The fields `hvac` and the shared `fixtureSchedule[]` exist on the model but have **no operation functions** — leave them at template defaults. All other mutable areas have dedicated modules. - **Operation modules (functional)**: all mutations go through free @@ -39,6 +39,7 @@ Triggers: - `project_envelope_operations` — roofs, walls, floors, windows, doors, skylights, thermal bridges - `project_interior_lighting_operations` — `ActivityUse` items, aliased as `InteriorSpace` (interior lighting spaces + fixtures) - `project_exterior_lighting_operations` — `ExteriorUse` items, aliased as `ExteriorArea`, + zone type + - `project_renewable_operations` — renewable exception, on-site systems, off-site procurement (supported codes only) - **Envelope items attach to a building-area key**: every `add_*_to_project` envelope function takes `(project, building_area_key, new_component)`. Look up the key @@ -153,8 +154,8 @@ print(result["performanceRating"]) bypass the validation logic in the operation modules. Always go through `project_envelope_operations` and `project_building_area_operations` instead. -- Don't add, update, or remove `fixtureSchedule[]`, HVAC/mechanical, or - renewable-energy components — no operations exist for them yet. +- Don't add, update, or remove `fixtureSchedule[]` or HVAC/mechanical + components — no operations exist for them yet. The `COMcheckClient` user methods (`list_projects`, `get_project`, `update_project`, `update_uvalues`, `start_run_simulation`, `get_simulation_status`, `get_simulation_result`, `set_api_key`) @@ -164,9 +165,9 @@ print(result["performanceRating"]) methods (`calculate_interior_space_allowed_wattage`, `calculate_interior_spaces_allowed_wattage`). If asked for an unsupported mutation area, tell the user it's not implemented and - offer building-area / envelope / lighting / simulation instead. + offer building-area / envelope / lighting / renewable / simulation instead. `comcheck_api.list_operations()` enumerates the `building_area`, - `envelope`, `interior_lighting`, and `exterior_lighting` groups. + `envelope`, `interior_lighting`, `exterior_lighting`, and `renewable` groups. ## Common patterns @@ -376,6 +377,60 @@ report = client.generate_report( library, so it returns metadata and lets the caller decide (`webbrowser.open(report["url"])`). +### Adding renewable energy systems + +Renewable operations require a supported energy code (ASHRAE 90.1 2022, +IECC 2024, IECC 2024 Appendix C/F, or state codes based on them). Exception +and on-site systems coexist — setting an exception doesn't preclude adding +renewable systems. **Confirm with the user before adding off-site +procurement** (not always present on projects). + +```python +from comcheck_api import project_renewable_operations as rn_ops +from comcheck_api.defaults import ( + get_default_renewable_system_template, + get_default_offsite_renewable_procurement_template, +) +from comcheck_api.types import ( + EnergyCodeOptions, + RenewableExceptionOptions, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, + OffsiteRenewableProcurementType, +) + +# Ensure the project uses a supported code +if project.control.code not in { + EnergyCodeOptions.CEZ_90_1_2022, + EnergyCodeOptions.CEZ_IECC2024, + EnergyCodeOptions.CEZ_IECC2024_APPXCF, +}: + # Unsupported — all mutating ops raise ValueError + print("Renewable operations are not supported for this energy code") + +# 1. Set a renewable exception (coexists with systems) +project = rn_ops.set_renewable_exception_in_project( + project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_NONE +) + +# 2. Add an on-site renewable system +renewable_system = get_default_renewable_system_template() +renewable_system.description = "Rooftop Solar" +renewable_system.renewableSystemType = RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_SOLAR +renewable_system.renewableSystemCapacity = 50000.0 +renewable_system.renewableSystemCapacityUnit = RenewableSystemCapacityUnitOptions.RENEWABLE_SYSTEM_UNIT_WATT +project = rn_ops.add_renewable_system_to_project(project, renewable_system) + +# 3. Only add off-site procurement if confirmed with the user +# offsite_procurement = get_default_offsite_renewable_procurement_template() +# offsite_procurement.description = "Wind PPA" +# offsite_procurement.offsiteRenewableProcurementType = ( +# OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_PHYSICAL_AGREEMENT +# ) +# offsite_procurement.offsiteRenewableEnergy = 100000.0 # kWh +# project = rn_ops.add_offsite_renewable_procurement_to_project(project, offsite_procurement) +``` + ## Gotchas - **Field names are lowercase camelCase**, not PascalCase. diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index 0a12fca..91de17d 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -1,11 +1,12 @@ # Project Operations Reference -Operation functions are free functions in four modules: +Operation functions are free functions in five modules: - `comcheck_api.project_operations.project_building_area_operations` - `comcheck_api.project_operations.project_envelope_operations` - `comcheck_api.project_operations.project_interior_lighting_operations` - `comcheck_api.project_operations.project_exterior_lighting_operations` +- `comcheck_api.project_operations.project_renewable_operations` Each function takes a `ComBuilding` and a payload, and returns a new `ComBuilding`. Treat them as immutable transformations. @@ -174,6 +175,38 @@ Use `get_default_exterior_area_template()` as a starting point. `areaDescription` is the identifier — it is unique within `exteriorUse[]` and is auto-generated if missing. +## Renewable energy operations + +```python +from comcheck_api import project_renewable_operations as rn_ops +``` + +Renewable energy is managed at the project level through the +`ComBuilding.renewable` singleton. Renewable operations require a supported +energy code (ASHRAE 90.1 2022, IECC 2024, IECC 2024 Appendix C/F, or state +codes based on them). All mutating operations raise `ValueError` for +unsupported codes. Exception and on-site systems coexist — setting an +exception does not preclude adding renewable systems. **Confirm with the user +before adding off-site procurement** (not always present on projects). + +| Function | Purpose | +|---|---| +| `set_renewable_exception_in_project(project, renewable_exception)` | Set `renewable.renewableException`. Raises `ValueError` for `NoneType_None`, `TypeError` for non-enum values. | +| `update_renewable_in_project(project, updates)` | Update scalar fields on the renewable singleton. Raises `ValueError` for engine-calculated, API-calculated, or list fields. | +| `add_renewable_system_to_project(project, new_renewable_system)` | Add an on-site `RenewableSystem`. | +| `update_renewable_system_in_project(project, renewable_system_description, updates)` | Update a `RenewableSystem` by its `description`. | +| `remove_renewable_system_from_project(project, renewable_system_description)` | Remove a `RenewableSystem` by its `description`. | +| `get_renewable_system_keys_from_project(project)` | List `[{description, renewableSystemType, renewableSystemCapacity, renewableSystemCapacityUnit}, …]` for the project. Works for any code. | +| `add_offsite_renewable_procurement_to_project(project, new_procurement)` | Add an `OffsiteRenewableProcurement`. **Confirm with user first.** | +| `update_offsite_renewable_procurement_in_project(project, procurement_description, updates)` | Update an `OffsiteRenewableProcurement` by its `description`. | +| `remove_offsite_renewable_procurement_from_project(project, procurement_description)` | Remove an `OffsiteRenewableProcurement` by its `description`. | +| `get_offsite_renewable_procurement_keys_from_project(project)` | List `[{description, offsiteRenewableProcurementType, offsiteRenewableEnergy}, …]` for the project. Works for any code. | + +Use `get_default_renewable_system_template()` and +`get_default_offsite_renewable_procurement_template()` as starting points. +`description` is the identifier — it is unique within each list and is +auto-generated if missing. + ## U-value calculation requires a construction type When `update_uvalues` (or `start_run_simulation`) recalculates assembly diff --git a/comcheck_api/ai/skill/scripts/validate_code.py b/comcheck_api/ai/skill/scripts/validate_code.py index cd40890..317fb63 100644 --- a/comcheck_api/ai/skill/scripts/validate_code.py +++ b/comcheck_api/ai/skill/scripts/validate_code.py @@ -28,11 +28,11 @@ def _read_input(arg: str) -> str: return Path(arg).read_text() -UNSUPPORTED_PROJECT_ATTRS = {"hvac", "renewable"} +UNSUPPORTED_PROJECT_ATTRS = {"hvac"} # `wholeBldgUse` (building areas + interior lighting), `activityUse` (interior -# lighting spaces), `exteriorUse` (exterior lighting), and -# `exteriorLightingZoneType` all have operation modules. Only `fixtureSchedule` -# has no operations yet. +# lighting spaces), `exteriorUse` (exterior lighting), `exteriorLightingZoneType`, +# and `renewable` (renewable energy systems and procurement) all have operation +# modules. Only `hvac` and `fixtureSchedule` have no operations yet. UNSUPPORTED_LIGHTING_ATTRS = { "fixtureSchedule", } diff --git a/comcheck_api/api/api_services.py b/comcheck_api/api/api_services.py index d0832e0..a2a6989 100644 --- a/comcheck_api/api/api_services.py +++ b/comcheck_api/api/api_services.py @@ -416,6 +416,35 @@ def get_simulation_result(self, session_id: str) -> SimulationResultResponse: except Exception as error: self._handle_api_error(error) + def area_for_renewable( + self, project_data: Dict[str, Any], energy_code: str + ) -> Dict[str, Any]: + """Calculate renewable area metrics (largestThreeFloorArea, roofAreaForRenewable). + + .. warning:: + Not yet implemented — the ``area-for-renewable`` endpoint is pending + in the external ``comcheck-cdk`` repository. + + Args: + project_data: The project data (or subset) to send in the request body. + energy_code: The energy code for the API endpoint path. + + Returns: + API response data as dictionary with ``largestThreeFloorArea`` and + ``roofAreaForRenewable`` keys. + + Raises: + NotImplementedError: Always — the endpoint is not yet available. + """ + # TODO: Implement when the area-for-renewable endpoint is available in comcheck-cdk. + # The results should be written to project.renewable.largestThreeFloorArea + # and project.renewable.roofAreaForRenewable. + raise NotImplementedError( + "The area-for-renewable endpoint is pending implementation in comcheck-cdk. " + "Results will be written to project.renewable.largestThreeFloorArea and " + "project.renewable.roofAreaForRenewable once available." + ) + def close(self) -> None: """Close the HTTP client connection.""" if self._client is not None: diff --git a/comcheck_api/client/comcheck_client.py b/comcheck_api/client/comcheck_client.py index eb03235..2a07399 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -288,6 +288,37 @@ def update_uvalues(self, project: ComBuilding) -> ComBuilding: return project + def update_renewable_area(self, project: ComBuilding) -> ComBuilding: + """Calculate renewable area metrics and update the project in place. + + .. warning:: + Not yet implemented — the ``area-for-renewable`` endpoint is pending + in the external ``comcheck-cdk`` repository. + + Sends the project (or the needed subset) to the area-for-renewable + endpoint and writes the returned ``largestThreeFloorArea`` and + ``roofAreaForRenewable`` values back onto ``project.renewable``. + + Args: + project: The project whose renewable area metrics should be refreshed. + + Returns: + The same ``project`` instance, with renewable area metrics updated. + + Raises: + NotImplementedError: Always — the endpoint is not yet available. + """ + # TODO: Implement when the area-for-renewable endpoint is available in comcheck-cdk. + # This should mirror the pattern of update_uvalues: POST project data to + # /{energy_code}/renewable/area (or similar path), extract the results, and + # write them back to project.renewable.largestThreeFloorArea and + # project.renewable.roofAreaForRenewable. + raise NotImplementedError( + "The area-for-renewable endpoint is pending implementation in comcheck-cdk. " + "This method will calculate largestThreeFloorArea and roofAreaForRenewable " + "from the project envelope/geometry once the endpoint is available." + ) + def calculate_interior_space_allowed_wattage( self, interior_space: InteriorSpace, energy_code: str ) -> Any: diff --git a/comcheck_api/constants/renewable_constants.py b/comcheck_api/constants/renewable_constants.py new file mode 100644 index 0000000..4049adb --- /dev/null +++ b/comcheck_api/constants/renewable_constants.py @@ -0,0 +1,34 @@ +"""Renewable energy constants for COMcheck projects.""" + +from comcheck_api.types.core_types import ( + EnergyCodeOptions, + OffsiteRenewableProcurement, + OffsiteRenewableProcurementType, + RenewableSystem, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, +) + +# Supported energy codes for renewable operations (ASHRAE 90.1 2022, IECC 2024, and state codes based on them) +RENEWABLE_SUPPORTED_CODES = frozenset( + { + EnergyCodeOptions.CEZ_90_1_2022, + EnergyCodeOptions.CEZ_IECC2024, + EnergyCodeOptions.CEZ_IECC2024_APPXCF, + } +) + +DEFAULT_RENEWABLE_SYSTEM: RenewableSystem = RenewableSystem( + description="Solar PV", + renewableSystemType=RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_SOLAR, + renewableSystemCapacity=0.0, + renewableSystemCapacityUnit=RenewableSystemCapacityUnitOptions.RENEWABLE_SYSTEM_UNIT_WATT, +) + +DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT: OffsiteRenewableProcurement = ( + OffsiteRenewableProcurement( + description="Offsite Procurement", + offsiteRenewableProcurementType=OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_UNSPECIFIED, + offsiteRenewableEnergy=0.0, + ) +) diff --git a/comcheck_api/defaults.py b/comcheck_api/defaults.py index 7ade2e9..04074cc 100644 --- a/comcheck_api/defaults.py +++ b/comcheck_api/defaults.py @@ -11,6 +11,10 @@ from comcheck_api.constants.exterior_lighting_constants import ( DEFAULT_EXTERIOR_AREA, ) +from comcheck_api.constants.renewable_constants import ( + DEFAULT_RENEWABLE_SYSTEM, + DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT, +) from comcheck_api.constants.envelope_constants import ( DEFAULT_AG_WALL, DEFAULT_BG_WALL, @@ -208,3 +212,25 @@ def get_default_fixture_schedule_template(): # TODO: Implement when DEFAULT_FIXTURE_SCHEDULE constant is available # return {**DEFAULT_FIXTURE_SCHEDULE, "scheduleFixtureKey": str(uuid4())} raise NotImplementedError("DEFAULT_FIXTURE_SCHEDULE constant not yet implemented") + + +def get_default_renewable_system_template(): + """Return a deep copy of the default renewable system template. + + Defaults to a Solar PV system with 0.0 capacity in Watts. + + Returns: + A new :class:`~comcheck_api.types.core_types.RenewableSystem` instance. + """ + return copy.deepcopy(DEFAULT_RENEWABLE_SYSTEM) + + +def get_default_offsite_renewable_procurement_template(): + """Return a deep copy of the default offsite renewable procurement template. + + Defaults to an unspecified procurement type with 0.0 kWh annual energy. + + Returns: + A new :class:`~comcheck_api.types.core_types.OffsiteRenewableProcurement` instance. + """ + return copy.deepcopy(DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT) diff --git a/comcheck_api/introspection.py b/comcheck_api/introspection.py index 3e0df6f..8f56b5e 100644 --- a/comcheck_api/introspection.py +++ b/comcheck_api/introspection.py @@ -25,6 +25,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_renewable_operations, ) _OP_MODULES = { @@ -32,6 +33,7 @@ "envelope": project_envelope_operations, "interior_lighting": project_interior_lighting_operations, "exterior_lighting": project_exterior_lighting_operations, + "renewable": project_renewable_operations, } diff --git a/comcheck_api/managers/data_manager.py b/comcheck_api/managers/data_manager.py index 6ce77e7..e280d09 100644 --- a/comcheck_api/managers/data_manager.py +++ b/comcheck_api/managers/data_manager.py @@ -325,9 +325,7 @@ def modify_one(self, id_value: Any, updates: T | dict[str, Any]) -> T: # Validate partial updates by attempting to construct with merged data # This will raise Pydantic ValidationError if types don't align # Get only the model fields to avoid serializing dynamically added methods - original_dict = original.model_dump( - mode="python", by_alias=False - ) + original_dict = original.model_dump(mode="python", by_alias=False) merged = {**original_dict, **updates_dict} try: updated_item = type(original).model_validate(merged) @@ -367,6 +365,8 @@ def get_model_info(model_class: Type[BaseModel]) -> IdInfo | None: Skylight, ThermalBridge, WholeBldgUse, + RenewableSystem, + OffsiteRenewableProcurement, ) MODEL_TO_ID_INFO = { @@ -381,5 +381,9 @@ def get_model_info(model_class: Type[BaseModel]) -> IdInfo | None: Skylight: IdInfo(identifier="assemblyType", id_prefix="Skylight:Skylight"), ThermalBridge: IdInfo(identifier="id", id_prefix=None), WholeBldgUse: IdInfo(identifier="key", id_prefix=None), + RenewableSystem: IdInfo(identifier="description", id_prefix="Renewable System"), + OffsiteRenewableProcurement: IdInfo( + identifier="description", id_prefix="Offsite Procurement" + ), } return MODEL_TO_ID_INFO.get(model_class) diff --git a/comcheck_api/project_operations/__init__.py b/comcheck_api/project_operations/__init__.py index 9de6550..dfb3085 100644 --- a/comcheck_api/project_operations/__init__.py +++ b/comcheck_api/project_operations/__init__.py @@ -5,6 +5,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_renewable_operations, ) __all__ = [ @@ -12,4 +13,5 @@ "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", + "project_renewable_operations", ] diff --git a/comcheck_api/project_operations/project_renewable_operations.py b/comcheck_api/project_operations/project_renewable_operations.py new file mode 100644 index 0000000..5e00278 --- /dev/null +++ b/comcheck_api/project_operations/project_renewable_operations.py @@ -0,0 +1,395 @@ +"""Project Renewable Energy Operations. + +Manages renewable energy systems and off-site procurement at the project level. +The renewable data lives in ``ComBuilding.renewable`` (a required singleton +``Renewable`` object), which carries: + +- ``renewableException`` — the user's exception selection (can coexist with systems) +- Sizing scalars — ``numberOfFloors``, ``largestThreeFloorArea``, ``roofAreaForRenewable``, + ``requiredCapacity``, ``proposedCapacity`` +- ``renewableSystem[]`` — list of on-site ``RenewableSystem`` +- Off-site fields — ``requiredOffsiteRenewableEnergy``, ``proposedOffsiteRenewableEnergy``, + and ``offsiteRenewableProcurement[]`` + +Supported energy codes +---------------------- +Renewable operations require one of the supported energy codes (ASHRAE 90.1 2022, +IECC 2024, or state codes based on them). All mutating operations raise +:exc:`ValueError` for unsupported codes. + +Exception and systems coexistence +---------------------------------- +Setting a renewable exception does **not** preclude adding on-site renewable +systems, and vice versa — the two can coexist on the same project. + +Off-site procurement +-------------------- +Off-site renewable procurement is **not** always present. Before adding any +``offsiteRenewableProcurement[]`` entry, confirm with the user that the project +actually uses off-site procurement. +""" + +import logging +from typing import Any +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.types.core_types import ( + ComBuilding, + Renewable, + RenewableExceptionOptions, + RenewableSystem, + OffsiteRenewableProcurement, +) +from comcheck_api.utilities.project_utilities import ( + _require_renewable_system, + _require_offsite_renewable_procurement, + _require_renewable_supported_code, +) + +logger = logging.getLogger(__name__) + +# Engine-calculated or API-calculated fields that must not be writable via update_renewable_in_project +_ENGINE_CALCULATED_RENEWABLE_FIELDS = frozenset( + { + "requiredCapacity", + "proposedCapacity", + "requiredOffsiteRenewableEnergy", + "proposedOffsiteRenewableEnergy", + "largestThreeFloorArea", + "roofAreaForRenewable", + } +) + +# List fields that must not be writable via update_renewable_in_project (use list operations instead) +_LIST_FIELDS = frozenset( + { + "renewableSystem", + "offsiteRenewableProcurement", + } +) + + +def set_renewable_exception_in_project( + project: ComBuilding, + renewable_exception: RenewableExceptionOptions, +) -> ComBuilding: + """Set the renewable exception on the project. + + On-site renewable systems and an exception can coexist — setting an exception + does not preclude adding ``renewableSystem[]``, and vice versa. + + Args: + project: The project to modify. + renewable_exception: A :class:`~comcheck_api.types.core_types.RenewableExceptionOptions` + value. Must not be ``NoneType_None``. + + Returns: + Updated project with the renewable exception set. + + Raises: + TypeError: If renewable_exception is not a RenewableExceptionOptions member. + ValueError: If renewable_exception is NoneType_None, or if the project's + energy code is not supported for renewable operations. + """ + if not isinstance(renewable_exception, RenewableExceptionOptions): + raise TypeError( + f"renewable_exception must be a RenewableExceptionOptions member, " + f"got {type(renewable_exception).__name__!r}." + ) + if renewable_exception == RenewableExceptionOptions.NoneType_None: + raise ValueError( + "NoneType_None is not a valid renewable exception. " + "Choose a value from RenewableExceptionOptions other than NoneType_None." + ) + + _require_renewable_supported_code(project) + + updated_project = project.model_copy(deep=True) + updated_project.renewable.renewableException = renewable_exception + return updated_project + + +def update_renewable_in_project( + project: ComBuilding, + updates: dict[str, Any], +) -> ComBuilding: + """Update scalar fields on the renewable singleton. + + Only user-writable scalar fields (``numberOfFloors``) may be updated here. + Engine-calculated fields (``requiredCapacity``, ``proposedCapacity``, + ``requiredOffsiteRenewableEnergy``, ``proposedOffsiteRenewableEnergy``), + API-calculated fields (``largestThreeFloorArea``, ``roofAreaForRenewable``), + and list fields (``renewableSystem``, ``offsiteRenewableProcurement``) are + rejected. + + Args: + project: The project to modify. + updates: Partial updates (dict) to apply to the renewable singleton. + + Returns: + Updated project with the renewable singleton modified. + + Raises: + ValueError: If updates contains renewableException, engine-calculated, + API-calculated, or list fields; if a value fails validation (e.g. + negative numberOfFloors); or if the project's energy code is not + supported. + """ + _require_renewable_supported_code(project) + + if "renewableException" in updates: + raise ValueError( + "Use set_renewable_exception_in_project to change renewableException." + ) + + disallowed = (_ENGINE_CALCULATED_RENEWABLE_FIELDS | _LIST_FIELDS) & updates.keys() + if disallowed: + raise ValueError( + f"Cannot update engine-calculated, API-calculated, or list fields via update_renewable_in_project: " + f"{sorted(disallowed)}. Use list operations for renewableSystem and offsiteRenewableProcurement." + ) + + # Revalidate the merged singleton — plain setattr skips Pydantic validation, + # so e.g. a negative or non-integer numberOfFloors would slip through. + updated_project = project.model_copy(deep=True) + merged = {**updated_project.renewable.model_dump(mode="python"), **updates} + updated_project.renewable = Renewable.model_validate(merged) + return updated_project + + +def add_renewable_system_to_project( + project: ComBuilding, + new_renewable_system: RenewableSystem, +) -> ComBuilding: + """Add a new on-site renewable system to the project. + + On-site renewable systems can coexist with a renewable exception. + + Args: + project: The project to modify. + new_renewable_system: The RenewableSystem to add. + + Returns: + Updated project with the new RenewableSystem added. + + Raises: + ValueError: If the project's energy code is not supported for renewable operations. + """ + _require_renewable_supported_code(project) + + updated_project = project.model_copy(deep=True) + new_renewable_system = new_renewable_system.model_copy(deep=True) + updated_project.renewable.append_subcomponent(new_renewable_system) + return updated_project + + +def update_renewable_system_in_project( + project: ComBuilding, + renewable_system_description: str, + updates: dict[str, Any] | RenewableSystem, +) -> ComBuilding: + """Update an existing on-site renewable system. + + Args: + project: The project to modify. + renewable_system_description: The description of the RenewableSystem to update. + updates: Partial updates (dict) or full RenewableSystem to apply. + + Returns: + Updated project with the RenewableSystem modified. + + Raises: + ValueError: If the renewable system is not found, or if the project's + energy code is not supported. + """ + _require_renewable_supported_code(project) + _require_renewable_system(project, renewable_system_description) + + updated_project = project.model_copy(deep=True) + updated_project.renewable.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=renewable_system_description, + subcomponent_name="renewableSystem", + ) + return updated_project + + +def remove_renewable_system_from_project( + project: ComBuilding, + renewable_system_description: str, +) -> ComBuilding: + """Remove an on-site renewable system from the project. + + Args: + project: The project to modify. + renewable_system_description: The description of the RenewableSystem to remove. + + Returns: + Updated project with the RenewableSystem removed. + + Raises: + ValueError: If the renewable system is not found, or if the project's + energy code is not supported. + """ + _require_renewable_supported_code(project) + _require_renewable_system(project, renewable_system_description) + + updated_project = project.model_copy(deep=True) + updated_project.renewable.remove_from_subcomponent_list( + subcomponent_id=renewable_system_description, + subcomponent_name="renewableSystem", + ) + return updated_project + + +def get_renewable_system_keys_from_project(project: ComBuilding) -> list[dict]: + """Return the description, type, capacity, and unit of all renewable systems. + + This is a read-only operation and works for any energy code. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``renewableSystemType``, + ``renewableSystemCapacity``, and ``renewableSystemCapacityUnit`` + for each RenewableSystem in the project. + """ + renewable_systems = project.get_by_path("renewable.renewableSystem") + if not isinstance(renewable_systems, list): + return [] + return [ + { + "description": getattr(system, "description", None), + "renewableSystemType": getattr(system, "renewableSystemType", None), + "renewableSystemCapacity": getattr(system, "renewableSystemCapacity", None), + "renewableSystemCapacityUnit": getattr( + system, "renewableSystemCapacityUnit", None + ), + } + for system in renewable_systems + ] + + +def add_offsite_renewable_procurement_to_project( + project: ComBuilding, + new_procurement: OffsiteRenewableProcurement, +) -> ComBuilding: + """Add a new offsite renewable procurement to the project. + + .. note:: + Off-site renewable procurement is **not** always present. Confirm with + the user that the project actually uses off-site procurement before + calling this function. + + Args: + project: The project to modify. + new_procurement: The OffsiteRenewableProcurement to add. + + Returns: + Updated project with the new OffsiteRenewableProcurement added. + + Raises: + ValueError: If the project's energy code is not supported for renewable operations. + """ + _require_renewable_supported_code(project) + + updated_project = project.model_copy(deep=True) + new_procurement = new_procurement.model_copy(deep=True) + + # Initialize offsiteRenewableProcurement to [] if it's MISSING + if updated_project.renewable.offsiteRenewableProcurement is MISSING: + updated_project.renewable.offsiteRenewableProcurement = [] + + updated_project.renewable.append_subcomponent(new_procurement) + return updated_project + + +def update_offsite_renewable_procurement_in_project( + project: ComBuilding, + procurement_description: str, + updates: dict[str, Any] | OffsiteRenewableProcurement, +) -> ComBuilding: + """Update an existing offsite renewable procurement. + + Args: + project: The project to modify. + procurement_description: The description of the OffsiteRenewableProcurement to update. + updates: Partial updates (dict) or full OffsiteRenewableProcurement to apply. + + Returns: + Updated project with the OffsiteRenewableProcurement modified. + + Raises: + ValueError: If the offsite renewable procurement is not found, or if + the project's energy code is not supported. + """ + _require_renewable_supported_code(project) + _require_offsite_renewable_procurement(project, procurement_description) + + updated_project = project.model_copy(deep=True) + updated_project.renewable.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=procurement_description, + subcomponent_name="offsiteRenewableProcurement", + ) + return updated_project + + +def remove_offsite_renewable_procurement_from_project( + project: ComBuilding, + procurement_description: str, +) -> ComBuilding: + """Remove an offsite renewable procurement from the project. + + Args: + project: The project to modify. + procurement_description: The description of the OffsiteRenewableProcurement to remove. + + Returns: + Updated project with the OffsiteRenewableProcurement removed. + + Raises: + ValueError: If the offsite renewable procurement is not found, or if + the project's energy code is not supported. + """ + _require_renewable_supported_code(project) + _require_offsite_renewable_procurement(project, procurement_description) + + updated_project = project.model_copy(deep=True) + updated_project.renewable.remove_from_subcomponent_list( + subcomponent_id=procurement_description, + subcomponent_name="offsiteRenewableProcurement", + ) + return updated_project + + +def get_offsite_renewable_procurement_keys_from_project( + project: ComBuilding, +) -> list[dict]: + """Return the description, type, and energy of all offsite renewable procurements. + + This is a read-only operation and works for any energy code. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``offsiteRenewableProcurementType``, + and ``offsiteRenewableEnergy`` for each OffsiteRenewableProcurement in the project. + """ + offsite_procurements = project.get_by_path("renewable.offsiteRenewableProcurement") + if offsite_procurements is MISSING or not isinstance(offsite_procurements, list): + return [] + return [ + { + "description": getattr(procurement, "description", None), + "offsiteRenewableProcurementType": getattr( + procurement, "offsiteRenewableProcurementType", None + ), + "offsiteRenewableEnergy": getattr( + procurement, "offsiteRenewableEnergy", None + ), + } + for procurement in offsite_procurements + ] diff --git a/comcheck_api/utilities/project_utilities.py b/comcheck_api/utilities/project_utilities.py index 1665bd2..46af7d3 100644 --- a/comcheck_api/utilities/project_utilities.py +++ b/comcheck_api/utilities/project_utilities.py @@ -184,3 +184,82 @@ def merge_fixtures( ] return remaining_fixtures + upserted_fixtures + + +def _require_renewable_system(project: ComBuilding, description: str) -> None: + """Ensure project.renewable.renewableSystem contains a system with the given description. + + Args: + project: The project to check. + description: The description of the renewable system to find. + + Raises: + ValueError: If no renewable system with the given description is found. + """ + renewable_systems = project.get_by_path("renewable.renewableSystem") + if not isinstance(renewable_systems, list): + raise ValueError( + "No renewable systems (renewable.renewableSystem) found in project." + ) + + if not any( + getattr(system, "description", None) == description + for system in renewable_systems + ): + raise ValueError( + f"RenewableSystem with description '{description}' " + f"not found in renewable.renewableSystem." + ) + + +def _require_offsite_renewable_procurement( + project: ComBuilding, description: str +) -> None: + """Ensure project.renewable.offsiteRenewableProcurement contains a procurement with the given description. + + Args: + project: The project to check. + description: The description of the offsite renewable procurement to find. + + Raises: + ValueError: If no offsite renewable procurement with the given description is found. + """ + from pydantic.experimental.missing_sentinel import MISSING + + offsite_procurements = project.get_by_path("renewable.offsiteRenewableProcurement") + if offsite_procurements is MISSING or not isinstance(offsite_procurements, list): + raise ValueError( + "No offsite renewable procurement (renewable.offsiteRenewableProcurement) found in project." + ) + + if not any( + getattr(procurement, "description", None) == description + for procurement in offsite_procurements + ): + raise ValueError( + f"OffsiteRenewableProcurement with description '{description}' " + f"not found in renewable.offsiteRenewableProcurement." + ) + + +def _require_renewable_supported_code(project: ComBuilding) -> None: + """Ensure the project's energy code is supported for renewable operations. + + Args: + project: The project to check. + + Raises: + ValueError: If the project's control.code is not in the supported set. + """ + from comcheck_api.constants.renewable_constants import RENEWABLE_SUPPORTED_CODES + + code = project.control.code + if code not in RENEWABLE_SUPPORTED_CODES: + supported_list = ", ".join( + str(c.value) + for c in sorted(RENEWABLE_SUPPORTED_CODES, key=lambda x: x.value) + ) + raise ValueError( + f"Renewable operations are not supported for energy code '{code.value}'. " + f"Supported codes: {supported_list}" + ) diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index 1b9b595..51b0821 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -1,12 +1,183 @@ -# Renewable Energy +# Renewable Energy Operations -!!! warning "Under Development" - Renewable energy operations are not yet implemented. This section is planned for a future release. +Renewable energy is managed at the **project level** through the +`ComBuilding.renewable` singleton. The `Renewable` object carries: -This page will cover operations for managing renewable energy systems in COMcheck projects, including: +- `renewableException` — the user's exception selection +- Sizing scalars — `numberOfFloors`, `largestThreeFloorArea`, `roofAreaForRenewable`, + `requiredCapacity`, `proposedCapacity` +- `renewableSystem[]` — list of on-site renewable systems +- Off-site fields — `requiredOffsiteRenewableEnergy`, `proposedOffsiteRenewableEnergy`, + and `offsiteRenewableProcurement[]` -- Adding and updating renewable energy systems -- Offsite renewable energy procurement -- Renewable energy exceptions and credits +## Supported energy codes -The underlying types for renewable energy (`RenewableSystem`, `OffsiteRenewableProcurement`, `Renewable`) are already available. See the [Types Guide](types-guide.md) for details on importing and using them. +Renewable operations require one of the supported energy codes: + +- ASHRAE 90.1 2022 (`CEZ_90_1_2022`) +- IECC 2024 (`CEZ_IECC2024`) +- IECC 2024 Appendix C/F (`CEZ_IECC2024_APPXCF`) +- State codes based on the above + +All mutating operations raise `ValueError` for unsupported codes. + +## Key concepts + +- **Exception and systems coexist.** Setting a renewable exception does **not** + preclude adding on-site renewable systems, and vice versa — the two can + coexist on the same project. + +- **Off-site procurement confirmation.** Off-site renewable procurement is + **not** always present. Before adding any `offsiteRenewableProcurement[]` + entry, confirm with the user that the project actually uses off-site + procurement. + +- **Read-only operations work for any code.** The `get_*_keys` functions work + for any energy code, not just the supported set. + +## description uniqueness + +`description` must be unique within `renewable.renewableSystem[]` and +`renewable.offsiteRenewableProcurement[]`. It is the identifier used by update +and remove operations. If missing, a unique value is auto-generated with the +prefix `"Renewable System"` or `"Offsite Procurement"`. + +## Operations + +```python +from comcheck_api import project_renewable_operations as rn_ops +``` + +| Function | Description | +|---|---| +| `set_renewable_exception_in_project(project, renewable_exception)` | Set the renewable exception (rejects `NoneType_None`) | +| `update_renewable_in_project(project, updates)` | Update scalar fields on the renewable singleton | +| `add_renewable_system_to_project(project, new_renewable_system)` | Add a new on-site renewable system | +| `update_renewable_system_in_project(project, renewable_system_description, updates)` | Update an existing renewable system | +| `remove_renewable_system_from_project(project, renewable_system_description)` | Remove a renewable system | +| `get_renewable_system_keys_from_project(project)` | List all renewable systems in the project | +| `add_offsite_renewable_procurement_to_project(project, new_procurement)` | Add a new offsite renewable procurement | +| `update_offsite_renewable_procurement_in_project(project, procurement_description, updates)` | Update an existing offsite renewable procurement | +| `remove_offsite_renewable_procurement_from_project(project, procurement_description)` | Remove an offsite renewable procurement | +| `get_offsite_renewable_procurement_keys_from_project(project)` | List all offsite renewable procurements | + +## Setting the renewable exception + +```python +from comcheck_api import project_renewable_operations as rn_ops +from comcheck_api.types.core_types import RenewableExceptionOptions + +project = rn_ops.set_renewable_exception_in_project( + project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD +) +``` + +`NoneType_None` is rejected with a `ValueError`. A raw string raises a +`TypeError` — always use the enum. + +## Adding an on-site renewable system + +```python +from comcheck_api.defaults import get_default_renewable_system_template +from comcheck_api.types.core_types import ( + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, +) + +renewable_system = get_default_renewable_system_template() +renewable_system.description = "Rooftop Solar" +renewable_system.renewableSystemType = RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_SOLAR +renewable_system.renewableSystemCapacity = 50000.0 +renewable_system.renewableSystemCapacityUnit = RenewableSystemCapacityUnitOptions.RENEWABLE_SYSTEM_UNIT_WATT + +project = rn_ops.add_renewable_system_to_project(project, renewable_system) +``` + +## Updating a renewable system + +Pass only the fields you want to change — unchanged fields are preserved: + +```python +project = rn_ops.update_renewable_system_in_project( + project, + "Rooftop Solar", + {"renewableSystemCapacity": 75000.0}, +) +``` + +## Adding offsite renewable procurement + +!!! note + Off-site renewable procurement is **not** always present. Confirm with the + user that the project actually uses off-site procurement before calling + this function. + +```python +from comcheck_api.defaults import get_default_offsite_renewable_procurement_template +from comcheck_api.types.core_types import OffsiteRenewableProcurementType + +offsite_procurement = get_default_offsite_renewable_procurement_template() +offsite_procurement.description = "Wind PPA" +offsite_procurement.offsiteRenewableProcurementType = OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_PHYSICAL_AGREEMENT +offsite_procurement.offsiteRenewableEnergy = 100000.0 # kWh + +project = rn_ops.add_offsite_renewable_procurement_to_project(project, offsite_procurement) +``` + +## Updating scalar fields + +Only user-writable scalar fields (currently just `numberOfFloors`) may be +updated via `update_renewable_in_project`. Engine-calculated fields +(`requiredCapacity`, `proposedCapacity`, `requiredOffsiteRenewableEnergy`, +`proposedOffsiteRenewableEnergy`), API-calculated fields +(`largestThreeFloorArea`, `roofAreaForRenewable`), and list fields +(`renewableSystem`, `offsiteRenewableProcurement`) are rejected with a +`ValueError`: + +```python +project = rn_ops.update_renewable_in_project(project, {"numberOfFloors": 5}) +``` + +## Renewable area calculation (placeholder) + +!!! warning "Pending Implementation" + The `COMcheckClient.update_renewable_area` method is a placeholder. The + `area-for-renewable` endpoint is pending implementation in the external + `comcheck-cdk` repository. + +The `update_renewable_area` method will calculate `largestThreeFloorArea` and +`roofAreaForRenewable` from the project envelope/geometry once the endpoint is +available. It follows the same pattern as `update_uvalues`: + +```python +# This will raise NotImplementedError until the endpoint is available +# project = client.update_renewable_area(project) +``` + +## Removing renewable systems + +```python +project = rn_ops.remove_renewable_system_from_project(project, "Rooftop Solar") +``` + +## Removing offsite renewable procurement + +```python +project = rn_ops.remove_offsite_renewable_procurement_from_project(project, "Wind PPA") +``` + +## Listing renewable systems and procurements + +```python +renewable_systems = rn_ops.get_renewable_system_keys_from_project(project) +# [{"description": "Rooftop Solar", "renewableSystemType": "RENEWABLE_SYSTEM_TYPE_SOLAR", ...}] + +offsite_procurements = rn_ops.get_offsite_renewable_procurement_keys_from_project(project) +# [{"description": "Wind PPA", "offsiteRenewableProcurementType": "...", ...}] +``` + +## Related + +- [Types Guide](types-guide.md) — `Renewable`, `RenewableSystem`, `OffsiteRenewableProcurement` +- [Interior Lighting](interior-lighting.md) — Similar pattern for lighting spaces +- [Exterior Lighting](exterior-lighting.md) — Similar pattern for exterior lighting areas diff --git a/examples/project_operations/renewable_operations.py b/examples/project_operations/renewable_operations.py new file mode 100644 index 0000000..7c3bece --- /dev/null +++ b/examples/project_operations/renewable_operations.py @@ -0,0 +1,169 @@ +"""Example: renewable energy operations. + +Renewable energy is managed at the project level through the +ComBuilding.renewable singleton. The Renewable object carries renewable +exceptions, sizing scalars, on-site renewable systems (renewableSystem[]), +and off-site procurement (offsiteRenewableProcurement[]). + +Supported energy codes +---------------------- +Renewable operations require one of the supported energy codes: ASHRAE 90.1 +2022, IECC 2024, IECC 2024 Appendix C/F, or state codes based on them. + +Key concepts +------------ +- Exception and systems coexist: setting a renewable exception does not + preclude adding on-site renewable systems, and vice versa. +- Off-site procurement confirmation: confirm with the user before adding + off-site renewable procurement (not always present on projects). +""" + +import logging +import os +from dotenv import load_dotenv + +from comcheck_api import ( + COMcheckClient, + project_renewable_operations as rn_ops, +) +from comcheck_api.defaults import ( + get_default_renewable_system_template, + get_default_offsite_renewable_procurement_template, +) +from comcheck_api.types.core_types import ( + EnergyCodeOptions, + RenewableExceptionOptions, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, + OffsiteRenewableProcurementType, +) + +load_dotenv(override=True) +client = COMcheckClient() +client.set_api_key(os.getenv("COM_API_KEY") or "your-api-key-here") + +# Fetch an existing project so changes can be saved back to the account. +# (update_project persists to the server; it requires a project that already +# exists there, so we start from a fetched project rather than a local +# template.) +project = client.get_project("your-project-id") +if not project: + raise ValueError("Project not found") +project_id = str(project.id) + +# Ensure the project uses a supported energy code +if project.control.code not in { + EnergyCodeOptions.CEZ_90_1_2022, + EnergyCodeOptions.CEZ_IECC2024, + EnergyCodeOptions.CEZ_IECC2024_APPXCF, +}: + print( + f"Warning: Renewable operations are not supported for code {project.control.code}" + ) + print("Setting to CEZ_90_1_2022 for this example") + project.control.code = EnergyCodeOptions.CEZ_90_1_2022 + project = client.update_project(project_id, project) + if not project: + raise ValueError("Project not found after update") + + +# ── Step 1: Set the renewable exception ─────────────────────────────────────── +# A renewable exception can coexist with on-site renewable systems +project = rn_ops.set_renewable_exception_in_project( + project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_NONE +) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Renewable exception set: {project.renewable.renewableException}") + +# ── Step 2: Add an on-site renewable system ─────────────────────────────────── +renewable_system = get_default_renewable_system_template() +renewable_system.description = "Rooftop Solar" +renewable_system.renewableSystemType = ( + RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_SOLAR +) +renewable_system.renewableSystemCapacity = 50000.0 +renewable_system.renewableSystemCapacityUnit = ( + RenewableSystemCapacityUnitOptions.RENEWABLE_SYSTEM_UNIT_WATT +) + +project = rn_ops.add_renewable_system_to_project(project, renewable_system) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Renewable system added: {renewable_system.description!r}") + +# ── Step 3: List all renewable systems ──────────────────────────────────────── +keys = rn_ops.get_renewable_system_keys_from_project(project) +print(f"Renewable systems: {keys}") + +# ── Step 4: Update the renewable system (change capacity) ───────────────────── +project = rn_ops.update_renewable_system_in_project( + project, + "Rooftop Solar", + {"renewableSystemCapacity": 75000.0}, +) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Renewable system capacity updated to 75000.0 W") + +# ── Step 5: Add an offsite renewable procurement ────────────────────────────── +# NOTE: Only add offsite procurement if the project actually uses it. +# Confirm with the user before calling this function. +offsite_procurement = get_default_offsite_renewable_procurement_template() +offsite_procurement.description = "Wind PPA" +offsite_procurement.offsiteRenewableProcurementType = ( + OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_PHYSICAL_AGREEMENT +) +offsite_procurement.offsiteRenewableEnergy = 100000.0 # kWh + +project = rn_ops.add_offsite_renewable_procurement_to_project( + project, offsite_procurement +) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Offsite procurement added: {offsite_procurement.description!r}") + +# ── Step 6: List all offsite renewable procurements ─────────────────────────── +offsite_keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(project) +print(f"Offsite renewable procurements: {offsite_keys}") + +# ── Step 7: Update a scalar field on the renewable singleton ────────────────── +project = rn_ops.update_renewable_in_project(project, {"numberOfFloors": 5}) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Number of floors updated to 5") + +# ── Step 8: Remove the offsite renewable procurement ────────────────────────── +project = rn_ops.remove_offsite_renewable_procurement_from_project(project, "Wind PPA") + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Offsite procurement 'Wind PPA' removed") + +# ── Step 9: Remove the on-site renewable system ─────────────────────────────── +project = rn_ops.remove_renewable_system_from_project(project, "Rooftop Solar") + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print(f"Renewable system 'Rooftop Solar' removed") + +# ── Final check ──────────────────────────────────────────────────────────────── +renewable_systems = rn_ops.get_renewable_system_keys_from_project(project) +offsite_procurements = rn_ops.get_offsite_renewable_procurement_keys_from_project( + project +) +print(f"\nFinal renewable systems: {renewable_systems}") +print(f"Final offsite procurements: {offsite_procurements}") +print(f"Renewable exception: {project.renewable.renewableException}") diff --git a/tests/project_operation_tests/test_renewable_operations.py b/tests/project_operation_tests/test_renewable_operations.py new file mode 100644 index 0000000..2d349f3 --- /dev/null +++ b/tests/project_operation_tests/test_renewable_operations.py @@ -0,0 +1,462 @@ +"""Tests for project_renewable_operations.""" + +import pytest +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.defaults import ( + get_default_renewable_system_template, + get_default_offsite_renewable_procurement_template, +) +from comcheck_api.project_operations import ( + project_renewable_operations as rn_ops, +) +from comcheck_api.types.core_types import ( + ComBuilding, + EnergyCodeOptions, + RenewableExceptionOptions, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, + OffsiteRenewableProcurementType, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fresh(project: ComBuilding) -> ComBuilding: + return project.model_copy(deep=True) + + +def _set_supported_code(project: ComBuilding) -> ComBuilding: + """Set the project to a supported energy code.""" + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_90_1_2022 + return proj + + +# --------------------------------------------------------------------------- +# set_renewable_exception_in_project +# --------------------------------------------------------------------------- + + +def test_set_renewable_exception(project: ComBuilding): + proj = _set_supported_code(project) + result = rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + assert ( + result.renewable.renewableException + == RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + + +def test_set_renewable_exception_rejects_nonetype(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="NoneType_None"): + rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.NoneType_None + ) + + +def test_set_renewable_exception_rejects_non_enum(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(TypeError): + rn_ops.set_renewable_exception_in_project( + proj, "RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD" + ) + + +def test_set_renewable_exception_does_not_mutate_original(project: ComBuilding): + proj = _set_supported_code(project) + original_exception = proj.renewable.renewableException + rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + assert proj.renewable.renewableException == original_exception + + +def test_set_renewable_exception_unsupported_code(project: ComBuilding): + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 # Unsupported code + with pytest.raises(ValueError, match="not supported for energy code"): + rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + + +# --------------------------------------------------------------------------- +# update_renewable_in_project +# --------------------------------------------------------------------------- + + +def test_update_renewable_scalar_fields(project: ComBuilding): + proj = _set_supported_code(project) + result = rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 5}) + assert result.renewable.numberOfFloors == 5 + + +def test_update_renewable_validates_values(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError): + rn_ops.update_renewable_in_project(proj, {"numberOfFloors": -3}) + with pytest.raises(ValueError): + rn_ops.update_renewable_in_project(proj, {"numberOfFloors": "abc"}) + + +def test_update_renewable_rejects_exception_field(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="set_renewable_exception_in_project"): + rn_ops.update_renewable_in_project(proj, {"renewableException": "junk"}) + + +def test_update_renewable_preserves_other_fields(project: ComBuilding): + proj = _set_supported_code(project) + proj = rn_ops.add_renewable_system_to_project( + proj, get_default_renewable_system_template() + ) + result = rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 4}) + assert result.renewable.id == proj.renewable.id + assert result.renewable.renewableSystem == proj.renewable.renewableSystem + assert ( + result.renewable.offsiteRenewableProcurement + == proj.renewable.offsiteRenewableProcurement + ) + + +def test_update_renewable_rejects_engine_fields(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="engine-calculated"): + rn_ops.update_renewable_in_project(proj, {"requiredCapacity": 100.0}) + with pytest.raises(ValueError, match="engine-calculated"): + rn_ops.update_renewable_in_project(proj, {"proposedCapacity": 100.0}) + with pytest.raises(ValueError, match="engine-calculated"): + rn_ops.update_renewable_in_project( + proj, {"requiredOffsiteRenewableEnergy": 100.0} + ) + with pytest.raises(ValueError, match="engine-calculated"): + rn_ops.update_renewable_in_project( + proj, {"proposedOffsiteRenewableEnergy": 100.0} + ) + + +def test_update_renewable_rejects_api_fields(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="API-calculated"): + rn_ops.update_renewable_in_project(proj, {"largestThreeFloorArea": 1000.0}) + with pytest.raises(ValueError, match="API-calculated"): + rn_ops.update_renewable_in_project(proj, {"roofAreaForRenewable": 500.0}) + + +def test_update_renewable_rejects_list_fields(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="list fields"): + rn_ops.update_renewable_in_project(proj, {"renewableSystem": []}) + with pytest.raises(ValueError, match="list fields"): + rn_ops.update_renewable_in_project(proj, {"offsiteRenewableProcurement": []}) + + +def test_update_renewable_unsupported_code(project: ComBuilding): + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 # Unsupported code + with pytest.raises(ValueError, match="not supported for energy code"): + rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 5}) + + +# --------------------------------------------------------------------------- +# add_renewable_system_to_project +# --------------------------------------------------------------------------- + + +def test_add_renewable_system(project: ComBuilding): + proj = _set_supported_code(project) + rs = get_default_renewable_system_template() + rs.description = "Solar panels" + rs.renewableSystemCapacity = 50000.0 + + result = rn_ops.add_renewable_system_to_project(proj, rs) + + keys = rn_ops.get_renewable_system_keys_from_project(result) + assert any(k["description"] == "Solar panels" for k in keys) + + +def test_add_renewable_system_does_not_mutate_original(project: ComBuilding): + proj = _set_supported_code(project) + original_count = len(proj.renewable.renewableSystem) + rs = get_default_renewable_system_template() + rs.description = "Wind turbine" + rn_ops.add_renewable_system_to_project(proj, rs) + assert len(proj.renewable.renewableSystem) == original_count + + +def test_add_renewable_system_auto_generates_description(project: ComBuilding): + proj = _set_supported_code(project) + rs1 = get_default_renewable_system_template() + rs1.description = None # Missing description + result = rn_ops.add_renewable_system_to_project(proj, rs1) + + # The description should be auto-generated with the prefix "Renewable System" + keys = rn_ops.get_renewable_system_keys_from_project(result) + assert any("Renewable System" in k.get("description", "") for k in keys) + + +def test_add_renewable_system_unsupported_code(project: ComBuilding): + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 + rs = get_default_renewable_system_template() + with pytest.raises(ValueError, match="not supported for energy code"): + rn_ops.add_renewable_system_to_project(proj, rs) + + +# --------------------------------------------------------------------------- +# update_renewable_system_in_project +# --------------------------------------------------------------------------- + + +def test_update_renewable_system(project: ComBuilding): + proj = _set_supported_code(project) + rs = get_default_renewable_system_template() + rs.description = "Solar array" + proj = rn_ops.add_renewable_system_to_project(proj, rs) + + result = rn_ops.update_renewable_system_in_project( + proj, + "Solar array", + { + "renewableSystemCapacity": 75000.0, + "renewableSystemType": RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_WIND, + }, + ) + + systems = result.renewable.renewableSystem + updated = next(s for s in systems if s.description == "Solar array") + assert updated.renewableSystemCapacity == 75000.0 + assert ( + updated.renewableSystemType + == RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_WIND + ) + + +def test_update_renewable_system_not_found(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="not found"): + rn_ops.update_renewable_system_in_project( + proj, "Nonexistent", {"renewableSystemCapacity": 100.0} + ) + + +# --------------------------------------------------------------------------- +# remove_renewable_system_from_project +# --------------------------------------------------------------------------- + + +def test_remove_renewable_system(project: ComBuilding): + proj = _set_supported_code(project) + rs = get_default_renewable_system_template() + rs.description = "Geothermal" + proj = rn_ops.add_renewable_system_to_project(proj, rs) + + result = rn_ops.remove_renewable_system_from_project(proj, "Geothermal") + + keys = rn_ops.get_renewable_system_keys_from_project(result) + assert not any(k["description"] == "Geothermal" for k in keys) + + +def test_remove_renewable_system_not_found(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="not found"): + rn_ops.remove_renewable_system_from_project(proj, "Nonexistent") + + +# --------------------------------------------------------------------------- +# get_renewable_system_keys_from_project +# --------------------------------------------------------------------------- + + +def test_get_renewable_system_keys(project: ComBuilding): + proj = _set_supported_code(project) + keys = rn_ops.get_renewable_system_keys_from_project(proj) + assert isinstance(keys, list) + + +def test_get_renewable_system_keys_works_for_any_code(project: ComBuilding): + # Read-only operations work for any code + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 + keys = rn_ops.get_renewable_system_keys_from_project(proj) + assert isinstance(keys, list) + + +# --------------------------------------------------------------------------- +# add_offsite_renewable_procurement_to_project +# --------------------------------------------------------------------------- + + +def test_add_offsite_renewable_procurement(project: ComBuilding): + proj = _set_supported_code(project) + op = get_default_offsite_renewable_procurement_template() + op.description = "PPA agreement" + op.offsiteRenewableEnergy = 100000.0 + + result = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(result) + assert any(k["description"] == "PPA agreement" for k in keys) + + +def test_add_offsite_renewable_procurement_initializes_missing_list( + project: ComBuilding, +): + proj = _set_supported_code(project) + # Force offsiteRenewableProcurement to MISSING + proj.renewable.offsiteRenewableProcurement = MISSING + + op = get_default_offsite_renewable_procurement_template() + op.description = "Community solar" + result = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + # The list should be initialized + assert result.renewable.offsiteRenewableProcurement is not MISSING + assert isinstance(result.renewable.offsiteRenewableProcurement, list) + assert len(result.renewable.offsiteRenewableProcurement) >= 1 + + +def test_add_offsite_renewable_procurement_does_not_mutate_original( + project: ComBuilding, +): + proj = _set_supported_code(project) + # Ensure list is initialized + if proj.renewable.offsiteRenewableProcurement is MISSING: + proj.renewable.offsiteRenewableProcurement = [] + original_count = len(proj.renewable.offsiteRenewableProcurement) + + op = get_default_offsite_renewable_procurement_template() + op.description = "Green tariff" + rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + assert len(proj.renewable.offsiteRenewableProcurement) == original_count + + +def test_add_offsite_renewable_procurement_unsupported_code(project: ComBuilding): + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 + op = get_default_offsite_renewable_procurement_template() + with pytest.raises(ValueError, match="not supported for energy code"): + rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + +# --------------------------------------------------------------------------- +# update_offsite_renewable_procurement_in_project +# --------------------------------------------------------------------------- + + +def test_update_offsite_renewable_procurement(project: ComBuilding): + proj = _set_supported_code(project) + op = get_default_offsite_renewable_procurement_template() + op.description = "Wind PPA" + proj = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + result = rn_ops.update_offsite_renewable_procurement_in_project( + proj, + "Wind PPA", + { + "offsiteRenewableEnergy": 200000.0, + "offsiteRenewableProcurementType": OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_PHYSICAL_AGREEMENT, + }, + ) + + procurements = result.renewable.offsiteRenewableProcurement + updated = next(p for p in procurements if p.description == "Wind PPA") + assert updated.offsiteRenewableEnergy == 200000.0 + assert ( + updated.offsiteRenewableProcurementType + == OffsiteRenewableProcurementType.OFFSITE_RENEWABLE_PROCUREMENT_PHYSICAL_AGREEMENT + ) + + +def test_update_offsite_renewable_procurement_not_found(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="not found"): + rn_ops.update_offsite_renewable_procurement_in_project( + proj, "Nonexistent", {"offsiteRenewableEnergy": 100.0} + ) + + +# --------------------------------------------------------------------------- +# remove_offsite_renewable_procurement_from_project +# --------------------------------------------------------------------------- + + +def test_remove_offsite_renewable_procurement(project: ComBuilding): + proj = _set_supported_code(project) + op = get_default_offsite_renewable_procurement_template() + op.description = "REC purchase" + proj = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) + + result = rn_ops.remove_offsite_renewable_procurement_from_project( + proj, "REC purchase" + ) + + keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(result) + assert not any(k["description"] == "REC purchase" for k in keys) + + +def test_remove_offsite_renewable_procurement_not_found(project: ComBuilding): + proj = _set_supported_code(project) + with pytest.raises(ValueError, match="not found"): + rn_ops.remove_offsite_renewable_procurement_from_project(proj, "Nonexistent") + + +# --------------------------------------------------------------------------- +# get_offsite_renewable_procurement_keys_from_project +# --------------------------------------------------------------------------- + + +def test_get_offsite_renewable_procurement_keys(project: ComBuilding): + proj = _set_supported_code(project) + keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(proj) + assert isinstance(keys, list) + + +def test_get_offsite_renewable_procurement_keys_works_for_any_code( + project: ComBuilding, +): + # Read-only operations work for any code + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 + keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(proj) + assert isinstance(keys, list) + + +def test_get_offsite_renewable_procurement_keys_handles_missing(project: ComBuilding): + proj = _fresh(project) + proj.renewable.offsiteRenewableProcurement = MISSING + keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(proj) + assert keys == [] + + +# --------------------------------------------------------------------------- +# Exception and systems coexistence +# --------------------------------------------------------------------------- + + +def test_exception_and_systems_coexist(project: ComBuilding): + proj = _set_supported_code(project) + + # Set an exception + proj = rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + + # Add a renewable system + rs = get_default_renewable_system_template() + rs.description = "Backup solar" + proj = rn_ops.add_renewable_system_to_project(proj, rs) + + # Both should be present + assert ( + proj.renewable.renewableException + == RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + systems = rn_ops.get_renewable_system_keys_from_project(proj) + assert any(k["description"] == "Backup solar" for k in systems) diff --git a/tests/test_renewable_area_placeholder.py b/tests/test_renewable_area_placeholder.py new file mode 100644 index 0000000..dede31c --- /dev/null +++ b/tests/test_renewable_area_placeholder.py @@ -0,0 +1,29 @@ +"""Tests for renewable area placeholder methods.""" + +import pytest + +from comcheck_api import COMcheckClient +from comcheck_api.api.api_services import COMCheckApiService +from comcheck_api.types.core_types import ComBuilding, EnergyCodeOptions + + +def test_area_for_renewable_service_raises_not_implemented(): + """Test that COMCheckApiService.area_for_renewable raises NotImplementedError.""" + service = COMCheckApiService(api_key="test") + with pytest.raises( + NotImplementedError, match="area-for-renewable endpoint is pending" + ): + service.area_for_renewable({}, "CEZ_90_1_2022") + + +def test_update_renewable_area_client_raises_not_implemented(): + """Test that COMcheckClient.update_renewable_area raises NotImplementedError.""" + from comcheck_api.defaults import get_default_project_template + + client = COMcheckClient(api_key="test") + project = get_default_project_template() + project.control.code = EnergyCodeOptions.CEZ_90_1_2022 + with pytest.raises( + NotImplementedError, match="area-for-renewable endpoint is pending" + ): + client.update_renewable_area(project) From 97f149f5c914ef2d20ffd5bea9a3fe70f22e1a41 Mon Sep 17 00:00:00 2001 From: yanz571 Date: Tue, 13 Oct 2026 15:03:54 -0700 Subject: [PATCH 2/2] Remove energy-code allowlist from renewable operations State codes based on 90.1/IECC also carry renewables, so a fixed code list blocked valid projects. Ops no longer check control.code; docs, skill, and the example no longer enumerate codes, leaving the decision to the user. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 - comcheck_api/ai/skill/SKILL.md | 17 +-- comcheck_api/ai/skill/reference/operations.md | 7 +- comcheck_api/constants/renewable_constants.py | 10 -- .../project_renewable_operations.py | 51 ++------ comcheck_api/utilities/project_utilities.py | 23 ---- docs_site/api/operations/renewable-energy.md | 16 +-- .../renewable_operations.py | 25 +--- .../test_renewable_operations.py | 113 +++++++----------- 9 files changed, 73 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3646572..fa31129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ All notable changes to this project are documented in this file. - `get_renewable_system_keys_from_project` — list all renewable systems in a project - `add_offsite_renewable_procurement_to_project` / `update_offsite_renewable_procurement_in_project` / `remove_offsite_renewable_procurement_from_project` — manage offsite renewable procurement - `get_offsite_renewable_procurement_keys_from_project` — list all offsite renewable procurements -- Renewable operations support for ASHRAE 90.1 2022, IECC 2024, and IECC 2024 Appendix C/F energy codes - `get_default_renewable_system_template` and `get_default_offsite_renewable_procurement_template` default templates - `COMcheckClient.update_renewable_area` and `COMCheckApiService.area_for_renewable` placeholder methods (raise `NotImplementedError` until endpoint is available) - `RenewableSystem` and `OffsiteRenewableProcurement` added to `MODEL_TO_ID_INFO` with auto-generated descriptions diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index e14e6df..129755f 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -39,7 +39,7 @@ Triggers: - `project_envelope_operations` — roofs, walls, floors, windows, doors, skylights, thermal bridges - `project_interior_lighting_operations` — `ActivityUse` items, aliased as `InteriorSpace` (interior lighting spaces + fixtures) - `project_exterior_lighting_operations` — `ExteriorUse` items, aliased as `ExteriorArea`, + zone type - - `project_renewable_operations` — renewable exception, on-site systems, off-site procurement (supported codes only) + - `project_renewable_operations` — renewable exception, on-site systems, off-site procurement - **Envelope items attach to a building-area key**: every `add_*_to_project` envelope function takes `(project, building_area_key, new_component)`. Look up the key @@ -379,8 +379,9 @@ library, so it returns metadata and lets the caller decide ### Adding renewable energy systems -Renewable operations require a supported energy code (ASHRAE 90.1 2022, -IECC 2024, IECC 2024 Appendix C/F, or state codes based on them). Exception +Renewable requirements only apply under some energy codes; the operations +don't check `control.code`, so ask the user whether the project needs +renewables rather than deciding from the code. Exception and on-site systems coexist — setting an exception doesn't preclude adding renewable systems. **Confirm with the user before adding off-site procurement** (not always present on projects). @@ -392,22 +393,12 @@ from comcheck_api.defaults import ( get_default_offsite_renewable_procurement_template, ) from comcheck_api.types import ( - EnergyCodeOptions, RenewableExceptionOptions, RenewableSystemTypeOptions, RenewableSystemCapacityUnitOptions, OffsiteRenewableProcurementType, ) -# Ensure the project uses a supported code -if project.control.code not in { - EnergyCodeOptions.CEZ_90_1_2022, - EnergyCodeOptions.CEZ_IECC2024, - EnergyCodeOptions.CEZ_IECC2024_APPXCF, -}: - # Unsupported — all mutating ops raise ValueError - print("Renewable operations are not supported for this energy code") - # 1. Set a renewable exception (coexists with systems) project = rn_ops.set_renewable_exception_in_project( project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_NONE diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index 91de17d..e69dd1c 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -182,10 +182,9 @@ from comcheck_api import project_renewable_operations as rn_ops ``` Renewable energy is managed at the project level through the -`ComBuilding.renewable` singleton. Renewable operations require a supported -energy code (ASHRAE 90.1 2022, IECC 2024, IECC 2024 Appendix C/F, or state -codes based on them). All mutating operations raise `ValueError` for -unsupported codes. Exception and on-site systems coexist — setting an +`ComBuilding.renewable` singleton. Renewable requirements only apply under +some energy codes; the operations don't check `control.code` — ask the user +whether the project needs renewables. Exception and on-site systems coexist — setting an exception does not preclude adding renewable systems. **Confirm with the user before adding off-site procurement** (not always present on projects). diff --git a/comcheck_api/constants/renewable_constants.py b/comcheck_api/constants/renewable_constants.py index 4049adb..9487715 100644 --- a/comcheck_api/constants/renewable_constants.py +++ b/comcheck_api/constants/renewable_constants.py @@ -1,7 +1,6 @@ """Renewable energy constants for COMcheck projects.""" from comcheck_api.types.core_types import ( - EnergyCodeOptions, OffsiteRenewableProcurement, OffsiteRenewableProcurementType, RenewableSystem, @@ -9,15 +8,6 @@ RenewableSystemCapacityUnitOptions, ) -# Supported energy codes for renewable operations (ASHRAE 90.1 2022, IECC 2024, and state codes based on them) -RENEWABLE_SUPPORTED_CODES = frozenset( - { - EnergyCodeOptions.CEZ_90_1_2022, - EnergyCodeOptions.CEZ_IECC2024, - EnergyCodeOptions.CEZ_IECC2024_APPXCF, - } -) - DEFAULT_RENEWABLE_SYSTEM: RenewableSystem = RenewableSystem( description="Solar PV", renewableSystemType=RenewableSystemTypeOptions.RENEWABLE_SYSTEM_TYPE_SOLAR, diff --git a/comcheck_api/project_operations/project_renewable_operations.py b/comcheck_api/project_operations/project_renewable_operations.py index 5e00278..7478d88 100644 --- a/comcheck_api/project_operations/project_renewable_operations.py +++ b/comcheck_api/project_operations/project_renewable_operations.py @@ -11,11 +11,11 @@ - Off-site fields — ``requiredOffsiteRenewableEnergy``, ``proposedOffsiteRenewableEnergy``, and ``offsiteRenewableProcurement[]`` -Supported energy codes ----------------------- -Renewable operations require one of the supported energy codes (ASHRAE 90.1 2022, -IECC 2024, or state codes based on them). All mutating operations raise -:exc:`ValueError` for unsupported codes. +Energy codes +------------ +Renewable requirements only apply under some energy codes (and state codes +based on them). These operations don't check ``control.code`` — whether the +project needs renewable data is up to you. Exception and systems coexistence ---------------------------------- @@ -43,7 +43,6 @@ from comcheck_api.utilities.project_utilities import ( _require_renewable_system, _require_offsite_renewable_procurement, - _require_renewable_supported_code, ) logger = logging.getLogger(__name__) @@ -88,8 +87,7 @@ def set_renewable_exception_in_project( Raises: TypeError: If renewable_exception is not a RenewableExceptionOptions member. - ValueError: If renewable_exception is NoneType_None, or if the project's - energy code is not supported for renewable operations. + ValueError: If renewable_exception is NoneType_None. """ if not isinstance(renewable_exception, RenewableExceptionOptions): raise TypeError( @@ -102,8 +100,6 @@ def set_renewable_exception_in_project( "Choose a value from RenewableExceptionOptions other than NoneType_None." ) - _require_renewable_supported_code(project) - updated_project = project.model_copy(deep=True) updated_project.renewable.renewableException = renewable_exception return updated_project @@ -132,11 +128,8 @@ def update_renewable_in_project( Raises: ValueError: If updates contains renewableException, engine-calculated, API-calculated, or list fields; if a value fails validation (e.g. - negative numberOfFloors); or if the project's energy code is not - supported. + negative numberOfFloors). """ - _require_renewable_supported_code(project) - if "renewableException" in updates: raise ValueError( "Use set_renewable_exception_in_project to change renewableException." @@ -171,12 +164,7 @@ def add_renewable_system_to_project( Returns: Updated project with the new RenewableSystem added. - - Raises: - ValueError: If the project's energy code is not supported for renewable operations. """ - _require_renewable_supported_code(project) - updated_project = project.model_copy(deep=True) new_renewable_system = new_renewable_system.model_copy(deep=True) updated_project.renewable.append_subcomponent(new_renewable_system) @@ -199,10 +187,8 @@ def update_renewable_system_in_project( Updated project with the RenewableSystem modified. Raises: - ValueError: If the renewable system is not found, or if the project's - energy code is not supported. + ValueError: If the renewable system is not found. """ - _require_renewable_supported_code(project) _require_renewable_system(project, renewable_system_description) updated_project = project.model_copy(deep=True) @@ -228,10 +214,8 @@ def remove_renewable_system_from_project( Updated project with the RenewableSystem removed. Raises: - ValueError: If the renewable system is not found, or if the project's - energy code is not supported. + ValueError: If the renewable system is not found. """ - _require_renewable_supported_code(project) _require_renewable_system(project, renewable_system_description) updated_project = project.model_copy(deep=True) @@ -245,8 +229,6 @@ def remove_renewable_system_from_project( def get_renewable_system_keys_from_project(project: ComBuilding) -> list[dict]: """Return the description, type, capacity, and unit of all renewable systems. - This is a read-only operation and works for any energy code. - Args: project: The project to query. @@ -288,12 +270,7 @@ def add_offsite_renewable_procurement_to_project( Returns: Updated project with the new OffsiteRenewableProcurement added. - - Raises: - ValueError: If the project's energy code is not supported for renewable operations. """ - _require_renewable_supported_code(project) - updated_project = project.model_copy(deep=True) new_procurement = new_procurement.model_copy(deep=True) @@ -321,10 +298,8 @@ def update_offsite_renewable_procurement_in_project( Updated project with the OffsiteRenewableProcurement modified. Raises: - ValueError: If the offsite renewable procurement is not found, or if - the project's energy code is not supported. + ValueError: If the offsite renewable procurement is not found. """ - _require_renewable_supported_code(project) _require_offsite_renewable_procurement(project, procurement_description) updated_project = project.model_copy(deep=True) @@ -350,10 +325,8 @@ def remove_offsite_renewable_procurement_from_project( Updated project with the OffsiteRenewableProcurement removed. Raises: - ValueError: If the offsite renewable procurement is not found, or if - the project's energy code is not supported. + ValueError: If the offsite renewable procurement is not found. """ - _require_renewable_supported_code(project) _require_offsite_renewable_procurement(project, procurement_description) updated_project = project.model_copy(deep=True) @@ -369,8 +342,6 @@ def get_offsite_renewable_procurement_keys_from_project( ) -> list[dict]: """Return the description, type, and energy of all offsite renewable procurements. - This is a read-only operation and works for any energy code. - Args: project: The project to query. diff --git a/comcheck_api/utilities/project_utilities.py b/comcheck_api/utilities/project_utilities.py index 46af7d3..8b717e5 100644 --- a/comcheck_api/utilities/project_utilities.py +++ b/comcheck_api/utilities/project_utilities.py @@ -240,26 +240,3 @@ def _require_offsite_renewable_procurement( f"OffsiteRenewableProcurement with description '{description}' " f"not found in renewable.offsiteRenewableProcurement." ) - - -def _require_renewable_supported_code(project: ComBuilding) -> None: - """Ensure the project's energy code is supported for renewable operations. - - Args: - project: The project to check. - - Raises: - ValueError: If the project's control.code is not in the supported set. - """ - from comcheck_api.constants.renewable_constants import RENEWABLE_SUPPORTED_CODES - - code = project.control.code - if code not in RENEWABLE_SUPPORTED_CODES: - supported_list = ", ".join( - str(c.value) - for c in sorted(RENEWABLE_SUPPORTED_CODES, key=lambda x: x.value) - ) - raise ValueError( - f"Renewable operations are not supported for energy code '{code.value}'. " - f"Supported codes: {supported_list}" - ) diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index 51b0821..10e35a9 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -10,16 +10,11 @@ Renewable energy is managed at the **project level** through the - Off-site fields — `requiredOffsiteRenewableEnergy`, `proposedOffsiteRenewableEnergy`, and `offsiteRenewableProcurement[]` -## Supported energy codes +## Energy codes -Renewable operations require one of the supported energy codes: - -- ASHRAE 90.1 2022 (`CEZ_90_1_2022`) -- IECC 2024 (`CEZ_IECC2024`) -- IECC 2024 Appendix C/F (`CEZ_IECC2024_APPXCF`) -- State codes based on the above - -All mutating operations raise `ValueError` for unsupported codes. +Renewable requirements only apply under some energy codes (and state codes +based on them). These operations don't check `control.code` — whether the +project needs renewable data is up to you. ## Key concepts @@ -32,9 +27,6 @@ All mutating operations raise `ValueError` for unsupported codes. entry, confirm with the user that the project actually uses off-site procurement. -- **Read-only operations work for any code.** The `get_*_keys` functions work - for any energy code, not just the supported set. - ## description uniqueness `description` must be unique within `renewable.renewableSystem[]` and diff --git a/examples/project_operations/renewable_operations.py b/examples/project_operations/renewable_operations.py index 7c3bece..2c5365c 100644 --- a/examples/project_operations/renewable_operations.py +++ b/examples/project_operations/renewable_operations.py @@ -5,10 +5,11 @@ exceptions, sizing scalars, on-site renewable systems (renewableSystem[]), and off-site procurement (offsiteRenewableProcurement[]). -Supported energy codes ----------------------- -Renewable operations require one of the supported energy codes: ASHRAE 90.1 -2022, IECC 2024, IECC 2024 Appendix C/F, or state codes based on them. +Energy codes +------------ +Renewable requirements only apply under some energy codes (and state codes +based on them). These operations don't check ``control.code`` — whether the +project needs renewable data is up to you. Key concepts ------------ @@ -31,7 +32,6 @@ get_default_offsite_renewable_procurement_template, ) from comcheck_api.types.core_types import ( - EnergyCodeOptions, RenewableExceptionOptions, RenewableSystemTypeOptions, RenewableSystemCapacityUnitOptions, @@ -51,21 +51,6 @@ raise ValueError("Project not found") project_id = str(project.id) -# Ensure the project uses a supported energy code -if project.control.code not in { - EnergyCodeOptions.CEZ_90_1_2022, - EnergyCodeOptions.CEZ_IECC2024, - EnergyCodeOptions.CEZ_IECC2024_APPXCF, -}: - print( - f"Warning: Renewable operations are not supported for code {project.control.code}" - ) - print("Setting to CEZ_90_1_2022 for this example") - project.control.code = EnergyCodeOptions.CEZ_90_1_2022 - project = client.update_project(project_id, project) - if not project: - raise ValueError("Project not found after update") - # ── Step 1: Set the renewable exception ─────────────────────────────────────── # A renewable exception can coexist with on-site renewable systems diff --git a/tests/project_operation_tests/test_renewable_operations.py b/tests/project_operation_tests/test_renewable_operations.py index 2d349f3..ad2c393 100644 --- a/tests/project_operation_tests/test_renewable_operations.py +++ b/tests/project_operation_tests/test_renewable_operations.py @@ -28,20 +28,13 @@ def _fresh(project: ComBuilding) -> ComBuilding: return project.model_copy(deep=True) -def _set_supported_code(project: ComBuilding) -> ComBuilding: - """Set the project to a supported energy code.""" - proj = _fresh(project) - proj.control.code = EnergyCodeOptions.CEZ_90_1_2022 - return proj - - # --------------------------------------------------------------------------- # set_renewable_exception_in_project # --------------------------------------------------------------------------- def test_set_renewable_exception(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) result = rn_ops.set_renewable_exception_in_project( proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD ) @@ -52,7 +45,7 @@ def test_set_renewable_exception(project: ComBuilding): def test_set_renewable_exception_rejects_nonetype(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="NoneType_None"): rn_ops.set_renewable_exception_in_project( proj, RenewableExceptionOptions.NoneType_None @@ -60,7 +53,7 @@ def test_set_renewable_exception_rejects_nonetype(project: ComBuilding): def test_set_renewable_exception_rejects_non_enum(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(TypeError): rn_ops.set_renewable_exception_in_project( proj, "RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD" @@ -68,7 +61,7 @@ def test_set_renewable_exception_rejects_non_enum(project: ComBuilding): def test_set_renewable_exception_does_not_mutate_original(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) original_exception = proj.renewable.renewableException rn_ops.set_renewable_exception_in_project( proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD @@ -76,28 +69,19 @@ def test_set_renewable_exception_does_not_mutate_original(project: ComBuilding): assert proj.renewable.renewableException == original_exception -def test_set_renewable_exception_unsupported_code(project: ComBuilding): - proj = _fresh(project) - proj.control.code = EnergyCodeOptions.CEZ_IECC2009 # Unsupported code - with pytest.raises(ValueError, match="not supported for energy code"): - rn_ops.set_renewable_exception_in_project( - proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD - ) - - # --------------------------------------------------------------------------- # update_renewable_in_project # --------------------------------------------------------------------------- def test_update_renewable_scalar_fields(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) result = rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 5}) assert result.renewable.numberOfFloors == 5 def test_update_renewable_validates_values(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError): rn_ops.update_renewable_in_project(proj, {"numberOfFloors": -3}) with pytest.raises(ValueError): @@ -105,13 +89,13 @@ def test_update_renewable_validates_values(project: ComBuilding): def test_update_renewable_rejects_exception_field(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="set_renewable_exception_in_project"): rn_ops.update_renewable_in_project(proj, {"renewableException": "junk"}) def test_update_renewable_preserves_other_fields(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) proj = rn_ops.add_renewable_system_to_project( proj, get_default_renewable_system_template() ) @@ -125,7 +109,7 @@ def test_update_renewable_preserves_other_fields(project: ComBuilding): def test_update_renewable_rejects_engine_fields(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="engine-calculated"): rn_ops.update_renewable_in_project(proj, {"requiredCapacity": 100.0}) with pytest.raises(ValueError, match="engine-calculated"): @@ -141,7 +125,7 @@ def test_update_renewable_rejects_engine_fields(project: ComBuilding): def test_update_renewable_rejects_api_fields(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="API-calculated"): rn_ops.update_renewable_in_project(proj, {"largestThreeFloorArea": 1000.0}) with pytest.raises(ValueError, match="API-calculated"): @@ -149,27 +133,20 @@ def test_update_renewable_rejects_api_fields(project: ComBuilding): def test_update_renewable_rejects_list_fields(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="list fields"): rn_ops.update_renewable_in_project(proj, {"renewableSystem": []}) with pytest.raises(ValueError, match="list fields"): rn_ops.update_renewable_in_project(proj, {"offsiteRenewableProcurement": []}) -def test_update_renewable_unsupported_code(project: ComBuilding): - proj = _fresh(project) - proj.control.code = EnergyCodeOptions.CEZ_IECC2009 # Unsupported code - with pytest.raises(ValueError, match="not supported for energy code"): - rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 5}) - - # --------------------------------------------------------------------------- # add_renewable_system_to_project # --------------------------------------------------------------------------- def test_add_renewable_system(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) rs = get_default_renewable_system_template() rs.description = "Solar panels" rs.renewableSystemCapacity = 50000.0 @@ -181,7 +158,7 @@ def test_add_renewable_system(project: ComBuilding): def test_add_renewable_system_does_not_mutate_original(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) original_count = len(proj.renewable.renewableSystem) rs = get_default_renewable_system_template() rs.description = "Wind turbine" @@ -190,7 +167,7 @@ def test_add_renewable_system_does_not_mutate_original(project: ComBuilding): def test_add_renewable_system_auto_generates_description(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) rs1 = get_default_renewable_system_template() rs1.description = None # Missing description result = rn_ops.add_renewable_system_to_project(proj, rs1) @@ -200,21 +177,13 @@ def test_add_renewable_system_auto_generates_description(project: ComBuilding): assert any("Renewable System" in k.get("description", "") for k in keys) -def test_add_renewable_system_unsupported_code(project: ComBuilding): - proj = _fresh(project) - proj.control.code = EnergyCodeOptions.CEZ_IECC2009 - rs = get_default_renewable_system_template() - with pytest.raises(ValueError, match="not supported for energy code"): - rn_ops.add_renewable_system_to_project(proj, rs) - - # --------------------------------------------------------------------------- # update_renewable_system_in_project # --------------------------------------------------------------------------- def test_update_renewable_system(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) rs = get_default_renewable_system_template() rs.description = "Solar array" proj = rn_ops.add_renewable_system_to_project(proj, rs) @@ -238,7 +207,7 @@ def test_update_renewable_system(project: ComBuilding): def test_update_renewable_system_not_found(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="not found"): rn_ops.update_renewable_system_in_project( proj, "Nonexistent", {"renewableSystemCapacity": 100.0} @@ -251,7 +220,7 @@ def test_update_renewable_system_not_found(project: ComBuilding): def test_remove_renewable_system(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) rs = get_default_renewable_system_template() rs.description = "Geothermal" proj = rn_ops.add_renewable_system_to_project(proj, rs) @@ -263,7 +232,7 @@ def test_remove_renewable_system(project: ComBuilding): def test_remove_renewable_system_not_found(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="not found"): rn_ops.remove_renewable_system_from_project(proj, "Nonexistent") @@ -274,11 +243,29 @@ def test_remove_renewable_system_not_found(project: ComBuilding): def test_get_renewable_system_keys(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) keys = rn_ops.get_renewable_system_keys_from_project(proj) assert isinstance(keys, list) +def test_mutating_ops_do_not_check_energy_code(project: ComBuilding): + # Whether a project needs renewable data is the caller's call, so ops + # must not reject codes (e.g. state codes) outside a fixed list. + proj = _fresh(project) + proj.control.code = EnergyCodeOptions.CEZ_IECC2009 + proj = rn_ops.set_renewable_exception_in_project( + proj, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + proj = rn_ops.add_renewable_system_to_project( + proj, get_default_renewable_system_template() + ) + proj = rn_ops.add_offsite_renewable_procurement_to_project( + proj, get_default_offsite_renewable_procurement_template() + ) + assert len(proj.renewable.renewableSystem) == 1 + assert len(proj.renewable.offsiteRenewableProcurement) == 1 + + def test_get_renewable_system_keys_works_for_any_code(project: ComBuilding): # Read-only operations work for any code proj = _fresh(project) @@ -293,7 +280,7 @@ def test_get_renewable_system_keys_works_for_any_code(project: ComBuilding): def test_add_offsite_renewable_procurement(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) op = get_default_offsite_renewable_procurement_template() op.description = "PPA agreement" op.offsiteRenewableEnergy = 100000.0 @@ -307,7 +294,7 @@ def test_add_offsite_renewable_procurement(project: ComBuilding): def test_add_offsite_renewable_procurement_initializes_missing_list( project: ComBuilding, ): - proj = _set_supported_code(project) + proj = _fresh(project) # Force offsiteRenewableProcurement to MISSING proj.renewable.offsiteRenewableProcurement = MISSING @@ -324,7 +311,7 @@ def test_add_offsite_renewable_procurement_initializes_missing_list( def test_add_offsite_renewable_procurement_does_not_mutate_original( project: ComBuilding, ): - proj = _set_supported_code(project) + proj = _fresh(project) # Ensure list is initialized if proj.renewable.offsiteRenewableProcurement is MISSING: proj.renewable.offsiteRenewableProcurement = [] @@ -337,21 +324,13 @@ def test_add_offsite_renewable_procurement_does_not_mutate_original( assert len(proj.renewable.offsiteRenewableProcurement) == original_count -def test_add_offsite_renewable_procurement_unsupported_code(project: ComBuilding): - proj = _fresh(project) - proj.control.code = EnergyCodeOptions.CEZ_IECC2009 - op = get_default_offsite_renewable_procurement_template() - with pytest.raises(ValueError, match="not supported for energy code"): - rn_ops.add_offsite_renewable_procurement_to_project(proj, op) - - # --------------------------------------------------------------------------- # update_offsite_renewable_procurement_in_project # --------------------------------------------------------------------------- def test_update_offsite_renewable_procurement(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) op = get_default_offsite_renewable_procurement_template() op.description = "Wind PPA" proj = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) @@ -375,7 +354,7 @@ def test_update_offsite_renewable_procurement(project: ComBuilding): def test_update_offsite_renewable_procurement_not_found(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="not found"): rn_ops.update_offsite_renewable_procurement_in_project( proj, "Nonexistent", {"offsiteRenewableEnergy": 100.0} @@ -388,7 +367,7 @@ def test_update_offsite_renewable_procurement_not_found(project: ComBuilding): def test_remove_offsite_renewable_procurement(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) op = get_default_offsite_renewable_procurement_template() op.description = "REC purchase" proj = rn_ops.add_offsite_renewable_procurement_to_project(proj, op) @@ -402,7 +381,7 @@ def test_remove_offsite_renewable_procurement(project: ComBuilding): def test_remove_offsite_renewable_procurement_not_found(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) with pytest.raises(ValueError, match="not found"): rn_ops.remove_offsite_renewable_procurement_from_project(proj, "Nonexistent") @@ -413,7 +392,7 @@ def test_remove_offsite_renewable_procurement_not_found(project: ComBuilding): def test_get_offsite_renewable_procurement_keys(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) keys = rn_ops.get_offsite_renewable_procurement_keys_from_project(proj) assert isinstance(keys, list) @@ -441,7 +420,7 @@ def test_get_offsite_renewable_procurement_keys_handles_missing(project: ComBuil def test_exception_and_systems_coexist(project: ComBuilding): - proj = _set_supported_code(project) + proj = _fresh(project) # Set an exception proj = rn_ops.set_renewable_exception_in_project(