sliderule-cli 5.6.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.
- sliderule_cli-5.6.1.dist-info/METADATA +21 -0
- sliderule_cli-5.6.1.dist-info/RECORD +16 -0
- sliderule_cli-5.6.1.dist-info/WHEEL +5 -0
- sliderule_cli-5.6.1.dist-info/entry_points.txt +5 -0
- sliderule_cli-5.6.1.dist-info/licenses/LICENSE +12 -0
- sliderule_cli-5.6.1.dist-info/top_level.txt +4 -0
- sliderule_cluster/__init__.py +0 -0
- sliderule_cluster/main.py +194 -0
- sliderule_provisioner/__init__.py +0 -0
- sliderule_provisioner/main.py +154 -0
- sliderule_recorder/__init__.py +0 -0
- sliderule_recorder/environment.yml +12 -0
- sliderule_recorder/main.py +533 -0
- sliderule_runner/__init__.py +0 -0
- sliderule_runner/database.py +106 -0
- sliderule_runner/main.py +267 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import sys
|
|
3
|
+
import copy
|
|
4
|
+
import time
|
|
5
|
+
import argparse
|
|
6
|
+
import boto3
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
import geoip2.database
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from matplotlib.colors import LogNorm
|
|
12
|
+
import matplotlib.cm as cm
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
# -------------------------------------------
|
|
17
|
+
# command line arguments
|
|
18
|
+
# -------------------------------------------
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
description="""SlideRule Usage Report""",
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=(
|
|
23
|
+
"Examples:\n"
|
|
24
|
+
" python usage_report.py --start \"2026-01-10\" --end \"2026-01-29\"\n"
|
|
25
|
+
" python usage_report.py --start \"2026-01-10\"\n"
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument('--start', type=str, required=True, help='start is required as ISO datetime string YYYY-MM-DD HH:MM:SS)') #
|
|
29
|
+
parser.add_argument('--end', type=str, default=f'{datetime.now()}') # optional ISO datetime string
|
|
30
|
+
parser.add_argument('--grid', action='store_true', default=False, help="generates grids")
|
|
31
|
+
parser.add_argument('--grid_aoi', type=str, default="/data/web/sliderule_aoi_grid.png")
|
|
32
|
+
parser.add_argument('--grid_usage', type=str, default="/data/web/sliderule_usage_grid.png")
|
|
33
|
+
parser.add_argument('--geonames', type=str, default="/data/geopy/cities500.txt") # from http://download.geonames.org/export/dump/cities500.zip
|
|
34
|
+
parser.add_argument('--country_info', type=str, default="/data/geopy/countryInfo.txt") # from http://download.geonames.org/export/dump/countryInfo.txt
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
# -------------------------------------------
|
|
38
|
+
# constants
|
|
39
|
+
# -------------------------------------------
|
|
40
|
+
GLUE_DATABASE = 'recorder-database'
|
|
41
|
+
ATHENA_WORKGROUP = 'recorder-workgroup'
|
|
42
|
+
TELEMETRY_TABLE = 'telemetry'
|
|
43
|
+
ALERTS_TABLE = 'alerts'
|
|
44
|
+
PROJECT_BUCKET = 'sliderule'
|
|
45
|
+
AWS_REGION = 'us-west-2'
|
|
46
|
+
GEOLITE2_CITY_DB = '/data/GeoLite2-City.mmdb'
|
|
47
|
+
GEOLITE2_COUNTRY_DB = '/data/GeoLite2-Country.mmdb'
|
|
48
|
+
QUERY_WAIT_SECONDS = 300
|
|
49
|
+
QUERY_POLL_SECONDS = 2
|
|
50
|
+
MAX_LABEL_LEN = 30
|
|
51
|
+
MAX_VALUE_LEN = 20
|
|
52
|
+
ICESAT2_ENDPOINTS = ['atl03x', 'atl03s', 'atl03v', 'atl06', 'atl06s', 'atl06x', 'atl08', 'atl08x', 'atl13s', 'atl13x', 'atl24x']
|
|
53
|
+
ICESAT2_PROXY_ENDPOINTS = ['atl03x', 'atl03sp', 'atl03vp', 'atl06p', 'atl06sp', 'atl08p', 'atl13sp', 'atl13x', 'atl24x', 'atl06x', 'atl08x']
|
|
54
|
+
GEDI_ENDPOINTS = ['gedi01b', 'gedi02a', 'gedi04a']
|
|
55
|
+
GEDI_PROXY_ENDPOINTS = ['gedi01bp', 'gedi02ap', 'gedi04ap']
|
|
56
|
+
|
|
57
|
+
# -------------------------------------------
|
|
58
|
+
# globals
|
|
59
|
+
# -------------------------------------------
|
|
60
|
+
athena = boto3.client('athena', region_name=AWS_REGION)
|
|
61
|
+
glue = boto3.client('glue', region_name=AWS_REGION)
|
|
62
|
+
s3 = boto3.client('s3', region_name=AWS_REGION)
|
|
63
|
+
geo_country = geoip2.database.Reader(GEOLITE2_COUNTRY_DB)
|
|
64
|
+
geo_city = geoip2.database.Reader(GEOLITE2_CITY_DB)
|
|
65
|
+
|
|
66
|
+
# -------------------------------------------
|
|
67
|
+
# build where clause
|
|
68
|
+
# -------------------------------------------
|
|
69
|
+
def build_where_clause(start_dt, end_dt, days):
|
|
70
|
+
conditions = []
|
|
71
|
+
# build partition filter
|
|
72
|
+
if len(days) > 31: # enumerate individual days when the number of days is less than a typical month
|
|
73
|
+
parts = [f"(year = '{dt.year:04d}' AND month = '{dt.month:02d}' AND day = '{dt.day:02d}')" for dt in days]
|
|
74
|
+
else: # enumerate months
|
|
75
|
+
parts = list({f"(year = '{dt.year:04d}' AND month = '{dt.month:02d}')" for dt in days})
|
|
76
|
+
conditions.append( '(' + ' OR '.join(parts) + ')' )
|
|
77
|
+
# build timestamp expression
|
|
78
|
+
start_iso = start_dt.strftime('%Y-%m-%dT%H:%M:%S')
|
|
79
|
+
end_iso = end_dt.strftime('%Y-%m-%dT%H:%M:%S')
|
|
80
|
+
ts_expr = (
|
|
81
|
+
"CASE "
|
|
82
|
+
"WHEN regexp_like(timestamp, '^[0-9]+$') "
|
|
83
|
+
"THEN from_unixtime(CAST(timestamp AS bigint)) "
|
|
84
|
+
"ELSE from_iso8601_timestamp(timestamp) "
|
|
85
|
+
"END"
|
|
86
|
+
)
|
|
87
|
+
conditions.append(
|
|
88
|
+
f"{ts_expr} >= from_iso8601_timestamp('{start_iso}') AND "
|
|
89
|
+
f"{ts_expr} <= from_iso8601_timestamp('{end_iso}')"
|
|
90
|
+
)
|
|
91
|
+
# return where clause
|
|
92
|
+
return ' AND '.join(conditions)
|
|
93
|
+
|
|
94
|
+
# -------------------------------------------
|
|
95
|
+
# execute query
|
|
96
|
+
# -------------------------------------------
|
|
97
|
+
def execute_query(query, context=''):
|
|
98
|
+
|
|
99
|
+
# initiate athena query
|
|
100
|
+
request = {
|
|
101
|
+
'QueryString': query,
|
|
102
|
+
'QueryExecutionContext': {'Database': GLUE_DATABASE, 'Catalog': 'AwsDataCatalog'},
|
|
103
|
+
'WorkGroup': ATHENA_WORKGROUP
|
|
104
|
+
}
|
|
105
|
+
response = athena.start_query_execution(**request)
|
|
106
|
+
query_execution_id = response['QueryExecutionId']
|
|
107
|
+
print(f'Initiated query on {context}', end='')
|
|
108
|
+
|
|
109
|
+
# wait for athena query to complete
|
|
110
|
+
status = 'IN PROGRESS'
|
|
111
|
+
start_time = time.time()
|
|
112
|
+
while (time.time() - start_time) < QUERY_WAIT_SECONDS:
|
|
113
|
+
response = athena.get_query_execution(QueryExecutionId=query_execution_id)
|
|
114
|
+
status = response['QueryExecution']['Status']['State']
|
|
115
|
+
if status in ['SUCCEEDED', 'FAILED', 'CANCELLED']:
|
|
116
|
+
break
|
|
117
|
+
sys.stdout.write(".")
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
time.sleep(QUERY_POLL_SECONDS)
|
|
120
|
+
|
|
121
|
+
# check athena query status
|
|
122
|
+
if status == 'FAILED':
|
|
123
|
+
reason = response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
|
|
124
|
+
raise Exception(f'Query failed: {reason}')
|
|
125
|
+
elif status == 'IN PROGRESS':
|
|
126
|
+
raise Exception('Query execution timeout')
|
|
127
|
+
|
|
128
|
+
# get athena query results
|
|
129
|
+
results = []
|
|
130
|
+
paginator = athena.get_paginator('get_query_results')
|
|
131
|
+
page_iterator = paginator.paginate(QueryExecutionId=query_execution_id)
|
|
132
|
+
column_names = None
|
|
133
|
+
for page in page_iterator:
|
|
134
|
+
for row in page['ResultSet']['Rows']:
|
|
135
|
+
if column_names is None:
|
|
136
|
+
column_names = [col.get('VarCharValue') for col in row['Data']]
|
|
137
|
+
continue
|
|
138
|
+
row_data = {}
|
|
139
|
+
for i, col in enumerate(row['Data']):
|
|
140
|
+
col_name = column_names[i]
|
|
141
|
+
col_value = col.get('VarCharValue') if col else None
|
|
142
|
+
row_data[col_name] = col_value
|
|
143
|
+
results.append(row_data)
|
|
144
|
+
sys.stdout.write("!")
|
|
145
|
+
sys.stdout.flush()
|
|
146
|
+
|
|
147
|
+
# return athena query results
|
|
148
|
+
sys.stdout.write("\n")
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
return results
|
|
151
|
+
|
|
152
|
+
# -------------------------------------------
|
|
153
|
+
# list s3 day partitions
|
|
154
|
+
# -------------------------------------------
|
|
155
|
+
def list_s3_day_partitions(bucket, label, start_dt, end_dt):
|
|
156
|
+
found = set()
|
|
157
|
+
curr_dt = start_dt
|
|
158
|
+
while curr_dt <= end_dt:
|
|
159
|
+
prefix = f"{label}/year={curr_dt.year:04d}/month={curr_dt.month:02d}/"
|
|
160
|
+
token = None
|
|
161
|
+
while True:
|
|
162
|
+
if token:
|
|
163
|
+
response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, Delimiter='/', ContinuationToken=token)
|
|
164
|
+
else:
|
|
165
|
+
response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, Delimiter='/')
|
|
166
|
+
for cp in response.get('CommonPrefixes', []):
|
|
167
|
+
p = cp.get('Prefix', '')
|
|
168
|
+
if p.endswith('/') and p.startswith(prefix):
|
|
169
|
+
day_part = p[len(prefix):].strip('/')
|
|
170
|
+
if day_part.startswith('day='):
|
|
171
|
+
found.add(f"year={curr_dt.year:04d}/month={curr_dt.month:02d}/{day_part}")
|
|
172
|
+
token = response.get('NextContinuationToken')
|
|
173
|
+
if not response.get('IsTruncated'):
|
|
174
|
+
break
|
|
175
|
+
if curr_dt.month == 12:
|
|
176
|
+
curr_dt = curr_dt.replace(year=curr_dt.year+1, month=1, day=1)
|
|
177
|
+
else:
|
|
178
|
+
curr_dt = curr_dt.replace(month=curr_dt.month+1, day=1)
|
|
179
|
+
return found
|
|
180
|
+
|
|
181
|
+
# -------------------------------------------
|
|
182
|
+
# glue partitions
|
|
183
|
+
# -------------------------------------------
|
|
184
|
+
def glue_partitions(table_name):
|
|
185
|
+
partitions = []
|
|
186
|
+
paginator = glue.get_paginator('get_partitions')
|
|
187
|
+
for page in paginator.paginate(DatabaseName=GLUE_DATABASE, TableName=table_name):
|
|
188
|
+
for part in page.get('Partitions', []):
|
|
189
|
+
values = part.get('Values', [])
|
|
190
|
+
if len(values) >= 3:
|
|
191
|
+
year, month, day = values[0], values[1], values[2]
|
|
192
|
+
partitions.append(f'year={year}/month={month}/day={day}')
|
|
193
|
+
return set(partitions)
|
|
194
|
+
|
|
195
|
+
# -------------------------------------------
|
|
196
|
+
# ensure partitions for range
|
|
197
|
+
# -------------------------------------------
|
|
198
|
+
def ensure_partitions_for_range(table_name, expected, label, start_dt, end_dt):
|
|
199
|
+
# build list of requested partitions that are missing from the glue table
|
|
200
|
+
existing_glue = glue_partitions(table_name)
|
|
201
|
+
existing_s3 = list_s3_day_partitions(PROJECT_BUCKET, label, start_dt, end_dt)
|
|
202
|
+
missing = [p for p in expected if ((p not in existing_glue) and (p in existing_s3))]
|
|
203
|
+
print(f'Existing glue partitions for {label}: {len(existing_glue)}')
|
|
204
|
+
print(f'Existing s3 partitions for {label}: {len(existing_s3)}')
|
|
205
|
+
print(f'Missing partitions for {label}: {len(missing)}')
|
|
206
|
+
|
|
207
|
+
# add missing partitions to the glue table
|
|
208
|
+
table_def = glue.get_table(DatabaseName=GLUE_DATABASE, Name=table_name)
|
|
209
|
+
base_sd = table_def['Table']['StorageDescriptor']
|
|
210
|
+
inputs = []
|
|
211
|
+
for partition in missing:
|
|
212
|
+
kv = dict([item.split('=') for item in partition.split('/')])
|
|
213
|
+
location = f"s3://{PROJECT_BUCKET}/{label}/year={kv['year']}/month={kv['month']}/day={kv['day']}/"
|
|
214
|
+
sd = copy.deepcopy(base_sd)
|
|
215
|
+
sd['Location'] = location
|
|
216
|
+
inputs.append({'Values': [kv['year'], kv['month'], kv['day']], 'StorageDescriptor': sd})
|
|
217
|
+
for i in range(0, len(inputs), 100):
|
|
218
|
+
batch = inputs[i:i+100]
|
|
219
|
+
response = glue.batch_create_partition(DatabaseName=GLUE_DATABASE, TableName=table_name, PartitionInputList=batch)
|
|
220
|
+
for err in response.get('Errors', []):
|
|
221
|
+
code = err.get('ErrorDetail', {}).get('ErrorCode')
|
|
222
|
+
msg = err.get('ErrorDetail', {}).get('ErrorMessage')
|
|
223
|
+
if code != 'AlreadyExistsException':
|
|
224
|
+
print(f'Glue partition error: {msg}')
|
|
225
|
+
|
|
226
|
+
# -------------------------------------------
|
|
227
|
+
# value counts
|
|
228
|
+
# -------------------------------------------
|
|
229
|
+
def value_counts(table, field, where_clause):
|
|
230
|
+
query = f"""
|
|
231
|
+
SELECT {field} AS key, COUNT(*) AS count
|
|
232
|
+
FROM {table}
|
|
233
|
+
WHERE {where_clause}
|
|
234
|
+
GROUP BY {field}
|
|
235
|
+
"""
|
|
236
|
+
rows = execute_query(query, f'{field} of {table}')
|
|
237
|
+
return {row['key']: int(row['count']) for row in rows}
|
|
238
|
+
|
|
239
|
+
# -------------------------------------------
|
|
240
|
+
# sum counts
|
|
241
|
+
# -------------------------------------------
|
|
242
|
+
def sum_counts(counts, include_list=None, match_str=None):
|
|
243
|
+
total = 0
|
|
244
|
+
for item in counts:
|
|
245
|
+
if (not include_list and not match_str) or \
|
|
246
|
+
(include_list and item in include_list) or \
|
|
247
|
+
(match_str and match_str in item):
|
|
248
|
+
total += counts[item]
|
|
249
|
+
return total
|
|
250
|
+
|
|
251
|
+
# -------------------------------------------
|
|
252
|
+
# locate it
|
|
253
|
+
# -------------------------------------------
|
|
254
|
+
def locateit(source_ip, debug_info):
|
|
255
|
+
try:
|
|
256
|
+
if source_ip == '0.0.0.0' or source_ip == '127.0.0.1':
|
|
257
|
+
return 'localhost, localhost'
|
|
258
|
+
country = geo_country.country(source_ip).country.name
|
|
259
|
+
city = geo_city.city(source_ip).city.name
|
|
260
|
+
return f'{country}, {city}'
|
|
261
|
+
except Exception as e:
|
|
262
|
+
print(f'Failed to get location information for <{debug_info}>: {e}')
|
|
263
|
+
return 'unknown, unknown'
|
|
264
|
+
|
|
265
|
+
# -------------------------------------------
|
|
266
|
+
# build location counts
|
|
267
|
+
# -------------------------------------------
|
|
268
|
+
def build_location_counts(ip_counts):
|
|
269
|
+
location_counts = {}
|
|
270
|
+
for ip in ip_counts:
|
|
271
|
+
location = locateit(ip, ip)
|
|
272
|
+
if location not in location_counts:
|
|
273
|
+
location_counts[location] = 0
|
|
274
|
+
location_counts[location] += ip_counts[ip]
|
|
275
|
+
return location_counts
|
|
276
|
+
|
|
277
|
+
# -------------------------------------------
|
|
278
|
+
# get timespan
|
|
279
|
+
# -------------------------------------------
|
|
280
|
+
def get_timespan(table, where_clause):
|
|
281
|
+
query = f"""
|
|
282
|
+
SELECT MIN(timestamp) AS start_time, MAX(timestamp) AS end_time
|
|
283
|
+
FROM {table}
|
|
284
|
+
WHERE {where_clause}
|
|
285
|
+
"""
|
|
286
|
+
rows = execute_query(query, f'timespan of {table}')
|
|
287
|
+
start_unix_ts = int(rows[0]['start_time'].strip())
|
|
288
|
+
end_unix_ts = int(rows[0]['end_time'].strip())
|
|
289
|
+
start_dt = datetime.fromtimestamp(start_unix_ts, tz=timezone.utc)
|
|
290
|
+
end_dt = datetime.fromtimestamp(end_unix_ts, tz=timezone.utc)
|
|
291
|
+
return {'start': start_dt, 'end': end_dt, 'span': end_dt - start_dt}
|
|
292
|
+
|
|
293
|
+
# -------------------------------------------
|
|
294
|
+
# generate map
|
|
295
|
+
# -------------------------------------------
|
|
296
|
+
def generate_map(grid, filename):
|
|
297
|
+
data = grid.astype(np.float64)
|
|
298
|
+
data[data == 0] = np.nan
|
|
299
|
+
|
|
300
|
+
# Log-scale normalization to accentuate lower values
|
|
301
|
+
vmin = max(np.nanmin(data), 1.0) # clamp min to 1 for log scale
|
|
302
|
+
norm = LogNorm(vmin=vmin, vmax=np.nanmax(data))
|
|
303
|
+
mapped = cm.jet(norm(data)) # RGBA float array
|
|
304
|
+
mapped = (mapped * 255).astype(np.uint8)
|
|
305
|
+
|
|
306
|
+
# Make NaN pixels fully transparent
|
|
307
|
+
mask = np.isnan(data)
|
|
308
|
+
mapped[mask] = [0, 0, 0, 0]
|
|
309
|
+
|
|
310
|
+
# Save image
|
|
311
|
+
img = Image.fromarray(np.flipud(mapped), mode='RGBA')
|
|
312
|
+
img.save(filename)
|
|
313
|
+
print(f'Saved PNG {filename}: {img.size[0]}x{img.size[1]} pixels')
|
|
314
|
+
|
|
315
|
+
# -------------------------------------------
|
|
316
|
+
# AIO grid
|
|
317
|
+
# -------------------------------------------
|
|
318
|
+
def aoi_sql_grid(table, where_clause, filename):
|
|
319
|
+
query = f"""
|
|
320
|
+
SELECT
|
|
321
|
+
CAST(FLOOR((aoi_x + 180.0) / 0.25) AS INTEGER) AS grid_x,
|
|
322
|
+
CAST(FLOOR((aoi_y + 90.0) / 0.25) AS INTEGER) AS grid_y,
|
|
323
|
+
COUNT(*) AS point_count
|
|
324
|
+
FROM "recorder-database"."telemetry"
|
|
325
|
+
WHERE aoi_x BETWEEN -180.0 AND 180.0
|
|
326
|
+
AND aoi_y BETWEEN -90.0 AND 90.0
|
|
327
|
+
AND ({where_clause})
|
|
328
|
+
GROUP BY
|
|
329
|
+
FLOOR((aoi_x + 180.0) / 0.25),
|
|
330
|
+
FLOOR((aoi_y + 90.0) / 0.25)
|
|
331
|
+
ORDER BY
|
|
332
|
+
grid_x, grid_y;
|
|
333
|
+
"""
|
|
334
|
+
rows = execute_query(query, f'AOI of {table}')
|
|
335
|
+
grid = np.zeros((720, 1440), dtype=np.uint32)
|
|
336
|
+
for row in rows:
|
|
337
|
+
x = int(row["grid_x"])
|
|
338
|
+
y = int(row["grid_y"])
|
|
339
|
+
if x == 1440: x = 1439
|
|
340
|
+
if y == 720: y = 719
|
|
341
|
+
if(x < 1440 and x > 0 and y < 720 and y > 0): # 0,0 is an unfortunate artifact of not being populated
|
|
342
|
+
grid[y, x] = row["point_count"]
|
|
343
|
+
else:
|
|
344
|
+
print(f"Not gridding {y},{x} => {row['point_count']}")
|
|
345
|
+
generate_map(grid, filename)
|
|
346
|
+
|
|
347
|
+
# -------------------------------------------
|
|
348
|
+
# grid usage
|
|
349
|
+
# -------------------------------------------
|
|
350
|
+
def load_geonames(path):
|
|
351
|
+
"""Load GeoNames cities500.txt into a dict keyed by city name (lowercase).
|
|
352
|
+
Each entry is a list of (city_name, country_code, lat, lon, population)."""
|
|
353
|
+
print("Loading GeoNames database...")
|
|
354
|
+
cities = defaultdict(list)
|
|
355
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
356
|
+
reader = csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE)
|
|
357
|
+
for row in reader:
|
|
358
|
+
name = row[1]
|
|
359
|
+
asciiname = row[2]
|
|
360
|
+
lat = float(row[4])
|
|
361
|
+
lon = float(row[5])
|
|
362
|
+
country_code = row[8]
|
|
363
|
+
population = int(row[14]) if row[14] else 0
|
|
364
|
+
entry = (name, country_code, lat, lon, population)
|
|
365
|
+
cities[name.lower()].append(entry)
|
|
366
|
+
if asciiname.lower() != name.lower():
|
|
367
|
+
cities[asciiname.lower()].append(entry)
|
|
368
|
+
# Also index alternate names
|
|
369
|
+
for alt in row[3].split(","):
|
|
370
|
+
alt = alt.strip().lower()
|
|
371
|
+
if alt and alt not in cities:
|
|
372
|
+
cities[alt].append(entry)
|
|
373
|
+
print(f"Loaded {len(cities)} unique city name keys.")
|
|
374
|
+
return cities
|
|
375
|
+
|
|
376
|
+
def load_countrycodes(path):
|
|
377
|
+
"""Country code lookup from country name"""
|
|
378
|
+
print("Loading CountryCode database...")
|
|
379
|
+
country_to_code = {}
|
|
380
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
381
|
+
for line in f:
|
|
382
|
+
if line.startswith("#"):
|
|
383
|
+
continue
|
|
384
|
+
parts = line.strip().split("\t")
|
|
385
|
+
if len(parts) >= 5:
|
|
386
|
+
country_to_code[parts[4].lower()] = parts[0]
|
|
387
|
+
print(f"Loaded {len(country_to_code)} country codes.")
|
|
388
|
+
return country_to_code
|
|
389
|
+
|
|
390
|
+
def geocode_offline(place, geonames_db, countrycode_db):
|
|
391
|
+
"""Lookup lat/lon from 'Country, City' string using local GeoNames database."""
|
|
392
|
+
parts = [p.strip() for p in place.split(",")]
|
|
393
|
+
city_name = parts[-1] if len(parts) > 1 else parts[0]
|
|
394
|
+
country_name = parts[0] if len(parts) > 1 else None
|
|
395
|
+
# If city is "None", use the most populous city in the country as a fallback
|
|
396
|
+
if city_name.lower() == "none":
|
|
397
|
+
if country_name:
|
|
398
|
+
country_code = countrycode_db.get(country_name.lower())
|
|
399
|
+
if country_code:
|
|
400
|
+
# Find the most populous city in that country
|
|
401
|
+
best = None
|
|
402
|
+
for candidates_list in geonames_db.values():
|
|
403
|
+
for c in candidates_list:
|
|
404
|
+
if c[1] == country_code:
|
|
405
|
+
if best is None or c[4] > best[4]:
|
|
406
|
+
best = c
|
|
407
|
+
if best:
|
|
408
|
+
return best[2], best[3]
|
|
409
|
+
return None
|
|
410
|
+
candidates = geonames_db.get(city_name.lower(), [])
|
|
411
|
+
if not candidates:
|
|
412
|
+
return None
|
|
413
|
+
# filter by country if provided
|
|
414
|
+
if country_name:
|
|
415
|
+
country_code = countrycode_db.get(country_name.lower())
|
|
416
|
+
if country_code:
|
|
417
|
+
filtered = [c for c in candidates if c[1] == country_code]
|
|
418
|
+
if filtered:
|
|
419
|
+
candidates = filtered
|
|
420
|
+
# pick the one with highest population
|
|
421
|
+
best = max(candidates, key=lambda c: c[4])
|
|
422
|
+
return best[2], best[3]
|
|
423
|
+
|
|
424
|
+
def usage_grid(locations, filename):
|
|
425
|
+
grid = np.zeros((720, 1440), dtype=np.uint32)
|
|
426
|
+
geonames_db = load_geonames(args.geonames)
|
|
427
|
+
countrycode_db = load_countrycodes(args.country_info)
|
|
428
|
+
# create grid of locations
|
|
429
|
+
for place, count in locations.items():
|
|
430
|
+
loc = geocode_offline(place, geonames_db, countrycode_db)
|
|
431
|
+
if loc is not None:
|
|
432
|
+
lat, lon = loc
|
|
433
|
+
else:
|
|
434
|
+
print(f"Could not geocode {place}")
|
|
435
|
+
continue
|
|
436
|
+
# Convert lat/lon to 0.25 degree grid indices
|
|
437
|
+
# Lat: -90 to 90 -> row 0 (south) to 719 (north)
|
|
438
|
+
# Lon: -180 to 180 -> col 0 (west) to 1439 (east)
|
|
439
|
+
row = int((lat + 90) / 0.25)
|
|
440
|
+
col = int((lon + 180) / 0.25)
|
|
441
|
+
row = min(max(row, 0), 719)
|
|
442
|
+
col = min(max(col, 0), 1439)
|
|
443
|
+
grid[row, col] = count
|
|
444
|
+
# expand grid points into boxes based on value partitions
|
|
445
|
+
expanded = np.zeros_like(grid, dtype=np.uint32)
|
|
446
|
+
ys, xs = np.where(grid > 0) # Find all non-zero cells
|
|
447
|
+
for y, x in zip(ys, xs):
|
|
448
|
+
val = grid[y, x]
|
|
449
|
+
# determine partition
|
|
450
|
+
partition = val // 50 + 1
|
|
451
|
+
partition = min(partition, 5)
|
|
452
|
+
radius = partition
|
|
453
|
+
# define box bounds, clipped to grid
|
|
454
|
+
y_min = max(y - radius, 0)
|
|
455
|
+
y_max = min(y + radius, 719)
|
|
456
|
+
x_min = max(x - radius, 0)
|
|
457
|
+
x_max = min(x + radius, 1439)
|
|
458
|
+
# add value to all cells in the box
|
|
459
|
+
expanded[y_min:y_max+1, x_min:x_max+1] += val
|
|
460
|
+
# create png
|
|
461
|
+
generate_map(expanded, filename)
|
|
462
|
+
|
|
463
|
+
# -------------------------------------------
|
|
464
|
+
# display stats
|
|
465
|
+
# -------------------------------------------
|
|
466
|
+
def display_stats(title, stats, sort_values=False):
|
|
467
|
+
print(f'\n===================\n{title}\n===================')
|
|
468
|
+
if sort_values:
|
|
469
|
+
stat_list = sorted(stats.items(), key=lambda item: item[1], reverse=True)
|
|
470
|
+
else:
|
|
471
|
+
stat_list = stats.items()
|
|
472
|
+
for count in stat_list:
|
|
473
|
+
print(f'{str(count[0]).ljust(MAX_LABEL_LEN)} {str(count[1]).rjust(MAX_VALUE_LEN)}')
|
|
474
|
+
|
|
475
|
+
# -------------------------------------------
|
|
476
|
+
# main
|
|
477
|
+
# -------------------------------------------
|
|
478
|
+
def main():
|
|
479
|
+
|
|
480
|
+
# build needed athena partitions to handle request
|
|
481
|
+
start_dt = datetime.fromisoformat(args.start)
|
|
482
|
+
end_dt = datetime.fromisoformat(args.end)
|
|
483
|
+
days = pd.date_range(start_dt.date(), end_dt.date(), freq='D').date.tolist()
|
|
484
|
+
expected = [f"year={d.year:04d}/month={d.month:02d}/day={d.day:02d}" for d in days]
|
|
485
|
+
ensure_partitions_for_range(TELEMETRY_TABLE, expected, 'telemetry', start_dt, end_dt)
|
|
486
|
+
ensure_partitions_for_range(ALERTS_TABLE, expected, 'alerts', start_dt, end_dt)
|
|
487
|
+
|
|
488
|
+
# query for usage statistics
|
|
489
|
+
telemetry_table = f'"{GLUE_DATABASE}".{TELEMETRY_TABLE}'
|
|
490
|
+
alerts_table = f'"{GLUE_DATABASE}".{ALERTS_TABLE}'
|
|
491
|
+
where_clause = build_where_clause(start_dt, end_dt, days)
|
|
492
|
+
time_stats = get_timespan(telemetry_table, where_clause)
|
|
493
|
+
unique_ip_counts = value_counts(telemetry_table, 'source_ip', where_clause)
|
|
494
|
+
source_location_counts = build_location_counts(unique_ip_counts)
|
|
495
|
+
client_counts = value_counts(telemetry_table, 'client', where_clause)
|
|
496
|
+
endpoint_counts = value_counts(telemetry_table, 'endpoint', where_clause)
|
|
497
|
+
telemetry_status_code_counts = value_counts(telemetry_table, 'code', where_clause)
|
|
498
|
+
alert_status_code_counts = value_counts(alerts_table, 'code', where_clause)
|
|
499
|
+
summary = {
|
|
500
|
+
'Start': time_stats["start"].strftime("%Y-%m-%d %H:%M:%S"),
|
|
501
|
+
'End': time_stats["end"].strftime("%Y-%m-%d %H:%M:%S"),
|
|
502
|
+
'Duration': f"{time_stats['span'].days} days, {(time_stats['span'].total_seconds() / 3600) % 24:.2f} hours",
|
|
503
|
+
'Unique IPs': len(unique_ip_counts),
|
|
504
|
+
'Unique Locations': len(source_location_counts),
|
|
505
|
+
'Total Requests': sum_counts(endpoint_counts),
|
|
506
|
+
'Python Client Requests': sum_counts(client_counts, match_str="python"),
|
|
507
|
+
'Web Client Requests': sum_counts(client_counts, match_str="web"),
|
|
508
|
+
'Unknown Client Requests': sum_counts(client_counts, match_str="unknown"),
|
|
509
|
+
'ICESat-2 Granules Processed': sum_counts(endpoint_counts, ICESAT2_ENDPOINTS),
|
|
510
|
+
'ICESat-2 Proxied Requests': sum_counts(endpoint_counts, ICESAT2_PROXY_ENDPOINTS),
|
|
511
|
+
'GEDI Granules Processed': sum_counts(endpoint_counts, GEDI_ENDPOINTS),
|
|
512
|
+
'GEDI Proxied Requests': sum_counts(endpoint_counts, GEDI_PROXY_ENDPOINTS)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# grid requests
|
|
516
|
+
if args.grid:
|
|
517
|
+
usage_grid(source_location_counts, args.grid_usage)
|
|
518
|
+
aoi_sql_grid(telemetry_table, where_clause, args.grid_aoi)
|
|
519
|
+
|
|
520
|
+
# display usage statistics
|
|
521
|
+
display_stats('Source Locations', source_location_counts, True)
|
|
522
|
+
display_stats('Clients', client_counts, True)
|
|
523
|
+
display_stats('Endpoints', endpoint_counts, True)
|
|
524
|
+
display_stats('Request Codes', telemetry_status_code_counts, True)
|
|
525
|
+
display_stats('Alert Codes', alert_status_code_counts, True)
|
|
526
|
+
display_stats('Summary', summary, False)
|
|
527
|
+
display_stats('Globe', {
|
|
528
|
+
'icesat2': sum_counts(endpoint_counts, ICESAT2_ENDPOINTS),
|
|
529
|
+
'gedi': sum_counts(endpoint_counts, GEDI_ENDPOINTS),
|
|
530
|
+
}, False)
|
|
531
|
+
|
|
532
|
+
# running via direct invocation
|
|
533
|
+
if __name__ == "__main__": main()
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# {
|
|
2
|
+
# "submissions": {
|
|
3
|
+
# "<name>": {
|
|
4
|
+
# "run_url": <run url>,
|
|
5
|
+
# "job_id": <job id>,
|
|
6
|
+
# "status": {
|
|
7
|
+
# "SUBMITTED": <x>,
|
|
8
|
+
# "PENDING": <x>,
|
|
9
|
+
# "RUNNABLE": <x>,
|
|
10
|
+
# "STARTING": <x>,
|
|
11
|
+
# "RUNNING": <x>,
|
|
12
|
+
# "SUCCEEDED": <x>,
|
|
13
|
+
# "FAILED": <x>
|
|
14
|
+
# },
|
|
15
|
+
# "complete": <true|false>,
|
|
16
|
+
# "results": [ {result 1}, {result 2}, ... {result N} ]
|
|
17
|
+
# },
|
|
18
|
+
# ...
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from enum import Enum
|
|
25
|
+
|
|
26
|
+
# ###############################
|
|
27
|
+
# JobState (AWS Batch)
|
|
28
|
+
# ###############################
|
|
29
|
+
|
|
30
|
+
class JobState(str, Enum):
|
|
31
|
+
|
|
32
|
+
SUBMITTED = "SUBMITTED"
|
|
33
|
+
PENDING = "PENDING"
|
|
34
|
+
RUNNABLE = "RUNNABLE"
|
|
35
|
+
STARTING = "STARTING"
|
|
36
|
+
RUNNING = "RUNNING"
|
|
37
|
+
SUCCEEDED = "SUCCEEDED"
|
|
38
|
+
FAILED = "FAILED"
|
|
39
|
+
|
|
40
|
+
def __str__(self):
|
|
41
|
+
return self.value
|
|
42
|
+
|
|
43
|
+
# ###############################
|
|
44
|
+
# JobStatus (SlideRule Runner)
|
|
45
|
+
# ###############################
|
|
46
|
+
|
|
47
|
+
class JobStatus(str, Enum):
|
|
48
|
+
|
|
49
|
+
PENDING = "pending" # run has not been processed yet
|
|
50
|
+
SUCCESS = "success" # run completed and produced output
|
|
51
|
+
FAILURE = "failure" # run completed and produced no output
|
|
52
|
+
UNSUPPORTED = "unsupported" # results of run incompatible with automatic parsing
|
|
53
|
+
ERROR = "error" # run errored out and did not complete
|
|
54
|
+
|
|
55
|
+
def __str__(self):
|
|
56
|
+
return self.value
|
|
57
|
+
|
|
58
|
+
# ###############################
|
|
59
|
+
# QueuePriority
|
|
60
|
+
# ###############################
|
|
61
|
+
|
|
62
|
+
class QueuePriority(str, Enum):
|
|
63
|
+
|
|
64
|
+
URGENT = "urgent"
|
|
65
|
+
DEFAULT = "default"
|
|
66
|
+
BACKGROUND = "background"
|
|
67
|
+
|
|
68
|
+
def __str__(self):
|
|
69
|
+
return self.value
|
|
70
|
+
|
|
71
|
+
# ###############################
|
|
72
|
+
# Database
|
|
73
|
+
# ###############################
|
|
74
|
+
|
|
75
|
+
class Database:
|
|
76
|
+
|
|
77
|
+
def __init__(self, filename):
|
|
78
|
+
self.filename = filename
|
|
79
|
+
try:
|
|
80
|
+
# read database
|
|
81
|
+
with open(filename, "r") as file:
|
|
82
|
+
self.database = json.load(file)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
# create database
|
|
85
|
+
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
86
|
+
with open(filename, "w") as file:
|
|
87
|
+
self.database = {"submissions": {}}
|
|
88
|
+
json.dump(self.database, file)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def submissions(self):
|
|
92
|
+
return self.database["submissions"]
|
|
93
|
+
|
|
94
|
+
# Write database out to file
|
|
95
|
+
def write(self, filename=None):
|
|
96
|
+
filename = filename or self.filename
|
|
97
|
+
# written via a temporary file so that an interrupt cannot truncate the database
|
|
98
|
+
tmp_filename = f"{filename}.tmp"
|
|
99
|
+
with open(tmp_filename, "w") as file:
|
|
100
|
+
json.dump(self.database, file, indent=2)
|
|
101
|
+
os.replace(tmp_filename, filename)
|
|
102
|
+
|
|
103
|
+
# Remove database (not recoverable)
|
|
104
|
+
def remove(self):
|
|
105
|
+
if os.path.exists(self.filename):
|
|
106
|
+
os.remove(self.filename)
|