diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c0034..ab4db50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 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 +- `get_default_renewable_system_template` and `get_default_offsite_renewable_procurement_template` default templates +- `COMcheckClient.area_for_renewable` — calculate `largestThreeFloorArea` and `roofAreaForRenewable` via `POST /renewable/area-for-renewable` and write them onto `project.renewable` +- `COMcheckClient.renewable_capacity` — calculate required/proposed renewable capacity and off-site renewable energy via `POST /renewable/renewable-capacity` and write them onto `project.renewable` +- `COMCheckApiService.area_for_renewable` and `COMCheckApiService.renewable_capacity` service methods +- `examples/client/renewable.py` example +- `RenewableSystem` and `OffsiteRenewableProcurement` added to `MODEL_TO_ID_INFO` with auto-generated descriptions + + + ## [2.1.0] - 2026-10-16 ### Added 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 570bbcb..73d4228 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 - **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 @@ -70,6 +71,14 @@ Triggers: method mutates the input or writes to `allowedWattage`; both return the raw calculation payload (`{"spaceAllowedWattage": ...}` for the single form, `{areaDescription: wattage, ...}` for the list form). +- **Renewable areas and capacity are calculated server-side**: + `area_for_renewable(project)` writes `largestThreeFloorArea` and + `roofAreaForRenewable` onto `project.renewable`; + `renewable_capacity(project)` writes `requiredCapacity`, + `proposedCapacity`, `requiredOffsiteRenewableEnergy`, and + `proposedOffsiteRenewableEnergy`. Both mutate and return the same + project, like `update_uvalues`. Both send the full project; the energy + code comes from `project.control.code`. ## Quick start @@ -153,8 +162,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`) @@ -162,11 +171,12 @@ print(result["performanceRating"]) methods (`check_UA_compliance`, `check_requirements`, `generate_report`) are also fully supported, as are the allowed-wattage methods (`calculate_interior_space_allowed_wattage`, - `calculate_interior_spaces_allowed_wattage`). If asked for an + `calculate_interior_spaces_allowed_wattage`) and the renewable methods + (`area_for_renewable`, `renewable_capacity`). 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 @@ -379,6 +389,55 @@ report = client.generate_report(project, use_backstop_compliance=True) library, so it returns metadata and lets the caller decide (`webbrowser.open(report["url"])`). +### Adding renewable energy systems + +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). + +```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 ( + RenewableExceptionOptions, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, + OffsiteRenewableProcurementType, +) + +# 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) + +# 4. Calculate renewable areas and capacity (written onto project.renewable) +project = client.area_for_renewable(project) +project = client.renewable_capacity(project) +``` + ## 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..e69dd1c 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,37 @@ 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 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). + +| 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 3af3421..979881a 100644 --- a/comcheck_api/api/api_services.py +++ b/comcheck_api/api/api_services.py @@ -417,6 +417,53 @@ def get_simulation_result(self, session_id: str) -> SimulationResultResponse: except Exception as error: self._handle_api_error(error) + def renewable_capacity(self, project_data: Dict[str, Any]) -> Dict[str, Any]: + """Calculate required and proposed renewable capacity for a project. + + Args: + project_data: The project data to send in the request body + + Returns: + API response data as dictionary. ``data`` holds + ``requiredRenewableCapacity``, ``proposedRenewableCapacity``, + ``requiredOffsiteRenewableProcurementEnergy``, + ``proposedOffsiteRenewableProcurementEnergy``, and + ``renewableComplianceStatus``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post("/renewable/renewable-capacity", json=project_data) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def area_for_renewable(self, project_data: Dict[str, Any]) -> Dict[str, Any]: + """Calculate the building areas used for renewable requirements. + + Args: + project_data: The project data to send in the request body + + Returns: + API response data as dictionary. ``data`` holds + ``largestThreeFloorArea`` and ``roofAreaForRenewable``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post("/renewable/area-for-renewable", json=project_data) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + 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 f45e287..5045a5f 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -20,6 +20,11 @@ Mode = Literal["python", "json"] +def _optional_float(value: Any) -> Optional[float]: + """Convert an API numeric value to ``float``, passing ``None`` through.""" + return None if value is None else float(value) + + class COMcheckClient: """High-level client for the COMcheck Web API. @@ -288,6 +293,71 @@ def update_uvalues(self, project: ComBuilding) -> ComBuilding: return project + def area_for_renewable(self, project: ComBuilding) -> ComBuilding: + """Calculate renewable area metrics and update the project in place. + + Sends the project 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. + """ + project_data = project.model_dump(mode="json") + areas = self._service.area_for_renewable(project_data).get("data") or {} + + renewable = project.renewable + renewable.largestThreeFloorArea = _optional_float( + areas.get("largestThreeFloorArea") + ) + renewable.roofAreaForRenewable = _optional_float( + areas.get("roofAreaForRenewable") + ) + return project + + def renewable_capacity(self, project: ComBuilding) -> ComBuilding: + """Calculate renewable capacity and update the project in place. + + Sends the project to the renewable-capacity endpoint and writes the + returned values back onto ``project.renewable``: + + - ``requiredRenewableCapacity`` → ``requiredCapacity`` + - ``proposedRenewableCapacity`` → ``proposedCapacity`` + - ``requiredOffsiteRenewableProcurementEnergy`` → + ``requiredOffsiteRenewableEnergy`` + - ``proposedOffsiteRenewableProcurementEnergy`` → + ``proposedOffsiteRenewableEnergy`` + + The endpoint's ``renewableComplianceStatus`` has no field on the + project model and is not written back. + + Args: + project: The project whose renewable capacity should be refreshed. + + Returns: + The same ``project`` instance, with renewable capacity updated. + """ + project_data = project.model_dump(mode="json") + capacity = self._service.renewable_capacity(project_data).get("data") or {} + + renewable = project.renewable + renewable.requiredCapacity = _optional_float( + capacity.get("requiredRenewableCapacity") + ) + renewable.proposedCapacity = _optional_float( + capacity.get("proposedRenewableCapacity") + ) + renewable.requiredOffsiteRenewableEnergy = _optional_float( + capacity.get("requiredOffsiteRenewableProcurementEnergy") + ) + renewable.proposedOffsiteRenewableEnergy = _optional_float( + capacity.get("proposedOffsiteRenewableProcurementEnergy") + ) + return project + 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..9487715 --- /dev/null +++ b/comcheck_api/constants/renewable_constants.py @@ -0,0 +1,24 @@ +"""Renewable energy constants for COMcheck projects.""" + +from comcheck_api.types.core_types import ( + OffsiteRenewableProcurement, + OffsiteRenewableProcurementType, + RenewableSystem, + RenewableSystemTypeOptions, + RenewableSystemCapacityUnitOptions, +) + +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..7478d88 --- /dev/null +++ b/comcheck_api/project_operations/project_renewable_operations.py @@ -0,0 +1,366 @@ +"""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[]`` + +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 +---------------------------------- +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, +) + +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. + """ + 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." + ) + + 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). + """ + 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. + """ + 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. + """ + _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. + """ + _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. + + 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. + """ + 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. + """ + _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. + """ + _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. + + 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..8b717e5 100644 --- a/comcheck_api/utilities/project_utilities.py +++ b/comcheck_api/utilities/project_utilities.py @@ -184,3 +184,59 @@ 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." + ) diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index 1b9b595..8b12977 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -1,12 +1,198 @@ -# 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 +## 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 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 + +- **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. + +## 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 calculations (API) + +Two `COMcheckClient` methods call the COMcheck Web API to calculate renewable +values from the full project. Neither one depends on the energy code being +in the request path. The server reads `control.code` from the project. + +### Renewable areas + +`area_for_renewable` calculates `largestThreeFloorArea` and +`roofAreaForRenewable` from the project's envelope and geometry, writes them +onto `project.renewable`, and returns the same project. It follows the same +pattern as `update_uvalues`: + +```python +project = client.area_for_renewable(project) +print(project.renewable.largestThreeFloorArea, project.renewable.roofAreaForRenewable) +``` + +### Renewable capacity + +`renewable_capacity` calculates the required vs. proposed renewable capacity +and off-site renewable energy, writes them onto `project.renewable`, and +returns the same project: + +```python +project = client.renewable_capacity(project) +``` + +| API value | Written to `project.renewable` | +| --- | --- | +| `requiredRenewableCapacity` | `requiredCapacity` | +| `proposedRenewableCapacity` | `proposedCapacity` | +| `requiredOffsiteRenewableProcurementEnergy` | `requiredOffsiteRenewableEnergy` | +| `proposedOffsiteRenewableProcurementEnergy` | `proposedOffsiteRenewableEnergy` | + +Any value the engine does not produce is set to `None`. The endpoint also +returns a `renewableComplianceStatus`, but the project model has no field for +it, so it is not written back. + +## 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/README.md b/examples/README.md index 1d9ed53..5ea5c79 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,12 +12,14 @@ examples/ │ ├── assemblies.py # Assembly u-value calculation examples │ ├── compliance_and_report.py # Compliance, requirements & report examples │ ├── allowed_wattage.py # Interior lighting allowed-wattage calculation examples +│ ├── renewable.py # Renewable area & capacity calculation examples │ └── user_functions.py # User-facing function examples └── project_operations/ # Project operations examples ├── building_area_operations.py # Building area operations ├── envelope_operations.py # Envelope operations ├── interior_lighting_operations.py # Interior lighting (InteriorSpace) operations - └── exterior_lighting_operations.py # Exterior lighting (ExteriorArea) operations + ├── exterior_lighting_operations.py # Exterior lighting (ExteriorArea) operations + └── renewable_operations.py # Renewable systems & off-site procurement operations ``` ## Example Categories @@ -64,6 +66,18 @@ Demonstrates interior lighting allowed-wattage calculation. python examples/client/allowed_wattage.py ``` +#### Renewable Examples (`client/renewable.py`) +Demonstrates server-side renewable calculations. + +**What it demonstrates:** +- `area_for_renewable()` - Calculating `largestThreeFloorArea` and `roofAreaForRenewable` and writing them onto `project.renewable` +- `renewable_capacity()` - Calculating required vs. proposed renewable capacity and off-site energy and writing them onto `project.renewable` + +**Usage:** +```bash +python examples/client/renewable.py +``` + #### User Function Examples (`client/user_functions.py`) Demonstrates user-facing API client functions. diff --git a/examples/client/renewable.py b/examples/client/renewable.py new file mode 100644 index 0000000..a625685 --- /dev/null +++ b/examples/client/renewable.py @@ -0,0 +1,72 @@ +"""Example of using COMcheck API client renewable calculations.""" + +import os +from dotenv import load_dotenv +from comcheck_api.client import COMcheckClient +from comcheck_api import project_renewable_operations as rn_ops +from comcheck_api.defaults import ( + get_default_project_template, + get_default_building_area_template, + get_default_roof_template, + get_default_floor_template, + get_default_renewable_system_template, +) +from comcheck_api.project_operations import ( + project_building_area_operations, + project_envelope_operations, +) +from comcheck_api.types.core_types import ( + EnergyCodeOptions, + RenewableSystemCapacityUnitOptions, + RenewableSystemTypeOptions, +) + +# Initialize client +load_dotenv(override=True) +client = COMcheckClient() +api_key = os.getenv("COM_API_KEY") or "your-api-key-here" +client.set_api_key(api_key) + +# Build a project with a roof, a floor, and an on-site solar system. +project = get_default_project_template() +project.control.code = EnergyCodeOptions.CEZ_IECC2021 + +project = project_building_area_operations.add_building_area_to_project( + project, get_default_building_area_template() +) +building_area_key = str(project.lighting.wholeBldgUse[0].key) + +project = project_envelope_operations.add_roof_to_project( + project, building_area_key, get_default_roof_template() +) +project = project_envelope_operations.add_floor_to_project( + project, building_area_key, get_default_floor_template() +) +project = rn_ops.update_renewable_in_project(project, {"numberOfFloors": 3}) + +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) + +# Example 1: Calculate renewable areas and update the project in place. +project = client.area_for_renewable(project) +print(f"largestThreeFloorArea: {project.renewable.largestThreeFloorArea}") +print(f"roofAreaForRenewable: {project.renewable.roofAreaForRenewable}") + +# Example 2: Calculate renewable capacity and update the project in place. +project = client.renewable_capacity(project) +print(f"requiredCapacity: {project.renewable.requiredCapacity}") +print(f"proposedCapacity: {project.renewable.proposedCapacity}") +print( + f"requiredOffsiteRenewableEnergy: {project.renewable.requiredOffsiteRenewableEnergy}" +) +print( + f"proposedOffsiteRenewableEnergy: {project.renewable.proposedOffsiteRenewableEnergy}" +) diff --git a/examples/project_operations/renewable_operations.py b/examples/project_operations/renewable_operations.py new file mode 100644 index 0000000..2c5365c --- /dev/null +++ b/examples/project_operations/renewable_operations.py @@ -0,0 +1,154 @@ +"""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[]). + +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 +------------ +- 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 ( + 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) + + +# ── 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..ad2c393 --- /dev/null +++ b/tests/project_operation_tests/test_renewable_operations.py @@ -0,0 +1,441 @@ +"""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) + + +# --------------------------------------------------------------------------- +# set_renewable_exception_in_project +# --------------------------------------------------------------------------- + + +def test_set_renewable_exception(project: ComBuilding): + proj = _fresh(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 = _fresh(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 = _fresh(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 = _fresh(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 + + +# --------------------------------------------------------------------------- +# update_renewable_in_project +# --------------------------------------------------------------------------- + + +def test_update_renewable_scalar_fields(project: ComBuilding): + 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 = _fresh(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 = _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 = _fresh(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 = _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"): + 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 = _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"): + rn_ops.update_renewable_in_project(proj, {"roofAreaForRenewable": 500.0}) + + +def test_update_renewable_rejects_list_fields(project: ComBuilding): + 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": []}) + + +# --------------------------------------------------------------------------- +# add_renewable_system_to_project +# --------------------------------------------------------------------------- + + +def test_add_renewable_system(project: ComBuilding): + proj = _fresh(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 = _fresh(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 = _fresh(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) + + +# --------------------------------------------------------------------------- +# update_renewable_system_in_project +# --------------------------------------------------------------------------- + + +def test_update_renewable_system(project: ComBuilding): + proj = _fresh(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 = _fresh(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 = _fresh(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 = _fresh(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 = _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) + 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 = _fresh(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 = _fresh(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 = _fresh(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 + + +# --------------------------------------------------------------------------- +# update_offsite_renewable_procurement_in_project +# --------------------------------------------------------------------------- + + +def test_update_offsite_renewable_procurement(project: ComBuilding): + 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) + + 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 = _fresh(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 = _fresh(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 = _fresh(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 = _fresh(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 = _fresh(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_comcheck_client.py b/tests/test_comcheck_client.py index 01fd682..e8e4446 100644 --- a/tests/test_comcheck_client.py +++ b/tests/test_comcheck_client.py @@ -11,7 +11,10 @@ from dotenv import load_dotenv from comcheck_api.client import COMcheckClient -from comcheck_api.defaults import get_default_interior_space_template +from comcheck_api.defaults import ( + get_default_interior_space_template, + get_default_project_template, +) from comcheck_api.exceptions import COMCheckHTTPError from comcheck_api.types.core_types import ActivityTypeOptions, EnergyCodeOptions @@ -112,3 +115,35 @@ def test_calculate_interior_spaces_allowed_wattage(client: COMcheckClient): assert isinstance(result, dict) assert "Open Office" in result assert "Conference Room" in result + + +def test_area_for_renewable(client: COMcheckClient): + """Test calculating renewable areas for a default project.""" + project = get_default_project_template() + project.control.code = EnergyCodeOptions.CEZ_IECC2021 + + try: + result = client.area_for_renewable(project) + except COMCheckHTTPError as exc: + if exc.status_code in (401, 403): + pytest.skip( + f"COM_API_KEY rejected ({exc.status_code}); skipping live test." + ) + raise + assert result is project + + +def test_renewable_capacity(client: COMcheckClient): + """Test calculating renewable capacity for a default project.""" + project = get_default_project_template() + project.control.code = EnergyCodeOptions.CEZ_IECC2021 + + try: + result = client.renewable_capacity(project) + except COMCheckHTTPError as exc: + if exc.status_code in (401, 403): + pytest.skip( + f"COM_API_KEY rejected ({exc.status_code}); skipping live test." + ) + raise + assert result is project diff --git a/tests/test_renewable_api.py b/tests/test_renewable_api.py new file mode 100644 index 0000000..5eddfba --- /dev/null +++ b/tests/test_renewable_api.py @@ -0,0 +1,136 @@ +"""Offline tests for the renewable API service and client methods. + +Requests are served by an ``httpx.MockTransport``, so no API key or network +access is needed. +""" + +import json + +import httpx +import pytest + +from comcheck_api import COMcheckClient +from comcheck_api.api.api_services import COMCheckApiService +from comcheck_api.defaults import get_default_project_template +from comcheck_api.exceptions import COMCheckHTTPError +from comcheck_api.types.core_types import EnergyCodeOptions + +AREA_DATA = {"largestThreeFloorArea": 30000.0, "roofAreaForRenewable": 8000.0} +CAPACITY_DATA = { + "requiredRenewableCapacity": 120.5, + "proposedRenewableCapacity": 150.0, + "requiredOffsiteRenewableProcurementEnergy": None, + "proposedOffsiteRenewableProcurementEnergy": None, + "renewableComplianceStatus": "PASS", +} + + +def _make_service(handler) -> COMCheckApiService: + """Build a service whose HTTP client is backed by *handler*.""" + service = COMCheckApiService(api_key="test") + service._client = httpx.Client( + base_url=service.base_url, + headers=service._prepare_headers(), + transport=httpx.MockTransport(handler), + ) + return service + + +def _make_client(handler) -> COMcheckClient: + client = COMcheckClient(api_key="test") + client._api_service = _make_service(handler) + return client + + +@pytest.fixture +def project(): + project = get_default_project_template() + project.control.code = EnergyCodeOptions.CEZ_IECC2021 + return project + + +@pytest.fixture +def requests(): + return [] + + +def _ok(requests, data): + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"message": "ok", "data": data}) + + return handler + + +def test_area_for_renewable_service_posts_project(requests, project): + service = _make_service(_ok(requests, AREA_DATA)) + project_data = project.model_dump(mode="json") + + result = service.area_for_renewable(project_data) + + assert result["data"] == AREA_DATA + (request,) = requests + assert request.method == "POST" + assert request.url.path.endswith("/renewable/area-for-renewable") + assert request.headers["x-api-key"] == "test" + assert json.loads(request.content) == project_data + + +def test_renewable_capacity_service_posts_project(requests, project): + service = _make_service(_ok(requests, CAPACITY_DATA)) + project_data = project.model_dump(mode="json") + + result = service.renewable_capacity(project_data) + + assert result["data"] == CAPACITY_DATA + (request,) = requests + assert request.method == "POST" + assert request.url.path.endswith("/renewable/renewable-capacity") + assert json.loads(request.content) == project_data + + +def test_renewable_service_maps_http_errors(project): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(400, json={"_tag": "InvalidBodyException"}) + + service = _make_service(handler) + with pytest.raises(COMCheckHTTPError) as exc_info: + service.area_for_renewable(project.model_dump(mode="json")) + assert exc_info.value.status_code == 400 + + with pytest.raises(COMCheckHTTPError): + service.renewable_capacity(project.model_dump(mode="json")) + + +def test_area_for_renewable_updates_project(requests, project): + client = _make_client(_ok(requests, AREA_DATA)) + + result = client.area_for_renewable(project) + + assert result is project + assert project.renewable.largestThreeFloorArea == 30000.0 + assert project.renewable.roofAreaForRenewable == 8000.0 + + +def test_area_for_renewable_handles_null_values(requests, project): + client = _make_client( + _ok(requests, {"largestThreeFloorArea": None, "roofAreaForRenewable": 0}) + ) + + client.area_for_renewable(project) + + assert project.renewable.largestThreeFloorArea is None + assert project.renewable.roofAreaForRenewable == 0.0 + + +def test_renewable_capacity_updates_project(requests, project): + client = _make_client(_ok(requests, CAPACITY_DATA)) + + result = client.renewable_capacity(project) + + assert result is project + renewable = project.renewable + assert renewable.requiredCapacity == 120.5 + assert renewable.proposedCapacity == 150.0 + assert renewable.requiredOffsiteRenewableEnergy is None + assert renewable.proposedOffsiteRenewableEnergy is None