From 3211eabbf9e1f9f656e809827dfe8aa3f94518b4 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Thu, 22 Oct 2026 16:34:56 -0700 Subject: [PATCH 1/4] Add mechanical section: operations, API calls, skill, documentation, test - project_mechanical_operations: HVAC systems, fan systems (with fans), plants, and water heaters, identified by description with UI-style default names; fanSystemKey UUIDs linked via assign_fan_system_to_hvac_system - Mechanical API calls on COMcheckClient/COMCheckApiService for the /{energyCode}/mechanical endpoints, with fan system write-back - CustomBaseModel resolves list element types from field annotations - Skill, docs, example, and changelog updates Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 + comcheck_api/__init__.py | 2 + comcheck_api/ai/skill/SKILL.md | 75 +- comcheck_api/ai/skill/reference/operations.md | 52 +- comcheck_api/ai/skill/reference/types.md | 2 +- .../ai/skill/scripts/validate_code.py | 19 +- comcheck_api/api/api_services.py | 207 +++++ comcheck_api/client/comcheck_client.py | 352 ++++++- comcheck_api/introspection.py | 2 + .../components/mechanical/__init__.py | 13 + .../components/mechanical/fan_system.py | 86 ++ .../components/mechanical/hvac_plant.py | 13 + .../components/mechanical/hvac_system.py | 13 + .../components/mechanical/water_heater.py | 13 + comcheck_api/managers/data_manager.py | 10 + comcheck_api/project_operations/__init__.py | 2 + .../project_mechanical_operations.py | 858 ++++++++++++++++++ comcheck_api/types/api_types.py | 104 +++ comcheck_api/types/custom_base_model.py | 54 +- comcheck_api/utilities/project_utilities.py | 133 +++ docs_site/api/operations/mechanical.md | 398 +++++++- examples/README.md | 20 + examples/client/mechanical.py | 132 +++ .../test_mechanical_operations.py | 649 +++++++++++++ tests/test_base_model.py | 53 +- tests/test_mechanical_api.py | 479 ++++++++++ 26 files changed, 3720 insertions(+), 42 deletions(-) create mode 100644 comcheck_api/managers/components/mechanical/__init__.py create mode 100644 comcheck_api/managers/components/mechanical/fan_system.py create mode 100644 comcheck_api/managers/components/mechanical/hvac_plant.py create mode 100644 comcheck_api/managers/components/mechanical/hvac_system.py create mode 100644 comcheck_api/managers/components/mechanical/water_heater.py create mode 100644 comcheck_api/project_operations/project_mechanical_operations.py create mode 100644 examples/client/mechanical.py create mode 100644 tests/project_operation_tests/test_mechanical_operations.py create mode 100644 tests/test_mechanical_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7afc4de..fed87c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,24 @@ 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 +- Mechanical operations module (`project_mechanical_operations`) for managing HVAC systems, fan systems, HVAC plants, and water heaters +- `add_hvac_system_to_project` / `update_hvac_system_in_project` / `remove_hvac_system_from_project` — manage HVAC systems +- `assign_fan_system_to_hvac_system` — assign a fan system to an HVAC system by description (copies the UUID into `fanSystemKey`) +- `get_hvac_system_keys_from_project` — list all HVAC systems with their fan system links +- `add_fan_system_to_project` / `update_fan_system_in_project` / `remove_fan_system_from_project` — manage fan systems +- `get_fan_system_keys_from_project` — list all fan systems +- `add_fan_to_fan_system` / `update_fan_in_fan_system` / `remove_fan_from_fan_system` — manage fans within fan systems +- `add_hvac_plant_to_project` / `update_hvac_plant_in_project` / `remove_hvac_plant_from_project` — manage HVAC plants +- `get_hvac_plant_keys_from_project` — list all HVAC plants +- `add_water_heater_to_project` / `update_water_heater_in_project` / `remove_water_heater_from_project` — manage water heaters +- `get_water_heater_keys_from_project` — list all water heaters +- Automatic `fanSystemKey` UUID generation and validation for fan systems +- `HVACSystemListManager`, `FanSystemListManager`, `HVACPlantListManager`, `WaterHeaterListManager` in `comcheck_api.managers.components.mechanical` +- `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, `ServiceWaterHeatingSystem` added to `MODEL_TO_ID_INFO` with auto-generated descriptions ("HVAC System", "Fan System", "Fan", "Plant", "Water Heater") +- Mechanical API calls on `COMcheckClient` (`POST /{energyCode}/mechanical/...`): `calculate_water_heater_efficiency`, `calculate_hvac_system_efficiency`, `validate_hvac_system_economizer`, `calculate_plant_efficiency`, `calculate_fan_pressure_drop_credit`, `check_fan_system_compliance`, `calculate_fan_maximum_allowable_hp` +- `COMcheckClient.update_fan_system_compliance` and `COMcheckClient.update_fan_pressure_drop_credits` — calculate fan system compliance / pressure drop credits and write them onto the project's fan system +- Matching `COMCheckApiService` service methods, and `WaterHeaterEfficiency`, `HVACSystemEfficiency`, `EconomizerValidation`, `PlantEfficiency`, `FanSystemComplianceResult` result types +- `examples/client/mechanical.py` example - 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 @@ -31,6 +49,9 @@ All notable changes to this project are documented in this file. ### 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 +- `CustomBaseModel._get_normalized_subcomponent_info` now extracts element type from list annotations when `subcomponent_name` is given, fixing type lookup for `hvacSystem`, `fanSystem`, `hvacPlant`, and `swhSystem` +- `CustomBaseModel._get_subcomponent_list` now normalizes `None` to `[]` for optional list fields (e.g., `hvac.fanSystem`) +- Skill validation no longer blocks `hvac` or `swhSystem` — mechanical operations are now fully supported ### 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 188a398..84eb017 100644 --- a/comcheck_api/__init__.py +++ b/comcheck_api/__init__.py @@ -61,6 +61,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_mechanical_operations, project_renewable_operations, ) @@ -94,6 +95,7 @@ "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", + "project_mechanical_operations", "project_renewable_operations", # Introspection "list_operations", diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 097e1c2..6e9ac58 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -28,18 +28,19 @@ 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`, `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 - at template defaults. All other mutable areas have dedicated modules. + building areas), `hvac`, `swhSystem` (water heaters), + `renewable`, `efficiencyPackages` (energy credits), and `control` + (energy code). No `Project`/`Control` PascalCase aliases exist. + The shared `fixtureSchedule[]` exists on the model but has **no + operation functions** — leave it at template defaults. All other + mutable areas have dedicated modules. - **Operation modules (functional)**: all mutations go through free functions that take a `ComBuilding` and return a new `ComBuilding`: - `project_building_area_operations` — `WholeBldgUse` items - `project_envelope_operations` — roofs, walls, floors, windows, doors, skylights, thermal bridges - `project_interior_lighting_operations` — `ActivityUse` items, aliased as `InteriorSpace` (interior lighting spaces + fixtures) - `project_exterior_lighting_operations` — `ExteriorUse` items, aliased as `ExteriorArea`, + zone type + - `project_mechanical_operations` — HVAC systems, fan systems (with fans), plants, water heaters - `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 @@ -170,10 +171,9 @@ print(result["performanceRating"]) bypass the validation logic in the operation modules. Always go through `project_envelope_operations` and `project_building_area_operations` instead. -- Don't add, update, or remove `fixtureSchedule[]` or HVAC/mechanical - components — no operations exist for them yet. - The `COMcheckClient` user methods (`list_projects`, `get_project`, - `update_project`, `update_uvalues`, `start_run_simulation`, +- Don't add, update, or remove `fixtureSchedule[]` — no operations + exist for it yet. The `COMcheckClient` user methods (`list_projects`, + `get_project`, `update_project`, `update_uvalues`, `start_run_simulation`, `get_simulation_status`, `get_simulation_result`, `set_api_key`) are fully supported and fine to use. The compliance/report client methods (`check_UA_compliance`, `check_requirements`, @@ -183,10 +183,10 @@ print(result["performanceRating"]) (`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 / credits / simulation instead. + offer building-area / envelope / lighting / mechanical / renewable / credits / simulation instead. `comcheck_api.list_operations()` enumerates the `building_area`, - `envelope`, `interior_lighting`, `exterior_lighting`, `renewable`, and - `credits` groups. + `envelope`, `interior_lighting`, `exterior_lighting`, `mechanical`, + `renewable`, and `credits` groups. ## Common patterns @@ -448,6 +448,55 @@ project = client.area_for_renewable(project) project = client.renewable_capacity(project) ``` +### Adding mechanical systems + +Mechanical operations cover HVAC systems, fan systems, HVAC plants, and service +water heating. The `hvac` singleton can be `MISSING` and is created automatically +on add. Fan systems link to HVAC systems via a UUID (`fanSystemKey`) that is +set by the library — use `assign_fan_system_to_hvac_system` to create the link +by description. + +```python +from comcheck_api import project_mechanical_operations as mech_ops +from comcheck_api.types.core_types import ( + Fan, + FanSystem, + HVACPlant, + HVACSystem, + ServiceWaterHeatingSystem, +) + +# 1. Add an HVAC system +hvac_system = HVACSystem(description="Rooftop Unit 1", quantity=2) +project = mech_ops.add_hvac_system_to_project(project, hvac_system) + +# 2. Add a fan system +fan_system = FanSystem(description="Supply Fan 1") +project = mech_ops.add_fan_system_to_project(project, fan_system) + +# 3. Add a fan to the fan system +fan = Fan(description="Fan 1") +project = mech_ops.add_fan_to_fan_system(project, "Supply Fan 1", fan) + +# 4. Assign the fan system to the HVAC system (by description, not UUID) +project = mech_ops.assign_fan_system_to_hvac_system( + project, "Rooftop Unit 1", "Supply Fan 1" +) + +# 5. Add a plant +plant = HVACPlant(description="Chiller 1") +project = mech_ops.add_hvac_plant_to_project(project, plant) + +# 6. Add a water heater (swhSystem is top-level, not under hvac) +water_heater = ServiceWaterHeatingSystem(description="Water Heater 1") +project = mech_ops.add_water_heater_to_project(project, water_heater) +``` + +**Fan system keys**: the library sets a new UUID on `fanSystemKey` when you add +a fan system if it's empty or already used. You cannot update `fanSystemKey` +directly — use `assign_fan_system_to_hvac_system` or `remove_fan_system_from_project` +(which clears `fanSystemKey` on referencing HVAC systems). + ### Adding energy credits Energy credits apply to IECC 2021+, ASHRAE 90.1-2022, and state codes diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index 1fe36da..5d2e08e 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -1,11 +1,12 @@ # Project Operations Reference -Operation functions are free functions in six modules: +Operation functions are free functions in seven 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_mechanical_operations` - `comcheck_api.project_operations.project_renewable_operations` - `comcheck_api.project_operations.project_credit_operations` @@ -176,6 +177,55 @@ Use `get_default_exterior_area_template()` as a starting point. `areaDescription` is the identifier — it is unique within `exteriorUse[]` and is auto-generated if missing. +## Mechanical operations + +```python +from comcheck_api import project_mechanical_operations as mech_ops +``` + +Mechanical operations manage HVAC systems, fan systems, HVAC plants, and service +water heating. The `hvac` singleton can be `MISSING` and is created automatically +on add. `swhSystem[]` is a top-level list (not under `hvac`). + +| Function | Purpose | +|---|---| +| **HVAC systems** (`hvac.hvacSystem[]`) | | +| `add_hvac_system_to_project(project, new_hvac_system)` | Add an `HVACSystem`. Raises `ValueError` if `fanSystemKey` references a non-existent fan system. | +| `update_hvac_system_in_project(project, hvac_system_description, updates)` | Update an `HVACSystem` by its `description`. Raises `ValueError` for engine-calculated fields (`requirementAnswer`) or invalid `fanSystemKey`. | +| `remove_hvac_system_from_project(project, hvac_system_description)` | Remove an `HVACSystem` by its `description`. | +| `assign_fan_system_to_hvac_system(project, hvac_system_description, fan_system_description)` | Assign a fan system to an HVAC system by copying its UUID into `fanSystemKey`. Pass `fan_system_description=None` to unassign. | +| `get_hvac_system_keys_from_project(project)` | List `[{description, systemType, quantity, fanSystemKey, fanSystemDescription}, …]` for the project. | +| **Fan systems** (`hvac.fanSystem[]`) | | +| `add_fan_system_to_project(project, new_fan_system)` | Add a `FanSystem`. The library sets a new UUID on `fanSystemKey` if it's empty or already used. | +| `update_fan_system_in_project(project, fan_system_description, updates)` | Update a `FanSystem` by its `description`. Raises `ValueError` for engine-calculated fields (`complies`, `complianceMessage`, `requirementAnswer`) or `fanSystemKey` changes. | +| `remove_fan_system_from_project(project, fan_system_description)` | Remove a `FanSystem` by its `description`. Also clears `fanSystemKey` on HVAC systems that reference it. | +| `get_fan_system_keys_from_project(project)` | List `[{description, fanSystemKey, complyMethod}, …]` for the project. | +| **Fans** (nested in `FanSystem.fan[]`) | | +| `add_fan_to_fan_system(project, fan_system_description, new_fan)` | Add a `Fan` to a fan system. | +| `update_fan_in_fan_system(project, fan_system_description, fan_description, updates)` | Update a `Fan` by its `description`. | +| `remove_fan_from_fan_system(project, fan_system_description, fan_description)` | Remove a `Fan` by its `description`. | +| **HVAC plants** (`hvac.hvacPlant[]`) | | +| `add_hvac_plant_to_project(project, new_hvac_plant)` | Add an `HVACPlant`. | +| `update_hvac_plant_in_project(project, hvac_plant_description, updates)` | Update an `HVACPlant` by its `description`. Raises `ValueError` for engine-calculated fields (`requirementAnswer`). | +| `remove_hvac_plant_from_project(project, hvac_plant_description)` | Remove an `HVACPlant` by its `description`. | +| `get_hvac_plant_keys_from_project(project)` | List `[{description, plantType, systemType, quantity}, …]` for the project. | +| **Water heaters** (`swhSystem[]`, top-level) | | +| `add_water_heater_to_project(project, new_water_heater)` | Add a `ServiceWaterHeatingSystem`. | +| `update_water_heater_in_project(project, water_heater_description, updates)` | Update a `ServiceWaterHeatingSystem` by its `description`. Raises `ValueError` for engine-calculated fields (`requirementAnswer`). | +| `remove_water_heater_from_project(project, water_heater_description)` | Remove a `ServiceWaterHeatingSystem` by its `description`. | +| `get_water_heater_keys_from_project(project)` | List `[{description, swhSystemType, fuelType, quantity}, …]` for the project. | + +Construct `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, and +`ServiceWaterHeatingSystem` directly from `comcheck_api.types.core_types` +(all fields are optional). `description` is the +identifier — it is unique within each list and is auto-generated if missing. + +**Fan system keys**: the library sets a new UUID on `fanSystemKey` when you add +a fan system if it's empty or already used. You cannot update `fanSystemKey` +directly — use `assign_fan_system_to_hvac_system` or `remove_fan_system_from_project` +(which clears `fanSystemKey` on referencing HVAC systems). Callers use descriptions +and never handle the UUID directly. + ## Renewable energy operations ```python diff --git a/comcheck_api/ai/skill/reference/types.md b/comcheck_api/ai/skill/reference/types.md index 6a366b2..58cae10 100644 --- a/comcheck_api/ai/skill/reference/types.md +++ b/comcheck_api/ai/skill/reference/types.md @@ -58,7 +58,7 @@ for any of these models. Leave `project.hvac` at its template default. | `HVACSystem` | One HVAC system definition. | | `HVACPlant` | Central plant equipment. | | `Fan` / `FanSystem` | Fan and fan-system models. | -| `ServiceWaterHeatingSystem` (SWH) | Service water heating. | +| `ServiceWaterHeatingSystem` (SWH) | Water heater (stored in `swhSystem[]`). | ## Enums (StrEnum) diff --git a/comcheck_api/ai/skill/scripts/validate_code.py b/comcheck_api/ai/skill/scripts/validate_code.py index 36441b9..f3524f0 100644 --- a/comcheck_api/ai/skill/scripts/validate_code.py +++ b/comcheck_api/ai/skill/scripts/validate_code.py @@ -28,12 +28,13 @@ def _read_input(arg: str) -> str: return Path(arg).read_text() -UNSUPPORTED_PROJECT_ATTRS = {"hvac"} +UNSUPPORTED_PROJECT_ATTRS: set[str] = set() # `wholeBldgUse` (building areas + interior lighting), `activityUse` (interior # lighting spaces), `exteriorUse` (exterior lighting), `exteriorLightingZoneType`, -# `renewable` (renewable energy systems and procurement), and `efficiencyPackages` -# (energy credits) all have operation modules. Only `hvac` and `fixtureSchedule` -# have no operations yet. +# `renewable` (renewable energy systems and procurement), `efficiencyPackages` +# (energy credits), `hvac` (HVAC systems, fan systems, plants), and `swhSystem` +# (water heaters) all have operation modules. Only `fixtureSchedule` +# has no operations yet. UNSUPPORTED_LIGHTING_ATTRS = { "fixtureSchedule", } @@ -132,6 +133,9 @@ def validate(code: str) -> dict: "project_building_area_operations", "project_interior_lighting_operations", "project_exterior_lighting_operations", + "project_mechanical_operations", + "project_renewable_operations", + "project_credit_operations", }: op_module_aliases.add(alias.asname or alias.name) elif isinstance(node, ast.ImportFrom) and node.module == ( @@ -143,12 +147,15 @@ def validate(code: str) -> dict: "project_building_area_operations", "project_interior_lighting_operations", "project_exterior_lighting_operations", + "project_mechanical_operations", + "project_renewable_operations", + "project_credit_operations", }: op_module_aliases.add(alias.asname or alias.name) for node in ast.walk(tree): - # Forbid `project.hvac` / `project.renewable` access in any context. - if isinstance(node, ast.Attribute) and any( + # Check for unsupported project attrs (currently none — hvac and swhSystem now have operations) + if UNSUPPORTED_PROJECT_ATTRS and isinstance(node, ast.Attribute) and any( _is_attr_chain(node, "project", attr) for attr in UNSUPPORTED_PROJECT_ATTRS ): errors.append( diff --git a/comcheck_api/api/api_services.py b/comcheck_api/api/api_services.py index 489ee46..7c3a013 100644 --- a/comcheck_api/api/api_services.py +++ b/comcheck_api/api/api_services.py @@ -487,6 +487,213 @@ def update_energy_credits(self, project_data: Dict[str, Any]) -> Dict[str, Any]: except Exception as error: self._handle_api_error(error) + def water_heater_efficiency( + self, swh_data: Dict[str, Any], energy_code: str, water_heater_name: Optional[str] = None + ) -> Dict[str, Any]: + """Calculate efficiency for water heater systems. + + Args: + swh_data: ServiceWaterHeatingSystem data or list of systems + energy_code: The energy code for the endpoint path + water_heater_name: Optional name to match a system description + + Returns: + API response data as dictionary. ``data`` is a dict keyed by + system description, with ``minEfficiency``, ``maxEfficiency``, + and ``efficiencyUnits``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + params = {"waterHeaterName": water_heater_name} if water_heater_name else None + response = client.post( + f"/{energy_code}/mechanical/water-heaters/efficiency", + json=swh_data, + params=params, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def hvac_system_efficiency( + self, hvac_data: Dict[str, Any], energy_code: str, hvac_name: Optional[str] = None + ) -> Dict[str, Any]: + """Calculate efficiency for HVAC systems. + + Args: + hvac_data: HVACSystem data or list of systems + energy_code: The energy code for the endpoint path + hvac_name: Optional name to match a system description + + Returns: + API response data as dictionary. ``data`` is a dict keyed by + system description, with efficiency fields. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + params = {"hvacName": hvac_name} if hvac_name else None + response = client.post( + f"/{energy_code}/mechanical/hvac-systems/efficiency", + json=hvac_data, + params=params, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def hvac_system_economizers_validation( + self, hvac_data: Dict[str, Any], energy_code: str + ) -> Dict[str, Any]: + """Validate economizer requirements for an HVAC system. + + Args: + hvac_data: Single HVACSystem data + energy_code: The energy code for the endpoint path + + Returns: + API response data as dictionary. ``data`` is a dict keyed by + system description, with economizer validation fields. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post( + f"/{energy_code}/mechanical/hvac-systems/economizers-validation", + json=hvac_data, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def plant_efficiency( + self, plant_data: Dict[str, Any], energy_code: str, plant_name: Optional[str] = None + ) -> Dict[str, Any]: + """Calculate efficiency for HVAC plants. + + Args: + plant_data: HVACPlant data or list of plants + energy_code: The energy code for the endpoint path + plant_name: Optional name to match a plant description + + Returns: + API response data as dictionary. ``data`` is a dict keyed by + plant description, with efficiency fields. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + params = {"plantName": plant_name} if plant_name else None + response = client.post( + f"/{energy_code}/mechanical/plants/efficiency", + json=plant_data, + params=params, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def fan_pressure_drop_credit( + self, credit_data: Dict[str, Any], energy_code: str, fan_pressure_credit_type: str + ) -> Dict[str, Any]: + """Calculate pressure drop credit for a fan system. + + Args: + credit_data: FanSystemPressureDropCredits data without type field + energy_code: The energy code for the endpoint path + fan_pressure_credit_type: The credit type (required query param) + + Returns: + API response data as dictionary. ``data`` holds + ``pressureDropCredit``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post( + f"/{energy_code}/mechanical/fan-systems/fan-pressure-credit", + json=credit_data, + params={"fanPressureCreditType": fan_pressure_credit_type}, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def fan_system_compliance( + self, fan_system_data: Dict[str, Any], energy_code: str + ) -> Dict[str, Any]: + """Check compliance for a fan system. + + Args: + fan_system_data: Single FanSystem data + energy_code: The energy code for the endpoint path + + Returns: + API response data as dictionary. ``data`` holds compliance fields. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post( + f"/{energy_code}/mechanical/fan-systems/compliance", + json=fan_system_data, + ) + response.raise_for_status() + return response.json() + except Exception as error: + self._handle_api_error(error) + + def fan_maximum_allowable_hp( + self, fan_data: Dict[str, Any], energy_code: str + ) -> Dict[str, Any]: + """Calculate maximum allowable horsepower for a fan. + + Args: + fan_data: Fan data + energy_code: The energy code for the endpoint path + + Returns: + API response data as dictionary. ``data`` holds + ``maxNameplateHp``. + + Raises: + COMCheckHTTPError: If the API returns an error status + COMCheckConnectionError: If the request fails + """ + try: + client = self._get_client() + response = client.post( + f"/{energy_code}/mechanical/fan-systems/maximum-allowable-hp", + json=fan_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 f29d949..8f8474e 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -11,12 +11,28 @@ from comcheck_api.api import COMCheckApiService from comcheck_api.exceptions import ( + COMCheckAPIError, COMCheckProjectNotFoundError, COMCheckSimulationError, ) -from comcheck_api.types.api_types import EnergyCreditResult +from comcheck_api.types.api_types import ( + EnergyCreditResult, + WaterHeaterEfficiency, + HVACSystemEfficiency, + EconomizerValidation, + PlantEfficiency, + FanSystemComplianceResult, +) from comcheck_api.types.common_types import InteriorSpace -from comcheck_api.types.core_types import ComBuilding +from comcheck_api.types.core_types import ( + ComBuilding, + ServiceWaterHeatingSystem, + HVACSystem, + HVACPlant, + FanSystem, + FanSystemPressureDropCredits, + Fan, +) Mode = Literal["python", "json"] @@ -583,6 +599,338 @@ def get_simulation_result(self, session_id: str) -> Dict[str, Any]: ) return result_response.data.model_dump(mode="python") + def calculate_water_heater_efficiency( + self, + water_heaters: Union[ServiceWaterHeatingSystem, List[ServiceWaterHeatingSystem]], + energy_code: str, + water_heater_name: Optional[str] = None, + ) -> Dict[str, WaterHeaterEfficiency]: + """Calculate efficiency requirements for water heater systems. + + Args: + water_heaters: A single ``ServiceWaterHeatingSystem`` or a list of them. + energy_code: The energy code to use. + water_heater_name: Optional name to match a system description. + + Returns: + A dict keyed by system description, with efficiency requirements. + + Raises: + ValueError: If a list contains duplicate descriptions. + """ + systems = [water_heaters] if not isinstance(water_heaters, list) else water_heaters + if len(systems) > 1: + descriptions = [s.description for s in systems] + if len(descriptions) != len(set(descriptions)): + raise ValueError("All water heater descriptions must be unique") + + if water_heater_name: + descriptions = [s.description for s in systems] + if water_heater_name not in descriptions: + raise ValueError( + f"water_heater_name '{water_heater_name}' does not match any system description" + ) + + swh_data = ( + [s.model_dump(mode="json") for s in systems] + if len(systems) > 1 + else systems[0].model_dump(mode="json") + ) + result = self._service.water_heater_efficiency(swh_data, energy_code, water_heater_name) + data = result.get("data") or {} + return { + desc: WaterHeaterEfficiency.model_validate(eff) + for desc, eff in data.items() + } + + def calculate_hvac_system_efficiency( + self, + hvac_systems: Union[HVACSystem, List[HVACSystem]], + energy_code: str, + hvac_name: Optional[str] = None, + ) -> Dict[str, HVACSystemEfficiency]: + """Calculate efficiency requirements for HVAC systems. + + Args: + hvac_systems: A single ``HVACSystem`` or a list of them. + energy_code: The energy code to use. + hvac_name: Optional name to match a system description. + + Returns: + A dict keyed by system description, with efficiency requirements. + + Raises: + ValueError: If a list contains duplicate descriptions. + """ + systems = [hvac_systems] if not isinstance(hvac_systems, list) else hvac_systems + if len(systems) > 1: + descriptions = [s.description for s in systems] + if len(descriptions) != len(set(descriptions)): + raise ValueError("All HVAC system descriptions must be unique") + + if hvac_name: + descriptions = [s.description for s in systems] + if hvac_name not in descriptions: + raise ValueError( + f"hvac_name '{hvac_name}' does not match any system description" + ) + + hvac_data = ( + [s.model_dump(mode="json") for s in systems] + if len(systems) > 1 + else systems[0].model_dump(mode="json") + ) + result = self._service.hvac_system_efficiency(hvac_data, energy_code, hvac_name) + data = result.get("data") or {} + return { + desc: HVACSystemEfficiency.model_validate(eff) + for desc, eff in data.items() + } + + def validate_hvac_system_economizer( + self, hvac_system: HVACSystem, energy_code: str + ) -> EconomizerValidation: + """Validate economizer requirements for an HVAC system. + + Args: + hvac_system: The HVAC system to validate. + energy_code: The energy code to use. + + Returns: + Economizer validation results. + + Raises: + ValueError: If the HVAC system description is empty. + """ + if not hvac_system.description or not hvac_system.description.strip(): + raise ValueError("HVAC system description cannot be empty") + + hvac_data = hvac_system.model_dump(mode="json") + result = self._service.hvac_system_economizers_validation(hvac_data, energy_code) + data = result.get("data") or {} + # The endpoint returns data keyed by description + for validation_data in data.values(): + return EconomizerValidation.model_validate(validation_data) + return EconomizerValidation() + + def calculate_plant_efficiency( + self, + plants: Union[HVACPlant, List[HVACPlant]], + energy_code: str, + plant_name: Optional[str] = None, + ) -> Dict[str, PlantEfficiency]: + """Calculate efficiency requirements for HVAC plants. + + Args: + plants: A single ``HVACPlant`` or a list of them. + energy_code: The energy code to use. + plant_name: Optional name to match a plant description. + + Returns: + A dict keyed by plant description, with efficiency requirements. + + Raises: + ValueError: If a list contains duplicate descriptions. + """ + plant_list = [plants] if not isinstance(plants, list) else plants + if len(plant_list) > 1: + descriptions = [p.description for p in plant_list] + if len(descriptions) != len(set(descriptions)): + raise ValueError("All plant descriptions must be unique") + + if plant_name: + descriptions = [p.description for p in plant_list] + if plant_name not in descriptions: + raise ValueError( + f"plant_name '{plant_name}' does not match any plant description" + ) + + plant_data = ( + [p.model_dump(mode="json") for p in plant_list] + if len(plant_list) > 1 + else plant_list[0].model_dump(mode="json") + ) + result = self._service.plant_efficiency(plant_data, energy_code, plant_name) + data = result.get("data") or {} + return { + desc: PlantEfficiency.model_validate(eff) + for desc, eff in data.items() + } + + def calculate_fan_pressure_drop_credit( + self, credit: FanSystemPressureDropCredits, energy_code: str + ) -> float: + """Calculate pressure drop credit for a fan system. + + Args: + credit: The pressure drop credit to calculate. The ``type`` field + must be set and cannot be ``PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED``. + energy_code: The energy code to use. + + Returns: + The calculated pressure drop credit value. + + Raises: + ValueError: If the credit type is unset or unspecified. + COMCheckAPIError: If the response has no ``pressureDropCredit``. + """ + if not credit.type or credit.type == "PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED": + raise ValueError( + "Credit type must be set and cannot be PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED" + ) + + # Dump the credit without the type field + credit_dict = credit.model_dump(mode="json") + credit_type = credit_dict.pop("type", None) + + result = self._service.fan_pressure_drop_credit(credit_dict, energy_code, credit_type) + data = result.get("data") or {} + if data.get("pressureDropCredit") is None: + raise COMCheckAPIError("No pressureDropCredit returned from the API") + return float(data["pressureDropCredit"]) + + def check_fan_system_compliance( + self, fan_system: FanSystem, energy_code: str + ) -> FanSystemComplianceResult: + """Check compliance for a fan system. + + Args: + fan_system: The fan system to check. + energy_code: The energy code to use. + + Returns: + Fan system compliance results. + """ + fan_system_data = fan_system.model_dump(mode="json") + result = self._service.fan_system_compliance(fan_system_data, energy_code) + data = result.get("data") or {} + return FanSystemComplianceResult.model_validate(data) + + def calculate_fan_maximum_allowable_hp( + self, fan: Fan, energy_code: str + ) -> float: + """Calculate maximum allowable horsepower for a fan. + + Args: + fan: The fan to calculate for. + energy_code: The energy code to use. + + Returns: + The maximum nameplate horsepower. + + Raises: + COMCheckAPIError: If the response has no ``maxNameplateHp``. + """ + fan_data = fan.model_dump(mode="json") + result = self._service.fan_maximum_allowable_hp(fan_data, energy_code) + data = result.get("data") or {} + if data.get("maxNameplateHp") is None: + raise COMCheckAPIError("No maxNameplateHp returned from the API") + return float(data["maxNameplateHp"]) + + def update_fan_system_compliance( + self, project: ComBuilding, fan_system_description: str + ) -> ComBuilding: + """Calculate fan system compliance and update the project in place. + + Sends the fan system to the compliance endpoint and writes the returned + values back onto the matching fan system in ``project.hvac.fanSystem`` + (matched by ``description``). + + Args: + project: The project whose fan system should be updated. + fan_system_description: The description of the fan system to update. + + Returns: + The same ``project`` instance, with compliance fields updated. + + Raises: + ValueError: If hvac is missing or the fan system is not found. + """ + from comcheck_api.types.core_types import MISSING + + if project.hvac is MISSING or not project.hvac: + raise ValueError("Project has no hvac data") + + fan_system = None + for fs in project.hvac.fanSystem or []: + if fs.description == fan_system_description: + fan_system = fs + break + + if fan_system is None: + raise ValueError( + f"Fan system '{fan_system_description}' not found in project" + ) + + energy_code = str(project.control.code) + compliance = self.check_fan_system_compliance(fan_system, energy_code) + + results = { + field: getattr(compliance, field) + for field in ( + "complies", + "complianceMessage", + "complyMethod", + "hasPressureDropCredits", + ) + if getattr(compliance, field) is not None + } + # Revalidate the merged fan system — plain setattr skips Pydantic + # validation, so e.g. a boolean hasPressureDropCredits would not be + # converted to the HasPressureDropCredits enum. + updated_fan_system = FanSystem.model_validate( + {**fan_system.model_dump(mode="python"), **results} + ) + fan_systems = project.hvac.fanSystem + fan_systems[fan_systems.index(fan_system)] = updated_fan_system + + return project + + def update_fan_pressure_drop_credits( + self, project: ComBuilding, fan_system_description: str + ) -> ComBuilding: + """Calculate pressure drop credits and update the project in place. + + For each credit in the fan system's ``pressureDropCredits`` list, + calculates the credit value and writes it back to + ``pressureDropCredit`` on that credit. + + Args: + project: The project whose fan system should be updated. + fan_system_description: The description of the fan system to update. + + Returns: + The same ``project`` instance, with pressure drop credit values updated. + + Raises: + ValueError: If hvac is missing or the fan system is not found. + """ + from comcheck_api.types.core_types import MISSING + + if project.hvac is MISSING or not project.hvac: + raise ValueError("Project has no hvac data") + + fan_system = None + for fs in project.hvac.fanSystem or []: + if fs.description == fan_system_description: + fan_system = fs + break + + if fan_system is None: + raise ValueError( + f"Fan system '{fan_system_description}' not found in project" + ) + + energy_code = str(project.control.code) + + for credit in fan_system.pressureDropCredits or []: + if credit.type and credit.type != "PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED": + credit_value = self.calculate_fan_pressure_drop_credit(credit, energy_code) + credit.pressureDropCredit = credit_value + + return project + def close(self) -> None: """Close the API service connection.""" if self._api_service is not None: diff --git a/comcheck_api/introspection.py b/comcheck_api/introspection.py index da81315..3f637d9 100644 --- a/comcheck_api/introspection.py +++ b/comcheck_api/introspection.py @@ -26,6 +26,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_mechanical_operations, project_renewable_operations, ) @@ -34,6 +35,7 @@ "envelope": project_envelope_operations, "interior_lighting": project_interior_lighting_operations, "exterior_lighting": project_exterior_lighting_operations, + "mechanical": project_mechanical_operations, "renewable": project_renewable_operations, "credits": project_credit_operations, } diff --git a/comcheck_api/managers/components/mechanical/__init__.py b/comcheck_api/managers/components/mechanical/__init__.py new file mode 100644 index 0000000..65d594c --- /dev/null +++ b/comcheck_api/managers/components/mechanical/__init__.py @@ -0,0 +1,13 @@ +"""Mechanical component managers.""" + +from comcheck_api.managers.components.mechanical.hvac_system import HVACSystemListManager +from comcheck_api.managers.components.mechanical.fan_system import FanSystemListManager +from comcheck_api.managers.components.mechanical.hvac_plant import HVACPlantListManager +from comcheck_api.managers.components.mechanical.water_heater import WaterHeaterListManager + +__all__ = [ + "HVACSystemListManager", + "FanSystemListManager", + "HVACPlantListManager", + "WaterHeaterListManager", +] diff --git a/comcheck_api/managers/components/mechanical/fan_system.py b/comcheck_api/managers/components/mechanical/fan_system.py new file mode 100644 index 0000000..c4f3513 --- /dev/null +++ b/comcheck_api/managers/components/mechanical/fan_system.py @@ -0,0 +1,86 @@ +"""Fan system manager for COMcheck projects.""" + +from comcheck_api.types.core_types import Fan, FanSystem +from comcheck_api.managers.data_manager import DataManager + + +class FanSystemListManager(DataManager[FanSystem]): + """Manager for fan systems with support for nested fans. + + This manager handles fan systems and their nested fans, + with automatic unique description generation. + """ + + model_type = FanSystem + + def add_new_fan(self, fan_system: FanSystem, fan: Fan) -> FanSystem: + """Add a new fan to a fan system. + + Args: + fan_system: The fan system to add the fan to. + fan: The fan configuration to add. + + Returns: + The updated fan system. + """ + return self.add_subcomponent(fan_system, fan) + + def update_fan(self, fan_system: FanSystem, fan_description: str, updates: dict | Fan) -> FanSystem: + """Update an existing fan in a fan system. + + Args: + fan_system: The fan system containing the fan. + fan_description: The description of the fan to update. + updates: Partial updates (dict) or full Fan object to apply. + + Returns: + The updated fan system. + """ + from comcheck_api.managers.data_manager import DataManager + + current_fans = getattr(fan_system, "fan", []) or [] + fan_manager: DataManager[Fan] = DataManager( + initial_data=current_fans, model_type=Fan + ) + + fan_manager.modify_one(id_value=fan_description, updates=updates) + updated_fan_system = fan_system.model_copy(deep=True) + updated_fan_system.fan = fan_manager.get_all() + + # Update the fan system in the manager + return self.modify_one( + id_value=getattr(fan_system, self._identifier), + updates=updated_fan_system + ) + + def remove_fan(self, fan_system: FanSystem, fan_description: str) -> FanSystem: + """Remove a fan from a fan system. + + Args: + fan_system: The fan system containing the fan. + fan_description: The description of the fan to remove. + + Returns: + The updated fan system. + """ + from comcheck_api.managers.data_manager import DataManager + + current_fans = getattr(fan_system, "fan", []) or [] + fan_manager: DataManager[Fan] = DataManager( + initial_data=current_fans, model_type=Fan + ) + + was_deleted = fan_manager.delete_one(fan_description) + if not was_deleted: + raise ValueError( + f"Fan with description '{fan_description}' not found in fan system." + ) + + updated_fan_system = fan_system.model_copy(deep=True) + updated_fan_system.fan = fan_manager.get_all() + + # Update the fan system in the manager + return self.modify_one( + id_value=getattr(fan_system, self._identifier), + updates=updated_fan_system + ) diff --git a/comcheck_api/managers/components/mechanical/hvac_plant.py b/comcheck_api/managers/components/mechanical/hvac_plant.py new file mode 100644 index 0000000..bda0d15 --- /dev/null +++ b/comcheck_api/managers/components/mechanical/hvac_plant.py @@ -0,0 +1,13 @@ +"""HVAC plant manager for COMcheck projects.""" + +from comcheck_api.types.core_types import HVACPlant +from comcheck_api.managers.data_manager import DataManager + + +class HVACPlantListManager(DataManager[HVACPlant]): + """Manager for HVAC plants with automatic unique description generation. + + This manager handles HVAC plants identified by their description. + """ + + model_type = HVACPlant diff --git a/comcheck_api/managers/components/mechanical/hvac_system.py b/comcheck_api/managers/components/mechanical/hvac_system.py new file mode 100644 index 0000000..90a3c12 --- /dev/null +++ b/comcheck_api/managers/components/mechanical/hvac_system.py @@ -0,0 +1,13 @@ +"""HVAC system manager for COMcheck projects.""" + +from comcheck_api.types.core_types import HVACSystem +from comcheck_api.managers.data_manager import DataManager + + +class HVACSystemListManager(DataManager[HVACSystem]): + """Manager for HVAC systems with automatic unique description generation. + + This manager handles HVAC systems identified by their description. + """ + + model_type = HVACSystem diff --git a/comcheck_api/managers/components/mechanical/water_heater.py b/comcheck_api/managers/components/mechanical/water_heater.py new file mode 100644 index 0000000..aeb17d8 --- /dev/null +++ b/comcheck_api/managers/components/mechanical/water_heater.py @@ -0,0 +1,13 @@ +"""Water heater manager for COMcheck projects.""" + +from comcheck_api.types.core_types import ServiceWaterHeatingSystem +from comcheck_api.managers.data_manager import DataManager + + +class WaterHeaterListManager(DataManager[ServiceWaterHeatingSystem]): + """Manager for water heaters with automatic unique description generation. + + This manager handles water heaters identified by their description. + """ + + model_type = ServiceWaterHeatingSystem diff --git a/comcheck_api/managers/data_manager.py b/comcheck_api/managers/data_manager.py index e280d09..1abfb0e 100644 --- a/comcheck_api/managers/data_manager.py +++ b/comcheck_api/managers/data_manager.py @@ -367,6 +367,11 @@ def get_model_info(model_class: Type[BaseModel]) -> IdInfo | None: WholeBldgUse, RenewableSystem, OffsiteRenewableProcurement, + HVACSystem, + FanSystem, + HVACPlant, + ServiceWaterHeatingSystem, + Fan, ) MODEL_TO_ID_INFO = { @@ -385,5 +390,10 @@ def get_model_info(model_class: Type[BaseModel]) -> IdInfo | None: OffsiteRenewableProcurement: IdInfo( identifier="description", id_prefix="Offsite Procurement" ), + HVACSystem: IdInfo(identifier="description", id_prefix="HVAC System"), + FanSystem: IdInfo(identifier="description", id_prefix="Fan System"), + HVACPlant: IdInfo(identifier="description", id_prefix="Plant"), + ServiceWaterHeatingSystem: IdInfo(identifier="description", id_prefix="Water Heater"), + Fan: IdInfo(identifier="description", id_prefix="Fan"), } return MODEL_TO_ID_INFO.get(model_class) diff --git a/comcheck_api/project_operations/__init__.py b/comcheck_api/project_operations/__init__.py index cea321c..2c313ff 100644 --- a/comcheck_api/project_operations/__init__.py +++ b/comcheck_api/project_operations/__init__.py @@ -6,6 +6,7 @@ project_envelope_operations, project_exterior_lighting_operations, project_interior_lighting_operations, + project_mechanical_operations, project_renewable_operations, ) @@ -15,5 +16,6 @@ "project_envelope_operations", "project_exterior_lighting_operations", "project_interior_lighting_operations", + "project_mechanical_operations", "project_renewable_operations", ] diff --git a/comcheck_api/project_operations/project_mechanical_operations.py b/comcheck_api/project_operations/project_mechanical_operations.py new file mode 100644 index 0000000..a7b81f7 --- /dev/null +++ b/comcheck_api/project_operations/project_mechanical_operations.py @@ -0,0 +1,858 @@ +"""Project Mechanical Operations. + +Manages HVAC systems, fan systems, plants, and water heaters at the +project level. Mechanical data lives in ``ComBuilding.hvac`` (which can be ``MISSING``) +and ``ComBuilding.swhSystem`` (also can be ``MISSING``). + +The ``hvac`` singleton contains: + +- ``hvacSystem[]`` — list of HVAC systems, identified by ``description`` +- ``fanSystem[]`` — list of fan systems (can be ``None``), identified by ``description`` +- ``hvacPlant[]`` — list of HVAC plants, identified by ``description`` + +Water heaters are separate: + +- ``swhSystem[]`` — list at the top level, identified by ``description`` + +Fan system cross-reference +--------------------------- +``FanSystem.fanSystemKey`` is a UUID that links a fan system to an HVAC system. +When a fan system is assigned to an HVAC system, its UUID is copied into +``HVACSystem.fanSystemKey``. Use ``assign_fan_system_to_hvac_system`` to create +this link by description; callers never handle the UUID directly. + +Energy codes +------------ +Mechanical operations don't check ``control.code`` — which mechanical components +apply to the project is up to you. +""" + +import logging +from typing import Any +from uuid import uuid4 +from pydantic import BaseModel +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.types.core_types import ( + ComBuilding, + HVAC, + HVACSystem, + FanSystem, + Fan, + HVACPlant, + ServiceWaterHeatingSystem, +) +from comcheck_api.utilities.project_utilities import ( + _ensure_hvac, + _require_hvac_system, + _require_fan_system, + _require_hvac_plant, + _require_water_heater, +) + +logger = logging.getLogger(__name__) + +# Engine-calculated fields that must not be writable via update operations +_ENGINE_CALCULATED_FAN_SYSTEM_FIELDS = frozenset( + { + "complies", + "complianceMessage", + } +) + +_ENGINE_CALCULATED_MECHANICAL_FIELDS = frozenset( + { + "requirementAnswer", + } +) + +# Identity fields that must not be changed via update +_FAN_SYSTEM_IDENTITY_FIELDS = frozenset({"fanSystemKey"}) + + +# *********** HVAC system operations *********** + + +def add_hvac_system_to_project( + project: ComBuilding, + new_hvac_system: HVACSystem, +) -> ComBuilding: + """Add a new HVAC system to the project. + + If ``project.hvac`` is ``MISSING``, it is created automatically. + + Args: + project: The project to modify. + new_hvac_system: The HVACSystem to add. + + Returns: + Updated project with the new HVACSystem added. + + Raises: + ValueError: If the HVAC system's fanSystemKey references a non-existent fan system. + """ + updated_project = project.model_copy(deep=True) + new_hvac_system = new_hvac_system.model_copy(deep=True) + + # Ensure hvac exists + _ensure_hvac(updated_project) + + # Validate fanSystemKey if provided + fan_system_key = getattr(new_hvac_system, "fanSystemKey", None) + if fan_system_key: + _validate_fan_system_key(updated_project, fan_system_key) + + updated_project.hvac.append_subcomponent(new_hvac_system, subcomponent_name="hvacSystem") + return updated_project + + +def update_hvac_system_in_project( + project: ComBuilding, + hvac_system_description: str, + updates: dict[str, Any] | HVACSystem, +) -> ComBuilding: + """Update an existing HVAC system. + + Engine-calculated fields (``requirementAnswer``) are rejected. + + Args: + project: The project to modify. + hvac_system_description: The description of the HVAC system to update. + updates: Partial updates (dict) or full HVACSystem to apply. + + Returns: + Updated project with the HVAC system modified. + + Raises: + ValueError: If the HVAC system is not found, or if updates contain + engine-calculated fields, or if fanSystemKey references a non-existent fan system. + """ + _require_hvac_system(project, hvac_system_description) + + current = _find_by_description(project.hvac.hvacSystem, hvac_system_description) + updates = _updates_to_dict(updates) + _reject_changed_fields( + current, + updates, + _ENGINE_CALCULATED_MECHANICAL_FIELDS, + "update_hvac_system_in_project", + ) + + # Validate fanSystemKey if being updated + fan_system_key = updates.get("fanSystemKey") + if fan_system_key is not None: + _validate_fan_system_key(project, fan_system_key) + + updated_project = project.model_copy(deep=True) + updated_project.hvac.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=hvac_system_description, + subcomponent_name="hvacSystem", + ) + return updated_project + + +def remove_hvac_system_from_project( + project: ComBuilding, + hvac_system_description: str, +) -> ComBuilding: + """Remove an HVAC system from the project. + + Args: + project: The project to modify. + hvac_system_description: The description of the HVAC system to remove. + + Returns: + Updated project with the HVAC system removed. + + Raises: + ValueError: If the HVAC system is not found. + """ + _require_hvac_system(project, hvac_system_description) + + updated_project = project.model_copy(deep=True) + updated_project.hvac.remove_from_subcomponent_list( + subcomponent_id=hvac_system_description, + subcomponent_name="hvacSystem", + ) + return updated_project + + +def assign_fan_system_to_hvac_system( + project: ComBuilding, + hvac_system_description: str, + fan_system_description: str | None, +) -> ComBuilding: + """Assign a fan system to an HVAC system by copying its UUID into fanSystemKey. + + Pass ``fan_system_description=None`` to unassign the fan system. + + Args: + project: The project to modify. + hvac_system_description: The description of the HVAC system. + fan_system_description: The description of the fan system to assign, or None to unassign. + + Returns: + Updated project with the fan system assigned. + + Raises: + ValueError: If the HVAC system or fan system is not found. + """ + _require_hvac_system(project, hvac_system_description) + + fan_system_key = None + if fan_system_description is not None: + _require_fan_system(project, fan_system_description) + # Look up the fan system's key + fan_systems = project.hvac.fanSystem or [] + for fan_system in fan_systems: + if getattr(fan_system, "description", None) == fan_system_description: + fan_system_key = getattr(fan_system, "fanSystemKey", None) + break + if not fan_system_key or fan_system_key is MISSING: + raise ValueError( + f"Fan system '{fan_system_description}' has no fanSystemKey. " + "Add fan systems with add_fan_system_to_project so a key is set." + ) + + updated_project = project.model_copy(deep=True) + + # Find and update the HVAC system directly to avoid MISSING serialization issues + hvac_systems = updated_project.hvac.hvacSystem or [] + for hvac_system in hvac_systems: + if getattr(hvac_system, "description", None) == hvac_system_description: + hvac_system.fanSystemKey = fan_system_key + break + + return updated_project + + +def get_hvac_system_keys_from_project(project: ComBuilding) -> list[dict]: + """Return the description, system type, quantity, and fan system info of all HVAC systems. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``systemType``, ``quantity``, + ``fanSystemKey``, and ``fanSystemDescription`` for each HVACSystem in the project. + """ + if project.hvac is MISSING: + return [] + + hvac_systems = getattr(project.hvac, "hvacSystem", None) + if not isinstance(hvac_systems, list): + return [] + + # Build a map of fanSystemKey -> description for lookup + fan_system_map = {} + fan_systems = getattr(project.hvac, "fanSystem", None) or [] + if isinstance(fan_systems, list): + for fan_system in fan_systems: + key = getattr(fan_system, "fanSystemKey", None) + desc = getattr(fan_system, "description", None) + if key: + fan_system_map[key] = desc + + result = [] + for system in hvac_systems: + fan_system_key = getattr(system, "fanSystemKey", None) + result.append( + { + "description": getattr(system, "description", None), + "systemType": getattr(system, "systemType", None), + "quantity": getattr(system, "quantity", None), + "fanSystemKey": fan_system_key, + "fanSystemDescription": fan_system_map.get(fan_system_key) if fan_system_key else None, + } + ) + return result + + +# *********** Fan system operations *********** + + +def add_fan_system_to_project( + project: ComBuilding, + new_fan_system: FanSystem, +) -> ComBuilding: + """Add a new fan system to the project. + + If ``project.hvac`` is ``MISSING``, it is created automatically. The library sets + ``fanSystemKey`` to a new UUID if it is empty or already used by another fan system. + + Args: + project: The project to modify. + new_fan_system: The FanSystem to add. + + Returns: + Updated project with the new FanSystem added. + """ + updated_project = project.model_copy(deep=True) + new_fan_system = new_fan_system.model_copy(deep=True) + + # Ensure hvac exists + _ensure_hvac(updated_project) + + # Generate or validate fanSystemKey + current_keys = set() + fan_systems = updated_project.hvac.fanSystem or [] + for fs in fan_systems: + key = getattr(fs, "fanSystemKey", None) + if key and key is not MISSING: + current_keys.add(key) + + new_key = getattr(new_fan_system, "fanSystemKey", None) + # Check if key is missing, None, or already exists + if new_key is MISSING or new_key is None or not new_key or new_key in current_keys: + new_key = str(uuid4()) + new_fan_system.fanSystemKey = new_key + + updated_project.hvac.append_subcomponent(new_fan_system, subcomponent_name="fanSystem") + return updated_project + + +def update_fan_system_in_project( + project: ComBuilding, + fan_system_description: str, + updates: dict[str, Any] | FanSystem, +) -> ComBuilding: + """Update an existing fan system. + + Engine-calculated fields (``complies``, ``complianceMessage``, ``requirementAnswer``) + and identity fields (``fanSystemKey``) are rejected. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system to update. + updates: Partial updates (dict) or full FanSystem to apply. + + Returns: + Updated project with the fan system modified. + + Raises: + ValueError: If the fan system is not found, or if updates contain + disallowed fields. + """ + _require_fan_system(project, fan_system_description) + + current = _find_by_description(project.hvac.fanSystem, fan_system_description) + updates = _updates_to_dict(updates) + _reject_changed_fields( + current, + updates, + _ENGINE_CALCULATED_FAN_SYSTEM_FIELDS + | _ENGINE_CALCULATED_MECHANICAL_FIELDS + | _FAN_SYSTEM_IDENTITY_FIELDS, + "update_fan_system_in_project", + hint="Use assign_fan_system_to_hvac_system for linking.", + ) + + updated_project = project.model_copy(deep=True) + updated_project.hvac.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=fan_system_description, + subcomponent_name="fanSystem", + ) + return updated_project + + +def remove_fan_system_from_project( + project: ComBuilding, + fan_system_description: str, +) -> ComBuilding: + """Remove a fan system from the project. + + Also clears ``fanSystemKey`` on any HVAC systems that reference it. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system to remove. + + Returns: + Updated project with the fan system removed. + + Raises: + ValueError: If the fan system is not found. + """ + _require_fan_system(project, fan_system_description) + + # Get the fan system's key before removing it + fan_systems = project.hvac.fanSystem or [] + removed_key = None + for fan_system in fan_systems: + if getattr(fan_system, "description", None) == fan_system_description: + removed_key = getattr(fan_system, "fanSystemKey", None) + break + + updated_project = project.model_copy(deep=True) + updated_project.hvac.remove_from_subcomponent_list( + subcomponent_id=fan_system_description, + subcomponent_name="fanSystem", + ) + + # Clear fanSystemKey on HVAC systems that reference the removed fan system + if removed_key: + hvac_systems = getattr(updated_project.hvac, "hvacSystem", None) or [] + for hvac_system in hvac_systems: + if getattr(hvac_system, "fanSystemKey", None) == removed_key: + hvac_system.fanSystemKey = None + + return updated_project + + +def get_fan_system_keys_from_project(project: ComBuilding) -> list[dict]: + """Return the description, fan system key, and comply method of all fan systems. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``fanSystemKey``, and ``complyMethod`` + for each FanSystem in the project. + """ + if project.hvac is MISSING: + return [] + + fan_systems = getattr(project.hvac, "fanSystem", None) or [] + if not isinstance(fan_systems, list): + return [] + + return [ + { + "description": getattr(system, "description", None), + "fanSystemKey": getattr(system, "fanSystemKey", None), + "complyMethod": getattr(system, "complyMethod", None), + } + for system in fan_systems + ] + + +# *********** Fans and pressure drop credits in a fan system *********** + + +def add_fan_to_fan_system( + project: ComBuilding, + fan_system_description: str, + new_fan: Fan, +) -> ComBuilding: + """Add a new fan to a fan system. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + new_fan: The Fan to add. + + Returns: + Updated project with the new fan added. + + Raises: + ValueError: If the fan system is not found. + """ + _require_fan_system(project, fan_system_description) + + updated_project = project.model_copy(deep=True) + new_fan = new_fan.model_copy(deep=True) + + # Find the fan system and update it + fan_systems = updated_project.hvac.fanSystem or [] + for fan_system in fan_systems: + if getattr(fan_system, "description", None) == fan_system_description: + fan_system.append_subcomponent(new_fan, subcomponent_name="fan") + break + + return updated_project + + +def update_fan_in_fan_system( + project: ComBuilding, + fan_system_description: str, + fan_description: str, + updates: dict[str, Any] | Fan, +) -> ComBuilding: + """Update an existing fan in a fan system. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + fan_description: The description of the fan to update. + updates: Partial updates (dict) or full Fan to apply. + + Returns: + Updated project with the fan modified. + + Raises: + ValueError: If the fan system or fan is not found. + """ + _require_fan_system(project, fan_system_description) + + updated_project = project.model_copy(deep=True) + + # Find the fan system and update the fan + fan_systems = updated_project.hvac.fanSystem or [] + for fan_system in fan_systems: + if getattr(fan_system, "description", None) == fan_system_description: + fan_system.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=fan_description, + subcomponent_name="fan", + ) + break + + return updated_project + + +def remove_fan_from_fan_system( + project: ComBuilding, + fan_system_description: str, + fan_description: str, +) -> ComBuilding: + """Remove a fan from a fan system. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + fan_description: The description of the fan to remove. + + Returns: + Updated project with the fan removed. + + Raises: + ValueError: If the fan system or fan is not found. + """ + _require_fan_system(project, fan_system_description) + + updated_project = project.model_copy(deep=True) + + # Find the fan system and remove the fan + fan_systems = updated_project.hvac.fanSystem or [] + for fan_system in fan_systems: + if getattr(fan_system, "description", None) == fan_system_description: + fan_system.remove_from_subcomponent_list( + subcomponent_id=fan_description, + subcomponent_name="fan", + ) + break + + return updated_project + + +# *********** Plant operations *********** + + +def add_hvac_plant_to_project( + project: ComBuilding, + new_hvac_plant: HVACPlant, +) -> ComBuilding: + """Add a new HVAC plant to the project. + + If ``project.hvac`` is ``MISSING``, it is created automatically. + + Args: + project: The project to modify. + new_hvac_plant: The HVACPlant to add. + + Returns: + Updated project with the new HVACPlant added. + """ + updated_project = project.model_copy(deep=True) + new_hvac_plant = new_hvac_plant.model_copy(deep=True) + + # Ensure hvac exists + _ensure_hvac(updated_project) + + updated_project.hvac.append_subcomponent(new_hvac_plant, subcomponent_name="hvacPlant") + return updated_project + + +def update_hvac_plant_in_project( + project: ComBuilding, + hvac_plant_description: str, + updates: dict[str, Any] | HVACPlant, +) -> ComBuilding: + """Update an existing HVAC plant. + + Engine-calculated fields (``requirementAnswer``) are rejected. + + Args: + project: The project to modify. + hvac_plant_description: The description of the HVAC plant to update. + updates: Partial updates (dict) or full HVACPlant to apply. + + Returns: + Updated project with the HVAC plant modified. + + Raises: + ValueError: If the HVAC plant is not found, or if updates contain + engine-calculated fields. + """ + _require_hvac_plant(project, hvac_plant_description) + + current = _find_by_description(project.hvac.hvacPlant, hvac_plant_description) + updates = _updates_to_dict(updates) + _reject_changed_fields( + current, + updates, + _ENGINE_CALCULATED_MECHANICAL_FIELDS, + "update_hvac_plant_in_project", + ) + + updated_project = project.model_copy(deep=True) + updated_project.hvac.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=hvac_plant_description, + subcomponent_name="hvacPlant", + ) + return updated_project + + +def remove_hvac_plant_from_project( + project: ComBuilding, + hvac_plant_description: str, +) -> ComBuilding: + """Remove an HVAC plant from the project. + + Args: + project: The project to modify. + hvac_plant_description: The description of the HVAC plant to remove. + + Returns: + Updated project with the HVAC plant removed. + + Raises: + ValueError: If the HVAC plant is not found. + """ + _require_hvac_plant(project, hvac_plant_description) + + updated_project = project.model_copy(deep=True) + updated_project.hvac.remove_from_subcomponent_list( + subcomponent_id=hvac_plant_description, + subcomponent_name="hvacPlant", + ) + return updated_project + + +def get_hvac_plant_keys_from_project(project: ComBuilding) -> list[dict]: + """Return the description, plant type, system type, and quantity of all HVAC plants. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``plantType``, ``systemType``, + and ``quantity`` for each HVACPlant in the project. + """ + if project.hvac is MISSING: + return [] + + hvac_plants = getattr(project.hvac, "hvacPlant", None) + if not isinstance(hvac_plants, list): + return [] + + return [ + { + "description": getattr(plant, "description", None), + "plantType": getattr(plant, "plantType", None), + "systemType": getattr(plant, "systemType", None), + "quantity": getattr(plant, "quantity", None), + } + for plant in hvac_plants + ] + + +# *********** Water heater operations *********** + + +def add_water_heater_to_project( + project: ComBuilding, + new_water_heater: ServiceWaterHeatingSystem, +) -> ComBuilding: + """Add a new water heater to the project. + + If ``project.swhSystem`` is ``MISSING``, it is initialized to an empty list. + + Args: + project: The project to modify. + new_water_heater: The ServiceWaterHeatingSystem to add. + + Returns: + Updated project with the new ServiceWaterHeatingSystem added. + """ + updated_project = project.model_copy(deep=True) + new_water_heater = new_water_heater.model_copy(deep=True) + + # Initialize swhSystem to [] if it's MISSING + if updated_project.swhSystem is MISSING: + updated_project.swhSystem = [] + + updated_project.append_subcomponent(new_water_heater, subcomponent_name="swhSystem") + return updated_project + + +def update_water_heater_in_project( + project: ComBuilding, + water_heater_description: str, + updates: dict[str, Any] | ServiceWaterHeatingSystem, +) -> ComBuilding: + """Update an existing water heater. + + Engine-calculated fields (``requirementAnswer``) are rejected. + + Args: + project: The project to modify. + water_heater_description: The description of the water heater to update. + updates: Partial updates (dict) or full ServiceWaterHeatingSystem to apply. + + Returns: + Updated project with the water heater modified. + + Raises: + ValueError: If the water heater is not found, or if updates + contain engine-calculated fields. + """ + _require_water_heater(project, water_heater_description) + + current = _find_by_description(project.swhSystem, water_heater_description) + updates = _updates_to_dict(updates) + _reject_changed_fields( + current, + updates, + _ENGINE_CALCULATED_MECHANICAL_FIELDS, + "update_water_heater_in_project", + ) + + updated_project = project.model_copy(deep=True) + updated_project.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=water_heater_description, + subcomponent_name="swhSystem", + ) + return updated_project + + +def remove_water_heater_from_project( + project: ComBuilding, + water_heater_description: str, +) -> ComBuilding: + """Remove a water heater from the project. + + Args: + project: The project to modify. + water_heater_description: The description of the water heater to remove. + + Returns: + Updated project with the water heater removed. + + Raises: + ValueError: If the water heater is not found. + """ + _require_water_heater(project, water_heater_description) + + updated_project = project.model_copy(deep=True) + updated_project.remove_from_subcomponent_list( + subcomponent_id=water_heater_description, + subcomponent_name="swhSystem", + ) + return updated_project + + +def get_water_heater_keys_from_project(project: ComBuilding) -> list[dict]: + """Return the description, system type, fuel type, and quantity of all water heaters. + + Args: + project: The project to query. + + Returns: + List of dicts with keys ``description``, ``swhSystemType``, ``fuelType``, + and ``quantity`` for each ServiceWaterHeatingSystem in the project. + """ + water_heaters = project.swhSystem + if water_heaters is MISSING or not isinstance(water_heaters, list): + return [] + + return [ + { + "description": getattr(system, "description", None), + "swhSystemType": getattr(system, "swhSystemType", None), + "fuelType": getattr(system, "fuelType", None), + "quantity": getattr(system, "quantity", None), + } + for system in water_heaters + ] + + +# *********** Helper Functions *********** + + +def _find_by_description(items, description: str): + """Return the item in items with the given description (None if not found).""" + return next( + (item for item in items or [] if getattr(item, "description", None) == description), + None, + ) + + +def _updates_to_dict(updates: dict[str, Any] | BaseModel) -> dict[str, Any]: + """Convert model updates to a dict the same way DataManager.modify_one does.""" + if isinstance(updates, BaseModel): + return updates.model_dump(mode="json", exclude_unset=True) + return updates + + +def _reject_changed_fields( + current: BaseModel | None, + updates: dict[str, Any], + fields: frozenset[str], + function_name: str, + hint: str = "", +) -> None: + """Raise if updates change any of the given engine-calculated or identity fields. + + A field whose value is unchanged is allowed, so an edited copy of an existing + item can be passed back as the update. + + Raises: + ValueError: If any of the fields is set to a different value. + """ + current_values = current.model_dump(mode="json") if current is not None else {} + changed = sorted( + field + for field in fields & updates.keys() + if updates[field] != current_values.get(field) + ) + if changed: + raise ValueError( + f"Cannot update engine-calculated or identity fields via {function_name}: " + f"{changed}. {hint}".rstrip() + ) + + +def _validate_fan_system_key(project: ComBuilding, fan_system_key: str) -> None: + """Validate that a fanSystemKey references an existing fan system. + + Args: + project: The project to check. + fan_system_key: The fanSystemKey to validate. + + Raises: + ValueError: If the fanSystemKey does not reference an existing fan system. + """ + if project.hvac is MISSING: + raise ValueError( + f"fanSystemKey '{fan_system_key}' references a non-existent fan system. " + "No HVAC data found in project." + ) + + fan_systems = getattr(project.hvac, "fanSystem", None) or [] + if not isinstance(fan_systems, list): + raise ValueError( + f"fanSystemKey '{fan_system_key}' references a non-existent fan system. " + "No fan systems found in project." + ) + + if not any( + getattr(fs, "fanSystemKey", None) == fan_system_key for fs in fan_systems + ): + raise ValueError( + f"fanSystemKey '{fan_system_key}' does not match any existing fan system." + ) diff --git a/comcheck_api/types/api_types.py b/comcheck_api/types/api_types.py index be6f260..6905e10 100644 --- a/comcheck_api/types/api_types.py +++ b/comcheck_api/types/api_types.py @@ -205,3 +205,107 @@ def passes(self) -> bool | None: return (self.proposedRenewableAndLoadMgmt or 0) >= ( self.requiredRenewableAndLoadMgmt or 0 ) + + +class WaterHeaterEfficiency(BaseModel): + """Efficiency requirements for a water heater. + + Attributes: + minEfficiency: Minimum required efficiency. + maxEfficiency: Maximum required efficiency. + efficiencyUnits: Units for the efficiency values. + """ + + model_config = ConfigDict(extra="allow") + + minEfficiency: float | None = None + maxEfficiency: float | None = None + efficiencyUnits: str | None = None + + +class HVACSystemEfficiency(BaseModel): + """Efficiency requirements for an HVAC system. + + Attributes: + minCoolingEfficiency: Minimum cooling efficiency required. + maxCoolingEfficiency: Maximum cooling efficiency required. + minHeatingEfficiency: Minimum heating efficiency required. + maxHeatingEfficiency: Maximum heating efficiency required. + minPartLoadEfficiency: Minimum part-load efficiency. + partLoadEfficiency: Part-load efficiency value. + coolingEfficiencyUnits: Units for cooling efficiency. + heatingEfficiencyUnits: Units for heating efficiency. + """ + + model_config = ConfigDict(extra="allow") + + minCoolingEfficiency: float | None = None + maxCoolingEfficiency: float | None = None + minHeatingEfficiency: float | None = None + maxHeatingEfficiency: float | None = None + minPartLoadEfficiency: float | None = None + partLoadEfficiency: str | float | None = None + coolingEfficiencyUnits: str | None = None + heatingEfficiencyUnits: str | None = None + + +class EconomizerValidation(BaseModel): + """Economizer validation results for an HVAC system. + + Attributes: + coolingCapLimitNoEconomizer: Cooling capacity limit without economizer. + coolCapNoReqLimit: Cooling capacity without requirement limit. + coolCapNoEconomizer: Cooling capacity without economizer. + complies: Whether the system complies with economizer requirements. + """ + + model_config = ConfigDict(extra="allow") + + coolingCapLimitNoEconomizer: str | float | None = None + coolCapNoReqLimit: str | float | None = None + coolCapNoEconomizer: str | float | None = None + complies: bool | None = None + + +class PlantEfficiency(BaseModel): + """Efficiency requirements for an HVAC plant. + + Attributes: + plantMinHeatingEfficiency: Minimum plant heating efficiency. + plantRequiredCoolingEfficiency: Required plant cooling efficiency. + minPartLoadEfficiency: Minimum part-load efficiency. + coolingEfficiencyUnits: Units for cooling efficiency. + heatingEfficiencyUnits: Units for heating efficiency. + partLoadEfficiencyUnits: Units for part-load efficiency. + """ + + model_config = ConfigDict(extra="allow") + + plantMinHeatingEfficiency: float | None = None + plantRequiredCoolingEfficiency: float | None = None + minPartLoadEfficiency: float | None = None + coolingEfficiencyUnits: str | None = None + heatingEfficiencyUnits: str | None = None + partLoadEfficiencyUnits: str | None = None + + +class FanSystemComplianceResult(BaseModel): + """Fan system compliance check results. + + Attributes: + hasPressureDropCredits: Whether the system has pressure drop credits. + complyMethod: Compliance method used. + complies: Whether the system complies. + complianceMessage: Compliance status message. + proposed: Proposed fan system value. + maxAllowed: Maximum allowed value. + """ + + model_config = ConfigDict(extra="allow") + + hasPressureDropCredits: bool | None = None + complyMethod: str | None = None + complies: bool | None = None + complianceMessage: str | None = None + proposed: float | None = None + maxAllowed: float | None = None diff --git a/comcheck_api/types/custom_base_model.py b/comcheck_api/types/custom_base_model.py index 09e84da..145bb4a 100644 --- a/comcheck_api/types/custom_base_model.py +++ b/comcheck_api/types/custom_base_model.py @@ -219,9 +219,10 @@ def _get_normalized_subcomponent_info( ): """Resolve the concrete type and attribute name for a subcomponent. - When *subcomponent_name* is provided the type is looked up from - ``core_types`` by capitalising the name. Otherwise the type and - name are derived from the *subcomponent* instance itself. + When *subcomponent_name* is provided the type is first looked up from + the field's annotation (unwrapping ``list[X]``, ``list[X] | None``, etc.), + falling back to capitalising the name and looking it up in ``core_types``. + Otherwise the type and name are derived from the *subcomponent* instance itself. Args: subcomponent: A model instance or dict representing the subcomponent. @@ -238,11 +239,38 @@ def _get_normalized_subcomponent_info( """ if subcomponent_name: from comcheck_api.types import core_types - - class_name = subcomponent_name[0].upper() + subcomponent_name[1:] - cls = getattr(core_types, class_name, None) + import types + import typing + + # Try to extract the type from the field annotation + cls = None + if subcomponent_name in self.__class__.model_fields: + field_info = self.__class__.model_fields[subcomponent_name] + annotation = field_info.annotation + + # Unwrap list[X] | None | MISSING etc. + origin = typing.get_origin(annotation) + if origin is typing.Union or origin is types.UnionType: + # For Union types, find the list type + args = typing.get_args(annotation) + for arg in args: + if typing.get_origin(arg) is list: + annotation = arg + origin = list + break + + if origin is list: + # Extract the element type from list[X] + args = typing.get_args(annotation) + if args: + cls = args[0] + + # Fall back to capitalising the name if cls is None: - raise ValueError(f"Unknown subcomponent type for name '{class_name}'") + class_name = subcomponent_name[0].upper() + subcomponent_name[1:] + cls = getattr(core_types, class_name, None) + if cls is None: + raise ValueError(f"Unknown subcomponent type for name '{class_name}'") return cls, subcomponent_name else: @@ -266,11 +294,11 @@ def _get_subcomponent_list(self, subcomponent_name: str): subcomponent_name: Name of the list-valued field on this model. Returns: - The current list value for the given attribute. + The current list value for the given attribute. ``None`` and ``MISSING`` are normalized to ``[]``. Raises: AttributeError: If the field does not exist on this model. - TypeError: If the field value is not a list. + TypeError: If the field value is not a list, None, or MISSING. """ if subcomponent_name not in self.__class__.model_fields: raise AttributeError( @@ -279,10 +307,16 @@ def _get_subcomponent_list(self, subcomponent_name: str): current_subcomponents = getattr(self, subcomponent_name, []) + # Normalize None and MISSING to [] + if current_subcomponents is None: + return [] + if _PYDANTIC_MISSING is not None and current_subcomponents is _PYDANTIC_MISSING: + return [] + if not isinstance(current_subcomponents, list): raise TypeError( f"{self.__class__.__name__}.{subcomponent_name} " - f"must be a list, got {type(current_subcomponents).__name__}" + f"must be a list, None, or MISSING, got {type(current_subcomponents).__name__}" ) return current_subcomponents diff --git a/comcheck_api/utilities/project_utilities.py b/comcheck_api/utilities/project_utilities.py index 8b717e5..949645c 100644 --- a/comcheck_api/utilities/project_utilities.py +++ b/comcheck_api/utilities/project_utilities.py @@ -240,3 +240,136 @@ def _require_offsite_renewable_procurement( f"OffsiteRenewableProcurement with description '{description}' " f"not found in renewable.offsiteRenewableProcurement." ) + + +def _ensure_hvac(project: ComBuilding): + """Create the hvac singleton if it is MISSING, and normalize fanSystem=None to []. + + Args: + project: The project to check. + + Returns: + The HVAC object (created if needed). + """ + from pydantic.experimental.missing_sentinel import MISSING + from comcheck_api.types.core_types import HVAC + + if project.hvac is MISSING: + project.hvac = HVAC(hvacSystem=[], hvacPlant=[], fanSystem=[]) + + # Normalize fanSystem=None to [] + if project.hvac.fanSystem is None: + project.hvac.fanSystem = [] + + return project.hvac + + +def _require_hvac_system(project: ComBuilding, description: str) -> None: + """Ensure project.hvac.hvacSystem contains a system with the given description. + + Args: + project: The project to check. + description: The description of the HVAC system to find. + + Raises: + ValueError: If no HVAC system with the given description is found. + """ + from pydantic.experimental.missing_sentinel import MISSING + + if project.hvac is MISSING: + raise ValueError("No HVAC data (hvac) found in project.") + + hvac_systems = getattr(project.hvac, "hvacSystem", None) + if not isinstance(hvac_systems, list): + raise ValueError("No HVAC systems (hvac.hvacSystem) found in project.") + + if not any( + getattr(system, "description", None) == description for system in hvac_systems + ): + raise ValueError( + f"HVACSystem with description '{description}' not found in hvac.hvacSystem." + ) + + +def _require_fan_system(project: ComBuilding, description: str) -> None: + """Ensure project.hvac.fanSystem contains a system with the given description. + + Args: + project: The project to check. + description: The description of the fan system to find. + + Raises: + ValueError: If no fan system with the given description is found. + """ + from pydantic.experimental.missing_sentinel import MISSING + + if project.hvac is MISSING: + raise ValueError("No HVAC data (hvac) found in project.") + + fan_systems = getattr(project.hvac, "fanSystem", None) + # fanSystem can be None, normalize to empty list for checking + if fan_systems is None: + fan_systems = [] + if not isinstance(fan_systems, list): + raise ValueError("No fan systems (hvac.fanSystem) found in project.") + + if not any( + getattr(system, "description", None) == description for system in fan_systems + ): + raise ValueError( + f"FanSystem with description '{description}' not found in hvac.fanSystem." + ) + + +def _require_hvac_plant(project: ComBuilding, description: str) -> None: + """Ensure project.hvac.hvacPlant contains a plant with the given description. + + Args: + project: The project to check. + description: The description of the HVAC plant to find. + + Raises: + ValueError: If no HVAC plant with the given description is found. + """ + from pydantic.experimental.missing_sentinel import MISSING + + if project.hvac is MISSING: + raise ValueError("No HVAC data (hvac) found in project.") + + hvac_plants = getattr(project.hvac, "hvacPlant", None) + if not isinstance(hvac_plants, list): + raise ValueError("No HVAC plants (hvac.hvacPlant) found in project.") + + if not any( + getattr(plant, "description", None) == description for plant in hvac_plants + ): + raise ValueError( + f"HVACPlant with description '{description}' not found in hvac.hvacPlant." + ) + + +def _require_water_heater(project: ComBuilding, description: str) -> None: + """Ensure project.swhSystem contains a system with the given description. + + Args: + project: The project to check. + description: The description of the water heater to find. + + Raises: + ValueError: If no water heater with the given description is found. + """ + from pydantic.experimental.missing_sentinel import MISSING + + water_heaters = project.swhSystem + if water_heaters is MISSING or not isinstance(water_heaters, list): + raise ValueError( + "No water heaters (swhSystem) found in project." + ) + + if not any( + getattr(system, "description", None) == description for system in water_heaters + ): + raise ValueError( + f"ServiceWaterHeatingSystem with description '{description}' " + f"not found in swhSystem." + ) diff --git a/docs_site/api/operations/mechanical.md b/docs_site/api/operations/mechanical.md index 67a936c..7279767 100644 --- a/docs_site/api/operations/mechanical.md +++ b/docs_site/api/operations/mechanical.md @@ -1,13 +1,395 @@ # Mechanical -!!! warning "Under Development" - Mechanical operations are not yet implemented. This section is planned for a future release. +The mechanical operations module manages HVAC systems, fan systems, HVAC plants, and water heaters in COMcheck projects. -This page will cover operations for managing mechanical systems in COMcheck projects, including: +```python +from comcheck_api import project_mechanical_operations as mech_ops +``` -- Adding and updating HVAC systems -- Managing HVAC plants -- Fan systems and pressure drop credits -- Service water heating systems +## Overview -The underlying types for mechanical systems (`HVACSystem`, `HVACPlant`, `Fan`, `FanSystem`, `FanSystemPressureDropCredits`, `ServiceWaterHeatingSystem`) are already available. See the [Types Guide](types-guide.md) for details on importing and using them. +Mechanical data lives in two places on `ComBuilding`: + +- **`hvac`** — a singleton (can be `MISSING`) containing: + - `hvacSystem[]` — list of HVAC systems + - `fanSystem[]` — list of fan systems (can be `None`) + - `hvacPlant[]` — list of HVAC plants +- **`swhSystem[]`** — water heaters (top-level, not under `hvac`) + +All four lists are identified by `description`, which is auto-generated if missing (e.g., "HVAC System 1", "Fan System 1", "Plant 1", "Water Heater 1"). The `hvac` singleton is created automatically when you add the first HVAC, fan, or plant component. + +## HVAC Systems + +HVAC systems are identified by their `description`. + +### add_hvac_system_to_project + +```python +project = mech_ops.add_hvac_system_to_project( + project, + new_hvac_system +) +``` + +Add a new HVAC system. Creates the `hvac` singleton if it's `MISSING`. + +**Parameters:** + +- `project` (`ComBuilding`) — The project to modify +- `new_hvac_system` (`HVACSystem`) — The HVAC system to add + +**Raises:** + +- `ValueError` — If the HVAC system's `fanSystemKey` references a non-existent fan system + +**Example:** + +```python +from comcheck_api.types.core_types import HVACSystem + +hvac_system = HVACSystem(description="Rooftop Unit 1", quantity=2) +project = mech_ops.add_hvac_system_to_project(project, hvac_system) +``` + +### update_hvac_system_in_project + +```python +project = mech_ops.update_hvac_system_in_project( + project, + hvac_system_description, + updates +) +``` + +Update an existing HVAC system. Engine-calculated fields (`requirementAnswer`) are rejected. + +**Parameters:** + +- `project` (`ComBuilding`) — The project to modify +- `hvac_system_description` (`str`) — The description of the HVAC system to update +- `updates` (`dict | HVACSystem`) — Partial updates (dict) or full HVACSystem + +**Raises:** + +- `ValueError` — If the HVAC system is not found, updates contain engine-calculated fields, or `fanSystemKey` references a non-existent fan system + +### remove_hvac_system_from_project + +```python +project = mech_ops.remove_hvac_system_from_project( + project, + hvac_system_description +) +``` + +Remove an HVAC system from the project. + +### assign_fan_system_to_hvac_system + +```python +project = mech_ops.assign_fan_system_to_hvac_system( + project, + hvac_system_description, + fan_system_description # or None to unassign +) +``` + +Assign a fan system to an HVAC system by copying its UUID into `fanSystemKey`. Pass `fan_system_description=None` to unassign the fan system. + +**Example:** + +```python +project = mech_ops.assign_fan_system_to_hvac_system( + project, "Rooftop Unit 1", "Supply Fan 1" +) +``` + +### get_hvac_system_keys_from_project + +```python +keys = mech_ops.get_hvac_system_keys_from_project(project) +# Returns: [{"description": "...", "systemType": "...", "quantity": ..., +# "fanSystemKey": "...", "fanSystemDescription": "..."}, ...] +``` + +## Fan Systems + +Fan systems are identified by their `description` and linked to HVAC systems via a UUID (`fanSystemKey`). + +### add_fan_system_to_project + +```python +project = mech_ops.add_fan_system_to_project( + project, + new_fan_system +) +``` + +Add a new fan system. The library sets a new UUID on `fanSystemKey` if it's empty or already used. + +**Example:** + +```python +from comcheck_api.types.core_types import FanSystem + +fan_system = FanSystem(description="Supply Fan 1") +project = mech_ops.add_fan_system_to_project(project, fan_system) +``` + +### update_fan_system_in_project + +```python +project = mech_ops.update_fan_system_in_project( + project, + fan_system_description, + updates +) +``` + +Update an existing fan system. Engine-calculated fields (`complies`, `complianceMessage`, `requirementAnswer`) and identity fields (`fanSystemKey`) are rejected. + +**Raises:** + +- `ValueError` — If updates contain disallowed fields. Use `assign_fan_system_to_hvac_system` for linking. + +### remove_fan_system_from_project + +```python +project = mech_ops.remove_fan_system_from_project( + project, + fan_system_description +) +``` + +Remove a fan system from the project. Also clears `fanSystemKey` on any HVAC systems that reference it. + +### get_fan_system_keys_from_project + +```python +keys = mech_ops.get_fan_system_keys_from_project(project) +# Returns: [{"description": "...", "fanSystemKey": "...", "complyMethod": "..."}, ...] +``` + +## Fans + +Fans are nested within fan systems (`FanSystem.fan[]`) and identified by their `description`. + +### add_fan_to_fan_system + +```python +project = mech_ops.add_fan_to_fan_system( + project, + fan_system_description, + new_fan +) +``` + +Add a new fan to a fan system. + +**Example:** + +```python +from comcheck_api.types.core_types import Fan + +fan = Fan(description="Fan 1") +project = mech_ops.add_fan_to_fan_system(project, "Supply Fan 1", fan) +``` + +### update_fan_in_fan_system + +```python +project = mech_ops.update_fan_in_fan_system( + project, + fan_system_description, + fan_description, + updates +) +``` + +Update an existing fan in a fan system. + +### remove_fan_from_fan_system + +```python +project = mech_ops.remove_fan_from_fan_system( + project, + fan_system_description, + fan_description +) +``` + +Remove a fan from a fan system. + +## HVAC Plants + +HVAC plants are identified by their `description`. + +### add_hvac_plant_to_project + +```python +project = mech_ops.add_hvac_plant_to_project( + project, + new_hvac_plant +) +``` + +Add a new HVAC plant. Creates the `hvac` singleton if it's `MISSING`. + +**Example:** + +```python +from comcheck_api.types.core_types import HVACPlant + +plant = HVACPlant(description="Chiller 1") +project = mech_ops.add_hvac_plant_to_project(project, plant) +``` + +### update_hvac_plant_in_project + +```python +project = mech_ops.update_hvac_plant_in_project( + project, + hvac_plant_description, + updates +) +``` + +Update an existing HVAC plant. Engine-calculated fields (`requirementAnswer`) are rejected. + +### remove_hvac_plant_from_project + +```python +project = mech_ops.remove_hvac_plant_from_project( + project, + hvac_plant_description +) +``` + +Remove an HVAC plant from the project. + +### get_hvac_plant_keys_from_project + +```python +keys = mech_ops.get_hvac_plant_keys_from_project(project) +# Returns: [{"description": "...", "plantType": "...", "systemType": "...", "quantity": ...}, ...] +``` + +## Water Heaters + +Water heaters live in the top-level `swhSystem[]` list (not under `hvac`) and are identified by their `description`. The model class keeps its schema name, `ServiceWaterHeatingSystem`. + +### add_water_heater_to_project + +```python +project = mech_ops.add_water_heater_to_project( + project, + new_water_heater +) +``` + +Add a new water heater. Initializes `swhSystem` to `[]` if it's `MISSING`. + +**Example:** + +```python +from comcheck_api.types.core_types import ServiceWaterHeatingSystem + +water_heater = ServiceWaterHeatingSystem(description="Water Heater 1") +project = mech_ops.add_water_heater_to_project(project, water_heater) +``` + +### update_water_heater_in_project + +```python +project = mech_ops.update_water_heater_in_project( + project, + water_heater_description, + updates +) +``` + +Update an existing water heater. Engine-calculated fields (`requirementAnswer`) are rejected. + +### remove_water_heater_from_project + +```python +project = mech_ops.remove_water_heater_from_project( + project, + water_heater_description +) +``` + +Remove a water heater from the project. + +### get_water_heater_keys_from_project + +```python +keys = mech_ops.get_water_heater_keys_from_project(project) +# Returns: [{"description": "...", "swhSystemType": "...", "fuelType": "...", "quantity": ...}, ...] +``` + +## Fan System Keys + +`FanSystem.fanSystemKey` is a UUID that links a fan system to an HVAC system: + +- The library sets a new UUID when you add a fan system if it's empty or already used +- You cannot update `fanSystemKey` directly — use `assign_fan_system_to_hvac_system` +- When a fan system is removed, `fanSystemKey` is cleared on any HVAC systems that reference it +- Callers use descriptions and never handle the UUID directly + +## API calls + +`COMcheckClient` calls the mechanical endpoints (`POST /COM/{energyCode}/mechanical/...`) +for efficiency requirements and fan system compliance. These methods take the +components and an energy code; they don't need a saved project. + +| Method | Endpoint | Returns | +|---|---|---| +| `calculate_water_heater_efficiency(water_heaters, energy_code, water_heater_name=None)` | `water-heaters/efficiency` | `dict[str, WaterHeaterEfficiency]` keyed by description | +| `calculate_hvac_system_efficiency(hvac_systems, energy_code, hvac_name=None)` | `hvac-systems/efficiency` | `dict[str, HVACSystemEfficiency]` keyed by description | +| `validate_hvac_system_economizer(hvac_system, energy_code)` | `hvac-systems/economizers-validation` | `EconomizerValidation` | +| `calculate_plant_efficiency(plants, energy_code, plant_name=None)` | `plants/efficiency` | `dict[str, PlantEfficiency]` keyed by description | +| `calculate_fan_pressure_drop_credit(credit, energy_code)` | `fan-systems/fan-pressure-credit` | `float` (`pressureDropCredit`) | +| `check_fan_system_compliance(fan_system, energy_code)` | `fan-systems/compliance` | `FanSystemComplianceResult` | +| `calculate_fan_maximum_allowable_hp(fan, energy_code)` | `fan-systems/maximum-allowable-hp` | `float` (`maxNameplateHp`) | + +- The water heater, HVAC system, and plant methods take one component or a list. + Descriptions in a list must be unique, and the optional `*_name` filter must + match one of them; otherwise a `ValueError` is raised before the request is sent. +- `calculate_fan_pressure_drop_credit` sends the credit's `type` as the + `fanPressureCreditType` query parameter. The type must be set and not + `PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED`. +- `validate_hvac_system_economizer` requires a non-empty `description`. + +```python +from comcheck_api import COMcheckClient +from comcheck_api.types.core_types import HVACSystem + +client = COMcheckClient() +efficiency = client.calculate_hvac_system_efficiency( + HVACSystem(description="Rooftop Unit 1"), "CEZ_IECC2021" +) +print(efficiency["Rooftop Unit 1"].minCoolingEfficiency) +``` + +### Writing results back to the project + +`complies`, `complianceMessage`, and `pressureDropCredit` are calculated by the +engine, so the update operations reject them. Use these client methods to fill +them in. They use `project.control.code`, update the project in place, and +return it: + +- `update_fan_system_compliance(project, fan_system_description)` — writes + `complies`, `complianceMessage`, `complyMethod`, and `hasPressureDropCredits`. +- `update_fan_pressure_drop_credits(project, fan_system_description)` — writes + `pressureDropCredit` on each entry of the fan system's `pressureDropCredits[]`. + +```python +project = client.update_fan_system_compliance(project, "Supply Fan 1") +``` + +See `examples/client/mechanical.py` for a full example. + +## Related + +- [Types Guide](../../types-guide.md) — Import `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, `ServiceWaterHeatingSystem` diff --git a/examples/README.md b/examples/README.md index d204345..f0a7b5d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ examples/ │ ├── allowed_wattage.py # Interior lighting allowed-wattage calculation examples │ ├── renewable.py # Renewable area & capacity calculation examples │ ├── credits.py # Energy credit operations & calculation examples +│ ├── mechanical.py # Mechanical efficiency & compliance calculation examples │ └── user_functions.py # User-facing function examples └── project_operations/ # Project operations examples ├── building_area_operations.py # Building area operations @@ -91,6 +92,25 @@ Demonstrates energy credit operations and the server-side credit calculation. python examples/client/credits.py ``` +#### Mechanical Examples (`client/mechanical.py`) +Demonstrates mechanical system efficiency and compliance calculations. + +**What it demonstrates:** +- `calculate_water_heater_efficiency()` - Calculating efficiency requirements for water heater systems +- `calculate_hvac_system_efficiency()` - Calculating efficiency requirements for HVAC systems +- `validate_hvac_system_economizer()` - Validating economizer requirements for an HVAC system +- `calculate_plant_efficiency()` - Calculating efficiency requirements for HVAC plants +- `calculate_fan_pressure_drop_credit()` - Calculating pressure drop credit for a fan system +- `check_fan_system_compliance()` - Checking fan system compliance +- `calculate_fan_maximum_allowable_hp()` - Calculating maximum allowable horsepower for a fan +- `update_fan_system_compliance()` - Updating fan system compliance fields in a project +- `update_fan_pressure_drop_credits()` - Updating pressure drop credit values in a project + +**Usage:** +```bash +python examples/client/mechanical.py +``` + #### User Function Examples (`client/user_functions.py`) Demonstrates user-facing API client functions. diff --git a/examples/client/mechanical.py b/examples/client/mechanical.py new file mode 100644 index 0000000..51b56ba --- /dev/null +++ b/examples/client/mechanical.py @@ -0,0 +1,132 @@ +"""Example of calculating mechanical efficiency requirements with the COMcheck API client.""" + +import os +from dotenv import load_dotenv +from comcheck_api.client import COMcheckClient +from comcheck_api.defaults import get_default_project_template +from comcheck_api.types.core_types import ( + EnergyCodeOptions, + ServiceWaterHeatingSystem, + HVACSystem, + HVACPlant, + FanSystem, + FanSystemPressureDropCredits, + Fan, + HVAC, +) + +# 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 +project = get_default_project_template() +project.control.code = EnergyCodeOptions.CEZ_IECC2024 +energy_code = str(project.control.code) + +# Example 1: Calculate water heater efficiency requirements +print("=== Water Heater Efficiency ===") +water_heater = ServiceWaterHeatingSystem( + description="Domestic Hot Water", + swhSystemType="SWH_SYSTEM_TYPE_STORAGE_TANK", + fuelType="NATURAL_GAS", +) +efficiency = client.calculate_water_heater_efficiency(water_heater, energy_code) +for desc, eff in efficiency.items(): + print(f"{desc}: {eff.minEfficiency} - {eff.maxEfficiency} {eff.efficiencyUnits}") + +# Example 2: Calculate HVAC system efficiency requirements +print("\n=== HVAC System Efficiency ===") +hvac_system = HVACSystem( + description="Rooftop Unit 1", + systemType="HVAC_SYSTEM_TYPE_PACKAGED_COOLING_AND_HEATING", + quantity=2, +) +hvac_eff = client.calculate_hvac_system_efficiency(hvac_system, energy_code) +for desc, eff in hvac_eff.items(): + print(f"{desc}:") + print(f" Cooling: {eff.minCoolingEfficiency} {eff.coolingEfficiencyUnits}") + if eff.minHeatingEfficiency: + print(f" Heating: {eff.minHeatingEfficiency} {eff.heatingEfficiencyUnits}") + +# Example 3: Validate economizer requirements +print("\n=== Economizer Validation ===") +hvac_system.coolingCapacity = 80000 # Btu/h +validation = client.validate_hvac_system_economizer(hvac_system, energy_code) +print(f"Complies: {validation.complies}") +print(f"Limit without economizer: {validation.coolingCapLimitNoEconomizer}") + +# Example 4: Calculate plant efficiency requirements +print("\n=== Plant Efficiency ===") +plant = HVACPlant( + description="Chiller 1", + plantType="PLANT_TYPE_CHILLER", + systemType="PLANT_SYSTEM_TYPE_WATER_COOLED", +) +plant_eff = client.calculate_plant_efficiency(plant, energy_code) +for desc, eff in plant_eff.items(): + print(f"{desc}:") + if eff.plantRequiredCoolingEfficiency: + print(f" Required cooling efficiency: {eff.plantRequiredCoolingEfficiency}") + +# Example 5: Calculate fan pressure drop credit +print("\n=== Fan Pressure Drop Credit ===") +credit = FanSystemPressureDropCredits( + type="PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12", +) +credit_value = client.calculate_fan_pressure_drop_credit(credit, energy_code) +print(f"Pressure drop credit: {credit_value}") + +# Example 6: Check fan system compliance +print("\n=== Fan System Compliance ===") +fan_system = FanSystem( + description="Supply Fan System 1", + complyMethod="FAN_SYSTEM_MOTOR_NAMEPLATE_HP", +) +compliance = client.check_fan_system_compliance(fan_system, energy_code) +print(f"Complies: {compliance.complies}") +print(f"Method: {compliance.complyMethod}") +print(f"Proposed: {compliance.proposed}, Max allowed: {compliance.maxAllowed}") +print(f"Message: {compliance.complianceMessage}") + +# Example 7: Calculate fan maximum allowable HP +print("\n=== Fan Maximum Allowable HP ===") +fan = Fan( + description="Supply Fan 1", + motorHp=5.0, + airflow=10000, +) +max_hp = client.calculate_fan_maximum_allowable_hp(fan, energy_code) +print(f"Maximum allowable nameplate HP: {max_hp}") + +# Example 8: Project write-back for fan system compliance +print("\n=== Update Fan System Compliance in Project ===") +project.hvac = HVAC( + hvacSystem=[], + hvacPlant=[], + fanSystem=[ + FanSystem( + description="Main Supply Fan System", + complyMethod="FAN_SYSTEM_MOTOR_NAMEPLATE_HP", + pressureDropCredits=[ + FanSystemPressureDropCredits( + type="PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12", + ) + ], + ) + ], +) + +# Update compliance fields +project = client.update_fan_system_compliance(project, "Main Supply Fan System") +fs = project.hvac.fanSystem[0] +print(f"Updated fan system - Complies: {fs.complies}, Method: {fs.complyMethod}") + +# Update pressure drop credit values +project = client.update_fan_pressure_drop_credits(project, "Main Supply Fan System") +credit = fs.pressureDropCredits[0] +print(f"Updated pressure drop credit: {credit.pressureDropCredit}") + +client.close() diff --git a/tests/project_operation_tests/test_mechanical_operations.py b/tests/project_operation_tests/test_mechanical_operations.py new file mode 100644 index 0000000..a713903 --- /dev/null +++ b/tests/project_operation_tests/test_mechanical_operations.py @@ -0,0 +1,649 @@ +"""Tests for project_mechanical_operations.""" + +import pytest +from uuid import UUID +from pydantic.experimental.missing_sentinel import MISSING + +from comcheck_api.project_operations import ( + project_mechanical_operations as mech_ops, +) +from comcheck_api.types.core_types import ( + ComBuilding, + HVACSystem, + FanSystem, + Fan, + HVACPlant, + ServiceWaterHeatingSystem, +) + + +def _get_test_hvac_system(): + """Create a minimal test HVAC system.""" + return HVACSystem(description="") + + +def _get_test_fan_system(): + """Create a minimal test fan system.""" + return FanSystem(description="") + + +def _get_test_fan(): + """Create a minimal test fan.""" + return Fan(description="") + + +def _get_test_hvac_plant(): + """Create a minimal test HVAC plant.""" + return HVACPlant(description="") + + +def _get_test_water_heater(): + """Create a minimal test water heater.""" + return ServiceWaterHeatingSystem(description="") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fresh(project: ComBuilding) -> ComBuilding: + return project.model_copy(deep=True) + + +# --------------------------------------------------------------------------- +# HVAC System Tests +# --------------------------------------------------------------------------- + + +def test_add_hvac_system_to_project(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC System" + + result = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + # Check that hvac was created and system was added + assert result.hvac is not MISSING + assert len(result.hvac.hvacSystem) == 1 + assert result.hvac.hvacSystem[0].description == "Test HVAC System" + + +def test_add_hvac_system_generates_description(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "" + + result = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + # Should have generated a description like "HVAC System 1" + assert result.hvac.hvacSystem[0].description.startswith("HVAC System") + + +def test_add_hvac_system_with_invalid_fan_system_key(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + hvac_system.fanSystemKey = "invalid-uuid" + + with pytest.raises(ValueError, match="does not match any existing fan system"): + mech_ops.add_hvac_system_to_project(proj, hvac_system) + + +def test_update_hvac_system_in_project(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + result = mech_ops.update_hvac_system_in_project(proj, "Test HVAC", {"quantity": 5}) + + assert result.hvac.hvacSystem[0].quantity == 5 + + +def test_update_hvac_system_rejects_engine_fields(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + with pytest.raises(ValueError, match="engine-calculated"): + mech_ops.update_hvac_system_in_project(proj, "Test HVAC", {"requirementAnswer": "test"}) + + +def test_update_hvac_system_not_found(project: ComBuilding): + proj = _fresh(project) + + with pytest.raises(ValueError, match="not found"): + mech_ops.update_hvac_system_in_project(proj, "Nonexistent", {"quantity": 5}) + + +def test_remove_hvac_system_from_project(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + result = mech_ops.remove_hvac_system_from_project(proj, "Test HVAC") + + assert len(result.hvac.hvacSystem) == 0 + + +def test_get_hvac_system_keys_from_project(project: ComBuilding): + proj = _fresh(project) + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + keys = mech_ops.get_hvac_system_keys_from_project(proj) + + assert len(keys) == 1 + assert keys[0]["description"] == "Test HVAC" + assert "systemType" in keys[0] + assert "fanSystemKey" in keys[0] + assert "fanSystemDescription" in keys[0] + + +def test_hvac_operations_do_not_mutate_original(project: ComBuilding): + proj = _fresh(project) + original_hvac = proj.hvac + + mech_ops.add_hvac_system_to_project(proj, _get_test_hvac_system()) + + assert proj.hvac == original_hvac + + +# --------------------------------------------------------------------------- +# Fan System Tests +# --------------------------------------------------------------------------- + + +def test_add_fan_system_to_project(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan System" + + result = mech_ops.add_fan_system_to_project(proj, fan_system) + + # Check that hvac was created and fan system was added + assert result.hvac is not MISSING + assert len(result.hvac.fanSystem) == 1 + assert result.hvac.fanSystem[0].description == "Test Fan System" + # Check that a UUID was generated + assert result.hvac.fanSystem[0].fanSystemKey is not None + + +def test_add_fan_system_generates_uuid(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + fan_system.fanSystemKey = None # No UUID provided + + result = mech_ops.add_fan_system_to_project(proj, fan_system) + + # Should have generated a valid UUID + uuid_str = result.hvac.fanSystem[0].fanSystemKey + assert uuid_str is not None + # Verify it's a valid UUID + UUID(uuid_str) + + +def test_add_fan_system_replaces_duplicate_uuid(project: ComBuilding): + proj = _fresh(project) + + # Add first fan system + fan1 = _get_test_fan_system() + fan1.description = "Fan 1" + proj = mech_ops.add_fan_system_to_project(proj, fan1) + + # Try to add second fan system with same UUID + fan2 = _get_test_fan_system() + fan2.description = "Fan 2" + fan2.fanSystemKey = proj.hvac.fanSystem[0].fanSystemKey + + result = mech_ops.add_fan_system_to_project(proj, fan2) + + # UUIDs should be different + assert result.hvac.fanSystem[0].fanSystemKey != result.hvac.fanSystem[1].fanSystemKey + + +def test_update_fan_system_in_project(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + result = mech_ops.update_fan_system_in_project(proj, "Test Fan", {"description": "Updated Fan"}) + + assert result.hvac.fanSystem[0].description == "Updated Fan" + + +def test_update_fan_system_rejects_fan_system_key(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + with pytest.raises(ValueError, match="identity fields"): + mech_ops.update_fan_system_in_project(proj, "Test Fan", {"fanSystemKey": "new-uuid"}) + + + +def test_update_fan_system_rejects_fan_system_key_in_model(project: ComBuilding): + """A model update is checked too, not only a dict update.""" + proj = mech_ops.add_fan_system_to_project( + _fresh(project), FanSystem(description="Test Fan") + ) + + with pytest.raises(ValueError, match="identity fields"): + mech_ops.update_fan_system_in_project( + proj, "Test Fan", FanSystem(fanSystemKey="new-uuid") + ) + + +def test_update_fan_system_accepts_unchanged_copy(project: ComBuilding): + """Passing back an edited copy keeps the same fanSystemKey and is allowed.""" + proj = mech_ops.add_fan_system_to_project( + _fresh(project), FanSystem(description="Test Fan") + ) + edited = proj.hvac.fanSystem[0].model_copy(update={"description2": "3 areas"}) + + proj = mech_ops.update_fan_system_in_project(proj, "Test Fan", edited) + + assert proj.hvac.fanSystem[0].description2 == "3 areas" + + +def test_update_hvac_system_rejects_unknown_fan_system_key_in_model(project: ComBuilding): + proj = mech_ops.add_hvac_system_to_project( + _fresh(project), HVACSystem(description="RTU 1") + ) + + with pytest.raises(ValueError, match="does not match any existing fan system"): + mech_ops.update_hvac_system_in_project( + proj, "RTU 1", HVACSystem(fanSystemKey="not-a-fan-system") + ) + + +def test_assign_fan_system_without_key_raises(project: ComBuilding): + proj = mech_ops.add_hvac_system_to_project( + _fresh(project), HVACSystem(description="RTU 1") + ) + proj = mech_ops.add_fan_system_to_project(proj, FanSystem(description="Fan Sys")) + proj.hvac.fanSystem[0].fanSystemKey = None + + with pytest.raises(ValueError, match="has no fanSystemKey"): + mech_ops.assign_fan_system_to_hvac_system(proj, "RTU 1", "Fan Sys") + +def test_update_fan_system_rejects_engine_fields(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + with pytest.raises(ValueError, match="engine-calculated"): + mech_ops.update_fan_system_in_project(proj, "Test Fan", {"complies": True}) + + with pytest.raises(ValueError, match="engine-calculated"): + mech_ops.update_fan_system_in_project(proj, "Test Fan", {"complianceMessage": "test"}) + + +def test_remove_fan_system_clears_hvac_references(project: ComBuilding): + proj = _fresh(project) + + # Add a fan system + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + fan_key = proj.hvac.fanSystem[0].fanSystemKey + + # Add an HVAC system and assign the fan system + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + proj = mech_ops.assign_fan_system_to_hvac_system(proj, "Test HVAC", "Test Fan") + + # Verify the link + assert proj.hvac.hvacSystem[0].fanSystemKey == fan_key + + # Remove the fan system + result = mech_ops.remove_fan_system_from_project(proj, "Test Fan") + + # Check that fanSystemKey was cleared on HVAC system + assert result.hvac.hvacSystem[0].fanSystemKey is None + + +def test_get_fan_system_keys_from_project(project: ComBuilding): + proj = _fresh(project) + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + keys = mech_ops.get_fan_system_keys_from_project(proj) + + assert len(keys) == 1 + assert keys[0]["description"] == "Test Fan" + assert "fanSystemKey" in keys[0] + assert "complyMethod" in keys[0] + + +# --------------------------------------------------------------------------- +# Fan System Assignment Tests +# --------------------------------------------------------------------------- + + +def test_assign_fan_system_to_hvac_system(project: ComBuilding): + proj = _fresh(project) + + # Add fan system and HVAC system + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + + # Assign + result = mech_ops.assign_fan_system_to_hvac_system(proj, "Test HVAC", "Test Fan") + + # Check the link + fan_key = result.hvac.fanSystem[0].fanSystemKey + assert result.hvac.hvacSystem[0].fanSystemKey == fan_key + + +def test_assign_fan_system_can_unassign(project: ComBuilding): + proj = _fresh(project) + + # Add and assign + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + proj = mech_ops.assign_fan_system_to_hvac_system(proj, "Test HVAC", "Test Fan") + + # Unassign + result = mech_ops.assign_fan_system_to_hvac_system(proj, "Test HVAC", None) + + assert result.hvac.hvacSystem[0].fanSystemKey is None + + +def test_get_hvac_system_keys_includes_fan_description(project: ComBuilding): + proj = _fresh(project) + + # Add fan system and HVAC system + fan_system = _get_test_fan_system() + fan_system.description = "Supply Fan 1" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + hvac_system = _get_test_hvac_system() + hvac_system.description = "Test HVAC" + proj = mech_ops.add_hvac_system_to_project(proj, hvac_system) + proj = mech_ops.assign_fan_system_to_hvac_system(proj, "Test HVAC", "Supply Fan 1") + + keys = mech_ops.get_hvac_system_keys_from_project(proj) + + assert keys[0]["fanSystemDescription"] == "Supply Fan 1" + + +# --------------------------------------------------------------------------- +# Fan Tests (nested in FanSystem) +# --------------------------------------------------------------------------- + + +def test_add_fan_to_fan_system(project: ComBuilding): + proj = _fresh(project) + + # Add fan system first + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan System" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + # Add a fan + fan = _get_test_fan() + fan.description = "Test Fan" + result = mech_ops.add_fan_to_fan_system(proj, "Test Fan System", fan) + + # Check + assert len(result.hvac.fanSystem[0].fan) == 1 + assert result.hvac.fanSystem[0].fan[0].description == "Test Fan" + + +def test_update_fan_in_fan_system(project: ComBuilding): + proj = _fresh(project) + + # Add fan system and fan + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan System" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + fan = _get_test_fan() + fan.description = "Test Fan" + proj = mech_ops.add_fan_to_fan_system(proj, "Test Fan System", fan) + + # Update the fan + result = mech_ops.update_fan_in_fan_system( + proj, "Test Fan System", "Test Fan", {"description": "Updated Fan"} + ) + + assert result.hvac.fanSystem[0].fan[0].description == "Updated Fan" + + +def test_remove_fan_from_fan_system(project: ComBuilding): + proj = _fresh(project) + + # Add fan system and fan + fan_system = _get_test_fan_system() + fan_system.description = "Test Fan System" + proj = mech_ops.add_fan_system_to_project(proj, fan_system) + + fan = _get_test_fan() + fan.description = "Test Fan" + proj = mech_ops.add_fan_to_fan_system(proj, "Test Fan System", fan) + + # Remove the fan + result = mech_ops.remove_fan_from_fan_system(proj, "Test Fan System", "Test Fan") + + assert len(result.hvac.fanSystem[0].fan) == 0 + + +# --------------------------------------------------------------------------- +# HVAC Plant Tests +# --------------------------------------------------------------------------- + + +def test_add_hvac_plant_to_project(project: ComBuilding): + proj = _fresh(project) + plant = _get_test_hvac_plant() + plant.description = "Test Plant" + + result = mech_ops.add_hvac_plant_to_project(proj, plant) + + assert result.hvac is not MISSING + assert len(result.hvac.hvacPlant) == 1 + assert result.hvac.hvacPlant[0].description == "Test Plant" + + +def test_update_hvac_plant_in_project(project: ComBuilding): + proj = _fresh(project) + plant = _get_test_hvac_plant() + plant.description = "Test Plant" + proj = mech_ops.add_hvac_plant_to_project(proj, plant) + + result = mech_ops.update_hvac_plant_in_project(proj, "Test Plant", {"quantity": 3}) + + assert result.hvac.hvacPlant[0].quantity == 3 + + +def test_update_hvac_plant_rejects_engine_fields(project: ComBuilding): + proj = _fresh(project) + plant = _get_test_hvac_plant() + plant.description = "Test Plant" + proj = mech_ops.add_hvac_plant_to_project(proj, plant) + + with pytest.raises(ValueError, match="engine-calculated"): + mech_ops.update_hvac_plant_in_project(proj, "Test Plant", {"requirementAnswer": "test"}) + + +def test_remove_hvac_plant_from_project(project: ComBuilding): + proj = _fresh(project) + plant = _get_test_hvac_plant() + plant.description = "Test Plant" + proj = mech_ops.add_hvac_plant_to_project(proj, plant) + + result = mech_ops.remove_hvac_plant_from_project(proj, "Test Plant") + + assert len(result.hvac.hvacPlant) == 0 + + +def test_get_hvac_plant_keys_from_project(project: ComBuilding): + proj = _fresh(project) + plant = _get_test_hvac_plant() + plant.description = "Test Plant" + proj = mech_ops.add_hvac_plant_to_project(proj, plant) + + keys = mech_ops.get_hvac_plant_keys_from_project(proj) + + assert len(keys) == 1 + assert keys[0]["description"] == "Test Plant" + assert "plantType" in keys[0] + assert "systemType" in keys[0] + + +# --------------------------------------------------------------------------- +# Water Heater Tests +# --------------------------------------------------------------------------- + + +def test_add_water_heater_to_project(project: ComBuilding): + proj = _fresh(project) + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + + result = mech_ops.add_water_heater_to_project(proj, swh) + + assert result.swhSystem is not MISSING + assert len(result.swhSystem) == 1 + assert result.swhSystem[0].description == "Test Water Heater" + + +def test_add_water_heater_when_missing(project: ComBuilding): + proj = _fresh(project) + # Ensure swhSystem is MISSING + proj.swhSystem = MISSING + + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + + result = mech_ops.add_water_heater_to_project(proj, swh) + + # Should have initialized to a list with one item + assert isinstance(result.swhSystem, list) + assert len(result.swhSystem) == 1 + + +def test_update_water_heater_in_project(project: ComBuilding): + proj = _fresh(project) + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + proj = mech_ops.add_water_heater_to_project(proj, swh) + + result = mech_ops.update_water_heater_in_project(proj, "Test Water Heater", {"quantity": 2}) + + assert result.swhSystem[0].quantity == 2 + + +def test_update_water_heater_rejects_engine_fields(project: ComBuilding): + proj = _fresh(project) + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + proj = mech_ops.add_water_heater_to_project(proj, swh) + + with pytest.raises(ValueError, match="engine-calculated"): + mech_ops.update_water_heater_in_project(proj, "Test Water Heater", {"requirementAnswer": "test"}) + + +def test_remove_water_heater_from_project(project: ComBuilding): + proj = _fresh(project) + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + proj = mech_ops.add_water_heater_to_project(proj, swh) + + result = mech_ops.remove_water_heater_from_project(proj, "Test Water Heater") + + assert len(result.swhSystem) == 0 + + +def test_get_water_heater_keys_from_project(project: ComBuilding): + proj = _fresh(project) + swh = _get_test_water_heater() + swh.description = "Test Water Heater" + proj = mech_ops.add_water_heater_to_project(proj, swh) + + keys = mech_ops.get_water_heater_keys_from_project(proj) + + assert len(keys) == 1 + assert keys[0]["description"] == "Test Water Heater" + assert "swhSystemType" in keys[0] + assert "fuelType" in keys[0] + + +# --------------------------------------------------------------------------- +# Round-trip and validation tests +# --------------------------------------------------------------------------- + + +def test_mechanical_round_trip(project: ComBuilding): + """Build a project with all four mechanical lists, dump, and validate.""" + proj = _fresh(project) + + # Add HVAC system + hvac = _get_test_hvac_system() + hvac.description = "RTU-1" + proj = mech_ops.add_hvac_system_to_project(proj, hvac) + + # Add fan system + fan_sys = _get_test_fan_system() + fan_sys.description = "Supply Fan" + proj = mech_ops.add_fan_system_to_project(proj, fan_sys) + + # Add fan to fan system + fan = _get_test_fan() + fan.description = "Fan 1" + proj = mech_ops.add_fan_to_fan_system(proj, "Supply Fan", fan) + + # Assign fan system to HVAC + proj = mech_ops.assign_fan_system_to_hvac_system(proj, "RTU-1", "Supply Fan") + + # Add plant + plant = _get_test_hvac_plant() + plant.description = "Chiller 1" + proj = mech_ops.add_hvac_plant_to_project(proj, plant) + + # Add a water heater + swh = _get_test_water_heater() + swh.description = "Water Heater 1" + proj = mech_ops.add_water_heater_to_project(proj, swh) + + # Round-trip through JSON + dumped = proj.model_dump(mode="json") + reloaded = ComBuilding.model_validate(dumped) + + # Verify structure + assert reloaded.hvac.hvacSystem[0].description == "RTU-1" + assert reloaded.hvac.fanSystem[0].description == "Supply Fan" + assert len(reloaded.hvac.fanSystem[0].fan) == 1 + assert reloaded.hvac.hvacPlant[0].description == "Chiller 1" + assert reloaded.swhSystem[0].description == "Water Heater 1" + + # Verify the fan system link exists and matches + # Note: fanSystemKey should be a valid UUID string + original_fan_key = proj.hvac.fanSystem[0].fanSystemKey + reloaded_fan_key = reloaded.hvac.fanSystem[0].fanSystemKey + reloaded_hvac_key = reloaded.hvac.hvacSystem[0].fanSystemKey + + # Both should have the same UUID + assert original_fan_key is not None + assert reloaded_fan_key == original_fan_key + assert reloaded_hvac_key == original_fan_key diff --git a/tests/test_base_model.py b/tests/test_base_model.py index 17bb6e5..afc5530 100644 --- a/tests/test_base_model.py +++ b/tests/test_base_model.py @@ -92,4 +92,55 @@ class BadModel(CustomBaseModel): model.remove_from_subcomponent_list( subcomponent_id="Child:Child 1", subcomponent_name="SampleChildModel", - ) \ No newline at end of file + ) + + +def test_get_normalized_subcomponent_info_from_annotation(): + """Test that _get_normalized_subcomponent_info extracts type from list annotation.""" + from comcheck_api.types.core_types import HVAC, HVACSystem, ServiceWaterHeatingSystem + from comcheck_api.defaults import get_default_project_template + + project = get_default_project_template() + + # Initialize hvac so we can test on it + hvac = HVAC(hvacSystem=[], hvacPlant=[], fanSystem=[]) + + # Test hvacSystem -> HVACSystem (list[HVACSystem] annotation) + cls, name = hvac._get_normalized_subcomponent_info(None, "hvacSystem") + assert cls == HVACSystem + assert name == "hvacSystem" + + # Test swhSystem -> ServiceWaterHeatingSystem (list[ServiceWaterHeatingSystem] | MISSING annotation) + cls, name = project._get_normalized_subcomponent_info(None, "swhSystem") + assert cls == ServiceWaterHeatingSystem + assert name == "swhSystem" + + +def test_get_normalized_subcomponent_info_from_annotation_hvac(): + """Test annotation extraction for hvac subcomponents.""" + from comcheck_api.types.core_types import HVAC, HVACSystem, FanSystem, HVACPlant + + # Initialize hvac + hvac = HVAC(hvacSystem=[], hvacPlant=[], fanSystem=[]) + + # Test fanSystem -> FanSystem (list[FanSystem] | None annotation) + cls, name = hvac._get_normalized_subcomponent_info(None, "fanSystem") + assert cls == FanSystem + assert name == "fanSystem" + + # Test hvacPlant -> HVACPlant + cls, name = hvac._get_normalized_subcomponent_info(None, "hvacPlant") + assert cls == HVACPlant + assert name == "hvacPlant" + + +def test_get_subcomponent_list_normalizes_none(): + """Test that _get_subcomponent_list normalizes None to [].""" + from comcheck_api.types.core_types import HVAC + + hvac = HVAC(hvacSystem=[], hvacPlant=[], fanSystem=None) + + # Should normalize None to [] + fan_list = hvac._get_subcomponent_list("fanSystem") + assert fan_list == [] + assert isinstance(fan_list, list) \ No newline at end of file diff --git a/tests/test_mechanical_api.py b/tests/test_mechanical_api.py new file mode 100644 index 0000000..b7a5c4c --- /dev/null +++ b/tests/test_mechanical_api.py @@ -0,0 +1,479 @@ +"""Offline tests for the mechanical 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 COMCheckAPIError, COMCheckHTTPError +from comcheck_api.types.api_types import ( + WaterHeaterEfficiency, + HVACSystemEfficiency, + EconomizerValidation, + PlantEfficiency, + FanSystemComplianceResult, +) +from comcheck_api.types.core_types import ( + EnergyCodeOptions, + ServiceWaterHeatingSystem, + HVACSystem, + HVACPlant, + FanSystem, + FanSystemPressureDropCredits, + Fan, + HVAC, + HasPressureDropCredits, + MISSING, +) + + +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 + project.hvac = HVAC( + hvacSystem=[], + hvacPlant=[], + fanSystem=[ + FanSystem( + description="Test Fan System", + pressureDropCredits=[ + FanSystemPressureDropCredits( + type="PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12", + ) + ], + ) + ], + ) + 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 + + +# Water heater efficiency tests +def test_water_heater_efficiency_service_single(requests): + data = {"Water Heater 1": {"minEfficiency": 0.8, "maxEfficiency": 0.95, "efficiencyUnits": "EF"}} + service = _make_service(_ok(requests, data)) + swh = {"description": "Water Heater 1"} + + result = service.water_heater_efficiency(swh, "CEZ_IECC2024") + + assert result["data"] == data + (request,) = requests + assert request.method == "POST" + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/water-heaters/efficiency") + assert json.loads(request.content) == swh + + +def test_water_heater_efficiency_service_list_with_query(requests): + data = { + "WH1": {"minEfficiency": 0.8, "maxEfficiency": 0.95, "efficiencyUnits": "EF"}, + "WH2": {"minEfficiency": 0.85, "maxEfficiency": 0.98, "efficiencyUnits": "EF"}, + } + service = _make_service(_ok(requests, data)) + swh_list = [{"description": "WH1"}, {"description": "WH2"}] + + result = service.water_heater_efficiency(swh_list, "CEZ_IECC2024", "WH1") + + assert result["data"] == data + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/water-heaters/efficiency") + assert str(request.url).endswith("?waterHeaterName=WH1") + assert json.loads(request.content) == swh_list + + +def test_calculate_water_heater_efficiency_single(requests): + data = {"Water Heater 1": {"minEfficiency": 0.8, "maxEfficiency": 0.95, "efficiencyUnits": "EF"}} + client = _make_client(_ok(requests, data)) + swh = ServiceWaterHeatingSystem(description="Water Heater 1") + + result = client.calculate_water_heater_efficiency(swh, "CEZ_IECC2024") + + assert isinstance(result, dict) + assert "Water Heater 1" in result + assert isinstance(result["Water Heater 1"], WaterHeaterEfficiency) + assert result["Water Heater 1"].minEfficiency == 0.8 + assert result["Water Heater 1"].efficiencyUnits == "EF" + + +def test_calculate_water_heater_efficiency_list(requests): + data = { + "WH1": {"minEfficiency": 0.8, "maxEfficiency": 0.95, "efficiencyUnits": "EF"}, + "WH2": {"minEfficiency": 0.85, "maxEfficiency": 0.98, "efficiencyUnits": "EF"}, + } + client = _make_client(_ok(requests, data)) + swh_list = [ + ServiceWaterHeatingSystem(description="WH1"), + ServiceWaterHeatingSystem(description="WH2"), + ] + + result = client.calculate_water_heater_efficiency(swh_list, "CEZ_IECC2024") + + assert len(result) == 2 + assert result["WH1"].minEfficiency == 0.8 + assert result["WH2"].minEfficiency == 0.85 + + +def test_calculate_water_heater_efficiency_duplicate_descriptions(): + client = _make_client(lambda r: httpx.Response(200, json={})) + swh_list = [ + ServiceWaterHeatingSystem(description="WH1"), + ServiceWaterHeatingSystem(description="WH1"), + ] + + with pytest.raises(ValueError, match="unique"): + client.calculate_water_heater_efficiency(swh_list, "CEZ_IECC2024") + + +def test_calculate_water_heater_efficiency_name_mismatch(): + client = _make_client(lambda r: httpx.Response(200, json={})) + swh = ServiceWaterHeatingSystem(description="WH1") + + with pytest.raises(ValueError, match="does not match"): + client.calculate_water_heater_efficiency(swh, "CEZ_IECC2024", water_heater_name="WH2") + + +# HVAC system efficiency tests +def test_hvac_system_efficiency_service(requests): + data = {"HVAC 1": {"minCoolingEfficiency": 13.0, "coolingEfficiencyUnits": "SEER"}} + service = _make_service(_ok(requests, data)) + hvac = {"description": "HVAC 1"} + + result = service.hvac_system_efficiency(hvac, "CEZ_IECC2024", "HVAC 1") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/hvac-systems/efficiency") + assert str(request.url).endswith("?hvacName=HVAC+1") + + +def test_calculate_hvac_system_efficiency(requests): + data = {"HVAC 1": {"minCoolingEfficiency": 13.0, "coolingEfficiencyUnits": "SEER"}} + client = _make_client(_ok(requests, data)) + hvac = HVACSystem(description="HVAC 1") + + result = client.calculate_hvac_system_efficiency(hvac, "CEZ_IECC2024") + + assert "HVAC 1" in result + assert isinstance(result["HVAC 1"], HVACSystemEfficiency) + assert result["HVAC 1"].minCoolingEfficiency == 13.0 + + +def test_calculate_hvac_system_efficiency_duplicate_descriptions(): + client = _make_client(lambda r: httpx.Response(200, json={})) + hvac_list = [HVACSystem(description="H1"), HVACSystem(description="H1")] + + with pytest.raises(ValueError, match="unique"): + client.calculate_hvac_system_efficiency(hvac_list, "CEZ_IECC2024") + + +# Economizer validation tests +def test_hvac_system_economizers_validation_service(requests): + data = {"HVAC 1": {"complies": True, "coolingCapLimitNoEconomizer": 54000.0}} + service = _make_service(_ok(requests, data)) + hvac = {"description": "HVAC 1"} + + result = service.hvac_system_economizers_validation(hvac, "CEZ_IECC2024") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/hvac-systems/economizers-validation") + assert json.loads(request.content) == hvac + + +def test_validate_hvac_system_economizer(requests): + data = {"HVAC 1": {"complies": True, "coolingCapLimitNoEconomizer": 54000.0}} + client = _make_client(_ok(requests, data)) + hvac = HVACSystem(description="HVAC 1") + + result = client.validate_hvac_system_economizer(hvac, "CEZ_IECC2024") + + assert isinstance(result, EconomizerValidation) + assert result.complies is True + assert result.coolingCapLimitNoEconomizer == 54000.0 + + +def test_validate_hvac_system_economizer_empty_description(): + client = _make_client(lambda r: httpx.Response(200, json={})) + hvac = HVACSystem(description="") + + with pytest.raises(ValueError, match="cannot be empty"): + client.validate_hvac_system_economizer(hvac, "CEZ_IECC2024") + + +# Plant efficiency tests +def test_plant_efficiency_service(requests): + data = {"Plant 1": {"plantMinHeatingEfficiency": 0.85}} + service = _make_service(_ok(requests, data)) + plant = {"description": "Plant 1"} + + result = service.plant_efficiency(plant, "CEZ_IECC2024") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/plants/efficiency") + + +def test_calculate_plant_efficiency(requests): + data = {"Plant 1": {"plantMinHeatingEfficiency": 0.85}} + client = _make_client(_ok(requests, data)) + plant = HVACPlant(description="Plant 1") + + result = client.calculate_plant_efficiency(plant, "CEZ_IECC2024") + + assert "Plant 1" in result + assert isinstance(result["Plant 1"], PlantEfficiency) + assert result["Plant 1"].plantMinHeatingEfficiency == 0.85 + + +def test_calculate_plant_efficiency_duplicate_descriptions(): + client = _make_client(lambda r: httpx.Response(200, json={})) + plant_list = [HVACPlant(description="P1"), HVACPlant(description="P1")] + + with pytest.raises(ValueError, match="unique"): + client.calculate_plant_efficiency(plant_list, "CEZ_IECC2024") + + +# Fan pressure drop credit tests +def test_fan_pressure_drop_credit_service(requests): + data = {"pressureDropCredit": 0.35} + service = _make_service(_ok(requests, data)) + credit = {"deviceType": "FILTER"} + + result = service.fan_pressure_drop_credit(credit, "CEZ_IECC2024", "PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/fan-systems/fan-pressure-credit") + assert "fanPressureCreditType=PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12" in str(request.url) + assert json.loads(request.content) == credit + + +def test_calculate_fan_pressure_drop_credit(requests): + data = {"pressureDropCredit": 0.35} + client = _make_client(_ok(requests, data)) + credit = FanSystemPressureDropCredits(type="PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12") + + result = client.calculate_fan_pressure_drop_credit(credit, "CEZ_IECC2024") + + assert result == 0.35 + (request,) = requests + # Check that type was sent as query param, not in body + body = json.loads(request.content) + assert "type" not in body + + +def test_calculate_fan_pressure_drop_credit_unspecified_type(): + client = _make_client(lambda r: httpx.Response(200, json={})) + credit = FanSystemPressureDropCredits(type="PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED") + + with pytest.raises(ValueError, match="UNSPECIFIED"): + client.calculate_fan_pressure_drop_credit(credit, "CEZ_IECC2024") + + +# Fan system compliance tests +def test_fan_system_compliance_service(requests): + data = { + "hasPressureDropCredits": True, + "complyMethod": "FAN_SYSTEM_MOTOR_NAMEPLATE_HP", + "complies": True, + "complianceMessage": "Complies", + "proposed": 5.0, + "maxAllowed": 10.0, + } + service = _make_service(_ok(requests, data)) + fan_system = {"description": "Fan System 1"} + + result = service.fan_system_compliance(fan_system, "CEZ_IECC2024") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/fan-systems/compliance") + assert json.loads(request.content) == fan_system + + +def test_check_fan_system_compliance(requests): + data = { + "hasPressureDropCredits": True, + "complyMethod": "FAN_SYSTEM_MOTOR_NAMEPLATE_HP", + "complies": True, + "complianceMessage": "Complies", + "proposed": 5.0, + "maxAllowed": 10.0, + } + client = _make_client(_ok(requests, data)) + fan_system = FanSystem(description="Fan System 1") + + result = client.check_fan_system_compliance(fan_system, "CEZ_IECC2024") + + assert isinstance(result, FanSystemComplianceResult) + assert result.complies is True + assert result.proposed == 5.0 + + +# Fan maximum allowable HP tests +def test_fan_maximum_allowable_hp_service(requests): + data = {"maxNameplateHp": 7.5} + service = _make_service(_ok(requests, data)) + fan = {"motorHp": 5.0} + + result = service.fan_maximum_allowable_hp(fan, "CEZ_IECC2024") + + (request,) = requests + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/fan-systems/maximum-allowable-hp") + + +def test_calculate_fan_maximum_allowable_hp(requests): + data = {"maxNameplateHp": 7.5} + client = _make_client(_ok(requests, data)) + fan = Fan(motorHp=5.0) + + result = client.calculate_fan_maximum_allowable_hp(fan, "CEZ_IECC2024") + + assert result == 7.5 + + +# Project write-back tests +def test_update_fan_system_compliance(requests, project): + data = { + "hasPressureDropCredits": True, + "complyMethod": "FAN_SYSTEM_MOTOR_NAMEPLATE_HP", + "complies": True, + "complianceMessage": "Complies", + "proposed": 5.0, + "maxAllowed": 10.0, + } + client = _make_client(_ok(requests, data)) + before = project.model_dump(mode="json") + + result = client.update_fan_system_compliance(project, "Test Fan System") + + assert result is project + fan_system = project.hvac.fanSystem[0] + assert fan_system.complies is True + assert fan_system.complianceMessage == "Complies" + assert fan_system.complyMethod == "FAN_SYSTEM_MOTOR_NAMEPLATE_HP" + assert fan_system.hasPressureDropCredits == HasPressureDropCredits.int_1 + + +def test_update_fan_system_compliance_not_found(requests, project): + client = _make_client(_ok(requests, {})) + + with pytest.raises(ValueError, match="not found"): + client.update_fan_system_compliance(project, "Nonexistent") + + +def test_update_fan_system_compliance_no_hvac(): + client = _make_client(lambda r: httpx.Response(200, json={})) + project = get_default_project_template() + project.hvac = MISSING + + with pytest.raises(ValueError, match="no hvac"): + client.update_fan_system_compliance(project, "Test") + + +def test_update_fan_pressure_drop_credits(requests, project): + data = {"pressureDropCredit": 0.35} + client = _make_client(_ok(requests, data)) + + result = client.update_fan_pressure_drop_credits(project, "Test Fan System") + + assert result is project + fan_system = project.hvac.fanSystem[0] + assert fan_system.pressureDropCredits[0].pressureDropCredit == 0.35 + + +def test_update_fan_pressure_drop_credits_not_found(requests, project): + client = _make_client(_ok(requests, {})) + + with pytest.raises(ValueError, match="not found"): + client.update_fan_pressure_drop_credits(project, "Nonexistent") + + +def test_update_fan_pressure_drop_credits_no_hvac(): + client = _make_client(lambda r: httpx.Response(200, json={})) + project = get_default_project_template() + project.hvac = MISSING + + with pytest.raises(ValueError, match="no hvac"): + client.update_fan_pressure_drop_credits(project, "Test") + + +# Error handling tests +def test_mechanical_service_maps_http_errors(): + 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.water_heater_efficiency({}, "CEZ_IECC2024") + assert exc_info.value.status_code == 400 + + with pytest.raises(COMCheckHTTPError): + service.hvac_system_efficiency({}, "CEZ_IECC2024") + + with pytest.raises(COMCheckHTTPError): + service.hvac_system_economizers_validation({}, "CEZ_IECC2024") + + with pytest.raises(COMCheckHTTPError): + service.plant_efficiency({}, "CEZ_IECC2024") + + with pytest.raises(COMCheckHTTPError): + service.fan_pressure_drop_credit({}, "CEZ_IECC2024", "TYPE") + + with pytest.raises(COMCheckHTTPError): + service.fan_system_compliance({}, "CEZ_IECC2024") + + with pytest.raises(COMCheckHTTPError): + service.fan_maximum_allowable_hp({}, "CEZ_IECC2024") + + +def test_calculate_fan_maximum_allowable_hp_missing_value_raises(requests): + """A response without maxNameplateHp raises instead of returning 0.""" + client = _make_client(_ok(requests, {})) + + with pytest.raises(COMCheckAPIError): + client.calculate_fan_maximum_allowable_hp(Fan(description="Fan 1"), "CEZ_IECC2024") + + +def test_validate_hvac_system_economizer_string_capacity(requests): + """Capacity fields are documented as strings in the OpenAPI spec.""" + data = {"HVAC 1": {"coolingCapLimitNoEconomizer": "54,000 Btu/h", "complies": True}} + client = _make_client(_ok(requests, data)) + + result = client.validate_hvac_system_economizer( + HVACSystem(description="HVAC 1"), "CEZ_IECC2024" + ) + + assert result.coolingCapLimitNoEconomizer == "54,000 Btu/h" From 3650c4341c1838f7d85e8aee1c2568ff1f47f624 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Thu, 22 Oct 2026 16:39:44 -0700 Subject: [PATCH 2/4] Add pressure drop credit operations for fan systems add/update/remove_pressure_drop_credit_*_fan_system match credits by type (unique per fan system, as in the UI). Updating an input clears the engine-calculated pressureDropCredit; adding to a fan system that doesn't use the brake HP method warns. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + comcheck_api/ai/skill/reference/operations.md | 3 + .../project_mechanical_operations.py | 162 ++++++++++++++++++ docs_site/api/operations/mechanical.md | 33 ++++ .../test_mechanical_operations.py | 124 ++++++++++++++ 5 files changed, 323 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fed87c8..56bf096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project are documented in this file. - `add_fan_system_to_project` / `update_fan_system_in_project` / `remove_fan_system_from_project` — manage fan systems - `get_fan_system_keys_from_project` — list all fan systems - `add_fan_to_fan_system` / `update_fan_in_fan_system` / `remove_fan_from_fan_system` — manage fans within fan systems +- `add_pressure_drop_credit_to_fan_system` / `update_pressure_drop_credit_in_fan_system` / `remove_pressure_drop_credit_from_fan_system` — manage a fan system's pressure drop credits by `type` - `add_hvac_plant_to_project` / `update_hvac_plant_in_project` / `remove_hvac_plant_from_project` — manage HVAC plants - `get_hvac_plant_keys_from_project` — list all HVAC plants - `add_water_heater_to_project` / `update_water_heater_in_project` / `remove_water_heater_from_project` — manage water heaters diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index 5d2e08e..4fb70cc 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -204,6 +204,9 @@ on add. `swhSystem[]` is a top-level list (not under `hvac`). | `add_fan_to_fan_system(project, fan_system_description, new_fan)` | Add a `Fan` to a fan system. | | `update_fan_in_fan_system(project, fan_system_description, fan_description, updates)` | Update a `Fan` by its `description`. | | `remove_fan_from_fan_system(project, fan_system_description, fan_description)` | Remove a `Fan` by its `description`. | +| `add_pressure_drop_credit_to_fan_system(project, fan_system_description, new_credit)` | Add a `FanSystemPressureDropCredits`. `type` is unique per fan system; `PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED` raises `ValueError`. Warns unless the fan system's `complyMethod` is `FAN_SYSTEM_MOTOR_BRAKE_HP`. | +| `update_pressure_drop_credit_in_fan_system(project, fan_system_description, credit_type, updates)` | Update a credit's inputs by its `type`. Raises `ValueError` for `type` or `pressureDropCredit` changes; changing an input clears `pressureDropCredit` (recalculate with `client.update_fan_pressure_drop_credits`). | +| `remove_pressure_drop_credit_from_fan_system(project, fan_system_description, credit_type)` | Remove a credit by its `type`. | | **HVAC plants** (`hvac.hvacPlant[]`) | | | `add_hvac_plant_to_project(project, new_hvac_plant)` | Add an `HVACPlant`. | | `update_hvac_plant_in_project(project, hvac_plant_description, updates)` | Update an `HVACPlant` by its `description`. Raises `ValueError` for engine-calculated fields (`requirementAnswer`). | diff --git a/comcheck_api/project_operations/project_mechanical_operations.py b/comcheck_api/project_operations/project_mechanical_operations.py index a7b81f7..938d29d 100644 --- a/comcheck_api/project_operations/project_mechanical_operations.py +++ b/comcheck_api/project_operations/project_mechanical_operations.py @@ -28,6 +28,7 @@ """ import logging +import warnings from typing import Any from uuid import uuid4 from pydantic import BaseModel @@ -39,6 +40,9 @@ HVACSystem, FanSystem, Fan, + FanSystemComplianceMethodOptions, + FanSystemPressureDropCredits, + PressureDropTypeOptions, HVACPlant, ServiceWaterHeatingSystem, ) @@ -537,6 +541,164 @@ def remove_fan_from_fan_system( return updated_project +def add_pressure_drop_credit_to_fan_system( + project: ComBuilding, + fan_system_description: str, + new_credit: FanSystemPressureDropCredits, +) -> ComBuilding: + """Add a pressure drop credit to a fan system. + + Credits are identified by ``type``, which is unique within a fan system. + ``pressureDropCredit`` is calculated by the engine — use + :meth:`~comcheck_api.client.COMcheckClient.update_fan_pressure_drop_credits` + to fill it in. + + Emits a :class:`UserWarning` if the fan system's ``complyMethod`` is not + ``FAN_SYSTEM_MOTOR_BRAKE_HP``; the UI only offers pressure drop credits for + the brake horsepower method. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + new_credit: The FanSystemPressureDropCredits to add. + + Returns: + Updated project with the credit added. + + Raises: + ValueError: If the fan system is not found, the credit type is + ``PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED``, or the fan system already + has a credit of that type. + """ + _require_fan_system(project, fan_system_description) + + if new_credit.type == PressureDropTypeOptions.PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED: + raise ValueError( + "PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED is not a valid credit type. " + "Choose a value from PressureDropTypeOptions." + ) + + updated_project = project.model_copy(deep=True) + fan_system = _find_by_description(updated_project.hvac.fanSystem, fan_system_description) + credits = fan_system.pressureDropCredits + if credits is MISSING or credits is None: + credits = [] + + if any(credit.type == new_credit.type for credit in credits): + raise ValueError( + f"Fan system '{fan_system_description}' already has a " + f"{new_credit.type} pressure drop credit." + ) + + if fan_system.complyMethod != FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP: + warnings.warn( + f"Fan system '{fan_system_description}' does not use the " + "FAN_SYSTEM_MOTOR_BRAKE_HP compliance method, so pressure drop " + "credits don't apply to it.", + UserWarning, + stacklevel=2, + ) + + fan_system.pressureDropCredits = [*credits, new_credit.model_copy(deep=True)] + return updated_project + + +def update_pressure_drop_credit_in_fan_system( + project: ComBuilding, + fan_system_description: str, + credit_type: PressureDropTypeOptions, + updates: dict[str, Any] | FanSystemPressureDropCredits, +) -> ComBuilding: + """Update the inputs of a pressure drop credit in a fan system. + + ``type`` can't be changed (remove the credit and add a new one), and the + engine-calculated ``pressureDropCredit`` can't be set. Changing an input + clears ``pressureDropCredit`` so a stale value isn't kept; recalculate it + with :meth:`~comcheck_api.client.COMcheckClient.update_fan_pressure_drop_credits`. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + credit_type: The ``type`` of the credit to update. + updates: Partial updates (dict) or full FanSystemPressureDropCredits to apply. + + Returns: + Updated project with the credit modified. + + Raises: + ValueError: If the fan system or credit is not found, updates change + ``type`` or ``pressureDropCredit``, or a value fails validation. + """ + _require_fan_system(project, fan_system_description) + + updated_project = project.model_copy(deep=True) + fan_system = _find_by_description(updated_project.hvac.fanSystem, fan_system_description) + credits = fan_system.pressureDropCredits + if credits is MISSING or credits is None: + credits = [] + index = next( + (i for i, credit in enumerate(credits) if credit.type == credit_type), None + ) + if index is None: + raise ValueError( + f"Pressure drop credit '{credit_type}' not found in fan system " + f"'{fan_system_description}'." + ) + + current = credits[index] + updates = _updates_to_dict(updates) + _reject_changed_fields( + current, + updates, + frozenset({"type", "pressureDropCredit"}), + "update_pressure_drop_credit_in_fan_system", + hint="Remove the credit and add a new one to change its type.", + ) + + merged = {**current.model_dump(mode="python"), **updates} + if merged != current.model_dump(mode="python"): + merged.pop("pressureDropCredit", None) + credits[index] = FanSystemPressureDropCredits.model_validate(merged) + fan_system.pressureDropCredits = credits + return updated_project + + +def remove_pressure_drop_credit_from_fan_system( + project: ComBuilding, + fan_system_description: str, + credit_type: PressureDropTypeOptions, +) -> ComBuilding: + """Remove a pressure drop credit from a fan system. + + Args: + project: The project to modify. + fan_system_description: The description of the fan system. + credit_type: The ``type`` of the credit to remove. + + Returns: + Updated project with the credit removed. + + Raises: + ValueError: If the fan system or credit is not found. + """ + _require_fan_system(project, fan_system_description) + + updated_project = project.model_copy(deep=True) + fan_system = _find_by_description(updated_project.hvac.fanSystem, fan_system_description) + credits = fan_system.pressureDropCredits + if credits is MISSING or credits is None: + credits = [] + remaining = [credit for credit in credits if credit.type != credit_type] + if len(remaining) == len(credits): + raise ValueError( + f"Pressure drop credit '{credit_type}' not found in fan system " + f"'{fan_system_description}'." + ) + + fan_system.pressureDropCredits = remaining + return updated_project + + # *********** Plant operations *********** diff --git a/docs_site/api/operations/mechanical.md b/docs_site/api/operations/mechanical.md index 7279767..adad78e 100644 --- a/docs_site/api/operations/mechanical.md +++ b/docs_site/api/operations/mechanical.md @@ -220,6 +220,39 @@ project = mech_ops.remove_fan_from_fan_system( Remove a fan from a fan system. +## Pressure Drop Credits + +Pressure drop credits are nested within fan systems (`FanSystem.pressureDropCredits[]`) +and identified by their `type` (a `PressureDropTypeOptions` value), which is +unique within a fan system. They apply to fan systems that use the +`FAN_SYSTEM_MOTOR_BRAKE_HP` compliance method; adding one to another fan system +emits a `UserWarning`. + +`pressureDropCredit` is calculated by the engine. Updating a credit's inputs +clears it; recalculate with `COMcheckClient.update_fan_pressure_drop_credits` +(see [API calls](#api-calls)). + +```python +from comcheck_api.types.core_types import ( + FanSystemPressureDropCredits, + PressureDropTypeOptions, +) + +MERV_9 = PressureDropTypeOptions.PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12 + +project = mech_ops.add_pressure_drop_credit_to_fan_system( + project, "Supply Fan 1", FanSystemPressureDropCredits(type=MERV_9, cfm=1000) +) +project = mech_ops.update_pressure_drop_credit_in_fan_system( + project, "Supply Fan 1", MERV_9, {"cfm": 1200} +) +project = mech_ops.remove_pressure_drop_credit_from_fan_system( + project, "Supply Fan 1", MERV_9 +) +``` + +`type` can't be updated — remove the credit and add a new one. + ## HVAC Plants HVAC plants are identified by their `description`. diff --git a/tests/project_operation_tests/test_mechanical_operations.py b/tests/project_operation_tests/test_mechanical_operations.py index a713903..f827b04 100644 --- a/tests/project_operation_tests/test_mechanical_operations.py +++ b/tests/project_operation_tests/test_mechanical_operations.py @@ -11,7 +11,10 @@ ComBuilding, HVACSystem, FanSystem, + FanSystemComplianceMethodOptions, + FanSystemPressureDropCredits, Fan, + PressureDropTypeOptions, HVACPlant, ServiceWaterHeatingSystem, ) @@ -647,3 +650,124 @@ def test_mechanical_round_trip(project: ComBuilding): assert original_fan_key is not None assert reloaded_fan_key == original_fan_key assert reloaded_hvac_key == original_fan_key + + +# --------------------------------------------------------------------------- +# Pressure Drop Credit Tests +# --------------------------------------------------------------------------- + +MERV_9 = PressureDropTypeOptions.PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12 +SOUND = PressureDropTypeOptions.SOUND_ATTENUATION_SECTION + + +def _with_brake_hp_fan_system(project: ComBuilding) -> ComBuilding: + return mech_ops.add_fan_system_to_project( + _fresh(project), + FanSystem( + description="Supply Fans", + complyMethod=FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP, + ), + ) + + +def test_add_pressure_drop_credit(project: ComBuilding): + proj = _with_brake_hp_fan_system(project) + + result = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9, cfm=1000) + ) + + credits = result.hvac.fanSystem[0].pressureDropCredits + assert [credit.type for credit in credits] == [MERV_9] + assert proj.hvac.fanSystem[0].pressureDropCredits is MISSING + + +def test_add_pressure_drop_credit_rejects_duplicate_type(project: ComBuilding): + proj = _with_brake_hp_fan_system(project) + proj = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9) + ) + + with pytest.raises(ValueError, match="already has"): + mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9) + ) + + +def test_add_pressure_drop_credit_rejects_unspecified_type(project: ComBuilding): + proj = _with_brake_hp_fan_system(project) + + with pytest.raises(ValueError, match="not a valid credit type"): + mech_ops.add_pressure_drop_credit_to_fan_system( + proj, + "Supply Fans", + FanSystemPressureDropCredits( + type=PressureDropTypeOptions.PRESSURE_DROP_CREDIT_TYPE_UNSPECIFIED + ), + ) + + +def test_add_pressure_drop_credit_warns_without_brake_hp(project: ComBuilding): + proj = mech_ops.add_fan_system_to_project( + _fresh(project), FanSystem(description="Supply Fans") + ) + + with pytest.warns(UserWarning, match="FAN_SYSTEM_MOTOR_BRAKE_HP"): + mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9) + ) + + +def test_update_pressure_drop_credit_clears_calculated_value(project: ComBuilding): + proj = _with_brake_hp_fan_system(project) + proj = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, + "Supply Fans", + FanSystemPressureDropCredits(type=MERV_9, cfm=1000, pressureDropCredit=0.5), + ) + + result = mech_ops.update_pressure_drop_credit_in_fan_system( + proj, "Supply Fans", MERV_9, {"cfm": 2000} + ) + + credit = result.hvac.fanSystem[0].pressureDropCredits[0] + assert credit.cfm == 2000 + assert credit.pressureDropCredit is MISSING + + +def test_update_pressure_drop_credit_rejects_type_and_calculated_value( + project: ComBuilding, +): + proj = _with_brake_hp_fan_system(project) + proj = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9) + ) + + with pytest.raises(ValueError, match="type"): + mech_ops.update_pressure_drop_credit_in_fan_system( + proj, "Supply Fans", MERV_9, {"type": SOUND} + ) + with pytest.raises(ValueError, match="pressureDropCredit"): + mech_ops.update_pressure_drop_credit_in_fan_system( + proj, "Supply Fans", MERV_9, {"pressureDropCredit": 1.0} + ) + + +def test_remove_pressure_drop_credit(project: ComBuilding): + proj = _with_brake_hp_fan_system(project) + proj = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9) + ) + proj = mech_ops.add_pressure_drop_credit_to_fan_system( + proj, "Supply Fans", FanSystemPressureDropCredits(type=SOUND) + ) + + result = mech_ops.remove_pressure_drop_credit_from_fan_system( + proj, "Supply Fans", MERV_9 + ) + + assert [c.type for c in result.hvac.fanSystem[0].pressureDropCredits] == [SOUND] + with pytest.raises(ValueError, match="not found"): + mech_ops.remove_pressure_drop_credit_from_fan_system( + result, "Supply Fans", MERV_9 + ) From 96bef1b79bad543acdafe7cd52dfd530bdb521d9 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Thu, 22 Oct 2026 17:10:54 -0700 Subject: [PATCH 3/4] Add mechanical operations example examples/project_operations/mechanical_operations.py walks through adding, linking, updating, and removing HVAC systems, fan systems (with fans and pressure drop credits), plants, and water heaters. The get_*_keys functions now report unset fields as None instead of MISSING. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../project_mechanical_operations.py | 40 +++-- docs_site/api/operations/mechanical.md | 2 + examples/README.md | 18 +- .../mechanical_operations.py | 163 ++++++++++++++++++ .../test_mechanical_operations.py | 12 ++ 6 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 examples/project_operations/mechanical_operations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bf096..8063ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ All notable changes to this project are documented in this file. - Mechanical API calls on `COMcheckClient` (`POST /{energyCode}/mechanical/...`): `calculate_water_heater_efficiency`, `calculate_hvac_system_efficiency`, `validate_hvac_system_economizer`, `calculate_plant_efficiency`, `calculate_fan_pressure_drop_credit`, `check_fan_system_compliance`, `calculate_fan_maximum_allowable_hp` - `COMcheckClient.update_fan_system_compliance` and `COMcheckClient.update_fan_pressure_drop_credits` — calculate fan system compliance / pressure drop credits and write them onto the project's fan system - Matching `COMCheckApiService` service methods, and `WaterHeaterEfficiency`, `HVACSystemEfficiency`, `EconomizerValidation`, `PlantEfficiency`, `FanSystemComplianceResult` result types -- `examples/client/mechanical.py` example +- `examples/client/mechanical.py` and `examples/project_operations/mechanical_operations.py` examples - 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 diff --git a/comcheck_api/project_operations/project_mechanical_operations.py b/comcheck_api/project_operations/project_mechanical_operations.py index 938d29d..287084f 100644 --- a/comcheck_api/project_operations/project_mechanical_operations.py +++ b/comcheck_api/project_operations/project_mechanical_operations.py @@ -253,19 +253,19 @@ def get_hvac_system_keys_from_project(project: ComBuilding) -> list[dict]: fan_systems = getattr(project.hvac, "fanSystem", None) or [] if isinstance(fan_systems, list): for fan_system in fan_systems: - key = getattr(fan_system, "fanSystemKey", None) - desc = getattr(fan_system, "description", None) + key = _field_value(fan_system, "fanSystemKey") + desc = _field_value(fan_system, "description") if key: fan_system_map[key] = desc result = [] for system in hvac_systems: - fan_system_key = getattr(system, "fanSystemKey", None) + fan_system_key = _field_value(system, "fanSystemKey") result.append( { - "description": getattr(system, "description", None), - "systemType": getattr(system, "systemType", None), - "quantity": getattr(system, "quantity", None), + "description": _field_value(system, "description"), + "systemType": _field_value(system, "systemType"), + "quantity": _field_value(system, "quantity"), "fanSystemKey": fan_system_key, "fanSystemDescription": fan_system_map.get(fan_system_key) if fan_system_key else None, } @@ -424,9 +424,9 @@ def get_fan_system_keys_from_project(project: ComBuilding) -> list[dict]: return [ { - "description": getattr(system, "description", None), - "fanSystemKey": getattr(system, "fanSystemKey", None), - "complyMethod": getattr(system, "complyMethod", None), + "description": _field_value(system, "description"), + "fanSystemKey": _field_value(system, "fanSystemKey"), + "complyMethod": _field_value(system, "complyMethod"), } for system in fan_systems ] @@ -813,10 +813,10 @@ def get_hvac_plant_keys_from_project(project: ComBuilding) -> list[dict]: return [ { - "description": getattr(plant, "description", None), - "plantType": getattr(plant, "plantType", None), - "systemType": getattr(plant, "systemType", None), - "quantity": getattr(plant, "quantity", None), + "description": _field_value(plant, "description"), + "plantType": _field_value(plant, "plantType"), + "systemType": _field_value(plant, "systemType"), + "quantity": _field_value(plant, "quantity"), } for plant in hvac_plants ] @@ -934,10 +934,10 @@ def get_water_heater_keys_from_project(project: ComBuilding) -> list[dict]: return [ { - "description": getattr(system, "description", None), - "swhSystemType": getattr(system, "swhSystemType", None), - "fuelType": getattr(system, "fuelType", None), - "quantity": getattr(system, "quantity", None), + "description": _field_value(system, "description"), + "swhSystemType": _field_value(system, "swhSystemType"), + "fuelType": _field_value(system, "fuelType"), + "quantity": _field_value(system, "quantity"), } for system in water_heaters ] @@ -946,6 +946,12 @@ def get_water_heater_keys_from_project(project: ComBuilding) -> list[dict]: # *********** Helper Functions *********** +def _field_value(item, field: str) -> Any: + """Return item.field, with an unset (MISSING) field as None.""" + value = getattr(item, field, None) + return None if value is MISSING else value + + def _find_by_description(items, description: str): """Return the item in items with the given description (None if not found).""" return next( diff --git a/docs_site/api/operations/mechanical.md b/docs_site/api/operations/mechanical.md index adad78e..57aca67 100644 --- a/docs_site/api/operations/mechanical.md +++ b/docs_site/api/operations/mechanical.md @@ -18,6 +18,8 @@ Mechanical data lives in two places on `ComBuilding`: All four lists are identified by `description`, which is auto-generated if missing (e.g., "HVAC System 1", "Fan System 1", "Plant 1", "Water Heater 1"). The `hvac` singleton is created automatically when you add the first HVAC, fan, or plant component. +See `examples/project_operations/mechanical_operations.py` for a full walkthrough. + ## HVAC Systems HVAC systems are identified by their `description`. diff --git a/examples/README.md b/examples/README.md index f0a7b5d..dfb0aee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,7 +21,8 @@ examples/ ├── envelope_operations.py # Envelope operations ├── interior_lighting_operations.py # Interior lighting (InteriorSpace) operations ├── exterior_lighting_operations.py # Exterior lighting (ExteriorArea) operations - └── renewable_operations.py # Renewable systems & off-site procurement operations + ├── renewable_operations.py # Renewable systems & off-site procurement operations + └── mechanical_operations.py # HVAC, fan systems, plants & water heater operations ``` ## Example Categories @@ -176,6 +177,21 @@ Demonstrates setting the exterior lighting zone type and managing ExteriorArea i python examples/project_operations/exterior_lighting_operations.py ``` +#### Mechanical Operations (`project_operations/mechanical_operations.py`) +Demonstrates managing HVAC systems, fan systems (with fans and pressure drop credits), plants, and water heaters, all identified by `description`. + +**What it demonstrates:** +- `add_hvac_system_to_project()` / `add_fan_system_to_project()` / `add_hvac_plant_to_project()` / `add_water_heater_to_project()` - Adding mechanical items +- `add_fan_to_fan_system()` / `add_pressure_drop_credit_to_fan_system()` - Adding fans and pressure drop credits to a fan system +- `assign_fan_system_to_hvac_system()` - Linking a fan system to an HVAC system by description +- `client.update_fan_pressure_drop_credits()` / `client.update_fan_system_compliance()` - Filling in engine-calculated fan system values +- `update_*` / `remove_*` and `get_*_keys_from_project()` - Updating, renaming, removing, and listing items + +**Usage:** +```bash +python examples/project_operations/mechanical_operations.py +``` + #### Envelope Operations (`project_operations/envelope_operations.py`) Comprehensive examples for all envelope operations including assemblies and nested components. This script demonstrates the complete workflow of adding and updating envelope components in a COMcheck project. diff --git a/examples/project_operations/mechanical_operations.py b/examples/project_operations/mechanical_operations.py new file mode 100644 index 0000000..88c61c5 --- /dev/null +++ b/examples/project_operations/mechanical_operations.py @@ -0,0 +1,163 @@ +"""Example: mechanical operations. + +Mechanical data lives in two places on ComBuilding: + +- ``hvac`` — HVAC systems (hvacSystem[]), fan systems (fanSystem[]) with their + fans and pressure drop credits, and plants (hvacPlant[]). It is created + automatically when the first item is added. +- ``swhSystem[]`` — water heaters (``ServiceWaterHeatingSystem``), at the top + level rather than under ``hvac``. + +Key concepts +------------ +- Every item is identified by its ``description``, which must be unique in its + list. An empty or duplicate description gets a UI-style name such as + "HVAC System 1", "Fan System 1", "Plant 1", or "Water Heater 1". +- Fan systems are linked to HVAC systems through ``fanSystemKey``, a UUID the + library sets. Link them by description with + ``assign_fan_system_to_hvac_system``; you never handle the UUID. +- Engine-calculated fields (``complies``, ``complianceMessage``, + ``pressureDropCredit``, ``requirementAnswer``) can't be set through the update + operations. ``client.update_fan_system_compliance`` and + ``client.update_fan_pressure_drop_credits`` fill them in. +""" + +import os +from dotenv import load_dotenv + +from comcheck_api import ( + COMcheckClient, + project_mechanical_operations as mech_ops, +) +from comcheck_api.types.core_types import ( + Fan, + FanSystem, + FanSystemComplianceMethodOptions, + FanSystemPressureDropCredits, + HVACPlant, + HVACSystem, + PlantTypeOptions, + PressureDropTypeOptions, + ServiceWaterHeatingSystem, + SWHFuelTypeOptions, + SWHSystemTypeOptions, +) + +load_dotenv(override=True) +client = COMcheckClient() +client.set_api_key(os.getenv("COM_API_KEY") or "your-api-key-here") + +# Fetch an existing project so changes can be saved back to the account. +# (update_project persists to the server; it requires a project that already +# exists there, so we start from a fetched project rather than a local +# template.) +project = client.get_project("your-project-id") +if not project: + raise ValueError("Project not found") +project_id = str(project.id) + + +# ── Step 1: Add an HVAC system ──────────────────────────────────────────────── +project = mech_ops.add_hvac_system_to_project( + project, HVACSystem(description="Rooftop Unit 1", systemType="HVAC", quantity=2) +) +print(f"HVAC systems: {mech_ops.get_hvac_system_keys_from_project(project)}") + +# ── Step 2: Add a fan system with a fan ─────────────────────────────────────── +# fanSystemKey is left empty; the library sets a UUID. +project = mech_ops.add_fan_system_to_project( + project, + FanSystem( + description="Supply Fans", + complyMethod=FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP, + ), +) +project = mech_ops.add_fan_to_fan_system( + project, "Supply Fans", Fan(description="Supply Fan 1", maxNameplateHp=10.0) +) + +# ── Step 3: Add a pressure drop credit to the fan system ────────────────────── +# Credits are identified by type, which is unique within a fan system. They +# apply to fan systems using the brake horsepower method. +MERV_9 = PressureDropTypeOptions.PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12 +project = mech_ops.add_pressure_drop_credit_to_fan_system( + project, "Supply Fans", FanSystemPressureDropCredits(type=MERV_9, cfm=4000.0) +) + +# ── Step 4: Link the fan system to the HVAC system ──────────────────────────── +project = mech_ops.assign_fan_system_to_hvac_system( + project, "Rooftop Unit 1", "Supply Fans" +) +print(f"HVAC systems: {mech_ops.get_hvac_system_keys_from_project(project)}") +print(f"Fan systems: {mech_ops.get_fan_system_keys_from_project(project)}") + +# ── Step 5: Add a plant and a water heater ──────────────────────────────────── +project = mech_ops.add_hvac_plant_to_project( + project, + HVACPlant(description="Boiler Plant", plantType=PlantTypeOptions.HEATING_PLANT), +) +project = mech_ops.add_water_heater_to_project( + project, + ServiceWaterHeatingSystem( + description="Water Heater 1", + systemType="Water Heater", + swhSystemType=SWHSystemTypeOptions.STORAGE_WATER_HEATER, + fuelType=SWHFuelTypeOptions.GAS, + quantity=1, + ), +) +print(f"Plants: {mech_ops.get_hvac_plant_keys_from_project(project)}") +print(f"Water heaters: {mech_ops.get_water_heater_keys_from_project(project)}") + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print("Mechanical systems saved") + +# ── Step 6: Fill in engine-calculated fan system values ─────────────────────── +# These call the mechanical API with project.control.code and update the +# project's fan system in place. +project = client.update_fan_pressure_drop_credits(project, "Supply Fans") +project = client.update_fan_system_compliance(project, "Supply Fans") +fan_system = project.hvac.fanSystem[0] +print(f"Pressure drop credit: {fan_system.pressureDropCredits[0].pressureDropCredit}") +print(f"Fan system complies: {fan_system.complies} ({fan_system.complianceMessage})") + +# ── Step 7: Update items by description ─────────────────────────────────────── +project = mech_ops.update_hvac_system_in_project( + project, "Rooftop Unit 1", {"quantity": 3} +) +project = mech_ops.update_fan_in_fan_system( + project, "Supply Fans", "Supply Fan 1", {"maxNameplateHp": 7.5} +) +# Changing a credit input clears its pressureDropCredit; recalculate it. +project = mech_ops.update_pressure_drop_credit_in_fan_system( + project, "Supply Fans", MERV_9, {"cfm": 5000.0} +) +project = client.update_fan_pressure_drop_credits(project, "Supply Fans") +# Renaming is an update of description; it must stay unique. +project = mech_ops.update_water_heater_in_project( + project, "Water Heater 1", {"description": "Kitchen Water Heater"} +) + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") +print("Mechanical systems updated") + +# ── Step 8: Remove items ────────────────────────────────────────────────────── +# Removing a fan system also clears fanSystemKey on HVAC systems that used it. +project = mech_ops.remove_fan_system_from_project(project, "Supply Fans") +project = mech_ops.remove_hvac_plant_from_project(project, "Boiler Plant") +project = mech_ops.remove_water_heater_from_project(project, "Kitchen Water Heater") +project = mech_ops.remove_hvac_system_from_project(project, "Rooftop Unit 1") + +project = client.update_project(project_id, project) +if not project: + raise ValueError("Project not found after update") + +# ── Final check ──────────────────────────────────────────────────────────────── +print(f"\nFinal HVAC systems: {mech_ops.get_hvac_system_keys_from_project(project)}") +print(f"Final fan systems: {mech_ops.get_fan_system_keys_from_project(project)}") +print(f"Final plants: {mech_ops.get_hvac_plant_keys_from_project(project)}") +print(f"Final water heaters: {mech_ops.get_water_heater_keys_from_project(project)}") diff --git a/tests/project_operation_tests/test_mechanical_operations.py b/tests/project_operation_tests/test_mechanical_operations.py index f827b04..72cea2a 100644 --- a/tests/project_operation_tests/test_mechanical_operations.py +++ b/tests/project_operation_tests/test_mechanical_operations.py @@ -771,3 +771,15 @@ def test_remove_pressure_drop_credit(project: ComBuilding): mech_ops.remove_pressure_drop_credit_from_fan_system( result, "Supply Fans", MERV_9 ) + + +def test_keys_report_unset_fields_as_none(project: ComBuilding): + proj = mech_ops.add_hvac_plant_to_project( + _fresh(project), HVACPlant(description="Boiler Plant") + ) + + keys = mech_ops.get_hvac_plant_keys_from_project(proj) + + assert keys == [ + {"description": "Boiler Plant", "plantType": None, "systemType": None, "quantity": None} + ] From eff786e6bdcb1a558c180a8df39c0d01e31e02b8 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Thu, 22 Oct 2026 17:25:21 -0700 Subject: [PATCH 4/4] Add default templates for mechanical systems DEFAULT_HVAC_SYSTEM, DEFAULT_FAN_SYSTEM, DEFAULT_FAN, DEFAULT_HVAC_PLANT, and DEFAULT_WATER_HEATER match what the UI's Add dialogs create, with an empty description so adding them gives UI-style names. Docs, skill, and the mechanical operations example use the new get_default_*_template functions. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + comcheck_api/ai/skill/SKILL.md | 28 +- comcheck_api/ai/skill/reference/operations.md | 10 +- .../constants/mechanical_constants.py | 143 +++++++++ comcheck_api/defaults.py | 66 ++++ docs_site/api/operations/mechanical.md | 26 +- .../mechanical_operations.py | 66 ++-- tests/test_mechanical_defaults.py | 286 ++++++++++++++++++ 8 files changed, 568 insertions(+), 58 deletions(-) create mode 100644 comcheck_api/constants/mechanical_constants.py create mode 100644 tests/test_mechanical_defaults.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8063ae1..abf7f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to this project are documented in this file. - Automatic `fanSystemKey` UUID generation and validation for fan systems - `HVACSystemListManager`, `FanSystemListManager`, `HVACPlantListManager`, `WaterHeaterListManager` in `comcheck_api.managers.components.mechanical` - `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, `ServiceWaterHeatingSystem` added to `MODEL_TO_ID_INFO` with auto-generated descriptions ("HVAC System", "Fan System", "Fan", "Plant", "Water Heater") +- `get_default_hvac_system_template`, `get_default_fan_system_template`, `get_default_fan_template`, `get_default_hvac_plant_template`, and `get_default_water_heater_template` default templates for mechanical systems - Mechanical API calls on `COMcheckClient` (`POST /{energyCode}/mechanical/...`): `calculate_water_heater_efficiency`, `calculate_hvac_system_efficiency`, `validate_hvac_system_economizer`, `calculate_plant_efficiency`, `calculate_fan_pressure_drop_credit`, `check_fan_system_compliance`, `calculate_fan_maximum_allowable_hp` - `COMcheckClient.update_fan_system_compliance` and `COMcheckClient.update_fan_pressure_drop_credits` — calculate fan system compliance / pressure drop credits and write them onto the project's fan system - Matching `COMCheckApiService` service methods, and `WaterHeaterEfficiency`, `HVACSystemEfficiency`, `EconomizerValidation`, `PlantEfficiency`, `FanSystemComplianceResult` result types diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 6e9ac58..7538649 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -458,24 +458,28 @@ by description. ```python from comcheck_api import project_mechanical_operations as mech_ops -from comcheck_api.types.core_types import ( - Fan, - FanSystem, - HVACPlant, - HVACSystem, - ServiceWaterHeatingSystem, +from comcheck_api.defaults import ( + get_default_hvac_system_template, + get_default_fan_system_template, + get_default_fan_template, + get_default_hvac_plant_template, + get_default_water_heater_template, ) # 1. Add an HVAC system -hvac_system = HVACSystem(description="Rooftop Unit 1", quantity=2) +hvac_system = get_default_hvac_system_template() +hvac_system.description = "Rooftop Unit 1" +hvac_system.quantity = 2 project = mech_ops.add_hvac_system_to_project(project, hvac_system) # 2. Add a fan system -fan_system = FanSystem(description="Supply Fan 1") +fan_system = get_default_fan_system_template() +fan_system.description = "Supply Fan 1" project = mech_ops.add_fan_system_to_project(project, fan_system) # 3. Add a fan to the fan system -fan = Fan(description="Fan 1") +fan = get_default_fan_template() +fan.description = "Fan 1" project = mech_ops.add_fan_to_fan_system(project, "Supply Fan 1", fan) # 4. Assign the fan system to the HVAC system (by description, not UUID) @@ -484,11 +488,13 @@ project = mech_ops.assign_fan_system_to_hvac_system( ) # 5. Add a plant -plant = HVACPlant(description="Chiller 1") +plant = get_default_hvac_plant_template() +plant.description = "Chiller 1" project = mech_ops.add_hvac_plant_to_project(project, plant) # 6. Add a water heater (swhSystem is top-level, not under hvac) -water_heater = ServiceWaterHeatingSystem(description="Water Heater 1") +water_heater = get_default_water_heater_template() +water_heater.description = "Water Heater 1" project = mech_ops.add_water_heater_to_project(project, water_heater) ``` diff --git a/comcheck_api/ai/skill/reference/operations.md b/comcheck_api/ai/skill/reference/operations.md index 4fb70cc..ea3edaf 100644 --- a/comcheck_api/ai/skill/reference/operations.md +++ b/comcheck_api/ai/skill/reference/operations.md @@ -218,10 +218,12 @@ on add. `swhSystem[]` is a top-level list (not under `hvac`). | `remove_water_heater_from_project(project, water_heater_description)` | Remove a `ServiceWaterHeatingSystem` by its `description`. | | `get_water_heater_keys_from_project(project)` | List `[{description, swhSystemType, fuelType, quantity}, …]` for the project. | -Construct `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, and -`ServiceWaterHeatingSystem` directly from `comcheck_api.types.core_types` -(all fields are optional). `description` is the -identifier — it is unique within each list and is auto-generated if missing. +Use `get_default_hvac_system_template()`, `get_default_fan_system_template()`, +`get_default_fan_template()`, `get_default_hvac_plant_template()`, and +`get_default_water_heater_template()` from `comcheck_api.defaults` as starting +points (or construct models directly from `comcheck_api.types.core_types`, where +all fields are optional). `description` is the identifier — it is unique within +each list and is auto-generated if missing. **Fan system keys**: the library sets a new UUID on `fanSystemKey` when you add a fan system if it's empty or already used. You cannot update `fanSystemKey` diff --git a/comcheck_api/constants/mechanical_constants.py b/comcheck_api/constants/mechanical_constants.py new file mode 100644 index 0000000..e617fbe --- /dev/null +++ b/comcheck_api/constants/mechanical_constants.py @@ -0,0 +1,143 @@ +"""Mechanical system constants for COMcheck projects.""" + +from comcheck_api.types.core_types import ( + HVACSystem, + FanSystem, + Fan, + HVACPlant, + ServiceWaterHeatingSystem, + HeatingEquipmentTypeOptions, + CoolingEquipmentTypeOptions, + CondenserTypeOptions, + DistributionTypeOptions, + EconomizerTypeOptions, + EconomizerExceptionOptions, + FanControlOptions, + FuelTypeOptions, + HeatPumpPoolHeater, + ZoneLayoutOptions, + HeatPumpTypeOptions, + FanSystemComplianceMethodOptions, + FanTypeOptions, + FanVolumeTypeOptions, + FanEfficiencyExceptionTypeOptions, + PlantTypeOptions, + HeatingPlantTypeOptions, + BoilerDraftTypeOptions, + CompliancePathOptions, + EquipmentEfficiencyRequirementExceptionOptions, + SWHSystemTypeOptions, + SWHFuelTypeOptions, + SWHSystemDrawPatternTypeOptions, + SWHSystemSubTypeOptions, +) + +# packages/front-end-ui/src/global/add-water-heater/initial-state.ts +# packages/front-end-ui/src/global/add-water-heater/type-creator-util.ts +DEFAULT_WATER_HEATER: ServiceWaterHeatingSystem = ServiceWaterHeatingSystem( + description="", + systemType="Water Heater", + swhSystemType=SWHSystemTypeOptions.STORAGE_WATER_HEATER, + fuelType=SWHFuelTypeOptions.UNKNOWN_FUEL, + quantity=1, + storageCapacity=0.0, + propSwhEquipEfficiency=0.0, + circulationPump=False, + heatTraceTapeInstalled=False, + poolSystem=False, + heatPumpPoolHeater=HeatPumpPoolHeater.int_0, + combinedSystem=False, + inputRating=75.0, + swhSystemDrawPatternType=SWHSystemDrawPatternTypeOptions.UNKNOWN_DRAW_PATTERN, + swhSystemSubType=SWHSystemSubTypeOptions.WATER_HEATER_STANDARD, + efficiencyRequirementException=EquipmentEfficiencyRequirementExceptionOptions.EFF_EXCEPTION_UNSPECIFIED, + altExemptType=None, +) + +# packages/front-end-ui/src/global/add-hvac/initial-form-state.ts +# packages/front-end-ui/src/global/add-hvac/utils.ts +DEFAULT_HVAC_SYSTEM: HVACSystem = HVACSystem( + description="", + systemType="HVAC", + condenser=CondenserTypeOptions.UNKNOWN_CONDENSER, + constVolMixingBox=False, + coolingEquipCapacity=0.0, + coolingEquipment=CoolingEquipmentTypeOptions.NO_COOLING_EQUIPMENT, + descriptionCoolEquip="Cooling Equip", + descriptionHeatEquip="Heating Equip", + distribution=DistributionTypeOptions.SINGLE_DUCT, + economizer=EconomizerTypeOptions.NO_ECONOMIZER, + economizerException=EconomizerExceptionOptions.NO_ECONOMIZER_EXCEPTION, + electricReheat=False, + fanControl=FanControlOptions.UNKNOWN_FAN_CONTROL, + fanPoweredVav=False, + fanSystemKey=None, + fuel=FuelTypeOptions.UNKNOWN_FUEL, + heatingEquipCapacity=0.0, + heatingEquipment=HeatingEquipmentTypeOptions.NO_HEATING_EQUIPMENT, + heatPump=HeatPumpTypeOptions.NO_HEATPUMP_TYPE, + hydronicReheat=False, + isHeatingSysWeatherized=False, + perimeterSystem=False, + propCoolingEquipEfficiencyPartial=0.0, + propCoolingEquipEfficiency=0.0, + propHeatingEquipEfficiency=0.0, + quantity=1, + quantityCoolEquip=1, + quantityHeatEquip=1, + reheatRecoolCoil=False, + returnFanHp=0.0, + steamReheat=False, + supplyFanHp=0.0, + supplyStaticPressure=0.0, + totalFanHp=0.0, + varAirBox=False, + varAirVolMixingBox=False, + zone=ZoneLayoutOptions.UNKNOWN_ZONE, + altExemptType=None, + spaceHeatingSystemException=None, +) + +# packages/front-end-ui/src/global/add-plant/initial-values.ts +# packages/front-end-ui/src/global/add-plant/type-creator-util.ts (toBoiler) +DEFAULT_HVAC_PLANT: HVACPlant = HVACPlant( + description="", + systemType="HEATING_PLANT", + plantType=PlantTypeOptions.HEATING_PLANT, + heatingPlant=HeatingPlantTypeOptions.HOTWATER_PLANT, + heatingPlantCapacity=100.0, + boilerDraftType=BoilerDraftTypeOptions.FORCED_DRAFT, + quantity=1, + propHeatingPlantEfficiency=0.0, + twoPipeSystem=False, + waterloopHeatPump=False, + compliancePath=CompliancePathOptions.COMPLIANCE_PATH_A, + efficiencyRequirementException=EquipmentEfficiencyRequirementExceptionOptions.EFF_EXCEPTION_UNSPECIFIED, + altExemptType=None, +) + +# packages/front-end-ui/src/global/add-fan-system/initial-values.ts +# packages/front-end-ui/src/global/add-fan-system/utils.ts +DEFAULT_FAN_SYSTEM: FanSystem = FanSystem( + description="", + description2="", + complyMethod=FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_NAMEPLATE_HP, + hasPressureDropCredits=False, + pressureDropCredits=[], + fan=[], + servesAllowanceAreaWithFlowControl=None, +) + +# packages/front-end-ui/src/global/add-fan-system/initial-values.ts (fanUnderEdit) +DEFAULT_FAN: Fan = Fan( + description="", + fanType=FanTypeOptions.FAN_TYPE_UNSPECIFIED, + fanVolumeType=FanVolumeTypeOptions.FAN_VOLUME_TYPE_UNSPECIFIED, + volume=0.0, + designBrakeHp=0.0, + nameplateHp=0.0, + fanEfficiency=0.0, + totalFanEfficiency=0.0, + fanDesignEfficiency=0.0, + fanEfficiencyExceptionType=FanEfficiencyExceptionTypeOptions.FAN_EFFICIENCY_EXCEPTION_TYPE_UNSPECIFIED, +) diff --git a/comcheck_api/defaults.py b/comcheck_api/defaults.py index 04074cc..a4f094a 100644 --- a/comcheck_api/defaults.py +++ b/comcheck_api/defaults.py @@ -15,6 +15,13 @@ DEFAULT_RENEWABLE_SYSTEM, DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT, ) +from comcheck_api.constants.mechanical_constants import ( + DEFAULT_HVAC_SYSTEM, + DEFAULT_FAN_SYSTEM, + DEFAULT_FAN, + DEFAULT_HVAC_PLANT, + DEFAULT_WATER_HEATER, +) from comcheck_api.constants.envelope_constants import ( DEFAULT_AG_WALL, DEFAULT_BG_WALL, @@ -234,3 +241,62 @@ def get_default_offsite_renewable_procurement_template(): A new :class:`~comcheck_api.types.core_types.OffsiteRenewableProcurement` instance. """ return copy.deepcopy(DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT) + + +def get_default_hvac_system_template(): + """Return a deep copy of the default HVAC system template. + + Defaults to an unknown cooling and heating equipment with no economizer, + single-duct distribution, and no fan system assigned. + + Returns: + A new :class:`~comcheck_api.types.core_types.HVACSystem` instance. + """ + return copy.deepcopy(DEFAULT_HVAC_SYSTEM) + + +def get_default_fan_system_template(): + """Return a deep copy of the default fan system template. + + Defaults to motor nameplate HP compliance method with no fans and no + pressure drop credits. + + Returns: + A new :class:`~comcheck_api.types.core_types.FanSystem` instance. + """ + return copy.deepcopy(DEFAULT_FAN_SYSTEM) + + +def get_default_fan_template(): + """Return a deep copy of the default fan template. + + Defaults to an unspecified fan type with 0.0 volume and no efficiency data. + + Returns: + A new :class:`~comcheck_api.types.core_types.Fan` instance. + """ + return copy.deepcopy(DEFAULT_FAN) + + +def get_default_hvac_plant_template(): + """Return a deep copy of the default HVAC plant template. + + Defaults to a heating plant with hot water boiler (100 kBtu/hr capacity) + and forced draft. + + Returns: + A new :class:`~comcheck_api.types.core_types.HVACPlant` instance. + """ + return copy.deepcopy(DEFAULT_HVAC_PLANT) + + +def get_default_water_heater_template(): + """Return a deep copy of the default service water heating system template. + + Defaults to a storage water heater with unknown fuel type, 1 quantity, + 0 storage capacity, and 75 kBtu/hr input rating. + + Returns: + A new :class:`~comcheck_api.types.core_types.ServiceWaterHeatingSystem` instance. + """ + return copy.deepcopy(DEFAULT_WATER_HEATER) diff --git a/docs_site/api/operations/mechanical.md b/docs_site/api/operations/mechanical.md index 57aca67..ddbff65 100644 --- a/docs_site/api/operations/mechanical.md +++ b/docs_site/api/operations/mechanical.md @@ -47,9 +47,11 @@ Add a new HVAC system. Creates the `hvac` singleton if it's `MISSING`. **Example:** ```python -from comcheck_api.types.core_types import HVACSystem +from comcheck_api.defaults import get_default_hvac_system_template -hvac_system = HVACSystem(description="Rooftop Unit 1", quantity=2) +hvac_system = get_default_hvac_system_template() +hvac_system.description = "Rooftop Unit 1" +hvac_system.quantity = 2 project = mech_ops.add_hvac_system_to_project(project, hvac_system) ``` @@ -132,9 +134,10 @@ Add a new fan system. The library sets a new UUID on `fanSystemKey` if it's empt **Example:** ```python -from comcheck_api.types.core_types import FanSystem +from comcheck_api.defaults import get_default_fan_system_template -fan_system = FanSystem(description="Supply Fan 1") +fan_system = get_default_fan_system_template() +fan_system.description = "Supply Fan 1" project = mech_ops.add_fan_system_to_project(project, fan_system) ``` @@ -191,9 +194,10 @@ Add a new fan to a fan system. **Example:** ```python -from comcheck_api.types.core_types import Fan +from comcheck_api.defaults import get_default_fan_template -fan = Fan(description="Fan 1") +fan = get_default_fan_template() +fan.description = "Fan 1" project = mech_ops.add_fan_to_fan_system(project, "Supply Fan 1", fan) ``` @@ -273,9 +277,10 @@ Add a new HVAC plant. Creates the `hvac` singleton if it's `MISSING`. **Example:** ```python -from comcheck_api.types.core_types import HVACPlant +from comcheck_api.defaults import get_default_hvac_plant_template -plant = HVACPlant(description="Chiller 1") +plant = get_default_hvac_plant_template() +plant.description = "Chiller 1" project = mech_ops.add_hvac_plant_to_project(project, plant) ``` @@ -327,9 +332,10 @@ Add a new water heater. Initializes `swhSystem` to `[]` if it's `MISSING`. **Example:** ```python -from comcheck_api.types.core_types import ServiceWaterHeatingSystem +from comcheck_api.defaults import get_default_water_heater_template -water_heater = ServiceWaterHeatingSystem(description="Water Heater 1") +water_heater = get_default_water_heater_template() +water_heater.description = "Water Heater 1" project = mech_ops.add_water_heater_to_project(project, water_heater) ``` diff --git a/examples/project_operations/mechanical_operations.py b/examples/project_operations/mechanical_operations.py index 88c61c5..7e103a0 100644 --- a/examples/project_operations/mechanical_operations.py +++ b/examples/project_operations/mechanical_operations.py @@ -29,16 +29,18 @@ COMcheckClient, project_mechanical_operations as mech_ops, ) +from comcheck_api.defaults import ( + get_default_hvac_system_template, + get_default_fan_system_template, + get_default_fan_template, + get_default_hvac_plant_template, + get_default_water_heater_template, +) from comcheck_api.types.core_types import ( - Fan, - FanSystem, FanSystemComplianceMethodOptions, FanSystemPressureDropCredits, - HVACPlant, - HVACSystem, PlantTypeOptions, PressureDropTypeOptions, - ServiceWaterHeatingSystem, SWHFuelTypeOptions, SWHSystemTypeOptions, ) @@ -58,23 +60,23 @@ # ── Step 1: Add an HVAC system ──────────────────────────────────────────────── -project = mech_ops.add_hvac_system_to_project( - project, HVACSystem(description="Rooftop Unit 1", systemType="HVAC", quantity=2) -) +hvac_system = get_default_hvac_system_template() +hvac_system.description = "Rooftop Unit 1" +hvac_system.quantity = 2 +project = mech_ops.add_hvac_system_to_project(project, hvac_system) print(f"HVAC systems: {mech_ops.get_hvac_system_keys_from_project(project)}") # ── Step 2: Add a fan system with a fan ─────────────────────────────────────── # fanSystemKey is left empty; the library sets a UUID. -project = mech_ops.add_fan_system_to_project( - project, - FanSystem( - description="Supply Fans", - complyMethod=FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP, - ), -) -project = mech_ops.add_fan_to_fan_system( - project, "Supply Fans", Fan(description="Supply Fan 1", maxNameplateHp=10.0) -) +fan_system = get_default_fan_system_template() +fan_system.description = "Supply Fans" +fan_system.complyMethod = FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP +project = mech_ops.add_fan_system_to_project(project, fan_system) + +fan = get_default_fan_template() +fan.description = "Supply Fan 1" +fan.nameplateHp = 10.0 +project = mech_ops.add_fan_to_fan_system(project, "Supply Fans", fan) # ── Step 3: Add a pressure drop credit to the fan system ────────────────────── # Credits are identified by type, which is unique within a fan system. They @@ -92,20 +94,18 @@ print(f"Fan systems: {mech_ops.get_fan_system_keys_from_project(project)}") # ── Step 5: Add a plant and a water heater ──────────────────────────────────── -project = mech_ops.add_hvac_plant_to_project( - project, - HVACPlant(description="Boiler Plant", plantType=PlantTypeOptions.HEATING_PLANT), -) -project = mech_ops.add_water_heater_to_project( - project, - ServiceWaterHeatingSystem( - description="Water Heater 1", - systemType="Water Heater", - swhSystemType=SWHSystemTypeOptions.STORAGE_WATER_HEATER, - fuelType=SWHFuelTypeOptions.GAS, - quantity=1, - ), -) +plant = get_default_hvac_plant_template() +plant.description = "Boiler Plant" +plant.plantType = PlantTypeOptions.HEATING_PLANT +project = mech_ops.add_hvac_plant_to_project(project, plant) + +water_heater = get_default_water_heater_template() +water_heater.description = "Water Heater 1" +water_heater.swhSystemType = SWHSystemTypeOptions.STORAGE_WATER_HEATER +water_heater.fuelType = SWHFuelTypeOptions.GAS +water_heater.quantity = 1 +project = mech_ops.add_water_heater_to_project(project, water_heater) + print(f"Plants: {mech_ops.get_hvac_plant_keys_from_project(project)}") print(f"Water heaters: {mech_ops.get_water_heater_keys_from_project(project)}") @@ -128,7 +128,7 @@ project, "Rooftop Unit 1", {"quantity": 3} ) project = mech_ops.update_fan_in_fan_system( - project, "Supply Fans", "Supply Fan 1", {"maxNameplateHp": 7.5} + project, "Supply Fans", "Supply Fan 1", {"nameplateHp": 7.5} ) # Changing a credit input clears its pressureDropCredit; recalculate it. project = mech_ops.update_pressure_drop_credit_in_fan_system( diff --git a/tests/test_mechanical_defaults.py b/tests/test_mechanical_defaults.py new file mode 100644 index 0000000..e797e4d --- /dev/null +++ b/tests/test_mechanical_defaults.py @@ -0,0 +1,286 @@ +"""Tests for mechanical defaults templates.""" + +import pytest +from uuid import UUID + +from comcheck_api.defaults import ( + get_default_project_template, + get_default_hvac_system_template, + get_default_fan_system_template, + get_default_fan_template, + get_default_hvac_plant_template, + get_default_water_heater_template, +) +from comcheck_api.project_operations import ( + project_mechanical_operations as mech_ops, +) +from comcheck_api.types.core_types import ( + ComBuilding, + HVACSystem, + FanSystem, + Fan, + HVACPlant, + ServiceWaterHeatingSystem, +) + + +@pytest.fixture +def project() -> ComBuilding: + """Return a fresh default project for each test.""" + return get_default_project_template() + + +def _fresh(project: ComBuilding) -> ComBuilding: + return project.model_copy(deep=True) + + +# --------------------------------------------------------------------------- +# HVAC System Template Tests +# --------------------------------------------------------------------------- + + +def test_hvac_system_template_returns_fresh_copy(): + """Test that get_default_hvac_system_template returns independent copies.""" + template1 = get_default_hvac_system_template() + template2 = get_default_hvac_system_template() + + # Mutate the first template + template1.description = "Modified" + template1.quantity = 999 + + # Second template should be unaffected + assert template2.description == "" + assert template2.quantity == 1 + + +def test_hvac_system_template_validates(): + """Test that the HVAC system template passes validation.""" + template = get_default_hvac_system_template() + assert isinstance(template, HVACSystem) + # Should be able to create a new instance from the template's dict + HVACSystem.model_validate(template.model_dump()) + + +def test_hvac_system_template_auto_naming(project: ComBuilding): + """Test that adding two HVAC systems with empty description generates auto names.""" + proj = _fresh(project) + + # Add first HVAC system + hvac1 = get_default_hvac_system_template() + proj = mech_ops.add_hvac_system_to_project(proj, hvac1) + + # Add second HVAC system + hvac2 = get_default_hvac_system_template() + proj = mech_ops.add_hvac_system_to_project(proj, hvac2) + + # Check that both got auto-generated names + assert proj.hvac.hvacSystem[0].description == "HVAC System 1" + assert proj.hvac.hvacSystem[1].description == "HVAC System 2" + + +# --------------------------------------------------------------------------- +# Fan System Template Tests +# --------------------------------------------------------------------------- + + +def test_fan_system_template_returns_fresh_copy(): + """Test that get_default_fan_system_template returns independent copies.""" + template1 = get_default_fan_system_template() + template2 = get_default_fan_system_template() + + # Mutate the first template + template1.description = "Modified" + template1.pressureDropCredits.append({"type": "test"}) + + # Second template should be unaffected + assert template2.description == "" + assert len(template2.pressureDropCredits) == 0 + + +def test_fan_system_template_validates(): + """Test that the fan system template passes validation.""" + template = get_default_fan_system_template() + assert isinstance(template, FanSystem) + # Should be able to create a new instance from the template's dict + FanSystem.model_validate(template.model_dump()) + + +def test_fan_system_template_auto_naming_and_uuid(project: ComBuilding): + """Test that adding two fan systems with empty description generates auto names and UUIDs.""" + proj = _fresh(project) + + # Add first fan system + fan_sys1 = get_default_fan_system_template() + proj = mech_ops.add_fan_system_to_project(proj, fan_sys1) + + # Add second fan system + fan_sys2 = get_default_fan_system_template() + proj = mech_ops.add_fan_system_to_project(proj, fan_sys2) + + # Check that both got auto-generated names + assert proj.hvac.fanSystem[0].description == "Fan System 1" + assert proj.hvac.fanSystem[1].description == "Fan System 2" + + # Check that both got unique UUIDs + uuid1 = proj.hvac.fanSystem[0].fanSystemKey + uuid2 = proj.hvac.fanSystem[1].fanSystemKey + assert uuid1 is not None + assert uuid2 is not None + assert uuid1 != uuid2 + # Verify they are valid UUIDs + UUID(uuid1) + UUID(uuid2) + + +# --------------------------------------------------------------------------- +# Fan Template Tests +# --------------------------------------------------------------------------- + + +def test_fan_template_returns_fresh_copy(): + """Test that get_default_fan_template returns independent copies.""" + template1 = get_default_fan_template() + template2 = get_default_fan_template() + + # Mutate the first template + template1.description = "Modified" + template1.volume = 999.0 + + # Second template should be unaffected + assert template2.description == "" + assert template2.volume == 0.0 + + +def test_fan_template_validates(): + """Test that the fan template passes validation.""" + template = get_default_fan_template() + assert isinstance(template, Fan) + # Should be able to create a new instance from the template's dict + Fan.model_validate(template.model_dump()) + + +def test_fan_template_auto_naming(project: ComBuilding): + """Test that adding two fans to a fan system with empty description generates auto names.""" + proj = _fresh(project) + + # Create a fan system first + fan_sys = get_default_fan_system_template() + proj = mech_ops.add_fan_system_to_project(proj, fan_sys) + + fan_system_desc = proj.hvac.fanSystem[0].description + + # Add first fan + fan1 = get_default_fan_template() + proj = mech_ops.add_fan_to_fan_system(proj, fan_system_desc, fan1) + + # Add second fan + fan2 = get_default_fan_template() + proj = mech_ops.add_fan_to_fan_system(proj, fan_system_desc, fan2) + + # Check that both got auto-generated names + assert proj.hvac.fanSystem[0].fan[0].description == "Fan 1" + assert proj.hvac.fanSystem[0].fan[1].description == "Fan 2" + + +# --------------------------------------------------------------------------- +# HVAC Plant Template Tests +# --------------------------------------------------------------------------- + + +def test_hvac_plant_template_returns_fresh_copy(): + """Test that get_default_hvac_plant_template returns independent copies.""" + template1 = get_default_hvac_plant_template() + template2 = get_default_hvac_plant_template() + + # Mutate the first template + template1.description = "Modified" + template1.quantity = 999 + + # Second template should be unaffected + assert template2.description == "" + assert template2.quantity == 1 + + +def test_hvac_plant_template_validates(): + """Test that the HVAC plant template passes validation.""" + template = get_default_hvac_plant_template() + assert isinstance(template, HVACPlant) + # Should be able to create a new instance from the template's dict + HVACPlant.model_validate(template.model_dump()) + + +def test_hvac_plant_template_auto_naming(project: ComBuilding): + """Test that adding two plants with empty description generates auto names.""" + proj = _fresh(project) + + # Add first plant + plant1 = get_default_hvac_plant_template() + proj = mech_ops.add_hvac_plant_to_project(proj, plant1) + + # Add second plant + plant2 = get_default_hvac_plant_template() + proj = mech_ops.add_hvac_plant_to_project(proj, plant2) + + # Check that both got auto-generated names + assert proj.hvac.hvacPlant[0].description == "Plant 1" + assert proj.hvac.hvacPlant[1].description == "Plant 2" + + +# --------------------------------------------------------------------------- +# Water Heater Template Tests +# --------------------------------------------------------------------------- + + +def test_water_heater_template_returns_fresh_copy(): + """Test that get_default_water_heater_template returns independent copies.""" + template1 = get_default_water_heater_template() + template2 = get_default_water_heater_template() + + # Mutate the first template + template1.description = "Modified" + template1.quantity = 999 + + # Second template should be unaffected + assert template2.description == "" + assert template2.quantity == 1 + + +def test_water_heater_template_validates(): + """Test that the water heater template passes validation.""" + template = get_default_water_heater_template() + assert isinstance(template, ServiceWaterHeatingSystem) + # Should be able to create a new instance from the template's dict + ServiceWaterHeatingSystem.model_validate(template.model_dump()) + + +def test_water_heater_template_auto_naming(project: ComBuilding): + """Test that adding two water heaters with empty description generates auto names.""" + proj = _fresh(project) + + # Add first water heater + wh1 = get_default_water_heater_template() + proj = mech_ops.add_water_heater_to_project(proj, wh1) + + # Add second water heater + wh2 = get_default_water_heater_template() + proj = mech_ops.add_water_heater_to_project(proj, wh2) + + # Check that both got auto-generated names + assert proj.swhSystem[0].description == "Water Heater 1" + assert proj.swhSystem[1].description == "Water Heater 2" + + +def test_templates_match_ui_defaults(): + """Spot-check values taken from the UI's Add dialogs.""" + from comcheck_api.types.core_types import ( + CoolingEquipmentTypeOptions, + HeatPumpPoolHeater, + ) + + hvac_system = get_default_hvac_system_template() + assert hvac_system.coolingEquipment == CoolingEquipmentTypeOptions.NO_COOLING_EQUIPMENT + assert hvac_system.propCoolingEquipEfficiency == 0.0 + + water_heater = get_default_water_heater_template() + assert water_heater.heatPumpPoolHeater == HeatPumpPoolHeater.int_0 + assert water_heater.inputRating == 75.0