From 1237c24241600984ff98ad40f16d252a52f2b838 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Mon, 2 Nov 2026 09:44:43 -0800 Subject: [PATCH 1/2] renewable data woun't harm for energy code which doesn't support it --- comcheck_api/ai/skill/SKILL.md | 7 ++++--- comcheck_api/ai/skill/reference/operations.md | 5 +++-- .../project_operations/project_renewable_operations.py | 5 +++-- docs_site/api/operations/renewable-energy.md | 5 +++-- examples/project_operations/renewable_operations.py | 5 +++-- tests/project_operation_tests/test_renewable_operations.py | 4 ++-- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 0a01f94..23c74ce 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -406,9 +406,10 @@ library, so it returns metadata and lets the caller decide ### 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 +Renewable requirements only apply under some energy codes, but the +operations don't check `control.code` and you don't need to either. The API +decides whether the code uses renewable data, and renewable inputs on other +codes do no harm. 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). diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index fd957d5..a039099 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -244,8 +244,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 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 +some energy codes, but the operations don't check `control.code` and you +don't need to either: the API decides whether the code uses renewable data, +and renewable inputs on other codes do no harm. 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/project_operations/project_renewable_operations.py b/comcheck_api/project_operations/project_renewable_operations.py index 7478d88..dd17182 100644 --- a/comcheck_api/project_operations/project_renewable_operations.py +++ b/comcheck_api/project_operations/project_renewable_operations.py @@ -14,8 +14,9 @@ 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. +based on them). These operations don't check ``control.code``. The COMcheck Web +API decides whether the project's code uses renewable data, and renewable +inputs on a project whose code doesn't use them do no harm. Exception and systems coexistence ---------------------------------- diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index 8b12977..53f87b4 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -13,8 +13,9 @@ Renewable energy is managed at the **project level** through the ## 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. +based on them). These operations don't check `control.code`. The COMcheck Web +API decides whether the project's code uses renewable data, and renewable +inputs on a project whose code doesn't use them do no harm. ## Key concepts diff --git a/examples/project_operations/renewable_operations.py b/examples/project_operations/renewable_operations.py index 2c5365c..c79752d 100644 --- a/examples/project_operations/renewable_operations.py +++ b/examples/project_operations/renewable_operations.py @@ -8,8 +8,9 @@ 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. +based on them). These operations don't check ``control.code``. The COMcheck Web +API decides whether the project's code uses renewable data, and renewable +inputs on a project whose code doesn't use them do no harm. Key concepts ------------ diff --git a/tests/project_operation_tests/test_renewable_operations.py b/tests/project_operation_tests/test_renewable_operations.py index ad2c393..6da1c37 100644 --- a/tests/project_operation_tests/test_renewable_operations.py +++ b/tests/project_operation_tests/test_renewable_operations.py @@ -249,8 +249,8 @@ def test_get_renewable_system_keys(project: ComBuilding): 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. + # The API decides whether the code uses renewable data, 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( From a15a38be9665a01e948334501a65bcd6e69ceedb Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Mon, 2 Nov 2026 09:55:54 -0800 Subject: [PATCH 2/2] update renewable capacity after exception updates and limit area of 3 largest floors --- CHANGELOG.md | 2 + comcheck_api/ai/skill/SKILL.md | 24 +++++- comcheck_api/ai/skill/reference/operations.md | 4 +- comcheck_api/client/comcheck_client.py | 36 ++++++++ .../project_renewable_operations.py | 74 ++++++++++++++-- docs_site/api/operations/renewable-energy.md | 44 ++++++++-- .../renewable_operations.py | 24 ++++-- .../test_renewable_operations.py | 86 ++++++++++++++++++- tests/test_renewable_api.py | 48 ++++++++++- 9 files changed, 316 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b3361..861f54b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ All notable changes to this project are documented in this file. - `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 +- `COMcheckClient.set_renewable_exception` — set the renewable exception and refresh renewable capacity via `POST /renewable/renewable-capacity` in one call; the project is only changed if the API call succeeds +- `update_renewable_in_project` accepts `largestThreeFloorArea` when `numberOfFloors` is greater than 3, and rejects values larger than the total building area floor area (sum of `lighting.wholeBldgUse[].floorArea`). With 3 floors or fewer it stays API-calculated and is rejected - `examples/client/renewable.py` example - `RenewableSystem` and `OffsiteRenewableProcurement` added to `MODEL_TO_ID_INFO` with auto-generated descriptions - Mechanical operations module (`project_mechanical_operations`) for managing HVAC systems, fan systems, HVAC plants, and water heaters diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 23c74ce..39e1e61 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -82,6 +82,19 @@ Triggers: `proposedOffsiteRenewableEnergy`. Both mutate and return the same project, like `update_uvalues`. Both send the full project; the energy code comes from `project.control.code`. +- **Changing the renewable exception changes the capacity**: use + `client.set_renewable_exception(project, exception)`, which sets the + exception and calls `renewable_capacity` in one step (the project is only + changed if the API call succeeds). If you use + `rn_ops.set_renewable_exception_in_project` directly, call + `client.renewable_capacity(project)` right after. +- **`largestThreeFloorArea` depends on `numberOfFloors`**: with 3 floors or + fewer it is the total building area floor area and `area_for_renewable` + calculates it (setting it via `update_renewable_in_project` raises). With + more than 3 floors **ask the user for it** and set it with + `update_renewable_in_project(project, {"numberOfFloors": n, + "largestThreeFloorArea": area})`; it can't exceed the sum of + `lighting.wholeBldgUse[].floorArea`, or the op raises `ValueError`. - **Energy credits are calculated server-side**: `calculate_energy_credits(project)` returns an `EnergyCreditResult` (`energyCredits[]` per building area and credit type, `proposed`, @@ -427,11 +440,18 @@ from comcheck_api.types import ( OffsiteRenewableProcurementType, ) -# 1. Set a renewable exception (coexists with systems) -project = rn_ops.set_renewable_exception_in_project( +# 1. Set a renewable exception (coexists with systems). This also refreshes +# the renewable capacity from the API. +project = client.set_renewable_exception( project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_NONE ) +# 1b. More than 3 floors: largestThreeFloorArea comes from the user and can't +# exceed the total building area floor area +# project = rn_ops.update_renewable_in_project( +# project, {"numberOfFloors": 5, "largestThreeFloorArea": 30000.0} +# ) + # 2. Add an on-site renewable system renewable_system = get_default_renewable_system_template() renewable_system.description = "Rooftop Solar" diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index a039099..a54cc50 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -252,8 +252,8 @@ 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. | +| `set_renewable_exception_in_project(project, renewable_exception)` | Set `renewable.renewableException`. Raises `ValueError` for `NoneType_None`, `TypeError` for non-enum values. Doesn't refresh capacity; prefer `client.set_renewable_exception(project, renewable_exception)`, which also calls the renewable-capacity API. | +| `update_renewable_in_project(project, updates)` | Update `numberOfFloors` and, with more than 3 floors, `largestThreeFloorArea` (must be ≤ the sum of `lighting.wholeBldgUse[].floorArea`). Raises `ValueError` for `largestThreeFloorArea` with 3 floors or fewer (calculated by `area_for_renewable`), and 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`. | diff --git a/comcheck_api/client/comcheck_client.py b/comcheck_api/client/comcheck_client.py index 5bef8db..a3d0a18 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -18,6 +18,9 @@ from comcheck_api.project_operations.project_credit_operations import ( add_energy_credit_to_project, ) +from comcheck_api.project_operations.project_renewable_operations import ( + set_renewable_exception_in_project, +) from comcheck_api.types.api_types import ( EnergyCreditResult, WaterHeaterEfficiency, @@ -30,6 +33,7 @@ from comcheck_api.types.core_types import ( ComBuilding, EfficiencyPackage, + RenewableExceptionOptions, ServiceWaterHeatingSystem, HVACSystem, HVACPlant, @@ -379,6 +383,38 @@ def renewable_capacity(self, project: ComBuilding) -> ComBuilding: ) return project + def set_renewable_exception( + self, + project: ComBuilding, + renewable_exception: RenewableExceptionOptions, + ) -> ComBuilding: + """Set the renewable exception and refresh renewable capacity in place. + + The capacity fields depend on the exception, so this applies + :func:`~comcheck_api.project_operations.project_renewable_operations.set_renewable_exception_in_project` + and then calls :meth:`renewable_capacity`. ``project`` is only changed + if the API call succeeds. + + Args: + project: The project to update. + renewable_exception: A :class:`~comcheck_api.types.core_types.RenewableExceptionOptions` + value. Must not be ``NoneType_None``. + + Returns: + The same ``project`` instance, with the exception set and renewable + capacity updated. + + Raises: + TypeError: If renewable_exception is not a RenewableExceptionOptions member. + ValueError: If renewable_exception is NoneType_None. + """ + updated_project = set_renewable_exception_in_project( + project, renewable_exception + ) + self.renewable_capacity(updated_project) + project.renewable = updated_project.renewable + return project + def calculate_energy_credits(self, project: ComBuilding) -> EnergyCreditResult: """Calculate the proposed and required energy credits of a project. diff --git a/comcheck_api/project_operations/project_renewable_operations.py b/comcheck_api/project_operations/project_renewable_operations.py index dd17182..1746312 100644 --- a/comcheck_api/project_operations/project_renewable_operations.py +++ b/comcheck_api/project_operations/project_renewable_operations.py @@ -18,6 +18,20 @@ API decides whether the project's code uses renewable data, and renewable inputs on a project whose code doesn't use them do no harm. +Largest three floor area +------------------------ +With 3 floors or fewer, ``largestThreeFloorArea`` is the sum of the building +areas' ``floorArea`` and is calculated by +:meth:`~comcheck_api.client.COMcheckClient.area_for_renewable`. With more than +3 floors you provide it through :func:`update_renewable_in_project`, and it +can't exceed the total building area floor area. + +Renewable capacity +------------------ +The capacity fields depend on the renewable exception. Use +:meth:`~comcheck_api.client.COMcheckClient.set_renewable_exception` to set the +exception and refresh the capacity from the API in one call. + Exception and systems coexistence ---------------------------------- Setting a renewable exception does **not** preclude adding on-site renewable @@ -55,11 +69,14 @@ "proposedCapacity", "requiredOffsiteRenewableEnergy", "proposedOffsiteRenewableEnergy", - "largestThreeFloorArea", "roofAreaForRenewable", } ) +# Above this many floors, largestThreeFloorArea is user-provided instead of +# being the sum of the building areas' floorArea. +_MAX_FLOORS_FOR_CALCULATED_AREA = 3 + # List fields that must not be writable via update_renewable_in_project (use list operations instead) _LIST_FIELDS = frozenset( { @@ -78,6 +95,10 @@ def set_renewable_exception_in_project( On-site renewable systems and an exception can coexist — setting an exception does not preclude adding ``renewableSystem[]``, and vice versa. + This doesn't refresh the capacity fields, which depend on the exception. + Use :meth:`~comcheck_api.client.COMcheckClient.set_renewable_exception` to + set the exception and call the renewable-capacity API together. + Args: project: The project to modify. renewable_exception: A :class:`~comcheck_api.types.core_types.RenewableExceptionOptions` @@ -112,12 +133,18 @@ def update_renewable_in_project( ) -> ComBuilding: """Update scalar fields on the renewable singleton. - Only user-writable scalar fields (``numberOfFloors``) may be updated here. + User-writable scalar fields are ``numberOfFloors`` and, when the project + has more than 3 floors, ``largestThreeFloorArea``. With more than 3 floors, + ``largestThreeFloorArea`` can't exceed the total building area floor area + (the sum of ``lighting.wholeBldgUse[].floorArea``). With 3 floors or fewer + it is calculated by + :meth:`~comcheck_api.client.COMcheckClient.area_for_renewable` and can't + be set here. + Engine-calculated fields (``requiredCapacity``, ``proposedCapacity``, ``requiredOffsiteRenewableEnergy``, ``proposedOffsiteRenewableEnergy``), - API-calculated fields (``largestThreeFloorArea``, ``roofAreaForRenewable``), - and list fields (``renewableSystem``, ``offsiteRenewableProcurement``) are - rejected. + the API-calculated ``roofAreaForRenewable``, and list fields + (``renewableSystem``, ``offsiteRenewableProcurement``) are rejected. Args: project: The project to modify. @@ -129,7 +156,9 @@ 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). + negative numberOfFloors); if largestThreeFloorArea is set with 3 + floors or fewer; or if, with more than 3 floors, + largestThreeFloorArea exceeds the total building area floor area. """ if "renewableException" in updates: raise ValueError( @@ -147,10 +176,41 @@ def update_renewable_in_project( # 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) + renewable = Renewable.model_validate(merged) + + if renewable.numberOfFloors <= _MAX_FLOORS_FOR_CALCULATED_AREA: + if "largestThreeFloorArea" in updates: + raise ValueError( + f"largestThreeFloorArea can only be set when numberOfFloors is " + f"greater than {_MAX_FLOORS_FOR_CALCULATED_AREA} (got " + f"{renewable.numberOfFloors}). With " + f"{_MAX_FLOORS_FOR_CALCULATED_AREA} floors or fewer it is the " + f"total building area floor area; use " + f"COMcheckClient.area_for_renewable to calculate it." + ) + elif renewable.largestThreeFloorArea is not None: + total_floor_area = _total_building_area_floor_area(updated_project) + if renewable.largestThreeFloorArea > total_floor_area: + raise ValueError( + f"largestThreeFloorArea ({renewable.largestThreeFloorArea} ft2) " + f"cannot exceed the total building area floor area " + f"({total_floor_area} ft2) when numberOfFloors is greater than " + f"{_MAX_FLOORS_FOR_CALCULATED_AREA}." + ) + + updated_project.renewable = renewable return updated_project +def _total_building_area_floor_area(project: ComBuilding) -> float: + """Sum ``floorArea`` across ``lighting.wholeBldgUse``, skipping unset values.""" + return sum( + float(area.floorArea) + for area in project.lighting.wholeBldgUse + if area.floorArea is not MISSING and area.floorArea is not None + ) + + def add_renewable_system_to_project( project: ComBuilding, new_renewable_system: RenewableSystem, diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index 53f87b4..ba3c1ad 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -68,6 +68,18 @@ project = rn_ops.set_renewable_exception_in_project( `NoneType_None` is rejected with a `ValueError`. A raw string raises a `TypeError` — always use the enum. +The renewable capacity depends on the exception, so after changing it call +the renewable-capacity API. `COMcheckClient.set_renewable_exception` does +both: it sets the exception, calls `renewable_capacity`, and updates the +project in place (only if the API call succeeds): + +```python +project = client.set_renewable_exception( + project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD +) +print(project.renewable.requiredCapacity, project.renewable.proposedCapacity) +``` + ## Adding an on-site renewable system ```python @@ -119,16 +131,34 @@ project = rn_ops.add_offsite_renewable_procurement_to_project(project, offsite_p ## Updating scalar fields -Only user-writable scalar fields (currently just `numberOfFloors`) may be -updated via `update_renewable_in_project`. Engine-calculated fields +Only user-writable scalar fields may be updated via +`update_renewable_in_project`: `numberOfFloors` and, when the project has +more than 3 floors, `largestThreeFloorArea`. Engine-calculated fields (`requiredCapacity`, `proposedCapacity`, `requiredOffsiteRenewableEnergy`, -`proposedOffsiteRenewableEnergy`), API-calculated fields -(`largestThreeFloorArea`, `roofAreaForRenewable`), and list fields -(`renewableSystem`, `offsiteRenewableProcurement`) are rejected with a -`ValueError`: +`proposedOffsiteRenewableEnergy`), the API-calculated `roofAreaForRenewable`, +and list fields (`renewableSystem`, `offsiteRenewableProcurement`) are +rejected with a `ValueError`: ```python -project = rn_ops.update_renewable_in_project(project, {"numberOfFloors": 5}) +project = rn_ops.update_renewable_in_project(project, {"numberOfFloors": 2}) +``` + +### Largest three floor area + +How `largestThreeFloorArea` is set depends on `numberOfFloors`: + +- **3 floors or fewer**: it is the total building area floor area (the sum of + `lighting.wholeBldgUse[].floorArea`), calculated by `area_for_renewable`. + Setting it through `update_renewable_in_project` raises a `ValueError`. +- **More than 3 floors**: you provide it. It can't exceed the total building + area floor area, or `update_renewable_in_project` raises a `ValueError`. + The check also runs when you raise `numberOfFloors` above 3 with a stored + value already in place. + +```python +project = rn_ops.update_renewable_in_project( + project, {"numberOfFloors": 5, "largestThreeFloorArea": 30000.0} +) ``` ## Renewable calculations (API) diff --git a/examples/project_operations/renewable_operations.py b/examples/project_operations/renewable_operations.py index c79752d..c16dc96 100644 --- a/examples/project_operations/renewable_operations.py +++ b/examples/project_operations/renewable_operations.py @@ -54,8 +54,10 @@ # ── Step 1: Set the renewable exception ─────────────────────────────────────── -# A renewable exception can coexist with on-site renewable systems -project = rn_ops.set_renewable_exception_in_project( +# A renewable exception can coexist with on-site renewable systems. The +# renewable capacity depends on the exception, so set_renewable_exception also +# calls the renewable-capacity API and writes the results onto the project. +project = client.set_renewable_exception( project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_NONE ) @@ -121,13 +123,25 @@ 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}) +# ── Step 7: Update scalar fields on the renewable singleton ─────────────────── +# With more than 3 floors, largestThreeFloorArea is user-provided and can't +# exceed the total building area floor area. (With 3 floors or fewer it is +# calculated by client.area_for_renewable and can't be set here.) +total_floor_area = sum( + area.floorArea or 0.0 for area in project.lighting.wholeBldgUse +) +project = rn_ops.update_renewable_in_project( + project, + {"numberOfFloors": 5, "largestThreeFloorArea": total_floor_area * 0.6}, +) 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") +print( + f"Number of floors updated to 5, largestThreeFloorArea set to " + f"{project.renewable.largestThreeFloorArea}" +) # ── Step 8: Remove the offsite renewable procurement ────────────────────────── project = rn_ops.remove_offsite_renewable_procurement_from_project(project, "Wind PPA") diff --git a/tests/project_operation_tests/test_renewable_operations.py b/tests/project_operation_tests/test_renewable_operations.py index 6da1c37..3558f0f 100644 --- a/tests/project_operation_tests/test_renewable_operations.py +++ b/tests/project_operation_tests/test_renewable_operations.py @@ -4,6 +4,7 @@ from pydantic.experimental.missing_sentinel import MISSING from comcheck_api.defaults import ( + get_default_building_area_template, get_default_renewable_system_template, get_default_offsite_renewable_procurement_template, ) @@ -126,12 +127,93 @@ def test_update_renewable_rejects_engine_fields(project: ComBuilding): 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 _with_floor_areas(project: ComBuilding, *floor_areas) -> ComBuilding: + """Copy of *project* with exactly one building area per given floorArea.""" + proj = _fresh(project) + template = get_default_building_area_template() + proj.lighting.wholeBldgUse = [ + template.model_copy(update={"floorArea": area, "areaDescription": f"A{i}"}) + for i, area in enumerate(floor_areas) + ] + return proj + + +def test_update_largest_three_floor_area_above_three_floors(project: ComBuilding): + proj = _with_floor_areas(project, 6000.0, 4000.0) + result = rn_ops.update_renewable_in_project( + proj, {"numberOfFloors": 5, "largestThreeFloorArea": 9000.0} + ) + assert result.renewable.numberOfFloors == 5 + assert result.renewable.largestThreeFloorArea == 9000.0 + + +def test_update_largest_three_floor_area_can_equal_total(project: ComBuilding): + proj = _with_floor_areas(project, 6000.0, 4000.0) + result = rn_ops.update_renewable_in_project( + proj, {"numberOfFloors": 4, "largestThreeFloorArea": 10000.0} + ) + assert result.renewable.largestThreeFloorArea == 10000.0 + + +def test_update_largest_three_floor_area_rejects_more_than_total( + project: ComBuilding, +): + proj = _with_floor_areas(project, 6000.0, 4000.0) + with pytest.raises(ValueError, match="cannot exceed the total building area"): + rn_ops.update_renewable_in_project( + proj, {"numberOfFloors": 5, "largestThreeFloorArea": 10000.5} + ) + + +def test_update_largest_three_floor_area_skips_unset_floor_areas( + project: ComBuilding, +): + proj = _with_floor_areas(project, 6000.0, None) + with pytest.raises(ValueError, match="cannot exceed the total building area"): + rn_ops.update_renewable_in_project( + proj, {"numberOfFloors": 5, "largestThreeFloorArea": 6001.0} + ) + + +def test_update_floors_above_three_checks_existing_largest_area( + project: ComBuilding, +): + # Raising numberOfFloors past 3 re-checks the stored largestThreeFloorArea. + proj = _with_floor_areas(project, 5000.0) + proj.renewable.numberOfFloors = 2 + proj.renewable.largestThreeFloorArea = 8000.0 + with pytest.raises(ValueError, match="cannot exceed the total building area"): + rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 4}) + + +def test_update_largest_three_floor_area_rejected_at_three_floors_or_fewer( + project: ComBuilding, +): + proj = _with_floor_areas(project, 6000.0) + with pytest.raises(ValueError, match="area_for_renewable"): + rn_ops.update_renewable_in_project( + proj, {"numberOfFloors": 3, "largestThreeFloorArea": 1000.0} + ) + proj.renewable.numberOfFloors = 2 + with pytest.raises(ValueError, match="greater than 3"): + rn_ops.update_renewable_in_project(proj, {"largestThreeFloorArea": 1000.0}) + + +def test_update_floors_at_or_below_three_ignores_stored_largest_area( + project: ComBuilding, +): + # With 3 floors or fewer the stored value is recalculated by the API, so a + # stale value larger than the total doesn't block other updates. + proj = _with_floor_areas(project, 5000.0) + proj.renewable.largestThreeFloorArea = 8000.0 + result = rn_ops.update_renewable_in_project(proj, {"numberOfFloors": 3}) + assert result.renewable.numberOfFloors == 3 + + def test_update_renewable_rejects_list_fields(project: ComBuilding): proj = _fresh(project) with pytest.raises(ValueError, match="list fields"): diff --git a/tests/test_renewable_api.py b/tests/test_renewable_api.py index 5eddfba..6fc5deb 100644 --- a/tests/test_renewable_api.py +++ b/tests/test_renewable_api.py @@ -13,7 +13,7 @@ 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 +from comcheck_api.types.core_types import EnergyCodeOptions, RenewableExceptionOptions AREA_DATA = {"largestThreeFloorArea": 30000.0, "roofAreaForRenewable": 8000.0} CAPACITY_DATA = { @@ -134,3 +134,49 @@ def test_renewable_capacity_updates_project(requests, project): assert renewable.proposedCapacity == 150.0 assert renewable.requiredOffsiteRenewableEnergy is None assert renewable.proposedOffsiteRenewableEnergy is None + + +def test_set_renewable_exception_sets_exception_and_refreshes_capacity( + requests, project +): + client = _make_client(_ok(requests, CAPACITY_DATA)) + exception = RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + + result = client.set_renewable_exception(project, exception) + + assert result is project + assert project.renewable.renewableException == exception + assert project.renewable.requiredCapacity == 120.5 + assert project.renewable.proposedCapacity == 150.0 + # The capacity request is sent with the new exception already applied. + (request,) = requests + assert request.url.path.endswith("/renewable/renewable-capacity") + sent = json.loads(request.content) + assert sent["renewable"]["renewableException"] == exception.value + + +def test_set_renewable_exception_leaves_project_unchanged_on_api_error(project): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"_tag": "InternalServerError"}) + + client = _make_client(handler) + before = project.model_dump(mode="json") + + with pytest.raises(COMCheckHTTPError): + client.set_renewable_exception( + project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD + ) + + assert project.model_dump(mode="json") == before + + +def test_set_renewable_exception_validates_before_calling_api(requests, project): + client = _make_client(_ok(requests, CAPACITY_DATA)) + + with pytest.raises(ValueError): + client.set_renewable_exception(project, RenewableExceptionOptions.NoneType_None) + with pytest.raises(TypeError): + client.set_renewable_exception( + project, "RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD" + ) + assert requests == []