cwms-python 0.8.0__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cwms_python-0.8.0 → cwms_python-1.0.0}/PKG-INFO +4 -2
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/api.py +73 -27
- cwms_python-1.0.0/cwms/catalog/blobs.py +148 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/catalog/catalog.py +35 -1
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/levels/location_levels.py +34 -5
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/locations/location_groups.py +85 -1
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/locations/physical_locations.py +7 -3
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/projects/water_supply/accounting.py +3 -1
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/ratings/ratings.py +219 -1
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/ratings/ratings_spec.py +16 -4
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries.py +289 -17
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_group.py +1 -1
- {cwms_python-0.8.0 → cwms_python-1.0.0}/pyproject.toml +1 -1
- cwms_python-0.8.0/cwms/catalog/blobs.py +0 -99
- {cwms_python-0.8.0 → cwms_python-1.0.0}/LICENSE +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/README.md +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/__init__.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/catalog/clobs.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/cwms_types.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/measurements/measurements.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/outlets/outlets.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/projects/project_locks.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/projects/projects.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/turbines/turbines.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/utils/__init__.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.0}/cwms/utils/checks.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: cwms-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data
|
|
5
5
|
License: LICENSE
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: USACE,water data,CWMS
|
|
7
8
|
Author: Eric Novotny
|
|
8
9
|
Author-email: eric.v.novotny@usace.army.mil
|
|
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
19
|
Requires-Dist: pandas (>=2.1.3,<3.0.0)
|
|
18
20
|
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
19
21
|
Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
|
|
@@ -132,6 +132,8 @@ def init_session(
|
|
|
132
132
|
|
|
133
133
|
global SESSION
|
|
134
134
|
if api_root:
|
|
135
|
+
# Ensure the API_ROOT ends with a single slash
|
|
136
|
+
api_root = api_root.rstrip("/") + "/"
|
|
135
137
|
logging.debug(f"Initializing root URL: api_root={api_root}")
|
|
136
138
|
SESSION = sessions.BaseUrlSession(base_url=api_root)
|
|
137
139
|
adapter = adapters.HTTPAdapter(
|
|
@@ -213,6 +215,27 @@ def get_xml(
|
|
|
213
215
|
return get(endpoint=endpoint, params=params, api_version=api_version)
|
|
214
216
|
|
|
215
217
|
|
|
218
|
+
def _process_response(response: Response) -> Any:
|
|
219
|
+
try:
|
|
220
|
+
# Avoid case sensitivity issues with the content type header
|
|
221
|
+
content_type = response.headers.get("Content-Type", "").lower()
|
|
222
|
+
# Most CDA content is JSON
|
|
223
|
+
if "application/json" in content_type or not content_type:
|
|
224
|
+
return cast(JSON, response.json())
|
|
225
|
+
# Use automatic charset detection with .text
|
|
226
|
+
if "text/plain" in content_type or "text/" in content_type:
|
|
227
|
+
return response.text
|
|
228
|
+
if content_type.startswith("image/"):
|
|
229
|
+
return base64.b64encode(response.content).decode("utf-8")
|
|
230
|
+
# Fallback for remaining content types
|
|
231
|
+
return response.content.decode("utf-8")
|
|
232
|
+
except JSONDecodeError as error:
|
|
233
|
+
logging.error(
|
|
234
|
+
f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text"
|
|
235
|
+
)
|
|
236
|
+
return response.text
|
|
237
|
+
|
|
238
|
+
|
|
216
239
|
def get(
|
|
217
240
|
endpoint: str,
|
|
218
241
|
params: Optional[RequestParams] = None,
|
|
@@ -241,24 +264,7 @@ def get(
|
|
|
241
264
|
if not response.ok:
|
|
242
265
|
logging.error(f"CDA Error: response={response}")
|
|
243
266
|
raise ApiError(response)
|
|
244
|
-
|
|
245
|
-
# Avoid case sensitivity issues with the content type header
|
|
246
|
-
content_type = response.headers.get("Content-Type", "").lower()
|
|
247
|
-
# Most CDA content is JSON
|
|
248
|
-
if "application/json" in content_type or not content_type:
|
|
249
|
-
return cast(JSON, response.json())
|
|
250
|
-
# Use automatic charset detection with .text
|
|
251
|
-
if "text/plain" in content_type or "text/" in content_type:
|
|
252
|
-
return response.text
|
|
253
|
-
if content_type.startswith("image/"):
|
|
254
|
-
return base64.b64encode(response.content).decode("utf-8")
|
|
255
|
-
# Fallback for remaining content types
|
|
256
|
-
return response.content.decode("utf-8")
|
|
257
|
-
except JSONDecodeError as error:
|
|
258
|
-
logging.error(
|
|
259
|
-
f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text"
|
|
260
|
-
)
|
|
261
|
-
return response.text
|
|
267
|
+
return _process_response(response)
|
|
262
268
|
|
|
263
269
|
|
|
264
270
|
def get_with_paging(
|
|
@@ -301,6 +307,25 @@ def get_with_paging(
|
|
|
301
307
|
return response
|
|
302
308
|
|
|
303
309
|
|
|
310
|
+
def _post_function(
|
|
311
|
+
endpoint: str,
|
|
312
|
+
data: Any,
|
|
313
|
+
params: Optional[RequestParams] = None,
|
|
314
|
+
*,
|
|
315
|
+
api_version: int = API_VERSION,
|
|
316
|
+
) -> Any:
|
|
317
|
+
|
|
318
|
+
# post requires different headers than get for
|
|
319
|
+
headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
|
|
320
|
+
if isinstance(data, dict) or isinstance(data, list):
|
|
321
|
+
data = json.dumps(data)
|
|
322
|
+
with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
|
|
323
|
+
if not response.ok:
|
|
324
|
+
logging.error(f"CDA Error: response={response}")
|
|
325
|
+
raise ApiError(response)
|
|
326
|
+
return response
|
|
327
|
+
|
|
328
|
+
|
|
304
329
|
def post(
|
|
305
330
|
endpoint: str,
|
|
306
331
|
data: Any,
|
|
@@ -320,22 +345,43 @@ def post(
|
|
|
320
345
|
the default API_VERSION will be used.
|
|
321
346
|
|
|
322
347
|
Returns:
|
|
323
|
-
|
|
348
|
+
None
|
|
324
349
|
|
|
325
350
|
Raises:
|
|
326
351
|
ApiError: If an error response is return by the API.
|
|
327
352
|
"""
|
|
353
|
+
_post_function(endpoint=endpoint, data=data, params=params, api_version=api_version)
|
|
328
354
|
|
|
329
|
-
# post requires different headers than get for
|
|
330
|
-
headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
|
|
331
355
|
|
|
332
|
-
|
|
333
|
-
|
|
356
|
+
def post_with_returned_data(
|
|
357
|
+
endpoint: str,
|
|
358
|
+
data: Any,
|
|
359
|
+
params: Optional[RequestParams] = None,
|
|
360
|
+
*,
|
|
361
|
+
api_version: int = API_VERSION,
|
|
362
|
+
) -> Any:
|
|
363
|
+
"""Make a POST request to the CWMS Data API.
|
|
334
364
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
365
|
+
Args:
|
|
366
|
+
endpoint: The CDA endpoint for the record type.
|
|
367
|
+
data: A dict containing the new record data. Must be JSON-serializable.
|
|
368
|
+
params (optional): Query parameters for the request.
|
|
369
|
+
|
|
370
|
+
Keyword Args:
|
|
371
|
+
api_version (optional): The CDA version to use for the request. If not specified,
|
|
372
|
+
the default API_VERSION will be used.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The response data.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
ApiError: If an error response is return by the API.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
response = _post_function(
|
|
382
|
+
endpoint=endpoint, data=data, params=params, api_version=api_version
|
|
383
|
+
)
|
|
384
|
+
return _process_response(response)
|
|
339
385
|
|
|
340
386
|
|
|
341
387
|
def patch(
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import cwms.api as api
|
|
5
|
+
from cwms.cwms_types import JSON, Data
|
|
6
|
+
from cwms.utils.checks import is_base64
|
|
7
|
+
|
|
8
|
+
STORE_DICT = """data = {
|
|
9
|
+
"office-id": "SWT",
|
|
10
|
+
"id": "MYFILE_OR_BLOB_ID.TXT",
|
|
11
|
+
"description": "Your description here",
|
|
12
|
+
"media-type-id": "text/plain",
|
|
13
|
+
"value": "STRING of content or BASE64_ENCODED_STRING"
|
|
14
|
+
}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_blob(blob_id: str, office_id: str) -> str:
|
|
19
|
+
"""Get a single BLOB (Binary Large Object).
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
blob_id: string
|
|
23
|
+
Specifies the id of the blob. ALL blob ids are UPPERCASE.
|
|
24
|
+
office_id: string
|
|
25
|
+
Specifies the office of the blob.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
str: the value returned based on the content-type it was stored with as a string
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
endpoint = f"blobs/{blob_id}"
|
|
33
|
+
params = {"office": office_id}
|
|
34
|
+
response = api.get(endpoint, params, api_version=1)
|
|
35
|
+
return str(response)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_blobs(
|
|
39
|
+
office_id: Optional[str] = None,
|
|
40
|
+
page_size: Optional[int] = 100,
|
|
41
|
+
blob_id_like: Optional[str] = None,
|
|
42
|
+
) -> Data:
|
|
43
|
+
"""Get a subset of Blobs
|
|
44
|
+
|
|
45
|
+
Parameters:
|
|
46
|
+
office_id: Optional[string]
|
|
47
|
+
Specifies the office of the blob.
|
|
48
|
+
page_sie: Optional[Integer]
|
|
49
|
+
How many entries per page returned. Default 100.
|
|
50
|
+
blob_id_like: Optional[string]
|
|
51
|
+
Posix regular expression matching against the clob id
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
cwms data type. data.json will return the JSON output and data.df will return a dataframe
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
endpoint = "blobs"
|
|
58
|
+
params = {"office": office_id, "page-size": page_size, "like": blob_id_like}
|
|
59
|
+
|
|
60
|
+
response = api.get(endpoint, params, api_version=2)
|
|
61
|
+
return Data(response, selector="blobs")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
|
|
65
|
+
"""Create New Blob
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
**Note**: The "id" field is automatically cast to uppercase.
|
|
69
|
+
|
|
70
|
+
Data: JSON dictionary
|
|
71
|
+
JSON containing information of Blob to be updated.
|
|
72
|
+
|
|
73
|
+
fail_if_exists: Boolean
|
|
74
|
+
Create will fail if the provided ID already exists. Default: True
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
None
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Cannot store a Blob without a JSON data dictionary:\n{STORE_DICT}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Encode value if it's not already Base64-encoded
|
|
86
|
+
if "value" in data and not is_base64(data["value"]):
|
|
87
|
+
# Encode to bytes, then Base64, then decode to string for storing
|
|
88
|
+
data["value"] = base64.b64encode(data["value"].encode("utf-8")).decode("utf-8")
|
|
89
|
+
|
|
90
|
+
endpoint = "blobs"
|
|
91
|
+
params = {"fail-if-exists": fail_if_exists}
|
|
92
|
+
return api.post(endpoint, data, params, api_version=1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def delete_blob(blob_id: str, office_id: str) -> None:
|
|
96
|
+
"""Delete a single BLOB.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
blob_id: string
|
|
101
|
+
Specifies the id of the blob. ALL blob ids are UPPERCASE.
|
|
102
|
+
office_id: string
|
|
103
|
+
Specifies the office of the blob.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
None
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
endpoint = f"blobs/{blob_id}"
|
|
111
|
+
params = {"office": office_id}
|
|
112
|
+
return api.delete(endpoint, params, api_version=1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def update_blob(data: JSON, fail_if_not_exists: Optional[bool] = True) -> None:
|
|
116
|
+
"""Update Existing Blob
|
|
117
|
+
|
|
118
|
+
Parameters:
|
|
119
|
+
**Note**: The "id" field is automatically cast to uppercase.
|
|
120
|
+
|
|
121
|
+
Data: JSON dictionary
|
|
122
|
+
JSON containing information of Blob to be updated.
|
|
123
|
+
|
|
124
|
+
fail_if_not_exists: Boolean
|
|
125
|
+
Update will fail if the provided ID does not already exist. Default: True
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
None
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
if not data:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Cannot update a Blob without a JSON data dictionary:\n{STORE_DICT}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if "id" not in data:
|
|
137
|
+
raise ValueError(f"Cannot update a Blob without an 'id' field:\n{STORE_DICT}")
|
|
138
|
+
|
|
139
|
+
# Encode value if it's not already Base64-encoded
|
|
140
|
+
if "value" in data and not is_base64(data["value"]):
|
|
141
|
+
# Encode to bytes, then Base64, then decode to string for storing
|
|
142
|
+
data["value"] = base64.b64encode(data["value"].encode("utf-8")).decode("utf-8")
|
|
143
|
+
|
|
144
|
+
blob_id = data.get("id", "").upper()
|
|
145
|
+
|
|
146
|
+
endpoint = f"blobs/{blob_id}"
|
|
147
|
+
params = {"fail-if-not-exists": fail_if_not_exists}
|
|
148
|
+
return api.patch(endpoint, data, params, api_version=1)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
from
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional, Tuple
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
2
5
|
|
|
3
6
|
import cwms.api as api
|
|
4
7
|
from cwms.cwms_types import Data
|
|
@@ -130,3 +133,34 @@ def get_timeseries_catalog(
|
|
|
130
133
|
|
|
131
134
|
response = api.get(endpoint=endpoint, params=params, api_version=2)
|
|
132
135
|
return Data(response, selector="entries")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_ts_extents(ts_id: str, office_id: str) -> Tuple[datetime, datetime, datetime]:
|
|
139
|
+
"""Retrieves earliest extent, latest extent, and last update via cwms.get_timeseries_catalog
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
ts_id: string
|
|
144
|
+
Timseries id to query.
|
|
145
|
+
office_id: string
|
|
146
|
+
The owning office of the timeseries group.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
tuple of datetime objects (earliest_time, latest_time, last_update)
|
|
151
|
+
"""
|
|
152
|
+
cwms_cat = get_timeseries_catalog(
|
|
153
|
+
office_id=office_id,
|
|
154
|
+
like=ts_id,
|
|
155
|
+
timeseries_group_like=None,
|
|
156
|
+
page_size=500,
|
|
157
|
+
include_extents=True,
|
|
158
|
+
).df
|
|
159
|
+
|
|
160
|
+
times = cwms_cat[cwms_cat.name == ts_id].extents.values[0][0]
|
|
161
|
+
|
|
162
|
+
earliest_time = pd.to_datetime(times["earliest-time"])
|
|
163
|
+
latest_time = pd.to_datetime(times["latest-time"])
|
|
164
|
+
last_update = pd.to_datetime(times["last-update"])
|
|
165
|
+
|
|
166
|
+
return earliest_time, latest_time, last_update
|
|
@@ -13,7 +13,7 @@ from cwms.cwms_types import JSON, Data
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def get_location_levels(
|
|
16
|
-
level_id_mask: str =
|
|
16
|
+
level_id_mask: Optional[str] = None,
|
|
17
17
|
office_id: Optional[str] = None,
|
|
18
18
|
unit: Optional[str] = None,
|
|
19
19
|
datum: Optional[str] = None,
|
|
@@ -58,13 +58,13 @@ def get_location_levels(
|
|
|
58
58
|
"level-id-mask": level_id_mask,
|
|
59
59
|
"unit": unit,
|
|
60
60
|
"datum": datum,
|
|
61
|
-
"begin": begin.isoformat() if begin else
|
|
62
|
-
"end": end.isoformat() if end else
|
|
61
|
+
"begin": begin.isoformat() if begin else None,
|
|
62
|
+
"end": end.isoformat() if end else None,
|
|
63
63
|
"page": page,
|
|
64
64
|
"page-size": page_size,
|
|
65
65
|
}
|
|
66
|
-
response = api.get(endpoint, params)
|
|
67
|
-
return Data(response)
|
|
66
|
+
response = api.get(endpoint=endpoint, params=params)
|
|
67
|
+
return Data(json=response, selector="levels")
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def get_location_level(
|
|
@@ -169,6 +169,35 @@ def delete_location_level(
|
|
|
169
169
|
return api.delete(endpoint, params)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def update_location_level(
|
|
173
|
+
data: JSON, level_id: str, effective_date: Optional[datetime] = None
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
data : dict
|
|
179
|
+
The JSON data dictionary containing the updated location level information.
|
|
180
|
+
level_id : str
|
|
181
|
+
The ID of the location level to be updated.
|
|
182
|
+
effective_date : datetime, optional
|
|
183
|
+
The effective date of the location level to be updated.
|
|
184
|
+
If the datetime has a timezone it will be used, otherwise it is assumed to be in UTC.
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
if data is None:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"Cannot update a location level without a JSON data dictionary"
|
|
190
|
+
)
|
|
191
|
+
if level_id is None:
|
|
192
|
+
raise ValueError("Cannot update a location level without an id")
|
|
193
|
+
endpoint = f"levels/{level_id}"
|
|
194
|
+
|
|
195
|
+
params = {
|
|
196
|
+
"effective-date": (effective_date.isoformat() if effective_date else None),
|
|
197
|
+
}
|
|
198
|
+
return api.patch(endpoint, data, params)
|
|
199
|
+
|
|
200
|
+
|
|
172
201
|
def get_level_as_timeseries(
|
|
173
202
|
location_level_id: str,
|
|
174
203
|
office_id: str,
|
|
@@ -85,6 +85,84 @@ def get_location_groups(
|
|
|
85
85
|
return Data(response)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def location_group_df_to_json(
|
|
89
|
+
data: pd.DataFrame,
|
|
90
|
+
group_id: str,
|
|
91
|
+
group_office_id: str,
|
|
92
|
+
category_office_id: str,
|
|
93
|
+
category_id: str,
|
|
94
|
+
) -> JSON:
|
|
95
|
+
"""
|
|
96
|
+
Converts a dataframe to a json dictionary in the correct format.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
data: pd.DataFrame
|
|
101
|
+
Dataframe containing timeseries information.
|
|
102
|
+
group_id: str
|
|
103
|
+
The group ID for the timeseries.
|
|
104
|
+
office_id: str
|
|
105
|
+
The ID of the office associated with the specified timeseries.
|
|
106
|
+
category_id: str
|
|
107
|
+
The ID of the category associated with the group
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
JSON
|
|
112
|
+
JSON dictionary of the timeseries data.
|
|
113
|
+
"""
|
|
114
|
+
df = data.copy()
|
|
115
|
+
required_columns = ["office-id", "location-id"]
|
|
116
|
+
optional_columns = ["alias-id", "attribute", "ref-location-id"]
|
|
117
|
+
for column in required_columns:
|
|
118
|
+
if column not in df.columns:
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"{column} is a required column in data when posting as a dataframe"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if df[required_columns].isnull().any().any():
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"Null/NaN values found in required columns: {required_columns}. "
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Fill optional columns with default values if missing
|
|
129
|
+
if "alias-id" not in df.columns:
|
|
130
|
+
df["alias-id"] = None
|
|
131
|
+
if "attribute" not in df.columns:
|
|
132
|
+
df["attribute"] = 0
|
|
133
|
+
|
|
134
|
+
# Replace NaN with None for optional columns
|
|
135
|
+
for column in optional_columns:
|
|
136
|
+
if column in df.columns:
|
|
137
|
+
df[column] = df[column].where(pd.notnull(df[column]), None)
|
|
138
|
+
|
|
139
|
+
# Build the list of time-series entries
|
|
140
|
+
assigned_locs = df.apply(
|
|
141
|
+
lambda entry: {
|
|
142
|
+
"office-id": entry["office-id"],
|
|
143
|
+
"location-id": entry["location-id"],
|
|
144
|
+
"alias-id": entry["alias-id"],
|
|
145
|
+
"attribute": entry["attribute"],
|
|
146
|
+
**(
|
|
147
|
+
{"ref-location-id": entry["ref-location-id"]}
|
|
148
|
+
if "ref-location-id" in entry and pd.notna(entry["ref-location-id"])
|
|
149
|
+
else {}
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
axis=1,
|
|
153
|
+
).tolist()
|
|
154
|
+
|
|
155
|
+
# Construct the final JSON dictionary
|
|
156
|
+
json_dict = {
|
|
157
|
+
"office-id": group_office_id,
|
|
158
|
+
"id": group_id,
|
|
159
|
+
"location-category": {"office-id": category_office_id, "id": category_id},
|
|
160
|
+
"assigned-locations": assigned_locs,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return json_dict
|
|
164
|
+
|
|
165
|
+
|
|
88
166
|
def store_location_groups(data: JSON) -> None:
|
|
89
167
|
"""
|
|
90
168
|
Create new Location Group
|
|
@@ -140,7 +218,12 @@ def update_location_group(
|
|
|
140
218
|
api.patch(endpoint=endpoint, data=data, params=params, api_version=1)
|
|
141
219
|
|
|
142
220
|
|
|
143
|
-
def delete_location_group(
|
|
221
|
+
def delete_location_group(
|
|
222
|
+
group_id: str,
|
|
223
|
+
category_id: str,
|
|
224
|
+
office_id: str,
|
|
225
|
+
cascade_delete: Optional[bool] = False,
|
|
226
|
+
) -> None:
|
|
144
227
|
"""Deletes requested time series group
|
|
145
228
|
|
|
146
229
|
Parameters
|
|
@@ -161,6 +244,7 @@ def delete_location_group(group_id: str, category_id: str, office_id: str) -> No
|
|
|
161
244
|
params = {
|
|
162
245
|
"office": office_id,
|
|
163
246
|
"category-id": category_id,
|
|
247
|
+
"cascade-delete": cascade_delete,
|
|
164
248
|
}
|
|
165
249
|
|
|
166
250
|
return api.delete(endpoint, params=params, api_version=1)
|
|
@@ -128,7 +128,7 @@ def delete_location(
|
|
|
128
128
|
return api.delete(endpoint, params=params)
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
def store_location(data: JSON) -> None:
|
|
131
|
+
def store_location(data: JSON, fail_if_exists: bool = True) -> None:
|
|
132
132
|
"""
|
|
133
133
|
This method is used to store and update location's data through CWMS Data API.
|
|
134
134
|
|
|
@@ -137,6 +137,10 @@ def store_location(data: JSON) -> None:
|
|
|
137
137
|
data : dict
|
|
138
138
|
A dictionary representing the JSON data to be stored.
|
|
139
139
|
If the `data` value is None, a `ValueError` will be raised.
|
|
140
|
+
fail_if_exists : bool, optional
|
|
141
|
+
A boolean value indicating whether to fail if the outlet already exists.
|
|
142
|
+
Default is True.
|
|
143
|
+
|
|
140
144
|
|
|
141
145
|
Returns
|
|
142
146
|
-------
|
|
@@ -148,8 +152,8 @@ def store_location(data: JSON) -> None:
|
|
|
148
152
|
raise ValueError("Storing location requires a JSON data dictionary")
|
|
149
153
|
|
|
150
154
|
endpoint = "locations"
|
|
151
|
-
|
|
152
|
-
return api.post(endpoint, data)
|
|
155
|
+
params = {"fail-if-exists": fail_if_exists}
|
|
156
|
+
return api.post(endpoint, data, params=params)
|
|
153
157
|
|
|
154
158
|
|
|
155
159
|
def update_location(location_id: str, data: JSON) -> None:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
|
|
3
3
|
# All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
|
|
4
4
|
# Source may not be released without written approval from HEC
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
5
7
|
import cwms.api as api
|
|
6
8
|
from cwms.cwms_types import JSON, Data
|
|
7
9
|
|
|
@@ -74,7 +76,7 @@ def get_pump_accounting(
|
|
|
74
76
|
|
|
75
77
|
endpoint = f"projects/{office_id}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting"
|
|
76
78
|
|
|
77
|
-
params: dict[str, str
|
|
79
|
+
params: dict[str, Union[str, int]] = {
|
|
78
80
|
"start": start,
|
|
79
81
|
"end": end,
|
|
80
82
|
"timezone": timezone,
|