cwms-python 0.8.0__tar.gz → 1.0.1__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.1}/PKG-INFO +4 -2
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/api.py +105 -38
- cwms_python-1.0.1/cwms/catalog/blobs.py +148 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/catalog/catalog.py +35 -1
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/levels/location_levels.py +34 -5
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/location_groups.py +85 -1
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/physical_locations.py +7 -3
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/water_supply/accounting.py +3 -1
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings.py +219 -1
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings_spec.py +16 -4
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries.py +289 -17
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_group.py +1 -1
- {cwms_python-0.8.0 → cwms_python-1.0.1}/pyproject.toml +1 -1
- cwms_python-0.8.0/cwms/catalog/blobs.py +0 -99
- {cwms_python-0.8.0 → cwms_python-1.0.1}/LICENSE +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/README.md +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/__init__.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/catalog/clobs.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/cwms_types.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/measurements/measurements.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/outlets/outlets.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/project_locks.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/projects.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/turbines/turbines.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/utils/__init__.py +0 -0
- {cwms_python-0.8.0 → cwms_python-1.0.1}/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.1
|
|
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)
|
|
@@ -29,6 +29,7 @@ the error.
|
|
|
29
29
|
import base64
|
|
30
30
|
import json
|
|
31
31
|
import logging
|
|
32
|
+
from http import HTTPStatus
|
|
32
33
|
from json import JSONDecodeError
|
|
33
34
|
from typing import Any, Optional, cast
|
|
34
35
|
|
|
@@ -72,9 +73,9 @@ class InvalidVersion(Exception):
|
|
|
72
73
|
class ApiError(Exception):
|
|
73
74
|
"""CWMS Data Api Error.
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
Light wrapper around a response-like object (e.g., requests.Response or a
|
|
77
|
+
test stub with url, status_code, reason, and content attributes). Produces
|
|
78
|
+
a concise, single-line error message with an optional hint.
|
|
78
79
|
"""
|
|
79
80
|
|
|
80
81
|
def __init__(self, response: Response):
|
|
@@ -91,23 +92,37 @@ class ApiError(Exception):
|
|
|
91
92
|
message += "."
|
|
92
93
|
|
|
93
94
|
# Add additional context to help the user resolve the issue.
|
|
94
|
-
|
|
95
|
+
hint = self.hint()
|
|
96
|
+
if hint:
|
|
95
97
|
message += f" {hint}"
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
# Optional content (decoded if bytes)
|
|
100
|
+
content = getattr(self.response, "content", None)
|
|
101
|
+
if content:
|
|
102
|
+
if isinstance(content, bytes):
|
|
103
|
+
try:
|
|
104
|
+
text = content.decode("utf-8", errors="replace")
|
|
105
|
+
except Exception:
|
|
106
|
+
text = repr(content)
|
|
107
|
+
else:
|
|
108
|
+
text = str(content)
|
|
109
|
+
message += f" {text}"
|
|
99
110
|
|
|
100
111
|
return message
|
|
101
112
|
|
|
102
113
|
def hint(self) -> str:
|
|
103
|
-
"""Return a
|
|
114
|
+
"""Return a short hint based on HTTP status code."""
|
|
115
|
+
status = getattr(self.response, "status_code", None)
|
|
104
116
|
|
|
105
|
-
if
|
|
117
|
+
if status == 429:
|
|
118
|
+
return "Too many requests made."
|
|
119
|
+
if status == 400:
|
|
106
120
|
return "Check that your parameters are correct."
|
|
107
|
-
|
|
121
|
+
if status == 404:
|
|
108
122
|
return "May be the result of an empty query."
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
|
|
124
|
+
# No hint for other codes
|
|
125
|
+
return ""
|
|
111
126
|
|
|
112
127
|
|
|
113
128
|
def init_session(
|
|
@@ -132,6 +147,8 @@ def init_session(
|
|
|
132
147
|
|
|
133
148
|
global SESSION
|
|
134
149
|
if api_root:
|
|
150
|
+
# Ensure the API_ROOT ends with a single slash
|
|
151
|
+
api_root = api_root.rstrip("/") + "/"
|
|
135
152
|
logging.debug(f"Initializing root URL: api_root={api_root}")
|
|
136
153
|
SESSION = sessions.BaseUrlSession(base_url=api_root)
|
|
137
154
|
adapter = adapters.HTTPAdapter(
|
|
@@ -213,6 +230,33 @@ def get_xml(
|
|
|
213
230
|
return get(endpoint=endpoint, params=params, api_version=api_version)
|
|
214
231
|
|
|
215
232
|
|
|
233
|
+
def _process_response(response: Response) -> Any:
|
|
234
|
+
try:
|
|
235
|
+
# Avoid case sensitivity issues with the content type header
|
|
236
|
+
content_type = response.headers.get("Content-Type", "").lower()
|
|
237
|
+
# Most CDA content is JSON
|
|
238
|
+
if "application/json" in content_type or not content_type:
|
|
239
|
+
return cast(JSON, response.json())
|
|
240
|
+
# Use automatic charset detection with .text
|
|
241
|
+
if "text/plain" in content_type or "text/" in content_type:
|
|
242
|
+
return response.text
|
|
243
|
+
if content_type.startswith("image/"):
|
|
244
|
+
return base64.b64encode(response.content).decode("utf-8")
|
|
245
|
+
# Handle excel content types
|
|
246
|
+
if content_type in [
|
|
247
|
+
"application/vnd.ms-excel",
|
|
248
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
249
|
+
]:
|
|
250
|
+
return response.content
|
|
251
|
+
# Fallback for remaining content types
|
|
252
|
+
return response.content.decode("utf-8")
|
|
253
|
+
except JSONDecodeError as error:
|
|
254
|
+
logging.error(
|
|
255
|
+
f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text"
|
|
256
|
+
)
|
|
257
|
+
return response.text
|
|
258
|
+
|
|
259
|
+
|
|
216
260
|
def get(
|
|
217
261
|
endpoint: str,
|
|
218
262
|
params: Optional[RequestParams] = None,
|
|
@@ -241,24 +285,7 @@ def get(
|
|
|
241
285
|
if not response.ok:
|
|
242
286
|
logging.error(f"CDA Error: response={response}")
|
|
243
287
|
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
|
|
288
|
+
return _process_response(response)
|
|
262
289
|
|
|
263
290
|
|
|
264
291
|
def get_with_paging(
|
|
@@ -301,6 +328,25 @@ def get_with_paging(
|
|
|
301
328
|
return response
|
|
302
329
|
|
|
303
330
|
|
|
331
|
+
def _post_function(
|
|
332
|
+
endpoint: str,
|
|
333
|
+
data: Any,
|
|
334
|
+
params: Optional[RequestParams] = None,
|
|
335
|
+
*,
|
|
336
|
+
api_version: int = API_VERSION,
|
|
337
|
+
) -> Any:
|
|
338
|
+
|
|
339
|
+
# post requires different headers than get for
|
|
340
|
+
headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
|
|
341
|
+
if isinstance(data, dict) or isinstance(data, list):
|
|
342
|
+
data = json.dumps(data)
|
|
343
|
+
with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
|
|
344
|
+
if not response.ok:
|
|
345
|
+
logging.error(f"CDA Error: response={response}")
|
|
346
|
+
raise ApiError(response)
|
|
347
|
+
return response
|
|
348
|
+
|
|
349
|
+
|
|
304
350
|
def post(
|
|
305
351
|
endpoint: str,
|
|
306
352
|
data: Any,
|
|
@@ -320,22 +366,43 @@ def post(
|
|
|
320
366
|
the default API_VERSION will be used.
|
|
321
367
|
|
|
322
368
|
Returns:
|
|
323
|
-
|
|
369
|
+
None
|
|
324
370
|
|
|
325
371
|
Raises:
|
|
326
372
|
ApiError: If an error response is return by the API.
|
|
327
373
|
"""
|
|
374
|
+
_post_function(endpoint=endpoint, data=data, params=params, api_version=api_version)
|
|
328
375
|
|
|
329
|
-
# post requires different headers than get for
|
|
330
|
-
headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
|
|
331
376
|
|
|
332
|
-
|
|
333
|
-
|
|
377
|
+
def post_with_returned_data(
|
|
378
|
+
endpoint: str,
|
|
379
|
+
data: Any,
|
|
380
|
+
params: Optional[RequestParams] = None,
|
|
381
|
+
*,
|
|
382
|
+
api_version: int = API_VERSION,
|
|
383
|
+
) -> Any:
|
|
384
|
+
"""Make a POST request to the CWMS Data API.
|
|
334
385
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
386
|
+
Args:
|
|
387
|
+
endpoint: The CDA endpoint for the record type.
|
|
388
|
+
data: A dict containing the new record data. Must be JSON-serializable.
|
|
389
|
+
params (optional): Query parameters for the request.
|
|
390
|
+
|
|
391
|
+
Keyword Args:
|
|
392
|
+
api_version (optional): The CDA version to use for the request. If not specified,
|
|
393
|
+
the default API_VERSION will be used.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The response data.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ApiError: If an error response is return by the API.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
response = _post_function(
|
|
403
|
+
endpoint=endpoint, data=data, params=params, api_version=api_version
|
|
404
|
+
)
|
|
405
|
+
return _process_response(response)
|
|
339
406
|
|
|
340
407
|
|
|
341
408
|
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,
|