cwms-cli 0.1.1__py3-none-any.whl
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_cli-0.1.1.dist-info/METADATA +40 -0
- cwms_cli-0.1.1.dist-info/RECORD +41 -0
- cwms_cli-0.1.1.dist-info/WHEEL +4 -0
- cwms_cli-0.1.1.dist-info/entry_points.txt +3 -0
- cwms_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- cwmscli/__init__.py +12 -0
- cwmscli/__main__.py +15 -0
- cwmscli/callbacks/__init__.py +18 -0
- cwmscli/commands/blob.py +439 -0
- cwmscli/commands/commands_cwms.py +227 -0
- cwmscli/commands/csv2cwms/.gitignore +3 -0
- cwmscli/commands/csv2cwms/README.md +51 -0
- cwmscli/commands/csv2cwms/__init__.py +5 -0
- cwmscli/commands/csv2cwms/__main__.py +265 -0
- cwmscli/commands/csv2cwms/examples/complete_config.json +19 -0
- cwmscli/commands/csv2cwms/examples/hourly.json +243 -0
- cwmscli/commands/csv2cwms/examples/minutes.json +315 -0
- cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
- cwmscli/commands/csv2cwms/tests/data/.gitignore +1 -0
- cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +278 -0
- cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +9 -0
- cwmscli/commands/csv2cwms/tests/data/sample_config.json +45 -0
- cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +35 -0
- cwmscli/commands/csv2cwms/tests/test_dateutils.py +68 -0
- cwmscli/commands/csv2cwms/tests/test_expressions.py +49 -0
- cwmscli/commands/csv2cwms/tests/test_fileio.py +43 -0
- cwmscli/commands/csv2cwms/utils/__init__.py +5 -0
- cwmscli/commands/csv2cwms/utils/dateutils.py +105 -0
- cwmscli/commands/csv2cwms/utils/expression.py +39 -0
- cwmscli/commands/csv2cwms/utils/fileio.py +26 -0
- cwmscli/commands/csv2cwms/utils/logging.py +80 -0
- cwmscli/commands/csv2cwms/utils/terminal.py +45 -0
- cwmscli/commands/shef_critfile_import.py +146 -0
- cwmscli/requirements.py +25 -0
- cwmscli/usgs/__init__.py +161 -0
- cwmscli/usgs/getUSGS_ratings_cda.py +346 -0
- cwmscli/usgs/getusgs_cda.py +345 -0
- cwmscli/usgs/getusgs_measurements_cda.py +961 -0
- cwmscli/usgs/rating_ini_file_import.py +130 -0
- cwmscli/utils/__init__.py +68 -0
- cwmscli/utils/deps.py +102 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from cwmscli import requirements as reqs
|
|
4
|
+
from cwmscli.callbacks import csv_to_list
|
|
5
|
+
from cwmscli.commands import csv2cwms
|
|
6
|
+
from cwmscli.utils import api_key_loc_option, common_api_options
|
|
7
|
+
from cwmscli.utils.deps import requires
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command(
|
|
11
|
+
"shefcritimport",
|
|
12
|
+
help="Import SHEF crit file into timeseries group for SHEF file processing",
|
|
13
|
+
)
|
|
14
|
+
@click.option(
|
|
15
|
+
"-f",
|
|
16
|
+
"--filename",
|
|
17
|
+
required=True,
|
|
18
|
+
type=str,
|
|
19
|
+
help="filename of SHEF crit file to be processed",
|
|
20
|
+
)
|
|
21
|
+
@common_api_options
|
|
22
|
+
@api_key_loc_option
|
|
23
|
+
@requires(reqs.cwms)
|
|
24
|
+
def shefcritimport(filename, office, api_root, api_key, api_key_loc):
|
|
25
|
+
from cwmscli.commands.shef_critfile_import import import_shef_critfile
|
|
26
|
+
|
|
27
|
+
api_key = get_api_key(api_key, api_key_loc)
|
|
28
|
+
import_shef_critfile(
|
|
29
|
+
file_path=filename,
|
|
30
|
+
office_id=office,
|
|
31
|
+
api_root=api_root,
|
|
32
|
+
api_key=api_key,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command("csv2cwms", help="Store CSV TimeSeries data to CWMS using a config file")
|
|
37
|
+
@common_api_options
|
|
38
|
+
@click.option(
|
|
39
|
+
"--input-keys",
|
|
40
|
+
"input_keys",
|
|
41
|
+
default="all",
|
|
42
|
+
show_default=True,
|
|
43
|
+
help='Input keys. Defaults to all keys/files with --input-keys=all. These are the keys under "input_files" in a given config file. This option lets you run a single file from a config that contains multiple files. Example: --input-keys=file1',
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"-lb",
|
|
47
|
+
"--lookback",
|
|
48
|
+
type=int,
|
|
49
|
+
default=24 * 5,
|
|
50
|
+
show_default=True,
|
|
51
|
+
help="Lookback period in HOURS",
|
|
52
|
+
)
|
|
53
|
+
@click.option("-v", "--verbose", is_flag=True, help="Verbose logging")
|
|
54
|
+
@click.option(
|
|
55
|
+
"-c",
|
|
56
|
+
"--config",
|
|
57
|
+
"config_path",
|
|
58
|
+
required=True,
|
|
59
|
+
type=click.Path(exists=True),
|
|
60
|
+
help="Path to JSON config file",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"-df",
|
|
64
|
+
"--data-file",
|
|
65
|
+
"data_file",
|
|
66
|
+
type=str,
|
|
67
|
+
help="Override CSV file (else use config)",
|
|
68
|
+
)
|
|
69
|
+
@click.option("--log", show_default=True, help="Path to the log file.")
|
|
70
|
+
@click.option("--dry-run", is_flag=True, help="Log only (no HTTP calls)")
|
|
71
|
+
@click.option("--begin", type=str, help="YYYY-MM-DDTHH:MM (local to --tz)")
|
|
72
|
+
@click.option("-tz", "--timezone", "tz", default="GMT", show_default=True)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--ignore-ssl-errors", is_flag=True, help="Ignore TLS errors (testing only)"
|
|
75
|
+
)
|
|
76
|
+
@click.version_option(version=csv2cwms.__version__)
|
|
77
|
+
@requires(reqs.cwms)
|
|
78
|
+
def csv2cwms_cmd(**kwargs):
|
|
79
|
+
from cwmscli.commands.csv2cwms.__main__ import main as csv2_main
|
|
80
|
+
|
|
81
|
+
# Handle the version for this specific command
|
|
82
|
+
if kwargs.pop("version", False):
|
|
83
|
+
from cwmscli.commands.csv2cwms import __version__
|
|
84
|
+
|
|
85
|
+
click.echo(f"csv2cwms v{__version__}")
|
|
86
|
+
return
|
|
87
|
+
csv2_main(**kwargs)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# region Blob
|
|
91
|
+
# ================================================================================
|
|
92
|
+
# BLOB
|
|
93
|
+
# ================================================================================
|
|
94
|
+
@click.group(
|
|
95
|
+
"blob",
|
|
96
|
+
help="Manage CWMS Blobs (upload, download, delete, update, list)",
|
|
97
|
+
epilog="""
|
|
98
|
+
* Store a PDF/image as a CWMS blob with optional description
|
|
99
|
+
* Download a blob by id to your local filesystem
|
|
100
|
+
* Update a blob's name/description
|
|
101
|
+
* Bulk list blobs for an office
|
|
102
|
+
""",
|
|
103
|
+
)
|
|
104
|
+
@requires(reqs.cwms)
|
|
105
|
+
def blob_group():
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ================================================================================
|
|
110
|
+
# Upload
|
|
111
|
+
# ================================================================================
|
|
112
|
+
@blob_group.command("upload", help="Upload a file as a blob")
|
|
113
|
+
@click.option(
|
|
114
|
+
"--input-file",
|
|
115
|
+
required=True,
|
|
116
|
+
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=str),
|
|
117
|
+
help="Path to the file to upload.",
|
|
118
|
+
)
|
|
119
|
+
@click.option("--blob-id", required=True, type=str, help="Blob ID to create.")
|
|
120
|
+
@click.option("--description", default=None, help="Optional description JSON or text.")
|
|
121
|
+
@click.option(
|
|
122
|
+
"--media-type",
|
|
123
|
+
default=None,
|
|
124
|
+
help="Override media type (guessed from file if omitted).",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--overwrite/--no-overwrite",
|
|
128
|
+
default=False,
|
|
129
|
+
show_default=True,
|
|
130
|
+
help="If true, replace existing blob.",
|
|
131
|
+
)
|
|
132
|
+
@click.option("--dry-run", is_flag=True, help="Show request; do not send.")
|
|
133
|
+
@common_api_options
|
|
134
|
+
def blob_upload(**kwargs):
|
|
135
|
+
from cwmscli.commands.blob import upload_cmd
|
|
136
|
+
|
|
137
|
+
upload_cmd(**kwargs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ================================================================================
|
|
141
|
+
# Download
|
|
142
|
+
# ================================================================================
|
|
143
|
+
@blob_group.command("download", help="Download a blob by ID")
|
|
144
|
+
# TODO: test XML
|
|
145
|
+
@click.option("--blob-id", required=True, type=str, help="Blob ID to download.")
|
|
146
|
+
@click.option(
|
|
147
|
+
"--dest",
|
|
148
|
+
default=None,
|
|
149
|
+
help="Destination file path. Defaults to blob-id.",
|
|
150
|
+
)
|
|
151
|
+
@common_api_options
|
|
152
|
+
def blob_download(**kwargs):
|
|
153
|
+
from cwmscli.commands.blob import download_cmd
|
|
154
|
+
|
|
155
|
+
download_cmd(**kwargs)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ================================================================================
|
|
159
|
+
# Delete
|
|
160
|
+
# ================================================================================
|
|
161
|
+
@blob_group.command("delete", help="[Not implemented] Delete a blob by ID")
|
|
162
|
+
@click.option("--blob-id", required=True, type=str, help="Blob ID to delete.")
|
|
163
|
+
@common_api_options
|
|
164
|
+
def delete_cmd(**kwargs):
|
|
165
|
+
from cwmscli.commands.blob import delete_cmd
|
|
166
|
+
|
|
167
|
+
delete_cmd(**kwargs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ================================================================================
|
|
171
|
+
# Update
|
|
172
|
+
# ================================================================================
|
|
173
|
+
@blob_group.command("update", help="[Not implemented] Update/patch a blob by ID")
|
|
174
|
+
@click.option("--blob-id", required=True, type=str, help="Blob ID to update.")
|
|
175
|
+
@click.option(
|
|
176
|
+
"--input-file",
|
|
177
|
+
required=False,
|
|
178
|
+
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=str),
|
|
179
|
+
help="Optional file content to upload with update.",
|
|
180
|
+
)
|
|
181
|
+
@common_api_options
|
|
182
|
+
def update_cmd(**kwargs):
|
|
183
|
+
from cwmscli.commands.blob import update_cmd
|
|
184
|
+
|
|
185
|
+
update_cmd(**kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ================================================================================
|
|
189
|
+
# List
|
|
190
|
+
# ================================================================================
|
|
191
|
+
@blob_group.command("list", help="List blobs with optional filters and sorting")
|
|
192
|
+
# TODO: Add link to regex docs when new CWMS-DATA site is deployed to PROD
|
|
193
|
+
@click.option(
|
|
194
|
+
"--blob-id-like", help="LIKE filter for blob ID (e.g., ``*PNG``)."
|
|
195
|
+
) # Escape the wildcard/asterisk for RTD generation with double backticks
|
|
196
|
+
@click.option(
|
|
197
|
+
"--columns",
|
|
198
|
+
multiple=True,
|
|
199
|
+
callback=csv_to_list,
|
|
200
|
+
help="Columns to show (repeat or comma-separate).",
|
|
201
|
+
)
|
|
202
|
+
@click.option(
|
|
203
|
+
"--sort-by",
|
|
204
|
+
multiple=True,
|
|
205
|
+
callback=csv_to_list,
|
|
206
|
+
help="Columns to sort by (repeat or comma-separate).",
|
|
207
|
+
)
|
|
208
|
+
@click.option(
|
|
209
|
+
"--desc/--asc",
|
|
210
|
+
default=False,
|
|
211
|
+
show_default=True,
|
|
212
|
+
help="Sort descending instead of ascending.",
|
|
213
|
+
)
|
|
214
|
+
@click.option("--limit", type=int, default=None, help="Max rows to show.")
|
|
215
|
+
@click.option(
|
|
216
|
+
"--to-csv",
|
|
217
|
+
type=click.Path(dir_okay=False, writable=True, path_type=str),
|
|
218
|
+
help="If set, write results to this CSV file.",
|
|
219
|
+
)
|
|
220
|
+
@common_api_options
|
|
221
|
+
def list_cmd(**kwargs):
|
|
222
|
+
from cwmscli.commands.blob import list_cmd
|
|
223
|
+
|
|
224
|
+
list_cmd(**kwargs)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# CSV2CWMS
|
|
2
|
+
|
|
3
|
+
Writes CSV timeseries data to CDA using a configuration file.
|
|
4
|
+
|
|
5
|
+
To View the Help: `cwms-cli csv2cwms --help`
|
|
6
|
+
|
|
7
|
+
## USAGE (--help)
|
|
8
|
+
|
|
9
|
+
Usage: cwms-cli csv2cwms [OPTIONS]
|
|
10
|
+
|
|
11
|
+
Store CSV TimeSeries data to CWMS using a config file
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
-o, --office TEXT Office to grab data for [required]
|
|
15
|
+
-a, --api_root TEXT Api Root for CDA. Can be user defined or placed
|
|
16
|
+
in a env variable CDA_API_ROOT [required]
|
|
17
|
+
-k, --api_key TEXT api key for CDA. Can be user defined or place in
|
|
18
|
+
env variable CDA_API_KEY. one of api_key or
|
|
19
|
+
api_key_loc are required
|
|
20
|
+
-l, --location TEXT Location ID. Use "-p=all" for all locations.
|
|
21
|
+
[default: all]
|
|
22
|
+
-lb, --lookback INTEGER Lookback period in HOURS [default: 120]
|
|
23
|
+
-v, --verbose Verbose logging
|
|
24
|
+
-c, --config PATH Path to JSON config file [required]
|
|
25
|
+
[default: all]
|
|
26
|
+
-lb, --lookback INTEGER Lookback period in HOURS [default: 120]
|
|
27
|
+
-v, --verbose Verbose logging
|
|
28
|
+
[default: all]
|
|
29
|
+
[default: all]
|
|
30
|
+
-lb, --lookback INTEGER Lookback period in HOURS [default: 120]
|
|
31
|
+
-v, --verbose Verbose logging
|
|
32
|
+
-c, --config PATH Path to JSON config file [required]
|
|
33
|
+
-df, --data-file TEXT Override CSV file (else use config)
|
|
34
|
+
--log TEXT Path to the log file.
|
|
35
|
+
-dp, --data-path DIRECTORY Directory where csv files are stored [default:
|
|
36
|
+
.]
|
|
37
|
+
--dry-run Log only (no HTTP calls)
|
|
38
|
+
--begin TEXT YYYY-MM-DDTHH:MM (local to --tz)
|
|
39
|
+
-tz, --timezone TEXT [default: GMT]
|
|
40
|
+
--ignore-ssl-errors Ignore TLS errors (testing only)
|
|
41
|
+
--version Show the version and exit.
|
|
42
|
+
--help Show this message and exit.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- Allow for specifying one or more date formats that might be seen per input csv file
|
|
47
|
+
- Allow mathematical operations across multiple columns and storing into one timeseries
|
|
48
|
+
- Store one column of data with a user-specified precision and units to a timeseries identifier
|
|
49
|
+
- Dry runs to test what data might look like prior to database storage
|
|
50
|
+
- Verbose logging via the -v flag
|
|
51
|
+
- Colored terminal output for user readability
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Script Entry File
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import cwms
|
|
9
|
+
|
|
10
|
+
# Add the current directory to the path
|
|
11
|
+
# This is necessary for the script to be run as a standalone script
|
|
12
|
+
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Handle imports for local and package use
|
|
16
|
+
# This is necessary for the script to be run as a package or as a standalone script
|
|
17
|
+
# The script can be run as a standalone script by running `python -m scada_ts` from the parent directory
|
|
18
|
+
# or as a package by running `python scada_ts` from the parent directory
|
|
19
|
+
try:
|
|
20
|
+
# Relative imports for modules
|
|
21
|
+
from . import __author__, __license__, __version__
|
|
22
|
+
from .utils import (
|
|
23
|
+
colorize,
|
|
24
|
+
colorize_count,
|
|
25
|
+
determine_interval,
|
|
26
|
+
eval_expression,
|
|
27
|
+
load_csv,
|
|
28
|
+
logger,
|
|
29
|
+
parse_date,
|
|
30
|
+
read_config,
|
|
31
|
+
safe_zoneinfo,
|
|
32
|
+
setup_logger,
|
|
33
|
+
)
|
|
34
|
+
except ImportError:
|
|
35
|
+
from __init__ import __author__, __license__, __version__
|
|
36
|
+
from utils import (
|
|
37
|
+
colorize,
|
|
38
|
+
colorize_count,
|
|
39
|
+
determine_interval,
|
|
40
|
+
eval_expression,
|
|
41
|
+
load_csv,
|
|
42
|
+
logger,
|
|
43
|
+
parse_date,
|
|
44
|
+
read_config,
|
|
45
|
+
safe_zoneinfo,
|
|
46
|
+
setup_logger,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Load environment variables
|
|
50
|
+
API_KEY = os.getenv("CDA_API_KEY")
|
|
51
|
+
OFFICE = os.getenv("CDA_OFFICE", "SWT")
|
|
52
|
+
HOST = os.getenv("CDA_HOST")
|
|
53
|
+
|
|
54
|
+
if [API_KEY, OFFICE, HOST].count(None) > 0:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Environment variables CDA_API_KEY, CDA_OFFICE, and CDA_HOST must be set."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_file(file_path, begin_time, date_format, timezone="GMT"):
|
|
61
|
+
csv_data = load_csv(file_path)
|
|
62
|
+
header = csv_data[0]
|
|
63
|
+
data = csv_data[1:]
|
|
64
|
+
ts_data = {}
|
|
65
|
+
logger.debug(f"Begin time: {begin_time}")
|
|
66
|
+
for row in data:
|
|
67
|
+
# Skip empty rows or rows without a timestamp
|
|
68
|
+
if not row:
|
|
69
|
+
continue
|
|
70
|
+
row_datetime = parse_date(row[0], tz_str=timezone, date_format=date_format)
|
|
71
|
+
# Guarantee only one entry per timestamp
|
|
72
|
+
ts_data[int(row_datetime.timestamp())] = row
|
|
73
|
+
return {"header": header, "data": ts_data}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_timeseries(file_data, file_key, config):
|
|
77
|
+
header = file_data.get("header", [])
|
|
78
|
+
data = file_data.get("data", {})
|
|
79
|
+
|
|
80
|
+
if not header or not data:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"No data found in the CSV file for the range selected. Please ensure you set the timezone of the CSV file with --tz America/Chicago or similar."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
ts_config = config["input_files"][file_key]["timeseries"]
|
|
86
|
+
file_ts = []
|
|
87
|
+
|
|
88
|
+
# Interval in seconds
|
|
89
|
+
interval = config.get("interval")
|
|
90
|
+
if not interval:
|
|
91
|
+
interval = determine_interval(data, 10)
|
|
92
|
+
logger.warning(
|
|
93
|
+
f"Interval not found in configuration. Determined interval: {interval} seconds."
|
|
94
|
+
)
|
|
95
|
+
start_epoch = min(data.keys())
|
|
96
|
+
end_epoch = max(data.keys())
|
|
97
|
+
|
|
98
|
+
# Map column names to indexes (case-insensitive)
|
|
99
|
+
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
|
100
|
+
logger.debug(f"Header map (column name -> index): {header_map}")
|
|
101
|
+
|
|
102
|
+
for name, meta in ts_config.items():
|
|
103
|
+
expr = meta["columns"]
|
|
104
|
+
units = meta.get("units", "")
|
|
105
|
+
precision = meta.get("precision", 2)
|
|
106
|
+
values = []
|
|
107
|
+
epoch = start_epoch
|
|
108
|
+
while epoch <= end_epoch:
|
|
109
|
+
row = data.get(epoch)
|
|
110
|
+
if row:
|
|
111
|
+
value = eval_expression(expr, row, header_map)
|
|
112
|
+
value = round(value, precision) if value is not None else None
|
|
113
|
+
quality = 3 if value is not None else 5
|
|
114
|
+
else:
|
|
115
|
+
value = None
|
|
116
|
+
quality = 5
|
|
117
|
+
logger.debug(
|
|
118
|
+
f"[{name}] {datetime.fromtimestamp(epoch)} -> {value} (quality: {quality})"
|
|
119
|
+
)
|
|
120
|
+
values.append([epoch * 1000, value, quality])
|
|
121
|
+
# Convert seconds to minutes
|
|
122
|
+
epoch += interval
|
|
123
|
+
|
|
124
|
+
ts_obj = {"name": name, "units": units, "values": values}
|
|
125
|
+
valid = sum(1 for _, v, _ in values if v is not None)
|
|
126
|
+
total = len(values)
|
|
127
|
+
logger.info(
|
|
128
|
+
f"Built timeseries {colorize(name, 'blue')} with {colorize_count(valid, total)} valid points."
|
|
129
|
+
)
|
|
130
|
+
logger.debug(
|
|
131
|
+
f"Timeseries {name} data range: {colorize(datetime.fromtimestamp(start_epoch), 'blue')} to {colorize(datetime.fromtimestamp(end_epoch), 'blue')}"
|
|
132
|
+
)
|
|
133
|
+
file_ts.append(ts_obj)
|
|
134
|
+
|
|
135
|
+
return file_ts
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def config_check(config):
|
|
139
|
+
"""Checks a configuration file for required keys"""
|
|
140
|
+
if not config.get("interval"):
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Configuration file does not contain an 'interval' key (and value in seconds), this is recommended per CSV file to avoid ambiguity."
|
|
143
|
+
)
|
|
144
|
+
if config.get("projects"):
|
|
145
|
+
logger.warning(
|
|
146
|
+
"Configuration file contains a 'projects' key, this has been renamed to 'input_files' for clarity. Continuing for backwards compatibility."
|
|
147
|
+
)
|
|
148
|
+
config["input_files"] = config.pop("projects")
|
|
149
|
+
if not config.get("input_files"):
|
|
150
|
+
raise ValueError("Configuration file must contain an 'input_files' key.")
|
|
151
|
+
for file_key, file_data in config.get("input_files").items():
|
|
152
|
+
# Only check the specified keys or if all keys are specified
|
|
153
|
+
if file_key != "all" and file_key != file_key.lower():
|
|
154
|
+
continue
|
|
155
|
+
if not file_data.get("timeseries"):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Configuration file must contain a 'timeseries' key for file '{file_key}'."
|
|
158
|
+
)
|
|
159
|
+
for ts_name, ts_data in file_data.get("timeseries").items():
|
|
160
|
+
if not ts_data.get("columns"):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"Configuration file must contain a 'columns' key for timeseries '{ts_name}' in file '{file_key}'."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(*args, **kwargs):
|
|
167
|
+
"""
|
|
168
|
+
Main function to execute the scada_ts script.
|
|
169
|
+
This function serves as the entry point for the script.
|
|
170
|
+
"""
|
|
171
|
+
start_time = time.time()
|
|
172
|
+
tz = safe_zoneinfo(kwargs.get("tz"))
|
|
173
|
+
if kwargs.get("begin"):
|
|
174
|
+
try:
|
|
175
|
+
begin_time = datetime.strptime(
|
|
176
|
+
kwargs.get("begin"), "%Y-%m-%dT%H:%M"
|
|
177
|
+
).replace(tzinfo=tz)
|
|
178
|
+
except ValueError:
|
|
179
|
+
raise ValueError("--begin must be in format YYYY-MM-DDTHH:MM")
|
|
180
|
+
else:
|
|
181
|
+
begin_time = datetime.now(tz)
|
|
182
|
+
|
|
183
|
+
cwms.api.init_session(
|
|
184
|
+
api_root=kwargs.get("api_root"), api_key=kwargs.get("api_key")
|
|
185
|
+
)
|
|
186
|
+
# Setup the logger if a path is provided
|
|
187
|
+
setup_logger(kwargs.get("log"), verbose=kwargs.get("verbose"))
|
|
188
|
+
logger.info(f"Begin time: {begin_time}")
|
|
189
|
+
logger.debug(f"Timezone: {tz}")
|
|
190
|
+
# Override environment variables if provided in CLI
|
|
191
|
+
if kwargs.get("coop"):
|
|
192
|
+
HOST = os.getenv("CDA_COOP_HOST")
|
|
193
|
+
if not HOST:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"Environment variable CDA_COOP_HOST must be set to use --coop flag."
|
|
196
|
+
)
|
|
197
|
+
config_path = kwargs.get("config_path")
|
|
198
|
+
config = read_config(config_path)
|
|
199
|
+
config_check(config)
|
|
200
|
+
INPUT_FILES = config.get("input_files", {})
|
|
201
|
+
# Override file names if one is specified in CLI
|
|
202
|
+
if kwargs.get("input_keys"):
|
|
203
|
+
if kwargs.get("input_keys") == "all":
|
|
204
|
+
INPUT_FILES = config.get("input_files", {}).keys()
|
|
205
|
+
else:
|
|
206
|
+
INPUT_FILES = kwargs.get("input_keys").split(",")
|
|
207
|
+
logger.info(f"Started for {','.join(INPUT_FILES)} input files.")
|
|
208
|
+
# Input checks
|
|
209
|
+
# if kwargs.get("file_name") != "all" and kwargs.get("file_name") not in INPUT_FILES:
|
|
210
|
+
# raise ValueError(
|
|
211
|
+
# f"Invalid file name '{kwargs.get("file_name")}'. Valid options are: {', '.join(INPUT_FILES)}"
|
|
212
|
+
# )
|
|
213
|
+
|
|
214
|
+
# Loop the file names and post the data
|
|
215
|
+
for file_name in INPUT_FILES:
|
|
216
|
+
# Grab the csv file path from the config
|
|
217
|
+
CONFIG_ITEM = config.get("input_files", {}).get(file_name, {})
|
|
218
|
+
DATA_FILE = CONFIG_ITEM.get("data_path", "")
|
|
219
|
+
if not DATA_FILE:
|
|
220
|
+
logger.warning(
|
|
221
|
+
# TODO: List URL to example in doc site once available
|
|
222
|
+
f"No data file specified for input-keys '{file_name}' in {config_path}. {colorize(f'Skipping {file_name}', 'red')}. Please provide a valid CSV file path by ensuring the 'data_path' key is set in the config."
|
|
223
|
+
)
|
|
224
|
+
continue
|
|
225
|
+
csv_data = parse_file(
|
|
226
|
+
DATA_FILE,
|
|
227
|
+
begin_time,
|
|
228
|
+
CONFIG_ITEM.get("date_format"),
|
|
229
|
+
kwargs.get("tz"),
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
ts_min_data = load_timeseries(csv_data, file_name, config)
|
|
233
|
+
except ValueError as e:
|
|
234
|
+
logger.error(f"Error loading timeseries for {file_name}: {e}")
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if kwargs.get("dry_run"):
|
|
238
|
+
logger.info("DRY RUN enabled. No data will be posted")
|
|
239
|
+
for ts_object in ts_min_data:
|
|
240
|
+
try:
|
|
241
|
+
ts_object.update({"office-id": kwargs.get("office")})
|
|
242
|
+
logger.info(
|
|
243
|
+
"Store Rule: " + CONFIG_ITEM.get("store_rule", "")
|
|
244
|
+
if CONFIG_ITEM.get("store_rule", "")
|
|
245
|
+
else f"No Store Rule specified, will default to REPLACE_ALL in {config_path}."
|
|
246
|
+
)
|
|
247
|
+
if kwargs.get("dry_run"):
|
|
248
|
+
logger.info(f"DRY RUN: {ts_object}")
|
|
249
|
+
else:
|
|
250
|
+
cwms.store_timeseries(
|
|
251
|
+
data=ts_object,
|
|
252
|
+
store_rule=CONFIG_ITEM.get("store_rule", "REPLACE_ALL"),
|
|
253
|
+
)
|
|
254
|
+
logger.info(f"Stored {ts_object['name']} values")
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(
|
|
257
|
+
f"Error posting data for {file_name}: {e}\n{traceback.format_exc()}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
logger.debug(f"\tExecution time: {round(time.time() - start_time, 3)} seconds.")
|
|
261
|
+
logger.debug(f"\tMemory usage: {round(os.sys.getsizeof(locals()) / 1024, 2)} KB")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"interval": 3600,
|
|
3
|
+
"input_files": {
|
|
4
|
+
"BROK": {
|
|
5
|
+
"data_path": "cwmscli/commands/csv2cwms/tests/data/sample_brok.csv",
|
|
6
|
+
"date_format": [
|
|
7
|
+
"%m/%d/%Y %H:%M:%S",
|
|
8
|
+
"%m/%d/%Y %H:%M"
|
|
9
|
+
],
|
|
10
|
+
"timeseries": {
|
|
11
|
+
"BROK.Elev.Inst.15Minutes.0.Rev-SCADA-cda": {
|
|
12
|
+
"columns": "Headwater",
|
|
13
|
+
"units": "ft",
|
|
14
|
+
"precision": 2
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|