From 85c28f3c432f122e89c16528989dfb91e10ca93c Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Mon, 2 Nov 2026 09:30:19 -0800 Subject: [PATCH] Add operation for a single credit option calculation --- CHANGELOG.md | 1 + comcheck_api/ai/skill/SKILL.md | 14 ++- comcheck_api/ai/skill/reference/operations.md | 4 +- comcheck_api/client/comcheck_client.py | 42 +++++++++ .../project_credit_operations.py | 4 +- docs_site/api/operations/credits.md | 30 +++++++ examples/README.md | 1 + examples/client/credits.py | 11 +++ tests/test_credits_api.py | 88 ++++++++++++++++++- 9 files changed, 190 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf7f87..7c18150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to this project are documented in this file. - `get_energy_credit_keys_from_project` — list the energy credits in a project - `set_energy_credit_multiplier_exception_in_project` and `set_core_and_shell_credit_in_project` — set the project-level credit fields - `COMcheckClient.calculate_energy_credits` — calculate proposed and required energy credits via `POST /update-energy-credits`, returning an `EnergyCreditResult` +- `COMcheckClient.calculate_single_energy_credit` — calculate the credits one energy credit earns for a building area when it is the only credit selected (runs on a copy of the project) - `COMCheckApiService.update_energy_credits` service method - `EnergyCreditResult`, `BuildingAreaCredit`, and `EnergyCreditMessage` response models - `examples/client/credits.py` example diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 7538649..960e233 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -88,6 +88,11 @@ Triggers: `required`, renewable and load management totals, `warnings`, `userAlerts`, and a derived `passes`). Unlike the renewable methods it does **not** modify the project. + `calculate_single_energy_credit(project, building_area_key, energy_credit)` + returns the same result for one `EfficiencyPackage` as if it were the only + credit (the other credits are dropped from a copy; `proposed` is that + credit's value). Use it when asked how much one + credit is worth. ## Quick start @@ -181,7 +186,7 @@ print(result["performanceRating"]) methods (`calculate_interior_space_allowed_wattage`, `calculate_interior_spaces_allowed_wattage`), the renewable methods (`area_for_renewable`, `renewable_capacity`), and - `calculate_energy_credits`. If asked for an + `calculate_energy_credits` / `calculate_single_energy_credit`. If asked for an unsupported mutation area, tell the user it's not implemented and offer building-area / envelope / lighting / mechanical / renewable / credits / simulation instead. `comcheck_api.list_operations()` enumerates the `building_area`, @@ -531,6 +536,13 @@ project = cr_ops.add_energy_credit_to_project( result = client.calculate_energy_credits(project) # project is not modified print(result.required, result.proposed, result.passes) + +# How much one credit is worth on its own (no need to add it first) +single = client.calculate_single_energy_credit( + project, area_key, + EfficiencyPackage(type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_FENESTRATION), +) +print(single.proposed, single.warnings) ``` ## Gotchas diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index ea3edaf..fdd313b 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -285,7 +285,9 @@ pick credit types from the enum for the project's code family. `remove_building_area_from_project` also removes the building area's energy credits. Use `client.calculate_energy_credits(project)` for the -proposed and required credits. +proposed and required credits, and +`client.calculate_single_energy_credit(project, building_area_key, energy_credit)` +for what one credit earns when it is the only credit selected. ## U-value calculation requires a construction type diff --git a/comcheck_api/client/comcheck_client.py b/comcheck_api/client/comcheck_client.py index 8f8474e..5bef8db 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -15,6 +15,9 @@ COMCheckProjectNotFoundError, COMCheckSimulationError, ) +from comcheck_api.project_operations.project_credit_operations import ( + add_energy_credit_to_project, +) from comcheck_api.types.api_types import ( EnergyCreditResult, WaterHeaterEfficiency, @@ -26,6 +29,7 @@ from comcheck_api.types.common_types import InteriorSpace from comcheck_api.types.core_types import ( ComBuilding, + EfficiencyPackage, ServiceWaterHeatingSystem, HVACSystem, HVACPlant, @@ -393,6 +397,44 @@ def calculate_energy_credits(self, project: ComBuilding) -> EnergyCreditResult: credits = self._service.update_energy_credits(project_data).get("data") or {} return EnergyCreditResult.model_validate(credits) + def calculate_single_energy_credit( + self, + project: ComBuilding, + building_area_key: str, + energy_credit: EfficiencyPackage, + ) -> EnergyCreditResult: + """Calculate the credits one energy credit earns on its own. + + Use this to check how many credits a credit is worth before (or + instead of) combining it with others. The calculation runs on a copy + of the project in which ``energy_credit`` is the only credit: all + ``efficiencyPackages`` are replaced by it, and all other project data + is kept. ``project`` is not modified, and the credit does not need to + be in it already. + + Args: + project: The project the credit applies to. + building_area_key: The key of the building area (``WholeBldgUse.key``) + that gets the credit. + energy_credit: The EfficiencyPackage to calculate. ``type`` selects + the credit; set the inputs that credit needs. + + Returns: + The result of the calculation. ``proposed`` (or + ``proposedRenewableAndLoadMgmt`` for renewable and load management + credits) is the credit's value, weighted by the building area's + share of the gross floor area. ``warnings`` report missing inputs. + + Raises: + ValueError: If the building area is not found. + """ + trial_project = project.model_copy(deep=True) + trial_project.efficiencyPackages = [] + trial_project = add_energy_credit_to_project( + trial_project, building_area_key, energy_credit + ) + return self.calculate_energy_credits(trial_project) + def calculate_interior_space_allowed_wattage( self, interior_space: InteriorSpace, energy_code: str ) -> Any: diff --git a/comcheck_api/project_operations/project_credit_operations.py b/comcheck_api/project_operations/project_credit_operations.py index 1b62509..d274c6a 100644 --- a/comcheck_api/project_operations/project_credit_operations.py +++ b/comcheck_api/project_operations/project_credit_operations.py @@ -23,7 +23,9 @@ ``EFF_PACKAGE_ENERGY_CREDIT_IECC2024_*``, ``ENERGY_CREDIT_VT_2024_*``, ...). These operations don't check ``control.code`` — which credits fit the project is up to you. Use :meth:`~comcheck_api.client.COMcheckClient.calculate_energy_credits` to -get the proposed and required credits. +get the proposed and required credits, and +:meth:`~comcheck_api.client.COMcheckClient.calculate_single_energy_credit` to +see what one credit earns when it is the only credit selected. """ from typing import Any diff --git a/docs_site/api/operations/credits.md b/docs_site/api/operations/credits.md index 3e2dbe2..2c70742 100644 --- a/docs_site/api/operations/credits.md +++ b/docs_site/api/operations/credits.md @@ -120,3 +120,33 @@ print("Meets required credits:", result.passes) `passes` is derived from the totals. The compliance check (`COMcheckClient.check_UA_compliance`) reports the official energy credit status. + +## Calculating a single credit + +Credits can interact, so the full calculation doesn't always show what one +credit is worth on its own. `COMcheckClient.calculate_single_energy_credit` +calculates one credit for one building area as if it were the only credit +selected. It works on a copy of the project with `efficiencyPackages` replaced +by that credit; the rest of the project is kept. The project is not modified, +and the credit doesn't need to be added to it first. + +```python +result = client.calculate_single_energy_credit( + project, + building_area_key, + EfficiencyPackage( + type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + airInfiltration=0.25, + airInfiltrationMeasurementType="AIR_INFILTRATION_MEASUREMENT_75PA", + ), +) +print(f"Credits from this credit alone: {result.proposed}") +for warning in result.warnings: + print(warning.level, warning.message) +``` + +The return value is the same `EnergyCreditResult`. `proposed` is the credit's +value (`proposedRenewableAndLoadMgmt` for renewable and load management +credits), weighted by the building area's share of the gross floor area. +Check `warnings` for missing inputs. Raises `ValueError` if the building area +is not found. diff --git a/examples/README.md b/examples/README.md index dfb0aee..2ac8db6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -87,6 +87,7 @@ Demonstrates energy credit operations and the server-side credit calculation. **What it demonstrates:** - `add_energy_credit_to_project()` / `update_energy_credit_in_project()` - Adding energy credits to a building area and updating their inputs - `calculate_energy_credits()` - Calculating proposed vs. required credits per building area (the project is not modified) +- `calculate_single_energy_credit()` - Calculating how many credits one credit earns when it is the only credit selected **Usage:** ```bash diff --git a/examples/client/credits.py b/examples/client/credits.py index 5d8927c..3b1b969 100644 --- a/examples/client/credits.py +++ b/examples/client/credits.py @@ -68,3 +68,14 @@ for alert in result.userAlerts: print(f" [{alert.level}] {alert.message}") print(f"Meets required credits: {result.passes}") + +# Example 4: Check how many credits one credit earns when it is the only credit +# selected. The credit doesn't have to be in the project, and the project is not modified. +single = client.calculate_single_energy_credit( + project, + building_area_key, + EfficiencyPackage(type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_FENESTRATION), +) +print(f"Enhanced fenestration alone - Proposed: {single.proposed}") +for warning in single.warnings: + print(f" [{warning.level}] {warning.message}") diff --git a/tests/test_credits_api.py b/tests/test_credits_api.py index ede87fd..fc3354d 100644 --- a/tests/test_credits_api.py +++ b/tests/test_credits_api.py @@ -11,10 +11,17 @@ 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.defaults import ( + get_default_building_area_template, + get_default_project_template, +) from comcheck_api.exceptions import COMCheckHTTPError +from comcheck_api.project_operations import ( + project_building_area_operations, + project_credit_operations, +) from comcheck_api.types.api_types import EnergyCreditResult -from comcheck_api.types.core_types import EnergyCodeOptions +from comcheck_api.types.core_types import EfficiencyPackage, EnergyCodeOptions CREDITS_DATA = { "energyCredits": [ @@ -165,3 +172,80 @@ def test_calculate_energy_credits_handles_missing_data(requests, project): ) def test_energy_credit_result_passes(totals, passes): assert EnergyCreditResult.model_validate(totals).passes is passes + + +def _project_with_two_credits(project): + project = project_building_area_operations.add_building_area_to_project( + project, get_default_building_area_template() + ) + area_key = str(project.lighting.wholeBldgUse[0].key) + for credit_type in ( + "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10", + "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ELECTRIC_STORAGE", + ): + project = project_credit_operations.add_energy_credit_to_project( + project, area_key, EfficiencyPackage(type=credit_type) + ) + return project, area_key + + +def test_calculate_single_energy_credit_sends_only_that_credit(requests, project): + project, area_key = _project_with_two_credits(project) + single = { + "energyCredits": [CREDITS_DATA["energyCredits"][0]], + "proposed": 3.2, + "required": 10, + } + client = _make_client(_ok(requests, single)) + before = project.model_dump(mode="json") + + result = client.calculate_single_energy_credit( + project, + area_key, + EfficiencyPackage( + type="EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION", + airInfiltration=0.25, + ), + ) + + assert isinstance(result, EnergyCreditResult) + assert result.proposed == 3.2 + (request,) = requests + assert request.url.path.endswith("/update-energy-credits") + sent = json.loads(request.content) + (sent_credit,) = sent["efficiencyPackages"] + assert sent_credit["type"] == "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION" + assert sent_credit["bldgUseKey"] == area_key + assert sent_credit["airInfiltration"] == 0.25 + assert project.model_dump(mode="json") == before + + +def test_calculate_single_energy_credit_accepts_credit_already_in_project( + requests, project +): + project, area_key = _project_with_two_credits(project) + client = _make_client(_ok(requests, CREDITS_DATA)) + + client.calculate_single_energy_credit( + project, + area_key, + EfficiencyPackage(type="EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10"), + ) + + sent = json.loads(requests[0].content) + assert [c["type"] for c in sent["efficiencyPackages"]] == [ + "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10" + ] + + +def test_calculate_single_energy_credit_unknown_building_area(requests, project): + project, _ = _project_with_two_credits(project) + client = _make_client(_ok(requests, CREDITS_DATA)) + + with pytest.raises(ValueError, match="not found"): + client.calculate_single_energy_credit( + project, + "no-such-area", + EfficiencyPackage(type="EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10"), + ) + assert requests == []