From 894263eeba00b6965ac04dfe516e82e05d0babc5 Mon Sep 17 00:00:00 2001 From: Yanyan Zhu Date: Wed, 11 Nov 2026 11:51:19 -0800 Subject: [PATCH] update doc site --- comcheck_api/ai/skill/SKILL.md | 2 +- comcheck_api/api/api_services.py | 6 +- comcheck_api/client/comcheck_client.py | 1028 +++++++++-------- comcheck_api/defaults.py | 64 +- comcheck_api/introspection.py | 11 +- .../project_envelope_operations.py | 21 +- .../project_mechanical_operations.py | 36 +- comcheck_api/validation.py | 5 + docs_site/api/client.md | 156 +++ docs_site/api/defaults.md | 137 ++- docs_site/api/introspection.md | 12 + docs_site/api/operations/building-area.md | 86 +- docs_site/api/operations/credits.md | 57 +- docs_site/api/operations/envelope.md | 196 +++- docs_site/api/operations/exterior-lighting.md | 57 +- docs_site/api/operations/interior-lighting.md | 43 +- docs_site/api/operations/mechanical.md | 533 ++++----- docs_site/api/operations/renewable-energy.md | 82 +- docs_site/getting-started.md | 196 +++- docs_site/index.md | 7 +- docs_site/types-guide.md | 109 +- examples/README.md | 3 +- examples/client/mechanical.py | 10 +- mkdocs.yml | 8 + .../test_mechanical_operations.py | 83 ++ tests/test_introspection_validation.py | 75 ++ tests/test_mechanical_api.py | 104 +- 27 files changed, 2114 insertions(+), 1013 deletions(-) create mode 100644 tests/test_introspection_validation.py diff --git a/comcheck_api/ai/skill/SKILL.md b/comcheck_api/ai/skill/SKILL.md index 39e1e61..6612ae9 100644 --- a/comcheck_api/ai/skill/SKILL.md +++ b/comcheck_api/ai/skill/SKILL.md @@ -204,7 +204,7 @@ print(result["performanceRating"]) offer building-area / envelope / lighting / mechanical / renewable / credits / simulation instead. `comcheck_api.list_operations()` enumerates the `building_area`, `envelope`, `interior_lighting`, `exterior_lighting`, `mechanical`, - `renewable`, and `credits` groups. + `credits`, and `renewable` groups. ## Common patterns diff --git a/comcheck_api/api/api_services.py b/comcheck_api/api/api_services.py index 7c3a013..4af1e3d 100644 --- a/comcheck_api/api/api_services.py +++ b/comcheck_api/api/api_services.py @@ -197,7 +197,7 @@ def update_project( self._handle_api_error(error) def assemblies_uvalue( - self, envelope_data: Dict[str, Any], energy_code + self, envelope_data: Dict[str, Any], energy_code: str ) -> Dict[str, Any]: """get assemblies u values @@ -672,7 +672,9 @@ def fan_maximum_allowable_hp( """Calculate maximum allowable horsepower for a fan. Args: - fan_data: Fan data + fan_data: A single object with ``designBrakeHp`` and ``brakeHp``. + Both keys are required; the endpoint returns a server error + if ``brakeHp`` is absent or if a list is sent. energy_code: The energy code for the endpoint path Returns: diff --git a/comcheck_api/client/comcheck_client.py b/comcheck_api/client/comcheck_client.py index a3d0a18..826839c 100644 --- a/comcheck_api/client/comcheck_client.py +++ b/comcheck_api/client/comcheck_client.py @@ -5,6 +5,7 @@ import logging import os +import warnings from typing import Any, Dict, List, Literal, Optional, Union, overload import httpx @@ -31,7 +32,9 @@ ) from comcheck_api.types.common_types import InteriorSpace from comcheck_api.types.core_types import ( + MISSING, ComBuilding, + FanSystemComplianceMethodOptions, EfficiencyPackage, RenewableExceptionOptions, ServiceWaterHeatingSystem, @@ -318,159 +321,6 @@ def update_uvalues(self, project: ComBuilding) -> ComBuilding: return project - def area_for_renewable(self, project: ComBuilding) -> ComBuilding: - """Calculate renewable area metrics and update the project in place. - - Sends the project to the area-for-renewable endpoint and writes the - returned ``largestThreeFloorArea`` and ``roofAreaForRenewable`` values - back onto ``project.renewable``. - - Args: - project: The project whose renewable area metrics should be refreshed. - - Returns: - The same ``project`` instance, with renewable area metrics updated. - """ - project_data = project.model_dump(mode="json") - areas = self._service.area_for_renewable(project_data).get("data") or {} - - renewable = project.renewable - renewable.largestThreeFloorArea = _optional_float( - areas.get("largestThreeFloorArea") - ) - renewable.roofAreaForRenewable = _optional_float( - areas.get("roofAreaForRenewable") - ) - return project - - def renewable_capacity(self, project: ComBuilding) -> ComBuilding: - """Calculate renewable capacity and update the project in place. - - Sends the project to the renewable-capacity endpoint and writes the - returned values back onto ``project.renewable``: - - - ``requiredRenewableCapacity`` → ``requiredCapacity`` - - ``proposedRenewableCapacity`` → ``proposedCapacity`` - - ``requiredOffsiteRenewableProcurementEnergy`` → - ``requiredOffsiteRenewableEnergy`` - - ``proposedOffsiteRenewableProcurementEnergy`` → - ``proposedOffsiteRenewableEnergy`` - - The endpoint's ``renewableComplianceStatus`` has no field on the - project model and is not written back. - - Args: - project: The project whose renewable capacity should be refreshed. - - Returns: - The same ``project`` instance, with renewable capacity updated. - """ - project_data = project.model_dump(mode="json") - capacity = self._service.renewable_capacity(project_data).get("data") or {} - - renewable = project.renewable - renewable.requiredCapacity = _optional_float( - capacity.get("requiredRenewableCapacity") - ) - renewable.proposedCapacity = _optional_float( - capacity.get("proposedRenewableCapacity") - ) - renewable.requiredOffsiteRenewableEnergy = _optional_float( - capacity.get("requiredOffsiteRenewableProcurementEnergy") - ) - renewable.proposedOffsiteRenewableEnergy = _optional_float( - capacity.get("proposedOffsiteRenewableProcurementEnergy") - ) - return project - - def set_renewable_exception( - self, - project: ComBuilding, - renewable_exception: RenewableExceptionOptions, - ) -> ComBuilding: - """Set the renewable exception and refresh renewable capacity in place. - - The capacity fields depend on the exception, so this applies - :func:`~comcheck_api.project_operations.project_renewable_operations.set_renewable_exception_in_project` - and then calls :meth:`renewable_capacity`. ``project`` is only changed - if the API call succeeds. - - Args: - project: The project to update. - renewable_exception: A :class:`~comcheck_api.types.core_types.RenewableExceptionOptions` - value. Must not be ``NoneType_None``. - - Returns: - The same ``project`` instance, with the exception set and renewable - capacity updated. - - Raises: - TypeError: If renewable_exception is not a RenewableExceptionOptions member. - ValueError: If renewable_exception is NoneType_None. - """ - updated_project = set_renewable_exception_in_project( - project, renewable_exception - ) - self.renewable_capacity(updated_project) - project.renewable = updated_project.renewable - return project - - def calculate_energy_credits(self, project: ComBuilding) -> EnergyCreditResult: - """Calculate the proposed and required energy credits of a project. - - Sends the project to the update-energy-credits endpoint. Nothing is - written back to the project — the project model has no fields for - calculated credits. - - Args: - project: The project whose energy credits should be calculated. - - Returns: - The credits achieved per building area and credit type, the proposed - and required totals, and any warnings and user alerts. - """ - project_data = project.model_dump(mode="json") - credits = self._service.update_energy_credits(project_data).get("data") or {} - return EnergyCreditResult.model_validate(credits) - - def calculate_single_energy_credit( - self, - project: ComBuilding, - building_area_key: str, - energy_credit: EfficiencyPackage, - ) -> EnergyCreditResult: - """Calculate the credits one energy credit earns on its own. - - Use this to check how many credits a credit is worth before (or - instead of) combining it with others. The calculation runs on a copy - of the project in which ``energy_credit`` is the only credit: all - ``efficiencyPackages`` are replaced by it, and all other project data - is kept. ``project`` is not modified, and the credit does not need to - be in it already. - - Args: - project: The project the credit applies to. - building_area_key: The key of the building area (``WholeBldgUse.key``) - that gets the credit. - energy_credit: The EfficiencyPackage to calculate. ``type`` selects - the credit; set the inputs that credit needs. - - Returns: - The result of the calculation. ``proposed`` (or - ``proposedRenewableAndLoadMgmt`` for renewable and load management - credits) is the credit's value, weighted by the building area's - share of the gross floor area. ``warnings`` report missing inputs. - - Raises: - ValueError: If the building area is not found. - """ - trial_project = project.model_copy(deep=True) - trial_project.efficiencyPackages = [] - trial_project = add_energy_credit_to_project( - trial_project, building_area_key, energy_credit - ) - return self.calculate_energy_credits(trial_project) - def calculate_interior_space_allowed_wattage( self, interior_space: InteriorSpace, energy_code: str ) -> Any: @@ -513,169 +363,352 @@ def calculate_interior_spaces_allowed_wattage( ) return response.get("data") - def check_UA_compliance(self, project: ComBuilding) -> Any: - """Check UA path compliance for a project. + 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: - project: The project data to evaluate. + 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: - The compliance results payload returned by the API. + A dict keyed by system description, with efficiency requirements. + + Raises: + ValueError: If a list contains duplicate descriptions. """ - project_data = project.model_dump(mode="json") - response = self._service.check_UA_compliance(project_data) - return response.get("data") + 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") - def check_requirements(self, project: ComBuilding) -> Any: - """Check the applicable requirements for a project. + 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: - project: The project data to evaluate. + hvac_system: The HVAC system to validate. + energy_code: The energy code to use. Returns: - The requirements payload returned by the API. - """ - project_data = project.model_dump(mode="json") - response = self._service.check_requirements(project_data) - return response.get("data") + Economizer validation results. - def generate_report( - self, - project: ComBuilding, - envelope: bool = True, - extlighting: bool = True, - intlighting: bool = True, - mechanical: bool = True, - use_backstop_compliance: bool = False, - download: bool = False, - download_dir: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - """Generate a PDF report for a project. + 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") - The report PDF is stored in S3; the API returns a presigned URL to - download it. The URL is short-lived (expires within a few minutes). + 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() - This method does not open the report in a browser — as a library it - returns the report metadata and lets the caller decide what to do - (e.g. ``webbrowser.open(report["url"])``, stream it onward, or save it - via *download*). + def calculate_fan_pressure_drop_credit( + self, credit: FanSystemPressureDropCredits, energy_code: str + ) -> float: + """Calculate pressure drop credit for a fan system. Args: - project: The project data to generate a report for. - envelope: Whether to include the envelope section in the report. - extlighting: Whether to include the exterior lighting section. - intlighting: Whether to include the interior lighting section. - mechanical: Whether to include the mechanical section. - use_backstop_compliance: Whether to evaluate the report using - backstop compliance (sent as ``useBackstopCompliance``). - download: When ``True``, fetch the PDF from the presigned URL and - save it using the server-provided ``fileName``. - download_dir: Directory to save the PDF into when *download* is - ``True``. Defaults to the user's ``~/Downloads`` folder. + 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 report metadata dict with ``url`` (the presigned S3 URL), - ``expires``, and ``fileName``. + The calculated pressure drop credit value. + + Raises: + ValueError: If the credit type is unset or unspecified. + COMCheckAPIError: If the response has no ``pressureDropCredit``. """ - report_data = { - "building": project.model_dump(mode="json"), - "envelope": envelope, - "extlighting": extlighting, - "intlighting": intlighting, - "mechanical": mechanical, - "useBackstopCompliance": use_backstop_compliance, - } - response = self._service.generate_report(report_data) - if response and download: - if download_dir is None: - download_dir = os.path.join(os.path.expanduser("~"), "Downloads") - os.makedirs(download_dir, exist_ok=True) - pdf_response = httpx.get(response["url"], timeout=60.0) - pdf_response.raise_for_status() - output_path = os.path.join(download_dir, response["fileName"]) - with open(output_path, "wb") as f: - f.write(pdf_response.content) - return response + 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" + ) - def start_run_simulation( - self, project: ComBuilding, project_id: Optional[int] = None - ) -> str: - """Start a compliance simulation run for a project. + # Dump the credit without the type field + credit_dict = credit.model_dump(mode="json") + credit_type = credit_dict.pop("type", None) - If *project_id* is supplied the project is persisted via - :meth:`update_project` before the simulation is launched. + 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: - project: The project data to simulate. - project_id: Optional server-side project ID. When given, the - project is saved before the simulation starts. + fan_system: The fan system to check. + energy_code: The energy code to use. Returns: - The simulation session ID that can be passed to - :meth:`get_simulation_status` and :meth:`get_simulation_result`. + 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 _fan_maximum_allowable_hp_data(self, fan: Fan, energy_code: str) -> Dict[str, Any]: + """Call the maximum-allowable-hp endpoint and return its ``data``. + + The endpoint only accepts ``designBrakeHp`` and ``brakeHp`` and fails + with a server error when ``brakeHp`` is absent, so only those two + fields are sent and a missing ``brakeHp`` is sent as ``0``. + """ + design_brake_hp = getattr(fan, "designBrakeHp", None) + if design_brake_hp is None or design_brake_hp is MISSING: + raise ValueError( + f"Fan '{fan.description}' needs designBrakeHp to calculate maximum allowable HP" + ) + brake_hp = getattr(fan, "brakeHp", None) + if brake_hp is None or brake_hp is MISSING: + brake_hp = 0.0 + fan_data = {"designBrakeHp": design_brake_hp, "brakeHp": brake_hp} + 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 data + + def calculate_fan_maximum_allowable_hp( + self, fan: Fan, energy_code: str + ) -> float: + """Calculate maximum allowable horsepower for a fan. + + Only ``designBrakeHp`` and ``brakeHp`` are sent; ``brakeHp`` defaults + to ``0`` when unset. + + Args: + fan: The fan to calculate for. ``designBrakeHp`` must be set. + energy_code: The energy code to use. + + Returns: + The maximum nameplate horsepower. Raises: - COMCheckSimulationError: If the API returns no session data. + ValueError: If ``designBrakeHp`` is not set. + COMCheckAPIError: If the response has no ``maxNameplateHp``. """ - logger = logging.getLogger(__name__) + data = self._fan_maximum_allowable_hp_data(fan, energy_code) + return float(data["maxNameplateHp"]) - # Always refresh the calculated assembly u-values before simulating. - project = self.update_uvalues(project) + @staticmethod + def _find_fan_system(project: ComBuilding, fan_system_description: str) -> FanSystem: + """Return the fan system in ``project`` with the given description. - if project_id: - logger.info("Updating project: %s", project_id) - project = self.update_project(str(project_id), project) + Raises: + ValueError: If hvac is missing or the fan system is not found. + """ + if project.hvac is MISSING or not project.hvac: + raise ValueError("Project has no hvac data") + for fan_system in project.hvac.fanSystem or []: + if fan_system.description == fan_system_description: + return fan_system + raise ValueError(f"Fan system '{fan_system_description}' not found in project") - project_data = project.model_dump(mode="json") - run_result = self._service.start_run_simulation(project_data) - if run_result.data is None: - raise COMCheckSimulationError( - "Simulation start failed: no session data returned" - ) - return run_result.data.sessionId + def update_fan_system_compliance( + self, project: ComBuilding, fan_system_description: str + ) -> ComBuilding: + """Calculate fan system compliance and update the project in place. - def get_simulation_status(self, session_id: str) -> Dict[str, Any]: - """Get the status of a running or completed simulation. + 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: - session_id: The session ID returned by :meth:`start_run_simulation`. + project: The project whose fan system should be updated. + fan_system_description: The description of the fan system to update. Returns: - A dict containing ``sessionId``, ``status``, and optional - ``message``. ``status`` is one of the values defined in - :class:`comcheck_api.types.SimulationStatus`. Terminal - values are ``"SUCCESS"`` and ``"FAILED"``. + The same ``project`` instance, with compliance fields updated. Raises: - COMCheckSimulationError: If the status cannot be retrieved. + ValueError: If hvac is missing or the fan system is not found. """ - status_response = self._service.get_simulation_status(session_id) - if status_response.data is None: - raise COMCheckSimulationError( - f"Failed to get simulation status for session {session_id}" + fan_system = self._find_fan_system(project, fan_system_description) + + 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", ) - return status_response.data.model_dump(mode="json") + 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 - def get_simulation_result(self, session_id: str) -> Dict[str, Any]: - """Get the result metrics of a completed simulation. + 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: - session_id: The session ID returned by :meth:`start_run_simulation`. + project: The project whose fan system should be updated. + fan_system_description: The description of the fan system to update. Returns: - A dict containing ``sessionId``, ``performanceRating``, - ``energyCreditPerformanceRating``, ``proposedBpf``, and - ``baselineBpf``. + The same ``project`` instance, with pressure drop credit values updated. Raises: - COMCheckSimulationError: If the result cannot be retrieved. + ValueError: If hvac is missing or the fan system is not found. """ - result_response = self._service.get_simulation_result(session_id) - if result_response.data is None: - raise COMCheckSimulationError( - f"Failed to get simulation result for session {session_id}" + fan_system = self._find_fan_system(project, fan_system_description) + + energy_code = str(project.control.code) + + credits = fan_system.pressureDropCredits + if credits is MISSING or not credits: + credits = [] + for credit in credits: + 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 update_fan_maximum_allowable_hp( + self, project: ComBuilding, fan_system_description: str + ) -> ComBuilding: + """Calculate maximum allowable HP for each fan and update the project in place. + + Maximum nameplate HP only applies to fan systems that use the + ``FAN_SYSTEM_MOTOR_NAMEPLATE_HP`` compliance method. For any other + method a ``UserWarning`` is emitted and the project is returned + unchanged, without calling the API. + + For each fan in the fan system with ``designBrakeHp`` set, calls the + maximum-allowable-hp endpoint and writes the returned + ``maxNameplateHp`` back onto the fan. ``brakeHp`` is an input and is + never overwritten. Fans without ``designBrakeHp`` are skipped. + + 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 fan maximum HP values updated. + + Raises: + ValueError: If hvac is missing or the fan system is not found. + """ + fan_system = self._find_fan_system(project, fan_system_description) + if fan_system.complyMethod != FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_NAMEPLATE_HP: + warnings.warn( + f"Fan system '{fan_system_description}' uses compliance method " + f"{fan_system.complyMethod}; maximum allowable HP only applies to " + "FAN_SYSTEM_MOTOR_NAMEPLATE_HP fan systems, so no fans were updated.", + UserWarning, + stacklevel=2, ) - return result_response.data.model_dump(mode="python") + return project + energy_code = str(project.control.code) + + fans = fan_system.fan + if fans is MISSING or not fans: + fans = [] + for fan in fans: + if fan.designBrakeHp is None or fan.designBrakeHp is MISSING: + continue + data = self._fan_maximum_allowable_hp_data(fan, energy_code) + fan.maxNameplateHp = float(data["maxNameplateHp"]) + + return project + + 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_water_heater_efficiency( self, @@ -721,293 +754,322 @@ def calculate_water_heater_efficiency( 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. + def calculate_energy_credits(self, project: ComBuilding) -> EnergyCreditResult: + """Calculate the proposed and required energy credits of a project. + + Sends the project to the update-energy-credits endpoint. Nothing is + written back to the project — the project model has no fields for + calculated credits. Args: - 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. + project: The project whose energy credits should be calculated. Returns: - A dict keyed by system description, with efficiency requirements. - - Raises: - ValueError: If a list contains duplicate descriptions. + The credits achieved per building area and credit type, the proposed + and required totals, and any warnings and user alerts. """ - 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" - ) + project_data = project.model_dump(mode="json") + credits = self._service.update_energy_credits(project_data).get("data") or {} + return EnergyCreditResult.model_validate(credits) - 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 calculate_single_energy_credit( + self, + project: ComBuilding, + building_area_key: str, + energy_credit: EfficiencyPackage, + ) -> EnergyCreditResult: + """Calculate the credits one energy credit earns on its own. - def validate_hvac_system_economizer( - self, hvac_system: HVACSystem, energy_code: str - ) -> EconomizerValidation: - """Validate economizer requirements for an HVAC system. + Use this to check how many credits a credit is worth before (or + instead of) combining it with others. The calculation runs on a copy + of the project in which ``energy_credit`` is the only credit: all + ``efficiencyPackages`` are replaced by it, and all other project data + is kept. ``project`` is not modified, and the credit does not need to + be in it already. Args: - hvac_system: The HVAC system to validate. - energy_code: The energy code to use. + project: The project the credit applies to. + building_area_key: The key of the building area (``WholeBldgUse.key``) + that gets the credit. + energy_credit: The EfficiencyPackage to calculate. ``type`` selects + the credit; set the inputs that credit needs. Returns: - Economizer validation results. + The result of the calculation. ``proposed`` (or + ``proposedRenewableAndLoadMgmt`` for renewable and load management + credits) is the credit's value, weighted by the building area's + share of the gross floor area. ``warnings`` report missing inputs. Raises: - ValueError: If the HVAC system description is empty. + ValueError: If the building area is not found. """ - if not hvac_system.description or not hvac_system.description.strip(): - raise ValueError("HVAC system description cannot be empty") + trial_project = project.model_copy(deep=True) + trial_project.efficiencyPackages = [] + trial_project = add_energy_credit_to_project( + trial_project, building_area_key, energy_credit + ) + return self.calculate_energy_credits(trial_project) - 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 area_for_renewable(self, project: ComBuilding) -> ComBuilding: + """Calculate renewable area metrics and update the project in place. - 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. + Sends the project to the area-for-renewable endpoint and writes the + returned ``largestThreeFloorArea`` and ``roofAreaForRenewable`` values + back onto ``project.renewable``. 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. + project: The project whose renewable area metrics should be refreshed. Returns: - A dict keyed by plant description, with efficiency requirements. - - Raises: - ValueError: If a list contains duplicate descriptions. + The same ``project`` instance, with renewable area metrics updated. """ - 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" - ) + project_data = project.model_dump(mode="json") + areas = self._service.area_for_renewable(project_data).get("data") or {} - 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") + renewable = project.renewable + renewable.largestThreeFloorArea = _optional_float( + areas.get("largestThreeFloorArea") ) - 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() - } + renewable.roofAreaForRenewable = _optional_float( + areas.get("roofAreaForRenewable") + ) + return project - def calculate_fan_pressure_drop_credit( - self, credit: FanSystemPressureDropCredits, energy_code: str - ) -> float: - """Calculate pressure drop credit for a fan system. + def renewable_capacity(self, project: ComBuilding) -> ComBuilding: + """Calculate renewable capacity and update the project in place. + + Sends the project to the renewable-capacity endpoint and writes the + returned values back onto ``project.renewable``: + + - ``requiredRenewableCapacity`` → ``requiredCapacity`` + - ``proposedRenewableCapacity`` → ``proposedCapacity`` + - ``requiredOffsiteRenewableProcurementEnergy`` → + ``requiredOffsiteRenewableEnergy`` + - ``proposedOffsiteRenewableProcurementEnergy`` → + ``proposedOffsiteRenewableEnergy`` + + The endpoint's ``renewableComplianceStatus`` has no field on the + project model and is not written back. 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. + project: The project whose renewable capacity should be refreshed. Returns: - The calculated pressure drop credit value. - - Raises: - ValueError: If the credit type is unset or unspecified. - COMCheckAPIError: If the response has no ``pressureDropCredit``. + The same ``project`` instance, with renewable capacity updated. """ - 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" - ) + project_data = project.model_dump(mode="json") + capacity = self._service.renewable_capacity(project_data).get("data") or {} - # Dump the credit without the type field - credit_dict = credit.model_dump(mode="json") - credit_type = credit_dict.pop("type", None) + renewable = project.renewable + renewable.requiredCapacity = _optional_float( + capacity.get("requiredRenewableCapacity") + ) + renewable.proposedCapacity = _optional_float( + capacity.get("proposedRenewableCapacity") + ) + renewable.requiredOffsiteRenewableEnergy = _optional_float( + capacity.get("requiredOffsiteRenewableProcurementEnergy") + ) + renewable.proposedOffsiteRenewableEnergy = _optional_float( + capacity.get("proposedOffsiteRenewableProcurementEnergy") + ) + return project - 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 set_renewable_exception( + self, + project: ComBuilding, + renewable_exception: RenewableExceptionOptions, + ) -> ComBuilding: + """Set the renewable exception and refresh renewable capacity in place. - def check_fan_system_compliance( - self, fan_system: FanSystem, energy_code: str - ) -> FanSystemComplianceResult: - """Check compliance for a fan system. + The capacity fields depend on the exception, so this applies + :func:`~comcheck_api.project_operations.project_renewable_operations.set_renewable_exception_in_project` + and then calls :meth:`renewable_capacity`. ``project`` is only changed + if the API call succeeds. Args: - fan_system: The fan system to check. - energy_code: The energy code to use. + project: The project to update. + renewable_exception: A :class:`~comcheck_api.types.core_types.RenewableExceptionOptions` + value. Must not be ``NoneType_None``. Returns: - Fan system compliance results. + The same ``project`` instance, with the exception set and renewable + capacity updated. + + Raises: + TypeError: If renewable_exception is not a RenewableExceptionOptions member. + ValueError: If renewable_exception is NoneType_None. """ - 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) + updated_project = set_renewable_exception_in_project( + project, renewable_exception + ) + self.renewable_capacity(updated_project) + project.renewable = updated_project.renewable + return project - def calculate_fan_maximum_allowable_hp( - self, fan: Fan, energy_code: str - ) -> float: - """Calculate maximum allowable horsepower for a fan. + def start_run_simulation( + self, project: ComBuilding, project_id: Optional[int] = None + ) -> str: + """Start a compliance simulation run for a project. + + If *project_id* is supplied the project is persisted via + :meth:`update_project` before the simulation is launched. Args: - fan: The fan to calculate for. - energy_code: The energy code to use. + project: The project data to simulate. + project_id: Optional server-side project ID. When given, the + project is saved before the simulation starts. Returns: - The maximum nameplate horsepower. + The simulation session ID that can be passed to + :meth:`get_simulation_status` and :meth:`get_simulation_result`. Raises: - COMCheckAPIError: If the response has no ``maxNameplateHp``. + COMCheckSimulationError: If the API returns no session data. """ - 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"]) + logger = logging.getLogger(__name__) - def update_fan_system_compliance( - self, project: ComBuilding, fan_system_description: str - ) -> ComBuilding: - """Calculate fan system compliance and update the project in place. + # Always refresh the calculated assembly u-values before simulating. + project = self.update_uvalues(project) - 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``). + if project_id: + logger.info("Updating project: %s", project_id) + project = self.update_project(str(project_id), project) + + project_data = project.model_dump(mode="json") + run_result = self._service.start_run_simulation(project_data) + if run_result.data is None: + raise COMCheckSimulationError( + "Simulation start failed: no session data returned" + ) + return run_result.data.sessionId + + def get_simulation_status(self, session_id: str) -> Dict[str, Any]: + """Get the status of a running or completed simulation. Args: - project: The project whose fan system should be updated. - fan_system_description: The description of the fan system to update. + session_id: The session ID returned by :meth:`start_run_simulation`. Returns: - The same ``project`` instance, with compliance fields updated. + A dict containing ``sessionId``, ``status``, and optional + ``message``. ``status`` is one of the values defined in + :class:`comcheck_api.types.SimulationStatus`. Terminal + values are ``"SUCCESS"`` and ``"FAILED"``. Raises: - ValueError: If hvac is missing or the fan system is not found. + COMCheckSimulationError: If the status cannot be retrieved. """ - from comcheck_api.types.core_types import MISSING - - if project.hvac is MISSING or not project.hvac: - raise ValueError("Project has no hvac data") + status_response = self._service.get_simulation_status(session_id) + if status_response.data is None: + raise COMCheckSimulationError( + f"Failed to get simulation status for session {session_id}" + ) + return status_response.data.model_dump(mode="json") - fan_system = None - for fs in project.hvac.fanSystem or []: - if fs.description == fan_system_description: - fan_system = fs - break + def get_simulation_result(self, session_id: str) -> Dict[str, Any]: + """Get the result metrics of a completed simulation. - if fan_system is None: - raise ValueError( - f"Fan system '{fan_system_description}' not found in project" - ) + Args: + session_id: The session ID returned by :meth:`start_run_simulation`. - energy_code = str(project.control.code) - compliance = self.check_fan_system_compliance(fan_system, energy_code) + Returns: + A dict containing ``sessionId``, ``performanceRating``, + ``energyCreditPerformanceRating``, ``proposedBpf``, and + ``baselineBpf``. - results = { - field: getattr(compliance, field) - for field in ( - "complies", - "complianceMessage", - "complyMethod", - "hasPressureDropCredits", + Raises: + COMCheckSimulationError: If the result cannot be retrieved. + """ + result_response = self._service.get_simulation_result(session_id) + if result_response.data is None: + raise COMCheckSimulationError( + f"Failed to get simulation result for session {session_id}" ) - 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 result_response.data.model_dump(mode="python") - return project + def check_UA_compliance(self, project: ComBuilding) -> Any: + """Check UA path compliance for a 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. + Args: + project: The project data to evaluate. - For each credit in the fan system's ``pressureDropCredits`` list, - calculates the credit value and writes it back to - ``pressureDropCredit`` on that credit. + Returns: + The compliance results payload returned by the API. + """ + project_data = project.model_dump(mode="json") + response = self._service.check_UA_compliance(project_data) + return response.get("data") + + def check_requirements(self, project: ComBuilding) -> Any: + """Check the applicable requirements for a project. Args: - project: The project whose fan system should be updated. - fan_system_description: The description of the fan system to update. + project: The project data to evaluate. Returns: - The same ``project`` instance, with pressure drop credit values updated. - - Raises: - ValueError: If hvac is missing or the fan system is not found. + The requirements payload returned by the API. """ - from comcheck_api.types.core_types import MISSING - - if project.hvac is MISSING or not project.hvac: - raise ValueError("Project has no hvac data") + project_data = project.model_dump(mode="json") + response = self._service.check_requirements(project_data) + return response.get("data") - fan_system = None - for fs in project.hvac.fanSystem or []: - if fs.description == fan_system_description: - fan_system = fs - break + def generate_report( + self, + project: ComBuilding, + envelope: bool = True, + extlighting: bool = True, + intlighting: bool = True, + mechanical: bool = True, + use_backstop_compliance: bool = False, + download: bool = False, + download_dir: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """Generate a PDF report for a project. - if fan_system is None: - raise ValueError( - f"Fan system '{fan_system_description}' not found in project" - ) + The report PDF is stored in S3; the API returns a presigned URL to + download it. The URL is short-lived (expires within a few minutes). - energy_code = str(project.control.code) + This method does not open the report in a browser — as a library it + returns the report metadata and lets the caller decide what to do + (e.g. ``webbrowser.open(report["url"])``, stream it onward, or save it + via *download*). - 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 + Args: + project: The project data to generate a report for. + envelope: Whether to include the envelope section in the report. + extlighting: Whether to include the exterior lighting section. + intlighting: Whether to include the interior lighting section. + mechanical: Whether to include the mechanical section. + use_backstop_compliance: Whether to evaluate the report using + backstop compliance (sent as ``useBackstopCompliance``). + download: When ``True``, fetch the PDF from the presigned URL and + save it using the server-provided ``fileName``. + download_dir: Directory to save the PDF into when *download* is + ``True``. Defaults to the user's ``~/Downloads`` folder. - return project + Returns: + The report metadata dict with ``url`` (the presigned S3 URL), + ``expires``, and ``fileName``. + """ + report_data = { + "building": project.model_dump(mode="json"), + "envelope": envelope, + "extlighting": extlighting, + "intlighting": intlighting, + "mechanical": mechanical, + "useBackstopCompliance": use_backstop_compliance, + } + response = self._service.generate_report(report_data) + if response and download: + if download_dir is None: + download_dir = os.path.join(os.path.expanduser("~"), "Downloads") + os.makedirs(download_dir, exist_ok=True) + pdf_response = httpx.get(response["url"], timeout=60.0) + pdf_response.raise_for_status() + output_path = os.path.join(download_dir, response["fileName"]) + with open(output_path, "wb") as f: + f.write(pdf_response.content) + return response def close(self) -> None: """Close the API service connection.""" diff --git a/comcheck_api/defaults.py b/comcheck_api/defaults.py index a4f094a..6d6592f 100644 --- a/comcheck_api/defaults.py +++ b/comcheck_api/defaults.py @@ -1,6 +1,7 @@ """Module for project default templates/values.""" import copy +from typing import NoReturn from uuid import uuid4 from comcheck_api.constants.building_area_constants import DEFAULT_BUILDING_AREA @@ -34,11 +35,32 @@ ) from comcheck_api.constants.common_constants import PROJECT_TEMPLATE +from comcheck_api.types.common_types import ExteriorArea, InteriorSpace +from comcheck_api.types.core_types import ( + AgWall, + BgWall, + ComBuilding, + Door, + Fan, + FanSystem, + Fixture, + Floor, + HVACPlant, + HVACSystem, + OffsiteRenewableProcurement, + RenewableSystem, + Roof, + ServiceWaterHeatingSystem, + Skylight, + ThermalBridge, + WholeBldgUse, + Window, +) # from comcheck_api.constants.lighting_constants import DEFAULT_FIXTURE_SCHEDULE -def get_default_project_template(): +def get_default_project_template() -> ComBuilding: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.ComBuilding` template. The template is pre-populated with sensible defaults for location @@ -50,7 +72,7 @@ def get_default_project_template(): return copy.deepcopy(PROJECT_TEMPLATE) -def get_default_building_area_template(): +def get_default_building_area_template() -> WholeBldgUse: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.WholeBldgUse` template. Defaults to an *Automotive Facility* with 1 000 sq ft floor area and @@ -68,7 +90,7 @@ def get_default_building_area_template(): return area -def get_default_roof_template(): +def get_default_roof_template() -> Roof: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Roof` template. Defaults to an above-deck roof with 6 000 sq ft gross area and @@ -80,7 +102,7 @@ def get_default_roof_template(): return copy.deepcopy(DEFAULT_ROOF) -def get_default_ag_wall_template(): +def get_default_ag_wall_template() -> AgWall: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.AgWall` template. Defaults to a metal-frame 24-inch above-grade wall with 4 800 sq ft @@ -92,7 +114,7 @@ def get_default_ag_wall_template(): return copy.deepcopy(DEFAULT_AG_WALL) -def get_default_floor_template(): +def get_default_floor_template() -> Floor: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Floor` template. Defaults to a heated slab-on-grade floor with 320 sq ft gross area @@ -104,7 +126,7 @@ def get_default_floor_template(): return copy.deepcopy(DEFAULT_FLOOR) -def get_default_bg_wall_template(): +def get_default_bg_wall_template() -> BgWall: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.BgWall` template. Defaults to a concrete below-grade wall with 3 000 sq ft gross area, @@ -116,7 +138,7 @@ def get_default_bg_wall_template(): return copy.deepcopy(DEFAULT_BG_WALL) -def get_default_skylight_template(): +def get_default_skylight_template() -> Skylight: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Skylight` template. Defaults to a no-curb, double-pane low-E skylight with 100 sq ft @@ -128,7 +150,7 @@ def get_default_skylight_template(): return copy.deepcopy(DEFAULT_SKYLIGHT) -def get_default_window_template(): +def get_default_window_template() -> Window: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Window` template. Defaults to a triple-pane, non-operable, metal-frame window with @@ -140,7 +162,7 @@ def get_default_window_template(): return copy.deepcopy(DEFAULT_WINDOW) -def get_default_door_template(): +def get_default_door_template() -> Door: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Door` template. Defaults to a swinging, insulated metal entrance door with 23 sq ft @@ -152,7 +174,7 @@ def get_default_door_template(): return copy.deepcopy(DEFAULT_DOOR) -def get_default_thermal_bridge_template(): +def get_default_thermal_bridge_template() -> ThermalBridge: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.ThermalBridge` template. Defaults to a prescriptive linear floor-to-wall intersection thermal @@ -164,7 +186,7 @@ def get_default_thermal_bridge_template(): return copy.deepcopy(DEFAULT_THERMAL_BRIDGE) -def get_default_interior_space_template(): +def get_default_interior_space_template() -> InteriorSpace: """Return a deep copy of the default interior lighting space template. In the COMcheck API schema, an interior lighting space is represented by @@ -182,7 +204,7 @@ def get_default_interior_space_template(): return copy.deepcopy(DEFAULT_INTERIOR_SPACE_AREA) -def get_default_fixture_template(): +def get_default_fixture_template() -> Fixture: """Return a deep copy of the default :class:`~comcheck_api.types.core_types.Fixture` template. Defaults to an LED fixture at 32 W, quantity 1, with no lighting controls. @@ -193,7 +215,7 @@ def get_default_fixture_template(): return copy.deepcopy(DEFAULT_FIXTURE) -def get_default_exterior_area_template(): +def get_default_exterior_area_template() -> ExteriorArea: """Return a deep copy of the default exterior lighting area template. In the COMcheck API schema, an exterior lighting area is represented by @@ -210,7 +232,7 @@ def get_default_exterior_area_template(): return copy.deepcopy(DEFAULT_EXTERIOR_AREA) -def get_default_fixture_schedule_template(): +def get_default_fixture_schedule_template() -> NoReturn: """Return a default fixture schedule template with a unique key. .. warning:: @@ -221,7 +243,7 @@ def get_default_fixture_schedule_template(): raise NotImplementedError("DEFAULT_FIXTURE_SCHEDULE constant not yet implemented") -def get_default_renewable_system_template(): +def get_default_renewable_system_template() -> RenewableSystem: """Return a deep copy of the default renewable system template. Defaults to a Solar PV system with 0.0 capacity in Watts. @@ -232,7 +254,7 @@ def get_default_renewable_system_template(): return copy.deepcopy(DEFAULT_RENEWABLE_SYSTEM) -def get_default_offsite_renewable_procurement_template(): +def get_default_offsite_renewable_procurement_template() -> OffsiteRenewableProcurement: """Return a deep copy of the default offsite renewable procurement template. Defaults to an unspecified procurement type with 0.0 kWh annual energy. @@ -243,7 +265,7 @@ def get_default_offsite_renewable_procurement_template(): return copy.deepcopy(DEFAULT_OFFSITE_RENEWABLE_PROCUREMENT) -def get_default_hvac_system_template(): +def get_default_hvac_system_template() -> HVACSystem: """Return a deep copy of the default HVAC system template. Defaults to an unknown cooling and heating equipment with no economizer, @@ -255,7 +277,7 @@ def get_default_hvac_system_template(): return copy.deepcopy(DEFAULT_HVAC_SYSTEM) -def get_default_fan_system_template(): +def get_default_fan_system_template() -> FanSystem: """Return a deep copy of the default fan system template. Defaults to motor nameplate HP compliance method with no fans and no @@ -267,7 +289,7 @@ def get_default_fan_system_template(): return copy.deepcopy(DEFAULT_FAN_SYSTEM) -def get_default_fan_template(): +def get_default_fan_template() -> Fan: """Return a deep copy of the default fan template. Defaults to an unspecified fan type with 0.0 volume and no efficiency data. @@ -278,7 +300,7 @@ def get_default_fan_template(): return copy.deepcopy(DEFAULT_FAN) -def get_default_hvac_plant_template(): +def get_default_hvac_plant_template() -> HVACPlant: """Return a deep copy of the default HVAC plant template. Defaults to a heating plant with hot water boiler (100 kBtu/hr capacity) @@ -290,7 +312,7 @@ def get_default_hvac_plant_template(): return copy.deepcopy(DEFAULT_HVAC_PLANT) -def get_default_water_heater_template(): +def get_default_water_heater_template() -> ServiceWaterHeatingSystem: """Return a deep copy of the default service water heating system template. Defaults to a storage water heater with unknown fuel type, 1 quantity, diff --git a/comcheck_api/introspection.py b/comcheck_api/introspection.py index 3f637d9..2ee5ef2 100644 --- a/comcheck_api/introspection.py +++ b/comcheck_api/introspection.py @@ -36,8 +36,8 @@ "interior_lighting": project_interior_lighting_operations, "exterior_lighting": project_exterior_lighting_operations, "mechanical": project_mechanical_operations, - "renewable": project_renewable_operations, "credits": project_credit_operations, + "renewable": project_renewable_operations, } @@ -64,12 +64,14 @@ class FieldSchema(BaseModel): class EnumMember(BaseModel): + """One member of an enum: its Python name and its value.""" + name: str value: str class TypeSchema(BaseModel): - """Reflected schema of a Pydantic model or StrEnum.""" + """Reflected schema of a Pydantic model or enum.""" name: str kind: Literal["model", "enum"] @@ -82,7 +84,10 @@ def list_operations() -> list[OperationInfo]: """Discover public functions in the project operation modules. Discovered live via :mod:`inspect` so the list always matches the - installed SDK version. + installed SDK version. Groups are returned in the order + ``building_area``, ``envelope``, ``interior_lighting``, + ``exterior_lighting``, ``mechanical``, ``credits``, ``renewable``. + ``COMcheckClient`` methods (API calls) are not included. """ out: list[OperationInfo] = [] for group, mod in _OP_MODULES.items(): diff --git a/comcheck_api/project_operations/project_envelope_operations.py b/comcheck_api/project_operations/project_envelope_operations.py index 9ecf5f9..485a3ab 100644 --- a/comcheck_api/project_operations/project_envelope_operations.py +++ b/comcheck_api/project_operations/project_envelope_operations.py @@ -201,7 +201,7 @@ def add_bg_wall_to_project( def update_bg_wall_in_project( project: ComBuilding, bg_wall_assembly_type: str, updates: dict[str, Any] | BgWall ) -> ComBuilding: - """Update an bgWall in the project's envelope. + """Update a bgWall in the project's envelope. Args: project: The project object to modify @@ -225,7 +225,7 @@ def update_bg_wall_in_project( def remove_bg_wall_from_project( project: ComBuilding, bg_wall_assembly_type: str ) -> ComBuilding: - """Remove an bgWall from the envelope in any project object. + """Remove a bgWall from the envelope in any project object. Args: project: The project object to modify @@ -275,7 +275,7 @@ def add_floor_to_project( def update_floor_in_project( project: ComBuilding, floor_assembly_type: str, updates: dict[str, Any] | Floor ) -> ComBuilding: - """Update an floor in the project's envelope. + """Update a floor in the project's envelope. Args: project: The project object to modify @@ -299,7 +299,7 @@ def update_floor_in_project( def remove_floor_from_project( project: ComBuilding, floor_assembly_type: str ) -> ComBuilding: - """Remove an bgWall from the envelope in any project object. + """Remove a floor from the envelope in any project object. Args: project: The project object to modify @@ -378,17 +378,20 @@ def remove_skylight_from_project( project: ComBuilding, skylight_assembly_type: str, ) -> ComBuilding: - """Add a new skylight to a specific roof in the envelope. + """Remove a skylight from the project. + + Looks for the skylight on the roofs, or in the orphaned + ``envelope.skylight`` list for alteration projects. Args: - project: ComBuilding, - skylight_assembly_type: str + project: The project object to modify + skylight_assembly_type: The assemblyType of the skylight to remove Returns: - Updated project object with the skylight added to the specified roof + Updated project object with the skylight removed Raises: - ValueError: If buildingAreaKey is not found or if roof's bldgUseKey doesn't match buildingAreaKey + ValueError: If no skylight with the given assemblyType is found """ updated_project = project.model_copy(deep=True) diff --git a/comcheck_api/project_operations/project_mechanical_operations.py b/comcheck_api/project_operations/project_mechanical_operations.py index e24703b..35f6ad0 100644 --- a/comcheck_api/project_operations/project_mechanical_operations.py +++ b/comcheck_api/project_operations/project_mechanical_operations.py @@ -483,6 +483,11 @@ def update_fan_in_fan_system( ) -> ComBuilding: """Update an existing fan in a fan system. + Changing ``designBrakeHp`` clears ``maxNameplateHp`` so a stale value + isn't kept, unless the updates set ``maxNameplateHp`` too; recalculate it + with :meth:`~comcheck_api.client.COMcheckClient.update_fan_maximum_allowable_hp`. + Engine-calculated fan fields can be updated: compliance recalculates them. + Args: project: The project to modify. fan_system_description: The description of the fan system. @@ -497,18 +502,29 @@ def update_fan_in_fan_system( """ _require_fan_system(project, fan_system_description) + updates = _updates_to_dict(updates) + fans = _find_by_description(project.hvac.fanSystem, fan_system_description).fan + current_fan = _find_by_description(fans if fans is not MISSING else [], fan_description) updated_project = project.model_copy(deep=True) + fan_system = _find_by_description(updated_project.hvac.fanSystem, fan_system_description) + fan_system.update_subcomponent_list( + subcomponent_updates=updates, + subcomponent_id=fan_description, + subcomponent_name="fan", + ) - # 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 + if ( + current_fan is not None + and "designBrakeHp" in updates + and "maxNameplateHp" not in updates + ): + before = current_fan.model_dump(mode="json").get("designBrakeHp") + updated_fan = _find_by_description( + fan_system.fan, updates.get("description", fan_description) + ) + after = updated_fan.model_dump(mode="json").get("designBrakeHp") + if after != before: + updated_fan.maxNameplateHp = MISSING return updated_project diff --git a/comcheck_api/validation.py b/comcheck_api/validation.py index c9e3d82..7b93b9d 100644 --- a/comcheck_api/validation.py +++ b/comcheck_api/validation.py @@ -42,6 +42,11 @@ def validate_project(data: dict[str, Any] | ComBuilding) -> ValidationResult: Accepts a dict or an existing ``ComBuilding`` (round-trips through ``model_dump`` so the same code path runs in both cases). + + This checks the data against the schema only (field types, required + fields, enum values). Rules the project operations enforce — such as + unique descriptions or a ``fanSystemKey`` that points to an existing fan + system — are not checked. """ payload = data.model_dump() if isinstance(data, ComBuilding) else data try: diff --git a/docs_site/api/client.md b/docs_site/api/client.md index 0e36197..fa731d0 100644 --- a/docs_site/api/client.md +++ b/docs_site/api/client.md @@ -2,4 +2,160 @@ The primary interface for interacting with the COMcheck Web API. +Use the client to load and save projects and to call COMcheck calculations. +To build or edit a project locally, use the +[project operations](operations/building-area.md) modules, then save the +result with `update_project`. + +Methods that end with "update the project in place" write the calculated +values back onto the `project` you pass in and return that same instance. + +Exterior lighting has no client calculation methods — use +[`project_exterior_lighting_operations`](operations/exterior-lighting.md). + ::: comcheck_api.client.comcheck_client.COMcheckClient + options: + members: false + show_root_heading: false + +## Projects + +::: comcheck_api.client.comcheck_client.COMcheckClient.set_api_key + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.get_project + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.list_projects + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.update_project + options: + heading_level: 3 + +## Envelope + +See also: [Envelope operations](operations/envelope.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.update_uvalues + options: + heading_level: 3 + +## Interior Lighting + +See also: [Interior Lighting operations](operations/interior-lighting.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_interior_space_allowed_wattage + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_interior_spaces_allowed_wattage + options: + heading_level: 3 + +## Mechanical + +See also: [Mechanical operations](operations/mechanical.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_hvac_system_efficiency + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.validate_hvac_system_economizer + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_fan_pressure_drop_credit + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.check_fan_system_compliance + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_fan_maximum_allowable_hp + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.update_fan_system_compliance + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.update_fan_pressure_drop_credits + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.update_fan_maximum_allowable_hp + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_plant_efficiency + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_water_heater_efficiency + options: + heading_level: 3 + +## Credits + +See also: [Credits operations](operations/credits.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_energy_credits + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.calculate_single_energy_credit + options: + heading_level: 3 + +## Renewable Energy + +See also: [Renewable Energy operations](operations/renewable-energy.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.area_for_renewable + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.renewable_capacity + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.set_renewable_exception + options: + heading_level: 3 + +## Simulation + +See also: [Simulation](simulation.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.start_run_simulation + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.get_simulation_status + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.get_simulation_result + options: + heading_level: 3 + +## Compliance & Reports + +See also: [Compliance & Reports](compliance.md). + +::: comcheck_api.client.comcheck_client.COMcheckClient.check_UA_compliance + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.check_requirements + options: + heading_level: 3 + +::: comcheck_api.client.comcheck_client.COMcheckClient.generate_report + options: + heading_level: 3 diff --git a/docs_site/api/defaults.md b/docs_site/api/defaults.md index 024a3e9..fe527ca 100644 --- a/docs_site/api/defaults.md +++ b/docs_site/api/defaults.md @@ -1,6 +1,139 @@ # Project Defaults Functions that return deep copies of pre-configured default templates for -projects and building components. +projects and building components. Each call returns a new copy, so customize +it freely and pass it to the matching `add_*` operation. -::: comcheck_api.defaults +```python +from comcheck_api.defaults import get_default_roof_template + +roof = get_default_roof_template() +roof.grossArea = 8000.0 +project = project_envelope_operations.add_roof_to_project( + project, building_area_key=area_key, new_roof=roof +) +``` + +## Project + +::: comcheck_api.defaults.get_default_project_template + options: + heading_level: 3 + +## Building Area + +Pass to `add_building_area_to_project`. Each call gets a fresh `key` and a unique `areaDescription`. + +See [Building Area operations](operations/building-area.md). + +::: comcheck_api.defaults.get_default_building_area_template + options: + heading_level: 3 + +## Envelope + +Pass to the matching `add_*_to_project` function in `project_envelope_operations`. `add_thermal_bridge_to_project` is the exception: it takes the thermal bridge values as arguments (`psi_factor`, `thermal_bridge_length`, ...) rather than a `ThermalBridge`, so use the template as a reference for those values. + +See [Envelope operations](operations/envelope.md). + +::: comcheck_api.defaults.get_default_roof_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_ag_wall_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_bg_wall_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_floor_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_window_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_door_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_skylight_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_thermal_bridge_template + options: + heading_level: 3 + +## Interior Lighting + +Pass the interior space to `add_interior_space_to_project`. Fixtures go in `interiorLightingSpace.fixture[]` or through `update_fixtures_in_interior_space`; the same fixture template is used for [exterior lighting](#exterior-lighting). + +See [Interior Lighting operations](operations/interior-lighting.md). + +::: comcheck_api.defaults.get_default_interior_space_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_fixture_template + options: + heading_level: 3 + +## Exterior Lighting + +Pass to `add_exterior_area_to_project`. Use `get_default_fixture_template` (above) for its fixtures. + +See [Exterior Lighting operations](operations/exterior-lighting.md). + +::: comcheck_api.defaults.get_default_exterior_area_template + options: + heading_level: 3 + +## Mechanical + +Pass to `add_hvac_system_to_project`, `add_fan_system_to_project`, `add_fan_to_fan_system`, `add_hvac_plant_to_project`, and `add_water_heater_to_project`. Give each one a unique `description` — mechanical components are identified by it. + +See [Mechanical operations](operations/mechanical.md). + +::: comcheck_api.defaults.get_default_hvac_system_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_fan_system_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_fan_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_hvac_plant_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_water_heater_template + options: + heading_level: 3 + +## Credits + +There is no credit template — build an `EfficiencyPackage` with the credit `type` directly and pass it to `add_energy_credit_to_project`. + +See [Credits operations](operations/credits.md). + +## Renewable Energy + +Pass to `add_renewable_system_to_project` and `add_offsite_renewable_procurement_to_project`. + +See [Renewable Energy operations](operations/renewable-energy.md). + +::: comcheck_api.defaults.get_default_renewable_system_template + options: + heading_level: 3 + +::: comcheck_api.defaults.get_default_offsite_renewable_procurement_template + options: + heading_level: 3 diff --git a/docs_site/api/introspection.md b/docs_site/api/introspection.md index ad655fd..760af19 100644 --- a/docs_site/api/introspection.md +++ b/docs_site/api/introspection.md @@ -25,6 +25,18 @@ if not result.ok: print(err.loc, err.msg) ``` +- `list_operations` covers the project operation modules, grouped as + `building_area`, `envelope`, `interior_lighting`, `exterior_lighting`, + `mechanical`, `credits`, and `renewable`. `COMcheckClient` methods (API + calls) are not included — see [COMcheckClient](client.md). +- `lookup_type` accepts any model or enum name from `comcheck_api.types`, + including the `InteriorSpace` and `ExteriorArea` aliases. The match is + case-insensitive; it returns `None` if nothing matches. +- `validate_project` checks the schema only (field types, required fields, + enum values). Rules the project operations enforce, such as unique + descriptions or a `fanSystemKey` that points to an existing fan system, are + not checked. + ## Introspection ::: comcheck_api.introspection diff --git a/docs_site/api/operations/building-area.md b/docs_site/api/operations/building-area.md index e5e58f8..01f4e29 100644 --- a/docs_site/api/operations/building-area.md +++ b/docs_site/api/operations/building-area.md @@ -1,5 +1,87 @@ # Building Area Operations -Functions for adding, updating, and removing building areas in a COMcheck project. +Building areas are stored in `lighting.wholeBldgUse[]`, one `WholeBldgUse` +per area. A building area is the parent of envelope components, interior +lighting spaces, and energy credits, so add one before adding any of those. -::: comcheck_api.project_operations.project_building_area_operations +```python +from comcheck_api import project_building_area_operations as ba_ops +``` + +## Key concepts + +- **Building areas are identified by `key`.** Update and remove operations + find a building area by its `key`. Other components point to it through + `bldgUseKey` (envelope components, energy credits). Interior spaces use + `key` instead. +- **`areaDescription` must be unique** within `lighting.wholeBldgUse[]`. The + check is case-sensitive. Adding or renaming an area to a description that's + already used raises a `ValueError`. +- **Use the default template for new areas.** + `get_default_building_area_template()` returns an area with a new unique + `key` and `areaDescription` every time you call it. +- **`interiorLightingSpace` is filled in for you.** If it's `None` when you + add the area, it's set from the default template. +- **Removing a building area also removes its energy credits** + (`efficiencyPackages` entries with a matching `bldgUseKey`). + +## Operations + +| Function | Description | +|---|---| +| `add_building_area_to_project(project, new_building_area)` | Add a new building area | +| `update_building_area_in_project(project, building_area_key, updates)` | Update an existing building area | +| `remove_building_area_from_project(project, building_area_key)` | Remove a building area and its energy credits | +| `get_building_area_keys_from_project(project)` | List the `key` and `areaDescription` of all building areas | + +## Adding a building area + +```python +from comcheck_api.defaults import get_default_building_area_template +from comcheck_api.types import WholeBuildingTypeOptions + +area = get_default_building_area_template() +area.areaDescription = "Office Area" +area.wholeBldgType = WholeBuildingTypeOptions.WHOLE_BUILDING_OFFICE +area.floorArea = 10000.0 + +project = ba_ops.add_building_area_to_project(project, area) +area_key = area.key +``` + +## Updating a building area + +Pass only the fields you want to change. Fields you don't pass keep their +values: + +```python +project = ba_ops.update_building_area_in_project( + project, + area_key, + {"floorArea": 12000.0, "areaDescription": "Office Area - Level 1"}, +) +``` + +Raises `ValueError` if `building_area_key` is not found, or if the new +`areaDescription` already belongs to another building area. + +## Removing a building area + +```python +project = ba_ops.remove_building_area_from_project(project, area_key) +``` + +## Listing building areas + +```python +keys = ba_ops.get_building_area_keys_from_project(project) +# [{"key": "3f2c...", "areaDescription": "Office Area"}, ...] +``` + +See `examples/project_operations/building_area_operations.py` for a full walkthrough. + +## Related + +- [Types Guide — Building Area](../../types-guide.md#building-area) — `WholeBldgUse`, `WholeBuildingTypeOptions` +- [Envelope](envelope.md), [Interior Lighting](interior-lighting.md), [Credits](credits.md) — components that belong to a building area +- [Project Defaults](../defaults.md) — `get_default_building_area_template` diff --git a/docs_site/api/operations/credits.md b/docs_site/api/operations/credits.md index 2c70742..3efbc07 100644 --- a/docs_site/api/operations/credits.md +++ b/docs_site/api/operations/credits.md @@ -1,20 +1,28 @@ # Credits Operations -Energy credits are stored in the flat `ComBuilding.efficiencyPackages` list. -Each `EfficiencyPackage` entry: +Energy credits are stored in the flat `ComBuilding.efficiencyPackages` list, +one `EfficiencyPackage` per credit. -- belongs to one building area — `bldgUseKey` is the `WholeBldgUse.key` -- is identified by its `type` — a building area holds at most one entry per credit type -- carries the inputs its credit type needs, e.g. `airInfiltration` for the - reduced air infiltration credits or `onSiteRenewableCapacity` for on-site - renewable credits (see the field descriptions on `EfficiencyPackage`) - -Two project-level fields also affect credits: +```python +from comcheck_api import project_credit_operations as cr_ops +``` -- `energyCreditMultiplierException` — the IECC 2024 Appendix C/F energy credit - multiplier exception (new construction and additions) -- `projectCoreAndShellCredit` — credits gained from core and shell (initial - build-out with a core and shell mechanical type) +## Key concepts + +- **Credits belong to a building area.** `bldgUseKey` is the building area's + `WholeBldgUse.key`. Removing the building area removes its credits. +- **Credits are identified by `type`.** A building area holds at most one + credit of each type. Update and remove operations take the building area + key and the credit type. +- **Each credit type needs its own inputs.** For example, the reduced air + infiltration credits need `airInfiltration`, and the on-site renewable + credits need `onSiteRenewableCapacity`. See the field descriptions on + `EfficiencyPackage`. +- **Two project-level fields also affect credits.** + - `energyCreditMultiplierException` — the IECC 2024 Appendix C/F energy + credit multiplier exception (new construction and additions) + - `projectCoreAndShellCredit` — credits gained from core and shell + (initial build-out with a core and shell mechanical type) ## Energy codes @@ -35,10 +43,6 @@ up to you. Older codes that use additional efficiency packages instead ## Operations -```python -from comcheck_api import project_credit_operations as cr_ops -``` - | Function | Description | |---|---| | `add_energy_credit_to_project(project, building_area_key, new_energy_credit)` | Add an energy credit to a building area | @@ -48,15 +52,10 @@ from comcheck_api import project_credit_operations as cr_ops | `set_energy_credit_multiplier_exception_in_project(project, multiplier_exception)` | Set `energyCreditMultiplierException` | | `set_core_and_shell_credit_in_project(project, core_and_shell_credit)` | Set `projectCoreAndShellCredit` | -Removing a building area with -`project_building_area_operations.remove_building_area_from_project` also -removes its energy credits. - ## Adding and updating credits ```python -from comcheck_api import project_credit_operations as cr_ops -from comcheck_api.types.core_types import ( +from comcheck_api.types import ( EfficiencyPackage, IECC2024EnergyCreditTypeOptions as Credit, ) @@ -89,7 +88,7 @@ project = cr_ops.update_energy_credit_in_project( `type` and `bldgUseKey` can't be updated — remove the credit and add a new one. -## Calculating credits +## Calculating credits (API) `COMcheckClient.calculate_energy_credits` sends the project to `POST /update-energy-credits` and returns an `EnergyCreditResult`. The project @@ -121,7 +120,7 @@ print("Meets required credits:", result.passes) `passes` is derived from the totals. The compliance check (`COMcheckClient.check_UA_compliance`) reports the official energy credit status. -## Calculating a single credit +## Calculating a single credit (API) Credits can interact, so the full calculation doesn't always show what one credit is worth on its own. `COMcheckClient.calculate_single_energy_credit` @@ -150,3 +149,11 @@ value (`proposedRenewableAndLoadMgmt` for renewable and load management credits), weighted by the building area's share of the gross floor area. Check `warnings` for missing inputs. Raises `ValueError` if the building area is not found. + +See `examples/client/credits.py` for a full walkthrough. + +## Related + +- [Types Guide — Credits](../../types-guide.md#credits) — `EfficiencyPackage` and the credit type enums +- [Building Area](building-area.md) — credits belong to a building area +- [COMcheckClient — Credits](../client.md#credits) — `calculate_energy_credits`, `calculate_single_energy_credit` diff --git a/docs_site/api/operations/envelope.md b/docs_site/api/operations/envelope.md index b7a6fb8..3f00520 100644 --- a/docs_site/api/operations/envelope.md +++ b/docs_site/api/operations/envelope.md @@ -1,9 +1,193 @@ # Envelope Operations -Functions for adding, updating, and removing envelope components -(roofs, walls, floors, windows, doors, skylights, and thermal bridges) -in a COMcheck project. +Envelope components are stored under `ComBuilding.envelope`. Roofs, above- +and below-grade walls, and floors sit in `envelope.roof[]`, `agWall[]`, +`bgWall[]`, and `floor[]`. Windows, doors, skylights, and thermal bridges +are nested inside their parent wall or roof. -::: comcheck_api.project_operations.project_envelope_operations - options: - show_if_no_docstring: false +```python +from comcheck_api import project_envelope_operations as env_ops +``` + +## Key concepts + +- **Roofs, walls, and floors belong to a building area.** Pass the building + area's `key` as `building_area_key` when you add one. It's written to the + component's `bldgUseKey`. +- **Windows and doors go on a wall, and skylights go on a roof.** Pass the + parent as `wall=` (an `AgWall` or `BgWall`) or `roof=`. A parent is + required for non-alteration projects, and its `bldgUseKey` must match + `building_area_key`. In alteration projects (`ProjectTypeOptions.ALTERATION`) + these components are added without a parent, directly to + `envelope.window[]`, `envelope.door[]`, or `envelope.skylight[]`. +- **Thermal bridges belong to an above-grade wall.** They can't exist + without a parent. +- **Components are identified by `assemblyType`.** Update and remove + operations find a component by its `assemblyType`. If it's missing or + already used, a unique value is generated (e.g. `"Roof:Roof 1"`, + `"AgWall:Ext Wall 1"`, `"Window:Window 1"`). Window, door, and skylight + operations search every wall or roof, so you don't pass the parent. +- **A building area must exist first.** Add one with + [`add_building_area_to_project`](building-area.md). + +## Operations + +| Function | Description | +|---|---| +| `add_roof_to_project(project, building_area_key, new_roof)` | Add a roof to a building area | +| `update_roof_in_project(project, roof_assembly_type, updates)` | Update an existing roof | +| `remove_roof_from_project(project, roof_assembly_type)` | Remove a roof | +| `add_ag_wall_to_project(project, building_area_key, new_ag_wall)` | Add an above-grade wall to a building area | +| `update_ag_wall_in_project(project, ag_wall_assembly_type, updates)` | Update an existing above-grade wall | +| `remove_ag_wall_from_project(project, ag_wall_assembly_type)` | Remove an above-grade wall | +| `add_bg_wall_to_project(project, building_area_key, new_bg_wall)` | Add a below-grade wall to a building area | +| `update_bg_wall_in_project(project, bg_wall_assembly_type, updates)` | Update an existing below-grade wall | +| `remove_bg_wall_from_project(project, bg_wall_assembly_type)` | Remove a below-grade wall | +| `add_floor_to_project(project, building_area_key, new_floor)` | Add a floor to a building area | +| `update_floor_in_project(project, floor_assembly_type, updates)` | Update an existing floor | +| `remove_floor_from_project(project, floor_assembly_type)` | Remove a floor | +| `add_window_to_project(project, building_area_key, new_window, wall=None)` | Add a window to a wall | +| `update_window_in_project(project, window_assembly_type, updates)` | Update an existing window | +| `remove_window_from_project(project, window_assembly_type)` | Remove a window | +| `add_door_to_project(project, building_area_key, new_door, wall=None)` | Add a door to a wall | +| `update_door_in_project(project, door_assembly_type, updates)` | Update an existing door | +| `remove_door_from_project(project, door_assembly_type)` | Remove a door | +| `add_skylight_to_project(project, building_area_key, new_skylight, roof=None)` | Add a skylight to a roof | +| `update_skylight_in_project(project, skylight_assembly_type, updates)` | Update an existing skylight | +| `remove_skylight_from_project(project, skylight_assembly_type)` | Remove a skylight | +| `add_thermal_bridge_to_project(project, building_area_key, ag_wall, ...)` | Add a thermal bridge to an above-grade wall | + +## Adding roofs, walls, and floors + +```python +from comcheck_api.defaults import ( + get_default_roof_template, + get_default_ag_wall_template, + get_default_floor_template, +) + +area_key = project.lighting.wholeBldgUse[0].key + +roof = get_default_roof_template() +roof.assemblyType = "Roof:Main Roof" +project = env_ops.add_roof_to_project(project, area_key, roof) + +wall = get_default_ag_wall_template() +wall.assemblyType = "AgWall:North Wall" +project = env_ops.add_ag_wall_to_project(project, area_key, wall) + +floor = get_default_floor_template() +project = env_ops.add_floor_to_project(project, area_key, floor) +``` + +`add_bg_wall_to_project` works the same way with +`get_default_bg_wall_template()`. + +## Adding windows, doors, and skylights + +Pass the parent component from the project as `wall=` or `roof=`: + +```python +from comcheck_api.defaults import ( + get_default_window_template, + get_default_door_template, + get_default_skylight_template, +) + +north_wall = next( + w for w in project.envelope.agWall if w.assemblyType == "AgWall:North Wall" +) +main_roof = next( + r for r in project.envelope.roof if r.assemblyType == "Roof:Main Roof" +) + +window = get_default_window_template() +window.assemblyType = "Window:North Window" +project = env_ops.add_window_to_project(project, area_key, window, wall=north_wall) + +door = get_default_door_template() +project = env_ops.add_door_to_project(project, area_key, door, wall=north_wall) + +skylight = get_default_skylight_template() +project = env_ops.add_skylight_to_project(project, area_key, skylight, roof=main_roof) +``` + +Raises `ValueError` if the parent is missing in a non-alteration project, or +if its `bldgUseKey` doesn't match `building_area_key`. + +## Adding a thermal bridge + +`add_thermal_bridge_to_project` takes the thermal bridge inputs as keyword +arguments instead of a model: + +```python +from comcheck_api.types import ThermalBridgeTypeOptions + +project = env_ops.add_thermal_bridge_to_project( + project, + area_key, + north_wall, + thermal_bridge_type=ThermalBridgeTypeOptions.THERMAL_BRIDGE_OTHER, + psi_factor=0.2, + thermal_bridge_length=100.0, +) +``` + +| Argument | Default | +|---|---| +| `thermal_bridge_type` | `ThermalBridgeTypeOptions.THERMAL_BRIDGE_OTHER` | +| `thermal_bridge_category` | `ThermalBridgeCategoryOptions.THERMAL_BRIDGE_UNCATEGORIZED` | +| `thermal_bridge_compliance_type` | `ThermalBridgeComplianceTypeOptions.THERMAL_BRIDGE_NON_PRESCRIPTIVE` | +| `psi_factor`, `chi_factor`, `thermal_bridge_length` | `0.0` | +| `number_of_points` | `0` | + +Raises `ValueError` if the wall isn't in the project or its `bldgUseKey` +doesn't match `building_area_key`. + +## Updating components + +Pass only the fields you want to change. Fields you don't pass keep their +values: + +```python +project = env_ops.update_roof_in_project( + project, "Roof:Main Roof", {"grossArea": 8000.0} +) +project = env_ops.update_window_in_project( + project, "Window:North Window", {"grossArea": 250.0} +) +``` + +Window, door, and skylight updates raise `ValueError` if no component with +that `assemblyType` is found. + +## Removing components + +```python +project = env_ops.remove_window_from_project(project, "Window:North Window") +project = env_ops.remove_ag_wall_from_project(project, "AgWall:North Wall") +``` + +Removing a wall or roof also removes the windows, doors, skylights, and +thermal bridges on it. + +## Calculating U-values (API) + +`COMcheckClient.update_uvalues` sends the envelope to the COMcheck Web API. +It writes the calculated U-values back onto the matching `agWall`, `bgWall`, +`roof`, and `floor` components (matched by `assemblyType`). It updates the +project in place and returns it: + +```python +project = client.update_uvalues(project) +print(project.envelope.agWall[0].propUValue) +``` + +See `examples/project_operations/envelope_operations.py` for a full walkthrough. + +## Related + +- [Types Guide — Envelope Components](../../types-guide.md#envelope-components) — `Roof`, `AgWall`, `BgWall`, `Floor`, `Window`, `Door`, `Skylight`, `ThermalBridge` +- [Building Area](building-area.md) — add the parent building area first +- [Project Defaults](../defaults.md) — default templates for every envelope component +- [COMcheckClient — Envelope](../client.md#envelope) — `update_uvalues` diff --git a/docs_site/api/operations/exterior-lighting.md b/docs_site/api/operations/exterior-lighting.md index 4d4f110..0b0ab95 100644 --- a/docs_site/api/operations/exterior-lighting.md +++ b/docs_site/api/operations/exterior-lighting.md @@ -1,14 +1,20 @@ # Exterior Lighting Operations -Exterior lighting is managed at the **`ExteriorArea`** granularity. In the -COMcheck API schema, an exterior lighting area is represented by the -`ExteriorUse` model, aliased as `ExteriorArea`. Each `ExteriorArea` lives -directly under `lighting.exteriorUse[]` (no parent building area needed) and -carries exactly one singleton `ExteriorLightingSpace` whose `fixture[]` holds -the fixtures. +Exterior lighting areas are stored in `lighting.exteriorUse[]`, one +`ExteriorArea` per area. They don't need a parent building area. Each area +holds one `ExteriorLightingSpace`, whose `fixture[]` list holds its fixtures. +The project-wide zone type is `lighting.exteriorLightingZoneType`. + +```python +from comcheck_api import project_exterior_lighting_operations as el_ops +``` ## Key concepts +- **`ExteriorArea` is an alias of `ExteriorUse`.** The schema name is + `ExteriorUse`. These operations use the `ExteriorArea` alias so it isn't + confused with `InteriorSpace` (see the + [Types Guide](../../types-guide.md#exterior-lighting)). - **Fixtures are batch-edited by `fixtureType`.** Use `update_fixtures_in_exterior_area` to add, update, and/or remove fixtures on an area in one call — fixtures are matched by `fixtureType` (the @@ -16,28 +22,17 @@ the fixtures. not `id`. You can still edit `exteriorLightingSpace.fixture[]` directly and pass the whole `ExteriorArea` through `update_exterior_area_in_project` if you prefer. - -## Zone type - -Exterior compliance requires a real zone type on -`lighting.exteriorLightingZoneType`. Set it with -`set_exterior_lighting_zone_type_in_project` **before** exterior compliance -can be evaluated. Adding an `ExteriorArea` while the zone is still -`EXT_ZONE_UNSPECIFIED` emits a `UserWarning` (not an error) so you can build -up a project incrementally. - -## areaDescription uniqueness - -`areaDescription` must be unique within `lighting.exteriorUse[]`. It is the -identifier used by update and remove operations. If missing, a unique value is -auto-generated with the prefix `"Ext Area"`. +- **`areaDescription` must be unique** within `lighting.exteriorUse[]`. It is + the identifier used by update and remove operations. If missing, a unique + value is auto-generated with the prefix `"Ext Area"`. +- **Set the zone type before checking compliance.** Exterior compliance + requires a real zone type on `lighting.exteriorLightingZoneType`. Set it + with `set_exterior_lighting_zone_type_in_project`. Adding an `ExteriorArea` + while the zone is still `EXT_ZONE_UNSPECIFIED` emits a `UserWarning` (not an + error), so you can build up a project incrementally. ## Operations -```python -from comcheck_api import project_exterior_lighting_operations as el_ops -``` - | Function | Description | |---|---| | `set_exterior_lighting_zone_type_in_project(project, zone_type)` | Set the project-level exterior lighting zone type (rejects `EXT_ZONE_UNSPECIFIED`) | @@ -50,8 +45,7 @@ from comcheck_api import project_exterior_lighting_operations as el_ops ## Setting the zone type ```python -from comcheck_api import project_exterior_lighting_operations as el_ops -from comcheck_api.types.core_types import ExteriorLightingZoneTypeOptions +from comcheck_api.types import ExteriorLightingZoneTypeOptions project = el_ops.set_exterior_lighting_zone_type_in_project( project, ExteriorLightingZoneTypeOptions.EXT_ZONE_NEIGHBORHOOD_BUS_DISTRICT @@ -65,7 +59,7 @@ a `TypeError` — always use the enum. ```python from comcheck_api.defaults import get_default_exterior_area_template, get_default_fixture_template -from comcheck_api.types.core_types import ExteriorUseTypeOptions +from comcheck_api.types import ExteriorUseTypeOptions # fixtureType is the required identifier (a description string); lightingType # is optional and marked for deprecation. @@ -162,3 +156,10 @@ project = el_ops.remove_exterior_area_from_project(project, "Main Parking Area") keys = el_ops.get_exterior_area_keys_from_project(project) # [{"areaDescription": "Main Parking Area", "exteriorType": "EXTERIOR_PARKING_AREA"}, ...] ``` + +See `examples/project_operations/exterior_lighting_operations.py` for a full walkthrough. + +## Related + +- [Types Guide — Exterior Lighting](../../types-guide.md#exterior-lighting) — `ExteriorArea` (`ExteriorUse`), `Fixture`, `ExteriorLightingZoneTypeOptions` +- [Project Defaults](../defaults.md) — `get_default_exterior_area_template`, `get_default_fixture_template` diff --git a/docs_site/api/operations/interior-lighting.md b/docs_site/api/operations/interior-lighting.md index 6d8aeba..8416140 100644 --- a/docs_site/api/operations/interior-lighting.md +++ b/docs_site/api/operations/interior-lighting.md @@ -1,13 +1,20 @@ # Interior Lighting Operations -Interior lighting is managed at the **`InteriorSpace`** granularity. In the -COMcheck API schema, an interior lighting space is represented by the -`ActivityUse` model, aliased as `InteriorSpace`. Each `InteriorSpace` belongs -to a `WholeBldgUse` (building area) and carries exactly one singleton -`InteriorLightingSpace` whose `fixture[]` holds the fixtures. +Interior lighting spaces are stored in each building area's +`lighting.wholeBldgUse[].activityUse[]`, one `InteriorSpace` per space. Each +space holds one `InteriorLightingSpace`, whose `fixture[]` list holds its +fixtures. + +```python +from comcheck_api import project_interior_lighting_operations as il_ops +``` ## Key concepts +- **`InteriorSpace` is an alias of `ActivityUse`.** The schema name is + `ActivityUse`. These operations use the `InteriorSpace` alias so it isn't + confused with `ExteriorArea` (see the + [Types Guide](../../types-guide.md#interior-lighting)). - **Fixtures are batch-edited by `fixtureType`.** Use `update_fixtures_in_interior_space` to add, update, and/or remove fixtures on a space in one call — fixtures are matched by `fixtureType` (the @@ -15,20 +22,16 @@ to a `WholeBldgUse` (building area) and carries exactly one singleton not `id`. You can still edit `interiorLightingSpace.fixture[]` directly and pass the whole `InteriorSpace` through `update_interior_space_in_project` if you prefer. -- **InteriorSpace.key** is always set to the parent `WholeBldgUse.key` — the - add operation sets this automatically. +- **`InteriorSpace.key` is the parent building area's key.** The add + operation sets it to `WholeBldgUse.key` for you. - **`areaDescription` must be unique** within a building area's `activityUse[]` list. It is the identifier used by update and remove operations. If missing, a unique value is auto-generated with the prefix `"Space"`. -- A building area must exist before adding interior spaces — add one with - `project_building_area_operations.add_building_area_to_project` first. +- **A building area must exist first.** Add one with + [`add_building_area_to_project`](building-area.md). ## Operations -```python -from comcheck_api import project_interior_lighting_operations as il_ops -``` - | Function | Description | |---|---| | `add_interior_space_to_project(project, building_area_key, new_interior_space)` | Add a new InteriorSpace to a building area | @@ -49,7 +52,7 @@ from comcheck_api.defaults import ( get_default_interior_space_template, get_default_fixture_template, ) -from comcheck_api.types.core_types import ActivityTypeOptions +from comcheck_api.types import ActivityTypeOptions # A building area must exist first area = get_default_building_area_template() @@ -158,7 +161,7 @@ keys = il_ops.get_interior_space_keys_from_project(project, area_key) # [{"areaDescription": "Open Office", "activityType": "ACTIVITY_COMMON_OFFICE_OPEN"}, ...] ``` -## Calculating allowed wattage +## Calculating allowed wattage (API) Allowed wattage is calculated server-side via two `COMcheckClient` methods, not the `il_ops` free functions above. Both take the energy code as an @@ -182,3 +185,13 @@ results = client.calculate_interior_spaces_allowed_wattage( These calls don't mutate the `InteriorSpace` objects passed in or write the result onto `allowedWattage` — they return the raw calculation payload for the caller to use as needed. + +See `examples/project_operations/interior_lighting_operations.py` and +`examples/client/allowed_wattage.py` for full walkthroughs. + +## Related + +- [Types Guide — Interior Lighting](../../types-guide.md#interior-lighting) — `InteriorSpace` (`ActivityUse`), `Fixture`, `ActivityTypeOptions` +- [Building Area](building-area.md) — interior spaces belong to a building area +- [Project Defaults](../defaults.md) — `get_default_interior_space_template`, `get_default_fixture_template` +- [COMcheckClient — Interior Lighting](../client.md#interior-lighting) — allowed wattage methods diff --git a/docs_site/api/operations/mechanical.md b/docs_site/api/operations/mechanical.md index 5e9bb48..a7019ed 100644 --- a/docs_site/api/operations/mechanical.md +++ b/docs_site/api/operations/mechanical.md @@ -1,24 +1,66 @@ -# Mechanical +# Mechanical Operations -The mechanical operations module manages HVAC systems, fan systems, HVAC plants, and water heaters in COMcheck projects. +The mechanical operations module manages HVAC systems, fan systems (with their +fans and pressure drop credits), HVAC plants, and water heaters in COMcheck +projects. ```python from comcheck_api import project_mechanical_operations as mech_ops ``` -## Overview - -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. - -See `examples/project_operations/mechanical_operations.py` for a full walkthrough. +## Key concepts + +- **Where the data lives.** Mechanical data is in two places on `ComBuilding`: + - **`hvac`** — a singleton (can be `MISSING`) containing `hvacSystem[]`, + `fanSystem[]` (can be `None`), and `hvacPlant[]`. It is created + automatically when you add the first HVAC system, fan system, or plant. + - **`swhSystem[]`** — water heaters (top-level, not under `hvac`). +- **Components are identified by `description`.** HVAC systems, fan systems, + fans, plants, and water heaters are matched by `description` in update and + remove operations. If missing, one is auto-generated (e.g., "HVAC System 1", + "Fan System 1", "Plant 1", "Water Heater 1"). Pressure drop credits are the + exception — they are identified by `type`. +- **Fans and pressure drop credits belong to a fan system.** Add the fan + system first, then pass its description to the fan and pressure drop credit + operations. +- **Fan system keys link fan systems to HVAC systems.** + `FanSystem.fanSystemKey` is a UUID: + - 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`, which copies it into + `HVACSystem.fanSystemKey`. + - 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. +- **The fan system's compliance method decides which fan inputs matter.** + `FanSystem.complyMethod` is one of: + - `FAN_SYSTEM_MOTOR_NAMEPLATE_HP` — enter `nameplateHp` and + `designBrakeHp` on each fan. Compliance uses the nameplate HP, and each + fan's maximum nameplate HP is sized from its design brake HP (see + [Maximum allowable HP](#maximum-allowable-hp)). + - `FAN_SYSTEM_MOTOR_BRAKE_HP` — enter `designBrakeHp` and `brakeHp` + (the fan's maximum brake HP, shown as "max. BHP" in the report) on each + fan. Compliance uses the design brake HP and checks that the motor + nameplate HP is at least 75% of `brakeHp`. Only this method supports + pressure drop credits. + + `maxNameplateHp` on fans is calculated by the engine, not entered. +- **Pressure drop credits only count when `hasPressureDropCredits` is set.** + Set `FanSystem.hasPressureDropCredits` to `HasPressureDropCredits.int_1` + on a `FAN_SYSTEM_MOTOR_BRAKE_HP` fan system; otherwise its credits are + ignored. +- **Engine-calculated fields are read-only.** Update operations reject + `requirementAnswer` on all components, `complies` and `complianceMessage` + on fan systems, and `pressureDropCredit` on pressure drop credits. Fill + them in with the + [client write-back methods](#writing-results-back-to-the-project). + `maxNameplateHp` on fans is the exception: `update_fan_in_fan_system` + accepts it, since compliance recalculates it anyway. +- **Changing an input clears the value calculated from it.** Changing a fan's + `designBrakeHp` clears its `maxNameplateHp`, and changing a pressure drop + credit's inputs clears its `pressureDropCredit`. Re-run the matching + write-back method afterwards. !!! warning "Mechanical requirements" Mechanical requirements (`requirementAnswer` on HVAC systems, fan systems, @@ -26,178 +68,69 @@ See `examples/project_operations/mechanical_operations.py` for a full walkthroug then open it on [COMcheck Web](https://comcheck.energycode.pnl.gov/) to fill them out. -## HVAC Systems - -HVAC systems are identified by their `description`. - -### add_hvac_system_to_project +## Operations + +| Function | Description | +|---|---| +| `add_hvac_system_to_project(project, new_hvac_system)` | Add an HVAC system (creates `hvac` if it's `MISSING`) | +| `update_hvac_system_in_project(project, hvac_system_description, updates)` | Update an existing HVAC system | +| `remove_hvac_system_from_project(project, hvac_system_description)` | Remove an HVAC system | +| `assign_fan_system_to_hvac_system(project, hvac_system_description, fan_system_description)` | Link a fan system to an HVAC system (`None` to unlink) | +| `get_hvac_system_keys_from_project(project)` | List all HVAC systems | +| `add_fan_system_to_project(project, new_fan_system)` | Add a fan system (creates `hvac` if it's `MISSING`) | +| `update_fan_system_in_project(project, fan_system_description, updates)` | Update an existing fan system | +| `remove_fan_system_from_project(project, fan_system_description)` | Remove a fan system and unlink it from HVAC systems | +| `get_fan_system_keys_from_project(project)` | List all fan systems | +| `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 an existing fan | +| `remove_fan_from_fan_system(project, fan_system_description, fan_description)` | Remove a fan | +| `add_pressure_drop_credit_to_fan_system(project, fan_system_description, new_credit)` | Add a pressure drop credit to a fan system | +| `update_pressure_drop_credit_in_fan_system(project, fan_system_description, credit_type, updates)` | Update an existing pressure drop credit | +| `remove_pressure_drop_credit_from_fan_system(project, fan_system_description, credit_type)` | Remove a pressure drop credit | +| `add_hvac_plant_to_project(project, new_hvac_plant)` | Add an HVAC plant (creates `hvac` if it's `MISSING`) | +| `update_hvac_plant_in_project(project, hvac_plant_description, updates)` | Update an existing HVAC plant | +| `remove_hvac_plant_from_project(project, hvac_plant_description)` | Remove an HVAC plant | +| `get_hvac_plant_keys_from_project(project)` | List all HVAC plants | +| `add_water_heater_to_project(project, new_water_heater)` | Add a water heater (initializes `swhSystem` if it's `MISSING`) | +| `update_water_heater_in_project(project, water_heater_description, updates)` | Update an existing water heater | +| `remove_water_heater_from_project(project, water_heater_description)` | Remove a water heater | +| `get_water_heater_keys_from_project(project)` | List all water heaters | + +Update operations raise `ValueError` if the component isn't found or the +updates contain read-only fields (see [Key concepts](#key-concepts)). + +## Adding an HVAC system and a fan system + +Add both, then link them by description: ```python -project = mech_ops.add_hvac_system_to_project( - project, - new_hvac_system +from comcheck_api.defaults import ( + get_default_hvac_system_template, + get_default_fan_system_template, ) -``` - -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.defaults import get_default_hvac_system_template 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) -``` - -### 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.defaults import get_default_fan_system_template fan_system = get_default_fan_system_template() fan_system.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 +project = mech_ops.assign_fan_system_to_hvac_system( + project, "Rooftop Unit 1", "Supply Fan 1" ) ``` -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 -) -``` +Pass `None` as the fan system description to unlink it. +`add_hvac_system_to_project` and `update_hvac_system_in_project` raise a +`ValueError` if `fanSystemKey` points to a fan system that doesn't exist. -Add a new fan to a fan system. +## Adding and updating fans -**Example:** +Fans are stored in `FanSystem.fan[]`. Pass the fan system's description to +every fan operation: ```python from comcheck_api.defaults import get_default_fan_template @@ -207,49 +140,43 @@ fan.description = "Fan 1" project = mech_ops.add_fan_to_fan_system(project, "Supply Fan 1", fan) ``` -### update_fan_in_fan_system +Changing `designBrakeHp` clears `maxNameplateHp`, so the old value isn't +kept (unless the same update also sets `maxNameplateHp`). Recalculate it with +`client.update_fan_maximum_allowable_hp` (see +[Maximum allowable HP](#maximum-allowable-hp)): ```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 + project, "Supply Fan 1", "Fan 1", {"designBrakeHp": 10.0} ) +project = client.update_fan_maximum_allowable_hp(project, "Supply Fan 1") ``` -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`. +## Pressure drop credits -`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)). +Pressure drop credits are stored in `FanSystem.pressureDropCredits[]`. Each +one is identified by its `type` (a `PressureDropTypeOptions` value), which is +unique within a fan system. They only apply to fan systems that use the +`FAN_SYSTEM_MOTOR_BRAKE_HP` compliance method. Adding one to any other fan +system emits a `UserWarning`. ```python -from comcheck_api.types.core_types import ( +from comcheck_api.types import ( + FanSystemComplianceMethodOptions, FanSystemPressureDropCredits, + HasPressureDropCredits, PressureDropTypeOptions, ) +project = mech_ops.update_fan_system_in_project( + project, + "Supply Fan 1", + { + "complyMethod": FanSystemComplianceMethodOptions.FAN_SYSTEM_MOTOR_BRAKE_HP, + "hasPressureDropCredits": HasPressureDropCredits.int_1, + }, +) + MERV_9 = PressureDropTypeOptions.PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12 project = mech_ops.add_pressure_drop_credit_to_fan_system( @@ -263,154 +190,114 @@ project = mech_ops.remove_pressure_drop_credit_from_fan_system( ) ``` -`type` can't be updated — remove the credit and add a new one. - -## HVAC Plants - -HVAC plants are identified by their `description`. +`type` can't be updated. Remove the credit and add a new one. -### add_hvac_plant_to_project +The API calculates the credit value, `pressureDropCredit`. Updating a +credit's inputs clears it, so recalculate after you edit credits: ```python -project = mech_ops.add_hvac_plant_to_project( - project, - new_hvac_plant -) +project = client.update_fan_pressure_drop_credits(project, "Supply Fan 1") +project.hvac.fanSystem[0].pressureDropCredits[0].pressureDropCredit # e.g. 0.12104 ``` -Add a new HVAC plant. Creates the `hvac` singleton if it's `MISSING`. +To get the value of a single credit without changing the project, use +`calculate_fan_pressure_drop_credit` (see [Mechanical calculations](#mechanical-calculations-api)). -**Example:** +## Adding HVAC plants and water heaters ```python -from comcheck_api.defaults import get_default_hvac_plant_template +from comcheck_api.defaults import ( + get_default_hvac_plant_template, + get_default_water_heater_template, +) plant = get_default_hvac_plant_template() plant.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.defaults import get_default_water_heater_template water_heater = get_default_water_heater_template() water_heater.description = "Water Heater 1" project = mech_ops.add_water_heater_to_project(project, water_heater) ``` -### update_water_heater_in_project +Water heaters are stored in the top-level `swhSystem[]` list, not under +`hvac`. The model class keeps its schema name, `ServiceWaterHeatingSystem`. + +## Updating components + +Pass only the fields you want to change. Fields you don't pass keep their +values: ```python +project = mech_ops.update_hvac_system_in_project( + project, "Rooftop Unit 1", {"quantity": 3} +) project = mech_ops.update_water_heater_in_project( - project, - water_heater_description, - updates + project, "Water Heater 1", {"quantity": 2} ) ``` -Update an existing water heater. Engine-calculated fields (`requirementAnswer`) are rejected. +`update_fan_system_in_project` also rejects `fanSystemKey`. Use +`assign_fan_system_to_hvac_system` to link systems instead. -### remove_water_heater_from_project +## Removing components ```python -project = mech_ops.remove_water_heater_from_project( - project, - water_heater_description -) +project = mech_ops.remove_fan_from_fan_system(project, "Supply Fan 1", "Fan 1") +project = mech_ops.remove_fan_system_from_project(project, "Supply Fan 1") +project = mech_ops.remove_hvac_system_from_project(project, "Rooftop Unit 1") +project = mech_ops.remove_hvac_plant_from_project(project, "Chiller 1") +project = mech_ops.remove_water_heater_from_project(project, "Water Heater 1") ``` -Remove a water heater from the project. +Removing a fan system clears `fanSystemKey` on any HVAC system that +references it. -### get_water_heater_keys_from_project +## Listing components ```python -keys = mech_ops.get_water_heater_keys_from_project(project) -# Returns: [{"description": "...", "swhSystemType": "...", "fuelType": "...", "quantity": ...}, ...] -``` +mech_ops.get_hvac_system_keys_from_project(project) +# [{"description": "...", "systemType": "...", "quantity": ..., +# "fanSystemKey": "...", "fanSystemDescription": "..."}, ...] -## Fan System Keys +mech_ops.get_fan_system_keys_from_project(project) +# [{"description": "...", "fanSystemKey": "...", "complyMethod": "..."}, ...] -`FanSystem.fanSystemKey` is a UUID that links a fan system to an HVAC system: +mech_ops.get_hvac_plant_keys_from_project(project) +# [{"description": "...", "plantType": "...", "systemType": "...", "quantity": ...}, ...] -- 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 +mech_ops.get_water_heater_keys_from_project(project) +# [{"description": "...", "swhSystemType": "...", "fuelType": "...", "quantity": ...}, ...] +``` -## API calls +## Mechanical calculations (API) `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. +for efficiency requirements, fan system compliance, and pressure drop credits. +These methods take the components and an energy code; they don't need a saved +project, and they only return results. | 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`) | +| `calculate_plant_efficiency(plants, energy_code, plant_name=None)` | `plants/efficiency` | `dict[str, PlantEfficiency]` keyed by description | +| `calculate_water_heater_efficiency(water_heaters, energy_code, water_heater_name=None)` | `water-heaters/efficiency` | `dict[str, WaterHeaterEfficiency]` keyed by description | -- The water heater, HVAC system, and plant methods take one component or a list. +- The HVAC system, plant, and water heater 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. +- `validate_hvac_system_economizer` requires a non-empty `description`. - `calculate_fan_pressure_drop_credit` sends the credit's `type` as the - `fanPressureCreditType` query parameter. The type must be set and not + `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 +from comcheck_api.types import HVACSystem client = COMcheckClient() efficiency = client.calculate_hvac_system_efficiency( @@ -419,24 +306,64 @@ efficiency = client.calculate_hvac_system_efficiency( print(efficiency["Rooftop Unit 1"].minCoolingEfficiency) ``` +### Maximum allowable HP + +Maximum nameplate HP applies to `FAN_SYSTEM_MOTOR_NAMEPLATE_HP` fan systems. +The API sizes the motor from the fan's brake horsepower — about 1.5× below +6 bhp and 1.3× at 6 bhp or more, rounded up to the next standard motor size +(e.g. 3.1 → 5, 13.4 → 20). + +- `calculate_fan_maximum_allowable_hp(fan, energy_code)` sends only the fan's + `designBrakeHp` and `brakeHp` — the endpoint's only inputs, and it uses the + larger of the two. `designBrakeHp` must be set (otherwise a `ValueError` is + raised); `brakeHp` is sent as `0` when unset. +- `update_fan_maximum_allowable_hp(project, fan_system_description)` writes + `maxNameplateHp` onto each fan (see below). For a fan system that uses any + other compliance method it emits a `UserWarning` and leaves the project + unchanged. + +```python +project = client.update_fan_maximum_allowable_hp(project, "Supply Fan 1") +project.hvac.fanSystem[0].fan[0].maxNameplateHp # e.g. 5.0 +``` + ### 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: +Only fan system and fan results have fields on the project model. These client +methods use `project.control.code`, update the project **in place**, and return +it — call `client.update_project(...)` afterwards to save: -- `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[]`. +| Method | Writes | +|---|---| +| `update_fan_system_compliance(project, fan_system_description)` | `complies`, `complianceMessage`, `complyMethod`, `hasPressureDropCredits` | +| `update_fan_pressure_drop_credits(project, fan_system_description)` | `pressureDropCredit` on each entry of `pressureDropCredits[]` | +| `update_fan_maximum_allowable_hp(project, fan_system_description)` | `maxNameplateHp` on each fan in `fan[]` that has `designBrakeHp` (fans without it are skipped). `FAN_SYSTEM_MOTOR_NAMEPLATE_HP` fan systems only — others warn and are skipped. | ```python +project = client.update_fan_pressure_drop_credits(project, "Supply Fan 1") project = client.update_fan_system_compliance(project, "Supply Fan 1") +project = client.update_fan_maximum_allowable_hp(project, "Supply Fan 1") +client.update_project("project-id", project) ``` -See `examples/client/mechanical.py` for a full example. +!!! note "Written-back values are for reference" + The engine recalculates `complies`, `complianceMessage`, + `pressureDropCredit`, and `maxNameplateHp` from the fan inputs every time + — in `check_fan_system_compliance`, in `update_fan_system_compliance`, and + in the report. Stored values (even wrong ones) don't change the result, so + writing them back keeps the project in sync with what COMcheck shows; it + doesn't feed the next calculation. Re-run the write-back methods after + changing fan inputs so the stored values stay current. + +The HVAC system, economizer, plant, and water heater methods only return +results — `HVACSystem`, `HVACPlant`, and `ServiceWaterHeatingSystem` have no +fields for them. + +See `examples/project_operations/mechanical_operations.py` and +`examples/client/mechanical.py` for full walkthroughs. ## Related -- [Types Guide](../../types-guide.md) — Import `HVACSystem`, `FanSystem`, `Fan`, `HVACPlant`, `ServiceWaterHeatingSystem` +- [Types Guide — Mechanical](../../types-guide.md#mechanical) — `HVACSystem`, `FanSystem`, `Fan`, `FanSystemPressureDropCredits`, `HVACPlant`, `ServiceWaterHeatingSystem` +- [Project Defaults](../defaults.md) — default templates for every mechanical component +- [COMcheckClient — Mechanical](../client.md#mechanical) — calculation and write-back methods diff --git a/docs_site/api/operations/renewable-energy.md b/docs_site/api/operations/renewable-energy.md index ba3c1ad..00aee33 100644 --- a/docs_site/api/operations/renewable-energy.md +++ b/docs_site/api/operations/renewable-energy.md @@ -10,37 +10,33 @@ Renewable energy is managed at the **project level** through the - Off-site fields — `requiredOffsiteRenewableEnergy`, `proposedOffsiteRenewableEnergy`, and `offsiteRenewableProcurement[]` -## Energy codes - -Renewable requirements only apply under some energy codes (and state codes -based on them). These operations don't check `control.code`. The COMcheck Web -API decides whether the project's code uses renewable data, and renewable -inputs on a project whose code doesn't use them do no harm. +```python +from comcheck_api import project_renewable_operations as rn_ops +``` ## Key concepts - **Exception and systems coexist.** Setting a renewable exception does **not** preclude adding on-site renewable systems, and vice versa — the two can coexist on the same project. - - **Off-site procurement confirmation.** Off-site renewable procurement is **not** always present. Before adding any `offsiteRenewableProcurement[]` entry, confirm with the user that the project actually uses off-site procurement. +- **`description` must be unique** within `renewable.renewableSystem[]` and + `renewable.offsiteRenewableProcurement[]`. It is the identifier used by + update and remove operations. If missing, a unique value is auto-generated + with the prefix `"Renewable System"` or `"Offsite Procurement"`. -## description uniqueness +## Energy codes -`description` must be unique within `renewable.renewableSystem[]` and -`renewable.offsiteRenewableProcurement[]`. It is the identifier used by update -and remove operations. If missing, a unique value is auto-generated with the -prefix `"Renewable System"` or `"Offsite Procurement"`. +Renewable requirements only apply under some energy codes (and state codes +based on them). These operations don't check `control.code`. The COMcheck Web +API decides whether the project's code uses renewable data, and renewable +inputs on a project whose code doesn't use them do no harm. ## Operations -```python -from comcheck_api import project_renewable_operations as rn_ops -``` - | Function | Description | |---|---| | `set_renewable_exception_in_project(project, renewable_exception)` | Set the renewable exception (rejects `NoneType_None`) | @@ -57,8 +53,7 @@ from comcheck_api import project_renewable_operations as rn_ops ## Setting the renewable exception ```python -from comcheck_api import project_renewable_operations as rn_ops -from comcheck_api.types.core_types import RenewableExceptionOptions +from comcheck_api.types import RenewableExceptionOptions project = rn_ops.set_renewable_exception_in_project( project, RenewableExceptionOptions.RENEWABLE_ONSITE_EXCEPTION_LOW_SOLAR_RAD @@ -84,7 +79,7 @@ print(project.renewable.requiredCapacity, project.renewable.proposedCapacity) ```python from comcheck_api.defaults import get_default_renewable_system_template -from comcheck_api.types.core_types import ( +from comcheck_api.types import ( RenewableSystemTypeOptions, RenewableSystemCapacityUnitOptions, ) @@ -119,7 +114,7 @@ project = rn_ops.update_renewable_system_in_project( ```python from comcheck_api.defaults import get_default_offsite_renewable_procurement_template -from comcheck_api.types.core_types import OffsiteRenewableProcurementType +from comcheck_api.types import OffsiteRenewableProcurementType offsite_procurement = get_default_offsite_renewable_procurement_template() offsite_procurement.description = "Wind PPA" @@ -161,6 +156,23 @@ project = rn_ops.update_renewable_in_project( ) ``` +## Removing renewable systems and procurements + +```python +project = rn_ops.remove_renewable_system_from_project(project, "Rooftop Solar") +project = rn_ops.remove_offsite_renewable_procurement_from_project(project, "Wind PPA") +``` + +## Listing renewable systems and procurements + +```python +renewable_systems = rn_ops.get_renewable_system_keys_from_project(project) +# [{"description": "Rooftop Solar", "renewableSystemType": "RENEWABLE_SYSTEM_TYPE_SOLAR", ...}] + +offsite_procurements = rn_ops.get_offsite_renewable_procurement_keys_from_project(project) +# [{"description": "Wind PPA", "offsiteRenewableProcurementType": "...", ...}] +``` + ## Renewable calculations (API) Two `COMcheckClient` methods call the COMcheck Web API to calculate renewable @@ -200,30 +212,12 @@ Any value the engine does not produce is set to `None`. The endpoint also returns a `renewableComplianceStatus`, but the project model has no field for it, so it is not written back. -## Removing renewable systems - -```python -project = rn_ops.remove_renewable_system_from_project(project, "Rooftop Solar") -``` - -## Removing offsite renewable procurement - -```python -project = rn_ops.remove_offsite_renewable_procurement_from_project(project, "Wind PPA") -``` - -## Listing renewable systems and procurements - -```python -renewable_systems = rn_ops.get_renewable_system_keys_from_project(project) -# [{"description": "Rooftop Solar", "renewableSystemType": "RENEWABLE_SYSTEM_TYPE_SOLAR", ...}] - -offsite_procurements = rn_ops.get_offsite_renewable_procurement_keys_from_project(project) -# [{"description": "Wind PPA", "offsiteRenewableProcurementType": "...", ...}] -``` +See `examples/project_operations/renewable_operations.py` and +`examples/client/renewable.py` for full walkthroughs. ## Related -- [Types Guide](types-guide.md) — `Renewable`, `RenewableSystem`, `OffsiteRenewableProcurement` -- [Interior Lighting](interior-lighting.md) — Similar pattern for lighting spaces -- [Exterior Lighting](exterior-lighting.md) — Similar pattern for exterior lighting areas +- [Types Guide — Renewable](../../types-guide.md#renewable) — `Renewable`, `RenewableSystem`, `OffsiteRenewableProcurement` +- [Credits](credits.md) — on-site renewable energy credits +- [Project Defaults](../defaults.md) — `get_default_renewable_system_template`, `get_default_offsite_renewable_procurement_template` +- [COMcheckClient — Renewable Energy](../client.md#renewable-energy) — `area_for_renewable`, `renewable_capacity`, `set_renewable_exception` diff --git a/docs_site/getting-started.md b/docs_site/getting-started.md index 4b01b90..0a966c7 100644 --- a/docs_site/getting-started.md +++ b/docs_site/getting-started.md @@ -1,7 +1,7 @@ # Getting Started !!! note "Supported Sections" - Currently, **Building Area**, **Envelope**, **Interior Lighting**, **Exterior Lighting**, **Mechanical**, **Renewable Energy**, **Credits**, and **Compliance Simulation** operations are supported. Mechanical requirements are not supported; fill them out on [COMcheck Web](https://comcheck.energycode.pnl.gov/). See the [home page](index.md#current-status) for the full status table. + Currently, **Building Area**, **Envelope**, **Interior Lighting**, **Exterior Lighting**, **Mechanical**, **Credits**, **Renewable Energy**, and **Compliance Simulation** operations are supported. Mechanical requirements are not supported; fill them out on [COMcheck Web](https://comcheck.energycode.pnl.gov/). See the [home page](index.md#current-status) for the full status table. ## Setup @@ -56,6 +56,11 @@ client.set_api_key(os.environ["COM_API_KEY"]) ## Basic Workflow +Every project operation takes a `ComBuilding`, returns an **updated copy**, +and leaves the original unchanged — always reassign the result +(`project = ...`). Changes are local until you call +`client.update_project(...)`. + ### Fetch and inspect a project ```python @@ -72,43 +77,188 @@ with COMcheckClient(api_key="your-key") as client: project_dict = client.get_project("project-id", mode="json") ``` -### Modify a project with envelope operations +### Work with building areas + +Envelope components, interior lighting spaces, and energy credits all belong +to a building area, so add one first and keep its `key`. ```python -from comcheck_api import COMcheckClient, project_envelope_operations +from comcheck_api import project_building_area_operations +from comcheck_api.defaults import get_default_building_area_template + +# Add a building area +area = get_default_building_area_template() +project = project_building_area_operations.add_building_area_to_project( + project, new_building_area=area +) +area_key = area.key + +# List building area keys +keys = project_building_area_operations.get_building_area_keys_from_project(project) +``` + +See [Building Area](api/operations/building-area.md). + +### Envelope + +```python +from comcheck_api import project_envelope_operations from comcheck_api.defaults import ( get_default_roof_template, get_default_ag_wall_template, ) -client = COMcheckClient(api_key="your-key") -project = client.get_project("project-id") - # Add a roof roof = get_default_roof_template() project = project_envelope_operations.add_roof_to_project( - project, building_area_key="area-key", new_roof=roof + project, building_area_key=area_key, new_roof=roof ) # Add an above-grade wall wall = get_default_ag_wall_template() project = project_envelope_operations.add_ag_wall_to_project( - project, building_area_key="area-key", new_ag_wall=wall + project, building_area_key=area_key, new_ag_wall=wall ) +``` -# Save changes -client.update_project("project-id", project) +See [Envelope](api/operations/envelope.md). + +### Interior lighting + +```python +from comcheck_api import project_interior_lighting_operations +from comcheck_api.defaults import ( + get_default_interior_space_template, + get_default_fixture_template, +) +from comcheck_api.types import ActivityTypeOptions + +fixture = get_default_fixture_template() +fixture.description = "Recessed LED" +fixture.fixtureType = "Recessed LED" +fixture.fixtureWattage = 20.0 +fixture.quantity = 10 + +space = get_default_interior_space_template() +space.areaDescription = "Open Office" +space.activityType = ActivityTypeOptions.ACTIVITY_COMMON_OFFICE_OPEN +space.floorArea = 2000.0 +space.interiorLightingSpace = space.interiorLightingSpace.model_copy( + deep=True, update={"fixture": [fixture]} +) + +project = project_interior_lighting_operations.add_interior_space_to_project( + project, building_area_key=area_key, new_interior_space=space +) +``` + +See [Interior Lighting](api/operations/interior-lighting.md). + +### Exterior lighting + +```python +from comcheck_api import project_exterior_lighting_operations +from comcheck_api.defaults import get_default_exterior_area_template +from comcheck_api.types import ExteriorLightingZoneTypeOptions + +project = project_exterior_lighting_operations.set_exterior_lighting_zone_type_in_project( + project, ExteriorLightingZoneTypeOptions.EXT_ZONE_NEIGHBORHOOD_BUS_DISTRICT +) + +ext_area = get_default_exterior_area_template() +ext_area.areaDescription = "Front Parking Lot" +ext_area.useQuantity = 5000.0 +project = project_exterior_lighting_operations.add_exterior_area_to_project( + project, new_exterior_area=ext_area +) +``` + +See [Exterior Lighting](api/operations/exterior-lighting.md). + +### Mechanical + +Mechanical components are identified by their `description`, so give each +one a unique name. + +```python +from comcheck_api import project_mechanical_operations +from comcheck_api.defaults import ( + get_default_hvac_system_template, + get_default_fan_system_template, + get_default_water_heater_template, +) + +hvac = get_default_hvac_system_template() +hvac.description = "RTU-1" +project = project_mechanical_operations.add_hvac_system_to_project( + project, new_hvac_system=hvac +) + +fan_system = get_default_fan_system_template() +fan_system.description = "Fan System 1" +project = project_mechanical_operations.add_fan_system_to_project( + project, new_fan_system=fan_system +) +project = project_mechanical_operations.assign_fan_system_to_hvac_system( + project, hvac_system_description="RTU-1", fan_system_description="Fan System 1" +) + +water_heater = get_default_water_heater_template() +water_heater.description = "WH-1" +project = project_mechanical_operations.add_water_heater_to_project( + project, new_water_heater=water_heater +) ``` -### Run a compliance simulation +See [Mechanical](api/operations/mechanical.md). + +### Credits + +```python +from comcheck_api import project_credit_operations +from comcheck_api.types import EfficiencyPackage, IECC2024EnergyCreditTypeOptions + +project = project_credit_operations.add_energy_credit_to_project( + project, + building_area_key=area_key, + new_energy_credit=EfficiencyPackage( + type=IECC2024EnergyCreditTypeOptions.EFF_PACKAGE_ENERGY_CREDIT_IECC2024_ENHANCED_ROOF_R10 + ), +) + +# Calculate earned vs. required credits +result = client.calculate_energy_credits(project) +``` + +See [Credits](api/operations/credits.md). + +### Renewable energy + +```python +from comcheck_api import project_renewable_operations +from comcheck_api.defaults import get_default_renewable_system_template + +pv = get_default_renewable_system_template() +pv.description = "Rooftop PV" +pv.renewableSystemCapacity = 25000.0 # watts +project = project_renewable_operations.add_renewable_system_to_project( + project, new_renewable_system=pv +) + +# Refresh required vs. proposed capacity +project = client.renewable_capacity(project) +``` + +See [Renewable Energy](api/operations/renewable-energy.md). + +### Save and run a compliance simulation ```python import time -from comcheck_api import COMcheckClient from comcheck_api.types import SimulationStatus -client = COMcheckClient(api_key="your-key") -project = client.get_project("project-id") +# Save changes +client.update_project("project-id", project) # Start simulation session_id = client.start_run_simulation(project) @@ -128,23 +278,7 @@ result = client.get_simulation_result(session_id) print(f"Performance Rating: {result['performanceRating']}") ``` -### Work with building areas - -```python -from comcheck_api import project_building_area_operations -from comcheck_api.defaults import ( - get_default_building_area_template, -) - -# Add a building area -area = get_default_building_area_template() -project = project_building_area_operations.add_building_area_to_project( - project, new_building_area=area -) - -# List building area keys -keys = project_building_area_operations.get_building_area_keys_from_project(project) -``` +See [Simulation](api/simulation.md) and [Compliance & Reports](api/compliance.md). ## Error Handling diff --git a/docs_site/index.md b/docs_site/index.md index 4c7c7e4..8de4235 100644 --- a/docs_site/index.md +++ b/docs_site/index.md @@ -65,5 +65,8 @@ uv add comcheck_api | Module | Description | |--------|-------------| -| [`comcheck_api.client`](api/client.md) | High-level client interface | -| [`comcheck_api.project_operations`](api/operations/building-area.md) | [Building area](api/operations/building-area.md), [envelope](api/operations/envelope.md), [interior lighting](api/operations/interior-lighting.md), and [exterior lighting](api/operations/exterior-lighting.md) operations | +| [`comcheck_api.client`](api/client.md) | High-level client interface (`COMcheckClient`), including [simulation](api/simulation.md) and [compliance & reports](api/compliance.md) | +| [`comcheck_api.project_operations`](api/operations/building-area.md) | [Building area](api/operations/building-area.md), [envelope](api/operations/envelope.md), [interior lighting](api/operations/interior-lighting.md), [exterior lighting](api/operations/exterior-lighting.md), [mechanical](api/operations/mechanical.md), [credits](api/operations/credits.md), and [renewable energy](api/operations/renewable-energy.md) operations | +| [`comcheck_api.defaults`](api/defaults.md) | Default templates for projects and building components | +| [`comcheck_api.introspection`](api/introspection.md) / [`comcheck_api.validation`](api/introspection.md) | Runtime discovery of SDK capabilities and project data validation | +| [`comcheck_api.types`](types-guide.md) | Pydantic models for projects and building components | diff --git a/docs_site/types-guide.md b/docs_site/types-guide.md index 74e26e4..6ae1e40 100644 --- a/docs_site/types-guide.md +++ b/docs_site/types-guide.md @@ -1,6 +1,6 @@ # Types Guide -The `comcheck_api` library is fully typed with Pydantic models and `StrEnum`/`IntEnum` option types defined in `comcheck_api.types.core_types`. Understanding these types is essential for correctly building and modifying COMcheck projects. +The `comcheck_api` library is fully typed with Pydantic models and `StrEnum`/`Enum` option types defined in `comcheck_api.types.core_types`. Understanding these types is essential for correctly building and modifying COMcheck projects. ## Importing Types @@ -44,7 +44,7 @@ print(roof.roofType) # IDE shows available attributes print(roof.orientation) # typed as OrientationOptions | None ``` -### Enum Options (`StrEnum` / `IntEnum`) +### Enum Options (`StrEnum` / `Enum`) Enum options define the valid values for fields like building types, orientations, energy codes, etc. Using them prevents typos and gives you IDE auto-completion for all valid choices. @@ -77,29 +77,57 @@ for code in EnergyCodeOptions: ## Common Types by Domain +Each domain below has a matching project operations +module for adding, updating, and removing its components: + +| Domain | Operations module | Page | +|---|---|---| +| Building Area | `project_building_area_operations` | [Building Area](api/operations/building-area.md) | +| Envelope | `project_envelope_operations` | [Envelope](api/operations/envelope.md) | +| Interior Lighting | `project_interior_lighting_operations` | [Interior Lighting](api/operations/interior-lighting.md) | +| Exterior Lighting | `project_exterior_lighting_operations` | [Exterior Lighting](api/operations/exterior-lighting.md) | +| Mechanical | `project_mechanical_operations` | [Mechanical](api/operations/mechanical.md) | +| Credits | `project_credit_operations` | [Credits](api/operations/credits.md) | +| Renewable | `project_renewable_operations` | [Renewable Energy](api/operations/renewable-energy.md) | + ### Project Structure The top-level model is `ComBuilding`, which contains all project data: -| Model | Description | -|---|---| -| `ComBuilding` | Top-level project model | -| `Project` | Project info (name, address) | -| `Location` | Geographic location | -| `Envelope` | All envelope assemblies (roofs, walls, floors) | -| `Lighting` | Lighting data (building areas, exterior) | -| `HVAC` | HVAC systems | +| Attribute | Model | Description | +|---|---|---| +| — | `ComBuilding` | Top-level project model | +| `project` | `Project` | Project info (name, address) | +| `location` | `Location` | Geographic location | +| `envelope` | `Envelope` | Envelope assemblies (roofs, walls, floors, fenestration) | +| `lighting` | `Lighting` | Building areas, interior lighting spaces, and exterior lighting areas | +| `hvac` | `HVAC` | HVAC systems, fan systems, and HVAC plants | +| `swhSystem` | `list[ServiceWaterHeatingSystem]` | Service water heating systems | +| `efficiencyPackages` | `list[EfficiencyPackage]` | Energy credits | +| `renewable` | `Renewable` | On-site renewable systems and off-site procurement | ```python from comcheck_api.types import ComBuilding project: ComBuilding = client.get_project("project-id") -project.project # Project -project.location # Location -project.envelope # Envelope -project.lighting # Lighting +project.project # Project +project.location # Location +project.envelope # Envelope +project.lighting # Lighting +project.hvac # HVAC +project.swhSystem # list[ServiceWaterHeatingSystem] +project.efficiencyPackages # list[EfficiencyPackage] +project.renewable # Renewable ``` +### Building Area + +| Model | Description | Key Options | +|---|---|---| +| `WholeBldgUse` | Building use area — parent of envelope components, interior lighting spaces, and energy credits | `WholeBuildingTypeOptions` | + +See also: [Building Area operations](api/operations/building-area.md). + ### Envelope Components | Model | Description | Key Options | @@ -113,28 +141,42 @@ project.lighting # Lighting | `Skylight` | Skylight component | `SkylightCurbTypeOptions`, `GlazingTypeOptions` | | `ThermalBridge` | Thermal bridge | `ThermalBridgeTypeOptions` | +See also: [Envelope operations](api/operations/envelope.md). + ```python from comcheck_api.types import Roof, RoofTypeOptions, OrientationOptions # Type hints show you what fields are available roof = Roof( - roofType=RoofTypeOptions.ATTIC_ROOF_WITH_WOOD_JOIST, + roofType=RoofTypeOptions.WOOD_STD_JOIST_TRUSS, orientation=OrientationOptions.NORTH, # ... IDE auto-completes all fields ) ``` -### Building Area & Lighting +### Interior Lighting | Model | Description | Key Options | |---|---|---| -| `WholeBldgUse` | Building use area | `WholeBuildingTypeOptions` | -| `ActivityUse` | Interior lighting space within a building use, aliased as `InteriorSpace` | `ActivityTypeOptions` | -| `ExteriorUse` | Exterior lighting area, aliased as `ExteriorArea` | `ExteriorUseTypeOptions` | +| `ActivityUse` | Interior lighting space within a building area, aliased as `InteriorSpace` | `ActivityTypeOptions` | +| `InteriorLightingSpace` | Lighting details of an interior space (fixtures, daylight areas) | - | | `Fixture` | Lighting fixture | `LightingTypeOptions` | | `FixtureSchedule` | Fixture schedule | - | | `LightingControls` | Lighting controls | `LightingControlTypeOptions` | +See also: [Interior Lighting operations](api/operations/interior-lighting.md). + +### Exterior Lighting + +| Model | Description | Key Options | +|---|---|---| +| `Lighting` | Holds the project-wide exterior lighting zone | `ExteriorLightingZoneTypeOptions` | +| `ExteriorUse` | Exterior lighting area, aliased as `ExteriorArea` | `ExteriorUseTypeOptions` | +| `ExteriorLightingSpace` | Lighting details of an exterior area (fixtures) | - | +| `Fixture` | Lighting fixture | `LightingTypeOptions` | + +See also: [Exterior Lighting operations](api/operations/exterior-lighting.md). + !!! tip "InteriorSpace and ExteriorArea aliases" `comcheck_api.types.common_types` defines `InteriorSpace = ActivityUse` and `ExteriorArea = ExteriorUse`. The `project_interior_lighting_operations` @@ -147,11 +189,36 @@ roof = Roof( | Model | Description | Key Options | |---|---|---| | `HVACSystem` | HVAC system | `CoolingEquipmentTypeOptions`, `FuelTypeOptions` | -| `HVACPlant` | HVAC plant | `BoilerDraftTypeOptions` | -| `Fan` | Fan | `FanEfficiencyExceptionTypeOptions` | | `FanSystem` | Fan system | `FanSystemComplianceMethodOptions` | +| `Fan` | Fan within a fan system | `FanEfficiencyExceptionTypeOptions` | +| `FanSystemPressureDropCredits` | Pressure drop credit within a fan system | `PressureDropTypeOptions` | +| `HVACPlant` | HVAC plant | `BoilerDraftTypeOptions` | | `ServiceWaterHeatingSystem` | Water heating | `SWHSystemDrawPatternTypeOptions` | +See also: [Mechanical operations](api/operations/mechanical.md). + +### Credits + +| Model | Description | Key Options | +|---|---|---| +| `EfficiencyPackage` | Energy credit tied to a building area by `bldgUseKey`; its `type` selects the credit | `IECC2024EnergyCreditTypeOptions`, `VT2024EnergyCreditTypeOptions`, `Denver2022EnergyCreditTypeOptions`, `MAS2022EnergyCreditTypeOptions`, `EfficiencyPackageTypeOptions` | +| `ComBuilding` | Project-level `energyCreditMultiplierException` | `EnergyCreditMultiplierException` | + +See also: [Credits operations](api/operations/credits.md). + +Each energy code family has its own credit type enum — pick the one that +matches `project.control.code`. + +### Renewable + +| Model | Description | Key Options | +|---|---|---| +| `Renewable` | Renewable section: exception, required/proposed capacity, systems, and off-site procurement | `RenewableExceptionOptions` | +| `RenewableSystem` | On-site renewable system | `RenewableSystemTypeOptions`, `RenewableSystemCapacityUnitOptions` | +| `OffsiteRenewableProcurement` | Off-site renewable energy procurement | `OffsiteRenewableProcurementType` | + +See also: [Renewable Energy operations](api/operations/renewable-energy.md). + ## Tips **Use your IDE.** The biggest advantage of these types is auto-completion. When you type `project.envelope.`, your IDE will show all available attributes and their types. diff --git a/examples/README.md b/examples/README.md index 2ac8db6..a3fc7c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -107,6 +107,7 @@ Demonstrates mechanical system efficiency and compliance calculations. - `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 +- `update_fan_maximum_allowable_hp()` - Updating fan maximum nameplate HP values in a project **Usage:** ```bash @@ -185,7 +186,7 @@ Demonstrates managing HVAC systems, fan systems (with fans and pressure drop cre - `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 +- `client.update_fan_pressure_drop_credits()` / `client.update_fan_system_compliance()` / `client.update_fan_maximum_allowable_hp()` - Filling in engine-calculated fan system values - `update_*` / `remove_*` and `get_*_keys_from_project()` - Updating, renaming, removing, and listing items **Usage:** diff --git a/examples/client/mechanical.py b/examples/client/mechanical.py index 51b56ba..1a3eb01 100644 --- a/examples/client/mechanical.py +++ b/examples/client/mechanical.py @@ -93,10 +93,10 @@ # Example 7: Calculate fan maximum allowable HP print("\n=== Fan Maximum Allowable HP ===") +# Only designBrakeHp and brakeHp are sent; brakeHp defaults to 0 when unset. fan = Fan( description="Supply Fan 1", - motorHp=5.0, - airflow=10000, + designBrakeHp=5.0, ) max_hp = client.calculate_fan_maximum_allowable_hp(fan, energy_code) print(f"Maximum allowable nameplate HP: {max_hp}") @@ -110,6 +110,7 @@ FanSystem( description="Main Supply Fan System", complyMethod="FAN_SYSTEM_MOTOR_NAMEPLATE_HP", + fan=[Fan(description="Supply Fan 1", designBrakeHp=5.0)], pressureDropCredits=[ FanSystemPressureDropCredits( type="PARTICULATE_FILTRATION_CREDIT_MERV_9_THRU_12", @@ -129,4 +130,9 @@ credit = fs.pressureDropCredits[0] print(f"Updated pressure drop credit: {credit.pressureDropCredit}") +# Update maximum allowable HP on each fan +project = client.update_fan_maximum_allowable_hp(project, "Main Supply Fan System") +fan = fs.fan[0] +print(f"Updated fan max nameplate HP: {fan.maxNameplateHp}") + client.close() diff --git a/mkdocs.yml b/mkdocs.yml index 6a2d4c4..3e29610 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,14 @@ site_description: Type-safe Python client for the PNNL COMcheck Web API site_url: https://pnnl.github.io/comcheckweb-api-python/ repo_url: https://github.com/pnnl/comcheckweb-api-python docs_dir: docs_site +# Low-level reference pages kept in the repo but intentionally hidden from nav +not_in_nav: | + /api/api-service.md + /api/exceptions.md + /api/managers/ + /api/constants/ + /api/types/ + /api/utilities/ theme: name: material diff --git a/tests/project_operation_tests/test_mechanical_operations.py b/tests/project_operation_tests/test_mechanical_operations.py index 72cea2a..0b99de5 100644 --- a/tests/project_operation_tests/test_mechanical_operations.py +++ b/tests/project_operation_tests/test_mechanical_operations.py @@ -433,6 +433,89 @@ def test_update_fan_in_fan_system(project: ComBuilding): assert result.hvac.fanSystem[0].fan[0].description == "Updated Fan" +def _with_sized_fan(project: ComBuilding) -> ComBuilding: + """A fan system with one fan whose maxNameplateHp was already calculated.""" + proj = _fresh(project) + proj = mech_ops.add_fan_system_to_project(proj, FanSystem(description="FS")) + proj = mech_ops.add_fan_to_fan_system( + proj, "FS", Fan(description="Fan 1", designBrakeHp=5.0, brakeHp=5.0) + ) + proj.hvac.fanSystem[0].fan[0].maxNameplateHp = 7.5 # as written back by the client + return proj + + +def test_update_fan_design_brake_hp_clears_max_nameplate_hp(project: ComBuilding): + proj = _with_sized_fan(project) + + result = mech_ops.update_fan_in_fan_system(proj, "FS", "Fan 1", {"designBrakeHp": 10.0}) + + fan = result.hvac.fanSystem[0].fan[0] + assert fan.designBrakeHp == 10.0 + assert fan.maxNameplateHp is MISSING + assert "maxNameplateHp" not in result.model_dump(mode="json")["hvac"]["fanSystem"][0]["fan"][0] + # The input project is not modified. + assert proj.hvac.fanSystem[0].fan[0].maxNameplateHp == 7.5 + + +def test_update_fan_design_brake_hp_clears_after_rename(project: ComBuilding): + proj = _with_sized_fan(project) + + result = mech_ops.update_fan_in_fan_system( + proj, "FS", "Fan 1", {"description": "Fan A", "designBrakeHp": 10.0} + ) + + fan = result.hvac.fanSystem[0].fan[0] + assert fan.description == "Fan A" + assert fan.maxNameplateHp is MISSING + + +@pytest.mark.parametrize( + "updates", + [ + {"nameplateHp": 7.5}, + {"brakeHp": 6.0}, + {"designBrakeHp": 5.0}, # unchanged value + ], +) +def test_update_fan_keeps_max_nameplate_hp(project: ComBuilding, updates): + proj = _with_sized_fan(project) + + result = mech_ops.update_fan_in_fan_system(proj, "FS", "Fan 1", updates) + + assert result.hvac.fanSystem[0].fan[0].maxNameplateHp == 7.5 + + +def test_update_fan_allows_max_nameplate_hp(project: ComBuilding): + """Engine-calculated fan fields can be set; compliance recalculates them.""" + proj = _with_sized_fan(project) + + result = mech_ops.update_fan_in_fan_system(proj, "FS", "Fan 1", {"maxNameplateHp": 100.0}) + + assert result.hvac.fanSystem[0].fan[0].maxNameplateHp == 100.0 + + +def test_update_fan_explicit_max_nameplate_hp_is_not_cleared(project: ComBuilding): + proj = _with_sized_fan(project) + + result = mech_ops.update_fan_in_fan_system( + proj, "FS", "Fan 1", {"designBrakeHp": 10.0, "maxNameplateHp": 15.0} + ) + + assert result.hvac.fanSystem[0].fan[0].maxNameplateHp == 15.0 + + +def test_update_fan_accepts_unchanged_copy(project: ComBuilding): + """An edited copy of the fan (same maxNameplateHp) can be passed back.""" + proj = _with_sized_fan(project) + edited = proj.hvac.fanSystem[0].fan[0].model_copy(update={"nameplateHp": 7.5}) + + result = mech_ops.update_fan_in_fan_system(proj, "FS", "Fan 1", edited) + + fan = result.hvac.fanSystem[0].fan[0] + assert fan.nameplateHp == 7.5 + assert fan.maxNameplateHp == 7.5 + + def test_remove_fan_from_fan_system(project: ComBuilding): proj = _fresh(project) diff --git a/tests/test_introspection_validation.py b/tests/test_introspection_validation.py new file mode 100644 index 0000000..b3e77ff --- /dev/null +++ b/tests/test_introspection_validation.py @@ -0,0 +1,75 @@ +"""Tests for the introspection and validation helpers.""" + +import comcheck_api as cc +from comcheck_api.defaults import get_default_project_template + + +def test_list_operations_groups_in_documented_order(): + groups = list(dict.fromkeys(op.group for op in cc.list_operations())) + + assert groups == [ + "building_area", + "envelope", + "interior_lighting", + "exterior_lighting", + "mechanical", + "credits", + "renewable", + ] + + +def test_list_operations_lists_public_functions_only(): + ops = cc.list_operations() + names = {op.name for op in ops} + + assert "add_roof_to_project" in names + assert "update_fan_in_fan_system" in names + assert not any(name.startswith("_") for name in names) + # Client methods are API calls, not project operations. + assert "update_fan_maximum_allowable_hp" not in names + roof = next(op for op in ops if op.name == "add_roof_to_project") + assert roof.group == "envelope" + assert roof.signature.startswith("add_roof_to_project(") + + +def test_lookup_type_model(): + schema = cc.lookup_type("Fan") + + assert schema.kind == "model" + fields = {f.name: f for f in schema.fields} + assert "designBrakeHp" in fields + + +def test_lookup_type_enums(): + str_enum = cc.lookup_type("FanSystemComplianceMethodOptions") + plain_enum = cc.lookup_type("RenewableExceptionOptions") + + assert str_enum.kind == "enum" + assert "FAN_SYSTEM_MOTOR_BRAKE_HP" in {m.name for m in str_enum.members} + assert plain_enum.kind == "enum" + assert plain_enum.members + + +def test_lookup_type_alias_case_insensitive_and_missing(): + assert cc.lookup_type("InteriorSpace").kind == "model" + assert cc.lookup_type("combuilding").name == "ComBuilding" + assert cc.lookup_type("NotAType") is None + + +def test_validate_project_accepts_model_and_dict(): + project = get_default_project_template() + + assert cc.validate_project(project).ok + assert cc.validate_project(project.model_dump(mode="json")).ok + + +def test_validate_project_reports_errors_with_dotted_location(): + data = get_default_project_template().model_dump(mode="json") + data["envelope"] = 5 + del data["control"] + + result = cc.validate_project(data) + + assert not result.ok + locs = {err.loc for err in result.errors} + assert {"control", "envelope"} <= locs diff --git a/tests/test_mechanical_api.py b/tests/test_mechanical_api.py index b7a5c4c..395dd58 100644 --- a/tests/test_mechanical_api.py +++ b/tests/test_mechanical_api.py @@ -356,11 +356,33 @@ def test_fan_maximum_allowable_hp_service(requests): def test_calculate_fan_maximum_allowable_hp(requests): data = {"maxNameplateHp": 7.5} client = _make_client(_ok(requests, data)) - fan = Fan(motorHp=5.0) + fan = Fan(description="Fan 1", designBrakeHp=5.0, nameplateHp=7.0) result = client.calculate_fan_maximum_allowable_hp(fan, "CEZ_IECC2024") assert result == 7.5 + (request,) = requests + # Only the endpoint's inputs are sent; a missing brakeHp is sent as 0. + assert json.loads(request.content) == {"designBrakeHp": 5.0, "brakeHp": 0.0} + + +def test_calculate_fan_maximum_allowable_hp_sends_brake_hp(requests): + client = _make_client(_ok(requests, {"maxNameplateHp": 7.5})) + + client.calculate_fan_maximum_allowable_hp( + Fan(designBrakeHp=5.0, brakeHp=4.0), "CEZ_IECC2024" + ) + + (request,) = requests + assert json.loads(request.content) == {"designBrakeHp": 5.0, "brakeHp": 4.0} + + +def test_calculate_fan_maximum_allowable_hp_requires_design_brake_hp(requests): + client = _make_client(_ok(requests, {"maxNameplateHp": 7.5})) + + with pytest.raises(ValueError, match="designBrakeHp"): + client.calculate_fan_maximum_allowable_hp(Fan(description="Fan 1"), "CEZ_IECC2024") + assert requests == [] # Project write-back tests @@ -429,6 +451,82 @@ def test_update_fan_pressure_drop_credits_no_hvac(): client.update_fan_pressure_drop_credits(project, "Test") +def _fan_project(project, fans, comply_method="FAN_SYSTEM_MOTOR_NAMEPLATE_HP"): + fan_system = project.hvac.fanSystem[0] + fan_system.fan = fans + fan_system.complyMethod = comply_method + return project + + +def test_update_fan_maximum_allowable_hp(requests, project): + client = _make_client(_ok(requests, {"maxNameplateHp": 7.5})) + project = _fan_project( + project, + [Fan(description="Fan 1", designBrakeHp=5.0), Fan(description="No design HP")], + ) + + result = client.update_fan_maximum_allowable_hp(project, "Test Fan System") + + assert result is project + fan_1, skipped = project.hvac.fanSystem[0].fan + assert fan_1.maxNameplateHp == 7.5 + assert fan_1.brakeHp is MISSING # input field, left unchanged + assert skipped.maxNameplateHp is MISSING + (request,) = requests # fans without designBrakeHp are not sent + assert request.url.path.endswith("/CEZ_IECC2024/mechanical/fan-systems/maximum-allowable-hp") + # The written values are kept when the project is serialized for saving. + dumped = project.model_dump(mode="json")["hvac"]["fanSystem"][0]["fan"][0] + assert dumped["maxNameplateHp"] == 7.5 + + +def test_update_fan_maximum_allowable_hp_keeps_brake_hp_input(requests, project): + """brakeHp is an input: it is sent to the API and never overwritten.""" + client = _make_client(_ok(requests, {"maxNameplateHp": 7.5, "brakeHp": 4.2})) + project = _fan_project( + project, [Fan(description="Fan 1", designBrakeHp=5.0, brakeHp=6.0)] + ) + + client.update_fan_maximum_allowable_hp(project, "Test Fan System") + + fan = project.hvac.fanSystem[0].fan[0] + assert fan.maxNameplateHp == 7.5 + assert fan.brakeHp == 6.0 + (request,) = requests + assert json.loads(request.content) == {"designBrakeHp": 5.0, "brakeHp": 6.0} + + +def test_update_fan_maximum_allowable_hp_skips_brake_hp_method(requests, project): + client = _make_client(_ok(requests, {"maxNameplateHp": 7.5})) + project = _fan_project( + project, + [Fan(description="Fan 1", designBrakeHp=5.0)], + comply_method="FAN_SYSTEM_MOTOR_BRAKE_HP", + ) + + with pytest.warns(UserWarning, match="FAN_SYSTEM_MOTOR_NAMEPLATE_HP"): + result = client.update_fan_maximum_allowable_hp(project, "Test Fan System") + + assert result is project + assert project.hvac.fanSystem[0].fan[0].maxNameplateHp is MISSING + assert requests == [] + + +def test_update_fan_maximum_allowable_hp_not_found(requests, project): + client = _make_client(_ok(requests, {})) + + with pytest.raises(ValueError, match="not found"): + client.update_fan_maximum_allowable_hp(project, "Nonexistent") + + +def test_update_fan_maximum_allowable_hp_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_maximum_allowable_hp(project, "Test") + + # Error handling tests def test_mechanical_service_maps_http_errors(): def handler(request: httpx.Request) -> httpx.Response: @@ -464,7 +562,9 @@ def test_calculate_fan_maximum_allowable_hp_missing_value_raises(requests): client = _make_client(_ok(requests, {})) with pytest.raises(COMCheckAPIError): - client.calculate_fan_maximum_allowable_hp(Fan(description="Fan 1"), "CEZ_IECC2024") + client.calculate_fan_maximum_allowable_hp( + Fan(description="Fan 1", designBrakeHp=5.0), "CEZ_IECC2024" + ) def test_validate_hvac_system_economizer_string_capacity(requests):