From 529cd2a2cff20b19c017e78ae96605f413699879 Mon Sep 17 00:00:00 2001 From: Dayton Muxlow Date: Mon, 13 Jul 2026 22:59:21 -0700 Subject: [PATCH 1/4] Upgrade Python client for API v0.6 --- README.md | 71 +-- pyproject.toml | 2 +- src/hvakr/__init__.py | 38 +- src/hvakr/client.py | 865 +++++++++++++++------------------- src/hvakr/exceptions.py | 3 + src/hvakr/schemas/__init__.py | 349 ++++---------- src/hvakr/schemas/api.py | 149 ++++++ src/hvakr/schemas/outputs.py | 111 +++++ src/hvakr/schemas/project.py | 440 ++++++++++------- tests/test_client.py | 473 ++++++++----------- uv.lock | 2 +- 11 files changed, 1277 insertions(+), 1226 deletions(-) create mode 100644 src/hvakr/schemas/api.py diff --git a/README.md b/README.md index 4207206..453e8ff 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ client = HVAKRClient(access_token="your-access-token") # List all projects projects = client.list_projects() -print(projects) # {"ids": ["project-1", "project-2", ...]} +for project in projects.projects: + print(project.id, project.name) # Get a specific project project = client.get_project("project-id") @@ -69,13 +70,15 @@ HVAKRClient( ### Project Methods -#### list_projects() +#### list_projects(limit=None, cursor=None, search=None, status=None, project_type=None) -List all projects accessible to the authenticated user. +List projects using the v0.6 paginated endpoint. Pass `next_cursor` as `cursor` +while `has_more` is true. ```python -result = client.list_projects() -# Returns: {"ids": ["project-1", "project-2", ...]} +page = client.list_projects(limit=50, search="office", project_type="commercial") +for project in page.projects: + print(project.id, project.name) ``` #### get_project(project_id, expand=False) @@ -90,7 +93,7 @@ project = client.get_project("project-id") expanded = client.get_project("project-id", expand=True) ``` -#### create_project(project_data, revit_payload=False) +#### create_project(project_data, idempotency_key=None) Create a new project. @@ -101,10 +104,10 @@ result = client.create_project({ "latitude": 40.7128, "longitude": -74.0060, }) -# Returns: {"id": "new-project-id"} +# `equipmentModes` can be omitted; the API seeds cooling_mode and heating_mode. ``` -#### update_project(project_id, project_data, revit_payload=False) +#### update_project(project_id, project_data, idempotency_key=None) Update an existing project. @@ -122,42 +125,44 @@ Delete a project. result = client.delete_project("project-id") ``` -#### get_project_outputs(project_id, output_type) +#### get_project_calculations(project_id, include=None) -Retrieve calculated outputs for a project. +Run the calculator once and retrieve selected sections. Calculation airflows, +checksums, and equipment results are keyed by the project's equipment mode IDs. ```python -# Get heating/cooling loads -loads = client.get_project_outputs("project-id", "loads") - -# Get dry side graph -graph = client.get_project_outputs("project-id", "dryside_graph") - -# Get register schedule -schedule = client.get_project_outputs("project-id", "register_schedule") +calculations = client.get_project_calculations( + "project-id", include=["loads", "airflows", "equipment"] +) +cooling_airflows = calculations.airflows.project.by_mode["cooling_mode"] ``` -### Weather Station Methods - -#### search_weather_stations(latitude, longitude) - -Search for weather stations near a geographic location. +### Jobs, products, and identity ```python -result = client.search_weather_stations(40.7128, -74.0060) -# Returns: {"weatherStationIds": ["station-1", "station-2", ...]} +# Start a report, auto-group, check, or auto-takeoff job. +job = client.create_job("project-id", {"type": "check"}) +job = client.get_job("project-id", job.job_id) + +# Browse the product catalog and inspect the authenticated account. +products = client.list_products(search="fan") +identity = client.me() ``` -#### get_weather_station(weather_station_id) +## v0.6 migration notes -Retrieve detailed data for a specific weather station. +This SDK follows the breaking HVAKR v0.6 API: + +- Use `system.equipmentConfig` and `zone.equipmentConfig` instead of the legacy + central/terminal configuration fields. Components are ordered in `components` + and configured per mode in `componentConfigsByMode`. +- Use `project.equipmentModes` and mode keys such as `cooling_mode` rather than + fixed cooling/heating output paths. +- Replace flat space airflow fields with `designAirflowsByMode`, and place + ventilation/infiltration overrides under `airflowRequirementsByLoadCondition`. +- `get_project_outputs`, weather-station methods, and Revit payload options were + removed by the platform. Use `get_project_calculations` and project jobs. -```python -station = client.get_weather_station("station-id") -print(station.station) # Station name -print(station.climate_zone) # Climate zone -print(station.elevation) # Elevation -``` ## Error Handling diff --git a/pyproject.toml b/pyproject.toml index bdd8ed9..e5cc357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hvakr" -version = "0.1.0" +version = "0.6.0" description = "Official Python SDK for the HVAKR API - HVAC load calculation and building analysis" readme = "README.md" license = "MIT" diff --git a/src/hvakr/__init__.py b/src/hvakr/__init__.py index 482f140..694203d 100644 --- a/src/hvakr/__init__.py +++ b/src/hvakr/__init__.py @@ -28,10 +28,16 @@ async def main(): from hvakr.client import AsyncHVAKRClient, HVAKRClient from hvakr.exceptions import HVAKRClientError from hvakr.schemas import ( - APIOutputType, - APIProjectOutputDrySideGraph, - APIProjectOutputLoads, - APIProjectOutputRegisterSchedule, + APIJob, + APIJobCreate, + APIMe, + APIMeOrganization, + APIMePlan, + APIMeRateLimit, + APIMeUser, + APIProduct, + APIProjectCalculations, + APIReport, Box, DisplayUnitSystemId, ExpandedProject, @@ -52,16 +58,14 @@ async def main(): ProjectData, ProjectPost, Rect, - RevitData, Size, UnknownWebhookEvent, - WeatherStationData, WebhookEvent, WebhookEventType, ) from hvakr.webhooks import HVAKRWebhookError, construct_webhook_event -__version__ = "0.1.0" +__version__ = "0.6.0" __all__ = [ # Client classes @@ -86,11 +90,17 @@ async def main(): "Graph", "GraphNode", "NodeType", - # Output schemas - "APIOutputType", - "APIProjectOutputDrySideGraph", - "APIProjectOutputLoads", - "APIProjectOutputRegisterSchedule", + # API schemas + "APIJob", + "APIJobCreate", + "APIMe", + "APIMeOrganization", + "APIMePlan", + "APIMeRateLimit", + "APIMeUser", + "APIProduct", + "APIProjectCalculations", + "APIReport", # Project schemas "ExpandedProject", "ExpandedProjectPatch", @@ -98,10 +108,6 @@ async def main(): "Project", "ProjectData", "ProjectPost", - # Revit schemas - "RevitData", - # Weather schemas - "WeatherStationData", # Webhook schemas "KnownWebhookEvent", "OpportunityCreatedEvent", diff --git a/src/hvakr/client.py b/src/hvakr/client.py index dc25946..17b08cb 100644 --- a/src/hvakr/client.py +++ b/src/hvakr/client.py @@ -1,37 +1,70 @@ -"""HVAKR API client.""" +"""Synchronous and asynchronous clients for the HVAKR v0 API.""" -from typing import Literal, overload +import logging +from collections.abc import Mapping, Sequence +from typing import Any, Literal, overload from urllib.parse import quote import httpx +from pydantic import BaseModel from hvakr.exceptions import HVAKRClientError -from hvakr.schemas.outputs import ( - APIProjectOutputDrySideGraph, - APIProjectOutputLoads, - APIProjectOutputRegisterSchedule, +from hvakr.schemas.api import ( + APIJob, + APIJobCreate, + APIMe, + APIProduct, + ProductListResponse, + ProjectListResponse, ) +from hvakr.schemas.outputs import APIProjectCalculations from hvakr.schemas.project import ( ExpandedProject, ExpandedProjectPatch, ExpandedProjectPost, Project, + ProjectPost, ) -from hvakr.schemas.revit import RevitData -from hvakr.schemas.weather import WeatherStationData - -class HVAKRClient: - """Client for interacting with the HVAKR API. - - Example: - ```python - from hvakr import HVAKRClient - - client = HVAKRClient(access_token="your-token") - projects = client.list_projects() - ``` - """ +__version__ = "0.6.0" +_LOGGER = logging.getLogger(__name__) +_WARNED_CLIENT_MESSAGES: set[str] = set() +_ProjectSubcollectionKey = Literal[ + "branchTypes", + "deadlines", + "doorTypes", + "ductTypes", + "graph", + "pipeTypes", + "registerTypes", + "roofTypes", + "sheetFiles", + "sheets", + "slabTypes", + "spaceTypes", + "spaces", + "systems", + "wallTypes", + "windowTypes", + "zones", + "reports", +] +_CalculationSection = Literal[ + "loads", + "register_schedule", + "dryside_graph", + "ventilation", + "equipment", + "checksums", + "airflows", +] +_ProjectPayload = ExpandedProjectPost | ProjectPost | ExpandedProjectPatch | Mapping[str, Any] +_JobPayload = APIJobCreate | Mapping[str, Any] +_QueryValue = str | int | float | bool + + +class _ClientBase: + """Shared URL, header, payload, and response handling.""" def __init__( self, @@ -40,158 +73,161 @@ def __init__( version: str = "v0", timeout: float = 30.0, ) -> None: - """Create a new HVAKR client instance. - - Args: - access_token: Access token for authentication. Obtain from your HVAKR account settings. - base_url: Base URL for the API. Defaults to "https://api.hvakr.com". - version: API version to use. Defaults to "v0". - timeout: Request timeout in seconds. Defaults to 30.0. - """ self._access_token = access_token self._base_url = base_url self._version = version self._timeout = timeout - self._client: httpx.Client | None = None - - @property - def _http_client(self) -> httpx.Client: - """Get or create the HTTP client.""" - if self._client is None: - self._client = httpx.Client(timeout=self._timeout) - return self._client def _get_auth_headers(self) -> dict[str, str]: - """Get authentication headers.""" - return {"Authorization": f"Bearer {self._access_token}"} + """Build headers sent with every request.""" + return { + "Authorization": f"Bearer {self._access_token}", + "X-HVAKR-Client": f"hvakr-python/{__version__}", + } @staticmethod def _encode_path_segment(segment: str) -> str: - """URL-encode a path segment.""" return quote(segment, safe="") - def _create_url( - self, - path: str, - query_params: dict[str, str | bool] | None = None, - ) -> str: - """Construct a full API URL with optional query parameters. - - Args: - path: API endpoint path (e.g., "/projects"). - query_params: Optional query parameters to append. - - Returns: - The full URL string. - """ - base = self._base_url.rstrip("/") - url = f"{base}/{self._version}{path}" - if query_params: - params = [] - for key, value in query_params.items(): - if isinstance(value, bool): - if value: - params.append(quote(key, safe="")) - elif value: - params.append( - f"{quote(key, safe='')}={quote(str(value), safe='')}" - ) - if params: - url += "?" + "&".join(params) - return url - - def _handle_response(self, response: httpx.Response) -> dict: - """Handle API response and raise errors if needed. - - Args: - response: The HTTP response. - - Returns: - The parsed JSON response. - - Raises: - HVAKRClientError: If the response indicates an error. - """ + def _create_url(self, path: str, query_params: Mapping[str, _QueryValue] | None = None) -> str: + """Construct an API URL, encoding paths and query values safely.""" + url = f"{self._base_url.rstrip('/')}/{self._version}{path}" + if not query_params: + return url + + params: list[str] = [] + for key, value in query_params.items(): + if isinstance(value, bool): + if value: + params.append(quote(key, safe="")) + elif value is not None: + params.append(f"{quote(key, safe='')}={quote(str(value), safe='')}") + return f"{url}?{'&'.join(params)}" if params else url + + def _write_headers(self, idempotency_key: str | None = None) -> dict[str, str]: + headers = { + **self._get_auth_headers(), + "Accept": "application/json", + "Content-Type": "application/json", + } + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + return headers + + @staticmethod + def _payload(data: BaseModel | Mapping[str, Any]) -> dict[str, Any]: + if isinstance(data, BaseModel): + return data.model_dump(by_alias=True, exclude_none=True) + return dict(data) + + @staticmethod + def _warn_if_outdated(response: httpx.Response) -> None: + warning = response.headers.get("X-HVAKR-Client-Warning") + if warning and warning not in _WARNED_CLIENT_MESSAGES: + _WARNED_CLIENT_MESSAGES.add(warning) + _LOGGER.warning("[hvakr] %s", warning) + + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: + self._warn_if_outdated(response) try: data = response.json() - except Exception as e: + except (ValueError, TypeError) as exc: raise HVAKRClientError( "Failed to parse JSON response", status_code=response.status_code, - metadata={"error": str(e)}, - ) from e + metadata={"error": str(exc)}, + ) from exc if not response.is_success: raise HVAKRClientError( - "API request failed", + f"Error {response.status_code}", + status_code=response.status_code, + metadata=data, + ) + if not isinstance(data, dict): + raise HVAKRClientError( + "Expected an object JSON response", status_code=response.status_code, metadata=data, ) return data + @staticmethod + def _list_params( + *, + limit: int | None = None, + cursor: str | None = None, + search: str | None = None, + status: str | None = None, + project_type: str | None = None, + ) -> dict[str, _QueryValue]: + return { + key: value + for key, value in { + "limit": limit, + "cursor": cursor, + "search": search, + "status": status, + "projectType": project_type, + }.items() + if value is not None + } + + +class HVAKRClient(_ClientBase): + """Synchronous client for the HVAKR v0 API.""" + + def __init__( + self, + access_token: str, + base_url: str = "https://api.hvakr.com", + version: str = "v0", + timeout: float = 30.0, + ) -> None: + super().__init__(access_token, base_url, version, timeout) + self._client: httpx.Client | None = None + + @property + def _http_client(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self._timeout) + return self._client + def close(self) -> None: - """Close the HTTP client.""" if self._client is not None: self._client.close() self._client = None def __enter__(self) -> "HVAKRClient": - """Enter context manager.""" return self def __exit__(self, *args: object) -> None: - """Exit context manager.""" self.close() - # Project methods - - def create_project( + def list_projects( self, - project_data: ExpandedProjectPost | RevitData | dict, - revit_payload: bool = False, - ) -> dict[str, str]: - """Create a new HVAKR project. - - Args: - project_data: The project data to create. - revit_payload: Set to True if the data is in Revit format. - - Returns: - A dictionary containing the ID of the newly created project. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url("/projects", {"revitPayload": revit_payload}) - - if isinstance(project_data, dict): - body = project_data - else: - body = project_data.model_dump(by_alias=True, exclude_none=True) - - response = self._http_client.post( - url, - headers={ - **self._get_auth_headers(), - "Accept": "application/json", - "Content-Type": "application/json", - }, - json=body, + *, + limit: int | None = None, + cursor: str | None = None, + search: str | None = None, + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None, + project_type: Literal["residential", "commercial"] | None = None, + ) -> ProjectListResponse: + """Return a paginated page of project summaries.""" + response = self._http_client.get( + self._create_url( + "/projects", + self._list_params( + limit=limit, + cursor=cursor, + search=search, + status=status, + project_type=project_type, + ), + ), + headers=self._get_auth_headers(), ) - return self._handle_response(response) - - def list_projects(self) -> dict[str, list[str]]: - """List all projects accessible to the authenticated user. - - Returns: - A dictionary containing an array of project IDs under the "ids" key. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url("/projects") - response = self._http_client.get(url, headers=self._get_auth_headers()) - return self._handle_response(response) + return ProjectListResponse.model_validate(self._handle_response(response)) @overload def get_project(self, project_id: str) -> Project: ... @@ -202,194 +238,129 @@ def get_project(self, project_id: str, expand: Literal[False]) -> Project: ... @overload def get_project(self, project_id: str, expand: Literal[True]) -> ExpandedProject: ... + @overload def get_project( - self, - project_id: str, - expand: bool = False, - ) -> Project | ExpandedProject: - """Retrieve a project by ID. - - Args: - project_id: The ID of the project to retrieve. - expand: If True, returns the full expanded project data. + self, project_id: str, expand: Sequence[_ProjectSubcollectionKey] + ) -> ExpandedProject: ... - Returns: - The project data (expanded or basic depending on the expand parameter). - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}", - {"expand": expand}, + def get_project( + self, project_id: str, expand: bool | Sequence[_ProjectSubcollectionKey] = False + ) -> Project | ExpandedProject: + """Get a project; ``expand`` can select all or specific subcollections.""" + expand_value: str | bool + if isinstance(expand, Sequence) and not isinstance(expand, (str, bytes)): + expand_value = ",".join(expand) + else: + expand_value = bool(expand) + response = self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}", {"expand": expand_value} + ), + headers=self._get_auth_headers(), ) - response = self._http_client.get(url, headers=self._get_auth_headers()) data = self._handle_response(response) - if expand: + if expand_value: return ExpandedProject.model_validate(data) return Project.model_validate(data) + def create_project( + self, project_data: _ProjectPayload, *, idempotency_key: str | None = None + ) -> dict[str, Any]: + """Create a project. Omit ``equipmentModes`` to receive the default modes.""" + response = self._http_client.post( + self._create_url("/projects"), + headers=self._write_headers(idempotency_key), + json=self._payload(project_data), + ) + return self._handle_response(response) + def update_project( self, project_id: str, - project_data: ExpandedProjectPatch | RevitData | dict, - revit_payload: bool = False, - ) -> dict: - """Update an existing HVAKR project. - - Args: - project_id: The ID of the project to update. - project_data: The updated project data. - revit_payload: Set to True if the data is in Revit format. - - Returns: - The updated project data. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}", - {"revitPayload": revit_payload}, - ) - - if isinstance(project_data, dict): - body = project_data - else: - body = project_data.model_dump(by_alias=True, exclude_none=True) - + project_data: _ProjectPayload, + *, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + """Patch a project with canonical v0 fields and subcollections.""" response = self._http_client.patch( - url, - headers={ - **self._get_auth_headers(), - "Accept": "application/json", - "Content-Type": "application/json", - }, - json=body, + self._create_url(f"/projects/{self._encode_path_segment(project_id)}"), + headers=self._write_headers(idempotency_key), + json=self._payload(project_data), ) return self._handle_response(response) - def delete_project(self, project_id: str) -> dict: - """Delete an HVAKR project. - - Args: - project_id: The ID of the project to delete. - - Returns: - The deletion confirmation response. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url(f"/projects/{self._encode_path_segment(project_id)}") - response = self._http_client.delete(url, headers=self._get_auth_headers()) + def delete_project(self, project_id: str) -> dict[str, Any]: + response = self._http_client.delete( + self._create_url(f"/projects/{self._encode_path_segment(project_id)}"), + headers=self._get_auth_headers(), + ) return self._handle_response(response) - # Project outputs - - @overload - def get_project_outputs( - self, project_id: str, output_type: Literal["loads"] - ) -> APIProjectOutputLoads: ... - - @overload - def get_project_outputs( - self, project_id: str, output_type: Literal["dryside_graph"] - ) -> APIProjectOutputDrySideGraph: ... - - @overload - def get_project_outputs( - self, project_id: str, output_type: Literal["register_schedule"] - ) -> APIProjectOutputRegisterSchedule: ... + def get_project_calculations( + self, project_id: str, *, include: Sequence[_CalculationSection] | None = None + ) -> APIProjectCalculations: + """Run the calculator once and return all requested calculation sections.""" + query = {"include": ",".join(include)} if include else None + response = self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}/calculations", query + ), + headers=self._get_auth_headers(), + ) + return APIProjectCalculations.model_validate(self._handle_response(response)) - def get_project_outputs( + def create_job( self, project_id: str, - output_type: Literal["loads", "dryside_graph", "register_schedule"], - ) -> APIProjectOutputLoads | APIProjectOutputDrySideGraph | APIProjectOutputRegisterSchedule: - """Retrieve calculated outputs for a project. - - Args: - project_id: The ID of the project. - output_type: The type of output ("loads", "dryside_graph", or "register_schedule"). - - Returns: - The project output data for the specified type. - - Raises: - HVAKRClientError: If the API returns an error or JSON parsing fails. - """ - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}" - f"/outputs/{self._encode_path_segment(output_type)}" + body: _JobPayload, + *, + idempotency_key: str | None = None, + ) -> APIJob: + response = self._http_client.post( + self._create_url(f"/projects/{self._encode_path_segment(project_id)}/jobs"), + headers=self._write_headers(idempotency_key), + json=self._payload(body), ) - response = self._http_client.get(url, headers=self._get_auth_headers()) - data = self._handle_response(response) - - if output_type == "loads": - return APIProjectOutputLoads.model_validate(data) - elif output_type == "dryside_graph": - return APIProjectOutputDrySideGraph.model_validate(data) - else: - return APIProjectOutputRegisterSchedule.model_validate(data) - - # Weather station methods - - def search_weather_stations(self, latitude: float, longitude: float) -> dict[str, list[str]]: - """Search for weather stations near a geographic location. - - Args: - latitude: The latitude coordinate. - longitude: The longitude coordinate. - - Returns: - A dictionary containing an array of nearby weather station IDs. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url( - "/weather-stations", - {"latitude": str(latitude), "longitude": str(longitude)}, + return APIJob.model_validate(self._handle_response(response)) + + def get_job(self, project_id: str, job_id: str) -> APIJob: + response = self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}/jobs/" + f"{self._encode_path_segment(job_id)}" + ), + headers=self._get_auth_headers(), ) - response = self._http_client.get(url, headers=self._get_auth_headers()) - return self._handle_response(response) - - def get_weather_station(self, weather_station_id: str) -> WeatherStationData: - """Retrieve detailed data for a specific weather station. - - Args: - weather_station_id: The ID of the weather station. - - Returns: - The weather station data including climate information. - - Raises: - HVAKRClientError: If the API returns an error response. - """ - url = self._create_url( - f"/weather-stations/{self._encode_path_segment(weather_station_id)}" + return APIJob.model_validate(self._handle_response(response)) + + def list_products( + self, *, search: str | None = None, limit: int | None = None, cursor: str | None = None + ) -> ProductListResponse: + query_params = { + key: value + for key, value in {"search": search, "limit": limit, "cursor": cursor}.items() + if value is not None + } + response = self._http_client.get( + self._create_url("/products", query_params), + headers=self._get_auth_headers(), ) - response = self._http_client.get(url, headers=self._get_auth_headers()) - data = self._handle_response(response) - return WeatherStationData.model_validate(data) + return ProductListResponse.model_validate(self._handle_response(response)) + def get_product(self, product_id: str) -> APIProduct: + response = self._http_client.get( + self._create_url(f"/products/{self._encode_path_segment(product_id)}"), + headers=self._get_auth_headers(), + ) + return APIProduct.model_validate(self._handle_response(response)) -class AsyncHVAKRClient: - """Async client for interacting with the HVAKR API. - - Example: - ```python - import asyncio - from hvakr import AsyncHVAKRClient + def me(self) -> APIMe: + response = self._http_client.get(self._create_url("/me"), headers=self._get_auth_headers()) + return APIMe.model_validate(self._handle_response(response)) - async def main(): - async with AsyncHVAKRClient(access_token="your-token") as client: - projects = await client.list_projects() - asyncio.run(main()) - ``` - """ +class AsyncHVAKRClient(_ClientBase): + """Asynchronous client for the HVAKR v0 API.""" def __init__( self, @@ -398,122 +369,49 @@ def __init__( version: str = "v0", timeout: float = 30.0, ) -> None: - """Create a new async HVAKR client instance. - - Args: - access_token: Access token for authentication. Obtain from your HVAKR account settings. - base_url: Base URL for the API. Defaults to "https://api.hvakr.com". - version: API version to use. Defaults to "v0". - timeout: Request timeout in seconds. Defaults to 30.0. - """ - self._access_token = access_token - self._base_url = base_url - self._version = version - self._timeout = timeout + super().__init__(access_token, base_url, version, timeout) self._client: httpx.AsyncClient | None = None @property def _http_client(self) -> httpx.AsyncClient: - """Get or create the async HTTP client.""" if self._client is None: self._client = httpx.AsyncClient(timeout=self._timeout) return self._client - def _get_auth_headers(self) -> dict[str, str]: - """Get authentication headers.""" - return {"Authorization": f"Bearer {self._access_token}"} - - @staticmethod - def _encode_path_segment(segment: str) -> str: - """URL-encode a path segment.""" - return quote(segment, safe="") - - def _create_url( - self, - path: str, - query_params: dict[str, str | bool] | None = None, - ) -> str: - """Construct a full API URL with optional query parameters.""" - base = self._base_url.rstrip("/") - url = f"{base}/{self._version}{path}" - if query_params: - params = [] - for key, value in query_params.items(): - if isinstance(value, bool): - if value: - params.append(quote(key, safe="")) - elif value: - params.append( - f"{quote(key, safe='')}={quote(str(value), safe='')}" - ) - if params: - url += "?" + "&".join(params) - return url - - async def _handle_response(self, response: httpx.Response) -> dict: - """Handle API response and raise errors if needed.""" - try: - data = response.json() - except Exception as e: - raise HVAKRClientError( - "Failed to parse JSON response", - status_code=response.status_code, - metadata={"error": str(e)}, - ) from e - - if not response.is_success: - raise HVAKRClientError( - "API request failed", - status_code=response.status_code, - metadata=data, - ) - return data - async def close(self) -> None: - """Close the async HTTP client.""" if self._client is not None: await self._client.aclose() self._client = None async def __aenter__(self) -> "AsyncHVAKRClient": - """Enter async context manager.""" return self async def __aexit__(self, *args: object) -> None: - """Exit async context manager.""" await self.close() - # Project methods - - async def create_project( + async def list_projects( self, - project_data: ExpandedProjectPost | RevitData | dict, - revit_payload: bool = False, - ) -> dict[str, str]: - """Create a new HVAKR project.""" - url = self._create_url("/projects", {"revitPayload": revit_payload}) - - if isinstance(project_data, dict): - body = project_data - else: - body = project_data.model_dump(by_alias=True, exclude_none=True) - - response = await self._http_client.post( - url, - headers={ - **self._get_auth_headers(), - "Accept": "application/json", - "Content-Type": "application/json", - }, - json=body, + *, + limit: int | None = None, + cursor: str | None = None, + search: str | None = None, + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None, + project_type: Literal["residential", "commercial"] | None = None, + ) -> ProjectListResponse: + response = await self._http_client.get( + self._create_url( + "/projects", + self._list_params( + limit=limit, + cursor=cursor, + search=search, + status=status, + project_type=project_type, + ), + ), + headers=self._get_auth_headers(), ) - return await self._handle_response(response) - - async def list_projects(self) -> dict[str, list[str]]: - """List all projects accessible to the authenticated user.""" - url = self._create_url("/projects") - response = await self._http_client.get(url, headers=self._get_auth_headers()) - return await self._handle_response(response) + return ProjectListResponse.model_validate(self._handle_response(response)) @overload async def get_project(self, project_id: str) -> Project: ... @@ -524,111 +422,120 @@ async def get_project(self, project_id: str, expand: Literal[False]) -> Project: @overload async def get_project(self, project_id: str, expand: Literal[True]) -> ExpandedProject: ... + @overload async def get_project( - self, - project_id: str, - expand: bool = False, + self, project_id: str, expand: Sequence[_ProjectSubcollectionKey] + ) -> ExpandedProject: ... + + async def get_project( + self, project_id: str, expand: bool | Sequence[_ProjectSubcollectionKey] = False ) -> Project | ExpandedProject: - """Retrieve a project by ID.""" - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}", - {"expand": expand}, + expand_value: str | bool + if isinstance(expand, Sequence) and not isinstance(expand, (str, bytes)): + expand_value = ",".join(expand) + else: + expand_value = bool(expand) + response = await self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}", {"expand": expand_value} + ), + headers=self._get_auth_headers(), ) - response = await self._http_client.get(url, headers=self._get_auth_headers()) - data = await self._handle_response(response) - if expand: + data = self._handle_response(response) + if expand_value: return ExpandedProject.model_validate(data) return Project.model_validate(data) + async def create_project( + self, project_data: _ProjectPayload, *, idempotency_key: str | None = None + ) -> dict[str, Any]: + response = await self._http_client.post( + self._create_url("/projects"), + headers=self._write_headers(idempotency_key), + json=self._payload(project_data), + ) + return self._handle_response(response) + async def update_project( self, project_id: str, - project_data: ExpandedProjectPatch | RevitData | dict, - revit_payload: bool = False, - ) -> dict: - """Update an existing HVAKR project.""" - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}", - {"revitPayload": revit_payload}, - ) - - if isinstance(project_data, dict): - body = project_data - else: - body = project_data.model_dump(by_alias=True, exclude_none=True) - + project_data: _ProjectPayload, + *, + idempotency_key: str | None = None, + ) -> dict[str, Any]: response = await self._http_client.patch( - url, - headers={ - **self._get_auth_headers(), - "Accept": "application/json", - "Content-Type": "application/json", - }, - json=body, + self._create_url(f"/projects/{self._encode_path_segment(project_id)}"), + headers=self._write_headers(idempotency_key), + json=self._payload(project_data), ) - return await self._handle_response(response) - - async def delete_project(self, project_id: str) -> dict: - """Delete an HVAKR project.""" - url = self._create_url(f"/projects/{self._encode_path_segment(project_id)}") - response = await self._http_client.delete(url, headers=self._get_auth_headers()) - return await self._handle_response(response) - - # Project outputs - - @overload - async def get_project_outputs( - self, project_id: str, output_type: Literal["loads"] - ) -> APIProjectOutputLoads: ... + return self._handle_response(response) - @overload - async def get_project_outputs( - self, project_id: str, output_type: Literal["dryside_graph"] - ) -> APIProjectOutputDrySideGraph: ... + async def delete_project(self, project_id: str) -> dict[str, Any]: + response = await self._http_client.delete( + self._create_url(f"/projects/{self._encode_path_segment(project_id)}"), + headers=self._get_auth_headers(), + ) + return self._handle_response(response) - @overload - async def get_project_outputs( - self, project_id: str, output_type: Literal["register_schedule"] - ) -> APIProjectOutputRegisterSchedule: ... + async def get_project_calculations( + self, project_id: str, *, include: Sequence[_CalculationSection] | None = None + ) -> APIProjectCalculations: + query = {"include": ",".join(include)} if include else None + response = await self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}/calculations", query + ), + headers=self._get_auth_headers(), + ) + return APIProjectCalculations.model_validate(self._handle_response(response)) - async def get_project_outputs( + async def create_job( self, project_id: str, - output_type: Literal["loads", "dryside_graph", "register_schedule"], - ) -> APIProjectOutputLoads | APIProjectOutputDrySideGraph | APIProjectOutputRegisterSchedule: - """Retrieve calculated outputs for a project.""" - url = self._create_url( - f"/projects/{self._encode_path_segment(project_id)}" - f"/outputs/{self._encode_path_segment(output_type)}" + body: _JobPayload, + *, + idempotency_key: str | None = None, + ) -> APIJob: + response = await self._http_client.post( + self._create_url(f"/projects/{self._encode_path_segment(project_id)}/jobs"), + headers=self._write_headers(idempotency_key), + json=self._payload(body), ) - response = await self._http_client.get(url, headers=self._get_auth_headers()) - data = await self._handle_response(response) - - if output_type == "loads": - return APIProjectOutputLoads.model_validate(data) - elif output_type == "dryside_graph": - return APIProjectOutputDrySideGraph.model_validate(data) - else: - return APIProjectOutputRegisterSchedule.model_validate(data) - - # Weather station methods + return APIJob.model_validate(self._handle_response(response)) + + async def get_job(self, project_id: str, job_id: str) -> APIJob: + response = await self._http_client.get( + self._create_url( + f"/projects/{self._encode_path_segment(project_id)}/jobs/" + f"{self._encode_path_segment(job_id)}" + ), + headers=self._get_auth_headers(), + ) + return APIJob.model_validate(self._handle_response(response)) + + async def list_products( + self, *, search: str | None = None, limit: int | None = None, cursor: str | None = None + ) -> ProductListResponse: + query_params = { + key: value + for key, value in {"search": search, "limit": limit, "cursor": cursor}.items() + if value is not None + } + response = await self._http_client.get( + self._create_url("/products", query_params), + headers=self._get_auth_headers(), + ) + return ProductListResponse.model_validate(self._handle_response(response)) - async def search_weather_stations( - self, latitude: float, longitude: float - ) -> dict[str, list[str]]: - """Search for weather stations near a geographic location.""" - url = self._create_url( - "/weather-stations", - {"latitude": str(latitude), "longitude": str(longitude)}, + async def get_product(self, product_id: str) -> APIProduct: + response = await self._http_client.get( + self._create_url(f"/products/{self._encode_path_segment(product_id)}"), + headers=self._get_auth_headers(), ) - response = await self._http_client.get(url, headers=self._get_auth_headers()) - return await self._handle_response(response) + return APIProduct.model_validate(self._handle_response(response)) - async def get_weather_station(self, weather_station_id: str) -> WeatherStationData: - """Retrieve detailed data for a specific weather station.""" - url = self._create_url( - f"/weather-stations/{self._encode_path_segment(weather_station_id)}" + async def me(self) -> APIMe: + response = await self._http_client.get( + self._create_url("/me"), headers=self._get_auth_headers() ) - response = await self._http_client.get(url, headers=self._get_auth_headers()) - data = await self._handle_response(response) - return WeatherStationData.model_validate(data) + return APIMe.model_validate(self._handle_response(response)) diff --git a/src/hvakr/exceptions.py b/src/hvakr/exceptions.py index 38cc7f1..d58acaa 100644 --- a/src/hvakr/exceptions.py +++ b/src/hvakr/exceptions.py @@ -28,6 +28,9 @@ def __init__( super().__init__(message) self.message = message self.status_code = status_code + # ``status`` matches the public v0.6 API client contract; retain + # ``status_code`` for callers upgrading from earlier Python releases. + self.status = status_code self.metadata = metadata def __str__(self) -> str: diff --git a/src/hvakr/schemas/__init__.py b/src/hvakr/schemas/__init__.py index e475f79..8c4ba83 100644 --- a/src/hvakr/schemas/__init__.py +++ b/src/hvakr/schemas/__init__.py @@ -1,154 +1,61 @@ -"""HVAKR API schema definitions.""" +"""Public Pydantic schemas for the HVAKR v0 API.""" -from hvakr.schemas.common import ( - Box, - DisplayUnitSystemId, - Point, - Polygon, - Rect, - Size, -) -from hvakr.schemas.graph import ( - AdjacencyType, - AssociatedAdjacencyData, - AssociatedNodeData, - CentralUnitNodeData, - CircleDuctSize, - CoordinateNodeData, - DuctAdjacencyData, - DuctSize, - DuctSizeType, - FittingNodeData, - FlowType, - Graph, - GraphAdjacency, - GraphNode, - LinkAdjacencyData, - NodeData, - NodeType, - PortalNodeData, - RectangleDuctSize, - RegisterNodeData, - RegisterPlacementType, - RegisterSpecificData, - Selection, - TerminalUnitNodeData, +from hvakr.schemas.api import ( + APIJob, + APIJobCreate, + APIMe, + APIMeOrganization, + APIMePlan, + APIMeRateLimit, + APIMeUser, + APIProduct, + ProductListResponse, + ProjectListItem, + ProjectListResponse, ) +from hvakr.schemas.common import Box, DisplayUnitSystemId, Point, Polygon, Rect, Size +from hvakr.schemas.graph import FlowType, Graph, GraphNode, NodeType from hvakr.schemas.outputs import ( - APIOutputType, - APIProjectOutputDrySideGraph, - APIProjectOutputLoads, - APIProjectOutputRegisterSchedule, - CoolingLoads, - ErrorInfo, - HeatingLoads, - HourlyLoads, - MetaDrySideAdjacencyData, - MetaDrySideDuctAdjacency, - MetaDrySideGraph, - MetaDrySideGraphNode, - MetaDrySideLinkAdjacency, - MetaDrySideNodeData, - MonthlyLoads, - RegisterModelType, - SpaceRegisterScheduleRow, + Airflows, + APIProjectCalculations, + CalcAirflowsSection, + CalcChecksumsSection, + CalcLoadsSection, + CalcVentilationSection, + MaximumModeAirflows, ) from hvakr.schemas.project import ( - BranchTypeData, - BuildingData, + APIReport, CentralUnitConfiguration, CentralUnitDimensionData, - ComputedProjectData, - Constraint, - Contact, - CoolingCoilData, - CoolingCoilType, - CoolingPercent, - CustomScaleInfo, - DeadlineData, - DefaultRegisterType, - DiversityData, - DoorData, - DoorTypeData, - DrySideData, - DuctSizingData, - DuctTypeData, - Edge, - EdgeExposure, - ElbowData, + ComponentConfiguration, EnergyConfiguration, + EnergySchedule, + EquipmentComponent, + EquipmentComponentConfiguration, + EquipmentComponentType, + EquipmentData, + EquipmentInletData, + EquipmentInletMethod, + EquipmentLink, + EquipmentLinkData, + EquipmentMode, ExpandedProject, ExpandedProjectPatch, ExpandedProjectPost, - ExternalShadingData, - FittingsConfig, - HeatingCoilData, - HeatingCoilType, - HeatingPercent, - InfiltrationRequirementMethod, - InternalShadingData, - LengthUnit, - MapSpec, - MapType, - OutsideAirData, + LoadCondition, OutsideAirMethod, - PipeMaterialType, - PipeTypeData, Project, ProjectData, ProjectPost, - ProjectSubcollections, - ProjectType, - ProjectUserData, - ProjectUserRole, - RegisterSpecConstraints, - RegisterTypeData, - ReportData, - ReportFileType, - ReportTemplate, - ReportTemplateOption, - ReturnAirData, - Revision, - RoofTypeData, - SheetAnnotation, - SheetData, - SheetFileData, - SheetPlacementData, - SheetType, - SheetVersionData, - SkylightData, - SlabTypeData, + SpaceAirflowRequirements, SpaceData, - SpaceTypeData, - Standard, - SupplyAirData, + SpaceDesignAirflows, SystemData, TerminalUnitConfiguration, TerminalUnitDimensionData, - TerminalUnitInletSize, - TerminalUnitOutsideAirData, - TerminalUnitOutsideAirMethod, - TerminalUnitSupplyAirData, - TransitionData, - TransitionType, - VentilationStandard, - WallTypeData, - WeatherSpec, - WindowData, - WindowTypeData, - WyeData, - WyeType, ZoneData, ) -from hvakr.schemas.revit import ( - RevitBoundarySegment, - RevitData, - RevitSpaceData, -) -from hvakr.schemas.weather import ( - MonthlyBulbTemps, - WeatherStationData, -) from hvakr.schemas.webhooks import ( KnownWebhookEvent, OpportunityCreatedEvent, @@ -161,156 +68,70 @@ ) __all__ = [ - # Common + "APIJob", + "APIJobCreate", + "APIMe", + "APIMeOrganization", + "APIMePlan", + "APIMeRateLimit", + "APIMeUser", + "APIProduct", + "APIReport", + "APIProjectCalculations", + "Airflows", "Box", - "DisplayUnitSystemId", - "Point", - "Polygon", - "Rect", - "Size", - # Graph - "AdjacencyType", - "AssociatedAdjacencyData", - "AssociatedNodeData", - "CentralUnitNodeData", - "CircleDuctSize", - "CoordinateNodeData", - "DuctAdjacencyData", - "DuctSize", - "DuctSizeType", - "FittingNodeData", - "FlowType", - "Graph", - "GraphAdjacency", - "GraphNode", - "LinkAdjacencyData", - "NodeData", - "NodeType", - "PortalNodeData", - "RectangleDuctSize", - "RegisterNodeData", - "RegisterPlacementType", - "RegisterSpecificData", - "Selection", - "TerminalUnitNodeData", - # Outputs - "APIOutputType", - "APIProjectOutputDrySideGraph", - "APIProjectOutputLoads", - "APIProjectOutputRegisterSchedule", - "CoolingLoads", - "ErrorInfo", - "HeatingLoads", - "HourlyLoads", - "MetaDrySideAdjacencyData", - "MetaDrySideDuctAdjacency", - "MetaDrySideGraph", - "MetaDrySideGraphNode", - "MetaDrySideLinkAdjacency", - "MetaDrySideNodeData", - "MonthlyLoads", - "RegisterModelType", - "SpaceRegisterScheduleRow", - # Project - "BranchTypeData", - "BuildingData", + "CalcAirflowsSection", + "CalcChecksumsSection", + "CalcLoadsSection", + "CalcVentilationSection", "CentralUnitConfiguration", "CentralUnitDimensionData", - "ComputedProjectData", - "Constraint", - "Contact", - "CoolingCoilData", - "CoolingCoilType", - "CoolingPercent", - "CustomScaleInfo", - "DeadlineData", - "DefaultRegisterType", - "DiversityData", - "DoorData", - "DoorTypeData", - "DrySideData", - "DuctSizingData", - "DuctTypeData", - "Edge", - "EdgeExposure", - "ElbowData", + "ComponentConfiguration", + "DisplayUnitSystemId", "EnergyConfiguration", + "EnergySchedule", + "EquipmentComponent", + "EquipmentComponentConfiguration", + "EquipmentComponentType", + "EquipmentData", + "EquipmentInletData", + "EquipmentInletMethod", + "EquipmentLink", + "EquipmentLinkData", + "EquipmentMode", "ExpandedProject", "ExpandedProjectPatch", "ExpandedProjectPost", - "ExternalShadingData", - "FittingsConfig", - "HeatingCoilData", - "HeatingCoilType", - "HeatingPercent", - "InfiltrationRequirementMethod", - "InternalShadingData", - "LengthUnit", - "MapSpec", - "MapType", - "OutsideAirData", + "FlowType", + "Graph", + "GraphNode", + "KnownWebhookEvent", + "LoadCondition", + "MaximumModeAirflows", + "NodeType", + "OpportunityCreatedEvent", + "OpportunityCreatedPayload", "OutsideAirMethod", - "PipeMaterialType", - "PipeTypeData", + "Point", + "Polygon", + "ProductListResponse", "Project", + "ProjectCreatedEvent", + "ProjectCreatedPayload", "ProjectData", + "ProjectListItem", + "ProjectListResponse", "ProjectPost", - "ProjectSubcollections", - "ProjectType", - "ProjectUserData", - "ProjectUserRole", - "RegisterSpecConstraints", - "RegisterTypeData", - "ReportData", - "ReportFileType", - "ReportTemplate", - "ReportTemplateOption", - "ReturnAirData", - "Revision", - "RoofTypeData", - "SheetAnnotation", - "SheetData", - "SheetFileData", - "SheetPlacementData", - "SheetType", - "SheetVersionData", - "SkylightData", - "SlabTypeData", + "Rect", + "Size", + "SpaceAirflowRequirements", "SpaceData", - "SpaceTypeData", - "Standard", - "SupplyAirData", + "SpaceDesignAirflows", "SystemData", "TerminalUnitConfiguration", "TerminalUnitDimensionData", - "TerminalUnitInletSize", - "TerminalUnitOutsideAirData", - "TerminalUnitOutsideAirMethod", - "TerminalUnitSupplyAirData", - "TransitionData", - "TransitionType", - "VentilationStandard", - "WallTypeData", - "WeatherSpec", - "WindowData", - "WindowTypeData", - "WyeData", - "WyeType", - "ZoneData", - # Revit - "RevitBoundarySegment", - "RevitData", - "RevitSpaceData", - # Weather - "MonthlyBulbTemps", - "WeatherStationData", - # Webhook schemas - "KnownWebhookEvent", - "OpportunityCreatedEvent", - "OpportunityCreatedPayload", - "ProjectCreatedEvent", - "ProjectCreatedPayload", "UnknownWebhookEvent", "WebhookEvent", "WebhookEventType", + "ZoneData", ] diff --git a/src/hvakr/schemas/api.py b/src/hvakr/schemas/api.py new file mode 100644 index 0000000..c47a846 --- /dev/null +++ b/src/hvakr/schemas/api.py @@ -0,0 +1,149 @@ +"""Schemas for public HVAKR v0 API resources.""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from hvakr.schemas.common import DisplayUnitSystemId +from hvakr.schemas.project import ProjectType + + +class ProjectListItem(BaseModel): + """A compact project returned by the paginated list endpoint.""" + + id: str + name: str | None = None + number: str | None = None + address: str | None = None + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None + project_type: ProjectType | None = Field(default=None, alias="projectType") + timestamp: float | None = None + last_open_time: float | None = Field(default=None, alias="lastOpenTime") + + model_config = {"populate_by_name": True} + + +class ProjectListResponse(BaseModel): + """A page of project summaries.""" + + projects: list[ProjectListItem] + has_more: bool = Field(alias="hasMore") + next_cursor: str | None = Field(alias="nextCursor") + + model_config = {"populate_by_name": True} + + +class APIJobCreate(BaseModel): + """Parameters for a report, auto-group, check, or auto-takeoff job.""" + + type: Literal["report", "auto-group", "check", "auto-takeoff"] + template: str | None = None + name: str | None = None + display_unit_system_id: DisplayUnitSystemId | None = Field( + default=None, alias="displayUnitSystemId" + ) + scope: Literal["spaces", "zones"] | None = None + entity_ids: list[str] | None = Field(default=None, alias="entityIds") + confidence: float | None = None + levels: list[float] | None = None + roof_type_id: str | None = Field(default=None, alias="roofTypeId") + slab_type_id: str | None = Field(default=None, alias="slabTypeId") + wall_type_id: str | None = Field(default=None, alias="wallTypeId") + window_type_id: str | None = Field(default=None, alias="windowTypeId") + + model_config = {"populate_by_name": True} + + +class APIJob(BaseModel): + """An asynchronous or synchronous job returned by the API.""" + + job_id: str = Field(alias="jobId") + type: Literal["report", "auto-group", "check", "auto-takeoff"] + status: Literal["queued", "running", "completed", "failed"] + result: dict[str, Any] | None = None + error: str | None = None + + model_config = {"populate_by_name": True} + + +class APIProductFile(BaseModel): + """A file attached to a catalog product.""" + + name: str + url: str + + +class APIProduct(BaseModel): + """A product available to the authenticated organization.""" + + id: str + name: str + manufacturer: str | None = None + model: str | None = None + description: str | None = None + type: str | None = None + price: float | None = None + image_url: str | None = Field(default=None, alias="imageUrl") + specifications: dict[str, Any] | None = None + files: dict[str, APIProductFile] | None = None + + model_config = {"populate_by_name": True} + + +class ProductListResponse(BaseModel): + """A page of products.""" + + products: list[APIProduct] + has_more: bool = Field(alias="hasMore") + next_cursor: str | None = Field(alias="nextCursor") + + model_config = {"populate_by_name": True} + + +class APIMeUser(BaseModel): + """The authenticated API caller.""" + + id: str + email: str + first_name: str | None = Field(default=None, alias="firstName") + last_name: str | None = Field(default=None, alias="lastName") + license: Literal["enterprise", "payPerProject", "solo", "team", "university"] + + model_config = {"populate_by_name": True} + + +class APIMeOrganization(BaseModel): + """An organization membership of the authenticated caller.""" + + id: str + name: str | None = None + domain: str | None = None + role: Literal[1, 7, 10] + + +class APIMePlan(BaseModel): + """Plan entitlements for the caller.""" + + license: Literal["enterprise", "payPerProject", "solo", "team", "university"] + api_access: bool = Field(alias="apiAccess") + + model_config = {"populate_by_name": True} + + +class APIMeRateLimit(BaseModel): + """Rate-limit budget for the caller.""" + + limit_per_minute: float = Field(alias="limitPerMinute") + + model_config = {"populate_by_name": True} + + +class APIMe(BaseModel): + """Authenticated user, organization, plan, and rate-limit information.""" + + user: APIMeUser + organizations: list[APIMeOrganization] + plan: APIMePlan + rate_limit: APIMeRateLimit = Field(alias="rateLimit") + + model_config = {"populate_by_name": True} diff --git a/src/hvakr/schemas/outputs.py b/src/hvakr/schemas/outputs.py index 9f4b88b..80af93a 100644 --- a/src/hvakr/schemas/outputs.py +++ b/src/hvakr/schemas/outputs.py @@ -329,3 +329,114 @@ class APIProjectOutputDrySideGraph(BaseModel): errors: list[ErrorInfo] model_config = {"populate_by_name": True} + + +# Unified calculation response schemas (v0.4+) +class Airflows(BaseModel): + """Supply, return, outside, relief, and exhaust airflow totals.""" + + exhaust: float + outside: float + relief: float + return_air: float = Field(alias="return") + supply: float + + model_config = {"populate_by_name": True} + + +class ModeAirflows(BaseModel): + """Design and required airflows for one equipment mode.""" + + airflow_differential: dict[str, float] = Field(alias="airflowDifferential") + design: Airflows + required: Airflows + space_peaks_sum: Airflows = Field(alias="spacePeaksSum") + supply_sources: dict[str, float] = Field(alias="supplySources") + month_hour: dict[str, float] | None = Field(default=None, alias="monthHour") + + model_config = {"populate_by_name": True} + + +class MaximumModeAirflows(BaseModel): + """The peak design and required airflows across the project's modes.""" + + design: Airflows + required: Airflows + + +class ProjectScopeAirflows(BaseModel): + """Mode-keyed airflows for a project, space, zone, or system.""" + + by_mode: dict[str, ModeAirflows] = Field(alias="byMode") + calculated_outside_airflow: dict[str, float] = Field(alias="calculatedOutsideAirflow") + max: MaximumModeAirflows + required_outside_airflow_components: dict[str, Any] = Field( + alias="requiredOutsideAirflowComponents" + ) + + model_config = {"populate_by_name": True} + + +class CalcLoadsSection(BaseModel): + """Cooling and heating loads for each project scope.""" + + space_cooling_loads: dict[str, CoolingLoads] = Field(alias="spaceCoolingLoads") + space_heating_loads: dict[str, HeatingLoads] = Field(alias="spaceHeatingLoads") + system_cooling_loads: dict[str, CoolingLoads] = Field(alias="systemCoolingLoads") + system_heating_loads: dict[str, HeatingLoads] = Field(alias="systemHeatingLoads") + zone_cooling_loads: dict[str, CoolingLoads] = Field(alias="zoneCoolingLoads") + zone_heating_loads: dict[str, HeatingLoads] = Field(alias="zoneHeatingLoads") + + model_config = {"populate_by_name": True} + + +class CalcVentilationSection(BaseModel): + """Ventilation results keyed by project scope and equipment id.""" + + project: dict[str, Any] | None = None + spaces: dict[str, dict[str, Any]] + systems: dict[str, dict[str, Any]] + zones: dict[str, dict[str, Any]] + equipment: dict[str, dict[str, Any]] + + +class CalcChecksumsSection(BaseModel): + """Mode-keyed design checksums for each scope and equipment item.""" + + project: dict[str, dict[str, float | None]] | None = None + spaces: dict[str, dict[str, dict[str, float | None]]] + systems: dict[str, dict[str, dict[str, float | None]]] + zones: dict[str, dict[str, dict[str, float | None]]] + equipment: dict[str, dict[str, dict[str, float | None]]] + + +class CalcAirflowsSection(BaseModel): + """Mode-keyed airflow results for every project scope.""" + + project: ProjectScopeAirflows | None = None + spaces: dict[str, ProjectScopeAirflows] + systems: dict[str, ProjectScopeAirflows] + zones: dict[str, ProjectScopeAirflows] + + +class APIProjectCalculations(BaseModel): + """Response from ``GET /projects/{id}/calculations``. + + ``equipment`` intentionally preserves component pipeline data verbatim: + pipeline components are extensible and are keyed by project-defined mode + and component ids. + """ + + errors: list[ErrorInfo] + flags: dict[str, list[dict[str, Any]]] + loads: CalcLoadsSection | None = None + register_schedule: list[SpaceRegisterScheduleRow] | None = Field( + default=None, alias="registerSchedule" + ) + dry_side_graph: MetaDrySideGraph | None = Field(default=None, alias="drySideGraph") + ventilation: CalcVentilationSection | None = None + equipment: dict[str, dict[str, Any]] | None = None + checksums: CalcChecksumsSection | None = None + airflows: CalcAirflowsSection | None = None + + model_config = {"populate_by_name": True} diff --git a/src/hvakr/schemas/project.py b/src/hvakr/schemas/project.py index 4b9a3ee..ac2a37a 100644 --- a/src/hvakr/schemas/project.py +++ b/src/hvakr/schemas/project.py @@ -1,7 +1,7 @@ """Project schema definitions.""" from enum import Enum -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field @@ -76,13 +76,43 @@ class EdgeExposure(str, Enum): W = "W" -class OutsideAirMethod(int, Enum): +class OutsideAirMethod(str, Enum): """Method for calculating outside air.""" - SUM_OF_SPACES = 0 - PERCENT = 1 - CUSTOM = 2 - MULTI_ZONE = 3 + SUM_OF_SPACES = "SUM_OF_SPACES" + PERCENT = "PERCENT" + CUSTOM = "CUSTOM" + MULTI_ZONE = "MULTI_ZONE" + + +class LoadCondition(str, Enum): + """The design condition represented by an equipment mode.""" + + COOLING = "COOLING" + HEATING = "HEATING" + + +class EquipmentComponentType(str, Enum): + """Component types supported by a modular equipment configuration.""" + + OUTSIDE_AIR_INTAKE = "OUTSIDE_AIR_INTAKE" + ENERGY_RECOVERY_UNIT = "ENERGY_RECOVERY_UNIT" + RETURN_AIR_INTAKE = "RETURN_AIR_INTAKE" + COOLING_COIL = "COOLING_COIL" + HEATING_COIL = "HEATING_COIL" + EQUIPMENT_INEFFICIENCY = "EQUIPMENT_INEFFICIENCY" + HUMIDIFIER = "HUMIDIFIER" + DEHUMIDIFIER = "DEHUMIDIFIER" + EQUIPMENT_INLET = "EQUIPMENT_INLET" + EQUIPMENT_OUTLET = "EQUIPMENT_OUTLET" + + +class EquipmentInletMethod(str, Enum): + """Methods for configuring a terminal unit's upstream inlet airflow.""" + + SUM_OF_SPACES_OA = "SUM_OF_SPACES_OA" + PERCENT_SUPPLY = "PERCENT_SUPPLY" + CUSTOM = "CUSTOM" class CoolingCoilType(int, Enum): @@ -163,6 +193,8 @@ class ReportFileType(str, Enum): PDF = "PDF" CSV = "CSV" + DOCX = "DOCX" + ZIP = "ZIP" class LengthUnit(str, Enum): @@ -289,6 +321,68 @@ class ProjectUserData(BaseModel): model_config = {"populate_by_name": True} +class EquipmentMode(BaseModel): + """A project-wide operating mode used by all equipment configurations.""" + + id: str + load_condition: LoadCondition = Field(alias="loadCondition") + name: str + description: str + + model_config = {"populate_by_name": True} + + +class UpstreamEquipmentLinkEndpoint(BaseModel): + """The outlet selected on an upstream equipment item.""" + + id: str + outlet_id: str = Field(alias="outletId") + + model_config = {"populate_by_name": True} + + +class EquipmentLinkData(BaseModel): + """Connects one equipment outlet to another equipment inlet.""" + + upstream_equipment: UpstreamEquipmentLinkEndpoint = Field(alias="upstreamEquipment") + downstream_equipment: "DownstreamEquipmentLinkEndpoint" = Field(alias="downstreamEquipment") + + model_config = {"populate_by_name": True} + + +class DownstreamEquipmentLinkEndpoint(BaseModel): + """The inlet selected on a downstream equipment item.""" + + id: str + inlet_id: str = Field(alias="inletId") + + model_config = {"populate_by_name": True} + + +class EquipmentLink(EquipmentLinkData): + """A persisted equipment link.""" + + id: str + + +DEFAULT_COOLING_MODE_ID = "cooling_mode" +DEFAULT_HEATING_MODE_ID = "heating_mode" +DEFAULT_EQUIPMENT_MODES: dict[str, EquipmentMode] = { + DEFAULT_COOLING_MODE_ID: EquipmentMode( + id=DEFAULT_COOLING_MODE_ID, + loadCondition=LoadCondition.COOLING, + name="Cooling", + description="", + ), + DEFAULT_HEATING_MODE_ID: EquipmentMode( + id=DEFAULT_HEATING_MODE_ID, + loadCondition=LoadCondition.HEATING, + name="Heating", + description="", + ), +} + + # Fittings configuration class ElbowData(BaseModel): """Elbow fitting data.""" @@ -454,42 +548,22 @@ class Edge(BaseModel): class SpaceData(BaseModel): """Space data.""" - air_transfer_in: float | None = Field(default=None, alias="airTransferIn") - air_transfer_out: float | None = Field(default=None, alias="airTransferOut") + design_airflows_by_mode: dict[str, "SpaceDesignAirflows"] | None = Field( + default=None, alias="designAirflowsByMode" + ) + airflow_requirements_by_load_condition: ( + dict[LoadCondition, "SpaceAirflowRequirements"] | None + ) = Field(default=None, alias="airflowRequirementsByLoadCondition") apply_roof_load_to_ceiling: bool | None = Field(default=None, alias="applyRoofLoadToCeiling") ceiling_height: float | None = Field(default=None, alias="ceilingHeight") - custom_exhaust: float | None = Field(default=None, alias="customExhaust") - custom_outside_airflow: float | None = Field(default=None, alias="customOutsideAirflow") - custom_return: float | None = Field(default=None, alias="customReturn") - custom_supply: float | None = Field(default=None, alias="customSupply") - description: str | None = None + creation_source: Literal[ + "API", "API_REVIT", "AUTO", "LEGACY", "MERGE", "PASTE", "POLYGON", "RECTANGLE", "SPLIT" + ] = Field(alias="creationSource") edges: dict[str, Edge] exhaust_units: float | None = Field(default=None, alias="exhaustUnits") - infiltration_ach_req: float | None = Field(default=None, alias="infiltrationAchReq") - infiltration_area_req: float | None = Field(default=None, alias="infiltrationAreaReq") - infiltration_flow_rate_req: float | None = Field(default=None, alias="infiltrationFlowRateReq") - infiltration_lf_req: float | None = Field(default=None, alias="infiltrationLfReq") - infiltration_req_method: InfiltrationRequirementMethod | None = Field( - default=None, alias="infiltrationReqMethod" - ) infiltration_use_separate_winter_reqs: bool | None = Field( default=None, alias="infiltrationUseSeparateWinterReqs" ) - infiltration_winter_ach_req: float | None = Field( - default=None, alias="infiltrationWinterAchReq" - ) - infiltration_winter_area_req: float | None = Field( - default=None, alias="infiltrationWinterAreaReq" - ) - infiltration_winter_flow_rate_req: float | None = Field( - default=None, alias="infiltrationWinterFlowRateReq" - ) - infiltration_winter_lf_req: float | None = Field( - default=None, alias="infiltrationWinterLfReq" - ) - infiltration_winter_req_method: InfiltrationRequirementMethod | None = Field( - default=None, alias="infiltrationWinterReqMethod" - ) level: int misc_heating_load: float | None = Field(default=None, alias="miscHeatingLoad") misc_latent_cooling_load: float | None = Field(default=None, alias="miscLatentCoolingLoad") @@ -497,14 +571,19 @@ class SpaceData(BaseModel): name: str | None = None number: str | None = None occupancy: float | None = None + processed: bool | None = None revit_id: str | None = Field(default=None, alias="revitId") - roof_direction: EdgeExposure | None = Field(default=None, alias="roofDirection") + roof_azimuth: float | None = Field(default=None, alias="roofAzimuth") roof_pitch: float | None = Field(default=None, alias="roofPitch") roof_type_id: str | None = Field(default=None, alias="roofTypeId") skylights: dict[str, SkylightData] | None = None slab_height: float | None = Field(default=None, alias="slabHeight") slab_type_id: str | None = Field(default=None, alias="slabTypeId") space_type_id: str | None = Field(default=None, alias="spaceTypeId") + space_name_and_number_input_hash: str | None = Field( + default=None, alias="spaceNameAndNumberInputHash" + ) + suggested: bool | None = None suggested_space_name: str | None = Field(default=None, alias="suggestedSpaceName") suggested_space_number: str | None = Field(default=None, alias="suggestedSpaceNumber") zone_id: str | None = Field(default=None, alias="zoneId") @@ -512,53 +591,93 @@ class SpaceData(BaseModel): model_config = {"populate_by_name": True} -# System configuration -class SupplyAirData(BaseModel): - """Supply air data.""" +# Modular equipment configuration +class SpaceDesignAirflows(BaseModel): + """Per-mode design airflow overrides for a space.""" - cooling_temperature: float | None = Field(default=None, alias="coolingTemperature") - custom_supply_in: float | None = Field(default=None, alias="customSupplyIn") - duct_heat_gain: float | None = Field(default=None, alias="ductHeatGain") - duct_leakage_percent: float | None = Field(default=None, alias="ductLeakagePercent") - heating_temperature: float | None = Field(default=None, alias="heatingTemperature") + air_transfer_in: float | None = Field(default=None, alias="airTransferIn") + air_transfer_out: float | None = Field(default=None, alias="airTransferOut") + exhaust_air: float | None = Field(default=None, alias="exhaustAir") + outside_air: float | None = Field(default=None, alias="outsideAir") + return_air: float | None = Field(default=None, alias="returnAir") + supply_air: float | None = Field(default=None, alias="supplyAir") model_config = {"populate_by_name": True} -class CoolingCoilData(BaseModel): - """Cooling coil data.""" +class SpaceAirflowRequirements(BaseModel): + """Per-load-condition ventilation and infiltration overrides for a space.""" - chilled_water_delta_t: float | None = Field(default=None, alias="chilledWaterDeltaT") - type: CoolingCoilType | None = None + infiltration_ach_req: float | None = Field(default=None, alias="infiltrationAchReq") + infiltration_area_req: float | None = Field(default=None, alias="infiltrationAreaReq") + infiltration_flow_rate_req: float | None = Field(default=None, alias="infiltrationFlowRateReq") + infiltration_lf_req: float | None = Field(default=None, alias="infiltrationLfReq") + infiltration_req_method: InfiltrationRequirementMethod | None = Field( + default=None, alias="infiltrationReqMethod" + ) + ventilation_req: float | None = Field(default=None, alias="ventilationReq") model_config = {"populate_by_name": True} -class HeatingCoilData(BaseModel): - """Heating coil data.""" +class EquipmentComponent(BaseModel): + """A component in the ordered equipment pipeline.""" - heating_water_delta_t: float | None = Field(default=None, alias="heatingWaterDeltaT") - type: HeatingCoilType | None = None + id: str + type: EquipmentComponentType - model_config = {"populate_by_name": True} +class EquipmentComponentConfiguration(BaseModel): + """Configuration for one component in one operating mode. -class ReturnAirData(BaseModel): - """Return air data.""" + The component type determines which optional fields apply. Keeping the + shared wire fields together mirrors the API's component registry while + retaining an extensible model for newly introduced components. + """ + component_type: EquipmentComponentType = Field(alias="componentType") + method: OutsideAirMethod | EquipmentInletMethod | None = None + percentage: float | None = None + flow_rate: float | None = Field(default=None, alias="flowRate") duct_heat_gain: float | None = Field(default=None, alias="ductHeatGain") duct_leakage_percent: float | None = Field(default=None, alias="ductLeakagePercent") + erv_wheel_effectiveness: float | None = Field(default=None, alias="ervWheelEffectiveness") + relief_enabled: bool | None = Field(default=None, alias="reliefEnabled") + target_temperature: float | None = Field(default=None, alias="targetTemperature") + type: int | None = None + water_delta_t: float | None = Field(default=None, alias="waterDeltaT") + sensible_heat_gain: dict[str, Any] | None = Field(default=None, alias="sensibleHeatGain") + decoupled: bool | None = None - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "allow"} -class OutsideAirData(BaseModel): - """Outside air data.""" +class ComponentConfiguration(BaseModel): + """Enabled state and configuration of a component for a single mode.""" - custom: float | None = None - method: OutsideAirMethod | None = None - percentage: float | None = None - previous_method: OutsideAirMethod | None = Field(default=None, alias="previousMethod") + enabled: bool + configuration: EquipmentComponentConfiguration + + +class EquipmentInletData(BaseModel): + """Terminal-unit inlet configuration shared by all modes.""" + + enabled: bool + configuration: EquipmentComponentConfiguration + + +class EquipmentData(BaseModel): + """Shared modular equipment configuration for central and terminal units.""" + + components: list[EquipmentComponent] | None = None + component_configs_by_mode: dict[str, dict[str, ComponentConfiguration]] | None = Field( + default=None, alias="componentConfigsByMode" + ) + duct_heat_gain: float | None = Field(default=None, alias="ductHeatGain") + duct_leakage_percent: float | None = Field(default=None, alias="ductLeakagePercent") + misc_inefficiencies: float | None = Field(default=None, alias="miscInefficiencies") + pressure_loss: float | None = Field(default=None, alias="pressureLoss") + inlet_data: EquipmentInletData | None = Field(default=None, alias="inletData") model_config = {"populate_by_name": True} @@ -578,52 +697,52 @@ class CentralUnitDimensionData(BaseModel): width: float | None = None -class CentralUnitConfiguration(BaseModel): - """Central unit configuration.""" +class EnergySchedule(BaseModel): + """Operating schedule used for central-unit energy calculations.""" - cooling_coil: bool | None = Field(default=None, alias="coolingCoil") - cooling_coil_data: CoolingCoilData | None = Field(default=None, alias="coolingCoilData") - custom_relief_air: float | None = Field(default=None, alias="customReliefAir") - dimension_data: CentralUnitDimensionData | None = Field(default=None, alias="dimensionData") - diversity_data: DiversityData | None = Field(default=None, alias="diversityData") - erv_wheel: bool | None = Field(default=None, alias="ervWheel") - erv_wheel_effectiveness: float | None = Field(default=None, alias="ervWheelEffectiveness") - fan_motor_heat_gain: float | None = Field(default=None, alias="fanMotorHeatGain") - heating_coil: bool | None = Field(default=None, alias="heatingCoil") - heating_coil_data: HeatingCoilData | None = Field(default=None, alias="heatingCoilData") - misc_inefficiencies: float | None = Field(default=None, alias="miscInefficiencies") - outside_air: bool | None = Field(default=None, alias="outsideAir") - outside_air_data: OutsideAirData | None = Field(default=None, alias="outsideAirData") - pressure_loss: float | None = Field(default=None, alias="pressureLoss") - return_air: bool | None = Field(default=None, alias="returnAir") - return_air_data: ReturnAirData | None = Field(default=None, alias="returnAirData") - supply_air_data: SupplyAirData | None = Field(default=None, alias="supplyAirData") + occupied_hours: dict[str, Any] | None = Field(default=None, alias="occupiedHours") + warmup_hours: float | None = Field(default=None, alias="warmupHours") + warmup_multiplier: float | None = Field(default=None, alias="warmupMultiplier") model_config = {"populate_by_name": True} -class EnergyConfiguration(BaseModel): - """Energy configuration.""" +class EquipmentEfficiency(BaseModel): + """Central-unit heating and cooling efficiency inputs.""" - efficiency: float | None = None - energy_type: Literal["electric", "gas"] | None = Field(default=None, alias="energyType") - name: str | None = None - use_factor: float | None = Field(default=None, alias="useFactor") + heating_type: Literal["heatPump", "gasFurnace"] | None = Field( + default=None, alias="heatingType" + ) + cooling_seer: float | None = Field(default=None, alias="coolingSeer") + heating_cop: float | None = Field(default=None, alias="heatingCop") + heating_afue: float | None = Field(default=None, alias="heatingAfue") model_config = {"populate_by_name": True} +class EnergyConfiguration(BaseModel): + """Energy configuration attached to a central equipment configuration.""" + + schedule: EnergySchedule | None = None + efficiency: EquipmentEfficiency | None = None + + +class CentralUnitConfiguration(EquipmentData): + """Modular central-unit equipment configuration.""" + + dimension_data: CentralUnitDimensionData | None = Field(default=None, alias="dimensionData") + energy_configuration: EnergyConfiguration | None = Field( + default=None, alias="energyConfiguration" + ) + + class SystemData(BaseModel): """System data.""" - central_unit_configuration: CentralUnitConfiguration | None = Field( - default=None, alias="centralUnitConfiguration" - ) + equipment_config: CentralUnitConfiguration | None = Field(default=None, alias="equipmentConfig") color: str | None = None configured: bool | None = None - energy_configurations: dict[str, EnergyConfiguration] | None = Field( - default=None, alias="energyConfigurations" - ) + diversity_data: DiversityData | None = Field(default=None, alias="diversityData") name: str | None = None model_config = {"populate_by_name": True} @@ -638,46 +757,10 @@ class TerminalUnitDimensionData(BaseModel): model_config = {"populate_by_name": True} -class TerminalUnitOutsideAirData(BaseModel): - """Terminal unit outside air data.""" - - custom: float | None = None - method: TerminalUnitOutsideAirMethod | None = None - percentage: float | None = None - previous_method: TerminalUnitOutsideAirMethod | None = Field( - default=None, alias="previousMethod" - ) +class TerminalUnitConfiguration(EquipmentData): + """Modular terminal-unit equipment configuration.""" - model_config = {"populate_by_name": True} - - -class TerminalUnitSupplyAirData(BaseModel): - """Terminal unit supply air data.""" - - cooling_temperature: float | None = Field(default=None, alias="coolingTemperature") - custom_supply_in: float | None = Field(default=None, alias="customSupplyIn") - heating_temperature: float | None = Field(default=None, alias="heatingTemperature") - - model_config = {"populate_by_name": True} - - -class TerminalUnitConfiguration(BaseModel): - """Terminal unit configuration.""" - - cooling_coil: bool | None = Field(default=None, alias="coolingCoil") - cooling_coil_data: CoolingCoilData | None = Field(default=None, alias="coolingCoilData") - custom_return_in: float | None = Field(default=None, alias="customReturnIn") dimension_data: TerminalUnitDimensionData | None = Field(default=None, alias="dimensionData") - heating_coil: bool | None = Field(default=None, alias="heatingCoil") - heating_coil_data: HeatingCoilData | None = Field(default=None, alias="heatingCoilData") - outside_air_data: TerminalUnitOutsideAirData | None = Field( - default=None, alias="outsideAirData" - ) - pressure_loss: float | None = Field(default=None, alias="pressureLoss") - return_air: bool | None = Field(default=None, alias="returnAir") - supply_air_data: TerminalUnitSupplyAirData | None = Field(default=None, alias="supplyAirData") - - model_config = {"populate_by_name": True} class ZoneData(BaseModel): @@ -685,11 +768,11 @@ class ZoneData(BaseModel): color: str | None = None configured: bool | None = None + equipment_config: TerminalUnitConfiguration | None = Field( + default=None, alias="equipmentConfig" + ) name: str | None = None system_id: str | None = Field(default=None, alias="systemId") - terminal_unit_configuration: TerminalUnitConfiguration | None = Field( - default=None, alias="terminalUnitConfiguration" - ) model_config = {"populate_by_name": True} @@ -804,6 +887,20 @@ class ReportTemplate(BaseModel): model_config = {"populate_by_name": True} +class APIReport(BaseModel): + """Public report projection returned by an expanded project.""" + + id: str + name: str + status: Literal["pending", "completed", "failed"] + download_url: str | None = Field(default=None, alias="downloadUrl") + date: float + output_file_type: ReportFileType | None = Field(default=None, alias="outputFileType") + progress: float | None = None + + model_config = {"populate_by_name": True} + + class ReportData(BaseModel): """Report data.""" @@ -948,9 +1045,7 @@ class SpaceTypeData(BaseModel): infiltration_winter_area_req: float | None = Field( default=None, alias="infiltrationWinterAreaReq" ) - infiltration_winter_lf_req: float | None = Field( - default=None, alias="infiltrationWinterLfReq" - ) + infiltration_winter_lf_req: float | None = Field(default=None, alias="infiltrationWinterLfReq") lighting_ceiling_load_percent: float | None = Field( default=None, alias="lightingCeilingLoadPercent" ) @@ -982,12 +1077,8 @@ class WallTypeData(BaseModel): """Wall type data.""" ashrae_wall_type_id: str | None = Field(default=None, alias="ashraeWallTypeId") - below_grade_cooling_temp_f: float | None = Field( - default=None, alias="belowGradeCoolingTempF" - ) - below_grade_heating_temp_f: float | None = Field( - default=None, alias="belowGradeHeatingTempF" - ) + below_grade_cooling_temp_f: float | None = Field(default=None, alias="belowGradeCoolingTempF") + below_grade_heating_temp_f: float | None = Field(default=None, alias="belowGradeHeatingTempF") color: str | None = None name: str | None = None surface_absorptance: float | None = Field(default=None, alias="surfaceAbsorptance") @@ -1009,9 +1100,7 @@ class WindowTypeData(BaseModel): infiltration_winter_area_req: float | None = Field( default=None, alias="infiltrationWinterAreaReq" ) - infiltration_winter_lf_req: float | None = Field( - default=None, alias="infiltrationWinterLfReq" - ) + infiltration_winter_lf_req: float | None = Field(default=None, alias="infiltrationWinterLfReq") name: str | None = None shgc: float | None = None timestamp: float | None = None @@ -1024,12 +1113,9 @@ class WindowTypeData(BaseModel): class ProjectData(BaseModel): """Project data.""" - # Computed fields - owner: str | None = Field(default=None, alias="_owner") - user_emails: list[str] | None = Field(default=None, alias="_userEmails") - # Core fields address: str | None = None + airflow_increment: int | None = Field(default=None, alias="airflowIncrement", ge=1) api_created: bool | None = Field(default=None, alias="apiCreated") building: BuildingData | None = None constraints: dict[str, Constraint] | None = None @@ -1039,18 +1125,15 @@ class ProjectData(BaseModel): contacts: dict[str, Contact] | None = None description: str | None = None dry_side: DrySideData | None = Field(default=None, alias="drySide") - duplicated_from: str | None = Field(default=None, alias="duplicatedFrom") elevation: float | None = None - from_example: str | None = Field(default=None, alias="fromExample") - is_archived: bool | None = Field(default=None, alias="isArchived") - is_deleted: bool | None = Field(default=None, alias="isDeleted") - is_example: bool | None = Field(default=None, alias="isExample") - is_hvakr_template: bool | None = Field(default=None, alias="isHVAKRTemplate") + equipment_modes: dict[str, EquipmentMode] = Field(alias="equipmentModes") + is_healthcare: bool | None = Field(default=None, alias="isHealthcare") + is_open: bool | None = Field(default=None, alias="isOpen") is_template: bool | None = Field(default=None, alias="isTemplate") last_open_time: float | None = Field(default=None, alias="lastOpenTime") latitude: float | None = None longitude: float | None = None - map_spec: MapSpec | None = Field(default=None, alias="mapSpec") + maps: dict[str, MapSpec] | None = None name: str number: str | None = None picture_thumbnail_url: str | None = Field(default=None, alias="pictureThumbnailURL") @@ -1061,6 +1144,7 @@ class ProjectData(BaseModel): revisions: dict[str, Revision] | None = None sheet_markers: dict[str, Point] | None = Field(default=None, alias="sheetMarkers") standards: dict[str, Standard] | None = None + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None timestamp: float | None = None unit_system: DisplayUnitSystemId | None = Field(default=None, alias="unitSystem") users: dict[str, ProjectUserData] @@ -1089,7 +1173,31 @@ class ProjectSubcollections(BaseModel): graph: Graph | None = None pipe_types: dict[str, PipeTypeData] | None = Field(default=None, alias="pipeTypes") register_types: dict[str, RegisterTypeData] | None = Field(default=None, alias="registerTypes") - reports: dict[str, ReportData] | None = None + reports: dict[str, APIReport] | None = None + roof_types: dict[str, RoofTypeData] | None = Field(default=None, alias="roofTypes") + sheet_files: dict[str, SheetFileData] | None = Field(default=None, alias="sheetFiles") + sheets: dict[str, SheetData] | None = None + slab_types: dict[str, SlabTypeData] | None = Field(default=None, alias="slabTypes") + space_types: dict[str, SpaceTypeData] | None = Field(default=None, alias="spaceTypes") + spaces: dict[str, SpaceData] | None = None + systems: dict[str, SystemData] | None = None + wall_types: dict[str, WallTypeData] | None = Field(default=None, alias="wallTypes") + window_types: dict[str, WindowTypeData] | None = Field(default=None, alias="windowTypes") + zones: dict[str, ZoneData] | None = None + + model_config = {"populate_by_name": True} + + +class WritableProjectSubcollections(BaseModel): + """Project subcollections accepted by create and update requests.""" + + branch_types: dict[str, BranchTypeData] | None = Field(default=None, alias="branchTypes") + deadlines: dict[str, DeadlineData] | None = None + door_types: dict[str, DoorTypeData] | None = Field(default=None, alias="doorTypes") + duct_types: dict[str, DuctTypeData] | None = Field(default=None, alias="ductTypes") + graph: Graph | None = None + pipe_types: dict[str, PipeTypeData] | None = Field(default=None, alias="pipeTypes") + register_types: dict[str, RegisterTypeData] | None = Field(default=None, alias="registerTypes") roof_types: dict[str, RoofTypeData] | None = Field(default=None, alias="roofTypes") sheet_files: dict[str, SheetFileData] | None = Field(default=None, alias="sheetFiles") sheets: dict[str, SheetData] | None = None @@ -1115,10 +1223,9 @@ class ProjectPost(BaseModel): # Optional because project can be created with default name name: str | None = None - # Optional because project can be created with default users - users: dict[str, ProjectUserData] | None = None # All other ProjectData fields are optional for POST address: str | None = None + airflow_increment: int | None = Field(default=None, alias="airflowIncrement", ge=1) api_created: bool | None = Field(default=None, alias="apiCreated") building: BuildingData | None = None constraints: dict[str, Constraint] | None = None @@ -1128,12 +1235,14 @@ class ProjectPost(BaseModel): contacts: dict[str, Contact] | None = None description: str | None = None dry_side: DrySideData | None = Field(default=None, alias="drySide") + equipment_modes: dict[str, EquipmentMode] | None = Field(default=None, alias="equipmentModes") elevation: float | None = None latitude: float | None = None longitude: float | None = None - map_spec: MapSpec | None = Field(default=None, alias="mapSpec") + maps: dict[str, MapSpec] | None = None number: str | None = None project_type: ProjectType | None = Field(default=None, alias="projectType") + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None unit_system: DisplayUnitSystemId | None = Field(default=None, alias="unitSystem") ventilation_standard: VentilationStandard | None = Field( default=None, alias="ventilationStandard" @@ -1144,7 +1253,7 @@ class ProjectPost(BaseModel): model_config = {"populate_by_name": True} -class ExpandedProjectPost(ProjectPost, ProjectSubcollections): +class ExpandedProjectPost(ProjectPost, WritableProjectSubcollections): """Data for creating a new expanded project.""" pass @@ -1155,8 +1264,8 @@ class ExpandedProjectPatch(BaseModel): # All fields from ExpandedProject but optional name: str | None = None - users: dict[str, ProjectUserData] | None = None address: str | None = None + airflow_increment: int | None = Field(default=None, alias="airflowIncrement", ge=1) building: BuildingData | None = None constraints: dict[str, Constraint] | None = None construction_type: Literal["New", "Retrofit"] | None = Field( @@ -1165,12 +1274,14 @@ class ExpandedProjectPatch(BaseModel): contacts: dict[str, Contact] | None = None description: str | None = None dry_side: DrySideData | None = Field(default=None, alias="drySide") + equipment_modes: dict[str, EquipmentMode] | None = Field(default=None, alias="equipmentModes") elevation: float | None = None latitude: float | None = None longitude: float | None = None - map_spec: MapSpec | None = Field(default=None, alias="mapSpec") + maps: dict[str, MapSpec] | None = None number: str | None = None project_type: ProjectType | None = Field(default=None, alias="projectType") + status: Literal["new", "inProgress", "inReview", "done", "archived"] | None = None unit_system: DisplayUnitSystemId | None = Field(default=None, alias="unitSystem") ventilation_standard: VentilationStandard | None = Field( default=None, alias="ventilationStandard" @@ -1185,7 +1296,6 @@ class ExpandedProjectPatch(BaseModel): graph: Graph | None = None pipe_types: dict[str, PipeTypeData] | None = Field(default=None, alias="pipeTypes") register_types: dict[str, RegisterTypeData] | None = Field(default=None, alias="registerTypes") - reports: dict[str, ReportData] | None = None roof_types: dict[str, RoofTypeData] | None = Field(default=None, alias="roofTypes") sheet_files: dict[str, SheetFileData] | None = Field(default=None, alias="sheetFiles") sheets: dict[str, SheetData] | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 4b63c1c..5db26c7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,89 +1,78 @@ -"""Tests for HVAKRClient.""" +"""Tests for the HVAKR v0.6 client surface.""" import pytest from pytest_httpx import HTTPXMock -from hvakr import ( - AsyncHVAKRClient, - HVAKRClient, - HVAKRClientError, - Project, - WeatherStationData, -) +from hvakr import APIProjectCalculations, AsyncHVAKRClient, HVAKRClient, HVAKRClientError, Project +from hvakr.schemas import APIJob, EquipmentMode, LoadCondition, SpaceData, TerminalUnitConfiguration + +DEFAULT_EQUIPMENT_MODES = { + "cooling_mode": { + "id": "cooling_mode", + "loadCondition": "COOLING", + "name": "Cooling", + "description": "", + }, + "heating_mode": { + "id": "heating_mode", + "loadCondition": "HEATING", + "name": "Heating", + "description": "", + }, +} class TestHVAKRClient: - """Tests for synchronous HVAKRClient.""" + """Tests for the synchronous client.""" - def test_create_url_basic(self) -> None: - """Test URL creation without query params.""" + def test_create_url_encodes_query_values_and_flags(self) -> None: client = HVAKRClient(access_token="test-token") - url = client._create_url("/projects") - assert url == "https://api.hvakr.com/v0/projects" - - def test_create_url_with_string_params(self) -> None: - """Test URL creation with string query params.""" - client = HVAKRClient(access_token="test-token") - url = client._create_url("/weather-stations", {"latitude": "40.7", "longitude": "-74.0"}) - assert url == "https://api.hvakr.com/v0/weather-stations?latitude=40.7&longitude=-74.0" - - def test_create_url_with_bool_params(self) -> None: - """Test URL creation with boolean query params.""" - client = HVAKRClient(access_token="test-token") - url = client._create_url("/projects/123", {"expand": True}) - assert url == "https://api.hvakr.com/v0/projects/123?expand" + url = client._create_url( + "/projects/project id", {"search": "A & B", "expand": True, "unused": False} + ) + assert url == "https://api.hvakr.com/v0/projects/project id?search=A%20%26%20B&expand" - def test_create_url_with_false_bool_params(self) -> None: - """Test URL creation with false boolean query params (should be omitted).""" - client = HVAKRClient(access_token="test-token") - url = client._create_url("/projects/123", {"expand": False}) - assert url == "https://api.hvakr.com/v0/projects/123" - - def test_custom_base_url(self) -> None: - """Test client with custom base URL.""" - client = HVAKRClient(access_token="test-token", base_url="https://custom.api.com") - url = client._create_url("/projects") - assert url == "https://custom.api.com/v0/projects" - - def test_custom_version(self) -> None: - """Test client with custom API version.""" - client = HVAKRClient(access_token="test-token", version="v1") - url = client._create_url("/projects") - assert url == "https://api.hvakr.com/v1/projects" - - def test_auth_headers(self) -> None: - """Test authentication headers.""" + def test_auth_headers_identify_the_sdk(self) -> None: client = HVAKRClient(access_token="my-secret-token") - headers = client._get_auth_headers() - assert headers == {"Authorization": "Bearer my-secret-token"} - - def test_context_manager(self) -> None: - """Test client as context manager.""" - with HVAKRClient(access_token="test-token") as client: - assert client._client is None # Client created lazily + assert client._get_auth_headers() == { + "Authorization": "Bearer my-secret-token", + "X-HVAKR-Client": "hvakr-python/0.6.0", + } - def test_list_projects(self, httpx_mock: HTTPXMock) -> None: - """Test listing projects.""" + def test_list_projects_is_paginated_and_filterable(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects", - json={"ids": ["project-1", "project-2", "project-3"]}, + url=( + "https://api.hvakr.com/v0/projects?limit=25&cursor=next&search=office" + "&status=inProgress&projectType=commercial" + ), + json={ + "projects": [{"id": "project-1", "name": "Office", "status": "inProgress"}], + "hasMore": True, + "nextCursor": "again", + }, ) with HVAKRClient(access_token="test-token") as client: - result = client.list_projects() - - assert result == {"ids": ["project-1", "project-2", "project-3"]} - - def test_get_project(self, httpx_mock: HTTPXMock) -> None: - """Test getting a project.""" + result = client.list_projects( + limit=25, + cursor="next", + search="office", + status="inProgress", + project_type="commercial", + ) + + assert result.projects[0].id == "project-1" + assert result.has_more is True + assert result.next_cursor == "again" + + def test_get_project_uses_mode_keyed_project_data(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://api.hvakr.com/v0/projects/project-123", json={ "id": "project-123", "name": "Test Project", - "users": { - "user-1": {"role": 10} - }, + "users": {"user-1": {"role": 10}}, + "equipmentModes": DEFAULT_EQUIPMENT_MODES, }, ) @@ -91,99 +80,86 @@ def test_get_project(self, httpx_mock: HTTPXMock) -> None: result = client.get_project("project-123") assert isinstance(result, Project) - assert result.id == "project-123" - assert result.name == "Test Project" + assert result.equipment_modes["cooling_mode"].load_condition is LoadCondition.COOLING - def test_create_project(self, httpx_mock: HTTPXMock) -> None: - """Test creating a project.""" + def test_get_project_can_expand_selected_subcollections(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects", - json={"id": "new-project-id"}, + url="https://api.hvakr.com/v0/projects/project-123?expand=spaces%2Czones", + json={ + "id": "project-123", + "name": "Test Project", + "users": {"user-1": {"role": 10}}, + "equipmentModes": DEFAULT_EQUIPMENT_MODES, + "spaces": {}, + "zones": {}, + }, ) with HVAKRClient(access_token="test-token") as client: - result = client.create_project({"name": "New Project"}) + result = client.get_project("project-123", ["spaces", "zones"]) - assert result == {"id": "new-project-id"} + assert result.spaces == {} + assert result.zones == {} - def test_update_project(self, httpx_mock: HTTPXMock) -> None: - """Test updating a project.""" - httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects/project-123", - json={"id": "project-123", "name": "Updated Project"}, - ) + def test_write_requests_send_idempotency_key(self, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://api.hvakr.com/v0/projects", json={"id": "new-project"}) with HVAKRClient(access_token="test-token") as client: - result = client.update_project("project-123", {"name": "Updated Project"}) + result = client.create_project({"name": "New Project"}, idempotency_key="create-1") - assert result["name"] == "Updated Project" + request = httpx_mock.get_requests()[0] + assert result == {"id": "new-project"} + assert request.headers["Idempotency-Key"] == "create-1" + assert request.headers["X-HVAKR-Client"] == "hvakr-python/0.6.0" - def test_delete_project(self, httpx_mock: HTTPXMock) -> None: - """Test deleting a project.""" + def test_get_project_calculations_selects_sections(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects/project-123", - json={"deleted": True}, + url="https://api.hvakr.com/v0/projects/project-123/calculations?include=airflows%2Cequipment", + json={"errors": [], "flags": {}, "equipment": {"system-1": {"modes": {}}}}, ) with HVAKRClient(access_token="test-token") as client: - result = client.delete_project("project-123") + result = client.get_project_calculations( + "project-123", include=["airflows", "equipment"] + ) - assert result == {"deleted": True} + assert isinstance(result, APIProjectCalculations) + assert result.equipment == {"system-1": {"modes": {}}} - def test_search_weather_stations(self, httpx_mock: HTTPXMock) -> None: - """Test searching weather stations.""" + def test_job_product_and_me_endpoints(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( - url="https://api.hvakr.com/v0/weather-stations?latitude=40.7128&longitude=-74.006", - json={"weatherStationIds": ["station-1", "station-2"]}, + url="https://api.hvakr.com/v0/projects/project-123/jobs", + json={"jobId": "job-1", "type": "check", "status": "completed", "result": {}}, ) - - with HVAKRClient(access_token="test-token") as client: - result = client.search_weather_stations(40.7128, -74.006) - - assert result == {"weatherStationIds": ["station-1", "station-2"]} - - def test_get_weather_station(self, httpx_mock: HTTPXMock) -> None: - """Test getting weather station data.""" httpx_mock.add_response( - url="https://api.hvakr.com/v0/weather-stations/station-123", + url="https://api.hvakr.com/v0/products?search=fan&limit=10", json={ - "station": "NYC Central Park", - "latitude": 40.7128, - "longitude": -74.006, - "elevation": 10.0, - "climateZone": "4A", - "timezoneOffset": -5, - "averageDailyTemperature": [30, 32, 40, 50, 60, 70, 75, 74, 67, 55, 45, 35], - "stdDevDailyTemperature": [5, 5, 6, 7, 7, 6, 5, 5, 6, 7, 6, 5], - "dbRange": [10, 10, 12, 14, 15, 14, 13, 12, 11, 10, 10, 10], - "wbRange": [8, 8, 9, 10, 11, 10, 9, 9, 8, 8, 8, 8], - "hdd50": [600, 500, 300, 100, 0, 0, 0, 0, 0, 50, 200, 500], - "hdd65": [1000, 900, 700, 400, 150, 20, 0, 0, 50, 250, 500, 900], - "cdd50": [0, 0, 0, 0, 100, 300, 500, 450, 250, 50, 0, 0], - "cdd65": [0, 0, 0, 0, 0, 100, 300, 250, 100, 0, 0, 0], - "cdh74": [0, 0, 0, 0, 50, 200, 400, 350, 150, 0, 0, 0], - "cdh80": [0, 0, 0, 0, 10, 100, 250, 200, 50, 0, 0, 0], - "taub": [0.35, 0.36, 0.40, 0.42, 0.45, 0.47, 0.50, 0.48, 0.44, 0.40, 0.37, 0.35], - "taud": [2.3, 2.2, 2.1, 2.0, 1.9, 1.8, 1.7, 1.8, 2.0, 2.1, 2.2, 2.3], - "dbTempByHeatingPercent": {"99": 10, "99.6": 5}, - "monthlyBulbTempsByCoolingPercent": { - "0.4": {"db": [85, 86, 87], "wb": [70, 71, 72]}, - "2": {"db": [83, 84, 85], "wb": [68, 69, 70]}, - "5": {"db": [81, 82, 83], "wb": [66, 67, 68]}, - "10": {"db": [79, 80, 81], "wb": [64, 65, 66]}, - }, + "products": [{"id": "fan-1", "name": "Fan"}], + "hasMore": False, + "nextCursor": None, + }, + ) + httpx_mock.add_response( + url="https://api.hvakr.com/v0/me", + json={ + "user": {"id": "user-1", "email": "user@example.com", "license": "team"}, + "organizations": [], + "plan": {"license": "team", "apiAccess": True}, + "rateLimit": {"limitPerMinute": 60}, }, ) with HVAKRClient(access_token="test-token") as client: - result = client.get_weather_station("station-123") + job = client.create_job("project-123", {"type": "check"}) + products = client.list_products(search="fan", limit=10) + me = client.me() - assert isinstance(result, WeatherStationData) - assert result.station == "NYC Central Park" - assert result.latitude == 40.7128 + assert isinstance(job, APIJob) + assert job.job_id == "job-1" + assert products.products[0].id == "fan-1" + assert me.rate_limit.limit_per_minute == 60 - def test_error_response(self, httpx_mock: HTTPXMock) -> None: - """Test error handling for failed requests.""" + def test_error_response_exposes_status_and_metadata(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://api.hvakr.com/v0/projects", status_code=401, @@ -195,159 +171,122 @@ def test_error_response(self, httpx_mock: HTTPXMock) -> None: client.list_projects() assert exc_info.value.status_code == 401 + assert exc_info.value.status == 401 assert exc_info.value.metadata == {"error": "Unauthorized", "message": "Invalid token"} class TestAsyncHVAKRClient: - """Tests for asynchronous AsyncHVAKRClient.""" + """Tests for the asynchronous client.""" @pytest.mark.asyncio - async def test_list_projects(self, httpx_mock: HTTPXMock) -> None: - """Test listing projects asynchronously.""" + async def test_list_projects_and_calculations(self, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://api.hvakr.com/v0/projects", - json={"ids": ["project-1", "project-2"]}, + json={"projects": [], "hasMore": False, "nextCursor": None}, ) - - async with AsyncHVAKRClient(access_token="test-token") as client: - result = await client.list_projects() - - assert result == {"ids": ["project-1", "project-2"]} - - @pytest.mark.asyncio - async def test_get_project(self, httpx_mock: HTTPXMock) -> None: - """Test getting a project asynchronously.""" httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects/project-123", - json={ - "id": "project-123", - "name": "Async Test Project", - "users": { - "user-1": {"role": 10} - }, - }, + url="https://api.hvakr.com/v0/projects/project-123/calculations", + json={"errors": [], "flags": {}}, ) async with AsyncHVAKRClient(access_token="test-token") as client: - result = await client.get_project("project-123") + projects = await client.list_projects() + calculations = await client.get_project_calculations("project-123") - assert isinstance(result, Project) - assert result.name == "Async Test Project" - - @pytest.mark.asyncio - async def test_error_response(self, httpx_mock: HTTPXMock) -> None: - """Test error handling for failed async requests.""" - httpx_mock.add_response( - url="https://api.hvakr.com/v0/projects", - status_code=403, - json={"error": "Forbidden"}, - ) - - async with AsyncHVAKRClient(access_token="test-token") as client: - with pytest.raises(HVAKRClientError) as exc_info: - await client.list_projects() - - assert exc_info.value.status_code == 403 + assert projects.projects == [] + assert calculations.errors == [] class TestSchemaValidation: - """Tests for Pydantic schema validation.""" - - def test_project_validation(self) -> None: - """Test Project schema validation.""" - data = { - "id": "project-123", - "name": "Test Project", - "users": { - "user-1": {"role": 10, "firstName": "John", "lastName": "Doe"} - }, - "latitude": 40.7128, - "longitude": -74.006, + """Tests for the modular-equipment data model.""" + + def test_calculations_preserve_airflows_by_project_mode(self) -> None: + airflow_totals = { + "supply": 400, + "return": 300, + "outside": 100, + "relief": 0, + "exhaust": 0, } - project = Project.model_validate(data) - assert project.id == "project-123" - assert project.name == "Test Project" - assert project.latitude == 40.7128 - - def test_weather_station_validation(self) -> None: - """Test WeatherStationData schema validation.""" - data = { - "station": "Test Station", - "latitude": 40.0, - "longitude": -74.0, - "elevation": 100.0, - "climateZone": "4A", - "timezoneOffset": -5, - "averageDailyTemperature": [30] * 12, - "stdDevDailyTemperature": [5] * 12, - "dbRange": [10] * 12, - "wbRange": [8] * 12, - "hdd50": [500] * 12, - "hdd65": [800] * 12, - "cdd50": [100] * 12, - "cdd65": [50] * 12, - "cdh74": [200] * 12, - "cdh80": [100] * 12, - "taub": [0.4] * 12, - "taud": [2.0] * 12, - "dbTempByHeatingPercent": {"99": 10, "99.6": 5}, - "monthlyBulbTempsByCoolingPercent": { - "0.4": {"db": [85] * 12, "wb": [70] * 12}, - "2": {"db": [83] * 12, "wb": [68] * 12}, - "5": {"db": [81] * 12, "wb": [66] * 12}, - "10": {"db": [79] * 12, "wb": [64] * 12}, + mode_airflows = { + "airflowDifferential": {"design": 0, "required": 0}, + "design": airflow_totals, + "required": airflow_totals, + "spacePeaksSum": airflow_totals, + "supplySources": { + "codeRequiredSupply": 0, + "directAirSpaceSensible": 0, + "directOutsideAir": 0, + "loadRequiredSupply": 400, + "totalSpaceSensible": 0, }, } - station = WeatherStationData.model_validate(data) - assert station.station == "Test Station" - assert station.climate_zone == "4A" - - -class TestHVAKRClientError: - """Tests for HVAKRClientError.""" - - def test_error_str(self) -> None: - """Test error string representation.""" - error = HVAKRClientError("Something went wrong", status_code=500) - assert str(error) == "Error 500: Something went wrong" - - def test_error_str_without_status(self) -> None: - """Test error string without status code.""" - error = HVAKRClientError("Something went wrong") - assert str(error) == "Something went wrong" - - def test_error_repr(self) -> None: - """Test error repr.""" - error = HVAKRClientError("Error", status_code=400, metadata={"detail": "Bad request"}) - repr_str = repr(error) - assert "HVAKRClientError" in repr_str - assert "Error" in repr_str - assert "400" in repr_str - - -# Integration tests - only run with HVAKR_API_TOKEN set -class TestIntegration: - """Integration tests that require a real API token.""" - - @pytest.mark.integration - def test_list_projects_integration( - self, api_token: str | None, skip_without_token: None - ) -> None: - """Test listing projects with real API.""" - assert api_token is not None - with HVAKRClient(access_token=api_token) as client: - result = client.list_projects() - assert "ids" in result - assert isinstance(result["ids"], list) - - @pytest.mark.integration - @pytest.mark.asyncio - async def test_list_projects_async_integration( - self, api_token: str | None, skip_without_token: None - ) -> None: - """Test listing projects asynchronously with real API.""" - assert api_token is not None - async with AsyncHVAKRClient(access_token=api_token) as client: - result = await client.list_projects() - assert "ids" in result - assert isinstance(result["ids"], list) + calculations = APIProjectCalculations.model_validate( + { + "errors": [], + "flags": {}, + "airflows": { + "project": { + "byMode": {"cooling_mode": mode_airflows}, + "calculatedOutsideAirflow": {"cooling": 100, "heating": 0, "max": 100}, + "max": {"design": airflow_totals, "required": airflow_totals}, + "requiredOutsideAirflowComponents": { + "code": {"ach": 0}, + "load": {"area": 0, "people": 0, "total": 0}, + }, + }, + "spaces": {}, + "systems": {}, + "zones": {}, + }, + } + ) + + cooling = calculations.airflows.project.by_mode["cooling_mode"] + assert cooling.design.supply == 400 + assert calculations.airflows.project.max.required.outside == 100 + + def test_equipment_mode_and_terminal_config_round_trip(self) -> None: + mode = EquipmentMode( + id="cooling_mode", + loadCondition="COOLING", + name="Cooling", + description="", + ) + config = TerminalUnitConfiguration.model_validate( + { + "components": [{"id": "coil", "type": "COOLING_COIL"}], + "componentConfigsByMode": { + "cooling_mode": { + "coil": { + "enabled": True, + "configuration": { + "componentType": "COOLING_COIL", + "targetTemperature": 55, + }, + } + } + }, + } + ) + + assert mode.load_condition is LoadCondition.COOLING + component = config.component_configs_by_mode["cooling_mode"]["coil"] + assert component.configuration.target_temperature == 55 + + def test_space_uses_per_mode_airflows_and_per_condition_requirements(self) -> None: + space = SpaceData.model_validate( + { + "creationSource": "API", + "edges": {}, + "level": 1, + "designAirflowsByMode": {"cooling_mode": {"supplyAir": 400}}, + "airflowRequirementsByLoadCondition": { + "COOLING": {"ventilationReq": 100, "infiltrationReqMethod": "AREA"} + }, + } + ) + + assert space.design_airflows_by_mode["cooling_mode"].supply_air == 400 + cooling_requirements = space.airflow_requirements_by_load_condition[LoadCondition.COOLING] + assert cooling_requirements.ventilation_req == 100 diff --git a/uv.lock b/uv.lock index a7d7a9b..24b1dfd 100644 --- a/uv.lock +++ b/uv.lock @@ -103,7 +103,7 @@ wheels = [ [[package]] name = "hvakr" -version = "0.1.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 0cc5e4b92972b8668bd81aeaf733798a3ccf85e0 Mon Sep 17 00:00:00 2001 From: Dayton Muxlow Date: Mon, 13 Jul 2026 23:08:10 -0700 Subject: [PATCH 2/4] docs: remove stale type hint imports --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 453e8ff..b237724 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,6 @@ from hvakr import ( HVAKRClient, Project, ExpandedProject, - WeatherStationData, - APIProjectOutputLoads, ) client = HVAKRClient(access_token="your-token") From af7dcbb3b4976d73598caad7ebc1d67c43abbf94 Mon Sep 17 00:00:00 2001 From: Dayton Muxlow Date: Mon, 13 Jul 2026 23:31:21 -0700 Subject: [PATCH 3/4] fix: disambiguate equipment inlet methods --- src/hvakr/schemas/project.py | 15 ++++++++++++++- tests/test_client.py | 24 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/hvakr/schemas/project.py b/src/hvakr/schemas/project.py index ac2a37a..aafcac5 100644 --- a/src/hvakr/schemas/project.py +++ b/src/hvakr/schemas/project.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from hvakr.schemas.common import Box, DisplayUnitSystemId, Point, Rect, Size from hvakr.schemas.graph import DuctSize, FlowType, Graph, RegisterPlacementType @@ -651,6 +651,19 @@ class EquipmentComponentConfiguration(BaseModel): model_config = {"populate_by_name": True, "extra": "allow"} + @model_validator(mode="before") + @classmethod + def resolve_method_for_component_type(cls, data: Any) -> Any: + """Use the component type to disambiguate overlapping method values.""" + if not isinstance(data, dict) or data.get("method") is None: + return data + + component_type = data.get("componentType", data.get("component_type")) + if component_type == EquipmentComponentType.EQUIPMENT_INLET: + data = data.copy() + data["method"] = EquipmentInletMethod(data["method"]) + return data + class ComponentConfiguration(BaseModel): """Enabled state and configuration of a component for a single mode.""" diff --git a/tests/test_client.py b/tests/test_client.py index 5db26c7..f6955fa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,7 +4,14 @@ from pytest_httpx import HTTPXMock from hvakr import APIProjectCalculations, AsyncHVAKRClient, HVAKRClient, HVAKRClientError, Project -from hvakr.schemas import APIJob, EquipmentMode, LoadCondition, SpaceData, TerminalUnitConfiguration +from hvakr.schemas import ( + APIJob, + EquipmentInletMethod, + EquipmentMode, + LoadCondition, + SpaceData, + TerminalUnitConfiguration, +) DEFAULT_EQUIPMENT_MODES = { "cooling_mode": { @@ -274,6 +281,21 @@ def test_equipment_mode_and_terminal_config_round_trip(self) -> None: component = config.component_configs_by_mode["cooling_mode"]["coil"] assert component.configuration.target_temperature == 55 + def test_equipment_inlet_custom_method_uses_inlet_enum(self) -> None: + config = TerminalUnitConfiguration.model_validate( + { + "inletData": { + "enabled": True, + "configuration": { + "componentType": "EQUIPMENT_INLET", + "method": "CUSTOM", + }, + } + } + ) + + assert config.inlet_data.configuration.method is EquipmentInletMethod.CUSTOM + def test_space_uses_per_mode_airflows_and_per_condition_requirements(self) -> None: space = SpaceData.model_validate( { From f8e96676753637ec7d42c74147960b4738aca256 Mon Sep 17 00:00:00 2001 From: Dayton Muxlow Date: Tue, 14 Jul 2026 14:02:50 -0700 Subject: [PATCH 4/4] fix: support v0.6 calculation output fields --- src/hvakr/schemas/outputs.py | 4 ++++ tests/test_client.py | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/hvakr/schemas/outputs.py b/src/hvakr/schemas/outputs.py index 80af93a..2a61382 100644 --- a/src/hvakr/schemas/outputs.py +++ b/src/hvakr/schemas/outputs.py @@ -28,6 +28,7 @@ class RegisterModelType(str, Enum): CEILING_SQUARE = "ceilingSquare" CEILING_LINEAR = "ceilingLinear" + SIDEWALL = "sidewall" class ErrorInfo(BaseModel): @@ -360,9 +361,12 @@ class ModeAirflows(BaseModel): class MaximumModeAirflows(BaseModel): """The peak design and required airflows across the project's modes.""" + airflow_differential: dict[str, float] = Field(alias="airflowDifferential") design: Airflows required: Airflows + model_config = {"populate_by_name": True} + class ProjectScopeAirflows(BaseModel): """Mode-keyed airflows for a project, space, zone, or system.""" diff --git a/tests/test_client.py b/tests/test_client.py index f6955fa..53de1d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -236,7 +236,11 @@ def test_calculations_preserve_airflows_by_project_mode(self) -> None: "project": { "byMode": {"cooling_mode": mode_airflows}, "calculatedOutsideAirflow": {"cooling": 100, "heating": 0, "max": 100}, - "max": {"design": airflow_totals, "required": airflow_totals}, + "max": { + "airflowDifferential": {"design": 20, "required": 10}, + "design": airflow_totals, + "required": airflow_totals, + }, "requiredOutsideAirflowComponents": { "code": {"ach": 0}, "load": {"area": 0, "people": 0, "total": 0}, @@ -252,6 +256,39 @@ def test_calculations_preserve_airflows_by_project_mode(self) -> None: cooling = calculations.airflows.project.by_mode["cooling_mode"] assert cooling.design.supply == 400 assert calculations.airflows.project.max.required.outside == 100 + assert calculations.airflows.project.max.airflow_differential == { + "design": 20, + "required": 10, + } + + def test_calculations_accept_sidewall_register_schedule_rows(self) -> None: + calculations = APIProjectCalculations.model_validate( + { + "errors": [], + "flags": {}, + "registerSchedule": [ + { + "configuration": "Supply", + "flowType": "SUPPLY", + "inletSize": "6 in.", + "manufacturer": "Example Manufacturer", + "model": "Example Model", + "modelType": "sidewall", + "quantity": 1, + "registerCFM": 100, + "registerFPM": 500, + "registerNC": 20, + "registerSize": "12 x 4 in.", + "spaceName": "Office", + "spaceNumber": "101", + "totalCFM": 100, + } + ], + } + ) + + assert calculations.register_schedule is not None + assert calculations.register_schedule[0].model_type.value == "sidewall" def test_equipment_mode_and_terminal_config_round_trip(self) -> None: mode = EquipmentMode(