diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4db50..7afc4de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,22 @@ All notable changes to this project are documented in this file. - `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 +- Energy credit operations module (`project_credit_operations`) for managing energy credits (`efficiencyPackages`) per building area +- `add_energy_credit_to_project` / `update_energy_credit_in_project` / `remove_energy_credit_from_project` — manage a building area's energy credits +- `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` +- `COMCheckApiService.update_energy_credits` service method +- `EnergyCreditResult`, `BuildingAreaCredit`, and `EnergyCreditMessage` response models +- `examples/client/credits.py` example +- Schema: Vermont 2024 credit types (`VT2024EnergyCreditTypeOptions`), all eight `EnergyCreditMultiplierException` values, and the `EfficiencyPackage` inputs read by the IECC 2024 / Vermont 2024 credits (`onSiteRenewableCapacity`, `airInfiltrationMeasurementType`, `demandResponseSignal`, `demandResponseRequired`, `ventilationShiftIncluded`, `sumOfGuestRoomFloorAreaWithKitchens`, `sumOfTotalGuestRoomFloorArea`, `tsprProposed`, `tsprTarget`, `tsprReference`, `tsprMpf`) + +### Changed +- `ComBuilding.efficiencyPackages` is now typed `list[EfficiencyPackage]` (was `list[Any]`); the schema used `item` instead of `items`, so entries were never validated +- `remove_building_area_from_project` also removes the building area's energy credits + +### Deprecated +- `EfficiencyPackage.renewableCapacity` — use `onSiteRenewableCapacity`. The API still accepts it and copies it to `onSiteRenewableCapacity`. diff --git a/comcheck_api/__init__.py b/comcheck_api/__init__.py index d618a4c..188a398 100644 --- a/comcheck_api/__init__.py +++ b/comcheck_api/__init__.py @@ -57,6 +57,7 @@ # Project Operations from .project_operations import ( project_building_area_operations, + project_credit_operations, project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, @@ -89,6 +90,7 @@ "defaults", # Project Operations "project_building_area_operations", + "project_credit_operations", "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 73d4228..097e1c2 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -28,7 +28,8 @@ Triggers: - **Project shape**: a project is a `ComBuilding` Pydantic model with lowercase top-level fields: `project` (metadata), `location`, `envelope`, `lighting` (which contains `wholeBldgUse[]` — the - building areas), `hvac`, `renewable`, and `control` (energy code). + building areas), `hvac`, `renewable`, `efficiencyPackages` (energy + credits), and `control` (energy code). No `Project`/`Control` PascalCase aliases exist. The fields `hvac` and the shared `fixtureSchedule[]` exist on the model but have **no operation functions** — leave them @@ -40,6 +41,7 @@ Triggers: - `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 + - `project_credit_operations` — energy credits (`EfficiencyPackage` items) per building area, multiplier exception, core and shell credit - **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 @@ -79,6 +81,12 @@ 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`. +- **Energy credits are calculated server-side**: + `calculate_energy_credits(project)` returns an `EnergyCreditResult` + (`energyCredits[]` per building area and credit type, `proposed`, + `required`, renewable and load management totals, `warnings`, + `userAlerts`, and a derived `passes`). Unlike the renewable methods it + does **not** modify the project. ## Quick start @@ -171,12 +179,14 @@ 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`) and the renewable methods - (`area_for_renewable`, `renewable_capacity`). If asked for an + `calculate_interior_spaces_allowed_wattage`), the renewable methods + (`area_for_renewable`, `renewable_capacity`), and + `calculate_energy_credits`. If asked for an unsupported mutation area, tell the user it's not implemented and - offer building-area / envelope / lighting / renewable / simulation instead. + offer building-area / envelope / lighting / renewable / credits / simulation instead. `comcheck_api.list_operations()` enumerates the `building_area`, - `envelope`, `interior_lighting`, `exterior_lighting`, and `renewable` groups. + `envelope`, `interior_lighting`, `exterior_lighting`, `renewable`, and + `credits` groups. ## Common patterns @@ -438,6 +448,36 @@ project = client.area_for_renewable(project) project = client.renewable_capacity(project) ``` +### Adding energy credits + +Energy credits apply to IECC 2021+, ASHRAE 90.1-2022, and state codes +based on them; each code family has its own credit type enum +(`EfficiencyPackageTypeOptions`, `IECC2024EnergyCreditTypeOptions`, +`VT2024EnergyCreditTypeOptions`, ...). Pick credit types matching +`project.control.code`. A building area holds at most one credit per type. + +```python +from comcheck_api import project_credit_operations as cr_ops +from comcheck_api.types.core_types import ( + EfficiencyPackage, + IECC2024EnergyCreditTypeOptions as Credit, +) + +area_key = ba_ops.get_building_area_keys_from_project(project)[0]["key"] +project = cr_ops.add_energy_credit_to_project( + project, + area_key, + EfficiencyPackage( + type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + airInfiltration=0.25, + airInfiltrationMeasurementType="AIR_INFILTRATION_MEASUREMENT_75PA", + ), +) + +result = client.calculate_energy_credits(project) # project is not modified +print(result.required, result.proposed, result.passes) +``` + ## 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 e69dd1c..1fe36da 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -1,12 +1,13 @@ # Project Operations Reference -Operation functions are free functions in five modules: +Operation functions are free functions in six 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` +- `comcheck_api.project_operations.project_credit_operations` Each function takes a `ComBuilding` and a payload, and returns a new `ComBuilding`. Treat them as immutable transformations. @@ -206,6 +207,31 @@ Use `get_default_renewable_system_template()` and `description` is the identifier — it is unique within each list and is auto-generated if missing. +## Energy credit operations + +```python +from comcheck_api import project_credit_operations as cr_ops +``` + +Energy credits live in the flat `project.efficiencyPackages` list. Each +`EfficiencyPackage` belongs to a building area (`bldgUseKey` = +`WholeBldgUse.key`) and is identified by its `type`; a building area holds +at most one credit per type. The operations don't check `control.code` — +pick credit types from the enum for the project's code family. + +| Function | Notes | +|---|---| +| `add_energy_credit_to_project(project, building_area_key, new_energy_credit)` | Sets `bldgUseKey`. Raises `ValueError` if the building area is missing or already has that credit type. | +| `update_energy_credit_in_project(project, building_area_key, credit_type, updates)` | Revalidates the merged entry. Raises `ValueError` for `type` / `bldgUseKey` updates (remove + add instead). | +| `remove_energy_credit_from_project(project, building_area_key, credit_type)` | Remove one credit. | +| `get_energy_credit_keys_from_project(project, building_area_key=None)` | List `[{bldgUseKey, type}, …]`, optionally for one building area. | +| `set_energy_credit_multiplier_exception_in_project(project, multiplier_exception)` | Set `energyCreditMultiplierException` (IECC 2024 Appendix C/F). Raises `TypeError` for non-enum values. | +| `set_core_and_shell_credit_in_project(project, core_and_shell_credit)` | Set `projectCoreAndShellCredit`; `None` clears it. Raises `ValueError` for negative values. | + +`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. + ## 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 317fb63..36441b9 100644 --- a/comcheck_api/ai/skill/scripts/validate_code.py +++ b/comcheck_api/ai/skill/scripts/validate_code.py @@ -31,8 +31,9 @@ def _read_input(arg: str) -> str: UNSUPPORTED_PROJECT_ATTRS = {"hvac"} # `wholeBldgUse` (building areas + interior lighting), `activityUse` (interior # 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. +# `renewable` (renewable energy systems and procurement), and `efficiencyPackages` +# (energy credits) all have operation modules. Only `hvac` and `fixtureSchedule` +# have no operations yet. UNSUPPORTED_LIGHTING_ATTRS = { "fixtureSchedule", } @@ -80,8 +81,8 @@ def validate(code: str) -> dict: 1. Syntax check via :func:`compile`. 2. Import check on every imported module name. 3. Scope check that the code only uses operations actually exposed - by the SDK and does not mutate the unsupported `hvac`, - `renewable`, or `lighting.fixtureSchedule` subtrees. + by the SDK and does not mutate the unsupported `hvac` or + `lighting.fixtureSchedule` subtrees. """ errors: list[dict] = [] diff --git a/comcheck_api/api/api_services.py b/comcheck_api/api/api_services.py index 979881a..489ee46 100644 --- a/comcheck_api/api/api_services.py +++ b/comcheck_api/api/api_services.py @@ -464,6 +464,29 @@ def area_for_renewable(self, project_data: Dict[str, Any]) -> Dict[str, Any]: except Exception as error: self._handle_api_error(error) + def update_energy_credits(self, project_data: Dict[str, Any]) -> Dict[str, Any]: + """Calculate the proposed and required energy credits for a project. + + Args: + project_data: The project data to send in the request body + + Returns: + API response data as dictionary. ``data`` holds ``energyCredits``, + ``proposed``, ``required``, ``proposedRenewableAndLoadMgmt``, + ``requiredRenewableAndLoadMgmt``, ``warnings``, and ``userAlerts``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post("/update-energy-credits", 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 5045a5f..f29d949 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -14,6 +14,7 @@ COMCheckProjectNotFoundError, COMCheckSimulationError, ) +from comcheck_api.types.api_types import EnergyCreditResult from comcheck_api.types.common_types import InteriorSpace from comcheck_api.types.core_types import ComBuilding @@ -358,6 +359,24 @@ def renewable_capacity(self, project: ComBuilding) -> ComBuilding: ) return project + def calculate_energy_credits(self, project: ComBuilding) -> EnergyCreditResult: + """Calculate the proposed and required energy credits of a project. + + Sends the project to the update-energy-credits endpoint. Nothing is + written back to the project — the project model has no fields for + calculated credits. + + Args: + project: The project whose energy credits should be calculated. + + Returns: + The credits achieved per building area and credit type, the proposed + and required totals, and any warnings and user alerts. + """ + project_data = project.model_dump(mode="json") + credits = self._service.update_energy_credits(project_data).get("data") or {} + return EnergyCreditResult.model_validate(credits) + def calculate_interior_space_allowed_wattage( self, interior_space: InteriorSpace, energy_code: str ) -> Any: diff --git a/comcheck_api/introspection.py b/comcheck_api/introspection.py index 8f56b5e..da81315 100644 --- a/comcheck_api/introspection.py +++ b/comcheck_api/introspection.py @@ -22,6 +22,7 @@ from comcheck_api import ( project_building_area_operations, + project_credit_operations, project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, @@ -34,6 +35,7 @@ "interior_lighting": project_interior_lighting_operations, "exterior_lighting": project_exterior_lighting_operations, "renewable": project_renewable_operations, + "credits": project_credit_operations, } diff --git a/comcheck_api/project_operations/__init__.py b/comcheck_api/project_operations/__init__.py index dfb3085..cea321c 100644 --- a/comcheck_api/project_operations/__init__.py +++ b/comcheck_api/project_operations/__init__.py @@ -2,6 +2,7 @@ from . import ( project_building_area_operations, + project_credit_operations, project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, @@ -10,6 +11,7 @@ __all__ = [ "project_building_area_operations", + "project_credit_operations", "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", diff --git a/comcheck_api/project_operations/project_building_area_operations.py b/comcheck_api/project_operations/project_building_area_operations.py index 5e3f3f0..35e1627 100644 --- a/comcheck_api/project_operations/project_building_area_operations.py +++ b/comcheck_api/project_operations/project_building_area_operations.py @@ -1,6 +1,7 @@ """Project Building Area Operations.""" from typing import Any +from pydantic.experimental.missing_sentinel import MISSING from comcheck_api.constants.building_area_constants import DEFAULT_BUILDING_AREA from comcheck_api.types.core_types import ComBuilding, WholeBldgUse @@ -91,6 +92,8 @@ def remove_building_area_from_project( ) -> ComBuilding: """Remove an existing building area in the project. + Energy credits (``efficiencyPackages``) of the building area are removed too. + Args: project: The project object to modify building_area_key: The key of the building area to update @@ -106,6 +109,12 @@ def remove_building_area_from_project( updated_project.lighting.remove_from_subcomponent_list( subcomponent_id=building_area_key, subcomponent_name="wholeBldgUse" ) + if updated_project.efficiencyPackages is not MISSING: + updated_project.efficiencyPackages = [ + efficiency_package + for efficiency_package in updated_project.efficiencyPackages + if efficiency_package.bldgUseKey != building_area_key + ] return updated_project diff --git a/comcheck_api/project_operations/project_credit_operations.py b/comcheck_api/project_operations/project_credit_operations.py new file mode 100644 index 0000000..1b62509 --- /dev/null +++ b/comcheck_api/project_operations/project_credit_operations.py @@ -0,0 +1,262 @@ +"""Project Energy Credit Operations. + +Manages energy credits (efficiency packages) at the project level. Credits live +in the flat ``ComBuilding.efficiencyPackages`` list. Each +:class:`~comcheck_api.types.core_types.EfficiencyPackage` entry is tied to a +building area by ``bldgUseKey`` (the ``WholeBldgUse.key``) and identified by +its ``type``; a building area holds at most one entry per credit type. + +The extra inputs a credit needs depend on its type, e.g. ``airInfiltration`` and +``airInfiltrationMeasurementType`` for the reduced air infiltration credits or +``onSiteRenewableCapacity`` for on-site renewable credits. See the field +descriptions on ``EfficiencyPackage``. + +Project-level fields that also affect credits: + +- ``energyCreditMultiplierException`` — IECC 2024 Appendix C/F multiplier exception +- ``projectCoreAndShellCredit`` — credits gained from core and shell + +Energy codes +------------ +Energy credits apply to IECC 2021+, ASHRAE 90.1-2022, and state codes based on +them, and each code family has its own credit types (``EFF_PACKAGE_ENERGY_CREDIT_*``, +``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. +""" + +from typing import Any +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.types.core_types import ( + ComBuilding, + EfficiencyPackage, + EnergyCreditMultiplierException, +) +from comcheck_api.utilities.project_utilities import _require_building_area + +# Identity fields; change them with remove + add instead +_IDENTITY_FIELDS = frozenset({"type", "bldgUseKey"}) + + +def _find_energy_credit_index( + project: ComBuilding, building_area_key: str, credit_type: str +) -> int | None: + efficiency_packages = project.efficiencyPackages + if efficiency_packages is MISSING: + return None + for index, efficiency_package in enumerate(efficiency_packages): + if efficiency_package.bldgUseKey == building_area_key and str( + efficiency_package.type + ) == str(credit_type): + return index + return None + + +def _require_energy_credit_index( + project: ComBuilding, building_area_key: str, credit_type: str +) -> int: + index = _find_energy_credit_index(project, building_area_key, credit_type) + if index is None: + raise ValueError( + f"Energy credit '{credit_type}' not found for building area " + f"'{building_area_key}' in efficiencyPackages." + ) + return index + + +def add_energy_credit_to_project( + project: ComBuilding, + building_area_key: str, + new_energy_credit: EfficiencyPackage, +) -> ComBuilding: + """Add an energy credit to a building area. + + ``new_energy_credit.bldgUseKey`` is set to ``building_area_key``. + + Args: + project: The project to modify. + building_area_key: The key of the building area (``WholeBldgUse.key``) + that gets the credit. + new_energy_credit: The EfficiencyPackage to add. ``type`` selects the + credit; set the inputs that credit needs. + + Returns: + Updated project with the energy credit added. + + Raises: + ValueError: If the building area is not found, or it already has a + credit of the same type. + """ + _require_building_area(project, building_area_key) + if ( + _find_energy_credit_index(project, building_area_key, new_energy_credit.type) + is not None + ): + raise ValueError( + f"Building area '{building_area_key}' already has energy credit " + f"'{new_energy_credit.type}'. Use update_energy_credit_in_project to change it." + ) + + updated_project = project.model_copy(deep=True) + new_energy_credit = new_energy_credit.model_copy(deep=True) + new_energy_credit.bldgUseKey = building_area_key + + if updated_project.efficiencyPackages is MISSING: + updated_project.efficiencyPackages = [] + updated_project.efficiencyPackages.append(new_energy_credit) + return updated_project + + +def update_energy_credit_in_project( + project: ComBuilding, + building_area_key: str, + credit_type: str, + updates: dict[str, Any], +) -> ComBuilding: + """Update the inputs of an existing energy credit. + + ``type`` and ``bldgUseKey`` identify the credit and cannot be updated here; + remove the credit and add a new one instead. + + Args: + project: The project to modify. + building_area_key: The key of the building area that has the credit. + credit_type: The ``type`` of the credit to update. + updates: Partial updates (dict) to apply to the EfficiencyPackage. + + Returns: + Updated project with the energy credit modified. + + Raises: + ValueError: If the credit is not found, updates contains ``type`` or + ``bldgUseKey``, or a value fails validation. + """ + index = _require_energy_credit_index(project, building_area_key, credit_type) + + disallowed = _IDENTITY_FIELDS & updates.keys() + if disallowed: + raise ValueError( + f"Cannot update {sorted(disallowed)} via update_energy_credit_in_project. " + "Remove the energy credit and add a new one instead." + ) + + # Revalidate the merged entry — plain setattr skips Pydantic validation + updated_project = project.model_copy(deep=True) + current = updated_project.efficiencyPackages[index] + merged = {**current.model_dump(mode="python"), **updates} + updated_project.efficiencyPackages[index] = EfficiencyPackage.model_validate(merged) + return updated_project + + +def remove_energy_credit_from_project( + project: ComBuilding, + building_area_key: str, + credit_type: str, +) -> ComBuilding: + """Remove an energy credit from a building area. + + Args: + project: The project to modify. + building_area_key: The key of the building area that has the credit. + credit_type: The ``type`` of the credit to remove. + + Returns: + Updated project with the energy credit removed. + + Raises: + ValueError: If the credit is not found. + """ + index = _require_energy_credit_index(project, building_area_key, credit_type) + + updated_project = project.model_copy(deep=True) + del updated_project.efficiencyPackages[index] + return updated_project + + +def get_energy_credit_keys_from_project( + project: ComBuilding, + building_area_key: str | None = None, +) -> list[dict]: + """Return the building area key and type of the project's energy credits. + + Args: + project: The project to query. + building_area_key: Only return credits of this building area. Returns + all credits when ``None``. + + Returns: + List of dicts with keys ``bldgUseKey`` and ``type`` for each + EfficiencyPackage in the project. + """ + efficiency_packages = project.efficiencyPackages + if efficiency_packages is MISSING: + return [] + return [ + { + "bldgUseKey": efficiency_package.bldgUseKey, + "type": efficiency_package.type, + } + for efficiency_package in efficiency_packages + if building_area_key is None + or efficiency_package.bldgUseKey == building_area_key + ] + + +def set_energy_credit_multiplier_exception_in_project( + project: ComBuilding, + multiplier_exception: EnergyCreditMultiplierException, +) -> ComBuilding: + """Set the energy credit multiplier exception on the project. + + Used by IECC 2024 Appendix C/F for new construction and additions. + + Args: + project: The project to modify. + multiplier_exception: An + :class:`~comcheck_api.types.core_types.EnergyCreditMultiplierException` + value. + + Returns: + Updated project with the multiplier exception set. + + Raises: + TypeError: If multiplier_exception is not an EnergyCreditMultiplierException member. + """ + if not isinstance(multiplier_exception, EnergyCreditMultiplierException): + raise TypeError( + f"multiplier_exception must be an EnergyCreditMultiplierException member, " + f"got {type(multiplier_exception).__name__!r}." + ) + + updated_project = project.model_copy(deep=True) + updated_project.energyCreditMultiplierException = multiplier_exception + return updated_project + + +def set_core_and_shell_credit_in_project( + project: ComBuilding, + core_and_shell_credit: float | None, +) -> ComBuilding: + """Set the credits gained from core and shell on the project. + + Applies to initial build-out projects whose mechanical type is core and shell. + + Args: + project: The project to modify. + core_and_shell_credit: Credits gained from core and shell, or ``None`` + to clear them. + + Returns: + Updated project with ``projectCoreAndShellCredit`` set. + + Raises: + ValueError: If core_and_shell_credit is negative. + """ + if core_and_shell_credit is not None and core_and_shell_credit < 0: + raise ValueError("core_and_shell_credit must not be negative.") + + updated_project = project.model_copy(deep=True) + updated_project.projectCoreAndShellCredit = core_and_shell_credit + return updated_project diff --git a/comcheck_api/schemas/comCheck.schema.json b/comcheck_api/schemas/comCheck.schema.json index e31c52e..d1b63e3 100644 --- a/comcheck_api/schemas/comCheck.schema.json +++ b/comcheck_api/schemas/comCheck.schema.json @@ -127,7 +127,7 @@ "efficiencyPackages": { "description": "Efficiency Package or Energy Credits", "type": "array", - "item": { + "items": { "$ref": "comCheck.schema.json#/definitions/EfficiencyPackage" }, "minItems": 0 @@ -267,8 +267,13 @@ "description": "Energy Credit Multiplier Exception", "enum": [ "NO_ENERGY_CREDIT_MULTIPLIER_EXCEPTION", - "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PEAK_HEATING_LOAD", "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PRIMARY_HEAT_PUMP", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_MEETING_LOAD", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_ELECTRIC_RESISTANCE_HEAT", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_UTILITY_MISC_GROUP", + "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE", null ] } @@ -4760,7 +4765,7 @@ "null" ], "minimum": 0.0, - "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_ONSITE_RENEWABLES, EFF_PACKAGE_ENERGY_CREDIT_DENVER_ONSITE_RENEWABLES, EFF_PACKAGE_ENERGY_CREDIT_DENVER_ENHANCED_RENEWABLES, EFF_PACKAGE_ENERGY_CREDIT_MAS_ONSITE_RENEWABLES]" + "$comment": "Deprecated, use onSiteRenewableCapacity. Still accepted and copied to onSiteRenewableCapacity by the API when that is not set." }, "renewableType": { "description": "Renewable system type", @@ -4932,6 +4937,107 @@ "minimum": 0, "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_EFFICIENT_ELEVATOR, EFF_PACKAGE_ENERGY_CREDIT_IECC2024_EFFICIENT_ELEVATOR]" }, + "onSiteRenewableCapacity": { + "description": "On-site renewable capacity, unit is Btu for thermal system and watts for electric system.", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "$comment": "Read by the compliance engine for IECC 2021, IECC 2024 and VT 2024 on-site renewable energy credits. Supersedes renewableCapacity." + }, + "airInfiltrationMeasurementType": { + "description": "Pressure at which air infiltration was measured", + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "comCheck.schema.json#/definitions/AirInfiltrationMeasurementTypeOptions" + } + ], + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, ENERGY_CREDIT_VT_2024_REDUCE_AIR_INFILTRATION]" + }, + "demandResponseSignal": { + "description": "Flag indicates the load management system responds to a demand response signal", + "type": [ + "boolean", + "null" + ], + "$comment": "Used for IECC 2024 and VT 2024 load management energy credits" + }, + "demandResponseRequired": { + "description": "Flag indicates demand response is required", + "type": [ + "boolean", + "null" + ], + "$comment": "Used for IECC 2024 and VT 2024 load management energy credits" + }, + "ventilationShiftIncluded": { + "description": "Flag indicates ventilation load shifting is included", + "type": [ + "boolean", + "null" + ], + "$comment": "Used for IECC 2024 and VT 2024 HVAC and lighting load management energy credits" + }, + "sumOfGuestRoomFloorAreaWithKitchens": { + "description": "Sum of guest room floor area with kitchens", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "unit": "sqft", + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_EFFICIENT_RES_KITCHEN_EQUIPMENT] and the VT 2024 equivalent" + }, + "sumOfTotalGuestRoomFloorArea": { + "description": "Sum of total guest room floor area", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "unit": "sqft", + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_EFFICIENT_RES_KITCHEN_EQUIPMENT] and the VT 2024 equivalent" + }, + "tsprProposed": { + "description": "Proposed total system performance ratio (TSPR)", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_HVAC_TSPR] and the VT 2024 equivalent" + }, + "tsprTarget": { + "description": "Target total system performance ratio (TSPR)", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_HVAC_TSPR] and the VT 2024 equivalent" + }, + "tsprReference": { + "description": "Reference total system performance ratio (TSPR)", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_HVAC_TSPR] and the VT 2024 equivalent" + }, + "tsprMpf": { + "description": "TSPR mechanical performance factor (MPF)", + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "$comment": "Used when $.type is in [EFF_PACKAGE_ENERGY_CREDIT_IECC2024_HVAC_TSPR] and the VT 2024 equivalent" + }, "type": { "description": "Efficiency Package or Energy Credit Type", "anyOf": [ @@ -4946,6 +5052,9 @@ }, { "$ref": "comCheck.schema.json#/definitions/IECC2024EnergyCreditTypeOptions" + }, + { + "$ref": "comCheck.schema.json#/definitions/VT2024EnergyCreditTypeOptions" } ] } @@ -7604,6 +7713,61 @@ "C406.3.7 G07 Building thermal mass" ] }, + "VT2024EnergyCreditTypeOptions": { + "type": "string", + "enum": [ + "ENERGY_CREDIT_VT_2024_ENVELOPE_PERFORMANCE", + "ENERGY_CREDIT_VT_2024_UA_REDUCTION", + "ENERGY_CREDIT_VT_2024_ENVELOPE_LEAK_REDUCTION", + "ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R10", + "ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R5", + "ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R5", + "ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R2_5", + "ENERGY_CREDIT_VT_2024_IMPROVE_FENESTRATION", + "ENERGY_CREDIT_VT_2024_HVAC_PERFORMANCE", + "ENERGY_CREDIT_VT_2024_HEATING_EFFICIENCY", + "ENERGY_CREDIT_VT_2024_COOLING_EFFICIENCY", + "ENERGY_CREDIT_VT_2024_RES_HVAC_CTRL", + "ENERGY_CREDIT_VT_2024_ENERGY_RECOVERY", + "ENERGY_CREDIT_VT_2024_SHW_PREHEAT_RECOVERY", + "ENERGY_CREDIT_VT_2024_HP_WATER_HEATER", + "ENERGY_CREDIT_VT_2024_SHW_PIPE_INSULATION", + "ENERGY_CREDIT_VT_2024_POINT_USE_WATER_HEATER", + "ENERGY_CREDIT_VT_2024_THERM_BALANCE_VALVES", + "ENERGY_CREDIT_VT_2024_SHW_HEAT_TRACE", + "ENERGY_CREDIT_VT_2024_SHW_SUBMETERS", + "ENERGY_CREDIT_VT_2024_SHW_DIST_SIZING", + "ENERGY_CREDIT_VT_2024_SHOWER_HEAT_RECOVERY", + "ENERGY_CREDIT_VT_2024_ENERGY_MONITOR", + "ENERGY_CREDIT_VT_2024_ENHANCED_LGT_CTRL", + "ENERGY_CREDIT_VT_2024_INCREASE_OCCP_SENSORS", + "ENERGY_CREDIT_VT_2024_INCREASE_DAYLIT_AREA", + "ENERGY_CREDIT_VT_2024_RES_LGT_CTRL", + "ENERGY_CREDIT_VT_2024_REDUCED_LGT_POWER", + "ENERGY_CREDIT_VT_2024_EFF_ELEVATOR", + "ENERGY_CREDIT_VT_2024_COM_KITCHEN_EQUIP", + "ENERGY_CREDIT_VT_2024_RES_KITCHEN_EQUIP", + "ENERGY_CREDIT_VT_2024_FAULT_DETECTION", + "ENERGY_CREDIT_VT_2024_RENEWABLE_ENERGY", + "ENERGY_CREDIT_VT_2024_LGT_LOAD_MGMT", + "ENERGY_CREDIT_VT_2024_HVAC_LOAD_MGMT", + "ENERGY_CREDIT_VT_2024_AUTO_SHAD", + "ENERGY_CREDIT_VT_2024_ELEC_ENERGY_STORAGE", + "ENERGY_CREDIT_VT_2024_COOL_ENERGY_STORAGE", + "ENERGY_CREDIT_VT_2024_SHW_ENERGY_STORAGE", + "ENERGY_CREDIT_VT_2024_BLDG_THERM_MASS", + "ENERGY_CREDIT_VT_2024_INSUL_EMBODIED_CARB", + "ENERGY_CREDIT_VT_2024_ADD_ELEC_INFRAS" + ] + }, + "AirInfiltrationMeasurementTypeOptions": { + "type": "string", + "enum": [ + "AIR_INFILTRATION_MEASUREMENT_UNKNOWN", + "AIR_INFILTRATION_MEASUREMENT_75PA", + "AIR_INFILTRATION_MEASUREMENT_50PA" + ] + }, "Denver2022EnergyCreditTypeOptions": { "type": "string", "enum": [ diff --git a/comcheck_api/types/api_types.py b/comcheck_api/types/api_types.py index a52e756..be6f260 100644 --- a/comcheck_api/types/api_types.py +++ b/comcheck_api/types/api_types.py @@ -125,3 +125,83 @@ class SimulationResultResponse(ApiResponse): """ data: SimulationResultInfo | None = None + + +class EnergyCreditMessage(BaseModel): + """A warning or user alert from the energy credit calculation. + + Attributes: + level: ``info``, ``warning``, or ``error``. + category: Message category, e.g. ``energy_credit`` or ``renewable``. + message: Human-readable message. + metadata: Extra details, if any. + """ + + model_config = ConfigDict(extra="allow") + + level: str | None = None + category: str | None = None + message: str | None = None + metadata: dict[str, Any] | None = None + + +class BuildingAreaCredit(BaseModel): + """Credits achieved by one energy credit in one building area. + + Attributes: + creditType: The energy credit type (``EfficiencyPackage.type``). + bldgUseKey: The building area key (``WholeBldgUse.key``). + category: ``regular``, or ``renewable_load`` for renewable and load + management credits. May be ``None`` from older backends. + value: Credits achieved, weighted by the building area's share of the + gross floor area. + """ + + model_config = ConfigDict(extra="allow") + + creditType: str | None = None + bldgUseKey: str | None = None + category: str | None = None + value: float | None = None + + +class EnergyCreditResult(BaseModel): + """Result of the ``update-energy-credits`` endpoint. + + Attributes: + energyCredits: Credits achieved per building area and credit type. + proposed: Total proposed credits. + required: Total required credits. + proposedRenewableAndLoadMgmt: Proposed renewable and load management + credits. ``0`` for codes without the renewable trade-off. + requiredRenewableAndLoadMgmt: Required renewable and load management + credits. ``0`` for codes without the renewable trade-off. + warnings: Calculation warnings, e.g. a credit missing an input. + userAlerts: Alerts for the user, e.g. insufficient credits. + """ + + model_config = ConfigDict(extra="allow") + + energyCredits: list[BuildingAreaCredit] = [] + proposed: float | None = None + required: float | None = None + proposedRenewableAndLoadMgmt: float | None = None + requiredRenewableAndLoadMgmt: float | None = None + warnings: list[EnergyCreditMessage] = [] + userAlerts: list[EnergyCreditMessage] = [] + + @property + def passes(self) -> bool | None: + """Whether proposed credits meet the required credits. + + Checks the renewable and load management credits too. ``None`` when + the totals are missing. This is derived from the totals; the + compliance check reports the official energy credit status. + """ + if self.proposed is None or self.required is None: + return None + if self.proposed < self.required: + return False + return (self.proposedRenewableAndLoadMgmt or 0) >= ( + self.requiredRenewableAndLoadMgmt or 0 + ) diff --git a/comcheck_api/types/core_types.py b/comcheck_api/types/core_types.py index e19491c..39e31df 100644 --- a/comcheck_api/types/core_types.py +++ b/comcheck_api/types/core_types.py @@ -1,11 +1,11 @@ # generated by datamodel-codegen: # filename: comCheck.schema.json -# timestamp: 2026-09-19T00:01:20+00:00 +# timestamp: 2026-10-19T21:23:21+00:00 from __future__ import annotations from enum import Enum, StrEnum -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from comcheck_api.types.custom_base_model import CustomBaseModel from pydantic import ConfigDict, Field, RootModel @@ -35,12 +35,27 @@ class EnergyCreditMultiplierException(Enum): """ NO_ENERGY_CREDIT_MULTIPLIER_EXCEPTION = 'NO_ENERGY_CREDIT_MULTIPLIER_EXCEPTION' - ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS = ( - 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS' + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PEAK_HEATING_LOAD = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PEAK_HEATING_LOAD' ) ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PRIMARY_HEAT_PUMP = ( 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_PRIMARY_HEAT_PUMP' ) + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_MEETING_LOAD = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_MEETING_LOAD' + ) + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_ELECTRIC_RESISTANCE_HEAT = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_ELECTRIC_RESISTANCE_HEAT' + ) + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_LOW_ENERGY_BUILDINGS' + ) + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_UTILITY_MISC_GROUP = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_UTILITY_MISC_GROUP' + ) + ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE = ( + 'ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE' + ) NoneType_None = None @@ -1915,6 +1930,97 @@ class IECC2024EnergyCreditTypeOptions(StrEnum): ) +class VT2024EnergyCreditTypeOptions(StrEnum): + ENERGY_CREDIT_VT_2024_ENVELOPE_PERFORMANCE = ( + 'ENERGY_CREDIT_VT_2024_ENVELOPE_PERFORMANCE' + ) + ENERGY_CREDIT_VT_2024_UA_REDUCTION = 'ENERGY_CREDIT_VT_2024_UA_REDUCTION' + ENERGY_CREDIT_VT_2024_ENVELOPE_LEAK_REDUCTION = ( + 'ENERGY_CREDIT_VT_2024_ENVELOPE_LEAK_REDUCTION' + ) + ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R10 = ( + 'ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R10' + ) + ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R5 = ( + 'ENERGY_CREDIT_VT_2024_ADD_ROOF_INSULATION_R5' + ) + ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R5 = ( + 'ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R5' + ) + ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R2_5 = ( + 'ENERGY_CREDIT_VT_2024_ADD_WALL_INSULATION_R2_5' + ) + ENERGY_CREDIT_VT_2024_IMPROVE_FENESTRATION = ( + 'ENERGY_CREDIT_VT_2024_IMPROVE_FENESTRATION' + ) + ENERGY_CREDIT_VT_2024_HVAC_PERFORMANCE = 'ENERGY_CREDIT_VT_2024_HVAC_PERFORMANCE' + ENERGY_CREDIT_VT_2024_HEATING_EFFICIENCY = ( + 'ENERGY_CREDIT_VT_2024_HEATING_EFFICIENCY' + ) + ENERGY_CREDIT_VT_2024_COOLING_EFFICIENCY = ( + 'ENERGY_CREDIT_VT_2024_COOLING_EFFICIENCY' + ) + ENERGY_CREDIT_VT_2024_RES_HVAC_CTRL = 'ENERGY_CREDIT_VT_2024_RES_HVAC_CTRL' + ENERGY_CREDIT_VT_2024_ENERGY_RECOVERY = 'ENERGY_CREDIT_VT_2024_ENERGY_RECOVERY' + ENERGY_CREDIT_VT_2024_SHW_PREHEAT_RECOVERY = ( + 'ENERGY_CREDIT_VT_2024_SHW_PREHEAT_RECOVERY' + ) + ENERGY_CREDIT_VT_2024_HP_WATER_HEATER = 'ENERGY_CREDIT_VT_2024_HP_WATER_HEATER' + ENERGY_CREDIT_VT_2024_SHW_PIPE_INSULATION = ( + 'ENERGY_CREDIT_VT_2024_SHW_PIPE_INSULATION' + ) + ENERGY_CREDIT_VT_2024_POINT_USE_WATER_HEATER = ( + 'ENERGY_CREDIT_VT_2024_POINT_USE_WATER_HEATER' + ) + ENERGY_CREDIT_VT_2024_THERM_BALANCE_VALVES = ( + 'ENERGY_CREDIT_VT_2024_THERM_BALANCE_VALVES' + ) + ENERGY_CREDIT_VT_2024_SHW_HEAT_TRACE = 'ENERGY_CREDIT_VT_2024_SHW_HEAT_TRACE' + ENERGY_CREDIT_VT_2024_SHW_SUBMETERS = 'ENERGY_CREDIT_VT_2024_SHW_SUBMETERS' + ENERGY_CREDIT_VT_2024_SHW_DIST_SIZING = 'ENERGY_CREDIT_VT_2024_SHW_DIST_SIZING' + ENERGY_CREDIT_VT_2024_SHOWER_HEAT_RECOVERY = ( + 'ENERGY_CREDIT_VT_2024_SHOWER_HEAT_RECOVERY' + ) + ENERGY_CREDIT_VT_2024_ENERGY_MONITOR = 'ENERGY_CREDIT_VT_2024_ENERGY_MONITOR' + ENERGY_CREDIT_VT_2024_ENHANCED_LGT_CTRL = 'ENERGY_CREDIT_VT_2024_ENHANCED_LGT_CTRL' + ENERGY_CREDIT_VT_2024_INCREASE_OCCP_SENSORS = ( + 'ENERGY_CREDIT_VT_2024_INCREASE_OCCP_SENSORS' + ) + ENERGY_CREDIT_VT_2024_INCREASE_DAYLIT_AREA = ( + 'ENERGY_CREDIT_VT_2024_INCREASE_DAYLIT_AREA' + ) + ENERGY_CREDIT_VT_2024_RES_LGT_CTRL = 'ENERGY_CREDIT_VT_2024_RES_LGT_CTRL' + ENERGY_CREDIT_VT_2024_REDUCED_LGT_POWER = 'ENERGY_CREDIT_VT_2024_REDUCED_LGT_POWER' + ENERGY_CREDIT_VT_2024_EFF_ELEVATOR = 'ENERGY_CREDIT_VT_2024_EFF_ELEVATOR' + ENERGY_CREDIT_VT_2024_COM_KITCHEN_EQUIP = 'ENERGY_CREDIT_VT_2024_COM_KITCHEN_EQUIP' + ENERGY_CREDIT_VT_2024_RES_KITCHEN_EQUIP = 'ENERGY_CREDIT_VT_2024_RES_KITCHEN_EQUIP' + ENERGY_CREDIT_VT_2024_FAULT_DETECTION = 'ENERGY_CREDIT_VT_2024_FAULT_DETECTION' + ENERGY_CREDIT_VT_2024_RENEWABLE_ENERGY = 'ENERGY_CREDIT_VT_2024_RENEWABLE_ENERGY' + ENERGY_CREDIT_VT_2024_LGT_LOAD_MGMT = 'ENERGY_CREDIT_VT_2024_LGT_LOAD_MGMT' + ENERGY_CREDIT_VT_2024_HVAC_LOAD_MGMT = 'ENERGY_CREDIT_VT_2024_HVAC_LOAD_MGMT' + ENERGY_CREDIT_VT_2024_AUTO_SHAD = 'ENERGY_CREDIT_VT_2024_AUTO_SHAD' + ENERGY_CREDIT_VT_2024_ELEC_ENERGY_STORAGE = ( + 'ENERGY_CREDIT_VT_2024_ELEC_ENERGY_STORAGE' + ) + ENERGY_CREDIT_VT_2024_COOL_ENERGY_STORAGE = ( + 'ENERGY_CREDIT_VT_2024_COOL_ENERGY_STORAGE' + ) + ENERGY_CREDIT_VT_2024_SHW_ENERGY_STORAGE = ( + 'ENERGY_CREDIT_VT_2024_SHW_ENERGY_STORAGE' + ) + ENERGY_CREDIT_VT_2024_BLDG_THERM_MASS = 'ENERGY_CREDIT_VT_2024_BLDG_THERM_MASS' + ENERGY_CREDIT_VT_2024_INSUL_EMBODIED_CARB = ( + 'ENERGY_CREDIT_VT_2024_INSUL_EMBODIED_CARB' + ) + ENERGY_CREDIT_VT_2024_ADD_ELEC_INFRAS = 'ENERGY_CREDIT_VT_2024_ADD_ELEC_INFRAS' + + +class AirInfiltrationMeasurementTypeOptions(StrEnum): + AIR_INFILTRATION_MEASUREMENT_UNKNOWN = 'AIR_INFILTRATION_MEASUREMENT_UNKNOWN' + AIR_INFILTRATION_MEASUREMENT_75PA = 'AIR_INFILTRATION_MEASUREMENT_75PA' + AIR_INFILTRATION_MEASUREMENT_50PA = 'AIR_INFILTRATION_MEASUREMENT_50PA' + + class Denver2022EnergyCreditTypeOptions(StrEnum): EFF_PACKAGE_ENERGY_CREDIT_DENVER_5_ENHANCED_HEATING_PERFORMANCE = ( 'EFF_PACKAGE_ENERGY_CREDIT_DENVER_5_ENHANCED_HEATING_PERFORMANCE' @@ -3406,11 +3512,61 @@ class EfficiencyPackage(CustomBaseModel): ge=0, ), ] = MISSING + onSiteRenewableCapacity: Annotated[ + float | None | MISSING, + Field( + description='On-site renewable capacity, unit is Btu for thermal system and watts for electric system.', + ge=0.0, + ), + ] = MISSING + airInfiltrationMeasurementType: Annotated[ + AirInfiltrationMeasurementTypeOptions | None | MISSING, + Field(description='Pressure at which air infiltration was measured'), + ] = MISSING + demandResponseSignal: Annotated[ + bool | None | MISSING, + Field( + description='Flag indicates the load management system responds to a demand response signal' + ), + ] = MISSING + demandResponseRequired: Annotated[ + bool | None | MISSING, + Field(description='Flag indicates demand response is required'), + ] = MISSING + ventilationShiftIncluded: Annotated[ + bool | None | MISSING, + Field(description='Flag indicates ventilation load shifting is included'), + ] = MISSING + sumOfGuestRoomFloorAreaWithKitchens: Annotated[ + float | None | MISSING, + Field(description='Sum of guest room floor area with kitchens', ge=0.0), + ] = MISSING + sumOfTotalGuestRoomFloorArea: Annotated[ + float | None | MISSING, + Field(description='Sum of total guest room floor area', ge=0.0), + ] = MISSING + tsprProposed: Annotated[ + float | None | MISSING, + Field(description='Proposed total system performance ratio (TSPR)', ge=0.0), + ] = MISSING + tsprTarget: Annotated[ + float | None | MISSING, + Field(description='Target total system performance ratio (TSPR)', ge=0.0), + ] = MISSING + tsprReference: Annotated[ + float | None | MISSING, + Field(description='Reference total system performance ratio (TSPR)', ge=0.0), + ] = MISSING + tsprMpf: Annotated[ + float | None | MISSING, + Field(description='TSPR mechanical performance factor (MPF)', ge=0.0), + ] = MISSING type: Annotated[ EfficiencyPackageTypeOptions | MAS2022EnergyCreditTypeOptions | Denver2022EnergyCreditTypeOptions - | IECC2024EnergyCreditTypeOptions, + | IECC2024EnergyCreditTypeOptions + | VT2024EnergyCreditTypeOptions, Field(description='Efficiency Package or Energy Credit Type'), ] @@ -4400,7 +4556,7 @@ class ComBuilding(CustomBaseModel): Field(description='Service water heating system', min_length=0), ] = MISSING efficiencyPackages: Annotated[ - list[Any] | MISSING, + list[EfficiencyPackage] | MISSING, Field(description='Efficiency Package or Energy Credits', min_length=0), ] = MISSING constructionType: Annotated[ diff --git a/docs_site/api/operations/credits.md b/docs_site/api/operations/credits.md index 78cfb39..3e2dbe2 100644 --- a/docs_site/api/operations/credits.md +++ b/docs_site/api/operations/credits.md @@ -1,11 +1,122 @@ -# Credits +# Credits Operations -!!! warning "Under Development" - Credits operations are not yet implemented. This section is planned for a future release. +Energy credits are stored in the flat `ComBuilding.efficiencyPackages` list. +Each `EfficiencyPackage` entry: -This page will cover operations for managing energy credits in COMcheck projects, including: +- belongs to one building area — `bldgUseKey` is the `WholeBldgUse.key` +- is identified by its `type` — a building area holds at most one entry per credit type +- carries the inputs its credit type needs, e.g. `airInfiltration` for the + reduced air infiltration credits or `onSiteRenewableCapacity` for on-site + renewable credits (see the field descriptions on `EfficiencyPackage`) -- Efficiency packages -- Energy credit calculations +Two project-level fields also affect credits: -The underlying types for credits (`EfficiencyPackage`) are already available. See the [Types Guide](types-guide.md) for details on importing and using them. +- `energyCreditMultiplierException` — the IECC 2024 Appendix C/F energy credit + multiplier exception (new construction and additions) +- `projectCoreAndShellCredit` — credits gained from core and shell (initial + build-out with a core and shell mechanical type) + +## Energy codes + +Energy credits apply to IECC 2021 and newer, ASHRAE 90.1-2022, and state codes +based on them. Each code family has its own credit types: + +| Code family | Credit type enum | Prefix | +|---|---|---| +| IECC 2021, ASHRAE 90.1-2022 | `EfficiencyPackageTypeOptions` | `EFF_PACKAGE_ENERGY_CREDIT_` | +| IECC 2024 (incl. Appendix C/F, NYS 2024, NYC 2025) | `IECC2024EnergyCreditTypeOptions` | `EFF_PACKAGE_ENERGY_CREDIT_IECC2024_` | +| Massachusetts Stretch | `MAS2022EnergyCreditTypeOptions` | `EFF_PACKAGE_ENERGY_CREDIT_MAS_` | +| Denver | `Denver2022EnergyCreditTypeOptions` | `EFF_PACKAGE_ENERGY_CREDIT_DENVER_` | +| Vermont 2024 | `VT2024EnergyCreditTypeOptions` | `ENERGY_CREDIT_VT_2024_` | + +These operations don't check `control.code` — which credits fit the project is +up to you. Older codes that use additional efficiency packages instead +(IECC 2015/2018) get `0` proposed and required credits from the calculation. + +## Operations + +```python +from comcheck_api import project_credit_operations as cr_ops +``` + +| Function | Description | +|---|---| +| `add_energy_credit_to_project(project, building_area_key, new_energy_credit)` | Add an energy credit to a building area | +| `update_energy_credit_in_project(project, building_area_key, credit_type, updates)` | Update the inputs of an existing energy credit | +| `remove_energy_credit_from_project(project, building_area_key, credit_type)` | Remove an energy credit from a building area | +| `get_energy_credit_keys_from_project(project, building_area_key=None)` | List the `bldgUseKey` and `type` of the energy credits | +| `set_energy_credit_multiplier_exception_in_project(project, multiplier_exception)` | Set `energyCreditMultiplierException` | +| `set_core_and_shell_credit_in_project(project, core_and_shell_credit)` | Set `projectCoreAndShellCredit` | + +Removing a building area with +`project_building_area_operations.remove_building_area_from_project` also +removes its energy credits. + +## Adding and updating credits + +```python +from comcheck_api import project_credit_operations as cr_ops +from comcheck_api.types.core_types import ( + EfficiencyPackage, + IECC2024EnergyCreditTypeOptions as Credit, +) + +area_key = project.lighting.wholeBldgUse[0].key + +project = cr_ops.add_energy_credit_to_project( + project, + area_key, + EfficiencyPackage(type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10), +) + +project = cr_ops.add_energy_credit_to_project( + project, + area_key, + EfficiencyPackage( + type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + airInfiltration=0.25, + airInfiltrationMeasurementType="AIR_INFILTRATION_MEASUREMENT_75PA", + ), +) + +project = cr_ops.update_energy_credit_in_project( + project, + area_key, + Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + {"airInfiltration": 0.2}, +) +``` + +`type` and `bldgUseKey` can't be updated — remove the credit and add a new one. + +## Calculating credits + +`COMcheckClient.calculate_energy_credits` sends the project to +`POST /update-energy-credits` and returns an `EnergyCreditResult`. The project +is not modified. + +```python +from comcheck_api import COMcheckClient + +with COMcheckClient(api_key="your-key") as client: + result = client.calculate_energy_credits(project) + +print(f"Credits - Required: {result.required} Proposed: {result.proposed}") +for credit in result.energyCredits: + print(credit.bldgUseKey, credit.creditType, credit.value) +for alert in result.userAlerts: + print(alert.level, alert.message) +print("Meets required credits:", result.passes) +``` + +| Field | Description | +|---|---| +| `energyCredits` | Credits achieved per building area (`bldgUseKey`) and credit type (`creditType`). `category` is `regular`, or `renewable_load` for renewable and load management credits, and may be `None` from older backends. `value` is weighted by the building area's share of the gross floor area. | +| `proposed` / `required` | Total proposed and required credits | +| `proposedRenewableAndLoadMgmt` / `requiredRenewableAndLoadMgmt` | Renewable and load management credits, reported separately by codes with the renewable trade-off (IECC 2024, Vermont 2024); `0` otherwise | +| `warnings` | Calculation warnings, e.g. a credit missing an input | +| `userAlerts` | Alerts for the user, e.g. insufficient credits | +| `passes` | `True` when proposed credits (and renewable and load management credits) meet the required credits; `None` when the totals are missing | + +`passes` is derived from the totals. The compliance check +(`COMcheckClient.check_UA_compliance`) reports the official energy credit status. diff --git a/docs_site/getting-started.md b/docs_site/getting-started.md index 68d8dd8..9355fcb 100644 --- a/docs_site/getting-started.md +++ b/docs_site/getting-started.md @@ -1,7 +1,7 @@ # Getting Started !!! note "Supported Sections" - Currently, **Building Area**, **Envelope**, **Interior Lighting**, **Exterior Lighting**, and **Compliance Simulation** operations are fully supported. Mechanical, credits, and renewable energy sections are planned but not yet implemented. See the [home page](index.md#current-status) for the full status table. + Currently, **Building Area**, **Envelope**, **Interior Lighting**, **Exterior Lighting**, **Renewable Energy**, **Credits**, and **Compliance Simulation** operations are fully supported. The mechanical section is planned but not yet implemented. See the [home page](index.md#current-status) for the full status table. ## Setup diff --git a/docs_site/index.md b/docs_site/index.md index b5623a8..84cc42d 100644 --- a/docs_site/index.md +++ b/docs_site/index.md @@ -14,8 +14,8 @@ This package is under active development. Here is the current support status for | Exterior Lighting | Supported | | Compliance Simulation | Supported | | Mechanical | TBD | -| Credits | TBD | -| Renewable Energy | TBD | +| Credits | Supported | +| Renewable Energy | Supported | Operations and data managers for the TBD sections are not yet implemented. The underlying types and models for these sections are available (see [Types Guide](types-guide.md)), but there are no high-level convenience functions for them yet. diff --git a/examples/README.md b/examples/README.md index 5ea5c79..d204345 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ 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 +│ ├── credits.py # Energy credit operations & calculation examples │ └── user_functions.py # User-facing function examples └── project_operations/ # Project operations examples ├── building_area_operations.py # Building area operations @@ -78,6 +79,18 @@ Demonstrates server-side renewable calculations. python examples/client/renewable.py ``` +#### Credits Examples (`client/credits.py`) +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) + +**Usage:** +```bash +python examples/client/credits.py +``` + #### User Function Examples (`client/user_functions.py`) Demonstrates user-facing API client functions. diff --git a/examples/client/credits.py b/examples/client/credits.py new file mode 100644 index 0000000..5d8927c --- /dev/null +++ b/examples/client/credits.py @@ -0,0 +1,70 @@ +"""Example of adding energy credits and calculating them with the COMcheck API client.""" + +import os +from dotenv import load_dotenv +from comcheck_api.client import COMcheckClient +from comcheck_api import project_credit_operations as cr_ops +from comcheck_api.defaults import ( + get_default_project_template, + get_default_building_area_template, +) +from comcheck_api.project_operations import project_building_area_operations +from comcheck_api.types.core_types import ( + EfficiencyPackage, + EnergyCodeOptions, + IECC2024EnergyCreditTypeOptions as Credit, +) + +# 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 an IECC 2024 project with one building area. +project = get_default_project_template() +project.control.code = EnergyCodeOptions.CEZ_IECC2024 + +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) + +# Example 1: Add energy credits to the building area. +project = cr_ops.add_energy_credit_to_project( + project, + building_area_key, + EfficiencyPackage(type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_FENESTRATION), +) +project = cr_ops.add_energy_credit_to_project( + project, + building_area_key, + EfficiencyPackage( + type=Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + airInfiltration=0.25, + airInfiltrationMeasurementType="AIR_INFILTRATION_MEASUREMENT_75PA", + ), +) + +# Example 2: Update the inputs of a credit. +project = cr_ops.update_energy_credit_in_project( + project, + building_area_key, + Credit.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION, + {"airInfiltration": 0.2}, +) +print(cr_ops.get_energy_credit_keys_from_project(project)) + +# Example 3: Calculate the proposed and required credits (the project is not modified). +result = client.calculate_energy_credits(project) +print(f"Credits - Required: {result.required} Proposed: {result.proposed}") +print( + "Renewable and Load Management Credits - " + f"Required: {result.requiredRenewableAndLoadMgmt} " + f"Proposed: {result.proposedRenewableAndLoadMgmt}" +) +for credit in result.energyCredits: + print(f" {credit.creditType}: {credit.value}") +for alert in result.userAlerts: + print(f" [{alert.level}] {alert.message}") +print(f"Meets required credits: {result.passes}") diff --git a/tests/project_operation_tests/conftest.py b/tests/project_operation_tests/conftest.py index 7f44d59..14cfd4a 100644 --- a/tests/project_operation_tests/conftest.py +++ b/tests/project_operation_tests/conftest.py @@ -2,7 +2,7 @@ import json import os from pathlib import Path -from typing import Callable, Optional +from typing import Any, Callable, Optional import uuid import pytest diff --git a/tests/project_operation_tests/test_credit_operations.py b/tests/project_operation_tests/test_credit_operations.py new file mode 100644 index 0000000..210e0b2 --- /dev/null +++ b/tests/project_operation_tests/test_credit_operations.py @@ -0,0 +1,292 @@ +"""Tests for project_credit_operations.""" + +import pytest +from pydantic import ValidationError +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.defaults import ( + get_default_building_area_template, + get_default_project_template, +) +from comcheck_api.project_operations import ( + project_building_area_operations as ba_ops, + project_credit_operations as cr_ops, +) +from comcheck_api.types.core_types import ( + AirInfiltrationMeasurementTypeOptions, + ComBuilding, + EfficiencyPackage, + EnergyCreditMultiplierException, + IECC2024EnergyCreditTypeOptions, +) + +ROOF_R10 = ( + IECC2024EnergyCreditTypeOptions.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10 +) +AIR_INFILTRATION = ( + IECC2024EnergyCreditTypeOptions.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_REDUCE_AIR_INFILTRATION +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def credit_project() -> ComBuilding: + """Default project with two building areas.""" + project = get_default_project_template() + first_area = get_default_building_area_template() + first_area.areaDescription = "Office" + second_area = get_default_building_area_template() + second_area.areaDescription = "Retail" + project = ba_ops.add_building_area_to_project(project, first_area) + return ba_ops.add_building_area_to_project(project, second_area) + + +def _area_keys(project: ComBuilding) -> list[str]: + return [area.key for area in project.lighting.wholeBldgUse] + + +def _with_roof_credit(project: ComBuilding) -> tuple[ComBuilding, str]: + key = _area_keys(project)[0] + return ( + cr_ops.add_energy_credit_to_project( + project, key, EfficiencyPackage(type=ROOF_R10) + ), + key, + ) + + +# --------------------------------------------------------------------------- +# add_energy_credit_to_project +# --------------------------------------------------------------------------- + + +def test_add_energy_credit_sets_building_area_key(credit_project: ComBuilding): + result, key = _with_roof_credit(credit_project) + + (credit,) = result.efficiencyPackages + assert credit.type == ROOF_R10 + assert credit.bldgUseKey == key + + +def test_add_energy_credit_initializes_missing_list(credit_project: ComBuilding): + credit_project.efficiencyPackages = MISSING + + result, _ = _with_roof_credit(credit_project) + + assert len(result.efficiencyPackages) == 1 + + +def test_add_energy_credit_does_not_mutate_inputs(credit_project: ComBuilding): + key = _area_keys(credit_project)[0] + new_credit = EfficiencyPackage(type=ROOF_R10) + + cr_ops.add_energy_credit_to_project(credit_project, key, new_credit) + + assert credit_project.efficiencyPackages == [] + assert new_credit.bldgUseKey is MISSING + + +def test_add_same_credit_type_to_other_area(credit_project: ComBuilding): + result, _ = _with_roof_credit(credit_project) + other_key = _area_keys(credit_project)[1] + + result = cr_ops.add_energy_credit_to_project( + result, other_key, EfficiencyPackage(type=ROOF_R10) + ) + + assert len(result.efficiencyPackages) == 2 + + +def test_add_duplicate_credit_raises(credit_project: ComBuilding): + result, key = _with_roof_credit(credit_project) + + with pytest.raises(ValueError, match="already has energy credit"): + cr_ops.add_energy_credit_to_project( + result, key, EfficiencyPackage(type=ROOF_R10) + ) + + +def test_add_energy_credit_unknown_area_raises(credit_project: ComBuilding): + with pytest.raises(ValueError, match="not found"): + cr_ops.add_energy_credit_to_project( + credit_project, "no-such-key", EfficiencyPackage(type=ROOF_R10) + ) + + +def test_efficiency_package_rejects_unknown_type(): + with pytest.raises(ValidationError): + EfficiencyPackage(type="NOT_A_CREDIT") + + +# --------------------------------------------------------------------------- +# update_energy_credit_in_project +# --------------------------------------------------------------------------- + + +def test_update_energy_credit_inputs(credit_project: ComBuilding): + key = _area_keys(credit_project)[0] + project = cr_ops.add_energy_credit_to_project( + credit_project, key, EfficiencyPackage(type=AIR_INFILTRATION) + ) + + result = cr_ops.update_energy_credit_in_project( + project, + key, + AIR_INFILTRATION, + { + "airInfiltration": 0.25, + "airInfiltrationMeasurementType": "AIR_INFILTRATION_MEASUREMENT_50PA", + }, + ) + + (credit,) = result.efficiencyPackages + assert credit.airInfiltration == 0.25 + assert ( + credit.airInfiltrationMeasurementType + == AirInfiltrationMeasurementTypeOptions.AIR_INFILTRATION_MEASUREMENT_50PA + ) + assert credit.bldgUseKey == key + assert project.efficiencyPackages[0].airInfiltration is MISSING + + +def test_update_energy_credit_validates_values(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + + with pytest.raises(ValidationError): + cr_ops.update_energy_credit_in_project( + project, key, ROOF_R10, {"fractionGrossFloorAreaServedByCAV": 2.0} + ) + + +@pytest.mark.parametrize("field", ["type", "bldgUseKey"]) +def test_update_energy_credit_rejects_identity_fields( + credit_project: ComBuilding, field: str +): + project, key = _with_roof_credit(credit_project) + + with pytest.raises(ValueError, match="Remove the energy credit"): + cr_ops.update_energy_credit_in_project(project, key, ROOF_R10, {field: "x"}) + + +def test_update_missing_energy_credit_raises(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + + with pytest.raises(ValueError, match="not found"): + cr_ops.update_energy_credit_in_project( + project, key, AIR_INFILTRATION, {"airInfiltration": 0.25} + ) + + +# --------------------------------------------------------------------------- +# remove_energy_credit_from_project +# --------------------------------------------------------------------------- + + +def test_remove_energy_credit(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + + result = cr_ops.remove_energy_credit_from_project(project, key, ROOF_R10) + + assert result.efficiencyPackages == [] + assert len(project.efficiencyPackages) == 1 + + +def test_remove_energy_credit_only_from_given_area(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + other_key = _area_keys(credit_project)[1] + project = cr_ops.add_energy_credit_to_project( + project, other_key, EfficiencyPackage(type=ROOF_R10) + ) + + result = cr_ops.remove_energy_credit_from_project(project, key, ROOF_R10) + + assert [credit.bldgUseKey for credit in result.efficiencyPackages] == [other_key] + + +def test_remove_missing_energy_credit_raises(credit_project: ComBuilding): + with pytest.raises(ValueError, match="not found"): + cr_ops.remove_energy_credit_from_project( + credit_project, _area_keys(credit_project)[0], ROOF_R10 + ) + + +def test_remove_building_area_removes_its_credits(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + other_key = _area_keys(credit_project)[1] + project = cr_ops.add_energy_credit_to_project( + project, other_key, EfficiencyPackage(type=AIR_INFILTRATION) + ) + + result = ba_ops.remove_building_area_from_project(project, key) + + assert [credit.bldgUseKey for credit in result.efficiencyPackages] == [other_key] + + +# --------------------------------------------------------------------------- +# get_energy_credit_keys_from_project +# --------------------------------------------------------------------------- + + +def test_get_energy_credit_keys(credit_project: ComBuilding): + project, key = _with_roof_credit(credit_project) + other_key = _area_keys(credit_project)[1] + project = cr_ops.add_energy_credit_to_project( + project, other_key, EfficiencyPackage(type=AIR_INFILTRATION) + ) + + assert cr_ops.get_energy_credit_keys_from_project(project) == [ + {"bldgUseKey": key, "type": ROOF_R10}, + {"bldgUseKey": other_key, "type": AIR_INFILTRATION}, + ] + assert cr_ops.get_energy_credit_keys_from_project(project, other_key) == [ + {"bldgUseKey": other_key, "type": AIR_INFILTRATION}, + ] + + +def test_get_energy_credit_keys_missing_list(credit_project: ComBuilding): + credit_project.efficiencyPackages = MISSING + + assert cr_ops.get_energy_credit_keys_from_project(credit_project) == [] + + +# --------------------------------------------------------------------------- +# Project-level credit fields +# --------------------------------------------------------------------------- + + +def test_set_energy_credit_multiplier_exception(credit_project: ComBuilding): + result = cr_ops.set_energy_credit_multiplier_exception_in_project( + credit_project, + EnergyCreditMultiplierException.ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE, + ) + + assert ( + result.energyCreditMultiplierException + == EnergyCreditMultiplierException.ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE + ) + assert credit_project.energyCreditMultiplierException is MISSING + + +def test_set_energy_credit_multiplier_exception_rejects_non_enum( + credit_project: ComBuilding, +): + with pytest.raises(TypeError): + cr_ops.set_energy_credit_multiplier_exception_in_project( + credit_project, "ENERGY_CREDIT_MULTIPLIER_EXCEPTION_INDUSTRIAL_USE" + ) + + +def test_set_core_and_shell_credit(credit_project: ComBuilding): + result = cr_ops.set_core_and_shell_credit_in_project(credit_project, 2.5) + assert result.projectCoreAndShellCredit == 2.5 + + result = cr_ops.set_core_and_shell_credit_in_project(result, None) + assert result.projectCoreAndShellCredit is None + + +def test_set_core_and_shell_credit_rejects_negative(credit_project: ComBuilding): + with pytest.raises(ValueError): + cr_ops.set_core_and_shell_credit_in_project(credit_project, -1) diff --git a/tests/test_credits_api.py b/tests/test_credits_api.py new file mode 100644 index 0000000..ede87fd --- /dev/null +++ b/tests/test_credits_api.py @@ -0,0 +1,167 @@ +"""Offline tests for the energy credit 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.api_types import EnergyCreditResult +from comcheck_api.types.core_types import EnergyCodeOptions + +CREDITS_DATA = { + "energyCredits": [ + { + "creditType": "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10", + "bldgUseKey": "area-1", + "category": "regular", + "value": 3.2, + }, + { + "creditType": "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ELECTRIC_STORAGE", + "bldgUseKey": "area-1", + "category": "renewable_load", + "value": 4.0, + }, + ], + "proposed": 3.2, + "required": 10, + "proposedRenewableAndLoadMgmt": 4.0, + "requiredRenewableAndLoadMgmt": 5, + "warnings": [], + "userAlerts": [ + { + "level": "error", + "category": "energy_credit", + "message": "Insufficient energy credits: 3.2 proposed, 10 required (deficit: 6.8)", + } + ], +} + + +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_IECC2024 + 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": "Success", "data": data}) + + return handler + + +def test_update_energy_credits_service_posts_project(requests, project): + service = _make_service(_ok(requests, CREDITS_DATA)) + project_data = project.model_dump(mode="json") + + result = service.update_energy_credits(project_data) + + assert result["data"] == CREDITS_DATA + (request,) = requests + assert request.method == "POST" + assert request.url.path.endswith("/update-energy-credits") + assert request.headers["x-api-key"] == "test" + assert json.loads(request.content) == project_data + + +def test_update_energy_credits_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.update_energy_credits(project.model_dump(mode="json")) + assert exc_info.value.status_code == 400 + + +def test_calculate_energy_credits_returns_result(requests, project): + client = _make_client(_ok(requests, CREDITS_DATA)) + before = project.model_dump(mode="json") + + result = client.calculate_energy_credits(project) + + assert isinstance(result, EnergyCreditResult) + assert [credit.creditType for credit in result.energyCredits] == [ + "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10", + "EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ELECTRIC_STORAGE", + ] + assert result.energyCredits[0].bldgUseKey == "area-1" + assert result.energyCredits[0].value == 3.2 + assert result.proposed == 3.2 + assert result.required == 10.0 + assert result.requiredRenewableAndLoadMgmt == 5.0 + assert result.userAlerts[0].level == "error" + assert result.passes is False + assert project.model_dump(mode="json") == before + + +def test_calculate_energy_credits_handles_missing_data(requests, project): + client = _make_client(_ok(requests, None)) + + result = client.calculate_energy_credits(project) + + assert result.energyCredits == [] + assert result.proposed is None + assert result.passes is None + + +@pytest.mark.parametrize( + ("totals", "passes"), + [ + ({"proposed": 10, "required": 10}, True), + ({"proposed": 9.9, "required": 10}, False), + ( + { + "proposed": 12, + "required": 10, + "proposedRenewableAndLoadMgmt": 0, + "requiredRenewableAndLoadMgmt": 0, + }, + True, + ), + ( + { + "proposed": 12, + "required": 10, + "proposedRenewableAndLoadMgmt": 4, + "requiredRenewableAndLoadMgmt": 5, + }, + False, + ), + ], +) +def test_energy_credit_result_passes(totals, passes): + assert EnergyCreditResult.model_validate(totals).passes is passes