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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion comcheck_api/ai/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion comcheck_api/ai/skill/reference/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions comcheck_api/client/comcheck_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +29,7 @@
from comcheck_api.types.common_types import InteriorSpace
from comcheck_api.types.core_types import (
ComBuilding,
EfficiencyPackage,
ServiceWaterHeatingSystem,
HVACSystem,
HVACPlant,
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion comcheck_api/project_operations/project_credit_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs_site/api/operations/credits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/client/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
88 changes: 86 additions & 2 deletions tests/test_credits_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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 == []
Loading