diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1863635c..c4e6da6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ Run poetry against a single file with: *\*From the root of the project\** ```sh -poetry run pytest tests/turbines/turbines_test.py` +poetry run pytest tests/turbines/turbines_test.py ``` ### Code Style diff --git a/cwms/__init__.py b/cwms/__init__.py index 8fc70e22..cd20e2d9 100644 --- a/cwms/__init__.py +++ b/cwms/__init__.py @@ -25,6 +25,7 @@ from cwms.timeseries.timeseries_profile_instance import * from cwms.timeseries.timeseries_profile_parser import * from cwms.timeseries.timeseries_txt import * +from cwms.turbines.turbines import * try: __version__ = version("cwms-python") diff --git a/cwms/turbines/turbines.py b/cwms/turbines/turbines.py new file mode 100644 index 00000000..e6fafdb7 --- /dev/null +++ b/cwms/turbines/turbines.py @@ -0,0 +1,242 @@ +from datetime import datetime +from typing import Optional + +import cwms.api as api +from cwms.cwms_types import JSON, Data + +# ========================================================================== +# GET CWMS TURBINES +# ========================================================================== + + +def get_project_turbines(office: str, project_id: str) -> Data: + """Returns matching CWMS Turbine Data for a Reservoir Project. Get cwmsData projects turbines. + Args: + office (str): The office associated with the turbine data. + project_id (str): The ID of the project. + Returns: + dict: A dictionary containing the turbine data. + """ + endpoint = "projects/turbines" + params = {"office": office, "project-id": project_id} + + response = api.get(endpoint=endpoint, params=params) + + return Data(response) + + +def get_project_turbine(office: str, name: str) -> Data: + """Returns CWMS Turbine Data Get cwmsData projects turbines with name. + Args: + office (str): The office associated with the turbine data. + name (str): The name of the turbine. + Returns: + dict: A dictionary containing the turbine data. + """ + endpoint = f"projects/turbines/{name}" + params = {"office": office} + response = api.get(endpoint=endpoint, params=params) + return Data(response) + + +def get_project_turbine_changes( + name: str, + begin: datetime, + end: datetime, + office: str, + page_size: Optional[int], + unit_system: Optional[str], + start_time_inclusive: Optional[bool], + end_time_inclusive: Optional[bool], +) -> Data: + """ + Returns CWMS Turbine Data for projects with specified office and turbine name changes within a given time range. + Args: + begin (str): The start date and time for the data retrieval in ISO 8601 format. + end (str): The end date and time for the data retrieval in ISO 8601 format. + end_time_inclusive (Optional[bool]): Whether the end time is inclusive. + name (str): The name of the turbine. + office (str): The office associated with the turbine data. + page_size (Optional[int]): The number of records to return per page. + start_time_inclusive (Optional[bool]): Whether the start time is inclusive. + unit_system (Optional[str]): The unit system to use for the data [SI, EN]. + Returns: + dict: A dictionary containing the turbine data. + """ + if begin and not isinstance(begin, datetime): + raise ValueError("begin needs to be in datetime") + if end and not isinstance(end, datetime): + raise ValueError("end needs to be in datetime") + + endpoint = f"projects/{office}/{name}/turbine-changes" + params = { + "name": name, + "begin": begin.isoformat() if begin else None, + "end": end.isoformat() if end else None, + "office": office, + "page-size": page_size, + "unit-system": unit_system, + "start-time-inclusive": start_time_inclusive, + "end-time-inclusive": end_time_inclusive, + } + response = api.get(endpoint=endpoint, params=params) + return Data(response) + + +# ========================================================================== +# POST CWMS TURBINES +# ========================================================================== + + +def store_project_turbine(data: JSON, fail_if_exists: Optional[bool]) -> None: + """ + Create a new turbine in CWMS. + Parameters + ---------- + fail_if_exists (bool): If True, the request will fail if the turbine already exists. + + Returns + ------- + None + + + Raises + ------ + ValueError + If provided data is None + Unauthorized + 401 - Indicates that the client request has not been completed because it lacks valid authentication credentials for the requested resource. + Forbidden + 403 - Indicates that the server understands the request but refuses to authorize it. + Not Found + 404 - Indicates that the server cannot find the requested resource. + Server Error + 500 - Indicates that the server encountered an unexpected condition that prevented it from fulfilling the request. + """ + if data is None: + raise ValueError( + "Cannot store project turbine changes without a JSON data dictionary" + ) + endpoint = "projects/turbines" + params = { + "fail-if-exists": fail_if_exists, + } + return api.post(endpoint=endpoint, data=data, params=params) + + +def store_project_turbine_changes( + data: JSON, office: str, name: str, override_protection: Optional[bool] +) -> None: + """ + Create CWMS Turbine Changes + Parameters + ---------- + office (str): Office id for the reservoir project location associated with the turbine changes. + name (str): Specifies the name of project of the Turbine changes whose data is to stored. + override_protection (bool): A flag ('True'/'False') specifying whether to delete protected data. Default is False + + Returns + ------- + None - Turbine successfully stored to CWMS. + + + Raises + ------ + ValueError + If provided data is None + Unauthorized + 401 - Indicates that the client request has not been completed because it lacks valid authentication credentials for the requested resource. + Forbidden + 403 - Indicates that the server understands the request but refuses to authorize it. + Not Found + 404 - Indicates that the server cannot find the requested resource. + Server Error + 500 - Indicates that the server encountered an unexpected condition that prevented it from fulfilling the request. + """ + if data is None: + raise ValueError( + "Cannot store project turbine changes without a JSON data dictionary" + ) + endpoint = f"projects/{office}/{name}/turbine-changes" + params = {"override-protection": override_protection} + return api.post(endpoint=endpoint, data=data, params=params) + + +# ========================================================================== +# DELETE CWMS TURBINES +# ========================================================================== + + +def delete_project_turbine(name: str, office: str, method: Optional[str]) -> None: + """ + Delete CWMS Turbine + Parameters + ---------- + name (str): Specifies the name of the turbine to be deleted. + office (str): Specifies the owning office of the turbine to be deleted. + method (str): Specifies the delete method used. Defaults to "DELETE_KEY". Options are: DELETE_KEY, DELETE_DATA, DELETE_ALL + Returns + ------- + None - Turbine successfully deleted from CWMS. + + + Raises + ------ + ValueError + If provided data is None + Unauthorized + 401 - Indicates that the client request has not been completed because it lacks valid authentication credentials for the requested resource. + Forbidden + 403 - Indicates that the server understands the request but refuses to authorize it. + Not Found + 404 - Indicates that the server cannot find the requested resource. + Server Error + 500 - Indicates that the server encountered an unexpected condition that prevented it from fulfilling the request. + """ + endpoint = f"projects/turbines/{name}" + params = {"office": office, "method": method} + return api.delete(endpoint=endpoint, params=params, api_version=1) + + +def delete_project_turbine_changes( + office: str, + name: str, + begin: datetime, + end: datetime, + override_protection: Optional[bool], +) -> None: + """ + Delete CWMS Turbine Changes + Parameters + ---------- + name (str): Specifies the name of project for the turbine changes to be deleted. + office (str): Specifies the owning office of the turbine to be deleted. + begin (datetime): The start of the time window + end (datetime): The end of the time window + override_protection (bool): A flag ('True'/'False') specifying whether to delete protected data. Default is False + + Returns + ------- + None - Turbine successfully deleted from CWMS. + + + Raises + ------ + ValueError + If provided data is None + Unauthorized + 401 - Indicates that the client request has not been completed because it lacks valid authentication credentials for the requested resource. + Forbidden + 403 - Indicates that the server understands the request but refuses to authorize it. + Not Found + 404 - Indicates that the server cannot find the requested resource. + Server Error + 500 - Indicates that the server encountered an unexpected condition that prevented it from fulfilling the request. + """ + endpoint = f"projects/{office}/{name}/turbine-changes" + params = { + "begin": begin.isoformat() if begin else None, + "end": end.isoformat() if end else None, + "override-protection": override_protection, + } + return api.delete(endpoint=endpoint, params=params, api_version=1) diff --git a/pyproject.toml b/pyproject.toml index 45e508a7..19dffd47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cwms-python" -version = "0.5.2" +version = "0.5.3" packages = [ { include = "cwms" }, ] diff --git a/tests/resources/turbines.json b/tests/resources/turbines.json new file mode 100644 index 00000000..588a932e --- /dev/null +++ b/tests/resources/turbines.json @@ -0,0 +1,58 @@ +[ + { + "project-id": { + "office-id": "SWT", + "name": "KEYS" + }, + "location": { + "office-id": "SWT", + "name": "KEYS-Turbine1", + "latitude": 36.1506371, + "longitude": -96.2525088, + "active": true, + "public-name": "Turbine1", + "long-name": "Turbine1", + "timezone-name": "US/Central", + "location-kind": "TURBINE", + "nation": "US", + "state-initial": "OK", + "county-name": "Tulsa", + "nearest-city": "Sand Springs, OK", + "horizontal-datum": "NAD83", + "published-longitude": 0, + "published-latitude": 0, + "vertical-datum": "NGVD29", + "elevation": 0, + "bounding-office-id": "SWT", + "elevation-units": "m" + } + }, + { + "project-id": { + "office-id": "SWT", + "name": "KEYS" + }, + "location": { + "office-id": "SWT", + "name": "KEYS-Turbine2", + "latitude": 36.1506371, + "longitude": -96.2525088, + "active": true, + "public-name": "Turbine2", + "long-name": "Turbine2", + "timezone-name": "US/Central", + "location-kind": "TURBINE", + "nation": "US", + "state-initial": "OK", + "county-name": "Tulsa", + "nearest-city": "Sand Springs, OK", + "horizontal-datum": "NAD83", + "published-longitude": 0, + "published-latitude": 0, + "vertical-datum": "NGVD29", + "elevation": 0, + "bounding-office-id": "SWT", + "elevation-units": "m" + } + } + ] \ No newline at end of file diff --git a/tests/resources/turbines_name.json b/tests/resources/turbines_name.json new file mode 100644 index 00000000..9fbae703 --- /dev/null +++ b/tests/resources/turbines_name.json @@ -0,0 +1,28 @@ +{ + "project-id": { + "office-id": "SWT", + "name": "KEYS" + }, + "location": { + "office-id": "SWT", + "name": "KEYS-Turbine1", + "latitude": 36.1506371, + "longitude": -96.2525088, + "active": true, + "public-name": "Turbine1", + "long-name": "Turbine1", + "timezone-name": "US/Central", + "location-kind": "TURBINE", + "nation": "US", + "state-initial": "OK", + "county-name": "Tulsa", + "nearest-city": "Sand Springs, OK", + "horizontal-datum": "NAD83", + "published-longitude": 0, + "published-latitude": 0, + "vertical-datum": "NGVD29", + "elevation": 0, + "bounding-office-id": "SWT", + "elevation-units": "m" + } + } \ No newline at end of file diff --git a/tests/resources/turbines_office_name.json b/tests/resources/turbines_office_name.json new file mode 100644 index 00000000..f589792e --- /dev/null +++ b/tests/resources/turbines_office_name.json @@ -0,0 +1,55 @@ +{ + "project-id": { + "office-id": "SWT", + "name": "KEYS" + }, + "change-date": 1738713600000, + "protected": false, + "discharge-computation-type": { + "office-id": "SWT", + "display-value": "R", + "tooltip": "Reported by powerhouse", + "active": true + }, + "reason-type": { + "office-id": "SWT", + "display-value": "S", + "tooltip": "Scheduled release to meet loads", + "active": true + }, + "notes": "from SCADA", + "new-total-discharge-override": 0, + "old-total-discharge-override": 0, + "discharge-units": "cfs", + "tailwater-elevation": 637.7499999999999, + "elevation-units": "ft", + "settings": [ + { + "type": "turbine-setting", + "location-id": { + "office-id": "SWT", + "name": "KEYS-Turbine1" + }, + "discharge-units": "cfs", + "old-discharge": 0, + "new-discharge": 0, + "generation-units": "MW", + "scheduled-load": 0, + "real-power": 0 + }, + { + "type": "turbine-setting", + "location-id": { + "office-id": "SWT", + "name": "KEYS-Turbine2" + }, + "discharge-units": "cfs", + "old-discharge": 0, + "new-discharge": 0, + "generation-units": "MW", + "scheduled-load": 0, + "real-power": 0 + } + ], + "pool-elevation": 723.12 +} \ No newline at end of file diff --git a/tests/turbines/turbines_test.py b/tests/turbines/turbines_test.py new file mode 100644 index 00000000..8a4b2204 --- /dev/null +++ b/tests/turbines/turbines_test.py @@ -0,0 +1,325 @@ +from datetime import datetime + +import pandas as pd +import pytest + +import cwms.api +import cwms.turbines.turbines as turbines +from tests._test_utils import read_resource_file + +_MOCK_ROOT = "https://mockwebserver.cwms.gov" +_TURBINES = read_resource_file("turbines.json") +_TURBINES_NAME = read_resource_file("turbines_name.json") +_TURBINES_OFFICE_NAME = read_resource_file("turbines_office_name.json") + + +@pytest.fixture(autouse=True) +def init_session(): + cwms.api.init_session(api_root=_MOCK_ROOT) + + +# ========================================================================== +# GET CWMS TURBINES +# ========================================================================== + + +def test_get_project_turbines(requests_mock): + office = "SWT" + project_id = "KEYS" + + requests_mock.get( + f"{_MOCK_ROOT}/projects/turbines?project-id={project_id}&office={office}", + json=_TURBINES, + ) + + data = turbines.get_project_turbines(project_id=project_id, office=office) + + assert data.json == _TURBINES + assert isinstance(data.df, pd.DataFrame) + + # Validate columns + expected_columns = [ + "project-id.office-id", + "project-id.name", + "location.office-id", + "location.name", + "location.latitude", + "location.longitude", + "location.active", + "location.public-name", + "location.long-name", + "location.timezone-name", + "location.location-kind", + "location.nation", + "location.state-initial", + "location.county-name", + "location.nearest-city", + "location.horizontal-datum", + "location.published-longitude", + "location.published-latitude", + "location.vertical-datum", + "location.elevation", + "location.bounding-office-id", + "location.elevation-units", + ] + assert list(data.df.columns) == expected_columns + + # Validate DataFrame shape (2 rows, 22 columns) + assert data.df.shape == (2, 22) + + # Validate the first row of data + expected_values = [ + [ + "SWT", + "KEYS", + "SWT", + "KEYS-Turbine1", + 36.1506371, + -96.2525088, + True, + "Turbine1", + "Turbine1", + "US/Central", + "TURBINE", + "US", + "OK", + "Tulsa", + "Sand Springs, OK", + "NAD83", + 0, + 0, + "NGVD29", + 0, + "SWT", + "m", + ], + [ + "SWT", + "KEYS", + "SWT", + "KEYS-Turbine2", + 36.1506371, + -96.2525088, + True, + "Turbine2", + "Turbine2", + "US/Central", + "TURBINE", + "US", + "OK", + "Tulsa", + "Sand Springs, OK", + "NAD83", + 0, + 0, + "NGVD29", + 0, + "SWT", + "m", + ], + ] + actual_values = data.df.to_numpy().tolist() + + assert actual_values == expected_values + + +def get_project_turbine(requests_mock): + project_id = "KEYS" + office = "SWT" + + requests_mock.get( + f"{_MOCK_ROOT}/projects/turbines?project_id={project_id}&office={office}", + json=_TURBINES_NAME, + ) + + data = turbines.get_project_turbine(office=office, project_id=project_id) + expected_columns = [ + "project-id.office-id", + "project-id.name", + "location.office-id", + "location.name", + "location.latitude", + "location.longitude", + "location.active", + "location.public-name", + "location.long-name", + "location.timezone-name", + "location.location-kind", + "location.nation", + "location.state-initial", + "location.county-name", + "location.nearest-city", + "location.horizontal-datum", + "location.published-longitude", + "location.published-latitude", + "location.vertical-datum", + "location.elevation", + "location.bounding-office-id", + "location.elevation-units", + ] + + expected_values = [["SWT", "KEYS", "SWT"]] + assert list(data.df.columns) == expected_columns + assert data.df.shape == (1, len(expected_columns)) + assert data.df.to_numpy().tolist() == expected_values + + +def test_get_project_turbine_changes(requests_mock): + office = "SWT" + name = "KEYS" + + requests_mock.get( + f"{_MOCK_ROOT}/projects/{office}/{name}/turbine-changes", + json=_TURBINES_OFFICE_NAME, + ) + + data = turbines.get_project_turbine_changes( + name=name, + begin=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + office=office, + page_size=100, + unit_system=None, + start_time_inclusive=True, + end_time_inclusive=True, + ) + expected_columns = [ + "change-date", + "protected", + "notes", + "new-total-discharge-override", + "old-total-discharge-override", + "discharge-units", + "tailwater-elevation", + "elevation-units", + "settings", + "pool-elevation", + "project-id.office-id", + "project-id.name", + "discharge-computation-type.office-id", + "discharge-computation-type.display-value", + "discharge-computation-type.tooltip", + "discharge-computation-type.active", + "reason-type.office-id", + "reason-type.display-value", + "reason-type.tooltip", + "reason-type.active", + ] + + expected_values = [ + [ + 1738713600000, + False, + "from SCADA", + 0, + 0, + "cfs", + 637.7499999999999, + "ft", + [ + { + "type": "turbine-setting", + "location-id": {"office-id": "SWT", "name": "KEYS-Turbine1"}, + "discharge-units": "cfs", + "old-discharge": 0, + "new-discharge": 0, + "generation-units": "MW", + "scheduled-load": 0, + "real-power": 0, + }, + { + "type": "turbine-setting", + "location-id": {"office-id": "SWT", "name": "KEYS-Turbine2"}, + "discharge-units": "cfs", + "old-discharge": 0, + "new-discharge": 0, + "generation-units": "MW", + "scheduled-load": 0, + "real-power": 0, + }, + ], + 723.12, + "SWT", + "KEYS", + "SWT", + "R", + "Reported by powerhouse", + True, + "SWT", + "S", + "Scheduled release to meet loads", + True, + ] + ] + + assert list(data.df.columns) == expected_columns + assert data.df.shape == (1, len(expected_columns)) + assert data.df.to_numpy().tolist() == expected_values + + +# ========================================================================== +# POST CWMS TURBINES +# ========================================================================== + + +def test_store_project_turbine(requests_mock): + requests_mock.post(f"{_MOCK_ROOT}/projects/turbines?fail-if-exists=false") + + turbines.store_project_turbine(data=_TURBINES, fail_if_exists=False) + assert requests_mock.called + assert requests_mock.call_count == 1 + + +def test_store_project_turbine_changes(requests_mock): + office = "SWT" + name = "KEYS" + + requests_mock.post( + f"{_MOCK_ROOT}/projects/{office}/{name}/turbine-changes?override-protection=False" + ) + + turbines.store_project_turbine_changes( + data=_TURBINES_OFFICE_NAME, office="SWT", name="KEYS", override_protection=False + ) + assert requests_mock.called + assert requests_mock.call_count == 1 + + +# ========================================================================== +# DELETE CWMS TURBINES +# ========================================================================== + + +def test_delete_project_turbine(requests_mock): + name = "KEYS-Turbine1" + office = "SWT" + method = "DELETE_ALL" + + requests_mock.delete( + f"{_MOCK_ROOT}/projects/turbines/{name}?office={office}&method={method}" + ) + + turbines.delete_project_turbine(name=name, office=office, method=method) + + assert requests_mock.called + assert requests_mock.call_count == 1 + + +def test_delete_project_turbine_changes(requests_mock): + office = "SWT" + name = "KEYS" + + requests_mock.delete( + f"{_MOCK_ROOT}/projects/{office}/{name}/turbine-changes?override-protection=False" + ) + + turbines.delete_project_turbine_changes( + office=office, + name=name, + override_protection=False, + begin=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + ) + + assert requests_mock.called + assert requests_mock.call_count == 1